diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt new file mode 100644 index 000000000..b175e598a --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt @@ -0,0 +1,68 @@ +package com.nuvio.app.features.player + +import platform.UIKit.UIViewController + +/** + * Bridge interface for the MPV player. + * Swift side implements this and registers a factory at app startup. + */ +interface NuvioPlayerBridge { + fun createPlayerViewController(): UIViewController + fun loadFile(url: String) + fun play() + fun pause() + fun seekTo(positionMs: Long) + fun seekBy(offsetMs: Long) + fun retry() + fun setPlaybackSpeed(speed: Float) + fun setResizeMode(mode: Int) // 0=Fit, 1=Fill, 2=Zoom + fun getAudioTrackCount(): Int + fun getAudioTrackIndex(at: Int): Int + fun getAudioTrackId(at: Int): String + fun getAudioTrackLabel(at: Int): String + fun getAudioTrackLang(at: Int): String + fun isAudioTrackSelected(at: Int): Boolean + fun getSubtitleTrackCount(): Int + fun getSubtitleTrackIndex(at: Int): Int + fun getSubtitleTrackId(at: Int): String + fun getSubtitleTrackLabel(at: Int): String + fun getSubtitleTrackLang(at: Int): String + fun isSubtitleTrackSelected(at: Int): Boolean + fun selectAudioTrack(trackId: Int) + fun selectSubtitleTrack(trackId: Int) + fun setSubtitleUrl(url: String) + fun clearExternalSubtitle() + fun clearExternalSubtitleAndSelect(trackId: Int) + fun getIsLoading(): Boolean + fun getIsPlaying(): Boolean + fun getIsEnded(): Boolean + fun getDurationMs(): Long + fun getPositionMs(): Long + fun getBufferedMs(): Long + fun getPlaybackSpeed(): Float + fun destroy() +} + +/** + * Registry for the player bridge factory. + * Swift calls [registerFactory] during app startup before Compose is initialized. + */ +object NuvioPlayerBridgeFactory { + private var factoryRef: NuvioPlayerBridgeCreator? = null + + fun registerFactory(creator: NuvioPlayerBridgeCreator) { + this.factoryRef = creator + } + + fun create(): NuvioPlayerBridge? = factoryRef?.createBridge() + + val isRegistered: Boolean get() = factoryRef != null +} + +/** + * Interface for creating bridge instances. + * Swift implements this to provide the factory. + */ +interface NuvioPlayerBridgeCreator { + fun createBridge(): NuvioPlayerBridge +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt index 687c68bcd..83b235c84 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt @@ -1,14 +1,20 @@ package com.nuvio.app.features.player import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.interop.UIKitViewController +import co.touchlab.kermit.Logger +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +private const val TAG = "NuvioiOSPlayer" + +@OptIn(ExperimentalForeignApi::class) @Composable actual fun PlatformPlayerSurface( sourceUrl: String, @@ -19,33 +25,172 @@ actual fun PlatformPlayerSurface( onSnapshot: (PlayerPlaybackSnapshot) -> Unit, onError: (String?) -> Unit, ) { - val controller = remember { - object : PlayerEngineController { - override fun play() = Unit - override fun pause() = Unit - override fun seekTo(positionMs: Long) = Unit - override fun seekBy(offsetMs: Long) = Unit - override fun retry() = Unit - override fun setPlaybackSpeed(speed: Float) = Unit - override fun getAudioTracks(): List = emptyList() - override fun getSubtitleTracks(): List = emptyList() - override fun selectAudioTrack(index: Int) = Unit - override fun selectSubtitleTrack(index: Int) = Unit - override fun setSubtitleUri(url: String) = Unit - override fun clearExternalSubtitle() = Unit - override fun clearExternalSubtitleAndSelect(trackIndex: Int) = Unit + val latestOnSnapshot = rememberUpdatedState(onSnapshot) + val latestOnError = rememberUpdatedState(onError) + + val bridge = remember(sourceUrl) { + NuvioPlayerBridgeFactory.create() + } + + if (bridge == null) { + LaunchedEffect(Unit) { + onError("MPV player engine not available. Please rebuild the app.") + } + return + } + + // Create controller + LaunchedEffect(bridge) { + onControllerReady( + object : PlayerEngineController { + override fun play() { + bridge.play() + } + + override fun pause() { + bridge.pause() + } + + override fun seekTo(positionMs: Long) { + bridge.seekTo(positionMs) + } + + override fun seekBy(offsetMs: Long) { + bridge.seekBy(offsetMs) + } + + override fun retry() { + bridge.retry() + } + + override fun setPlaybackSpeed(speed: Float) { + bridge.setPlaybackSpeed(speed) + } + + override fun getAudioTracks(): List { + val count = bridge.getAudioTrackCount() + return (0 until count).map { i -> + AudioTrack( + index = bridge.getAudioTrackIndex(i), + id = bridge.getAudioTrackId(i), + label = bridge.getAudioTrackLabel(i), + language = bridge.getAudioTrackLang(i), + isSelected = bridge.isAudioTrackSelected(i), + ) + } + } + + override fun getSubtitleTracks(): List { + val count = bridge.getSubtitleTrackCount() + val tracks = (0 until count).map { i -> + SubtitleTrack( + index = bridge.getSubtitleTrackIndex(i), + id = bridge.getSubtitleTrackId(i), + label = bridge.getSubtitleTrackLabel(i), + language = bridge.getSubtitleTrackLang(i), + isSelected = bridge.isSubtitleTrackSelected(i), + ) + } + Logger.d(TAG) { "getSubtitleTracks: found ${tracks.size} tracks" } + return tracks + } + + override fun selectAudioTrack(index: Int) { + // Convert from list index to mpv track id + val count = bridge.getAudioTrackCount() + if (index in 0 until count) { + val trackId = bridge.getAudioTrackId(index).toIntOrNull() ?: (index + 1) + bridge.selectAudioTrack(trackId) + } + } + + override fun selectSubtitleTrack(index: Int) { + if (index < 0) { + bridge.selectSubtitleTrack(-1) // disable + } else { + val count = bridge.getSubtitleTrackCount() + if (index in 0 until count) { + val trackId = bridge.getSubtitleTrackId(index).toIntOrNull() ?: (index + 1) + bridge.selectSubtitleTrack(trackId) + } + } + } + + override fun setSubtitleUri(url: String) { + Logger.d(TAG) { "setSubtitleUri: $url" } + bridge.setSubtitleUrl(url) + } + + override fun clearExternalSubtitle() { + bridge.clearExternalSubtitle() + } + + override fun clearExternalSubtitleAndSelect(trackIndex: Int) { + val trackId = if (trackIndex < 0) -1 else { + val count = bridge.getSubtitleTrackCount() + if (trackIndex in 0 until count) { + bridge.getSubtitleTrackId(trackIndex).toIntOrNull() ?: (trackIndex + 1) + } else trackIndex + 1 + } + bridge.clearExternalSubtitleAndSelect(trackId) + } + } + ) + } + + // Load file and set initial state + LaunchedEffect(bridge, sourceUrl) { + bridge.loadFile(sourceUrl) + if (playWhenReady) { + bridge.play() + } else { + bridge.pause() } } - LaunchedEffect(sourceUrl) { - onControllerReady(controller) - onSnapshot(PlayerPlaybackSnapshot(isLoading = false)) - onError("Playback is not implemented on iOS yet.") + // Update playWhenReady + LaunchedEffect(bridge, playWhenReady) { + if (playWhenReady) bridge.play() else bridge.pause() } - Box( - modifier = modifier - .fillMaxSize() - .background(Color.Black), + // Update resize mode + LaunchedEffect(bridge, resizeMode) { + bridge.setResizeMode( + when (resizeMode) { + PlayerResizeMode.Fit -> 0 + PlayerResizeMode.Fill -> 1 + PlayerResizeMode.Zoom -> 2 + } + ) + } + + // Polling for snapshots + LaunchedEffect(bridge) { + while (isActive) { + val snapshot = PlayerPlaybackSnapshot( + isLoading = bridge.getIsLoading(), + isPlaying = bridge.getIsPlaying(), + isEnded = bridge.getIsEnded(), + durationMs = bridge.getDurationMs(), + positionMs = bridge.getPositionMs(), + bufferedPositionMs = bridge.getBufferedMs(), + playbackSpeed = bridge.getPlaybackSpeed(), + ) + latestOnSnapshot.value(snapshot) + delay(250L) + } + } + + // Cleanup + DisposableEffect(bridge) { + onDispose { + bridge.destroy() + } + } + + // Render the player view + UIKitViewController( + factory = { bridge.createPlayerViewController() }, + modifier = modifier, ) } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.ios.kt index c4437c2c3..045fe7d80 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.ios.kt @@ -1,9 +1,22 @@ package com.nuvio.app.features.player import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import platform.UIKit.UIApplication @Composable -actual fun LockPlayerToLandscape() = Unit +actual fun LockPlayerToLandscape() { + // iOS handles orientation through Info.plist supported orientations + // The player view will fill available space in landscape +} @Composable -actual fun EnterImmersivePlayerMode() = Unit +actual fun EnterImmersivePlayerMode() { + DisposableEffect(Unit) { + // Request idle timer disabled to keep screen awake during playback + UIApplication.sharedApplication.setIdleTimerDisabled(true) + onDispose { + UIApplication.sharedApplication.setIdleTimerDisabled(false) + } + } +} diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 1776f8064..ff0b5cfee 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -85,6 +85,7 @@ ); name = iosApp; packageProductDependencies = ( + A1B2C3D4E5F6A7B8C9D0E1F2 /* MPVKit */, ); productName = iosApp; productReference = 07921B4E8F8546BEE682C2FE /* Nuvio.app */; @@ -116,6 +117,9 @@ minimizedProjectReferenceProxies = 1; preferredProjectObjectVersion = 77; productRefGroup = AD93033A33973C81BB77B73A /* Products */; + packageReferences = ( + F1E2D3C4B5A6F7E8D9C0B1A2 /* XCLocalSwiftPackageReference "../MPVKit" */, + ); projectDirPath = ""; projectRoot = ""; targets = ( @@ -368,6 +372,21 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + F1E2D3C4B5A6F7E8D9C0B1A2 /* XCLocalSwiftPackageReference "../MPVKit" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../MPVKit; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + A1B2C3D4E5F6A7B8C9D0E1F2 /* MPVKit */ = { + isa = XCSwiftPackageProductDependency; + package = F1E2D3C4B5A6F7E8D9C0B1A2 /* XCLocalSwiftPackageReference "../MPVKit" */; + productName = MPVKit; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = E819B502921EC7C68AC4965A /* Project object */; } diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 52da32974..589c9e760 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -4,6 +4,9 @@ import ComposeApp struct ComposeView: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> UIViewController { + // Register MPV player bridge before Compose initializes + NuvioPlayerRegistration.register() + let controller = MainViewControllerKt.MainViewController() controller.view.backgroundColor = UIColor(red: 0.008, green: 0.016, blue: 0.016, alpha: 1.0) return controller diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift new file mode 100644 index 000000000..72292f7e7 --- /dev/null +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -0,0 +1,509 @@ +import Foundation +import UIKit +import Libmpv +import ComposeApp + +// MARK: - Player Bridge Implementation (Kotlin protocol conformance) + +final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge { + + private var playerVC: MPVPlayerViewController? + + func createPlayerViewController() -> UIViewController { + let vc = MPVPlayerViewController() + self.playerVC = vc + return vc + } + + func loadFile(url: String) { playerVC?.loadFile(url) } + func play() { playerVC?.playPlayback() } + func pause() { playerVC?.pausePlayback() } + func seekTo(positionMs: Int64) { playerVC?.seekToMs(positionMs) } + func seekBy(offsetMs: Int64) { playerVC?.seekByMs(offsetMs) } + func retry() { playerVC?.retryPlayback() } + func setPlaybackSpeed(speed: Float) { playerVC?.setSpeed(speed) } + func setResizeMode(mode: Int32) { playerVC?.setResize(Int(mode)) } + + // Audio tracks + func getAudioTrackCount() -> Int32 { Int32(playerVC?.audioTracks.count ?? 0) } + func getAudioTrackIndex(at: Int32) -> Int32 { + guard let t = playerVC?.audioTracks, Int(at) < t.count else { return 0 } + return Int32(t[Int(at)].index) + } + func getAudioTrackId(at: Int32) -> String { + guard let t = playerVC?.audioTracks, Int(at) < t.count else { return "0" } + return "\(t[Int(at)].id)" + } + func getAudioTrackLabel(at: Int32) -> String { + guard let t = playerVC?.audioTracks, Int(at) < t.count else { return "" } + return t[Int(at)].title + } + func getAudioTrackLang(at: Int32) -> String { + guard let t = playerVC?.audioTracks, Int(at) < t.count else { return "" } + return t[Int(at)].lang + } + func isAudioTrackSelected(at: Int32) -> Bool { + guard let t = playerVC?.audioTracks, Int(at) < t.count else { return false } + return t[Int(at)].selected + } + + // Subtitle tracks + func getSubtitleTrackCount() -> Int32 { Int32(playerVC?.subtitleTracks.count ?? 0) } + func getSubtitleTrackIndex(at: Int32) -> Int32 { + guard let t = playerVC?.subtitleTracks, Int(at) < t.count else { return 0 } + return Int32(t[Int(at)].index) + } + func getSubtitleTrackId(at: Int32) -> String { + guard let t = playerVC?.subtitleTracks, Int(at) < t.count else { return "0" } + return "\(t[Int(at)].id)" + } + func getSubtitleTrackLabel(at: Int32) -> String { + guard let t = playerVC?.subtitleTracks, Int(at) < t.count else { return "" } + return t[Int(at)].title + } + func getSubtitleTrackLang(at: Int32) -> String { + guard let t = playerVC?.subtitleTracks, Int(at) < t.count else { return "" } + return t[Int(at)].lang + } + func isSubtitleTrackSelected(at: Int32) -> Bool { + guard let t = playerVC?.subtitleTracks, Int(at) < t.count else { return false } + return t[Int(at)].selected + } + + func selectAudioTrack(trackId: Int32) { playerVC?.selectAudio(Int(trackId)) } + func selectSubtitleTrack(trackId: Int32) { playerVC?.selectSubtitle(Int(trackId)) } + func setSubtitleUrl(url: String) { playerVC?.addSubtitleUrl(url) } + func clearExternalSubtitle() { playerVC?.removeExternalSubtitles() } + func clearExternalSubtitleAndSelect(trackId: Int32) { playerVC?.removeExternalSubtitlesAndSelect(Int(trackId)) } + + // State - refreshes position from mpv on each call (polled from Kotlin every 250ms) + func getIsLoading() -> Bool { playerVC?.refreshPlaybackState(); return playerVC?.isPlayerLoading ?? true } + func getIsPlaying() -> Bool { return playerVC?.isPlayerPlaying ?? false } + func getIsEnded() -> Bool { return playerVC?.isPlayerEnded ?? false } + func getDurationMs() -> Int64 { return playerVC?.durationMs ?? 0 } + func getPositionMs() -> Int64 { return playerVC?.positionMs ?? 0 } + func getBufferedMs() -> Int64 { return playerVC?.bufferedMs ?? 0 } + func getPlaybackSpeed() -> Float { playerVC?.currentSpeed ?? 1.0 } + + func destroy() { + playerVC?.destroyPlayer() + playerVC = nil + } +} + +// MARK: - Track Info + +struct TrackInfo { + let index: Int + let id: Int + let type: String + let title: String + let lang: String + let selected: Bool +} + +// MARK: - MPV Player View Controller + +final class MPVPlayerViewController: UIViewController { + + private var metalLayer = MetalLayer() + private var mpv: OpaquePointer? + private lazy var eventQueue = DispatchQueue(label: "mpv-events", qos: .userInitiated) + + // Cached track lists + var audioTracks: [TrackInfo] = [] + var subtitleTracks: [TrackInfo] = [] + + // State (polled from Kotlin every 250ms) + var isPlayerLoading: Bool = true + var isPlayerPlaying: Bool = false + var isPlayerEnded: Bool = false + var durationMs: Int64 = 0 + var positionMs: Int64 = 0 + var bufferedMs: Int64 = 0 + var currentSpeed: Float = 1.0 + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + + metalLayer.frame = view.bounds + metalLayer.contentsScale = UIScreen.main.nativeScale + metalLayer.framebufferOnly = true + metalLayer.backgroundColor = UIColor.black.cgColor + view.layer.addSublayer(metalLayer) + + setupMpv() + setupNotifications() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + metalLayer.frame = view.bounds + } + + // MARK: - MPV Setup + + private func setupMpv() { + mpv = mpv_create() + guard mpv != nil else { + print("[MPV] Failed to create mpv instance") + return + } + +#if DEBUG + checkError(mpv_request_log_messages(mpv, "warn")) +#else + checkError(mpv_request_log_messages(mpv, "no")) +#endif + + checkError(mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &metalLayer)) + checkError(mpv_set_option_string(mpv, "vo", "gpu-next")) + checkError(mpv_set_option_string(mpv, "gpu-api", "vulkan")) + checkError(mpv_set_option_string(mpv, "gpu-context", "moltenvk")) + checkError(mpv_set_option_string(mpv, "hwdec", "videotoolbox")) + checkError(mpv_set_option_string(mpv, "video-rotate", "no")) + checkError(mpv_set_option_string(mpv, "subs-match-os-language", "yes")) + checkError(mpv_set_option_string(mpv, "subs-fallback", "yes")) + checkError(mpv_set_option_string(mpv, "keep-open", "yes")) + checkError(mpv_set_option_string(mpv, "target-colorspace-hint", "yes")) + + checkError(mpv_initialize(mpv)) + + // Observe properties + mpv_observe_property(mpv, 0, "pause", MPV_FORMAT_FLAG) + mpv_observe_property(mpv, 0, "paused-for-cache", MPV_FORMAT_FLAG) + mpv_observe_property(mpv, 0, "core-idle", MPV_FORMAT_FLAG) + mpv_observe_property(mpv, 0, "eof-reached", MPV_FORMAT_FLAG) + mpv_observe_property(mpv, 0, "seeking", MPV_FORMAT_FLAG) + mpv_observe_property(mpv, 0, "track-list/count", MPV_FORMAT_INT64) + + mpv_set_wakeup_callback(mpv, { ctx in + let vc = unsafeBitCast(ctx, to: MPVPlayerViewController.self) + vc.readEvents() + }, UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())) + } + + private func setupNotifications() { + NotificationCenter.default.addObserver(self, selector: #selector(enterBackground), + name: UIApplication.didEnterBackgroundNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(enterForeground), + name: UIApplication.willEnterForegroundNotification, object: nil) + } + + @objc private func enterBackground() { + guard mpv != nil else { return } + pausePlayback() + checkError(mpv_set_option_string(mpv, "vid", "no")) + } + + @objc private func enterForeground() { + guard mpv != nil else { return } + checkError(mpv_set_option_string(mpv, "vid", "auto")) + playPlayback() + } + + // MARK: - Playback API + + func loadFile(_ urlString: String) { + guard mpv != nil else { return } + isPlayerLoading = true + isPlayerEnded = false + command("loadfile", args: [urlString, "replace"]) + } + + func playPlayback() { + guard mpv != nil else { return } + setFlag("pause", false) + } + + func pausePlayback() { + guard mpv != nil else { return } + setFlag("pause", true) + } + + func seekToMs(_ ms: Int64) { + guard mpv != nil else { return } + let seconds = Double(ms) / 1000.0 + command("seek", args: [String(format: "%.3f", seconds), "absolute"]) + } + + func seekByMs(_ ms: Int64) { + guard mpv != nil else { return } + let seconds = Double(ms) / 1000.0 + command("seek", args: [String(format: "%.3f", seconds), "relative"]) + } + + func retryPlayback() { + guard mpv != nil else { return } + if let path = getString("path") { + let pos = getDouble("time-pos") + command("loadfile", args: [path, "replace"]) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.command("seek", args: [String(format: "%.3f", pos), "absolute"]) + } + } + } + + func setSpeed(_ speed: Float) { + guard mpv != nil else { return } + var s = Double(speed) + mpv_set_property(mpv, "speed", MPV_FORMAT_DOUBLE, &s) + } + + func setResize(_ mode: Int) { + guard mpv != nil else { return } + switch mode { + case 1: // Fill + checkError(mpv_set_option_string(mpv, "panscan", "1.0")) + checkError(mpv_set_option_string(mpv, "video-unscaled", "no")) + case 2: // Zoom + checkError(mpv_set_option_string(mpv, "panscan", "0.0")) + checkError(mpv_set_option_string(mpv, "video-unscaled", "downscale-big")) + default: // Fit + checkError(mpv_set_option_string(mpv, "panscan", "0.0")) + checkError(mpv_set_option_string(mpv, "video-unscaled", "no")) + } + } + + // MARK: - Track selection + + func selectAudio(_ trackId: Int) { + guard mpv != nil else { return } + var id = Int64(trackId) + mpv_set_property(mpv, "aid", MPV_FORMAT_INT64, &id) + } + + func selectSubtitle(_ trackId: Int) { + guard mpv != nil else { return } + if trackId < 0 { + checkError(mpv_set_option_string(mpv, "sid", "no")) + } else { + var id = Int64(trackId) + mpv_set_property(mpv, "sid", MPV_FORMAT_INT64, &id) + } + } + + func addSubtitleUrl(_ url: String) { + guard mpv != nil else { return } + command("sub-add", args: [url, "select"]) + } + + func removeExternalSubtitles() { + guard mpv != nil else { return } + let count = getInt("track-list/count") + for i in stride(from: count - 1, through: 0, by: -1) { + let type = getString("track-list/\(i)/type") ?? "" + let external = getFlag("track-list/\(i)/external") + if type == "sub" && external { + let id = getInt("track-list/\(i)/id") + command("sub-remove", args: ["\(id)"], checkForErrors: false) + } + } + checkError(mpv_set_option_string(mpv, "sid", "no")) + } + + func removeExternalSubtitlesAndSelect(_ trackId: Int) { + guard mpv != nil else { return } + let count = getInt("track-list/count") + for i in stride(from: count - 1, through: 0, by: -1) { + let type = getString("track-list/\(i)/type") ?? "" + let external = getFlag("track-list/\(i)/external") + if type == "sub" && external { + let id = getInt("track-list/\(i)/id") + command("sub-remove", args: ["\(id)"], checkForErrors: false) + } + } + if trackId >= 0 { + selectSubtitle(trackId) + } else { + checkError(mpv_set_option_string(mpv, "sid", "no")) + } + } + + func destroyPlayer() { + NotificationCenter.default.removeObserver(self) + if let mpv = mpv { + mpv_terminate_destroy(mpv) + } + mpv = nil + } + + // MARK: - State Update + + /// Lightweight state refresh — called by Kotlin polling (every 250ms). + /// Only reads cheap scalar properties; does NOT re-enumerate tracks. + func refreshPlaybackState() { + guard mpv != nil else { return } + let duration = getDouble("duration") + let position = getDouble("time-pos") + let cached = getDouble("demuxer-cache-time") + let speed = getDouble("speed") + let paused = getFlag("pause") + let eofReached = getFlag("eof-reached") + let idle = getFlag("core-idle") + let seeking = getFlag("seeking") + let bufferingCache = getFlag("paused-for-cache") + + isPlayerLoading = (idle && !paused && !eofReached) || seeking || bufferingCache + isPlayerPlaying = !paused && !idle && !eofReached + isPlayerEnded = eofReached + durationMs = Int64(duration * 1000) + positionMs = Int64(max(position, 0) * 1000) + bufferedMs = Int64(max(position + cached, 0) * 1000) + currentSpeed = Float(speed > 0 ? speed : 1.0) + } + + /// Full state + track refresh — called from MPV event loop on property changes. + func updateState() { + refreshPlaybackState() + refreshTracks() + } + + private func refreshTracks() { + guard mpv != nil else { return } + var audio = [TrackInfo]() + var subs = [TrackInfo]() + let count = getInt("track-list/count") + var audioIdx = 0 + var subIdx = 0 + + for i in 0..(OpaquePointer(data)).pointee + if endFile.reason == MPV_END_FILE_REASON_ERROR { + print("[MPV] End file error: \(String(cString: mpv_error_string(endFile.error)))") + } + } + case MPV_EVENT_SHUTDOWN: + mpv_terminate_destroy(mpv) + self.mpv = nil + return + case MPV_EVENT_LOG_MESSAGE: + if let msg = UnsafeMutablePointer(OpaquePointer(eventPtr.pointee.data)) { + let prefix = String(cString: msg.pointee.prefix!) + let level = String(cString: msg.pointee.level!) + let text = String(cString: msg.pointee.text!) + print("[MPV][\(prefix)] \(level): \(text)", terminator: "") + } + default: + break + } + } + } + } + + // MARK: - MPV Helpers + + private func command(_ command: String, args: [String?] = [], checkForErrors: Bool = true) { + guard mpv != nil else { return } + var cargs = makeCArgs(command, args).map { $0.flatMap { UnsafePointer(strdup($0)) } } + defer { for ptr in cargs where ptr != nil { free(UnsafeMutablePointer(mutating: ptr!)) } } + let ret = mpv_command(mpv, &cargs) + if checkForErrors { checkError(ret) } + } + + private func makeCArgs(_ command: String, _ args: [String?]) -> [String?] { + var strArgs = args + strArgs.insert(command, at: 0) + strArgs.append(nil) + return strArgs + } + + private func getDouble(_ name: String) -> Double { + guard mpv != nil else { return 0.0 } + var data = Double() + mpv_get_property(mpv, name, MPV_FORMAT_DOUBLE, &data) + return data + } + + private func getString(_ name: String) -> String? { + guard mpv != nil else { return nil } + let cstr = mpv_get_property_string(mpv, name) + let str: String? = cstr == nil ? nil : String(cString: cstr!) + mpv_free(cstr) + return str + } + + private func getFlag(_ name: String) -> Bool { + guard mpv != nil else { return false } + var data = Int64() + mpv_get_property(mpv, name, MPV_FORMAT_FLAG, &data) + return data > 0 + } + + private func setFlag(_ name: String, _ flag: Bool) { + guard mpv != nil else { return } + var data: Int = flag ? 1 : 0 + mpv_set_property(mpv, name, MPV_FORMAT_FLAG, &data) + } + + private func getInt(_ name: String) -> Int { + guard mpv != nil else { return 0 } + var data = Int64() + mpv_get_property(mpv, name, MPV_FORMAT_INT64, &data) + return Int(data) + } + + private func checkError(_ status: CInt) { + if status < 0 { + print("[MPV] API error: \(String(cString: mpv_error_string(status)))") + } + } +} + +// MARK: - Bridge Creator (implements Kotlin protocol) + +final class MPVPlayerBridgeCreator: NSObject, NuvioPlayerBridgeCreator { + func createBridge() -> any NuvioPlayerBridge { + return MPVPlayerBridgeImpl() + } +} + +// MARK: - Registration (called from Swift app startup) + +enum NuvioPlayerRegistration { + static func register() { + NuvioPlayerBridgeFactory.shared.registerFactory(creator: MPVPlayerBridgeCreator()) + } +} diff --git a/iosApp/iosApp/Player/MetalLayer.swift b/iosApp/iosApp/Player/MetalLayer.swift new file mode 100644 index 000000000..27c325ab7 --- /dev/null +++ b/iosApp/iosApp/Player/MetalLayer.swift @@ -0,0 +1,26 @@ +import Foundation +import UIKit + +class MetalLayer: CAMetalLayer { + override var drawableSize: CGSize { + get { return super.drawableSize } + set { + if Int(newValue.width) > 1 && Int(newValue.height) > 1 { + super.drawableSize = newValue + } + } + } + + override var wantsExtendedDynamicRangeContent: Bool { + get { return super.wantsExtendedDynamicRangeContent } + set { + if Thread.isMainThread { + super.wantsExtendedDynamicRangeContent = newValue + } else { + DispatchQueue.main.sync { + super.wantsExtendedDynamicRangeContent = newValue + } + } + } + } +}