diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/AppIconPainter.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/AppIconPainter.android.kt new file mode 100644 index 000000000..7b640508c --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/AppIconPainter.android.kt @@ -0,0 +1,19 @@ +package com.nuvio.app.core.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource +import com.nuvio.app.R + +@Composable +actual fun appIconPainter(icon: AppIconResource): Painter = + painterResource( + id = when (icon) { + AppIconResource.PlayerPlay -> R.drawable.ic_player_play + AppIconResource.PlayerPause -> R.drawable.ic_player_pause + AppIconResource.PlayerAspectRatio -> R.drawable.ic_player_aspect_ratio + AppIconResource.PlayerSubtitles -> R.drawable.ic_player_subtitles + AppIconResource.PlayerAudioFilled -> R.drawable.ic_player_audio_filled + AppIconResource.LibraryAddPlus -> R.drawable.library_add_plus + } + ) diff --git a/composeApp/src/androidMain/res/drawable/ic_player_aspect_ratio.xml b/composeApp/src/androidMain/res/drawable/ic_player_aspect_ratio.xml new file mode 100644 index 000000000..0862074ba --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/ic_player_aspect_ratio.xml @@ -0,0 +1,10 @@ + + + + diff --git a/composeApp/src/androidMain/res/drawable/ic_player_audio_filled.xml b/composeApp/src/androidMain/res/drawable/ic_player_audio_filled.xml new file mode 100644 index 000000000..952108aff --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/ic_player_audio_filled.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/composeApp/src/androidMain/res/drawable/ic_player_pause.xml b/composeApp/src/androidMain/res/drawable/ic_player_pause.xml new file mode 100644 index 000000000..63b259cda --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/ic_player_pause.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/composeApp/src/androidMain/res/drawable/ic_player_play.xml b/composeApp/src/androidMain/res/drawable/ic_player_play.xml new file mode 100644 index 000000000..fc441f7cc --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/ic_player_play.xml @@ -0,0 +1,10 @@ + + + + diff --git a/composeApp/src/androidMain/res/drawable/ic_player_subtitles.xml b/composeApp/src/androidMain/res/drawable/ic_player_subtitles.xml new file mode 100644 index 000000000..f60b24ace --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/ic_player_subtitles.xml @@ -0,0 +1,12 @@ + + + + diff --git a/composeApp/src/androidMain/res/drawable/library_add_plus.xml b/composeApp/src/androidMain/res/drawable/library_add_plus.xml new file mode 100644 index 000000000..e8a9c5dce --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/library_add_plus.xml @@ -0,0 +1,14 @@ + + + + diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/AppIconPainter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/AppIconPainter.kt new file mode 100644 index 000000000..ad77cc131 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/AppIconPainter.kt @@ -0,0 +1,16 @@ +package com.nuvio.app.core.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter + +enum class AppIconResource { + PlayerPlay, + PlayerPause, + PlayerAspectRatio, + PlayerSubtitles, + PlayerAudioFilled, + LibraryAddPlus, +} + +@Composable +expect fun appIconPainter(icon: AppIconResource): Painter diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt index b5e37cacd..271f53444 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt @@ -21,10 +21,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import nuvio.composeapp.generated.resources.Res -import nuvio.composeapp.generated.resources.ic_player_play -import nuvio.composeapp.generated.resources.library_add_plus -import org.jetbrains.compose.resources.painterResource +import com.nuvio.app.core.ui.AppIconResource +import com.nuvio.app.core.ui.appIconPainter @Composable fun DetailActionButtons( @@ -35,8 +33,8 @@ fun DetailActionButtons( onPlayClick: () -> Unit = {}, onSaveClick: () -> Unit = {}, ) { - val playPainter = painterResource(Res.drawable.ic_player_play) - val libraryAddPainter = painterResource(Res.drawable.library_add_plus) + val playPainter = appIconPainter(AppIconResource.PlayerPlay) + val libraryAddPainter = appIconPainter(AppIconResource.LibraryAddPlus) Row( modifier = modifier.fillMaxWidth(), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt index e724c5553..f879624ac 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt @@ -42,15 +42,10 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.nuvio.app.core.ui.AppIconResource import com.nuvio.app.core.ui.NuvioBackButton +import com.nuvio.app.core.ui.appIconPainter import com.nuvio.app.core.ui.nuvioTypeScale -import nuvio.composeapp.generated.resources.Res -import nuvio.composeapp.generated.resources.ic_player_aspect_ratio -import nuvio.composeapp.generated.resources.ic_player_audio_filled -import nuvio.composeapp.generated.resources.ic_player_pause -import nuvio.composeapp.generated.resources.ic_player_play -import nuvio.composeapp.generated.resources.ic_player_subtitles -import org.jetbrains.compose.resources.painterResource @Composable internal fun PlayerControlsShell( @@ -321,8 +316,8 @@ private fun PlayPauseControlButton( metrics: PlayerLayoutMetrics, onClick: () -> Unit, ) { - val playPausePainter = painterResource( - if (isPlaying) Res.drawable.ic_player_pause else Res.drawable.ic_player_play, + val playPausePainter = appIconPainter( + if (isPlaying) AppIconResource.PlayerPause else AppIconResource.PlayerPlay, ) Box( @@ -364,9 +359,9 @@ private fun ProgressControls( modifier: Modifier = Modifier, ) { val durationMs = playbackSnapshot.durationMs.coerceAtLeast(1L) - val aspectRatioPainter = painterResource(Res.drawable.ic_player_aspect_ratio) - val subtitlesPainter = painterResource(Res.drawable.ic_player_subtitles) - val audioPainter = painterResource(Res.drawable.ic_player_audio_filled) + val aspectRatioPainter = appIconPainter(AppIconResource.PlayerAspectRatio) + val subtitlesPainter = appIconPainter(AppIconResource.PlayerSubtitles) + val audioPainter = appIconPainter(AppIconResource.PlayerAudioFilled) Column(modifier = modifier) { Slider( diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/AppIconPainter.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/AppIconPainter.ios.kt new file mode 100644 index 000000000..5607e29ee --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/AppIconPainter.ios.kt @@ -0,0 +1,25 @@ +package com.nuvio.app.core.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.ic_player_aspect_ratio +import nuvio.composeapp.generated.resources.ic_player_audio_filled +import nuvio.composeapp.generated.resources.ic_player_pause +import nuvio.composeapp.generated.resources.ic_player_play +import nuvio.composeapp.generated.resources.ic_player_subtitles +import nuvio.composeapp.generated.resources.library_add_plus +import org.jetbrains.compose.resources.painterResource + +@Composable +actual fun appIconPainter(icon: AppIconResource): Painter = + painterResource( + when (icon) { + AppIconResource.PlayerPlay -> Res.drawable.ic_player_play + AppIconResource.PlayerPause -> Res.drawable.ic_player_pause + AppIconResource.PlayerAspectRatio -> Res.drawable.ic_player_aspect_ratio + AppIconResource.PlayerSubtitles -> Res.drawable.ic_player_subtitles + AppIconResource.PlayerAudioFilled -> Res.drawable.ic_player_audio_filled + AppIconResource.LibraryAddPlus -> Res.drawable.library_add_plus + } + ) 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 index b175e598a..f5fdb63fd 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt @@ -40,6 +40,7 @@ interface NuvioPlayerBridge { fun getPositionMs(): Long fun getBufferedMs(): Long fun getPlaybackSpeed(): Float + fun getErrorMessage(): String fun destroy() } 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 94d847bb4..9a9d234f2 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 @@ -174,6 +174,7 @@ actual fun PlatformPlayerSurface( // Polling for snapshots LaunchedEffect(bridge) { + var lastReportedError: String? = null while (isActive) { val snapshot = PlayerPlaybackSnapshot( isLoading = bridge.getIsLoading(), @@ -185,6 +186,11 @@ actual fun PlatformPlayerSurface( playbackSpeed = bridge.getPlaybackSpeed(), ) latestOnSnapshot.value(snapshot) + val errorMessage = bridge.getErrorMessage().ifBlank { null } + if (errorMessage != lastReportedError) { + lastReportedError = errorMessage + latestOnError.value(errorMessage) + } delay(250L) } } diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index 434b3f8ae..712e281b3 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -84,6 +84,7 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge { func getPositionMs() -> Int64 { return playerVC?.positionMs ?? 0 } func getBufferedMs() -> Int64 { return playerVC?.bufferedMs ?? 0 } func getPlaybackSpeed() -> Float { playerVC?.currentSpeed ?? 1.0 } + func getErrorMessage() -> String { playerVC?.currentErrorMessage ?? "" } func destroy() { playerVC?.destroyPlayer() @@ -106,9 +107,11 @@ struct TrackInfo { final class MPVPlayerViewController: UIViewController { + private let errorStateLock = NSLock() private var metalLayer = MetalLayer() private var mpv: OpaquePointer? private lazy var eventQueue = DispatchQueue(label: "mpv-events", qos: .userInitiated) + private var recentPlaybackLogs: [String] = [] // Cached track lists var audioTracks: [TrackInfo] = [] @@ -122,6 +125,12 @@ final class MPVPlayerViewController: UIViewController { var positionMs: Int64 = 0 var bufferedMs: Int64 = 0 var currentSpeed: Float = 1.0 + var currentErrorMessage: String { + errorStateLock.lock() + defer { errorStateLock.unlock() } + return _currentErrorMessage ?? "" + } + private var _currentErrorMessage: String? // MARK: - Lifecycle @@ -153,11 +162,7 @@ final class MPVPlayerViewController: UIViewController { 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")) @@ -209,6 +214,7 @@ final class MPVPlayerViewController: UIViewController { func loadFile(_ urlString: String) { guard mpv != nil else { return } + clearPlaybackError() isPlayerLoading = true isPlayerEnded = false command("loadfile", args: [urlString, "replace"]) @@ -239,6 +245,7 @@ final class MPVPlayerViewController: UIViewController { func retryPlayback() { guard mpv != nil else { return } if let path = getString("path") { + clearPlaybackError() let pos = getDouble("time-pos") command("loadfile", args: [path, "replace"]) DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in @@ -325,6 +332,7 @@ final class MPVPlayerViewController: UIViewController { func destroyPlayer() { NotificationCenter.default.removeObserver(self) + clearPlaybackError() guard let ctx = mpv else { return } mpv = nil // nil first so event loop stops reading mpv_terminate_destroy(ctx) @@ -388,6 +396,38 @@ final class MPVPlayerViewController: UIViewController { subtitleTracks = subs } + private func clearPlaybackError() { + errorStateLock.lock() + recentPlaybackLogs.removeAll(keepingCapacity: true) + _currentErrorMessage = nil + errorStateLock.unlock() + } + + private func appendPlaybackLog(prefix: String, level: String, text: String) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + guard level == "warn" || level == "error" || level == "fatal" else { return } + + let formatted = "[\(prefix)] \(trimmed)" + errorStateLock.lock() + recentPlaybackLogs.append(formatted) + if recentPlaybackLogs.count > 4 { + recentPlaybackLogs.removeFirst(recentPlaybackLogs.count - 4) + } + errorStateLock.unlock() + } + + private func setPlaybackError(_ fallback: String) { + let trimmedFallback = fallback.trimmingCharacters(in: .whitespacesAndNewlines) + errorStateLock.lock() + var parts = recentPlaybackLogs.suffix(3) + if !trimmedFallback.isEmpty && !parts.contains(trimmedFallback) { + parts.append(trimmedFallback) + } + _currentErrorMessage = parts.isEmpty ? "Unable to play this stream." : parts.joined(separator: "\n") + errorStateLock.unlock() + } + // MARK: - Event Loop private func readEvents() { @@ -404,6 +444,7 @@ final class MPVPlayerViewController: UIViewController { DispatchQueue.main.async { self.updateState() } case MPV_EVENT_FILE_LOADED: DispatchQueue.main.async { + self.clearPlaybackError() self.isPlayerLoading = false self.updateState() } @@ -411,7 +452,9 @@ final class MPVPlayerViewController: UIViewController { if let data = eventPtr.pointee.data { let endFile = UnsafePointer(OpaquePointer(data)).pointee if endFile.reason == MPV_END_FILE_REASON_ERROR { - print("[MPV] End file error: \(String(cString: mpv_error_string(endFile.error)))") + let errorText = String(cString: mpv_error_string(endFile.error)) + self.setPlaybackError("[mpv] \(errorText)") + print("[MPV] End file error: \(errorText)") } } case MPV_EVENT_SHUTDOWN: @@ -421,6 +464,7 @@ final class MPVPlayerViewController: UIViewController { let prefix = String(cString: msg.pointee.prefix!) let level = String(cString: msg.pointee.level!) let text = String(cString: msg.pointee.text!) + self.appendPlaybackLog(prefix: prefix, level: level, text: text) print("[MPV][\(prefix)] \(level): \(text)", terminator: "") } default: