Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .superconductor/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"setup": ["node scripts/setup-worktree.mjs"],
"run": [
{
"name": "Development server",
"commands": ["npm run dev:video"],
"default": true
}
]
}
192 changes: 192 additions & 0 deletions scripts/setup-worktree.mjs
Original file line number Diff line number Diff line change
@@ -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}`));
});
});
}
90 changes: 84 additions & 6 deletions src/app/routes/WatchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,38 @@ 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}>();
const playlist = usePlaylist(yearId);
const player = useRef<ScreeningPlayerHandle>(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;
Comment thread
HazAT marked this conversation as resolved.
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(
Expand All @@ -40,26 +65,60 @@ export function WatchPage() {
<h1>play the reel</h1>
</div>
</header>
{groups.length > 0 && (
<section className="reelGroups" aria-labelledby="reel-groups-heading">
<header>
<div>
<p className="kicker">watch party playlists</p>
<h2 id="reel-groups-heading">choose a group</h2>
</div>
<p>{selectedVideos.length} ready videos in this playlist</p>
</header>
<div className="reelGroupGrid" role="group" aria-label="Playlist group">
<button
type="button"
aria-pressed={!selectedGroup}
onClick={() => selectGroup(null)}
>
<strong>all groups</strong>
<span>{videoCountLabel(videos.length)}</span>
</button>
{groups.map((group) => (
<button
type="button"
aria-pressed={selectedGroup?.id === group.id}
key={group.id}
onClick={() => selectGroup(group.id)}
>
<strong>{group.name}</strong>
<span>{videoCountLabel(group.videoCount)}</span>
</button>
))}
</div>
</section>
)}
<ScreeningPlayer
key={playlist.data.videos.map(({videoId}) => 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 && (
<section className="reelIndex" aria-labelledby="reel-index-heading">
<p className="kicker">screening order</p>
<h2 id="reel-index-heading">playlist</h2>
<h2 id="reel-index-heading">
{selectedGroup ? `${selectedGroup.name} playlist` : 'playlist'}
</h2>
<div className="projectList reelPlaylist">
{playlist.data.videos.map((clip) => (
{selectedVideos.map((clip, index) => (
<ProjectListItem
key={clip.videoId}
name={clip.projectName}
groupName={clip.groupName ?? 'ungrouped'}
detail={`${String(clip.position + 1).padStart(2, '0')} · ${formatDuration(clip.durationSeconds)}`}
detail={`${String(index + 1).padStart(2, '0')} · ${formatDuration(clip.durationSeconds)}`}
members={clip.teamMembers}
emptyMemberLabel="Hackweek team"
actionLabel={`start reel from ${clip.projectName}`}
Expand Down Expand Up @@ -157,6 +216,25 @@ export function VideoWatchPage() {
);
}

function playlistGroups(videos: PlaylistItem[]) {
const groups = new Map<string, {id: string; name: string; videoCount: number}>();
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')}`;
}
Loading
Loading