0.2.71
This commit is contained in:
@@ -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" }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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("9–15 August", weeks[2].label)
|
||||
assertEquals("10–16 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))
|
||||
}
|
||||
}
|
||||
|
||||
+44
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user