diff --git a/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt b/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt index 988bffe..93591e0 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt @@ -57,6 +57,8 @@ data class EmbyItem( @SerializedName("ImageTags") val imageTags: Map? = null, @SerializedName("BackdropImageTags") val _backdropImageTags: List? = 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? = emptyList(), @SerializedName("Studios") val _studios: List? = emptyList(), @SerializedName("MediaStreams") val _mediaStreams: List? = emptyList(), + @SerializedName("Chapters") val _chapters: List? = emptyList(), @SerializedName("UserData") val userData: EmbyUserData? = null, ) { val genres: List get() = _genres.orEmpty() @@ -73,6 +76,7 @@ data class EmbyItem( val people: List get() = _people.orEmpty() val studios: List get() = _studios.orEmpty() val mediaStreams: List get() = _mediaStreams.orEmpty() + val chapters: List 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( diff --git a/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt b/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt index 7c17047..098c9ff 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt @@ -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 = 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, diff --git a/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt index f8071dc..cb65426 100644 --- a/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt +++ b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt @@ -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, ) } }, diff --git a/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt index 9bf6c9e..59af63f 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt @@ -5,6 +5,7 @@ import android.media.AudioManager import android.content.Context import android.content.ContextWrapper import android.content.pm.ActivityInfo +import android.content.res.Configuration import android.provider.Settings import android.view.Surface import androidx.activity.compose.BackHandler @@ -62,6 +63,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -98,18 +100,25 @@ import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.TransferListener import androidx.media3.ui.PlayerView import com.example.myapplication.data.EmbyItem +import com.example.myapplication.data.EmbyChapter +import com.example.myapplication.data.EmbyRepository import androidx.compose.ui.layout.ContentScale import coil.compose.AsyncImage import com.example.myapplication.R as Res import com.example.myapplication.ui.design.LumaIcon import com.example.myapplication.ui.design.LumaIconView import com.example.myapplication.ui.design.LumaLoading +import com.example.myapplication.ui.design.LumaAsyncImage import com.example.myapplication.ui.design.LumaSeekBar import com.example.myapplication.ui.design.LumaGlassLevel import com.example.myapplication.ui.design.lumaGlass import com.example.myapplication.ui.design.LumaShapes import com.example.myapplication.ui.LocalGlassBackdrop import kotlinx.coroutines.delay +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import top.yukonga.miuix.kmp.basic.Text import kotlin.math.abs @@ -179,11 +188,67 @@ internal class SpeedMonitor( } } +internal fun calculateSwipeSeekPosition( + startPositionMs: Long, + durationMs: Long, + horizontalDragPx: Float, + viewportWidthPx: Float, +): Long { + if (durationMs <= 0L || viewportWidthPx <= 0f) return startPositionMs.coerceAtLeast(0L) + val seekWindowMs = minOf(durationMs, 120_000L) + val offsetMs = (horizontalDragPx / viewportWidthPx * seekWindowMs).toLong() + return (startPositionMs + offsetMs).coerceIn(0L, durationMs) +} + +internal enum class PlayerDoubleTapAction { + SeekBackward, + TogglePlayback, + SeekForward, +} + +internal fun resolveDoubleTapAction(horizontalPositionPx: Float, viewportWidthPx: Float): PlayerDoubleTapAction = when { + horizontalPositionPx < viewportWidthPx / 3f -> PlayerDoubleTapAction.SeekBackward + horizontalPositionPx > viewportWidthPx * 2f / 3f -> PlayerDoubleTapAction.SeekForward + else -> PlayerDoubleTapAction.TogglePlayback +} + +internal fun calculateTenSecondSeekPosition( + currentPositionMs: Long, + durationMs: Long, + forward: Boolean, +): Long { + val targetPositionMs = currentPositionMs + if (forward) 10_000L else -10_000L + return if (durationMs > 0L) targetPositionMs.coerceIn(0L, durationMs) else targetPositionMs.coerceAtLeast(0L) +} + +internal fun findIntroEndPositionMs(chapters: List): Long? = chapters + .firstOrNull { chapter -> + chapter.markerType.equals("IntroEnd", ignoreCase = true) || + chapter.name.orEmpty().contains("片头结束") || + chapter.name.orEmpty().contains("intro end", ignoreCase = true) + } + ?.startPositionMs + +internal fun findCreditsStartPositionMs(chapters: List): Long? = chapters + .firstOrNull { chapter -> + chapter.markerType.equals("CreditsStart", ignoreCase = true) || + chapter.name.orEmpty().contains("片尾") || + chapter.name.orEmpty().contains("credits", ignoreCase = true) + } + ?.startPositionMs + private enum class PlayerLevelGesture { Brightness, Volume, } +private enum class PlayerDoubleTapFeedback { + SeekBackward, + Pause, + Play, + SeekForward, +} + private tailrec fun Context.findActivity(): Activity? = when (this) { is Activity -> this is ContextWrapper -> baseContext.findActivity() @@ -197,23 +262,31 @@ fun VideoPlayerScreen( titleLogoUrl: String = "", serverName: String = "", serverIconUrl: String = "", + serverUrl: String = "", playbackUrl: String? = null, initialPositionMs: Long = 0L, isLoading: Boolean = false, errorText: String? = null, episodes: List = emptyList(), + chapters: List = emptyList(), currentEpisodeId: String? = null, onSelectEpisode: (EmbyItem) -> Unit = {}, onClose: () -> Unit = {}, onPlaybackStarted: (positionMs: Long) -> Unit = {}, - onPlaybackProgress: (positionMs: Long, paused: Boolean, eventName: String) -> Unit = { _, _, _ -> }, + onPlaybackProgress: (positionMs: Long, paused: Boolean, eventName: String, force: Boolean) -> Unit = { _, _, _, _ -> }, onPlaybackStopped: (positionMs: Long) -> Unit = {}, ) { val context = LocalContext.current + val configuration = LocalConfiguration.current val cannotPlayMessage = stringResource(Res.string.player_cannot_play) val lifecycleOwner = LocalLifecycleOwner.current var showSubtitleMenu by remember { mutableStateOf(false) } var showEpisodeMenu by remember { mutableStateOf(false) } + var showAudioMenu by remember { mutableStateOf(false) } + var showPlaybackToolsMenu by remember { mutableStateOf(false) } + var showNextEpisodeConfirmation by remember { mutableStateOf(false) } + var confirmBeforeContinuousPlayback by remember { mutableStateOf(true) } + var audioDisabledByUser by remember { mutableStateOf(false) } val speedMonitor = remember { SpeedMonitor() } val transferListener = remember { object : TransferListener { @@ -249,6 +322,11 @@ fun VideoPlayerScreen( val latestOnPlaybackStarted by rememberUpdatedState(onPlaybackStarted) val latestOnPlaybackProgress by rememberUpdatedState(onPlaybackProgress) val latestOnPlaybackStopped by rememberUpdatedState(onPlaybackStopped) + val latestEpisodes by rememberUpdatedState(episodes) + val latestCurrentEpisodeId by rememberUpdatedState(currentEpisodeId) + val latestOnSelectEpisode by rememberUpdatedState(onSelectEpisode) + val latestConfirmBeforeContinuousPlayback by rememberUpdatedState(confirmBeforeContinuousPlayback) + val latestAudioDisabledByUser by rememberUpdatedState(audioDisabledByUser) var stopReported by remember(playbackUrl) { mutableStateOf(false) } var progress by remember { mutableFloatStateOf(0f) } @@ -266,6 +344,11 @@ fun VideoPlayerScreen( var speedUpValue by remember { mutableFloatStateOf(2f) } var levelGesture by remember { mutableStateOf(null) } var levelGestureValue by remember { mutableFloatStateOf(0f) } + var seekPreviewPositionMs by remember { mutableStateOf(null) } + var seekPreviewDeltaMs by remember { mutableLongStateOf(0L) } + var isScreenLocked by remember { mutableStateOf(false) } + var doubleTapFeedback by remember { mutableStateOf(null) } + var doubleTapFeedbackSequence by remember { mutableIntStateOf(0) } var brightnessLevel by remember { val windowLevel = activity?.window?.attributes?.screenBrightness?.takeIf { it >= 0f } val systemLevel = Settings.System.getInt(context.contentResolver, Settings.System.SCREEN_BRIGHTNESS, 128) / 255f @@ -278,17 +361,35 @@ fun VideoPlayerScreen( } var controlsVisible by remember { mutableStateOf(true) } - var controlsLocked by remember { mutableStateOf(false) } - LaunchedEffect(controlsVisible, isPlaying, controlsLocked) { - if (controlsVisible && isPlaying && !controlsLocked) { + var controlsInteractionLocked by remember { mutableStateOf(false) } + LaunchedEffect( + controlsVisible, + isPlaying, + controlsInteractionLocked, + showSubtitleMenu, + showEpisodeMenu, + showAudioMenu, + showPlaybackToolsMenu, + showNextEpisodeConfirmation, + ) { + val menuVisible = showSubtitleMenu || showEpisodeMenu || showAudioMenu || + showPlaybackToolsMenu || showNextEpisodeConfirmation + if (controlsVisible && isPlaying && !controlsInteractionLocked && !menuVisible) { delay(5000) controlsVisible = false } } + LaunchedEffect(doubleTapFeedbackSequence) { + if (doubleTapFeedbackSequence > 0) { + delay(800) + doubleTapFeedback = null + } + } + LaunchedEffect(Unit) { while (true) { - if (player.duration > 0) { + if (player.duration > 0 && seekPreviewPositionMs == null) { progress = player.currentPosition.toFloat() / player.duration.toFloat() } isPlaying = player.isPlaying @@ -351,7 +452,7 @@ fun VideoPlayerScreen( LaunchedEffect(player, playbackUrl) { while (true) { if (!playbackUrl.isNullOrBlank() && player.currentMediaItem != null && player.isPlaying) { - latestOnPlaybackProgress(player.currentPosition, false, "TimeUpdate") + latestOnPlaybackProgress(player.currentPosition, false, "TimeUpdate", false) } delay(10_000) } @@ -381,6 +482,7 @@ fun VideoPlayerScreen( } DisposableEffect(playbackUrl) { playerError = null + showNextEpisodeConfirmation = false if (!playbackUrl.isNullOrBlank()) { player.setMediaItem(MediaItem.fromUri(playbackUrl), initialPositionMs.coerceAtLeast(0L)) player.prepare() @@ -401,10 +503,44 @@ fun VideoPlayerScreen( } override fun onIsPlayingChanged(isPlaying: Boolean) { + if (!isPlaying && player.playWhenReady) return if (!latestPlaybackUrl.isNullOrBlank()) { - latestOnPlaybackProgress(player.currentPosition, !isPlaying, if (isPlaying) "Unpause" else "Pause") + latestOnPlaybackProgress( + player.currentPosition, + !isPlaying, + if (isPlaying) "Unpause" else "Pause", + true, + ) } } + + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState != Player.STATE_ENDED) return + val currentIndex = latestEpisodes.indexOfFirst { it.id == latestCurrentEpisodeId } + val nextEpisode = latestEpisodes.getOrNull(currentIndex + 1) ?: return + if (latestConfirmBeforeContinuousPlayback) { + controlsVisible = true + showSubtitleMenu = false + showEpisodeMenu = false + showAudioMenu = false + showPlaybackToolsMenu = false + showNextEpisodeConfirmation = true + } else { + latestOnSelectEpisode(nextEpisode) + } + } + + override fun onTracksChanged(tracks: androidx.media3.common.Tracks) { + if (latestAudioDisabledByUser || tracks.groups.any { it.type == C.TRACK_TYPE_AUDIO && it.isSelected }) return + val audioGroup = tracks.groups.firstOrNull { it.type == C.TRACK_TYPE_AUDIO && it.length > 0 } ?: return + val trackIndex = 0 + player.trackSelectionParameters = player.trackSelectionParameters.buildUpon() + .setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false) + .clearOverridesOfType(C.TRACK_TYPE_AUDIO) + .addOverride(TrackSelectionOverride(audioGroup.mediaTrackGroup, trackIndex)) + .build() + latestOnPlaybackProgress(player.currentPosition, !player.isPlaying, "AudioTrackChange", true) + } } player.addListener(listener) onDispose { @@ -429,16 +565,89 @@ fun VideoPlayerScreen( lifecycleOwner.lifecycle.addObserver(obs) onDispose { lifecycleOwner.lifecycle.removeObserver(obs) } } - BackHandler(enabled = true, onBack = onClose) + BackHandler(enabled = true) { + when { + showSubtitleMenu || showEpisodeMenu || showAudioMenu || showPlaybackToolsMenu || showNextEpisodeConfirmation -> { + showSubtitleMenu = false + showEpisodeMenu = false + showAudioMenu = false + showPlaybackToolsMenu = false + showNextEpisodeConfirmation = false + } + isScreenLocked -> { + isScreenLocked = false + controlsVisible = true + } + else -> onClose() + } + } Box( Modifier .fillMaxSize() - .background(Color.Black) - .pointerInput(player, activity, audioManager) { + .background(Color.Black), + ) { + Box(Modifier.fillMaxSize()) { + if (playbackUrl.isNullOrBlank()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + when { + isLoading -> LumaLoading(label = stringResource(Res.string.player_preparing)) + errorText != null -> Text(text = errorText, color = Color.White, modifier = Modifier.padding(24.dp)) + else -> Text(text = stringResource(Res.string.player_preparing), color = Color.White) + } + } + } else { + AndroidView( + factory = { ctx -> + PlayerView(ctx).also { pv -> + pv.player = player + pv.useController = false + pv.resizeMode = androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT + } + }, + modifier = Modifier.fillMaxSize(), + ) + } + + if (playerError != null) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = playerError ?: stringResource(Res.string.player_failed), + color = Color.White, + modifier = Modifier.padding(24.dp), + ) + } + } + } + + Box( + Modifier + .fillMaxWidth() + .fillMaxHeight(0.70f) + .align(Alignment.TopCenter) + .pointerInput(player, activity, audioManager, isScreenLocked) { + val pointerCoroutineScope = CoroutineScope(currentCoroutineContext()) + var lastTapAtMs = 0L + var lastTapAction: PlayerDoubleTapAction? = null + var pendingSingleTapJob: Job? = null awaitEachGesture { val down = awaitFirstDown(requireUnconsumed = true) val startPosition = down.position + val downAction = resolveDoubleTapAction(startPosition.x, size.width.toFloat()) + if (downAction == lastTapAction && + SystemClock.elapsedRealtime() - lastTapAtMs <= viewConfiguration.doubleTapTimeoutMillis + ) { + pendingSingleTapJob?.cancel() + } + if (isScreenLocked) { + var change = down + while (change.pressed) { + val event = awaitPointerEvent() + change = event.changes.firstOrNull { it.id == down.id } ?: break + } + controlsVisible = true + return@awaitEachGesture + } val firstChange: PointerInputChange? = withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) { var detected: PointerInputChange? = null while (detected == null) { @@ -463,7 +672,7 @@ fun VideoPlayerScreen( if (!player.isPlaying) player.play() speedUpVisible = true speedUpValue = targetSpeed - controlsLocked = true + controlsInteractionLocked = true try { while (true) { val event = awaitPointerEvent() @@ -473,10 +682,65 @@ fun VideoPlayerScreen( speedUpVisible = false player.setPlaybackSpeed(prevSpeed) if (!wasPlaying) player.pause() - controlsLocked = false + controlsInteractionLocked = false } } else if (current == null || !current.pressed) { - if (current?.isConsumed != true) controlsVisible = !controlsVisible + if (current?.isConsumed != true) { + val nowMs = SystemClock.elapsedRealtime() + val tapAction = resolveDoubleTapAction(startPosition.x, size.width.toFloat()) + val isDoubleTap = tapAction == lastTapAction && + nowMs - lastTapAtMs <= viewConfiguration.doubleTapTimeoutMillis + if (isDoubleTap) { + pendingSingleTapJob?.cancel() + when (tapAction) { + PlayerDoubleTapAction.SeekBackward -> { + val targetPosition = calculateTenSecondSeekPosition( + currentPositionMs = player.currentPosition, + durationMs = player.duration, + forward = false, + ) + player.seekTo(targetPosition) + if (player.duration > 0L) progress = targetPosition.toFloat() / player.duration + latestOnPlaybackProgress(targetPosition, !player.isPlaying, "TimeUpdate", true) + doubleTapFeedback = PlayerDoubleTapFeedback.SeekBackward + } + PlayerDoubleTapAction.SeekForward -> { + val durationMs = player.duration + val targetPosition = calculateTenSecondSeekPosition( + currentPositionMs = player.currentPosition, + durationMs = durationMs, + forward = true, + ) + player.seekTo(targetPosition) + if (durationMs > 0L) progress = targetPosition.toFloat() / durationMs + latestOnPlaybackProgress(targetPosition, !player.isPlaying, "TimeUpdate", true) + doubleTapFeedback = PlayerDoubleTapFeedback.SeekForward + } + PlayerDoubleTapAction.TogglePlayback -> if (player.isPlaying) { + player.pause() + doubleTapFeedback = PlayerDoubleTapFeedback.Pause + } else { + player.play() + doubleTapFeedback = PlayerDoubleTapFeedback.Play + } + } + doubleTapFeedbackSequence++ + lastTapAtMs = 0L + lastTapAction = null + } else { + lastTapAtMs = nowMs + lastTapAction = tapAction + pendingSingleTapJob?.cancel() + pendingSingleTapJob = pointerCoroutineScope.launch { + delay(viewConfiguration.doubleTapTimeoutMillis) + controlsVisible = !controlsVisible + if (lastTapAtMs == nowMs) { + lastTapAtMs = 0L + lastTapAction = null + } + } + } + } } else if (!current.isConsumed) { val activeChange = checkNotNull(current) val dx = activeChange.position.x - startPosition.x @@ -493,7 +757,7 @@ fun VideoPlayerScreen( audioManager.getStreamVolume(AudioManager.STREAM_MUSIC).toFloat() / maxMusicVolume } levelGesture = kind - controlsLocked = true + controlsInteractionLocked = true var change = activeChange try { while (change.pressed) { @@ -521,52 +785,46 @@ fun VideoPlayerScreen( } } finally { levelGesture = null - controlsLocked = false + controlsInteractionLocked = false } } else { - // 横向移动交给进度条等子控件处理,不切换控制栏。 - while (currentEvent.changes.any { it.id == down.id && it.pressed }) { - awaitPointerEvent() + val durationMs = player.duration.takeIf { it > 0L } ?: 0L + val initialPositionMs = player.currentPosition.coerceAtLeast(0L) + var targetPositionMs = initialPositionMs + var change = activeChange + controlsInteractionLocked = true + controlsVisible = true + try { + while (change.pressed) { + targetPositionMs = calculateSwipeSeekPosition( + startPositionMs = initialPositionMs, + durationMs = durationMs, + horizontalDragPx = change.position.x - startPosition.x, + viewportWidthPx = size.width.toFloat(), + ) + seekPreviewPositionMs = targetPositionMs + seekPreviewDeltaMs = targetPositionMs - initialPositionMs + change.consume() + val event = awaitPointerEvent() + change = event.changes.firstOrNull { it.id == down.id } ?: break + } + if (durationMs > 0L) { + player.seekTo(targetPositionMs) + progress = targetPositionMs.toFloat() / durationMs + latestOnPlaybackProgress(targetPositionMs, !player.isPlaying, "TimeUpdate", true) + } + } finally { + seekPreviewPositionMs = null + seekPreviewDeltaMs = 0L + controlsInteractionLocked = false } } } } - } - ) { - if (playbackUrl.isNullOrBlank()) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - when { - isLoading -> LumaLoading(label = stringResource(Res.string.player_preparing)) - errorText != null -> Text(text = errorText, color = Color.White, modifier = Modifier.padding(24.dp)) - else -> Text(text = stringResource(Res.string.player_preparing), color = Color.White) - } - } - } else { - Box(Modifier.fillMaxSize()) { - AndroidView( - factory = { ctx -> - PlayerView(ctx).also { pv -> - pv.player = player - pv.useController = false - pv.resizeMode = androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT - } - }, - modifier = Modifier.fillMaxSize(), - ) - } - } + }, + ) - if (playerError != null) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text( - text = playerError ?: stringResource(Res.string.player_failed), - color = Color.White, - modifier = Modifier.padding(24.dp), - ) - } - } - - AnimatedVisibility( + AnimatedVisibility( visible = controlsVisible, enter = fadeIn(), exit = fadeOut() @@ -587,26 +845,49 @@ fun VideoPlayerScreen( ), ) + LiquidSurfaceButton( + icon = if (isScreenLocked) LumaIcon.Lock else LumaIcon.Unlock, + contentDescription = stringResource( + if (isScreenLocked) Res.string.player_unlock_screen else Res.string.player_lock_screen + ), + active = isScreenLocked, + size = 40.dp, + modifier = Modifier.align(Alignment.CenterStart).padding(start = capsuleInset), + ) { + isScreenLocked = !isScreenLocked + controlsVisible = true + showSubtitleMenu = false + showEpisodeMenu = false + showAudioMenu = false + showPlaybackToolsMenu = false + } + + if (!isScreenLocked) { + LiquidSurfaceButton( + icon = if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) LumaIcon.Phone else LumaIcon.Tablet, + contentDescription = stringResource(Res.string.player_rotate_screen), + active = false, + size = 40.dp, + modifier = Modifier.align(Alignment.CenterEnd).padding(end = capsuleInset), + ) { + activity?.requestedOrientation = if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { + ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT + } else { + ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + } + } + Row( - Modifier.align(Alignment.Center), - horizontalArrangement = Arrangement.spacedBy(24.dp), + Modifier.align(Alignment.BottomStart).padding(start = capsuleInset, bottom = 14.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, ) { - LiquidSurfaceButton( - icon = LumaIcon.Replay, - contentDescription = stringResource(Res.string.player_seek_backward), - active = false, - size = 32.dp, - ) { - val pos = player.currentPosition - 10_000L - player.seekTo(maxOf(0L, pos)) - latestOnPlaybackProgress(player.currentPosition, !player.isPlaying, "TimeUpdate") - } LiquidSurfaceButton( icon = LumaIcon.Previous, contentDescription = stringResource(Res.string.player_previous_episode), active = false, size = 40.dp, + iconSize = 24.dp, ) { val idx = episodes.indexOfFirst { it.id == currentEpisodeId } if (idx > 0) onSelectEpisode(episodes[idx - 1]) @@ -615,8 +896,8 @@ fun VideoPlayerScreen( icon = if (isPlaying) LumaIcon.Pause else LumaIcon.Play, contentDescription = stringResource(if (isPlaying) Res.string.player_pause else Res.string.player_play), active = true, - size = 48.dp, - iconSize = 28.dp, + size = 40.dp, + iconSize = 24.dp, ) { if (player.isPlaying) { player.pause() @@ -629,19 +910,11 @@ fun VideoPlayerScreen( contentDescription = stringResource(Res.string.player_next_episode), active = false, size = 40.dp, + iconSize = 24.dp, ) { val idx = episodes.indexOfFirst { it.id == currentEpisodeId } if (idx >= 0 && idx < episodes.lastIndex) onSelectEpisode(episodes[idx + 1]) } - LiquidSurfaceButton( - icon = LumaIcon.Speed, - contentDescription = stringResource(Res.string.player_seek_forward), - active = false, - size = 32.dp, - ) { - val pos = player.currentPosition + 10_000L - player.seekTo(minOf(pos, player.duration)) - } } Column(Modifier.fillMaxWidth().statusBarsPadding().padding(top = 16.dp)) { @@ -701,7 +974,10 @@ fun VideoPlayerScreen( active = showSubtitleMenu, size = 40.dp, ) { - showSubtitleMenu = !showSubtitleMenu; showEpisodeMenu = false + showSubtitleMenu = !showSubtitleMenu + showEpisodeMenu = false + showAudioMenu = false + showPlaybackToolsMenu = false } LiquidSurfaceButton( icon = LumaIcon.Watch, @@ -709,7 +985,32 @@ fun VideoPlayerScreen( active = showEpisodeMenu, size = 40.dp, ) { - showEpisodeMenu = !showEpisodeMenu; showSubtitleMenu = false + showEpisodeMenu = !showEpisodeMenu + showSubtitleMenu = false + showAudioMenu = false + showPlaybackToolsMenu = false + } + LiquidSurfaceButton( + icon = LumaIcon.Headphones, + contentDescription = stringResource(Res.string.player_audio_tracks), + active = showAudioMenu, + size = 40.dp, + ) { + showAudioMenu = !showAudioMenu + showSubtitleMenu = false + showEpisodeMenu = false + showPlaybackToolsMenu = false + } + LiquidSurfaceButton( + icon = LumaIcon.More, + contentDescription = stringResource(Res.string.player_playback_tools), + active = showPlaybackToolsMenu, + size = 40.dp, + ) { + showPlaybackToolsMenu = !showPlaybackToolsMenu + showSubtitleMenu = false + showEpisodeMenu = false + showAudioMenu = false } } } @@ -740,7 +1041,7 @@ fun VideoPlayerScreen( Modifier .align(Alignment.BottomCenter) .fillMaxWidth(0.85f) - .padding(bottom = 32.dp), + .padding(bottom = 64.dp), ) { // 技术信息行(进度胶囊上方、胶囊外部):内核|格式|码率|帧率 Row( @@ -758,16 +1059,20 @@ fun VideoPlayerScreen( .fillMaxWidth() .lumaGlass( backdrop = null, - radius = 24.dp, + radius = 16.dp, level = LumaGlassLevel.Strong, tint = Color.Black.copy(alpha = 0.40f), borderColor = Color.White.copy(alpha = 0.22f), shadowElevation = 4.dp, ) - .padding(horizontal = 16.dp, vertical = 6.dp), + .padding(horizontal = 16.dp, vertical = 2.dp), ) { - var totalDurationMs by remember { mutableLongStateOf(0L) } - var currentPositionMs by remember { mutableLongStateOf(0L) } + var totalDurationMs by remember(player) { + mutableLongStateOf(player.duration.coerceAtLeast(0L)) + } + var currentPositionMs by remember(player) { + mutableLongStateOf(player.currentPosition.coerceAtLeast(0L)) + } var isSeeking by remember { mutableStateOf(false) } var seekPosition by remember { mutableFloatStateOf(0f) } var bufferProgress by remember { mutableFloatStateOf(0f) } @@ -776,7 +1081,7 @@ fun VideoPlayerScreen( while (true) { if (player.duration > 0) { totalDurationMs = player.duration - if (!isSeeking) { + if (!isSeeking && seekPreviewPositionMs == null) { currentPositionMs = player.currentPosition progress = player.currentPosition.toFloat() / player.duration.toFloat() val b = (player.bufferedPosition.toFloat() / player.duration).coerceIn(0f, 1f) @@ -788,14 +1093,19 @@ fun VideoPlayerScreen( } Row(verticalAlignment = Alignment.CenterVertically) { - Text(formatMs(currentPositionMs), color = Color.White, fontSize = 12.sp) + val swipePositionMs = seekPreviewPositionMs + Text(formatMs(swipePositionMs ?: currentPositionMs), color = Color.White, fontSize = 12.sp) LumaSeekBar( - value = if (isSeeking) seekPosition else progress, + value = when { + isSeeking -> seekPosition + swipePositionMs != null && totalDurationMs > 0L -> swipePositionMs.toFloat() / totalDurationMs + else -> progress + }, onValueChange = { v -> isSeeking = true seekPosition = v currentPositionMs = (v * totalDurationMs).toLong() - controlsLocked = true + controlsInteractionLocked = true }, onValueChangeFinished = { finalValue -> val targetPosition = (finalValue * totalDurationMs).toLong() @@ -803,8 +1113,8 @@ fun VideoPlayerScreen( progress = finalValue currentPositionMs = targetPosition isSeeking = false - controlsLocked = false - latestOnPlaybackProgress(targetPosition, !player.isPlaying, "TimeUpdate") + controlsInteractionLocked = false + latestOnPlaybackProgress(targetPosition, !player.isPlaying, "TimeUpdate", true) }, progressContentDescription = stringResource(Res.string.player_progress), modifier = Modifier.weight(1f).padding(horizontal = 12.dp), @@ -819,52 +1129,248 @@ fun VideoPlayerScreen( } } } + } } } + seekPreviewPositionMs?.let { targetPositionMs -> + SeekPreviewOverlay( + targetPositionMs = targetPositionMs, + durationMs = player.duration.coerceAtLeast(0L), + deltaMs = seekPreviewDeltaMs, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 132.dp), + ) + } + SpeedUpCapsule( visible = speedUpVisible, speed = speedUpValue, modifier = Modifier.align(Alignment.TopCenter).statusBarsPadding().padding(top = 24.dp), ) levelGesture?.let { kind -> - PlayerLevelCapsule( + PlayerLevelControl( + kind = kind, label = stringResource( if (kind == PlayerLevelGesture.Brightness) Res.string.player_brightness else Res.string.player_volume ), level = levelGestureValue, modifier = Modifier .align(if (kind == PlayerLevelGesture.Brightness) Alignment.CenterStart else Alignment.CenterEnd) - .padding(horizontal = 72.dp), + .padding(horizontal = 48.dp), + ) + } + + doubleTapFeedback?.let { feedback -> + DoubleTapFeedbackCapsule( + feedback = feedback, + modifier = Modifier + .align( + when (feedback) { + PlayerDoubleTapFeedback.SeekBackward -> Alignment.CenterStart + PlayerDoubleTapFeedback.SeekForward -> Alignment.CenterEnd + else -> Alignment.Center + } + ) + .padding(horizontal = if ( + feedback == PlayerDoubleTapFeedback.SeekBackward || feedback == PlayerDoubleTapFeedback.SeekForward + ) 64.dp else 0.dp), ) } if (showSubtitleMenu) { - LiquidGlassPanel(title = stringResource(Res.string.player_subtitle), onDismiss = { showSubtitleMenu = false }) { - SubtitleMenuContent(player = player) { showSubtitleMenu = false } + LiquidGlassPanel( + title = stringResource(Res.string.player_subtitle), + onDismiss = { showSubtitleMenu = false }, + ) { + SubtitleMenuContent( + player = player, + onTrackChanged = { eventName -> + latestOnPlaybackProgress(player.currentPosition, !player.isPlaying, eventName, true) + showSubtitleMenu = false + }, + ) } } if (showEpisodeMenu) { - LiquidGlassPanel(title = title.ifBlank { stringResource(Res.string.player_episode_list) }, onDismiss = { showEpisodeMenu = false }) { - LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { - items(items = episodes) { ep -> - LiquidEpisodeItem( - episode = ep, - displayIndex = ep.indexNumber ?: 0, - selected = ep.id == currentEpisodeId, - ) { showEpisodeMenu = false; onSelectEpisode(ep) } + LiquidGlassPanel( + title = title.ifBlank { stringResource(Res.string.player_episode_list) }, + titleLogoUrl = titleLogoUrl, + onDismiss = { showEpisodeMenu = false }, + ) { + if (episodes.isEmpty()) { + Text( + text = stringResource(Res.string.player_no_episodes), + color = Color.White.copy(alpha = 0.68f), + fontSize = 13.sp, + modifier = Modifier.padding(vertical = 12.dp), + ) + } else { + LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(items = episodes) { ep -> + LiquidEpisodeItem( + episode = ep, + serverUrl = serverUrl, + selected = ep.id == currentEpisodeId, + ) { showEpisodeMenu = false; onSelectEpisode(ep) } + } } } } } + if (showAudioMenu) { + LiquidGlassPanel( + title = stringResource(Res.string.player_audio_tracks), + onDismiss = { showAudioMenu = false }, + ) { + AudioMenuContent( + player = player, + onAudioDisabledChange = { audioDisabledByUser = it }, + onTrackChanged = { eventName -> + latestOnPlaybackProgress(player.currentPosition, !player.isPlaying, eventName, true) + showAudioMenu = false + }, + ) + } + } + if (showPlaybackToolsMenu) { + LiquidGlassPanel( + title = stringResource(Res.string.player_playback_tools), + onDismiss = { showPlaybackToolsMenu = false }, + ) { + PlaybackToolsMenuContent( + chapters = chapters, + confirmBeforeContinuousPlayback = confirmBeforeContinuousPlayback, + onConfirmBeforeContinuousPlaybackChange = { confirmBeforeContinuousPlayback = it }, + onSeek = { targetPositionMs -> + player.seekTo(targetPositionMs) + latestOnPlaybackProgress(targetPositionMs, !player.isPlaying, "TimeUpdate", true) + showPlaybackToolsMenu = false + }, + onSkipOutro = { + val currentIndex = episodes.indexOfFirst { it.id == currentEpisodeId } + val nextEpisode = episodes.getOrNull(currentIndex + 1) + if (nextEpisode != null) { + showPlaybackToolsMenu = false + onSelectEpisode(nextEpisode) + } else { + val targetPositionMs = (player.duration - 500L).coerceAtLeast(0L) + player.seekTo(targetPositionMs) + latestOnPlaybackProgress(targetPositionMs, !player.isPlaying, "TimeUpdate", true) + showPlaybackToolsMenu = false + } + }, + ) + } + } + if (showNextEpisodeConfirmation) { + val currentIndex = episodes.indexOfFirst { it.id == currentEpisodeId } + val nextEpisode = episodes.getOrNull(currentIndex + 1) + LiquidGlassPanel( + title = stringResource(Res.string.player_continue_playback), + onDismiss = { showNextEpisodeConfirmation = false }, + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (nextEpisode != null) { + LiquidEpisodeItem( + episode = nextEpisode, + serverUrl = serverUrl, + selected = false, + ) {} + LiquidMenuItem( + text = stringResource(Res.string.player_play_next_episode), + selected = true, + ) { + showNextEpisodeConfirmation = false + onSelectEpisode(nextEpisode) + } + } + LiquidMenuItem( + text = stringResource(Res.string.player_cancel_continuous_playback), + selected = false, + ) { showNextEpisodeConfirmation = false } + } + } + } } } +@Composable +private fun SeekPreviewOverlay( + targetPositionMs: Long, + durationMs: Long, + deltaMs: Long, + modifier: Modifier = Modifier, +) { + val forward = deltaMs >= 0L + val direction = stringResource( + if (forward) Res.string.player_swipe_forward else Res.string.player_swipe_backward + ) + val targetProgress = if (durationMs > 0L) targetPositionMs.toFloat() / durationMs else 0f + Column( + modifier + .width(216.dp) + .lumaGlass( + backdrop = LocalGlassBackdrop.current, + radius = 16.dp, + level = LumaGlassLevel.Strong, + tint = Color.Black.copy(alpha = 0.68f), + borderColor = Color.White.copy(alpha = 0.24f), + shadowElevation = 5.dp, + ) + .padding(horizontal = 14.dp, vertical = 10.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + LumaIconView( + icon = if (forward) LumaIcon.Speed else LumaIcon.Replay, + contentDescription = direction, + tint = Color.White, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(7.dp)) + Text( + text = "$direction ${if (forward) "+" else "-"}${formatMs(abs(deltaMs))}", + color = Color.White, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.weight(1f)) + Text( + text = formatMs(targetPositionMs), + color = Color.White.copy(alpha = 0.82f), + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + } + Canvas(Modifier.fillMaxWidth().padding(top = 8.dp).height(4.dp)) { + val radius = size.height / 2f + drawRoundRect( + color = Color.White.copy(alpha = 0.18f), + size = size, + cornerRadius = CornerRadius(radius, radius), + ) + drawRoundRect( + color = Color.White.copy(alpha = 0.92f), + size = Size(size.width * targetProgress.coerceIn(0f, 1f), size.height), + cornerRadius = CornerRadius(radius, radius), + ) + } + Text( + text = "${formatMs(targetPositionMs)} / ${formatMs(durationMs)}", + color = Color.White.copy(alpha = 0.62f), + fontSize = 10.sp, + modifier = Modifier.align(Alignment.End).padding(top = 4.dp), + ) + } +} + @Composable private fun LiquidSurfaceButton( icon: LumaIcon, - contentDescription: String? = null, active: Boolean, + modifier: Modifier = Modifier, + contentDescription: String? = null, size: Dp = 44.dp, iconSize: Dp = 24.dp, onClick: () -> Unit, @@ -881,112 +1387,109 @@ private fun LiquidSurfaceButton( ) Box( - Modifier + modifier .size(touchSize) + .clickable(indication = null, interactionSource = interactionSource, onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Box( + Modifier + .size(size) .graphicsLayer { scaleX = scale; scaleY = scale } .lumaGlass( backdrop = bd, - radius = touchSize / 2f, + radius = size / 2f, level = LumaGlassLevel.Interactive, tint = surfaceColor, borderColor = Color.White.copy(alpha = 0.3f), shadowElevation = 4.dp, - ) - .clickable(indication = null, interactionSource = interactionSource, onClick = onClick), - contentAlignment = Alignment.Center, - ) { - LumaIconView(icon, contentDescription = contentDescription, tint = Color.White, modifier = Modifier.size(iconSize)) - } -} - -@Composable -private fun LiquidSurfaceButton( - icon: LumaIcon, - active: Boolean, - size: Dp = 44.dp, - iconSize: Dp = 24.dp, - onClick: () -> Unit, -) { - val bd = LocalGlassBackdrop.current - val touchSize = size.coerceAtLeast(48.dp) - val surfaceColor = if (active) Color.White.copy(alpha = 0.26f) else Color.White.copy(alpha = 0.09f) - val interactionSource = remember { MutableInteractionSource() } - val pressed by interactionSource.collectIsPressedAsState() - val scale by animateFloatAsState( - targetValue = if (pressed) 0.94f else 1f, - animationSpec = tween(180), - label = "playerIconPress", - ) - Box( - Modifier - .size(touchSize) - .graphicsLayer { scaleX = scale; scaleY = scale } - .lumaGlass( - backdrop = bd, - radius = touchSize / 2f, - level = LumaGlassLevel.Interactive, - tint = surfaceColor, - borderColor = Color.White.copy(alpha = 0.22f), - shadowElevation = 4.dp, - ) - .clickable(indication = null, interactionSource = interactionSource, onClick = onClick), - contentAlignment = Alignment.Center, - ) { - LumaIconView(icon, contentDescription = null, tint = Color.White, modifier = Modifier.size(iconSize)) + ), + contentAlignment = Alignment.Center, + ) { + LumaIconView(icon, contentDescription = contentDescription, tint = Color.White, modifier = Modifier.size(iconSize)) + } } } @Composable private fun LiquidGlassPanel( title: String, + titleLogoUrl: String = "", onDismiss: () -> Unit, content: @Composable () -> Unit, ) { - val bd = LocalGlassBackdrop.current Box( Modifier .fillMaxSize() - .background(Color.Black.copy(alpha = 0.3f)) + .background( + Brush.horizontalGradient( + listOf( + Color.Black.copy(alpha = 0.12f), + Color(0xFF07111F).copy(alpha = 0.68f), + ) + ) + ) .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = onDismiss), contentAlignment = Alignment.CenterEnd, ) { Column( Modifier - .padding(top = 16.dp, bottom = 16.dp, end = 16.dp) - .fillMaxHeight() - .width(320.dp) + .padding(end = 20.dp) + .fillMaxHeight(0.82f) + .width(340.dp) .lumaGlass( - backdrop = bd, - radius = 24.dp, + backdrop = null, + radius = 22.dp, level = LumaGlassLevel.Strong, - tint = Color.White.copy(alpha = 0.08f), - borderColor = Color.White.copy(alpha = 0.16f), - shadowElevation = 8.dp, + tint = Color(0xFF101C31).copy(alpha = 0.94f), + borderColor = Color.White.copy(alpha = 0.26f), + shadowElevation = 12.dp, ) .clickable( - enabled = false, indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = {}, ) - .padding(20.dp), + .background( + Brush.verticalGradient( + listOf( + Color(0xFF243A59).copy(alpha = 0.90f), + Color(0xFF09111F).copy(alpha = 0.95f), + ) + ), + RoundedCornerShape(22.dp), + ) + .padding(18.dp), ) { - Text(title, color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold) - Spacer(Modifier.height(16.dp)) + if (titleLogoUrl.isNotBlank()) { + AsyncImage( + model = titleLogoUrl, + contentDescription = title, + contentScale = ContentScale.Fit, + modifier = Modifier.height(32.dp).widthIn(max = 250.dp), + ) + } else { + Text(title, color = Color.White, fontSize = 19.sp, fontWeight = FontWeight.Bold) + } + Spacer(Modifier.height(14.dp)) Box(Modifier.weight(1f)) { content() } } } } @Composable -private fun SubtitleMenuContent(player: ExoPlayer, onDismiss: () -> Unit) { +private fun SubtitleMenuContent(player: ExoPlayer, onTrackChanged: (String) -> Unit) { + val unnamedTrack = stringResource(Res.string.player_unnamed_subtitle) val textTracks = remember(player.currentTracks) { val list = mutableListOf() player.currentTracks.groups.forEach { group -> if (group.type == C.TRACK_TYPE_TEXT) { repeat(group.length) { i -> val fmt = group.getTrackFormat(i) - val label = listOfNotNull(fmt.label, fmt.language?.uppercase()).joinToString(" - ") + val label = listOfNotNull( + fmt.label?.takeIf { it.isNotBlank() }, + fmt.language?.uppercase()?.takeIf { it.isNotBlank() }, + ).joinToString(" · ").ifBlank { "$unnamedTrack ${list.size + 1}" } list.add(SubtitleTrackInfo(group, i, label)) } } @@ -997,16 +1500,17 @@ private fun SubtitleMenuContent(player: ExoPlayer, onDismiss: () -> Unit) { item { LiquidMenuItem(text = stringResource(Res.string.player_disable_subtitle), selected = !player.currentTracks.groups.any { it.type == C.TRACK_TYPE_TEXT && it.isSelected }) { player.trackSelectionParameters = player.trackSelectionParameters.buildUpon().setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true).build() - onDismiss() + onTrackChanged("SubtitleTrackChange") } } items(items = textTracks) { info -> - LiquidMenuItem(text = info.label, selected = info.group.isSelected) { + LiquidMenuItem(text = info.label, selected = info.group.isTrackSelected(info.index)) { player.trackSelectionParameters = player.trackSelectionParameters.buildUpon() .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) + .clearOverridesOfType(C.TRACK_TYPE_TEXT) .addOverride(TrackSelectionOverride(info.group.mediaTrackGroup, info.index)) .build() - onDismiss() + onTrackChanged("SubtitleTrackChange") } } } @@ -1018,19 +1522,135 @@ private data class SubtitleTrackInfo( val label: String ) +@Composable +private fun AudioMenuContent( + player: ExoPlayer, + onAudioDisabledChange: (Boolean) -> Unit, + onTrackChanged: (String) -> Unit, +) { + val unnamedTrack = stringResource(Res.string.player_unnamed_audio) + val audioTracks = remember(player.currentTracks) { + val list = mutableListOf() + player.currentTracks.groups.forEach { group -> + if (group.type == C.TRACK_TYPE_AUDIO) { + repeat(group.length) { index -> + val format = group.getTrackFormat(index) + val label = listOfNotNull( + format.label?.takeIf { it.isNotBlank() }, + format.language?.uppercase()?.takeIf { it.isNotBlank() }, + format.codecs?.takeIf { it.isNotBlank() }, + ).distinct().joinToString(" · ").ifBlank { "$unnamedTrack ${list.size + 1}" } + list.add(AudioTrackInfo(group, index, label)) + } + } + } + list + } + LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { + item { + LiquidMenuItem( + text = stringResource(Res.string.player_disable_audio), + selected = !player.currentTracks.groups.any { it.type == C.TRACK_TYPE_AUDIO && it.isSelected }, + ) { + onAudioDisabledChange(true) + player.trackSelectionParameters = player.trackSelectionParameters.buildUpon() + .setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, true) + .build() + onTrackChanged("AudioTrackChange") + } + } + items(items = audioTracks) { info -> + LiquidMenuItem(text = info.label, selected = info.group.isTrackSelected(info.index)) { + onAudioDisabledChange(false) + player.trackSelectionParameters = player.trackSelectionParameters.buildUpon() + .setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false) + .clearOverridesOfType(C.TRACK_TYPE_AUDIO) + .addOverride(TrackSelectionOverride(info.group.mediaTrackGroup, info.index)) + .build() + onTrackChanged("AudioTrackChange") + } + } + } +} + +private data class AudioTrackInfo( + @param:androidx.annotation.OptIn(UnstableApi::class) val group: androidx.media3.common.Tracks.Group, + val index: Int, + val label: String, +) + +@Composable +private fun PlaybackToolsMenuContent( + chapters: List, + confirmBeforeContinuousPlayback: Boolean, + onConfirmBeforeContinuousPlaybackChange: (Boolean) -> Unit, + onSeek: (Long) -> Unit, + onSkipOutro: () -> Unit, +) { + val introEndPositionMs = findIntroEndPositionMs(chapters) + val creditsStartPositionMs = findCreditsStartPositionMs(chapters) + LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { + item { + LiquidMenuItem( + text = stringResource(Res.string.player_confirm_before_continuous_playback), + selected = confirmBeforeContinuousPlayback, + ) { onConfirmBeforeContinuousPlaybackChange(!confirmBeforeContinuousPlayback) } + } + if (introEndPositionMs != null) { + item { + LiquidMenuItem(text = stringResource(Res.string.player_skip_intro), selected = false) { + onSeek(introEndPositionMs) + } + } + } + if (creditsStartPositionMs != null) { + item { + LiquidMenuItem(text = stringResource(Res.string.player_skip_outro), selected = false, onClick = onSkipOutro) + } + } + item { + Text( + text = stringResource(Res.string.player_chapters), + color = Color.White.copy(alpha = 0.72f), + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 6.dp, bottom = 2.dp), + ) + } + if (chapters.isEmpty()) { + item { + Text( + text = stringResource(Res.string.player_no_chapters), + color = Color.White.copy(alpha = 0.62f), + fontSize = 13.sp, + modifier = Modifier.padding(vertical = 12.dp), + ) + } + } else { + items(items = chapters) { chapter -> + val chapterName = chapter.name?.takeIf { it.isNotBlank() } + ?: stringResource(Res.string.player_chapter_at, formatMs(chapter.startPositionMs)) + LiquidMenuItem( + text = "$chapterName · ${formatMs(chapter.startPositionMs)}", + selected = false, + ) { onSeek(chapter.startPositionMs) } + } + } + } +} + @Composable private fun LiquidMenuItem(text: String, selected: Boolean, onClick: () -> Unit) { - val bd = LocalGlassBackdrop.current - val surface = if (selected) Color.White.copy(alpha = 0.18f) else Color.White.copy(alpha = 0.06f) + val surface = if (selected) Color(0xFF4D78B8).copy(alpha = 0.48f) else Color(0xFF16243A).copy(alpha = 0.72f) Row( Modifier .fillMaxWidth() .lumaGlass( - backdrop = bd, + backdrop = null, radius = LumaShapes.small, level = LumaGlassLevel.Interactive, tint = surface, - borderColor = Color.White.copy(alpha = 0.16f), + borderColor = Color.White.copy(alpha = if (selected) 0.34f else 0.16f), shadowElevation = 0.dp, ) .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = onClick) @@ -1126,53 +1746,156 @@ private fun SpeedUpCapsule(visible: Boolean, speed: Float, modifier: Modifier = } @Composable -private fun PlayerLevelCapsule(label: String, level: Float, modifier: Modifier = Modifier) { +private fun PlayerLevelControl( + kind: PlayerLevelGesture, + label: String, + level: Float, + modifier: Modifier = Modifier, +) { val bd = LocalGlassBackdrop.current - Box( - modifier - .clip(RoundedCornerShape(50)) - .lumaGlass( - backdrop = bd, - radius = 50.dp, - level = LumaGlassLevel.Interactive, - tint = Color.Black.copy(alpha = 0.6f), - borderColor = Color.White.copy(alpha = 0.25f), - shadowElevation = 4.dp, - ) - .padding(horizontal = 16.dp, vertical = 10.dp), - contentAlignment = Alignment.Center, + val icon = if (kind == PlayerLevelGesture.Brightness) LumaIcon.Brightness else LumaIcon.Speaker + val iconOnFill = level >= 0.16f + Column( + modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Text( - text = "$label ${(level * 100).roundToInt()}%", - color = Color.White, - fontSize = 16.sp, - fontWeight = FontWeight.Bold, - ) + Text(label, color = Color.White.copy(alpha = 0.82f), fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + Box( + Modifier + .width(60.dp) + .height(160.dp) + .clip(RoundedCornerShape(30.dp)) + .lumaGlass( + backdrop = bd, + radius = 30.dp, + level = LumaGlassLevel.Strong, + tint = Color.Black.copy(alpha = 0.62f), + borderColor = Color.White.copy(alpha = 0.28f), + shadowElevation = 6.dp, + ), + ) { + Box( + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .fillMaxHeight(level.coerceIn(0f, 1f)) + .background(Color.White.copy(alpha = 0.92f)), + ) + Text( + text = "${(level * 100).roundToInt()}%", + color = if (level >= 0.78f) Color.Black.copy(alpha = 0.72f) else Color.White, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.align(Alignment.TopCenter).padding(top = 14.dp), + ) + LumaIconView( + icon = icon, + contentDescription = label, + tint = if (iconOnFill) Color.Black.copy(alpha = 0.78f) else Color.White, + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 14.dp).size(20.dp), + ) + } } } @Composable -private fun LiquidEpisodeItem(episode: EmbyItem, displayIndex: Int, selected: Boolean, onClick: () -> Unit) { +private fun DoubleTapFeedbackCapsule( + feedback: PlayerDoubleTapFeedback, + modifier: Modifier = Modifier, +) { val bd = LocalGlassBackdrop.current - val surface = if (selected) Color.White.copy(alpha = 0.18f) else Color.White.copy(alpha = 0.06f) + val icon = when (feedback) { + PlayerDoubleTapFeedback.SeekBackward -> LumaIcon.Replay + PlayerDoubleTapFeedback.Pause -> LumaIcon.Pause + PlayerDoubleTapFeedback.Play -> LumaIcon.Play + PlayerDoubleTapFeedback.SeekForward -> LumaIcon.Speed + } + val text = stringResource( + when (feedback) { + PlayerDoubleTapFeedback.SeekBackward -> Res.string.player_seek_backward + PlayerDoubleTapFeedback.Pause -> Res.string.player_pause + PlayerDoubleTapFeedback.Play -> Res.string.player_play + PlayerDoubleTapFeedback.SeekForward -> Res.string.player_seek_forward + } + ) + Row( + modifier + .lumaGlass( + backdrop = bd, + radius = 24.dp, + level = LumaGlassLevel.Interactive, + tint = Color.Black.copy(alpha = 0.68f), + borderColor = Color.White.copy(alpha = 0.26f), + shadowElevation = 4.dp, + ) + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(7.dp), + ) { + LumaIconView(icon, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp)) + Text(text, color = Color.White, fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + } +} + +@Composable +private fun LiquidEpisodeItem(episode: EmbyItem, serverUrl: String, selected: Boolean, onClick: () -> Unit) { + val imageUrl = EmbyRepository.buildEpisodeImageUrl(serverUrl, episode, maxWidth = 360) + val episodeNumber = episode.indexNumber?.let { stringResource(Res.string.detail_episode_number, it) } + ?: stringResource(Res.string.detail_episode_short) + val surface = if (selected) Color(0xFF4D78B8).copy(alpha = 0.48f) else Color(0xFF16243A).copy(alpha = 0.72f) Row( Modifier .fillMaxWidth() .lumaGlass( - backdrop = bd, + backdrop = null, radius = LumaShapes.small, level = LumaGlassLevel.Interactive, tint = surface, - borderColor = Color.White.copy(alpha = 0.16f), + borderColor = Color.White.copy(alpha = if (selected) 0.34f else 0.16f), shadowElevation = 0.dp, ) .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = onClick) - .padding(12.dp), + .padding(8.dp), verticalAlignment = Alignment.CenterVertically ) { - Box(Modifier.size(32.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.1f)), contentAlignment = Alignment.Center) { - Text(displayIndex.toString(), color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.Bold) + Box( + Modifier + .size(width = 112.dp, height = 63.dp) + .clip(RoundedCornerShape(10.dp)) + .background(Color(0xFF0B1424)), + contentAlignment = Alignment.Center, + ) { + if (imageUrl != null) { + LumaAsyncImage( + model = imageUrl, + contentDescription = episode.name, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } else { + Text(episodeNumber, color = Color.White.copy(alpha = 0.72f), fontSize = 12.sp, fontWeight = FontWeight.Bold) + } + } + Column(Modifier.weight(1f).padding(start = 12.dp)) { + Text( + episodeNumber, + color = if (selected) Color(0xFFBFD8FF) else Color.White.copy(alpha = 0.64f), + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(3.dp)) + Text( + episode.name, + color = Color.White, + fontSize = 14.sp, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + if (selected) { + LumaIconView(LumaIcon.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(18.dp)) } - Text(episode.name, color = Color.White, fontSize = 14.sp, modifier = Modifier.weight(1f).padding(start = 12.dp), maxLines = 1, overflow = TextOverflow.Ellipsis) } } diff --git a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt index 9c5a83b..87ffd5e 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt @@ -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 diff --git a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaIcons.kt b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaIcons.kt index 83ad323..1a818b7 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaIcons.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaIcons.kt @@ -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), diff --git a/app/src/main/res/drawable/aurora_brightness.xml b/app/src/main/res/drawable/aurora_brightness.xml new file mode 100644 index 0000000..5f503f6 --- /dev/null +++ b/app/src/main/res/drawable/aurora_brightness.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/app/src/main/res/drawable/aurora_brightness_selected.xml b/app/src/main/res/drawable/aurora_brightness_selected.xml new file mode 100644 index 0000000..f09a38a --- /dev/null +++ b/app/src/main/res/drawable/aurora_brightness_selected.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 81015fe..8a0f183 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -143,6 +143,21 @@ Playback failed Subtitles Episode list + No episodes are available + Audio tracks + Disable audio + Audio track + Subtitle + Playback tools + Chapter markers + No chapter markers are available + Chapter %1$s + Skip intro + Skip outro + Confirm before continuous playback + Continue playing? + Play next episode + Not now Close Seek back 10 seconds Previous episode @@ -153,6 +168,11 @@ Playback progress Brightness Volume + Lock screen + Unlock screen + Switch orientation + Forward + Rewind Disable subtitles This resource cannot be played Failed to resolve playback URL diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 215dcfa..6bd8cd2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -143,6 +143,21 @@ 播放失败 字幕 剧集列表 + 未找到可用剧集 + 音轨 + 关闭音轨 + 音轨 + 字幕 + 播放工具 + 章节标记 + 该媒体没有章节标记 + 章节 %1$s + 跳过片头 + 跳过片尾 + 连播前确认 + 连续播放确认 + 播放下一集 + 暂不继续播放 关闭 后退 10 秒 上一集 @@ -153,6 +168,11 @@ 播放进度 亮度 音量 + 锁定屏幕 + 解锁屏幕 + 切换横竖屏 + 快进 + 快退 关闭字幕 播放器无法播放该资源 获取播放地址失败 diff --git a/app/src/test/kotlin/com/example/myapplication/data/PlaybackReportingTest.kt b/app/src/test/kotlin/com/example/myapplication/data/PlaybackReportingTest.kt new file mode 100644 index 0000000..66872d9 --- /dev/null +++ b/app/src/test/kotlin/com/example/myapplication/data/PlaybackReportingTest.kt @@ -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)) + } +} diff --git a/app/src/test/kotlin/com/example/myapplication/screens/VideoPlayerGestureTest.kt b/app/src/test/kotlin/com/example/myapplication/screens/VideoPlayerGestureTest.kt new file mode 100644 index 0000000..9fe4ad0 --- /dev/null +++ b/app/src/test/kotlin/com/example/myapplication/screens/VideoPlayerGestureTest.kt @@ -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)) + } +}