From 2a076ff7b030832a596d1519f536cdfe3fdc7423 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Thu, 13 Aug 2026 23:39:38 +0200 Subject: [PATCH 1/2] feat(reel): add group-specific playlists Let watch parties select a project group and play only that group's ready videos while preserving the curated screening order. Expose stable group IDs in playlist responses, keep the selected group in the URL, and cover the filtered player and API payload behavior. --- src/app/routes/WatchPage.tsx | 90 ++++++++++++++++++++++++++++++--- src/app/styles.css | 71 ++++++++++++++++++++++++++ src/shared/videos.ts | 1 + src/worker/services/videos.ts | 4 +- test/player/controller.test.tsx | 2 + test/video-ui/video-ui.test.tsx | 20 ++++++++ test/video/video.test.ts | 2 + 7 files changed, 183 insertions(+), 7 deletions(-) diff --git a/src/app/routes/WatchPage.tsx b/src/app/routes/WatchPage.tsx index 47f3e99..f6b7f21 100644 --- a/src/app/routes/WatchPage.tsx +++ b/src/app/routes/WatchPage.tsx @@ -7,6 +7,9 @@ import {IndividualPlayer} from '../player/IndividualPlayer'; import {ScreeningPlayer, type ScreeningPlayerHandle} from '../player/ScreeningPlayer'; import {getPlayback, usePlaylist, useProjectVideo} from '../queries/videos'; import {PageState, QueryState} from '../components/AppLayout'; +import type {PlaylistItem} from '../../shared/videos'; + +const UNGROUPED_PLAYLIST_ID = '__ungrouped__'; export function WatchPage() { const {yearId} = useParams<{yearId: string}>(); @@ -14,6 +17,28 @@ export function WatchPage() { const player = useRef(null); const [search, setSearch] = useSearchParams(); const initialVideoId = search.get('from'); + const videos = playlist.data?.videos ?? []; + const groups = playlistGroups(videos); + const selectedGroupId = search.get('group'); + const selectedGroup = groups.find(({id}) => id === selectedGroupId) ?? null; + const selectedVideos = selectedGroup + ? videos.filter((video) => playlistGroupId(video) === selectedGroup.id) + : videos; + const selectGroup = useCallback( + (groupId: string | null) => { + setSearch( + (current) => { + const next = new URLSearchParams(current); + if (groupId) next.set('group', groupId); + else next.delete('group'); + next.delete('from'); + return next; + }, + {replace: true}, + ); + }, + [setSearch], + ); const trackActiveVideo = useCallback( (videoId: string) => { setSearch( @@ -40,26 +65,60 @@ export function WatchPage() {

play the reel

+ {groups.length > 0 && ( +
+
+
+

watch party playlists

+

choose a group

+
+

{selectedVideos.length} ready videos in this playlist

+
+
+ + {groups.map((group) => ( + + ))} +
+
+ )} videoId).join(':')} + key={selectedVideos.map(({videoId}) => videoId).join(':')} ref={player} - playlist={playlist.data.videos} + playlist={selectedVideos} getPlayback={getPlayback} yearId={yearId} initialVideoId={initialVideoId} onActiveVideoChange={trackActiveVideo} /> - {playlist.data.videos.length > 0 && ( + {selectedVideos.length > 0 && (

screening order

-

playlist

+

+ {selectedGroup ? `${selectedGroup.name} playlist` : 'playlist'} +

- {playlist.data.videos.map((clip) => ( + {selectedVideos.map((clip, index) => ( (); + for (const video of videos) { + const id = playlistGroupId(video); + const group = groups.get(id); + if (group) group.videoCount += 1; + else groups.set(id, {id, name: video.groupName ?? 'ungrouped', videoCount: 1}); + } + return [...groups.values()].sort((left, right) => left.name.localeCompare(right.name)); +} + +function playlistGroupId(video: PlaylistItem) { + return video.groupId ?? UNGROUPED_PLAYLIST_ID; +} + +function videoCountLabel(count: number) { + return `${count} ${count === 1 ? 'video' : 'videos'}`; +} + function formatDuration(value: number) { return `${Math.floor(value / 60)}:${String(Math.round(value % 60)).padStart(2, '0')}`; } diff --git a/src/app/styles.css b/src/app/styles.css index ceae68e..a2e3236 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -2264,6 +2264,70 @@ kbd { .screeningEmpty p { color: var(--muted); } +.reelGroups { + padding: clamp(1rem, 2.5vw, 1.5rem); + margin-bottom: 1.25rem; + border: 1px solid var(--line); + border-radius: 0.85rem; + background: var(--soft); +} +.reelGroups > header { + display: flex; + gap: 1rem; + align-items: end; + justify-content: space-between; + margin-bottom: 1rem; +} +.reelGroups h2, +.reelGroups p { + margin: 0; +} +.reelGroups > header > p { + color: var(--muted); + font-size: 0.78rem; +} +.reelGroupGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); + gap: 0.55rem; +} +.reelGroupGrid button { + padding: 0.8rem 0.9rem; + text-align: left; + color: var(--ink); + border: 1px solid #c9c3d1; + border-radius: 0.65rem; + background: var(--paper); + transition: + color 100ms ease, + border-color 100ms ease, + background 100ms ease, + transform 100ms ease; +} +.reelGroupGrid button:hover { + border-color: var(--blurple); + transform: translateY(-1px); +} +.reelGroupGrid button[aria-pressed='true'] { + color: #fff; + border-color: var(--dark-blurple); + background: var(--dark-blurple); +} +.reelGroupGrid strong, +.reelGroupGrid span { + display: block; +} +.reelGroupGrid strong { + font-size: 0.88rem; +} +.reelGroupGrid span { + margin-top: 0.2rem; + color: var(--muted); + font-size: 0.7rem; +} +.reelGroupGrid button[aria-pressed='true'] span { + color: #d9cae5; +} .reelIndex { padding-top: 4rem; } @@ -2326,6 +2390,13 @@ kbd { } } @media (max-width: 560px) { + .reelGroups > header { + align-items: flex-start; + flex-direction: column; + } + .reelGroupGrid { + grid-template-columns: 1fr 1fr; + } .videoPanel > header { align-items: flex-start; flex-direction: column; diff --git a/src/shared/videos.ts b/src/shared/videos.ts index 1ff5b83..976e7fd 100644 --- a/src/shared/videos.ts +++ b/src/shared/videos.ts @@ -68,6 +68,7 @@ export interface PlaylistItem { videoId: string; projectId: string; projectName: string; + groupId: string | null; groupName: string | null; teamMembers: Array<{id: string; displayName: string}>; durationSeconds: number; diff --git a/src/worker/services/videos.ts b/src/worker/services/videos.ts index a766e51..d5933a6 100644 --- a/src/worker/services/videos.ts +++ b/src/worker/services/videos.ts @@ -105,7 +105,7 @@ export async function listPlaylist( const {results} = await db .prepare( `SELECT pv.id video_id, p.id project_id, p.name project_name, - g.name group_name, pv.duration_seconds, pv.gain_db, so.position + g.id group_id, g.name group_name, pv.duration_seconds, pv.gain_db, so.position FROM projects p JOIN video_submissions pv ON pv.project_id = p.id LEFT JOIN groups g ON g.id = p.group_id @@ -121,6 +121,7 @@ export async function listPlaylist( video_id: string; project_id: string; project_name: string; + group_id: string | null; group_name: string | null; duration_seconds: number; gain_db: number; @@ -149,6 +150,7 @@ export async function listPlaylist( videoId: row.video_id, projectId: row.project_id, projectName: row.project_name, + groupId: row.group_id, groupName: row.group_name, teamMembers: membersByProject.get(row.project_id) ?? [], durationSeconds: row.duration_seconds, diff --git a/test/player/controller.test.tsx b/test/player/controller.test.tsx index d5a4aa0..5ca6028 100644 --- a/test/player/controller.test.tsx +++ b/test/player/controller.test.tsx @@ -157,6 +157,7 @@ const playlist: PlaylistItem[] = [ videoId: 'video-1', projectId: 'project-1', projectName: 'First', + groupId: 'europe', groupName: 'Europe', teamMembers: [ {id: 'ada', displayName: 'Ada'}, @@ -170,6 +171,7 @@ const playlist: PlaylistItem[] = [ videoId: 'video-2', projectId: 'project-2', projectName: 'Second', + groupId: 'americas', groupName: 'Americas', teamMembers: [{id: 'linus', displayName: 'Linus'}], durationSeconds: 20, diff --git a/test/video-ui/video-ui.test.tsx b/test/video-ui/video-ui.test.tsx index 9db4528..663e810 100644 --- a/test/video-ui/video-ui.test.tsx +++ b/test/video-ui/video-ui.test.tsx @@ -251,6 +251,12 @@ describe('video user experience', () => { expect(await screen.findByRole('heading', {name: 'play the reel'})).toBeTruthy(); expect(screen.getByRole('img', {name: 'Hackweek 2026'})).toBeTruthy(); expect(screen.getByRole('button', {name: 'play all'})).toBeTruthy(); + expect(screen.getByRole('heading', {name: 'choose a group'})).toBeTruthy(); + expect( + screen + .getByRole('button', {name: 'all groups 2 videos'}) + .getAttribute('aria-pressed'), + ).toBe('true'); expect(screen.getByRole('heading', {name: 'playlist'})).toBeTruthy(); expect(screen.getByText('Ada Lovelace · Grace Hopper')).toBeTruthy(); const firstRow = screen @@ -266,6 +272,18 @@ describe('video user experience', () => { 'private progressive MP4 playback in the curated screening order.', ), ).toBeNull(); + + await userEvent.click(screen.getByRole('button', {name: 'Europe 1 video'})); + expect( + screen.getByRole('button', {name: 'Europe 1 video'}).getAttribute('aria-pressed'), + ).toBe('true'); + expect(screen.getByRole('heading', {name: 'Europe playlist'})).toBeTruthy(); + expect( + screen.getByRole('button', {name: 'start reel from First project'}), + ).toBeTruthy(); + expect( + screen.queryByRole('button', {name: 'start reel from Second project'}), + ).toBeNull(); reel.unmount(); renderRoute(, '/years/2026/watch?from=video-2', '/years/:yearId/watch'); @@ -395,6 +413,7 @@ const playlist: PlaylistItem[] = [ videoId: 'video-1', projectId: 'project', projectName: 'First project', + groupId: 'europe', groupName: 'Europe', teamMembers: [ {id: 'ada', displayName: 'Ada Lovelace'}, @@ -408,6 +427,7 @@ const playlist: PlaylistItem[] = [ videoId: 'video-2', projectId: 'project-2', projectName: 'Second project', + groupId: 'americas', groupName: 'Americas', teamMembers: [{id: 'linus', displayName: 'Linus Torvalds'}], durationSeconds: 45, diff --git a/test/video/video.test.ts b/test/video/video.test.ts index e0b829d..2ff91dd 100644 --- a/test/video/video.test.ts +++ b/test/video/video.test.ts @@ -719,12 +719,14 @@ describe('R2 multipart video lifecycle', () => { ]); expect(playlist.body.videos[0]).toMatchObject({ projectName: 'Curated second', + groupId, groupName: 'Video group', position: 0, teamMembers: [{displayName: 'Hackweek Member'}], }); expect(playlist.body.videos[1]).toMatchObject({ projectName: 'Curated first', + groupId, groupName: 'Video group', position: 1, teamMembers: [{displayName: 'Hackweek Member'}, {displayName: 'Hackweek Member'}], From cfa0ff551880dfdbcc2b2ef06a206bfeceea64a5 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Thu, 13 Aug 2026 23:39:43 +0200 Subject: [PATCH 2/2] chore(dev): seed new worktrees from master Configure Superconductor worktree setup to clone the master checkout's local Wrangler state, secrets, and compatible dependencies without overwriting existing data. Use copy-on-write clones on macOS, fall back to npm ci when lockfiles differ, and apply pending local D1 migrations before development starts. --- .superconductor/config.json | 10 ++ scripts/setup-worktree.mjs | 192 ++++++++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 .superconductor/config.json create mode 100644 scripts/setup-worktree.mjs diff --git a/.superconductor/config.json b/.superconductor/config.json new file mode 100644 index 0000000..a91a14a --- /dev/null +++ b/.superconductor/config.json @@ -0,0 +1,10 @@ +{ + "setup": ["node scripts/setup-worktree.mjs"], + "run": [ + { + "name": "Development server", + "commands": ["npm run dev:video"], + "default": true + } + ] +} diff --git a/scripts/setup-worktree.mjs b/scripts/setup-worktree.mjs new file mode 100644 index 0000000..a3231e2 --- /dev/null +++ b/scripts/setup-worktree.mjs @@ -0,0 +1,192 @@ +import {constants, copyFile, mkdir, readFile, stat} from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import {spawn} from 'node:child_process'; + +const dryRun = process.argv.includes('--dry-run'); + +await main(); + +async function main() { + const targetRoot = await gitRoot(); + const sourceRoot = await masterWorktree(); + + if (path.resolve(sourceRoot) === path.resolve(targetRoot)) { + console.log('worktree setup: already in the master worktree; nothing to copy'); + return; + } + + console.log(`worktree setup: using master state from ${sourceRoot}`); + + await prepareDependencies(sourceRoot, targetRoot); + await cloneDirectoryIfMissing( + path.join(sourceRoot, '.wrangler', 'state'), + path.join(targetRoot, '.wrangler', 'state'), + 'Wrangler state', + ); + await cloneFileIfMissing( + path.join(sourceRoot, '.dev.vars'), + path.join(targetRoot, '.dev.vars'), + 'local environment', + ); + + if (dryRun) { + console.log('worktree setup: would apply pending local D1 migrations'); + return; + } + + await run('npm', ['run', 'db:migrate:local'], {cwd: targetRoot}); + console.log('worktree setup: ready for npm run dev:video'); +} + +async function prepareDependencies(sourceRoot, targetRoot) { + const targetModules = path.join(targetRoot, 'node_modules'); + if (await exists(targetModules)) { + console.log('worktree setup: node_modules already exists; keeping it'); + return; + } + + const sourceModules = path.join(sourceRoot, 'node_modules'); + const locksMatch = await filesMatch( + path.join(sourceRoot, 'package-lock.json'), + path.join(targetRoot, 'package-lock.json'), + ); + if ((await exists(sourceModules)) && locksMatch) { + await cloneDirectory(sourceModules, targetModules, 'node_modules'); + return; + } + + if (dryRun) { + console.log('worktree setup: would install dependencies with npm ci'); + return; + } + await run('npm', ['ci'], {cwd: targetRoot}); +} + +async function cloneDirectoryIfMissing(source, destination, label) { + if (await exists(destination)) { + console.log(`worktree setup: ${label} already exists; keeping it`); + return; + } + if (!(await exists(source))) { + console.log(`worktree setup: master has no ${label}; skipping it`); + return; + } + await cloneDirectory(source, destination, label); +} + +async function cloneDirectory(source, destination, label) { + if (dryRun) { + console.log(`worktree setup: would clone ${label}`); + return; + } + if (process.platform !== 'darwin') { + throw new Error( + `Cloning ${label} without duplicating its data is currently supported only on macOS`, + ); + } + await mkdir(path.dirname(destination), {recursive: true}); + await run('cp', ['-cR', source, destination]); + console.log(`worktree setup: cloned ${label}`); +} + +async function cloneFileIfMissing(source, destination, label) { + if (await exists(destination)) { + console.log(`worktree setup: ${label} already exists; keeping it`); + return; + } + if (!(await exists(source))) { + console.log(`worktree setup: master has no ${label}; skipping it`); + return; + } + if (dryRun) { + console.log(`worktree setup: would clone ${label}`); + return; + } + await mkdir(path.dirname(destination), {recursive: true}); + await copyFile( + source, + destination, + constants.COPYFILE_EXCL | constants.COPYFILE_FICLONE, + ); + console.log(`worktree setup: cloned ${label}`); +} + +async function masterWorktree() { + const output = await capture('git', ['worktree', 'list', '--porcelain']); + const worktrees = output + .trim() + .split(/\n\s*\n/) + .map((record) => + Object.fromEntries( + record.split('\n').map((line) => { + const separator = line.indexOf(' '); + return separator === -1 + ? [line, true] + : [line.slice(0, separator), line.slice(separator + 1)]; + }), + ), + ); + const master = worktrees.find((worktree) => worktree.branch === 'refs/heads/master'); + if (typeof master?.worktree !== 'string') { + throw new Error( + 'The master branch must have a checked-out worktree to seed local state', + ); + } + return master.worktree; +} + +async function gitRoot() { + return (await capture('git', ['rev-parse', '--show-toplevel'])).trim(); +} + +async function filesMatch(left, right) { + try { + const [leftContent, rightContent] = await Promise.all([ + readFile(left), + readFile(right), + ]); + return leftContent.equals(rightContent); + } catch (error) { + if (error && typeof error === 'object' && error.code === 'ENOENT') return false; + throw error; + } +} + +async function exists(value) { + return stat(value).then( + () => true, + (error) => { + if (error && typeof error === 'object' && error.code === 'ENOENT') return false; + throw error; + }, + ); +} + +function capture(command, args) { + return new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + const child = spawn(command, args, {stdio: ['ignore', 'pipe', 'pipe']}); + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk) => (stdout += chunk)); + child.stderr.on('data', (chunk) => (stderr += chunk)); + child.once('error', reject); + child.once('close', (code) => { + if (code === 0) resolve(stdout); + else reject(new Error(`${command} ${args.join(' ')} failed: ${stderr.trim()}`)); + }); + }); +} + +function run(command, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, {...options, stdio: 'inherit'}); + child.once('error', reject); + child.once('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(`${command} ${args.join(' ')} exited with ${code}`)); + }); + }); +}