This commit is contained in:
ponzischeme89
2026-08-18 08:41:48 +12:00
parent 1da91e40a1
commit 36d171e51b
50 changed files with 4972 additions and 377 deletions
@@ -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, "Couldnt 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, "Couldnt 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, "Couldnt 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, "Couldnt 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))
}
}