0.2.71
This commit is contained in:
@@ -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
|
||||
- Improvements: Playback "cold start" improvements.
|
||||
|
||||
|
||||
@@ -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
|
||||
costs 20–200 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
|
||||
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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
An independent Android TV client for Emby, by **ponzischeme89**. Memby combines a
|
||||
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)
|
||||
|
||||
|
||||
+12
File diff suppressed because one or more lines are too long
-12
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -13,7 +13,7 @@
|
||||
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"
|
||||
/>
|
||||
<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="stylesheet" crossorigin href="/admin/assets/index-CASotpHk.css">
|
||||
</head>
|
||||
|
||||
@@ -65,6 +65,16 @@ export interface Maintenance {
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface QuietTime {
|
||||
enabled: boolean;
|
||||
active: boolean;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
message: string;
|
||||
timeZone: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface UpdatePolicy {
|
||||
enabled: boolean;
|
||||
latestVersion: string;
|
||||
@@ -218,6 +228,7 @@ export interface AdminStatus {
|
||||
serverVersion: string;
|
||||
currentUser?: string;
|
||||
maintenance: Maintenance;
|
||||
quietTime: QuietTime;
|
||||
updatePolicy: UpdatePolicy;
|
||||
library: LibraryStats;
|
||||
syncRunning: boolean;
|
||||
|
||||
@@ -139,9 +139,10 @@ export function Layout() {
|
||||
const [confirmingOffline, setConfirmingOffline] = useState(false);
|
||||
const location = useLocation();
|
||||
const offline = Boolean(status?.maintenance?.enabled);
|
||||
const quiet = Boolean(status?.quietTime?.active);
|
||||
const account = useRef<HTMLDivElement>(null);
|
||||
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 () => {
|
||||
if (changingAvailability || !status) return;
|
||||
@@ -227,9 +228,9 @@ export function Layout() {
|
||||
className="topbar-status"
|
||||
data-tone={statusTone}
|
||||
aria-pressed={offline}
|
||||
disabled={!status || !online || changingAvailability}
|
||||
title={!status ? 'Checking Memby status' : 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'}
|
||||
disabled={!status || !online || changingAvailability || quiet}
|
||||
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={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)}
|
||||
>
|
||||
<span className="dot" aria-hidden="true" />
|
||||
|
||||
+1
-1
@@ -265,7 +265,7 @@ export const nav: NavGroup[] = [
|
||||
path: '/admin/maintenance',
|
||||
label: 'Maintenance',
|
||||
title: 'Maintenance',
|
||||
intro: 'Take Memby offline for every television.',
|
||||
intro: 'Take Memby offline now or schedule daily quiet time.',
|
||||
icon: 'wrench',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@ import { api } from '../api/client';
|
||||
import { useAction } from '../lib/hooks';
|
||||
import { useGateway } from '../lib/gateway';
|
||||
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() {
|
||||
const { status, error, loading, reload } = useGateway();
|
||||
@@ -12,6 +12,11 @@ export function MaintenancePage() {
|
||||
const [message, setMessage] = useState('');
|
||||
const [confirming, setConfirming] = 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);
|
||||
|
||||
@@ -23,6 +28,14 @@ export function MaintenancePage() {
|
||||
if (!touched && status) setMessage(status.maintenance?.message ?? '');
|
||||
}, [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) =>
|
||||
run(next ? 'on' : 'off', async () => {
|
||||
await wrap(
|
||||
@@ -34,9 +47,24 @@ export function MaintenancePage() {
|
||||
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 (
|
||||
<>
|
||||
<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} />
|
||||
|
||||
{loading ? (
|
||||
@@ -76,6 +104,38 @@ export function MaintenancePage() {
|
||||
</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 ? (
|
||||
<Confirm
|
||||
title="Take Memby offline?"
|
||||
|
||||
@@ -108,6 +108,8 @@ export function OverviewPage() {
|
||||
label: 'Availability',
|
||||
value: status.maintenance?.enabled ? (
|
||||
<Tag tone="bad">offline for maintenance</Tag>
|
||||
) : status.quietTime?.active ? (
|
||||
<Tag tone="warn">quiet time active</Tag>
|
||||
) : (
|
||||
<Tag tone="ok">online</Tag>
|
||||
),
|
||||
|
||||
@@ -46,7 +46,7 @@ val projectNoticeText =
|
||||
|
||||
// A release workflow can derive the app version from its Git tag without editing the
|
||||
// source tree. Local builds keep using the checked-in default.
|
||||
val defaultVersionName = "0.2.69"
|
||||
val defaultVersionName = "0.2.71"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
|
||||
@@ -97,6 +97,7 @@ data class Playable(
|
||||
val itemId: String,
|
||||
val title: String,
|
||||
val url: String,
|
||||
val seriesName: String? = null,
|
||||
val resumePositionMs: Long = 0L,
|
||||
val logoUrl: String? = null,
|
||||
val subtitles: List<PlayableSubtitle> = emptyList(),
|
||||
@@ -203,6 +204,7 @@ data class PlaybackRequest(
|
||||
val itemId: String,
|
||||
val itemType: String = "",
|
||||
val title: String = "",
|
||||
val seriesName: String? = null,
|
||||
val isSeries: Boolean = false,
|
||||
val resumePositionMs: Long = 0L,
|
||||
val logoUrl: String? = null,
|
||||
@@ -259,6 +261,8 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
val showTitleLogo: Boolean get() = snapshot.showTitleLogo
|
||||
private val _playbackStops = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||
val playbackStops = _playbackStops.asSharedFlow()
|
||||
/** Emby session reports must arrive in order; an older progress request cannot follow Stop. */
|
||||
private val playbackReportMutex = Mutex()
|
||||
private val playableMutex = Mutex()
|
||||
private val playableCache = LinkedHashMap<String, CachedPlayable>(16, 0.75f, true)
|
||||
private val playableInFlight = mutableMapOf<String, Deferred<Playable>>()
|
||||
@@ -1858,6 +1862,11 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
itemId = item.id,
|
||||
itemType = item.type,
|
||||
title = item.name,
|
||||
seriesName = when {
|
||||
item.isEpisode -> item.seriesName
|
||||
item.isSeries -> item.name
|
||||
else -> null
|
||||
},
|
||||
isSeries = item.isSeries,
|
||||
resumePositionMs = item.resumePositionMs,
|
||||
logoUrl = logoUrl(item),
|
||||
@@ -2121,6 +2130,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
return Playable(
|
||||
itemId = playback.itemId,
|
||||
title = playback.title.ifBlank { item.title },
|
||||
seriesName = playback.seriesName.ifBlank { item.seriesName },
|
||||
url = playback.url,
|
||||
resumePositionMs = playback.resumePositionMs,
|
||||
logoUrl = item.logoUrl,
|
||||
@@ -2159,6 +2169,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
return Playable(
|
||||
itemId = episode.id,
|
||||
title = title,
|
||||
seriesName = item.title,
|
||||
url = discovery.url ?: buildStreamUrl(episode.id),
|
||||
resumePositionMs = episode.resumePositionMs,
|
||||
logoUrl = item.logoUrl,
|
||||
@@ -2184,6 +2195,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
return Playable(
|
||||
itemId = item.itemId,
|
||||
title = item.title,
|
||||
seriesName = item.seriesName,
|
||||
url = discovery.url ?: buildStreamUrl(item.itemId),
|
||||
resumePositionMs = item.resumePositionMs,
|
||||
logoUrl = item.logoUrl,
|
||||
@@ -2248,12 +2260,14 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
}
|
||||
|
||||
suspend fun reportPlaybackStarted(session: PlaybackSession, positionMs: Long) {
|
||||
playbackReportMutex.withLock {
|
||||
if (ServerConfig.isGateway) {
|
||||
requireGateway().report("started", session.gatewayReport(positionMs, false, null))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
requireApi().reportPlaybackStarted(playbackReport(session, positionMs, false, null))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun reportPlaybackProgress(
|
||||
session: PlaybackSession,
|
||||
@@ -2261,19 +2275,23 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
isPaused: Boolean,
|
||||
eventName: String,
|
||||
durationMs: Long = 0L,
|
||||
): String? {
|
||||
): String? = playbackReportMutex.withLock {
|
||||
if (ServerConfig.isGateway) {
|
||||
return requireGateway().report(
|
||||
requireGateway().report(
|
||||
"progress",
|
||||
session.gatewayReport(positionMs, isPaused, eventName, durationMs),
|
||||
).autoFollowedShowTitle.takeIf(String::isNotBlank)
|
||||
}
|
||||
)
|
||||
.autoFollowedShowTitle
|
||||
.takeIf(String::isNotBlank)
|
||||
} else {
|
||||
requireApi().reportPlaybackProgress(playbackReport(session, positionMs, isPaused, eventName))
|
||||
return null
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun reportPlaybackStopped(session: PlaybackSession, positionMs: Long) {
|
||||
try {
|
||||
playbackReportMutex.withLock {
|
||||
if (ServerConfig.isGateway) {
|
||||
// Stopping is also what drops the gateway's cached rows for this user,
|
||||
// so Continue Watching reflects the new position on the next home load.
|
||||
@@ -2281,6 +2299,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
} else {
|
||||
requireApi().reportPlaybackStopped(playbackReport(session, positionMs, true, null))
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearPlayableCache()
|
||||
// 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 {
|
||||
runCatching { reportPlaybackStopped(session, positionMs) }
|
||||
.onSuccess { onSuccess() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -894,7 +894,7 @@ data class GatewayCalendar(
|
||||
val next: String = "",
|
||||
/** The household's today, only when it falls inside this month. */
|
||||
val today: String = "",
|
||||
/** Sunday is 0, matching the weekday header the grid draws. */
|
||||
/** Sunday is 0 on the wire; the TV rotates it into its Monday-first NZ week. */
|
||||
val firstWeekday: Int = 0,
|
||||
val dayCount: Int = 0,
|
||||
val days: List<GatewayCalendarDay> = emptyList(),
|
||||
|
||||
@@ -181,6 +181,17 @@ enum class BrowseDestination(val label: String, val icon: ImageVector) {
|
||||
SETTINGS("Settings", Icons.Default.Settings),
|
||||
}
|
||||
|
||||
/**
|
||||
* The user switcher is a launcher action, not a browsing destination. Keep it pinned above
|
||||
* Home while the destinations below it may change with server capabilities.
|
||||
*/
|
||||
internal fun navigationRailItems(calendarEnabled: Boolean): List<BrowseDestination> =
|
||||
listOf(BrowseDestination.PROFILES) + BrowseDestination.entries.filter {
|
||||
it != BrowseDestination.PROFILES &&
|
||||
it != BrowseDestination.SETTINGS &&
|
||||
(it != BrowseDestination.CALENDAR || calendarEnabled)
|
||||
}
|
||||
|
||||
// No NEXT_UP: those episodes are part of CONTINUE, which is one row.
|
||||
enum class MediaRowKind { CONTINUE, MOVIES, SHOWS, FAVORITES }
|
||||
|
||||
@@ -238,6 +249,7 @@ private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when {
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun TvNavigationRail(
|
||||
config: NavigationRemoteConfig = BundledRemoteConfig.value.navigation,
|
||||
@@ -358,12 +370,22 @@ fun TvNavigationRail(
|
||||
// A destination with nothing behind it is worse than one fewer: the calendar
|
||||
// needs the gateway and a Sonarr, and a household with neither would otherwise
|
||||
// carry a rail item that only ever opens an apology.
|
||||
val destinations = remember(calendarEnabled) {
|
||||
BrowseDestination.entries.filter {
|
||||
it != BrowseDestination.CALENDAR || calendarEnabled
|
||||
val destinations = remember(calendarEnabled) { navigationRailItems(calendarEnabled) }
|
||||
// Up and Down are explicit because the profile entry is an action while every
|
||||
// item beneath it changes the current destination. Leaving this to spatial
|
||||
// search allowed content behind the expanded rail to win occasionally.
|
||||
val itemFocusRequesters = remember(
|
||||
destinations,
|
||||
focusDestination,
|
||||
navigationFocusRequester,
|
||||
) {
|
||||
destinations.associateWith { destination ->
|
||||
if (destination == focusDestination) navigationFocusRequester
|
||||
else FocusRequester()
|
||||
}
|
||||
}
|
||||
destinations.forEach { destination ->
|
||||
destinations.forEachIndexed { index, destination ->
|
||||
val itemFocusRequester = itemFocusRequesters.getValue(destination)
|
||||
ExpandableNavigationItem(
|
||||
destination = destination,
|
||||
label = if (destination == BrowseDestination.PROFILES) {
|
||||
@@ -373,10 +395,19 @@ fun TvNavigationRail(
|
||||
},
|
||||
selected = destination == selected,
|
||||
expanded = expanded,
|
||||
modifier = if (destination == focusDestination) {
|
||||
Modifier.focusRequester(navigationFocusRequester)
|
||||
modifier = Modifier
|
||||
.focusRequester(itemFocusRequester)
|
||||
.focusProperties {
|
||||
up = if (index == 0) {
|
||||
FocusRequester.Cancel
|
||||
} else {
|
||||
Modifier
|
||||
itemFocusRequesters.getValue(destinations[index - 1])
|
||||
}
|
||||
down = if (index == destinations.lastIndex) {
|
||||
FocusRequester.Cancel
|
||||
} else {
|
||||
itemFocusRequesters.getValue(destinations[index + 1])
|
||||
}
|
||||
},
|
||||
onFocused = {},
|
||||
onClick = { onDestinationSelected(destination) },
|
||||
@@ -433,6 +464,7 @@ fun UserSwitcherOverlay(
|
||||
modifier: Modifier = Modifier,
|
||||
alertCount: Int = 0,
|
||||
onOpenAlerts: () -> Unit = {},
|
||||
onOpenSettings: () -> Unit = {},
|
||||
/**
|
||||
* Whether this viewer may ask the household for titles. False hides the entry entirely
|
||||
* rather than dimming it: an operator's allowlist is not something a viewer can act on,
|
||||
@@ -540,7 +572,10 @@ fun UserSwitcherOverlay(
|
||||
state = profileListState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 248.dp),
|
||||
// Settings adds one fixed action below this list. Give that row its
|
||||
// space while keeping the panel's established 400dp maximum; larger
|
||||
// households still reach every profile through the lazy list.
|
||||
.heightIn(max = 208.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
itemsIndexed(
|
||||
@@ -580,8 +615,7 @@ fun UserSwitcherOverlay(
|
||||
},
|
||||
onClick = onOpenAlerts,
|
||||
)
|
||||
// Requests sits between the two because it is news-shaped like Notifications
|
||||
// rather than administrative like Manage users, which stays last.
|
||||
// Requests sits beside Notifications because both are personal activity.
|
||||
if (showRequests) {
|
||||
UserSwitcherAction(
|
||||
label = "Requests",
|
||||
@@ -594,10 +628,21 @@ fun UserSwitcherOverlay(
|
||||
onClick = onOpenRequests,
|
||||
)
|
||||
}
|
||||
val settingsIndex = profiles.size + if (showRequests) 2 else 1
|
||||
UserSwitcherAction(
|
||||
label = "Settings",
|
||||
icon = Icons.Default.Settings,
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequesters[settingsIndex])
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) focusedIndex = settingsIndex
|
||||
},
|
||||
onClick = onOpenSettings,
|
||||
)
|
||||
val manageIndex = profiles.size + actionCount - 1
|
||||
UserSwitcherAction(
|
||||
label = "Manage users",
|
||||
icon = Icons.Default.Settings,
|
||||
icon = Icons.Default.Person,
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequesters[manageIndex])
|
||||
.onFocusChanged {
|
||||
@@ -610,13 +655,13 @@ fun UserSwitcherOverlay(
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifications, then Requests when this viewer may make them, then Manage users.
|
||||
* Notifications, then Requests when this viewer may make them, Settings, then Manage users.
|
||||
*
|
||||
* Pure and derived in one place because three things read it — the requester list's length,
|
||||
* the D-pad's lower bound and Manage users' own index — and a count that disagreed with the
|
||||
* rows actually drawn is how the last item in a menu becomes unreachable.
|
||||
*/
|
||||
internal fun userSwitcherActionCount(showRequests: Boolean): Int = if (showRequests) 3 else 2
|
||||
internal fun userSwitcherActionCount(showRequests: Boolean): Int = if (showRequests) 4 else 3
|
||||
|
||||
@Composable
|
||||
private fun UserSwitcherProfileItem(
|
||||
|
||||
@@ -2191,6 +2191,8 @@ private fun HomeScreen(
|
||||
context = context,
|
||||
request = request,
|
||||
posterUrl = repo.primaryUrl(item, maxWidth = 500),
|
||||
backdropUrl = repo.backdropUrl(item, maxWidth = 1920)
|
||||
?: repo.primaryUrl(item, maxWidth = 1920),
|
||||
requestStartedAtMs = playbackRequestedAtMs,
|
||||
),
|
||||
)
|
||||
@@ -2239,6 +2241,9 @@ private fun HomeScreen(
|
||||
title = playable.title,
|
||||
resumePositionMs = playable.resumePositionMs,
|
||||
logoUrl = playable.logoUrl,
|
||||
backdropUrl = repo.backdropUrl(item, maxWidth = 1920)
|
||||
?: repo.primaryUrl(item, maxWidth = 1920),
|
||||
seriesName = playable.seriesName,
|
||||
overview = playable.overview ?: item.overview,
|
||||
episodeCode = playable.episodeCode,
|
||||
runtimeMs = playable.runtimeMs,
|
||||
@@ -3175,6 +3180,18 @@ private fun HomeScreen(
|
||||
showProfiles = true
|
||||
},
|
||||
alertCount = displayedNotifications.size,
|
||||
onOpenSettings = {
|
||||
homeViewModel.trackJourney(
|
||||
category = "navigation", action = "select",
|
||||
screen = journeyScreen, feature = "settings",
|
||||
source = journeyScreen, target = "settings",
|
||||
)
|
||||
userSwitcherVisible = false
|
||||
navigationExpanded = false
|
||||
railFocusDestination = BrowseDestination.PROFILES
|
||||
restoreRailAfterSettings = true
|
||||
showSettings = true
|
||||
},
|
||||
onOpenAlerts = {
|
||||
homeViewModel.trackJourney(
|
||||
category = "notifications", action = "open", screen = journeyScreen,
|
||||
|
||||
@@ -3,9 +3,9 @@ package com.ponzischeme89.memby.ui
|
||||
internal enum class UserSwitcherDirection { UP, DOWN }
|
||||
|
||||
/**
|
||||
* Profiles occupy [0, profileCount); the pinned actions — Notifications, then Manage users —
|
||||
* follow them in order. Keeping this arithmetic outside Compose makes remote navigation
|
||||
* deterministic.
|
||||
* Profiles occupy [0, profileCount); the pinned actions — Notifications, optional Requests,
|
||||
* Settings, then Manage users — follow them in order. Keeping this arithmetic outside
|
||||
* Compose makes remote navigation deterministic.
|
||||
*/
|
||||
internal fun userSwitcherInitialIndex(
|
||||
profileIds: List<String>,
|
||||
|
||||
@@ -31,8 +31,8 @@ data class CalendarCell(
|
||||
|
||||
const val CALENDAR_COLUMNS = 7
|
||||
|
||||
/** Sunday first, matching the `firstWeekday` the gateway sends. */
|
||||
val CALENDAR_WEEKDAYS = listOf("Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat")
|
||||
/** New Zealand week order. The gateway's `firstWeekday` remains Sunday-based on the wire. */
|
||||
val CALENDAR_WEEKDAYS = listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
|
||||
|
||||
data class CalendarAgendaWeek(
|
||||
val cells: List<CalendarCell>,
|
||||
@@ -73,7 +73,8 @@ fun calendarAgendaWeekDate(week: CalendarAgendaWeek): String =
|
||||
fun calendarWeeks(calendar: GatewayCalendar): List<List<CalendarCell>> {
|
||||
val dayCount = calendar.dayCount.coerceIn(0, 31)
|
||||
if (dayCount == 0) return emptyList()
|
||||
val leading = calendar.firstWeekday.coerceIn(0, CALENDAR_COLUMNS - 1)
|
||||
// The wire uses Sunday = 0. Rotate that offset into the Monday-first grid shown on TV.
|
||||
val leading = (calendar.firstWeekday.coerceIn(0, CALENDAR_COLUMNS - 1) + 6) % CALENDAR_COLUMNS
|
||||
val byDay = calendar.days.associateBy { it.day }
|
||||
|
||||
val cells = ArrayList<CalendarCell>(leading + dayCount)
|
||||
|
||||
@@ -24,6 +24,13 @@ class PlaybackStopWorker(
|
||||
ServiceLocator.init(applicationContext)
|
||||
val itemId = inputData.getString(ITEM_ID).orEmpty()
|
||||
if (itemId.isBlank()) return Result.failure()
|
||||
if (!playbackStopIsFresh(
|
||||
enqueuedAtMs = inputData.getLong(ENQUEUED_AT_MS, 0L),
|
||||
nowMs = System.currentTimeMillis(),
|
||||
)
|
||||
) {
|
||||
return Result.success()
|
||||
}
|
||||
val session = PlaybackSession(
|
||||
itemId = itemId,
|
||||
mediaSourceId = inputData.getString(MEDIA_SOURCE_ID).orEmpty().ifBlank { itemId },
|
||||
@@ -47,6 +54,7 @@ class PlaybackStopWorker(
|
||||
private const val PLAY_SESSION_ID = "play_session_id"
|
||||
private const val PLAY_METHOD = "play_method"
|
||||
private const val POSITION_MS = "position_ms"
|
||||
private const val ENQUEUED_AT_MS = "enqueued_at_ms"
|
||||
private const val MAX_RETRIES = 5
|
||||
|
||||
private fun workName(session: PlaybackSession): String =
|
||||
@@ -59,15 +67,23 @@ class PlaybackStopWorker(
|
||||
.putString(PLAY_SESSION_ID, session.playSessionId)
|
||||
.putString(PLAY_METHOD, session.playMethod)
|
||||
.putLong(POSITION_MS, positionMs.coerceAtLeast(0L))
|
||||
.putLong(ENQUEUED_AT_MS, System.currentTimeMillis())
|
||||
.build()
|
||||
val request = OneTimeWorkRequestBuilder<PlaybackStopWorker>()
|
||||
.setInputData(data)
|
||||
.build()
|
||||
WorkManager.getInstance(context.applicationContext).enqueueUniqueWork(
|
||||
val workManager = WorkManager.getInstance(context.applicationContext)
|
||||
workManager.enqueueUniqueWork(
|
||||
workName(session),
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
request,
|
||||
)
|
||||
// WorkManager is the process-death fallback, not the ordinary delivery path.
|
||||
// Send now from the repository's process scope, which survives Activity
|
||||
// destruction, then cancel this exact fallback request once Emby accepts it.
|
||||
ServiceLocator.repository.enqueuePlaybackStopped(session, positionMs) {
|
||||
workManager.cancelWorkById(request.id)
|
||||
}
|
||||
}
|
||||
|
||||
/** A resumed session must not be stopped later by work queued while backgrounded. */
|
||||
@@ -79,3 +95,10 @@ class PlaybackStopWorker(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A late retry must not overwrite progress made after the viewer moved to another client. */
|
||||
internal fun playbackStopIsFresh(
|
||||
enqueuedAtMs: Long,
|
||||
nowMs: Long,
|
||||
maxAgeMs: Long = 60_000L,
|
||||
): Boolean = enqueuedAtMs > 0L && nowMs >= enqueuedAtMs && nowMs - enqueuedAtMs <= maxAgeMs
|
||||
|
||||
@@ -124,6 +124,21 @@ internal fun passthroughOsdSummary(preference: AudioPassthroughPreference): Stri
|
||||
else -> "${preference.codecs.size} formats"
|
||||
}
|
||||
|
||||
/** Episode wording for the station ident, separate from the logo/fallback presentation. */
|
||||
internal fun playbackIdentityEpisodeLabel(
|
||||
title: String,
|
||||
seriesName: String?,
|
||||
episodeCode: String?,
|
||||
): String? {
|
||||
val code = episodeCode?.trim().orEmpty()
|
||||
if (code.isEmpty()) return null
|
||||
val episodeTitle = title.trim()
|
||||
.removePrefix(seriesName?.trim().orEmpty() + " – ")
|
||||
.trim()
|
||||
.takeUnless { it.isEmpty() || it == seriesName?.trim() }
|
||||
return listOfNotNull(code, episodeTitle).joinToString(" · ")
|
||||
}
|
||||
|
||||
/**
|
||||
* Fullscreen Media3 player with native stream-track selection. Press Menu while
|
||||
* playing to choose an audio or subtitle track; the subtitle controller button
|
||||
@@ -151,6 +166,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
private var remainingView: TextView? = null
|
||||
private var finishTimeView: TextView? = null
|
||||
private var loadingView: View? = null
|
||||
private var loadingBackdropView: ImageView? = null
|
||||
private var loadingTitleView: TextView? = null
|
||||
private var loadingHintView: TextView? = null
|
||||
private val playbackLoadingQuote: String by lazy {
|
||||
@@ -197,6 +213,8 @@ class PlayerActivity : ComponentActivity() {
|
||||
private var launchTraceCookie = NO_TRACE
|
||||
private var firstFrameTraceCookie = NO_TRACE
|
||||
private var logoUrl: String? = null
|
||||
private var loadingBackdropUrl: String? = null
|
||||
private var playbackSeriesName: String? = null
|
||||
private var playbackTitle = ""
|
||||
private var pauseOverview = ""
|
||||
private var prerollEpisodeCode = ""
|
||||
@@ -306,6 +324,8 @@ class PlayerActivity : ComponentActivity() {
|
||||
private var previewResumeDurationMs = 0L
|
||||
private var previewResumeTitle = ""
|
||||
private var previewResumeLogoUrl: String? = null
|
||||
private var previewResumeBackdropUrl: String? = null
|
||||
private var previewResumeSeriesName: String? = null
|
||||
private var previewResumePosterUrl: String? = null
|
||||
private var previewResumeOverview = ""
|
||||
private var previewResumePlaybackStarted = false
|
||||
@@ -548,6 +568,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
bindScrubPreview(view)
|
||||
applyPictureMode()
|
||||
loadingView = findViewById(R.id.playback_loading)
|
||||
loadingBackdropView = findViewById(R.id.playback_loading_backdrop)
|
||||
loadingTitleView = findViewById(R.id.playback_loading_title)
|
||||
loadingHintView = findViewById<TextView>(R.id.playback_loading_hint).also {
|
||||
// Set this as soon as the layout is mounted so the XML fallback never flashes
|
||||
@@ -572,6 +593,10 @@ class PlayerActivity : ComponentActivity() {
|
||||
streamStatusView = view.findViewById(R.id.player_stream_status)
|
||||
logoUrl = savedInstanceState?.getString(STATE_LOGO_URL)
|
||||
?: intent.getStringExtra(EXTRA_LOGO_URL)
|
||||
loadingBackdropUrl = savedInstanceState?.getString(STATE_BACKDROP_URL)
|
||||
?: intent.getStringExtra(EXTRA_BACKDROP_URL)
|
||||
playbackSeriesName = savedInstanceState?.getString(STATE_SERIES_NAME)
|
||||
?: intent.getStringExtra(EXTRA_SERIES_NAME)
|
||||
playbackTitle = savedInstanceState?.getString(STATE_TITLE)
|
||||
?: intent.getStringExtra(EXTRA_TITLE).orEmpty()
|
||||
pauseOverview = savedInstanceState?.getString(STATE_OVERVIEW)
|
||||
@@ -585,6 +610,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
}
|
||||
pausePosterUrl = savedInstanceState?.getString(STATE_POSTER_URL)
|
||||
?: intent.getStringExtra(EXTRA_POSTER_URL)
|
||||
bindLoadingBackdrop(loadingBackdropUrl)
|
||||
if (showPreroll) startPreroll() else startWithoutPreroll()
|
||||
setUpPlaybackError()
|
||||
|
||||
@@ -789,7 +815,12 @@ class PlayerActivity : ComponentActivity() {
|
||||
// --- Decoration -------------------------------------------------------------
|
||||
bindTitleArtwork(title = playbackTitle, logoUrl = logoUrl)
|
||||
bindPauseOverlay(view)
|
||||
setUpPlaybackIdentity(title = playbackTitle)
|
||||
setUpPlaybackIdentity(
|
||||
title = playbackTitle,
|
||||
seriesName = playbackSeriesName,
|
||||
episodeCode = prerollEpisodeCode,
|
||||
logoUrl = logoUrl,
|
||||
)
|
||||
setUpSubtitleOverlay()
|
||||
setUpCastOverlay()
|
||||
setUpNextUpBanner()
|
||||
@@ -882,7 +913,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
playMethod = playable.playMethod
|
||||
playbackTitle = playable.title.ifBlank { request.title + " trailer" }
|
||||
bindTitleArtwork(playbackTitle, logoUrl)
|
||||
setUpPlaybackIdentity(playbackTitle)
|
||||
setUpPlaybackIdentity(playbackTitle, null, null, logoUrl)
|
||||
startMedia(playable.url, emptyList(), 0L, playWhenReady = true)
|
||||
}
|
||||
.onFailure { error ->
|
||||
@@ -1126,15 +1157,21 @@ class PlayerActivity : ComponentActivity() {
|
||||
playable.overview?.takeIf(String::isNotBlank)?.let { pauseOverview = it }
|
||||
playable.episodeCode?.takeIf(String::isNotBlank)?.let { prerollEpisodeCode = it }
|
||||
playable.logoUrl?.takeIf(String::isNotBlank)?.let { logoUrl = it }
|
||||
playable.seriesName?.takeIf(String::isNotBlank)?.let { playbackSeriesName = it }
|
||||
Log.i(
|
||||
PLAYBACK_LOG_TAG,
|
||||
"event=subtitle_configs item=${playable.itemId} " +
|
||||
"count=${playable.subtitles.size} source=launch",
|
||||
)
|
||||
if (playable.title.isBlank() || playable.title == playbackTitle) return
|
||||
playbackTitle = playable.title
|
||||
if (playable.title.isNotBlank()) playbackTitle = playable.title
|
||||
bindTitleArtwork(title = playbackTitle, logoUrl = logoUrl)
|
||||
setUpPlaybackIdentity(title = playbackTitle)
|
||||
if (prerollActive) bindPrerollIdentity()
|
||||
setUpPlaybackIdentity(
|
||||
title = playbackTitle,
|
||||
seriesName = playbackSeriesName,
|
||||
episodeCode = prerollEpisodeCode,
|
||||
logoUrl = logoUrl,
|
||||
)
|
||||
pauseOverlay?.findViewById<TextView>(R.id.player_pause_title)?.text = playbackTitle
|
||||
}
|
||||
|
||||
@@ -1151,6 +1188,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
hideController()
|
||||
}
|
||||
if (!attachLocalPreroll()) enterPrerollVideoFrame()
|
||||
bindPrerollIdentity()
|
||||
bindPrerollNow()
|
||||
bindPrerollSchedule(GatewayPrerollSchedule(), loading = true)
|
||||
prerollScheduleJob = lifecycleScope.launch {
|
||||
@@ -1519,6 +1557,52 @@ class PlayerActivity : ComponentActivity() {
|
||||
pauseOverview.ifBlank { "Memby is preparing this episode for playback." }
|
||||
}
|
||||
|
||||
private fun bindPrerollIdentity() {
|
||||
val logo = findViewById<ImageView>(R.id.player_preroll_identity_logo)
|
||||
val fallback = findViewById<TextView>(R.id.player_preroll_identity_title)
|
||||
val episode = findViewById<TextView>(R.id.player_preroll_identity_episode)
|
||||
val episodeLabel = playbackIdentityEpisodeLabel(
|
||||
playbackTitle,
|
||||
playbackSeriesName,
|
||||
prerollEpisodeCode,
|
||||
)
|
||||
episode.text = episodeLabel.orEmpty()
|
||||
episode.visibility = if (episodeLabel == null) View.GONE else View.VISIBLE
|
||||
|
||||
// Films retain the Memby station mark. An episode is introduced by the programme
|
||||
// itself, with its series name as the honest fallback when Emby has no logo.
|
||||
if (episodeLabel == null) {
|
||||
logo.clearColorFilter()
|
||||
logo.setImageResource(R.drawable.emby_logo)
|
||||
logo.visibility = View.VISIBLE
|
||||
fallback.visibility = View.GONE
|
||||
return
|
||||
}
|
||||
fallback.text = playbackSeriesName?.takeIf(String::isNotBlank)
|
||||
?: playbackTitle.substringBefore(" – ").ifBlank { "Now playing" }
|
||||
if (logoUrl.isNullOrBlank()) {
|
||||
logo.clearColorFilter()
|
||||
logo.setImageDrawable(null)
|
||||
logo.visibility = View.GONE
|
||||
fallback.visibility = View.VISIBLE
|
||||
return
|
||||
}
|
||||
logo.load(logoUrl) {
|
||||
crossfade(false)
|
||||
listener(
|
||||
onSuccess = { _, result ->
|
||||
makeLogoVisibleOnDarkBackground(logo, result.drawable)
|
||||
logo.visibility = View.VISIBLE
|
||||
fallback.visibility = View.GONE
|
||||
},
|
||||
onError = { _, _ ->
|
||||
logo.visibility = View.GONE
|
||||
fallback.visibility = View.VISIBLE
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun prerollEntry(entry: GatewayPrerollEntry, label: String): View =
|
||||
LayoutInflater.from(this).inflate(R.layout.player_preroll_schedule_card, null).apply {
|
||||
findViewById<TextView>(R.id.player_preroll_card_label).text = label
|
||||
@@ -2015,11 +2099,52 @@ class PlayerActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun setUpPlaybackIdentity(title: String) {
|
||||
private fun bindLoadingBackdrop(url: String?) {
|
||||
val backdrop = loadingBackdropView ?: return
|
||||
if (url.isNullOrBlank()) {
|
||||
backdrop.setImageDrawable(null)
|
||||
return
|
||||
}
|
||||
backdrop.load(url) {
|
||||
crossfade(false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setUpPlaybackIdentity(
|
||||
title: String,
|
||||
seriesName: String?,
|
||||
episodeCode: String?,
|
||||
logoUrl: String?,
|
||||
) {
|
||||
playbackIdentityView = findViewById(R.id.player_playback_identity)
|
||||
findViewById<TextView>(R.id.player_playback_identity_title).apply {
|
||||
text = title.ifBlank { "Now playing" }
|
||||
visibility = View.VISIBLE
|
||||
val logo = findViewById<ImageView>(R.id.player_playback_identity_logo)
|
||||
val fallback = findViewById<TextView>(R.id.player_playback_identity_title).apply {
|
||||
text = seriesName?.takeIf(String::isNotBlank) ?: title.ifBlank { "Now playing" }
|
||||
}
|
||||
findViewById<TextView>(R.id.player_playback_identity_episode).apply {
|
||||
text = playbackIdentityEpisodeLabel(title, seriesName, episodeCode).orEmpty()
|
||||
visibility = if (text.isNullOrBlank()) View.GONE else View.VISIBLE
|
||||
}
|
||||
if (logoUrl.isNullOrBlank()) {
|
||||
logo.clearColorFilter()
|
||||
logo.setImageDrawable(null)
|
||||
logo.visibility = View.GONE
|
||||
fallback.visibility = View.VISIBLE
|
||||
return
|
||||
}
|
||||
logo.load(logoUrl) {
|
||||
crossfade(false)
|
||||
listener(
|
||||
onSuccess = { _, result ->
|
||||
makeLogoVisibleOnDarkBackground(logo, result.drawable)
|
||||
logo.visibility = View.VISIBLE
|
||||
fallback.visibility = View.GONE
|
||||
},
|
||||
onError = { _, _ ->
|
||||
logo.visibility = View.GONE
|
||||
fallback.visibility = View.VISIBLE
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2930,6 +3055,8 @@ class PlayerActivity : ComponentActivity() {
|
||||
previewResumeDurationMs = playback.duration.coerceAtLeast(previewResumePositionMs)
|
||||
previewResumeTitle = playbackTitle
|
||||
previewResumeLogoUrl = logoUrl
|
||||
previewResumeBackdropUrl = loadingBackdropUrl
|
||||
previewResumeSeriesName = playbackSeriesName
|
||||
previewResumePosterUrl = pausePosterUrl
|
||||
previewResumeOverview = pauseOverview
|
||||
previewResumePlaybackStarted = playbackStarted
|
||||
@@ -2943,11 +3070,14 @@ class PlayerActivity : ComponentActivity() {
|
||||
hideNextUp()
|
||||
if (creditsActive) leaveEndCredits(restoreSpeed = true)
|
||||
playbackTitle = "Next: ${nextTitle(next)}"
|
||||
playbackSeriesName = next.seriesName
|
||||
logoUrl = next.logoUrl
|
||||
loadingBackdropUrl = next.imageUrl
|
||||
bindLoadingBackdrop(loadingBackdropUrl)
|
||||
pausePosterUrl = next.imageUrl
|
||||
pauseOverview = next.overview
|
||||
bindTitleArtwork(playbackTitle, logoUrl)
|
||||
setUpPlaybackIdentity(playbackTitle)
|
||||
setUpPlaybackIdentity(playbackTitle, playbackSeriesName, next.episodeCode, logoUrl)
|
||||
renderedFirstFrame = false
|
||||
showPlaybackLoading(title = "Finding the next episode…", hint = "Starting recap or preview")
|
||||
startMedia(preview.url, emptyList(), 0L, playWhenReady = true)
|
||||
@@ -2979,12 +3109,20 @@ class PlayerActivity : ComponentActivity() {
|
||||
}
|
||||
playbackTitle = previewResumeTitle
|
||||
logoUrl = previewResumeLogoUrl
|
||||
loadingBackdropUrl = previewResumeBackdropUrl
|
||||
playbackSeriesName = previewResumeSeriesName
|
||||
bindLoadingBackdrop(loadingBackdropUrl)
|
||||
pausePosterUrl = previewResumePosterUrl
|
||||
pauseOverview = previewResumeOverview
|
||||
playbackStarted = previewResumePlaybackStarted
|
||||
stopReported = previewResumeStopReported
|
||||
bindTitleArtwork(playbackTitle, logoUrl)
|
||||
setUpPlaybackIdentity(playbackTitle)
|
||||
setUpPlaybackIdentity(
|
||||
playbackTitle,
|
||||
playbackSeriesName,
|
||||
prerollEpisodeCode,
|
||||
logoUrl,
|
||||
)
|
||||
renderedFirstFrame = false
|
||||
if (previewResumeUrl.isBlank()) {
|
||||
// The original stream should always be known, but losing the optional preview
|
||||
@@ -3555,13 +3693,21 @@ class PlayerActivity : ComponentActivity() {
|
||||
// The cast reloads with the rest of the new episode's session, once it is playing.
|
||||
|
||||
playbackTitle = nextTitle(next)
|
||||
playbackSeriesName = next.seriesName
|
||||
logoUrl = next.logoUrl
|
||||
loadingBackdropUrl = next.imageUrl
|
||||
bindLoadingBackdrop(loadingBackdropUrl)
|
||||
pausePosterUrl = next.imageUrl
|
||||
pauseOverview = next.overview
|
||||
prerollEpisodeCode = next.episodeCode.orEmpty()
|
||||
prerollRuntimeMs = next.runtimeMs.coerceAtLeast(0L)
|
||||
bindTitleArtwork(title = playbackTitle, logoUrl = logoUrl)
|
||||
setUpPlaybackIdentity(title = playbackTitle)
|
||||
setUpPlaybackIdentity(
|
||||
title = playbackTitle,
|
||||
seriesName = playbackSeriesName,
|
||||
episodeCode = prerollEpisodeCode,
|
||||
logoUrl = logoUrl,
|
||||
)
|
||||
pauseOverlay?.findViewById<TextView>(R.id.player_pause_title)?.text = playbackTitle
|
||||
pauseOverlay?.findViewById<TextView>(R.id.player_pause_overview)?.text =
|
||||
pauseOverview.ifBlank { getString(R.string.player_pause_overview_fallback) }
|
||||
@@ -4588,6 +4734,14 @@ class PlayerActivity : ComponentActivity() {
|
||||
outState.putBoolean(STATE_END_CREDITS, endCreditsAvailable)
|
||||
outState.putString(STATE_TITLE, if (savingPreview) previewResumeTitle else playbackTitle)
|
||||
outState.putString(STATE_LOGO_URL, if (savingPreview) previewResumeLogoUrl else logoUrl)
|
||||
outState.putString(
|
||||
STATE_BACKDROP_URL,
|
||||
if (savingPreview) previewResumeBackdropUrl else loadingBackdropUrl,
|
||||
)
|
||||
outState.putString(
|
||||
STATE_SERIES_NAME,
|
||||
if (savingPreview) previewResumeSeriesName else playbackSeriesName,
|
||||
)
|
||||
outState.putString(STATE_OVERVIEW, if (savingPreview) previewResumeOverview else pauseOverview)
|
||||
outState.putString(STATE_POSTER_URL, if (savingPreview) previewResumePosterUrl else pausePosterUrl)
|
||||
outState.putString(STATE_EPISODE_CODE, prerollEpisodeCode)
|
||||
@@ -4878,6 +5032,8 @@ class PlayerActivity : ComponentActivity() {
|
||||
private const val EXTRA_TITLE = "extra_title"
|
||||
private const val EXTRA_RESUME_POSITION_MS = "extra_resume_position_ms"
|
||||
private const val EXTRA_LOGO_URL = "extra_logo_url"
|
||||
private const val EXTRA_BACKDROP_URL = "extra_backdrop_url"
|
||||
private const val EXTRA_SERIES_NAME = "extra_series_name"
|
||||
private const val EXTRA_OVERVIEW = "extra_overview"
|
||||
private const val EXTRA_EPISODE_CODE = "extra_episode_code"
|
||||
private const val EXTRA_RUNTIME_MS = "extra_runtime_ms"
|
||||
@@ -4922,6 +5078,8 @@ class PlayerActivity : ComponentActivity() {
|
||||
private const val STATE_END_CREDITS = "state_end_credits"
|
||||
private const val STATE_TITLE = "state_title"
|
||||
private const val STATE_LOGO_URL = "state_logo_url"
|
||||
private const val STATE_BACKDROP_URL = "state_backdrop_url"
|
||||
private const val STATE_SERIES_NAME = "state_series_name"
|
||||
private const val STATE_OVERVIEW = "state_overview"
|
||||
private const val STATE_POSTER_URL = "state_poster_url"
|
||||
private const val STATE_EPISODE_CODE = "state_episode_code"
|
||||
@@ -4951,6 +5109,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
context: Context,
|
||||
request: PlaybackRequest,
|
||||
posterUrl: String? = null,
|
||||
backdropUrl: String? = null,
|
||||
requestStartedAtMs: Long = SystemClock.elapsedRealtime(),
|
||||
): Intent = Intent(context, PlayerActivity::class.java).apply {
|
||||
putExtra(EXTRA_PLAYBACK_REQUEST, playerJson.encodeToString(request))
|
||||
@@ -4958,10 +5117,12 @@ class PlayerActivity : ComponentActivity() {
|
||||
putExtra(EXTRA_TITLE, request.title)
|
||||
putExtra(EXTRA_RESUME_POSITION_MS, request.resumePositionMs)
|
||||
request.logoUrl?.let { putExtra(EXTRA_LOGO_URL, it) }
|
||||
request.seriesName?.takeIf(String::isNotBlank)?.let { putExtra(EXTRA_SERIES_NAME, it) }
|
||||
request.overview?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_OVERVIEW, it) }
|
||||
request.episodeCode?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_EPISODE_CODE, it) }
|
||||
putExtra(EXTRA_RUNTIME_MS, request.runtimeMs.coerceAtLeast(0L))
|
||||
posterUrl?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_POSTER_URL, it) }
|
||||
backdropUrl?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_BACKDROP_URL, it) }
|
||||
putExtra(EXTRA_REQUEST_STARTED_AT_MS, requestStartedAtMs)
|
||||
}
|
||||
|
||||
@@ -4994,6 +5155,8 @@ class PlayerActivity : ComponentActivity() {
|
||||
title: String?,
|
||||
resumePositionMs: Long = 0L,
|
||||
logoUrl: String? = null,
|
||||
backdropUrl: String? = null,
|
||||
seriesName: String? = null,
|
||||
overview: String? = null,
|
||||
episodeCode: String? = null,
|
||||
runtimeMs: Long = 0L,
|
||||
@@ -5018,6 +5181,8 @@ class PlayerActivity : ComponentActivity() {
|
||||
putExtra(EXTRA_TITLE, title)
|
||||
putExtra(EXTRA_RESUME_POSITION_MS, resumePositionMs)
|
||||
logoUrl?.let { putExtra(EXTRA_LOGO_URL, it) }
|
||||
backdropUrl?.takeIf(String::isNotBlank)?.let { putExtra(EXTRA_BACKDROP_URL, it) }
|
||||
seriesName?.takeIf(String::isNotBlank)?.let { putExtra(EXTRA_SERIES_NAME, it) }
|
||||
overview?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_OVERVIEW, it) }
|
||||
episodeCode?.takeIf { it.isNotBlank() }?.let { putExtra(EXTRA_EPISODE_CODE, it) }
|
||||
putExtra(EXTRA_RUNTIME_MS, runtimeMs.coerceAtLeast(0L))
|
||||
|
||||
@@ -377,7 +377,7 @@ private fun MyRequestsPane(
|
||||
RequestsNotice(
|
||||
icon = Icons.Default.Inbox,
|
||||
heading = "You have not asked for anything yet",
|
||||
body = "Find a film or series and it will show up here while the household gets hold of it.",
|
||||
body = "Find a film or series and it will show up here while it is added to your library.",
|
||||
action = "Request something",
|
||||
onAction = onBrowse,
|
||||
actionFocusRequester = paneFocusRequester,
|
||||
@@ -686,7 +686,7 @@ private fun CandidatesPane(
|
||||
RequestsSearchPhase.NO_MATCHES ->
|
||||
"Nothing matched “${state.searchedTerm ?: state.query.trim()}”. " +
|
||||
"Check the spelling, or try the original title."
|
||||
else -> "Type a name and press Search to find something the household does not have yet."
|
||||
else -> "Type a name and press Search to find something that is not in your library yet."
|
||||
},
|
||||
)
|
||||
return@Column
|
||||
@@ -743,7 +743,7 @@ private fun CandidatesPane(
|
||||
private fun candidateDetail(candidate: GatewayRequestCandidate): String = when (candidate.status) {
|
||||
RequestStatus.AVAILABLE -> "Already in your library"
|
||||
RequestStatus.REQUESTED -> "You have already asked for this"
|
||||
RequestStatus.PROCESSING -> "The household is already getting this"
|
||||
RequestStatus.PROCESSING -> "Already being added to your library"
|
||||
RequestStatus.PENDING -> "Already tracked, not out yet"
|
||||
RequestStatus.REQUESTABLE -> "Press to request"
|
||||
else -> candidate.statusLabel.ifBlank { "Press to request" }
|
||||
|
||||
@@ -67,42 +67,7 @@
|
||||
android:layout_gravity="top"
|
||||
android:focusable="false" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/playback_loading"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#F2050708"
|
||||
android:focusable="false"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/playback_loading_logo"
|
||||
android:layout_width="82dp"
|
||||
android:layout_height="70dp"
|
||||
android:contentDescription="@string/playback_loading"
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/emby_logo" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/playback_loading_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="18dp"
|
||||
android:text="@string/playback_loading"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/playback_loading_hint"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="7dp"
|
||||
android:text="@string/playback_loading_hint"
|
||||
android:textColor="#FF929AA0"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
<include layout="@layout/player_loading" />
|
||||
|
||||
<!-- The existing PlayerView is temporarily hosted inside this overlay, then returned
|
||||
here at full-screen size after the countdown. No second player or stream is used. -->
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The title's backdrop keeps the loading state connected to what the viewer selected.
|
||||
The dark wash protects the small status copy without hiding the artwork again. -->
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/playback_loading"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#FF050708"
|
||||
android:focusable="false">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/playback_loading_backdrop"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:alpha="0.58"
|
||||
android:contentDescription="@null"
|
||||
android:scaleType="centerCrop" />
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#8A050708" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/playback_loading_logo"
|
||||
android:layout_width="82dp"
|
||||
android:layout_height="70dp"
|
||||
android:contentDescription="@string/playback_loading"
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/emby_logo" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/playback_loading_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="18dp"
|
||||
android:shadowColor="#E0000000"
|
||||
android:shadowDx="0"
|
||||
android:shadowDy="2"
|
||||
android:shadowRadius="5"
|
||||
android:text="@string/playback_loading"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/playback_loading_hint"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="7dp"
|
||||
android:shadowColor="#E0000000"
|
||||
android:shadowDx="0"
|
||||
android:shadowDy="2"
|
||||
android:shadowRadius="5"
|
||||
android:text="@string/playback_loading_hint"
|
||||
android:textColor="#FFD5DADD"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
@@ -1,39 +1,56 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- A short, non-focusable station ident shown over the first five seconds of content. -->
|
||||
<!-- A short, non-focusable station ident shown over the first five seconds of content.
|
||||
For television, the programme logo leads and the episode sits directly beneath it. -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/player_playback_identity"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="54dp"
|
||||
android:layout_width="560dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="start|top"
|
||||
android:layout_marginStart="48dp"
|
||||
android:layout_marginTop="34dp"
|
||||
android:alpha="0"
|
||||
android:focusable="false"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="start"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="42dp"
|
||||
android:layout_height="38dp"
|
||||
android:contentDescription="@string/player_preroll_brand_logo"
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/emby_logo" />
|
||||
android:id="@+id/player_playback_identity_logo"
|
||||
android:layout_width="300dp"
|
||||
android:layout_height="82dp"
|
||||
android:adjustViewBounds="true"
|
||||
android:contentDescription="@string/player_title_logo"
|
||||
android:scaleType="fitStart"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_playback_identity_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginStart="14dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:gravity="start|center_vertical"
|
||||
android:maxLines="1"
|
||||
android:maxWidth="520dp"
|
||||
android:maxWidth="540dp"
|
||||
android:shadowColor="#E0000000"
|
||||
android:shadowDx="0"
|
||||
android:shadowDy="2"
|
||||
android:shadowRadius="5"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="25sp"
|
||||
android:textSize="26sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_playback_identity_episode"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="7dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:maxWidth="540dp"
|
||||
android:shadowColor="#E0000000"
|
||||
android:shadowDx="0"
|
||||
android:shadowDy="2"
|
||||
android:shadowRadius="5"
|
||||
android:textColor="#E6FFFFFF"
|
||||
android:textSize="19sp"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
|
||||
@@ -9,17 +9,48 @@
|
||||
android:focusable="false"
|
||||
android:visibility="gone">
|
||||
|
||||
<ImageView
|
||||
<LinearLayout
|
||||
android:id="@+id/player_preroll_brand"
|
||||
android:layout_width="58dp"
|
||||
android:layout_height="50dp"
|
||||
android:layout_width="300dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="start|top"
|
||||
android:layout_marginStart="44dp"
|
||||
android:layout_marginTop="28dp"
|
||||
android:contentDescription="@string/player_preroll_brand_logo"
|
||||
android:scaleType="fitCenter"
|
||||
android:layout_marginTop="20dp"
|
||||
android:gravity="start"
|
||||
android:orientation="vertical">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/player_preroll_identity_logo"
|
||||
android:layout_width="220dp"
|
||||
android:layout_height="44dp"
|
||||
android:contentDescription="@string/player_title_logo"
|
||||
android:scaleType="fitStart"
|
||||
android:src="@drawable/emby_logo" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_preroll_identity_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_preroll_identity_episode"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="#FFD0D6DB"
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/player_preroll_content"
|
||||
android:layout_width="match_parent"
|
||||
@@ -28,7 +59,7 @@
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="44dp"
|
||||
android:paddingTop="84dp"
|
||||
android:paddingTop="92dp"
|
||||
android:paddingEnd="44dp"
|
||||
android:paddingBottom="22dp">
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.GatewayCalendar
|
||||
import com.ponzischeme89.memby.data.model.GatewayCalendarDay
|
||||
import com.ponzischeme89.memby.ui.calendar.CALENDAR_COLUMNS
|
||||
import com.ponzischeme89.memby.ui.calendar.CALENDAR_WEEKDAYS
|
||||
import com.ponzischeme89.memby.ui.calendar.CalendarUiState
|
||||
import com.ponzischeme89.memby.ui.calendar.calendarDate
|
||||
import com.ponzischeme89.memby.ui.calendar.calendarAgendaWeekDate
|
||||
@@ -40,11 +41,13 @@ class CalendarGridTest {
|
||||
@Test
|
||||
fun `the grid pads to whole weeks`() {
|
||||
val weeks = calendarWeeks(august())
|
||||
// Six leading blanks plus thirty-one days is thirty-seven cells: six rows of seven.
|
||||
// A Saturday start has five leading blanks in a Monday-first NZ week.
|
||||
assertEquals(6, weeks.size)
|
||||
assertTrue(weeks.all { it.size == CALENDAR_COLUMNS })
|
||||
assertTrue(weeks.first().take(6).all { it.isPad })
|
||||
assertEquals(1, weeks.first().last().day)
|
||||
assertEquals(listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"), CALENDAR_WEEKDAYS)
|
||||
assertTrue(weeks.first().take(5).all { it.isPad })
|
||||
assertEquals(1, weeks.first()[5].day)
|
||||
assertEquals(2, weeks.first().last().day)
|
||||
assertEquals(31, weeks.flatten().count { !it.isPad })
|
||||
}
|
||||
|
||||
@@ -63,7 +66,7 @@ class CalendarGridTest {
|
||||
)
|
||||
|
||||
assertEquals(6, weeks.size)
|
||||
assertEquals("9–15 August", weeks[2].label)
|
||||
assertEquals("10–16 August", weeks[2].label)
|
||||
assertEquals(2, weeks[2].episodeCount)
|
||||
assertEquals(2, calendarAgendaWeekIndex(weeks, "2026-08-12"))
|
||||
assertEquals("2026-08-11", calendarAgendaWeekDate(weeks[2]))
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
|
||||
class NavigationRailTest {
|
||||
@Test
|
||||
fun `user switcher is pinned directly above home`() {
|
||||
val items = navigationRailItems(calendarEnabled = false)
|
||||
|
||||
assertEquals(BrowseDestination.PROFILES, items[0])
|
||||
assertEquals(BrowseDestination.HOME, items[1])
|
||||
assertEquals(1, items.count { it == BrowseDestination.PROFILES })
|
||||
assertFalse(items.contains(BrowseDestination.SETTINGS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calendar capability does not change the first two rail items`() {
|
||||
val items = navigationRailItems(calendarEnabled = true)
|
||||
|
||||
assertEquals(
|
||||
listOf(BrowseDestination.PROFILES, BrowseDestination.HOME),
|
||||
items.take(2),
|
||||
)
|
||||
assertEquals(BrowseDestination.entries.size - 1, items.size)
|
||||
assertFalse(navigationRailItems(calendarEnabled = false).contains(BrowseDestination.CALENDAR))
|
||||
}
|
||||
}
|
||||
@@ -47,20 +47,22 @@ class UserSwitcherNavigationTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `both pinned actions are reachable below the profiles`() {
|
||||
fun `all pinned actions are reachable below the profiles`() {
|
||||
val profileCount = 3
|
||||
|
||||
// …the last profile, then Notifications, then Manage users, and no further.
|
||||
assertEquals(3, userSwitcherNextIndex(2, profileCount, UserSwitcherDirection.DOWN, 2))
|
||||
assertEquals(4, userSwitcherNextIndex(3, profileCount, UserSwitcherDirection.DOWN, 2))
|
||||
assertEquals(4, userSwitcherNextIndex(4, profileCount, UserSwitcherDirection.DOWN, 2))
|
||||
assertEquals(3, userSwitcherNextIndex(4, profileCount, UserSwitcherDirection.UP, 2))
|
||||
// …the last profile, Notifications, Settings, Manage users, and no further.
|
||||
assertEquals(3, userSwitcherNextIndex(2, profileCount, UserSwitcherDirection.DOWN, 3))
|
||||
assertEquals(4, userSwitcherNextIndex(3, profileCount, UserSwitcherDirection.DOWN, 3))
|
||||
assertEquals(5, userSwitcherNextIndex(4, profileCount, UserSwitcherDirection.DOWN, 3))
|
||||
assertEquals(5, userSwitcherNextIndex(5, profileCount, UserSwitcherDirection.DOWN, 3))
|
||||
assertEquals(4, userSwitcherNextIndex(5, profileCount, UserSwitcherDirection.UP, 3))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pinned actions stay reachable with a single profile`() {
|
||||
assertEquals(1, userSwitcherNextIndex(0, 1, UserSwitcherDirection.DOWN, 2))
|
||||
assertEquals(2, userSwitcherNextIndex(1, 1, UserSwitcherDirection.DOWN, 2))
|
||||
assertEquals(2, userSwitcherNextIndex(9, 1, UserSwitcherDirection.DOWN, 2))
|
||||
assertEquals(1, userSwitcherNextIndex(0, 1, UserSwitcherDirection.DOWN, 3))
|
||||
assertEquals(2, userSwitcherNextIndex(1, 1, UserSwitcherDirection.DOWN, 3))
|
||||
assertEquals(3, userSwitcherNextIndex(2, 1, UserSwitcherDirection.DOWN, 3))
|
||||
assertEquals(3, userSwitcherNextIndex(9, 1, UserSwitcherDirection.DOWN, 3))
|
||||
}
|
||||
}
|
||||
|
||||
+44
-2
@@ -57,7 +57,7 @@ class PlaybackIdentityScreenshotTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plain title and emby mark appear over playback for five seconds`() {
|
||||
fun `show logo and episode information appear over playback for five seconds`() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val root = FrameLayout(activity)
|
||||
val backdrop = ImageView(activity).apply {
|
||||
@@ -82,7 +82,15 @@ class PlaybackIdentityScreenshotTest {
|
||||
visibility = View.VISIBLE
|
||||
alpha = 1f
|
||||
}
|
||||
identity.findViewById<TextView>(R.id.player_playback_identity_title).text = "Dark Matter"
|
||||
identity.findViewById<ImageView>(R.id.player_playback_identity_logo).apply {
|
||||
setImageBitmap(colourLogo())
|
||||
visibility = View.VISIBLE
|
||||
}
|
||||
identity.findViewById<TextView>(R.id.player_playback_identity_title).visibility = View.GONE
|
||||
identity.findViewById<TextView>(R.id.player_playback_identity_episode).apply {
|
||||
text = "S02E04 · The Other You"
|
||||
visibility = View.VISIBLE
|
||||
}
|
||||
root.addView(identity)
|
||||
activity.setContentView(root)
|
||||
|
||||
@@ -91,4 +99,38 @@ class PlaybackIdentityScreenshotTest {
|
||||
"build/screenshots/playback-identity/player-playback-identity.png",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loading keeps the selected backdrop visible`() {
|
||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||
val loading = LayoutInflater.from(activity).inflate(R.layout.player_loading, null).apply {
|
||||
findViewById<ImageView>(R.id.playback_loading_backdrop).setImageBitmap(
|
||||
javaClass.classLoader
|
||||
?.getResourceAsStream("home_hero_preview_art.png")
|
||||
?.use(BitmapFactory::decodeStream),
|
||||
)
|
||||
}
|
||||
activity.setContentView(loading)
|
||||
|
||||
loading.captureRoboImage(
|
||||
"build/screenshots/playback-loading/player-loading-with-backdrop.png",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `episode ident separates the series from the episode`() {
|
||||
assertEquals(
|
||||
"S02E04 · The Other You",
|
||||
playbackIdentityEpisodeLabel(
|
||||
title = "Dark Matter – The Other You",
|
||||
seriesName = "Dark Matter",
|
||||
episodeCode = "S02E04",
|
||||
),
|
||||
)
|
||||
assertEquals(null, playbackIdentityEpisodeLabel("Arrival", null, null))
|
||||
}
|
||||
|
||||
private fun colourLogo(): Bitmap = Bitmap.createBitmap(420, 120, Bitmap.Config.ARGB_8888).apply {
|
||||
eraseColor(Color.rgb(82, 181, 75))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PlaybackStopWorkerTest {
|
||||
@Test
|
||||
fun `a newly queued final position remains deliverable`() {
|
||||
assertTrue(playbackStopIsFresh(enqueuedAtMs = 10_000L, nowMs = 10_500L))
|
||||
assertTrue(playbackStopIsFresh(enqueuedAtMs = 10_000L, nowMs = 70_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stale final position cannot overwrite another client`() {
|
||||
assertFalse(playbackStopIsFresh(enqueuedAtMs = 10_000L, nowMs = 70_001L))
|
||||
assertFalse(playbackStopIsFresh(enqueuedAtMs = 0L, nowMs = 10_000L))
|
||||
assertFalse(playbackStopIsFresh(enqueuedAtMs = 20_000L, nowMs = 10_000L))
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import android.view.LayoutInflater
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.GridLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.ponzischeme89.memby.R
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -35,7 +37,9 @@ class PrerollLayoutTest {
|
||||
videoHost.layoutParams.width,
|
||||
)
|
||||
assertNotNull(preroll.findViewById<PrerollCountdownView>(R.id.player_preroll_countdown))
|
||||
assertNotNull(preroll.findViewById<ImageView>(R.id.player_preroll_brand))
|
||||
assertNotNull(preroll.findViewById<LinearLayout>(R.id.player_preroll_brand))
|
||||
assertNotNull(preroll.findViewById<ImageView>(R.id.player_preroll_identity_logo))
|
||||
assertNotNull(preroll.findViewById<TextView>(R.id.player_preroll_identity_episode))
|
||||
assertEquals(
|
||||
"Starting in 7 seconds…",
|
||||
context.getString(R.string.player_preroll_countdown_initial),
|
||||
|
||||
@@ -2,6 +2,10 @@ package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
@@ -63,6 +67,14 @@ class PrerollScreenshotTest {
|
||||
)
|
||||
preroll.findViewById<TextView>(R.id.player_preroll_now_title).text =
|
||||
"Northbound – The Crossing"
|
||||
preroll.findViewById<ImageView>(R.id.player_preroll_identity_logo).apply {
|
||||
setImageBitmap(programmeLogo())
|
||||
visibility = View.VISIBLE
|
||||
}
|
||||
preroll.findViewById<TextView>(R.id.player_preroll_identity_episode).apply {
|
||||
text = "S02E04 · The Crossing"
|
||||
visibility = View.VISIBLE
|
||||
}
|
||||
preroll.findViewById<TextView>(R.id.player_preroll_now_metadata).text =
|
||||
"EPISODE · S02E04 · 48 mins"
|
||||
preroll.findViewById<TextView>(R.id.player_preroll_now_overview).text =
|
||||
@@ -105,4 +117,17 @@ class PrerollScreenshotTest {
|
||||
|
||||
private fun dp(activity: Activity, value: Int): Int =
|
||||
(value * activity.resources.displayMetrics.density).toInt()
|
||||
|
||||
private fun programmeLogo(): Bitmap = Bitmap.createBitmap(520, 110, Bitmap.Config.ARGB_8888).apply {
|
||||
Canvas(this).drawText(
|
||||
"NORTHBOUND",
|
||||
0f,
|
||||
82f,
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.WHITE
|
||||
textSize = 76f
|
||||
typeface = android.graphics.Typeface.DEFAULT_BOLD
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,8 +147,8 @@ class RequestPresentationTest {
|
||||
|
||||
@Test
|
||||
fun `the switcher's action count matches the rows actually drawn`() {
|
||||
assertEquals(2, userSwitcherActionCount(showRequests = false))
|
||||
assertEquals(3, userSwitcherActionCount(showRequests = true))
|
||||
assertEquals(3, userSwitcherActionCount(showRequests = false))
|
||||
assertEquals(4, userSwitcherActionCount(showRequests = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -228,7 +228,6 @@ func run(log *slog.Logger, events *logging.Buffer) error {
|
||||
Log: log,
|
||||
Config: creditsConfig,
|
||||
})
|
||||
go creditsService.Run(ctx)
|
||||
// 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
|
||||
// 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)
|
||||
dispatcher := integrations.New(st, log, adminBus)
|
||||
adminBus.AddSink(dispatcher)
|
||||
dispatcher.Start(ctx)
|
||||
sched := scheduler.New(st, log, adminBus)
|
||||
|
||||
server := api.New(cfg, api.Deps{
|
||||
@@ -270,6 +268,16 @@ func run(log *slog.Logger, events *logging.Buffer) error {
|
||||
Scheduler: sched,
|
||||
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
|
||||
// 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
|
||||
}
|
||||
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.
|
||||
go server.WatchEmbyReachability(ctx, cfg.EmbyHealthInterval)
|
||||
// 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 syncer.Schedule(ctx, cfg.SyncInterval)
|
||||
go syncer.Schedule(ctx, cfg.SyncInterval, server.ActivityPaused)
|
||||
if cfg.SyncOnStart {
|
||||
go func() {
|
||||
if server.ActivityPaused() {
|
||||
return
|
||||
}
|
||||
if _, err := syncer.Sync(ctx, "incremental", "startup"); err != nil {
|
||||
log.Warn("startup sync failed", "error", err)
|
||||
}
|
||||
@@ -319,8 +331,12 @@ func run(log *slog.Logger, events *logging.Buffer) error {
|
||||
cfg.TracearrSyncInterval,
|
||||
cfg.TracearrFullInterval,
|
||||
cfg.ForYouRebuildHour,
|
||||
server.ActivityPaused,
|
||||
)
|
||||
go func() {
|
||||
if server.ActivityPaused() {
|
||||
return
|
||||
}
|
||||
importCtx, cancel := context.WithTimeout(ctx, cfg.SyncTimeout)
|
||||
// Only if one is actually owed. An unconditional startup import made every
|
||||
// redeploy or container bounce a fresh pass over Tracearr's history.
|
||||
|
||||
@@ -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/for-you", s.adminAuth(s.handleAdminForYou))
|
||||
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/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
|
||||
mux.Handle("GET /admin/api/release-builder", s.adminAuth(s.handleAdminReleaseBuilderStatus))
|
||||
@@ -233,6 +234,7 @@ type adminStatus struct {
|
||||
ServerVersion string `json:"serverVersion"`
|
||||
CurrentUser string `json:"currentUser,omitempty"`
|
||||
Maintenance store.Maintenance `json:"maintenance"`
|
||||
QuietTime quietTimeStatus `json:"quietTime"`
|
||||
UpdatePolicy appupdate.Policy `json:"updatePolicy"`
|
||||
Library store.LibraryStats `json:"library"`
|
||||
SyncRunning bool `json:"syncRunning"`
|
||||
@@ -301,6 +303,7 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
|
||||
return username
|
||||
}(),
|
||||
Maintenance: s.maintenance.get(),
|
||||
QuietTime: s.quietTimeStatus(time.Now()),
|
||||
UpdatePolicy: s.updatePolicy.get(),
|
||||
Library: stats,
|
||||
SyncRunning: s.syncer.Running(),
|
||||
@@ -605,6 +608,9 @@ type syncRequest struct {
|
||||
// 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.
|
||||
func (s *Server) handleAdminSync(w http.ResponseWriter, r *http.Request) {
|
||||
if s.rejectWorkDuringQuietTime(w) {
|
||||
return
|
||||
}
|
||||
var req syncRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "malformed request body")
|
||||
@@ -640,6 +646,9 @@ type forYouAdminRequest struct {
|
||||
// handleAdminForYou provides the recovery controls needed for an idempotent backfill:
|
||||
// import all Tracearr sessions again, or rebuild every active user's derived pool.
|
||||
func (s *Server) handleAdminForYou(w http.ResponseWriter, r *http.Request) {
|
||||
if s.rejectWorkDuringQuietTime(w) {
|
||||
return
|
||||
}
|
||||
if s.forYou == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "Tracearr is not configured")
|
||||
return
|
||||
@@ -731,6 +740,47 @@ func (s *Server) handleAdminMaintenance(w http.ResponseWriter, r *http.Request)
|
||||
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) {
|
||||
days := queryInt(r, "days", 7, 90)
|
||||
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
|
||||
// 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) {
|
||||
if s.rejectWorkDuringQuietTime(w) {
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(r.PathValue("integrationID"))
|
||||
if err := s.integrations.Test(r.Context(), id); err != nil {
|
||||
s.loggerFor(r.Context()).Warn("integration test failed",
|
||||
|
||||
@@ -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
|
||||
// difference between a five-minute fix and an evening of guessing.
|
||||
func (s *Server) handleAdminSubtitleTest(w http.ResponseWriter, r *http.Request) {
|
||||
if s.rejectWorkDuringQuietTime(w) {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
type probe struct {
|
||||
Provider string `json:"provider"`
|
||||
|
||||
@@ -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) {
|
||||
server := testServer(config.Config{AdminToken: "secret"})
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
@@ -98,6 +98,7 @@ type Server struct {
|
||||
|
||||
recommendationBuilds recommendationBuilds
|
||||
maintenance maintenanceState
|
||||
quietTime quietTimeState
|
||||
updatePolicy updatePolicyCache
|
||||
// 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.
|
||||
@@ -293,7 +294,7 @@ func (s *Server) Routes() http.Handler {
|
||||
mux.Handle("/v1/", s.maintenanceGate(v1))
|
||||
// Radarr pushes here when an import finishes. Outside the gate on purpose: an event
|
||||
// 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
|
||||
// /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.
|
||||
|
||||
@@ -83,7 +83,7 @@ func (s *Server) RegisterCreditsTasks(sched *scheduler.Scheduler) {
|
||||
ID: "credits-candidates",
|
||||
Name: "Credits candidate refresh",
|
||||
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.",
|
||||
Interval: 10 * time.Minute,
|
||||
Timeout: 2 * time.Minute,
|
||||
|
||||
@@ -89,8 +89,8 @@ var featureCatalogue = []featureDefinition{
|
||||
Key: featureEndCredits, Name: "Speed through the credits", Area: "Playback",
|
||||
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, " +
|
||||
"and where it has none the position discovered for the episodes the household " +
|
||||
"is about to watch is used instead. It is read from the same chapter list as the " +
|
||||
"and where it has none the position discovered from episodes viewers are about to watch " +
|
||||
"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.",
|
||||
DefaultEnabled: true, MinimumProtocol: 1, Capability: "end_credits_v1",
|
||||
Recovery: "Takes effect the next time playback starts; the credits simply play out full size.",
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
<div class="mark">M</div>
|
||||
<h1>Install Memby</h1>
|
||||
{{if .Ready}}
|
||||
<p class="intro">The private Android TV client for this household’s 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>
|
||||
<p class="meta">Signed Android APK · {{.Size}}</p>
|
||||
<ol>
|
||||
|
||||
@@ -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.
|
||||
func (s *Server) maintenanceGate(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.quietTimeActive() {
|
||||
s.quietTimeUnavailable(w)
|
||||
return
|
||||
}
|
||||
state := s.maintenance.get()
|
||||
if !state.Enabled {
|
||||
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.
|
||||
func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||
state := s.maintenance.get()
|
||||
quiet := s.quietTimeStatus(time.Now())
|
||||
if quiet.Active {
|
||||
state.Enabled = true
|
||||
state.Message = quiet.Message
|
||||
}
|
||||
message := state.Message
|
||||
if state.Enabled && message == "" {
|
||||
message = store.DefaultMaintenanceMessage
|
||||
@@ -123,6 +132,7 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, ses
|
||||
featurePolicy := s.currentFeaturePolicy(r.Context())
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"maintenance": state.Enabled,
|
||||
"quietTime": quiet.Active,
|
||||
"message": message,
|
||||
"alerts": alerts,
|
||||
"compatible": compatible,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -23,6 +23,9 @@ func (s *Server) handleAdminReleaseBuilderStatus(w http.ResponseWriter, r *http.
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminReleaseBuilderStart(w http.ResponseWriter, r *http.Request) {
|
||||
if s.rejectWorkDuringQuietTime(w) {
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
|
||||
var request releaseBuilderRequest
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
|
||||
@@ -132,6 +132,9 @@ func (s *Server) WatchEmbyReachability(ctx context.Context, interval time.Durati
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if s.quietTimeActive() {
|
||||
continue
|
||||
}
|
||||
probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
err := s.emby.Ping(probeCtx)
|
||||
cancel()
|
||||
|
||||
@@ -19,6 +19,9 @@ func (s *Server) WatchSonarrLifecycle(ctx context.Context, interval time.Duratio
|
||||
return
|
||||
}
|
||||
scan := func() {
|
||||
if s.quietTimeActive() {
|
||||
return
|
||||
}
|
||||
if err := s.scanSonarrLifecycle(ctx); err != nil && ctx.Err() == nil {
|
||||
s.log.Warn("Sonarr lifecycle scan failed", "error", err)
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ type Service struct {
|
||||
behaviour BehaviourSource
|
||||
load LoadGauge
|
||||
log *slog.Logger
|
||||
paused func() bool
|
||||
cfg Config
|
||||
cfgMu sync.RWMutex
|
||||
|
||||
@@ -109,6 +110,9 @@ type Service struct {
|
||||
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 {
|
||||
cfg := NormaliseConfig(deps.Config)
|
||||
detector := deps.Detector
|
||||
@@ -312,6 +316,12 @@ func (s *Service) Run(ctx context.Context) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if s.paused != nil && s.paused() {
|
||||
if !sleep(ctx, idlePoll) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
candidate, found := s.queue.Claim()
|
||||
if !found {
|
||||
if !sleep(ctx, idlePoll) {
|
||||
|
||||
@@ -467,7 +467,7 @@ func (s *Service) recommendationUsers(ctx context.Context) ([]store.Session, err
|
||||
|
||||
embyUsers, err := s.emby.Users(ctx, s.serviceCred)
|
||||
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
|
||||
}
|
||||
tracearrUsers, traceErr := s.allTracearrUsers(ctx)
|
||||
@@ -771,6 +771,7 @@ func (s *Service) Schedule(
|
||||
ctx context.Context,
|
||||
importEvery, fullEvery time.Duration,
|
||||
rebuildHour int,
|
||||
paused ...func() bool,
|
||||
) {
|
||||
// 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
|
||||
@@ -798,13 +799,18 @@ func (s *Service) Schedule(
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-importC:
|
||||
if len(paused) > 0 && paused[0] != nil && paused[0]() {
|
||||
continue
|
||||
}
|
||||
if _, _, err := s.ImportIfDue(ctx, importEvery, fullEvery); err != nil {
|
||||
s.log.Warn("scheduled Tracearr import failed", "error", err)
|
||||
}
|
||||
case <-rebuildTimer.C:
|
||||
if len(paused) == 0 || paused[0] == nil || !paused[0]() {
|
||||
if err := s.RebuildAll(ctx, true); err != nil {
|
||||
s.log.Warn("scheduled daily For You rebuild failed", "error", err)
|
||||
}
|
||||
}
|
||||
rebuildTimer.Reset(time.Until(nextDailyRebuild(time.Now(), s.location, rebuildHour)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ type Dispatcher struct {
|
||||
|
||||
transports map[string]Transport
|
||||
queue chan job
|
||||
paused func() bool
|
||||
|
||||
mu sync.Mutex
|
||||
cached store.IntegrationSettings
|
||||
@@ -76,6 +77,9 @@ type Dispatcher struct {
|
||||
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 {
|
||||
dispatcher := &Dispatcher{
|
||||
store: st, log: log.With("component", "integrations"), events: events,
|
||||
@@ -101,6 +105,15 @@ func (d *Dispatcher) Start(ctx context.Context) {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// 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 {
|
||||
s.log.Info("library auto-sync disabled")
|
||||
return
|
||||
@@ -291,6 +291,10 @@ func (s *Syncer) Schedule(ctx context.Context, interval time.Duration) {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
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() {
|
||||
s.log.Info("skipping scheduled sync; one is already running")
|
||||
continue
|
||||
|
||||
@@ -93,6 +93,7 @@ type Scheduler struct {
|
||||
store *store.Store
|
||||
log *slog.Logger
|
||||
events *adminevents.Bus
|
||||
paused func() bool
|
||||
|
||||
mu sync.RWMutex
|
||||
tasks map[string]*registered
|
||||
@@ -101,6 +102,21 @@ type Scheduler struct {
|
||||
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 {
|
||||
return &Scheduler{
|
||||
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) {
|
||||
if s.isPaused() {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
s.mu.RLock()
|
||||
entries := make([]*registered, 0, len(s.tasks))
|
||||
@@ -248,6 +267,9 @@ func (s *Scheduler) RunNow(ctx context.Context, id string) error {
|
||||
if !ok {
|
||||
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()
|
||||
if entry.running {
|
||||
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) {
|
||||
if s.isPaused() {
|
||||
return
|
||||
}
|
||||
entry.mu.Lock()
|
||||
if entry.running {
|
||||
entry.mu.Unlock()
|
||||
|
||||
@@ -40,6 +40,22 @@ func TestATaskNeedsAnIDAndAFunction(t *testing.T) {
|
||||
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) {
|
||||
// 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
|
||||
|
||||
@@ -39,7 +39,7 @@ func (s *Store) HouseholdCompletionScores(
|
||||
AND coalesce(nullif(emby_series_id, ''), emby_item_id) <> ''
|
||||
GROUP BY item_id`, since)
|
||||
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()
|
||||
out := map[string]float64{}
|
||||
|
||||
@@ -15,6 +15,9 @@ import (
|
||||
// MaintenanceKey is the app_settings row backing maintenance mode.
|
||||
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.
|
||||
const RequestPolicyKey = "request_policy"
|
||||
|
||||
@@ -742,6 +745,101 @@ func (s *Store) SetMaintenance(ctx context.Context, state Maintenance) error {
|
||||
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.
|
||||
const UpdatePolicyKey = "update_policy"
|
||||
|
||||
|
||||
@@ -5,6 +5,34 @@ import (
|
||||
"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) {
|
||||
policy := RequestPolicy{AllowedUserIDs: []string{"user-2"}}
|
||||
if policy.Allows("user-1") {
|
||||
|
||||
Reference in New Issue
Block a user