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
+3 -3
View File
@@ -68,9 +68,9 @@
android:name=".MembyApp"
android:allowBackup="true"
android:banner="@drawable/app_banner"
android:icon="@drawable/emby_logo"
android:icon="@drawable/memby_mark"
android:label="@string/app_name"
android:roundIcon="@drawable/emby_logo"
android:roundIcon="@drawable/memby_mark"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.Memby">
@@ -145,7 +145,7 @@
<service
android:name=".screensaver.MembyDreamService"
android:exported="true"
android:icon="@drawable/emby_logo"
android:icon="@drawable/memby_mark"
android:label="@string/screensaver_name"
android:permission="android.permission.BIND_DREAM_SERVICE">
<intent-filter>
@@ -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')}"
}
@@ -1,9 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A wash, not a panel. This sits on a programme somebody is still watching, so it has no
stroke and no flat fill: it is darkest under the logo, where the type needs the
contrast, and thins towards the countdown so the bar reads as part of the picture
rather than as a card laid over it. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#F2101418" />
<corners android:radius="14dp" />
<stroke
android:width="1dp"
android:color="#26FFFFFF" />
<gradient
android:angle="0"
android:centerColor="#D0070A0C"
android:endColor="#8C070A0C"
android:startColor="#F0070A0C"
android:type="linear" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#3DFFFFFF" />
</shape>
@@ -1,6 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A shallow native end-of-episode overlay. The outgoing PlayerView is scaled into the
open left side while this single prefetched episode card fades in. -->
<!-- What is on next, said in one short bar in the bottom-left corner while the episode the
viewer is watching keeps playing behind it.
It replaced a 420dp card that shrank the picture to 58% to make room for itself, which
is the whole complaint: the credits are the last thing an episode has to say and the
overlay was covering them. Nothing here is focusable — the bar takes no part in the
remote's world, so the transport, the seek keys and the centre button all keep meaning
what they meant a moment before it appeared. Play now is the transport's own Next
Episode button, which is offered whenever this bar is, and Back dismisses.
Everything is in dp and nothing is measured against the screen, so it is the same size
on a 720p set and a 4K one; the safe-area margins keep it clear of overscan. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/player_next_up"
@@ -10,102 +20,89 @@
android:visibility="gone">
<LinearLayout
android:layout_width="420dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical|end"
android:layout_marginEnd="50dp"
android:layout_gravity="bottom|start"
android:layout_marginStart="48dp"
android:layout_marginBottom="48dp"
android:background="@drawable/next_up_banner_background"
android:orientation="vertical"
android:padding="20dp">
android:baselineAligned="false"
android:focusable="false"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="22dp"
android:paddingTop="13dp"
android:paddingEnd="22dp"
android:paddingBottom="13dp">
<ImageView
android:id="@+id/player_next_up_image"
android:layout_width="match_parent"
android:layout_height="214dp"
android:background="#FF1B2026"
android:contentDescription="@null"
android:scaleType="centerCrop" />
<TextView
<!-- The show's own title treatment, with its name as the fallback underneath. Both
live in the same slot at the same height, so which one arrives cannot change
the shape of the bar. -->
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:letterSpacing="0.14"
android:text="@string/next_up_label"
android:textColor="#FF69CD61"
android:textSize="11sp" />
android:layout_height="34dp">
<TextView
android:id="@+id/player_next_up_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:ellipsize="end"
android:maxLines="2"
android:textColor="#FFFFFFFF"
android:textSize="24sp"
android:textStyle="bold" />
<ImageView
android:id="@+id/player_next_up_logo"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:adjustViewBounds="true"
android:contentDescription="@null"
android:maxWidth="168dp"
android:scaleType="fitStart"
android:visibility="gone"
tools:visibility="visible" />
<TextView
android:id="@+id/player_next_up_meta"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="#BFFFFFFF"
android:textSize="13sp" />
<TextView
android:id="@+id/player_next_up_series"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:ellipsize="end"
android:gravity="center_vertical"
android:maxLines="1"
android:maxWidth="220dp"
android:textColor="#FFFFFFFF"
android:textSize="18sp"
android:textStyle="bold"
tools:text="Lioness" />
</FrameLayout>
<TextView
android:id="@+id/player_next_up_countdown"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:textColor="#FF69CD61"
android:textSize="16sp"
android:textStyle="bold" />
<View
android:layout_width="1dp"
android:layout_height="26dp"
android:layout_marginStart="18dp"
android:layout_marginEnd="18dp"
android:background="@drawable/next_up_divider" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:orientation="horizontal"
tools:ignore="ButtonStyle">
android:orientation="vertical">
<Button
android:id="@+id/player_next_up_play"
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/next_up_primary_button"
android:focusable="true"
android:minWidth="0dp"
android:paddingStart="22dp"
android:paddingTop="9dp"
android:paddingEnd="22dp"
android:paddingBottom="9dp"
android:stateListAnimator="@null"
android:text="@string/next_up_play_now"
android:textAllCaps="false"
android:textColor="@color/next_up_button_text"
android:textSize="15sp" />
android:letterSpacing="0.14"
android:text="@string/next_up_next_episode"
android:textColor="#FF69CD61"
android:textSize="11sp"
android:textStyle="bold" />
<Button
android:id="@+id/player_next_up_dismiss"
<TextView
android:id="@+id/player_next_up_countdown"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:background="@drawable/next_up_secondary_button"
android:focusable="true"
android:minWidth="0dp"
android:paddingStart="22dp"
android:paddingTop="9dp"
android:paddingEnd="22dp"
android:paddingBottom="9dp"
android:stateListAnimator="@null"
android:text="@string/next_up_dismiss"
android:textAllCaps="false"
android:textColor="@color/next_up_button_text"
android:textSize="15sp" />
android:layout_marginTop="3dp"
android:maxLines="1"
android:textColor="#D9FFFFFF"
android:textSize="13sp"
tools:text="Starting in 15s" />
</LinearLayout>
<com.ponzischeme89.memby.ui.player.CountdownRingView
android:id="@+id/player_next_up_ring"
android:layout_width="38dp"
android:layout_height="38dp"
android:layout_marginStart="20dp" />
</LinearLayout>
</FrameLayout>
+8 -2
View File
@@ -87,8 +87,14 @@
<string name="player_skip_intro_countdown">%1$s left</string>
<string name="next_up_label">NEXT UP</string>
<string name="next_up_play_now">Play now</string>
<string name="next_up_dismiss">Dismiss</string>
<string name="next_up_starting_in">Starting in %1$ds</string>
<!-- The eyebrow on the compact next-up bar. "NEXT EPISODE" rather than "NEXT UP":
with the show's own logo beside it there is room to say which of the two the bar
is promising, and the credits pane already uses the longer wording. -->
<string name="next_up_next_episode">NEXT EPISODE</string>
<!-- The line under the eyebrow, given the same figure the ring beside it is drawing:
a clock value over a minute, a bare count of seconds under one. Two figures on one
bar that disagree - "60s" beside "1:00" - read as a countdown that has gone wrong. -->
<string name="next_up_starting_in">Starting in %1$s</string>
<!-- The way back to the credits at normal size and normal speed. Worded as wanting the
credits rather than as dismissing a panel: it is the only thing this button does,
and somebody pressing it is asking to watch them. -->
@@ -0,0 +1,26 @@
package com.ponzischeme89.memby.data
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* The one boundary this feature has: anything other than exactly "v2" is "v1", never a
* screen the app cannot draw. A missing field, a blank string and a future value this build
* has never heard of must all be indistinguishable from an operator who left it alone.
*/
class DetailExperienceTest {
@Test
fun `v2 is the only value that opts in`() {
assertEquals("v2", detailExperienceOrDefault("v2"))
}
@Test
fun `missing, blank and unknown values all fall back to v1`() {
assertEquals("v1", detailExperienceOrDefault(""))
assertEquals("v1", detailExperienceOrDefault("v1"))
assertEquals("v1", detailExperienceOrDefault("V2"))
assertEquals("v1", detailExperienceOrDefault("v3"))
assertEquals("v1", detailExperienceOrDefault("unknown"))
}
}
@@ -0,0 +1,194 @@
package com.ponzischeme89.memby.ui.player
import android.app.Activity
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorFilter
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.PixelFormat
import android.graphics.RadialGradient
import android.graphics.Shader
import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import androidx.core.view.isVisible
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.R
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* The compact next-up bar over a stand-in for the closing minute of an episode, captured
* from the real player XML at TV resolution: `build/screenshots/next-up/`.
*
* This is the only test that can judge the thing the redesign is actually about. A unit test
* can check that the countdown says "15"; it cannot check that the bar leaves the programme
* visible, which is the entire complaint the old 420dp card produced. So the background is a
* deliberately *lit* stand-in for a frame with the light pooled where the bar sits, and the
* capture is of the whole 960x540 area rather than of the bar alone: if the picture is not
* still readable around it, this is where that shows.
*
* Both identity cases are captured because they are the two halves of the logo rule - a show
* whose title treatment Emby holds, and one where the name is all there is. The bar must be
* the same height either way, or a household whose library is half decorated gets a bar that
* changes shape between episodes.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class NextUpOverlayScreenshotTest {
@Test
fun `what is on next, wearing the show's own title treatment`() {
capture(name = "next-up-logo", seriesName = "Lioness", withLogo = true, remainingMs = 15_000L)
}
@Test
fun `a show with no logo falls back to its name`() {
capture(name = "next-up-text-fallback", seriesName = "Lioness", withLogo = false, remainingMs = 15_000L)
}
@Test
fun `the bar as it opens, with the whole minute still to run`() {
// The ring is full and the figure is at its widest here, which is where it either
// fits inside the arc or prints over it.
capture(name = "next-up-opening", seriesName = "Slow Horses", withLogo = false, remainingMs = 60_000L)
}
@Test
fun `the last seconds before the handover`() {
capture(name = "next-up-running-out", seriesName = "Slow Horses", withLogo = false, remainingMs = 3_000L)
}
@Test
fun `with automatic advance off there is no countdown to draw`() {
// The bar still says what is next - the offer is not the transition - but nothing on
// it promises the episode will start by itself, because it will not.
capture(
name = "next-up-manual",
seriesName = "The Bear",
withLogo = false,
remainingMs = 0L,
autoAdvance = false,
)
}
private fun capture(
name: String,
seriesName: String,
withLogo: Boolean,
remainingMs: Long,
autoAdvance: Boolean = true,
) {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val root = FrameLayout(activity).apply { background = fakeScene() }
val bar = LayoutInflater.from(activity).inflate(R.layout.player_next_up_banner, root, false)
bar.visibility = View.VISIBLE
val logo = bar.findViewById<ImageView>(R.id.player_next_up_logo)
val fallback = bar.findViewById<TextView>(R.id.player_next_up_series).apply {
text = seriesName
}
// Stands in for the fetched title treatment: a wide, short wordmark rather than a
// photograph, because what matters is that a piece of artwork of that shape sits in
// the slot at the same height the name would have taken.
logo.setImageDrawable(if (withLogo) fakeTitleTreatment(seriesName) else null)
logo.isVisible = withLogo
fallback.isVisible = !withLogo
bar.findViewById<TextView>(R.id.player_next_up_countdown).apply {
isVisible = autoAdvance
text = activity.getString(R.string.next_up_starting_in, formatRemaining(remainingMs))
}
bar.findViewById<CountdownRingView>(R.id.player_next_up_ring).apply {
isVisible = autoAdvance
// The same three colours PlayerActivity sets, so this is the real ring.
setInk(
track = Color.argb(56, 255, 255, 255),
arc = Color.rgb(0x69, 0xCD, 0x61),
figure = Color.WHITE,
)
setRemaining(remainingMs, LEAD_MS)
}
root.addView(bar)
activity.setContentView(root)
root.captureRoboImage("build/screenshots/next-up/$name.png")
}
/** A wide, short wordmark: the shape an Emby title treatment actually is. */
private fun fakeTitleTreatment(text: String): Drawable = object : Drawable() {
override fun draw(canvas: Canvas) {
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = bounds.height() * 0.66f
letterSpacing = 0.20f
isFakeBoldText = true
}
val baseline = bounds.height() / 2f - (paint.ascent() + paint.descent()) / 2f
canvas.drawText(text.uppercase(), 0f, baseline, paint)
}
override fun setAlpha(alpha: Int) = Unit
override fun setColorFilter(colorFilter: ColorFilter?) = Unit
@Deprecated("Deprecated in Java")
override fun getOpacity() = PixelFormat.TRANSLUCENT
override fun getIntrinsicWidth() = 420
override fun getIntrinsicHeight() = 96
}
/**
* Stands in for a frame of an episode's closing minute, lit where the bar sits. The bar
* carries a wash rather than a panel, so the question this answers is whether that wash
* is enough for the type over a bright picture - and whether the picture is still there,
* which is the whole point of the redesign.
*/
private fun fakeScene(): Drawable = object : GradientDrawable(
Orientation.TL_BR,
intArrayOf(Color.rgb(28, 46, 66), Color.rgb(74, 62, 44), Color.rgb(150, 132, 96)),
) {
override fun draw(canvas: Canvas) {
super.draw(canvas)
val width = bounds.width().toFloat()
val height = bounds.height().toFloat()
canvas.drawPaint(
Paint().apply {
shader = RadialGradient(
width * 0.24f, height * 0.78f, width * 0.40f,
intArrayOf(Color.argb(205, 255, 238, 205), Color.TRANSPARENT),
null,
Shader.TileMode.CLAMP,
)
},
)
canvas.drawRect(
0f, height * 0.62f, width, height,
Paint().apply {
shader = LinearGradient(
0f, height * 0.62f, 0f, height,
Color.argb(110, 12, 16, 22), Color.argb(200, 6, 8, 12),
Shader.TileMode.CLAMP,
)
},
)
}
}
private companion object {
/** The final minute, which is the window the bar and its ring both measure. */
const val LEAD_MS = 60_000L
}
}