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
+8
View File
@@ -1,3 +1,11 @@
## 0.2.71 — 2026-08-17
- Fixed: Leaving playback now saves the final position immediately and prevents an older progress update from moving the saved playhead backwards.
- Fixed: Playback loading, pre-roll and first-frame screens now keep the selected title's artwork, series logo and episode details correct through previews, auto-advance and screen recreation.
- Fixed: D-pad navigation now moves predictably through the navigation rail and profile menu, with Profiles kept at the top and Settings available from the profile menu.
- Fixed: The TV calendar now displays New Zealand's Monday-first week order.
- Technical fix: Playback reports are serialised, successful stop reports cancel their exact fallback job, and stale stop retries expire before they can overwrite progress made on another device.
- Technical fix: Added configurable daily gateway quiet time, pausing television requests and background work while leaving the admin console and health controls available.
## 0.2.70 - 2026-08-16 ## 0.2.70 - 2026-08-16
- Improvements: Playback "cold start" improvements. - Improvements: Playback "cold start" improvements.
+25
View File
@@ -1339,6 +1339,31 @@ pays DNS, TCP, TLS and Emby's file open. Pre-warming that connection is the open
opportunity. Two things that look like causes and are not: the subtitle auto-selection opportunity. Two things that look like causes and are not: the subtitle auto-selection
costs 20200 ms, not seconds, and the seek itself is about 900 ms. costs 20200 ms, not seconds, and the seek itself is about 900 ms.
**Playback position has one ordered exit path.** Ten-second progress updates, pause/seek
updates and the final Stop all pass through `EmbyRepository`'s `playbackReportMutex`, so a
slow older Progress request cannot complete after Stop and move Emby's saved playhead back.
`PlaybackStopWorker.enqueue` still writes the final position to WorkManager first, but also
sends it immediately from the repository's process scope — leaving the activity no longer
means waiting for WorkManager before Emby Web can resume at the right frame. A successful
immediate delivery cancels that exact fallback request, not the unique work name, because a
newer stop for the same playback session may already have replaced it. The worker drops a
report after 60 seconds: a late retry overwriting progress made in another Emby client is
worse than losing an old fallback. `PlaybackStopWorkerTest` pins that freshness boundary.
**Playback keeps the selected title visible while it starts.** The launcher hands
`PlayerActivity` the backdrop it already has, and `player_loading.xml` holds that artwork
under a dark wash rather than replacing it with an almost-black field. This is intent
metadata, not another playback-path request; it is saved across recreation and replaced by
the next episode's landscape artwork during auto-advance. On the station-style pre-roll, a
television episode replaces the Memby mark at top-left with the programme's own logo and
puts its episode code and title directly beneath it. The same hierarchy is repeated by the
five-second ident once the first frame lands. `seriesName` and `episodeCode` travel with the
resolved `Playable`; a missing or failed logo falls back to the series name instead of
leaving an empty corner. `PrerollScreenshotTest` and `PlaybackIdentityScreenshotTest`
record the pre-roll, the first-frame ident and the backdrop loading state under
`build/screenshots/sonarr-preroll/`, `build/screenshots/playback-identity/` and
`build/screenshots/playback-loading/`.
**The local Memby preroll is prepared while Home is idle.** `PrerollPreloader` owns one **The local Memby preroll is prepared while Home is idle.** `PrerollPreloader` owns one
process-scoped ExoPlayer for `res/raw/emby_preroll.mp4`; `MembyApp` queues its first prepare process-scoped ExoPlayer for `res/raw/emby_preroll.mp4`; `MembyApp` queues its first prepare
on the main queue's idle handler, so decoder construction and the local resource read never on the main queue's idle handler, so decoder construction and the local resource read never
+1 -1
View File
@@ -2,7 +2,7 @@
An independent Android TV client for Emby, by **ponzischeme89**. Memby combines a An independent Android TV client for Emby, by **ponzischeme89**. Memby combines a
personalised television launcher, full media player, system screensaver and an optional personalised television launcher, full media player, system screensaver and an optional
self-hosted gateway that shapes the experience for every viewer in the household. self-hosted gateway that shapes the experience for every viewer using your library.
Source: [g.sublogue.com/admin/memby](https://g.sublogue.com/admin/memby) Source: [g.sublogue.com/admin/memby](https://g.sublogue.com/admin/memby)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -13,7 +13,7 @@
rel="icon" rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='16' fill='%2352b54b'/%3E%3Ctext x='16' y='23' font-family='system-ui,sans-serif' font-size='19' font-weight='800' text-anchor='middle' fill='%2306240a'%3EM%3C/text%3E%3C/svg%3E" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='16' fill='%2352b54b'/%3E%3Ctext x='16' y='23' font-family='system-ui,sans-serif' font-size='19' font-weight='800' text-anchor='middle' fill='%2306240a'%3EM%3C/text%3E%3C/svg%3E"
/> />
<script type="module" crossorigin src="/admin/assets/index-CSt3yVnU.js"></script> <script type="module" crossorigin src="/admin/assets/index-CEU6X4jy.js"></script>
<link rel="modulepreload" crossorigin href="/admin/assets/router-D9WH5XEU.js"> <link rel="modulepreload" crossorigin href="/admin/assets/router-D9WH5XEU.js">
<link rel="stylesheet" crossorigin href="/admin/assets/index-CASotpHk.css"> <link rel="stylesheet" crossorigin href="/admin/assets/index-CASotpHk.css">
</head> </head>
+11
View File
@@ -65,6 +65,16 @@ export interface Maintenance {
updatedAt?: string; updatedAt?: string;
} }
export interface QuietTime {
enabled: boolean;
active: boolean;
startTime: string;
endTime: string;
message: string;
timeZone: string;
updatedAt?: string;
}
export interface UpdatePolicy { export interface UpdatePolicy {
enabled: boolean; enabled: boolean;
latestVersion: string; latestVersion: string;
@@ -218,6 +228,7 @@ export interface AdminStatus {
serverVersion: string; serverVersion: string;
currentUser?: string; currentUser?: string;
maintenance: Maintenance; maintenance: Maintenance;
quietTime: QuietTime;
updatePolicy: UpdatePolicy; updatePolicy: UpdatePolicy;
library: LibraryStats; library: LibraryStats;
syncRunning: boolean; syncRunning: boolean;
+5 -4
View File
@@ -139,9 +139,10 @@ export function Layout() {
const [confirmingOffline, setConfirmingOffline] = useState(false); const [confirmingOffline, setConfirmingOffline] = useState(false);
const location = useLocation(); const location = useLocation();
const offline = Boolean(status?.maintenance?.enabled); const offline = Boolean(status?.maintenance?.enabled);
const quiet = Boolean(status?.quietTime?.active);
const account = useRef<HTMLDivElement>(null); const account = useRef<HTMLDivElement>(null);
const initial = Array.from(currentUser.trim())[0]?.toLocaleUpperCase('en-NZ') || 'A'; const initial = Array.from(currentUser.trim())[0]?.toLocaleUpperCase('en-NZ') || 'A';
const statusTone = status && online && !offline ? 'ok' : status || !loading ? 'bad' : 'checking'; const statusTone = status && online && !offline && !quiet ? 'ok' : status || !loading ? 'bad' : 'checking';
const toggleAvailability = async () => { const toggleAvailability = async () => {
if (changingAvailability || !status) return; if (changingAvailability || !status) return;
@@ -227,9 +228,9 @@ export function Layout() {
className="topbar-status" className="topbar-status"
data-tone={statusTone} data-tone={statusTone}
aria-pressed={offline} aria-pressed={offline}
disabled={!status || !online || changingAvailability} disabled={!status || !online || changingAvailability || quiet}
title={!status ? 'Checking Memby status' : offline ? 'Bring Memby back online' : online ? 'Take Memby offline' : 'Memby is not responding'} title={!status ? 'Checking Memby status' : quiet ? 'Memby quiet time is active' : offline ? 'Bring Memby back online' : online ? 'Take Memby offline' : 'Memby is not responding'}
aria-label={offline ? 'Memby is offline. Bring it online' : status && online ? 'Memby is online. Take it offline' : loading ? 'Checking Memby status' : 'Memby is not responding'} aria-label={quiet ? 'Memby quiet time is active' : offline ? 'Memby is offline. Bring it online' : status && online ? 'Memby is online. Take it offline' : loading ? 'Checking Memby status' : 'Memby is not responding'}
onClick={() => offline ? void toggleAvailability() : setConfirmingOffline(true)} onClick={() => offline ? void toggleAvailability() : setConfirmingOffline(true)}
> >
<span className="dot" aria-hidden="true" /> <span className="dot" aria-hidden="true" />
+1 -1
View File
@@ -265,7 +265,7 @@ export const nav: NavGroup[] = [
path: '/admin/maintenance', path: '/admin/maintenance',
label: 'Maintenance', label: 'Maintenance',
title: 'Maintenance', title: 'Maintenance',
intro: 'Take Memby offline for every television.', intro: 'Take Memby offline now or schedule daily quiet time.',
icon: 'wrench', icon: 'wrench',
}, },
{ {
+62 -2
View File
@@ -3,7 +3,7 @@ import { api } from '../api/client';
import { useAction } from '../lib/hooks'; import { useAction } from '../lib/hooks';
import { useGateway } from '../lib/gateway'; import { useGateway } from '../lib/gateway';
import { useToast } from '../lib/toast'; import { useToast } from '../lib/toast';
import { Banner, Button, Card, Confirm, Field, Loading, PageHead, Tag } from '../components/ui'; import { Banner, Button, Card, Confirm, Field, Loading, PageHead, Tag, Toggle } from '../components/ui';
export function MaintenancePage() { export function MaintenancePage() {
const { status, error, loading, reload } = useGateway(); const { status, error, loading, reload } = useGateway();
@@ -12,6 +12,11 @@ export function MaintenancePage() {
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false);
const [touched, setTouched] = useState(false); const [touched, setTouched] = useState(false);
const [quietEnabled, setQuietEnabled] = useState(false);
const [quietStart, setQuietStart] = useState('23:00');
const [quietEnd, setQuietEnd] = useState('07:00');
const [quietMessage, setQuietMessage] = useState('');
const [quietTouched, setQuietTouched] = useState(false);
const enabled = Boolean(status?.maintenance?.enabled); const enabled = Boolean(status?.maintenance?.enabled);
@@ -23,6 +28,14 @@ export function MaintenancePage() {
if (!touched && status) setMessage(status.maintenance?.message ?? ''); if (!touched && status) setMessage(status.maintenance?.message ?? '');
}, [status, touched]); }, [status, touched]);
useEffect(() => {
if (quietTouched || !status?.quietTime) return;
setQuietEnabled(status.quietTime.enabled);
setQuietStart(status.quietTime.startTime);
setQuietEnd(status.quietTime.endTime);
setQuietMessage(status.quietTime.message);
}, [status, quietTouched]);
const set = (next: boolean) => const set = (next: boolean) =>
run(next ? 'on' : 'off', async () => { run(next ? 'on' : 'off', async () => {
await wrap( await wrap(
@@ -34,9 +47,24 @@ export function MaintenancePage() {
await reload(); await reload();
}); });
const saveQuietTime = () =>
run('quiet', async () => {
await wrap(
() => api.post('/admin/api/quiet-time', {
enabled: quietEnabled,
startTime: quietStart,
endTime: quietEnd,
message: quietMessage,
}),
quietEnabled ? 'Quiet time saved.' : 'Quiet time turned off.',
);
setQuietTouched(false);
await reload();
});
return ( return (
<> <>
<PageHead title="Maintenance" intro="Take Memby offline for every television." /> <PageHead title="Maintenance" intro="Take Memby offline now or schedule daily quiet time." />
<Banner message={error} /> <Banner message={error} />
{loading ? ( {loading ? (
@@ -76,6 +104,38 @@ export function MaintenancePage() {
</Card> </Card>
)} )}
{!loading ? (
<Card
title="Quiet time"
intro={`Pause new television requests and server background work every day in ${status?.quietTime?.timeZone ?? 'the household timezone'}. Work already under way finishes safely. The admin console and health checks stay available so the schedule can always be changed.`}
icon="clock"
tone={status?.quietTime?.active ? 'warn' : 'info'}
actions={status?.quietTime?.active ? <Tag tone="warn">active now</Tag> : quietEnabled ? <Tag tone="ok">scheduled</Tag> : <Tag>off</Tag>}
footer={
<Button variant="primary" busy={busy === 'quiet'} onClick={() => void saveQuietTime()}>
Save quiet time
</Button>
}
>
<Toggle
label="Pause server activity during quiet time"
checked={quietEnabled}
onChange={(next) => { setQuietEnabled(next); setQuietTouched(true); }}
/>
<div className="fields">
<Field label="Starts" hint="Uses the household's 24-hour clock.">
<input type="time" value={quietStart} onChange={(event) => { setQuietStart(event.target.value); setQuietTouched(true); }} />
</Field>
<Field label="Ends" hint="May be on the following day, for example 23:00 to 07:00.">
<input type="time" value={quietEnd} onChange={(event) => { setQuietEnd(event.target.value); setQuietTouched(true); }} />
</Field>
</div>
<Field label="Message shown on the television" hint="Shown when a television contacts Memby during quiet time.">
<input type="text" value={quietMessage} placeholder="Quiet time — try again after 7 am" onChange={(event) => { setQuietMessage(event.target.value); setQuietTouched(true); }} />
</Field>
</Card>
) : null}
{confirming ? ( {confirming ? (
<Confirm <Confirm
title="Take Memby offline?" title="Take Memby offline?"
+2
View File
@@ -108,6 +108,8 @@ export function OverviewPage() {
label: 'Availability', label: 'Availability',
value: status.maintenance?.enabled ? ( value: status.maintenance?.enabled ? (
<Tag tone="bad">offline for maintenance</Tag> <Tag tone="bad">offline for maintenance</Tag>
) : status.quietTime?.active ? (
<Tag tone="warn">quiet time active</Tag>
) : ( ) : (
<Tag tone="ok">online</Tag> <Tag tone="ok">online</Tag>
), ),
+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 // 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. // source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.69" val defaultVersionName = "0.2.71"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.trim()
@@ -97,6 +97,7 @@ data class Playable(
val itemId: String, val itemId: String,
val title: String, val title: String,
val url: String, val url: String,
val seriesName: String? = null,
val resumePositionMs: Long = 0L, val resumePositionMs: Long = 0L,
val logoUrl: String? = null, val logoUrl: String? = null,
val subtitles: List<PlayableSubtitle> = emptyList(), val subtitles: List<PlayableSubtitle> = emptyList(),
@@ -203,6 +204,7 @@ data class PlaybackRequest(
val itemId: String, val itemId: String,
val itemType: String = "", val itemType: String = "",
val title: String = "", val title: String = "",
val seriesName: String? = null,
val isSeries: Boolean = false, val isSeries: Boolean = false,
val resumePositionMs: Long = 0L, val resumePositionMs: Long = 0L,
val logoUrl: String? = null, val logoUrl: String? = null,
@@ -259,6 +261,8 @@ class EmbyRepository(private val settings: SettingsStore) {
val showTitleLogo: Boolean get() = snapshot.showTitleLogo val showTitleLogo: Boolean get() = snapshot.showTitleLogo
private val _playbackStops = MutableSharedFlow<String>(extraBufferCapacity = 1) private val _playbackStops = MutableSharedFlow<String>(extraBufferCapacity = 1)
val playbackStops = _playbackStops.asSharedFlow() 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 playableMutex = Mutex()
private val playableCache = LinkedHashMap<String, CachedPlayable>(16, 0.75f, true) private val playableCache = LinkedHashMap<String, CachedPlayable>(16, 0.75f, true)
private val playableInFlight = mutableMapOf<String, Deferred<Playable>>() private val playableInFlight = mutableMapOf<String, Deferred<Playable>>()
@@ -1858,6 +1862,11 @@ class EmbyRepository(private val settings: SettingsStore) {
itemId = item.id, itemId = item.id,
itemType = item.type, itemType = item.type,
title = item.name, title = item.name,
seriesName = when {
item.isEpisode -> item.seriesName
item.isSeries -> item.name
else -> null
},
isSeries = item.isSeries, isSeries = item.isSeries,
resumePositionMs = item.resumePositionMs, resumePositionMs = item.resumePositionMs,
logoUrl = logoUrl(item), logoUrl = logoUrl(item),
@@ -2121,6 +2130,7 @@ class EmbyRepository(private val settings: SettingsStore) {
return Playable( return Playable(
itemId = playback.itemId, itemId = playback.itemId,
title = playback.title.ifBlank { item.title }, title = playback.title.ifBlank { item.title },
seriesName = playback.seriesName.ifBlank { item.seriesName },
url = playback.url, url = playback.url,
resumePositionMs = playback.resumePositionMs, resumePositionMs = playback.resumePositionMs,
logoUrl = item.logoUrl, logoUrl = item.logoUrl,
@@ -2159,6 +2169,7 @@ class EmbyRepository(private val settings: SettingsStore) {
return Playable( return Playable(
itemId = episode.id, itemId = episode.id,
title = title, title = title,
seriesName = item.title,
url = discovery.url ?: buildStreamUrl(episode.id), url = discovery.url ?: buildStreamUrl(episode.id),
resumePositionMs = episode.resumePositionMs, resumePositionMs = episode.resumePositionMs,
logoUrl = item.logoUrl, logoUrl = item.logoUrl,
@@ -2184,6 +2195,7 @@ class EmbyRepository(private val settings: SettingsStore) {
return Playable( return Playable(
itemId = item.itemId, itemId = item.itemId,
title = item.title, title = item.title,
seriesName = item.seriesName,
url = discovery.url ?: buildStreamUrl(item.itemId), url = discovery.url ?: buildStreamUrl(item.itemId),
resumePositionMs = item.resumePositionMs, resumePositionMs = item.resumePositionMs,
logoUrl = item.logoUrl, logoUrl = item.logoUrl,
@@ -2248,12 +2260,14 @@ class EmbyRepository(private val settings: SettingsStore) {
} }
suspend fun reportPlaybackStarted(session: PlaybackSession, positionMs: Long) { suspend fun reportPlaybackStarted(session: PlaybackSession, positionMs: Long) {
playbackReportMutex.withLock {
if (ServerConfig.isGateway) { if (ServerConfig.isGateway) {
requireGateway().report("started", session.gatewayReport(positionMs, false, null)) requireGateway().report("started", session.gatewayReport(positionMs, false, null))
return } else {
}
requireApi().reportPlaybackStarted(playbackReport(session, positionMs, false, null)) requireApi().reportPlaybackStarted(playbackReport(session, positionMs, false, null))
} }
}
}
suspend fun reportPlaybackProgress( suspend fun reportPlaybackProgress(
session: PlaybackSession, session: PlaybackSession,
@@ -2261,19 +2275,23 @@ class EmbyRepository(private val settings: SettingsStore) {
isPaused: Boolean, isPaused: Boolean,
eventName: String, eventName: String,
durationMs: Long = 0L, durationMs: Long = 0L,
): String? { ): String? = playbackReportMutex.withLock {
if (ServerConfig.isGateway) { if (ServerConfig.isGateway) {
return requireGateway().report( requireGateway().report(
"progress", "progress",
session.gatewayReport(positionMs, isPaused, eventName, durationMs), session.gatewayReport(positionMs, isPaused, eventName, durationMs),
).autoFollowedShowTitle.takeIf(String::isNotBlank) )
} .autoFollowedShowTitle
.takeIf(String::isNotBlank)
} else {
requireApi().reportPlaybackProgress(playbackReport(session, positionMs, isPaused, eventName)) requireApi().reportPlaybackProgress(playbackReport(session, positionMs, isPaused, eventName))
return null null
}
} }
suspend fun reportPlaybackStopped(session: PlaybackSession, positionMs: Long) { suspend fun reportPlaybackStopped(session: PlaybackSession, positionMs: Long) {
try { try {
playbackReportMutex.withLock {
if (ServerConfig.isGateway) { if (ServerConfig.isGateway) {
// Stopping is also what drops the gateway's cached rows for this user, // 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. // so Continue Watching reflects the new position on the next home load.
@@ -2281,6 +2299,7 @@ class EmbyRepository(private val settings: SettingsStore) {
} else { } else {
requireApi().reportPlaybackStopped(playbackReport(session, positionMs, true, null)) requireApi().reportPlaybackStopped(playbackReport(session, positionMs, true, null))
} }
}
} finally { } finally {
clearPlayableCache() clearPlayableCache()
// Episode progress and watched badges may have changed during playback. // Episode progress and watched badges may have changed during playback.
@@ -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 { scope.launch {
runCatching { reportPlaybackStopped(session, positionMs) } runCatching { reportPlaybackStopped(session, positionMs) }
.onSuccess { onSuccess() }
} }
} }
@@ -894,7 +894,7 @@ data class GatewayCalendar(
val next: String = "", val next: String = "",
/** The household's today, only when it falls inside this month. */ /** The household's today, only when it falls inside this month. */
val today: String = "", 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 firstWeekday: Int = 0,
val dayCount: Int = 0, val dayCount: Int = 0,
val days: List<GatewayCalendarDay> = emptyList(), val days: List<GatewayCalendarDay> = emptyList(),
@@ -181,6 +181,17 @@ enum class BrowseDestination(val label: String, val icon: ImageVector) {
SETTINGS("Settings", Icons.Default.Settings), 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. // No NEXT_UP: those episodes are part of CONTINUE, which is one row.
enum class MediaRowKind { CONTINUE, MOVIES, SHOWS, FAVORITES } enum class MediaRowKind { CONTINUE, MOVIES, SHOWS, FAVORITES }
@@ -238,6 +249,7 @@ private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when {
) )
} }
@OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
fun TvNavigationRail( fun TvNavigationRail(
config: NavigationRemoteConfig = BundledRemoteConfig.value.navigation, config: NavigationRemoteConfig = BundledRemoteConfig.value.navigation,
@@ -358,12 +370,22 @@ fun TvNavigationRail(
// A destination with nothing behind it is worse than one fewer: the calendar // 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 // needs the gateway and a Sonarr, and a household with neither would otherwise
// carry a rail item that only ever opens an apology. // carry a rail item that only ever opens an apology.
val destinations = remember(calendarEnabled) { val destinations = remember(calendarEnabled) { navigationRailItems(calendarEnabled) }
BrowseDestination.entries.filter { // Up and Down are explicit because the profile entry is an action while every
it != BrowseDestination.CALENDAR || calendarEnabled // 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( ExpandableNavigationItem(
destination = destination, destination = destination,
label = if (destination == BrowseDestination.PROFILES) { label = if (destination == BrowseDestination.PROFILES) {
@@ -373,10 +395,19 @@ fun TvNavigationRail(
}, },
selected = destination == selected, selected = destination == selected,
expanded = expanded, expanded = expanded,
modifier = if (destination == focusDestination) { modifier = Modifier
Modifier.focusRequester(navigationFocusRequester) .focusRequester(itemFocusRequester)
.focusProperties {
up = if (index == 0) {
FocusRequester.Cancel
} else { } else {
Modifier itemFocusRequesters.getValue(destinations[index - 1])
}
down = if (index == destinations.lastIndex) {
FocusRequester.Cancel
} else {
itemFocusRequesters.getValue(destinations[index + 1])
}
}, },
onFocused = {}, onFocused = {},
onClick = { onDestinationSelected(destination) }, onClick = { onDestinationSelected(destination) },
@@ -433,6 +464,7 @@ fun UserSwitcherOverlay(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
alertCount: Int = 0, alertCount: Int = 0,
onOpenAlerts: () -> Unit = {}, onOpenAlerts: () -> Unit = {},
onOpenSettings: () -> Unit = {},
/** /**
* Whether this viewer may ask the household for titles. False hides the entry entirely * 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, * rather than dimming it: an operator's allowlist is not something a viewer can act on,
@@ -540,7 +572,10 @@ fun UserSwitcherOverlay(
state = profileListState, state = profileListState,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .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), verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
itemsIndexed( itemsIndexed(
@@ -580,8 +615,7 @@ fun UserSwitcherOverlay(
}, },
onClick = onOpenAlerts, onClick = onOpenAlerts,
) )
// Requests sits between the two because it is news-shaped like Notifications // Requests sits beside Notifications because both are personal activity.
// rather than administrative like Manage users, which stays last.
if (showRequests) { if (showRequests) {
UserSwitcherAction( UserSwitcherAction(
label = "Requests", label = "Requests",
@@ -594,10 +628,21 @@ fun UserSwitcherOverlay(
onClick = onOpenRequests, 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 val manageIndex = profiles.size + actionCount - 1
UserSwitcherAction( UserSwitcherAction(
label = "Manage users", label = "Manage users",
icon = Icons.Default.Settings, icon = Icons.Default.Person,
modifier = Modifier modifier = Modifier
.focusRequester(focusRequesters[manageIndex]) .focusRequester(focusRequesters[manageIndex])
.onFocusChanged { .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, * 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 * 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. * 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 @Composable
private fun UserSwitcherProfileItem( private fun UserSwitcherProfileItem(
@@ -2191,6 +2191,8 @@ private fun HomeScreen(
context = context, context = context,
request = request, request = request,
posterUrl = repo.primaryUrl(item, maxWidth = 500), posterUrl = repo.primaryUrl(item, maxWidth = 500),
backdropUrl = repo.backdropUrl(item, maxWidth = 1920)
?: repo.primaryUrl(item, maxWidth = 1920),
requestStartedAtMs = playbackRequestedAtMs, requestStartedAtMs = playbackRequestedAtMs,
), ),
) )
@@ -2239,6 +2241,9 @@ private fun HomeScreen(
title = playable.title, title = playable.title,
resumePositionMs = playable.resumePositionMs, resumePositionMs = playable.resumePositionMs,
logoUrl = playable.logoUrl, logoUrl = playable.logoUrl,
backdropUrl = repo.backdropUrl(item, maxWidth = 1920)
?: repo.primaryUrl(item, maxWidth = 1920),
seriesName = playable.seriesName,
overview = playable.overview ?: item.overview, overview = playable.overview ?: item.overview,
episodeCode = playable.episodeCode, episodeCode = playable.episodeCode,
runtimeMs = playable.runtimeMs, runtimeMs = playable.runtimeMs,
@@ -3175,6 +3180,18 @@ private fun HomeScreen(
showProfiles = true showProfiles = true
}, },
alertCount = displayedNotifications.size, 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 = { onOpenAlerts = {
homeViewModel.trackJourney( homeViewModel.trackJourney(
category = "notifications", action = "open", screen = journeyScreen, category = "notifications", action = "open", screen = journeyScreen,
@@ -3,9 +3,9 @@ package com.ponzischeme89.memby.ui
internal enum class UserSwitcherDirection { UP, DOWN } internal enum class UserSwitcherDirection { UP, DOWN }
/** /**
* Profiles occupy [0, profileCount); the pinned actions Notifications, then Manage users * Profiles occupy [0, profileCount); the pinned actions Notifications, optional Requests,
* follow them in order. Keeping this arithmetic outside Compose makes remote navigation * Settings, then Manage users follow them in order. Keeping this arithmetic outside
* deterministic. * Compose makes remote navigation deterministic.
*/ */
internal fun userSwitcherInitialIndex( internal fun userSwitcherInitialIndex(
profileIds: List<String>, profileIds: List<String>,
@@ -31,8 +31,8 @@ data class CalendarCell(
const val CALENDAR_COLUMNS = 7 const val CALENDAR_COLUMNS = 7
/** Sunday first, matching the `firstWeekday` the gateway sends. */ /** New Zealand week order. The gateway's `firstWeekday` remains Sunday-based on the wire. */
val CALENDAR_WEEKDAYS = listOf("Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat") val CALENDAR_WEEKDAYS = listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
data class CalendarAgendaWeek( data class CalendarAgendaWeek(
val cells: List<CalendarCell>, val cells: List<CalendarCell>,
@@ -73,7 +73,8 @@ fun calendarAgendaWeekDate(week: CalendarAgendaWeek): String =
fun calendarWeeks(calendar: GatewayCalendar): List<List<CalendarCell>> { fun calendarWeeks(calendar: GatewayCalendar): List<List<CalendarCell>> {
val dayCount = calendar.dayCount.coerceIn(0, 31) val dayCount = calendar.dayCount.coerceIn(0, 31)
if (dayCount == 0) return emptyList() 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 byDay = calendar.days.associateBy { it.day }
val cells = ArrayList<CalendarCell>(leading + dayCount) val cells = ArrayList<CalendarCell>(leading + dayCount)
@@ -24,6 +24,13 @@ class PlaybackStopWorker(
ServiceLocator.init(applicationContext) ServiceLocator.init(applicationContext)
val itemId = inputData.getString(ITEM_ID).orEmpty() val itemId = inputData.getString(ITEM_ID).orEmpty()
if (itemId.isBlank()) return Result.failure() if (itemId.isBlank()) return Result.failure()
if (!playbackStopIsFresh(
enqueuedAtMs = inputData.getLong(ENQUEUED_AT_MS, 0L),
nowMs = System.currentTimeMillis(),
)
) {
return Result.success()
}
val session = PlaybackSession( val session = PlaybackSession(
itemId = itemId, itemId = itemId,
mediaSourceId = inputData.getString(MEDIA_SOURCE_ID).orEmpty().ifBlank { 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_SESSION_ID = "play_session_id"
private const val PLAY_METHOD = "play_method" private const val PLAY_METHOD = "play_method"
private const val POSITION_MS = "position_ms" private const val POSITION_MS = "position_ms"
private const val ENQUEUED_AT_MS = "enqueued_at_ms"
private const val MAX_RETRIES = 5 private const val MAX_RETRIES = 5
private fun workName(session: PlaybackSession): String = private fun workName(session: PlaybackSession): String =
@@ -59,15 +67,23 @@ class PlaybackStopWorker(
.putString(PLAY_SESSION_ID, session.playSessionId) .putString(PLAY_SESSION_ID, session.playSessionId)
.putString(PLAY_METHOD, session.playMethod) .putString(PLAY_METHOD, session.playMethod)
.putLong(POSITION_MS, positionMs.coerceAtLeast(0L)) .putLong(POSITION_MS, positionMs.coerceAtLeast(0L))
.putLong(ENQUEUED_AT_MS, System.currentTimeMillis())
.build() .build()
val request = OneTimeWorkRequestBuilder<PlaybackStopWorker>() val request = OneTimeWorkRequestBuilder<PlaybackStopWorker>()
.setInputData(data) .setInputData(data)
.build() .build()
WorkManager.getInstance(context.applicationContext).enqueueUniqueWork( val workManager = WorkManager.getInstance(context.applicationContext)
workManager.enqueueUniqueWork(
workName(session), workName(session),
ExistingWorkPolicy.REPLACE, ExistingWorkPolicy.REPLACE,
request, 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. */ /** 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" 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 * Fullscreen Media3 player with native stream-track selection. Press Menu while
* playing to choose an audio or subtitle track; the subtitle controller button * 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 remainingView: TextView? = null
private var finishTimeView: TextView? = null private var finishTimeView: TextView? = null
private var loadingView: View? = null private var loadingView: View? = null
private var loadingBackdropView: ImageView? = null
private var loadingTitleView: TextView? = null private var loadingTitleView: TextView? = null
private var loadingHintView: TextView? = null private var loadingHintView: TextView? = null
private val playbackLoadingQuote: String by lazy { private val playbackLoadingQuote: String by lazy {
@@ -197,6 +213,8 @@ class PlayerActivity : ComponentActivity() {
private var launchTraceCookie = NO_TRACE private var launchTraceCookie = NO_TRACE
private var firstFrameTraceCookie = NO_TRACE private var firstFrameTraceCookie = NO_TRACE
private var logoUrl: String? = null private var logoUrl: String? = null
private var loadingBackdropUrl: String? = null
private var playbackSeriesName: String? = null
private var playbackTitle = "" private var playbackTitle = ""
private var pauseOverview = "" private var pauseOverview = ""
private var prerollEpisodeCode = "" private var prerollEpisodeCode = ""
@@ -306,6 +324,8 @@ class PlayerActivity : ComponentActivity() {
private var previewResumeDurationMs = 0L private var previewResumeDurationMs = 0L
private var previewResumeTitle = "" private var previewResumeTitle = ""
private var previewResumeLogoUrl: String? = null private var previewResumeLogoUrl: String? = null
private var previewResumeBackdropUrl: String? = null
private var previewResumeSeriesName: String? = null
private var previewResumePosterUrl: String? = null private var previewResumePosterUrl: String? = null
private var previewResumeOverview = "" private var previewResumeOverview = ""
private var previewResumePlaybackStarted = false private var previewResumePlaybackStarted = false
@@ -548,6 +568,7 @@ class PlayerActivity : ComponentActivity() {
bindScrubPreview(view) bindScrubPreview(view)
applyPictureMode() applyPictureMode()
loadingView = findViewById(R.id.playback_loading) loadingView = findViewById(R.id.playback_loading)
loadingBackdropView = findViewById(R.id.playback_loading_backdrop)
loadingTitleView = findViewById(R.id.playback_loading_title) loadingTitleView = findViewById(R.id.playback_loading_title)
loadingHintView = findViewById<TextView>(R.id.playback_loading_hint).also { loadingHintView = findViewById<TextView>(R.id.playback_loading_hint).also {
// Set this as soon as the layout is mounted so the XML fallback never flashes // 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) streamStatusView = view.findViewById(R.id.player_stream_status)
logoUrl = savedInstanceState?.getString(STATE_LOGO_URL) logoUrl = savedInstanceState?.getString(STATE_LOGO_URL)
?: intent.getStringExtra(EXTRA_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) playbackTitle = savedInstanceState?.getString(STATE_TITLE)
?: intent.getStringExtra(EXTRA_TITLE).orEmpty() ?: intent.getStringExtra(EXTRA_TITLE).orEmpty()
pauseOverview = savedInstanceState?.getString(STATE_OVERVIEW) pauseOverview = savedInstanceState?.getString(STATE_OVERVIEW)
@@ -585,6 +610,7 @@ class PlayerActivity : ComponentActivity() {
} }
pausePosterUrl = savedInstanceState?.getString(STATE_POSTER_URL) pausePosterUrl = savedInstanceState?.getString(STATE_POSTER_URL)
?: intent.getStringExtra(EXTRA_POSTER_URL) ?: intent.getStringExtra(EXTRA_POSTER_URL)
bindLoadingBackdrop(loadingBackdropUrl)
if (showPreroll) startPreroll() else startWithoutPreroll() if (showPreroll) startPreroll() else startWithoutPreroll()
setUpPlaybackError() setUpPlaybackError()
@@ -789,7 +815,12 @@ class PlayerActivity : ComponentActivity() {
// --- Decoration ------------------------------------------------------------- // --- Decoration -------------------------------------------------------------
bindTitleArtwork(title = playbackTitle, logoUrl = logoUrl) bindTitleArtwork(title = playbackTitle, logoUrl = logoUrl)
bindPauseOverlay(view) bindPauseOverlay(view)
setUpPlaybackIdentity(title = playbackTitle) setUpPlaybackIdentity(
title = playbackTitle,
seriesName = playbackSeriesName,
episodeCode = prerollEpisodeCode,
logoUrl = logoUrl,
)
setUpSubtitleOverlay() setUpSubtitleOverlay()
setUpCastOverlay() setUpCastOverlay()
setUpNextUpBanner() setUpNextUpBanner()
@@ -882,7 +913,7 @@ class PlayerActivity : ComponentActivity() {
playMethod = playable.playMethod playMethod = playable.playMethod
playbackTitle = playable.title.ifBlank { request.title + " trailer" } playbackTitle = playable.title.ifBlank { request.title + " trailer" }
bindTitleArtwork(playbackTitle, logoUrl) bindTitleArtwork(playbackTitle, logoUrl)
setUpPlaybackIdentity(playbackTitle) setUpPlaybackIdentity(playbackTitle, null, null, logoUrl)
startMedia(playable.url, emptyList(), 0L, playWhenReady = true) startMedia(playable.url, emptyList(), 0L, playWhenReady = true)
} }
.onFailure { error -> .onFailure { error ->
@@ -1126,15 +1157,21 @@ class PlayerActivity : ComponentActivity() {
playable.overview?.takeIf(String::isNotBlank)?.let { pauseOverview = it } playable.overview?.takeIf(String::isNotBlank)?.let { pauseOverview = it }
playable.episodeCode?.takeIf(String::isNotBlank)?.let { prerollEpisodeCode = it } playable.episodeCode?.takeIf(String::isNotBlank)?.let { prerollEpisodeCode = it }
playable.logoUrl?.takeIf(String::isNotBlank)?.let { logoUrl = it } playable.logoUrl?.takeIf(String::isNotBlank)?.let { logoUrl = it }
playable.seriesName?.takeIf(String::isNotBlank)?.let { playbackSeriesName = it }
Log.i( Log.i(
PLAYBACK_LOG_TAG, PLAYBACK_LOG_TAG,
"event=subtitle_configs item=${playable.itemId} " + "event=subtitle_configs item=${playable.itemId} " +
"count=${playable.subtitles.size} source=launch", "count=${playable.subtitles.size} source=launch",
) )
if (playable.title.isBlank() || playable.title == playbackTitle) return if (playable.title.isNotBlank()) playbackTitle = playable.title
playbackTitle = playable.title
bindTitleArtwork(title = playbackTitle, logoUrl = logoUrl) 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 pauseOverlay?.findViewById<TextView>(R.id.player_pause_title)?.text = playbackTitle
} }
@@ -1151,6 +1188,7 @@ class PlayerActivity : ComponentActivity() {
hideController() hideController()
} }
if (!attachLocalPreroll()) enterPrerollVideoFrame() if (!attachLocalPreroll()) enterPrerollVideoFrame()
bindPrerollIdentity()
bindPrerollNow() bindPrerollNow()
bindPrerollSchedule(GatewayPrerollSchedule(), loading = true) bindPrerollSchedule(GatewayPrerollSchedule(), loading = true)
prerollScheduleJob = lifecycleScope.launch { prerollScheduleJob = lifecycleScope.launch {
@@ -1519,6 +1557,52 @@ class PlayerActivity : ComponentActivity() {
pauseOverview.ifBlank { "Memby is preparing this episode for playback." } 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 = private fun prerollEntry(entry: GatewayPrerollEntry, label: String): View =
LayoutInflater.from(this).inflate(R.layout.player_preroll_schedule_card, null).apply { LayoutInflater.from(this).inflate(R.layout.player_preroll_schedule_card, null).apply {
findViewById<TextView>(R.id.player_preroll_card_label).text = label 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) playbackIdentityView = findViewById(R.id.player_playback_identity)
findViewById<TextView>(R.id.player_playback_identity_title).apply { val logo = findViewById<ImageView>(R.id.player_playback_identity_logo)
text = title.ifBlank { "Now playing" } val fallback = findViewById<TextView>(R.id.player_playback_identity_title).apply {
visibility = View.VISIBLE 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) previewResumeDurationMs = playback.duration.coerceAtLeast(previewResumePositionMs)
previewResumeTitle = playbackTitle previewResumeTitle = playbackTitle
previewResumeLogoUrl = logoUrl previewResumeLogoUrl = logoUrl
previewResumeBackdropUrl = loadingBackdropUrl
previewResumeSeriesName = playbackSeriesName
previewResumePosterUrl = pausePosterUrl previewResumePosterUrl = pausePosterUrl
previewResumeOverview = pauseOverview previewResumeOverview = pauseOverview
previewResumePlaybackStarted = playbackStarted previewResumePlaybackStarted = playbackStarted
@@ -2943,11 +3070,14 @@ class PlayerActivity : ComponentActivity() {
hideNextUp() hideNextUp()
if (creditsActive) leaveEndCredits(restoreSpeed = true) if (creditsActive) leaveEndCredits(restoreSpeed = true)
playbackTitle = "Next: ${nextTitle(next)}" playbackTitle = "Next: ${nextTitle(next)}"
playbackSeriesName = next.seriesName
logoUrl = next.logoUrl logoUrl = next.logoUrl
loadingBackdropUrl = next.imageUrl
bindLoadingBackdrop(loadingBackdropUrl)
pausePosterUrl = next.imageUrl pausePosterUrl = next.imageUrl
pauseOverview = next.overview pauseOverview = next.overview
bindTitleArtwork(playbackTitle, logoUrl) bindTitleArtwork(playbackTitle, logoUrl)
setUpPlaybackIdentity(playbackTitle) setUpPlaybackIdentity(playbackTitle, playbackSeriesName, next.episodeCode, logoUrl)
renderedFirstFrame = false renderedFirstFrame = false
showPlaybackLoading(title = "Finding the next episode…", hint = "Starting recap or preview") showPlaybackLoading(title = "Finding the next episode…", hint = "Starting recap or preview")
startMedia(preview.url, emptyList(), 0L, playWhenReady = true) startMedia(preview.url, emptyList(), 0L, playWhenReady = true)
@@ -2979,12 +3109,20 @@ class PlayerActivity : ComponentActivity() {
} }
playbackTitle = previewResumeTitle playbackTitle = previewResumeTitle
logoUrl = previewResumeLogoUrl logoUrl = previewResumeLogoUrl
loadingBackdropUrl = previewResumeBackdropUrl
playbackSeriesName = previewResumeSeriesName
bindLoadingBackdrop(loadingBackdropUrl)
pausePosterUrl = previewResumePosterUrl pausePosterUrl = previewResumePosterUrl
pauseOverview = previewResumeOverview pauseOverview = previewResumeOverview
playbackStarted = previewResumePlaybackStarted playbackStarted = previewResumePlaybackStarted
stopReported = previewResumeStopReported stopReported = previewResumeStopReported
bindTitleArtwork(playbackTitle, logoUrl) bindTitleArtwork(playbackTitle, logoUrl)
setUpPlaybackIdentity(playbackTitle) setUpPlaybackIdentity(
playbackTitle,
playbackSeriesName,
prerollEpisodeCode,
logoUrl,
)
renderedFirstFrame = false renderedFirstFrame = false
if (previewResumeUrl.isBlank()) { if (previewResumeUrl.isBlank()) {
// The original stream should always be known, but losing the optional preview // 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. // The cast reloads with the rest of the new episode's session, once it is playing.
playbackTitle = nextTitle(next) playbackTitle = nextTitle(next)
playbackSeriesName = next.seriesName
logoUrl = next.logoUrl logoUrl = next.logoUrl
loadingBackdropUrl = next.imageUrl
bindLoadingBackdrop(loadingBackdropUrl)
pausePosterUrl = next.imageUrl pausePosterUrl = next.imageUrl
pauseOverview = next.overview pauseOverview = next.overview
prerollEpisodeCode = next.episodeCode.orEmpty() prerollEpisodeCode = next.episodeCode.orEmpty()
prerollRuntimeMs = next.runtimeMs.coerceAtLeast(0L) prerollRuntimeMs = next.runtimeMs.coerceAtLeast(0L)
bindTitleArtwork(title = playbackTitle, logoUrl = logoUrl) 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_title)?.text = playbackTitle
pauseOverlay?.findViewById<TextView>(R.id.player_pause_overview)?.text = pauseOverlay?.findViewById<TextView>(R.id.player_pause_overview)?.text =
pauseOverview.ifBlank { getString(R.string.player_pause_overview_fallback) } pauseOverview.ifBlank { getString(R.string.player_pause_overview_fallback) }
@@ -4588,6 +4734,14 @@ class PlayerActivity : ComponentActivity() {
outState.putBoolean(STATE_END_CREDITS, endCreditsAvailable) outState.putBoolean(STATE_END_CREDITS, endCreditsAvailable)
outState.putString(STATE_TITLE, if (savingPreview) previewResumeTitle else playbackTitle) outState.putString(STATE_TITLE, if (savingPreview) previewResumeTitle else playbackTitle)
outState.putString(STATE_LOGO_URL, if (savingPreview) previewResumeLogoUrl else logoUrl) 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_OVERVIEW, if (savingPreview) previewResumeOverview else pauseOverview)
outState.putString(STATE_POSTER_URL, if (savingPreview) previewResumePosterUrl else pausePosterUrl) outState.putString(STATE_POSTER_URL, if (savingPreview) previewResumePosterUrl else pausePosterUrl)
outState.putString(STATE_EPISODE_CODE, prerollEpisodeCode) outState.putString(STATE_EPISODE_CODE, prerollEpisodeCode)
@@ -4878,6 +5032,8 @@ class PlayerActivity : ComponentActivity() {
private const val EXTRA_TITLE = "extra_title" private const val EXTRA_TITLE = "extra_title"
private const val EXTRA_RESUME_POSITION_MS = "extra_resume_position_ms" private const val EXTRA_RESUME_POSITION_MS = "extra_resume_position_ms"
private const val EXTRA_LOGO_URL = "extra_logo_url" 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_OVERVIEW = "extra_overview"
private const val EXTRA_EPISODE_CODE = "extra_episode_code" private const val EXTRA_EPISODE_CODE = "extra_episode_code"
private const val EXTRA_RUNTIME_MS = "extra_runtime_ms" 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_END_CREDITS = "state_end_credits"
private const val STATE_TITLE = "state_title" private const val STATE_TITLE = "state_title"
private const val STATE_LOGO_URL = "state_logo_url" 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_OVERVIEW = "state_overview"
private const val STATE_POSTER_URL = "state_poster_url" private const val STATE_POSTER_URL = "state_poster_url"
private const val STATE_EPISODE_CODE = "state_episode_code" private const val STATE_EPISODE_CODE = "state_episode_code"
@@ -4951,6 +5109,7 @@ class PlayerActivity : ComponentActivity() {
context: Context, context: Context,
request: PlaybackRequest, request: PlaybackRequest,
posterUrl: String? = null, posterUrl: String? = null,
backdropUrl: String? = null,
requestStartedAtMs: Long = SystemClock.elapsedRealtime(), requestStartedAtMs: Long = SystemClock.elapsedRealtime(),
): Intent = Intent(context, PlayerActivity::class.java).apply { ): Intent = Intent(context, PlayerActivity::class.java).apply {
putExtra(EXTRA_PLAYBACK_REQUEST, playerJson.encodeToString(request)) putExtra(EXTRA_PLAYBACK_REQUEST, playerJson.encodeToString(request))
@@ -4958,10 +5117,12 @@ class PlayerActivity : ComponentActivity() {
putExtra(EXTRA_TITLE, request.title) putExtra(EXTRA_TITLE, request.title)
putExtra(EXTRA_RESUME_POSITION_MS, request.resumePositionMs) putExtra(EXTRA_RESUME_POSITION_MS, request.resumePositionMs)
request.logoUrl?.let { putExtra(EXTRA_LOGO_URL, it) } 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.overview?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_OVERVIEW, it) }
request.episodeCode?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_EPISODE_CODE, it) } request.episodeCode?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_EPISODE_CODE, it) }
putExtra(EXTRA_RUNTIME_MS, request.runtimeMs.coerceAtLeast(0L)) putExtra(EXTRA_RUNTIME_MS, request.runtimeMs.coerceAtLeast(0L))
posterUrl?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_POSTER_URL, it) } 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) putExtra(EXTRA_REQUEST_STARTED_AT_MS, requestStartedAtMs)
} }
@@ -4994,6 +5155,8 @@ class PlayerActivity : ComponentActivity() {
title: String?, title: String?,
resumePositionMs: Long = 0L, resumePositionMs: Long = 0L,
logoUrl: String? = null, logoUrl: String? = null,
backdropUrl: String? = null,
seriesName: String? = null,
overview: String? = null, overview: String? = null,
episodeCode: String? = null, episodeCode: String? = null,
runtimeMs: Long = 0L, runtimeMs: Long = 0L,
@@ -5018,6 +5181,8 @@ class PlayerActivity : ComponentActivity() {
putExtra(EXTRA_TITLE, title) putExtra(EXTRA_TITLE, title)
putExtra(EXTRA_RESUME_POSITION_MS, resumePositionMs) putExtra(EXTRA_RESUME_POSITION_MS, resumePositionMs)
logoUrl?.let { putExtra(EXTRA_LOGO_URL, it) } 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) } overview?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_OVERVIEW, it) }
episodeCode?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_EPISODE_CODE, it) } episodeCode?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_EPISODE_CODE, it) }
putExtra(EXTRA_RUNTIME_MS, runtimeMs.coerceAtLeast(0L)) putExtra(EXTRA_RUNTIME_MS, runtimeMs.coerceAtLeast(0L))
@@ -377,7 +377,7 @@ private fun MyRequestsPane(
RequestsNotice( RequestsNotice(
icon = Icons.Default.Inbox, icon = Icons.Default.Inbox,
heading = "You have not asked for anything yet", 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", action = "Request something",
onAction = onBrowse, onAction = onBrowse,
actionFocusRequester = paneFocusRequester, actionFocusRequester = paneFocusRequester,
@@ -686,7 +686,7 @@ private fun CandidatesPane(
RequestsSearchPhase.NO_MATCHES -> RequestsSearchPhase.NO_MATCHES ->
"Nothing matched “${state.searchedTerm ?: state.query.trim()}”. " + "Nothing matched “${state.searchedTerm ?: state.query.trim()}”. " +
"Check the spelling, or try the original title." "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 return@Column
@@ -743,7 +743,7 @@ private fun CandidatesPane(
private fun candidateDetail(candidate: GatewayRequestCandidate): String = when (candidate.status) { private fun candidateDetail(candidate: GatewayRequestCandidate): String = when (candidate.status) {
RequestStatus.AVAILABLE -> "Already in your library" RequestStatus.AVAILABLE -> "Already in your library"
RequestStatus.REQUESTED -> "You have already asked for this" 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.PENDING -> "Already tracked, not out yet"
RequestStatus.REQUESTABLE -> "Press to request" RequestStatus.REQUESTABLE -> "Press to request"
else -> candidate.statusLabel.ifBlank { "Press to request" } else -> candidate.statusLabel.ifBlank { "Press to request" }
+1 -36
View File
@@ -67,42 +67,7 @@
android:layout_gravity="top" android:layout_gravity="top"
android:focusable="false" /> android:focusable="false" />
<LinearLayout <include layout="@layout/player_loading" />
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>
<!-- The existing PlayerView is temporarily hosted inside this overlay, then returned <!-- 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. --> 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"?> <?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" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/player_playback_identity" android:id="@+id/player_playback_identity"
android:layout_width="wrap_content" android:layout_width="560dp"
android:layout_height="54dp" android:layout_height="wrap_content"
android:layout_gravity="start|top" android:layout_gravity="start|top"
android:layout_marginStart="48dp" android:layout_marginStart="48dp"
android:layout_marginTop="34dp" android:layout_marginTop="34dp"
android:alpha="0" android:alpha="0"
android:focusable="false" android:focusable="false"
android:gravity="center_vertical" android:gravity="start"
android:orientation="horizontal" android:orientation="vertical"
android:visibility="gone"> android:visibility="gone">
<ImageView <ImageView
android:layout_width="42dp" android:id="@+id/player_playback_identity_logo"
android:layout_height="38dp" android:layout_width="300dp"
android:contentDescription="@string/player_preroll_brand_logo" android:layout_height="82dp"
android:scaleType="fitCenter" android:adjustViewBounds="true"
android:src="@drawable/emby_logo" /> android:contentDescription="@string/player_title_logo"
android:scaleType="fitStart"
android:visibility="gone" />
<TextView <TextView
android:id="@+id/player_playback_identity_title" android:id="@+id/player_playback_identity_title"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="match_parent" android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:ellipsize="end" android:ellipsize="end"
android:gravity="start|center_vertical"
android:maxLines="1" android:maxLines="1"
android:maxWidth="520dp" android:maxWidth="540dp"
android:shadowColor="#E0000000" android:shadowColor="#E0000000"
android:shadowDx="0" android:shadowDx="0"
android:shadowDy="2" android:shadowDy="2"
android:shadowRadius="5" android:shadowRadius="5"
android:textColor="#FFFFFFFF" android:textColor="#FFFFFFFF"
android:textSize="25sp" android:textSize="26sp"
android:textStyle="bold" /> 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> </LinearLayout>
+38 -7
View File
@@ -9,17 +9,48 @@
android:focusable="false" android:focusable="false"
android:visibility="gone"> android:visibility="gone">
<ImageView <LinearLayout
android:id="@+id/player_preroll_brand" android:id="@+id/player_preroll_brand"
android:layout_width="58dp" android:layout_width="300dp"
android:layout_height="50dp" android:layout_height="wrap_content"
android:layout_gravity="start|top" android:layout_gravity="start|top"
android:layout_marginStart="44dp" android:layout_marginStart="44dp"
android:layout_marginTop="28dp" android:layout_marginTop="20dp"
android:contentDescription="@string/player_preroll_brand_logo" android:gravity="start"
android:scaleType="fitCenter" 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" /> 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 <LinearLayout
android:id="@+id/player_preroll_content" android:id="@+id/player_preroll_content"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -28,7 +59,7 @@
android:gravity="center_horizontal" android:gravity="center_horizontal"
android:orientation="vertical" android:orientation="vertical"
android:paddingStart="44dp" android:paddingStart="44dp"
android:paddingTop="84dp" android:paddingTop="92dp"
android:paddingEnd="44dp" android:paddingEnd="44dp"
android:paddingBottom="22dp"> 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.GatewayCalendar
import com.ponzischeme89.memby.data.model.GatewayCalendarDay import com.ponzischeme89.memby.data.model.GatewayCalendarDay
import com.ponzischeme89.memby.ui.calendar.CALENDAR_COLUMNS 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.CalendarUiState
import com.ponzischeme89.memby.ui.calendar.calendarDate import com.ponzischeme89.memby.ui.calendar.calendarDate
import com.ponzischeme89.memby.ui.calendar.calendarAgendaWeekDate import com.ponzischeme89.memby.ui.calendar.calendarAgendaWeekDate
@@ -40,11 +41,13 @@ class CalendarGridTest {
@Test @Test
fun `the grid pads to whole weeks`() { fun `the grid pads to whole weeks`() {
val weeks = calendarWeeks(august()) 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) assertEquals(6, weeks.size)
assertTrue(weeks.all { it.size == CALENDAR_COLUMNS }) assertTrue(weeks.all { it.size == CALENDAR_COLUMNS })
assertTrue(weeks.first().take(6).all { it.isPad }) assertEquals(listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"), CALENDAR_WEEKDAYS)
assertEquals(1, weeks.first().last().day) 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 }) assertEquals(31, weeks.flatten().count { !it.isPad })
} }
@@ -63,7 +66,7 @@ class CalendarGridTest {
) )
assertEquals(6, weeks.size) assertEquals(6, weeks.size)
assertEquals("915 August", weeks[2].label) assertEquals("1016 August", weeks[2].label)
assertEquals(2, weeks[2].episodeCount) assertEquals(2, weeks[2].episodeCount)
assertEquals(2, calendarAgendaWeekIndex(weeks, "2026-08-12")) assertEquals(2, calendarAgendaWeekIndex(weeks, "2026-08-12"))
assertEquals("2026-08-11", calendarAgendaWeekDate(weeks[2])) 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 @Test
fun `both pinned actions are reachable below the profiles`() { fun `all pinned actions are reachable below the profiles`() {
val profileCount = 3 val profileCount = 3
// …the last profile, then Notifications, then Manage users, and no further. // …the last profile, Notifications, Settings, Manage users, and no further.
assertEquals(3, userSwitcherNextIndex(2, profileCount, UserSwitcherDirection.DOWN, 2)) assertEquals(3, userSwitcherNextIndex(2, profileCount, UserSwitcherDirection.DOWN, 3))
assertEquals(4, userSwitcherNextIndex(3, profileCount, UserSwitcherDirection.DOWN, 2)) assertEquals(4, userSwitcherNextIndex(3, profileCount, UserSwitcherDirection.DOWN, 3))
assertEquals(4, userSwitcherNextIndex(4, profileCount, UserSwitcherDirection.DOWN, 2)) assertEquals(5, userSwitcherNextIndex(4, profileCount, UserSwitcherDirection.DOWN, 3))
assertEquals(3, userSwitcherNextIndex(4, profileCount, UserSwitcherDirection.UP, 2)) assertEquals(5, userSwitcherNextIndex(5, profileCount, UserSwitcherDirection.DOWN, 3))
assertEquals(4, userSwitcherNextIndex(5, profileCount, UserSwitcherDirection.UP, 3))
} }
@Test @Test
fun `pinned actions stay reachable with a single profile`() { fun `pinned actions stay reachable with a single profile`() {
assertEquals(1, userSwitcherNextIndex(0, 1, UserSwitcherDirection.DOWN, 2)) assertEquals(1, userSwitcherNextIndex(0, 1, UserSwitcherDirection.DOWN, 3))
assertEquals(2, userSwitcherNextIndex(1, 1, UserSwitcherDirection.DOWN, 2)) assertEquals(2, userSwitcherNextIndex(1, 1, UserSwitcherDirection.DOWN, 3))
assertEquals(2, userSwitcherNextIndex(9, 1, UserSwitcherDirection.DOWN, 2)) assertEquals(3, userSwitcherNextIndex(2, 1, UserSwitcherDirection.DOWN, 3))
assertEquals(3, userSwitcherNextIndex(9, 1, UserSwitcherDirection.DOWN, 3))
} }
} }
@@ -57,7 +57,7 @@ class PlaybackIdentityScreenshotTest {
} }
@Test @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 activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val root = FrameLayout(activity) val root = FrameLayout(activity)
val backdrop = ImageView(activity).apply { val backdrop = ImageView(activity).apply {
@@ -82,7 +82,15 @@ class PlaybackIdentityScreenshotTest {
visibility = View.VISIBLE visibility = View.VISIBLE
alpha = 1f 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) root.addView(identity)
activity.setContentView(root) activity.setContentView(root)
@@ -91,4 +99,38 @@ class PlaybackIdentityScreenshotTest {
"build/screenshots/playback-identity/player-playback-identity.png", "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.FrameLayout
import android.widget.GridLayout import android.widget.GridLayout
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import com.ponzischeme89.memby.R import com.ponzischeme89.memby.R
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@@ -35,7 +37,9 @@ class PrerollLayoutTest {
videoHost.layoutParams.width, videoHost.layoutParams.width,
) )
assertNotNull(preroll.findViewById<PrerollCountdownView>(R.id.player_preroll_countdown)) 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( assertEquals(
"Starting in 7 seconds…", "Starting in 7 seconds…",
context.getString(R.string.player_preroll_countdown_initial), context.getString(R.string.player_preroll_countdown_initial),
@@ -2,6 +2,10 @@ package com.ponzischeme89.memby.ui.player
import android.app.Activity import android.app.Activity
import android.graphics.BitmapFactory 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.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@@ -63,6 +67,14 @@ class PrerollScreenshotTest {
) )
preroll.findViewById<TextView>(R.id.player_preroll_now_title).text = preroll.findViewById<TextView>(R.id.player_preroll_now_title).text =
"Northbound The Crossing" "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 = preroll.findViewById<TextView>(R.id.player_preroll_now_metadata).text =
"EPISODE · S02E04 · 48 mins" "EPISODE · S02E04 · 48 mins"
preroll.findViewById<TextView>(R.id.player_preroll_now_overview).text = preroll.findViewById<TextView>(R.id.player_preroll_now_overview).text =
@@ -105,4 +117,17 @@ class PrerollScreenshotTest {
private fun dp(activity: Activity, value: Int): Int = private fun dp(activity: Activity, value: Int): Int =
(value * activity.resources.displayMetrics.density).toInt() (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 @Test
fun `the switcher's action count matches the rows actually drawn`() { fun `the switcher's action count matches the rows actually drawn`() {
assertEquals(2, userSwitcherActionCount(showRequests = false)) assertEquals(3, userSwitcherActionCount(showRequests = false))
assertEquals(3, userSwitcherActionCount(showRequests = true)) assertEquals(4, userSwitcherActionCount(showRequests = true))
} }
@Test @Test
+19 -3
View File
@@ -228,7 +228,6 @@ func run(log *slog.Logger, events *logging.Buffer) error {
Log: log, Log: log,
Config: creditsConfig, Config: creditsConfig,
}) })
go creditsService.Run(ctx)
// media_url is on this line rather than the ready line because credits detection is // media_url is on this line rather than the ready line because credits detection is
// the only thing that uses it, and because reading it back is the only way an // the only thing that uses it, and because reading it back is the only way an
// operator can tell that a scan is taking the short path. Where it equals EmbyURL // operator can tell that a scan is taking the short path. Where it equals EmbyURL
@@ -247,7 +246,6 @@ func run(log *slog.Logger, events *logging.Buffer) error {
adminBus := adminevents.New(st, log) adminBus := adminevents.New(st, log)
dispatcher := integrations.New(st, log, adminBus) dispatcher := integrations.New(st, log, adminBus)
adminBus.AddSink(dispatcher) adminBus.AddSink(dispatcher)
dispatcher.Start(ctx)
sched := scheduler.New(st, log, adminBus) sched := scheduler.New(st, log, adminBus)
server := api.New(cfg, api.Deps{ server := api.New(cfg, api.Deps{
@@ -270,6 +268,16 @@ func run(log *slog.Logger, events *logging.Buffer) error {
Scheduler: sched, Scheduler: sched,
Integrations: dispatcher, Integrations: dispatcher,
}) })
if err := server.LoadQuietTime(ctx); err != nil {
return err
}
sched.SetPaused(server.ActivityPaused)
dispatcher.SetPaused(server.ActivityPaused)
dispatcher.Start(ctx)
if creditsService != nil {
creditsService.SetPaused(server.ActivityPaused)
go creditsService.Run(ctx)
}
// Registration is separate from construction so the task list reads as a declaration // Registration is separate from construction so the task list reads as a declaration
// of what the gateway does in the background rather than as more wiring in here. // of what the gateway does in the background rather than as more wiring in here.
@@ -294,6 +302,7 @@ func run(log *slog.Logger, events *logging.Buffer) error {
return err return err
} }
go server.WatchMaintenance(ctx, 30*time.Second) go server.WatchMaintenance(ctx, 30*time.Second)
go server.WatchQuietTime(ctx, 30*time.Second)
// One probe per gateway, not per TV: the answer is the same for the whole house. // One probe per gateway, not per TV: the answer is the same for the whole house.
go server.WatchEmbyReachability(ctx, cfg.EmbyHealthInterval) go server.WatchEmbyReachability(ctx, cfg.EmbyHealthInterval)
// One Sonarr catalogue reading per day records lifecycle changes for the household and // One Sonarr catalogue reading per day records lifecycle changes for the household and
@@ -305,9 +314,12 @@ func run(log *slog.Logger, events *logging.Buffer) error {
} }
go server.WatchUpdatePolicy(ctx, 60*time.Second) go server.WatchUpdatePolicy(ctx, 60*time.Second)
go syncer.Schedule(ctx, cfg.SyncInterval) go syncer.Schedule(ctx, cfg.SyncInterval, server.ActivityPaused)
if cfg.SyncOnStart { if cfg.SyncOnStart {
go func() { go func() {
if server.ActivityPaused() {
return
}
if _, err := syncer.Sync(ctx, "incremental", "startup"); err != nil { if _, err := syncer.Sync(ctx, "incremental", "startup"); err != nil {
log.Warn("startup sync failed", "error", err) log.Warn("startup sync failed", "error", err)
} }
@@ -319,8 +331,12 @@ func run(log *slog.Logger, events *logging.Buffer) error {
cfg.TracearrSyncInterval, cfg.TracearrSyncInterval,
cfg.TracearrFullInterval, cfg.TracearrFullInterval,
cfg.ForYouRebuildHour, cfg.ForYouRebuildHour,
server.ActivityPaused,
) )
go func() { go func() {
if server.ActivityPaused() {
return
}
importCtx, cancel := context.WithTimeout(ctx, cfg.SyncTimeout) importCtx, cancel := context.WithTimeout(ctx, cfg.SyncTimeout)
// Only if one is actually owed. An unconditional startup import made every // Only if one is actually owed. An unconditional startup import made every
// redeploy or container bounce a fresh pass over Tracearr's history. // redeploy or container bounce a fresh pass over Tracearr's history.
+50
View File
@@ -57,6 +57,7 @@ func (s *Server) adminRoutes() http.Handler {
mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync)) mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync))
mux.Handle("POST /admin/api/for-you", s.adminAuth(s.handleAdminForYou)) mux.Handle("POST /admin/api/for-you", s.adminAuth(s.handleAdminForYou))
mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance)) mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance))
mux.Handle("POST /admin/api/quiet-time", s.adminAuth(s.handleAdminQuietTime))
mux.Handle("POST /admin/api/deployment-alert", s.adminAuth(s.handleAdminDeploymentAlert)) mux.Handle("POST /admin/api/deployment-alert", s.adminAuth(s.handleAdminDeploymentAlert))
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy)) mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
mux.Handle("GET /admin/api/release-builder", s.adminAuth(s.handleAdminReleaseBuilderStatus)) mux.Handle("GET /admin/api/release-builder", s.adminAuth(s.handleAdminReleaseBuilderStatus))
@@ -233,6 +234,7 @@ type adminStatus struct {
ServerVersion string `json:"serverVersion"` ServerVersion string `json:"serverVersion"`
CurrentUser string `json:"currentUser,omitempty"` CurrentUser string `json:"currentUser,omitempty"`
Maintenance store.Maintenance `json:"maintenance"` Maintenance store.Maintenance `json:"maintenance"`
QuietTime quietTimeStatus `json:"quietTime"`
UpdatePolicy appupdate.Policy `json:"updatePolicy"` UpdatePolicy appupdate.Policy `json:"updatePolicy"`
Library store.LibraryStats `json:"library"` Library store.LibraryStats `json:"library"`
SyncRunning bool `json:"syncRunning"` SyncRunning bool `json:"syncRunning"`
@@ -301,6 +303,7 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
return username return username
}(), }(),
Maintenance: s.maintenance.get(), Maintenance: s.maintenance.get(),
QuietTime: s.quietTimeStatus(time.Now()),
UpdatePolicy: s.updatePolicy.get(), UpdatePolicy: s.updatePolicy.get(),
Library: stats, Library: stats,
SyncRunning: s.syncer.Running(), SyncRunning: s.syncer.Running(),
@@ -605,6 +608,9 @@ type syncRequest struct {
// handleAdminSync starts an import in the background and returns immediately. A full // handleAdminSync starts an import in the background and returns immediately. A full
// import of a large library takes minutes; the page polls /admin/api/status for progress. // import of a large library takes minutes; the page polls /admin/api/status for progress.
func (s *Server) handleAdminSync(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAdminSync(w http.ResponseWriter, r *http.Request) {
if s.rejectWorkDuringQuietTime(w) {
return
}
var req syncRequest var req syncRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil { if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body") writeError(w, http.StatusBadRequest, "malformed request body")
@@ -640,6 +646,9 @@ type forYouAdminRequest struct {
// handleAdminForYou provides the recovery controls needed for an idempotent backfill: // handleAdminForYou provides the recovery controls needed for an idempotent backfill:
// import all Tracearr sessions again, or rebuild every active user's derived pool. // import all Tracearr sessions again, or rebuild every active user's derived pool.
func (s *Server) handleAdminForYou(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAdminForYou(w http.ResponseWriter, r *http.Request) {
if s.rejectWorkDuringQuietTime(w) {
return
}
if s.forYou == nil { if s.forYou == nil {
writeError(w, http.StatusServiceUnavailable, "Tracearr is not configured") writeError(w, http.StatusServiceUnavailable, "Tracearr is not configured")
return return
@@ -731,6 +740,47 @@ func (s *Server) handleAdminMaintenance(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, s.maintenance.get()) writeJSON(w, http.StatusOK, s.maintenance.get())
} }
type quietTimeRequest struct {
Enabled bool `json:"enabled"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
Message string `json:"message"`
}
func (s *Server) handleAdminQuietTime(w http.ResponseWriter, r *http.Request) {
var req quietTimeRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
start, startErr := time.Parse("15:04", strings.TrimSpace(req.StartTime))
end, endErr := time.Parse("15:04", strings.TrimSpace(req.EndTime))
if startErr != nil || endErr != nil {
writeError(w, http.StatusBadRequest, "quiet time must use valid 24-hour start and end times")
return
}
if start.Equal(end) {
writeError(w, http.StatusBadRequest, "quiet time start and end must be different")
return
}
policy := store.QuietTime{
Enabled: req.Enabled, StartTime: start.Format("15:04"), EndTime: end.Format("15:04"),
Message: strings.TrimSpace(req.Message),
}
if err := s.store.SetQuietTime(r.Context(), policy); err != nil {
s.loggerFor(r.Context()).Error("quiet-time write failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not update quiet time")
return
}
if err := s.LoadQuietTime(r.Context()); err != nil {
s.loggerFor(r.Context()).Warn("quiet-time reload failed", "error", err)
}
status := s.quietTimeStatus(time.Now())
s.loggerFor(r.Context()).Info("quiet time changed", "enabled", status.Enabled,
"active", status.Active, "start", status.StartTime, "end", status.EndTime)
writeJSON(w, http.StatusOK, status)
}
func (s *Server) handleAdminAnalytics(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAdminAnalytics(w http.ResponseWriter, r *http.Request) {
days := queryInt(r, "days", 7, 90) days := queryInt(r, "days", 7, 90)
since := time.Now().UTC().AddDate(0, 0, -days) since := time.Now().UTC().AddDate(0, 0, -days)
@@ -214,6 +214,9 @@ func (s *Server) handleAdminDeleteIntegration(w http.ResponseWriter, r *http.Req
// Synchronous on purpose: a test is a question, and an operator who pressed it needs the // Synchronous on purpose: a test is a question, and an operator who pressed it needs the
// answer here rather than on a delivery history they would have to go and refresh. // answer here rather than on a delivery history they would have to go and refresh.
func (s *Server) handleAdminTestIntegration(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAdminTestIntegration(w http.ResponseWriter, r *http.Request) {
if s.rejectWorkDuringQuietTime(w) {
return
}
id := strings.TrimSpace(r.PathValue("integrationID")) id := strings.TrimSpace(r.PathValue("integrationID"))
if err := s.integrations.Test(r.Context(), id); err != nil { if err := s.integrations.Test(r.Context(), id); err != nil {
s.loggerFor(r.Context()).Warn("integration test failed", s.loggerFor(r.Context()).Warn("integration test failed",
+3
View File
@@ -159,6 +159,9 @@ func (s *Server) handleAdminSubtitleSettings(w http.ResponseWriter, r *http.Requ
// the search comes back empty. One button that says "the key is rejected" is the whole // the search comes back empty. One button that says "the key is rejected" is the whole
// difference between a five-minute fix and an evening of guessing. // difference between a five-minute fix and an evening of guessing.
func (s *Server) handleAdminSubtitleTest(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAdminSubtitleTest(w http.ResponseWriter, r *http.Request) {
if s.rejectWorkDuringQuietTime(w) {
return
}
ctx := r.Context() ctx := r.Context()
type probe struct { type probe struct {
Provider string `json:"provider"` Provider string `json:"provider"`
+25
View File
@@ -90,6 +90,31 @@ func TestMaintenanceGateFallsBackToADefaultMessage(t *testing.T) {
} }
} }
func TestQuietTimeGateBlocksClientWorkWithTheConfiguredMessage(t *testing.T) {
server := testServer(config.Config{})
now := time.Now()
server.quietTime.set(store.QuietTime{
Enabled: true, StartTime: now.Add(-time.Minute).Format("15:04"),
EndTime: now.Add(time.Minute).Format("15:04"), Message: "Sleeping until morning",
})
rec := httptest.NewRecorder()
server.maintenanceGate(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("client handler ran during quiet time")
})).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/home", nil))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("quiet-time response = %d, want 503", rec.Code)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("body: %v", err)
}
if body["quietTime"] != true || body["message"] != "Sleeping until morning" {
t.Fatalf("quiet-time response = %#v", body)
}
}
func TestBareAdminURLRedirectsToTheConsoleRoot(t *testing.T) { func TestBareAdminURLRedirectsToTheConsoleRoot(t *testing.T) {
server := testServer(config.Config{AdminToken: "secret"}) server := testServer(config.Config{AdminToken: "secret"})
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
+2 -1
View File
@@ -98,6 +98,7 @@ type Server struct {
recommendationBuilds recommendationBuilds recommendationBuilds recommendationBuilds
maintenance maintenanceState maintenance maintenanceState
quietTime quietTimeState
updatePolicy updatePolicyCache updatePolicy updatePolicyCache
// embyHealth is the reachability probe's live finding, which /v1/status publishes so // embyHealth is the reachability probe's live finding, which /v1/status publishes so
// a TV can show why playback stopped even if it missed the announcement. // a TV can show why playback stopped even if it missed the announcement.
@@ -293,7 +294,7 @@ func (s *Server) Routes() http.Handler {
mux.Handle("/v1/", s.maintenanceGate(v1)) mux.Handle("/v1/", s.maintenanceGate(v1))
// Radarr pushes here when an import finishes. Outside the gate on purpose: an event // Radarr pushes here when an import finishes. Outside the gate on purpose: an event
// arriving during maintenance would otherwise be lost rather than delayed. // arriving during maintenance would otherwise be lost rather than delayed.
mux.HandleFunc("POST /hooks/radarr", s.handleRadarrWebhook) mux.Handle("POST /hooks/radarr", s.quietTimeGate(http.HandlerFunc(s.handleRadarrWebhook)))
// State the canonical console URL explicitly. The console and its assets live below // State the canonical console URL explicitly. The console and its assets live below
// /admin/, while a bare /admin is routinely typed and some reverse proxies do not // /admin/, while a bare /admin is routinely typed and some reverse proxies do not
// preserve ServeMux's implicit trailing-slash redirect for a mounted subtree. // preserve ServeMux's implicit trailing-slash redirect for a mounted subtree.
+1 -1
View File
@@ -83,7 +83,7 @@ func (s *Server) RegisterCreditsTasks(sched *scheduler.Scheduler) {
ID: "credits-candidates", ID: "credits-candidates",
Name: "Credits candidate refresh", Name: "Credits candidate refresh",
Group: "Library", Group: "Library",
Description: "Rebuilds the credits-detection queue from what the household has " + Description: "Rebuilds the credits-detection queue from what viewers have " +
"recently been watching. Only episodes viewers are about to reach are queued.", "recently been watching. Only episodes viewers are about to reach are queued.",
Interval: 10 * time.Minute, Interval: 10 * time.Minute,
Timeout: 2 * time.Minute, Timeout: 2 * time.Minute,
+2 -2
View File
@@ -89,8 +89,8 @@ var featureCatalogue = []featureDefinition{
Key: featureEndCredits, Name: "Speed through the credits", Area: "Playback", Key: featureEndCredits, Name: "Speed through the credits", Area: "Playback",
Description: "Shrink the picture and run the closing credits at double speed with " + Description: "Shrink the picture and run the closing credits at double speed with " +
"the next episode beside them. Emby's own marker is preferred where it has one, " + "the next episode beside them. Emby's own marker is preferred where it has one, " +
"and where it has none the position discovered for the episodes the household " + "and where it has none the position discovered from episodes viewers are about to watch " +
"is about to watch is used instead. It is read from the same chapter list as the " + "in your library is used instead. It is read from the same chapter list as the " +
"title sequence, so turning this off saves no request unless that is off too.", "title sequence, so turning this off saves no request unless that is off too.",
DefaultEnabled: true, MinimumProtocol: 1, Capability: "end_credits_v1", DefaultEnabled: true, MinimumProtocol: 1, Capability: "end_credits_v1",
Recovery: "Takes effect the next time playback starts; the credits simply play out full size.", Recovery: "Takes effect the next time playback starts; the credits simply play out full size.",
+1 -1
View File
@@ -86,7 +86,7 @@
<div class="mark">M</div> <div class="mark">M</div>
<h1>Install Memby</h1> <h1>Install Memby</h1>
{{if .Ready}} {{if .Ready}}
<p class="intro">The private Android TV client for this households Emby library.</p> <p class="intro">The private Android TV client for your Emby library.</p>
<a class="download" href="{{.DownloadURL}}">Download Memby {{.Version}}</a> <a class="download" href="{{.DownloadURL}}">Download Memby {{.Version}}</a>
<p class="meta">Signed Android APK · {{.Size}}</p> <p class="meta">Signed Android APK · {{.Size}}</p>
<ol> <ol>
+10
View File
@@ -67,6 +67,10 @@ func (s *Server) WatchMaintenance(ctx context.Context, interval time.Duration) {
// deliberately outside this gate — you need them most while the app is down. // deliberately outside this gate — you need them most while the app is down.
func (s *Server) maintenanceGate(next http.Handler) http.Handler { func (s *Server) maintenanceGate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.quietTimeActive() {
s.quietTimeUnavailable(w)
return
}
state := s.maintenance.get() state := s.maintenance.get()
if !state.Enabled { if !state.Enabled {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
@@ -95,6 +99,11 @@ func (s *Server) maintenanceGate(next http.Handler) http.Handler {
// already listening to — a push channel would be a second connection for a banner. // already listening to — a push channel would be a second connection for a banner.
func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, sess store.Session) { func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, sess store.Session) {
state := s.maintenance.get() state := s.maintenance.get()
quiet := s.quietTimeStatus(time.Now())
if quiet.Active {
state.Enabled = true
state.Message = quiet.Message
}
message := state.Message message := state.Message
if state.Enabled && message == "" { if state.Enabled && message == "" {
message = store.DefaultMaintenanceMessage message = store.DefaultMaintenanceMessage
@@ -123,6 +132,7 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, ses
featurePolicy := s.currentFeaturePolicy(r.Context()) featurePolicy := s.currentFeaturePolicy(r.Context())
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"maintenance": state.Enabled, "maintenance": state.Enabled,
"quietTime": quiet.Active,
"message": message, "message": message,
"alerts": alerts, "alerts": alerts,
"compatible": compatible, "compatible": compatible,
+106
View File
@@ -0,0 +1,106 @@
package api
import (
"context"
"net/http"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/store"
)
// quietTimeState keeps the daily policy on the hot path without turning every television
// request or scheduler tick into a database read.
type quietTimeState struct {
mu sync.RWMutex
policy store.QuietTime
}
func (q *quietTimeState) get() store.QuietTime {
q.mu.RLock()
defer q.mu.RUnlock()
return q.policy
}
func (q *quietTimeState) set(policy store.QuietTime) {
q.mu.Lock()
defer q.mu.Unlock()
q.policy = policy
}
type quietTimeStatus struct {
store.QuietTime
Active bool `json:"active"`
TimeZone string `json:"timeZone"`
}
func (s *Server) quietTimeStatus(now time.Time) quietTimeStatus {
policy := s.quietTime.get()
return quietTimeStatus{
QuietTime: policy,
Active: store.QuietTimeActive(policy, now, s.sonarrLocation()),
TimeZone: s.sonarrLocation().String(),
}
}
func (s *Server) quietTimeActive() bool {
return s.quietTimeStatus(time.Now()).Active
}
// ActivityPaused is the shared gate used by workers constructed outside the API package.
func (s *Server) ActivityPaused() bool { return s.quietTimeActive() }
func (s *Server) LoadQuietTime(ctx context.Context) error {
policy, err := s.store.QuietTime(ctx)
if err != nil {
return err
}
s.quietTime.set(policy)
return nil
}
// WatchQuietTime lets another gateway instance or a direct database edit take effect
// without a restart. It is control-plane work and therefore continues during quiet time.
func (s *Server) WatchQuietTime(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := s.LoadQuietTime(ctx); err != nil {
s.log.Warn("quiet-time refresh failed", "component", "quiet-time", "error", err)
}
}
}
}
func (s *Server) quietTimeUnavailable(w http.ResponseWriter) {
status := s.quietTimeStatus(time.Now())
message := status.Message
if message == "" {
message = store.DefaultQuietTimeMessage
}
w.Header().Set("Retry-After", "300")
writeJSON(w, http.StatusServiceUnavailable, map[string]any{
"error": message, "maintenance": true, "quietTime": true, "message": message,
})
}
func (s *Server) rejectWorkDuringQuietTime(w http.ResponseWriter) bool {
if !s.quietTimeActive() {
return false
}
s.quietTimeUnavailable(w)
return true
}
func (s *Server) quietTimeGate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.rejectWorkDuringQuietTime(w) {
return
}
next.ServeHTTP(w, r)
})
}
+3
View File
@@ -23,6 +23,9 @@ func (s *Server) handleAdminReleaseBuilderStatus(w http.ResponseWriter, r *http.
} }
func (s *Server) handleAdminReleaseBuilderStart(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAdminReleaseBuilderStart(w http.ResponseWriter, r *http.Request) {
if s.rejectWorkDuringQuietTime(w) {
return
}
r.Body = http.MaxBytesReader(w, r.Body, 16<<10) r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
var request releaseBuilderRequest var request releaseBuilderRequest
decoder := json.NewDecoder(r.Body) decoder := json.NewDecoder(r.Body)
+3
View File
@@ -132,6 +132,9 @@ func (s *Server) WatchEmbyReachability(ctx context.Context, interval time.Durati
case <-ctx.Done(): case <-ctx.Done():
return return
case <-ticker.C: case <-ticker.C:
if s.quietTimeActive() {
continue
}
probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
err := s.emby.Ping(probeCtx) err := s.emby.Ping(probeCtx)
cancel() cancel()
+3
View File
@@ -19,6 +19,9 @@ func (s *Server) WatchSonarrLifecycle(ctx context.Context, interval time.Duratio
return return
} }
scan := func() { scan := func() {
if s.quietTimeActive() {
return
}
if err := s.scanSonarrLifecycle(ctx); err != nil && ctx.Err() == nil { if err := s.scanSonarrLifecycle(ctx); err != nil && ctx.Err() == nil {
s.log.Warn("Sonarr lifecycle scan failed", "error", err) s.log.Warn("Sonarr lifecycle scan failed", "error", err)
} }
+10
View File
@@ -95,6 +95,7 @@ type Service struct {
behaviour BehaviourSource behaviour BehaviourSource
load LoadGauge load LoadGauge
log *slog.Logger log *slog.Logger
paused func() bool
cfg Config cfg Config
cfgMu sync.RWMutex cfgMu sync.RWMutex
@@ -109,6 +110,9 @@ type Service struct {
liveDelay time.Duration liveDelay time.Duration
} }
// SetPaused installs the server-wide quiet-time gate before Run starts.
func (s *Service) SetPaused(paused func() bool) { s.paused = paused }
func New(deps Deps) *Service { func New(deps Deps) *Service {
cfg := NormaliseConfig(deps.Config) cfg := NormaliseConfig(deps.Config)
detector := deps.Detector detector := deps.Detector
@@ -312,6 +316,12 @@ func (s *Service) Run(ctx context.Context) {
if ctx.Err() != nil { if ctx.Err() != nil {
return return
} }
if s.paused != nil && s.paused() {
if !sleep(ctx, idlePoll) {
return
}
continue
}
candidate, found := s.queue.Claim() candidate, found := s.queue.Claim()
if !found { if !found {
if !sleep(ctx, idlePoll) { if !sleep(ctx, idlePoll) {
+7 -1
View File
@@ -467,7 +467,7 @@ func (s *Service) recommendationUsers(ctx context.Context) ([]store.Session, err
embyUsers, err := s.emby.Users(ctx, s.serviceCred) embyUsers, err := s.emby.Users(ctx, s.serviceCred)
if err != nil { if err != nil {
s.log.Warn("Emby household users unavailable; using signed-in users", "error", err) s.log.Warn("Emby user catalogue unavailable; using signed-in users", "error", err)
return active, nil return active, nil
} }
tracearrUsers, traceErr := s.allTracearrUsers(ctx) tracearrUsers, traceErr := s.allTracearrUsers(ctx)
@@ -771,6 +771,7 @@ func (s *Service) Schedule(
ctx context.Context, ctx context.Context,
importEvery, fullEvery time.Duration, importEvery, fullEvery time.Duration,
rebuildHour int, rebuildHour int,
paused ...func() bool,
) { ) {
// One ticker asks "is anything owed?"; the persisted stamps decide what and whether. // One ticker asks "is anything owed?"; the persisted stamps decide what and whether.
// Two independent tickers measured process uptime, which is what let a restart reset // Two independent tickers measured process uptime, which is what let a restart reset
@@ -798,13 +799,18 @@ func (s *Service) Schedule(
case <-ctx.Done(): case <-ctx.Done():
return return
case <-importC: case <-importC:
if len(paused) > 0 && paused[0] != nil && paused[0]() {
continue
}
if _, _, err := s.ImportIfDue(ctx, importEvery, fullEvery); err != nil { if _, _, err := s.ImportIfDue(ctx, importEvery, fullEvery); err != nil {
s.log.Warn("scheduled Tracearr import failed", "error", err) s.log.Warn("scheduled Tracearr import failed", "error", err)
} }
case <-rebuildTimer.C: case <-rebuildTimer.C:
if len(paused) == 0 || paused[0] == nil || !paused[0]() {
if err := s.RebuildAll(ctx, true); err != nil { if err := s.RebuildAll(ctx, true); err != nil {
s.log.Warn("scheduled daily For You rebuild failed", "error", err) s.log.Warn("scheduled daily For You rebuild failed", "error", err)
} }
}
rebuildTimer.Reset(time.Until(nextDailyRebuild(time.Now(), s.location, rebuildHour))) rebuildTimer.Reset(time.Until(nextDailyRebuild(time.Now(), s.location, rebuildHour)))
} }
} }
@@ -65,6 +65,7 @@ type Dispatcher struct {
transports map[string]Transport transports map[string]Transport
queue chan job queue chan job
paused func() bool
mu sync.Mutex mu sync.Mutex
cached store.IntegrationSettings cached store.IntegrationSettings
@@ -76,6 +77,9 @@ type Dispatcher struct {
dropped int64 dropped int64
} }
// SetPaused installs the server-wide quiet-time gate before Start is called.
func (d *Dispatcher) SetPaused(paused func() bool) { d.paused = paused }
func New(st *store.Store, log *slog.Logger, events *adminevents.Bus) *Dispatcher { func New(st *store.Store, log *slog.Logger, events *adminevents.Bus) *Dispatcher {
dispatcher := &Dispatcher{ dispatcher := &Dispatcher{
store: st, log: log.With("component", "integrations"), events: events, store: st, log: log.With("component", "integrations"), events: events,
@@ -101,6 +105,15 @@ func (d *Dispatcher) Start(ctx context.Context) {
case <-ctx.Done(): case <-ctx.Done():
return return
case work := <-d.queue: case work := <-d.queue:
for d.paused != nil && d.paused() {
timer := time.NewTimer(30 * time.Second)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
}
d.deliver(ctx, work) d.deliver(ctx, work)
} }
} }
+5 -1
View File
@@ -277,7 +277,7 @@ func (s *Syncer) credentials(ctx context.Context) (emby.Credentials, error) {
// //
// New episodes tend to land through the day and films weekly; an hourly incremental pass // New episodes tend to land through the day and films weekly; an hourly incremental pass
// covers both without ever asking Emby for the whole catalogue again. // covers both without ever asking Emby for the whole catalogue again.
func (s *Syncer) Schedule(ctx context.Context, interval time.Duration) { func (s *Syncer) Schedule(ctx context.Context, interval time.Duration, paused ...func() bool) {
if interval <= 0 { if interval <= 0 {
s.log.Info("library auto-sync disabled") s.log.Info("library auto-sync disabled")
return return
@@ -291,6 +291,10 @@ func (s *Syncer) Schedule(ctx context.Context, interval time.Duration) {
case <-ctx.Done(): case <-ctx.Done():
return return
case <-ticker.C: case <-ticker.C:
if len(paused) > 0 && paused[0] != nil && paused[0]() {
s.log.Debug("skipping scheduled sync; quiet time is active")
continue
}
if s.Running() { if s.Running() {
s.log.Info("skipping scheduled sync; one is already running") s.log.Info("skipping scheduled sync; one is already running")
continue continue
+25
View File
@@ -93,6 +93,7 @@ type Scheduler struct {
store *store.Store store *store.Store
log *slog.Logger log *slog.Logger
events *adminevents.Bus events *adminevents.Bus
paused func() bool
mu sync.RWMutex mu sync.RWMutex
tasks map[string]*registered tasks map[string]*registered
@@ -101,6 +102,21 @@ type Scheduler struct {
started bool started bool
} }
// SetPaused installs the server-wide activity gate. The function is intentionally read at
// execution time so an admin change takes effect without rebuilding the task registry.
func (s *Scheduler) SetPaused(paused func() bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.paused = paused
}
func (s *Scheduler) isPaused() bool {
s.mu.RLock()
paused := s.paused
s.mu.RUnlock()
return paused != nil && paused()
}
func New(st *store.Store, log *slog.Logger, events *adminevents.Bus) *Scheduler { func New(st *store.Store, log *slog.Logger, events *adminevents.Bus) *Scheduler {
return &Scheduler{ return &Scheduler{
store: st, log: log.With("component", "scheduler"), events: events, store: st, log: log.With("component", "scheduler"), events: events,
@@ -219,6 +235,9 @@ func (s *Scheduler) loop(ctx context.Context) {
} }
func (s *Scheduler) runDue(ctx context.Context) { func (s *Scheduler) runDue(ctx context.Context) {
if s.isPaused() {
return
}
now := time.Now() now := time.Now()
s.mu.RLock() s.mu.RLock()
entries := make([]*registered, 0, len(s.tasks)) entries := make([]*registered, 0, len(s.tasks))
@@ -248,6 +267,9 @@ func (s *Scheduler) RunNow(ctx context.Context, id string) error {
if !ok { if !ok {
return fmt.Errorf("scheduler: no task %q", id) return fmt.Errorf("scheduler: no task %q", id)
} }
if s.isPaused() {
return fmt.Errorf("scheduler: server activity is paused for quiet time")
}
entry.mu.Lock() entry.mu.Lock()
if entry.running { if entry.running {
entry.mu.Unlock() entry.mu.Unlock()
@@ -261,6 +283,9 @@ func (s *Scheduler) RunNow(ctx context.Context, id string) error {
} }
func (s *Scheduler) execute(ctx context.Context, entry *registered, trigger string) { func (s *Scheduler) execute(ctx context.Context, entry *registered, trigger string) {
if s.isPaused() {
return
}
entry.mu.Lock() entry.mu.Lock()
if entry.running { if entry.running {
entry.mu.Unlock() entry.mu.Unlock()
@@ -40,6 +40,22 @@ func TestATaskNeedsAnIDAndAFunction(t *testing.T) {
sched.Register(Task{ID: "broken"}) sched.Register(Task{ID: "broken"})
} }
func TestQuietTimePausesManualTasks(t *testing.T) {
sched := quietScheduler()
ran := false
sched.Register(Task{ID: "quiet", Name: "Quiet", Run: func(context.Context) (string, error) {
ran = true
return "", nil
}})
sched.SetPaused(func() bool { return true })
if err := sched.RunNow(context.Background(), "quiet"); err == nil {
t.Fatal("manual task started during quiet time")
}
if ran {
t.Fatal("quiet-time task function ran")
}
}
func TestAPanickingTaskBecomesAFailedRun(t *testing.T) { func TestAPanickingTaskBecomesAFailedRun(t *testing.T) {
// A background job is the one place a panic takes the whole process down for a reason // A background job is the one place a panic takes the whole process down for a reason
// nobody is watching for. One housekeeping job with a nil map must not be able to stop // nobody is watching for. One housekeeping job with a nil map must not be able to stop
+1 -1
View File
@@ -39,7 +39,7 @@ func (s *Store) HouseholdCompletionScores(
AND coalesce(nullif(emby_series_id, ''), emby_item_id) <> '' AND coalesce(nullif(emby_series_id, ''), emby_item_id) <> ''
GROUP BY item_id`, since) GROUP BY item_id`, since)
if err != nil { if err != nil {
return nil, fmt.Errorf("store: household completion scores: %w", err) return nil, fmt.Errorf("store: library-wide completion scores: %w", err)
} }
defer rows.Close() defer rows.Close()
out := map[string]float64{} out := map[string]float64{}
+98
View File
@@ -15,6 +15,9 @@ import (
// MaintenanceKey is the app_settings row backing maintenance mode. // MaintenanceKey is the app_settings row backing maintenance mode.
const MaintenanceKey = "maintenance" const MaintenanceKey = "maintenance"
// QuietTimeKey is the app_settings row backing the daily server quiet-time window.
const QuietTimeKey = "quiet_time"
// RequestPolicyKey controls which Emby users may send titles to Sonarr/Radarr. // RequestPolicyKey controls which Emby users may send titles to Sonarr/Radarr.
const RequestPolicyKey = "request_policy" const RequestPolicyKey = "request_policy"
@@ -742,6 +745,101 @@ func (s *Store) SetMaintenance(ctx context.Context, state Maintenance) error {
return nil return nil
} }
// QuietTime is a daily window in the household timezone during which Memby's data plane
// and background work stand down. The admin control plane and health checks remain live so
// an operator can change a bad schedule without restarting the container.
type QuietTime struct {
Enabled bool `json:"enabled"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
Message string `json:"message"`
UpdatedAt time.Time `json:"updatedAt"`
}
const DefaultQuietTimeMessage = "Memby is in quiet time. Try again later."
func DefaultQuietTime() QuietTime {
return QuietTime{StartTime: "23:00", EndTime: "07:00", Message: DefaultQuietTimeMessage}
}
// QuietTimeActive reports whether now falls in the configured local-clock window. A
// window crossing midnight includes late evening and the following morning. Equal or
// malformed endpoints are treated as inactive; the admin handler refuses both.
func QuietTimeActive(policy QuietTime, now time.Time, location *time.Location) bool {
if !policy.Enabled {
return false
}
start, startErr := time.Parse("15:04", policy.StartTime)
end, endErr := time.Parse("15:04", policy.EndTime)
if startErr != nil || endErr != nil || policy.StartTime == policy.EndTime {
return false
}
if location == nil {
location = time.UTC
}
local := now.In(location)
minute := local.Hour()*60 + local.Minute()
startMinute := start.Hour()*60 + start.Minute()
endMinute := end.Hour()*60 + end.Minute()
if startMinute < endMinute {
return minute >= startMinute && minute < endMinute
}
return minute >= startMinute || minute < endMinute
}
func normaliseQuietTime(policy QuietTime) QuietTime {
defaults := DefaultQuietTime()
if parsed, err := time.Parse("15:04", strings.TrimSpace(policy.StartTime)); err == nil {
policy.StartTime = parsed.Format("15:04")
} else {
policy.StartTime = defaults.StartTime
}
if parsed, err := time.Parse("15:04", strings.TrimSpace(policy.EndTime)); err == nil {
policy.EndTime = parsed.Format("15:04")
} else {
policy.EndTime = defaults.EndTime
}
policy.Message = strings.TrimSpace(policy.Message)
if policy.Message == "" {
policy.Message = DefaultQuietTimeMessage
}
return policy
}
func (s *Store) QuietTime(ctx context.Context) (QuietTime, error) {
var raw []byte
err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, QuietTimeKey).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return DefaultQuietTime(), nil
}
if err != nil {
return DefaultQuietTime(), fmt.Errorf("store: read quiet time: %w", err)
}
var policy QuietTime
if err := json.Unmarshal(raw, &policy); err != nil {
return DefaultQuietTime(), fmt.Errorf("store: decode quiet time: %w", err)
}
return normaliseQuietTime(policy), nil
}
func (s *Store) SetQuietTime(ctx context.Context, policy QuietTime) error {
policy = normaliseQuietTime(policy)
policy.UpdatedAt = time.Now().UTC()
raw, err := json.Marshal(policy)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO app_settings (key, value, updated_at)
VALUES ($1, $2::jsonb, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
QuietTimeKey, string(raw))
if err != nil {
return fmt.Errorf("store: write quiet time: %w", err)
}
return nil
}
// UpdatePolicyKey is the app_settings row backing the client update policy. // UpdatePolicyKey is the app_settings row backing the client update policy.
const UpdatePolicyKey = "update_policy" const UpdatePolicyKey = "update_policy"
+28
View File
@@ -5,6 +5,34 @@ import (
"time" "time"
) )
func TestQuietTimeActiveHandlesDaytimeAndOvernightWindows(t *testing.T) {
location := time.FixedZone("NZST", 12*60*60)
at := func(hour, minute int) time.Time {
return time.Date(2026, time.August, 17, hour, minute, 0, 0, location)
}
tests := []struct {
name string
policy QuietTime
now time.Time
want bool
}{
{"disabled", QuietTime{StartTime: "23:00", EndTime: "07:00"}, at(23, 30), false},
{"overnight evening", QuietTime{Enabled: true, StartTime: "23:00", EndTime: "07:00"}, at(23, 0), true},
{"overnight morning", QuietTime{Enabled: true, StartTime: "23:00", EndTime: "07:00"}, at(6, 59), true},
{"overnight end exclusive", QuietTime{Enabled: true, StartTime: "23:00", EndTime: "07:00"}, at(7, 0), false},
{"daytime inside", QuietTime{Enabled: true, StartTime: "09:00", EndTime: "17:00"}, at(12, 0), true},
{"daytime outside", QuietTime{Enabled: true, StartTime: "09:00", EndTime: "17:00"}, at(18, 0), false},
{"equal endpoints are safe", QuietTime{Enabled: true, StartTime: "09:00", EndTime: "09:00"}, at(9, 0), false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := QuietTimeActive(test.policy, test.now, location); got != test.want {
t.Fatalf("QuietTimeActive() = %v, want %v", got, test.want)
}
})
}
}
func TestRequestPolicyAllowsOnlyListedUsers(t *testing.T) { func TestRequestPolicyAllowsOnlyListedUsers(t *testing.T) {
policy := RequestPolicy{AllowedUserIDs: []string{"user-2"}} policy := RequestPolicy{AllowedUserIDs: []string{"user-2"}}
if policy.Allows("user-1") { if policy.Allows("user-1") {