0.2.72 - Magic button, Next up fixes

This commit is contained in:
ponzischeme89
2026-08-17 11:41:36 +12:00
parent 36cb1324fd
commit 12b27c77c3
2655 changed files with 5435 additions and 254 deletions
+1 -1
View File
@@ -46,7 +46,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.71"
val defaultVersionName = "0.2.72"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -6,6 +6,7 @@ import coil.ImageLoader
import coil.disk.DiskCache
import coil.memory.MemoryCache
import com.ponzischeme89.memby.data.remote.HttpStack
import com.ponzischeme89.memby.performance.StartupTrace
import com.ponzischeme89.memby.ui.player.PrerollPreloader
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
@@ -13,6 +14,10 @@ import java.util.concurrent.TimeUnit
class MembyApp : Application() {
override fun onCreate() {
super.onCreate()
// First statement of the process that this app controls, so every launch milestone
// is measured from as close to the beginning as this code can stand. Debug-only and
// a single field write; on a release build it returns before allocating.
StartupTrace.appStart()
Coil.setImageLoader(
ImageLoader.Builder(this)
// Coil builds its own OkHttpClient when not given one, which would mean a
@@ -50,6 +55,11 @@ class MembyApp : Application() {
// Registers an idle callback only: the local four-second clip is prepared after
// the launcher's queued start-up work, never on its critical path.
PrerollPreloader.start(this)
// Opens the connection to the machine video comes from while the launcher is still
// being drawn. Only a television that has played something before knows that
// address, which is the point: the playback that was slowest was always the first
// one after a cold start, and this is what it costs to make it the second.
ServiceLocator.streamWarmer.warm()
}
/**
@@ -8,6 +8,7 @@ import com.ponzischeme89.memby.data.PreferencesSync
import com.ponzischeme89.memby.data.SettingsStore
import com.ponzischeme89.memby.data.ThemeSync
import com.ponzischeme89.memby.data.playback.installAudioCapabilityProbe
import com.ponzischeme89.memby.data.remote.StreamWarmer
import com.ponzischeme89.memby.data.remoteconfig.RemoteConfigManager
import com.ponzischeme89.memby.update.RequiredUpdateGuard
@@ -52,6 +53,15 @@ object ServiceLocator {
lateinit var requiredUpdateGuard: RequiredUpdateGuard
private set
/**
* Held because the address it remembers is the only thing in the process that knows
* where video comes from before a title has been resolved, and because both the
* repository (which learns the address) and the launcher (which spends it) need the
* same instance.
*/
internal lateinit var streamWarmer: StreamWarmer
private set
fun init(context: Context) {
if (::repository.isInitialized) return
// Only hands the probe an application context; it does no work until the first
@@ -64,7 +74,10 @@ object ServiceLocator {
// Started before the repository, so a refusal answering the very first request a
// television makes has somewhere to be recorded.
requiredUpdateGuard = RequiredUpdateGuard(settings)
repository = EmbyRepository(settings)
// Constructed before the repository so the very first playback resolution of the
// process has somewhere to report Emby's address.
streamWarmer = StreamWarmer(context.applicationContext)
repository = EmbyRepository(settings, streamWarmer)
maintenance = MaintenanceMonitor(repository, settings)
// Takes the revision channel from the status poll rather than polling itself: the
// app already asks the gateway a question every ten seconds, and settings do not
@@ -27,6 +27,7 @@ import com.ponzischeme89.memby.data.remote.EmbyApi
import com.ponzischeme89.memby.data.remote.EmbyServiceFactory
import com.ponzischeme89.memby.data.remote.GatewayApi
import com.ponzischeme89.memby.data.remote.GatewayServiceFactory
import com.ponzischeme89.memby.data.remote.StreamWarmer
import com.ponzischeme89.memby.data.remote.TrickplayClient
import com.ponzischeme89.memby.data.playback.deviceAudioCapabilities
import com.ponzischeme89.memby.data.playback.devicePlaybackCapabilities
@@ -92,6 +93,22 @@ data class NextEpisode(
val endCreditsAvailable: Boolean = false,
)
/**
* One film Magic drew, flattened to what the player needs to launch it. Deliberately not a
* [Playable]: nothing has been negotiated yet, and resolving a stream for a title the viewer
* has not yet been shown would create a playback session for something nobody watched.
*/
data class MagicPick(
val itemId: String,
val title: String,
val itemType: String = "",
val overview: String? = null,
val runtimeMs: Long = 0L,
val logoUrl: String? = null,
val backdropUrl: String? = null,
val reasons: List<String> = emptyList(),
)
/** A resolved, directly playable stream. */
data class Playable(
val itemId: String,
@@ -213,7 +230,15 @@ data class PlaybackRequest(
val runtimeMs: Long = 0L,
)
class EmbyRepository(private val settings: SettingsStore) {
/**
* [streamWarmer] is optional because it needs a Context and this class does not otherwise
* take one; a repository built without it simply never learns where video comes from, which
* costs a cold handshake on the first playback and nothing else.
*/
class EmbyRepository internal constructor(
private val settings: SettingsStore,
private val streamWarmer: StreamWarmer? = null,
) {
private val clientTrailerResolver = ClientTrailerResolver()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -1958,6 +1983,16 @@ class EmbyRepository(private val settings: SettingsStore) {
return resolvePlayableUncached(request)
}
/**
* Opens a connection to the machine video comes from, if one is not already open and the
* address is known. Called where a Play press has become likely — a card focus that has
* settled — because the handshake it saves is otherwise paid in front of the viewer.
* Costs one HEAD request at most every few minutes and never blocks.
*/
fun warmStreamConnection() {
streamWarmer?.warm()
}
/** Resolves the likely stream after focus settles, without opening or buffering it. */
suspend fun prefetchPlayable(item: BaseItem) {
if (!item.membyPlayable) return
@@ -2095,6 +2130,10 @@ class EmbyRepository(private val settings: SettingsStore) {
val request = scope.async(start = CoroutineStart.LAZY) {
try {
resolvePlayableUncached(item).also { playable ->
// Every resolution passes through here, including the speculative one
// made while a card merely has focus — so on the gateway path this is
// where the process first learns Emby's address, well before Play.
streamWarmer?.remember(playable.url)
playableMutex.withLock {
playableCache[item.itemId] = CachedPlayable(
playable = playable,
@@ -2324,6 +2363,36 @@ class EmbyRepository(private val settings: SettingsStore) {
* finale, or simply a server that would not answer. "No next episode" is an ordinary
* outcome here, so failures are swallowed: the player just shows no banner.
*/
/**
* "Put something else on." One film, chosen by the gateway from the household's own
* catalogue and this viewer's taste.
*
* Gateway-only, and null rather than an exception for every way it can fail: the pick
* needs the watch history and the imported library, so on the direct path there is nobody
* to ask, a gateway that predates the route answers 404, and a household with nothing
* unseen left answers 404 as well. The player reads all three the same way — it stops
* offering the button — because none of them is worth an error over somebody's film.
*/
suspend fun magicPick(excludeIds: List<String> = emptyList()): MagicPick? {
if (!ServerConfig.isGateway) return null
val response = runCatching {
requireGateway().magicPick(
com.ponzischeme89.memby.data.model.GatewayMagicRequest(excludeIds = excludeIds),
)
}.getOrNull() ?: return null
val item = response.item?.takeIf { it.id.isNotBlank() } ?: return null
return MagicPick(
itemId = item.id,
title = item.name.orEmpty().ifBlank { "Something to watch" },
itemType = item.type.orEmpty(),
overview = item.overview,
runtimeMs = ((item.runTimeTicks ?: 0L) / 10_000L).coerceAtLeast(0L),
logoUrl = logoUrl(item),
backdropUrl = backdropUrl(item),
reasons = response.reasons,
)
}
suspend fun nextEpisode(itemId: String, seriesId: String?): NextEpisode? {
if (itemId.isBlank()) return null
return runCatching {
@@ -95,6 +95,7 @@ class MaintenanceMonitor(
private val _tvCalendarEnabled = MutableStateFlow(false)
private val _requestsAllowed = MutableStateFlow(false)
private val _gatewayVersion = MutableStateFlow("")
private val _embyVersion = MutableStateFlow("")
/**
* Which colour scheme this viewer's televisions should be painted, as an id and a
@@ -149,6 +150,19 @@ class MaintenanceMonitor(
/** Build reported by the connected gateway, for Settings → About. */
val gatewayVersion: StateFlow<String> = _gatewayVersion.asStateFlow()
/**
* The Emby build the gateway is talking to, printed beside the gateway's own on
* Settings → About. Blank whenever it is not known — no probe has answered yet, the
* probe is switched off, or the gateway predates the field — and About then prints the
* gateway's version alone rather than an empty pair of brackets.
*
* Deliberately *not* cleared when the gateway drops out, unlike [gatewayVersion]. That
* one describes a connection that is now gone; this describes a server that is still
* whatever version it was, and About is exactly the page somebody opens when something
* has stopped working.
*/
val embyVersion: StateFlow<String> = _embyVersion.asStateFlow()
/**
* The one writer for request permission, so the flow the switcher reads and the flag the
* repository enforces on can never disagree. Two fields set separately is exactly how a
@@ -271,6 +285,12 @@ class MaintenanceMonitor(
// operator taking Memby down while Emby is also unreachable
// should not have that fact disappear from the poll.
_embyOutage.value = outageFrom(status.emby)
// Beside the outage, and for the same reason: reported even
// during maintenance, since an operator looking at About while
// Memby is down still wants to know what Emby is running.
status.emby.version.takeIf(String::isNotBlank)?.let {
_embyVersion.value = it
}
if (status.maintenance) {
// The maintenance screen owns the display; anything
// cheerful in front of it would only be confusing.
@@ -302,6 +302,8 @@ data class Settings(
val userId: String? = null,
val serverId: String? = null,
val username: String? = null,
/** Admin-defined user-switcher avatar text; blank uses the username-derived fallback. */
val profileInitials: String = "",
val deviceId: String = "",
val deviceName: String = "",
val rotationIntervalSeconds: Int = DEFAULT_ROTATION_SECONDS,
@@ -470,6 +472,7 @@ data class EmbyProfile(
val token: String,
val userId: String,
val username: String,
val profileInitials: String = "",
val serverId: String? = null,
val homeCacheJson: String? = null,
val forYouMinutes: Int = 0,
@@ -564,6 +567,7 @@ class SettingsStore(private val context: Context) {
val HOME_PINNED_ROWS = stringPreferencesKey("home_pinned_rows")
val HOME_HIDDEN_ROWS = stringPreferencesKey("home_hidden_rows")
val WELCOME_QUOTE_STYLE = stringPreferencesKey("welcome_quote_style")
val PROFILE_INITIALS = stringPreferencesKey("profile_initials")
val THEME_ID = stringPreferencesKey("theme_id")
val THEME_PALETTE = stringPreferencesKey("theme_palette")
val THEME_REVISION = stringPreferencesKey("theme_revision")
@@ -792,6 +796,7 @@ class SettingsStore(private val context: Context) {
.joinToString(",")
context.dataStore.edit { store ->
store[Keys.HOME_SECTIONS] = sections
store[Keys.PROFILE_INITIALS] = preferences.profileInitials
store[Keys.HOME_CARD_DENSITY] = preferences.homeCardDensity
store[Keys.HOME_ARTWORK_STYLE] = preferences.homeArtworkStyle
store[Keys.SHOW_HOME_CARD_METADATA] = preferences.showHomeCardMetadata
@@ -815,6 +820,7 @@ class SettingsStore(private val context: Context) {
store[Keys.PREFERENCES_REVISION] = revision
updateActiveProfile(store) {
it.copy(
profileInitials = preferences.profileInitials,
homeSections = sections,
homeCardDensity = preferences.homeCardDensity,
homeArtworkStyle = preferences.homeArtworkStyle,
@@ -1230,6 +1236,7 @@ class SettingsStore(private val context: Context) {
token = token,
userId = userId,
username = username,
profileInitials = previous?.profileInitials.orEmpty(),
serverId = serverId,
homeCacheJson = previous?.homeCacheJson,
forYouMinutes = previous?.forYouMinutes ?: 0,
@@ -1350,6 +1357,7 @@ class SettingsStore(private val context: Context) {
// Cleared with the rest: a revision left behind would make the next profile on
// this TV believe it had already synced settings it has never seen.
preferences.remove(Keys.PREFERENCES_REVISION)
preferences.remove(Keys.PROFILE_INITIALS)
preferences.remove(Keys.USERNAME)
}
@@ -1358,6 +1366,7 @@ class SettingsStore(private val context: Context) {
preferences[Keys.TOKEN] = profile.token
preferences[Keys.USER_ID] = profile.userId
preferences[Keys.USERNAME] = profile.username
preferences[Keys.PROFILE_INITIALS] = profile.profileInitials
if (profile.serverId.isNullOrBlank()) preferences.remove(Keys.SERVER_ID)
else preferences[Keys.SERVER_ID] = profile.serverId
// Prefer the profile's dedicated cache key; fall back to a copy embedded in the
@@ -1422,6 +1431,7 @@ class SettingsStore(private val context: Context) {
token = token,
userId = userId,
username = username,
profileInitials = preferences[Keys.PROFILE_INITIALS].orEmpty(),
serverId = preferences[Keys.SERVER_ID],
homeCacheJson = activeHomeCache(preferences),
forYouMinutes = preferences[Keys.FOR_YOU_MINUTES] ?: 0,
@@ -1470,6 +1480,7 @@ class SettingsStore(private val context: Context) {
userId = preferences[Keys.USER_ID],
serverId = preferences[Keys.SERVER_ID],
username = preferences[Keys.USERNAME],
profileInitials = preferences[Keys.PROFILE_INITIALS].orEmpty(),
deviceId = preferences[Keys.DEVICE_ID].orEmpty(),
deviceName = preferences[Keys.DEVICE_NAME].orEmpty(),
rotationIntervalSeconds = preferences[Keys.ROTATION_SECONDS] ?: Settings.DEFAULT_ROTATION_SECONDS,
@@ -23,6 +23,8 @@ import kotlinx.serialization.json.putJsonArray
* conversions either side of it stay unit-testable.
*/
data class UserPreferences(
/** Admin-defined avatar text; blank keeps the name-derived fallback. */
val profileInitials: String = "",
val homeSections: List<String> = DEFAULT_SECTIONS,
val homeCardDensity: String = Settings.DEFAULT_HOME_CARD_DENSITY,
val homeArtworkStyle: String = Settings.DEFAULT_HOME_ARTWORK_STYLE,
@@ -75,6 +77,7 @@ data class UserPreferences(
* every screen actually renders from, so this is the state a viewer would recognise.
*/
fun Settings.toUserPreferences(): UserPreferences = UserPreferences(
profileInitials = profileInitials,
homeSections = homeSections.decodeCommaList(),
homeCardDensity = homeCardDensity,
homeArtworkStyle = homeArtworkStyle,
@@ -116,6 +119,7 @@ fun decodeUserPreferences(
json: JsonObject,
fallback: UserPreferences = UserPreferences(),
): UserPreferences = UserPreferences(
profileInitials = json.string("profileInitials", fallback.profileInitials),
homeSections = json.stringList("homeSections", fallback.homeSections)
.ifEmpty { fallback.homeSections },
homeCardDensity = json.string("homeCardDensity", fallback.homeCardDensity),
@@ -150,6 +154,7 @@ fun decodeUserPreferences(
/** The document as the gateway expects it. The server normalises whatever arrives. */
fun UserPreferences.encode(): JsonObject = buildJsonObject {
put("profileInitials", profileInitials)
putJsonArray("homeSections") { homeSections.forEach { add(JsonPrimitive(it)) } }
put("homeCardDensity", homeCardDensity)
put("homeArtworkStyle", homeArtworkStyle)
@@ -237,6 +237,12 @@ data class GatewayEmbyHealth(
/** When the last probe ran, RFC3339. The retry countdown is measured from here. */
val checkedAt: String = "",
val retrySeconds: Int = 0,
/**
* Emby's own version, for the About page. Blank when no probe has answered yet, when the
* probe is switched off, and on a gateway that predates the field — all three of which
* the page renders the same way, by printing the gateway's version alone.
*/
val version: String = "",
) {
/** An outage worth telling the viewer about: monitored, and currently failing. */
val isOutage: Boolean get() = monitored && !reachable
@@ -483,6 +489,26 @@ data class GatewayRelated(
val items: List<BaseItem> = emptyList(),
)
/**
* What Magic drew. The item is Emby's own JSON, like every other item on this wire, so the
* player renders it with the one [BaseItem] model rather than a shape of its own.
*/
@Serializable
data class GatewayMagicPick(
val item: BaseItem? = null,
val reasons: List<String> = emptyList(),
)
/**
* The film playing now and the last few this button already offered. The exclusions live on
* the client because it is the client that knows what it has already put in front of somebody.
*/
@Serializable
data class GatewayMagicRequest(
val excludeIds: List<String> = emptyList(),
val availableMinutes: Int = 0,
)
@Serializable
data class GatewaySearchHistory(
val queries: List<String> = emptyList(),
@@ -315,6 +315,18 @@ interface GatewayApi {
@Query("seriesId") seriesId: String,
): GatewayNextEpisode
/**
* 404 when there is nothing left to suggest, and on a gateway that predates the route —
* which the player reads the same way, by withdrawing the button.
*
* A POST because the exclusion list grows with every press, and a query string that
* lengthens each time is one something in the middle eventually truncates.
*/
@POST("v1/magic")
suspend fun magicPick(
@Body body: com.ponzischeme89.memby.data.model.GatewayMagicRequest,
): com.ponzischeme89.memby.data.model.GatewayMagicPick
@GET("v1/items/{id}/trailer")
suspend fun trailer(@Path("id") itemId: String): BaseItem
@@ -0,0 +1,182 @@
package com.ponzischeme89.memby.data.remote
import android.content.Context
import android.os.SystemClock
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
/**
* Opens a connection to the machine the video will come from, before the viewer asks for
* any video.
*
* Everything else this app fetches — the home rows, every poster, the status poll — goes to
* the *gateway*. The stream does not: video always direct-plays from Emby, so the first
* playback of a session is the first time the process has spoken to that host at all. It
* therefore pays for DNS, the TCP handshake and the TLS handshake before it can even ask
* for the first byte, and it pays for them at the one moment nobody will forgive a wait.
* Measured on a Chromecast with Google TV against the NAS, the same file at the same seek
* position took 4177 ms on the first playback of a session and 2004 ms on the second; the
* connection is the difference.
*
* The fix is only worth anything because of [HttpStack]: the stream, the artwork and the
* API calls all draw from one [okhttp3.ConnectionPool], so a connection opened here by an
* unrelated request is the connection ExoPlayer picks up later. Nothing is handed over and
* nothing is remembered but the address — the pool does the rest.
*
* Three properties are deliberate:
*
* - **It warms the host, never the title.** The obvious version asks for the first byte of
* the film, which would also warm Emby's file cache — and would put a delivery for a
* title nobody watched into somebody's server history. This app already refuses to warm
* `PlaybackInfo` on focus for that reason, and the handshake is the expensive half in any
* case. Any answer establishes the connection, so the request is a bare HEAD of the
* origin and a 404 or a 405 is every bit as good as a 200.
* - **The address outlives the process.** It is learned from a resolved stream URL, which on
* the gateway path is the only thing that ever names Emby — so a television that has just
* started has no idea where the video lives until it resolves one. Remembering it is what
* moves the saving to the *first* playback after a cold start, which is exactly the one
* that was slowest. It is kept in its own small file rather than in the settings
* DataStore, which rewrites and fsyncs everything it holds on every edit.
* - **Failure is silent and costs nothing.** This is a performance hint. A server that is
* asleep, an address that has since moved, no network at all — each of them simply leaves
* playback exactly as slow as it was before.
*/
internal class StreamWarmer(
context: Context,
private val client: OkHttpClient = warmClient,
private val elapsedRealtime: () -> Long = SystemClock::elapsedRealtime,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
) {
private val appContext = context.applicationContext
/**
* Opening a [android.content.SharedPreferences] file reads it from disk, and every entry
* point here is reached from the main thread — [warm] from the launcher's idle work,
* [remember] from a resolution that may already have resumed onto it. Both hop to
* [scope] before touching it, so the one thing this class exists to protect is never
* what it delays.
*/
private val store by lazy {
appContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
}
private val origin = AtomicReference<String?>(null)
/**
* Zero means "never warmed", which [elapsedRealtime] cannot itself produce at process
* start without a race, so the first call is always allowed through.
*/
private val lastWarmedAt = AtomicLong(0L)
/**
* Records where a resolved stream came from. Called for every playback resolution,
* including the speculative one made while a card merely has focus, so in practice the
* address is known long before anybody presses Play.
*/
fun remember(streamUrl: String) {
val learned = originOf(streamUrl) ?: return
if (origin.getAndSet(learned) == learned) return
// A server that has moved invalidates the warm we would otherwise keep repeating
// against its old address.
lastWarmedAt.set(0L)
scope.launch { store.edit().putString(ORIGIN_KEY, learned).apply() }
}
/**
* Opens a connection to the remembered origin if one has not been opened recently. Safe
* to call from the main thread: the address is read and the request enqueued on [scope],
* and the response is discarded.
*
* The interval is shorter than [HttpStack]'s five-minute keep-alive so that a viewer who
* has been browsing for a while still meets a live connection rather than one the pool
* has just evicted.
*/
fun warm() {
scope.launch {
// The remembered address is only consulted when this process has not yet
// resolved a stream of its own; a live answer always wins over a stored one.
val target = (origin.get() ?: store.getString(ORIGIN_KEY, null))
?.toHttpUrlOrNull()
?: return@launch
origin.compareAndSet(null, target.toString())
val now = elapsedRealtime()
val previous = lastWarmedAt.get()
if (previous != 0L && now - previous < WARM_INTERVAL_MS) return@launch
if (!lastWarmedAt.compareAndSet(previous, now)) return@launch
enqueueWarm(target)
}
}
private fun enqueueWarm(target: HttpUrl) {
val request = Request.Builder()
.url(target)
.head()
.header("User-Agent", WARM_USER_AGENT)
.build()
client.newCall(request).enqueue(object : okhttp3.Callback {
override fun onFailure(call: okhttp3.Call, e: IOException) {
// Allow an immediate retry: the address may simply have been unreachable
// for the moment, and holding the interval against a failure would keep the
// connection cold for the next several minutes.
lastWarmedAt.set(0L)
Log.d(TAG, "connection warm failed for ${target.host}: ${e.javaClass.simpleName}")
}
override fun onResponse(call: okhttp3.Call, response: okhttp3.Response) {
// The status is irrelevant — reaching one means the handshake is done and
// the connection is in the shared pool, which is the whole errand. Closing
// is what releases it to the pool rather than leaking it.
response.close()
}
})
}
private companion object {
const val TAG = "MembyStreamWarm"
const val PREFERENCES_NAME = "memby_stream_warm"
const val ORIGIN_KEY = "stream_origin"
const val WARM_INTERVAL_MS = 3L * 60L * 1000L
const val WARM_TIMEOUT_SECONDS = 5L
const val WARM_USER_AGENT = "MbyATV"
/**
* Redirects are not followed and the timeouts are short. A warm that chased a
* redirect would open a connection to whichever host answered rather than to the one
* the stream will use, and a warm still outstanding a few seconds later has already
* missed the press it was meant to cover.
*/
val warmClient: OkHttpClient by lazy {
HttpStack.playback.newBuilder()
.followRedirects(false)
.followSslRedirects(false)
.connectTimeout(WARM_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(WARM_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build()
}
}
}
/**
* The scheme, host and port a stream URL points at, as a bare origin, or null if [url] is
* not an absolute HTTP address. Pure so the parsing can be tested without a warmer.
*/
internal fun originOf(url: String): String? {
val parsed = url.toHttpUrlOrNull() ?: return null
return HttpUrl.Builder()
.scheme(parsed.scheme)
.host(parsed.host)
.port(parsed.port)
.build()
.toString()
}
@@ -0,0 +1,115 @@
package com.ponzischeme89.memby.performance
import android.os.SystemClock
import android.util.Log
import com.ponzischeme89.memby.BuildConfig
/**
* Times the journeys between switching the television on and being able to use it.
*
* [PlaybackTrace][com.ponzischeme89.memby.ui.player.PlaybackTrace] already says where the
* time goes between pressing Play and seeing a frame, which is the number playback is judged
* by. This is the same idea for the two waits either side of it — opening the app, and
* opening a page — because "the launcher feels slow" is no more actionable than "playback is
* slow" was, and the answer is different on every television.
*
* Two shapes, because there are two kinds of question:
*
* - **Launch milestones** are cumulative from process start, in the way playback's marks are
* cumulative from the Play press: the interesting number is always how long the viewer had
* been waiting, not how long one step took alone. Each is recorded once — a relaunch is a
* new process, and an activity Android recreated behind somebody is not a second launch.
* - **Spans** are repeatable and are the shape a detail page needs, since a viewer opens
* many of them in a session and each one is its own wait.
*
* It is debug-only, like [PerformanceMonitor]. Not because the arithmetic is expensive —
* it is a subtraction and a log line — but because a television has no log anybody reads,
* so on a release build these would be pure cost. Every entry point returns immediately on
* a release build, before allocating anything.
*/
object StartupTrace {
private const val TAG = "MembyStartup"
/** Set once, from the application object. Zero means the process has not reported one. */
@Volatile
private var processStartedAtMs: Long = 0L
private val launchMilestones = LinkedHashMap<String, Long>()
private val spans = HashMap<String, Long>()
/** The moment the process began, as far as anything here is concerned. */
fun appStart() {
if (!BuildConfig.DEBUG) return
synchronized(this) {
if (processStartedAtMs != 0L) return
processStartedAtMs = SystemClock.elapsedRealtime()
launchMilestones.clear()
}
Log.i(TAG, "$APP_START")
}
/**
* Records a launch milestone and logs it as `stage=cumulative(+delta)`, the same reading
* the playback trace prints. A repeat is ignored: the first time the viewer reached that
* point is the one that describes their launch.
*/
fun mark(stage: String) {
if (!BuildConfig.DEBUG) return
val line = synchronized(this) {
val startedAt = processStartedAtMs
if (startedAt == 0L || launchMilestones.containsKey(stage)) return
val elapsed = (SystemClock.elapsedRealtime() - startedAt).coerceAtLeast(0L)
val previous = launchMilestones.values.lastOrNull() ?: 0L
launchMilestones[stage] = elapsed
"$stage=${elapsed}ms(+${(elapsed - previous).coerceAtLeast(0L)}ms)"
}
Log.i(TAG, line)
}
/**
* Opens a repeatable span. Starting one that is already open replaces it, which is the
* correct reading: a viewer who pressed into a second page before the first had drawn is
* waiting for the second one.
*/
fun beginSpan(name: String) {
if (!BuildConfig.DEBUG) return
synchronized(this) { spans[name] = SystemClock.elapsedRealtime() }
}
/**
* Closes a span and logs its duration. Closing one that was never opened is silent
* rather than an error — a detail page can be reached by a route that did not announce
* itself, and instrumentation must never be what draws attention to itself.
*/
fun endSpan(name: String) {
if (!BuildConfig.DEBUG) return
val line = synchronized(this) {
val startedAt = spans.remove(name) ?: return
"$name=${(SystemClock.elapsedRealtime() - startedAt).coerceAtLeast(0L)}ms"
}
Log.i(TAG, line)
}
/** Discards an open span without reporting it — the viewer navigated away instead. */
fun cancelSpan(name: String) {
if (!BuildConfig.DEBUG) return
synchronized(this) { spans.remove(name) }
}
/** The process is alive and the application object has run. */
const val APP_START = "app_start"
/** The launcher has composed its first frame of actual content. */
const val HOME_VISIBLE = "home_visible"
/** The first home row with something in it has been laid out. */
const val FIRST_ROW_VISIBLE = "first_row_visible"
/** Every row the launcher intends to show has arrived; nothing is still loading. */
const val HOME_INTERACTIVE = "home_interactive"
/** A detail page: from the press that asked for it to the frame that shows it. */
const val DETAIL = "detail_visible"
}
@@ -262,6 +262,7 @@ fun TvNavigationRail(
modifier: Modifier = Modifier,
alertCount: Int = 0,
activeUsername: String = "",
activeProfileInitials: String = "",
calendarEnabled: Boolean = false,
) {
var railHasFocus by remember { mutableStateOf(false) }
@@ -420,7 +421,7 @@ fun TvNavigationRail(
},
avatarInitials = activeUsername.takeIf {
destination == BrowseDestination.PROFILES
}?.let(::profileInitials),
}?.let { profileInitials(it, activeProfileInitials) },
)
Spacer(Modifier.height(4.dp))
}
@@ -700,7 +701,7 @@ private fun UserSwitcherProfileItem(
contentAlignment = Alignment.Center,
) {
Text(
profileInitials(profile.username),
profileInitials(profile.username, profile.profileInitials),
color = Color.White,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
@@ -888,8 +889,9 @@ fun ExpandableNavigationItem(
}
}
/** Two readable initials for compact profile avatars: AlwynV → AV, Matt Cohen → MC. */
internal fun profileInitials(username: String): String {
/** Admin-defined initials win; otherwise derive two readable characters from the name. */
internal fun profileInitials(username: String, override: String = ""): String {
override.trim().takeIf(String::isNotEmpty)?.let { return it.take(2).uppercase() }
val trimmed = username.trim()
if (trimmed.isEmpty()) return "?"
val words = trimmed.split(Regex("\\s+")).filter(String::isNotBlank)
@@ -406,6 +406,11 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
*/
private suspend fun warmDetailPage(item: BaseItem) {
if (item.isSchedule) return
// Focus settling on a playable card is the best warning of a Play press this app
// gets. Opening the connection to Emby now means the press pays for bytes rather
// than for DNS, TCP and TLS. It is not part of the coroutineScope below because
// nothing waits on it and it cannot fail in a way anybody should hear about.
if (item.membyPlayable) repository.warmStreamConnection()
coroutineScope {
// Related, episodes and trailers are detail-page work. Waiting until focus has
// genuinely settled prevents a held D-pad from starting long-lived requests
@@ -151,6 +151,7 @@ import com.ponzischeme89.memby.ui.genre.GenreBrowseScreen
import com.ponzischeme89.memby.ui.player.PlayerActivity
import com.ponzischeme89.memby.ui.player.PrerollPreloader
import com.ponzischeme89.memby.performance.PerformanceMonitor
import com.ponzischeme89.memby.performance.StartupTrace
import com.ponzischeme89.memby.ui.search.SearchScreen
import com.ponzischeme89.memby.ui.settings.SettingsSheet
import com.ponzischeme89.memby.ui.whatsnew.WhatsNewDecision
@@ -1226,6 +1227,7 @@ private fun ProfileChooser(
) {
ProfileTile(
name = profile.username,
symbol = profileInitials(profile.username, profile.profileInitials),
current = profile.id == currentProfileId,
enabled = switchingProfileId == null && removingProfileId == null,
onClick = { onSelect(profile) },
@@ -2329,6 +2331,22 @@ private fun HomeScreen(
}
applyWatchedVisibility(destinationRows, settings.hideWatchedMovies)
}
// The three launch milestones, recorded from effects rather than from the composable
// body so that measuring the launcher can never be a reason the launcher recomposes.
// Each is keyed on the condition that decides it, so it runs when that condition
// changes rather than on every pass; StartupTrace ignores a repeat in any case.
LaunchedEffect(Unit) { StartupTrace.mark(StartupTrace.HOME_VISIBLE) }
val hasPopulatedRow = rows.any { it.items.isNotEmpty() }
LaunchedEffect(hasPopulatedRow) {
if (hasPopulatedRow) StartupTrace.mark(StartupTrace.FIRST_ROW_VISIBLE)
}
// Interactive means nothing is still arriving. A cached launcher reaches this almost at
// once, which is the whole promise HomeCache makes; a cold one reaches it when the last
// section lands.
val homeSettled = hasPopulatedRow && homeContent.loading.isEmpty()
LaunchedEffect(homeSettled) {
if (homeSettled) StartupTrace.mark(StartupTrace.HOME_INTERACTIVE)
}
// Keyed on the day as well as the rows, so the feature changes when the date does and
// not merely when the launcher happens to be rebuilt.
val heroDay = rememberHomeHeroDay()
@@ -2460,7 +2478,11 @@ private fun HomeScreen(
Row(Modifier.fillMaxSize()) {
TvNavigationRail(
config = remoteConfig.navigation,
selected = selectedDestination,
selected = if (userSwitcherVisible) {
BrowseDestination.PROFILES
} else {
selectedDestination
},
focusDestination = railFocusDestination,
expanded = navigationExpanded,
navigationFocusRequester = navigationFocusRequester,
@@ -2534,6 +2556,7 @@ private fun HomeScreen(
},
alertCount = displayedNotifications.size,
activeUsername = settings.username.orEmpty(),
activeProfileInitials = settings.profileInitials,
calendarEnabled = tvCalendarEnabled,
)
androidx.compose.foundation.layout.BoxWithConstraints(
@@ -3348,6 +3371,13 @@ private fun HomeScreen(
onPauseOrDispose { }
}
detailsItem?.let { selected ->
// A detail page is opened by a press that sets `detailsItem`, so the composition
// this runs in *is* the frame that press caused — which makes it the earliest
// point that can honestly stand for "the viewer asked for this page". Keyed on
// the item, so walking a "More like this" trail times each page separately.
// Debug-only: on a release build both calls return before allocating.
remember(selected.id) { StartupTrace.beginSpan(StartupTrace.DETAIL) }
LaunchedEffect(selected.id) { StartupTrace.endSpan(StartupTrace.DETAIL) }
BackHandler {
val previous = detailsTrail.lastOrNull()
if (previous != null) {
@@ -3536,7 +3566,11 @@ private fun HomeScreen(
) {
TvNavigationRail(
config = remoteConfig.navigation,
selected = selectedDestination,
selected = if (userSwitcherVisible) {
BrowseDestination.PROFILES
} else {
selectedDestination
},
expanded = requestsRailExpanded,
navigationFocusRequester = requestsRailFocusRequester,
onRailFocusChanged = { requestsRailExpanded = it },
@@ -3580,6 +3614,7 @@ private fun HomeScreen(
},
alertCount = displayedNotifications.size,
activeUsername = settings.username.orEmpty(),
activeProfileInitials = settings.profileInitials,
calendarEnabled = tvCalendarEnabled,
)
RequestsScreen(
@@ -0,0 +1,198 @@
package com.ponzischeme89.memby.ui.player
import com.ponzischeme89.memby.data.NextEpisode
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* The one authoritative answer to "what plays after this?".
*
* Before this there were three callers asking the question and only one of them ever got an
* answer: the credits pane, the next-up banner and the ended-frame handler all read a single
* [NextEpisode] field that [NextUpResolver] now owns, and that field was only ever populated
* when auto-play was switched on so with the setting off the credits pane and the banner
* were unreachable code. Resolution and *automatic advance* are two different decisions and
* are now two different things: this resolves unconditionally for episodic content, and
* [shouldAutoAdvance] is the only place the viewer's setting is consulted.
*
* Everything that wants to know goes through one instance the manual Next Episode button,
* the credits pane, the countdown and the ended-frame handler alike so there is exactly one
* request per episode and no way for two parts of the player to disagree about what is next.
*/
internal class NextUpResolver(
private val scope: CoroutineScope,
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?,
) {
/** Guards the whole of [resolve]: two callers must never produce two requests. */
private val lock = Mutex()
/** The item the current answer is *about*, not the item the answer names. */
private var subjectId: String? = null
private var answer: NextEpisode? = null
/**
* Nullable rather than a zero sentinel: [android.os.SystemClock.elapsedRealtime] is a
* time since boot and is legitimately near zero on a television that has just been
* switched on, which read as a sentinel would make every answer instantly stale and turn
* every press into a fresh request.
*/
private var resolvedAt: Long? = null
private var inFlight: CompletableDeferred<NextEpisode?>? = null
private var job: Job? = null
/** Non-blocking read for the render path. Null means "no next episode, or not yet known". */
val current: NextEpisode? get() = answer
/** True while a lookup is outstanding, so the ended frame can wait rather than leave. */
val resolving: Boolean get() = inFlight != null
/**
* Points the resolver at a new episode and starts the background lookup.
*
* Called once when playback settles and again on every auto-advance. Discarding the
* previous answer here is what stops the outgoing episode's successor being offered
* against the incoming one.
*/
fun begin(itemId: String?) {
job?.cancel()
job = null
inFlight?.cancel()
inFlight = null
subjectId = itemId?.takeIf { it.isNotBlank() }
answer = null
resolvedAt = null
val id = subjectId ?: return
job = scope.launch { resolve(id) }
}
/**
* The single-flight lookup. Repeated calls for the same subject the credits marker
* firing, a progress tick and a press of the Next Episode button can all arrive within a
* frame of each other join the outstanding request instead of starting another.
*/
suspend fun resolve(itemId: String, forceRefresh: Boolean = false): NextEpisode? {
// Whoever wins the lock owns the request; everyone else is handed its deferred and
// awaits it *outside* the lock, or the winner could never take the lock back to
// publish the answer they are all waiting for.
val pending = CompletableDeferred<NextEpisode?>()
val joined = lock.withLock {
if (subjectId != itemId) return null
if (!forceRefresh && answer != null && !stale()) return answer
val outstanding = inFlight
if (outstanding != null) {
outstanding
} else {
inFlight = pending
null
}
}
if (joined != null) return joined.await()
val resolved = runCatching { fetch(itemId) }.getOrNull()
// A next episode with no stream behind it is not a next episode: kept as one it
// 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) {
answer = resolved
resolvedAt = elapsedRealtime()
}
inFlight = null
}
pending.complete(resolved)
return resolved
}
/**
* What to actually hand [android.media.MediaPlayer] re-resolving first if the stream has
* gone cold.
*
* The URL and its play session are negotiated when playback *starts*, which for a
* three-quarter-hour episode is three-quarters of an hour before the credits roll. Emby
* expires a play session well inside that, which is the intermittent "could not establish
* the stream" a viewer saw on perhaps one advance in four. The metadata is still good, so
* the banner and the button are drawn from the stale copy immediately and only the stream
* is fetched again.
*/
suspend fun playable(itemId: String): NextEpisode? =
if (answer != null && !stale()) answer else resolve(itemId, forceRefresh = true)
/**
* Re-resolves ahead of the moment of use, so the press itself never waits on a request.
* Called as the playhead enters the closing stretch.
*/
fun warm() {
val id = subjectId ?: return
if (answer == null || !stale() || inFlight != null) return
scope.launch { resolve(id, forceRefresh = true) }
}
private fun stale(): Boolean =
resolvedAt?.let { elapsedRealtime() - it >= STREAM_FRESHNESS_MS } ?: true
fun cancel() {
job?.cancel()
job = null
inFlight?.cancel()
inFlight = null
subjectId = null
answer = null
resolvedAt = null
}
internal companion object {
/**
* How long a negotiated stream is trusted for. Comfortably inside Emby's own play
* session expiry, and long enough that an ordinary advance costs no extra request.
*/
const val STREAM_FRESHNESS_MS = 10 * 60_000L
}
}
/**
* Whether the player should transition by itself when the countdown reaches zero.
*
* The one place [com.ponzischeme89.memby.data.Settings.autoPlayNextEpisode] is read. The
* offer the banner, the credits pane and the manual button is not conditional on it:
* a viewer who has turned automatic advance off has said they want to press something, not
* that they want to be returned to the launcher and made to find the next episode by hand.
*/
internal fun shouldAutoAdvance(
autoPlayEnabled: Boolean,
hasNextEpisode: Boolean,
dismissed: Boolean,
): Boolean = autoPlayEnabled && hasNextEpisode && !dismissed
/**
* Whether the manual Next Episode control belongs on the transport row.
*
* Deliberately not gated on the playhead: the spec is that a viewer may skip ahead at any
* point, so this is true from the moment the lookup answers. A movie resolves to null and
* the button never appears, which is why nothing here special-cases item type.
*/
internal fun shouldOfferNextEpisodeButton(
hasNextEpisode: Boolean,
advancing: Boolean,
playingPreview: Boolean,
): Boolean = hasNextEpisode && !advancing && !playingPreview
/**
* Whether the Magic control belongs on the transport row.
*
* Withheld for episodic content on purpose. The server's picker is films only
* (`recommend.magicCandidates` `onlyMovies`), on the reasoning that the button plays
* something immediately and a series is a question about which episode and beside an
* explicit Next Episode action, a second "play something else" control is two answers to one
* question. So Magic is the movie player's control and Next Episode is the episode player's.
*/
internal fun shouldOfferMagicButton(
isEpisode: Boolean,
magicAvailable: Boolean,
advancing: Boolean,
playingPreview: Boolean,
): Boolean = magicAvailable && !isEpisode && !advancing && !playingPreview
@@ -68,5 +68,18 @@ internal class PlaybackTrace(
/** A frame is on the screen. This is the number the viewer actually experiences. */
const val FIRST_FRAME = "first_frame"
/**
* Sound is actually coming out media3's audio position has begun advancing, which
* is a stronger claim than the renderer having been enabled.
*
* It is worth having beside [FIRST_FRAME] rather than assumed from it, because the
* two come apart in the cases most worth diagnosing: a surround track being
* bitstreamed waits on the receiver to lock to the format, and a track this
* television cannot decode in hardware falls back to the software decoder. Either
* can leave a picture running silently for a moment, which a viewer reads as broken
* rather than as slow, and which no video-side mark would ever show.
*/
const val AUDIO_START = "audio_start"
}
}
@@ -45,6 +45,7 @@ import androidx.media3.common.Tracks
import androidx.media3.common.VideoSize
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.analytics.AnalyticsListener
import androidx.media3.ui.CaptionStyleCompat
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
@@ -69,6 +70,7 @@ import com.ponzischeme89.memby.data.NextEpisode
import com.ponzischeme89.memby.data.ResolvedRemoteTrailer
import com.ponzischeme89.memby.data.Playable
import com.ponzischeme89.memby.data.PlayableSubtitle
import com.ponzischeme89.memby.data.ServerConfig
import com.ponzischeme89.memby.data.PlaybackRequest
import com.ponzischeme89.memby.data.PlaybackSession
import com.ponzischeme89.memby.data.SKIP_INTRO_AUTO
@@ -305,9 +307,19 @@ class PlayerActivity : ComponentActivity() {
private var fullscreenPlayerIndex = -1
private var fullscreenPlayerLayoutParams: ViewGroup.LayoutParams? = null
// "Next up" state. [nextEpisode] is prefetched as soon as playback settles so the
// banner can appear — and the next episode start — without waiting on the network.
private var nextEpisode: NextEpisode? = null
// "Next up" state. The resolver is the single authority: the credits pane, the next-up
// banner, the manual Next Episode button and the ended frame all read [nextEpisode] from
// it rather than asking Emby their own question. It is pointed at the current episode as
// soon as playback settles, so the banner can appear — and the next episode start —
// without waiting on the network.
private val nextUpResolver by lazy {
NextUpResolver(
scope = lifecycleScope,
elapsedRealtime = SystemClock::elapsedRealtime,
fetch = { id -> ServiceLocator.repository.nextEpisode(id, seriesId = null) },
)
}
private val nextEpisode: NextEpisode? get() = nextUpResolver.current
private var returningHomeAfterCompletion = false
private var nextUpJob: Job? = null
private var nextEpisodeLookupJob: Job? = null
@@ -336,6 +348,28 @@ class PlayerActivity : ComponentActivity() {
private var nextUpCountdown: TextView? = null
private var nextUpDismissed = false
private var advancing = false
/**
* Claimed the instant an advance is asked for, and released only once [playNext] has the
* stream in hand. [advancing] cannot do this job: it is not set until the coroutine that
* re-negotiates the stream comes back, and the credits marker, a progress tick and a
* button press all arrive well inside that window.
*/
private var advanceRequested = false
private var nextEpisodeButton: View? = null
private var magicButton: View? = null
private var magicJob: Job? = null
/** What Magic has already offered this session, so a second press is a second film. */
private val magicOffered = mutableListOf<String>()
/**
* Starts true in gateway mode and is switched off by the first request that cannot be
* answered. The pick needs the household's watch history and imported catalogue, so on
* the direct path and against a gateway that predates the route there is nobody to
* ask, and a control that only ever apologises is worse than no control.
*/
private var magicAvailable = ServerConfig.isGateway
private var encodedSubtitleJob: Job? = null
private var subtitleStreamGeneration = 0L
private var restoredPositionMs: Long? = null
@@ -560,6 +594,22 @@ class PlayerActivity : ComponentActivity() {
showSubtitleOverlay()
}
view.findViewById<View>(R.id.player_cast)?.setOnClickListener { showCastOverlay() }
nextEpisodeButton = view.findViewById<View>(R.id.player_next_episode)?.apply {
setOnClickListener {
// The same resolved answer the credits pane and the countdown use, and the
// same single entry point, so pressing this during the countdown cannot
// produce a second advance alongside the automatic one.
view.hideController()
startNextEpisode()
}
}
magicButton = view.findViewById<View>(R.id.player_magic)?.apply {
setOnClickListener {
view.hideController()
playSomethingElse()
}
}
updateNextEpisodeButton()
view.findViewById<View>(R.id.player_hide_controls)?.setOnClickListener { view.hideController() }
view.findViewById<View>(R.id.player_exit)?.setOnClickListener { finish() }
view.findViewById<View>(androidx.media3.ui.R.id.exo_settings)?.setOnClickListener {
@@ -647,6 +697,18 @@ class PlayerActivity : ComponentActivity() {
player = createdPlayer.also { playback ->
view.player = playback
trace.mark(PlaybackTrace.PLAYER_BUILT)
// Audio start is not on Player.Listener — only the analytics interface
// reports the moment the audio position actually begins advancing, which is
// the difference between the sink being open and sound being audible. One
// callback, recorded once per launch by the trace itself.
playback.addAnalyticsListener(object : AnalyticsListener {
override fun onAudioPositionAdvancing(
eventTime: AnalyticsListener.EventTime,
playoutStartSystemTimeMs: Long,
) {
trace.mark(PlaybackTrace.AUDIO_START)
}
})
playback.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
MembyDiagnostics.debug("media3_state", "playback" to playSessionId, "item" to itemId, "state" to media3StateName(playbackState),
@@ -2910,7 +2972,7 @@ class PlayerActivity : ComponentActivity() {
nextUpBanner = banner
nextUpCountdown = banner.findViewById(R.id.player_next_up_countdown)
banner.findViewById<View>(R.id.player_next_up_play).setOnClickListener {
nextEpisode?.let(::playNext)
startNextEpisode()
}
banner.findViewById<View>(R.id.player_next_up_dismiss).setOnClickListener {
dismissNextUp()
@@ -2925,23 +2987,20 @@ class PlayerActivity : ComponentActivity() {
private fun prefetchNextEpisode() {
nextEpisodeLookupJob?.cancel()
nextEpisodePreviewLookupJob?.cancel()
nextEpisode = null
nextEpisodePreview = null
previewWindowArmed = false
val id = itemId?.takeIf { it.isNotBlank() } ?: return
val id = itemId?.takeIf { it.isNotBlank() }
// Points the one resolver at this episode, discarding the outgoing episode's answer.
nextUpResolver.begin(id)
updateNextEpisodeButton()
if (id == null) return
nextEpisodeLookupJob = lifecycleScope.launch {
val playbackSettings = ServiceLocator.repository.settingsFlow.first()
val resolved = if (playbackSettings.autoPlayNextEpisode) {
// A next episode with no stream behind it is not a next episode. Kept as one
// it would put up a banner and a countdown promising something that cannot be
// played, and then — because the countdown runs itself out — swap it in
// automatically and fail, with a viewer who pressed nothing having their
// programme replaced by an error.
ServiceLocator.repository.nextEpisode(id, seriesId = null)
?.takeIf { it.url.isNotBlank() }
} else {
null
}
// Unconditional: the viewer's auto-play setting decides whether the transition
// happens by itself, not whether the player is allowed to *know* what is next.
// Gating the lookup on it is what left the credits pane, the next-up banner and
// the ended frame reading a field that was permanently null with the setting off.
val resolved = nextUpResolver.resolve(id)
resolved?.imageUrl?.let { imageUrl ->
imageLoader.enqueue(
ImageRequest.Builder(this@PlayerActivity)
@@ -2952,15 +3011,24 @@ class PlayerActivity : ComponentActivity() {
}
// Playback may have moved on to another episode while this was in flight.
if (itemId == id) {
nextEpisode = resolved
updateNextEpisodeButton()
if (resolved != null && playbackSettings.playNextEpisodePreview) {
prefetchNextEpisodePreview(id, resolved)
}
// Extremely short episodes and hostile latency can reach Ended before the
// lookup. The ended frame waits for this answer rather than leaving Home.
if (player?.playbackState == Player.STATE_ENDED) {
when (playbackCompletionAction(resolved != null, nextUpDismissed)) {
PlaybackCompletionAction.PLAY_NEXT -> resolved?.let(::playNext)
when (
playbackCompletionAction(
hasNextEpisode = shouldAutoAdvance(
autoPlayEnabled = playbackSettings.autoPlayNextEpisode,
hasNextEpisode = resolved != null,
dismissed = nextUpDismissed,
),
nextUpDismissed = nextUpDismissed,
)
) {
PlaybackCompletionAction.PLAY_NEXT -> startNextEpisode()
PlaybackCompletionAction.RETURN_HOME -> returnHomeAfterCompletion()
PlaybackCompletionAction.WAIT_FOR_NEXT -> Unit
}
@@ -2969,6 +3037,99 @@ class PlayerActivity : ComponentActivity() {
}
}
/**
* Whether this is a programme rather than a film. Read off the series name the launcher
* handed over, which is set for every episode and blank for everything else, rather than
* from the presence of a next episode a series finale is still an episode, and Magic
* must not appear on one just because nothing follows it.
*/
private val playingAnEpisode: Boolean
get() = !playbackSeriesName.isNullOrBlank() || prerollEpisodeCode.isNotBlank()
/**
* Puts the two optional transport controls in step with what is actually known.
*
* Called whenever the resolver's answer could have changed. Both controls are removed
* rather than disabled: a television is driven by a D-pad, and a greyed button is a stop
* on the way to the one the viewer wanted.
*/
private fun updateNextEpisodeButton() {
nextEpisodeButton?.isVisible = shouldOfferNextEpisodeButton(
hasNextEpisode = nextEpisode != null,
advancing = advancing,
playingPreview = playingNextEpisodePreview,
)
magicButton?.isVisible = shouldOfferMagicButton(
isEpisode = playingAnEpisode,
magicAvailable = magicAvailable,
advancing = advancing,
playingPreview = playingNextEpisodePreview,
)
}
/**
* Magic: "put something else on, I don't mind what."
*
* The pick is the gateway's the same taste profile the home rows and the recommendation
* reasons are built from because the television holds neither the watch history nor the
* catalogue to draw from. [magicOffered] is what stops a second press repeating the first
* answer, and travels with the request rather than living on the server because it is the
* player that knows what it has already put in front of this viewer.
*/
private fun playSomethingElse() {
if (magicJob?.isActive == true || advanceRequested || advancing) return
val current = itemId?.takeIf { it.isNotBlank() }
magicJob = lifecycleScope.launch {
showPlaybackLoading(
title = getString(R.string.player_magic_finding),
hint = getString(R.string.player_magic_pick_hint),
)
val pick = runCatching {
ServiceLocator.repository.magicPick(
// Never the film playing right now, and never one of the last few this
// button has already offered.
excludeIds = (listOfNotNull(current) + magicOffered).distinct(),
)
}.getOrNull()
if (pick == null) {
// 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.
magicAvailable = false
updateNextEpisodeButton()
hidePlaybackLoading()
Toast.makeText(this@PlayerActivity, R.string.player_magic_empty, Toast.LENGTH_SHORT)
.show()
return@launch
}
magicOffered += pick.itemId
if (magicOffered.size > MAGIC_MEMORY) magicOffered.removeAt(0)
Toast.makeText(
this@PlayerActivity,
getString(R.string.player_magic_selected, pick.title),
Toast.LENGTH_SHORT,
).show()
// A film is a new subject, not the next step of this one, so it goes through the
// ordinary launch rather than through playNext: a fresh player, a fresh pre-roll
// decision and a fresh session, exactly as pressing Play on its detail page gives.
startActivity(
intent(
this@PlayerActivity,
PlaybackRequest(
itemId = pick.itemId,
itemType = pick.itemType,
title = pick.title,
overview = pick.overview,
runtimeMs = pick.runtimeMs,
logoUrl = pick.logoUrl,
),
backdropUrl = pick.backdropUrl,
),
)
finish()
}
}
private fun prefetchNextEpisodePreview(currentItemId: String, next: NextEpisode) {
nextEpisodePreviewLookupJob?.cancel()
nextEpisodePreviewLookupJob = lifecycleScope.launch {
@@ -3019,14 +3180,29 @@ class PlayerActivity : ComponentActivity() {
// amount. Raising the banner over it would fight for the transform and print the
// episode twice, so the countdown moves into the pane instead — and it is only worth
// drawing inside the last minute, where it was always the banner's job.
// Re-negotiate the stream ahead of the moment of use. The URL and play session were
// resolved when this episode started, which for a full-length episode is long enough
// ago for Emby to have expired the session — the intermittent "could not establish
// the stream" on an advance. Doing it here means the press never waits on a request.
if (remainingMs <= NEXT_UP_STREAM_WARM_LEAD_MS) nextUpResolver.warm()
val autoAdvance = shouldAutoAdvance(
autoPlayEnabled = ServiceLocator.repository.currentSettings.autoPlayNextEpisode,
hasNextEpisode = true,
dismissed = nextUpDismissed,
)
if (creditsActive) {
if (remainingMs in 1L..NEXT_UP_LEAD_MS) {
// The countdown is a promise that something will happen by itself, so it is drawn
// only where it is true. With automatic advance off the pane still says what is
// next and its Play action still starts it — the offer is not the transition.
if (autoAdvance && remainingMs in 1L..NEXT_UP_LEAD_MS) {
creditsCountdown?.text = creditsCountdownLabel(remainingMs)
creditsCountdownGroup?.visibility = View.VISIBLE
} else {
creditsCountdownGroup?.visibility = View.GONE
}
if (remainingMs == 0L) playNext(next)
if (remainingMs == 0L && autoAdvance) startNextEpisode()
return
}
@@ -3036,11 +3212,18 @@ class PlayerActivity : ComponentActivity() {
nextUpDismissed = false
}
nextUpDismissed -> Unit
remainingMs == 0L -> playNext(next)
remainingMs == 0L -> if (autoAdvance) startNextEpisode()
else -> {
showNextUp(next)
val seconds = ceil(remainingMs / 1_000.0).toInt()
nextUpCountdown?.text = getString(R.string.next_up_starting_in, seconds)
nextUpCountdown?.apply {
if (autoAdvance) {
val seconds = ceil(remainingMs / 1_000.0).toInt()
text = getString(R.string.next_up_starting_in, seconds)
visibility = View.VISIBLE
} else {
visibility = View.GONE
}
}
}
}
}
@@ -3064,6 +3247,7 @@ class PlayerActivity : ComponentActivity() {
previewOutgoingReported = false
previewNextEpisode = next
playingNextEpisodePreview = true
updateNextEpisodeButton()
stopReported = true // Prevent preview positions being reported against the episode.
stopProgressUploading()
@@ -3102,9 +3286,10 @@ class PlayerActivity : ComponentActivity() {
nextEpisodePreviewTimeoutJob = null
nextEpisodePreview = null
playingNextEpisodePreview = false
updateNextEpisodeButton()
previewNextEpisode = null
if (previewOutgoingReported) {
next?.let(::playNext) ?: returnHomeAfterCompletion()
if (next != null) startNextEpisode() else returnHomeAfterCompletion()
return
}
playbackTitle = previewResumeTitle
@@ -3127,7 +3312,7 @@ class PlayerActivity : ComponentActivity() {
if (previewResumeUrl.isBlank()) {
// The original stream should always be known, but losing the optional preview
// must never strand the viewer on a generic error pane.
next?.let(::playNext) ?: finish()
if (next != null) startNextEpisode() else finish()
return
}
showPlaybackLoading(title = playbackTitle, hint = "Returning to the episode")
@@ -3161,7 +3346,7 @@ class PlayerActivity : ComponentActivity() {
playingNextEpisodePreview = false
previewNextEpisode = null
nextEpisodePreview = null
next?.let(::playNext) ?: returnHomeAfterCompletion()
if (next != null) startNextEpisode() else returnHomeAfterCompletion()
}
@OptIn(UnstableApi::class)
@@ -3251,7 +3436,7 @@ class PlayerActivity : ComponentActivity() {
creditsLogo = view.findViewById(R.id.player_end_credits_logo)
creditsLogoText = view.findViewById(R.id.player_end_credits_logo_text)
view.findViewById<View>(R.id.player_end_credits_play).setOnClickListener {
nextEpisode?.let(::playNext)
startNextEpisode()
}
view.findViewById<View>(R.id.player_end_credits_dismiss).setOnClickListener {
dismissEndCredits()
@@ -3567,12 +3752,21 @@ class PlayerActivity : ComponentActivity() {
)
}
}
val autoPlayEnabled = ServiceLocator.repository.currentSettings.autoPlayNextEpisode
when (playbackCompletionAction(
hasNextEpisode = nextEpisode != null,
hasNextEpisode = shouldAutoAdvance(
autoPlayEnabled = autoPlayEnabled,
hasNextEpisode = nextEpisode != null,
dismissed = nextUpDismissed,
),
nextUpDismissed = nextUpDismissed,
nextLookupInFlight = nextEpisodeLookupJob?.isActive == true,
// Only worth holding the ended frame for an answer that could change what
// happens. With automatic advance off it cannot: the viewer is going back to the
// launcher either way, and waiting would only be a pause before it.
nextLookupInFlight = autoPlayEnabled &&
(nextUpResolver.resolving || nextEpisodeLookupJob?.isActive == true),
)) {
PlaybackCompletionAction.PLAY_NEXT -> nextEpisode?.let(::playNext)
PlaybackCompletionAction.PLAY_NEXT -> startNextEpisode()
PlaybackCompletionAction.RETURN_HOME -> returnHomeAfterCompletion()
PlaybackCompletionAction.WAIT_FOR_NEXT -> showPlaybackLoading(
title = "Finding the next episode…",
@@ -3607,9 +3801,46 @@ class PlayerActivity : ComponentActivity() {
* no teardown, no black frame between episodes. The outgoing episode is reported
* stopped first so Emby records it as finished.
*/
/**
* The one way anything asks for the next episode to start.
*
* Every trigger goes through here the manual button, the next-up banner's Play, the
* credits pane's Play, the countdown reaching zero, the ended frame and the end of a
* preview so that two of them arriving together cannot produce two advances.
* [advanceRequested] is claimed synchronously, before the coroutine that does the work,
* because the credits marker, a progress tick and a button press can all land inside one
* frame and [advancing] is not set until [playNext] itself runs.
*
* It also re-negotiates a stale stream first. The URL and play session handed over by the
* resolver were obtained when *this* episode started; on a full-length episode that is
* long enough ago for Emby to have expired the session, which is the intermittent failure
* to establish the stream that an advance used to show. When the stream is still fresh
* this costs a dispatch and nothing else.
*/
private fun startNextEpisode() {
if (advanceRequested || advancing || returningHomeAfterCompletion) return
val subject = itemId?.takeIf { it.isNotBlank() } ?: return
val known = nextEpisode ?: return
advanceRequested = true
showPlaybackLoading(title = nextTitle(known), hint = "Starting the next episode")
lifecycleScope.launch {
// Falling back to the known copy rather than failing: a re-negotiation that could
// not be made is no reason to refuse an advance the old stream may well serve.
val fresh = runCatching { nextUpResolver.playable(subject) }.getOrNull() ?: known
advanceRequested = false
playNext(fresh)
}
}
private fun playNext(next: NextEpisode) {
if (advancing || returningHomeAfterCompletion) return
advancing = true
advanceRequested = false
// The outgoing episode's successor is the episode now starting. Clearing the resolver
// is what stops the banner offering the programme the viewer is watching; the new
// session's own lookup begins from startPlaybackSession a moment later.
nextUpResolver.cancel()
updateNextEpisodeButton()
nextUpJob?.cancel()
nextEpisodeLookupJob?.cancel()
nextEpisodePreviewLookupJob?.cancel()
@@ -3684,7 +3915,6 @@ class PlayerActivity : ComponentActivity() {
// a different point, and a speed left behind would run the next episode's opening
// scene at double speed.
resetEndCredits()
nextEpisode = null
nextUpDismissed = false
requestStartedAtMs = SystemClock.elapsedRealtime()
trace = PlaybackTrace(requestStartedAtMs, SystemClock::elapsedRealtime)
@@ -5297,6 +5527,20 @@ class PlayerActivity : ComponentActivity() {
/** Show against the still-playing final minute, using media time for determinism. */
private const val NEXT_UP_LEAD_MS = 60_000L
/**
* How far out the next episode's stream is re-negotiated. Comfortably ahead of the
* credits marker, which is the earliest a viewer can be offered the advance, so the
* press itself never waits on a request.
*/
private const val NEXT_UP_STREAM_WARM_LEAD_MS = 5 * 60_000L
/**
* How many of Magic's own picks it remembers not to repeat. Long enough that a viewer
* pressing it a few times running gets a few different films, short enough that it
* cannot talk itself out of a small library.
*/
private const val MAGIC_MEMORY = 8
private const val NEXT_UP_TICK_MS = 250L
private const val NEXT_EPISODE_PREVIEW_LEAD_MS = 120_000L
private const val NEXT_EPISODE_PREVIEW_STARTUP_TIMEOUT_MS = 8_000L
@@ -39,7 +39,6 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Devices
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Info
@@ -271,6 +270,7 @@ internal data class SettingsPanelState(
val selectedPage: SettingsPage = SettingsPage.APPEARANCE,
val installedVersion: String = "",
val gatewayVersion: String = "",
val embyVersion: String = "",
val releaseHistory: List<ReleaseNote> = MembyReleaseHistory,
val devices: List<GatewayDevice> = emptyList(),
val devicesLoading: Boolean = false,
@@ -379,6 +379,7 @@ fun SettingsSheet(
val resolvedTheme by ServiceLocator.themeSync.theme.collectAsStateWithLifecycle()
val availableThemes by ServiceLocator.themeSync.available.collectAsStateWithLifecycle()
val gatewayVersion by ServiceLocator.maintenance.gatewayVersion.collectAsStateWithLifecycle()
val embyVersion by ServiceLocator.maintenance.embyVersion.collectAsStateWithLifecycle()
var selectedPage by rememberSaveable { mutableStateOf(SettingsPage.APPEARANCE) }
var devices by remember { mutableStateOf<List<GatewayDevice>>(emptyList()) }
var devicesLoading by remember { mutableStateOf(false) }
@@ -514,6 +515,7 @@ fun SettingsSheet(
selectedPage = selectedPage,
installedVersion = checker.installedVersion,
gatewayVersion = gatewayVersion,
embyVersion = embyVersion,
devices = devices,
devicesLoading = devicesLoading,
devicesError = devicesError,
@@ -807,7 +809,6 @@ internal fun SettingsPanelContent(
SettingsSecondaryRail(
selected = state.selectedPage,
onSelected = actions.onPageSelected,
onClose = actions.onClose,
firstFocusRequester = firstFocusRequester,
navigationFocusRequester = navigationFocusRequester,
contentFocusRequester = contentFocusRequester,
@@ -1136,7 +1137,7 @@ internal fun SettingsPanelContent(
SettingDivider()
VersionRow(
label = "Memby gateway",
version = state.gatewayVersion.takeIf(String::isNotBlank) ?: "Not connected",
version = gatewayVersionLabel(state.gatewayVersion, state.embyVersion),
)
}
}
@@ -1151,6 +1152,29 @@ internal fun SettingsPanelContent(
}
}
/**
* What the About page prints beside "Memby gateway" the gateway's own build, with the Emby
* it is talking to in brackets after it: `0.1.50 (4.10.0.21)`.
*
* The two are one row rather than two because they are one fact: this gateway, against that
* server. Split across two rows an operator reading a support message has to be told which
* of them to look at, and the Emby version on its own says nothing about whether Memby can
* reach it.
*
* Every way Emby's version can be unknown no probe has answered yet, the operator has
* switched the probe off, the gateway predates the field produces the gateway's version
* alone. Empty brackets would read as a server that answered with nothing, which is a fault,
* where the truth is only that nobody has asked yet.
*/
internal fun gatewayVersionLabel(gatewayVersion: String, embyVersion: String): String {
val gateway = gatewayVersion.trim()
// A gateway that is not answering cannot vouch for what Emby is running either, so the
// brackets go with it: "Not connected (4.10.0.21)" claims a reading nothing just took.
if (gateway.isBlank()) return "Not connected"
val emby = embyVersion.trim()
return if (emby.isBlank()) gateway else "$gateway ($emby)"
}
internal fun deviceDescription(device: GatewayDevice): String = buildList {
if (device.current) add("This TV")
device.clientVersion.takeIf(String::isNotBlank)?.let { add("Memby v$it") }
@@ -1346,7 +1370,6 @@ private const val SETTINGS_PAGE_SETTLE_MS = 130L
private fun SettingsSecondaryRail(
selected: SettingsPage,
onSelected: (SettingsPage) -> Unit,
onClose: () -> Unit,
firstFocusRequester: FocusRequester?,
navigationFocusRequester: FocusRequester?,
contentFocusRequester: FocusRequester,
@@ -1373,16 +1396,11 @@ private fun SettingsSecondaryRail(
// every vertical move an explicit destination so focus cannot leak through the
// settings surface and activate home content.
val railFocusRequesters = remember(firstFocusRequester) {
buildList {
add(FocusRequester()) // Back
pages.forEach { page ->
add(
if (page == selectedRailPage && firstFocusRequester != null) {
firstFocusRequester
} else {
FocusRequester()
},
)
pages.map { page ->
if (page == selectedRailPage && firstFocusRequester != null) {
firstFocusRequester
} else {
FocusRequester()
}
}
}
@@ -1413,14 +1431,6 @@ private fun SettingsSecondaryRail(
letterSpacing = 1.4.sp,
modifier = Modifier.padding(start = 12.dp, bottom = 8.dp),
)
SettingsBackRailItem(
compact = compact,
onClick = onClose,
focusRequester = railFocusRequesters[0],
downFocusRequester = railFocusRequesters[1],
leftFocusRequester = navigationFocusRequester,
rightFocusRequester = contentFocusRequester,
)
pages.forEachIndexed { index, page ->
var focused by remember { mutableStateOf(false) }
val active = page == focusedPage
@@ -1455,13 +1465,17 @@ private fun SettingsSecondaryRail(
Modifier
},
)
.focusRequester(railFocusRequesters[index + 1])
.focusRequester(railFocusRequesters[index])
.focusProperties {
up = railFocusRequesters[index]
up = if (index == 0) {
FocusRequester.Cancel
} else {
railFocusRequesters[index - 1]
}
down = if (index == pages.lastIndex) {
FocusRequester.Cancel
} else {
railFocusRequesters[index + 2]
railFocusRequesters[index + 1]
}
// Left leaves for the app's main rail when it is beside us, and is
// cancelled rather than left to spatial search otherwise: the home
@@ -1533,55 +1547,6 @@ private fun SettingsSecondaryRail(
}
}
@Composable
private fun SettingsBackRailItem(
compact: Boolean,
onClick: () -> Unit,
focusRequester: FocusRequester,
downFocusRequester: FocusRequester,
leftFocusRequester: FocusRequester?,
rightFocusRequester: FocusRequester,
) {
var focused by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(if (focused) Color.White else Color.Transparent)
.border(
width = 1.dp,
color = if (focused) Color.White else Color.Transparent,
shape = RoundedCornerShape(10.dp),
)
.focusRequester(focusRequester)
.focusProperties {
up = FocusRequester.Cancel
down = downFocusRequester
left = leftFocusRequester ?: FocusRequester.Cancel
right = rightFocusRequester
}
.onFocusChanged { focused = it.isFocused }
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = if (focused) Canvas else TextSecondary,
modifier = Modifier.size(19.dp),
)
Text(
"Back",
color = if (focused) Canvas else TextPrimary,
fontSize = if (compact) 13.sp else 14.sp,
fontWeight = if (focused) FontWeight.Bold else FontWeight.Medium,
maxLines = 1,
)
}
}
@Composable
private fun SettingsHeader(page: SettingsPage) {
Row(
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A wand and its sparks, for "put something on and don't ask me what". Deliberately not a
shuffle glyph, which on a player means reordering a queue the viewer can see; this makes
a choice on their behalf out of a library they cannot. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M14.06,7.52l2.42,2.42L6.42,20H4v-2.42L14.06,7.52zM15.47,6.11l1.71,-1.71 2.42,2.42 -1.71,1.71 -2.42,-2.42z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M5,3l0.62,1.88L7.5,5.5 5.62,6.12 5,8 4.38,6.12 2.5,5.5 4.38,4.88 5,3zM19,13l0.62,1.88 1.88,0.62 -1.88,0.62L19,18l-0.62,-1.88 -1.88,-0.62 1.88,-0.62L19,13z" />
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Skip-to-next: a triangle against a bar. The transport's own "fast forward" glyph is a
double chevron and means "move through this programme"; this means "leave it", so the
bar is the load-bearing half of the shape and is drawn at full weight. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z" />
</vector>
@@ -226,6 +226,18 @@
android:contentDescription="@string/exo_controls_fastforward_description"
android:src="@drawable/exo_icon_fastforward"
tools:ignore="PrivateResource" />
<!-- In the transport group rather than out with the panels, because it moves
the programme on in the way rewind and fast-forward do: it is the last
step of the same journey. Gone rather than disabled when there is no next
episode, so a film's transport row is the three controls it has always
been and the D-pad never lands on a dead target. -->
<ImageButton
android:id="@+id/player_next_episode"
style="@style/MembyPlayerControlButton"
android:contentDescription="@string/player_next_episode"
android:src="@drawable/ic_player_next_episode"
android:visibility="gone" />
</LinearLayout>
<LinearLayout
@@ -235,6 +247,16 @@
android:gravity="center_vertical"
android:orientation="horizontal">
<!-- Withheld for episodic content: Next Episode already answers "what now?"
there, and the server's picker is films only. Gone rather than disabled
for the same reason as Next Episode. -->
<ImageButton
android:id="@+id/player_magic"
style="@style/MembyPlayerControlButton"
android:contentDescription="@string/player_magic"
android:src="@drawable/ic_player_magic"
android:visibility="gone" />
<ImageButton
android:id="@id/exo_subtitle"
style="@style/MembyPlayerControlButton"
+8
View File
@@ -55,6 +55,14 @@
<string name="player_subtitle_disabled">Subtitles off</string>
<string name="player_subtitle_generic">Selected</string>
<string name="player_cast_open">Cast</string>
<string name="player_next_episode">Next episode</string>
<string name="player_magic">Play something else</string>
<string name="player_magic_finding">Finding something to watch…</string>
<string name="player_magic_pick_hint">Choosing from your library</string>
<!-- Says what was chosen before the picture changes: a button that silently replaces the
programme is one nobody presses twice. -->
<string name="player_magic_selected">Playing %1$s</string>
<string name="player_magic_empty">Nothing new to suggest just now</string>
<string name="player_back_to_close">BACK · CLOSE</string>
<string name="player_ends_at">Ends at %1$s</string>
<string name="player_preroll_countdown_initial">Starting in 7 seconds…</string>
@@ -18,6 +18,7 @@ class UserPreferencesTest {
@Test
fun `encoding and decoding is a fixed point`() {
val original = UserPreferences(
profileInitials = "MC",
homeSections = listOf("latest", "continue"),
homeCardDensity = "large",
homeArtworkStyle = "poster",
@@ -134,6 +135,7 @@ class UserPreferencesTest {
@Test
fun `settings project onto the document the server holds`() {
val settings = Settings(
profileInitials = "MC",
homeSections = "continue,latest",
homeCardDensity = "compact",
homeArtworkStyle = "backdrop",
@@ -153,6 +155,7 @@ class UserPreferencesTest {
assertEquals(
UserPreferences(
profileInitials = "MC",
homeSections = listOf("continue", "latest"),
homeCardDensity = "compact",
homeArtworkStyle = "backdrop",
@@ -0,0 +1,58 @@
package com.ponzischeme89.memby.data.remote
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* The address the connection warm remembers. It is derived from a resolved stream URL, and
* a warm aimed at the wrong host is worse than no warm at all it opens a connection
* nothing will use and leaves the one that matters cold.
*/
class StreamOriginTest {
@Test
fun `drops the path, query and credential from a stream url`() {
assertEquals(
"http://10.0.0.213:8096/",
originOf("http://10.0.0.213:8096/Videos/abc123/stream?static=true&api_key=secret"),
)
}
@Test
fun `keeps the scheme, because a warm on the wrong one shares no connection`() {
assertEquals("https://emby.example.com/", originOf("https://emby.example.com/Videos/x/stream"))
}
@Test
fun `two titles on one server share an origin`() {
assertEquals(
originOf("https://emby.example.com/Videos/one/stream?static=true"),
originOf("https://emby.example.com/Videos/two/stream?static=true"),
)
}
@Test
fun `implicit and explicit default ports agree`() {
assertEquals(
originOf("https://emby.example.com/Videos/x/stream"),
originOf("https://emby.example.com:443/Videos/x/stream"),
)
}
@Test
fun `a non-default port is part of the origin`() {
assertEquals("http://host/", originOf("http://host:80/a"))
assertEquals("http://host:8096/", originOf("http://host:8096/a"))
}
@Test
fun `anything that is not an absolute http address is refused`() {
// A relative path, a file the direct path may hand back, and an empty string are all
// legitimate values here; none of them names a host worth opening a connection to.
assertNull(originOf(""))
assertNull(originOf("/Videos/abc/stream"))
assertNull(originOf("file:///storage/emulated/0/clip.mp4"))
assertNull(originOf("not a url"))
}
}
@@ -17,6 +17,12 @@ class ProfileInitialsTest {
assertEquals("?", profileInitials(" "))
}
@Test
fun `admin override wins and is normalised for display`() {
assertEquals("MC", profileInitials("Matt", " mc "))
assertEquals("M", profileInitials("Matt", "m"))
}
@Test
fun `profile destination names the signed in viewer`() {
assertEquals("Matt Cohen", profileDestinationLabel(" Matt Cohen "))
@@ -0,0 +1,242 @@
package com.ponzischeme89.memby.ui.player
import com.ponzischeme89.memby.data.NextEpisode
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.concurrent.atomic.AtomicInteger
@OptIn(ExperimentalCoroutinesApi::class)
class NextUpPipelineTest {
private fun episode(id: String, url: String = "https://emby/$id") = NextEpisode(
itemId = id,
title = "Episode $id",
seriesName = "A Show",
episodeCode = "S01E01",
imageUrl = null,
url = url,
)
// --- The gate that was the bug -------------------------------------------------------
/**
* The whole defect: resolution used to be conditional on the auto-play setting, so with
* it off the credits pane, the banner and the ended frame read a permanently null field.
* The offer and the transition are now separate decisions.
*/
@Test
fun `the offer does not depend on the auto-play setting`() {
assertTrue(
"the button is an offer, not a transition",
shouldOfferNextEpisodeButton(hasNextEpisode = true, advancing = false, playingPreview = false),
)
assertFalse(
shouldAutoAdvance(autoPlayEnabled = false, hasNextEpisode = true, dismissed = false),
)
assertTrue(
shouldAutoAdvance(autoPlayEnabled = true, hasNextEpisode = true, dismissed = false),
)
}
@Test
fun `a dismissed banner stops the automatic transition but nothing else`() {
assertFalse(shouldAutoAdvance(autoPlayEnabled = true, hasNextEpisode = true, dismissed = true))
assertTrue(
shouldOfferNextEpisodeButton(hasNextEpisode = true, advancing = false, playingPreview = false),
)
}
/** A series finale, and every film: nothing follows, so nothing is offered. */
@Test
fun `no next episode means no button and no advance`() {
assertFalse(
shouldOfferNextEpisodeButton(hasNextEpisode = false, advancing = false, playingPreview = false),
)
assertFalse(shouldAutoAdvance(autoPlayEnabled = true, hasNextEpisode = false, dismissed = false))
}
@Test
fun `neither control is offered while the player is already changing programme`() {
assertFalse(
shouldOfferNextEpisodeButton(hasNextEpisode = true, advancing = true, playingPreview = false),
)
assertFalse(
shouldOfferNextEpisodeButton(hasNextEpisode = true, advancing = false, playingPreview = true),
)
assertFalse(
shouldOfferMagicButton(
isEpisode = false, magicAvailable = true, advancing = true, playingPreview = false,
),
)
}
// --- Magic ---------------------------------------------------------------------------
@Test
fun `magic belongs to films and stands aside for next episode`() {
assertTrue(
shouldOfferMagicButton(
isEpisode = false, magicAvailable = true, advancing = false, playingPreview = false,
),
)
assertFalse(
"next episode already answers this question for a programme",
shouldOfferMagicButton(
isEpisode = true, magicAvailable = true, advancing = false, playingPreview = false,
),
)
assertFalse(
"no gateway, or one that predates the route, means nobody to ask",
shouldOfferMagicButton(
isEpisode = false, magicAvailable = false, advancing = false, playingPreview = false,
),
)
}
// --- The resolver --------------------------------------------------------------------
@Test
fun `repeated callers share one request`() = runTest {
val calls = AtomicInteger()
val resolver = NextUpResolver(
scope = TestScope(),
elapsedRealtime = { 0L },
fetch = { calls.incrementAndGet(); episode("e2") },
)
resolver.begin("e1")
// The credits marker, a progress tick and a button press, all inside one frame.
val answers = listOf(
async { resolver.resolve("e1") },
async { resolver.resolve("e1") },
async { resolver.resolve("e1") },
).awaitAll()
assertEquals("one episode, one request", 1, calls.get())
assertEquals(listOf("e2", "e2", "e2"), answers.map { it?.itemId })
}
/** Determinism: reaching the credits of the same episode gives the same answer. */
@Test
fun `the answer is stable while the subject has not changed`() = runTest {
var served = 0
val resolver = NextUpResolver(
scope = TestScope(),
elapsedRealtime = { 0L },
fetch = { episode("e${++served}") },
)
resolver.begin("e1")
val first = resolver.resolve("e1")
assertSame(first, resolver.resolve("e1"))
assertSame(first, resolver.current)
assertEquals(1, served)
}
/** A next episode with no stream behind it is not a next episode. */
@Test
fun `an unplayable answer is discarded`() = runTest {
val resolver = NextUpResolver(
scope = TestScope(),
elapsedRealtime = { 0L },
fetch = { episode("e2", url = "") },
)
resolver.begin("e1")
assertNull(resolver.resolve("e1"))
assertNull(resolver.current)
}
@Test
fun `a failed lookup is null rather than a crash`() = runTest {
val resolver = NextUpResolver(
scope = TestScope(),
elapsedRealtime = { 0L },
fetch = { error("the server is down") },
)
resolver.begin("e1")
assertNull(resolver.resolve("e1"))
}
/** An answer about the outgoing episode must never be offered against the incoming one. */
@Test
fun `pointing the resolver at a new episode discards the old answer`() = runTest {
val resolver = NextUpResolver(
scope = TestScope(),
elapsedRealtime = { 0L },
fetch = { id -> episode("after-$id") },
)
resolver.begin("e1")
assertEquals("after-e1", resolver.resolve("e1")?.itemId)
resolver.begin("e2")
assertNull("the previous answer is gone the moment the subject moves", resolver.current)
assertNull("and a question about the old subject is refused", resolver.resolve("e1"))
assertEquals("after-e2", resolver.resolve("e2")?.itemId)
}
/**
* The intermittent stream failure: the URL negotiated when a 45-minute episode started is
* stale by the time its credits roll, so [NextUpResolver.playable] fetches it again.
*/
@Test
fun `a stale stream is re-negotiated before it is played`() = runTest {
var now = 0L
var served = 0
val resolver = NextUpResolver(
scope = TestScope(),
elapsedRealtime = { now },
fetch = { episode("e2", url = "https://emby/e2?session=${++served}") },
)
resolver.begin("e1")
resolver.resolve("e1")
assertEquals(1, served)
// Still warm: the press costs no request.
now += NextUpResolver.STREAM_FRESHNESS_MS - 1
assertEquals("https://emby/e2?session=1", resolver.playable("e1")?.url)
assertEquals(1, served)
// An episode's length later, the play session has expired.
now += 2
assertEquals("https://emby/e2?session=2", resolver.playable("e1")?.url)
assertEquals(2, served)
}
@Test
fun `warming ahead of the press leaves nothing for the press to wait on`() = runTest {
var now = 0L
var served = 0
val resolver = NextUpResolver(
scope = TestScope(),
elapsedRealtime = { now },
fetch = { episode("e2", url = "u${++served}") },
)
resolver.begin("e1")
resolver.resolve("e1")
now += NextUpResolver.STREAM_FRESHNESS_MS
resolver.resolve("e1", forceRefresh = true) // what warm() does, synchronously here
assertEquals(2, served)
assertEquals("and the press itself then costs nothing", "u2", resolver.playable("e1")?.url)
assertEquals(2, served)
}
@Test
fun `cancelling leaves nothing to offer`() = runTest {
val resolver = NextUpResolver(
scope = TestScope(),
elapsedRealtime = { 0L },
fetch = { episode("e2") },
)
resolver.begin("e1")
resolver.resolve("e1")
resolver.cancel()
assertNull(resolver.current)
assertFalse(resolver.resolving)
}
}
@@ -0,0 +1,35 @@
package com.ponzischeme89.memby.ui.settings
import org.junit.Assert.assertEquals
import org.junit.Test
class GatewayVersionLabelTest {
@Test
fun `the emby version goes in brackets after the gateway's`() {
assertEquals("0.1.50 (4.10.0.21)", gatewayVersionLabel("0.1.50", "4.10.0.21"))
}
/**
* Every way Emby's version can be unknown no probe has answered yet, the operator has
* switched the probe off, the gateway predates the field reads the same way. Empty
* brackets would look like a server that answered with nothing.
*/
@Test
fun `an unknown emby version leaves the gateway's standing alone`() {
assertEquals("0.1.50", gatewayVersionLabel("0.1.50", ""))
assertEquals("0.1.50", gatewayVersionLabel("0.1.50", " "))
}
/** A gateway that is not answering cannot vouch for what Emby is running either. */
@Test
fun `no gateway means no version and no brackets`() {
assertEquals("Not connected", gatewayVersionLabel("", "4.10.0.21"))
assertEquals("Not connected", gatewayVersionLabel(" ", ""))
}
@Test
fun `surrounding whitespace never reaches the screen`() {
assertEquals("0.1.50 (4.10.0.21)", gatewayVersionLabel(" 0.1.50 ", " 4.10.0.21 "))
}
}