Design: Catch Up and the sleep timer, for Paul's approval
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 2m1s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m42s

Four screens in the approved guide's style (design/catch-up/build-catchup.py):
the guide scrolled back into the last two days with a Catch Up mark on past
programmes, the Catch Up pop-up (watch from the start, or both parts of a
two-part story), watching from the start with skip, pause and back to live,
and the sleep timer in the watching panel with its one-minute warning.
Roadmap notes Silo (siloserver.org) among the servers to support.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-05 19:40:55 +00:00
co-authored by Claude Opus 5.5
parent b731c9336e
commit 883f9595cf
6 changed files with 137 additions and 2 deletions
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""Catch Up and the sleep timer, design proposal (Paul, 5 November 2026). Four TV screens in the approved guide's style: the
guide scrolled back into the last two days, the Catch Up pop-up, watching a programme from the start, and the sleep timer in
the watching panel with its one-minute warning. A design for approval only: nothing in the app yet."""
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
HERE = Path(__file__).resolve().parent
F = HERE.parent / 'channels' / 'network-fonts'
def font(name, size): return ImageFont.truetype(str(F / name), size)
W, H = 1920, 1080
NAVY, PANEL, PANEL2, BLUE, YELLOW, WHITE, MUTED, PALE, DIM, GREEN = (7, 22, 50), (18, 53, 107), (24, 66, 130), (25, 76, 145), (255, 222, 89), (255, 255, 255), (183, 203, 232), (232, 238, 247), (12, 32, 70), (110, 220, 170)
anton, semi, reg, cond = (lambda s: font('anton-regular.ttf', s)), (lambda s: font('barlow-semibold.ttf', s)), (lambda s: font('barlow-regular.ttf', s)), (lambda s: font('barlow-condensed-medium.ttf', s))
def screen(): im = Image.new('RGB', (W, H), NAVY); return im, ImageDraw.Draw(im)
def picture():
"""A programme playing: the approved watching mock-up's city scene, with its own bar covered by ours."""
src = Image.open(HERE.parent / 'interface' / 'review' / 'orbit_grid_guide.png').convert('RGB').crop((64, 120, 634, 438))
im = src.resize((W, H), Image.LANCZOS)
return im, ImageDraw.Draw(im)
def box(d, x, y, w, h, fill=PANEL, r=10, outline=None, width=3): d.rounded_rectangle((x, y, x + w, y + h), r, fill=fill, outline=outline, width=width if outline else 0)
def header(d, when):
d.text((80, 40), 'ORBIT', font=semi(64), fill=WHITE); d.text((1460, 46), when, font=semi(40), fill=WHITE)
def arrows(d, x, y, kind, colour):
"""Draws ◄► or ▲▼ as shapes (the fonts have no arrow glyphs); returns the width used."""
s = 22
if kind == 'lr':
d.polygon([(x, y + s // 2 + 6), (x + s, y + 6), (x + s, y + s + 6)], fill=colour); d.polygon([(x + s + 8, y + 6), (x + 2 * s + 8, y + s // 2 + 6), (x + s + 8, y + s + 6)], fill=colour)
else:
d.polygon([(x, y + s + 6), (x + s // 2, y + 6), (x + s, y + s + 6)], fill=colour); d.polygon([(x + s + 8, y + 6), (x + 2 * s + 8, y + 6), (x + s + s // 2 + 8, y + s + 6)], fill=colour)
return 2 * s + 8
def keys(d, items, y=1020, colour=WHITE, label_colour=MUTED, size=32):
x = 70
for k, label in items:
if k in ('◄►', '▲▼'): x += arrows(d, x, y, 'lr' if k == '◄►' else 'ud', colour) + 16
else: d.text((x, y), k, font=semi(size), fill=colour); x += d.textlength(k, font=semi(size)) + 16
d.text((x, y), label, font=reg(size), fill=label_colour); x += d.textlength(label, font=reg(size)) + 48
def catch_mark(d, x, y, colour=GREEN):
# a small rewind arc: the Catch Up mark on a past programme
d.arc((x, y, x + 30, y + 30), 40, 330, fill=colour, width=4); d.polygon([(x + 23, y - 2), (x + 33, y + 6), (x + 21, y + 10)], fill=colour)
# 1. The guide, scrolled back into the last two days.
im, d = screen(); header(d, 'Thu 5 Nov 20:14')
d.text((80, 140), 'CATCH UP', font=anton(46), fill=GREEN)
d.text((80, 205), 'Doctor Who', font=semi(64), fill=WHITE)
d.text((82, 285), '18:00 to 18:45 · Thu 5 Nov · Orbit Paradox · aired 1 hour ago', font=semi(32), fill=MUTED)
d.text((82, 340), 'S3 E8 · Human Nature (1). Part 1 of 2: The Family of Blood follows in Catch Up too.', font=reg(32), fill=WHITE)
d.text((82, 400), 'OK Catch Up · available for 2 days after it airs', font=reg(28), fill=MUTED)
box(d, 60, 470, 1800, 48, fill=BLUE, r=0)
d.text((80, 478), 'All Channels', font=semi(30), fill=WHITE)
for i, t in enumerate(('17:30', '18:00', '18:30', '19:00', '19:30', '20:00')):
d.text((410 + i * 245, 478), t, font=semi(30), fill=WHITE)
# Half-hour steps from 17:30; now is 20:14.
rows = [('101', 'Orbit 1', [('The Fresh Prince of Bel-Air', 0, 2, 'past'), ('Saved by the Bell', 2, 3, 'past'), ('Full House', 3, 4, 'past'), ('Family Matters', 4, 5, 'past'), ('Sabrina', 5, 6, 'now')]),
('106', 'Paradox', [('The X-Files', 0, 1, 'past'), ('Doctor Who', 1, 2.5, 'focus'), ('Stargate SG-1', 2.5, 4, 'past'), ('Sliders', 4, 5.5, 'now')]),
('107', 'Evidence', [('CSI', 0, 2, 'past'), ('NCIS', 2, 3.5, 'past'), ('Law & Order', 3.5, 5.5, 'now')]),
('201', 'Terra', [('Planet Earth', 0, 2, 'past'), ('Blue Planet', 2, 4, 'past'), ('Frozen Planet', 4, 6, 'now')])]
y = 530
for num, name, progs in rows:
box(d, 60, y, 330, 82, fill=PANEL, r=0); d.text((76, y + 22), num, font=semi(32), fill=WHITE); d.text((150, y + 22), name, font=semi(32), fill=WHITE)
for title, a, b, state in progs:
x0, x1 = 400 + a * 245, min(400 + b * 245, 1866)
fill = {'past': DIM, 'focus': YELLOW, 'now': PANEL2}[state]
box(d, x0, y, x1 - x0 - 6, 82, fill=fill, r=0)
colour = NAVY if state == 'focus' else (MUTED if state == 'past' else WHITE)
arc = state in ('past', 'focus')
room = (x1 - x0) - 36 - (48 if arc else 0)
shown = title
while d.textlength(shown, font=reg(30)) > room and len(shown) > 3: shown = shown[:-2].rstrip() + '…'
d.text((x0 + 18, y + 24), shown, font=reg(30), fill=colour)
if arc: catch_mark(d, x1 - 50, y + 26, NAVY if state == 'focus' else GREEN)
y += 92
nowx = 400 + (164 / 30) * 245
d.line((nowx, 520, nowx, 900), fill=WHITE, width=3)
d.text((80, 930), 'Scroll left past now and the guide goes back two days. Programmes with the arc can be watched from the start.', font=reg(28), fill=MUTED)
keys(d, [('◄►', 'Time'), ('▲▼', 'Channel'), ('OK', 'Catch Up'), ('Hold OK', 'Number'), ('Back', 'Categories')])
im.save(HERE / 'catch-up-1-guide.png')
# 2. The Catch Up pop-up.
im, d = screen(); header(d, 'Thu 5 Nov 20:14')
for k in range(6): box(d, 60, 470 + k * 92, 1800, 82, fill=(10, 27, 60), r=0)
box(d, 420, 170, 1080, 680, fill=(10, 30, 66), r=18, outline=YELLOW)
d.text((470, 210), 'CATCH UP', font=anton(36), fill=GREEN); d.text((1270, 214), 'ORBIT PARADOX', font=semi(28), fill=MUTED)
d.text((470, 265), 'DOCTOR WHO', font=anton(78), fill=WHITE)
d.text((474, 375), 'S3 E8 · Human Nature (1)', font=semi(34), fill=WHITE)
d.text((474, 425), 'Aired today 18:00 to 18:45 · 45 min · watchable until Sat 7 Nov 18:00', font=reg(28), fill=MUTED)
d.text((474, 480), 'England, 1913: a schoolteacher called John Smith dreams of a', font=reg(30), fill=WHITE)
d.text((474, 520), 'blue box and a life he can\'t remember.', font=reg(30), fill=WHITE)
box(d, 470, 590, 500, 74, fill=YELLOW, r=8); d.text((500, 608), 'Watch from the start', font=semi(32), fill=NAVY)
box(d, 990, 590, 460, 74, fill=PANEL, r=8, outline=(56, 98, 154), width=2); d.text((1020, 608), 'Watch parts 1 and 2', font=reg(30), fill=WHITE)
box(d, 470, 684, 300, 64, fill=PANEL, r=8, outline=(56, 98, 154), width=2); d.text((500, 698), 'Back to guide', font=reg(30), fill=WHITE)
d.text((474, 780), 'A two-part story: "Watch parts 1 and 2" plays The Family of Blood straight after.', font=reg(26), fill=MUTED)
keys(d, [('◄►', 'Choose'), ('OK', 'Select'), ('Back', 'Close')])
im.save(HERE / 'catch-up-2-popup.png')
# 3. Watching from the start.
im, d = picture()
box(d, 70, 60, 300, 64, fill=GREEN, r=8); d.text((96, 72), 'CATCH UP', font=anton(36), fill=NAVY)
box(d, 132, 760, 1656, 252, fill=(24, 66, 130), r=0)
box(d, 132, 760, 1656, 46, fill=PALE, r=0); d.text((158, 766), '106 Orbit Paradox · Catch Up', font=semi(30), fill=NAVY); d.text((1500, 766), '20:16 Thu 5 Nov', font=reg(30), fill=NAVY)
d.text((160, 826), 'FROM THE START', font=semi(28), fill=GREEN); d.text((420, 820), 'Doctor Who · Human Nature (1)', font=semi(42), fill=WHITE)
d.line((420, 896, 1700, 896), fill=(10, 40, 90), width=10); d.line((420, 896, 640, 896), fill=WHITE, width=10)
d.text((420, 912), '07:42 of 45:00', font=reg(28), fill=MUTED); d.text((1420, 912), 'Live now: Sliders', font=reg(28), fill=MUTED)
box(d, 132, 960, 1656, 52, fill=PALE, r=0)
x = 160
for k, label in (('◄►', 'Skip 30 s'), ('OK', 'Pause'), ('▲▼', 'Back to live channels'), ('Back', 'Leave Catch Up')):
if k in ('◄►', '▲▼'): x += arrows(d, x, 966, 'lr' if k == '◄►' else 'ud', NAVY) + 14
else: d.text((x, 966), k, font=semi(30), fill=NAVY); x += d.textlength(k, font=semi(30)) + 14
d.text((x, 966), label, font=reg(30), fill=NAVY); x += d.textlength(label, font=reg(30)) + 60
im.save(HERE / 'catch-up-3-watching.png')
# 4. The sleep timer, in the watching panel, and its warning.
im, d = picture()
box(d, 1340, 0, 580, H, fill=(10, 30, 66), r=0)
d.text((1380, 60), 'WATCHING', font=anton(40), fill=WHITE)
opts = [('Subtitles', 'Off'), ('Audio', 'English'), ('Sleep timer', '60 min')]
y = 140
for label, value in opts:
focus = label == 'Sleep timer'
box(d, 1370, y, 520, 96, fill=YELLOW if focus else PANEL, r=8)
d.text((1396, y + 14), label, font=semi(32), fill=NAVY if focus else WHITE); d.text((1396, y + 54), value, font=reg(28), fill=NAVY if focus else MUTED)
y += 112
d.text((1380, 500), 'SLEEP TIMER', font=semi(26), fill=MUTED)
for k, v in enumerate(('Off', '30 min', '60 min', '90 min', 'After this programme')):
on = v == '60 min'
box(d, 1370, 540 + k * 74, 520, 62, fill=PANEL2 if on else PANEL, r=8, outline=GREEN if on else None, width=2)
d.text((1396, 552 + k * 74), v, font=reg(30), fill=WHITE)
if on: d.line([(1840, 572 + k * 74), (1852, 586 + k * 74), (1872, 558 + k * 74)], fill=GREEN, width=5)
d.text((1380, 930), 'Switches Orbit off and lets the TV', font=reg(26), fill=MUTED); d.text((1380, 964), 'sleep. Resets each time you open it.', font=reg(26), fill=MUTED)
box(d, 300, 120, 760, 210, fill=(10, 30, 66), r=14, outline=YELLOW)
d.text((340, 150), 'SLEEP TIMER', font=anton(34), fill=YELLOW)
d.text((340, 205), 'Orbit switches off in 1 minute', font=semi(40), fill=WHITE)
d.text((340, 262), 'Press any button to keep watching', font=reg(30), fill=MUTED)
im.save(HERE / 'sleep-timer.png')
print('done')
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 746 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 590 KiB

+2 -2
View File
@@ -392,10 +392,10 @@ The project is now in git, so each release can also be rolled back from its comm
Paul will watch Orbit for a few weeks first, so the channels, the Director's Chair and its learning mature on real use before anyone else gets it. In rough order:
1. **Catch-up:** watch a programme that aired in the last couple of days from the start. Holding OK on a past programme in the guide offers "Watch from the start"; the channel itself carries on. Design first (FigCraft mock-up for Paul's approval), then build.
1. **Catch-up:** watch a programme that aired in the last couple of days from the start; the channel itself carries on. Designed 5 November 2026 (design/catch-up): the guide scrolls back two days, past programmes carry a Catch Up mark, OK on one opens the Catch Up pop-up (Hold OK stays the number box), a two-part story offers both parts. Awaiting approval, then build.
2. **Sleep timer:** fade out and stop after 30, 60 or 90 minutes.
3. **First run (pre-launch setup):** a guided first launch that connects a library, checks it against Orbit's channel presets and switches on the channels it can fill; sets the After Dark lock (a PIN on After Dark and The Late Show) and the sleep timer there too.
4. **More media servers:** beyond Emby, Plex and Jellyfin (and "Silo", to confirm with Paul what that is), behind one library interface so the scheduler, the Chair and the Drive-In don't care which server it is.
4. **More media servers:** beyond Emby, Plex, Jellyfin and Silo (a new open-source media server, https://siloserver.org/), behind one library interface so the scheduler, the Chair and the Drive-In don't care which server it is.
5. **Your own channels:** people choose which channels they want, use Orbit's presets (the Chair picks the shows that best suit each channel from their own library), create their own channels and add or remove shows.
Not built yet; each needs its own proposal and approval.