0.2.75
This commit is contained in:
+21
-10
@@ -8,6 +8,10 @@ plugins {
|
||||
id("androidx.baselineprofile")
|
||||
}
|
||||
|
||||
// One Media3 version for every artifact in the build — see the dependency block for why
|
||||
// the Jellyfin FFmpeg extension pins which line that can be.
|
||||
val media3Version = "1.9.4"
|
||||
|
||||
// Set in gradle.properties (or ~/.gradle/gradle.properties, or -Pmemby.serverUrl=...).
|
||||
// Blank means "no hardwired server": the setup screen asks the user for an address.
|
||||
val embyServerUrl: String = (project.findProperty("memby.serverUrl") as String?).orEmpty().trim()
|
||||
@@ -46,7 +50,7 @@ val projectNoticeText =
|
||||
|
||||
// A release workflow can derive the app version from its Git tag without editing the
|
||||
// source tree. Local builds keep using the checked-in default.
|
||||
val defaultVersionName = "0.2.74"
|
||||
val defaultVersionName = "0.2.75"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
@@ -235,7 +239,7 @@ dependencies {
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
|
||||
// Compose for TV
|
||||
implementation("androidx.tv:tv-material:1.0.0")
|
||||
implementation("androidx.tv:tv-material:1.1.0")
|
||||
|
||||
// Image loading
|
||||
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||
@@ -248,20 +252,27 @@ dependencies {
|
||||
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
|
||||
|
||||
// DataStore (persisted settings)
|
||||
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
||||
implementation("androidx.datastore:datastore-preferences:1.2.1")
|
||||
|
||||
// Media3 / ExoPlayer for in-app playback
|
||||
implementation("androidx.media3:media3-exoplayer:1.5.1")
|
||||
implementation("androidx.media3:media3-exoplayer-hls:1.5.1")
|
||||
implementation("androidx.media3:media3-ui:1.5.1")
|
||||
// One version for the whole Media3 line, including the Jellyfin FFmpeg extension below.
|
||||
// That extension is compiled against media3-exoplayer and reached reflectively through
|
||||
// EXTENSION_RENDERER_MODE_ON, so a core built from a different minor line fails at
|
||||
// renderer construction rather than at compile time — and PlayerEngine's LinkageError
|
||||
// fallback would swallow it, silently withdrawing surround software decode with nothing
|
||||
// in the log to say why. Jellyfin publishes up to the 1.9 line, so that is the line this
|
||||
// app is on; moving the core past it means finding a matching extension first.
|
||||
implementation("androidx.media3:media3-exoplayer:$media3Version")
|
||||
implementation("androidx.media3:media3-exoplayer-hls:$media3Version")
|
||||
implementation("androidx.media3:media3-ui:$media3Version")
|
||||
// Lets the player pull its bytes through the app's one OkHttp stack instead of
|
||||
// media3's own HttpURLConnection client — see ui/player/PlayerEngine.kt.
|
||||
implementation("androidx.media3:media3-datasource-okhttp:1.5.1")
|
||||
implementation("androidx.media3:media3-datasource-okhttp:$media3Version")
|
||||
|
||||
// Moonfin's Android TV backend keeps a software audio renderer behind Media3 so a
|
||||
// surround track that is not bitstreamed is decoded to PCM instead of making the
|
||||
// server re-encode the video beside it. This build targets Media3 1.5.x; Jellyfin's
|
||||
// 1.5.0 extension is the matching published binary for that line.
|
||||
// server re-encode the video beside it. This build targets Media3 1.9.x; Jellyfin's
|
||||
// 1.9.0 extension is the matching published binary for that line.
|
||||
//
|
||||
// This is the ONLY native dependency the app carries, and it is 1.5MB per ABI because
|
||||
// it links just the audio decoders it needs. It replaced a libmpv software-decoding
|
||||
@@ -270,7 +281,7 @@ dependencies {
|
||||
// the release APK from 3.1MB to 168MB and made every sideload a several-minute
|
||||
// affair. A last-resort video path is not worth fifty times the app. If one is wanted
|
||||
// again, it belongs behind a separately downloaded split, not in the base APK.
|
||||
implementation("org.jellyfin.media3:media3-ffmpeg-decoder:1.5.0+1")
|
||||
implementation("org.jellyfin.media3:media3-ffmpeg-decoder:1.9.0+1")
|
||||
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ class JourneyAnalytics(
|
||||
private val userId: String,
|
||||
private val now: () -> Long = System::currentTimeMillis,
|
||||
private val journeyId: String = UUID.randomUUID().toString(),
|
||||
) {
|
||||
) : JourneySink {
|
||||
private val lock = Any()
|
||||
private val buffer = ArrayList<GatewayJourneyEvent>()
|
||||
private var sequence = 0
|
||||
@@ -27,16 +27,18 @@ class JourneyAnalytics(
|
||||
// a process/session contribute exactly one home opening.
|
||||
init { track("session", "home_open", screen = "home", feature = "app") }
|
||||
|
||||
fun track(
|
||||
override fun track(
|
||||
category: String,
|
||||
action: String,
|
||||
screen: String = "",
|
||||
feature: String = "",
|
||||
source: String = "",
|
||||
target: String = "",
|
||||
itemName: String = "",
|
||||
itemType: String = "",
|
||||
outcome: String = "",
|
||||
screen: String,
|
||||
feature: String,
|
||||
source: String,
|
||||
target: String,
|
||||
itemId: String,
|
||||
itemName: String,
|
||||
itemType: String,
|
||||
playSessionId: String,
|
||||
outcome: String,
|
||||
) = synchronized(lock) {
|
||||
if (ended) return@synchronized
|
||||
buffer += GatewayJourneyEvent(
|
||||
@@ -49,12 +51,17 @@ class JourneyAnalytics(
|
||||
feature = clean(feature),
|
||||
source = clean(source),
|
||||
target = clean(target),
|
||||
itemId = clean(itemId),
|
||||
itemName = cleanName(itemName),
|
||||
itemType = clean(itemType),
|
||||
// Cleaned like every other controlled field rather than trusted: a play session
|
||||
// id is Emby's string, not ours, and the gateway rejects a whole event whose
|
||||
// fields it cannot read. A session id is worth less than the step it describes.
|
||||
playSessionId = clean(playSessionId),
|
||||
outcome = clean(outcome),
|
||||
occurredAt = timestamp(),
|
||||
)
|
||||
if (buffer.size > 200) buffer.removeAt(0)
|
||||
if (buffer.size > MAX_BUFFERED_EVENTS) buffer.removeAt(0)
|
||||
}
|
||||
|
||||
fun end(screen: String) = synchronized(lock) {
|
||||
@@ -77,6 +84,14 @@ class JourneyAnalytics(
|
||||
private fun timestamp(): String = iso8601.get()!!.format(Date(now()))
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The buffer is drained on a timer, but not while a viewer is in the player — see
|
||||
* [com.ponzischeme89.memby.ui.HomeViewModel.pauseAnalyticsForPlayback]. A Magic press
|
||||
* can chain several films inside that pause, so this holds comfortably more than one
|
||||
* playback's worth of steps and still drops the oldest rather than the newest.
|
||||
*/
|
||||
private const val MAX_BUFFERED_EVENTS = 200
|
||||
|
||||
private val iso8601 = object : ThreadLocal<SimpleDateFormat>() {
|
||||
override fun initialValue() = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.ponzischeme89.memby.data.analytics
|
||||
|
||||
/**
|
||||
* Somewhere a journey step can be written down.
|
||||
*
|
||||
* It exists so that [PlaybackJourney]'s wording can be exercised against a real
|
||||
* [JourneyAnalytics] in a unit test, and so that a caller which cannot see the collector —
|
||||
* `PlayerActivity`, which runs in its own activity and has no view model — can write into
|
||||
* the process-wide [JourneyTracker] through the same shape.
|
||||
*
|
||||
* Implementations must never block: this is called from the playback critical path.
|
||||
*/
|
||||
interface JourneySink {
|
||||
fun track(
|
||||
category: String,
|
||||
action: String,
|
||||
screen: String = "",
|
||||
feature: String = "",
|
||||
source: String = "",
|
||||
target: String = "",
|
||||
itemId: String = "",
|
||||
itemName: String = "",
|
||||
itemType: String = "",
|
||||
playSessionId: String = "",
|
||||
outcome: String = "",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.ponzischeme89.memby.data.analytics
|
||||
|
||||
import com.ponzischeme89.memby.data.model.GatewayJourneyEvent
|
||||
|
||||
/**
|
||||
* The one journey a process is building, reachable from outside a view model.
|
||||
*
|
||||
* The collector used to be private to `HomeViewModel`, which meant the only thing that could
|
||||
* record a step was the launcher — and playback happens in a second activity. Everything the
|
||||
* player does was therefore invisible: a Magic press starts a whole new film without the
|
||||
* launcher ever hearing about it, and nothing at all said whether a playback the launcher had
|
||||
* requested actually began.
|
||||
*
|
||||
* Held here rather than passed down because `PlayerActivity` has no owner in common with the
|
||||
* launcher, and because it must land in the *same* journey: a viewer who watches three films
|
||||
* in one sitting has had one app session, and starting a fresh journey id per activity would
|
||||
* report it as three.
|
||||
*
|
||||
* Everything on it is a synchronised append to an in-memory list. Nothing here uploads, opens
|
||||
* a connection or touches disk, which is what makes it safe to call from the moment a decoder
|
||||
* is starting. The buffer is drained by `HomeViewModel` once the player has returned.
|
||||
*/
|
||||
object JourneyTracker : JourneySink {
|
||||
@Volatile
|
||||
private var analytics: JourneyAnalytics? = null
|
||||
|
||||
/**
|
||||
* Starts the journey for a signed-in viewer and returns it, so its owner can drain it.
|
||||
* Replacing the previous one is deliberate: a profile switch is a different person, and
|
||||
* their steps must not be filed under the viewer who signed out.
|
||||
*/
|
||||
fun begin(userId: String): JourneyAnalytics =
|
||||
JourneyAnalytics(userId).also { analytics = it }
|
||||
|
||||
override fun track(
|
||||
category: String,
|
||||
action: String,
|
||||
screen: String,
|
||||
feature: String,
|
||||
source: String,
|
||||
target: String,
|
||||
itemId: String,
|
||||
itemName: String,
|
||||
itemType: String,
|
||||
playSessionId: String,
|
||||
outcome: String,
|
||||
) {
|
||||
// Silently nothing when no journey is open — the screensaver can start playback in a
|
||||
// process where nobody has reached the launcher, and telemetry must never be a reason
|
||||
// a television cannot play something.
|
||||
analytics?.track(
|
||||
category = category, action = action, screen = screen, feature = feature,
|
||||
source = source, target = target, itemId = itemId, itemName = itemName,
|
||||
itemType = itemType, playSessionId = playSessionId, outcome = outcome,
|
||||
)
|
||||
}
|
||||
|
||||
fun end(screen: String) { analytics?.end(screen) }
|
||||
|
||||
fun drain(): List<GatewayJourneyEvent> = analytics?.drain().orEmpty()
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.ponzischeme89.memby.data.analytics
|
||||
|
||||
/**
|
||||
* Where a playback was asked for.
|
||||
*
|
||||
* A journey is only worth reading if it says what somebody was *doing* when they pressed
|
||||
* Play, and the launcher's row id is not that: it is an id the gateway happened to send, it
|
||||
* differs between the gateway and the direct path, and a recommendation strip invents a new
|
||||
* one every day. This is the small, closed vocabulary the analytics are grouped by instead —
|
||||
* stated by whatever started the playback, never inferred downstream, because by the time an
|
||||
* event reaches the gateway the surface it came from is gone.
|
||||
*
|
||||
* [id] is the wire value and must stay stable: it is what the console groups on, so renaming
|
||||
* one silently splits a household's history in two.
|
||||
*/
|
||||
enum class PlaybackEntryPoint(val id: String) {
|
||||
CONTINUE_WATCHING("continue_watching"),
|
||||
MAGIC_MOVIE("magic_movie"),
|
||||
|
||||
/** The player advancing to the episode after this one, by itself or by a press. */
|
||||
NEXT_EPISODE("next_episode"),
|
||||
HOME_HERO("home_hero"),
|
||||
SEARCH("search"),
|
||||
GENRE_BROWSER("genre_browser"),
|
||||
CALENDAR("calendar"),
|
||||
FAVOURITES("favourites"),
|
||||
RECOMMENDATION("recommendation"),
|
||||
|
||||
/**
|
||||
* A detail page reached by some route that did not name itself — the "More like this"
|
||||
* trail, a schedule card's series page, a page restored after the player returned.
|
||||
*/
|
||||
DETAIL_PAGE("detail_page"),
|
||||
SCREENSAVER("screensaver"),
|
||||
UNKNOWN("unknown"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Reads an entry point back off the wire. Unknown rather than null, because an id
|
||||
* this build does not recognise is a television talking to a newer one, and a
|
||||
* playback that cannot name where it came from is still a playback.
|
||||
*/
|
||||
fun fromId(id: String?): PlaybackEntryPoint =
|
||||
entries.firstOrNull { it.id == id } ?: UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The entry point a launcher row stands for.
|
||||
*
|
||||
* Kind is asked first and id second, deliberately. The kind is the app's own classification
|
||||
* and is the same on both paths, where the id is the gateway's — Continue Watching is
|
||||
* `continue` today and a household running an older gateway still sends `nextup`, which is
|
||||
* the same shelf as far as a viewer is concerned. The id is only consulted for the three
|
||||
* panes that are not rows at all.
|
||||
*
|
||||
* Pure so it can be tested: this is the function that decides whether a resume is filed
|
||||
* under Continue Watching or lost in the general pile.
|
||||
*/
|
||||
fun playbackEntryPointFor(rowId: String?, rowKind: String? = null): PlaybackEntryPoint {
|
||||
when (rowKind?.lowercase()) {
|
||||
"continue", "nextup", "next_up" -> return PlaybackEntryPoint.CONTINUE_WATCHING
|
||||
"favorites", "favourites" -> return PlaybackEntryPoint.FAVOURITES
|
||||
}
|
||||
return when (val id = rowId?.trim()?.lowercase().orEmpty()) {
|
||||
"" -> PlaybackEntryPoint.DETAIL_PAGE
|
||||
"continue", "nextup", "next-up" -> PlaybackEntryPoint.CONTINUE_WATCHING
|
||||
"home-movie-hero" -> PlaybackEntryPoint.HOME_HERO
|
||||
"search-results" -> PlaybackEntryPoint.SEARCH
|
||||
"genre-browser-results" -> PlaybackEntryPoint.GENRE_BROWSER
|
||||
"favorites" -> PlaybackEntryPoint.FAVOURITES
|
||||
else -> when {
|
||||
// Server-composed shelves: "for-you:pick-up", "because-you-watched:123",
|
||||
// "highly-engaged". They are recommendations however they are named, and their
|
||||
// ids are generated, so matching them individually would be a losing game.
|
||||
id.startsWith("for-you") || id.startsWith("because-you-watched") ||
|
||||
id.startsWith("recommend") || id.startsWith("highly-engaged") ->
|
||||
PlaybackEntryPoint.RECOMMENDATION
|
||||
id.startsWith("continue") -> PlaybackEntryPoint.CONTINUE_WATCHING
|
||||
else -> PlaybackEntryPoint.DETAIL_PAGE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.ponzischeme89.memby.data.analytics
|
||||
|
||||
/**
|
||||
* What a playback writes into the journey, in one place.
|
||||
*
|
||||
* It is one object rather than a handful of `trackJourney` calls spread across the launcher
|
||||
* and the player because the two surfaces have to agree exactly. The console splits a
|
||||
* viewer's session into separate viewing journeys at `playback`/`request`, groups them by the
|
||||
* entry point in `source`, and decides the outcome from `playback`/`start`; a caller that got
|
||||
* one of those three words slightly wrong would not fail, it would quietly produce a journey
|
||||
* that reads as somebody jumping straight into a film from nowhere.
|
||||
*
|
||||
* Every category, action and outcome used here is inside the gateway's allowlist in
|
||||
* `internal/api/analytics.go`. An event using a word that is not on it is dropped on arrival
|
||||
* with nothing said, so adding one means adding it there too.
|
||||
*/
|
||||
object PlaybackJourney {
|
||||
private const val CATEGORY = "playback"
|
||||
private const val RECOMMENDATIONS = "recommendations"
|
||||
private const val FEATURE = "playback"
|
||||
private const val PLAYER = "player"
|
||||
|
||||
/** The feature name Magic's own steps are grouped under in the console. */
|
||||
const val MAGIC_FEATURE = "magic_movie"
|
||||
|
||||
/**
|
||||
* Somebody asked for this to play.
|
||||
*
|
||||
* The console cuts a session into viewing journeys here, so this is the step that must
|
||||
* exist for *every* route into the player — including the two that used to skip it
|
||||
* entirely — and [entryPoint] is the whole reason the resulting journey can say where it
|
||||
* began.
|
||||
*/
|
||||
fun requested(
|
||||
sink: JourneySink,
|
||||
entryPoint: PlaybackEntryPoint,
|
||||
screen: String,
|
||||
itemId: String,
|
||||
itemName: String,
|
||||
itemType: String,
|
||||
) = sink.track(
|
||||
category = CATEGORY, action = "request", screen = screen, feature = FEATURE,
|
||||
source = entryPoint.id, target = PLAYER,
|
||||
itemId = itemId, itemName = itemName, itemType = itemType,
|
||||
)
|
||||
|
||||
/**
|
||||
* A picture is up: the negotiation, the launch and the decoder all worked.
|
||||
*
|
||||
* Recorded from the player rather than from whatever started it, because only the player
|
||||
* knows whether anything actually happened — and it carries [playSessionId], which is
|
||||
* what ties this step to the stream Emby recorded on the other side.
|
||||
*/
|
||||
fun started(
|
||||
sink: JourneySink,
|
||||
entryPoint: PlaybackEntryPoint,
|
||||
itemId: String,
|
||||
itemName: String,
|
||||
itemType: String,
|
||||
playSessionId: String,
|
||||
) = sink.track(
|
||||
category = CATEGORY, action = "start", screen = PLAYER, feature = FEATURE,
|
||||
source = entryPoint.id, target = PLAYER,
|
||||
itemId = itemId, itemName = itemName, itemType = itemType,
|
||||
playSessionId = playSessionId, outcome = "success",
|
||||
)
|
||||
|
||||
/**
|
||||
* It did not start, or it stopped being able to play.
|
||||
*
|
||||
* The same action as [started] wearing the other outcome, so "how often does playback from
|
||||
* Continue Watching work" is one grouping rather than a comparison of two counts.
|
||||
*/
|
||||
fun failed(
|
||||
sink: JourneySink,
|
||||
entryPoint: PlaybackEntryPoint,
|
||||
screen: String,
|
||||
itemId: String,
|
||||
itemName: String,
|
||||
itemType: String,
|
||||
playSessionId: String = "",
|
||||
) = sink.track(
|
||||
category = CATEGORY, action = "start", screen = screen, feature = FEATURE,
|
||||
source = entryPoint.id, target = PLAYER,
|
||||
itemId = itemId, itemName = itemName, itemType = itemType,
|
||||
playSessionId = playSessionId, outcome = "failure",
|
||||
)
|
||||
|
||||
/**
|
||||
* This title's playback ended because the player moved on to another one.
|
||||
*
|
||||
* Deliberately not `stop`, which the launcher records once when the player hands the
|
||||
* window back: a Magic press or an episode advance ends one title inside a player the
|
||||
* viewer never left, and without this the films before the last one in a chain would have
|
||||
* no ending at all.
|
||||
*/
|
||||
fun finished(
|
||||
sink: JourneySink,
|
||||
entryPoint: PlaybackEntryPoint,
|
||||
itemId: String,
|
||||
itemName: String,
|
||||
itemType: String,
|
||||
playSessionId: String,
|
||||
completed: Boolean,
|
||||
) = sink.track(
|
||||
category = CATEGORY, action = "complete", screen = PLAYER, feature = FEATURE,
|
||||
source = entryPoint.id, target = PLAYER,
|
||||
itemId = itemId, itemName = itemName, itemType = itemType,
|
||||
playSessionId = playSessionId,
|
||||
outcome = if (completed) "completed" else "abandoned",
|
||||
)
|
||||
|
||||
/** Magic was pressed: the viewer asked for something to be chosen for them. */
|
||||
fun magicRequested(sink: JourneySink) = sink.track(
|
||||
category = RECOMMENDATIONS, action = "request", screen = PLAYER,
|
||||
feature = MAGIC_FEATURE, source = PLAYER, target = MAGIC_FEATURE,
|
||||
)
|
||||
|
||||
/**
|
||||
* Magic chose a film. Generation and selection are one step here because they are one
|
||||
* press: nothing is offered to the viewer to accept or decline, the pick is announced and
|
||||
* then played, and recording an "offered" the viewer never saw would invent a decision.
|
||||
*/
|
||||
fun magicSelected(
|
||||
sink: JourneySink,
|
||||
itemId: String,
|
||||
itemName: String,
|
||||
itemType: String,
|
||||
) = sink.track(
|
||||
category = RECOMMENDATIONS, action = "select", screen = PLAYER,
|
||||
feature = MAGIC_FEATURE, source = MAGIC_FEATURE, target = PLAYER,
|
||||
itemId = itemId, itemName = itemName, itemType = itemType, outcome = "success",
|
||||
)
|
||||
|
||||
/**
|
||||
* Magic had nothing to offer — an older gateway with no route to answer, or a household
|
||||
* that has run out of unseen library. Recorded because a press that produced nothing is
|
||||
* the one thing the console could not otherwise tell from a button nobody uses.
|
||||
*/
|
||||
fun magicEmpty(sink: JourneySink) = sink.track(
|
||||
category = RECOMMENDATIONS, action = "complete", screen = PLAYER,
|
||||
feature = MAGIC_FEATURE, source = MAGIC_FEATURE, target = PLAYER,
|
||||
outcome = "failure",
|
||||
)
|
||||
}
|
||||
@@ -873,8 +873,17 @@ data class GatewayJourneyEvent(
|
||||
val feature: String = "",
|
||||
val source: String = "",
|
||||
val target: String = "",
|
||||
val itemId: String = "",
|
||||
val itemName: String = "",
|
||||
val itemType: String = "",
|
||||
/**
|
||||
* Emby's own id for the stream this step is about, on the playback steps that have one.
|
||||
* It is what joins a journey to the gateway's playback log without the two having to
|
||||
* agree on anything else, and it is deliberately not put in [target] — that field is what
|
||||
* the console builds its screen-to-screen path graph from, and a value unique per
|
||||
* playback would fill it with rows nobody can read.
|
||||
*/
|
||||
val playSessionId: String = "",
|
||||
val outcome: String = "",
|
||||
val occurredAt: String = "",
|
||||
)
|
||||
|
||||
@@ -8,6 +8,8 @@ import com.ponzischeme89.memby.data.HomeCache
|
||||
import com.ponzischeme89.memby.data.HomeSnapshot
|
||||
import com.ponzischeme89.memby.data.analytics.RowAnalytics
|
||||
import com.ponzischeme89.memby.data.analytics.JourneyAnalytics
|
||||
import com.ponzischeme89.memby.data.analytics.JourneySink
|
||||
import com.ponzischeme89.memby.data.analytics.JourneyTracker
|
||||
import com.ponzischeme89.memby.data.friendlyEmbyError
|
||||
import com.ponzischeme89.memby.data.isMaintenanceError
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
@@ -170,7 +172,11 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
|
||||
/** Row engagement, buffered here and uploaded in batches. */
|
||||
private val analytics = RowAnalytics()
|
||||
private val journey = JourneyAnalytics(repository.currentSettings.userId.orEmpty())
|
||||
// Registered process-wide rather than kept private, because the player is a second
|
||||
// activity and everything it does — a Magic pick, a playback that actually started, one
|
||||
// that failed — has to land in this same journey rather than nowhere.
|
||||
private val journey: JourneyAnalytics =
|
||||
JourneyTracker.begin(repository.currentSettings.userId.orEmpty())
|
||||
@Volatile private var analyticsPausedForPlayback = false
|
||||
|
||||
init {
|
||||
@@ -226,9 +232,21 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
|
||||
fun trackJourney(
|
||||
category: String, action: String, screen: String = "", feature: String = "",
|
||||
source: String = "", target: String = "", itemName: String = "", itemType: String = "",
|
||||
outcome: String = "",
|
||||
) = journey.track(category, action, screen, feature, source, target, itemName, itemType, outcome)
|
||||
source: String = "", target: String = "", itemId: String = "", itemName: String = "",
|
||||
itemType: String = "", playSessionId: String = "", outcome: String = "",
|
||||
) = journey.track(
|
||||
category = category, action = action, screen = screen, feature = feature,
|
||||
source = source, target = target, itemId = itemId, itemName = itemName,
|
||||
itemType = itemType, playSessionId = playSessionId, outcome = outcome,
|
||||
)
|
||||
|
||||
/**
|
||||
* The collector itself, for the playback wording in
|
||||
* [com.ponzischeme89.memby.data.analytics.PlaybackJourney]. Exposed rather than mirrored
|
||||
* as another `trackJourney` overload so the launcher and the player write playback steps
|
||||
* through exactly one set of functions.
|
||||
*/
|
||||
val journeySink: JourneySink get() = journey
|
||||
|
||||
fun endJourney(screen: String) { journey.end(screen); flushAnalytics() }
|
||||
|
||||
|
||||
@@ -127,6 +127,8 @@ import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.Settings
|
||||
import com.ponzischeme89.memby.data.EmbyProfile
|
||||
import com.ponzischeme89.memby.data.friendlyEmbyError
|
||||
import com.ponzischeme89.memby.data.analytics.PlaybackJourney
|
||||
import com.ponzischeme89.memby.data.analytics.playbackEntryPointFor
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.GatewayUpdate
|
||||
import com.ponzischeme89.memby.data.model.HomeRow
|
||||
@@ -1975,6 +1977,13 @@ private fun HomeScreen(
|
||||
var resolvingItem by remember { mutableStateOf<BaseItem?>(null) }
|
||||
var returnRowId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var returnItemId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
// The shelf a Play press can be traced back to, as an entry point rather than as a row
|
||||
// id. Kept beside [returnRowId] and written wherever it is, because the row's *kind* is
|
||||
// the reliable half — a row id is the gateway's, differs on the direct path, and a
|
||||
// recommendation strip invents a new one daily — and because by the time `playItem` runs
|
||||
// the row list is out of scope. Saved with the rest so a recreate mid-browse does not
|
||||
// file the next resume under nothing.
|
||||
var returnRowKind by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var recentSearches by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
var initialSearchQuery by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
// Destination and row list states live above the conditional content branches.
|
||||
@@ -2167,12 +2176,37 @@ private fun HomeScreen(
|
||||
}
|
||||
val playItem: (BaseItem) -> Unit = playItem@{ item ->
|
||||
if (launchingItem != null || !item.membyPlayable) return@playItem
|
||||
homeViewModel.trackJourney(
|
||||
category = "playback", action = "request", screen = selectedDestination.name.lowercase(),
|
||||
feature = "playback", source = returnRowId.orEmpty(), target = "player",
|
||||
itemName = item.name, itemType = item.type,
|
||||
// The shelf this press traces back to, resolved once and then carried all the way
|
||||
// into the player, so the step the launcher records and the step the player records
|
||||
// agree about where the viewer came from. `source` used to be the raw row id, which
|
||||
// the console could not group on: Continue Watching arrived as `continue` from the
|
||||
// gateway, as `nextup` from a cached row, and as a recommendation id nobody had seen
|
||||
// before every other day.
|
||||
val entryPoint = playbackEntryPointFor(returnRowId, returnRowKind)
|
||||
PlaybackJourney.requested(
|
||||
sink = homeViewModel.journeySink,
|
||||
entryPoint = entryPoint,
|
||||
screen = selectedDestination.name.lowercase(),
|
||||
itemId = item.id,
|
||||
itemName = item.name,
|
||||
itemType = item.type,
|
||||
)
|
||||
// Nothing here uploads: the buffer is drained once the player hands the window back.
|
||||
homeViewModel.pauseAnalyticsForPlayback()
|
||||
// Every way a launch can die before the player exists. The player records its own
|
||||
// failures once it has one; these are the ones it would never hear about, and
|
||||
// without them a journey ends on a request that appears simply to have gone nowhere.
|
||||
val playbackScreen = selectedDestination.name.lowercase()
|
||||
val recordPlaybackFailed = {
|
||||
PlaybackJourney.failed(
|
||||
sink = homeViewModel.journeySink,
|
||||
entryPoint = entryPoint,
|
||||
screen = playbackScreen,
|
||||
itemId = item.id,
|
||||
itemName = item.name,
|
||||
itemType = item.type,
|
||||
)
|
||||
}
|
||||
launchingItem = item
|
||||
val playbackRequestedAtMs = SystemClock.elapsedRealtime()
|
||||
// Resuming: open the player now and let it resolve the stream while it starts.
|
||||
@@ -2189,6 +2223,7 @@ private fun HomeScreen(
|
||||
val request = repo.playbackRequest(item)
|
||||
request to repo.readyPlayableForLaunch(request)
|
||||
}.getOrElse {
|
||||
recordPlaybackFailed()
|
||||
Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show()
|
||||
launchingItem = null
|
||||
homeViewModel.resumeAnalyticsAfterPlayback()
|
||||
@@ -2206,10 +2241,12 @@ private fun HomeScreen(
|
||||
backdropUrl = repo.backdropUrl(item, maxWidth = 1920)
|
||||
?: repo.primaryUrl(item, maxWidth = 1920),
|
||||
requestStartedAtMs = playbackRequestedAtMs,
|
||||
journeySource = entryPoint.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (launched.isFailure) {
|
||||
recordPlaybackFailed()
|
||||
Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show()
|
||||
launchingItem = null
|
||||
homeViewModel.resumeAnalyticsAfterPlayback()
|
||||
@@ -2273,10 +2310,12 @@ private fun HomeScreen(
|
||||
playSessionId = playable.playSessionId,
|
||||
playMethod = playable.playMethod,
|
||||
requestStartedAtMs = playbackRequestedAtMs,
|
||||
journeySource = entryPoint.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (launched.isFailure) {
|
||||
recordPlaybackFailed()
|
||||
Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show()
|
||||
launchingItem = null
|
||||
homeViewModel.resumeAnalyticsAfterPlayback()
|
||||
@@ -2286,6 +2325,7 @@ private fun HomeScreen(
|
||||
// A cancellation is the viewer having pressed Back out of the
|
||||
// wait, which has already reopened the gate and said so on screen.
|
||||
if (error is kotlinx.coroutines.CancellationException) throw error
|
||||
recordPlaybackFailed()
|
||||
Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show()
|
||||
// Nothing was launched, so nothing will come back to reopen the gate.
|
||||
launchingItem = null
|
||||
@@ -2535,6 +2575,11 @@ private fun HomeScreen(
|
||||
savedFocus?.let { (rowId, itemId) ->
|
||||
returnRowId = rowId
|
||||
returnItemId = itemId
|
||||
// Only the id was remembered, so the kind is cleared rather
|
||||
// than left carrying whichever row was focused on the
|
||||
// destination being left; the id alone still names the
|
||||
// shelves that matter.
|
||||
returnRowKind = null
|
||||
}
|
||||
if (
|
||||
savedFocus != null &&
|
||||
@@ -2623,6 +2668,7 @@ private fun HomeScreen(
|
||||
onItemFocused = homeViewModel::focusItem,
|
||||
onItemSelected = { item ->
|
||||
returnRowId = SEARCH_ROW_ID
|
||||
returnRowKind = null
|
||||
returnItemId = item.id
|
||||
destinationFocus[BrowseDestination.SEARCH] = SEARCH_ROW_ID to item.id
|
||||
homeViewModel.focusItem(item)
|
||||
@@ -2705,6 +2751,7 @@ private fun HomeScreen(
|
||||
onItemFocused = homeViewModel::focusItem,
|
||||
onItemSelected = { item ->
|
||||
returnRowId = GENRE_BROWSER_ROW_ID
|
||||
returnRowKind = null
|
||||
returnItemId = item.id
|
||||
destinationFocus[BrowseDestination.GENRES] =
|
||||
GENRE_BROWSER_ROW_ID to item.id
|
||||
@@ -2744,6 +2791,7 @@ private fun HomeScreen(
|
||||
onItemFocused = homeViewModel::focusItem,
|
||||
onItemSelected = { item ->
|
||||
returnRowId = GENRE_BROWSER_ROW_ID
|
||||
returnRowKind = null
|
||||
returnItemId = item.id
|
||||
homeViewModel.focusItem(item)
|
||||
homeViewModel.trackJourney(
|
||||
@@ -2885,11 +2933,13 @@ private fun HomeScreen(
|
||||
focusedHomeRowId = null
|
||||
destinationFocus[selectedDestination] = HOME_HERO_ROW_ID to item.id
|
||||
returnRowId = HOME_HERO_ROW_ID
|
||||
returnRowKind = null
|
||||
returnItemId = item.id
|
||||
homeViewModel.focusItem(item)
|
||||
},
|
||||
onItemSelected = { item ->
|
||||
returnRowId = HOME_HERO_ROW_ID
|
||||
returnRowKind = null
|
||||
returnItemId = item.id
|
||||
homeViewModel.focusItem(item)
|
||||
homeViewModel.trackJourney(
|
||||
@@ -3148,12 +3198,14 @@ private fun HomeScreen(
|
||||
}
|
||||
destinationFocus[selectedDestination] = row.id to item.id
|
||||
returnRowId = row.id
|
||||
returnRowKind = row.kind.name
|
||||
returnItemId = item.id
|
||||
homeViewModel.focusItem(item)
|
||||
homeViewModel.trackRowFocused(row.id, row.kind.name, item.id)
|
||||
},
|
||||
onItemSelected = { item ->
|
||||
returnRowId = row.id
|
||||
returnRowKind = row.kind.name
|
||||
returnItemId = item.id
|
||||
homeViewModel.trackRowSelected(row.id, row.kind.name, item.id)
|
||||
homeViewModel.trackJourney(
|
||||
@@ -3181,6 +3233,7 @@ private fun HomeScreen(
|
||||
},
|
||||
onItemLongPressed = { item ->
|
||||
returnRowId = row.id
|
||||
returnRowKind = row.kind.name
|
||||
returnItemId = item.id
|
||||
homeViewModel.focusItem(item)
|
||||
if (item.membyPlayable) {
|
||||
|
||||
@@ -62,7 +62,9 @@ internal class MembyRenderersFactory(
|
||||
}
|
||||
return builder
|
||||
.setEnableFloatOutput(enableFloatOutput)
|
||||
.setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams)
|
||||
// Renamed in Media3 1.9; the override parameter above keeps the framework's own
|
||||
// spelling because it is the signature being overridden.
|
||||
.setEnableAudioOutputPlaybackParameters(enableAudioTrackPlaybackParams)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -70,9 +72,11 @@ internal class MembyRenderersFactory(
|
||||
/**
|
||||
* Builds the fixed-capabilities sink used by manual mode.
|
||||
*
|
||||
* Media3 1.5 ignores [DefaultAudioSink.Builder.setAudioCapabilities] when the builder has
|
||||
* a Context, because the live route receiver replaces the supplied value. Its deprecated
|
||||
* context-free builder is therefore the only 1.5 API that can honour a viewer override.
|
||||
* Media3 ignores [DefaultAudioSink.Builder.setAudioCapabilities] when the builder has a
|
||||
* Context, because the live route receiver replaces the supplied value. Its deprecated
|
||||
* context-free builder is therefore the only API that can honour a viewer override. Still
|
||||
* true on the 1.9 line; re-check it whenever the Media3 version moves, because the day it
|
||||
* stops being true this whole function is dead code.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
private fun fixedCapabilitiesAudioSinkBuilder(
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
import androidx.media3.exoplayer.source.preload.DefaultPreloadManager
|
||||
import androidx.media3.exoplayer.source.preload.DefaultPreloadManager.PreloadStatus
|
||||
import androidx.media3.exoplayer.source.preload.PreloadException
|
||||
import androidx.media3.exoplayer.source.preload.PreloadManagerListener
|
||||
import androidx.media3.exoplayer.source.preload.TargetPreloadStatusControl
|
||||
|
||||
/**
|
||||
* Opens the next episode's stream while the current one is still playing.
|
||||
*
|
||||
* The wait an advance costs is the same wait a cold start costs, and this app already knows
|
||||
* where it goes: `prepare()` → first frame is over 90% of it, and the largest single term
|
||||
* inside that is the connection to Emby (see [PlayerEngine]). An advance is the one case where
|
||||
* all of that can be paid *early* — [NextUpResolver] knows what is next minutes before anybody
|
||||
* presses anything, so by the time the credits roll the container header has been read, the
|
||||
* tracks have been selected and a few seconds of media are in hand.
|
||||
*
|
||||
* Media3's [DefaultPreloadManager] does the work; this owns the two things it cannot decide for
|
||||
* itself — which episode is worth preloading ([preloadTargetFor]) and when a preloaded episode
|
||||
* stops being the next one ([advanceTo]).
|
||||
*
|
||||
* Everything here degrades to nothing. A player built without a manager leaves [manager] null,
|
||||
* every method becomes a no-op and [sourceFor] answers null, which puts [PlayerActivity] back
|
||||
* on the ordinary `setMediaItem` path it used before any of this existed.
|
||||
*/
|
||||
@UnstableApi
|
||||
internal class NextEpisodePreloader(
|
||||
private val elapsedRealtime: () -> Long = SystemClock::elapsedRealtime,
|
||||
) {
|
||||
private class Entry(
|
||||
val rank: Int,
|
||||
val mediaItem: MediaItem,
|
||||
/** Where the episode would resume, which is where preloading it is worth starting. */
|
||||
val startPositionMs: Long,
|
||||
val registeredAtMs: Long,
|
||||
var readyAtMs: Long? = null,
|
||||
)
|
||||
|
||||
private var manager: DefaultPreloadManager? = null
|
||||
|
||||
/** Keyed by Emby item id, which is what the rest of the player identifies an episode by. */
|
||||
private val entries = LinkedHashMap<String, Entry>()
|
||||
|
||||
/**
|
||||
* Where in this journey the episode on screen sits. It is a counter rather than a playlist
|
||||
* index — see [preloadTargetFor] — so it only ever moves forward, and it is what both the
|
||||
* target-status rule and the eviction rule are expressed against.
|
||||
*/
|
||||
private var playingRank = 0
|
||||
|
||||
/**
|
||||
* Whether the episode now playing was started from preloaded work. Read by the trace, so
|
||||
* `event=first_frame` can say which of the two latencies it is reporting.
|
||||
*/
|
||||
var startedFromPreload: Boolean = false
|
||||
private set
|
||||
|
||||
/**
|
||||
* Handed to [DefaultPreloadManager.Builder] before the manager exists, which is why this is
|
||||
* a property of the preloader rather than something the engine writes: the rule has to read
|
||||
* [playingRank], and [playingRank] moves for the life of the session.
|
||||
*/
|
||||
val statusControl = TargetPreloadStatusControl<Int, PreloadStatus> { rank ->
|
||||
val target = preloadTargetFor(
|
||||
rank = rank,
|
||||
playingRank = playingRank,
|
||||
startPositionMs = startPositionFor(rank),
|
||||
)
|
||||
// PRELOAD_STATUS_NOT_PRELOADED rather than null, which is the other way Media3 spells
|
||||
// "skip this one": the package is @NonNullApi, so Kotlin reads the generic status as
|
||||
// non-null and will not let this return null at all. The manager compares against this
|
||||
// constant by value and calls onSkipped, so the two are the same instruction.
|
||||
// A zero-length range would not do — it would still have the source prepared, which is
|
||||
// most of what preloading costs.
|
||||
if (!target.preload) PreloadStatus.PRELOAD_STATUS_NOT_PRELOADED
|
||||
else PreloadStatus.specifiedRangeLoaded(target.startPositionMs, target.durationMs)
|
||||
}
|
||||
|
||||
private val listener = object : PreloadManagerListener {
|
||||
override fun onCompleted(mediaItem: MediaItem) {
|
||||
val entry = entries.values.firstOrNull { it.mediaItem == mediaItem } ?: return
|
||||
entry.readyAtMs = elapsedRealtime()
|
||||
Log.i(
|
||||
PLAYBACK_LOG_TAG,
|
||||
"event=preload_ready rank=${entry.rank} " +
|
||||
"tookMs=${(entry.readyAtMs ?: 0L) - entry.registeredAtMs} rangeMs=$PRELOAD_RANGE_MS",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onError(error: PreloadException) {
|
||||
// A failed preload costs nothing but the preload: the advance falls back to
|
||||
// resolving the stream the way it always did. It is logged rather than surfaced
|
||||
// for the reason row analytics are — nothing the viewer can act on.
|
||||
val rank = entries.values.firstOrNull { it.mediaItem == error.mediaItem }?.rank
|
||||
Log.w(
|
||||
PLAYBACK_LOG_TAG,
|
||||
"event=preload_failed rank=${rank ?: -1} reason=${error.message.orEmpty()}",
|
||||
error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Called once by [PlayerEngine], after the manager it shares components with is built. */
|
||||
fun attach(manager: DefaultPreloadManager) {
|
||||
this.manager = manager
|
||||
manager.addListener(listener)
|
||||
manager.setCurrentPlayingIndex(playingRank)
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers what plays after the episode on screen, at one rank ahead of it.
|
||||
*
|
||||
* Called whenever [NextUpResolver] answers — which happens once per episode and again on
|
||||
* every re-negotiation of a stale stream, so a repeat for the same episode is ordinary and
|
||||
* must not register it twice. A *different* episode for the same rank is the answer having
|
||||
* changed underneath us (a re-resolve that moved), and replaces the old one.
|
||||
*/
|
||||
fun register(itemId: String, mediaItem: MediaItem, startPositionMs: Long = 0L) {
|
||||
val active = manager ?: return
|
||||
if (itemId.isBlank()) return
|
||||
val rank = playingRank + 1
|
||||
val existing = entries[itemId]
|
||||
if (existing != null && existing.rank == rank && existing.mediaItem == mediaItem) return
|
||||
// Anything else already sitting at this rank is a superseded answer. It is safe to
|
||||
// remove because nothing has been handed to the player from it: only advanceTo does
|
||||
// that, and it moves the rank on as it does.
|
||||
entries.entries.filter { it.value.rank == rank && it.key != itemId }.forEach { (id, entry) ->
|
||||
active.remove(entry.mediaItem)
|
||||
entries.remove(id)
|
||||
}
|
||||
existing?.let { active.remove(it.mediaItem) }
|
||||
entries[itemId] = Entry(rank, mediaItem, startPositionMs.coerceAtLeast(0L), elapsedRealtime())
|
||||
active.add(mediaItem, rank)
|
||||
active.invalidate()
|
||||
Log.i(PLAYBACK_LOG_TAG, "event=preload_registered rank=$rank held=${entries.size}")
|
||||
}
|
||||
|
||||
/**
|
||||
* The preloaded source for [mediaItem], or null if there is none to reuse.
|
||||
*
|
||||
* Null is the ordinary answer on a cold start, on the direct-to-Emby path, on a retry with
|
||||
* a freshly negotiated URL, and any time the preload simply did not finish — all of which
|
||||
* put [PlayerActivity] back on `setMediaItem`.
|
||||
*/
|
||||
fun sourceFor(mediaItem: MediaItem): MediaSource? = manager?.getMediaSource(mediaItem)
|
||||
|
||||
/**
|
||||
* Moves the journey on to [itemId], which is now the episode playing.
|
||||
*
|
||||
* Everything strictly behind it is released ([obsoletePreloadRanks]); the entry for
|
||||
* [itemId] itself is deliberately kept, because the player has just been handed its source
|
||||
* and removing it from the manager would release that source underneath the decoder. Its
|
||||
* target status becomes "not preloaded" on the next [DefaultPreloadManager.invalidate],
|
||||
* which stops the loading without touching what the player holds.
|
||||
*/
|
||||
fun advanceTo(itemId: String, startedFromPreload: Boolean) {
|
||||
this.startedFromPreload = startedFromPreload
|
||||
val active = manager ?: return
|
||||
val entry = entries[itemId]
|
||||
playingRank = entry?.rank ?: (playingRank + 1)
|
||||
obsoletePreloadRanks(entries.values.map { it.rank }, playingRank).forEach { rank ->
|
||||
entries.entries.filter { it.value.rank == rank }.forEach { (id, stale) ->
|
||||
active.remove(stale.mediaItem)
|
||||
entries.remove(id)
|
||||
}
|
||||
}
|
||||
active.setCurrentPlayingIndex(playingRank)
|
||||
active.invalidate()
|
||||
Log.i(
|
||||
PLAYBACK_LOG_TAG,
|
||||
"event=preload_advanced rank=$playingRank preloaded=$startedFromPreload held=${entries.size}",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws away every preloaded source, for a journey that is no longer the one they belong
|
||||
* to — a trailer, a Magic pick, a next-episode preview, an interruption for maintenance, or
|
||||
* a retry that had to re-negotiate the stream. Each of those makes the episode this was
|
||||
* counting on either wrong or unreachable, and holding a prepared source for it would keep
|
||||
* a connection open to Emby for something nobody is going to watch.
|
||||
*/
|
||||
fun reset() {
|
||||
startedFromPreload = false
|
||||
val active = manager ?: return
|
||||
if (entries.isEmpty()) return
|
||||
entries.clear()
|
||||
active.reset()
|
||||
Log.i(PLAYBACK_LOG_TAG, "event=preload_reset")
|
||||
}
|
||||
|
||||
fun release() {
|
||||
entries.clear()
|
||||
manager?.let {
|
||||
it.removeListener(listener)
|
||||
it.release()
|
||||
}
|
||||
manager = null
|
||||
}
|
||||
|
||||
private fun startPositionFor(rank: Int): Long =
|
||||
entries.values.firstOrNull { it.rank == rank }?.startPositionMs ?: 0L
|
||||
}
|
||||
@@ -28,6 +28,16 @@ internal class NextUpResolver(
|
||||
private val elapsedRealtime: () -> Long,
|
||||
/** Injected so a test needs no repository, and so the direct/gateway split stays put. */
|
||||
private val fetch: suspend (itemId: String) -> NextEpisode?,
|
||||
/**
|
||||
* Called on the resolver's own thread every time an answer is *published* — the first
|
||||
* lookup, and again whenever [warm] or [playable] re-negotiates a stale stream.
|
||||
*
|
||||
* It exists for [NextEpisodePreloader], which has to hear about the second of those as well
|
||||
* as the first: a re-negotiated episode carries a new URL, so preloaded work keyed on the
|
||||
* old one is no longer the thing the player will be handed. Defaulting to nothing keeps
|
||||
* every existing test construction of this class untouched.
|
||||
*/
|
||||
private val onResolved: (NextEpisode?) -> Unit = {},
|
||||
) {
|
||||
/** Guards the whole of [resolve]: two callers must never produce two requests. */
|
||||
private val lock = Mutex()
|
||||
@@ -97,13 +107,19 @@ internal class NextUpResolver(
|
||||
// would put up a banner promising something that cannot be played and then, the
|
||||
// countdown having run out, swap it in automatically and fail.
|
||||
?.takeIf { it.url.isNotBlank() }
|
||||
lock.withLock {
|
||||
if (subjectId == itemId) {
|
||||
val published = lock.withLock {
|
||||
val current = subjectId == itemId
|
||||
if (current) {
|
||||
answer = resolved
|
||||
resolvedAt = elapsedRealtime()
|
||||
}
|
||||
inFlight = null
|
||||
current
|
||||
}
|
||||
// Only for the episode still being played. An answer that arrived after the viewer
|
||||
// moved on describes a journey that no longer exists, and announcing it would have the
|
||||
// preloader open a connection for it.
|
||||
if (published) onResolved(resolved)
|
||||
pending.complete(resolved)
|
||||
return resolved
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
/**
|
||||
* The one logcat tag everything on the playback path writes under, so `adb logcat -s
|
||||
* MembyPlayback` is the whole diagnosis. Top-level rather than private to [PlayerActivity]
|
||||
* because the pieces the activity delegates to — [NextEpisodePreloader], [PlayerEngine] — have
|
||||
* to land in the same stream to be readable beside it.
|
||||
*/
|
||||
internal const val PLAYBACK_LOG_TAG = "MembyPlayback"
|
||||
|
||||
/**
|
||||
* Times the stages between pressing Play and seeing a frame.
|
||||
*
|
||||
|
||||
@@ -65,6 +65,9 @@ import com.ponzischeme89.memby.diagnostics.MembyDiagnostics
|
||||
import com.ponzischeme89.memby.data.audioPassthroughPreference
|
||||
import com.ponzischeme89.memby.data.DEFAULT_SEEK_INTERVAL_SECONDS
|
||||
import com.ponzischeme89.memby.data.IntroSegment
|
||||
import com.ponzischeme89.memby.data.analytics.JourneyTracker
|
||||
import com.ponzischeme89.memby.data.analytics.PlaybackEntryPoint
|
||||
import com.ponzischeme89.memby.data.analytics.PlaybackJourney
|
||||
import com.ponzischeme89.memby.data.creditsWorthShowing
|
||||
import com.ponzischeme89.memby.data.NextEpisode
|
||||
import com.ponzischeme89.memby.data.ResolvedRemoteTrailer
|
||||
@@ -163,6 +166,26 @@ class PlayerActivity : ComponentActivity() {
|
||||
private var playSessionId = ""
|
||||
private var playMethod = "DirectPlay"
|
||||
private var stoppedInBackground = false
|
||||
|
||||
/**
|
||||
* Where the title on screen was asked for, so the player's own journey steps can say so.
|
||||
*
|
||||
* It arrives on the intent for a launch from the launcher and is set here for the two the
|
||||
* launcher never sees — a Magic press and an episode advance, both of which start a whole
|
||||
* new playback inside a player the viewer never left. Those were the entry points that
|
||||
* produced no journey at all: the launcher recorded a request for the film somebody
|
||||
* originally chose and then nothing until the window came back, hours and several titles
|
||||
* later.
|
||||
*/
|
||||
private var journeyEntryPoint = PlaybackEntryPoint.UNKNOWN
|
||||
|
||||
/**
|
||||
* Whether this title's failure has been recorded. A playback error pane can be raised
|
||||
* several times over one title — every automatic retry that gives up ends here — and the
|
||||
* console counts starts against failures, so a title that failed once must contribute
|
||||
* one. Cleared wherever [playbackStarted] is, because that is where the title changes.
|
||||
*/
|
||||
private var journeyFailureRecorded = false
|
||||
private var availableSubtitles: List<PlayableSubtitle> = emptyList()
|
||||
private var encodedSubtitleId: String? = null
|
||||
private var remainingView: TextView? = null
|
||||
@@ -317,9 +340,25 @@ class PlayerActivity : ComponentActivity() {
|
||||
scope = lifecycleScope,
|
||||
elapsedRealtime = SystemClock::elapsedRealtime,
|
||||
fetch = { id -> ServiceLocator.repository.nextEpisode(id, seriesId = null) },
|
||||
onResolved = ::preloadNextEpisode,
|
||||
)
|
||||
}
|
||||
private val nextEpisode: NextEpisode? get() = nextUpResolver.current
|
||||
|
||||
/**
|
||||
* Opens the next episode's stream while this one is still playing, so an advance does not
|
||||
* pay the connection, the container header and the seek index over again. Built beside the
|
||||
* player because the two share their renderers, load control and playback looper; null
|
||||
* until [onCreate] has built them, and inert on any television where it could not be.
|
||||
*/
|
||||
private var preloader: NextEpisodePreloader? = null
|
||||
|
||||
/**
|
||||
* Whether the stream now playing came from preloaded work. Set by [startMedia] and read by
|
||||
* the `first_frame` line, which is the only place the two latencies this feature exists to
|
||||
* separate — a cold start and a preloaded start — can be told apart in a log.
|
||||
*/
|
||||
private var startedFromPreloadedSource = false
|
||||
private var returningHomeAfterCompletion = false
|
||||
private var nextUpJob: Job? = null
|
||||
private var nextEpisodeLookupJob: Job? = null
|
||||
@@ -527,6 +566,13 @@ class PlayerActivity : ComponentActivity() {
|
||||
return
|
||||
}
|
||||
|
||||
// Saved state first, like every other value here: a configuration change is the same
|
||||
// playback, and re-reading the intent would be correct anyway — but a Magic press
|
||||
// arrives as a *new* intent, so the two must not be able to disagree.
|
||||
journeyEntryPoint = PlaybackEntryPoint.fromId(
|
||||
savedInstanceState?.getString(STATE_JOURNEY_SOURCE)
|
||||
?: intent.getStringExtra(EXTRA_JOURNEY_SOURCE),
|
||||
)
|
||||
itemId = savedInstanceState?.getString(STATE_ITEM_ID)
|
||||
?: intent.getStringExtra(EXTRA_ITEM_ID)
|
||||
mediaSourceId = savedInstanceState?.getString(STATE_MEDIA_SOURCE_ID)
|
||||
@@ -680,8 +726,8 @@ class PlayerActivity : ComponentActivity() {
|
||||
// first one cannot arrive until onCreate has returned.
|
||||
// Surround passthrough is settled before the sink is built, not after: an audio
|
||||
// sink cannot change its mind about bitstreaming a format once a track is open.
|
||||
val createdPlayer = runCatching {
|
||||
PlayerEngine.create(this, ServiceLocator.settings.current.audioPassthroughPreference)
|
||||
val createdSession = runCatching {
|
||||
PlayerEngine.createSession(this, ServiceLocator.settings.current.audioPassthroughPreference)
|
||||
}.getOrElse { error ->
|
||||
Log.e(PLAYBACK_LOG_TAG, "event=player_create_failed", error)
|
||||
prerollActive = false
|
||||
@@ -700,7 +746,8 @@ class PlayerActivity : ComponentActivity() {
|
||||
)
|
||||
return
|
||||
}
|
||||
player = createdPlayer.also { playback ->
|
||||
preloader = createdSession.preloader
|
||||
player = createdSession.player.also { playback ->
|
||||
view.player = playback
|
||||
trace.mark(PlaybackTrace.PLAYER_BUILT)
|
||||
// Audio start is not on Player.Listener — only the analytics interface
|
||||
@@ -842,6 +889,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
Log.i(
|
||||
PLAYBACK_LOG_TAG,
|
||||
"event=first_frame item=${itemId.orEmpty()} totalMs=$firstFrameMs " +
|
||||
"start=${if (startedFromPreloadedSource) "preloaded" else "cold"} " +
|
||||
"resumeMs=$initialResumePositionMs ${trace.summary()}",
|
||||
)
|
||||
}
|
||||
@@ -922,13 +970,25 @@ class PlayerActivity : ComponentActivity() {
|
||||
val playback = player ?: return
|
||||
currentMediaUrl = url
|
||||
currentMediaSubtitles = subtitles
|
||||
startedFromPreloadedSource = false
|
||||
val prepared = runCatching {
|
||||
require(url.isNotBlank()) { "Playback URL is blank" }
|
||||
val item = mediaItem(url, subtitles)
|
||||
// A source the preload manager already prepared, if this is the episode it was
|
||||
// told to expect. Null on every other launch — a cold start, a retry that
|
||||
// re-negotiated the URL, the direct-to-Emby path, a preload that did not finish —
|
||||
// and null is simply the path the player took before any of this existed.
|
||||
val preloaded = preloader?.sourceFor(item)
|
||||
// A retry must not inherit a wedged loader, extractor or decoder. This is the
|
||||
// in-process equivalent of the state reset an app restart used to provide.
|
||||
playback.stop()
|
||||
playback.clearMediaItems()
|
||||
playback.setMediaItem(mediaItem(url, subtitles), positionMs.coerceAtLeast(0L))
|
||||
if (preloaded != null) {
|
||||
startedFromPreloadedSource = true
|
||||
playback.setMediaSource(preloaded, positionMs.coerceAtLeast(0L))
|
||||
} else {
|
||||
playback.setMediaItem(item, positionMs.coerceAtLeast(0L))
|
||||
}
|
||||
playback.playWhenReady = playWhenReady
|
||||
playback.prepare()
|
||||
}
|
||||
@@ -1549,6 +1609,11 @@ class PlayerActivity : ComponentActivity() {
|
||||
private fun startPlaybackSession(playback: Player) {
|
||||
if (playbackStarted) return
|
||||
playbackStarted = true
|
||||
// First, because it is the answer to "did the thing the viewer asked for happen",
|
||||
// and last, because everything below it is a request. It is a synchronised append to
|
||||
// an in-memory list — no upload, no disk — so it cannot delay a decoder that has just
|
||||
// produced a picture; the buffer is drained by the launcher when the window returns.
|
||||
recordPlaybackStartedJourney()
|
||||
if (!prerollActive) showPlaybackIdentity()
|
||||
// Deliberately here rather than in onCreate. Nothing needs the cast until the
|
||||
// viewer opens the overlay, and on a series the launcher does not yet know which
|
||||
@@ -1969,6 +2034,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
private fun showPlaybackError(failure: PlaybackFailure) {
|
||||
recordPlaybackFailedJourney()
|
||||
hidePlaybackLoading()
|
||||
playerView?.hideController()
|
||||
errorTitleView?.text = failure.title
|
||||
@@ -3058,6 +3124,67 @@ class PlayerActivity : ComponentActivity() {
|
||||
private val playingAnEpisode: Boolean
|
||||
get() = !playbackSeriesName.isNullOrBlank() || prerollEpisodeCode.isNotBlank()
|
||||
|
||||
/**
|
||||
* Whether the journey should hear about what is on screen at all.
|
||||
*
|
||||
* A trailer and a next-episode preview both run through the same player and the same
|
||||
* start and error paths, and neither is a viewing: recorded, they would double the
|
||||
* playback counts and give a household a "watched" outcome for a film nobody put on.
|
||||
* The launcher records the trailer press itself, which is the part worth knowing.
|
||||
*/
|
||||
private val journeyRecordsPlayback: Boolean
|
||||
get() = pendingTrailerRequest == null && !playingNextEpisodePreview
|
||||
|
||||
/** Read off the series name, the same evidence [playingAnEpisode] uses. */
|
||||
private val journeyItemType: String
|
||||
get() = if (playingAnEpisode) "Episode" else "Movie"
|
||||
|
||||
private fun recordPlaybackStartedJourney() {
|
||||
if (!journeyRecordsPlayback) return
|
||||
journeyFailureRecorded = false
|
||||
PlaybackJourney.started(
|
||||
sink = JourneyTracker,
|
||||
entryPoint = journeyEntryPoint,
|
||||
itemId = itemId.orEmpty(),
|
||||
itemName = playbackTitle,
|
||||
itemType = journeyItemType,
|
||||
playSessionId = playSessionId,
|
||||
)
|
||||
}
|
||||
|
||||
private fun recordPlaybackFailedJourney() {
|
||||
if (!journeyRecordsPlayback || journeyFailureRecorded) return
|
||||
journeyFailureRecorded = true
|
||||
PlaybackJourney.failed(
|
||||
sink = JourneyTracker,
|
||||
entryPoint = journeyEntryPoint,
|
||||
screen = "player",
|
||||
itemId = itemId.orEmpty(),
|
||||
itemName = playbackTitle,
|
||||
itemType = journeyItemType,
|
||||
playSessionId = playSessionId,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The title on screen is being replaced by another one inside this same player — a Magic
|
||||
* pick or an episode advance. Without it the films before the last one in a chain would
|
||||
* have a request and a start and no ending at all, because the launcher's own `stop` is
|
||||
* recorded once, when the window finally comes back.
|
||||
*/
|
||||
private fun recordPlaybackFinishedJourney(completed: Boolean) {
|
||||
if (!journeyRecordsPlayback || !playbackStarted) return
|
||||
PlaybackJourney.finished(
|
||||
sink = JourneyTracker,
|
||||
entryPoint = journeyEntryPoint,
|
||||
itemId = itemId.orEmpty(),
|
||||
itemName = playbackTitle,
|
||||
itemType = journeyItemType,
|
||||
playSessionId = playSessionId,
|
||||
completed = completed,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the two optional transport controls in step with what is actually known.
|
||||
*
|
||||
@@ -3091,6 +3218,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
private fun playSomethingElse() {
|
||||
if (magicJob?.isActive == true || advanceRequested || advancing) return
|
||||
val current = itemId?.takeIf { it.isNotBlank() }
|
||||
PlaybackJourney.magicRequested(JourneyTracker)
|
||||
magicJob = lifecycleScope.launch {
|
||||
showPlaybackLoading(
|
||||
title = getString(R.string.player_magic_finding),
|
||||
@@ -3107,6 +3235,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
// An older gateway has no route to answer with, and a household that has run
|
||||
// out of unseen library has no answer to give. Neither is worth an error pane
|
||||
// over somebody's film: say so, put the picture back and withdraw the button.
|
||||
PlaybackJourney.magicEmpty(JourneyTracker)
|
||||
magicAvailable = false
|
||||
updateNextEpisodeButton()
|
||||
hidePlaybackLoading()
|
||||
@@ -3116,6 +3245,26 @@ class PlayerActivity : ComponentActivity() {
|
||||
}
|
||||
magicOffered += pick.itemId
|
||||
if (magicOffered.size > MAGIC_MEMORY) magicOffered.removeAt(0)
|
||||
// The whole sequence, in the order it happened: the film that was on ends, the
|
||||
// recommendation is recorded as chosen, and the request for the new one carries
|
||||
// magic_movie as its entry point — which is what makes the console cut a fresh
|
||||
// viewing journey here and attribute it to the button rather than to whatever
|
||||
// shelf the *previous* film came from hours ago.
|
||||
recordPlaybackFinishedJourney(completed = false)
|
||||
PlaybackJourney.magicSelected(
|
||||
sink = JourneyTracker,
|
||||
itemId = pick.itemId,
|
||||
itemName = pick.title,
|
||||
itemType = pick.itemType,
|
||||
)
|
||||
PlaybackJourney.requested(
|
||||
sink = JourneyTracker,
|
||||
entryPoint = PlaybackEntryPoint.MAGIC_MOVIE,
|
||||
screen = "player",
|
||||
itemId = pick.itemId,
|
||||
itemName = pick.title,
|
||||
itemType = pick.itemType,
|
||||
)
|
||||
Toast.makeText(
|
||||
this@PlayerActivity,
|
||||
getString(R.string.player_magic_selected, pick.title),
|
||||
@@ -3142,6 +3291,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
logoUrl = pick.logoUrl,
|
||||
),
|
||||
backdropUrl = pick.backdropUrl,
|
||||
journeySource = PlaybackEntryPoint.MAGIC_MOVIE.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -3849,6 +3999,29 @@ class PlayerActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the preloader what plays next, every time the resolver publishes an answer.
|
||||
*
|
||||
* Deliberately hung off the resolver rather than called from one place: the answer arrives
|
||||
* once when playback settles and again whenever a stale stream is re-negotiated, and a
|
||||
* re-negotiation is exactly when preloaded work stops matching what the player will be
|
||||
* handed. A null answer — a film, the last episode of a season — leaves the preloader with
|
||||
* nothing registered, which is the correct amount of work to do for it.
|
||||
*
|
||||
* Nothing here is allowed to interrupt playback: a preview, a trailer or a Magic pick is a
|
||||
* different journey, and the episode this would register is not the one that would play.
|
||||
*/
|
||||
private fun preloadNextEpisode(next: NextEpisode?) {
|
||||
val active = preloader ?: return
|
||||
if (playingNextEpisodePreview || pendingTrailerRequest != null) return
|
||||
if (next == null || next.url.isBlank() || next.itemId.isBlank()) return
|
||||
active.register(
|
||||
itemId = next.itemId,
|
||||
mediaItem = mediaItem(next.url, next.subtitles),
|
||||
startPositionMs = next.resumePositionMs,
|
||||
)
|
||||
}
|
||||
|
||||
private fun playNext(next: NextEpisode) {
|
||||
if (advancing || returningHomeAfterCompletion) return
|
||||
advancing = true
|
||||
@@ -3895,6 +4068,11 @@ class PlayerActivity : ComponentActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
// Recorded before the fields below move, or it would describe the incoming episode.
|
||||
// Completed rather than abandoned: every trigger that reaches here is somebody
|
||||
// choosing to go on to the next one, not leaving this one.
|
||||
recordPlaybackFinishedJourney(completed = true)
|
||||
|
||||
itemId = next.itemId
|
||||
mediaSourceId = next.mediaSourceId
|
||||
playSessionId = next.playSessionId
|
||||
@@ -3909,6 +4087,19 @@ class PlayerActivity : ComponentActivity() {
|
||||
encodedSubtitleId = null
|
||||
stopReported = false
|
||||
playbackStarted = false
|
||||
// The episode after this one was not asked for on the shelf the first one came from,
|
||||
// so the journey's entry point moves with the title. Recorded here rather than left
|
||||
// to the launcher, which cannot see an advance at all.
|
||||
journeyEntryPoint = PlaybackEntryPoint.NEXT_EPISODE
|
||||
journeyFailureRecorded = false
|
||||
PlaybackJourney.requested(
|
||||
sink = JourneyTracker,
|
||||
entryPoint = PlaybackEntryPoint.NEXT_EPISODE,
|
||||
screen = "player",
|
||||
itemId = next.itemId,
|
||||
itemName = nextTitle(next),
|
||||
itemType = "Episode",
|
||||
)
|
||||
playbackIdentityShown = false
|
||||
playbackIdentityHideJob?.cancel()
|
||||
playbackIdentityView?.apply {
|
||||
@@ -3973,6 +4164,11 @@ class PlayerActivity : ComponentActivity() {
|
||||
if (playback != null) {
|
||||
trace.mark(PlaybackTrace.STREAM_RESOLVED)
|
||||
startMedia(next.url, next.subtitles, next.resumePositionMs, playWhenReady = true)
|
||||
// Strictly after startMedia, never before it. Moving the journey on releases every
|
||||
// source behind the one now playing — including the episode that was on screen a
|
||||
// moment ago — and that episode's source is only safe to release once the player
|
||||
// has been handed its replacement.
|
||||
preloader?.advanceTo(next.itemId, startedFromPreload = startedFromPreloadedSource)
|
||||
}
|
||||
advancing = false
|
||||
}
|
||||
@@ -3986,6 +4182,11 @@ class PlayerActivity : ComponentActivity() {
|
||||
private fun interruptForMaintenance() {
|
||||
player?.pause()
|
||||
progressJob?.cancel()
|
||||
// The gateway has gone into maintenance, so the episode this was holding open a
|
||||
// connection for is not going to be played from here. finish() releases it a moment
|
||||
// later anyway; letting it go now means the connection is not held across the
|
||||
// transition back to the launcher.
|
||||
preloader?.reset()
|
||||
startActivity(
|
||||
Intent(this, MainActivity::class.java).addFlags(
|
||||
Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP,
|
||||
@@ -4969,6 +5170,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
outState.putString(STATE_MEDIA_SOURCE_ID, mediaSourceId)
|
||||
outState.putString(STATE_PLAY_SESSION_ID, playSessionId)
|
||||
outState.putString(STATE_PLAY_METHOD, playMethod)
|
||||
outState.putString(STATE_JOURNEY_SOURCE, journeyEntryPoint.id)
|
||||
val savedSubtitles = if (savingPreview) previewResumeSubtitles else availableSubtitles
|
||||
if (savedSubtitles.isNotEmpty()) {
|
||||
outState.putString(STATE_SUBTITLES, playerJson.encodeToString(savedSubtitles))
|
||||
@@ -5128,6 +5330,11 @@ class PlayerActivity : ComponentActivity() {
|
||||
)
|
||||
}
|
||||
playerView?.player = null
|
||||
// Before the player, not after: the manager holds sources the player is still attached
|
||||
// to, and releasing it second would leave preloading running against a released
|
||||
// playback looper for as long as it took to notice.
|
||||
preloader?.release()
|
||||
preloader = null
|
||||
playback?.release()
|
||||
player = null
|
||||
super.onDestroy()
|
||||
@@ -5312,6 +5519,13 @@ class PlayerActivity : ComponentActivity() {
|
||||
private const val EXTRA_MEDIA_SOURCE_ID = "extra_media_source_id"
|
||||
private const val EXTRA_PLAY_SESSION_ID = "extra_play_session_id"
|
||||
private const val EXTRA_PLAY_METHOD = "extra_play_method"
|
||||
/**
|
||||
* The surface the viewer pressed Play on, carried in so the player's own journey
|
||||
* steps can name it. There are two launch forms and both must set it — a field with
|
||||
* no `putExtra`/`getExtra` pair takes its default silently, which is exactly how
|
||||
* three "is this worth asking the backend" booleans once shipped switched off.
|
||||
*/
|
||||
private const val EXTRA_JOURNEY_SOURCE = "extra_journey_source"
|
||||
private const val EXTRA_PLAYBACK_REQUEST = "extra_playback_request"
|
||||
private const val EXTRA_TRAILER_REQUEST = "extra_trailer_request"
|
||||
private const val EXTRA_TRAILER_UNAVAILABLE = "extra_trailer_unavailable"
|
||||
@@ -5339,6 +5553,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
private const val STATE_RUNTIME_MS = "state_runtime_ms"
|
||||
private const val STATE_TRAILER_REQUEST = "state_trailer_request"
|
||||
private const val STATE_MAGIC_OFFERED = "state_magic_offered"
|
||||
private const val STATE_JOURNEY_SOURCE = "state_journey_source"
|
||||
private const val PLAYER_PREFERENCES = "player_preferences"
|
||||
private const val SUBTITLE_SIZE_KEY = "subtitle_size"
|
||||
private const val PICTURE_MODE_KEY = "picture_mode"
|
||||
@@ -5365,8 +5580,10 @@ class PlayerActivity : ComponentActivity() {
|
||||
posterUrl: String? = null,
|
||||
backdropUrl: String? = null,
|
||||
requestStartedAtMs: Long = SystemClock.elapsedRealtime(),
|
||||
journeySource: String = PlaybackEntryPoint.UNKNOWN.id,
|
||||
): Intent = Intent(context, PlayerActivity::class.java).apply {
|
||||
putExtra(EXTRA_PLAYBACK_REQUEST, playerJson.encodeToString(request))
|
||||
putExtra(EXTRA_JOURNEY_SOURCE, journeySource)
|
||||
putExtra(EXTRA_ITEM_ID, request.itemId)
|
||||
putExtra(EXTRA_TITLE, request.title)
|
||||
putExtra(EXTRA_RESUME_POSITION_MS, request.resumePositionMs)
|
||||
@@ -5428,8 +5645,10 @@ class PlayerActivity : ComponentActivity() {
|
||||
playSessionId: String = "",
|
||||
playMethod: String = "DirectPlay",
|
||||
requestStartedAtMs: Long = SystemClock.elapsedRealtime(),
|
||||
journeySource: String = PlaybackEntryPoint.UNKNOWN.id,
|
||||
): Intent =
|
||||
Intent(context, PlayerActivity::class.java).apply {
|
||||
putExtra(EXTRA_JOURNEY_SOURCE, journeySource)
|
||||
itemId?.let { putExtra(EXTRA_ITEM_ID, it) }
|
||||
putExtra(EXTRA_URL, url)
|
||||
putExtra(EXTRA_TITLE, title)
|
||||
@@ -5619,7 +5838,6 @@ class PlayerActivity : ComponentActivity() {
|
||||
private const val FRESH_STREAM_RETRY_ATTEMPT = 2
|
||||
private const val STABLE_PLAYBACK_RESET_MS = 30_000L
|
||||
private const val PROLONGED_REBUFFER_RECOVERY_MS = 12_000L
|
||||
private const val PLAYBACK_LOG_TAG = "MembyPlayback"
|
||||
|
||||
private fun playbackStateName(state: Int): String =
|
||||
when (state) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
@@ -10,7 +11,9 @@ import androidx.media3.exoplayer.DefaultLoadControl
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.source.preload.DefaultPreloadManager
|
||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||
import androidx.media3.exoplayer.trackselection.TrackSelector
|
||||
import androidx.media3.extractor.DefaultExtractorsFactory
|
||||
import com.ponzischeme89.memby.data.playback.AudioPassthroughPreference
|
||||
import com.ponzischeme89.memby.data.remote.HttpStack
|
||||
@@ -33,6 +36,30 @@ import java.util.concurrent.TimeUnit
|
||||
@UnstableApi
|
||||
internal object PlayerEngine {
|
||||
|
||||
/**
|
||||
* Two independent switches, so a television that misbehaves on one of these can have it
|
||||
* taken away without losing the other or the upgrade underneath them both.
|
||||
*
|
||||
* [PRELOADING_ENABLED] off leaves the player exactly as it was before preloading existed:
|
||||
* [Session.preloader] is unattached, every call on it is a no-op and `sourceFor` answers
|
||||
* null, which is the same path a cold start already takes. [DYNAMIC_SCHEDULING_ENABLED] is
|
||||
* Media3's own experimental scheduling, which wakes the playback loop when there is work
|
||||
* rather than on a fixed cadence; it is the half most likely to interact badly with a
|
||||
* vendor decoder, and it is the half that can be dropped with no behaviour change at all.
|
||||
*/
|
||||
const val PRELOADING_ENABLED = true
|
||||
const val DYNAMIC_SCHEDULING_ENABLED = true
|
||||
|
||||
/**
|
||||
* The player and, when preloading is on, the manager that shares its components.
|
||||
*
|
||||
* They are returned together because they are built together and must not be built twice:
|
||||
* [DefaultPreloadManager.Builder.buildExoPlayer] is what guarantees the preloaded source
|
||||
* and the player agree about the renderers, the load control, the track selector, the
|
||||
* bandwidth meter and — the one that would actually break — the playback looper.
|
||||
*/
|
||||
class Session(val player: ExoPlayer, val preloader: NextEpisodePreloader)
|
||||
|
||||
/**
|
||||
* [audio] decides whether a surround track is bitstreamed to the receiver or decoded
|
||||
* here into PCM. It defaults to automatic, which is also the right answer for the
|
||||
@@ -41,43 +68,102 @@ internal object PlayerEngine {
|
||||
fun create(
|
||||
context: Context,
|
||||
audio: AudioPassthroughPreference = AudioPassthroughPreference.AUTOMATIC,
|
||||
): ExoPlayer = try {
|
||||
buildPlayer(context, audio, DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
|
||||
): ExoPlayer = createSession(context, audio, preloading = false).player
|
||||
|
||||
/**
|
||||
* The main player, with next-episode preloading attached where it can be.
|
||||
*
|
||||
* Falls back to a plain player on any failure to build the manager rather than failing the
|
||||
* launch: preloading is a saving, and a television that cannot have it must still play.
|
||||
*/
|
||||
fun createSession(
|
||||
context: Context,
|
||||
audio: AudioPassthroughPreference = AudioPassthroughPreference.AUTOMATIC,
|
||||
preloading: Boolean = PRELOADING_ENABLED,
|
||||
): Session = try {
|
||||
buildSession(context, audio, DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON, preloading)
|
||||
} catch (_: RuntimeException) {
|
||||
// Extension construction happens while ExoPlayer is built. A binary mismatch or
|
||||
// broken vendor audio implementation must degrade to Android's renderers rather
|
||||
// than make Memby close during its process-wide pre-roll warm-up.
|
||||
buildPlayer(context, audio, DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF)
|
||||
buildSession(context, audio, DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF, preloading)
|
||||
} catch (_: LinkageError) {
|
||||
buildPlayer(context, audio, DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF)
|
||||
buildSession(context, audio, DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF, preloading)
|
||||
}
|
||||
|
||||
private fun buildPlayer(
|
||||
private fun buildSession(
|
||||
context: Context,
|
||||
audio: AudioPassthroughPreference,
|
||||
extensionRendererMode: Int,
|
||||
): ExoPlayer = ExoPlayer.Builder(context)
|
||||
.setMediaSourceFactory(mediaSourceFactory(context))
|
||||
.setRenderersFactory(
|
||||
MembyRenderersFactory(context, audio)
|
||||
// Some Android TV firmwares advertise a preferred hardware decoder which
|
||||
// fails only after initialization. Let Media3 try another installed decoder
|
||||
// before declaring the file unsupported.
|
||||
.setEnableDecoderFallback(true)
|
||||
// The bundled FFmpeg audio renderer sits after platform decoders. It is
|
||||
// reached only when Android cannot decode a surround format itself; its
|
||||
// output is PCM, so passthrough-capable tracks still bypass it untouched.
|
||||
.setExtensionRendererMode(extensionRendererMode),
|
||||
preloading: Boolean,
|
||||
): Session {
|
||||
val preloader = NextEpisodePreloader()
|
||||
val renderers = renderersFactory(context, audio, extensionRendererMode)
|
||||
val sources = mediaSourceFactory(context)
|
||||
// Everything the two share is set on the *preload manager's* builder, not on the
|
||||
// ExoPlayer one: buildExoPlayer overwrites the media source factory, renderers, load
|
||||
// control, bandwidth meter, track selector and playback looper on whatever builder it
|
||||
// is handed. Setting them twice would be harmless but misleading — the ExoPlayer
|
||||
// builder below carries only what the manager has no opinion about.
|
||||
val player = if (preloading) {
|
||||
runCatching {
|
||||
val managerBuilder = DefaultPreloadManager.Builder(context, preloader.statusControl)
|
||||
.setMediaSourceFactory(sources)
|
||||
.setRenderersFactory(renderers)
|
||||
.setTrackSelectorFactory(TrackSelector.Factory { DefaultTrackSelector(it) })
|
||||
.setLoadControl(loadControl())
|
||||
// Manager first, then the player from the same builder — the order Media3's
|
||||
// own documentation uses, and the one that guarantees the shared playback
|
||||
// looper exists before anything is handed to it.
|
||||
preloader.attach(managerBuilder.build())
|
||||
managerBuilder.buildExoPlayer(exoPlayerBuilder(context))
|
||||
}.onFailure { error ->
|
||||
// Named rather than swallowed. The fallback below is silent from the viewer's
|
||||
// side — playback works exactly as it did — so without this line a television
|
||||
// that never preloads anything is indistinguishable from one where the feature
|
||||
// is working and simply never saves any time.
|
||||
Log.w(PLAYBACK_LOG_TAG, "event=preload_unavailable reason=${error.javaClass.simpleName}", error)
|
||||
}.getOrNull()
|
||||
} else {
|
||||
null
|
||||
} ?: exoPlayerBuilder(context)
|
||||
.setMediaSourceFactory(sources)
|
||||
.setRenderersFactory(renderers)
|
||||
.setTrackSelector(DefaultTrackSelector(context))
|
||||
.setLoadControl(loadControl())
|
||||
.build()
|
||||
|
||||
player.setAudioAttributes(
|
||||
AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MOVIE).build(),
|
||||
/* handleAudioFocus = */ false,
|
||||
)
|
||||
.setTrackSelector(DefaultTrackSelector(context))
|
||||
.setLoadControl(loadControl())
|
||||
.build()
|
||||
.apply {
|
||||
setAudioAttributes(
|
||||
AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MOVIE).build(),
|
||||
/* handleAudioFocus = */ false,
|
||||
)
|
||||
}
|
||||
return Session(player, preloader)
|
||||
}
|
||||
|
||||
/**
|
||||
* Carries only what [DefaultPreloadManager.Builder.buildExoPlayer] does not overwrite.
|
||||
*
|
||||
* `enablePerStreamMediaProgression` belongs here and is deliberately absent: it arrived in
|
||||
* Media3 1.11, and this app is pinned to the 1.9 line by the Jellyfin FFmpeg extension (see
|
||||
* `app/build.gradle.kts`). Dynamic scheduling is the part of that same work which *is*
|
||||
* available here, and it is behind its own switch.
|
||||
*/
|
||||
private fun exoPlayerBuilder(context: Context) = ExoPlayer.Builder(context)
|
||||
.experimentalSetDynamicSchedulingEnabled(DYNAMIC_SCHEDULING_ENABLED)
|
||||
|
||||
private fun renderersFactory(
|
||||
context: Context,
|
||||
audio: AudioPassthroughPreference,
|
||||
extensionRendererMode: Int,
|
||||
) = MembyRenderersFactory(context, audio)
|
||||
// Some Android TV firmwares advertise a preferred hardware decoder which
|
||||
// fails only after initialization. Let Media3 try another installed decoder
|
||||
// before declaring the file unsupported.
|
||||
.setEnableDecoderFallback(true)
|
||||
// The bundled FFmpeg audio renderer sits after platform decoders. It is
|
||||
// reached only when Android cannot decode a surround format itself; its
|
||||
// output is PCM, so passthrough-capable tracks still bypass it untouched.
|
||||
.setExtensionRendererMode(extensionRendererMode)
|
||||
|
||||
private fun mediaSourceFactory(context: Context) = DefaultMediaSourceFactory(
|
||||
// DefaultDataSource still handles the non-HTTP schemes (file:, asset:, content:);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
/**
|
||||
* What, if anything, the preload manager should load ahead for one registered episode.
|
||||
*
|
||||
* Kept apart from [NextEpisodePreloader] and free of every Media3 type so it can be tested as
|
||||
* plain JUnit, the rule this repository applies to anything worth pinning. The decisions here
|
||||
* are the only ones that bound what preloading costs, and they are the half that a unit test
|
||||
* can actually check — whether ExoPlayer then honours the bound is a question for the device.
|
||||
*/
|
||||
internal data class PreloadTarget(
|
||||
/** False means "leave this one alone": the manager holds it but loads nothing for it. */
|
||||
val preload: Boolean,
|
||||
val startPositionMs: Long = 0L,
|
||||
val durationMs: Long = 0L,
|
||||
)
|
||||
|
||||
/**
|
||||
* Ranking data is a monotonically increasing position in *this playback journey* — the episode
|
||||
* playing now, then the one after it, then the one after that — rather than an index into a
|
||||
* playlist. Nothing here is a playlist: the player is handed one episode at a time and the next
|
||||
* one is discovered while it plays, so a rank is only ever assigned when an answer arrives.
|
||||
*
|
||||
* Exactly one episode ahead is ever worth preloading. Two would double the cost for a title the
|
||||
* viewer has two episodes' worth of time to walk away from, and this app ships to boxes where
|
||||
* the memory matters more than the second hop does.
|
||||
*/
|
||||
internal fun preloadTargetFor(
|
||||
rank: Int,
|
||||
playingRank: Int,
|
||||
startPositionMs: Long = 0L,
|
||||
rangeMs: Long = PRELOAD_RANGE_MS,
|
||||
): PreloadTarget =
|
||||
if (rank == playingRank + 1) {
|
||||
PreloadTarget(
|
||||
preload = true,
|
||||
startPositionMs = startPositionMs.coerceAtLeast(0L),
|
||||
durationMs = rangeMs.coerceAtLeast(0L),
|
||||
)
|
||||
} else {
|
||||
PreloadTarget(preload = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* The ranks whose preloaded work is now dead and should be handed back.
|
||||
*
|
||||
* Strictly *behind* the playing rank, never the playing rank itself. That exclusion is the
|
||||
* whole safety of eviction: on an advance the episode starting is the one the manager was
|
||||
* preloading, and the player has been handed its `MediaSource` — removing it from the manager
|
||||
* releases that source underneath a decoder that is using it. Everything before it is finished
|
||||
* with and can go.
|
||||
*/
|
||||
internal fun obsoletePreloadRanks(ranks: Collection<Int>, playingRank: Int): List<Int> =
|
||||
ranks.filter { it < playingRank }.sorted()
|
||||
|
||||
/**
|
||||
* How much of the next episode is pulled into memory ahead of time.
|
||||
*
|
||||
* Deliberately small. The expensive part of starting a stream here is not the bytes — it is
|
||||
* opening the connection to Emby, reading the container header and then the seek index, which
|
||||
* on a Matroska file commonly sits at the *end* of it (see [PlayerEngine]). Preparing the
|
||||
* source and selecting its tracks pays all of that, and a specified range is what makes Media3
|
||||
* go that far; the seconds of media on top are the margin that lets the first frame decode
|
||||
* without a round trip. Five of them is comfortably more than [PlayerEngine]'s
|
||||
* `BUFFER_FOR_PLAYBACK_MS` needs to start.
|
||||
*
|
||||
* The number is a memory ceiling before it is anything else: an episode direct-playing at 4K
|
||||
* can run past 30 Mbps, so every second held ahead is a few megabytes on a box that has little
|
||||
* to spare, for a title the viewer may well not go on to.
|
||||
*/
|
||||
internal const val PRELOAD_RANGE_MS = 5_000L
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.ponzischeme89.memby.data.analytics
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class PlaybackEntryPointTest {
|
||||
|
||||
/**
|
||||
* The kind is asked first because it is the app's own classification and is the same on
|
||||
* both paths. Every one of these is Continue Watching as far as a viewer is concerned:
|
||||
* the gateway calls the row `continue`, a home cache written by an older build still
|
||||
* carries `nextup`, and the direct path composes its own.
|
||||
*/
|
||||
@Test
|
||||
fun `every shape of the Continue Watching row is one entry point`() {
|
||||
val expected = PlaybackEntryPoint.CONTINUE_WATCHING
|
||||
assertEquals(expected, playbackEntryPointFor("continue", "CONTINUE"))
|
||||
assertEquals(expected, playbackEntryPointFor("continue", null))
|
||||
assertEquals(expected, playbackEntryPointFor("nextup", null))
|
||||
assertEquals(expected, playbackEntryPointFor("continue-watching", null))
|
||||
// A row id this build has never seen, with the kind still saying what it is.
|
||||
assertEquals(expected, playbackEntryPointFor("home-row-482", "continue"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the panes that are not rows name themselves`() {
|
||||
assertEquals(PlaybackEntryPoint.HOME_HERO, playbackEntryPointFor("home-movie-hero"))
|
||||
assertEquals(PlaybackEntryPoint.SEARCH, playbackEntryPointFor("search-results"))
|
||||
assertEquals(
|
||||
PlaybackEntryPoint.GENRE_BROWSER,
|
||||
playbackEntryPointFor("genre-browser-results"),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recommendation shelves generate their ids — a seed id, a time budget, a taste cluster —
|
||||
* so matching them one at a time would be a losing game and every household would end up
|
||||
* with a long tail of entry points used once each.
|
||||
*/
|
||||
@Test
|
||||
fun `generated recommendation rows collapse to one entry point`() {
|
||||
val expected = PlaybackEntryPoint.RECOMMENDATION
|
||||
assertEquals(expected, playbackEntryPointFor("for-you:pick-up"))
|
||||
assertEquals(expected, playbackEntryPointFor("because-you-watched:8821"))
|
||||
assertEquals(expected, playbackEntryPointFor("highly-engaged"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a favourites row is a favourites row under either spelling`() {
|
||||
assertEquals(PlaybackEntryPoint.FAVOURITES, playbackEntryPointFor("favorites"))
|
||||
assertEquals(PlaybackEntryPoint.FAVOURITES, playbackEntryPointFor("saved-row", "FAVORITES"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Nothing to go on is the detail page, not unknown: a Play press with no row behind it
|
||||
* came from a page the viewer reached some other way — the "More like this" trail, a
|
||||
* schedule card's series page, a page restored after the player returned.
|
||||
*/
|
||||
@Test
|
||||
fun `a press with no row behind it is the detail page`() {
|
||||
assertEquals(PlaybackEntryPoint.DETAIL_PAGE, playbackEntryPointFor(null, null))
|
||||
assertEquals(PlaybackEntryPoint.DETAIL_PAGE, playbackEntryPointFor(" ", null))
|
||||
assertEquals(PlaybackEntryPoint.DETAIL_PAGE, playbackEntryPointFor("some-other-row"))
|
||||
}
|
||||
|
||||
/**
|
||||
* The ids are the wire values, so they are pinned: renaming one would silently split a
|
||||
* household's history in two, with the console reporting the old name as a feature that
|
||||
* stopped being used on the day of the release.
|
||||
*/
|
||||
@Test
|
||||
fun `the wire values are stable and round-trip`() {
|
||||
assertEquals("continue_watching", PlaybackEntryPoint.CONTINUE_WATCHING.id)
|
||||
assertEquals("magic_movie", PlaybackEntryPoint.MAGIC_MOVIE.id)
|
||||
assertEquals("next_episode", PlaybackEntryPoint.NEXT_EPISODE.id)
|
||||
for (entry in PlaybackEntryPoint.entries) {
|
||||
assertEquals(entry, PlaybackEntryPoint.fromId(entry.id))
|
||||
}
|
||||
// A television talking to a build that knows an entry point this one does not still
|
||||
// records the playback, rather than dropping it for want of a name.
|
||||
assertEquals(PlaybackEntryPoint.UNKNOWN, PlaybackEntryPoint.fromId("party_mode"))
|
||||
assertEquals(PlaybackEntryPoint.UNKNOWN, PlaybackEntryPoint.fromId(null))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package com.ponzischeme89.memby.data.analytics
|
||||
|
||||
import com.ponzischeme89.memby.data.model.GatewayJourneyEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The two entry points that recorded nothing, and the one that always did.
|
||||
*
|
||||
* Continue Watching and Magic produced journeys that read as somebody arriving in the player
|
||||
* from nowhere — Magic because the press happens inside the player, which had no way to reach
|
||||
* the collector at all, and Continue Watching because the only thing naming where a playback
|
||||
* came from was the launcher's raw row id, which is the gateway's and is not a grouping
|
||||
* anything downstream could use. These pin the sequence each one now records; the detail-page
|
||||
* case is here because it is the path that already worked and must keep working.
|
||||
*/
|
||||
class PlaybackJourneyTest {
|
||||
|
||||
private fun collector() = JourneyAnalytics(
|
||||
userId = "user-1",
|
||||
now = { 1_700_000_000_000L },
|
||||
journeyId = "journey-1",
|
||||
).also { it.drain() } // Discard the home_open the journey opens with.
|
||||
|
||||
private fun List<GatewayJourneyEvent>.step(category: String, action: String) =
|
||||
singleOrNull { it.category == category && it.action == action }
|
||||
?: error("expected exactly one $category/$action in ${map { it.category + "/" + it.action }}")
|
||||
|
||||
@Test
|
||||
fun `a resume from Continue Watching records the whole sequence`() {
|
||||
val journey = collector()
|
||||
val entryPoint = playbackEntryPointFor(rowId = "continue", rowKind = "CONTINUE")
|
||||
|
||||
PlaybackJourney.requested(
|
||||
sink = journey, entryPoint = entryPoint, screen = "home",
|
||||
itemId = "ep-9", itemName = "The Pitt – 7:00 A.M.", itemType = "Episode",
|
||||
)
|
||||
PlaybackJourney.started(
|
||||
sink = journey, entryPoint = entryPoint,
|
||||
itemId = "ep-9", itemName = "The Pitt – 7:00 A.M.", itemType = "Episode",
|
||||
playSessionId = "sess-9",
|
||||
)
|
||||
val events = journey.drain()
|
||||
|
||||
assertEquals(PlaybackEntryPoint.CONTINUE_WATCHING, entryPoint)
|
||||
val requested = events.step("playback", "request")
|
||||
assertEquals("continue_watching", requested.source)
|
||||
assertEquals("player", requested.target)
|
||||
assertEquals("ep-9", requested.itemId)
|
||||
assertEquals("Episode", requested.itemType)
|
||||
assertEquals("The Pitt – 7:00 A.M.", requested.itemName)
|
||||
|
||||
val started = events.step("playback", "start")
|
||||
assertEquals("continue_watching", started.source)
|
||||
assertEquals("success", started.outcome)
|
||||
assertEquals("sess-9", started.playSessionId)
|
||||
assertEquals("user-1", started.userId)
|
||||
assertEquals("2023-11-14T22:13:20Z", started.occurredAt)
|
||||
// Ordered, and inside the one journey the app session opened with.
|
||||
assertEquals(events.map { it.sequence }.sorted(), events.map { it.sequence })
|
||||
assertTrue(events.all { it.journeyId == "journey-1" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a Magic pick records the recommendation and the playback it causes`() {
|
||||
val journey = collector()
|
||||
|
||||
// What the player does around the press, in order.
|
||||
PlaybackJourney.magicRequested(journey)
|
||||
PlaybackJourney.magicSelected(
|
||||
sink = journey, itemId = "film-3", itemName = "Boy", itemType = "Movie",
|
||||
)
|
||||
PlaybackJourney.finished(
|
||||
sink = journey, entryPoint = PlaybackEntryPoint.CONTINUE_WATCHING,
|
||||
itemId = "film-1", itemName = "Whale Rider", itemType = "Movie",
|
||||
playSessionId = "sess-1", completed = false,
|
||||
)
|
||||
PlaybackJourney.requested(
|
||||
sink = journey, entryPoint = PlaybackEntryPoint.MAGIC_MOVIE, screen = "player",
|
||||
itemId = "film-3", itemName = "Boy", itemType = "Movie",
|
||||
)
|
||||
PlaybackJourney.started(
|
||||
sink = journey, entryPoint = PlaybackEntryPoint.MAGIC_MOVIE,
|
||||
itemId = "film-3", itemName = "Boy", itemType = "Movie", playSessionId = "sess-3",
|
||||
)
|
||||
val events = journey.drain()
|
||||
|
||||
assertEquals("magic_movie", events.step("recommendations", "request").feature)
|
||||
val selected = events.step("recommendations", "select")
|
||||
assertEquals("Boy", selected.itemName)
|
||||
assertEquals("success", selected.outcome)
|
||||
|
||||
// The film that was on ends, so a chain of Magic presses does not leave every title
|
||||
// but the last one without an ending.
|
||||
val finished = events.step("playback", "complete")
|
||||
assertEquals("film-1", finished.itemId)
|
||||
assertEquals("abandoned", finished.outcome)
|
||||
|
||||
val requested = events.step("playback", "request")
|
||||
assertEquals("magic_movie", requested.source)
|
||||
assertEquals("film-3", requested.itemId)
|
||||
assertEquals("sess-3", events.step("playback", "start").playSessionId)
|
||||
|
||||
// One app session, not two: the console cuts a second *viewing* journey at the
|
||||
// playback request, and it can only do that if both films are in one journey id.
|
||||
assertEquals(setOf("journey-1"), events.map { it.journeyId }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `playing from a detail page still records a request and a start`() {
|
||||
val journey = collector()
|
||||
val entryPoint = playbackEntryPointFor(rowId = null, rowKind = null)
|
||||
|
||||
PlaybackJourney.requested(
|
||||
sink = journey, entryPoint = entryPoint, screen = "movies",
|
||||
itemId = "film-7", itemName = "Hunt for the Wilderpeople", itemType = "Movie",
|
||||
)
|
||||
PlaybackJourney.started(
|
||||
sink = journey, entryPoint = entryPoint, itemId = "film-7",
|
||||
itemName = "Hunt for the Wilderpeople", itemType = "Movie", playSessionId = "sess-7",
|
||||
)
|
||||
val events = journey.drain()
|
||||
|
||||
assertEquals(PlaybackEntryPoint.DETAIL_PAGE, entryPoint)
|
||||
assertEquals("detail_page", events.step("playback", "request").source)
|
||||
assertEquals("movies", events.step("playback", "request").screen)
|
||||
assertEquals("success", events.step("playback", "start").outcome)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failure is the same step wearing the other outcome`() {
|
||||
val journey = collector()
|
||||
PlaybackJourney.failed(
|
||||
sink = journey, entryPoint = PlaybackEntryPoint.CONTINUE_WATCHING, screen = "player",
|
||||
itemId = "ep-9", itemName = "The Pitt", itemType = "Episode",
|
||||
)
|
||||
val started = journey.drain().step("playback", "start")
|
||||
|
||||
assertEquals("failure", started.outcome)
|
||||
assertEquals("continue_watching", started.source)
|
||||
}
|
||||
|
||||
/**
|
||||
* A play session id is Emby's string, and the gateway drops a whole event whose fields it
|
||||
* cannot read. Losing the identifier is a smaller loss than losing the step, so it is
|
||||
* cleaned on the way in rather than sent as it came.
|
||||
*/
|
||||
@Test
|
||||
fun `an unreadable play session id costs the identifier and not the step`() {
|
||||
val journey = collector()
|
||||
PlaybackJourney.started(
|
||||
sink = journey, entryPoint = PlaybackEntryPoint.MAGIC_MOVIE, itemId = "film-3",
|
||||
itemName = "Boy", itemType = "Movie", playSessionId = "session for Boy (2010)",
|
||||
)
|
||||
val started = journey.drain().step("playback", "start")
|
||||
|
||||
assertEquals("sessionforBoy2010", started.playSessionId)
|
||||
assertEquals("success", started.outcome)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the player writes into the journey the launcher opened`() {
|
||||
val launcher = JourneyTracker.begin("user-1")
|
||||
launcher.drain()
|
||||
|
||||
// The launcher's own step, then one written from the player, which holds no reference
|
||||
// to the collector and reaches it through the process-wide tracker.
|
||||
PlaybackJourney.requested(
|
||||
sink = launcher, entryPoint = PlaybackEntryPoint.CONTINUE_WATCHING, screen = "home",
|
||||
itemId = "ep-9", itemName = "The Pitt", itemType = "Episode",
|
||||
)
|
||||
PlaybackJourney.started(
|
||||
sink = JourneyTracker, entryPoint = PlaybackEntryPoint.CONTINUE_WATCHING,
|
||||
itemId = "ep-9", itemName = "The Pitt", itemType = "Episode", playSessionId = "sess-9",
|
||||
)
|
||||
val events = launcher.drain()
|
||||
|
||||
assertEquals(2, events.size)
|
||||
assertEquals(1, events.map { it.journeyId }.distinct().size)
|
||||
assertEquals("sess-9", events.step("playback", "start").playSessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a profile switch starts a journey of its own`() {
|
||||
val first = JourneyTracker.begin("user-1")
|
||||
val firstJourneyId = first.drain().first().journeyId
|
||||
val second = JourneyTracker.begin("user-2")
|
||||
PlaybackJourney.started(
|
||||
sink = JourneyTracker, entryPoint = PlaybackEntryPoint.MAGIC_MOVIE, itemId = "film-3",
|
||||
itemName = "Boy", itemType = "Movie", playSessionId = "sess-3",
|
||||
)
|
||||
|
||||
// The person who signed out keeps nothing of what the next one did.
|
||||
assertTrue(first.drain().isEmpty())
|
||||
val events = second.drain()
|
||||
assertEquals("user-2", events.step("playback", "start").userId)
|
||||
assertNotEquals(firstJourneyId, events.first().journeyId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pins the two rules that decide what next-episode preloading costs.
|
||||
*
|
||||
* Neither can be checked on a device without watching an episode end, and both fail quietly:
|
||||
* preloading one episode too many is invisible except as memory on a box that had none to
|
||||
* spare, and evicting one episode too few is invisible except as a connection to Emby held
|
||||
* open for something nobody is going to watch.
|
||||
*/
|
||||
class PreloadPlanTest {
|
||||
|
||||
@Test
|
||||
fun `only the episode immediately after the one playing is preloaded`() {
|
||||
assertTrue(preloadTargetFor(rank = 1, playingRank = 0).preload)
|
||||
assertTrue(preloadTargetFor(rank = 5, playingRank = 4).preload)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the episode playing is not preloaded`() {
|
||||
// It is registered — it is what the player was handed — but there is nothing left to
|
||||
// load ahead for it, and a range target here would have Media3 open a second reader.
|
||||
assertFalse(preloadTargetFor(rank = 3, playingRank = 3).preload)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing two or more episodes ahead is preloaded`() {
|
||||
assertFalse(preloadTargetFor(rank = 2, playingRank = 0).preload)
|
||||
assertFalse(preloadTargetFor(rank = 9, playingRank = 0).preload)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing behind the episode playing is preloaded`() {
|
||||
assertFalse(preloadTargetFor(rank = 0, playingRank = 1).preload)
|
||||
assertFalse(preloadTargetFor(rank = 2, playingRank = 7).preload)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the preloaded range is bounded and starts where the episode would resume`() {
|
||||
val target = preloadTargetFor(rank = 1, playingRank = 0, startPositionMs = 42_000L)
|
||||
assertEquals(42_000L, target.startPositionMs)
|
||||
assertEquals(PRELOAD_RANGE_MS, target.durationMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a negative resume position is read as the start of the episode`() {
|
||||
// Nothing should ever send one, but a negative start would be handed to Media3 as a
|
||||
// seek before the beginning of the file.
|
||||
assertEquals(0L, preloadTargetFor(rank = 1, playingRank = 0, startPositionMs = -5L).startPositionMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `eviction never includes the episode playing`() {
|
||||
// The load-bearing one. On an advance the player has just been handed the source for
|
||||
// the episode at playingRank; removing it from the manager releases that source out
|
||||
// from under a decoder that is using it.
|
||||
assertEquals(listOf(0, 1), obsoletePreloadRanks(listOf(0, 1, 2, 3), playingRank = 2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `eviction leaves the episode ahead alone`() {
|
||||
assertEquals(emptyList<Int>(), obsoletePreloadRanks(listOf(2, 3), playingRank = 2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `eviction of an empty journey is empty`() {
|
||||
assertEquals(emptyList<Int>(), obsoletePreloadRanks(emptyList(), playingRank = 0))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user