This commit is contained in:
ponzischeme89
2026-08-17 07:34:23 +12:00
parent 93fb0fd728
commit 36cb1324fd
56 changed files with 1177 additions and 168 deletions
+1 -1
View File
@@ -46,7 +46,7 @@ val projectNoticeText =
// A release workflow can derive the app version from its Git tag without editing the
// source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.69"
val defaultVersionName = "0.2.71"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -97,6 +97,7 @@ data class Playable(
val itemId: String,
val title: String,
val url: String,
val seriesName: String? = null,
val resumePositionMs: Long = 0L,
val logoUrl: String? = null,
val subtitles: List<PlayableSubtitle> = emptyList(),
@@ -203,6 +204,7 @@ data class PlaybackRequest(
val itemId: String,
val itemType: String = "",
val title: String = "",
val seriesName: String? = null,
val isSeries: Boolean = false,
val resumePositionMs: Long = 0L,
val logoUrl: String? = null,
@@ -259,6 +261,8 @@ class EmbyRepository(private val settings: SettingsStore) {
val showTitleLogo: Boolean get() = snapshot.showTitleLogo
private val _playbackStops = MutableSharedFlow<String>(extraBufferCapacity = 1)
val playbackStops = _playbackStops.asSharedFlow()
/** Emby session reports must arrive in order; an older progress request cannot follow Stop. */
private val playbackReportMutex = Mutex()
private val playableMutex = Mutex()
private val playableCache = LinkedHashMap<String, CachedPlayable>(16, 0.75f, true)
private val playableInFlight = mutableMapOf<String, Deferred<Playable>>()
@@ -1858,6 +1862,11 @@ class EmbyRepository(private val settings: SettingsStore) {
itemId = item.id,
itemType = item.type,
title = item.name,
seriesName = when {
item.isEpisode -> item.seriesName
item.isSeries -> item.name
else -> null
},
isSeries = item.isSeries,
resumePositionMs = item.resumePositionMs,
logoUrl = logoUrl(item),
@@ -2121,6 +2130,7 @@ class EmbyRepository(private val settings: SettingsStore) {
return Playable(
itemId = playback.itemId,
title = playback.title.ifBlank { item.title },
seriesName = playback.seriesName.ifBlank { item.seriesName },
url = playback.url,
resumePositionMs = playback.resumePositionMs,
logoUrl = item.logoUrl,
@@ -2159,6 +2169,7 @@ class EmbyRepository(private val settings: SettingsStore) {
return Playable(
itemId = episode.id,
title = title,
seriesName = item.title,
url = discovery.url ?: buildStreamUrl(episode.id),
resumePositionMs = episode.resumePositionMs,
logoUrl = item.logoUrl,
@@ -2184,6 +2195,7 @@ class EmbyRepository(private val settings: SettingsStore) {
return Playable(
itemId = item.itemId,
title = item.title,
seriesName = item.seriesName,
url = discovery.url ?: buildStreamUrl(item.itemId),
resumePositionMs = item.resumePositionMs,
logoUrl = item.logoUrl,
@@ -2248,11 +2260,13 @@ class EmbyRepository(private val settings: SettingsStore) {
}
suspend fun reportPlaybackStarted(session: PlaybackSession, positionMs: Long) {
if (ServerConfig.isGateway) {
requireGateway().report("started", session.gatewayReport(positionMs, false, null))
return
playbackReportMutex.withLock {
if (ServerConfig.isGateway) {
requireGateway().report("started", session.gatewayReport(positionMs, false, null))
} else {
requireApi().reportPlaybackStarted(playbackReport(session, positionMs, false, null))
}
}
requireApi().reportPlaybackStarted(playbackReport(session, positionMs, false, null))
}
suspend fun reportPlaybackProgress(
@@ -2261,25 +2275,30 @@ class EmbyRepository(private val settings: SettingsStore) {
isPaused: Boolean,
eventName: String,
durationMs: Long = 0L,
): String? {
): String? = playbackReportMutex.withLock {
if (ServerConfig.isGateway) {
return requireGateway().report(
"progress",
session.gatewayReport(positionMs, isPaused, eventName, durationMs),
).autoFollowedShowTitle.takeIf(String::isNotBlank)
requireGateway().report(
"progress",
session.gatewayReport(positionMs, isPaused, eventName, durationMs),
)
.autoFollowedShowTitle
.takeIf(String::isNotBlank)
} else {
requireApi().reportPlaybackProgress(playbackReport(session, positionMs, isPaused, eventName))
null
}
requireApi().reportPlaybackProgress(playbackReport(session, positionMs, isPaused, eventName))
return null
}
suspend fun reportPlaybackStopped(session: PlaybackSession, positionMs: Long) {
try {
if (ServerConfig.isGateway) {
// Stopping is also what drops the gateway's cached rows for this user,
// so Continue Watching reflects the new position on the next home load.
requireGateway().report("stopped", session.gatewayReport(positionMs, true, null))
} else {
requireApi().reportPlaybackStopped(playbackReport(session, positionMs, true, null))
playbackReportMutex.withLock {
if (ServerConfig.isGateway) {
// Stopping is also what drops the gateway's cached rows for this user,
// so Continue Watching reflects the new position on the next home load.
requireGateway().report("stopped", session.gatewayReport(positionMs, true, null))
} else {
requireApi().reportPlaybackStopped(playbackReport(session, positionMs, true, null))
}
}
} finally {
clearPlayableCache()
@@ -2289,9 +2308,14 @@ class EmbyRepository(private val settings: SettingsStore) {
}
}
fun enqueuePlaybackStopped(session: PlaybackSession, positionMs: Long) {
fun enqueuePlaybackStopped(
session: PlaybackSession,
positionMs: Long,
onSuccess: () -> Unit = {},
) {
scope.launch {
runCatching { reportPlaybackStopped(session, positionMs) }
.onSuccess { onSuccess() }
}
}
@@ -894,7 +894,7 @@ data class GatewayCalendar(
val next: String = "",
/** The household's today, only when it falls inside this month. */
val today: String = "",
/** Sunday is 0, matching the weekday header the grid draws. */
/** Sunday is 0 on the wire; the TV rotates it into its Monday-first NZ week. */
val firstWeekday: Int = 0,
val dayCount: Int = 0,
val days: List<GatewayCalendarDay> = emptyList(),
@@ -181,6 +181,17 @@ enum class BrowseDestination(val label: String, val icon: ImageVector) {
SETTINGS("Settings", Icons.Default.Settings),
}
/**
* The user switcher is a launcher action, not a browsing destination. Keep it pinned above
* Home while the destinations below it may change with server capabilities.
*/
internal fun navigationRailItems(calendarEnabled: Boolean): List<BrowseDestination> =
listOf(BrowseDestination.PROFILES) + BrowseDestination.entries.filter {
it != BrowseDestination.PROFILES &&
it != BrowseDestination.SETTINGS &&
(it != BrowseDestination.CALENDAR || calendarEnabled)
}
// No NEXT_UP: those episodes are part of CONTINUE, which is one row.
enum class MediaRowKind { CONTINUE, MOVIES, SHOWS, FAVORITES }
@@ -238,6 +249,7 @@ private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when {
)
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun TvNavigationRail(
config: NavigationRemoteConfig = BundledRemoteConfig.value.navigation,
@@ -358,12 +370,22 @@ fun TvNavigationRail(
// A destination with nothing behind it is worse than one fewer: the calendar
// needs the gateway and a Sonarr, and a household with neither would otherwise
// carry a rail item that only ever opens an apology.
val destinations = remember(calendarEnabled) {
BrowseDestination.entries.filter {
it != BrowseDestination.CALENDAR || calendarEnabled
val destinations = remember(calendarEnabled) { navigationRailItems(calendarEnabled) }
// Up and Down are explicit because the profile entry is an action while every
// item beneath it changes the current destination. Leaving this to spatial
// search allowed content behind the expanded rail to win occasionally.
val itemFocusRequesters = remember(
destinations,
focusDestination,
navigationFocusRequester,
) {
destinations.associateWith { destination ->
if (destination == focusDestination) navigationFocusRequester
else FocusRequester()
}
}
destinations.forEach { destination ->
destinations.forEachIndexed { index, destination ->
val itemFocusRequester = itemFocusRequesters.getValue(destination)
ExpandableNavigationItem(
destination = destination,
label = if (destination == BrowseDestination.PROFILES) {
@@ -373,11 +395,20 @@ fun TvNavigationRail(
},
selected = destination == selected,
expanded = expanded,
modifier = if (destination == focusDestination) {
Modifier.focusRequester(navigationFocusRequester)
} else {
Modifier
},
modifier = Modifier
.focusRequester(itemFocusRequester)
.focusProperties {
up = if (index == 0) {
FocusRequester.Cancel
} else {
itemFocusRequesters.getValue(destinations[index - 1])
}
down = if (index == destinations.lastIndex) {
FocusRequester.Cancel
} else {
itemFocusRequesters.getValue(destinations[index + 1])
}
},
onFocused = {},
onClick = { onDestinationSelected(destination) },
// Notifications are one level in, behind the user picker. Without a mark out
@@ -433,6 +464,7 @@ fun UserSwitcherOverlay(
modifier: Modifier = Modifier,
alertCount: Int = 0,
onOpenAlerts: () -> Unit = {},
onOpenSettings: () -> Unit = {},
/**
* Whether this viewer may ask the household for titles. False hides the entry entirely
* rather than dimming it: an operator's allowlist is not something a viewer can act on,
@@ -540,7 +572,10 @@ fun UserSwitcherOverlay(
state = profileListState,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 248.dp),
// Settings adds one fixed action below this list. Give that row its
// space while keeping the panel's established 400dp maximum; larger
// households still reach every profile through the lazy list.
.heightIn(max = 208.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
itemsIndexed(
@@ -580,8 +615,7 @@ fun UserSwitcherOverlay(
},
onClick = onOpenAlerts,
)
// Requests sits between the two because it is news-shaped like Notifications
// rather than administrative like Manage users, which stays last.
// Requests sits beside Notifications because both are personal activity.
if (showRequests) {
UserSwitcherAction(
label = "Requests",
@@ -594,10 +628,21 @@ fun UserSwitcherOverlay(
onClick = onOpenRequests,
)
}
val settingsIndex = profiles.size + if (showRequests) 2 else 1
UserSwitcherAction(
label = "Settings",
icon = Icons.Default.Settings,
modifier = Modifier
.focusRequester(focusRequesters[settingsIndex])
.onFocusChanged {
if (it.isFocused) focusedIndex = settingsIndex
},
onClick = onOpenSettings,
)
val manageIndex = profiles.size + actionCount - 1
UserSwitcherAction(
label = "Manage users",
icon = Icons.Default.Settings,
icon = Icons.Default.Person,
modifier = Modifier
.focusRequester(focusRequesters[manageIndex])
.onFocusChanged {
@@ -610,13 +655,13 @@ fun UserSwitcherOverlay(
}
/**
* Notifications, then Requests when this viewer may make them, then Manage users.
* Notifications, then Requests when this viewer may make them, Settings, then Manage users.
*
* Pure and derived in one place because three things read it — the requester list's length,
* the D-pad's lower bound and Manage users' own index — and a count that disagreed with the
* rows actually drawn is how the last item in a menu becomes unreachable.
*/
internal fun userSwitcherActionCount(showRequests: Boolean): Int = if (showRequests) 3 else 2
internal fun userSwitcherActionCount(showRequests: Boolean): Int = if (showRequests) 4 else 3
@Composable
private fun UserSwitcherProfileItem(
@@ -2191,6 +2191,8 @@ private fun HomeScreen(
context = context,
request = request,
posterUrl = repo.primaryUrl(item, maxWidth = 500),
backdropUrl = repo.backdropUrl(item, maxWidth = 1920)
?: repo.primaryUrl(item, maxWidth = 1920),
requestStartedAtMs = playbackRequestedAtMs,
),
)
@@ -2239,6 +2241,9 @@ private fun HomeScreen(
title = playable.title,
resumePositionMs = playable.resumePositionMs,
logoUrl = playable.logoUrl,
backdropUrl = repo.backdropUrl(item, maxWidth = 1920)
?: repo.primaryUrl(item, maxWidth = 1920),
seriesName = playable.seriesName,
overview = playable.overview ?: item.overview,
episodeCode = playable.episodeCode,
runtimeMs = playable.runtimeMs,
@@ -3175,6 +3180,18 @@ private fun HomeScreen(
showProfiles = true
},
alertCount = displayedNotifications.size,
onOpenSettings = {
homeViewModel.trackJourney(
category = "navigation", action = "select",
screen = journeyScreen, feature = "settings",
source = journeyScreen, target = "settings",
)
userSwitcherVisible = false
navigationExpanded = false
railFocusDestination = BrowseDestination.PROFILES
restoreRailAfterSettings = true
showSettings = true
},
onOpenAlerts = {
homeViewModel.trackJourney(
category = "notifications", action = "open", screen = journeyScreen,
@@ -3,9 +3,9 @@ package com.ponzischeme89.memby.ui
internal enum class UserSwitcherDirection { UP, DOWN }
/**
* Profiles occupy [0, profileCount); the pinned actions Notifications, then Manage users
* follow them in order. Keeping this arithmetic outside Compose makes remote navigation
* deterministic.
* Profiles occupy [0, profileCount); the pinned actions Notifications, optional Requests,
* Settings, then Manage users follow them in order. Keeping this arithmetic outside
* Compose makes remote navigation deterministic.
*/
internal fun userSwitcherInitialIndex(
profileIds: List<String>,
@@ -31,8 +31,8 @@ data class CalendarCell(
const val CALENDAR_COLUMNS = 7
/** Sunday first, matching the `firstWeekday` the gateway sends. */
val CALENDAR_WEEKDAYS = listOf("Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat")
/** New Zealand week order. The gateway's `firstWeekday` remains Sunday-based on the wire. */
val CALENDAR_WEEKDAYS = listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
data class CalendarAgendaWeek(
val cells: List<CalendarCell>,
@@ -73,7 +73,8 @@ fun calendarAgendaWeekDate(week: CalendarAgendaWeek): String =
fun calendarWeeks(calendar: GatewayCalendar): List<List<CalendarCell>> {
val dayCount = calendar.dayCount.coerceIn(0, 31)
if (dayCount == 0) return emptyList()
val leading = calendar.firstWeekday.coerceIn(0, CALENDAR_COLUMNS - 1)
// The wire uses Sunday = 0. Rotate that offset into the Monday-first grid shown on TV.
val leading = (calendar.firstWeekday.coerceIn(0, CALENDAR_COLUMNS - 1) + 6) % CALENDAR_COLUMNS
val byDay = calendar.days.associateBy { it.day }
val cells = ArrayList<CalendarCell>(leading + dayCount)
@@ -24,6 +24,13 @@ class PlaybackStopWorker(
ServiceLocator.init(applicationContext)
val itemId = inputData.getString(ITEM_ID).orEmpty()
if (itemId.isBlank()) return Result.failure()
if (!playbackStopIsFresh(
enqueuedAtMs = inputData.getLong(ENQUEUED_AT_MS, 0L),
nowMs = System.currentTimeMillis(),
)
) {
return Result.success()
}
val session = PlaybackSession(
itemId = itemId,
mediaSourceId = inputData.getString(MEDIA_SOURCE_ID).orEmpty().ifBlank { itemId },
@@ -47,6 +54,7 @@ class PlaybackStopWorker(
private const val PLAY_SESSION_ID = "play_session_id"
private const val PLAY_METHOD = "play_method"
private const val POSITION_MS = "position_ms"
private const val ENQUEUED_AT_MS = "enqueued_at_ms"
private const val MAX_RETRIES = 5
private fun workName(session: PlaybackSession): String =
@@ -59,15 +67,23 @@ class PlaybackStopWorker(
.putString(PLAY_SESSION_ID, session.playSessionId)
.putString(PLAY_METHOD, session.playMethod)
.putLong(POSITION_MS, positionMs.coerceAtLeast(0L))
.putLong(ENQUEUED_AT_MS, System.currentTimeMillis())
.build()
val request = OneTimeWorkRequestBuilder<PlaybackStopWorker>()
.setInputData(data)
.build()
WorkManager.getInstance(context.applicationContext).enqueueUniqueWork(
val workManager = WorkManager.getInstance(context.applicationContext)
workManager.enqueueUniqueWork(
workName(session),
ExistingWorkPolicy.REPLACE,
request,
)
// WorkManager is the process-death fallback, not the ordinary delivery path.
// Send now from the repository's process scope, which survives Activity
// destruction, then cancel this exact fallback request once Emby accepts it.
ServiceLocator.repository.enqueuePlaybackStopped(session, positionMs) {
workManager.cancelWorkById(request.id)
}
}
/** A resumed session must not be stopped later by work queued while backgrounded. */
@@ -79,3 +95,10 @@ class PlaybackStopWorker(
}
}
}
/** A late retry must not overwrite progress made after the viewer moved to another client. */
internal fun playbackStopIsFresh(
enqueuedAtMs: Long,
nowMs: Long,
maxAgeMs: Long = 60_000L,
): Boolean = enqueuedAtMs > 0L && nowMs >= enqueuedAtMs && nowMs - enqueuedAtMs <= maxAgeMs
@@ -124,6 +124,21 @@ internal fun passthroughOsdSummary(preference: AudioPassthroughPreference): Stri
else -> "${preference.codecs.size} formats"
}
/** Episode wording for the station ident, separate from the logo/fallback presentation. */
internal fun playbackIdentityEpisodeLabel(
title: String,
seriesName: String?,
episodeCode: String?,
): String? {
val code = episodeCode?.trim().orEmpty()
if (code.isEmpty()) return null
val episodeTitle = title.trim()
.removePrefix(seriesName?.trim().orEmpty() + " ")
.trim()
.takeUnless { it.isEmpty() || it == seriesName?.trim() }
return listOfNotNull(code, episodeTitle).joinToString(" · ")
}
/**
* Fullscreen Media3 player with native stream-track selection. Press Menu while
* playing to choose an audio or subtitle track; the subtitle controller button
@@ -151,6 +166,7 @@ class PlayerActivity : ComponentActivity() {
private var remainingView: TextView? = null
private var finishTimeView: TextView? = null
private var loadingView: View? = null
private var loadingBackdropView: ImageView? = null
private var loadingTitleView: TextView? = null
private var loadingHintView: TextView? = null
private val playbackLoadingQuote: String by lazy {
@@ -197,6 +213,8 @@ class PlayerActivity : ComponentActivity() {
private var launchTraceCookie = NO_TRACE
private var firstFrameTraceCookie = NO_TRACE
private var logoUrl: String? = null
private var loadingBackdropUrl: String? = null
private var playbackSeriesName: String? = null
private var playbackTitle = ""
private var pauseOverview = ""
private var prerollEpisodeCode = ""
@@ -306,6 +324,8 @@ class PlayerActivity : ComponentActivity() {
private var previewResumeDurationMs = 0L
private var previewResumeTitle = ""
private var previewResumeLogoUrl: String? = null
private var previewResumeBackdropUrl: String? = null
private var previewResumeSeriesName: String? = null
private var previewResumePosterUrl: String? = null
private var previewResumeOverview = ""
private var previewResumePlaybackStarted = false
@@ -548,6 +568,7 @@ class PlayerActivity : ComponentActivity() {
bindScrubPreview(view)
applyPictureMode()
loadingView = findViewById(R.id.playback_loading)
loadingBackdropView = findViewById(R.id.playback_loading_backdrop)
loadingTitleView = findViewById(R.id.playback_loading_title)
loadingHintView = findViewById<TextView>(R.id.playback_loading_hint).also {
// Set this as soon as the layout is mounted so the XML fallback never flashes
@@ -572,6 +593,10 @@ class PlayerActivity : ComponentActivity() {
streamStatusView = view.findViewById(R.id.player_stream_status)
logoUrl = savedInstanceState?.getString(STATE_LOGO_URL)
?: intent.getStringExtra(EXTRA_LOGO_URL)
loadingBackdropUrl = savedInstanceState?.getString(STATE_BACKDROP_URL)
?: intent.getStringExtra(EXTRA_BACKDROP_URL)
playbackSeriesName = savedInstanceState?.getString(STATE_SERIES_NAME)
?: intent.getStringExtra(EXTRA_SERIES_NAME)
playbackTitle = savedInstanceState?.getString(STATE_TITLE)
?: intent.getStringExtra(EXTRA_TITLE).orEmpty()
pauseOverview = savedInstanceState?.getString(STATE_OVERVIEW)
@@ -585,6 +610,7 @@ class PlayerActivity : ComponentActivity() {
}
pausePosterUrl = savedInstanceState?.getString(STATE_POSTER_URL)
?: intent.getStringExtra(EXTRA_POSTER_URL)
bindLoadingBackdrop(loadingBackdropUrl)
if (showPreroll) startPreroll() else startWithoutPreroll()
setUpPlaybackError()
@@ -789,7 +815,12 @@ class PlayerActivity : ComponentActivity() {
// --- Decoration -------------------------------------------------------------
bindTitleArtwork(title = playbackTitle, logoUrl = logoUrl)
bindPauseOverlay(view)
setUpPlaybackIdentity(title = playbackTitle)
setUpPlaybackIdentity(
title = playbackTitle,
seriesName = playbackSeriesName,
episodeCode = prerollEpisodeCode,
logoUrl = logoUrl,
)
setUpSubtitleOverlay()
setUpCastOverlay()
setUpNextUpBanner()
@@ -882,7 +913,7 @@ class PlayerActivity : ComponentActivity() {
playMethod = playable.playMethod
playbackTitle = playable.title.ifBlank { request.title + " trailer" }
bindTitleArtwork(playbackTitle, logoUrl)
setUpPlaybackIdentity(playbackTitle)
setUpPlaybackIdentity(playbackTitle, null, null, logoUrl)
startMedia(playable.url, emptyList(), 0L, playWhenReady = true)
}
.onFailure { error ->
@@ -1126,15 +1157,21 @@ class PlayerActivity : ComponentActivity() {
playable.overview?.takeIf(String::isNotBlank)?.let { pauseOverview = it }
playable.episodeCode?.takeIf(String::isNotBlank)?.let { prerollEpisodeCode = it }
playable.logoUrl?.takeIf(String::isNotBlank)?.let { logoUrl = it }
playable.seriesName?.takeIf(String::isNotBlank)?.let { playbackSeriesName = it }
Log.i(
PLAYBACK_LOG_TAG,
"event=subtitle_configs item=${playable.itemId} " +
"count=${playable.subtitles.size} source=launch",
)
if (playable.title.isBlank() || playable.title == playbackTitle) return
playbackTitle = playable.title
if (playable.title.isNotBlank()) playbackTitle = playable.title
bindTitleArtwork(title = playbackTitle, logoUrl = logoUrl)
setUpPlaybackIdentity(title = playbackTitle)
if (prerollActive) bindPrerollIdentity()
setUpPlaybackIdentity(
title = playbackTitle,
seriesName = playbackSeriesName,
episodeCode = prerollEpisodeCode,
logoUrl = logoUrl,
)
pauseOverlay?.findViewById<TextView>(R.id.player_pause_title)?.text = playbackTitle
}
@@ -1151,6 +1188,7 @@ class PlayerActivity : ComponentActivity() {
hideController()
}
if (!attachLocalPreroll()) enterPrerollVideoFrame()
bindPrerollIdentity()
bindPrerollNow()
bindPrerollSchedule(GatewayPrerollSchedule(), loading = true)
prerollScheduleJob = lifecycleScope.launch {
@@ -1519,6 +1557,52 @@ class PlayerActivity : ComponentActivity() {
pauseOverview.ifBlank { "Memby is preparing this episode for playback." }
}
private fun bindPrerollIdentity() {
val logo = findViewById<ImageView>(R.id.player_preroll_identity_logo)
val fallback = findViewById<TextView>(R.id.player_preroll_identity_title)
val episode = findViewById<TextView>(R.id.player_preroll_identity_episode)
val episodeLabel = playbackIdentityEpisodeLabel(
playbackTitle,
playbackSeriesName,
prerollEpisodeCode,
)
episode.text = episodeLabel.orEmpty()
episode.visibility = if (episodeLabel == null) View.GONE else View.VISIBLE
// Films retain the Memby station mark. An episode is introduced by the programme
// itself, with its series name as the honest fallback when Emby has no logo.
if (episodeLabel == null) {
logo.clearColorFilter()
logo.setImageResource(R.drawable.emby_logo)
logo.visibility = View.VISIBLE
fallback.visibility = View.GONE
return
}
fallback.text = playbackSeriesName?.takeIf(String::isNotBlank)
?: playbackTitle.substringBefore(" ").ifBlank { "Now playing" }
if (logoUrl.isNullOrBlank()) {
logo.clearColorFilter()
logo.setImageDrawable(null)
logo.visibility = View.GONE
fallback.visibility = View.VISIBLE
return
}
logo.load(logoUrl) {
crossfade(false)
listener(
onSuccess = { _, result ->
makeLogoVisibleOnDarkBackground(logo, result.drawable)
logo.visibility = View.VISIBLE
fallback.visibility = View.GONE
},
onError = { _, _ ->
logo.visibility = View.GONE
fallback.visibility = View.VISIBLE
},
)
}
}
private fun prerollEntry(entry: GatewayPrerollEntry, label: String): View =
LayoutInflater.from(this).inflate(R.layout.player_preroll_schedule_card, null).apply {
findViewById<TextView>(R.id.player_preroll_card_label).text = label
@@ -2015,11 +2099,52 @@ class PlayerActivity : ComponentActivity() {
}
}
private fun setUpPlaybackIdentity(title: String) {
private fun bindLoadingBackdrop(url: String?) {
val backdrop = loadingBackdropView ?: return
if (url.isNullOrBlank()) {
backdrop.setImageDrawable(null)
return
}
backdrop.load(url) {
crossfade(false)
}
}
private fun setUpPlaybackIdentity(
title: String,
seriesName: String?,
episodeCode: String?,
logoUrl: String?,
) {
playbackIdentityView = findViewById(R.id.player_playback_identity)
findViewById<TextView>(R.id.player_playback_identity_title).apply {
text = title.ifBlank { "Now playing" }
visibility = View.VISIBLE
val logo = findViewById<ImageView>(R.id.player_playback_identity_logo)
val fallback = findViewById<TextView>(R.id.player_playback_identity_title).apply {
text = seriesName?.takeIf(String::isNotBlank) ?: title.ifBlank { "Now playing" }
}
findViewById<TextView>(R.id.player_playback_identity_episode).apply {
text = playbackIdentityEpisodeLabel(title, seriesName, episodeCode).orEmpty()
visibility = if (text.isNullOrBlank()) View.GONE else View.VISIBLE
}
if (logoUrl.isNullOrBlank()) {
logo.clearColorFilter()
logo.setImageDrawable(null)
logo.visibility = View.GONE
fallback.visibility = View.VISIBLE
return
}
logo.load(logoUrl) {
crossfade(false)
listener(
onSuccess = { _, result ->
makeLogoVisibleOnDarkBackground(logo, result.drawable)
logo.visibility = View.VISIBLE
fallback.visibility = View.GONE
},
onError = { _, _ ->
logo.visibility = View.GONE
fallback.visibility = View.VISIBLE
},
)
}
}
@@ -2930,6 +3055,8 @@ class PlayerActivity : ComponentActivity() {
previewResumeDurationMs = playback.duration.coerceAtLeast(previewResumePositionMs)
previewResumeTitle = playbackTitle
previewResumeLogoUrl = logoUrl
previewResumeBackdropUrl = loadingBackdropUrl
previewResumeSeriesName = playbackSeriesName
previewResumePosterUrl = pausePosterUrl
previewResumeOverview = pauseOverview
previewResumePlaybackStarted = playbackStarted
@@ -2943,11 +3070,14 @@ class PlayerActivity : ComponentActivity() {
hideNextUp()
if (creditsActive) leaveEndCredits(restoreSpeed = true)
playbackTitle = "Next: ${nextTitle(next)}"
playbackSeriesName = next.seriesName
logoUrl = next.logoUrl
loadingBackdropUrl = next.imageUrl
bindLoadingBackdrop(loadingBackdropUrl)
pausePosterUrl = next.imageUrl
pauseOverview = next.overview
bindTitleArtwork(playbackTitle, logoUrl)
setUpPlaybackIdentity(playbackTitle)
setUpPlaybackIdentity(playbackTitle, playbackSeriesName, next.episodeCode, logoUrl)
renderedFirstFrame = false
showPlaybackLoading(title = "Finding the next episode…", hint = "Starting recap or preview")
startMedia(preview.url, emptyList(), 0L, playWhenReady = true)
@@ -2979,12 +3109,20 @@ class PlayerActivity : ComponentActivity() {
}
playbackTitle = previewResumeTitle
logoUrl = previewResumeLogoUrl
loadingBackdropUrl = previewResumeBackdropUrl
playbackSeriesName = previewResumeSeriesName
bindLoadingBackdrop(loadingBackdropUrl)
pausePosterUrl = previewResumePosterUrl
pauseOverview = previewResumeOverview
playbackStarted = previewResumePlaybackStarted
stopReported = previewResumeStopReported
bindTitleArtwork(playbackTitle, logoUrl)
setUpPlaybackIdentity(playbackTitle)
setUpPlaybackIdentity(
playbackTitle,
playbackSeriesName,
prerollEpisodeCode,
logoUrl,
)
renderedFirstFrame = false
if (previewResumeUrl.isBlank()) {
// The original stream should always be known, but losing the optional preview
@@ -3555,13 +3693,21 @@ class PlayerActivity : ComponentActivity() {
// The cast reloads with the rest of the new episode's session, once it is playing.
playbackTitle = nextTitle(next)
playbackSeriesName = next.seriesName
logoUrl = next.logoUrl
loadingBackdropUrl = next.imageUrl
bindLoadingBackdrop(loadingBackdropUrl)
pausePosterUrl = next.imageUrl
pauseOverview = next.overview
prerollEpisodeCode = next.episodeCode.orEmpty()
prerollRuntimeMs = next.runtimeMs.coerceAtLeast(0L)
bindTitleArtwork(title = playbackTitle, logoUrl = logoUrl)
setUpPlaybackIdentity(title = playbackTitle)
setUpPlaybackIdentity(
title = playbackTitle,
seriesName = playbackSeriesName,
episodeCode = prerollEpisodeCode,
logoUrl = logoUrl,
)
pauseOverlay?.findViewById<TextView>(R.id.player_pause_title)?.text = playbackTitle
pauseOverlay?.findViewById<TextView>(R.id.player_pause_overview)?.text =
pauseOverview.ifBlank { getString(R.string.player_pause_overview_fallback) }
@@ -4588,6 +4734,14 @@ class PlayerActivity : ComponentActivity() {
outState.putBoolean(STATE_END_CREDITS, endCreditsAvailable)
outState.putString(STATE_TITLE, if (savingPreview) previewResumeTitle else playbackTitle)
outState.putString(STATE_LOGO_URL, if (savingPreview) previewResumeLogoUrl else logoUrl)
outState.putString(
STATE_BACKDROP_URL,
if (savingPreview) previewResumeBackdropUrl else loadingBackdropUrl,
)
outState.putString(
STATE_SERIES_NAME,
if (savingPreview) previewResumeSeriesName else playbackSeriesName,
)
outState.putString(STATE_OVERVIEW, if (savingPreview) previewResumeOverview else pauseOverview)
outState.putString(STATE_POSTER_URL, if (savingPreview) previewResumePosterUrl else pausePosterUrl)
outState.putString(STATE_EPISODE_CODE, prerollEpisodeCode)
@@ -4878,6 +5032,8 @@ class PlayerActivity : ComponentActivity() {
private const val EXTRA_TITLE = "extra_title"
private const val EXTRA_RESUME_POSITION_MS = "extra_resume_position_ms"
private const val EXTRA_LOGO_URL = "extra_logo_url"
private const val EXTRA_BACKDROP_URL = "extra_backdrop_url"
private const val EXTRA_SERIES_NAME = "extra_series_name"
private const val EXTRA_OVERVIEW = "extra_overview"
private const val EXTRA_EPISODE_CODE = "extra_episode_code"
private const val EXTRA_RUNTIME_MS = "extra_runtime_ms"
@@ -4922,6 +5078,8 @@ class PlayerActivity : ComponentActivity() {
private const val STATE_END_CREDITS = "state_end_credits"
private const val STATE_TITLE = "state_title"
private const val STATE_LOGO_URL = "state_logo_url"
private const val STATE_BACKDROP_URL = "state_backdrop_url"
private const val STATE_SERIES_NAME = "state_series_name"
private const val STATE_OVERVIEW = "state_overview"
private const val STATE_POSTER_URL = "state_poster_url"
private const val STATE_EPISODE_CODE = "state_episode_code"
@@ -4951,6 +5109,7 @@ class PlayerActivity : ComponentActivity() {
context: Context,
request: PlaybackRequest,
posterUrl: String? = null,
backdropUrl: String? = null,
requestStartedAtMs: Long = SystemClock.elapsedRealtime(),
): Intent = Intent(context, PlayerActivity::class.java).apply {
putExtra(EXTRA_PLAYBACK_REQUEST, playerJson.encodeToString(request))
@@ -4958,10 +5117,12 @@ class PlayerActivity : ComponentActivity() {
putExtra(EXTRA_TITLE, request.title)
putExtra(EXTRA_RESUME_POSITION_MS, request.resumePositionMs)
request.logoUrl?.let { putExtra(EXTRA_LOGO_URL, it) }
request.seriesName?.takeIf(String::isNotBlank)?.let { putExtra(EXTRA_SERIES_NAME, it) }
request.overview?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_OVERVIEW, it) }
request.episodeCode?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_EPISODE_CODE, it) }
putExtra(EXTRA_RUNTIME_MS, request.runtimeMs.coerceAtLeast(0L))
posterUrl?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_POSTER_URL, it) }
backdropUrl?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_BACKDROP_URL, it) }
putExtra(EXTRA_REQUEST_STARTED_AT_MS, requestStartedAtMs)
}
@@ -4994,6 +5155,8 @@ class PlayerActivity : ComponentActivity() {
title: String?,
resumePositionMs: Long = 0L,
logoUrl: String? = null,
backdropUrl: String? = null,
seriesName: String? = null,
overview: String? = null,
episodeCode: String? = null,
runtimeMs: Long = 0L,
@@ -5018,6 +5181,8 @@ class PlayerActivity : ComponentActivity() {
putExtra(EXTRA_TITLE, title)
putExtra(EXTRA_RESUME_POSITION_MS, resumePositionMs)
logoUrl?.let { putExtra(EXTRA_LOGO_URL, it) }
backdropUrl?.takeIf(String::isNotBlank)?.let { putExtra(EXTRA_BACKDROP_URL, it) }
seriesName?.takeIf(String::isNotBlank)?.let { putExtra(EXTRA_SERIES_NAME, it) }
overview?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_OVERVIEW, it) }
episodeCode?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_EPISODE_CODE, it) }
putExtra(EXTRA_RUNTIME_MS, runtimeMs.coerceAtLeast(0L))
@@ -377,7 +377,7 @@ private fun MyRequestsPane(
RequestsNotice(
icon = Icons.Default.Inbox,
heading = "You have not asked for anything yet",
body = "Find a film or series and it will show up here while the household gets hold of it.",
body = "Find a film or series and it will show up here while it is added to your library.",
action = "Request something",
onAction = onBrowse,
actionFocusRequester = paneFocusRequester,
@@ -686,7 +686,7 @@ private fun CandidatesPane(
RequestsSearchPhase.NO_MATCHES ->
"Nothing matched “${state.searchedTerm ?: state.query.trim()}”. " +
"Check the spelling, or try the original title."
else -> "Type a name and press Search to find something the household does not have yet."
else -> "Type a name and press Search to find something that is not in your library yet."
},
)
return@Column
@@ -743,7 +743,7 @@ private fun CandidatesPane(
private fun candidateDetail(candidate: GatewayRequestCandidate): String = when (candidate.status) {
RequestStatus.AVAILABLE -> "Already in your library"
RequestStatus.REQUESTED -> "You have already asked for this"
RequestStatus.PROCESSING -> "The household is already getting this"
RequestStatus.PROCESSING -> "Already being added to your library"
RequestStatus.PENDING -> "Already tracked, not out yet"
RequestStatus.REQUESTABLE -> "Press to request"
else -> candidate.statusLabel.ifBlank { "Press to request" }
+1 -36
View File
@@ -67,42 +67,7 @@
android:layout_gravity="top"
android:focusable="false" />
<LinearLayout
android:id="@+id/playback_loading"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#F2050708"
android:focusable="false"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:id="@+id/playback_loading_logo"
android:layout_width="82dp"
android:layout_height="70dp"
android:contentDescription="@string/playback_loading"
android:scaleType="fitCenter"
android:src="@drawable/emby_logo" />
<TextView
android:id="@+id/playback_loading_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="@string/playback_loading"
android:textColor="#FFFFFFFF"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/playback_loading_hint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="7dp"
android:text="@string/playback_loading_hint"
android:textColor="#FF929AA0"
android:textSize="14sp" />
</LinearLayout>
<include layout="@layout/player_loading" />
<!-- The existing PlayerView is temporarily hosted inside this overlay, then returned
here at full-screen size after the countdown. No second player or stream is used. -->
@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The title's backdrop keeps the loading state connected to what the viewer selected.
The dark wash protects the small status copy without hiding the artwork again. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/playback_loading"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#FF050708"
android:focusable="false">
<ImageView
android:id="@+id/playback_loading_backdrop"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:alpha="0.58"
android:contentDescription="@null"
android:scaleType="centerCrop" />
<View
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#8A050708" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:id="@+id/playback_loading_logo"
android:layout_width="82dp"
android:layout_height="70dp"
android:contentDescription="@string/playback_loading"
android:scaleType="fitCenter"
android:src="@drawable/emby_logo" />
<TextView
android:id="@+id/playback_loading_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:shadowColor="#E0000000"
android:shadowDx="0"
android:shadowDy="2"
android:shadowRadius="5"
android:text="@string/playback_loading"
android:textColor="#FFFFFFFF"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/playback_loading_hint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="7dp"
android:shadowColor="#E0000000"
android:shadowDx="0"
android:shadowDy="2"
android:shadowRadius="5"
android:text="@string/playback_loading_hint"
android:textColor="#FFD5DADD"
android:textSize="14sp" />
</LinearLayout>
</FrameLayout>
@@ -1,39 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A short, non-focusable station ident shown over the first five seconds of content. -->
<!-- A short, non-focusable station ident shown over the first five seconds of content.
For television, the programme logo leads and the episode sits directly beneath it. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/player_playback_identity"
android:layout_width="wrap_content"
android:layout_height="54dp"
android:layout_width="560dp"
android:layout_height="wrap_content"
android:layout_gravity="start|top"
android:layout_marginStart="48dp"
android:layout_marginTop="34dp"
android:alpha="0"
android:focusable="false"
android:gravity="center_vertical"
android:orientation="horizontal"
android:gravity="start"
android:orientation="vertical"
android:visibility="gone">
<ImageView
android:layout_width="42dp"
android:layout_height="38dp"
android:contentDescription="@string/player_preroll_brand_logo"
android:scaleType="fitCenter"
android:src="@drawable/emby_logo" />
android:id="@+id/player_playback_identity_logo"
android:layout_width="300dp"
android:layout_height="82dp"
android:adjustViewBounds="true"
android:contentDescription="@string/player_title_logo"
android:scaleType="fitStart"
android:visibility="gone" />
<TextView
android:id="@+id/player_playback_identity_title"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_marginStart="14dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:gravity="start|center_vertical"
android:maxLines="1"
android:maxWidth="520dp"
android:maxWidth="540dp"
android:shadowColor="#E0000000"
android:shadowDx="0"
android:shadowDy="2"
android:shadowRadius="5"
android:textColor="#FFFFFFFF"
android:textSize="25sp"
android:textSize="26sp"
android:textStyle="bold" />
<TextView
android:id="@+id/player_playback_identity_episode"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="7dp"
android:ellipsize="end"
android:maxLines="1"
android:maxWidth="540dp"
android:shadowColor="#E0000000"
android:shadowDx="0"
android:shadowDy="2"
android:shadowRadius="5"
android:textColor="#E6FFFFFF"
android:textSize="19sp"
android:visibility="gone" />
</LinearLayout>
+39 -8
View File
@@ -9,16 +9,47 @@
android:focusable="false"
android:visibility="gone">
<ImageView
<LinearLayout
android:id="@+id/player_preroll_brand"
android:layout_width="58dp"
android:layout_height="50dp"
android:layout_width="300dp"
android:layout_height="wrap_content"
android:layout_gravity="start|top"
android:layout_marginStart="44dp"
android:layout_marginTop="28dp"
android:contentDescription="@string/player_preroll_brand_logo"
android:scaleType="fitCenter"
android:src="@drawable/emby_logo" />
android:layout_marginTop="20dp"
android:gravity="start"
android:orientation="vertical">
<ImageView
android:id="@+id/player_preroll_identity_logo"
android:layout_width="220dp"
android:layout_height="44dp"
android:contentDescription="@string/player_title_logo"
android:scaleType="fitStart"
android:src="@drawable/emby_logo" />
<TextView
android:id="@+id/player_preroll_identity_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="#FFFFFFFF"
android:textSize="22sp"
android:textStyle="bold"
android:visibility="gone" />
<TextView
android:id="@+id/player_preroll_identity_episode"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="#FFD0D6DB"
android:textSize="13sp"
android:textStyle="bold"
android:visibility="gone" />
</LinearLayout>
<LinearLayout
android:id="@+id/player_preroll_content"
@@ -28,7 +59,7 @@
android:gravity="center_horizontal"
android:orientation="vertical"
android:paddingStart="44dp"
android:paddingTop="84dp"
android:paddingTop="92dp"
android:paddingEnd="44dp"
android:paddingBottom="22dp">
@@ -4,6 +4,7 @@ import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayCalendar
import com.ponzischeme89.memby.data.model.GatewayCalendarDay
import com.ponzischeme89.memby.ui.calendar.CALENDAR_COLUMNS
import com.ponzischeme89.memby.ui.calendar.CALENDAR_WEEKDAYS
import com.ponzischeme89.memby.ui.calendar.CalendarUiState
import com.ponzischeme89.memby.ui.calendar.calendarDate
import com.ponzischeme89.memby.ui.calendar.calendarAgendaWeekDate
@@ -40,11 +41,13 @@ class CalendarGridTest {
@Test
fun `the grid pads to whole weeks`() {
val weeks = calendarWeeks(august())
// Six leading blanks plus thirty-one days is thirty-seven cells: six rows of seven.
// A Saturday start has five leading blanks in a Monday-first NZ week.
assertEquals(6, weeks.size)
assertTrue(weeks.all { it.size == CALENDAR_COLUMNS })
assertTrue(weeks.first().take(6).all { it.isPad })
assertEquals(1, weeks.first().last().day)
assertEquals(listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"), CALENDAR_WEEKDAYS)
assertTrue(weeks.first().take(5).all { it.isPad })
assertEquals(1, weeks.first()[5].day)
assertEquals(2, weeks.first().last().day)
assertEquals(31, weeks.flatten().count { !it.isPad })
}
@@ -63,7 +66,7 @@ class CalendarGridTest {
)
assertEquals(6, weeks.size)
assertEquals("915 August", weeks[2].label)
assertEquals("1016 August", weeks[2].label)
assertEquals(2, weeks[2].episodeCount)
assertEquals(2, calendarAgendaWeekIndex(weeks, "2026-08-12"))
assertEquals("2026-08-11", calendarAgendaWeekDate(weeks[2]))
@@ -0,0 +1,29 @@
package com.ponzischeme89.memby.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
class NavigationRailTest {
@Test
fun `user switcher is pinned directly above home`() {
val items = navigationRailItems(calendarEnabled = false)
assertEquals(BrowseDestination.PROFILES, items[0])
assertEquals(BrowseDestination.HOME, items[1])
assertEquals(1, items.count { it == BrowseDestination.PROFILES })
assertFalse(items.contains(BrowseDestination.SETTINGS))
}
@Test
fun `calendar capability does not change the first two rail items`() {
val items = navigationRailItems(calendarEnabled = true)
assertEquals(
listOf(BrowseDestination.PROFILES, BrowseDestination.HOME),
items.take(2),
)
assertEquals(BrowseDestination.entries.size - 1, items.size)
assertFalse(navigationRailItems(calendarEnabled = false).contains(BrowseDestination.CALENDAR))
}
}
@@ -47,20 +47,22 @@ class UserSwitcherNavigationTest {
}
@Test
fun `both pinned actions are reachable below the profiles`() {
fun `all pinned actions are reachable below the profiles`() {
val profileCount = 3
// …the last profile, then Notifications, then Manage users, and no further.
assertEquals(3, userSwitcherNextIndex(2, profileCount, UserSwitcherDirection.DOWN, 2))
assertEquals(4, userSwitcherNextIndex(3, profileCount, UserSwitcherDirection.DOWN, 2))
assertEquals(4, userSwitcherNextIndex(4, profileCount, UserSwitcherDirection.DOWN, 2))
assertEquals(3, userSwitcherNextIndex(4, profileCount, UserSwitcherDirection.UP, 2))
// …the last profile, Notifications, Settings, Manage users, and no further.
assertEquals(3, userSwitcherNextIndex(2, profileCount, UserSwitcherDirection.DOWN, 3))
assertEquals(4, userSwitcherNextIndex(3, profileCount, UserSwitcherDirection.DOWN, 3))
assertEquals(5, userSwitcherNextIndex(4, profileCount, UserSwitcherDirection.DOWN, 3))
assertEquals(5, userSwitcherNextIndex(5, profileCount, UserSwitcherDirection.DOWN, 3))
assertEquals(4, userSwitcherNextIndex(5, profileCount, UserSwitcherDirection.UP, 3))
}
@Test
fun `pinned actions stay reachable with a single profile`() {
assertEquals(1, userSwitcherNextIndex(0, 1, UserSwitcherDirection.DOWN, 2))
assertEquals(2, userSwitcherNextIndex(1, 1, UserSwitcherDirection.DOWN, 2))
assertEquals(2, userSwitcherNextIndex(9, 1, UserSwitcherDirection.DOWN, 2))
assertEquals(1, userSwitcherNextIndex(0, 1, UserSwitcherDirection.DOWN, 3))
assertEquals(2, userSwitcherNextIndex(1, 1, UserSwitcherDirection.DOWN, 3))
assertEquals(3, userSwitcherNextIndex(2, 1, UserSwitcherDirection.DOWN, 3))
assertEquals(3, userSwitcherNextIndex(9, 1, UserSwitcherDirection.DOWN, 3))
}
}
@@ -57,7 +57,7 @@ class PlaybackIdentityScreenshotTest {
}
@Test
fun `plain title and emby mark appear over playback for five seconds`() {
fun `show logo and episode information appear over playback for five seconds`() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val root = FrameLayout(activity)
val backdrop = ImageView(activity).apply {
@@ -82,7 +82,15 @@ class PlaybackIdentityScreenshotTest {
visibility = View.VISIBLE
alpha = 1f
}
identity.findViewById<TextView>(R.id.player_playback_identity_title).text = "Dark Matter"
identity.findViewById<ImageView>(R.id.player_playback_identity_logo).apply {
setImageBitmap(colourLogo())
visibility = View.VISIBLE
}
identity.findViewById<TextView>(R.id.player_playback_identity_title).visibility = View.GONE
identity.findViewById<TextView>(R.id.player_playback_identity_episode).apply {
text = "S02E04 · The Other You"
visibility = View.VISIBLE
}
root.addView(identity)
activity.setContentView(root)
@@ -91,4 +99,38 @@ class PlaybackIdentityScreenshotTest {
"build/screenshots/playback-identity/player-playback-identity.png",
)
}
@Test
fun `loading keeps the selected backdrop visible`() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val loading = LayoutInflater.from(activity).inflate(R.layout.player_loading, null).apply {
findViewById<ImageView>(R.id.playback_loading_backdrop).setImageBitmap(
javaClass.classLoader
?.getResourceAsStream("home_hero_preview_art.png")
?.use(BitmapFactory::decodeStream),
)
}
activity.setContentView(loading)
loading.captureRoboImage(
"build/screenshots/playback-loading/player-loading-with-backdrop.png",
)
}
@Test
fun `episode ident separates the series from the episode`() {
assertEquals(
"S02E04 · The Other You",
playbackIdentityEpisodeLabel(
title = "Dark Matter The Other You",
seriesName = "Dark Matter",
episodeCode = "S02E04",
),
)
assertEquals(null, playbackIdentityEpisodeLabel("Arrival", null, null))
}
private fun colourLogo(): Bitmap = Bitmap.createBitmap(420, 120, Bitmap.Config.ARGB_8888).apply {
eraseColor(Color.rgb(82, 181, 75))
}
}
@@ -0,0 +1,20 @@
package com.ponzischeme89.memby.ui.player
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PlaybackStopWorkerTest {
@Test
fun `a newly queued final position remains deliverable`() {
assertTrue(playbackStopIsFresh(enqueuedAtMs = 10_000L, nowMs = 10_500L))
assertTrue(playbackStopIsFresh(enqueuedAtMs = 10_000L, nowMs = 70_000L))
}
@Test
fun `a stale final position cannot overwrite another client`() {
assertFalse(playbackStopIsFresh(enqueuedAtMs = 10_000L, nowMs = 70_001L))
assertFalse(playbackStopIsFresh(enqueuedAtMs = 0L, nowMs = 10_000L))
assertFalse(playbackStopIsFresh(enqueuedAtMs = 20_000L, nowMs = 10_000L))
}
}
@@ -5,6 +5,8 @@ import android.view.LayoutInflater
import android.widget.FrameLayout
import android.widget.GridLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.test.core.app.ApplicationProvider
import com.ponzischeme89.memby.R
import org.junit.Assert.assertEquals
@@ -35,7 +37,9 @@ class PrerollLayoutTest {
videoHost.layoutParams.width,
)
assertNotNull(preroll.findViewById<PrerollCountdownView>(R.id.player_preroll_countdown))
assertNotNull(preroll.findViewById<ImageView>(R.id.player_preroll_brand))
assertNotNull(preroll.findViewById<LinearLayout>(R.id.player_preroll_brand))
assertNotNull(preroll.findViewById<ImageView>(R.id.player_preroll_identity_logo))
assertNotNull(preroll.findViewById<TextView>(R.id.player_preroll_identity_episode))
assertEquals(
"Starting in 7 seconds…",
context.getString(R.string.player_preroll_countdown_initial),
@@ -2,6 +2,10 @@ package com.ponzischeme89.memby.ui.player
import android.app.Activity
import android.graphics.BitmapFactory
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -63,6 +67,14 @@ class PrerollScreenshotTest {
)
preroll.findViewById<TextView>(R.id.player_preroll_now_title).text =
"Northbound The Crossing"
preroll.findViewById<ImageView>(R.id.player_preroll_identity_logo).apply {
setImageBitmap(programmeLogo())
visibility = View.VISIBLE
}
preroll.findViewById<TextView>(R.id.player_preroll_identity_episode).apply {
text = "S02E04 · The Crossing"
visibility = View.VISIBLE
}
preroll.findViewById<TextView>(R.id.player_preroll_now_metadata).text =
"EPISODE · S02E04 · 48 mins"
preroll.findViewById<TextView>(R.id.player_preroll_now_overview).text =
@@ -105,4 +117,17 @@ class PrerollScreenshotTest {
private fun dp(activity: Activity, value: Int): Int =
(value * activity.resources.displayMetrics.density).toInt()
private fun programmeLogo(): Bitmap = Bitmap.createBitmap(520, 110, Bitmap.Config.ARGB_8888).apply {
Canvas(this).drawText(
"NORTHBOUND",
0f,
82f,
Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = 76f
typeface = android.graphics.Typeface.DEFAULT_BOLD
},
)
}
}
@@ -147,8 +147,8 @@ class RequestPresentationTest {
@Test
fun `the switcher's action count matches the rows actually drawn`() {
assertEquals(2, userSwitcherActionCount(showRequests = false))
assertEquals(3, userSwitcherActionCount(showRequests = true))
assertEquals(3, userSwitcherActionCount(showRequests = false))
assertEquals(4, userSwitcherActionCount(showRequests = true))
}
@Test