Integrate mediamp mpv player for Windows desktop

This commit is contained in:
tapframe 2026-04-18 15:14:53 +05:30
parent 890211e007
commit bca66b809b
12 changed files with 5566 additions and 85 deletions

View file

@ -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"))
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

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

View file

@ -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()
}
} }
} }
} }

View file

@ -0,0 +1,5 @@
package com.nuvio.app
import androidx.compose.runtime.staticCompositionLocalOf
val LocalDesktopWindow = staticCompositionLocalOf<java.awt.Window?> { null }

View file

@ -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?)
}

View file

@ -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() }
@ -829,3 +1484,8 @@ actual fun ManagePlayerPictureInPicture(
@Composable @Composable
actual fun rememberPlayerGestureController(): PlayerGestureController? = null actual fun rememberPlayerGestureController(): PlayerGestureController? = null
actual val usesNativePlayerChrome: Boolean
get() = isMacOS
actual val usesAnimatedPlayerChrome: Boolean = false

View file

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

View file

@ -29,3 +29,11 @@ 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"))
}
}