This commit is contained in:
ponzischeme89
2026-08-10 08:37:08 +12:00
parent d2f2eb62be
commit 78d26effbf
18 changed files with 592 additions and 44 deletions
+10
View File
@@ -104,6 +104,16 @@
android:theme="@style/Theme.Memby.Fullscreen"
tools:ignore="DiscouragedApi" />
<!-- libmpv is a last-resort codec/container fallback. It receives an already
resolved stream only after Media3 recovery has been exhausted. -->
<activity
android:name=".ui.player.MpvFallbackActivity"
android:exported="false"
android:screenOrientation="landscape"
android:configChanges="keyboard|keyboardHidden|navigation|screenSize|orientation"
android:theme="@style/Theme.Memby.Fullscreen"
tools:ignore="DiscouragedApi" />
<!-- The system screensaver (Daydream / Ambient mode source).
Interactive: select to open the panel, play, or favourite. -->
<service
@@ -288,9 +288,11 @@ fun episodesForSeason(episodes: List<BaseItem>, season: Int?): List<BaseItem> =
else episodes.filter { it.parentIndexNumber == season }.sortedWith(seriesEpisodeComparator)
/**
* What the viewer should watch next: whatever is part-watched, else the first unwatched
* episode. Specials are skipped while any numbered season exists — season 0 sorts first
* but is almost never where someone is up to.
* What the viewer should watch next, based on the furthest episode they have reached.
* An old resume marker behind later viewing must not pull them backwards — this matters
* when a schedule card opens a show whose early episode still has a stale position.
* Specials are skipped while any numbered season exists because season 0 sorts first but
* is almost never where someone is up to.
*/
fun nextEpisodeToWatch(episodes: List<BaseItem>): BaseItem? {
if (episodes.isEmpty()) return null
@@ -301,8 +303,12 @@ fun nextEpisodeToWatch(episodes: List<BaseItem>): BaseItem? {
} else {
ordered
}
return candidates.firstOrNull { it.isResumable && !it.isPlayed }
?: candidates.firstOrNull { !it.isPlayed }
val furthestProgress = candidates.indexOfLast { it.isPlayed || it.isResumable }
if (furthestProgress < 0) return candidates.firstOrNull { !it.isPlayed }
val current = candidates[furthestProgress]
return current.takeIf { it.isResumable && !it.isPlayed }
?: candidates.drop(furthestProgress + 1).firstOrNull { !it.isPlayed }
}
/** The season to open on: the one holding [nextEpisodeToWatch], else the earliest. */
@@ -0,0 +1,327 @@
package com.ponzischeme89.memby.ui.player
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import android.util.Log
import android.util.TypedValue
import android.view.Gravity
import android.view.KeyEvent
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.TextView
import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.PlayableSubtitle
import com.ponzischeme89.memby.data.PlaybackSession
import `is`.xyz.mpv.BaseMPVView
import `is`.xyz.mpv.MPV
import `is`.xyz.mpv.MPVNode
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlin.math.roundToLong
/**
* A deliberately small libmpv safety net for a stream Media3 could not decode or demux.
* Media3 remains Memby's normal Android TV player; this activity owns only the final
* compatibility hand-off and the Emby check-ins needed to keep resume state accurate.
*/
class MpvFallbackActivity : ComponentActivity(), MPV.EventObserver {
private lateinit var mpv: MPV
private lateinit var mpvView: BaseMPVView
private lateinit var controls: TextView
private var progressJob: Job? = null
private var positionMs = 0L
private var durationMs = 0L
private var loaded = false
private var stopped = false
private var controlsVisible = true
private val itemId by lazy { intent.getStringExtra(EXTRA_ITEM_ID).orEmpty() }
private val session by lazy {
PlaybackSession(
itemId = itemId,
mediaSourceId = intent.getStringExtra(EXTRA_MEDIA_SOURCE_ID).orEmpty().ifBlank { itemId },
playSessionId = intent.getStringExtra(EXTRA_PLAY_SESSION_ID).orEmpty(),
playMethod = intent.getStringExtra(EXTRA_PLAY_METHOD).orEmpty().ifBlank { "DirectPlay" },
)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val url = intent.getStringExtra(EXTRA_URL).orEmpty()
if (itemId.isBlank() || url.isBlank()) {
finish()
return
}
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility =
View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
val root = FrameLayout(this).apply { setBackgroundColor(Color.BLACK) }
mpvView = object : BaseMPVView(this@MpvFallbackActivity, null) {
override fun initOptions() {
// auto-safe uses hardware where mpv trusts it and falls back to software
// when the same vendor decoder that failed Media3 is not viable.
mpv.setOptionString("hwdec", "auto-safe")
mpv.setOptionString("video-sync", "audio")
}
override fun postInitOptions() {
mpv.setPropertyBoolean("pause", true)
}
override fun observeProperties() {
mpv.observeProperty("time-pos", MPV.mpvFormat.MPV_FORMAT_DOUBLE)
mpv.observeProperty("duration", MPV.mpvFormat.MPV_FORMAT_DOUBLE)
mpv.observeProperty("pause", MPV.mpvFormat.MPV_FORMAT_FLAG)
}
}
root.addView(
mpvView,
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
),
)
controls = TextView(this).apply {
setTextColor(Color.WHITE)
setBackgroundColor(Color.argb(185, 8, 10, 12))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
gravity = Gravity.CENTER
setPadding(dp(24), dp(14), dp(24), dp(14))
text = controlText(paused = true)
}
root.addView(
controls,
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL,
).apply { bottomMargin = dp(32) },
)
setContentView(root)
runCatching {
mpvView.initialize(
filesDir.resolve("mpv").path,
cacheDir.resolve("mpv").path,
)
mpv = mpvView.mpv
mpv.addObserver(this)
mpvView.setVo("gpu")
mpvView.playFile(url)
}.onFailure { error ->
Log.e(TAG, "event=libmpv_init_failed item=$itemId", error)
finish()
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean = when (keyCode) {
KeyEvent.KEYCODE_DPAD_CENTER,
KeyEvent.KEYCODE_ENTER,
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
-> {
togglePause()
true
}
KeyEvent.KEYCODE_DPAD_LEFT,
KeyEvent.KEYCODE_MEDIA_REWIND,
-> {
seekBy(-SEEK_SECONDS)
true
}
KeyEvent.KEYCODE_DPAD_RIGHT,
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD,
-> {
seekBy(SEEK_SECONDS)
true
}
KeyEvent.KEYCODE_MENU -> {
controlsVisible = !controlsVisible
controls.visibility = if (controlsVisible) View.VISIBLE else View.GONE
true
}
else -> super.onKeyDown(keyCode, event)
}
private fun togglePause() {
val paused = mpv.getPropertyBoolean("pause") != false
mpv.setPropertyBoolean("pause", !paused)
controls.visibility = View.VISIBLE
controlsVisible = true
controls.text = controlText(paused = !paused)
reportProgress(if (!paused) "Pause" else "Unpause", isPaused = !paused)
}
private fun seekBy(seconds: Int) {
mpv.command("seek", seconds.toString(), "relative+exact")
controls.visibility = View.VISIBLE
controlsVisible = true
controls.text = controlText(mpv.getPropertyBoolean("pause") != false)
reportProgress("TimeUpdate", mpv.getPropertyBoolean("pause") != false)
}
private fun controlText(paused: Boolean): String {
val title = intent.getStringExtra(EXTRA_TITLE).orEmpty().ifBlank { "Compatibility playback" }
val state = if (paused) "Paused" else "Playing with libmpv"
return "$title\n$state · Left/Right seek · OK pause"
}
private fun onFileLoaded() {
if (loaded) return
loaded = true
positionMs = intent.getLongExtra(EXTRA_POSITION_MS, 0L).coerceAtLeast(0L)
if (positionMs > 0L) mpv.setPropertyDouble("time-pos", positionMs / 1_000.0)
attachSelectedSubtitle()
mpv.setPropertyBoolean("pause", false)
controls.text = controlText(paused = false)
lifecycleScope.launch {
runCatching { ServiceLocator.repository.reportPlaybackStarted(session, positionMs) }
}
progressJob = lifecycleScope.launch {
while (isActive) {
delay(PROGRESS_INTERVAL_MS)
reportProgress("TimeUpdate", mpv.getPropertyBoolean("pause") != false)
}
}
}
private fun attachSelectedSubtitle() {
val selectedId = intent.getStringExtra(EXTRA_SELECTED_SUBTITLE_ID).orEmpty()
if (selectedId.isBlank()) return
val subtitle = decodeSubtitles(intent.getStringExtra(EXTRA_SUBTITLES))
.firstOrNull { it.id == selectedId && it.deliveryMethod.equals("External", true) }
?: return
val title = subtitle.label?.takeIf(String::isNotBlank) ?: subtitle.language.orEmpty()
mpv.command("sub-add", subtitle.url, "select", title, subtitle.language.orEmpty())
}
private fun reportProgress(eventName: String, isPaused: Boolean) {
if (!loaded) return
lifecycleScope.launch {
runCatching {
ServiceLocator.repository.reportPlaybackProgress(
session = session,
positionMs = positionMs,
isPaused = isPaused,
eventName = eventName,
durationMs = durationMs,
)
}
}
}
override fun onStop() {
if (loaded && !stopped) {
stopped = true
reportProgress("Pause", isPaused = true)
PlaybackStopWorker.enqueue(this, session, positionMs)
}
if (::mpv.isInitialized) mpv.setPropertyBoolean("pause", true)
super.onStop()
}
override fun onStart() {
super.onStart()
if (loaded && stopped) {
stopped = false
mpv.setPropertyBoolean("pause", false)
lifecycleScope.launch {
runCatching { ServiceLocator.repository.reportPlaybackStarted(session, positionMs) }
}
}
}
override fun onDestroy() {
progressJob?.cancel()
if (::mpv.isInitialized) {
runCatching { mpv.removeObserver(this) }
runCatching { mpvView.destroy() }
}
super.onDestroy()
}
override fun eventProperty(property: String) = Unit
override fun eventProperty(property: String, value: Long) = Unit
override fun eventProperty(property: String, value: Boolean) {
if (property == "pause") runOnUiThread { controls.text = controlText(value) }
}
override fun eventProperty(property: String, value: String) = Unit
override fun eventProperty(property: String, value: Double) {
when (property) {
"time-pos" -> positionMs = (value * 1_000.0).roundToLong().coerceAtLeast(0L)
"duration" -> durationMs = (value * 1_000.0).roundToLong().coerceAtLeast(0L)
}
}
override fun eventProperty(property: String, value: MPVNode) = Unit
override fun event(eventId: Int, data: MPVNode) {
when (eventId) {
MPV.mpvEvent.MPV_EVENT_FILE_LOADED -> runOnUiThread(::onFileLoaded)
MPV.mpvEvent.MPV_EVENT_END_FILE -> if (loaded) runOnUiThread(::finish)
}
}
private fun dp(value: Int): Int =
TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
value.toFloat(),
resources.displayMetrics,
).roundToLong().toInt()
companion object {
private const val TAG = "MembyPlayback"
private const val PROGRESS_INTERVAL_MS = 10_000L
private const val SEEK_SECONDS = 10
private const val EXTRA_ITEM_ID = "mpv_item_id"
private const val EXTRA_TITLE = "mpv_title"
private const val EXTRA_URL = "mpv_url"
private const val EXTRA_POSITION_MS = "mpv_position_ms"
private const val EXTRA_SUBTITLES = "mpv_subtitles"
private const val EXTRA_SELECTED_SUBTITLE_ID = "mpv_selected_subtitle_id"
private const val EXTRA_MEDIA_SOURCE_ID = "mpv_media_source_id"
private const val EXTRA_PLAY_SESSION_ID = "mpv_play_session_id"
private const val EXTRA_PLAY_METHOD = "mpv_play_method"
private val json = Json { ignoreUnknownKeys = true }
fun intent(
context: Context,
itemId: String,
title: String,
url: String,
positionMs: Long,
subtitles: List<PlayableSubtitle>,
selectedSubtitleId: String,
mediaSourceId: String,
playSessionId: String,
playMethod: String,
): Intent = Intent(context, MpvFallbackActivity::class.java).apply {
putExtra(EXTRA_ITEM_ID, itemId)
putExtra(EXTRA_TITLE, title)
putExtra(EXTRA_URL, url)
putExtra(EXTRA_POSITION_MS, positionMs.coerceAtLeast(0L))
if (subtitles.isNotEmpty()) putExtra(EXTRA_SUBTITLES, json.encodeToString(subtitles))
putExtra(EXTRA_SELECTED_SUBTITLE_ID, selectedSubtitleId)
putExtra(EXTRA_MEDIA_SOURCE_ID, mediaSourceId)
putExtra(EXTRA_PLAY_SESSION_ID, playSessionId)
putExtra(EXTRA_PLAY_METHOD, playMethod)
}
private fun decodeSubtitles(encoded: String?): List<PlayableSubtitle> =
encoded?.let {
runCatching { json.decodeFromString<List<PlayableSubtitle>>(it) }.getOrNull()
}.orEmpty()
}
}
@@ -88,6 +88,14 @@ internal fun automaticRetryDelayMs(attempt: Int): Long? =
else -> null
}
/**
* libmpv is the codec/container safety net, not a second network retry. Media3 first gets
* both automatic recovery attempts, including its lower-risk H.264 stream; only a local
* format failure that survives those attempts is handed over.
*/
internal fun shouldUseLibmpvFallback(failure: PlaybackFailure, completedAttempts: Int): Boolean =
failure.requiresTranscode && automaticRetryDelayMs(completedAttempts + 1) == null
/** One prolonged mid-programme rebuffer gets a lower-risk H.264 stream. */
internal fun shouldRecoverProlongedRebuffer(
renderedFirstFrame: Boolean,
@@ -142,6 +142,7 @@ class PlayerActivity : ComponentActivity() {
private var prolongedRebufferRecoveryAttempted = false
private var automaticRetryAttempt = 0
private var renderedFirstFrame = false
private var handingOffToLibmpv = false
private var requestStartedAtMs = 0L
private var trace = PlaybackTrace(SystemClock.elapsedRealtime(), SystemClock::elapsedRealtime)
@@ -1234,9 +1235,41 @@ class PlayerActivity : ComponentActivity() {
return
}
if (shouldUseLibmpvFallback(failure, automaticRetryAttempt) && handOffToLibmpv()) return
showPlaybackError(failure)
}
/** Continue the same Emby session in libmpv without a second pre-roll or stop report. */
private fun handOffToLibmpv(): Boolean {
val playback = player ?: return false
val id = itemId?.takeIf(String::isNotBlank) ?: return false
val url = playback.currentMediaItem?.localConfiguration?.uri?.toString()
?.takeIf(String::isNotBlank) ?: return false
val positionMs = playback.currentPosition.coerceAtLeast(0L)
handingOffToLibmpv = true
Log.w(
PLAYBACK_LOG_TAG,
"event=libmpv_fallback item=$id positionMs=$positionMs playMethod=$playMethod",
)
startActivity(
MpvFallbackActivity.intent(
context = this,
itemId = id,
title = playbackTitle,
url = url,
positionMs = positionMs,
subtitles = availableSubtitles,
selectedSubtitleId = serverSubtitleId,
mediaSourceId = mediaSourceId,
playSessionId = playSessionId,
playMethod = playMethod,
),
)
playback.pause()
finish()
return true
}
private fun retryPlayback(refreshSource: Boolean, forceTranscode: Boolean = false) {
retryJob?.cancel()
prolongedRebufferJob?.cancel()
@@ -3738,7 +3771,7 @@ class PlayerActivity : ComponentActivity() {
// this title resumes from — is the one the viewer had already skipped past.
commitSeek()
player?.let {
if (playbackStarted && !stopReported) {
if (!handingOffToLibmpv && playbackStarted && !stopReported) {
reportProgress(it.currentPosition, isPaused = true, eventName = "Pause")
stopReported = true
stoppedInBackground = true
@@ -3788,7 +3821,7 @@ class PlayerActivity : ComponentActivity() {
"bufferingMs=$totalBufferingMs positionMs=${player?.currentPosition ?: 0L}",
)
val playback = player
if (!stopReported && playbackStarted && !itemId.isNullOrBlank()) {
if (!handingOffToLibmpv && !stopReported && playbackStarted && !itemId.isNullOrBlank()) {
stopReported = true
PlaybackStopWorker.enqueue(
this,
@@ -5,6 +5,8 @@ import com.ponzischeme89.memby.data.model.UserItemData
import com.ponzischeme89.memby.ui.detail.availableSeasons
import com.ponzischeme89.memby.ui.detail.defaultSeason
import com.ponzischeme89.memby.ui.detail.episodesForSeason
import com.ponzischeme89.memby.ui.detail.nextEpisodeToWatch
import com.ponzischeme89.memby.ui.detail.primaryActionLabel
import com.ponzischeme89.memby.ui.detail.seasonLabel
import org.junit.Assert.assertEquals
import org.junit.Test
@@ -15,13 +17,14 @@ class SeriesDetailsTest {
season: Int,
number: Int,
played: Boolean = false,
resumeTicks: Long = 0,
) = BaseItem(
id = id,
name = "Episode $number",
type = "Episode",
parentIndexNumber = season,
indexNumber = number,
userData = UserItemData(played = played),
userData = UserItemData(played = played, playbackPositionTicks = resumeTicks),
)
@Test
@@ -58,6 +61,31 @@ class SeriesDetailsTest {
assertEquals(2, defaultSeason(episodes))
}
@Test
fun `an old resume marker does not pull schedule playback behind later progress`() {
val episodes = (1..7).map { number ->
episode(
id = "s3e$number",
season = 3,
number = number,
played = number in 2..6,
resumeTicks = when (number) {
1 -> 60_000_000L
7 -> 120_000_000L
else -> 0L
},
)
}
val next = requireNotNull(nextEpisodeToWatch(episodes))
assertEquals("s3e7", next.id)
assertEquals("Resume S03E07", primaryActionLabel(
BaseItem(id = "series", name = "Series", type = "Series"),
next,
))
}
@Test
fun `season zero is labelled specials`() {
assertEquals("Specials", seasonLabel(0))
@@ -8,6 +8,27 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class PlaybackRecoveryTest {
@Test
fun `libmpv takes codec failures only after Media3 recovery is exhausted`() {
val codecFailure = describePlaybackFailure(
PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED,
)
assertFalse(shouldUseLibmpvFallback(codecFailure, completedAttempts = 0))
assertFalse(shouldUseLibmpvFallback(codecFailure, completedAttempts = 1))
assertTrue(shouldUseLibmpvFallback(codecFailure, completedAttempts = 2))
}
@Test
fun `libmpv does not replace Media3 network recovery`() {
val networkFailure = describePlaybackFailure(
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
)
assertFalse(shouldUseLibmpvFallback(networkFailure, completedAttempts = 2))
}
@Test
fun networkFailuresAreSafeToRetry() {
val failure = describePlaybackFailure(