diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index 9d2c97bd..edce0781 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -80,6 +80,10 @@ actual fun PlatformPlayerSurface( playWhenReady: Boolean, resizeMode: PlayerResizeMode, useNativeController: Boolean, + playerControlsState: PlayerControlsState, + onPlayerControlsAction: (PlayerControlsAction) -> Boolean, + onPlayerControlsScrubChange: (Long) -> Boolean, + onPlayerControlsScrubFinished: (Long) -> Boolean, onControllerReady: (PlayerEngineController) -> Unit, onSnapshot: (PlayerPlaybackSnapshot) -> Unit, onError: (String?) -> Unit, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt index ae78fb8d..260a6077 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt @@ -23,6 +23,61 @@ interface PlayerEngineController { fun configureIosVideoOutput(settings: PlayerSettingsUiState) {} } +enum class PlayerControlsAction { + ToggleChrome, + RevealLockedOverlay, + Back, + TogglePlayback, + SeekBack, + SeekForward, + ResizeMode, + Speed, + Subtitles, + Audio, + Sources, + Episodes, + OpenExternalPlayer, + SubmitIntro, + LockToggle, + VideoSettings, + DoubleTapSeekBack, + DoubleTapSeekForward, +} + +data class PlayerControlsState( + val title: String = "", + val episodeText: String = "", + val streamTitle: String = "", + val providerName: String = "", + val resizeModeLabel: String = "Fit", + val playbackSpeedLabel: String = "1x", + val subtitlesLabel: String = "Subs", + val audioLabel: String = "Audio", + val sourcesLabel: String = "Sources", + val episodesLabel: String = "Episodes", + val externalPlayerLabel: String = "External", + val playLabel: String = "Play", + val pauseLabel: String = "Pause", + val closeLabel: String = "Close player", + val lockLabel: String = "Lock player controls", + val unlockLabel: String = "Unlock player controls", + val submitIntroLabel: String = "Submit Intro", + val videoSettingsLabel: String = "Video settings", + val tapToUnlockLabel: String = "Tap to unlock", + val isPlaying: Boolean = false, + val isLoading: Boolean = false, + val isLocked: Boolean = false, + val lockedOverlayVisible: Boolean = false, + val controlsVisible: Boolean = true, + val showSubmitIntro: Boolean = false, + val showVideoSettings: Boolean = false, + val showSources: Boolean = false, + val showEpisodes: Boolean = false, + val showExternalPlayer: Boolean = false, + val durationMs: Long = 0L, + val positionMs: Long = 0L, +) + internal fun sanitizePlaybackHeaders(headers: Map?): Map { val rawHeaders = headers ?: return emptyMap() if (rawHeaders.isEmpty()) return emptyMap() @@ -63,6 +118,10 @@ expect fun PlatformPlayerSurface( playWhenReady: Boolean = true, resizeMode: PlayerResizeMode = PlayerResizeMode.Fit, useNativeController: Boolean = false, + playerControlsState: PlayerControlsState = PlayerControlsState(), + onPlayerControlsAction: (PlayerControlsAction) -> Boolean = { false }, + onPlayerControlsScrubChange: (Long) -> Boolean = { false }, + onPlayerControlsScrubFinished: (Long) -> Boolean = { false }, onControllerReady: (PlayerEngineController) -> Unit, onSnapshot: (PlayerPlaybackSnapshot) -> Unit, onError: (String?) -> Unit, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt index 6319676f..a3a8f21b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt @@ -16,12 +16,16 @@ import com.nuvio.app.features.p2p.formatP2pSpeed import com.nuvio.app.isIos import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource @Composable internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { val runtime = this val displayedPositionMs = scrubbingPositionMs ?: playbackSnapshot.positionMs - val isEpisode = activeSeasonNumber != null && activeEpisodeNumber != null + val seasonNumber = activeSeasonNumber + val episodeNumber = activeEpisodeNumber + val episodeTitle = activeEpisodeTitle + val isEpisode = seasonNumber != null && episodeNumber != null val currentGestureFeedback = liveGestureFeedback ?: gestureFeedback val isP2pPlaybackActive = activeTorrentInfoHash != null val p2pStats = p2pStreamingState as? P2pStreamingState.Streaming @@ -81,6 +85,51 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { (bufferedSeconds / 10f).coerceIn(0f, 1f) } } + val episodeText = if (seasonNumber != null && episodeNumber != null && !episodeTitle.isNullOrBlank()) { + stringResource( + Res.string.compose_player_episode_title_format, + seasonNumber, + episodeNumber, + episodeTitle.orEmpty(), + ) + } else { + "" + } + val playerControlsState = PlayerControlsState( + title = title, + episodeText = episodeText, + streamTitle = activeStreamTitle, + providerName = activeProviderName, + resizeModeLabel = stringResource(resizeMode.labelRes), + playbackSpeedLabel = formatPlaybackSpeedLabel(playbackSnapshot.playbackSpeed), + subtitlesLabel = stringResource(Res.string.compose_player_subs), + audioLabel = stringResource(Res.string.compose_player_audio), + sourcesLabel = stringResource(Res.string.compose_player_sources), + episodesLabel = stringResource(Res.string.compose_player_episodes), + externalPlayerLabel = stringResource(Res.string.streams_open_external_player), + playLabel = stringResource(Res.string.detail_btn_play), + pauseLabel = stringResource(Res.string.compose_action_pause), + closeLabel = stringResource(Res.string.compose_player_close), + lockLabel = stringResource(Res.string.compose_player_lock_controls), + unlockLabel = stringResource(Res.string.compose_player_unlock_controls), + submitIntroLabel = stringResource(Res.string.submit_intro_action), + videoSettingsLabel = stringResource(Res.string.player_action_video_settings), + tapToUnlockLabel = stringResource(Res.string.compose_player_tap_to_unlock), + isPlaying = playbackSnapshot.isPlaying, + isLoading = playbackSnapshot.isLoading, + isLocked = playerControlsLocked, + lockedOverlayVisible = lockedOverlayVisible, + controlsVisible = (controlsVisible || showParentalGuide) && !playerControlsLocked, + showSubmitIntro = isSeries && + playerSettingsUiState.introSubmitEnabled && + playerSettingsUiState.introDbApiKey.isNotBlank(), + showVideoSettings = isIos, + showSources = activeVideoId != null, + showEpisodes = isSeries, + showExternalPlayer = args.onOpenInExternalPlayer != null, + durationMs = playbackSnapshot.durationMs, + positionMs = displayedPositionMs, + ) val gestureCallbacks = rememberSurfaceGestureCallbacks() Box( @@ -123,6 +172,16 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { modifier = Modifier.fillMaxSize(), playWhenReady = shouldPlay, resizeMode = resizeMode, + playerControlsState = playerControlsState, + onPlayerControlsAction = { action -> handlePlayerControlsAction(action) }, + onPlayerControlsScrubChange = { positionMs -> + handlePlayerControlsScrubChange(positionMs) + true + }, + onPlayerControlsScrubFinished = { positionMs -> + handlePlayerControlsScrubFinished(positionMs) + true + }, onControllerReady = { controller -> playerController = controller playerControllerSourceUrl = activeSourceUrl @@ -284,6 +343,102 @@ private fun PlayerScreenRuntime.RenderPlayerControls(displayedPositionMs: Long, } } +private fun PlayerScreenRuntime.handlePlayerControlsAction(action: PlayerControlsAction): Boolean { + when (action) { + PlayerControlsAction.ToggleChrome -> { + if (playerControlsLocked) { + revealLockedOverlay() + } else { + controlsVisible = !controlsVisible + } + } + PlayerControlsAction.RevealLockedOverlay -> revealLockedOverlay() + PlayerControlsAction.Back -> { + flushWatchProgress() + args.onBack() + } + PlayerControlsAction.TogglePlayback -> togglePlayback() + PlayerControlsAction.SeekBack -> seekBy(-10_000L) + PlayerControlsAction.SeekForward -> seekBy(10_000L) + PlayerControlsAction.ResizeMode -> cycleResizeMode() + PlayerControlsAction.Speed -> cyclePlaybackSpeed() + PlayerControlsAction.Subtitles -> { + refreshTracks() + showSubtitleModal = true + } + PlayerControlsAction.Audio -> { + refreshTracks() + showAudioModal = true + } + PlayerControlsAction.Sources -> { + if (activeVideoId != null) openSourcesPanel() + } + PlayerControlsAction.Episodes -> { + if (isSeries) openEpisodesPanel() + } + PlayerControlsAction.OpenExternalPlayer -> openInExternalPlayer() + PlayerControlsAction.SubmitIntro -> { + if ( + isSeries && + playerSettingsUiState.introSubmitEnabled && + playerSettingsUiState.introDbApiKey.isNotBlank() + ) { + showSubmitIntroModal = true + } + } + PlayerControlsAction.LockToggle -> { + if (playerControlsLocked) unlockPlayerControls() else lockPlayerControls() + } + PlayerControlsAction.VideoSettings -> { + if (isIos) { + showVideoSettingsModal = true + controlsVisible = true + } + } + PlayerControlsAction.DoubleTapSeekBack -> handleDoubleTapSeek(PlayerSeekDirection.Backward) + PlayerControlsAction.DoubleTapSeekForward -> handleDoubleTapSeek(PlayerSeekDirection.Forward) + } + return true +} + +private fun PlayerScreenRuntime.handlePlayerControlsScrubChange(positionMs: Long) { + isScrubbingTimeline = true + scrubbingPositionMs = positionMs +} + +private fun PlayerScreenRuntime.handlePlayerControlsScrubFinished(positionMs: Long) { + isScrubbingTimeline = false + scrubbingPositionMs = null + playerController?.seekTo(positionMs) + scheduleProgressSyncAfterSeek() +} + +private fun PlayerScreenRuntime.openInExternalPlayer() { + val openExternal = args.onOpenInExternalPlayer ?: return + val loadedSubtitles = addonSubtitles + .takeIf { it.isNotEmpty() } + ?.map { sub -> + SubtitleInput( + url = sub.url, + name = buildString { + if (!sub.addonName.isNullOrBlank()) append("[${sub.addonName}] ") + append(sub.display) + }, + lang = sub.language, + ) + } + openExternal( + ExternalPlayerPlaybackRequest( + sourceUrl = activeSourceUrl, + title = title, + streamTitle = activeStreamTitle, + sourceHeaders = activeSourceHeaders, + resumePositionMs = playbackSnapshot.positionMs, + subtitles = loadedSubtitles, + ), + ) +} + @Composable private fun BoxScope.RenderPlaybackOverlays( runtime: PlayerScreenRuntime, diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerEngine.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerEngine.desktop.kt index 8fd657c7..da8cd2f5 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerEngine.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerEngine.desktop.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.awt.SwingPanel import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -29,6 +30,10 @@ actual fun PlatformPlayerSurface( playWhenReady: Boolean, resizeMode: PlayerResizeMode, useNativeController: Boolean, + playerControlsState: PlayerControlsState, + onPlayerControlsAction: (PlayerControlsAction) -> Boolean, + onPlayerControlsScrubChange: (Long) -> Boolean, + onPlayerControlsScrubFinished: (Long) -> Boolean, onControllerReady: (PlayerEngineController) -> Unit, onSnapshot: (PlayerPlaybackSnapshot) -> Unit, onError: (String?) -> Unit, @@ -39,6 +44,10 @@ actual fun PlatformPlayerSurface( sourceHeaders = sourceHeaders, modifier = modifier, playWhenReady = playWhenReady, + playerControlsState = playerControlsState, + onPlayerControlsAction = onPlayerControlsAction, + onPlayerControlsScrubChange = onPlayerControlsScrubChange, + onPlayerControlsScrubFinished = onPlayerControlsScrubFinished, onControllerReady = onControllerReady, onSnapshot = onSnapshot, onError = onError, @@ -59,6 +68,10 @@ private fun NativePlayerSurface( sourceHeaders: Map, modifier: Modifier, playWhenReady: Boolean, + playerControlsState: PlayerControlsState, + onPlayerControlsAction: (PlayerControlsAction) -> Boolean, + onPlayerControlsScrubChange: (Long) -> Boolean, + onPlayerControlsScrubFinished: (Long) -> Boolean, onControllerReady: (PlayerEngineController) -> Unit, onSnapshot: (PlayerPlaybackSnapshot) -> Unit, onError: (String?) -> Unit, @@ -66,11 +79,22 @@ private fun NativePlayerSurface( val host = remember { NativePlayerHost() } val controller = remember(host) { NativePlayerController(host) } val playbackHeaders = remember(sourceHeaders) { sanitizePlaybackHeaders(sourceHeaders) } + val latestOnPlayerControlsAction = rememberUpdatedState(onPlayerControlsAction) + val latestOnPlayerControlsScrubChange = rememberUpdatedState(onPlayerControlsScrubChange) + val latestOnPlayerControlsScrubFinished = rememberUpdatedState(onPlayerControlsScrubFinished) LaunchedEffect(controller) { onControllerReady(controller) } + LaunchedEffect(controller) { + controller.setControlCallbacks( + onAction = { action -> latestOnPlayerControlsAction.value(action) }, + onScrubChange = { positionMs -> latestOnPlayerControlsScrubChange.value(positionMs) }, + onScrubFinished = { positionMs -> latestOnPlayerControlsScrubFinished.value(positionMs) }, + ) + } + DisposableEffect(controller, sourceUrl, playbackHeaders) { controller.attach( sourceUrl = sourceUrl, @@ -89,6 +113,10 @@ private fun NativePlayerSurface( } } + LaunchedEffect(controller, playerControlsState) { + controller.updateControls(playerControlsState) + } + LaunchedEffect(controller) { while (true) { onSnapshot(controller.snapshot()) diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerBridge.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerBridge.kt index 9179425e..0798c87b 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerBridge.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerBridge.kt @@ -3,6 +3,10 @@ package com.nuvio.app.features.player.desktop import java.io.File import java.nio.file.Files +internal fun interface NativePlayerEventSink { + fun onPlayerEvent(type: String, value: Double) +} + internal object NativePlayerBridge { init { loadNativeLibrary() @@ -14,9 +18,11 @@ internal object NativePlayerBridge { headerLines: Array, playWhenReady: Boolean, controlsHtml: String, + eventSink: NativePlayerEventSink, ): Long external fun dispose(handle: Long) + external fun updateControls(handle: Long, controlsJson: String) external fun setPaused(handle: Long, paused: Boolean) external fun seekTo(handle: Long, positionMs: Long) external fun seekBy(handle: Long, offsetMs: Long) diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt index 733ee69b..f6e66598 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/NativePlayerController.kt @@ -1,6 +1,8 @@ package com.nuvio.app.features.player.desktop import com.nuvio.app.features.player.AudioTrack +import com.nuvio.app.features.player.PlayerControlsAction +import com.nuvio.app.features.player.PlayerControlsState import com.nuvio.app.features.player.PlayerEngineController import com.nuvio.app.features.player.PlayerPlaybackSnapshot import com.nuvio.app.features.player.SubtitleTrack @@ -13,6 +15,15 @@ internal class NativePlayerController( @Volatile private var handle: Long = 0L private var pendingSource: PendingSource? = null + private var controlsState = PlayerControlsState() + private var onAction: (PlayerControlsAction) -> Boolean = { false } + private var onScrubChange: (Long) -> Boolean = { false } + private var onScrubFinished: (Long) -> Boolean = { false } + private val eventSink = NativePlayerEventSink { type, value -> + SwingUtilities.invokeLater { + handlePlayerEvent(type, value) + } + } fun attach( sourceUrl: String, @@ -41,14 +52,84 @@ internal class NativePlayerController( headerLines = pending.headerLines.toTypedArray(), playWhenReady = pending.playWhenReady, controlsHtml = NativePlayerBridge.controlsHtml, + eventSink = eventSink, ) if (handle == 0L) error("Native player did not return a handle.") + updateControls(controlsState) }.onFailure { error -> pending.onError(error.message) } } } + fun setControlCallbacks( + onAction: (PlayerControlsAction) -> Boolean, + onScrubChange: (Long) -> Boolean, + onScrubFinished: (Long) -> Boolean, + ) { + this.onAction = onAction + this.onScrubChange = onScrubChange + this.onScrubFinished = onScrubFinished + } + + fun updateControls(state: PlayerControlsState) { + controlsState = state + handle.takeIf { it != 0L }?.let { current -> + NativePlayerBridge.updateControls(current, state.toControlsJson()) + } + } + + private fun handlePlayerEvent(type: String, value: Double) { + when (type) { + "scrubChange" -> { + if (!onScrubChange(value.toLong())) { + updateLocalProgress(value.toLong()) + } + } + "scrubFinish" -> { + if (!onScrubFinished(value.toLong())) { + seekTo(value.toLong()) + } + } + else -> { + val action = type.toPlayerControlsAction() ?: return + if (!onAction(action)) { + handleFallbackAction(action) + } + } + } + } + + private fun updateLocalProgress(positionMs: Long) { + controlsState = controlsState.copy(positionMs = positionMs) + updateControls(controlsState) + } + + private fun handleFallbackAction(action: PlayerControlsAction) { + when (action) { + PlayerControlsAction.TogglePlayback -> { + handle.takeIf { it != 0L }?.let { current -> + NativePlayerBridge.setPaused(current, !NativePlayerBridge.isPaused(current)) + } + } + PlayerControlsAction.SeekBack -> seekBy(-10_000L) + PlayerControlsAction.SeekForward -> seekBy(10_000L) + PlayerControlsAction.DoubleTapSeekBack -> seekBy(-10_000L) + PlayerControlsAction.DoubleTapSeekForward -> seekBy(10_000L) + PlayerControlsAction.Speed -> cycleFallbackSpeed() + else -> Unit + } + } + + private fun cycleFallbackSpeed() { + val current = handle + if (current == 0L) return + val speeds = listOf(1f, 1.25f, 1.5f, 2f) + val currentSpeed = NativePlayerBridge.speed(current) + val next = speeds.firstOrNull { it > currentSpeed + 0.01f } ?: speeds.first() + NativePlayerBridge.setSpeed(current, next) + } + fun snapshot(): PlayerPlaybackSnapshot { val current = handle if (current == 0L) return PlayerPlaybackSnapshot(isLoading = true) @@ -131,3 +212,131 @@ private fun List.toHeaderMap(): Map = if (separator <= 0) return@mapNotNull null line.substring(0, separator).trim() to line.substring(separator + 1).trim() }.toMap() + +private fun String.toPlayerControlsAction(): PlayerControlsAction? = + when (this) { + "toggleChrome" -> PlayerControlsAction.ToggleChrome + "revealLockedOverlay" -> PlayerControlsAction.RevealLockedOverlay + "back" -> PlayerControlsAction.Back + "toggle" -> PlayerControlsAction.TogglePlayback + "seekBack" -> PlayerControlsAction.SeekBack + "seekForward" -> PlayerControlsAction.SeekForward + "resize" -> PlayerControlsAction.ResizeMode + "speed" -> PlayerControlsAction.Speed + "subtitles" -> PlayerControlsAction.Subtitles + "audio" -> PlayerControlsAction.Audio + "sources" -> PlayerControlsAction.Sources + "episodes" -> PlayerControlsAction.Episodes + "external" -> PlayerControlsAction.OpenExternalPlayer + "submitIntro" -> PlayerControlsAction.SubmitIntro + "lock" -> PlayerControlsAction.LockToggle + "videoSettings" -> PlayerControlsAction.VideoSettings + "doubleTapSeekBack" -> PlayerControlsAction.DoubleTapSeekBack + "doubleTapSeekForward" -> PlayerControlsAction.DoubleTapSeekForward + else -> null + } + +private fun PlayerControlsState.toControlsJson(): String = + buildString { + append('{') + appendJsonField("title", title) + append(',') + appendJsonField("episodeText", episodeText) + append(',') + appendJsonField("streamTitle", streamTitle) + append(',') + appendJsonField("providerName", providerName) + append(',') + appendJsonField("resizeModeLabel", resizeModeLabel) + append(',') + appendJsonField("playbackSpeedLabel", playbackSpeedLabel) + append(',') + appendJsonField("subtitlesLabel", subtitlesLabel) + append(',') + appendJsonField("audioLabel", audioLabel) + append(',') + appendJsonField("sourcesLabel", sourcesLabel) + append(',') + appendJsonField("episodesLabel", episodesLabel) + append(',') + appendJsonField("externalPlayerLabel", externalPlayerLabel) + append(',') + appendJsonField("playLabel", playLabel) + append(',') + appendJsonField("pauseLabel", pauseLabel) + append(',') + appendJsonField("closeLabel", closeLabel) + append(',') + appendJsonField("lockLabel", lockLabel) + append(',') + appendJsonField("unlockLabel", unlockLabel) + append(',') + appendJsonField("submitIntroLabel", submitIntroLabel) + append(',') + appendJsonField("videoSettingsLabel", videoSettingsLabel) + append(',') + appendJsonField("tapToUnlockLabel", tapToUnlockLabel) + append(',') + appendJsonField("isPlaying", isPlaying) + append(',') + appendJsonField("isLoading", isLoading) + append(',') + appendJsonField("isLocked", isLocked) + append(',') + appendJsonField("lockedOverlayVisible", lockedOverlayVisible) + append(',') + appendJsonField("controlsVisible", controlsVisible) + append(',') + appendJsonField("showSubmitIntro", showSubmitIntro) + append(',') + appendJsonField("showVideoSettings", showVideoSettings) + append(',') + appendJsonField("showSources", showSources) + append(',') + appendJsonField("showEpisodes", showEpisodes) + append(',') + appendJsonField("showExternalPlayer", showExternalPlayer) + append(',') + appendJsonField("durationMs", durationMs) + append(',') + appendJsonField("positionMs", positionMs) + append('}') + } + +private fun StringBuilder.appendJsonField(name: String, value: String) { + append('"').append(name).append("\":") + append(value.toJsonString()) +} + +private fun StringBuilder.appendJsonField(name: String, value: Boolean) { + append('"').append(name).append("\":").append(value) +} + +private fun StringBuilder.appendJsonField(name: String, value: Long) { + append('"').append(name).append("\":").append(value) +} + +private fun String.toJsonString(): String = + buildString(length + 2) { + append('"') + for (char in this@toJsonString) { + when (char) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\b' -> append("\\b") + '\u000C' -> append("\\f") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> { + if (char.code < 0x20) { + append("\\u") + append(char.code.toString(16).padStart(4, '0')) + } else { + append(char) + } + } + } + } + append('"') + } diff --git a/composeApp/src/desktopMain/native/macos/player_bridge.mm b/composeApp/src/desktopMain/native/macos/player_bridge.mm index e5648411..993b1d07 100644 --- a/composeApp/src/desktopMain/native/macos/player_bridge.mm +++ b/composeApp/src/desktopMain/native/macos/player_bridge.mm @@ -97,8 +97,12 @@ uint64_t mpv_render_context_update(mpv_render_context *ctx); sourceUrl:(NSString *)sourceUrl headerLines:(NSArray *)headerLines playWhenReady:(BOOL)playWhenReady - controlsHtml:(NSString *)controlsHtml; + controlsHtml:(NSString *)controlsHtml + javaVm:(JavaVM *)javaVm + eventSink:(jobject)eventSink + eventMethod:(jmethodID)eventMethod; - (void)shutdown; +- (void)updateControlsJson:(NSString *)controlsJson; - (void)setPaused:(BOOL)paused; - (BOOL)isPaused; - (void)seekToMilliseconds:(long long)positionMs; @@ -218,6 +222,19 @@ static void renderUpdateCallback(void *callbackContext) { } @end +static NSString *javaScriptStringLiteral(NSString *value) { + NSArray *array = @[value ?: @""]; + NSData *data = [NSJSONSerialization dataWithJSONObject:array options:0 error:nil]; + if (!data) { + return @"\"\""; + } + NSString *jsonArray = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + if (jsonArray.length < 2) { + return @"\"\""; + } + return [jsonArray substringWithRange:NSMakeRange(1, jsonArray.length - 2)]; +} + @implementation MpvWebPlayer { NSView *_hostView; PlayerOpenGLView *_videoView; @@ -226,18 +243,28 @@ static void renderUpdateCallback(void *callbackContext) { mpv_handle *_mpv; mpv_render_context *_renderContext; NSTimer *_timer; + JavaVM *_javaVm; + jobject _eventSink; + jmethodID _eventMethod; } - (instancetype)initWithHostView:(NSView *)hostView sourceUrl:(NSString *)sourceUrl headerLines:(NSArray *)headerLines playWhenReady:(BOOL)playWhenReady - controlsHtml:(NSString *)controlsHtml { + controlsHtml:(NSString *)controlsHtml + javaVm:(JavaVM *)javaVm + eventSink:(jobject)eventSink + eventMethod:(jmethodID)eventMethod { self = [super init]; if (!self) { return nil; } + _javaVm = javaVm; + _eventSink = eventSink; + _eventMethod = eventMethod; + _hostView = hostView; _hostView.wantsLayer = YES; _hostView.layer.backgroundColor = NSColor.blackColor.CGColor; @@ -345,6 +372,66 @@ static void renderUpdateCallback(void *callbackContext) { [_webView evaluateJavaScript:script completionHandler:nil]; } +- (JNIEnv *)jniEnvDidAttach:(BOOL *)didAttach { + if (didAttach) { + *didAttach = NO; + } + if (!_javaVm) { + return nullptr; + } + + JNIEnv *env = nullptr; + jint status = _javaVm->GetEnv((void **)&env, JNI_VERSION_1_6); + if (status == JNI_OK) { + return env; + } + if (status != JNI_EDETACHED) { + return nullptr; + } + if (_javaVm->AttachCurrentThread((void **)&env, nullptr) != JNI_OK) { + return nullptr; + } + if (didAttach) { + *didAttach = YES; + } + return env; +} + +- (void)sendPlayerEvent:(NSString *)type value:(double)value { + if (!_eventSink || !_eventMethod) { + return; + } + + BOOL didAttach = NO; + JNIEnv *env = [self jniEnvDidAttach:&didAttach]; + if (!env) { + return; + } + + jstring eventType = env->NewStringUTF(type.UTF8String); + env->CallVoidMethod(_eventSink, _eventMethod, eventType, (jdouble)value); + if (env->ExceptionCheck()) { + env->ExceptionClear(); + } + if (eventType) { + env->DeleteLocalRef(eventType); + } + if (didAttach) { + _javaVm->DetachCurrentThread(); + } +} + +- (void)updateControlsJson:(NSString *)controlsJson { + if (!_webView) { + return; + } + NSString *jsonString = javaScriptStringLiteral(controlsJson); + NSString *script = [NSString stringWithFormat: + @"if (window.playerControls) window.playerControls(JSON.parse(%@))", + jsonString]; + [_webView evaluateJavaScript:script completionHandler:nil]; +} + - (void)shutdown { [_timer invalidate]; _timer = nil; @@ -364,6 +451,19 @@ static void renderUpdateCallback(void *callbackContext) { _webView = nil; _videoView = nil; _scriptHandler = nil; + if (_eventSink) { + BOOL didAttach = NO; + JNIEnv *env = [self jniEnvDidAttach:&didAttach]; + if (env) { + env->DeleteGlobalRef(_eventSink); + } + if (didAttach) { + _javaVm->DetachCurrentThread(); + } + _eventSink = nullptr; + } + _eventMethod = nullptr; + _javaVm = nullptr; } - (void)setPaused:(BOOL)paused { @@ -422,15 +522,26 @@ static void renderUpdateCallback(void *callbackContext) { - (void)handleScriptMessage:(NSDictionary *)message { NSString *type = message[@"type"]; + if (![type isKindOfClass:[NSString class]]) { + return; + } + + NSNumber *value = message[@"value"]; + if (_eventSink && _eventMethod) { + [self sendPlayerEvent:type value:value ? value.doubleValue : 0.0]; + return; + } + if ([type isEqualToString:@"toggle"]) { [self setPaused:![self isPaused]]; [self syncControls]; } else if ([type isEqualToString:@"seekPercent"]) { - NSNumber *value = message[@"value"]; double duration = [self doubleProperty:"duration" fallback:0.0]; if (duration > 0.0 && value) { [self seekToMilliseconds:(long long)llround(duration * value.doubleValue * 1000.0)]; } + } else if ([type isEqualToString:@"scrubFinish"] && value) { + [self seekToMilliseconds:(long long)llround(value.doubleValue)]; } } @@ -494,7 +605,8 @@ Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_create( jstring sourceUrl, jobjectArray headerLines, jboolean playWhenReady, - jstring controlsHtml + jstring controlsHtml, + jobject eventSink ) { NSView *hostView = (__bridge NSView *)(void *)(intptr_t)hostViewPtr; if (!hostView) { @@ -502,6 +614,24 @@ Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_create( return 0; } + JavaVM *javaVm = nullptr; + env->GetJavaVM(&javaVm); + jobject eventSinkRef = nullptr; + jmethodID eventMethod = nullptr; + if (eventSink) { + eventSinkRef = env->NewGlobalRef(eventSink); + jclass eventSinkClass = env->GetObjectClass(eventSink); + eventMethod = env->GetMethodID(eventSinkClass, "onPlayerEvent", "(Ljava/lang/String;D)V"); + env->DeleteLocalRef(eventSinkClass); + if (!eventMethod) { + if (eventSinkRef) { + env->DeleteGlobalRef(eventSinkRef); + } + throwJavaError(env, @"Native player event sink is missing onPlayerEvent(String, Double)."); + return 0; + } + } + std::string source = jstringToString(env, sourceUrl); std::string controls = jstringToString(env, controlsHtml); NSArray *headers = jstringArrayToNSArray(env, headerLines); @@ -511,16 +641,22 @@ Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_create( @try { player = [[MpvWebPlayer alloc] initWithHostView:hostView - sourceUrl:[NSString stringWithUTF8String:source.c_str()] + sourceUrl:[NSString stringWithUTF8String:source.c_str()] headerLines:headers playWhenReady:playWhenReady == JNI_TRUE - controlsHtml:[NSString stringWithUTF8String:controls.c_str()]]; + controlsHtml:[NSString stringWithUTF8String:controls.c_str()] + javaVm:javaVm + eventSink:eventSinkRef + eventMethod:eventMethod]; } @catch (NSException *exception) { error = exception.reason ?: exception.name; } }); if (error) { + if (eventSinkRef) { + env->DeleteGlobalRef(eventSinkRef); + } throwJavaError(env, error); return 0; } @@ -541,6 +677,21 @@ Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_dispose( }); } +extern "C" JNIEXPORT void JNICALL +Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_updateControls( + JNIEnv *env, + jobject /* bridge */, + jlong handle, + jstring controlsJson +) { + if (handle == 0) return; + std::string controls = jstringToString(env, controlsJson); + MpvWebPlayer *player = (__bridge MpvWebPlayer *)(void *)(intptr_t)handle; + runOnMainAsync(^{ + [player updateControlsJson:[NSString stringWithUTF8String:controls.c_str()]]; + }); +} + extern "C" JNIEXPORT void JNICALL Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_setPaused( JNIEnv * /* env */, diff --git a/composeApp/src/desktopMain/resources/player-ui/controls.html b/composeApp/src/desktopMain/resources/player-ui/controls.html index fb190e66..bd821dc4 100644 --- a/composeApp/src/desktopMain/resources/player-ui/controls.html +++ b/composeApp/src/desktopMain/resources/player-ui/controls.html @@ -122,6 +122,38 @@ height: 100%; } + .top-gradient, + .bottom-gradient, + .metadata, + .header-actions, + .center-controls, + .progress { + transition: opacity 160ms ease; + } + + .player.chrome-hidden .top-gradient, + .player.chrome-hidden .bottom-gradient, + .player.chrome-hidden .metadata, + .player.chrome-hidden .header-actions, + .player.chrome-hidden .center-controls, + .player.chrome-hidden .progress, + .player.locked .metadata, + .player.locked .header-actions, + .player.locked .center-controls { + opacity: 0; + pointer-events: none; + } + + .player.locked.locked-visible .bottom-gradient, + .player.locked.locked-visible .progress { + opacity: 1; + pointer-events: none; + } + + [hidden] { + display: none !important; + } + .top-gradient, .bottom-gradient { position: absolute; @@ -272,6 +304,48 @@ flex-direction: column; } + .locked-overlay { + position: absolute; + inset: 0; + display: none; + place-items: center; + text-align: center; + pointer-events: none; + } + + .player.locked.locked-visible .locked-overlay { + display: grid; + } + + .locked-content { + display: grid; + justify-items: center; + gap: 12px; + pointer-events: auto; + } + + .locked-button { + width: 78px; + height: 78px; + display: grid; + place-items: center; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, .18); + background: rgba(0, 0, 0, .52); + } + + .locked-button svg { + width: 34px; + height: 34px; + } + + .locked-label { + font-size: 15px; + line-height: 1.25; + font-weight: 600; + color: rgba(255, 255, 255, .92); + } + .scrub { width: 100%; height: var(--slider-touch); @@ -411,6 +485,9 @@ + + + @@ -427,11 +504,12 @@ - + - - + + + @@ -444,24 +522,24 @@ -
+
@@ -481,32 +559,96 @@
- - - - - + + + + +
+
+
+ +
Tap to unlock
+
+
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt index adfa0126..d1b83214 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt @@ -34,6 +34,10 @@ actual fun PlatformPlayerSurface( playWhenReady: Boolean, resizeMode: PlayerResizeMode, useNativeController: Boolean, + playerControlsState: PlayerControlsState, + onPlayerControlsAction: (PlayerControlsAction) -> Boolean, + onPlayerControlsScrubChange: (Long) -> Boolean, + onPlayerControlsScrubFinished: (Long) -> Boolean, onControllerReady: (PlayerEngineController) -> Unit, onSnapshot: (PlayerPlaybackSnapshot) -> Unit, onError: (String?) -> Unit,