feat: Refactor player control icons to use AppIconResource and add error handling in player bridge

This commit is contained in:
tapframe 2026-03-29 15:12:51 +05:30
parent aac7fc9534
commit 0c4b81599d
14 changed files with 203 additions and 23 deletions

View file

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

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M19,3C20.5976809,3 21.9036609,4.24891996 21.9949073,5.82372721L22,6L22,18C22,19.5976809 20.75108,20.9036609 19.1762728,20.9949073L19,21L5,21C3.40231912,21 2.09633912,19.75108 2.00509269,18.1762728L2,18L2,6C2,4.40231912 3.24891996,3.09633912 4.82372721,3.00509269L5,3L19,3ZM19,5L5,5C4.48716416,5 4.06449284,5.38604019 4.00672773,5.88337887L4,6L4,18C4,18.5128358 4.38604019,18.9355072 4.88337887,18.9932723L5,19L19,19C19.5128358,19 19.9355072,18.6139598 19.9932723,18.1166211L20,18L20,6C20,5.48716416 19.6139598,5.06449284 19.1166211,5.00672773L19,5ZM17,12C17.5128358,12 17.9355072,12.3860402 17.9932723,12.8833789L18,13L18,16C18,16.5128358 17.6139598,16.9355072 17.1166211,16.9932723L17,17L14,17C13.4477153,17 13,16.5522847 13,16C13,15.4871642 13.3860402,15.0644928 13.8833789,15.0067277L14,15L16,15L16,13C16,12.4871642 16.3860402,12.0644928 16.8833789,12.0067277L17,12ZM10,7C10.5522847,7 11,7.44771525 11,8C11,8.51283584 10.6139598,8.93550716 10.1166211,8.99327227L10,9L8,9L8,11C8,11.5128358 7.61395981,11.9355072 7.11662113,11.9932723L7,12C6.48716416,12 6.06449284,11.6139598 6.00672773,11.1166211L6,11L6,8C6,7.48716416 6.38604019,7.06449284 6.88337887,7.00672773L7,7L10,7Z" />
</vector>

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF1C274C"
android:pathData="M12,6.25C11.3096,6.25 10.75,6.80964 10.75,7.5C10.75,8.19036 11.3096,8.75 12,8.75C12.6904,8.75 13.25,8.19036 13.25,7.5C13.25,6.80964 12.6904,6.25 12,6.25Z" />
<path
android:fillColor="#FF1C274C"
android:pathData="M9.75,15.5C9.75,14.2574 10.7574,13.25 12,13.25C13.2426,13.25 14.25,14.2574 14.25,15.5C14.25,16.7426 13.2426,17.75 12,17.75C10.7574,17.75 9.75,16.7426 9.75,15.5Z" />
<path
android:fillColor="#FF1C274C"
android:fillType="evenOdd"
android:pathData="M4,10C4,6.22876 4,4.34315 5.17157,3.17157C6.34315,2 8.22876,2 12,2C15.7712,2 17.6569,2 18.8284,3.17157C20,4.34315 20,6.22876 20,10V14C20,17.7712 20,19.6569 18.8284,20.8284C17.6569,22 15.7712,22 12,22C8.22876,22 6.34315,22 5.17157,20.8284C4,19.6569 4,17.7712 4,14V10ZM9.25,7.5C9.25,5.98122 10.4812,4.75 12,4.75C13.5188,4.75 14.75,5.98122 14.75,7.5C14.75,9.01878 13.5188,10.25 12,10.25C10.4812,10.25 9.25,9.01878 9.25,7.5ZM12,11.75C9.92893,11.75 8.25,13.4289 8.25,15.5C8.25,17.5711 9.92893,19.25 12,19.25C14.0711,19.25 15.75,17.5711 15.75,15.5C15.75,13.4289 14.0711,11.75 12,11.75Z" />
</vector>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF1C274C"
android:pathData="M2,6C2,4.11438 2,3.17157 2.58579,2.58579C3.17157,2 4.11438,2 6,2C7.88562,2 8.82843,2 9.41421,2.58579C10,3.17157 10,4.11438 10,6V18C10,19.8856 10,20.8284 9.41421,21.4142C8.82843,22 7.88562,22 6,22C4.11438,22 3.17157,22 2.58579,21.4142C2,20.8284 2,19.8856 2,18V6Z" />
<path
android:fillColor="#FF1C274C"
android:pathData="M14,6C14,4.11438 14,3.17157 14.5858,2.58579C15.1716,2 16.1144,2 18,2C19.8856,2 20.8284,2 21.4142,2.58579C22,3.17157 22,4.11438 22,6V18C22,19.8856 22,20.8284 21.4142,21.4142C20.8284,22 19.8856,22 18,22C16.1144,22 15.1716,22 14.5858,21.4142C14,20.8284 14,19.8856 14,18V6Z" />
</vector>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF1C274C"
android:pathData="M21.4086,9.35258C23.5305,10.5065 23.5305,13.4935 21.4086,14.6474L8.59662,21.6145C6.53435,22.736 4,21.2763 4,18.9671L4,5.0329C4,2.72368 6.53435,1.26402 8.59661,2.38548L21.4086,9.35258Z" />
</vector>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#00000000"
android:pathData="M1,12C1,5 2.5,4 12,4C21.5,4 23,5 23,12C23,19 21.5,20 12,20C2.5,20 1,19 1,12ZM5.25,14C5.25,15.5 6,16 7.75,16C9.5,16 10.25,15.5 10.25,14L9.97861679,14C9.97861671,15.25 8.97905547,16 7.75,16C6.52094453,16 5.52138329,15.25 5.52138321,14L5.52138321,10C5.5,8.75 6.5,8 7.75,8C9,8 10,8.75 9.97861679,10L10.25,10C10.25,8.75 9.2286998,8 7.75,8C6.2713002,8 5.25,8.75 5.25,10L5.25,14ZM13.25,14C13.25,15.5 14,16 15.75,16C17.5,16 18.25,15.5 18.25,14L17.9786168,14C17.9786167,15.25 16.9790555,16 15.75,16C14.5209445,16 13.5213833,15.25 13.5213832,14L13.5213832,10C13.5,8.75 14.5,8 15.75,8C17,8 18,8.75 17.9786168,10L18.25,10C18.25,8.75 17.2286998,8 15.75,8C14.2713002,8 13.25,8.75 13.25,10L13.25,14Z"
android:strokeColor="#FF000000"
android:strokeWidth="2" />
</vector>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#00000000"
android:pathData="M4,12H20M12,4V20"
android:strokeColor="#FF000000"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="2" />
</vector>

View file

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

View file

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

View file

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

View file

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

View file

@ -40,6 +40,7 @@ interface NuvioPlayerBridge {
fun getPositionMs(): Long
fun getBufferedMs(): Long
fun getPlaybackSpeed(): Float
fun getErrorMessage(): String
fun destroy()
}

View file

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

View file

@ -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<mpv_event_end_file>(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: