0.2.40
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user