feat: 完善播放器控制、媒体轨道与 Emby 状态上报

- 修复播放进度圆点与轨道位置偏移
- 优化字幕、剧集、音轨及播放工具弹层
- 完善剧集图片、集数、标题和选中状态展示
- 新增音轨选择、关闭及默认音轨自动恢复
- 新增章节跳转、跳过片头片尾和连播确认
- 支持从单集入口自动加载完整剧集列表
- 调整 Emby 每 10 秒及关键操作即时状态上报
- 补充章节识别、手势和播放上报单元测试
This commit is contained in:
beyond committed 2026-08-29 03:34:15 +08:00
1 parent 91366f24b8
commit ca5ab982da
12 files changed
+1224 -261

No files matched your search

@@ -57,6 +57,8 @@ data class EmbyItem(
@SerializedName("ImageTags") val imageTags: Map<String, String>? = null,
@SerializedName("BackdropImageTags") val _backdropImageTags: List<String>? = emptyList(),
@SerializedName("SeriesName") val seriesName: String? = null,
@SerializedName("SeriesId") val seriesId: String? = null,
@SerializedName("SeasonId") val seasonId: String? = null,
@SerializedName("ParentId") val parentId: String? = null,
@SerializedName("IndexNumber") val indexNumber: Int? = null,
@SerializedName("ParentIndexNumber") val parentIndexNumber: Int? = null,
@@ -65,6 +67,7 @@ data class EmbyItem(
@SerializedName("People") val _people: List<EmbyPerson>? = emptyList(),
@SerializedName("Studios") val _studios: List<EmbyNamedReference>? = emptyList(),
@SerializedName("MediaStreams") val _mediaStreams: List<EmbyMediaStream>? = emptyList(),
@SerializedName("Chapters") val _chapters: List<EmbyChapter>? = emptyList(),
@SerializedName("UserData") val userData: EmbyUserData? = null,
) {
val genres: List<String> get() = _genres.orEmpty()
@@ -73,6 +76,7 @@ data class EmbyItem(
val people: List<EmbyPerson> get() = _people.orEmpty()
val studios: List<EmbyNamedReference> get() = _studios.orEmpty()
val mediaStreams: List<EmbyMediaStream> get() = _mediaStreams.orEmpty()
val chapters: List<EmbyChapter> get() = _chapters.orEmpty()
val playbackPositionMs: Long
get() = (userData?.playbackPositionTicks ?: 0L).coerceAtLeast(0L) / 10_000L
@@ -121,6 +125,14 @@ data class EmbyMediaStream(
@SerializedName("Width") val width: Int? = null,
)
data class EmbyChapter(
@SerializedName("StartPositionTicks") val startPositionTicks: Long = 0L,
@SerializedName("Name") val name: String? = null,
@SerializedName("MarkerType") val markerType: String? = null,
) {
val startPositionMs: Long get() = startPositionTicks.coerceAtLeast(0L) / 10_000L
}
/* ---------- Playback Info ---------- */
data class EmbyPlaybackInfoResponse(
@@ -39,6 +39,17 @@ data class EmbyPlaybackSession(
val playMethod: String,
)
internal fun shouldSendPlaybackProgress(
eventName: String,
force: Boolean,
positionMs: Long,
lastPositionMs: Long,
nowMs: Long,
lastSentAtMs: Long,
intervalMs: Long = 10_000L,
): Boolean = force || eventName != "TimeUpdate" || lastPositionMs < 0L ||
abs(positionMs - lastPositionMs) >= intervalMs || nowMs - lastSentAtMs >= intervalMs
object EmbyRepository {
private const val CLIENT_NAME = "Luma Player"
@@ -71,6 +82,7 @@ object EmbyRepository {
override val positionMs: Long,
val paused: Boolean,
val eventName: String,
val force: Boolean,
override val session: EmbyPlaybackSession?,
) : PlaybackCommand
@@ -319,7 +331,7 @@ object EmbyRepository {
suspend fun getItem(server: EmbyServer, itemId: String): EmbyItem? = withContext(Dispatchers.IO) {
try {
val url = Uri.parse("${server.fullUrl}/emby/Users/${server.userId}/Items/$itemId").buildUpon()
.appendQueryParameter("Fields", "Overview,Genres,People,Studios,MediaStreams,UserData")
.appendQueryParameter("Fields", "Overview,Genres,People,Studios,MediaStreams,Chapters,UserData")
.appendQueryParameter("EnableImages", "true")
.appendQueryParameter("EnableUserData", "true")
.appendQueryParameter("EnableImageTypes", "Primary,Backdrop,Thumb,Logo")
@@ -393,7 +405,8 @@ object EmbyRepository {
suspend fun getEpisodes(server: EmbyServer, seriesId: String, seasonId: String): List<EmbyItem> = withContext(Dispatchers.IO) {
val dedicated = try {
val url = "${server.fullUrl}/emby/Shows/$seriesId/Episodes" +
"?userId=${server.userId}&seasonId=$seasonId&Fields=PrimaryImageAspectRatio,Overview,ProductionYear,UserData&EnableUserData=true"
"?userId=${server.userId}&seasonId=$seasonId&Fields=PrimaryImageAspectRatio,Overview,ProductionYear,ImageTags,BackdropImageTags,UserData" +
"&EnableImages=true&EnableImageTypes=Primary,Thumb,Backdrop&EnableUserData=true"
val request = Request.Builder()
.url(url)
.header("X-Emby-Token", server.apiKey)
@@ -415,7 +428,8 @@ object EmbyRepository {
return@withContext try {
val url = "${server.fullUrl}/emby/Users/${server.userId}/Items" +
"?ParentId=$seasonId&IncludeItemTypes=Episode&Recursive=true&SortBy=ParentIndexNumber,IndexNumber&SortOrder=Ascending" +
"&Fields=PrimaryImageAspectRatio,Overview,ProductionYear,UserData&EnableUserData=true"
"&Fields=PrimaryImageAspectRatio,Overview,ProductionYear,ImageTags,BackdropImageTags,UserData" +
"&EnableImages=true&EnableImageTypes=Primary,Thumb,Backdrop&EnableUserData=true"
val request = Request.Builder()
.url(url)
.header("X-Emby-Token", server.apiKey)
@@ -536,9 +550,10 @@ object EmbyRepository {
paused: Boolean,
eventName: String,
session: EmbyPlaybackSession?,
force: Boolean = false,
) {
playbackQueue.trySend(
PlaybackCommand.Progress(server, item, positionMs, paused, eventName, session),
PlaybackCommand.Progress(server, item, positionMs, paused, eventName, force, session),
)
}
@@ -588,11 +603,16 @@ object EmbyRepository {
is PlaybackCommand.Progress -> {
val now = SystemClock.elapsedRealtime()
val state = playbackReportState.getOrPut(key) { PlaybackReportState() }
val isUserInteraction = command.eventName != "TimeUpdate"
val positionChangedEnough = state.lastPositionMs < 0L ||
abs(command.positionMs - state.lastPositionMs) >= PLAYBACK_REPORT_INTERVAL_MS
val intervalElapsed = now - state.lastSentAtMs >= PLAYBACK_REPORT_INTERVAL_MS
if (!isUserInteraction && !positionChangedEnough && !intervalElapsed) return
if (!shouldSendPlaybackProgress(
eventName = command.eventName,
force = command.force,
positionMs = command.positionMs,
lastPositionMs = state.lastPositionMs,
nowMs = now,
lastSentAtMs = state.lastSentAtMs,
intervalMs = PLAYBACK_REPORT_INTERVAL_MS,
)
) return
sendPlaybackCheckIn(
server = command.server,
@@ -305,12 +305,14 @@ private fun PlayerDestination(
itemLoading = false
}
LaunchedEffect(server?.id, seriesId, seasonId) {
val effectiveSeriesId = seriesId ?: item?.seriesId
val effectiveSeasonId = seasonId ?: item?.seasonId ?: item?.parentId
LaunchedEffect(server?.id, effectiveSeriesId, effectiveSeasonId) {
episodes = try {
if (server == null || seriesId.isNullOrBlank() || seasonId.isNullOrBlank()) {
if (server == null || effectiveSeriesId.isNullOrBlank() || effectiveSeasonId.isNullOrBlank()) {
emptyList()
} else {
EmbyRepository.getEpisodes(server, seriesId, seasonId)
EmbyRepository.getEpisodes(server, effectiveSeriesId, effectiveSeasonId)
}
} catch (error: CancellationException) {
throw error
@@ -325,7 +327,13 @@ private fun PlayerDestination(
playbackLoading = true
playbackError = null
try {
playbackSession = EmbyRepository.resolvePlaybackSession(server, target)
val playbackTarget = if (target.id != item?.id) {
EmbyRepository.getItem(server, target.id) ?: target
} else {
target
}
if (target.id != item?.id && playbackTarget !== target) selectedItem = playbackTarget
playbackSession = EmbyRepository.resolvePlaybackSession(server, playbackTarget)
playbackUrl = playbackSession?.streamUrl
} catch (error: CancellationException) {
throw error
@@ -344,11 +352,13 @@ private fun PlayerDestination(
titleLogoUrl = target.getLogoUrl(server?.fullUrl.orEmpty()).orEmpty(),
serverName = server?.name.orEmpty(),
serverIconUrl = server?.iconUrl.orEmpty(),
serverUrl = server?.fullUrl.orEmpty(),
playbackUrl = playbackUrl,
initialPositionMs = target.playbackPositionMs,
isLoading = itemLoading || playbackLoading,
errorText = playbackError,
episodes = episodes,
chapters = target.chapters,
currentEpisodeId = target.id,
onSelectEpisode = { episode ->
selectedItem = episode
@@ -361,7 +371,7 @@ private fun PlayerDestination(
EmbyRepository.enqueuePlaybackStarted(currentServer, target, positionMs, playbackSession)
}
},
onPlaybackProgress = { positionMs, paused, eventName ->
onPlaybackProgress = { positionMs, paused, eventName, force ->
server?.let { currentServer ->
EmbyRepository.enqueuePlaybackProgress(
currentServer,
@@ -370,6 +380,7 @@ private fun PlayerDestination(
paused,
eventName,
playbackSession,
force,
)
}
},
File diff suppressed because it is too large. Load diff
@@ -10,6 +10,8 @@ import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearEasing
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
@@ -29,8 +31,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.Canvas
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Slider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
@@ -42,6 +42,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
@@ -49,6 +50,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.ContentScale
@@ -457,13 +459,12 @@ fun LumaSectionTitle(
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun LumaSeekBar(
value: Float,
onValueChange: (Float) -> Unit,
onValueChangeFinished: (Float) -> Unit,
progressContentDescription: String? = null,
modifier: Modifier = Modifier,
progressContentDescription: String? = null,
activeColor: Color = LumaColors.accent,
trackColor: Color = LumaColors.divider,
thumbColor: Color = activeColor,
@@ -474,56 +475,75 @@ fun LumaSeekBar(
val currentBuffered by rememberUpdatedState(bufferedValue.coerceIn(0f, 1f))
val currentOnValueChange by rememberUpdatedState(onValueChange)
val currentOnValueChangeFinished by rememberUpdatedState(onValueChangeFinished)
val interactionSource = remember { MutableInteractionSource() }
val dragging by interactionSource.collectIsPressedAsState()
var gestureValue by remember { mutableFloatStateOf(currentValue) }
var dragging by remember { mutableStateOf(false) }
Slider(
value = currentValue,
onValueChange = { newValue ->
gestureValue = newValue
currentOnValueChange(newValue)
},
onValueChangeFinished = { currentOnValueChangeFinished(gestureValue) },
interactionSource = interactionSource,
modifier = modifier
.heightIn(min = 30.dp)
Canvas(
modifier
.fillMaxWidth()
.height(32.dp)
.semantics {
if (progressContentDescription != null) contentDescription = progressContentDescription
},
thumb = {
Box(
Modifier
.size(if (dragging) 14.dp else 8.dp)
.background(thumbColor, CircleShape)
)
},
track = { sliderState ->
Canvas(Modifier.fillMaxWidth().height(4.dp)) {
val trackHeight = size.height
val progressWidth = size.width * sliderState.value.coerceIn(0f, 1f)
val bufferedWidth = size.width * currentBuffered
val radius = trackHeight / 2f
drawRoundRect(
color = trackColor,
size = Size(size.width, trackHeight),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(radius),
)
if (bufferedWidth > progressWidth) {
drawRoundRect(
color = bufferColor,
size = Size(bufferedWidth, trackHeight),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(radius),
)
}
drawRoundRect(
color = activeColor,
size = Size(progressWidth, trackHeight),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(radius),
)
}
},
)
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
val horizontalInset = 7.dp.toPx()
val usableWidth = (size.width - horizontalInset * 2f).coerceAtLeast(1f)
var gestureValue = ((down.position.x - horizontalInset) / usableWidth).coerceIn(0f, 1f)
dragging = true
currentOnValueChange(gestureValue)
var change = down
while (change.pressed) {
val event = awaitPointerEvent()
change = event.changes.firstOrNull { it.id == down.id } ?: break
if (change.pressed) {
gestureValue = ((change.position.x - horizontalInset) / usableWidth).coerceIn(0f, 1f)
currentOnValueChange(gestureValue)
change.consume()
}
}
dragging = false
currentOnValueChangeFinished(gestureValue)
}
},
) {
val trackHeight = 5.dp.toPx()
val horizontalInset = 7.dp.toPx()
val trackWidth = (size.width - horizontalInset * 2f).coerceAtLeast(0f)
val trackTop = (size.height - trackHeight) / 2f
val cornerRadius = CornerRadius(trackHeight / 2f)
val activeWidth = trackWidth * currentValue
val bufferedWidth = trackWidth * currentBuffered
val thumbCenter = Offset(horizontalInset + activeWidth, size.height / 2f)
drawRoundRect(
color = trackColor,
topLeft = Offset(horizontalInset, trackTop),
size = Size(trackWidth, trackHeight),
cornerRadius = cornerRadius,
)
if (bufferedWidth > activeWidth) {
drawRoundRect(
color = bufferColor,
topLeft = Offset(horizontalInset + activeWidth, trackTop),
size = Size(bufferedWidth - activeWidth, trackHeight),
cornerRadius = cornerRadius,
)
}
if (activeWidth > 0f) {
drawRoundRect(
color = activeColor,
topLeft = Offset(horizontalInset, trackTop),
size = Size(activeWidth, trackHeight),
cornerRadius = cornerRadius,
)
}
drawCircle(
color = thumbColor,
radius = (if (dragging) 7.dp else 4.dp).toPx(),
center = thumbCenter,
)
}
}
@Composable
@@ -64,6 +64,7 @@ enum class LumaIcon(
Laptop(R.drawable.aurora_laptop, R.drawable.aurora_laptop_selected),
Tv(R.drawable.aurora_tv, R.drawable.aurora_tv_selected),
Cast(R.drawable.aurora_cast, R.drawable.aurora_cast_selected),
Brightness(R.drawable.aurora_brightness, R.drawable.aurora_brightness_selected),
Speaker(R.drawable.aurora_speaker, R.drawable.aurora_speaker_selected),
Headphones(R.drawable.aurora_headphones, R.drawable.aurora_headphones_selected),
Remote(R.drawable.aurora_remote, R.drawable.aurora_remote_selected),
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:pathData="M50,31 A19,19 0,1 0,50,69 A19,19 0,1 0,50,31"
android:fillColor="@android:color/transparent"
android:strokeColor="#1C1C1E"
android:strokeWidth="6.4" />
<path
android:pathData="M50,11 L50,21 M50,79 L50,89 M11,50 L21,50 M79,50 L89,50 M22,22 L29,29 M71,71 L78,78 M78,22 L71,29 M29,71 L22,78"
android:fillColor="@android:color/transparent"
android:strokeColor="#1C1C1E"
android:strokeWidth="6.4"
android:strokeLineCap="round" />
</vector>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:pathData="M50,31 A19,19 0,1 0,50,69 A19,19 0,1 0,50,31"
android:fillColor="#0A84FF" />
<path
android:pathData="M50,11 L50,21 M50,79 L50,89 M11,50 L21,50 M79,50 L89,50 M22,22 L29,29 M71,71 L78,78 M78,22 L71,29 M29,71 L22,78"
android:fillColor="@android:color/transparent"
android:strokeColor="#0A84FF"
android:strokeWidth="6.4"
android:strokeLineCap="round" />
</vector>
+20
View File
@@ -143,6 +143,21 @@
<string name="player_failed">Playback failed</string>
<string name="player_subtitle">Subtitles</string>
<string name="player_episode_list">Episode list</string>
<string name="player_no_episodes">No episodes are available</string>
<string name="player_audio_tracks">Audio tracks</string>
<string name="player_disable_audio">Disable audio</string>
<string name="player_unnamed_audio">Audio track</string>
<string name="player_unnamed_subtitle">Subtitle</string>
<string name="player_playback_tools">Playback tools</string>
<string name="player_chapters">Chapter markers</string>
<string name="player_no_chapters">No chapter markers are available</string>
<string name="player_chapter_at">Chapter %1$s</string>
<string name="player_skip_intro">Skip intro</string>
<string name="player_skip_outro">Skip outro</string>
<string name="player_confirm_before_continuous_playback">Confirm before continuous playback</string>
<string name="player_continue_playback">Continue playing?</string>
<string name="player_play_next_episode">Play next episode</string>
<string name="player_cancel_continuous_playback">Not now</string>
<string name="player_close">Close</string>
<string name="player_seek_backward">Seek back 10 seconds</string>
<string name="player_previous_episode">Previous episode</string>
@@ -153,6 +168,11 @@
<string name="player_progress">Playback progress</string>
<string name="player_brightness">Brightness</string>
<string name="player_volume">Volume</string>
<string name="player_lock_screen">Lock screen</string>
<string name="player_unlock_screen">Unlock screen</string>
<string name="player_rotate_screen">Switch orientation</string>
<string name="player_swipe_forward">Forward</string>
<string name="player_swipe_backward">Rewind</string>
<string name="player_disable_subtitle">Disable subtitles</string>
<string name="player_cannot_play">This resource cannot be played</string>
<string name="playback_url_failed">Failed to resolve playback URL</string>
+20
View File
@@ -143,6 +143,21 @@
<string name="player_failed">播放失败</string>
<string name="player_subtitle">字幕</string>
<string name="player_episode_list">剧集列表</string>
<string name="player_no_episodes">未找到可用剧集</string>
<string name="player_audio_tracks">音轨</string>
<string name="player_disable_audio">关闭音轨</string>
<string name="player_unnamed_audio">音轨</string>
<string name="player_unnamed_subtitle">字幕</string>
<string name="player_playback_tools">播放工具</string>
<string name="player_chapters">章节标记</string>
<string name="player_no_chapters">该媒体没有章节标记</string>
<string name="player_chapter_at">章节 %1$s</string>
<string name="player_skip_intro">跳过片头</string>
<string name="player_skip_outro">跳过片尾</string>
<string name="player_confirm_before_continuous_playback">连播前确认</string>
<string name="player_continue_playback">连续播放确认</string>
<string name="player_play_next_episode">播放下一集</string>
<string name="player_cancel_continuous_playback">暂不继续播放</string>
<string name="player_close">关闭</string>
<string name="player_seek_backward">后退 10 秒</string>
<string name="player_previous_episode">上一集</string>
@@ -153,6 +168,11 @@
<string name="player_progress">播放进度</string>
<string name="player_brightness">亮度</string>
<string name="player_volume">音量</string>
<string name="player_lock_screen">锁定屏幕</string>
<string name="player_unlock_screen">解锁屏幕</string>
<string name="player_rotate_screen">切换横竖屏</string>
<string name="player_swipe_forward">快进</string>
<string name="player_swipe_backward">快退</string>
<string name="player_disable_subtitle">关闭字幕</string>
<string name="player_cannot_play">播放器无法播放该资源</string>
<string name="playback_url_failed">获取播放地址失败</string>
@@ -0,0 +1,20 @@
package com.example.myapplication.data
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PlaybackReportingTest {
@Test
fun periodicProgressWaitsForTenSecondInterval() {
assertFalse(shouldSendPlaybackProgress("TimeUpdate", false, 5_000L, 0L, 5_000L, 0L))
assertTrue(shouldSendPlaybackProgress("TimeUpdate", false, 10_000L, 0L, 10_000L, 0L))
}
@Test
fun seekAndTrackChangesBypassPeriodicThrottle() {
assertTrue(shouldSendPlaybackProgress("TimeUpdate", true, 1_000L, 0L, 1_000L, 0L))
assertTrue(shouldSendPlaybackProgress("AudioTrackChange", false, 1_000L, 0L, 1_000L, 0L))
assertTrue(shouldSendPlaybackProgress("SubtitleTrackChange", false, 1_000L, 0L, 1_000L, 0L))
}
}
@@ -0,0 +1,82 @@
package com.example.myapplication.screens
import com.example.myapplication.data.EmbyChapter
import org.junit.Assert.assertEquals
import org.junit.Test
class VideoPlayerGestureTest {
@Test
fun swipeRightSeeksForwardWithinTwoMinuteWindow() {
assertEquals(
90_000L,
calculateSwipeSeekPosition(
startPositionMs = 30_000L,
durationMs = 300_000L,
horizontalDragPx = 500f,
viewportWidthPx = 1_000f,
),
)
}
@Test
fun swipeLeftClampsAtStart() {
assertEquals(
0L,
calculateSwipeSeekPosition(
startPositionMs = 10_000L,
durationMs = 300_000L,
horizontalDragPx = -1_000f,
viewportWidthPx = 1_000f,
),
)
}
@Test
fun swipeRightClampsAtDuration() {
assertEquals(
100_000L,
calculateSwipeSeekPosition(
startPositionMs = 90_000L,
durationMs = 100_000L,
horizontalDragPx = 1_000f,
viewportWidthPx = 1_000f,
),
)
}
@Test
fun doubleTapRegionsResolveToIndependentActions() {
assertEquals(PlayerDoubleTapAction.SeekBackward, resolveDoubleTapAction(100f, 900f))
assertEquals(PlayerDoubleTapAction.TogglePlayback, resolveDoubleTapAction(450f, 900f))
assertEquals(PlayerDoubleTapAction.SeekForward, resolveDoubleTapAction(800f, 900f))
}
@Test
fun tenSecondSeekClampsToVideoBounds() {
assertEquals(0L, calculateTenSecondSeekPosition(5_000L, 60_000L, forward = false))
assertEquals(60_000L, calculateTenSecondSeekPosition(55_000L, 60_000L, forward = true))
assertEquals(30_000L, calculateTenSecondSeekPosition(20_000L, 60_000L, forward = true))
}
@Test
fun chapterMarkersResolveIntroAndCreditsPositions() {
val chapters = listOf(
EmbyChapter(startPositionTicks = 850_000_000L, name = "Intro end", markerType = "IntroEnd"),
EmbyChapter(startPositionTicks = 5_400_000_000L, name = "Credits", markerType = "CreditsStart"),
)
assertEquals(85_000L, findIntroEndPositionMs(chapters))
assertEquals(540_000L, findCreditsStartPositionMs(chapters))
}
@Test
fun localizedChapterNamesAreUsedWhenMarkerTypesAreMissing() {
val chapters = listOf(
EmbyChapter(startPositionTicks = 600_000_000L, name = "片头结束"),
EmbyChapter(startPositionTicks = 4_200_000_000L, name = "片尾"),
)
assertEquals(60_000L, findIntroEndPositionMs(chapters))
assertEquals(420_000L, findCreditsStartPositionMs(chapters))
}
}