0.2.72 - Magic button, Next up fixes
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 "))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user