feat: player ui parity phase 2

This commit is contained in:
tapframe 2026-06-08 04:13:23 +05:30
parent b649726176
commit 0b5e409372
9 changed files with 885 additions and 52 deletions

View file

@ -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,

View file

@ -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<String, String>?): Map<String, String> {
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,

View file

@ -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,

View file

@ -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<String, String>,
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())

View file

@ -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<String>,
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)

View file

@ -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<String>.toHeaderMap(): Map<String, String> =
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('"')
}

View file

@ -97,8 +97,12 @@ uint64_t mpv_render_context_update(mpv_render_context *ctx);
sourceUrl:(NSString *)sourceUrl
headerLines:(NSArray<NSString *> *)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<NSString *> *)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<NSString *> *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 */,

View file

@ -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 @@
<symbol id="icon-lock" viewBox="0 0 24 24">
<path d="M17 9h-1V7a4 4 0 0 0-8 0v2H7c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2v-8c0-1.1-.9-2-2-2Zm-7 0V7a2 2 0 0 1 4 0v2h-4Z"/>
</symbol>
<symbol id="icon-lock-open" viewBox="0 0 24 24">
<path d="M12 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm6-8h-8V7a2 2 0 1 1 4 0h2a4 4 0 0 0-8 0v2H7c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2v-8c0-1.1-.9-2-2-2Z"/>
</symbol>
<symbol id="icon-build" viewBox="0 0 24 24">
<path d="M22 19.59 12.41 10 14 8.41l1.17 1.17 2.83-2.83-1.17-1.17L19.66 2.75A6 6 0 0 0 12.2 10.2L2.3 20.1a1 1 0 0 0 0 1.41l.19.19a1 1 0 0 0 1.41 0l9.9-9.9A6 6 0 0 0 21.25 4.34l-2.83 2.83 1.17 1.17-2.83 2.83L15.59 10 14 11.59 20.41 18 22 19.59Z"/>
</symbol>
@ -427,11 +504,12 @@
<path d="M12 4a10 10 0 0 0-8.66 15h17.32A10 10 0 0 0 12 4Zm0 2a8 8 0 0 1 7.39 11H4.61A8 8 0 0 1 12 6Zm4.2 3.8-3.08 4.62a2 2 0 1 1-1.66-1.11l4.04-4.04.7.53Z"/>
</symbol>
<symbol id="icon-subs" viewBox="0 0 24 24">
<path d="M1 12C1 5 2.5 4 12 4s11 1 11 8-1.5 8-11 8S1 19 1 12Zm2 0c0 3.02.31 4.59 1.42 5.34C5.62 18.15 7.89 18 12 18s6.38.15 7.58-.66C20.69 16.59 21 15.02 21 12s-.31-4.59-1.42-5.34C18.38 5.85 16.11 6 12 6s-6.38-.15-7.58.66C3.31 7.41 3 8.98 3 12Zm3 3h6v1H6v-1Zm7 0h5v1h-5v-1ZM6 11h4v1H6v-1Zm5 0h7v1h-7v-1Z"/>
<path fill="none" stroke="currentColor" stroke-width="2" d="M1,12 C1,5 2.5,4 12,4 C21.5,4 23,5 23,12 C23,19 21.5,20 12,20 C2.5,20 1,19 1,12 Z M5.25,14 C5.25,15.5 6,16 7.75,16 C9.5,16 10.25,15.5 10.25,14 L9.97861679,14 C9.97861671,15.25 8.97905547,16 7.75,16 C6.52094453,16 5.52138329,15.25 5.52138321,14 L5.52138321,10 C5.5,8.75 6.5,8 7.75,8 C9,8 10,8.75 9.97861679,10 L10.25,10 C10.25,8.75 9.2286998,8 7.75,8 C6.2713002,8 5.25,8.75 5.25,10 L5.25,14 Z M13.25,14 C13.25,15.5 14,16 15.75,16 C17.5,16 18.25,15.5 18.25,14 L17.9786168,14 C17.9786167,15.25 16.9790555,16 15.75,16 C14.5209445,16 13.5213833,15.25 13.5213832,14 L13.5213832,10 C13.5,8.75 14.5,8 15.75,8 C17,8 18,8.75 17.9786168,10 L18.25,10 C18.25,8.75 17.2286998,8 15.75,8 C14.2713002,8 13.25,8.75 13.25,10 L13.25,14 Z"/>
</symbol>
<symbol id="icon-audio" viewBox="0 0 24 24">
<path d="M12 6.25c-.69 0-1.25.56-1.25 1.25S11.31 8.75 12 8.75s1.25-.56 1.25-1.25S12.69 6.25 12 6.25ZM9.75 15.5A2.25 2.25 0 1 1 12 17.75a2.25 2.25 0 0 1-2.25-2.25Z"/>
<path d="M4 10c0-3.77 0-5.66 1.17-6.83C6.34 2 8.23 2 12 2s5.66 0 6.83 1.17C20 4.34 20 6.23 20 10v4c0 3.77 0 5.66-1.17 6.83C17.66 22 15.77 22 12 22s-5.66 0-6.83-1.17C4 19.66 4 17.77 4 14v-4Zm5.25-2.5a2.75 2.75 0 1 0 5.5 0 2.75 2.75 0 0 0-5.5 0ZM12 11.75a3.75 3.75 0 1 0 0 7.5 3.75 3.75 0 0 0 0-7.5Z"/>
<path d="M12 6.25C11.3096 6.25 10.75 6.80964 10.75 7.5C10.75 8.19036 11.3096 8.75 12 8.75C12.6904 8.75 13.25 8.19036 13.25 7.5C13.25 6.80964 12.6904 6.25 12 6.25Z"/>
<path d="M9.75 15.5C9.75 14.2574 10.7574 13.25 12 13.25C13.2426 13.25 14.25 14.2574 14.25 15.5C14.25 16.7426 13.2426 17.75 12 17.75C10.7574 17.75 9.75 16.7426 9.75 15.5Z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 10C4 6.22876 4 4.34315 5.17157 3.17157C6.34315 2 8.22876 2 12 2C15.7712 2 17.6569 2 18.8284 3.17157C20 4.34315 20 6.22876 20 10V14C20 17.7712 20 19.6569 18.8284 20.8284C17.6569 22 15.7712 22 12 22C8.22876 22 6.34315 22 5.17157 20.8284C4 19.6569 4 17.7712 4 14V10ZM9.25 7.5C9.25 5.98122 10.4812 4.75 12 4.75C13.5188 4.75 14.75 5.98122 14.75 7.5C14.75 9.01878 13.5188 10.25 12 10.25C10.4812 10.25 9.25 9.01878 9.25 7.5ZM12 11.75C9.92893 11.75 8.25 13.4289 8.25 15.5C8.25 17.5711 9.92893 19.25 12 19.25C14.0711 19.25 15.75 17.5711 15.75 15.5C15.75 13.4289 14.0711 11.75 12 11.75Z"/>
</symbol>
<symbol id="icon-swap" viewBox="0 0 24 24">
<path d="M7 7h11l-3-3 1.4-1.4L21.8 8l-5.4 5.4L15 12l3-3H7V7Zm10 10H6l3 3-1.4 1.4L2.2 16l5.4-5.4L9 12l-3 3h11v2Z"/>
@ -444,24 +522,24 @@
</symbol>
</svg>
<div class="player">
<div class="player" id="playerRoot">
<div class="top-gradient"></div>
<div class="bottom-gradient"></div>
<div class="content">
<header class="header">
<section class="metadata" aria-label="Playback metadata">
<div class="title" id="title">Player controls</div>
<div class="episode" id="episode">S1 E1 - Episode title</div>
<div class="title" id="title"></div>
<div class="episode" id="episode" hidden></div>
<div class="meta-row">
<span class="stream-title" id="streamTitle">Stream title</span>
<span class="provider" id="providerName">Provider</span>
<span class="stream-title" id="streamTitle"></span>
<span class="provider" id="providerName"></span>
</div>
</section>
<nav class="header-actions" aria-label="Player actions">
<button class="header-button" data-command="submitIntro" aria-label="Submit intro"><svg><use href="#icon-flag"></use></svg></button>
<button class="header-button" data-command="lock" aria-label="Lock controls"><svg><use href="#icon-lock"></use></svg></button>
<button class="header-button" data-command="videoSettings" aria-label="Video settings"><svg><use href="#icon-build"></use></svg></button>
<button class="header-button" data-command="close" aria-label="Close player"><svg><use href="#icon-close"></use></svg></button>
<button class="header-button" id="submitIntroButton" data-command="submitIntro" aria-label="Submit intro"><svg><use href="#icon-flag"></use></svg></button>
<button class="header-button" id="lockButton" data-command="lock" aria-label="Lock controls"><svg><use id="lockIcon" href="#icon-lock"></use></svg></button>
<button class="header-button" id="videoSettingsButton" data-command="videoSettings" aria-label="Video settings"><svg><use href="#icon-build"></use></svg></button>
<button class="header-button" id="backButton" data-command="back" aria-label="Close player"><svg><use href="#icon-close"></use></svg></button>
</nav>
</header>
@ -481,32 +559,96 @@
<div class="action-pill">
<button class="action" data-command="resize"><svg><use href="#icon-aspect"></use></svg><span id="resizeLabel">Fit</span></button>
<button class="action" data-command="speed"><svg><use href="#icon-speed"></use></svg><span id="speedLabel">1x</span></button>
<button class="action" data-command="subtitles"><svg><use href="#icon-subs"></use></svg><span>Subs</span></button>
<button class="action" data-command="audio"><svg><use href="#icon-audio"></use></svg><span>Audio</span></button>
<button class="action optional" data-command="sources"><svg><use href="#icon-swap"></use></svg><span>Sources</span></button>
<button class="action optional" data-command="episodes"><svg><use href="#icon-video"></use></svg><span>Episodes</span></button>
<button class="action optional" data-command="external"><svg><use href="#icon-open"></use></svg><span>External</span></button>
<button class="action" data-command="subtitles"><svg><use href="#icon-subs"></use></svg><span id="subtitlesLabel">Subs</span></button>
<button class="action" data-command="audio"><svg><use href="#icon-audio"></use></svg><span id="audioLabel">Audio</span></button>
<button class="action optional" id="sourcesButton" data-command="sources"><svg><use href="#icon-swap"></use></svg><span id="sourcesLabel">Sources</span></button>
<button class="action optional" id="episodesButton" data-command="episodes"><svg><use href="#icon-video"></use></svg><span id="episodesLabel">Episodes</span></button>
<button class="action optional" id="externalButton" data-command="external"><svg><use href="#icon-open"></use></svg><span id="externalLabel">External</span></button>
</div>
</div>
</section>
<section class="locked-overlay" id="lockedOverlay" aria-label="Locked player controls">
<div class="locked-content">
<button class="locked-button" data-command="lock" aria-label="Unlock player controls"><svg><use href="#icon-lock"></use></svg></button>
<div class="locked-label" id="lockedLabel">Tap to unlock</div>
</div>
</section>
</div>
</div>
<script>
const root = document.getElementById("playerRoot");
const seek = document.getElementById("seek");
const positionLabel = document.getElementById("position");
const durationLabel = document.getElementById("duration");
const toggle = document.getElementById("toggle");
const toggleIcon = document.getElementById("toggleIcon");
const lockIcon = document.getElementById("lockIcon");
const title = document.getElementById("title");
const episode = document.getElementById("episode");
const streamTitle = document.getElementById("streamTitle");
const providerName = document.getElementById("providerName");
const resizeLabel = document.getElementById("resizeLabel");
const speedLabel = document.getElementById("speedLabel");
const subtitlesLabel = document.getElementById("subtitlesLabel");
const audioLabel = document.getElementById("audioLabel");
const sourcesLabel = document.getElementById("sourcesLabel");
const episodesLabel = document.getElementById("episodesLabel");
const externalLabel = document.getElementById("externalLabel");
const submitIntroButton = document.getElementById("submitIntroButton");
const lockButton = document.getElementById("lockButton");
const videoSettingsButton = document.getElementById("videoSettingsButton");
const backButton = document.getElementById("backButton");
const sourcesButton = document.getElementById("sourcesButton");
const episodesButton = document.getElementById("episodesButton");
const externalButton = document.getElementById("externalButton");
const lockedLabel = document.getElementById("lockedLabel");
let state = {
title: "",
episodeText: "",
streamTitle: "",
providerName: "",
resizeModeLabel: "Fit",
playbackSpeedLabel: "1x",
subtitlesLabel: "Subs",
audioLabel: "Audio",
sourcesLabel: "Sources",
episodesLabel: "Episodes",
externalPlayerLabel: "External",
playLabel: "Play",
pauseLabel: "Pause",
closeLabel: "Close player",
lockLabel: "Lock player controls",
unlockLabel: "Unlock player controls",
submitIntroLabel: "Submit Intro",
videoSettingsLabel: "Video settings",
tapToUnlockLabel: "Tap to unlock",
isPlaying: false,
isLoading: false,
isLocked: false,
lockedOverlayVisible: false,
controlsVisible: true,
showSubmitIntro: false,
showVideoSettings: false,
showSources: false,
showEpisodes: false,
showExternalPlayer: false,
durationMs: 0,
positionMs: 0,
};
let isScrubbing = false;
let scrubPositionMs = 0;
let tapTimer = 0;
const send = (type, value = 0) => {
const bridge = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.player;
if (bridge) bridge.postMessage({ type, value });
};
const formatTime = seconds => {
if (!Number.isFinite(seconds) || seconds < 0) seconds = 0;
const total = Math.floor(seconds);
const formatTime = milliseconds => {
if (!Number.isFinite(milliseconds) || milliseconds < 0) milliseconds = 0;
const total = Math.floor(milliseconds / 1000);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
@ -515,47 +657,122 @@
: `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
};
const setProgress = (position, duration) => {
const percent = duration > 0 ? Math.max(0, Math.min(100, position / duration * 100)) : 0;
const setProgress = (positionMs, durationMs) => {
const percent = durationMs > 0 ? Math.max(0, Math.min(100, positionMs / durationMs * 100)) : 0;
seek.value = Math.round(percent * 10);
seek.style.setProperty("--progress", `${percent}%`);
positionLabel.textContent = formatTime(position);
durationLabel.textContent = formatTime(duration);
positionLabel.textContent = formatTime(positionMs);
durationLabel.textContent = formatTime(durationMs);
};
const setText = (element, text) => {
element.textContent = text || "";
element.hidden = !text;
};
const setVisible = (element, visible) => {
element.hidden = !visible;
};
const rangePositionMs = () => {
const durationMs = Math.max(0, Number(state.durationMs) || 0);
return durationMs > 0 ? Math.round(durationMs * Number(seek.value) / 1000) : 0;
};
const render = () => {
const durationMs = Math.max(0, Number(state.durationMs) || 0);
const positionMs = isScrubbing ? scrubPositionMs : Math.max(0, Number(state.positionMs) || 0);
root.classList.toggle("locked", Boolean(state.isLocked));
root.classList.toggle("locked-visible", Boolean(state.isLocked && state.lockedOverlayVisible));
root.classList.toggle("chrome-hidden", Boolean(!state.controlsVisible && !(state.isLocked && state.lockedOverlayVisible)));
title.textContent = state.title || "";
setText(episode, state.episodeText);
setText(streamTitle, state.streamTitle);
setText(providerName, state.providerName);
resizeLabel.textContent = state.resizeModeLabel || "Fit";
speedLabel.textContent = state.playbackSpeedLabel || "1x";
subtitlesLabel.textContent = state.subtitlesLabel || "Subs";
audioLabel.textContent = state.audioLabel || "Audio";
sourcesLabel.textContent = state.sourcesLabel || "Sources";
episodesLabel.textContent = state.episodesLabel || "Episodes";
externalLabel.textContent = state.externalPlayerLabel || "External";
lockedLabel.textContent = state.tapToUnlockLabel || "Tap to unlock";
setVisible(submitIntroButton, Boolean(state.showSubmitIntro));
setVisible(videoSettingsButton, Boolean(state.showVideoSettings));
setVisible(sourcesButton, Boolean(state.showSources));
setVisible(episodesButton, Boolean(state.showEpisodes));
setVisible(externalButton, Boolean(state.showExternalPlayer));
const isPlaying = Boolean(state.isPlaying);
const playPauseLabel = isPlaying ? state.pauseLabel : state.playLabel;
toggle.setAttribute("aria-label", playPauseLabel || (isPlaying ? "Pause" : "Play"));
toggleIcon.setAttribute("href", isPlaying ? "#icon-pause" : "#icon-play");
lockButton.setAttribute("aria-label", state.isLocked ? state.unlockLabel : state.lockLabel);
lockIcon.setAttribute("href", state.isLocked ? "#icon-lock-open" : "#icon-lock");
backButton.setAttribute("aria-label", state.closeLabel || "Close player");
submitIntroButton.setAttribute("aria-label", state.submitIntroLabel || "Submit Intro");
videoSettingsButton.setAttribute("aria-label", state.videoSettingsLabel || "Video settings");
seek.disabled = Boolean(state.isLocked);
setProgress(positionMs, durationMs);
};
document.querySelectorAll("[data-command]").forEach(button => {
button.addEventListener("click", () => send(button.dataset.command, 0));
button.addEventListener("click", event => {
event.stopPropagation();
send(button.dataset.command, 0);
});
});
seek.addEventListener("input", () => {
seek.style.setProperty("--progress", `${Number(seek.value) / 10}%`);
send("seekPercent", Number(seek.value) / 1000);
isScrubbing = true;
scrubPositionMs = rangePositionMs();
setProgress(scrubPositionMs, state.durationMs);
send("scrubChange", scrubPositionMs);
});
seek.addEventListener("change", () => {
scrubPositionMs = rangePositionMs();
isScrubbing = false;
send("scrubFinish", scrubPositionMs);
state.positionMs = scrubPositionMs;
render();
});
window.playerUpdate = state => {
const duration = Number(state.duration) || 0;
const position = Number(state.position) || 0;
const paused = Boolean(state.paused);
toggle.setAttribute("aria-label", paused ? "Play" : "Pause");
toggleIcon.setAttribute("href", paused ? "#icon-play" : "#icon-pause");
setProgress(position, duration);
const durationMs = Math.round((Number(state.duration) || 0) * 1000);
const positionMs = Math.round((Number(state.position) || 0) * 1000);
window.playerControls({
durationMs,
positionMs,
isPlaying: !Boolean(state.paused),
});
};
window.playerMetadata = metadata => {
const title = document.getElementById("title");
const episode = document.getElementById("episode");
const streamTitle = document.getElementById("streamTitle");
const providerName = document.getElementById("providerName");
if ("title" in metadata) title.textContent = metadata.title || "";
if ("episode" in metadata) {
episode.textContent = metadata.episode || "";
episode.hidden = !metadata.episode;
}
if ("streamTitle" in metadata) streamTitle.textContent = metadata.streamTitle || "";
if ("providerName" in metadata) providerName.textContent = metadata.providerName || "";
window.playerControls = nextState => {
state = { ...state, ...nextState };
render();
};
root.addEventListener("click", event => {
if (event.target.closest("button,input")) return;
window.clearTimeout(tapTimer);
tapTimer = window.setTimeout(() => {
send(state.isLocked ? "revealLockedOverlay" : "toggleChrome");
}, 220);
});
root.addEventListener("dblclick", event => {
if (event.target.closest("button,input")) return;
window.clearTimeout(tapTimer);
const bounds = root.getBoundingClientRect();
const localX = event.clientX - bounds.left;
send(localX < bounds.width / 2 ? "doubleTapSeekBack" : "doubleTapSeekForward");
});
setProgress(0, 0);
render();
</script>
</body>
</html>

View file

@ -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,