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