Design: first launch for a new user
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m49s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m28s

Paul, 5 November 2026: welcome with Ollie, choose the media server (Emby,
Plex, Jellyfin, Silo), sign in while Drift reads the library, pick channels
suggested from it, who watches (Grown-ups PIN, Kids profile), all set.
For approval only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-05 23:15:35 +00:00
co-authored by Claude Opus 5.5
parent 8527e9daf6
commit 1970af8a28
7 changed files with 140 additions and 0 deletions
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""First launch for a new user, design proposal (Paul, 5 November 2026): welcome with Ollie, choose your media server, sign in,
Drift reads the library, pick your channels, who watches (the Grown-ups PIN and a Kids profile), all set. The starry style and
drawing come from the profiles design (build-profiles.py) and the cast's model sheet. A design for approval only."""
import ast
from pathlib import Path
HERE = Path(__file__).resolve().parent
PROFILES = HERE.parent / 'profiles' / 'build-profiles.py'
# Take build-profiles.py's set-up and drawing, without its screens.
tree = ast.parse(PROFILES.read_text())
keep = []
for n in tree.body:
if isinstance(n, ast.Expr) and isinstance(n.value, ast.Call) and getattr(n.value.func, 'id', '') != 'exec': continue
if isinstance(n, (ast.Import, ast.ImportFrom, ast.FunctionDef, ast.Expr)) or (isinstance(n, ast.Assign) and not any(isinstance(t, ast.Name) and t.id in ('cards', 'kinds', 'kids', 'rows', 'rail', 'overlay', 'x0') for t in n.targets)):
keep.append(n)
if isinstance(n, ast.Assign) and any(isinstance(t, ast.Name) and t.id == 'GROWNUP' for t in n.targets): break
g = {'__file__': str(PROFILES)}
exec(compile(ast.Module(keep, []), str(PROFILES), 'exec'), g)
globals().update({k: v for k, v in g.items() if k not in ('HERE', '__file__')})
import builtins
def save(name):
g['im'].resize((W, H), Image.LANCZOS).save(HERE / name); print(name)
def D(): return g['d']
GREEN = (110, 220, 170)
def steps(at):
"""Six dots along the top: where you are in setting up."""
names = ['Welcome', 'Server', 'Sign in', 'Channels', 'Who watches', 'All set']
x = 960 - (len(names) - 1) * 110
for i, n in enumerate(names):
cx = x + i * 220
col = YELLOW if i == at else (GREEN if i < at else (60, 80, 120))
circ((cx, 60), 9 if i == at else 7, col)
if i < len(names) - 1: D().line((S(cx + 16), S(60), S(cx + 204), S(60)), fill=(GREEN if i < at else (40, 60, 100)), width=SW(3))
text((cx, 80), n, reg(22), WHITE if i == at else MUTED, 'ma')
def button(x, y, w, label, focus=True):
box(x, y, w, 70, fill=YELLOW if focus else PANEL, r=10)
text((x + w / 2, y + 16), label, semi(32), NAVY if focus else WHITE, 'ma')
# 1. Welcome.
screen(); steps(0)
avatar('ollie', (960, 380), 150)
text((960, 580), 'Welcome to Orbit', anton(80), WHITE, 'ma')
text((960, 690), 'Proper TV channels, made from your own films and series.', reg(36), MUTED, 'ma')
text((960, 740), "It takes about five minutes. I'm Ollie, and I'll show you round.", reg(32), MUTED, 'ma')
button(810, 840, 300, "Let's go")
keys([('OK', 'Start')])
save('first-run-1-welcome.png')
# 2. Choose your server.
screen(); steps(1)
text((960, 170), 'Where are your films and series?', anton(60), WHITE, 'ma')
text((960, 260), 'Choose the media server they live on. Orbit only reads it, and never changes or copies anything.', reg(30), MUTED, 'ma')
servers = [('Emby', 'Sign in with your Emby account', (82, 181, 75)), ('Plex', 'Link with a code at plex.tv/link', (229, 160, 13)),
('Jellyfin', 'Sign in with your Jellyfin account', (170, 92, 195)), ('Silo', 'Sign in with your Silo account', (69, 184, 245))]
for i, (name, how, col) in enumerate(servers):
x = 150 + i * 420; y = 360
focus = i == 0
box(x, y, 380, 400, fill=NAVY if focus else PANEL, r=16, outline=YELLOW if focus else None, width=4)
# A plain server mark in its colour: our own drawing, not the product's logo.
for k in range(3): box(x + 130, y + 60 + k * 44, 120, 34, fill=col, r=8)
for k in range(3): circ((x + 230, y + 77 + k * 44), 6, NAVY)
text((x + 190, y + 230), name, anton(50), WHITE, 'ma')
text((x + 190, y + 310), how, reg(24), MUTED, 'ma')
text((960, 820), "Not sure? It's whichever app you watch your own films on. You can add another later in Settings.", reg(28), MUTED, 'ma')
keys([('◄►', 'Choose'), ('OK', 'Select'), ('Back', 'Welcome')])
save('first-run-2-server.png')
# 3. Sign in, then Drift reads the library.
screen(); steps(2)
text((480, 170), 'Sign in to Emby', anton(56), WHITE, 'ma')
for i, (label, value, focus) in enumerate((('Server address', 'https://media.example.home', False), ('Username', 'paul', False), ('Password', '••••••••••', True))):
y = 270 + i * 130
text((180, y), label, semi(28), MUTED)
box(180, y + 40, 600, 70, fill=NAVY if focus else PANEL, r=10, outline=YELLOW if focus else None)
text((206, y + 56), value, reg(32), WHITE)
button(180, 690, 600, 'Sign in', focus=False)
text((180, 790), 'Your password is never saved: only a sign-in token, encrypted on this box.', reg(24), MUTED)
box(1000, 200, 800, 640, fill=DEEP, r=18)
drift((1400, 380), 110, (53, 214, 242))
text((1400, 540), "Drift's reading your library", semi(40), WHITE, 'ma')
box(1120, 610, 560, 18, fill=(30, 50, 90), r=9); box(1120, 610, 380, 18, fill=GREEN, r=9)
text((1400, 650), '12,480 films · 3,102 series · 68%', reg(30), MUTED, 'ma')
text((1400, 720), 'Next: choosing your channels', reg(26), MUTED, 'ma')
keys([('▲▼', 'Move'), ('OK', 'Type'), ('Back', 'Servers')])
save('first-run-3-sign-in.png')
# 4. Pick your channels.
screen(); steps(3)
text((960, 150), 'Pick your channels', anton(60), WHITE, 'ma')
text((960, 240), 'Ticked ones suit your library best. Each runs its own schedule, all day, every day.', reg(30), MUTED, 'ma')
chans = [('orbit_one_epg', 'Sitcoms and favourites', 184, True), ('paradox_epg', 'Cult sci-fi and fantasy', 96, True), ('orbit_terra_epg', 'Nature and wildlife', 41, True),
('evidence_epg', 'Crime and investigation', 77, True), ('orbit_action_epg', 'Action films', 312, True), ('orbit_horror_epg', 'Horror films', 205, False),
('orbit_tots_epg', 'For the youngest', 23, True), ('toonz_epg', 'Cartoons', 64, True), ('orbit_ring_x_epg', 'Wrestling', 18, False),
('orbit_romance_epg', 'Romance films', 140, False), ('orbit_gold_epg', 'Classic comedy', 58, True), ('orbit_drive_in_epg', 'Weekend film nights', 0, True)]
for i, (logo_name, what, count, on) in enumerate(chans):
x = 120 + (i % 4) * 430; y = 310 + (i // 4) * 170
focus = i == 5
box(x, y, 400, 150, fill=NAVY if focus else PANEL, r=14, outline=YELLOW if focus else None)
logo(RES / f'{logo_name}.webp', x + 22, y + 20, 170)
text((x + 24, y + 86), what, semi(24), WHITE)
text((x + 24, y + 116), f'{count} in your library' if count else 'Picks from your films', reg(20), MUTED)
box(x + 330, y + 28, 46, 40, fill=GREEN if on else (40, 60, 100), r=8)
if on: D().line([(S(x + 341), S(y + 48)), (S(x + 351), S(y + 59)), (S(x + 367), S(y + 37))], fill=NAVY, width=SW(6))
button(760, 840, 400, 'Next: 9 channels')
text((960, 930), 'Change them any time in Settings › Channels.', reg(24), MUTED, 'ma')
keys([('◄►▲▼', 'Move'), ('OK', 'Tick'), ('Back', 'Sign in')])
save('first-run-4-channels.png')
# 5. Who watches?
screen(); steps(4)
text((960, 150), 'Who watches?', anton(60), WHITE, 'ma')
text((960, 240), 'Set a PIN for Grown-ups, and add a Kids profile if there are little ones about.', reg(30), MUTED, 'ma')
box(300, 320, 600, 480, fill=PANEL, r=18)
avatar(GROWNUP, (600, 440), 90)
text((600, 550), 'Grown-ups', semi(40), WHITE, 'ma')
text((600, 605), 'Everything, including After Dark', reg(26), MUTED, 'ma')
for k in range(4): box(470 + k * 70, 660, 54, 66, fill=YELLOW if k == 0 else (30, 50, 90), r=8)
text((497, 670), '4', semi(40), NAVY, 'ma')
box(1020, 320, 600, 480, fill=NAVY, r=18, outline=YELLOW, width=4)
avatar('ollie', (1320, 440), 90)
text((1320, 550), 'Kids', semi(40), WHITE, 'ma')
text((1320, 605), 'Tots, cartoons and the Arcade', reg(26), MUTED, 'ma')
box(1170, 660, 300, 66, fill=GREEN, r=10); text((1320, 672), 'Add Kids', semi(32), NAVY, 'ma')
text((960, 850), 'You can skip this: Orbit then opens straight to everything, with no PIN.', reg(26), MUTED, 'ma')
keys([('◄►', 'Choose'), ('OK', 'Select'), ('Back', 'Channels')])
save('first-run-5-who-watches.png')
# 6. All set.
screen(); steps(5)
for (fn, cx, cy, r) in ((mascot, 960, 420, 120),):
fn((cx, cy), r)
blaze((640, 470), 70, (255, 138, 61)); zip_((780, 330), 60, (255, 79, 163)); drift((1140, 330), 64, (53, 214, 242)); luna((1290, 470), 66, (167, 123, 255))
bumble((1150, 600), 54, None)
text((960, 740), "You're on air", anton(80), WHITE, 'ma')
text((960, 850), '9 channels are planning their first week. The Drive-In opens on Friday.', reg(32), MUTED, 'ma')
button(780, 920, 360, 'Start watching')
keys([('OK', 'Start watching')], y=1020)
save('first-run-6-all-set.png')
Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB