diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index c4f12ba8..fc776eaf 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -213,6 +213,7 @@ kotlin { implementation(compose.desktop.currentOs) implementation(libs.ktor.client.java) implementation(libs.kotlinx.coroutines.swing) + implementation(libs.jna) } } androidMain.dependencies { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt index 8a5b6730..bcc3e3b5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt @@ -18,6 +18,25 @@ interface PlayerEngineController { fun clearExternalSubtitle() fun clearExternalSubtitleAndSelect(trackIndex: Int) fun applySubtitleStyle(style: SubtitleStyleState) {} + fun setMetadata( + title: String, + streamTitle: String, + providerName: String, + seasonNumber: Int? = null, + episodeNumber: Int? = null, + episodeTitle: String? = null, + ) {} + fun showSkipButton(type: String, endTimeMs: Long) {} + fun hideSkipButton() {} + fun showNextEpisode( + season: Int, + episode: Int, + title: String, + thumbnail: String? = null, + hasAired: Boolean = true, + ) {} + fun hideNextEpisode() {} + fun setOnCloseCallback(callback: () -> Unit) {} } internal fun sanitizePlaybackHeaders(headers: Map?): Map { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index 78578511..ddecc989 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -1180,6 +1180,30 @@ fun PlayerScreen( } } + LaunchedEffect(activeSkipInterval, skipIntervalDismissed) { + val interval = activeSkipInterval + if (interval != null && !skipIntervalDismissed) { + playerController?.showSkipButton(interval.type, (interval.endTime * 1000).toLong()) + } else { + playerController?.hideSkipButton() + } + } + + LaunchedEffect(showNextEpisodeCard, nextEpisodeInfo) { + val info = nextEpisodeInfo + if (showNextEpisodeCard && info != null) { + playerController?.showNextEpisode( + season = info.season, + episode = info.episode, + title = info.title ?: "", + thumbnail = info.thumbnail, + hasAired = info.hasAired, + ) + } else { + playerController?.hideNextEpisode() + } + } + // Resolve next episode info when episodes list or current episode changes LaunchedEffect(allEpisodes, activeSeasonNumber, activeEpisodeNumber) { if (!isSeries || allEpisodes.isEmpty()) { @@ -1407,6 +1431,15 @@ fun PlayerScreen( onControllerReady = { controller -> playerController = controller playerControllerSourceUrl = activeSourceUrl + controller.setMetadata( + title = title, + streamTitle = activeStreamTitle, + providerName = activeProviderName, + seasonNumber = activeSeasonNumber, + episodeNumber = activeEpisodeNumber, + episodeTitle = activeEpisodeTitle, + ) + controller.setOnCloseCallback { onBackWithProgress() } }, onSnapshot = { snapshot -> playbackSnapshot = snapshot diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/NativePlayerBridge.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/NativePlayerBridge.kt new file mode 100644 index 00000000..bf7e9892 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/NativePlayerBridge.kt @@ -0,0 +1,118 @@ +package com.nuvio.app.features.player + +import com.sun.jna.Library +import com.sun.jna.Native +import com.sun.jna.Pointer + +internal interface DesktopMPVBridgeLib : Library { + companion object { + val INSTANCE: DesktopMPVBridgeLib by lazy { + val libPath = resolveLibraryPath() + if (libPath != null) { + System.setProperty( + "jna.library.path", + (System.getProperty("jna.library.path") ?: "") + ":" + libPath, + ) + } + Native.load("DesktopMPVBridge", DesktopMPVBridgeLib::class.java) + } + + private fun resolveLibraryPath(): String? { + val candidates = listOf( + "MPVKit/.build/arm64-apple-macosx/release", + "MPVKit/.build/arm64-apple-macosx/debug", + "../MPVKit/.build/arm64-apple-macosx/release", + "../MPVKit/.build/arm64-apple-macosx/debug", + ) + val userDir = System.getProperty("user.dir") ?: return null + for (candidate in candidates) { + val dir = java.io.File(userDir, candidate) + if (dir.exists() && dir.isDirectory) { + val dylib = java.io.File(dir, "libDesktopMPVBridge.dylib") + if (dylib.exists()) return dir.absolutePath + } + } + return null + } + } + + fun nuvio_player_create(): Pointer + fun nuvio_player_destroy(player: Pointer) + fun nuvio_player_show(player: Pointer) + + fun nuvio_player_set_metadata( + player: Pointer, + title: String, + streamTitle: String, + providerName: String, + season: Int, + episode: Int, + episodeTitle: String?, + ) + + 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 +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerDesktop.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerDesktop.desktop.kt index 2c58e9c8..ca7a2924 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerDesktop.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerDesktop.desktop.kt @@ -3,13 +3,16 @@ package com.nuvio.app.features.player import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.IntSize -import androidx.compose.material3.Text import com.nuvio.app.core.storage.ProfileScopedKey import com.nuvio.app.core.sync.decodeSyncBoolean import com.nuvio.app.core.sync.decodeSyncFloat @@ -22,7 +25,9 @@ import com.nuvio.app.core.sync.encodeSyncInt import com.nuvio.app.core.sync.encodeSyncString import com.nuvio.app.core.sync.encodeSyncStringSet import com.nuvio.app.desktop.DesktopPreferences +import com.sun.jna.Pointer import java.util.Locale +import kotlinx.coroutines.delay import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -42,53 +47,197 @@ actual fun PlatformPlayerSurface( onSnapshot: (PlayerPlaybackSnapshot) -> Unit, onError: (String?) -> Unit, ) { - val controller = remember { + val bridge = remember { DesktopMPVBridgeLib.INSTANCE } + val playerPtr = remember { bridge.nuvio_player_create() } + var onCloseCallback by remember { mutableStateOf<(() -> Unit)?>(null) } + + DisposableEffect(playerPtr) { + bridge.nuvio_player_show(playerPtr) + onDispose { + bridge.nuvio_player_destroy(playerPtr) + } + } + + LaunchedEffect(sourceUrl, sourceAudioUrl) { + val headersJson = if (sourceHeaders.isNotEmpty()) { + buildJsonObject { + sourceHeaders.forEach { (k, v) -> put(k, v) } + }.toString() + } else null + bridge.nuvio_player_load_file(playerPtr, sourceUrl, sourceAudioUrl, headersJson) + if (playWhenReady) { + bridge.nuvio_player_play(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() = Unit + 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 pause() = Unit + override fun getAudioTracks(): List { + val count = bridge.nuvio_player_get_audio_track_count(playerPtr) + return (0 until count).map { i -> + AudioTrack( + index = i, + id = bridge.nuvio_player_get_audio_track_id(playerPtr, i).toString(), + label = bridge.nuvio_player_get_audio_track_label(playerPtr, i) ?: "", + language = bridge.nuvio_player_get_audio_track_lang(playerPtr, i), + isSelected = bridge.nuvio_player_is_audio_track_selected(playerPtr, i), + ) + } + } - override fun seekTo(positionMs: Long) = Unit + override fun getSubtitleTracks(): List { + val count = bridge.nuvio_player_get_subtitle_track_count(playerPtr) + return (0 until count).map { i -> + SubtitleTrack( + index = i, + id = bridge.nuvio_player_get_subtitle_track_id(playerPtr, i).toString(), + label = bridge.nuvio_player_get_subtitle_track_label(playerPtr, i) ?: "", + language = bridge.nuvio_player_get_subtitle_track_lang(playerPtr, i), + isSelected = bridge.nuvio_player_is_subtitle_track_selected(playerPtr, i), + ) + } + } - override fun seekBy(offsetMs: Long) = Unit + override fun selectAudioTrack(index: Int) { + val count = bridge.nuvio_player_get_audio_track_count(playerPtr) + if (index in 0 until count) { + val trackId = bridge.nuvio_player_get_audio_track_id(playerPtr, index) + bridge.nuvio_player_select_audio_track(playerPtr, trackId) + } + } - override fun retry() = Unit + 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) { + val trackId = bridge.nuvio_player_get_subtitle_track_id(playerPtr, index) + bridge.nuvio_player_select_subtitle_track(playerPtr, trackId) + } + } - override fun setPlaybackSpeed(speed: Float) = Unit + override fun setSubtitleUri(url: String) = + bridge.nuvio_player_set_subtitle_url(playerPtr, url) - override fun getAudioTracks(): List = emptyList() + override fun clearExternalSubtitle() = + bridge.nuvio_player_clear_external_subtitle(playerPtr) - override fun getSubtitleTracks(): List = emptyList() + 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 selectAudioTrack(index: Int) = Unit + override fun applySubtitleStyle(style: SubtitleStyleState) { + val colorHex = style.textColor.toMpvColorString() + val outline = if (style.outlineEnabled) 2.0f else 0.0f + bridge.nuvio_player_apply_subtitle_style( + playerPtr, colorHex, outline, style.fontSizeSp.toFloat(), style.bottomOffset, + ) + } - override fun selectSubtitleTrack(index: Int) = Unit + override fun setMetadata( + title: String, + streamTitle: String, + providerName: String, + seasonNumber: Int?, + episodeNumber: Int?, + episodeTitle: String?, + ) { + bridge.nuvio_player_set_metadata( + playerPtr, title, streamTitle, providerName, + seasonNumber ?: 0, episodeNumber ?: 0, episodeTitle, + ) + } - override fun setSubtitleUri(url: String) = Unit + override fun showSkipButton(type: String, endTimeMs: Long) { + bridge.nuvio_player_show_skip_button(playerPtr, type, endTimeMs) + } - override fun clearExternalSubtitle() = Unit + override fun hideSkipButton() { + bridge.nuvio_player_hide_skip_button(playerPtr) + } - override fun clearExternalSubtitleAndSelect(trackIndex: Int) = Unit + 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 + } } } LaunchedEffect(controller) { onControllerReady(controller) - onSnapshot(PlayerPlaybackSnapshot(isLoading = false)) - onError(null) } - Box( - modifier = modifier.background(Color.Black), - contentAlignment = Alignment.Center, - ) { - Text( - text = "Desktop playback is not implemented yet.", - color = Color.White, - ) + LaunchedEffect(playerPtr) { + while (true) { + delay(250) + if (bridge.nuvio_player_is_closed(playerPtr)) { + onCloseCallback?.invoke() + break + } + bridge.nuvio_player_refresh_state(playerPtr) + val 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), + ) + onSnapshot(snapshot) + val error = bridge.nuvio_player_get_error(playerPtr) + onError(error) + } } + + Box(modifier = modifier.background(Color.Black)) } +private fun androidx.compose.ui.graphics.Color.toMpvColorString(): String { + val r = (red * 255).toInt().coerceIn(0, 255) + val g = (green * 255).toInt().coerceIn(0, 255) + val b = (blue * 255).toInt().coerceIn(0, 255) + val a = (alpha * 255).toInt().coerceIn(0, 255) + return "#${r.hex()}${g.hex()}${b.hex()}${a.hex()}" +} + +private fun Int.hex(): String = toString(16).padStart(2, '0').uppercase() + internal actual object DeviceLanguagePreferences { actual fun preferredLanguageCodes(): List = listOfNotNull(Locale.getDefault().toLanguageTag().takeIf { it.isNotBlank() }) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 04e2c315..6ea086fb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,7 @@ supabase = "3.4.1" quickjsKt = "1.0.1" ksoup = "0.2.6" reorderable = "3.0.0" +jna = "5.14.0" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -76,6 +77,7 @@ supabase-functions = { module = "io.github.jan-tennert.supabase:functions-kt", v quickjs-kt = { module = "io.github.dokar3:quickjs-kt", version.ref = "quickjsKt" } ksoup = { module = "com.fleeksoft.ksoup:ksoup", version.ref = "ksoup" } reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } +jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" }