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}`)); + }); + }); +} 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'}],