/** * Stream layouts: which predefined components sit where, per scene. * * A layout is plain JSON, stored on the broadcast channel and edited in the * layout editor. Coordinates are pixels on a 1920×1080 stage; the overlay * scales the stage to whatever size the browser source is. */ import type { MatchView, Phase } from '$lib/match-view'; export const STAGE = { width: 1920, height: 1080 } as const; export type SceneId = 'idle' | 'lobby' | 'pickban' | 'gameplay' | 'results'; export const SCENES: { id: SceneId; label: string; description: string }[] = [ { id: 'idle', label: 'Starting soon', description: 'No match to show: schedule, other live matches.' }, { id: 'lobby', label: 'Lobby', description: 'Players join and roll.' }, { id: 'pickban', label: 'Pick & ban', description: 'Choosing, banning, picking.' }, { id: 'gameplay', label: 'Gameplay', description: 'A map is being played.' }, { id: 'results', label: 'Results', description: 'The match is decided.' } ]; const SCENE_BY_PHASE: Record = { created: 'lobby', waiting: 'lobby', rolling: 'lobby', choosing: 'pickban', banning: 'pickban', picking: 'pickban', tb: 'pickban', playing: 'gameplay', closing: 'results', finished: 'results', cancelled: 'idle' }; export function stageOf(layout: Pick): { width: number; height: number } { return layout.stage ?? STAGE; } export function sceneFor(view: MatchView | null): SceneId { return view ? SCENE_BY_PHASE[view.phase] : 'idle'; } export type ComponentType = | 'scoreboard' | 'livescore' | 'nowplaying' | 'mappool' | 'turn' | 'history' | 'chat' | 'predictions' | 'players' | 'winner' | 'gameplay' | 'ticker' | 'schedule' | 'text' | 'image' | 'clock'; export interface PlacedComponent { id: string; type: ComponentType; x: number; y: number; w: number; h: number; props: Record; } export interface SceneLayout { /** What is behind the components: nothing (OBS shows what is under the browser source), the theme background, or an image. */ background: 'transparent' | 'solid' | 'image'; image?: string; components: PlacedComponent[]; } export interface Layout { version: 1; /** Stage size; 1920×1080 unless set (portrait channels use 1080×1920). */ stage?: { width: number; height: number }; theme: { /** Accent color; empty uses the tournament's accent. */ accent: string; background: string; /** Corner radius of panels, px. */ radius: number; }; scenes: Record; } export interface PropSpec { key: string; label: string; kind: 'text' | 'number' | 'boolean' | 'select' | 'url'; options?: string[]; default: string | number | boolean; } export const COMPONENTS: Record< ComponentType, { label: string; description: string; size: [number, number]; props: PropSpec[] } > = { scoreboard: { label: 'Scoreboard', description: 'Team names, avatars and maps won.', size: [1920, 130], props: [ { key: 'showRatings', label: 'Show ratings', kind: 'boolean', default: false }, { key: 'showRound', label: 'Show round name', kind: 'boolean', default: true } ] }, livescore: { label: 'Live score', description: 'Both sides’ scores on the current map, with the lead.', size: [1920, 90], props: [{ key: 'showPlayers', label: 'Show each player', kind: 'boolean', default: true }] }, nowplaying: { label: 'Now playing', description: 'The map being played or just picked.', size: [620, 150], props: [ { key: 'showStats', label: 'Show stars, BPM and length', kind: 'boolean', default: true } ] }, mappool: { label: 'Mappool', description: 'Every map with its pick, ban and winner.', size: [1800, 560], props: [{ key: 'columns', label: 'Columns', kind: 'number', default: 5 }] }, turn: { label: 'Turn', description: 'Who acts now, and the countdown.', size: [900, 120], props: [] }, history: { label: 'Map history', description: 'Maps played so far and who won them.', size: [480, 520], props: [] }, chat: { label: 'Lobby chat', description: 'The latest lines from the osu! lobby.', size: [480, 420], props: [ { key: 'lines', label: 'Lines', kind: 'number', default: 10 }, { key: 'showBancho', label: 'Show BanchoBot', kind: 'boolean', default: false } ] }, predictions: { label: 'Predictions', description: 'How the crowd called it, and the rating odds.', size: [620, 140], props: [{ key: 'showOdds', label: 'Show rating odds', kind: 'boolean', default: true }] }, players: { label: 'Players', description: 'Each side’s players with flags and ratings.', size: [1600, 420], props: [] }, winner: { label: 'Winner', description: 'The winning side, big.', size: [1200, 420], props: [{ key: 'headline', label: 'Headline', kind: 'text', default: 'WINNER' }] }, gameplay: { label: 'Gameplay window', description: 'A see-through frame for the tourney client capture under the overlay.', size: [1920, 860], props: [ { key: 'label', label: 'Label when empty', kind: 'text', default: '' }, { key: 'border', label: 'Border', kind: 'boolean', default: false } ] }, ticker: { label: 'Live ticker', description: 'Scores of every other live match, scrolling.', size: [1920, 48], props: [{ key: 'label', label: 'Label', kind: 'text', default: 'AROUND VASH' }] }, schedule: { label: 'Schedule', description: 'Matches coming up.', size: [720, 440], props: [{ key: 'title', label: 'Title', kind: 'text', default: 'Up next' }] }, text: { label: 'Text', description: 'Any text: a title, a sponsor line, a hashtag.', size: [800, 120], props: [ { key: 'text', label: 'Text', kind: 'text', default: 'Vash Esports' }, { key: 'size', label: 'Size (px)', kind: 'number', default: 64 }, { key: 'align', label: 'Align', kind: 'select', options: ['left', 'center', 'right'], default: 'center' }, { key: 'accent', label: 'Accent color', kind: 'boolean', default: false } ] }, image: { label: 'Image', description: 'A logo, banner or sponsor image.', size: [300, 300], props: [ { key: 'src', label: 'Image URL', kind: 'url', default: '/logo.png' }, { key: 'fit', label: 'Fit', kind: 'select', options: ['contain', 'cover'], default: 'contain' } ] }, clock: { label: 'Clock', description: 'The time, or a countdown to the next match.', size: [360, 120], props: [ { key: 'mode', label: 'Show', kind: 'select', options: ['countdown', 'time'], default: 'countdown' } ] } }; export function defaultProps(type: ComponentType): PlacedComponent['props'] { return Object.fromEntries(COMPONENTS[type].props.map((prop) => [prop.key, prop.default])); } export function place( type: ComponentType, x: number, y: number, w?: number, h?: number, props: PlacedComponent['props'] = {} ): PlacedComponent { const [dw, dh] = COMPONENTS[type].size; return { id: `${type}-${Math.random().toString(36).slice(2, 8)}`, type, x, y, w: w ?? dw, h: h ?? dh, props: { ...defaultProps(type), ...props } }; } // ── Presets ───────────────────────────────────────────────────────────── const theme: Layout['theme'] = { accent: '', background: '#09090b', radius: 18 }; /** Full broadcast around a tourney client capture: the gameplay scene leaves a see-through window. */ function tourney(): Layout { return { version: 1, theme, scenes: { idle: { background: 'solid', components: [ place('text', 160, 140, 1600, 140, { text: 'Starting soon', size: 110, align: 'left' }), place('clock', 160, 300, 520, 140), place('schedule', 160, 480, 820, 480), place('image', 1360, 440, 400, 400), place('ticker', 0, 1032) ] }, lobby: { background: 'solid', components: [ place('scoreboard', 0, 0), place('players', 160, 220, 1600, 520), place('predictions', 650, 800), place('ticker', 0, 1032) ] }, pickban: { background: 'solid', components: [ place('scoreboard', 0, 0), place('turn', 510, 150), place('mappool', 60, 300, 1800, 560), place('chat', 60, 880, 900, 140, { lines: 4 }), place('predictions', 1240, 880) ] }, gameplay: { background: 'transparent', components: [ place('scoreboard', 0, 0), place('gameplay', 0, 130, 1920, 800), place('livescore', 0, 930), place('nowplaying', 0, 1020, 1920, 60) ] }, results: { background: 'solid', components: [ place('winner', 360, 140), place('history', 360, 600, 1200, 400), place('ticker', 0, 1032) ] } } }; } /** No capture at all: everything from Vash's own data. What Vash TV streams. */ function vashTv(): Layout { const layout = tourney(); layout.scenes.gameplay = { background: 'solid', components: [ place('scoreboard', 0, 0), place('nowplaying', 160, 200, 1000, 220), place('predictions', 1220, 200, 540, 220), place('livescore', 0, 480, 1920, 200), place('history', 160, 720, 800, 290), place('chat', 1000, 720, 760, 290, { lines: 6 }), place('ticker', 0, 1032) ] }; return layout; } /** Just a slim scoreboard and live score over the capture; nothing else. */ function minimal(): Layout { const empty = (): SceneLayout => ({ background: 'transparent', components: [] }); return { version: 1, theme, scenes: { idle: empty(), lobby: { background: 'transparent', components: [place('scoreboard', 0, 0, 1920, 110)] }, pickban: { background: 'transparent', components: [place('scoreboard', 0, 0, 1920, 110), place('turn', 510, 950)] }, gameplay: { background: 'transparent', components: [ place('scoreboard', 0, 0, 1920, 110), place('livescore', 0, 1000, 1920, 80, { showPlayers: false }) ] }, results: { background: 'transparent', components: [place('winner', 360, 330)] } } }; } /** Upright, for phones: TikTok, Instagram, YouTube Shorts. Everything stacked. */ function vertical(): Layout { const W = 1080; const scenes: Layout['scenes'] = { idle: { background: 'solid', components: [ place('text', 80, 220, 920, 200, { text: 'Starting soon', size: 120, align: 'left' }), place('clock', 80, 440, 920, 160), place('schedule', 60, 660, 960, 700), place('image', 340, 1420, 400, 400), place('ticker', 0, 1872, W, 48) ] }, lobby: { background: 'solid', components: [ place('scoreboard', 0, 0, W, 130), place('players', 40, 200, 1000, 900), place('predictions', 40, 1160, 1000, 180), place('ticker', 0, 1872, W, 48) ] }, pickban: { background: 'solid', components: [ place('scoreboard', 0, 0, W, 130), place('turn', 40, 160, 1000, 120), place('mappool', 40, 320, 1000, 1100, { columns: 3 }), place('predictions', 40, 1450, 1000, 170), place('chat', 40, 1640, 1000, 220, { lines: 5 }) ] }, gameplay: { background: 'solid', components: [ place('scoreboard', 0, 0, W, 130), place('nowplaying', 40, 170, 1000, 220), place('livescore', 0, 430, W, 240), place('predictions', 40, 700, 1000, 180), place('history', 40, 910, 1000, 520), place('chat', 40, 1460, 1000, 380, { lines: 8 }), place('ticker', 0, 1872, W, 48) ] }, results: { background: 'solid', components: [ place('winner', 40, 280, 1000, 700), place('history', 40, 1040, 1000, 760), place('ticker', 0, 1872, W, 48) ] } }; return { version: 1, stage: { width: 1080, height: 1920 }, theme, scenes }; } export const PRESETS: Record< string, { label: string; description: string; when: string; format: 'landscape' | 'portrait'; make: () => Layout; } > = { tourney: { label: 'Over osu! gameplay', description: 'A full overlay with a see-through window in the middle for the players’ osu! screens.', when: 'You stream from OBS with the osu! tourney client captured underneath.', format: 'landscape', make: tourney }, vashtv: { label: 'Vash only', description: 'Everything comes from Vash: scoreboard, live scores, map pool, predictions, chat. No osu! needed.', when: 'The automatic stream (Vash TV container), or any stream without an osu! capture.', format: 'landscape', make: vashTv }, minimal: { label: 'Scoreboard only', description: 'A slim scoreboard and live score bar, nothing else.', when: 'You already have your own OBS scenes and just want the score on top.', format: 'landscape', make: minimal }, vertical: { label: 'Phone (portrait)', description: 'Everything stacked in a 9:16 frame: scoreboard, live score, pool, predictions, chat.', when: 'TikTok, Instagram or YouTube Shorts. Makes a portrait channel.', format: 'portrait', make: vertical } };