mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-16 23:12:12 +00:00
Integrate mediamp mpv player for Windows desktop
This commit is contained in:
parent
890211e007
commit
bca66b809b
12 changed files with 5566 additions and 85 deletions
|
|
@ -238,6 +238,9 @@ kotlin {
|
||||||
implementation(libs.ktor.client.java)
|
implementation(libs.ktor.client.java)
|
||||||
implementation(libs.kotlinx.coroutines.swing)
|
implementation(libs.kotlinx.coroutines.swing)
|
||||||
implementation(libs.jna)
|
implementation(libs.jna)
|
||||||
|
// mediamp-mpv for Windows desktop player
|
||||||
|
implementation("org.openani.mediamp:mediamp-api:0.1.0-dev-1")
|
||||||
|
implementation("org.openani.mediamp:mediamp-mpv:0.1.0-dev-1")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
|
|
@ -305,6 +308,21 @@ dependencies {
|
||||||
compose.desktop {
|
compose.desktop {
|
||||||
application {
|
application {
|
||||||
mainClass = "com.nuvio.app.DesktopAppKt"
|
mainClass = "com.nuvio.app.DesktopAppKt"
|
||||||
|
|
||||||
|
// Add mediamp native library path for Windows
|
||||||
|
val mediampNativeBuildDir = rootProject.file("mediamp/mediamp-mpv/build-ci")
|
||||||
|
val mediampPrebuiltDir = rootProject.file("mediamp/mediamp-mpv/libmpv/lib/windows/x86_64")
|
||||||
|
jvmArgs(
|
||||||
|
"-Dskiko.renderApi=OPENGL",
|
||||||
|
"-Djava.library.path=" + listOf(
|
||||||
|
mediampNativeBuildDir.absolutePath,
|
||||||
|
mediampNativeBuildDir.resolve("Debug").absolutePath,
|
||||||
|
mediampNativeBuildDir.resolve("Release").absolutePath,
|
||||||
|
mediampPrebuiltDir.absolutePath,
|
||||||
|
System.getenv("NUVIO_MPV_DIR")?.let { "$it/bin" } ?: "",
|
||||||
|
).filter { it.isNotEmpty() }.joinToString(System.getProperty("path.separator")),
|
||||||
|
)
|
||||||
|
|
||||||
buildTypes.release.proguard {
|
buildTypes.release.proguard {
|
||||||
configurationFiles.from(project.file("desktop-proguard-rules.pro"))
|
configurationFiles.from(project.file("desktop-proguard-rules.pro"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2246
composeApp/hs_err_pid12176.log
Normal file
2246
composeApp/hs_err_pid12176.log
Normal file
File diff suppressed because one or more lines are too long
2325
composeApp/hs_err_pid19792.log
Normal file
2325
composeApp/hs_err_pid19792.log
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -99,6 +99,10 @@ actual fun rememberPlayerGestureController(): PlayerGestureController? {
|
||||||
return controller
|
return controller
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actual val usesNativePlayerChrome: Boolean = false
|
||||||
|
|
||||||
|
actual val usesAnimatedPlayerChrome: Boolean = true
|
||||||
|
|
||||||
private tailrec fun Context.findActivity(): Activity? =
|
private tailrec fun Context.findActivity(): Activity? =
|
||||||
when (this) {
|
when (this) {
|
||||||
is Activity -> this
|
is Activity -> this
|
||||||
|
|
|
||||||
|
|
@ -29,3 +29,7 @@ expect fun ManagePlayerPictureInPicture(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
expect fun rememberPlayerGestureController(): PlayerGestureController?
|
expect fun rememberPlayerGestureController(): PlayerGestureController?
|
||||||
|
|
||||||
|
expect val usesNativePlayerChrome: Boolean
|
||||||
|
|
||||||
|
expect val usesAnimatedPlayerChrome: Boolean
|
||||||
|
|
|
||||||
|
|
@ -1602,7 +1602,7 @@ fun PlayerScreen(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if (pausedOverlayVisible && !controlsVisible) {
|
if (!usesNativePlayerChrome && pausedOverlayVisible && !controlsVisible) {
|
||||||
PauseMetadataOverlay(
|
PauseMetadataOverlay(
|
||||||
title = title,
|
title = title,
|
||||||
logo = logo,
|
logo = logo,
|
||||||
|
|
@ -1618,86 +1618,157 @@ fun PlayerScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
if (!usesNativePlayerChrome) {
|
||||||
visible = controlsVisible,
|
if (usesAnimatedPlayerChrome) {
|
||||||
enter = fadeIn(),
|
AnimatedVisibility(
|
||||||
exit = fadeOut(),
|
visible = controlsVisible,
|
||||||
) {
|
enter = fadeIn(),
|
||||||
PlayerControlsShell(
|
exit = fadeOut(),
|
||||||
title = title,
|
) {
|
||||||
streamTitle = activeStreamTitle,
|
PlayerControlsShell(
|
||||||
providerName = activeProviderName,
|
title = title,
|
||||||
seasonNumber = activeSeasonNumber,
|
streamTitle = activeStreamTitle,
|
||||||
episodeNumber = activeEpisodeNumber,
|
providerName = activeProviderName,
|
||||||
episodeTitle = activeEpisodeTitle,
|
seasonNumber = activeSeasonNumber,
|
||||||
playbackSnapshot = playbackSnapshot,
|
episodeNumber = activeEpisodeNumber,
|
||||||
displayedPositionMs = displayedPositionMs,
|
episodeTitle = activeEpisodeTitle,
|
||||||
metrics = metrics,
|
playbackSnapshot = playbackSnapshot,
|
||||||
resizeMode = resizeMode,
|
displayedPositionMs = displayedPositionMs,
|
||||||
onBack = onBackWithProgress,
|
metrics = metrics,
|
||||||
onTogglePlayback = ::togglePlayback,
|
resizeMode = resizeMode,
|
||||||
onSeekBack = { seekBy(-10_000L) },
|
onBack = onBackWithProgress,
|
||||||
onSeekForward = { seekBy(10_000L) },
|
onTogglePlayback = ::togglePlayback,
|
||||||
onResizeModeClick = ::cycleResizeMode,
|
onSeekBack = { seekBy(-10_000L) },
|
||||||
onSpeedClick = ::cyclePlaybackSpeed,
|
onSeekForward = { seekBy(10_000L) },
|
||||||
onSubtitleClick = {
|
onResizeModeClick = ::cycleResizeMode,
|
||||||
refreshTracks()
|
onSpeedClick = ::cyclePlaybackSpeed,
|
||||||
showSubtitleModal = true
|
onSubtitleClick = {
|
||||||
},
|
refreshTracks()
|
||||||
onAudioClick = {
|
showSubtitleModal = true
|
||||||
refreshTracks()
|
},
|
||||||
showAudioModal = true
|
onAudioClick = {
|
||||||
},
|
refreshTracks()
|
||||||
onSourcesClick = if (activeVideoId != null) {{ openSourcesPanel() }} else null,
|
showAudioModal = true
|
||||||
onEpisodesClick = if (isSeries) {{ openEpisodesPanel() }} else null,
|
},
|
||||||
onScrubChange = { positionMs -> scrubbingPositionMs = positionMs },
|
onSourcesClick = if (activeVideoId != null) {{ openSourcesPanel() }} else null,
|
||||||
onScrubFinished = { positionMs ->
|
onEpisodesClick = if (isSeries) {{ openEpisodesPanel() }} else null,
|
||||||
scrubbingPositionMs = null
|
onScrubChange = { positionMs -> scrubbingPositionMs = positionMs },
|
||||||
playerController?.seekTo(positionMs)
|
onScrubFinished = { positionMs ->
|
||||||
},
|
scrubbingPositionMs = null
|
||||||
horizontalSafePadding = horizontalSafePadding,
|
playerController?.seekTo(positionMs)
|
||||||
modifier = Modifier.fillMaxSize(),
|
},
|
||||||
)
|
horizontalSafePadding = horizontalSafePadding,
|
||||||
}
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = playerSettingsUiState.showLoadingOverlay && !initialLoadCompleted && errorMessage == null,
|
|
||||||
enter = fadeIn(),
|
|
||||||
exit = fadeOut(),
|
|
||||||
) {
|
|
||||||
OpeningOverlay(
|
|
||||||
artwork = backdropArtwork,
|
|
||||||
logo = logo,
|
|
||||||
title = title,
|
|
||||||
onBack = onBackWithProgress,
|
|
||||||
horizontalSafePadding = horizontalSafePadding,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = currentGestureFeedback != null,
|
|
||||||
enter = fadeIn(),
|
|
||||||
exit = fadeOut(),
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
) {
|
|
||||||
renderedGestureFeedback?.let { feedback ->
|
|
||||||
GestureFeedbackPill(
|
|
||||||
feedback = feedback,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.TopCenter)
|
|
||||||
.windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Top))
|
|
||||||
.padding(horizontal = horizontalSafePadding)
|
|
||||||
.padding(top = 40.dp),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
} else if (controlsVisible) {
|
||||||
|
PlayerControlsShell(
|
||||||
|
title = title,
|
||||||
|
streamTitle = activeStreamTitle,
|
||||||
|
providerName = activeProviderName,
|
||||||
|
seasonNumber = activeSeasonNumber,
|
||||||
|
episodeNumber = activeEpisodeNumber,
|
||||||
|
episodeTitle = activeEpisodeTitle,
|
||||||
|
playbackSnapshot = playbackSnapshot,
|
||||||
|
displayedPositionMs = displayedPositionMs,
|
||||||
|
metrics = metrics,
|
||||||
|
resizeMode = resizeMode,
|
||||||
|
onBack = onBackWithProgress,
|
||||||
|
onTogglePlayback = ::togglePlayback,
|
||||||
|
onSeekBack = { seekBy(-10_000L) },
|
||||||
|
onSeekForward = { seekBy(10_000L) },
|
||||||
|
onResizeModeClick = ::cycleResizeMode,
|
||||||
|
onSpeedClick = ::cyclePlaybackSpeed,
|
||||||
|
onSubtitleClick = {
|
||||||
|
refreshTracks()
|
||||||
|
showSubtitleModal = true
|
||||||
|
},
|
||||||
|
onAudioClick = {
|
||||||
|
refreshTracks()
|
||||||
|
showAudioModal = true
|
||||||
|
},
|
||||||
|
onSourcesClick = if (activeVideoId != null) {{ openSourcesPanel() }} else null,
|
||||||
|
onEpisodesClick = if (isSeries) {{ openEpisodesPanel() }} else null,
|
||||||
|
onScrubChange = { positionMs -> scrubbingPositionMs = positionMs },
|
||||||
|
onScrubFinished = { positionMs ->
|
||||||
|
scrubbingPositionMs = null
|
||||||
|
playerController?.seekTo(positionMs)
|
||||||
|
},
|
||||||
|
horizontalSafePadding = horizontalSafePadding,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val showOpeningOverlay =
|
||||||
|
playerSettingsUiState.showLoadingOverlay && !initialLoadCompleted && errorMessage == null
|
||||||
|
if (usesAnimatedPlayerChrome) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showOpeningOverlay,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
) {
|
||||||
|
OpeningOverlay(
|
||||||
|
artwork = backdropArtwork,
|
||||||
|
logo = logo,
|
||||||
|
title = title,
|
||||||
|
onBack = onBackWithProgress,
|
||||||
|
horizontalSafePadding = horizontalSafePadding,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (showOpeningOverlay) {
|
||||||
|
OpeningOverlay(
|
||||||
|
artwork = backdropArtwork,
|
||||||
|
logo = logo,
|
||||||
|
title = title,
|
||||||
|
onBack = onBackWithProgress,
|
||||||
|
horizontalSafePadding = horizontalSafePadding,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val showGestureFeedback = currentGestureFeedback != null
|
||||||
|
if (usesAnimatedPlayerChrome) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showGestureFeedback,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
renderedGestureFeedback?.let { feedback ->
|
||||||
|
GestureFeedbackPill(
|
||||||
|
feedback = feedback,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Top))
|
||||||
|
.padding(horizontal = horizontalSafePadding)
|
||||||
|
.padding(top = 40.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (showGestureFeedback) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
renderedGestureFeedback?.let { feedback ->
|
||||||
|
GestureFeedbackPill(
|
||||||
|
feedback = feedback,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Top))
|
||||||
|
.padding(horizontal = horizontalSafePadding)
|
||||||
|
.padding(top = 40.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip intro/recap/outro button
|
// Skip intro/recap/outro button
|
||||||
SkipIntroButton(
|
if (!usesNativePlayerChrome) SkipIntroButton(
|
||||||
interval = activeSkipInterval,
|
interval = activeSkipInterval,
|
||||||
dismissed = skipIntervalDismissed,
|
dismissed = skipIntervalDismissed,
|
||||||
controlsVisible = controlsVisible,
|
controlsVisible = controlsVisible,
|
||||||
|
|
@ -1713,7 +1784,7 @@ fun PlayerScreen(
|
||||||
)
|
)
|
||||||
|
|
||||||
// Next episode card
|
// Next episode card
|
||||||
if (isSeries) {
|
if (!usesNativePlayerChrome && isSeries) {
|
||||||
NextEpisodeCard(
|
NextEpisodeCard(
|
||||||
nextEpisode = nextEpisodeInfo,
|
nextEpisode = nextEpisodeInfo,
|
||||||
visible = showNextEpisodeCard,
|
visible = showNextEpisodeCard,
|
||||||
|
|
@ -1737,14 +1808,14 @@ fun PlayerScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errorMessage != null) {
|
if (!usesNativePlayerChrome && errorMessage != null) {
|
||||||
ErrorModal(
|
ErrorModal(
|
||||||
message = errorMessage.orEmpty(),
|
message = errorMessage.orEmpty(),
|
||||||
onDismiss = onBackWithProgress,
|
onDismiss = onBackWithProgress,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
AudioTrackModal(
|
if (!usesNativePlayerChrome) AudioTrackModal(
|
||||||
visible = showAudioModal,
|
visible = showAudioModal,
|
||||||
audioTracks = audioTracks,
|
audioTracks = audioTracks,
|
||||||
selectedIndex = selectedAudioIndex,
|
selectedIndex = selectedAudioIndex,
|
||||||
|
|
@ -1759,7 +1830,7 @@ fun PlayerScreen(
|
||||||
onDismiss = { showAudioModal = false },
|
onDismiss = { showAudioModal = false },
|
||||||
)
|
)
|
||||||
|
|
||||||
SubtitleModal(
|
if (!usesNativePlayerChrome) SubtitleModal(
|
||||||
visible = showSubtitleModal,
|
visible = showSubtitleModal,
|
||||||
activeTab = activeSubtitleTab,
|
activeTab = activeSubtitleTab,
|
||||||
subtitleTracks = subtitleTracks,
|
subtitleTracks = subtitleTracks,
|
||||||
|
|
@ -1796,7 +1867,7 @@ fun PlayerScreen(
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sources Panel
|
// Sources Panel
|
||||||
PlayerSourcesPanel(
|
if (!usesNativePlayerChrome) PlayerSourcesPanel(
|
||||||
visible = showSourcesPanel,
|
visible = showSourcesPanel,
|
||||||
streamsUiState = sourceStreamsState,
|
streamsUiState = sourceStreamsState,
|
||||||
currentStreamUrl = activeSourceUrl,
|
currentStreamUrl = activeSourceUrl,
|
||||||
|
|
@ -1821,7 +1892,7 @@ fun PlayerScreen(
|
||||||
)
|
)
|
||||||
|
|
||||||
// Episodes Panel
|
// Episodes Panel
|
||||||
if (isSeries) {
|
if (!usesNativePlayerChrome && isSeries) {
|
||||||
PlayerEpisodesPanel(
|
PlayerEpisodesPanel(
|
||||||
visible = showEpisodesPanel,
|
visible = showEpisodesPanel,
|
||||||
episodes = allEpisodes,
|
episodes = allEpisodes,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.nuvio.app
|
package com.nuvio.app
|
||||||
|
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.ui.window.Window
|
import androidx.compose.ui.window.Window
|
||||||
import androidx.compose.ui.window.application
|
import androidx.compose.ui.window.application
|
||||||
|
|
@ -27,7 +28,9 @@ fun main() {
|
||||||
onDispose { }
|
onDispose { }
|
||||||
}
|
}
|
||||||
|
|
||||||
App()
|
CompositionLocalProvider(LocalDesktopWindow provides window) {
|
||||||
|
App()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
package com.nuvio.app
|
||||||
|
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
|
||||||
|
val LocalDesktopWindow = staticCompositionLocalOf<java.awt.Window?> { null }
|
||||||
|
|
@ -3,6 +3,11 @@ package com.nuvio.app.features.player
|
||||||
import com.sun.jna.Library
|
import com.sun.jna.Library
|
||||||
import com.sun.jna.Native
|
import com.sun.jna.Native
|
||||||
import com.sun.jna.Pointer
|
import com.sun.jna.Pointer
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
private val isWindowsDesktop: Boolean by lazy {
|
||||||
|
System.getProperty("os.name")?.lowercase()?.contains("windows") == true
|
||||||
|
}
|
||||||
|
|
||||||
internal interface DesktopMPVBridgeLib : Library {
|
internal interface DesktopMPVBridgeLib : Library {
|
||||||
companion object {
|
companion object {
|
||||||
|
|
@ -160,3 +165,131 @@ internal interface DesktopMPVBridgeLib : Library {
|
||||||
fun nuvio_player_set_episode_selected_filter(player: Pointer, addonId: String?)
|
fun nuvio_player_set_episode_selected_filter(player: Pointer, addonId: String?)
|
||||||
fun nuvio_player_show_episode_streams(player: Pointer, season: Int, episode: Int, title: String?)
|
fun nuvio_player_show_episode_streams(player: Pointer, season: Int, episode: Int, title: String?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal interface WindowsDesktopMPVBridgeLib : Library {
|
||||||
|
companion object {
|
||||||
|
private val loadedInstance: WindowsDesktopMPVBridgeLib? by lazy {
|
||||||
|
val userDir = System.getProperty("user.dir") ?: ""
|
||||||
|
val candidates = listOf(
|
||||||
|
File(userDir, "WindowsBridge/build/Release"),
|
||||||
|
File(userDir, "WindowsBridge/build/Debug"),
|
||||||
|
File(userDir, "../WindowsBridge/build/Release"),
|
||||||
|
File(userDir, "../WindowsBridge/build/Debug"),
|
||||||
|
File(userDir, "composeApp/build/bin/desktop/debugExecutable"),
|
||||||
|
File(userDir, "composeApp/build/bin/desktop/releaseExecutable"),
|
||||||
|
)
|
||||||
|
val libraryFile = candidates
|
||||||
|
.asSequence()
|
||||||
|
.filter { it.exists() && it.isDirectory }
|
||||||
|
.map { File(it, "NuvioWindowsBridge.dll") }
|
||||||
|
.firstOrNull { it.exists() && it.isFile }
|
||||||
|
|
||||||
|
if (libraryFile != null) {
|
||||||
|
System.setProperty(
|
||||||
|
"jna.library.path",
|
||||||
|
listOfNotNull(System.getProperty("jna.library.path"), libraryFile.parentFile?.absolutePath).joinToString(";"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
if (libraryFile != null) {
|
||||||
|
Native.load(libraryFile.absolutePath, WindowsDesktopMPVBridgeLib::class.java)
|
||||||
|
} else {
|
||||||
|
Native.load("NuvioWindowsBridge", WindowsDesktopMPVBridgeLib::class.java)
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
val isAvailable: Boolean
|
||||||
|
get() = loadedInstance != null
|
||||||
|
|
||||||
|
fun loadOrNull(): WindowsDesktopMPVBridgeLib? = loadedInstance
|
||||||
|
|
||||||
|
val INSTANCE: WindowsDesktopMPVBridgeLib
|
||||||
|
get() = loadedInstance ?: error("NuvioWindowsBridge.dll is not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun nuvio_player_create(): Pointer
|
||||||
|
fun nuvio_player_destroy(player: Pointer)
|
||||||
|
fun nuvio_player_show(player: Pointer, hwnd: Long)
|
||||||
|
fun nuvio_player_set_bounds(player: Pointer, x: Int, y: Int, width: Int, height: Int)
|
||||||
|
|
||||||
|
fun nuvio_player_set_metadata(player: Pointer, title: String, streamTitle: String, providerName: String, season: Int, episode: Int, episodeTitle: String?, artwork: String?, logo: String?)
|
||||||
|
fun nuvio_player_set_has_video_id(player: Pointer, value: Boolean)
|
||||||
|
fun nuvio_player_set_is_series(player: Pointer, value: Boolean)
|
||||||
|
fun nuvio_player_load_file(player: Pointer, url: String, audioUrl: String?, headersJson: String?)
|
||||||
|
fun nuvio_player_play(player: Pointer)
|
||||||
|
fun nuvio_player_pause(player: Pointer)
|
||||||
|
fun nuvio_player_seek_to(player: Pointer, positionMs: Long)
|
||||||
|
fun nuvio_player_seek_by(player: Pointer, offsetMs: Long)
|
||||||
|
fun nuvio_player_set_speed(player: Pointer, speed: Float)
|
||||||
|
fun nuvio_player_set_resize_mode(player: Pointer, mode: Int)
|
||||||
|
fun nuvio_player_retry(player: Pointer)
|
||||||
|
fun nuvio_player_refresh_state(player: Pointer)
|
||||||
|
fun nuvio_player_is_loading(player: Pointer): Boolean
|
||||||
|
fun nuvio_player_is_playing(player: Pointer): Boolean
|
||||||
|
fun nuvio_player_is_ended(player: Pointer): Boolean
|
||||||
|
fun nuvio_player_get_position_ms(player: Pointer): Long
|
||||||
|
fun nuvio_player_get_duration_ms(player: Pointer): Long
|
||||||
|
fun nuvio_player_get_buffered_ms(player: Pointer): Long
|
||||||
|
fun nuvio_player_get_speed(player: Pointer): Float
|
||||||
|
fun nuvio_player_get_error(player: Pointer): String?
|
||||||
|
fun nuvio_player_get_audio_track_count(player: Pointer): Int
|
||||||
|
fun nuvio_player_get_audio_track_id(player: Pointer, index: Int): Int
|
||||||
|
fun nuvio_player_get_audio_track_label(player: Pointer, index: Int): String?
|
||||||
|
fun nuvio_player_get_audio_track_lang(player: Pointer, index: Int): String?
|
||||||
|
fun nuvio_player_is_audio_track_selected(player: Pointer, index: Int): Boolean
|
||||||
|
fun nuvio_player_select_audio_track(player: Pointer, trackId: Int)
|
||||||
|
fun nuvio_player_get_subtitle_track_count(player: Pointer): Int
|
||||||
|
fun nuvio_player_get_subtitle_track_id(player: Pointer, index: Int): Int
|
||||||
|
fun nuvio_player_get_subtitle_track_label(player: Pointer, index: Int): String?
|
||||||
|
fun nuvio_player_get_subtitle_track_lang(player: Pointer, index: Int): String?
|
||||||
|
fun nuvio_player_is_subtitle_track_selected(player: Pointer, index: Int): Boolean
|
||||||
|
fun nuvio_player_select_subtitle_track(player: Pointer, trackId: Int)
|
||||||
|
fun nuvio_player_set_subtitle_url(player: Pointer, url: String)
|
||||||
|
fun nuvio_player_clear_external_subtitle(player: Pointer)
|
||||||
|
fun nuvio_player_clear_external_subtitle_and_select(player: Pointer, trackId: Int)
|
||||||
|
fun nuvio_player_apply_subtitle_style(player: Pointer, textColor: String, outlineSize: Float, fontSize: Float, subPos: Int)
|
||||||
|
fun nuvio_player_show_skip_button(player: Pointer, type: String, endTimeMs: Long)
|
||||||
|
fun nuvio_player_hide_skip_button(player: Pointer)
|
||||||
|
fun nuvio_player_show_next_episode(player: Pointer, season: Int, episode: Int, title: String, thumbnail: String?, hasAired: Boolean)
|
||||||
|
fun nuvio_player_hide_next_episode(player: Pointer)
|
||||||
|
fun nuvio_player_is_closed(player: Pointer): Boolean
|
||||||
|
fun nuvio_player_pop_next_episode_pressed(player: Pointer): Boolean
|
||||||
|
fun nuvio_player_is_addon_subtitles_fetch_requested(player: Pointer): Boolean
|
||||||
|
fun nuvio_player_set_addon_subtitles_loading(player: Pointer, loading: Boolean)
|
||||||
|
fun nuvio_player_clear_addon_subtitles(player: Pointer)
|
||||||
|
fun nuvio_player_add_addon_subtitle(player: Pointer, id: String, url: String, language: String, display: String)
|
||||||
|
fun nuvio_player_pop_subtitle_style_changed(player: Pointer): Boolean
|
||||||
|
fun nuvio_player_get_subtitle_style_color_index(player: Pointer): Int
|
||||||
|
fun nuvio_player_get_subtitle_style_font_size(player: Pointer): Int
|
||||||
|
fun nuvio_player_get_subtitle_style_outline_enabled(player: Pointer): Boolean
|
||||||
|
fun nuvio_player_get_subtitle_style_bottom_offset(player: Pointer): Int
|
||||||
|
fun nuvio_player_pop_sources_open_requested(player: Pointer): Boolean
|
||||||
|
fun nuvio_player_pop_episodes_open_requested(player: Pointer): Boolean
|
||||||
|
fun nuvio_player_pop_source_stream_selected(player: Pointer): String?
|
||||||
|
fun nuvio_player_pop_source_filter_changed(player: Pointer): Boolean
|
||||||
|
fun nuvio_player_get_source_filter_value(player: Pointer): String?
|
||||||
|
fun nuvio_player_pop_source_reload(player: Pointer): Boolean
|
||||||
|
fun nuvio_player_pop_episode_selected(player: Pointer): String?
|
||||||
|
fun nuvio_player_pop_episode_stream_selected(player: Pointer): String?
|
||||||
|
fun nuvio_player_pop_episode_filter_changed(player: Pointer): Boolean
|
||||||
|
fun nuvio_player_get_episode_filter_value(player: Pointer): String?
|
||||||
|
fun nuvio_player_pop_episode_reload(player: Pointer): Boolean
|
||||||
|
fun nuvio_player_pop_episode_back(player: Pointer): Boolean
|
||||||
|
fun nuvio_player_set_sources_loading(player: Pointer, loading: Boolean)
|
||||||
|
fun nuvio_player_clear_source_streams(player: Pointer)
|
||||||
|
fun nuvio_player_add_source_stream(player: Pointer, id: String, label: String, subtitle: String?, addonName: String, addonId: String, url: String, isCurrent: Boolean)
|
||||||
|
fun nuvio_player_clear_source_addon_groups(player: Pointer)
|
||||||
|
fun nuvio_player_add_source_addon_group(player: Pointer, id: String, addonName: String, addonId: String, isLoading: Boolean, hasError: Boolean)
|
||||||
|
fun nuvio_player_set_source_selected_filter(player: Pointer, addonId: String?)
|
||||||
|
fun nuvio_player_clear_episodes(player: Pointer)
|
||||||
|
fun nuvio_player_add_episode(player: Pointer, id: String, title: String, overview: String?, thumbnail: String?, season: Int, episode: Int)
|
||||||
|
fun nuvio_player_set_episode_streams_loading(player: Pointer, loading: Boolean)
|
||||||
|
fun nuvio_player_clear_episode_streams(player: Pointer)
|
||||||
|
fun nuvio_player_add_episode_stream(player: Pointer, id: String, label: String, subtitle: String?, addonName: String, addonId: String, url: String, isCurrent: Boolean)
|
||||||
|
fun nuvio_player_clear_episode_addon_groups(player: Pointer)
|
||||||
|
fun nuvio_player_add_episode_addon_group(player: Pointer, id: String, addonName: String, addonId: String, isLoading: Boolean, hasError: Boolean)
|
||||||
|
fun nuvio_player_set_episode_selected_filter(player: Pointer, addonId: String?)
|
||||||
|
fun nuvio_player_show_episode_streams(player: Pointer, season: Int, episode: Int, title: String?)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package com.nuvio.app.features.player
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
|
@ -9,10 +10,14 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.boundsInWindow
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import com.nuvio.app.LocalDesktopWindow
|
||||||
import com.nuvio.app.core.storage.ProfileScopedKey
|
import com.nuvio.app.core.storage.ProfileScopedKey
|
||||||
import com.nuvio.app.core.sync.decodeSyncBoolean
|
import com.nuvio.app.core.sync.decodeSyncBoolean
|
||||||
import com.nuvio.app.core.sync.decodeSyncFloat
|
import com.nuvio.app.core.sync.decodeSyncFloat
|
||||||
|
|
@ -28,12 +33,30 @@ import com.nuvio.app.desktop.DesktopPreferences
|
||||||
import com.nuvio.app.features.details.MetaVideo
|
import com.nuvio.app.features.details.MetaVideo
|
||||||
import com.nuvio.app.features.streams.AddonStreamGroup
|
import com.nuvio.app.features.streams.AddonStreamGroup
|
||||||
import com.nuvio.app.features.streams.StreamItem
|
import com.nuvio.app.features.streams.StreamItem
|
||||||
|
import com.sun.jna.Native
|
||||||
import com.sun.jna.Pointer
|
import com.sun.jna.Pointer
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
import kotlinx.serialization.json.put
|
import kotlinx.serialization.json.put
|
||||||
|
import org.openani.mediamp.InternalMediampApi
|
||||||
|
import org.openani.mediamp.PlaybackState
|
||||||
|
import org.openani.mediamp.features.PlaybackSpeed
|
||||||
|
import org.openani.mediamp.mpv.MpvMediampPlayer
|
||||||
|
import org.openani.mediamp.mpv.compose.MpvMediampPlayerSurface
|
||||||
|
import org.openani.mediamp.source.UriMediaData
|
||||||
|
|
||||||
|
private val isMacOS: Boolean by lazy {
|
||||||
|
System.getProperty("os.name")?.lowercase()?.contains("mac") == true
|
||||||
|
}
|
||||||
|
|
||||||
|
private val hasWindowsNativeBridge: Boolean
|
||||||
|
get() = !isMacOS && WindowsDesktopMPVBridgeLib.isAvailable
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
actual fun PlatformPlayerSurface(
|
actual fun PlatformPlayerSurface(
|
||||||
|
|
@ -49,6 +72,638 @@ actual fun PlatformPlayerSurface(
|
||||||
onControllerReady: (PlayerEngineController) -> Unit,
|
onControllerReady: (PlayerEngineController) -> Unit,
|
||||||
onSnapshot: (PlayerPlaybackSnapshot) -> Unit,
|
onSnapshot: (PlayerPlaybackSnapshot) -> Unit,
|
||||||
onError: (String?) -> Unit,
|
onError: (String?) -> Unit,
|
||||||
|
) {
|
||||||
|
if (isMacOS) {
|
||||||
|
MacOSPlayerSurface(
|
||||||
|
sourceUrl = sourceUrl,
|
||||||
|
sourceAudioUrl = sourceAudioUrl,
|
||||||
|
sourceHeaders = sourceHeaders,
|
||||||
|
sourceResponseHeaders = sourceResponseHeaders,
|
||||||
|
useYoutubeChunkedPlayback = useYoutubeChunkedPlayback,
|
||||||
|
modifier = modifier,
|
||||||
|
playWhenReady = playWhenReady,
|
||||||
|
resizeMode = resizeMode,
|
||||||
|
useNativeController = useNativeController,
|
||||||
|
onControllerReady = onControllerReady,
|
||||||
|
onSnapshot = onSnapshot,
|
||||||
|
onError = onError,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
WindowsMpvPlayerSurface(
|
||||||
|
sourceUrl = sourceUrl,
|
||||||
|
sourceAudioUrl = sourceAudioUrl,
|
||||||
|
sourceHeaders = sourceHeaders,
|
||||||
|
modifier = modifier,
|
||||||
|
playWhenReady = playWhenReady,
|
||||||
|
resizeMode = resizeMode,
|
||||||
|
onControllerReady = onControllerReady,
|
||||||
|
onSnapshot = onSnapshot,
|
||||||
|
onError = onError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class WindowsBridgePollState(
|
||||||
|
val isClosed: Boolean,
|
||||||
|
val snapshot: PlayerPlaybackSnapshot,
|
||||||
|
val error: String?,
|
||||||
|
val addonSubtitlesFetchRequested: Boolean,
|
||||||
|
val subtitleStyleChanged: Boolean,
|
||||||
|
val subtitleStyleColorIndex: Int,
|
||||||
|
val subtitleStyleOutlineEnabled: Boolean,
|
||||||
|
val subtitleStyleFontSize: Int,
|
||||||
|
val subtitleStyleBottomOffset: Int,
|
||||||
|
val nextEpisodePressed: Boolean,
|
||||||
|
val sourcesOpenRequested: Boolean,
|
||||||
|
val episodesOpenRequested: Boolean,
|
||||||
|
val selectedSourceUrl: String?,
|
||||||
|
val sourceFilterChanged: Boolean,
|
||||||
|
val sourceFilterValue: String?,
|
||||||
|
val sourceReloadRequested: Boolean,
|
||||||
|
val selectedEpisodeId: String?,
|
||||||
|
val selectedEpisodeStreamUrl: String?,
|
||||||
|
val episodeFilterChanged: Boolean,
|
||||||
|
val episodeFilterValue: String?,
|
||||||
|
val episodeReloadRequested: Boolean,
|
||||||
|
val episodeBackRequested: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WindowsNativePlayerSurface(
|
||||||
|
sourceUrl: String,
|
||||||
|
sourceAudioUrl: String?,
|
||||||
|
sourceHeaders: Map<String, String>,
|
||||||
|
sourceResponseHeaders: Map<String, String>,
|
||||||
|
useYoutubeChunkedPlayback: Boolean,
|
||||||
|
modifier: Modifier,
|
||||||
|
playWhenReady: Boolean,
|
||||||
|
resizeMode: PlayerResizeMode,
|
||||||
|
useNativeController: Boolean,
|
||||||
|
onControllerReady: (PlayerEngineController) -> Unit,
|
||||||
|
onSnapshot: (PlayerPlaybackSnapshot) -> Unit,
|
||||||
|
onError: (String?) -> Unit,
|
||||||
|
) {
|
||||||
|
val bridge = remember { WindowsDesktopMPVBridgeLib.loadOrNull() }
|
||||||
|
if (bridge == null) {
|
||||||
|
WindowsMpvPlayerSurface(
|
||||||
|
sourceUrl = sourceUrl,
|
||||||
|
sourceAudioUrl = sourceAudioUrl,
|
||||||
|
sourceHeaders = sourceHeaders,
|
||||||
|
modifier = modifier,
|
||||||
|
playWhenReady = playWhenReady,
|
||||||
|
resizeMode = resizeMode,
|
||||||
|
onControllerReady = onControllerReady,
|
||||||
|
onSnapshot = onSnapshot,
|
||||||
|
onError = onError,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val desktopWindow = LocalDesktopWindow.current
|
||||||
|
val playerPtr = remember { bridge.nuvio_player_create() }
|
||||||
|
val playerAttached = remember(playerPtr) { booleanArrayOf(false) }
|
||||||
|
val lastBounds = remember(playerPtr) { arrayOfNulls<Rect>(1) }
|
||||||
|
var onCloseCallback by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
|
var onAddonSubtitlesFetchCallback by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
|
var onSourcesRequestedCallback by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
|
var onSourceStreamSelectedCallback by remember { mutableStateOf<((String) -> Unit)?>(null) }
|
||||||
|
var onSourceFilterChangedCallback by remember { mutableStateOf<((String?) -> Unit)?>(null) }
|
||||||
|
var onSourceReloadCallback by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
|
var onEpisodesRequestedCallback by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
|
var onEpisodeSelectedCallback by remember { mutableStateOf<((String) -> Unit)?>(null) }
|
||||||
|
var onEpisodeStreamSelectedCallback by remember { mutableStateOf<((String) -> Unit)?>(null) }
|
||||||
|
var onEpisodeFilterChangedCallback by remember { mutableStateOf<((String?) -> Unit)?>(null) }
|
||||||
|
var onEpisodeReloadCallback by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
|
var onEpisodeBackCallback by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
|
|
||||||
|
DisposableEffect(playerPtr) {
|
||||||
|
onDispose {
|
||||||
|
bridge.nuvio_player_destroy(playerPtr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(desktopWindow, playerPtr) {
|
||||||
|
val window = desktopWindow ?: return@LaunchedEffect
|
||||||
|
val nativePtr = Native.getComponentPointer(window) ?: return@LaunchedEffect
|
||||||
|
bridge.nuvio_player_show(playerPtr, Pointer.nativeValue(nativePtr))
|
||||||
|
playerAttached[0] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(sourceUrl, sourceAudioUrl) {
|
||||||
|
val headersJson = if (sourceHeaders.isNotEmpty()) {
|
||||||
|
buildJsonObject {
|
||||||
|
sourceHeaders.forEach { (key, value) -> put(key, value) }
|
||||||
|
}.toString()
|
||||||
|
} else null
|
||||||
|
bridge.nuvio_player_load_file(playerPtr, sourceUrl, sourceAudioUrl, headersJson)
|
||||||
|
if (playWhenReady) {
|
||||||
|
bridge.nuvio_player_play(playerPtr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(playWhenReady) {
|
||||||
|
if (playWhenReady) bridge.nuvio_player_play(playerPtr) else bridge.nuvio_player_pause(playerPtr)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(resizeMode) {
|
||||||
|
val mode = when (resizeMode) {
|
||||||
|
PlayerResizeMode.Fit -> 0
|
||||||
|
PlayerResizeMode.Fill -> 1
|
||||||
|
PlayerResizeMode.Zoom -> 2
|
||||||
|
}
|
||||||
|
bridge.nuvio_player_set_resize_mode(playerPtr, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
val controller = remember(playerPtr) {
|
||||||
|
object : PlayerEngineController {
|
||||||
|
override fun play() = bridge.nuvio_player_play(playerPtr)
|
||||||
|
override fun pause() = bridge.nuvio_player_pause(playerPtr)
|
||||||
|
override fun seekTo(positionMs: Long) = bridge.nuvio_player_seek_to(playerPtr, positionMs)
|
||||||
|
override fun seekBy(offsetMs: Long) = bridge.nuvio_player_seek_by(playerPtr, offsetMs)
|
||||||
|
override fun retry() = bridge.nuvio_player_retry(playerPtr)
|
||||||
|
override fun setPlaybackSpeed(speed: Float) = bridge.nuvio_player_set_speed(playerPtr, speed)
|
||||||
|
override fun getAudioTracks(): List<AudioTrack> {
|
||||||
|
val count = bridge.nuvio_player_get_audio_track_count(playerPtr)
|
||||||
|
return (0 until count).map { index ->
|
||||||
|
AudioTrack(
|
||||||
|
index = index,
|
||||||
|
id = bridge.nuvio_player_get_audio_track_id(playerPtr, index).toString(),
|
||||||
|
label = bridge.nuvio_player_get_audio_track_label(playerPtr, index) ?: "",
|
||||||
|
language = bridge.nuvio_player_get_audio_track_lang(playerPtr, index),
|
||||||
|
isSelected = bridge.nuvio_player_is_audio_track_selected(playerPtr, index),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun getSubtitleTracks(): List<SubtitleTrack> {
|
||||||
|
val count = bridge.nuvio_player_get_subtitle_track_count(playerPtr)
|
||||||
|
return (0 until count).map { index ->
|
||||||
|
SubtitleTrack(
|
||||||
|
index = index,
|
||||||
|
id = bridge.nuvio_player_get_subtitle_track_id(playerPtr, index).toString(),
|
||||||
|
label = bridge.nuvio_player_get_subtitle_track_label(playerPtr, index) ?: "",
|
||||||
|
language = bridge.nuvio_player_get_subtitle_track_lang(playerPtr, index),
|
||||||
|
isSelected = bridge.nuvio_player_is_subtitle_track_selected(playerPtr, index),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun selectAudioTrack(index: Int) {
|
||||||
|
val count = bridge.nuvio_player_get_audio_track_count(playerPtr)
|
||||||
|
if (index in 0 until count) {
|
||||||
|
bridge.nuvio_player_select_audio_track(playerPtr, bridge.nuvio_player_get_audio_track_id(playerPtr, index))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun selectSubtitleTrack(index: Int) {
|
||||||
|
if (index < 0) {
|
||||||
|
bridge.nuvio_player_select_subtitle_track(playerPtr, -1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val count = bridge.nuvio_player_get_subtitle_track_count(playerPtr)
|
||||||
|
if (index in 0 until count) {
|
||||||
|
bridge.nuvio_player_select_subtitle_track(playerPtr, bridge.nuvio_player_get_subtitle_track_id(playerPtr, index))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun setSubtitleUri(url: String) = bridge.nuvio_player_set_subtitle_url(playerPtr, url)
|
||||||
|
override fun clearExternalSubtitle() = bridge.nuvio_player_clear_external_subtitle(playerPtr)
|
||||||
|
override fun clearExternalSubtitleAndSelect(trackIndex: Int) {
|
||||||
|
val trackId = if (trackIndex >= 0) {
|
||||||
|
val count = bridge.nuvio_player_get_subtitle_track_count(playerPtr)
|
||||||
|
if (trackIndex < count) bridge.nuvio_player_get_subtitle_track_id(playerPtr, trackIndex) else -1
|
||||||
|
} else -1
|
||||||
|
bridge.nuvio_player_clear_external_subtitle_and_select(playerPtr, trackId)
|
||||||
|
}
|
||||||
|
override fun applySubtitleStyle(style: SubtitleStyleState) {
|
||||||
|
val colorHex = style.textColor.toMpvColorString()
|
||||||
|
val outline = if (style.outlineEnabled) 2.0f else 0.0f
|
||||||
|
val subPos = 100 - style.bottomOffset
|
||||||
|
bridge.nuvio_player_apply_subtitle_style(playerPtr, colorHex, outline, style.fontSizeSp.toFloat(), subPos)
|
||||||
|
}
|
||||||
|
override fun setMetadata(title: String, streamTitle: String, providerName: String, seasonNumber: Int?, episodeNumber: Int?, episodeTitle: String?, artwork: String?, logo: String?) {
|
||||||
|
bridge.nuvio_player_set_metadata(playerPtr, title, streamTitle, providerName, seasonNumber ?: 0, episodeNumber ?: 0, episodeTitle, artwork, logo)
|
||||||
|
}
|
||||||
|
override fun setPlayerFlags(hasVideoId: Boolean, isSeries: Boolean) {
|
||||||
|
bridge.nuvio_player_set_has_video_id(playerPtr, hasVideoId)
|
||||||
|
bridge.nuvio_player_set_is_series(playerPtr, isSeries)
|
||||||
|
}
|
||||||
|
override fun showSkipButton(type: String, endTimeMs: Long) = bridge.nuvio_player_show_skip_button(playerPtr, type, endTimeMs)
|
||||||
|
override fun hideSkipButton() = bridge.nuvio_player_hide_skip_button(playerPtr)
|
||||||
|
override fun showNextEpisode(season: Int, episode: Int, title: String, thumbnail: String?, hasAired: Boolean) = bridge.nuvio_player_show_next_episode(playerPtr, season, episode, title, thumbnail, hasAired)
|
||||||
|
override fun hideNextEpisode() = bridge.nuvio_player_hide_next_episode(playerPtr)
|
||||||
|
override fun setOnCloseCallback(callback: () -> Unit) { onCloseCallback = callback }
|
||||||
|
override fun setOnAddonSubtitlesFetchCallback(callback: () -> Unit) { onAddonSubtitlesFetchCallback = callback }
|
||||||
|
override fun pushAddonSubtitles(subtitles: List<AddonSubtitle>, isLoading: Boolean) {
|
||||||
|
bridge.nuvio_player_set_addon_subtitles_loading(playerPtr, isLoading)
|
||||||
|
if (!isLoading) {
|
||||||
|
bridge.nuvio_player_clear_addon_subtitles(playerPtr)
|
||||||
|
subtitles.forEach { addon -> bridge.nuvio_player_add_addon_subtitle(playerPtr, addon.id, addon.url, addon.language, addon.display) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun setOnSourcesRequestedCallback(callback: () -> Unit) { onSourcesRequestedCallback = callback }
|
||||||
|
override fun setOnSourceStreamSelectedCallback(callback: (String) -> Unit) { onSourceStreamSelectedCallback = callback }
|
||||||
|
override fun setOnSourceFilterChangedCallback(callback: (String?) -> Unit) { onSourceFilterChangedCallback = callback }
|
||||||
|
override fun setOnSourceReloadCallback(callback: () -> Unit) { onSourceReloadCallback = callback }
|
||||||
|
override fun setOnEpisodesRequestedCallback(callback: () -> Unit) { onEpisodesRequestedCallback = callback }
|
||||||
|
override fun setOnEpisodeSelectedCallback(callback: (String) -> Unit) { onEpisodeSelectedCallback = callback }
|
||||||
|
override fun setOnEpisodeStreamSelectedCallback(callback: (String) -> Unit) { onEpisodeStreamSelectedCallback = callback }
|
||||||
|
override fun setOnEpisodeFilterChangedCallback(callback: (String?) -> Unit) { onEpisodeFilterChangedCallback = callback }
|
||||||
|
override fun setOnEpisodeReloadCallback(callback: () -> Unit) { onEpisodeReloadCallback = callback }
|
||||||
|
override fun setOnEpisodeBackCallback(callback: () -> Unit) { onEpisodeBackCallback = callback }
|
||||||
|
override fun pushSourceData(streams: List<StreamItem>, groups: List<AddonStreamGroup>, loading: Boolean, selectedFilter: String?, currentStreamUrl: String?) {
|
||||||
|
bridge.nuvio_player_set_sources_loading(playerPtr, loading)
|
||||||
|
bridge.nuvio_player_set_source_selected_filter(playerPtr, selectedFilter)
|
||||||
|
bridge.nuvio_player_clear_source_addon_groups(playerPtr)
|
||||||
|
groups.forEach { group -> bridge.nuvio_player_add_source_addon_group(playerPtr, group.addonId, group.addonName, group.addonId, group.isLoading, group.error != null) }
|
||||||
|
bridge.nuvio_player_clear_source_streams(playerPtr)
|
||||||
|
streams.forEach { stream -> bridge.nuvio_player_add_source_stream(playerPtr, stream.addonId + "_" + (stream.url ?: stream.infoHash ?: ""), stream.streamLabel, stream.streamSubtitle, stream.addonName, stream.addonId, stream.directPlaybackUrl ?: "", stream.directPlaybackUrl == currentStreamUrl) }
|
||||||
|
}
|
||||||
|
override fun pushEpisodes(episodes: List<MetaVideo>) {
|
||||||
|
bridge.nuvio_player_clear_episodes(playerPtr)
|
||||||
|
episodes.forEach { episode -> bridge.nuvio_player_add_episode(playerPtr, episode.id, episode.title, episode.overview, episode.thumbnail, episode.season ?: 0, episode.episode ?: 0) }
|
||||||
|
}
|
||||||
|
override fun pushEpisodeStreamsData(streams: List<StreamItem>, groups: List<AddonStreamGroup>, loading: Boolean, selectedFilter: String?, currentStreamUrl: String?) {
|
||||||
|
bridge.nuvio_player_set_episode_streams_loading(playerPtr, loading)
|
||||||
|
bridge.nuvio_player_set_episode_selected_filter(playerPtr, selectedFilter)
|
||||||
|
bridge.nuvio_player_clear_episode_addon_groups(playerPtr)
|
||||||
|
groups.forEach { group -> bridge.nuvio_player_add_episode_addon_group(playerPtr, group.addonId, group.addonName, group.addonId, group.isLoading, group.error != null) }
|
||||||
|
bridge.nuvio_player_clear_episode_streams(playerPtr)
|
||||||
|
streams.forEach { stream -> bridge.nuvio_player_add_episode_stream(playerPtr, stream.addonId + "_" + (stream.url ?: stream.infoHash ?: ""), stream.streamLabel, stream.streamSubtitle, stream.addonName, stream.addonId, stream.directPlaybackUrl ?: "", stream.directPlaybackUrl == currentStreamUrl) }
|
||||||
|
}
|
||||||
|
override fun showEpisodeStreamsView(season: Int?, episode: Int?, title: String?) = bridge.nuvio_player_show_episode_streams(playerPtr, season ?: 0, episode ?: 0, title)
|
||||||
|
override fun switchSource(url: String, audioUrl: String?, headersJson: String?) = bridge.nuvio_player_load_file(playerPtr, url, audioUrl, headersJson)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(controller) {
|
||||||
|
onControllerReady(controller)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(playerPtr) {
|
||||||
|
while (true) {
|
||||||
|
delay(250)
|
||||||
|
val pollState = withContext(Dispatchers.IO) {
|
||||||
|
bridge.nuvio_player_refresh_state(playerPtr)
|
||||||
|
WindowsBridgePollState(
|
||||||
|
isClosed = bridge.nuvio_player_is_closed(playerPtr),
|
||||||
|
snapshot = PlayerPlaybackSnapshot(
|
||||||
|
isLoading = bridge.nuvio_player_is_loading(playerPtr),
|
||||||
|
isPlaying = bridge.nuvio_player_is_playing(playerPtr),
|
||||||
|
isEnded = bridge.nuvio_player_is_ended(playerPtr),
|
||||||
|
positionMs = bridge.nuvio_player_get_position_ms(playerPtr),
|
||||||
|
durationMs = bridge.nuvio_player_get_duration_ms(playerPtr),
|
||||||
|
bufferedPositionMs = bridge.nuvio_player_get_buffered_ms(playerPtr),
|
||||||
|
playbackSpeed = bridge.nuvio_player_get_speed(playerPtr),
|
||||||
|
),
|
||||||
|
error = bridge.nuvio_player_get_error(playerPtr),
|
||||||
|
addonSubtitlesFetchRequested = bridge.nuvio_player_is_addon_subtitles_fetch_requested(playerPtr),
|
||||||
|
subtitleStyleChanged = bridge.nuvio_player_pop_subtitle_style_changed(playerPtr),
|
||||||
|
subtitleStyleColorIndex = bridge.nuvio_player_get_subtitle_style_color_index(playerPtr),
|
||||||
|
subtitleStyleOutlineEnabled = bridge.nuvio_player_get_subtitle_style_outline_enabled(playerPtr),
|
||||||
|
subtitleStyleFontSize = bridge.nuvio_player_get_subtitle_style_font_size(playerPtr),
|
||||||
|
subtitleStyleBottomOffset = bridge.nuvio_player_get_subtitle_style_bottom_offset(playerPtr),
|
||||||
|
nextEpisodePressed = bridge.nuvio_player_pop_next_episode_pressed(playerPtr),
|
||||||
|
sourcesOpenRequested = bridge.nuvio_player_pop_sources_open_requested(playerPtr),
|
||||||
|
episodesOpenRequested = bridge.nuvio_player_pop_episodes_open_requested(playerPtr),
|
||||||
|
selectedSourceUrl = bridge.nuvio_player_pop_source_stream_selected(playerPtr),
|
||||||
|
sourceFilterChanged = bridge.nuvio_player_pop_source_filter_changed(playerPtr),
|
||||||
|
sourceFilterValue = bridge.nuvio_player_get_source_filter_value(playerPtr),
|
||||||
|
sourceReloadRequested = bridge.nuvio_player_pop_source_reload(playerPtr),
|
||||||
|
selectedEpisodeId = bridge.nuvio_player_pop_episode_selected(playerPtr),
|
||||||
|
selectedEpisodeStreamUrl = bridge.nuvio_player_pop_episode_stream_selected(playerPtr),
|
||||||
|
episodeFilterChanged = bridge.nuvio_player_pop_episode_filter_changed(playerPtr),
|
||||||
|
episodeFilterValue = bridge.nuvio_player_get_episode_filter_value(playerPtr),
|
||||||
|
episodeReloadRequested = bridge.nuvio_player_pop_episode_reload(playerPtr),
|
||||||
|
episodeBackRequested = bridge.nuvio_player_pop_episode_back(playerPtr),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (pollState.isClosed) {
|
||||||
|
onCloseCallback?.invoke()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
onSnapshot(pollState.snapshot)
|
||||||
|
onError(pollState.error)
|
||||||
|
if (pollState.addonSubtitlesFetchRequested) onAddonSubtitlesFetchCallback?.invoke()
|
||||||
|
if (pollState.subtitleStyleChanged) {
|
||||||
|
val colorIndex = pollState.subtitleStyleColorIndex.coerceIn(0, SubtitleColorSwatches.lastIndex)
|
||||||
|
PlayerSettingsRepository.setSubtitleStyle(
|
||||||
|
SubtitleStyleState(
|
||||||
|
textColor = SubtitleColorSwatches[colorIndex],
|
||||||
|
outlineEnabled = pollState.subtitleStyleOutlineEnabled,
|
||||||
|
fontSizeSp = pollState.subtitleStyleFontSize,
|
||||||
|
bottomOffset = pollState.subtitleStyleBottomOffset,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (pollState.sourcesOpenRequested) onSourcesRequestedCallback?.invoke()
|
||||||
|
if (pollState.episodesOpenRequested) onEpisodesRequestedCallback?.invoke()
|
||||||
|
pollState.selectedSourceUrl?.let { onSourceStreamSelectedCallback?.invoke(it) }
|
||||||
|
if (pollState.sourceFilterChanged) onSourceFilterChangedCallback?.invoke(pollState.sourceFilterValue)
|
||||||
|
if (pollState.sourceReloadRequested) onSourceReloadCallback?.invoke()
|
||||||
|
pollState.selectedEpisodeId?.let { onEpisodeSelectedCallback?.invoke(it) }
|
||||||
|
pollState.selectedEpisodeStreamUrl?.let { onEpisodeStreamSelectedCallback?.invoke(it) }
|
||||||
|
if (pollState.episodeFilterChanged) onEpisodeFilterChangedCallback?.invoke(pollState.episodeFilterValue)
|
||||||
|
if (pollState.episodeReloadRequested) onEpisodeReloadCallback?.invoke()
|
||||||
|
if (pollState.episodeBackRequested) onEpisodeBackCallback?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.background(Color.Black)
|
||||||
|
.onGloballyPositioned { coordinates ->
|
||||||
|
if (!playerAttached[0]) return@onGloballyPositioned
|
||||||
|
val bounds = coordinates.boundsInWindow()
|
||||||
|
if (lastBounds[0] == bounds) return@onGloballyPositioned
|
||||||
|
lastBounds[0] = bounds
|
||||||
|
bridge.nuvio_player_set_bounds(
|
||||||
|
playerPtr,
|
||||||
|
bounds.left.toInt(),
|
||||||
|
bounds.top.toInt(),
|
||||||
|
bounds.width.toInt().coerceAtLeast(1),
|
||||||
|
bounds.height.toInt().coerceAtLeast(1),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Windows: mediamp-mpv backed inline Compose rendering
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@OptIn(InternalMediampApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun WindowsMpvPlayerSurface(
|
||||||
|
sourceUrl: String,
|
||||||
|
sourceAudioUrl: String?,
|
||||||
|
sourceHeaders: Map<String, String>,
|
||||||
|
modifier: Modifier,
|
||||||
|
playWhenReady: Boolean,
|
||||||
|
resizeMode: PlayerResizeMode,
|
||||||
|
onControllerReady: (PlayerEngineController) -> Unit,
|
||||||
|
onSnapshot: (PlayerPlaybackSnapshot) -> Unit,
|
||||||
|
onError: (String?) -> Unit,
|
||||||
|
) {
|
||||||
|
val player = remember {
|
||||||
|
MpvMediampPlayer(Unit, kotlin.coroutines.EmptyCoroutineContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(player) {
|
||||||
|
onDispose {
|
||||||
|
player.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load source
|
||||||
|
LaunchedEffect(sourceUrl, sourceAudioUrl) {
|
||||||
|
try {
|
||||||
|
val headers = sourceHeaders.toMutableMap()
|
||||||
|
player.setMediaData(UriMediaData(sourceUrl, headers))
|
||||||
|
|
||||||
|
// Attach separate audio track if provided
|
||||||
|
if (!sourceAudioUrl.isNullOrEmpty()) {
|
||||||
|
player.command("audio-add", sourceAudioUrl, "auto")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playWhenReady) {
|
||||||
|
player.resume()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onError(e.message ?: "Failed to load media")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle play/pause changes
|
||||||
|
LaunchedEffect(playWhenReady) {
|
||||||
|
if (playWhenReady && player.getCurrentPlaybackState() == PlaybackState.PAUSED) {
|
||||||
|
player.resume()
|
||||||
|
} else if (!playWhenReady && player.getCurrentPlaybackState() == PlaybackState.PLAYING) {
|
||||||
|
player.pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle resize mode
|
||||||
|
LaunchedEffect(resizeMode) {
|
||||||
|
when (resizeMode) {
|
||||||
|
PlayerResizeMode.Fit -> {
|
||||||
|
player.setProperty("panscan", 0.0)
|
||||||
|
player.setProperty("keepaspect", true)
|
||||||
|
}
|
||||||
|
PlayerResizeMode.Fill, PlayerResizeMode.Zoom -> {
|
||||||
|
player.setProperty("panscan", 1.0)
|
||||||
|
player.setProperty("keepaspect", true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create controller
|
||||||
|
val controller = remember(player) {
|
||||||
|
WindowsMpvController(player)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(controller) {
|
||||||
|
onControllerReady(controller)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect playback state and report snapshots
|
||||||
|
LaunchedEffect(player) {
|
||||||
|
combine(
|
||||||
|
player.playbackState,
|
||||||
|
player.currentPositionMillis,
|
||||||
|
player.mediaProperties,
|
||||||
|
) { state, position, props ->
|
||||||
|
PlayerPlaybackSnapshot(
|
||||||
|
isLoading = state == PlaybackState.PAUSED_BUFFERING || state == PlaybackState.READY,
|
||||||
|
isPlaying = state == PlaybackState.PLAYING,
|
||||||
|
isEnded = state == PlaybackState.FINISHED,
|
||||||
|
positionMs = position,
|
||||||
|
durationMs = props?.durationMillis?.takeIf { it > 0 } ?: 0L,
|
||||||
|
bufferedPositionMs = 0L,
|
||||||
|
playbackSpeed = player.features[PlaybackSpeed]?.value ?: 1.0f,
|
||||||
|
)
|
||||||
|
}.collectLatest { snapshot ->
|
||||||
|
onSnapshot(snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect errors
|
||||||
|
LaunchedEffect(player) {
|
||||||
|
player.playbackState.collectLatest { state ->
|
||||||
|
if (state == PlaybackState.ERROR) {
|
||||||
|
onError("Playback error")
|
||||||
|
} else {
|
||||||
|
onError(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render surface: mpv renders directly into the Compose Canvas via OpenGL
|
||||||
|
MpvMediampPlayerSurface(
|
||||||
|
player = player,
|
||||||
|
modifier = modifier.background(Color.Black),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PlayerEngineController implementation backed by MpvMediampPlayer.
|
||||||
|
* Uses direct mpv property/command access for full control.
|
||||||
|
*/
|
||||||
|
private class WindowsMpvController(
|
||||||
|
private val player: MpvMediampPlayer,
|
||||||
|
) : PlayerEngineController {
|
||||||
|
|
||||||
|
private val isReady: Boolean
|
||||||
|
get() = !player.isClosed && player.getCurrentPlaybackState() != PlaybackState.FINISHED
|
||||||
|
|
||||||
|
override fun play() { if (!player.isClosed) player.resume() }
|
||||||
|
|
||||||
|
override fun pause() { if (!player.isClosed) player.pause() }
|
||||||
|
|
||||||
|
override fun seekTo(positionMs: Long) { if (!player.isClosed) player.seekTo(positionMs) }
|
||||||
|
|
||||||
|
override fun seekBy(offsetMs: Long) { if (!player.isClosed) player.skip(offsetMs) }
|
||||||
|
|
||||||
|
override fun retry() {
|
||||||
|
player.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setPlaybackSpeed(speed: Float) {
|
||||||
|
player.features[PlaybackSpeed]?.set(speed)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAudioTracks(): List<AudioTrack> {
|
||||||
|
if (!isReady) return emptyList()
|
||||||
|
val count = player.getPropertyInt("track-list/count")
|
||||||
|
val tracks = mutableListOf<AudioTrack>()
|
||||||
|
for (i in 0 until count) {
|
||||||
|
val type = player.getPropertyString("track-list/$i/type")
|
||||||
|
if (type != "audio") continue
|
||||||
|
val id = player.getPropertyInt("track-list/$i/id")
|
||||||
|
val title = player.getPropertyString("track-list/$i/title")
|
||||||
|
val lang = player.getPropertyString("track-list/$i/lang").takeIf { it.isNotBlank() }
|
||||||
|
val selected = player.getPropertyBoolean("track-list/$i/selected")
|
||||||
|
tracks.add(
|
||||||
|
AudioTrack(
|
||||||
|
index = tracks.size,
|
||||||
|
id = id.toString(),
|
||||||
|
label = title.ifEmpty { lang ?: "Track $id" },
|
||||||
|
language = lang,
|
||||||
|
isSelected = selected,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return tracks
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getSubtitleTracks(): List<SubtitleTrack> {
|
||||||
|
if (!isReady) return emptyList()
|
||||||
|
val count = player.getPropertyInt("track-list/count")
|
||||||
|
val tracks = mutableListOf<SubtitleTrack>()
|
||||||
|
for (i in 0 until count) {
|
||||||
|
val type = player.getPropertyString("track-list/$i/type")
|
||||||
|
if (type != "sub") continue
|
||||||
|
val id = player.getPropertyInt("track-list/$i/id")
|
||||||
|
val title = player.getPropertyString("track-list/$i/title")
|
||||||
|
val lang = player.getPropertyString("track-list/$i/lang").takeIf { it.isNotBlank() }
|
||||||
|
val selected = player.getPropertyBoolean("track-list/$i/selected")
|
||||||
|
tracks.add(
|
||||||
|
SubtitleTrack(
|
||||||
|
index = tracks.size,
|
||||||
|
id = id.toString(),
|
||||||
|
label = title.ifEmpty { lang ?: "Subtitle $id" },
|
||||||
|
language = lang,
|
||||||
|
isSelected = selected,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return tracks
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun selectAudioTrack(index: Int) {
|
||||||
|
val tracks = getAudioTracks()
|
||||||
|
if (index in tracks.indices) {
|
||||||
|
player.setProperty("aid", tracks[index].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun selectSubtitleTrack(index: Int) {
|
||||||
|
if (index < 0) {
|
||||||
|
player.setProperty("sid", "no")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val tracks = getSubtitleTracks()
|
||||||
|
if (index in tracks.indices) {
|
||||||
|
player.setProperty("sid", tracks[index].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setSubtitleUri(url: String) {
|
||||||
|
player.command("sub-add", url, "auto")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clearExternalSubtitle() {
|
||||||
|
if (!isReady) return
|
||||||
|
val count = player.getPropertyInt("track-list/count")
|
||||||
|
for (i in count - 1 downTo 0) {
|
||||||
|
val type = player.getPropertyString("track-list/$i/type")
|
||||||
|
val external = player.getPropertyBoolean("track-list/$i/external")
|
||||||
|
if (type == "sub" && external) {
|
||||||
|
val id = player.getPropertyInt("track-list/$i/id")
|
||||||
|
player.command("sub-remove", id.toString())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clearExternalSubtitleAndSelect(trackIndex: Int) {
|
||||||
|
clearExternalSubtitle()
|
||||||
|
selectSubtitleTrack(trackIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun applySubtitleStyle(style: SubtitleStyleState) {
|
||||||
|
val colorHex = style.textColor.toMpvColorString()
|
||||||
|
val outline = if (style.outlineEnabled) 2.0 else 0.0
|
||||||
|
val subPos = 100 - style.bottomOffset
|
||||||
|
player.option("sub-color", colorHex)
|
||||||
|
player.setProperty("sub-border-size", outline)
|
||||||
|
player.setProperty("sub-font-size", style.fontSizeSp.toDouble())
|
||||||
|
player.setProperty("sub-pos", subPos)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun switchSource(url: String, audioUrl: String?, headersJson: String?) {
|
||||||
|
// Parse headers from JSON if provided
|
||||||
|
if (headersJson != null) {
|
||||||
|
player.option("http-header-fields-clr", "")
|
||||||
|
// Simple JSON parsing for header fields
|
||||||
|
val headerPattern = Regex(""""([^"]+)"\s*:\s*"([^"]+)"""")
|
||||||
|
headerPattern.findAll(headersJson).forEach { match ->
|
||||||
|
val (key, value) = match.destructured
|
||||||
|
player.option("http-header-fields", "$key: $value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
player.command("stop")
|
||||||
|
player.command("playlist-clear")
|
||||||
|
player.command("loadfile", url)
|
||||||
|
if (!audioUrl.isNullOrEmpty()) {
|
||||||
|
player.command("audio-add", audioUrl, "auto")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// macOS: existing JNA bridge (unchanged)
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MacOSPlayerSurface(
|
||||||
|
sourceUrl: String,
|
||||||
|
sourceAudioUrl: String?,
|
||||||
|
sourceHeaders: Map<String, String>,
|
||||||
|
sourceResponseHeaders: Map<String, String>,
|
||||||
|
useYoutubeChunkedPlayback: Boolean,
|
||||||
|
modifier: Modifier,
|
||||||
|
playWhenReady: Boolean,
|
||||||
|
resizeMode: PlayerResizeMode,
|
||||||
|
useNativeController: Boolean,
|
||||||
|
onControllerReady: (PlayerEngineController) -> Unit,
|
||||||
|
onSnapshot: (PlayerPlaybackSnapshot) -> Unit,
|
||||||
|
onError: (String?) -> Unit,
|
||||||
) {
|
) {
|
||||||
val bridge = remember { DesktopMPVBridgeLib.INSTANCE }
|
val bridge = remember { DesktopMPVBridgeLib.INSTANCE }
|
||||||
val playerPtr = remember { bridge.nuvio_player_create() }
|
val playerPtr = remember { bridge.nuvio_player_create() }
|
||||||
|
|
@ -828,4 +1483,9 @@ actual fun ManagePlayerPictureInPicture(
|
||||||
) = Unit
|
) = Unit
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
actual fun rememberPlayerGestureController(): PlayerGestureController? = null
|
actual fun rememberPlayerGestureController(): PlayerGestureController? = null
|
||||||
|
|
||||||
|
actual val usesNativePlayerChrome: Boolean
|
||||||
|
get() = isMacOS
|
||||||
|
|
||||||
|
actual val usesAnimatedPlayerChrome: Boolean = false
|
||||||
|
|
@ -61,6 +61,10 @@ actual fun rememberPlayerGestureController(): PlayerGestureController? {
|
||||||
return controller
|
return controller
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actual val usesNativePlayerChrome: Boolean = false
|
||||||
|
|
||||||
|
actual val usesAnimatedPlayerChrome: Boolean = true
|
||||||
|
|
||||||
private class IOSPlayerGestureController : PlayerGestureController {
|
private class IOSPlayerGestureController : PlayerGestureController {
|
||||||
private val volumeView = MPVolumeView().apply {
|
private val volumeView = MPVolumeView().apply {
|
||||||
hidden = true
|
hidden = true
|
||||||
|
|
|
||||||
|
|
@ -28,4 +28,12 @@ dependencyResolutionManagement {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":composeApp")
|
include(":composeApp")
|
||||||
|
|
||||||
|
includeBuild("mediamp") {
|
||||||
|
dependencySubstitution {
|
||||||
|
substitute(module("org.openani.mediamp:mediamp-api")).using(project(":mediamp-api"))
|
||||||
|
substitute(module("org.openani.mediamp:mediamp-mpv")).using(project(":mediamp-mpv"))
|
||||||
|
substitute(module("org.openani.mediamp:mediamp-internal-utils")).using(project(":mediamp-internal-utils"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue