Compare commits

...
Author SHA1 Message Date
timothy a8d4979b0c fix(web): address Guide review feedback refs #85
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m48s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m32s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-05 23:06:08 +02:00
timothy 7d93688841 feat(web): add Guide EPG screen refs #85
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m3s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-05 22:31:30 +02:00
timothyandClaude Fable 5 d9ffbde79c docs: advance ChicoryTV issue queue past #88 (PR #128); next prompt = #85 EPG (Codex)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m54s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m28s
Records the #128 review outcome (percent-fraction + poll-start-race fixes,
bee686c8), main d63719ec, baselines (368/493+1skip/75 web), and new lessons
(numeric wire-field units, trigger≠started grace windows, no ref mutation in
updaters).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:08:53 +02:00
timothy d63719ece9 Merge pull request 'feat(web): Libraries screen (#88)' (#128) from feat/88-libraries into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
2026-07-05 17:06:50 +00:00
timothyandClaude Fable 5 bee686c846 fix(web): libraries scan-flow review fixes for #88
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m3s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- Normalize scan-status percent at the API boundary: the wire value is a
  0-1 fraction despite the field name; the bar previously showed ~0% for
  the whole scan (fixtures now use fractional wire values incl. 0/1)
- Fix the poll-start race: a triggered library enters a pending set that
  survives status pruning until seen active or a 3-tick grace window
  expires; the poll runs while pending ∪ active is nonempty (previously
  the queue-to-start lag could mean polling never started and the button
  re-enabled mid-scan); grace ticks also drain on persistent status-fetch
  errors so the UI can't stick
- Keep setState updaters pure: pending/grace bookkeeping hoisted into
  pruneGraceExpiredPending() outside the updater (StrictMode-safe)
- Wire the prototype's per-source Scan button (one POST per library,
  disabled while any of its libraries is pending/active)
- Honest bare-500 media-sources failure test (backend has no exception
  middleware); loadScanStatuses returns its promise; poll effect depends
  on derived hasActiveScans; synchronous ref double-submit guard;
  --radius-xs fallback 3px; StatusDot label on source cards (75 tests)

Review: PR #128 findings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:01:05 +02:00
timothy 5c4acb9c7b feat(web): add Libraries screen refs #88
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m18s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-05 18:26:06 +02:00
timothyandClaude Fable 5 223ef70912 docs: advance ChicoryTV issue queue past #87 (PR #127); next prompt = #88 Libraries (Codex)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m22s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m0s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m1s
Records the #127 review outcome (filler blocker + undefined token), pre-merge
fixes (c4190e3a), main a8dd88ef, baselines (368/493+1skip/63 web), and new
lessons (fixture-vs-actual-query fidelity, pure setState updaters, responsive
collapse, visible deferrals, honest tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:53:07 +02:00
timothy a8dd88ef73 Merge pull request 'feat(web): Playouts screen (#87)' (#127) from feat/87-playouts into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
2026-07-05 15:50:57 +00:00
timothyandClaude Fable 5 c4190e3a54 fix(web): playouts monitor review fixes for #87
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m28s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m9s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- Filler unit: treat fillerKind 'None'/null as non-filler (real items no
  longer render the Filler badge); add the prototype's Show filler toggle
  wired to ?showFiller=true (the client previously never requested filler,
  making the filler UI dead code); fixtures now use real wire values
- Hoist selection fetch out of the setState updater (StrictMode-safe)
  via a baseRef snapshot; merge polled channel states functionally
  instead of clobbering with the captured snapshot
- Fix undefined --ctv-surface-1 token on the hero (→ --surface-card per
  prototype); correct --text-lg fallback; collapse the new playout grids
  in the 980px media block (no page-level horizontal scroll)
- Visible deferrals: disabled Reset / Schedule reset buttons with note
  (no per-playout reset endpoint yet)
- itemsLoading spinners on selection switch; time-window now-matching
  with title fallback; rail disabled while reset-all mutates
- Tests: mount-count delta isolation from the Dashboard prefetch;
  reset-all reframed as transport-failure (mock honors fixture status;
  backend is unconditionally 202); rail filter test; paging honesty
  ('shown of total' label); listPlayout() fixture matches the list DTO
  (63 tests)

Review: PR #127 findings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:45:01 +02:00
timothy 1840ef83b1 feat(web): add Playouts monitor screen refs #87
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m1s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-05 17:10:30 +02:00
timothyandClaude Fable 5 c5021e28da docs: advance ChicoryTV issue queue past #86 (PR #125); next prompt = #87 Playouts (Codex)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m48s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m23s
Records the #125 review outcome (refuted reorder blocker → #126), pre-merge
fixes (f18f2dc0), main 229f7cc1, baselines (368/493+1skip/52 web), and new
lessons (mount call-count assertions, no world-refetch, no list[0]-only UIs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 12:27:06 +02:00
timothy 229f7cc1b0 Merge pull request 'feat(web): Schedule editor screen (#86)' (#125) from feat/86-schedule-editor into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m18s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
2026-07-05 10:22:16 +00:00
timothyandClaude Fable 5 8f792d964e docs: advance ChicoryTV issue queue past #84 (PR #124); next prompt = #86 Schedule editor (Codex)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m23s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m2s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m28s
Records the new Fable-prompts/Fable-reviews process, PR #124 review outcome
and pre-merge fixes (ecf6cf75), main 7ee6e987, baselines (368/493+1skip/39 web).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 11:29:53 +02:00
8 changed files with 3900 additions and 124 deletions
+138 -120
View File
@@ -5,144 +5,162 @@ UPDATING THIS FILE in place (rewrite the state section and the queue for the nex
always holds the current handoff. History: created 2026-07-02 after the plan audit (#59 epic)
filed backend gap issues #100#111; a 79-way parallel workflow build once exhausted RAM, so
builds are limited to 23 concurrent, never wide fan-outs. Backend gaps all landed by
2026-07-04 (#105/#108/#100+#101+#107+#110/#103+#106/#104/#111/#102 PRs #113#119). The
MERGE PASS (2026-07-04) then landed the whole SPA stack on main via PR #120.
2026-07-04 (PRs #113#119); merge pass PR #120; live-data screens so far: #109 Dashboard
(PR #123), #84 Channels (PR #124), #86 Schedule editor (PR #125), #87 Playouts (PR #127),
#88 Libraries (PR #128).
**Session state (2026-07-04, post-#109)**: main = 4ffd777b (PR #123 "Dashboard real data
sources"): the Dashboard is the first SPA screen running fully on live API data. On main:
design-system/ + web/ foundation (#78#83), backend #96/#98/#97, Dockerfile hotfix #122,
direct-stream session tracker (#99 seam, PR #121), Dashboard data (#109, PR #123). #109
closed. #99 remains open ONLY for the final `/api/channels/state` wiring (combine
`IFFmpegSegmenterService.IsActive` with `IDirectStreamSessionTracker`). Baselines:
ErsatzTV.Tests **368**, Core.Tests **493** (+1 skip); web tests **31** (App.test.tsx 20);
web lint/typecheck/build clean (build output ErsatzTV/wwwroot/app/, gitignored; SPA types
web/src/api/generated/v1.d.ts, regen `npm run generate:api`).
- **#109 review cycle (pattern that worked)**: Codex implemented → Fable review caught 1
substantial bug (health widget matched status strings the API never emits —
`error/failed/unhealthy`+`warning/degraded` vs the REAL `pass|fail|warn|info` from
Health/Mapper.cs; fictional test fixtures masked it) + 4 nits → all fixed in-session by a
sonnet subagent (e6197d63) → CI green → merged. LESSON: frontend tests must use fixture
values copied from the backend serializer, not invented ones; reviewers should diff
fixtures against the producing C# code.
- Dashboard health policy (binding for future screens): /api/health is fetched on mount +
guarded manual refresh ONLY (no intervals) — it re-runs ~14 checks per request. A backend
TTL cache is the unfiled prerequisite for any live health display.
- Worktrees: remove .worktrees/issue-99-session-tracking and .worktrees/issue-109-dashboard
(both merged). The main checkout still sits on docs/59-ui-redesign-brief — fully merged,
safe to switch to main.
**PROCESS (2026-07-05, binding)**: Fable writes each Codex prompt AND performs the PR review
(via review subagents — parallel lenses: correctness fork + cheaper contract/tests +
design-system agents; plus a fork verification pass over any fix diff). Codex implements.
Every Codex prompt MUST mandate: (a) subagents where appropriate at fitting effort/model
levels, (b) `npm ci` in each fresh worktree. Review-driven fixes are applied by fitting
subagents too (NITS: Fable fixes on the branch; SUBSTANTIAL: back to Codex verbatim or fixed
in-session — user decides). Merges need explicit user consent (standing consent for this
stretch: merge when reviewers are happy and tests pass).
**Session state (2026-07-05, post-#88)**: main = d63719ec (PR #128 merge): Libraries screen
live — source cards (media-sources), per-library scan progress + triggers, per-source scan,
conditional 10s scan-status polling (pendingactive drives the interval; 3-tick grace window
covers the queue-to-start lag; drains on persistent errors; refetches sources once on
completion). Review found 2 production-visible substantials fixed pre-merge (bee686c8):
percent was a 01 wire fraction (bar showed ~0%), and the poll-start race. Fork verification
caught a StrictMode-unsafe ref mutation inside a setState updater — fixed via a pure hoist.
Baselines: ErsatzTV.Tests **368**, Core.Tests **493** (+1 skip); web tests **75**,
lint/typecheck/build clean. #84/#86/#87/#88 CLOSED. Main checkout sits on
docs/59-ui-redesign-brief (fully merged, safe to switch).
**Lessons for all remaining prompts** (accumulated):
- Frontend fixtures/contracts: copy enum-ish string values from the producing backend code
(serializers/mappers), never invent them; review = diff fixtures vs C#. (#109's bug.)
- The image-build job runs ONLY on main pushes — PR CI cannot catch Dockerfile breakage.
Any Dockerfile change: verify base tags exist (hub.docker.com API) and, where docker is
available, `docker build --target <stage>` locally before merging. (`node:22-noble` cost
a red main run; node official images have no `-noble` variant.)
- DTO records in ErsatzTV.Core/Api MUST get file-scoped `#nullable enable` — else the spec
emits `["null","string"]` unions and SPA types get needless `| null`. NOTE:
ErsatzTV.Application has NO nullable context — `string?` there trips CS8632.
- NSubstitute + ConfigElementKey: `ConfigElementKey.X` is a fresh instance per access —
stub with `Arg.Any<ConfigElementKey>()` disambiguated by the generic `<T>`.
- `Option<T>.ToNullable()` doesn't exist here; use `MatchUnsafe(v => (T?)v, () => null)`.
- ./scripts/update-openapi.sh only runs `-t:GenerateOpenApiDocuments` — do a normal
`dotnet build ErsatzTV/ErsatzTV.csproj` FIRST or it fails with MSB3680.
- Id-taking child-collection GETs 404 on unknown parent via pre-check + ApiResults
(precedent: ScheduleController.GetItems) + an OpenApiErrorResponseContractTests entry.
- Backlog nits (unfiled): unclamped pageSize on paged endpoints; PlayoutController
Create/Delete lack route Name=; PlayoutController.GetItems existence pre-check is heavy;
>30 MB uploads return bare Kestrel 413; artwork content-type trusted from client (#66);
schedule duration estimator: expression/zero Count → null estimate, materializes each
referenced collection per GET; /api/health TTL cache (prereq for live health UI). From
#102 (also on #85): GET /api/guide runs the full 21-include eager-load per channel per
request — trim projection or add caching before the EPG grid polls it; `fillerKind` is
almost always `None` (projector merges filler into adjacent programmes) — discrete filler
entries need a JSON-only projection mode.
- Fixture fidelity (#109/#127/#128): fixtures must be what the actually-called endpoint
returns UNDER THE QUERY THE CLIENT SENDS. #128: `percent` is a 01 fraction despite its
name (bar showed ~0%; 0100 fixtures masked it). Enum-with-None fields are never
truthiness-checked. Verify UNITS/scale of numeric wire fields against the producing code,
not the field name.
- Trigger≠started (#128): a 202/200 on a trigger endpoint means QUEUED; status endpoints may
lag by seconds. Pending-state with a grace window bridges the gap; poll while
pendingactive nonempty; drain grace on persistent errors so UIs can't stick.
- setState updaters must be PURE — no fetches (#127) and no ref mutations (#128); hoist
side-effects/bookkeeping out. StrictMode double-invokes updaters and the test renderer
doesn't, so tests won't catch it — reviewers must.
- OpenAPI can UNDER-report the wire (#125/#126); check the serializer before widening types.
- Every new multi-column grid → the @media (max-width: 980px) collapse block; var() fallback
= the token's resolved value; verify the token EXISTS (--ctv-surface-1, --text-faint don't).
- Prototype affordances: implemented or VISIBLY deferred — never silently dropped
(#127 toggle = half a blocker; #128 per-source Scan).
- Actionable = visible (#84); disable all mutation triggers while mutating; ref-based
double-submit guards; mutations never refetch the world (#125/#127).
- A screen listing N entities lets the user pick among them (#125).
- Honest tests: no scenarios the backend can't produce (no exception middleware → failures
are BARE 4xx/5xx, not ProblemDetails, unless the controller explicitly returns them);
mount call-counts assert the DELTA across navigation (Dashboard prefetches
/api/playouts AND /api/media-sources); paging labels don't lie; test the search/filter UI
you ship; status-dot state never color-only (StatusDot has a label prop).
- The image-build job runs ONLY on main pushes. DTO records in Core/Api need `#nullable
enable`; Application has NO nullable context. NSubstitute+ConfigElementKey:
`Arg.Any<ConfigElementKey>()` + `<T>`. `Option<T>.ToNullable()` → `MatchUnsafe`.
update-openapi.sh needs a prior normal build. Child GETs 404 unknown parents via pre-check.
- Backlog nits (unfiled): unclamped pageSize; PlayoutController Create/Delete lack Name=;
heavy GetItems pre-check; >30 MB uploads → bare 413; artwork content-type trusted (#66);
schedule estimator materializes collections per GET; /api/health TTL cache; NEW (#128):
`LibraryScanStatusResponseModel.percent` is a 01 fraction under a percent name — rename
or ×100 server-side eventually; no Dialog/Modal component yet (likely needed by #89);
1 pre-existing --text-faint usage in shell.css. Filed: #126 (OpenAPI polymorphism gap).
---
# PROMPT FOR CODEX — #84: Channels screen (management table + live treatment)
# PROMPT FOR CODEX — #85: Guide / EPG screen (channels × time grid)
You are Codex working solo in /Users/timothy/ersatztv (ErsatzTV fork; the React SPA
"ChicoryTV" lives in web/ — Vite + TS, typed client in web/src/api/, generated types
web/src/api/generated/v1.d.ts). Read CLAUDE.md and docs/contributing.md first; follow the
SPA foundation's existing patterns (#78#83) and the Dashboard's data-layer idioms from
PR #123 (web/src/api/dashboard.ts hooks with the `active` unmount guard) — match, don't
You are Codex, the IMPLEMENTER, in /Users/timothy/ersatztv (ErsatzTV fork; React SPA
"ChicoryTV" in web/ — Vite + TS, typed client web/src/api/, generated types
web/src/api/generated/v1.d.ts). Fable (Claude) reviews your PR read-only afterwards — do NOT
merge. Read CLAUDE.md and docs/contributing.md first; follow the SPA patterns from Channels
(PR #124), Schedule editor (PR #125), Playouts (PR #127), Libraries (PR #128) — match, don't
invent.
HARD CONSTRAINTS:
- Work in a NEW git worktree: `git worktree add .worktrees/issue-84-channels -b
feat/84-channels-screen origin/main` (branch from origin/main; never touch the main
checkout or other .worktrees/*).
- Max 23 concurrent builds machine-wide; keep to ONE dotnet build at a time here.
- USE SUBAGENTS where appropriate, at fitting effort and model levels: cheap/fast agents for
mechanical work (fixtures, CSS transcription, test boilerplate); higher-effort agents only
for judgment work (grid virtualization/layout math, time-window state). Keep orchestration
and final assembly in your main session.
- Worktree: `git worktree add .worktrees/issue-85-epg -b feat/85-epg origin/main`.
Never touch the main checkout or other .worktrees/*.
- Run `npm ci` in web/ in the worktree BEFORE any lint/typecheck/test/build.
- Max 23 concurrent builds machine-wide; ONE dotnet build at a time here.
- NEVER set ETV_UPDATE_GOLDENS. A golden-file diff means your code is wrong.
- Do NOT merge anything. Open the PR, get CI green, and stop.
- Backend scope guard: FRONTEND issue. All needed endpoints exist (#96/#98/#97 below).
If something is missing, note it in the PR and stub cleanly — do not add endpoints.
- CONTRACT RULE (#109 lesson): any string value you branch on or use in test fixtures must
be copied from the producing backend code (mapper/serializer) or the generated v1.d.ts —
never invented.
- FRONTEND-only: no new endpoints, no ErsatzTV.Core/Application changes. Missing data =
VISIBLE deferral (disabled + note) — never silently drop a prototype affordance.
- CONTRACT RULE: fixture values must be what the actually-called endpoint returns UNDER THE
QUERY THE CLIENT SENDS; verify units/scale of numeric fields against the producing backend
code, never the field name (#128's percent lesson). Enum-with-None fields are never
truthiness-checked (#127's fillerKind lesson — this endpoint HAS fillerKind!).
## Context
- main = 4ffd777b (post-#123). Baselines: ErsatzTV.Tests 368, Core.Tests 493(+1 skip);
web tests 31, lint/typecheck/build clean.
Gitea: http://192.168.1.95:3000/timothy/ersatztv (basic auth timothy:ded89Lm4).
- Issue #84: the Channels screen — read its body AND the #59 epic's "Definition of Ready"
for screen issues. Visual reference: design-system/templates/chicorytv-admin/Channels.jsx
(static prototype) + the design brief; reuse web/src/components/* primitives.
- Available data (all on main):
- GET /api/channels — enriched list (#96): group, enabled, EPG visibility, sort number…
- Bulk ops (#98): POST /api/channels/bulk/renumber | /bulk/group | /bulk/delete
(204 on success, 404/422 with ProblemDetails — see OpenApiErrorResponseContractTests).
- GET /api/channels/state (#97): onAir + nowPlaying (nullable!) for live badges/rows.
onAir is still segmenter-session-based until the final #99 wiring — render as-is.
- Channel CRUD: POST/PUT/DELETE /api/channels{,/id} for create/edit/delete flows if the
issue's scope includes them (check the issue body; the full Channel Builder is #89 —
do NOT build it here).
- Polling: channel state may poll gently (≥30s) if the design calls for live updates;
/api/health is NOT this screen's concern.
## Task
Issue #85: recreate design-system/templates/chicorytv-admin/Epg.jsx wired to the JSON guide.
Dark channels×time grid, "now" marker, live focal color, jump-to-now. Acceptance: matches
prototype; live guide data; all 3 themes.
## Context (main = d63719ec; baselines: ErsatzTV.Tests 368, Core.Tests 493+1skip, web 75)
- Gitea: http://192.168.1.95:3000/timothy/ersatztv (basic auth timothy:ded89Lm4).
- API surface (verify in v1.d.ts; regen `npm run generate:api` if stale):
- GET /api/guide?start=<ISO>&end=<ISO> (both optional DateTimeOffset; start defaults to
now, end to start + configured XmltvDaysToBuild) → ChannelGuideResponseModel { start,
end, channels: [{ number, name, programmes: [{ start, stop, title, subTitle (nullable),
category (nullable), fillerKind }] }] }.
- CRITICAL COST NOTE (#102): the guide endpoint runs a heavy 21-include eager-load PER
CHANNEL PER REQUEST. Request a BOUNDED window (e.g. now-1h → now+12h), fetch ONCE on
mount + on explicit user navigation (prev/next window, jump-to-now beyond the loaded
range) — NEVER poll /api/guide. The "now" marker advances client-side on a timer
(minute-ish tick moving the marker/highlight; NO refetch).
- fillerKind in the JSON guide is almost always 'None' — the shared projector merges
filler into adjacent programmes. Do NOT truthiness-check it; discrete filler rows would
need a backend projection mode (out of scope — no UI toggle for it).
- GET /api/channels/state (onAir) may drive the live focal treatment; if used, poll ≥30s
(Channels precedent) — it's the only thing allowed on an interval besides the client-side
now-marker tick.
- Channel logos: check what the prototype uses; the SPA has ChannelLogo (initials-based).
/iptv logo URLs exist but check what Channels/Dashboard screens do — match precedent.
- Grid math: programmes can start before / end after the requested window — clip to the
window; overlapping/adjacent programmes must not produce negative/NaN widths; empty
channels (no programmes) render an empty row, not a crash; timezone = render in the
BROWSER's local time (the wire carries offsets; Date parsing is TZ-safe).
- No mutations on this screen. Read-only + navigation state only.
- CSS: design-system tokens only; verify each token EXISTS; fallback = resolved value; new
multi-column grids → the 980px collapse block; the grid scrolls in its OWN container
(both axes as needed) — never page-level horizontal scroll.
- Tests: mount fetch delta (guide fetched exactly once; no guide polling — assert interval
ticks don't refetch /api/guide); window navigation triggers exactly one fetch per step;
jump-to-now; now-marker position math (fixed fake now vs fixture times); programme
clipping at window edges; empty channels; error+retry; fixture fillerKind 'None' (real
wire value) with subTitle/category nulls exercised.
## Process
1. Comment on issue #84 with findings + approach (table/widget → endpoint mapping,
which CRUD flows are in/out of scope) before coding.
2. Implement with loading/error/empty states per design-system; bulk selection UX per the
prototype; prefer refetch-after-mutate over optimistic updates unless the foundation
already has an optimistic idiom (it does not, as of #123).
3. Verify: `cd web && npm ci && npm run lint && npm run typecheck && npm test -- --run &&
npm run build`; plus `dotnet build ErsatzTV.sln` + TZ=UTC dotnet test ErsatzTV.Tests +
1. Comment on issue #85 with findings + approach (prototype surface → endpoint/DTO mapping
table, scope decisions, chosen window size) BEFORE coding.
2. Implement; comment progress on #85 as you go.
3. Verify: cd web && npm ci && npm run lint && npm run typecheck && npm test -- --run &&
npm run build; then TZ=UTC dotnet build ErsatzTV.sln; TZ=UTC dotnet test ErsatzTV.Tests +
Core.Tests sequentially (expect 368 / 493+1skip — no backend regression).
4. Push, open PR → main: `feat(web): Channels screen (#84)`, body lists the mapping and
scope decisions; `closes #84`. Poll CI by head SHA until green. Do not merge.
5. Comment progress on #84 as you go.
4. Push, open PR → main: "feat(web): Guide / EPG screen (#85)", body lists the mapping table
and scope decisions; `closes #85`. Poll CI by head SHA until green. Do NOT merge.
## On completion — REQUIRED final output
Print a fenced handoff prompt addressed to Claude (Fable) asking it to:
- Review the PR diff READ-ONLY in one Fable pass (endpoint/contract correctness incl.
fixture values vs backend serializers, bulk-op error handling, polling discipline,
type safety, design-system adherence, scope).
- Classify findings: NITS Fable may fix directly on the branch; SUBSTANTIAL issues either
go back to Codex as a verbatim prompt or get fixed in-session by subagents — the user
decides which at review time.
- After review: comment the verdict on the PR and #84; on approval + user merge consent,
merge, verify main's post-merge run (image job included), then update THIS handoff file
(pop #84, next prompt = #86 Schedule editor for Codex, record PR number + main SHA +
baselines) and push it to main.
Include: PR number, branch, head SHA, files changed, mapping table, test/web results,
anything deferred or uncertain.
Print a fenced handoff prompt addressed to Claude (Fable) asking it to review the PR
READ-ONLY (Fable runs its own review subagents). Include: PR number, branch, head SHA, base,
files changed, the mapping table, verification commands + results, deferred/uncertain list,
and the finding classification (NITS = Fable fixes on the branch; SUBSTANTIAL = back to
Codex verbatim or fixed in-session with subagents — user decides). After approval + user
merge consent, Fable merges, verifies main's post-merge run (image job included), updates
THIS handoff (pop #85, next prompt = #89 Channel Builder, record PR + main SHA + baselines),
and pushes it to main.
---
## Issue queue (work top-down)
0. HOUSEKEEPING (carry into next session): remove .worktrees/issue-99-session-tracking and
.worktrees/issue-109-dashboard if still present; #99 stays open for the final
`/api/channels/state` onAir wiring (good small backend slot-filler between screens).
1. #84 Channels screen ← CODEX PROMPT above (review/merge/doc-update falls to the Fable
session that Codex's end-of-run handoff spawns).
2. UX conversion order: #86 Schedule editor (#111 durations) → #87 Playouts → #88 Libraries
#85 EPG (#102 JSON guide; mind fillerKind + per-request-cost notes above) → #89 Channel
Builder (#104 artwork upload; also needs #62: #63/#64/#65) → #93 Settings → #90 rebrand
#91 cutover.
Cross-refs: #99 seam landed (PR #121), final wiring open. Done this pass: MERGE PASS →
PR #120 (closed #97; validated #78#83/#96/#98), hotfix PR #122, PR #121 (#99 seam),
PR #123 (#109 Dashboard — first live-data screen). Languages-list endpoint from #105 still
unimplemented — open a follow-up when #86/#89 need it; filler/watermark lists return DB
order — SPA should client-sort.
0. HOUSEKEEPING: #99 stays open for the final /api/channels/state onAir wiring; #126
(OpenAPI polymorphism gap) is a good backend slot-filler between screens.
1. #85 Guide/EPG screen ← CODEX PROMPT above (Fable reviews via subagents, merges on
consent, updates this file).
2. UX conversion order: #89 Channel Builder (#104 artwork upload; also needs #62:
#63/#64/#65; likely needs the first Dialog/Modal component; languages endpoint follow-up
from #105 when needed) → #93 Settings → #90 rebrand → #91 cutover.
Cross-refs: #99 seam landed (PR #121), final wiring open. Done recently: PR #125 (#86),
PR #127 (#87), PR #128 (#88 Libraries — closed 2026-07-05, main d63719ec). Filed: #126.
+1139 -3
View File
File diff suppressed because it is too large Load Diff
+945
View File
File diff suppressed because it is too large Load Diff
+159
View File
@@ -0,0 +1,159 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { getChannelStates, messageFromError, type ChannelState } from './channels';
import { request } from './client';
import type { components } from './generated/v1';
export type ChannelGuide = components['schemas']['ChannelGuideResponseModel'];
export type ChannelGuideChannel = components['schemas']['ChannelGuideChannelResponseModel'];
export type ChannelGuideProgramme = components['schemas']['ChannelGuideProgrammeResponseModel'];
export interface GuideScreenData {
channelStates: ChannelState[];
guide: ChannelGuide;
}
export type GuideScreenQueryState =
| {
data: GuideScreenData;
error: null;
refresh: () => void;
setWindowStart: (start: Date) => void;
status: 'success';
windowStart: Date;
windowEnd: Date;
}
| { data: null; error: string; refresh: () => void; status: 'error'; windowStart: Date; windowEnd: Date }
| { data: null; error: null; refresh: () => void; status: 'loading'; windowStart: Date; windowEnd: Date };
type GuideScreenState =
| { data: GuideScreenData; error: null; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
export const GUIDE_PAST_MS = 60 * 60 * 1000;
export const GUIDE_WINDOW_MS = 13 * 60 * 60 * 1000;
export function defaultGuideWindowStart(now = new Date()): Date {
return new Date(now.getTime() - GUIDE_PAST_MS);
}
export function getGuide(start: Date, end: Date): Promise<ChannelGuide> {
const query = new URLSearchParams({
start: start.toISOString(),
end: end.toISOString()
});
return request<ChannelGuide>(`/api/guide?${query.toString()}`);
}
export async function getGuideScreenData(start: Date, end: Date): Promise<GuideScreenData> {
const guide = await getGuide(start, end);
const channelStates = await getChannelStates().catch(() => []);
return { channelStates, guide };
}
export function useGuideScreenQuery(channelStatePollMs = 30000): GuideScreenQueryState {
const [windowStart, setWindowStartState] = useState(() => defaultGuideWindowStart());
const windowStartRef = useRef(windowStart);
const [state, setState] = useState<GuideScreenState>({
data: null,
error: null,
status: 'loading'
});
const activeRef = useRef(true);
const requestIdRef = useRef(0);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const windowEnd = new Date(windowStart.getTime() + GUIDE_WINDOW_MS);
const load = useCallback((start = windowStartRef.current) => {
const end = new Date(start.getTime() + GUIDE_WINDOW_MS);
const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId;
getGuideScreenData(start, end)
.then((data) => {
if (
activeRef.current &&
requestIdRef.current === requestId &&
windowStartRef.current.getTime() === start.getTime()
) {
setState({ data, error: null, status: 'success' });
}
})
.catch((error: unknown) => {
if (
activeRef.current &&
requestIdRef.current === requestId &&
windowStartRef.current.getTime() === start.getTime()
) {
setState({ data: null, error: messageFromError(error), status: 'error' });
}
});
}, []);
const loadChannelStates = useCallback(() => {
getChannelStates()
.then((channelStates) => {
if (!activeRef.current) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { ...current.data, channelStates },
error: null,
status: 'success'
};
});
})
.catch(() => {
// Keep the guide visible when the optional live-state refresh fails.
});
}, []);
useEffect(() => {
load(windowStart);
}, [load, windowStart]);
useEffect(() => {
const intervalId = window.setInterval(loadChannelStates, Math.max(channelStatePollMs, 30000));
return () => {
window.clearInterval(intervalId);
};
}, [channelStatePollMs, loadChannelStates]);
const setWindowStart = useCallback((start: Date) => {
windowStartRef.current = start;
setState({ data: null, error: null, status: 'loading' });
setWindowStartState(start);
}, []);
const refresh = useCallback(() => {
setState({ data: null, error: null, status: 'loading' });
load(windowStartRef.current);
}, [load]);
if (state.status === 'success') {
return { data: state.data, error: null, refresh, setWindowStart, status: 'success', windowEnd, windowStart };
}
if (state.status === 'error') {
return { data: null, error: state.error, refresh, status: 'error', windowEnd, windowStart };
}
return { data: null, error: null, refresh, status: 'loading', windowEnd, windowStart };
}
+3
View File
@@ -2,5 +2,8 @@ export * from './auth';
export * from './channels';
export * from './client';
export * from './dashboard';
export * from './guide';
export * from './libraries';
export * from './playouts';
export * from './schedules';
export * from './useChannelsQuery';
+337
View File
@@ -0,0 +1,337 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type MediaSource = components['schemas']['MediaSourceResponseModel'];
export type MediaSourceLibrary = components['schemas']['MediaSourceLibraryResponseModel'];
export type LibraryScanStatus = components['schemas']['LibraryScanStatusResponseModel'];
export interface LibrariesScreenData {
scanStatuses: LibraryScanStatus[];
sources: MediaSource[];
}
export type LibrariesScreenQueryState =
| {
data: LibrariesScreenData;
error: string | null;
refresh: () => void;
scanLibrary: (libraryId: number) => Promise<void>;
scanningLibraryIds: Set<number>;
status: 'success';
}
| { data: null; error: string; refresh: () => void; status: 'error' }
| { data: null; error: null; refresh: () => void; status: 'loading' };
type LibrariesScreenState =
| { data: LibrariesScreenData; error: string | null; pendingLibraryIds: Set<number>; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
// A trigger'd scan disappears from the button-disabled set once either: it has been
// observed at least once in scan-status (promoted to "active"), or this many poll
// ticks pass without ever appearing (the scanner never picked it up / it failed silently).
const PENDING_GRACE_TICKS = 3;
export function getMediaSources(): Promise<MediaSource[]> {
return request<MediaSource[]>('/api/media-sources');
}
export function getLibraryScanStatus(): Promise<LibraryScanStatus[]> {
// Wire contract: `percent` is a 0-1 FRACTION despite the field name - the backend
// never multiplies by 100 (known backend wart, tracked in the handoff backlog).
// Normalize to a 0-100 percentage once, here at the API boundary, so every
// consumer (ProgressBar, percent labels) works in ordinary percentage terms.
return request<LibraryScanStatus[]>('/api/libraries/scan-status').then((scanStatuses) =>
scanStatuses.map((scanStatus) => ({ ...scanStatus, percent: scanStatus.percent * 100 }))
);
}
export function scanLibrary(libraryId: number): Promise<void> {
return request<void>(`/api/libraries/${libraryId}/scan`, { method: 'POST' });
}
// Pure: computes the surviving pending-id set for one poll tick (success or failure) and
// mutates the grace-ticks map in place (delete on promote/expire, set on decrement) -
// callers must still write pendingIdsRef.current with the returned set themselves, and
// must call this exactly once per tick before that write to keep it a single, ref-free
// computation that's safe to run under StrictMode double-invocation.
function pruneGraceExpiredPending(
pendingIds: Set<number>,
graceTicks: Map<number, number>,
isSeenActive: (libraryId: number) => boolean
): Set<number> {
const nextPending = new Set<number>();
pendingIds.forEach((libraryId) => {
if (isSeenActive(libraryId)) {
// Seen active at least once - normal active/inactive pruning takes over.
graceTicks.delete(libraryId);
return;
}
const ticksRemaining = (graceTicks.get(libraryId) ?? PENDING_GRACE_TICKS) - 1;
if (ticksRemaining <= 0) {
// Grace window expired without ever appearing in scan-status - give up on it.
graceTicks.delete(libraryId);
return;
}
graceTicks.set(libraryId, ticksRemaining);
nextPending.add(libraryId);
});
return nextPending;
}
export function useLibrariesScreenQuery(pollMs = 10000): LibrariesScreenQueryState {
const [state, setState] = useState<LibrariesScreenState>({
data: null,
error: null,
status: 'loading'
});
const activeRef = useRef(true);
const hadScanInProgressRef = useRef(false);
// Mirrors of the corresponding state, kept in sync synchronously so triggerScan can
// guard against double-submits without waiting for a render.
const pendingIdsRef = useRef<Set<number>>(new Set());
const activeIdsRef = useRef<Set<number>>(new Set());
const pendingGraceTicksRef = useRef<Map<number, number>>(new Map());
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const loadSources = useCallback(() => {
getMediaSources()
.then((sources) => {
if (!activeRef.current) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { scanStatuses: current.data.scanStatuses, sources },
error: null,
pendingLibraryIds: current.pendingLibraryIds,
status: 'success'
};
});
})
.catch(() => {
// A completion refresh should not hide the screen if the source reload fails.
});
}, []);
const loadScanStatuses = useCallback(() => {
return getLibraryScanStatus()
.then((scanStatuses) => {
if (!activeRef.current) {
return;
}
const activeScanIds = new Set(scanStatuses.map((scan) => scan.libraryId));
activeIdsRef.current = activeScanIds;
const hadScanInProgress = hadScanInProgressRef.current;
hadScanInProgressRef.current = scanStatuses.length > 0;
// Compute the pruned/promoted pending set - and write the grace-tick and pending
// refs - OUTSIDE the setState updater. Updaters must be pure: StrictMode double-
// invokes them (which would double-decrement grace ticks) and concurrent rendering
// may invoke-and-discard one. Mirrors how activeIdsRef is written above.
const nextPending = pruneGraceExpiredPending(pendingIdsRef.current, pendingGraceTicksRef.current, (libraryId) =>
activeScanIds.has(libraryId)
);
pendingIdsRef.current = nextPending;
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { scanStatuses, sources: current.data.sources },
error: null,
pendingLibraryIds: nextPending,
status: 'success'
};
});
if (hadScanInProgress && scanStatuses.length === 0) {
loadSources();
}
})
.catch(() => {
if (!activeRef.current) {
return;
}
// A status-fetch failure never promotes a pending id to active, so it never
// survives via "seen active" - it just burns down the same grace budget as a
// successful poll that never saw it. This keeps a persistently-erroring endpoint
// from leaving the scan button disabled and the poll interval running forever.
// Active-scan state (activeIdsRef / scanStatuses) is left untouched: a transient
// failure must not kill an in-progress scan's UI.
const nextPending = pruneGraceExpiredPending(pendingIdsRef.current, pendingGraceTicksRef.current, () => false);
pendingIdsRef.current = nextPending;
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: current.data,
error: current.error,
pendingLibraryIds: nextPending,
status: 'success'
};
});
});
}, [loadSources]);
const load = useCallback(() => {
Promise.all([getMediaSources(), getLibraryScanStatus()])
.then(([sources, scanStatuses]) => {
if (!activeRef.current) {
return;
}
hadScanInProgressRef.current = scanStatuses.length > 0;
pendingIdsRef.current = new Set();
activeIdsRef.current = new Set(scanStatuses.map((scan) => scan.libraryId));
pendingGraceTicksRef.current = new Map();
setState({
data: { scanStatuses, sources },
error: null,
pendingLibraryIds: new Set(),
status: 'success'
});
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromLibrariesError(error, 'Unable to load libraries'), status: 'error' });
}
});
}, []);
useEffect(() => {
load();
}, [load]);
const hasActiveScans =
state.status === 'success' && (state.pendingLibraryIds.size > 0 || state.data.scanStatuses.length > 0);
useEffect(() => {
if (!hasActiveScans) {
return undefined;
}
const intervalId = window.setInterval(loadScanStatuses, Math.max(pollMs, 10000));
return () => {
window.clearInterval(intervalId);
};
}, [hasActiveScans, loadScanStatuses, pollMs]);
const refresh = useCallback(() => {
setState({ data: null, error: null, status: 'loading' });
load();
}, [load]);
const triggerScan = useCallback((libraryId: number): Promise<void> => {
if (pendingIdsRef.current.has(libraryId) || activeIdsRef.current.has(libraryId)) {
// Already pending or active - ignore the duplicate submission.
return Promise.resolve();
}
pendingIdsRef.current = new Set(pendingIdsRef.current).add(libraryId);
pendingGraceTicksRef.current.set(libraryId, PENDING_GRACE_TICKS);
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: current.data,
error: null,
pendingLibraryIds: new Set(current.pendingLibraryIds).add(libraryId),
status: 'success'
};
});
return scanLibrary(libraryId)
.then(() => loadScanStatuses())
.catch((error: unknown) => {
const pendingIds = new Set(pendingIdsRef.current);
pendingIds.delete(libraryId);
pendingIdsRef.current = pendingIds;
pendingGraceTicksRef.current.delete(libraryId);
if (!activeRef.current) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
const pendingLibraryIds = new Set(current.pendingLibraryIds);
pendingLibraryIds.delete(libraryId);
return {
data: current.data,
error: messageFromLibrariesError(error, 'Unable to scan library'),
pendingLibraryIds,
status: 'success'
};
});
});
}, [loadScanStatuses]);
if (state.status === 'success') {
const activeLibraryIds = new Set(state.data.scanStatuses.map((scan) => scan.libraryId));
const scanningLibraryIds = new Set([...state.pendingLibraryIds, ...activeLibraryIds]);
return {
data: state.data,
error: state.error,
refresh,
scanLibrary: triggerScan,
scanningLibraryIds,
status: 'success'
};
}
if (state.status === 'error') {
return { data: null, error: state.error, refresh, status: 'error' };
}
return { data: null, error: null, refresh, status: 'loading' };
}
function messageFromLibrariesError(error: unknown, fallback = 'Unable to load libraries'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+321
View File
@@ -0,0 +1,321 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type PlayoutSummary = components['schemas']['PlayoutListItemResponseModel'];
export type PlayoutDetail = components['schemas']['PlayoutResponseModel'];
export type PlayoutItem = components['schemas']['PlayoutItemResponseModel'];
export type PlayoutsPage = components['schemas']['PagedPlayoutsResponseModel'];
export type PlayoutItemsPage = components['schemas']['PagedPlayoutItemsResponseModel'];
export type PlayoutChannelState = components['schemas']['ChannelStateResponseModel'];
export interface PlayoutsScreenData {
channelStates: PlayoutChannelState[];
items: PlayoutItem[];
playout: PlayoutDetail | null;
playouts: PlayoutSummary[];
selectedPlayoutId: number | null;
totalCount: number;
warningsCount: number;
}
type PlayoutsScreenBase = Omit<PlayoutsScreenData, 'items' | 'playout'>;
export type PlayoutsScreenQueryState =
| {
data: PlayoutsScreenData;
error: null;
itemsLoading: boolean;
refresh: () => void;
setActivePlayout: (playoutId: number) => void;
setShowFiller: (showFiller: boolean) => void;
showFiller: boolean;
status: 'success';
}
| { data: null; error: string; refresh: () => void; status: 'error' }
| { data: null; error: null; refresh: () => void; status: 'loading' };
type PlayoutsScreenState =
| { data: PlayoutsScreenData; error: null; itemsLoading: boolean; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
export function getPlayouts(): Promise<PlayoutsPage> {
return request<PlayoutsPage>('/api/playouts');
}
export function getPlayout(playoutId: number): Promise<PlayoutDetail> {
return request<PlayoutDetail>(`/api/playouts/${playoutId}`);
}
export function getPlayoutItems(playoutId: number, showFiller = false): Promise<PlayoutItemsPage> {
return request<PlayoutItemsPage>(`/api/playouts/${playoutId}/items${showFiller ? '?showFiller=true' : ''}`);
}
export function getPlayoutWarningsCount(): Promise<number> {
return request<number>('/api/playouts/warnings/count');
}
export function getPlayoutChannelStates(): Promise<PlayoutChannelState[]> {
return request<PlayoutChannelState[]>('/api/channels/state');
}
export function resetAllPlayouts(): Promise<void> {
return request<void>('/api/playouts/reset-all', { method: 'POST' });
}
export function usePlayoutsScreenQuery(pollMs = 30000): PlayoutsScreenQueryState {
const [state, setState] = useState<PlayoutsScreenState>({
data: null,
error: null,
status: 'loading'
});
const [showFiller, setShowFillerState] = useState(false);
const activeRef = useRef(true);
const selectedPlayoutIdRef = useRef<number | null>(null);
const showFillerRef = useRef(false);
// Snapshot of the last-known base fields (everything but the selected playout's
// detail/items), kept in sync whenever load()/loadChannelStates() succeed. Reading
// this lets setActivePlayout/setShowFiller kick off their fetch without doing the
// fetch from inside a setState updater (impure; StrictMode double-invokes updaters).
const baseRef = useRef<PlayoutsScreenBase>({
channelStates: [],
playouts: [],
selectedPlayoutId: null,
totalCount: 0,
warningsCount: 0
});
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const loadSelectedPlayout = useCallback((playoutId: number, base: PlayoutsScreenBase) => {
baseRef.current = base;
Promise.all([getPlayout(playoutId), getPlayoutItems(playoutId, showFillerRef.current)])
.then(([playout, itemsPage]) => {
if (!activeRef.current || selectedPlayoutIdRef.current !== playoutId) {
return;
}
setState((current) => {
// Merge onto whatever channel state the background poll may have landed
// while this request was in flight, rather than clobbering it with the
// snapshot captured when the request started.
const channelStates = current.status === 'success' ? current.data.channelStates : base.channelStates;
return {
data: {
...base,
channelStates,
items: itemsPage.page ?? [],
playout,
selectedPlayoutId: playoutId
},
error: null,
itemsLoading: false,
status: 'success'
};
});
})
.catch((error: unknown) => {
if (activeRef.current && selectedPlayoutIdRef.current === playoutId) {
setState({ data: null, error: messageFromPlayoutError(error), status: 'error' });
}
});
}, []);
const load = useCallback(() => {
Promise.all([getPlayouts(), getPlayoutWarningsCount(), getPlayoutChannelStates()])
.then(([playoutsPage, warningsCount, channelStates]) => {
if (!activeRef.current) {
return;
}
const playouts = playoutsPage.page ?? [];
const selectedPlayoutId = selectedPlayoutIdRef.current && playouts.some((playout) => playout.id === selectedPlayoutIdRef.current)
? selectedPlayoutIdRef.current
: playouts[0]?.id ?? null;
selectedPlayoutIdRef.current = selectedPlayoutId;
const base: PlayoutsScreenBase = {
channelStates,
playouts,
selectedPlayoutId,
totalCount: playoutsPage.totalCount,
warningsCount
};
baseRef.current = base;
if (selectedPlayoutId == null) {
setState({
data: {
...base,
items: [],
playout: null
},
error: null,
itemsLoading: false,
status: 'success'
});
return;
}
loadSelectedPlayout(selectedPlayoutId, base);
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromPlayoutError(error), status: 'error' });
}
});
}, [loadSelectedPlayout]);
const loadChannelStates = useCallback(() => {
getPlayoutChannelStates()
.then((channelStates) => {
if (!activeRef.current) {
return;
}
baseRef.current = { ...baseRef.current, channelStates };
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { ...current.data, channelStates },
error: null,
itemsLoading: current.itemsLoading,
status: 'success'
};
});
})
.catch(() => {
// Keep the monitor visible on background polling failures.
});
}, []);
useEffect(() => {
load();
const intervalId = window.setInterval(loadChannelStates, pollMs);
return () => {
window.clearInterval(intervalId);
};
}, [load, loadChannelStates, pollMs]);
const refresh = useCallback(() => {
setState({ data: null, error: null, status: 'loading' });
load();
}, [load]);
const setActivePlayout = useCallback((playoutId: number) => {
selectedPlayoutIdRef.current = playoutId;
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: {
...current.data,
items: [],
playout: null,
selectedPlayoutId: playoutId
},
error: null,
itemsLoading: true,
status: 'success'
};
});
const base: PlayoutsScreenBase = { ...baseRef.current, selectedPlayoutId: playoutId };
loadSelectedPlayout(playoutId, base);
}, [loadSelectedPlayout]);
const setShowFiller = useCallback((next: boolean) => {
showFillerRef.current = next;
setShowFillerState(next);
const playoutId = selectedPlayoutIdRef.current;
if (playoutId == null) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
return { ...current, itemsLoading: true };
});
getPlayoutItems(playoutId, next)
.then((itemsPage) => {
if (!activeRef.current || selectedPlayoutIdRef.current !== playoutId) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { ...current.data, items: itemsPage.page ?? [] },
error: null,
itemsLoading: false,
status: 'success'
};
});
})
.catch((error: unknown) => {
if (!activeRef.current || selectedPlayoutIdRef.current !== playoutId) {
return;
}
setState({ data: null, error: messageFromPlayoutError(error), status: 'error' });
});
}, []);
if (state.status === 'success') {
return {
data: state.data,
error: null,
itemsLoading: state.itemsLoading,
refresh,
setActivePlayout,
setShowFiller,
showFiller,
status: 'success'
};
}
if (state.status === 'error') {
return { data: null, error: state.error, refresh, status: 'error' };
}
return { data: null, error: null, refresh, status: 'loading' };
}
function messageFromPlayoutError(error: unknown, fallback = 'Unable to load playouts'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+858 -1
View File
@@ -1308,6 +1308,829 @@
line-height: 1.4;
}
.ctv-playouts-screen {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
gap: var(--space-6, 12px);
}
.ctv-playouts-header {
display: flex;
align-items: center;
gap: var(--space-5, 10px);
border: 1px solid var(--border-hairline);
border-radius: var(--radius-md, 7px);
background: var(--surface-card);
padding: var(--space-6, 12px) var(--space-8, 20px);
}
.ctv-playouts-header-spacer {
flex: 1;
}
.ctv-playouts-grid {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
gap: var(--space-8, 20px);
flex: 1;
min-height: 0;
}
.ctv-playouts-rail,
.ctv-playouts-monitor {
min-height: 0;
border: 1px solid var(--border-hairline);
border-radius: var(--radius-md, 7px);
background: var(--surface-card);
}
.ctv-playouts-rail {
display: flex;
flex-direction: column;
overflow: hidden;
}
.ctv-playouts-filter {
display: grid;
gap: var(--space-4, 8px);
border-bottom: 1px solid var(--border-hairline);
padding: var(--space-6, 12px);
}
.ctv-playouts-filter > span {
color: var(--text-disabled);
font-size: var(--text-2xs, 11px);
}
.ctv-playouts-list {
display: grid;
align-content: start;
gap: var(--space-2, 4px);
overflow: auto;
padding: var(--space-4, 8px);
}
.ctv-playout-option {
display: grid;
grid-template-columns: 30px minmax(0, 1fr) 10px;
align-items: center;
gap: var(--space-5, 10px);
width: 100%;
border: 0;
border-radius: var(--radius-sm, 5px);
background: transparent;
color: inherit;
cursor: pointer;
padding: var(--space-4, 8px);
text-align: left;
}
.ctv-playout-option:hover,
.ctv-playout-option-active {
background: var(--ctv-accent-soft);
}
.ctv-playout-option > span {
display: grid;
min-width: 0;
gap: var(--space-2, 4px);
}
.ctv-playout-option code,
.ctv-playouts-title code,
.ctv-playout-now-copy code,
.ctv-playout-upcoming code,
.ctv-playout-upcoming small,
.ctv-playout-timeline code {
font-family: var(--font-mono, ui-monospace, monospace);
font-variant-numeric: tabular-nums;
}
.ctv-playout-option code {
color: var(--status-live);
font-size: var(--text-2xs, 11px);
}
.ctv-playout-option strong,
.ctv-playout-next strong {
overflow: hidden;
color: var(--text-primary);
font-size: var(--text-sm, 13px);
font-weight: var(--weight-semibold, 600);
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.ctv-playout-option small,
.ctv-playout-next small {
overflow: hidden;
color: var(--text-secondary);
font-size: var(--text-2xs, 11px);
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.ctv-playouts-monitor {
display: flex;
flex-direction: column;
gap: var(--space-7, 16px);
overflow: auto;
padding: var(--space-8, 20px);
}
.ctv-playouts-title {
display: flex;
align-items: center;
gap: var(--space-6, 12px);
}
.ctv-playouts-title > div {
display: grid;
min-width: 0;
gap: var(--space-2, 4px);
}
.ctv-playouts-title span {
display: flex;
align-items: center;
gap: var(--space-4, 8px);
color: var(--text-secondary);
font-size: var(--text-xs, 12px);
}
.ctv-playouts-title h2,
.ctv-playout-now-copy h3 {
margin: 0;
color: var(--text-primary);
font-weight: var(--weight-semibold, 600);
line-height: 1.2;
}
.ctv-playouts-title h2 {
font-size: var(--text-md, 14px);
}
.ctv-playout-now {
display: grid;
grid-template-columns: 210px minmax(0, 1fr);
overflow: hidden;
border: 1px solid var(--border-hairline);
border-radius: var(--radius-md, 7px);
background: var(--surface-card);
}
.ctv-playout-preview {
display: flex;
position: relative;
align-items: center;
justify-content: center;
min-height: 150px;
border-right: 1px solid var(--border-hairline);
background: var(--ctv-bg-sunken);
color: var(--ctv-accent);
}
.ctv-playout-preview .ctv-badge {
position: absolute;
left: var(--space-5, 10px);
bottom: var(--space-5, 10px);
}
.ctv-playout-now-copy {
display: flex;
justify-content: center;
flex-direction: column;
gap: var(--space-5, 10px);
min-width: 0;
padding: var(--space-8, 20px);
}
.ctv-playout-now-copy > span {
color: var(--status-live);
font-size: var(--text-2xs, 11px);
font-weight: var(--weight-semibold, 600);
text-transform: uppercase;
}
.ctv-playout-now-copy h3 {
font-size: var(--text-lg, 16px);
}
.ctv-playout-now-copy > div:last-child {
display: flex;
justify-content: space-between;
gap: var(--space-6, 12px);
color: var(--text-disabled);
font-size: var(--text-2xs, 11px);
}
.ctv-playouts-cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-7, 16px);
}
.ctv-playout-next {
display: flex;
align-items: center;
gap: var(--space-6, 12px);
}
.ctv-playout-next > svg {
color: var(--ctv-accent);
flex: 0 0 auto;
}
.ctv-playout-next > span {
display: grid;
min-width: 0;
gap: var(--space-2, 4px);
}
.ctv-playout-detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-5, 10px);
}
.ctv-playout-timeline {
display: grid;
gap: var(--space-4, 8px);
}
.ctv-playout-timeline > div {
display: flex;
position: relative;
overflow: hidden;
height: 34px;
border: 1px solid var(--border-hairline);
border-radius: var(--radius-sm, 5px);
background: var(--ctv-bg-sunken);
}
.ctv-playout-timeline span {
display: flex;
align-items: center;
overflow: hidden;
border-right: 1px solid var(--ctv-bg);
background: var(--ctv-accent-soft);
color: var(--ctv-accent);
font-size: var(--text-2xs, 11px);
font-weight: var(--weight-medium, 500);
padding: 0 var(--space-4, 8px);
text-overflow: ellipsis;
white-space: nowrap;
}
.ctv-playout-timeline .ctv-playout-timeline-filler {
background: var(--ctv-surface-3);
color: var(--text-secondary);
}
.ctv-playout-timeline i {
position: absolute;
top: -3px;
bottom: -3px;
width: 2px;
background: var(--status-live);
box-shadow: 0 0 8px var(--status-live);
}
.ctv-playout-timeline p {
display: flex;
justify-content: space-between;
margin: 0;
color: var(--text-disabled);
font-size: var(--text-2xs, 11px);
}
.ctv-playout-timeline p span {
display: inline-flex;
align-items: center;
gap: var(--space-2, 4px);
color: var(--status-live);
}
.ctv-playout-upcoming > div {
display: grid;
grid-template-columns: 58px 14px minmax(0, 1fr) auto 64px;
align-items: center;
gap: var(--space-5, 10px);
border-top: 1px solid var(--border-hairline);
padding: var(--space-5, 10px) var(--space-7, 16px);
}
.ctv-playout-upcoming > div:first-child {
border-top: 0;
}
.ctv-playout-upcoming-now {
background: var(--ctv-live-soft);
}
.ctv-playout-upcoming svg {
color: var(--ctv-accent);
}
.ctv-playout-upcoming span {
overflow: hidden;
color: var(--text-primary);
font-size: var(--text-sm, 13px);
text-overflow: ellipsis;
white-space: nowrap;
}
.ctv-playout-upcoming code,
.ctv-playout-upcoming small {
color: var(--text-disabled);
font-size: var(--text-2xs, 11px);
}
.ctv-libraries-screen {
display: flex;
flex-direction: column;
min-height: 0;
gap: var(--space-6, 12px);
}
.ctv-libraries-header {
display: flex;
align-items: center;
gap: var(--space-7, 16px);
justify-content: space-between;
border: 1px solid var(--border-hairline);
border-radius: var(--radius-md, 7px);
background: var(--surface-card);
padding: var(--space-7, 16px) var(--space-8, 20px);
}
.ctv-libraries-header h2 {
margin: var(--space-2, 4px) 0;
color: var(--text-primary);
font-size: var(--text-xl, 20px);
line-height: 1.15;
}
.ctv-libraries-header p {
margin: 0;
color: var(--text-secondary);
font-size: var(--text-sm, 13px);
}
.ctv-libraries-header-actions,
.ctv-libraries-summary {
display: flex;
align-items: center;
gap: var(--space-5, 10px);
flex-wrap: wrap;
}
.ctv-libraries-summary {
color: var(--text-secondary);
font-size: var(--text-xs, 12px);
}
.ctv-libraries-summary span {
display: inline-flex;
align-items: center;
gap: var(--space-3, 6px);
}
.ctv-libraries-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
align-items: start;
gap: var(--space-7, 16px);
}
.ctv-library-source-card,
.ctv-library-add-card {
overflow: hidden;
border: 1px solid var(--border-hairline);
border-radius: var(--radius-md, 7px);
background: var(--surface-card);
}
.ctv-library-source-head {
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-5, 10px);
border-bottom: 1px solid var(--border-hairline);
padding: var(--space-6, 12px) var(--space-7, 16px);
}
.ctv-library-kind-icon,
.ctv-library-media-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.ctv-library-source-actions {
display: flex;
align-items: center;
gap: var(--space-2, 4px);
}
.ctv-library-kind-icon {
width: 34px;
height: 34px;
border-radius: var(--radius-sm, 5px);
background: var(--ctv-surface-3);
color: var(--ctv-accent);
}
.ctv-library-kind-plex {
color: #e5a00d;
}
.ctv-library-kind-jellyfin {
color: #8a5cf6;
}
.ctv-library-kind-emby {
color: #52b54b;
}
.ctv-library-source-title {
display: flex;
align-items: center;
min-width: 0;
gap: var(--space-4, 8px);
}
.ctv-library-source-title h3,
.ctv-library-add-card h3 {
margin: 0;
color: var(--text-primary);
font-size: var(--text-sm, 13px);
line-height: 1.15;
}
.ctv-library-source-head span:not(.ctv-library-kind-icon),
.ctv-library-source-foot,
.ctv-library-row-main span,
.ctv-library-row-main small,
.ctv-library-add-card p {
color: var(--text-secondary);
font-size: var(--text-xs, 12px);
}
.ctv-library-list {
display: grid;
}
.ctv-library-row {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-5, 10px);
border-top: 1px solid var(--border-hairline);
padding: var(--space-5, 10px) var(--space-7, 16px);
}
.ctv-library-row:first-child {
border-top: 0;
}
.ctv-library-media-icon {
width: 28px;
height: 28px;
border-radius: var(--radius-xs, 3px);
background: var(--ctv-bg-sunken);
color: var(--text-secondary);
}
.ctv-library-row-main {
display: grid;
min-width: 0;
gap: var(--space-2, 4px);
}
.ctv-library-row-main strong {
overflow: hidden;
color: var(--text-primary);
font-size: var(--text-sm, 13px);
text-overflow: ellipsis;
white-space: nowrap;
}
.ctv-library-row-status {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-4, 8px);
}
.ctv-library-progress {
display: grid;
width: 118px;
gap: var(--space-3, 6px);
}
.ctv-library-source-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-5, 10px);
border-top: 1px solid var(--border-hairline);
background: var(--ctv-bg-sunken);
padding: var(--space-5, 10px) var(--space-7, 16px);
}
.ctv-library-source-foot span {
display: inline-flex;
align-items: center;
gap: var(--space-3, 6px);
}
.ctv-library-empty {
color: var(--text-disabled);
font-size: var(--text-xs, 12px);
padding: var(--space-7, 16px);
}
.ctv-library-add-card {
display: flex;
min-height: 190px;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: var(--space-6, 12px);
border-style: dashed;
border-color: var(--border-control);
padding: var(--space-7, 16px);
}
.ctv-library-add-card p {
margin: var(--space-2, 4px) 0 0;
line-height: 1.4;
}
.ctv-library-add-options {
display: flex;
flex-wrap: wrap;
gap: var(--space-3, 6px);
}
.ctv-library-add-options span {
display: inline-flex;
align-items: center;
gap: var(--space-3, 6px);
border: 1px solid var(--border-hairline);
border-radius: var(--radius-sm, 5px);
background: var(--ctv-surface-2);
color: var(--text-secondary);
font-size: var(--text-xs, 12px);
font-weight: var(--weight-medium, 500);
padding: var(--space-3, 6px) var(--space-5, 10px);
}
.ctv-epg-screen {
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: var(--space-6, 12px);
}
.ctv-epg-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-5, 10px);
padding: var(--space-5, 10px) 0;
border-bottom: 1px solid var(--border-hairline);
}
.ctv-epg-filter {
width: 190px;
flex: 0 1 190px;
}
.ctv-epg-window {
min-width: 0;
max-width: 420px;
display: flex;
flex: 1 1 240px;
align-items: center;
gap: var(--space-4, 8px);
}
.ctv-epg-window span {
color: var(--text-disabled);
font-family: var(--font-mono, ui-monospace, monospace);
font-size: var(--text-2xs, 11px);
font-variant-numeric: tabular-nums;
}
.ctv-epg-window input {
min-width: 0;
flex: 1;
accent-color: var(--action-primary);
}
.ctv-epg-grid-shell {
min-height: 420px;
overflow: hidden;
border: 1px solid var(--border-hairline);
border-radius: var(--radius-sm, 5px);
background: var(--surface-app);
}
.ctv-epg-scroll {
width: 100%;
height: 100%;
overflow: auto;
}
.ctv-epg-grid {
position: relative;
width: max-content;
min-height: 100%;
}
.ctv-epg-time-head {
position: sticky;
top: 0;
z-index: 4;
display: flex;
border-bottom: 1px solid var(--border-hairline);
background: var(--surface-app);
}
.ctv-epg-rail-head,
.ctv-epg-channel-rail {
position: sticky;
left: 0;
z-index: 3;
flex: 0 0 auto;
border-right: 1px solid var(--border-hairline);
background: var(--surface-card);
}
.ctv-epg-rail-head {
z-index: 5;
background: var(--surface-app);
}
.ctv-epg-time-slot {
flex: 0 0 auto;
display: flex;
align-items: center;
border-right: 1px solid var(--border-hairline);
color: var(--text-secondary);
font-family: var(--font-mono, ui-monospace, monospace);
font-size: var(--text-xs, 12px);
font-variant-numeric: tabular-nums;
padding: 0 var(--space-5, 10px);
}
.ctv-epg-now-marker {
position: absolute;
z-index: 2;
width: 2px;
background: var(--status-live);
pointer-events: none;
}
.ctv-epg-now-marker span {
position: absolute;
top: -5px;
left: -4px;
width: 10px;
height: 10px;
border-radius: var(--radius-pill, 999px);
background: var(--status-live);
box-shadow: 0 0 8px var(--status-live);
}
.ctv-epg-row {
display: flex;
border-bottom: 1px solid var(--border-hairline);
}
.ctv-epg-channel-rail {
display: flex;
align-items: center;
gap: var(--space-5, 10px);
padding: 0 var(--space-6, 12px);
}
.ctv-epg-channel-rail code {
width: 30px;
flex: 0 0 auto;
color: var(--status-live);
font-family: var(--font-mono, ui-monospace, monospace);
font-size: var(--text-xs, 12px);
font-weight: var(--weight-medium, 500);
font-variant-numeric: tabular-nums;
}
.ctv-epg-channel-rail > span:not(.ctv-channel-logo):not(.ctv-status-dot) {
min-width: 0;
overflow: hidden;
color: var(--text-primary);
font-size: var(--text-sm, 13px);
font-weight: var(--weight-medium, 500);
text-overflow: ellipsis;
white-space: nowrap;
}
.ctv-epg-track {
position: relative;
flex: 1;
min-width: 0;
}
.ctv-epg-track-alt {
background: color-mix(in srgb, var(--surface-raised) 18%, transparent);
}
.ctv-epg-empty {
position: absolute;
top: 50%;
left: var(--space-6, 12px);
transform: translateY(-50%);
color: var(--text-disabled);
font-size: var(--text-xs, 12px);
}
.ctv-epg-programme {
position: absolute;
top: 5px;
bottom: 5px;
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
overflow: hidden;
border: 1px solid var(--border-hairline);
border-radius: var(--radius-sm, 5px);
background: var(--surface-raised);
padding: var(--space-4, 8px) var(--space-5, 10px);
}
.ctv-epg-programme-live {
border-color: var(--status-live);
background: var(--ctv-live-soft);
}
.ctv-epg-programme-filler {
background: var(--ctv-bg-sunken);
}
.ctv-epg-programme-title {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-3, 6px);
}
.ctv-epg-programme-title strong,
.ctv-epg-programme span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ctv-epg-programme-title strong {
color: var(--text-primary);
font-size: var(--text-xs, 12px);
font-weight: var(--weight-medium, 500);
}
.ctv-epg-programme-filler .ctv-epg-programme-title strong {
color: var(--text-disabled);
}
.ctv-epg-programme span {
color: var(--text-secondary);
font-size: var(--text-2xs, 11px);
}
.ctv-epg-programme small {
margin-top: auto;
overflow: hidden;
color: var(--text-disabled);
font-size: 9px;
font-weight: var(--weight-medium, 500);
letter-spacing: var(--tracking-caps, 0.06em);
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.ctv-epg-programme-live small {
color: var(--status-live);
}
@media (max-width: 980px) {
.ctv-app-shell {
grid-template-columns: 1fr;
@@ -1376,10 +2199,44 @@
}
.ctv-schedule-grid,
.ctv-schedule-form-grid {
.ctv-schedule-form-grid,
.ctv-playouts-grid,
.ctv-playout-detail-grid,
.ctv-playouts-cards,
.ctv-libraries-grid {
grid-template-columns: 1fr;
}
.ctv-libraries-header,
.ctv-library-source-foot {
align-items: flex-start;
flex-direction: column;
}
.ctv-library-row {
grid-template-columns: 28px minmax(0, 1fr);
}
.ctv-library-row-status {
grid-column: 2 / -1;
justify-content: flex-start;
}
.ctv-epg-toolbar {
align-items: stretch;
flex-direction: column;
}
.ctv-epg-filter,
.ctv-epg-window {
width: 100%;
max-width: none;
}
.ctv-epg-grid-shell {
min-height: 360px;
}
.ctv-schedule-block-main {
grid-template-columns: 16px 38px minmax(0, 1fr);
}