mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-31 16:49:28 +00:00
feat: player ui parity phase 2
This commit is contained in:
parent
b649726176
commit
0b5e409372
9 changed files with 885 additions and 52 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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('"')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue