0.3.21
This commit is contained in:
@@ -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 30–40dp. */
|
||||
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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user