0.3.21
This commit is contained in:
@@ -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')}"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user