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: