This commit is contained in:
ponzischeme89
2026-08-24 22:56:46 +12:00
parent 4f95767e2f
commit 396d35e2f5
48 changed files with 1541 additions and 672 deletions
@@ -0,0 +1,12 @@
package com.ponzischeme89.memby.data
/**
* The one legal non-default value. Anything else — missing, blank, a value a future variant
* introduces that this build does not know about — must fall back to "v1" rather than be
* handed to the UI to guess about.
*/
private const val DETAIL_EXPERIENCE_V2 = "v2"
const val DETAIL_EXPERIENCE_DEFAULT = "v1"
fun detailExperienceOrDefault(raw: String): String =
raw.takeIf { it == DETAIL_EXPERIENCE_V2 } ?: DETAIL_EXPERIENCE_DEFAULT
@@ -294,7 +294,10 @@ class EmbyRepository internal constructor(
* layout and must not collect a flow to answer a question that changes once a year.
*/
val showTitleLogo: Boolean get() = snapshot.showTitleLogo
private val _playbackStops = MutableSharedFlow<String>(extraBufferCapacity = 1)
// Carries whether the stopped item finished, computed at the report itself rather than
// read back from the [playbackPositions] side channel afterwards — the two are separate
// flows collected by separate coroutines, and nothing orders one against the other.
private val _playbackStops = MutableSharedFlow<PlaybackPosition>(extraBufferCapacity = 1)
val playbackStops = _playbackStops.asSharedFlow()
/**
@@ -2715,7 +2718,9 @@ class EmbyRepository internal constructor(
clearPlayableCache()
// Episode progress and watched badges may have changed during playback.
clearSeriesEpisodeCache()
_playbackStops.tryEmit(session.itemId)
_playbackStops.tryEmit(
PlaybackPosition(session.itemId, positionMs.coerceAtLeast(0L), durationMs.coerceAtLeast(0L)),
)
}
}
@@ -105,6 +105,7 @@ class MaintenanceMonitor(
private val _requestsAllowed = MutableStateFlow(false)
private val _gatewayVersion = MutableStateFlow("")
private val _embyVersion = MutableStateFlow("")
private val _detailExperience = MutableStateFlow(DETAIL_EXPERIENCE_DEFAULT)
/**
* Which colour scheme this viewer's televisions should be painted, as an id and a
@@ -129,6 +130,14 @@ class MaintenanceMonitor(
*/
val heroRevision: StateFlow<String> = _heroRevision.asStateFlow()
/**
* The v2 detail-page experiment for this viewer/device, already validated by
* [detailExperienceOrDefault] against garbage or a future value this build does not
* understand. "v1" whenever the server has not said otherwise — signed out, mid-outage,
* or on a gateway that predates the field — which is the existing detail page, unchanged.
*/
val detailExperience: StateFlow<String> = _detailExperience.asStateFlow()
/**
* The viewer's server-held settings revision, as of the last successful poll. This is
* how an operator's push reaches a television: the number changes, [PreferencesSync]
@@ -300,6 +309,7 @@ class MaintenanceMonitor(
_metadataHeroTimeRemainingColour.value = METADATA_HERO_TIME_COLOUR_GREEN
_theme.value = GatewayThemeStatus()
_heroRevision.value = ""
_detailExperience.value = DETAIL_EXPERIENCE_DEFAULT
_installPermissionPrompt.value = false
_genreBrowserEnabled.value = false
_tvCalendarEnabled.value = false
@@ -348,6 +358,7 @@ class MaintenanceMonitor(
?: METADATA_HERO_TIME_COLOUR_GREEN
_theme.value = status.theme
_heroRevision.value = status.hero.revision
_detailExperience.value = detailExperienceOrDefault(status.detailExperience)
_installPermissionPrompt.value =
status.features[INSTALL_PERMISSION_FEATURE] == true
_genreBrowserEnabled.value = status.features[GENRE_BROWSER_FEATURE] == true
@@ -396,6 +407,7 @@ class MaintenanceMonitor(
_metadataHeroTimeRemainingColour.value = METADATA_HERO_TIME_COLOUR_GREEN
_theme.value = GatewayThemeStatus()
_heroRevision.value = ""
_detailExperience.value = DETAIL_EXPERIENCE_DEFAULT
_installPermissionPrompt.value = false
_genreBrowserEnabled.value = false
_tvCalendarEnabled.value = false
@@ -275,6 +275,13 @@ data class GatewayServiceStatus(
* exactly how the launcher behaved before this existed.
*/
val hero: GatewayHeroStatus = GatewayHeroStatus(),
/**
* The v2 detail-page experiment. A plain string rather than a boolean feature flag,
* scoped per user/device like [theme]. Missing (a gateway that predates it) or anything
* other than exactly "v2" decodes to the compiled default of "v1" — see
* [com.ponzischeme89.memby.data.detailExperienceOrDefault].
*/
val detailExperience: String = "v1",
)
@Serializable
@@ -322,6 +322,14 @@ internal fun FocusedDetailsOverlay(
homeViewModel: HomeViewModel,
selected: BaseItem,
seriesStatusRevision: Long = 0,
/**
* The server-controlled detail-page experiment, already validated by
* [com.ponzischeme89.memby.data.detailExperienceOrDefault] to "v1" or "v2". Only Home's
* movie/series posters offer the seamless "v2" reveal — an episode (reached from
* Continue Watching, never from a poster) and a Radarr-only card (no shelf to reveal
* from) always take the ordinary v1 overlay.
*/
detailExperience: String = com.ponzischeme89.memby.data.DETAIL_EXPERIENCE_DEFAULT,
restorePosition: Boolean,
onPlay: (BaseItem) -> Unit,
onPlayTrailer: (BaseItem) -> Unit,
@@ -360,6 +368,39 @@ internal fun FocusedDetailsOverlay(
}
}
val item = detailItemForRoute(selected, focusedItem, detailMetadata)
// The seamless "v2" reveal only makes sense where there was a shelf to reveal from: a
// Radarr-only card has no Emby item behind it (its own page, see [RadarrMovieDetailsOverlay])
// and an episode is reached from Continue Watching rather than a poster press, so both
// keep the ordinary v1 overlay regardless of the operator's setting.
val isSeamless = com.ponzischeme89.memby.data.detailExperienceOrDefault(detailExperience) == "v2" &&
!item.isRadarrOnly && !item.isEpisode
var playPressed by remember(item.id) { mutableStateOf(false) }
LaunchedEffect(item.id, isSeamless) {
homeViewModel.trackJourney(
category = "details", action = "opened", screen = "details",
feature = if (isSeamless) "detail_v2" else "detail_v1",
itemName = item.name, itemType = item.type,
)
}
val trackedOnPlay: (BaseItem) -> Unit = { playedItem ->
playPressed = true
homeViewModel.trackJourney(
category = "details", action = "play_pressed", screen = "details",
feature = if (isSeamless) "detail_v2" else "detail_v1",
itemName = playedItem.name, itemType = playedItem.type,
)
onPlay(playedItem)
}
val trackedOnClose: () -> Unit = {
if (!playPressed) {
homeViewModel.trackJourney(
category = "details", action = "closed_without_playback", screen = "details",
feature = if (isSeamless) "detail_v2" else "detail_v1",
itemName = item.name, itemType = item.type,
)
}
onClose()
}
if (item.isRadarrOnly) {
// A film Radarr is tracking that Emby has never imported. It is the one card with a
// page of its own rather than an Emby one — see [RadarrMovieDetailsOverlay] for why
@@ -367,58 +408,80 @@ internal fun FocusedDetailsOverlay(
RadarrMovieDetailsOverlay(
card = item,
onPlayTrailer = onPlayTrailer,
onClose = onClose,
onClose = trackedOnClose,
onOpenEmbyItem = onOpenEmbyItem,
)
} else if (item.isSeries) {
SeriesDetailsOverlay(
item = item,
onPlay = onPlay,
onPlayTrailer = onPlayTrailer,
onToggleFavorite = onToggleFavorite,
isMyShow = isMyShow,
onToggleMyShow = onToggleMyShow,
onSeriesPlayedChanged = { series, played ->
homeViewModel.trackJourney(
category = "library", action = if (played) "mark_played" else "mark_unplayed",
screen = "details", feature = "played_status", itemName = series.name,
itemType = series.type, outcome = "success",
)
homeViewModel.applySeriesPlayed(series, played)
},
onSeriesPlayedSettled = homeViewModel::refreshContinueWatching,
onClose = onClose,
onOpenItem = onOpenItem,
restorePosition = restorePosition,
airingNotice = airingNotice,
)
// Seamless "v2" entry is a fast fade-in over what was already warmed on focus —
// the item, its logo, backdrop and ratings are already resident, and Coil already
// holds the decoded bitmaps, so this costs no re-fetch. v1 gets no wrapper at all,
// so its existing screenshot tests and behaviour are untouched.
AnimatedVisibility(
visible = true,
enter = if (isSeamless) fadeIn(tween(SEAMLESS_REVEAL_DURATION_MS)) else fadeIn(tween(0)),
) {
SeriesDetailsOverlay(
item = item,
onPlay = trackedOnPlay,
onPlayTrailer = onPlayTrailer,
onToggleFavorite = onToggleFavorite,
isMyShow = isMyShow,
onToggleMyShow = onToggleMyShow,
onSeriesPlayedChanged = { series, played ->
homeViewModel.trackJourney(
category = "library", action = if (played) "mark_played" else "mark_unplayed",
screen = "details", feature = "played_status", itemName = series.name,
itemType = series.type, outcome = "success",
)
homeViewModel.applySeriesPlayed(series, played)
},
onSeriesPlayedSettled = homeViewModel::refreshContinueWatching,
onClose = trackedOnClose,
onOpenItem = onOpenItem,
restorePosition = restorePosition,
airingNotice = airingNotice,
)
}
} else if (item.isEpisode) {
// An episode arrives here from Continue Watching, where it *is* the thing the
// viewer chose. It used to open the movie page, which named the
// episode with no way to tell which one it was or where in the show it sat.
// Never eligible for the seamless reveal — see [isSeamless].
EpisodeDetailsOverlay(
item = item,
onPlay = onPlay,
onPlay = trackedOnPlay,
onToggleFavorite = onToggleFavorite,
onTogglePlayed = onTogglePlayed,
onClose = onClose,
onClose = trackedOnClose,
onOpenItem = onOpenItem,
restorePosition = restorePosition,
)
} else {
MediaDetailsOverlay(
item = item,
onPlay = onPlay,
onPlayTrailer = onPlayTrailer,
onToggleFavorite = onToggleFavorite,
onTogglePlayed = onTogglePlayed,
onClose = onClose,
onOpenItem = onOpenItem,
restorePosition = restorePosition,
)
AnimatedVisibility(
visible = true,
enter = if (isSeamless) fadeIn(tween(SEAMLESS_REVEAL_DURATION_MS)) else fadeIn(tween(0)),
) {
MediaDetailsOverlay(
item = item,
onPlay = trackedOnPlay,
onPlayTrailer = onPlayTrailer,
onToggleFavorite = onToggleFavorite,
onTogglePlayed = onTogglePlayed,
onClose = trackedOnClose,
onOpenItem = onOpenItem,
restorePosition = restorePosition,
)
}
}
}
/**
* How long the v2 seamless reveal fades in the detail hero over the shelf it replaced. Kept
* short and restrained, per the experiment's own "things not moving" premise — this is not
* a cinematic transition, just enough to soften an otherwise instant cut.
*/
private const val SEAMLESS_REVEAL_DURATION_MS = 120
/**
* Combines the three owners involved in a detail page without confusing their lifetimes:
* the selected route fixes identity, current focus contributes live user state only when
@@ -266,6 +266,9 @@ internal fun HomeScreen(
ServiceLocator.maintenance.metadataHeroTimeRemainingColour.collectAsStateWithLifecycle()
val compatibilityNotice by ServiceLocator.maintenance.compatibility.collectAsStateWithLifecycle()
val genreBrowserEnabled by ServiceLocator.maintenance.genreBrowserEnabled.collectAsStateWithLifecycle()
// Server-controlled experiment: which detail-page layout Home opens. Already validated
// to "v1" or "v2" by MaintenanceMonitor, so this is safe to branch on directly.
val detailExperience by ServiceLocator.maintenance.detailExperience.collectAsStateWithLifecycle()
val tvCalendarEnabled by ServiceLocator.maintenance.tvCalendarEnabled.collectAsStateWithLifecycle()
val continueWatchingEnabled by
ServiceLocator.maintenance.continueWatchingEnabled.collectAsStateWithLifecycle()
@@ -2201,6 +2204,7 @@ internal fun HomeScreen(
homeViewModel = homeViewModel,
selected = selected,
seriesStatusRevision = seriesStatusRevision,
detailExperience = detailExperience,
restorePosition = restoreDetailPosition,
airingNotice = detailsAiringNotice,
onOpenItem = { related ->
@@ -220,7 +220,16 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
repository.playbackPositions.collect(::applyPlaybackPosition)
}
viewModelScope.launch {
repository.playbackStops.collect { refreshWatching(authoritative = false) }
repository.playbackStops.collect { stop ->
if (stop.completed) {
// The optimistic prune in applyPlaybackPosition has already removed the
// finished episode; only the gateway's own answer can put the next one in
// its place, and the stop report just invalidated its cached rows.
refreshAfterCompletion(stop.itemId)
} else {
refreshWatching(authoritative = false)
}
}
}
viewModelScope.launch {
// A D-pad produces focus changes far faster than anything should produce
@@ -862,6 +871,29 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
}
}
/**
* Follows a completed episode's stop report with the gateway's own answer, which is what
* actually puts the next episode in the finished one's place — the optimistic prune in
* [applyPlaybackPosition] only ever removes a card, it never adds one. The stop report
* that precedes this is what invalidates the gateway's cached rows, but Emby's own
* bookkeeping can lag behind it by a beat, so an authoritative refresh that comes back
* with the same Continue Watching membership it already had is retried a small, bounded
* number of times rather than trusted on the first try or polled without end.
*/
private suspend fun refreshAfterCompletion(itemId: String) {
val before = continueWatchingIds()
repeat(COMPLETION_REFRESH_ATTEMPTS) { attempt ->
refreshWatching(authoritative = true)
val after = continueWatchingIds()
val settled = (itemId !in after && after != before) || attempt == COMPLETION_REFRESH_ATTEMPTS - 1
if (settled) return
delay(COMPLETION_REFRESH_RETRY_DELAY_MS)
}
}
private fun continueWatchingIds(): Set<String> =
_state.value.continueWatching.mapTo(mutableSetOf(), BaseItem::id)
private suspend fun loadContinueWatching(clearLoading: Boolean = true) =
load(HomeSection.CONTINUE, clearLoading, { repository.getContinueWatching() }) { state, items ->
state.copy(
@@ -924,6 +956,11 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
private const val FOCUS_METADATA_DEBOUNCE_MS = 200L
private const val HERO_UPDATE_DEBOUNCE_MS = 120L
/** Bounded retries for [refreshAfterCompletion] — a race with Emby's own bookkeeping,
* not a poll, so this stays small and stops on its own. */
private const val COMPLETION_REFRESH_ATTEMPTS = 3
private const val COMPLETION_REFRESH_RETRY_DELAY_MS = 600L
/**
* How long focus must rest on a card before lightweight detail work is warmed, measured
* from the press that focused it. Deliberately well past
@@ -0,0 +1,143 @@
package com.ponzischeme89.memby.ui.player
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Typeface
import android.util.AttributeSet
import android.view.View
import com.ponzischeme89.memby.ui.theme.membyTypeface
import kotlin.math.ceil
import kotlin.math.min
/**
* A draining arc with the figure left inside it.
*
* Two things in the player count something down against the playhead — the skip-intro
* offer and the next-up overlay — and both want the same picture. This is that picture and
* nothing else: it runs no animator, holds no timer and knows nothing about what it is
* measuring. The caller advances it from media time, which is what keeps it honest — a
* pause holds the ring where it is and a seek moves it, neither of which a ring counting
* wall-clock seconds could do.
*
* Colours are set rather than derived, because the two callers want different answers: the
* skip-intro ring lives inside a state-list pill and has to invert with it, while the
* next-up ring sits on somebody's programme and is the same white and green wherever the
* picture behind it happens to be light.
*/
open class CountdownRingView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr) {
private val density = resources.displayMetrics.density
private val ringBounds = RectF()
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeWidth = 2.5f * density
}
private val progressPaint = Paint(trackPaint)
private val figurePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
typeface = context.membyTypeface(Typeface.BOLD)
color = Color.WHITE
}
private var figure = ""
private var progress = 1f
init {
setInk(track = Color.argb(72, 255, 255, 255), arc = Color.WHITE, figure = Color.WHITE)
}
/** How thick the arc is drawn. The default suits a ring of about 3040dp. */
fun setRingWidthDp(widthDp: Float) {
trackPaint.strokeWidth = widthDp * density
progressPaint.strokeWidth = trackPaint.strokeWidth
invalidate()
}
/** The three colours the ring is made of. Invalidates, so it is safe mid-countdown. */
fun setInk(track: Int, arc: Int, figure: Int) {
trackPaint.color = track
progressPaint.color = arc
figurePaint.color = figure
invalidate()
}
/**
* How much is left, and how long the whole thing runs for.
*
* Redraws only when the drawn result would actually differ. This is advanced several
* times a second for a minute or two at a stretch, and a long ring moves by a fraction
* of a degree per tick — invalidating on every one of them would be a couple of hundred
* pointless draws per episode on a box that has a decoder to feed.
*/
fun setRemaining(remainingMs: Long, totalMs: Long) {
val remaining = remainingMs.coerceAtLeast(0L)
val nextFigure = formatRemaining(remaining)
val nextProgress = if (totalMs > 0L) {
(remaining.toFloat() / totalMs.toFloat()).coerceIn(0f, 1f)
} else {
0f
}
// A degree is about the smallest movement worth a redraw; below that the arc lands
// on the same pixels.
val moved = kotlin.math.abs(nextProgress - progress) * 360f >= 1f
if (nextFigure == figure && !moved) return
figure = nextFigure
progress = nextProgress
describe(nextFigure)?.let { contentDescription = it }
invalidate()
}
/** What a screen reader should make of the figure. Nothing, unless a subclass says. */
protected open fun describe(figure: String): CharSequence? = null
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val strokeInset = trackPaint.strokeWidth / 2f
val diameter = min(width, height).toFloat()
val left = (width - diameter) / 2f + strokeInset
val top = (height - diameter) / 2f + strokeInset
ringBounds.set(
left,
top,
left + diameter - trackPaint.strokeWidth,
top + diameter - trackPaint.strokeWidth,
)
canvas.drawOval(ringBounds, trackPaint)
if (progress > 0f) {
// Anticlockwise from the top, so the ring empties the way a clock hand would
// sweep back rather than filling up as the thing it measures runs out.
canvas.drawArc(ringBounds, -90f, -360f * progress, false, progressPaint)
}
if (figure.isEmpty()) return
figurePaint.textSize = figureTextSize(diameter, figure.length)
val baseline = height / 2f - (figurePaint.ascent() + figurePaint.descent()) / 2f
canvas.drawText(figure, width / 2f, baseline, figurePaint)
}
/**
* The figure has to fit inside the ring, and "1:58" is twice the width of "12". Sizing
* from the string's own length is what stops a two-minute opening printing over its own
* arc — an intro is commonly long enough to be counted in minutes, so this is the
* ordinary case rather than the edge one.
*/
private fun figureTextSize(diameter: Float, characters: Int): Float =
diameter * if (characters >= 4) 0.30f else 0.42f
}
/**
* "1:58" over a minute, "58" under it. Never "0:58": a bare figure is read at a glance in a
* ring this size, and the colon is only worth its width once there are minutes to separate.
*/
internal fun formatRemaining(remainingMs: Long): String {
val seconds = ceil(remainingMs.coerceAtLeast(0L) / 1_000.0).toInt()
if (seconds < 60) return seconds.toString()
return "${seconds / 60}:${(seconds % 60).toString().padStart(2, '0')}"
}
@@ -399,6 +399,17 @@ class PlayerActivity : ComponentActivity() {
private var currentMediaSubtitles: List<PlayableSubtitle> = emptyList()
private var nextUpBanner: View? = null
private var nextUpCountdown: TextView? = null
private var nextUpRing: CountdownRingView? = null
private var nextUpLogo: ImageView? = null
private var nextUpSeries: TextView? = null
/**
* The logo the bar is currently wearing, so the identity is bound once per episode
* rather than on every 250ms tick of the countdown. Coil would answer the repeats from
* its memory cache, but each one is still a request built, a listener attached and a
* drawable re-tinted while a decoder is being fed.
*/
private var nextUpBoundLogoUrl: String? = null
private var nextUpBoundItemId: String? = null
private var nextUpDismissed = false
private var advancing = false
@@ -2710,7 +2721,6 @@ class PlayerActivity : ComponentActivity() {
playerView?.isControllerFullyVisible != true &&
!castPanelVisible.value &&
subtitleOverlay?.isVisible != true &&
nextUpBanner?.isVisible != true &&
creditsView?.isVisible != true &&
loadingView?.isVisible != true &&
errorView?.isVisible != true
@@ -3046,7 +3056,6 @@ class PlayerActivity : ComponentActivity() {
playerView?.isControllerFullyVisible != true &&
!castPanelVisible.value &&
subtitleOverlay?.isVisible != true &&
nextUpBanner?.isVisible != true &&
creditsView?.isVisible != true &&
loadingView?.isVisible != true &&
errorView?.isVisible != true
@@ -3202,11 +3211,18 @@ class PlayerActivity : ComponentActivity() {
val banner = findViewById<View>(R.id.player_next_up)
nextUpBanner = banner
nextUpCountdown = banner.findViewById(R.id.player_next_up_countdown)
banner.findViewById<View>(R.id.player_next_up_play).setOnClickListener {
startNextEpisode()
}
banner.findViewById<View>(R.id.player_next_up_dismiss).setOnClickListener {
dismissNextUp()
nextUpLogo = banner.findViewById(R.id.player_next_up_logo)
nextUpSeries = banner.findViewById(R.id.player_next_up_series)
nextUpRing = banner.findViewById<CountdownRingView>(R.id.player_next_up_ring)?.apply {
// Fixed rather than taken from a drawable state, because nothing here is
// focusable: the ring sits on somebody's programme and has to read against
// whatever the picture behind it happens to be doing.
setRingWidthDp(2.5f)
setInk(
track = Color.argb(56, 255, 255, 255),
arc = NEXT_UP_ACCENT,
figure = Color.WHITE,
)
}
}
@@ -3232,6 +3248,10 @@ class PlayerActivity : ComponentActivity() {
// 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)
// Binds the bar's logo a minute or more before it is drawn, and warms Coil with
// it in the same movement. Safe to do for an episode nobody reaches the end of:
// the bar is hidden, and the next `begin` clears what was bound.
resolved?.let(::bindNextUpIdentity)
resolved?.imageUrl?.let { imageUrl ->
imageLoader.enqueue(
ImageRequest.Builder(this@PlayerActivity)
@@ -3563,16 +3583,25 @@ class PlayerActivity : ComponentActivity() {
}
nextUpDismissed -> Unit
remainingMs == 0L -> if (autoAdvance) startNextEpisode()
// Something that owns the screen is up, or the transport is occupying the same
// bottom edge. The bar has nothing to say that is worth being drawn under
// either, and it comes straight back when they go.
!nextUpCanShow() -> hideNextUp()
else -> {
showNextUp(next)
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
}
// The countdown and its ring are a promise that something will happen by
// itself, so they are drawn only where that is true. With automatic advance
// off the bar still says what is next and the transport's Next Episode
// button still starts it — the offer is not the transition.
nextUpCountdown?.visibility = if (autoAdvance) View.VISIBLE else View.GONE
nextUpRing?.visibility = if (autoAdvance) View.VISIBLE else View.GONE
if (autoAdvance) {
nextUpCountdown?.text =
getString(R.string.next_up_starting_in, formatRemaining(remainingMs))
// Advanced from the playhead, not from a timer: pausing inside the last
// minute holds the ring where it is and seeking back out of the window
// takes the bar away entirely.
nextUpRing?.setRemaining(remainingMs, NEXT_UP_LEAD_MS)
}
}
}
@@ -3714,37 +3743,76 @@ class PlayerActivity : ComponentActivity() {
}
}
@OptIn(UnstableApi::class)
/**
* Whether the bar has anything to say and anywhere to say it.
*
* Every other overlay in this player *owns* the screen while it is up the drop-up,
* the cast panel, the credits pane, the error and loading surfaces and this one owns
* nothing at all, so wherever one of those is up this simply stands down rather than
* being drawn underneath it. The transport is in the list for the plainest reason:
* it is a full-width strip along the same bottom edge, and the two would overlap.
*/
private fun nextUpCanShow(): Boolean =
!prerollActive &&
playerView?.isControllerFullyVisible != true &&
!castPanelVisible.value &&
subtitleOverlay?.isVisible != true &&
skipIntroView?.isVisible != true &&
creditsView?.isVisible != true &&
loadingView?.isVisible != true &&
errorView?.isVisible != true
/**
* Puts the show's own title treatment on the bar, with its name as the fallback.
*
* Called when the next episode is *resolved* rather than when the bar appears, which
* is a minute or more of lead time on the ordinary path so by the time the bar fades
* in the logo is already in place and the viewer never sees the name swapped for the
* artwork. The fallback is shown meanwhile, because a bar held back waiting on a logo
* is a bar that is late for the thing it was announcing.
*/
private fun bindNextUpIdentity(next: NextEpisode) {
val logo = nextUpLogo ?: return
val fallback = nextUpSeries ?: return
val url = next.logoUrl?.takeIf { it.isNotBlank() }
if (nextUpBoundItemId == next.itemId && nextUpBoundLogoUrl == url) return
nextUpBoundItemId = next.itemId
nextUpBoundLogoUrl = url
fallback.text = next.seriesName.ifBlank { next.title }
logo.clearColorFilter()
logo.visibility = View.GONE
fallback.visibility = View.VISIBLE
if (url == null) return
logo.load(url) {
crossfade(false)
listener(
onSuccess = { _, result ->
// An Emby title treatment is commonly black-on-transparent, which on
// this wash is an invisible heading. Same judgement, same helper, as
// the ident in the opposite corner.
makeLogoVisibleOnDarkBackground(logo, result.drawable)
logo.visibility = View.VISIBLE
fallback.visibility = View.GONE
},
onError = { _, _ ->
logo.visibility = View.GONE
fallback.visibility = View.VISIBLE
},
)
}
}
private fun showNextUp(next: NextEpisode) {
val banner = nextUpBanner ?: return
bindNextUpIdentity(next)
if (banner.isVisible) return
banner.findViewById<TextView>(R.id.player_next_up_title).text =
next.title.ifBlank { next.seriesName }
val meta = listOfNotNull(
next.episodeCode,
next.seriesName.takeIf { it.isNotBlank() && next.title.isNotBlank() },
).joinToString(" · ")
banner.findViewById<TextView>(R.id.player_next_up_meta).apply {
text = meta
visibility = if (meta.isBlank()) View.GONE else View.VISIBLE
}
banner.findViewById<ImageView>(R.id.player_next_up_image).load(next.imageUrl) {
crossfade(true)
}
banner.alpha = 0f
banner.visibility = View.VISIBLE
banner.post {
zoomVideoForNextUp()
banner.animate()
.alpha(1f)
.setDuration(NEXT_UP_ANIMATION_MS)
.setInterpolator(DecelerateInterpolator())
.start()
playerView?.hideController()
banner.findViewById<View>(R.id.player_next_up_play).requestFocus()
}
banner.animate()
.alpha(1f)
.setDuration(NEXT_UP_ANIMATION_MS)
.setInterpolator(DecelerateInterpolator())
.start()
}
private fun hideNextUp() {
@@ -3758,37 +3826,17 @@ class PlayerActivity : ComponentActivity() {
banner.alpha = 1f
}
.start()
restoreVideoAfterNextUp()
}
/**
* Refuses the offer. Back is how it is reached, which is the same contract every other
* overlay in this player has one press per level, and the key that would otherwise
* leave the programme spends itself on the thing that appeared over it instead.
*/
private fun dismissNextUp() {
nextUpDismissed = true
nextUpJob?.cancel()
hideNextUp()
playerView?.requestFocus()
}
private fun zoomVideoForNextUp() {
val view = playerView ?: return
view.animate()
.scaleX(NEXT_UP_VIDEO_SCALE)
.scaleY(NEXT_UP_VIDEO_SCALE)
.translationX(-view.width * NEXT_UP_VIDEO_SHIFT_X)
.translationY(-view.height * NEXT_UP_VIDEO_SHIFT_Y)
.setDuration(NEXT_UP_ANIMATION_MS)
.setInterpolator(DecelerateInterpolator())
.start()
}
private fun restoreVideoAfterNextUp() {
playerView?.animate()
?.scaleX(1f)
?.scaleY(1f)
?.translationX(0f)
?.translationY(0f)
?.setDuration(NEXT_UP_ANIMATION_MS)
?.setInterpolator(DecelerateInterpolator())
?.start()
}
// --- Closing credits ----------------------------------------------------------------
@@ -4512,7 +4560,10 @@ class PlayerActivity : ComponentActivity() {
playerView?.isControllerFullyVisible != true &&
!castPanelVisible.value &&
subtitleOverlay?.isVisible != true &&
nextUpBanner?.isVisible != true &&
// The next-up bar is deliberately absent from this list. It takes no focus and
// holds no button, so the centre key still means pause while it is up — the
// whole point of the compact bar is that the remote goes on meaning what it
// meant a moment before it appeared.
// The pane's Play button holds focus while it is up, and the centre key is how a
// remote presses what it is focused on.
creditsView?.isVisible != true &&
@@ -6142,9 +6193,15 @@ class PlayerActivity : ComponentActivity() {
private const val NEXT_EPISODE_PREVIEW_LEAD_MS = 120_000L
private const val NEXT_EPISODE_PREVIEW_STARTUP_TIMEOUT_MS = 8_000L
private const val NEXT_UP_ANIMATION_MS = 260L
private const val NEXT_UP_VIDEO_SCALE = 0.58f
private const val NEXT_UP_VIDEO_SHIFT_X = 0.18f
private const val NEXT_UP_VIDEO_SHIFT_Y = 0.15f
/**
* The green the bar's eyebrow and countdown arc share with the rest of Memby.
*
* Written out rather than built with `Color.rgb`, because this companion is
* initialised by plain JUnit tests that have no Android framework under them and
* every android.graphics call from one throws.
*/
private val NEXT_UP_ACCENT = 0xFF69CD61.toInt()
private const val NEXT_UP_IMAGE_PREFETCH_WIDTH = 640
private const val NEXT_UP_IMAGE_PREFETCH_HEIGHT = 360
@@ -1,80 +1,28 @@
package com.ponzischeme89.memby.ui.player
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Typeface
import android.util.AttributeSet
import android.view.View
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ui.theme.membyTypeface
import kotlin.math.ceil
import kotlin.math.min
/**
* The ring on the skip-intro button: how long is left to press it, drawn as a draining arc
* with the figure inside.
* The ring on the skip-intro button: how long is left to press it.
*
* Like [PrerollCountdownView] it runs no animator of its own. PlayerActivity advances it
* from the playhead, which is what keeps it honest — pausing during the titles holds the
* ring where it is, and seeking moves it to wherever the film now is, neither of which a
* timer counting wall-clock seconds could do.
*
* It takes its colours from its own drawable state rather than from a setter. The button
* around it is a state-list pill — green with white text, white with dark text once
* focused — so the ring has to change with it or it disappears into the fill the moment
* somebody's remote reaches it. `duplicateParentState` in the layout is what feeds that
* state down; without it this draws focused colours never.
* The drawing is [CountdownRingView]'s, shared with the next-up overlay. What is this
* class's own is the one thing that differs — it takes its colours from its own drawable
* state rather than from a setter. The button around it is a state-list pill — green with
* white text, white with dark text once focused — so the ring has to change with it or it
* disappears into the fill the moment somebody's remote reaches it. `duplicateParentState`
* in the layout is what feeds that state down; without it this draws focused colours never.
*/
class SkipIntroCountdownView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr) {
) : CountdownRingView(context, attrs, defStyleAttr) {
private val density = resources.displayMetrics.density
private val ringBounds = RectF()
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeWidth = 2.5f * density
}
private val progressPaint = Paint(trackPaint)
private val figurePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
typeface = context.membyTypeface(Typeface.BOLD)
}
private var figure = ""
private var progress = 1f
/**
* How much of the offer is left, and how long it ran for.
*
* Redraws only when the drawn result would actually differ. This is advanced several
* times a second for two minutes at a stretch, and a two-minute ring moves by a
* fraction of a degree per tick — invalidating on every one of them would be a couple
* of hundred pointless draws per episode on a box that has a decoder to feed.
*/
fun setRemaining(remainingMs: Long, totalMs: Long) {
val remaining = remainingMs.coerceAtLeast(0L)
val nextFigure = formatRemaining(remaining)
val nextProgress = if (totalMs > 0L) {
(remaining.toFloat() / totalMs.toFloat()).coerceIn(0f, 1f)
} else {
0f
}
// A degree is about the smallest movement worth a redraw; below that the arc lands
// on the same pixels.
val moved = kotlin.math.abs(nextProgress - progress) * 360f >= 1f
if (nextFigure == figure && !moved) return
figure = nextFigure
progress = nextProgress
contentDescription = context.getString(R.string.player_skip_intro_countdown, nextFigure)
invalidate()
}
override fun describe(figure: String): CharSequence =
context.getString(R.string.player_skip_intro_countdown, figure)
override fun drawableStateChanged() {
super.drawableStateChanged()
@@ -85,61 +33,19 @@ class SkipIntroCountdownView @JvmOverloads constructor(
// The unspent part of the ring has to stay visible without competing with the arc.
// Dark ink on the focused white pill needs more of itself than white does on green,
// where the fill is already doing half the separating.
trackPaint.color = Color.argb(
if (focused) 92 else 72,
Color.red(ink),
Color.green(ink),
Color.blue(ink),
setInk(
track = Color.argb(
if (focused) 92 else 72,
Color.red(ink),
Color.green(ink),
Color.blue(ink),
),
arc = ink,
figure = ink,
)
progressPaint.color = ink
figurePaint.color = ink
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val strokeInset = trackPaint.strokeWidth / 2f
val diameter = min(width, height).toFloat()
val left = (width - diameter) / 2f + strokeInset
val top = (height - diameter) / 2f + strokeInset
ringBounds.set(
left,
top,
left + diameter - trackPaint.strokeWidth,
top + diameter - trackPaint.strokeWidth,
)
canvas.drawOval(ringBounds, trackPaint)
if (progress > 0f) {
// Anticlockwise from the top, so the ring empties the way a clock hand would
// sweep back rather than filling up as the thing it measures runs out.
canvas.drawArc(ringBounds, -90f, -360f * progress, false, progressPaint)
}
if (figure.isEmpty()) return
figurePaint.textSize = figureTextSize(diameter, figure.length)
val baseline = height / 2f - (figurePaint.ascent() + figurePaint.descent()) / 2f
canvas.drawText(figure, width / 2f, baseline, figurePaint)
}
/**
* The figure has to fit inside the ring, and "1:58" is twice the width of "12". Sizing
* from the string's own length is what stops a two-minute opening printing over its own
* arc — an intro is commonly long enough to be counted in minutes, so this is the
* ordinary case rather than the edge one.
*/
private fun figureTextSize(diameter: Float, characters: Int): Float =
diameter * if (characters >= 4) 0.30f else 0.42f
private companion object {
val FOCUSED_INK = Color.rgb(11, 14, 17)
}
}
/**
* "1:58" over a minute, "58" under it. Never "0:58": a bare figure is read at a glance in a
* ring this size, and the colon is only worth its width once there are minutes to separate.
*/
internal fun formatRemaining(remainingMs: Long): String {
val seconds = ceil(remainingMs.coerceAtLeast(0L) / 1_000.0).toInt()
if (seconds < 60) return seconds.toString()
return "${seconds / 60}:${(seconds % 60).toString().padStart(2, '0')}"
}