feat: 完善播放器控制、媒体轨道与 Emby 状态上报
- 修复播放进度圆点与轨道位置偏移 - 优化字幕、剧集、音轨及播放工具弹层 - 完善剧集图片、集数、标题和选中状态展示 - 新增音轨选择、关闭及默认音轨自动恢复 - 新增章节跳转、跳过片头片尾和连播确认 - 支持从单集入口自动加载完整剧集列表 - 调整 Emby 每 10 秒及关键操作即时状态上报 - 补充章节识别、手势和播放上报单元测试
This commit is contained in:
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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user