diff --git a/src/lib/Icon.svelte b/src/lib/Icon.svelte index fc65f8af2..e5f655a70 100644 --- a/src/lib/Icon.svelte +++ b/src/lib/Icon.svelte @@ -30,7 +30,9 @@ | 'instagram' | 'threads' | 'thumbtack' - | 'youtube'; + | 'youtube' + | 'headphones' + | 'video'; -
+ + +
{/if} - {show.number} + {#if !has_youtube} + {show.number} + {/if}

@@ -143,6 +165,15 @@

+ + {#if has_youtube} +
+ #{show.number} + +
+ {/if}
diff --git a/src/lib/videos/YoutubeMiniPlayer.svelte b/src/lib/videos/YoutubeMiniPlayer.svelte new file mode 100644 index 000000000..0d75386c4 --- /dev/null +++ b/src/lib/videos/YoutubeMiniPlayer.svelte @@ -0,0 +1,193 @@ + + +{#if is_mounted && active_show && watch_url} + +{/if} + + diff --git a/src/routes/(site)/+layout.svelte b/src/routes/(site)/+layout.svelte index 23ba755ae..731fea2a1 100644 --- a/src/routes/(site)/+layout.svelte +++ b/src/routes/(site)/+layout.svelte @@ -3,8 +3,10 @@ import 'media-chrome'; import 'youtube-video-element'; import { Toaster } from 'svelte-french-toast'; - import { onNavigate } from '$app/navigation'; + import { afterNavigate, beforeNavigate, onNavigate } from '$app/navigation'; import Player from '$lib/player/Player.svelte'; + import YoutubeMiniPlayer from '$lib/videos/YoutubeMiniPlayer.svelte'; + import { youtube_player } from '$state/youtube_player'; import Footer from './Footer.svelte'; import Header from './Header.svelte'; import Loading from '$lib/Loading.svelte'; @@ -19,6 +21,17 @@ let { data, children } = $props(); let { user, user_theme, latest } = $derived(data); + beforeNavigate((navigation) => { + if (navigation.willUnload) return; + youtube_player.before_navigate(); + // afterNavigate doesn't run for cancelled or failed navigations + navigation.complete.catch(() => youtube_player.after_navigate()); + }); + + afterNavigate(() => { + youtube_player.after_navigate(); + }); + onNavigate(async (navigation) => { if (!document.startViewTransition) return; @@ -55,6 +68,7 @@ {#if browser} + {/if} diff --git a/src/routes/(site)/+page.svelte b/src/routes/(site)/+page.svelte index 90ad8b005..f018e8375 100644 --- a/src/routes/(site)/+page.svelte +++ b/src/routes/(site)/+page.svelte @@ -14,11 +14,11 @@

Latest Episodes

-
+
{#if latest_show} {/if} - {#each last_ten as latest_ep} + {#each last_ten as latest_ep (latest_ep.id)} {/each}
@@ -31,4 +31,10 @@ section { margin-top: 5rem; } + + .episodes-list { + display: grid; + grid-template-columns: 1fr; + gap: 20px; + } diff --git a/src/routes/(site)/show/[show_number]/[slug]/+layout.svelte b/src/routes/(site)/show/[show_number]/[slug]/+layout.svelte index 7713af300..faafd4ff2 100644 --- a/src/routes/(site)/show/[show_number]/[slug]/+layout.svelte +++ b/src/routes/(site)/show/[show_number]/[slug]/+layout.svelte @@ -9,13 +9,16 @@ import Tabs from '$lib/Tabs.svelte'; import ShareWindow from '$lib/share/ShareWindow.svelte'; import { player } from '$state/player'; + import YoutubeEmbed from '$lib/videos/YoutubeEmbed.svelte'; + import { get_youtube_id } from '$utilities/youtube'; import { format } from 'date-fns'; import SaveOffline from '$lib/player/SaveOffline.svelte'; import { tsToS } from '$/utilities/format_time.js'; let { data, children } = $props(); let { show, time_start } = $derived(data); - let downloadName = $derived(`Syntax #${show.number} - ${show.title}`); + let download_name = $derived(`Syntax #${show.number} - ${show.title}`); + let has_youtube = $derived(Boolean(get_youtube_id(show.youtube_url))); async function handleClick(e: Event) { const { target } = e; @@ -23,20 +26,16 @@ e.preventDefault(); const href = target.getAttribute('href'); const timestamp = href ? tsToS(href.replace('#t=', '')) : 0; - // If we aren't already playing this episode, load it up and then jump it - if ($player.current_show?.number !== show.number) { - await player.start_show(show, timestamp); - } else { - // Jump to timestamp - - player.update_time(timestamp); - } + // Show note timestamps are on the audio timeline, play_timestamp picks video or audio accordingly + await player.play_timestamp(show, timestamp); } } function play_show() { if ($player.current_show?.number !== show.number || $player.status === 'INITIAL') { - player.start_show(show, time_param_to_seconds(time_start)); + const start_time = time_param_to_seconds(time_start); + // Without a ?t= param, resume from the saved position + player.start_show(show, start_time > 0 ? start_time : undefined); } else if ($player.status === 'PLAYING') { player.pause(); } else { @@ -48,7 +47,7 @@ replace_color(node); } - const showSchema = { + let show_schema = $derived({ '@context': 'https://schema.org/', '@type': 'PodcastEpisode', url: $page.url, @@ -66,19 +65,17 @@ name: 'Syntax', url: 'https://syntax.fm' } - }; + }); + // The closing tag is split so it doesn't end this - {@html ``} + {@html show_schema_html} -
- {show.number} +{#snippet show_details()}

{format(new Date(show.date), 'MMMM do, yyyy')} × @@ -96,11 +93,40 @@ {#if show.aiShowNote?.description}

{show.aiShowNote?.description}

{/if} +{/snippet} + +
+ {#if has_youtube} +
+ {@render show_details()} + +
+ +
+ #{show.number} + +
+ {:else} + {show.number} + {@render show_details()} + {/if}
-
- -
+{#if !has_youtube} +
+ +
+{/if}
@@ -130,7 +156,7 @@ class="icon" title="Download Episode" aria-label="Download" - download={downloadName} + download={download_name} href={show.url} > @@ -177,6 +203,59 @@ header { grid-column: content / content; position: relative; + + &.has-youtube { + display: grid; + grid-template-columns: 1fr; + gap: 0.75rem; + align-items: start; + margin-bottom: 2rem; + + @media (--above-med) { + grid-template-columns: 1fr 1fr; + gap: 2rem; + } + } + } + + .header-left { + min-width: 0; + + :global(.guests-and-hosts) { + margin-bottom: 0; + } + + /* leave room for the absolutely positioned show number */ + .show-page-date { + @media (--below-med) { + padding-right: calc(var(--font-size-xl) * 3.5); + } + } + } + + .header-right { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0; + width: 100%; + + @media (--above-med) { + gap: 0.5rem; + } + } + + .show-number-small { + font-size: var(--font-size-xl); + color: var(--primary); + line-height: 1; + text-align: right; + + @media (--below-med) { + position: absolute; + top: 0; + right: 0; + } } h1 { diff --git a/src/routes/(site)/show/[show_number]/[slug]/transcript/+page.svelte b/src/routes/(site)/show/[show_number]/[slug]/transcript/+page.svelte index ff9273922..48c8485f3 100644 --- a/src/routes/(site)/show/[show_number]/[slug]/transcript/+page.svelte +++ b/src/routes/(site)/show/[show_number]/[slug]/transcript/+page.svelte @@ -6,7 +6,7 @@ {#if transcript} - + {:else}

Transcript not available yet! We have the AI robots on the job, check back soon!

{/if} diff --git a/src/state/media_preference.ts b/src/state/media_preference.ts new file mode 100644 index 000000000..792011d03 --- /dev/null +++ b/src/state/media_preference.ts @@ -0,0 +1,29 @@ +import { get, writable } from 'svelte/store'; +import { get_youtube_id } from '$utilities/youtube'; +import type { MediaKind } from './media_timeline'; + +const STORAGE_KEY = 'media_preference'; + +function read_preference(): MediaKind { + if (typeof localStorage === 'undefined') return 'VIDEO'; + return localStorage.getItem(STORAGE_KEY) === 'AUDIO' ? 'AUDIO' : 'VIDEO'; +} + +// Whether episodes with a YouTube video should play the video or the audio file. Defaults to video. +export const media_preference = writable(read_preference()); + +media_preference.subscribe((value) => { + if (typeof localStorage !== 'undefined') { + localStorage.setItem(STORAGE_KEY, value); + } +}); + +/** + * Should this show play as a YouTube video? + * Requires a video, a video preference, and a network connection (offline always falls back to audio). + */ +export function should_play_video(show: { youtube_url?: string | null } | null | undefined) { + if (!show || !get_youtube_id(show.youtube_url)) return false; + if (typeof navigator !== 'undefined' && navigator.onLine === false) return false; + return get(media_preference) === 'VIDEO'; +} diff --git a/src/state/media_timeline.test.ts b/src/state/media_timeline.test.ts new file mode 100644 index 000000000..f58c258c1 --- /dev/null +++ b/src/state/media_timeline.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + get_saved_position, + record_duration, + reset_media_timeline, + save_position, + timelines_match, + timelines_match_cached +} from './media_timeline'; + +function create_storage() { + const data = new Map(); + return { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => data.set(key, value), + removeItem: (key: string) => data.delete(key), + clear: () => data.clear() + }; +} + +const youtube_url = 'https://www.youtube.com/watch?v=dj6tUUTDXAo'; + +describe('media_timeline', () => { + beforeEach(() => { + vi.stubGlobal('localStorage', create_storage()); + reset_media_timeline(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('keeps audio and video positions separate', () => { + save_position(1, 'AUDIO', 100, true); + save_position(1, 'VIDEO', 250, true); + expect(get_saved_position(1, 'AUDIO')).toBe(100); + expect(get_saved_position(1, 'VIDEO')).toBe(250); + // Audio keeps the original key + expect(localStorage.getItem('last_played_position_1')).toBe('100'); + }); + + it('throttles position writes unless forced', () => { + save_position(1, 'VIDEO', 10); + save_position(1, 'VIDEO', 12); + expect(get_saved_position(1, 'VIDEO')).toBe(10); + save_position(1, 'VIDEO', 16); + expect(get_saved_position(1, 'VIDEO')).toBe(16); + save_position(1, 'VIDEO', 17, true); + expect(get_saved_position(1, 'VIDEO')).toBe(17); + }); + + it('only borrows the other position when the timelines match', () => { + save_position(2, 'AUDIO', 300, true); + expect(get_saved_position(2, 'VIDEO')).toBe(0); + + record_duration(2, 'AUDIO', 3600); + record_duration(2, 'VIDEO', 3601.5); + expect(timelines_match_cached(2)).toBe(true); + expect(get_saved_position(2, 'VIDEO')).toBe(300); + }); + + it('detects mismatched timelines', async () => { + record_duration(3, 'AUDIO', 3600); + record_duration(3, 'VIDEO', 3450); + expect(timelines_match_cached(3)).toBe(false); + expect(await timelines_match({ number: 3, youtube_url })).toBe(false); + }); + + it('falls back to "no match" when durations are unknown', async () => { + expect(timelines_match_cached(4)).toBeNull(); + expect(await timelines_match({ number: 4, youtube_url })).toBe(false); + }); + + it('waits for a mounted video to learn its duration', async () => { + record_duration(5, 'AUDIO', 1800); + const video_el = { duration: 1801, loadComplete: Promise.resolve() }; + expect(await timelines_match({ number: 5, youtube_url }, video_el)).toBe(true); + }); + + it('persists durations across sessions', () => { + record_duration(6, 'AUDIO', 1200); + record_duration(6, 'VIDEO', 1200); + reset_media_timeline(); + expect(timelines_match_cached(6)).toBe(true); + }); +}); diff --git a/src/state/media_timeline.ts b/src/state/media_timeline.ts new file mode 100644 index 000000000..eefcb2509 --- /dev/null +++ b/src/state/media_timeline.ts @@ -0,0 +1,160 @@ +// Audio and YouTube versions of an episode don't always share a timeline (different intros, cuts, etc). +// This module tracks each version's duration and saved position separately so we never apply +// an audio timestamp to a video (or vice versa) unless we know the two line up. +import { get_youtube_id } from '$utilities/youtube'; + +export type MediaKind = 'AUDIO' | 'VIDEO'; + +// How far apart (in seconds) audio and video durations can be and still be considered the same timeline +const TIMELINE_TOLERANCE = 3; +// Minimum seconds of playback between localStorage position writes +const SAVE_INTERVAL = 5; +const PROBE_TIMEOUT = 5000; + +interface EpisodeDurations { + audio?: number; + video?: number; +} + +const durations = new Map(); +const last_saved = new Map(); + +const has_storage = () => typeof localStorage !== 'undefined'; + +function position_key(show_number: number, kind: MediaKind) { + // Audio keeps the original key so existing listening positions survive + return kind === 'AUDIO' + ? `last_played_position_${show_number}` + : `last_played_video_position_${show_number}`; +} + +function durations_key(show_number: number) { + return `episode_durations_${show_number}`; +} + +function get_durations(show_number: number): EpisodeDurations { + const cached = durations.get(show_number); + if (cached) return cached; + let stored: EpisodeDurations = {}; + if (has_storage()) { + try { + stored = JSON.parse(localStorage.getItem(durations_key(show_number)) ?? '{}'); + } catch { + stored = {}; + } + } + durations.set(show_number, stored); + return stored; +} + +export function record_duration(show_number: number, kind: MediaKind, seconds: number) { + if (!Number.isFinite(seconds) || seconds <= 0) return; + const current = get_durations(show_number); + const field = kind === 'AUDIO' ? 'audio' : 'video'; + if (current[field] === seconds) return; + const next = { ...current, [field]: seconds }; + durations.set(show_number, next); + if (has_storage()) { + localStorage.setItem(durations_key(show_number), JSON.stringify(next)); + } +} + +/** + * Synchronous check using only durations we've already seen. + * Returns null when we don't know yet. + */ +export function timelines_match_cached(show_number: number): boolean | null { + const { audio, video } = get_durations(show_number); + if (!audio || !video) return null; + return Math.abs(audio - video) <= TIMELINE_TOLERANCE; +} + +function probe_audio_duration(url: string): Promise { + if (typeof Audio === 'undefined') return Promise.resolve(null); + return new Promise((resolve) => { + const audio = new Audio(); + const finish = (value: number | null) => { + clearTimeout(timeout); + audio.removeAttribute('src'); + audio.load(); + resolve(value); + }; + const timeout = setTimeout(() => finish(null), PROBE_TIMEOUT); + audio.preload = 'metadata'; + audio.addEventListener('loadedmetadata', () => finish(audio.duration), { once: true }); + audio.addEventListener('error', () => finish(null), { once: true }); + audio.src = url; + }); +} + +async function wait_for_video_duration(video_el: { + duration: number; + loadComplete?: Promise; +}) { + if (!video_el.loadComplete) return video_el.duration; + const timeout = new Promise((resolve) => setTimeout(resolve, PROBE_TIMEOUT)); + await Promise.race([video_el.loadComplete, timeout]); + return video_el.duration; +} + +/** + * Do the audio and video versions of this show line up? + * Probes the audio file and waits on a mounted video element if durations aren't known yet. + * When we can't tell, returns false so timestamps fall back to the (exact) audio timeline. + */ +export async function timelines_match( + show: { number: number; url?: string; youtube_url?: string | null }, + video_el?: { duration: number; loadComplete?: Promise } | null +): Promise { + if (!get_youtube_id(show.youtube_url)) return false; + const cached = timelines_match_cached(show.number); + if (cached !== null) return cached; + + const known = get_durations(show.number); + const [audio_duration, video_duration] = await Promise.all([ + known.audio ?? (show.url ? probe_audio_duration(show.url) : null), + known.video ?? (video_el ? wait_for_video_duration(video_el) : null) + ]); + if (audio_duration) record_duration(show.number, 'AUDIO', audio_duration); + if (video_duration) record_duration(show.number, 'VIDEO', video_duration); + + return timelines_match_cached(show.number) ?? false; +} + +export function get_saved_position(show_number: number, kind: MediaKind): number { + if (!has_storage()) return 0; + const own = parseFloat(localStorage.getItem(position_key(show_number, kind)) ?? ''); + if (Number.isFinite(own) && own > 0) return own; + // No position for this version yet, but if the timelines match we can borrow the other one's + if (timelines_match_cached(show_number)) { + const other = parseFloat( + localStorage.getItem(position_key(show_number, kind === 'AUDIO' ? 'VIDEO' : 'AUDIO')) ?? '' + ); + if (Number.isFinite(other) && other > 0) return other; + } + return 0; +} + +/** + * Saves the playback position. Throttled to once every SAVE_INTERVAL seconds of playback + * unless `force` is set (pause, navigation, close, ended). + */ +export function save_position( + show_number: number, + kind: MediaKind, + seconds: number, + force = false +) { + if (!has_storage() || !Number.isFinite(seconds) || seconds < 0) return; + const key = position_key(show_number, kind); + const previous = last_saved.get(key); + if (!force && previous != null && Math.abs(seconds - previous) < SAVE_INTERVAL) return; + last_saved.set(key, seconds); + localStorage.setItem(key, seconds.toString()); +} + +// Test helper +export function reset_media_timeline() { + durations.clear(); + last_saved.clear(); +} diff --git a/src/state/player.ts b/src/state/player.ts index 89ccef8e6..e82ad1455 100644 --- a/src/state/player.ts +++ b/src/state/player.ts @@ -2,9 +2,20 @@ import * as Sentry from '@sentry/sveltekit'; import type { Show } from '@prisma/client'; import { get, writable } from 'svelte/store'; import { load_media_session } from '$utilities/media/load_media_session'; +import { get_youtube_id } from '$utilities/youtube'; import { minimize, player_window_status, toggle_minimize } from './player_window_status'; import { get_cached_or_network_show } from './player_offline'; import { load_state_from_indexed_db, open_db, STORE_NAME, type PlayerState } from './player_utils'; +import { media_preference, should_play_video } from './media_preference'; +import { + get_saved_position, + record_duration, + save_position as save_media_position, + timelines_match, + timelines_match_cached, + type MediaKind +} from './media_timeline'; +import { youtube_player } from './youtube_player'; export interface Timestamp { label: string; @@ -17,6 +28,16 @@ export interface Timestamp { export const episode_share_status = writable(false); +// media-chrome has no public API for pointing a at media outside of its slot. +// handleMediaUpdated is what it calls internally when the slotted media changes. +interface MediaControllerElement extends HTMLElement { + handleMediaUpdated: (media: HTMLElement) => Promise | void; +} + +function is_media_controller(el: unknown): el is MediaControllerElement { + return el instanceof HTMLElement && 'handleMediaUpdated' in el; +} + const new_player_state = () => { const initial_state: PlayerState = { current_show: null, @@ -24,12 +45,23 @@ const new_player_state = () => { media_controller: null, duration: 0, status: 'INITIAL', - initial_load: true + initial_load: true, + media_kind: 'AUDIO' }; const player_state = writable(initial_state); const { update, subscribe, set } = player_state; + // The media element the bottom bar's media-chrome controls are currently driving + let attached_media: HTMLElement | null = null; + + function attach_media(media: HTMLElement | null) { + const { media_controller } = get(player_state); + if (!media || media === attached_media || !is_media_controller(media_controller)) return; + attached_media = media; + void media_controller.handleMediaUpdated(media); + } + // Save state to IndexedDB const save_state_to_indexed_db = async () => { try { @@ -59,29 +91,46 @@ const new_player_state = () => { } }; - // Starts timer that save listening position. - function save_position(position?: number) { + // Saves the audio listening position. Throttled unless forced. + function save_position(force = false, position?: number) { const current_state = get(player_state); if (current_state.audio && current_state?.current_show?.number) { - localStorage.setItem( - `last_played_position_${current_state.current_show.number}`, - position != null ? position.toString() : current_state.audio.currentTime.toString() + save_media_position( + current_state.current_show.number, + 'AUDIO', + position ?? current_state.audio.currentTime, + force ); } } + function track_episode_start(show: Show) { + try { + // Analytics + Sentry.metrics.increment('episode_start', 1, { tags: { episode: show.number } }); + Sentry.metrics.increment('all_episode_start', 1); + + // Load incomming show into media session + // Side note: the mediaSession API is neat + // https://developer.mozilla.org/en-US/docs/Web/API/MediaSession + load_media_session(show); + + save_state_to_indexed_db(); + } catch (error) { + console.error('Error tracking episode start:', error); + } + } + // Prepares the player for the initial page load. Only called once. async function initialize(latest_show: Show) { const saved_state = await load_state_from_indexed_db(); - if (saved_state?.current_show) { - load_show(saved_state.current_show, true); - } else { - load_show(latest_show, true); - } + const show = saved_state?.current_show ?? latest_show; + await load_show(show, true); + update((state) => ({ ...state, media_kind: should_play_video(show) ? 'VIDEO' : 'AUDIO' })); } // Load show gets player state loaded and audio into playable state - // This is automatically run if you call start_show + // This is automatically run if you call start_audio async function load_show( requested_show: Show, is_initial_load = false, @@ -97,18 +146,8 @@ const new_player_state = () => { // Check to see if requested show is saved into cache const incoming_show = await get_cached_or_network_show(requested_show); - // Load position from local storate - const local_storage_episode_state = localStorage.getItem( - `last_played_position_${incoming_show.number}` - ); - - // If it exists, make string a number, otherwise set it to 0 - const actual_episode_state = local_storage_episode_state - ? parseFloat(local_storage_episode_state) - : 0; - // If playback is coming from timestamp, use timestamp, otherwise user last played position or 0. - const resume_time = play_from_position ?? actual_episode_state; + const resume_time = play_from_position ?? get_saved_position(incoming_show.number, 'AUDIO'); // Update state for new incomming show update((state) => { @@ -124,31 +163,164 @@ const new_player_state = () => { return incoming_show; } + function is_video_active_for(show: Show | null) { + return Boolean(show) && get(youtube_player).active_show?.number === show?.number; + } + + // Plays a show's audio file. Positions are on the audio timeline. + async function start_audio(requested_show: Show, play_from_position?: number, autoplay = true) { + youtube_player.close(); + update((state) => ({ ...state, media_kind: 'AUDIO' })); + attach_media(get(player_state).audio); + + const incoming_show = await load_show(requested_show, false, play_from_position); + try { + if (autoplay) track_episode_start(incoming_show); + + // This opens the UI Player drawer + player_window_status.set('ACTIVE'); + + // Finally Start Playing + if (autoplay) play_audio(); + } catch { + console.log('setting initial...'); + update((state) => ({ ...state, status: 'INITIAL' })); + } + } + + // Plays a show's YouTube video, inline if its embed is in view, otherwise in the mini-player. + // Positions are on the video timeline. + // resume_in_place: keep playing wherever the video already is (bottom bar play/resume). + // Otherwise playback moves to the show's embed when it's in view (e.g. a card's "Play" button). + async function start_video( + requested_show: Show, + play_from_position?: number, + { resume_in_place = false } = {} + ) { + const is_active = is_video_active_for(requested_show); + const is_resume = resume_in_place && is_active && play_from_position == null; + // Switch media kind before pausing audio so onpause doesn't treat this as the user pausing + update((state) => ({ + ...state, + initial_load: false, + current_show: requested_show, + status: 'PLAYING', + media_kind: 'VIDEO' + })); + get(player_state).audio?.pause(); + if (!is_active) track_episode_start(requested_show); + player_window_status.set('ACTIVE'); + + if (is_resume) { + await youtube_player.play(); + } else { + await youtube_player.start_show(requested_show, play_from_position); + } + } + + function play_audio() { + update((state) => { + if (state.audio) { + attach_media(state.audio); + state.audio.play(); + } + state.status = 'PLAYING'; + return state; + }); + } + + /** + * Plays a show from a timestamp on the audio timeline (show notes, transcript, ?t= links). + * Uses the video when its timeline matches the audio, otherwise the audio for exact timing. + */ + async function play_timestamp(show: Show, seconds: number) { + if (should_play_video(show)) { + if (await timelines_match(show, youtube_player.find_embed(show.number))) { + await start_video(show, seconds); + return; + } + } + + const state = get(player_state); + if (state.media_kind === 'AUDIO' && state.current_show?.number === show.number && state.audio) { + state.audio.currentTime = seconds; + play_audio(); + } else { + await start_audio(show, seconds); + } + } + // EVENTS - // Add these new functions function onplay() { - update((state) => ({ ...state, status: 'PLAYING' })); const current_state = get(player_state); + // The bottom bar was pointed at the audio element, but this show plays as video + if (current_state.media_kind === 'VIDEO' && current_state.current_show) { + void start_video(current_state.current_show, undefined, { resume_in_place: true }); + return; + } + update((state) => ({ ...state, status: 'PLAYING' })); if (current_state.current_show) { - save_position(); + save_position(true); } } function onpause() { + // Audio gets paused when playback moves to the video, that isn't the user pausing + if (get(player_state).media_kind === 'VIDEO') return; update((state) => ({ ...state, status: 'PAUSED' })); - save_position(); + save_position(true); } function ontimeupdate() { save_position(); } + function ondurationchange() { + const { audio, current_show } = get(player_state); + if (audio && current_show) { + record_duration(current_show.number, 'AUDIO', audio.duration); + } + } + function onended() { - save_position(0); + save_position(true, 0); } + // Keep the bottom bar in sync with whatever the YouTube player is doing + youtube_player.subscribe(({ active_show, status, active_element }) => { + if (!active_show || status === 'IDLE') return; + const state = get(player_state); + const next_status = status === 'PLAYING' ? 'PLAYING' : 'PAUSED'; + const is_new_show = state.current_show?.number !== active_show.number; + + if (is_new_show || state.status !== next_status || state.media_kind !== 'VIDEO') { + update((s) => ({ + ...s, + initial_load: false, + current_show: active_show, + status: next_status, + media_kind: 'VIDEO' + })); + } + // A video started from its own inline controls + if (is_new_show && status === 'PLAYING') { + track_episode_start(active_show); + } + if (status === 'PLAYING' && state.audio && !state.audio.paused) { + state.audio.pause(); + } + // With no video element mounted, the bottom bar drives the audio element, + // whose onplay hands back to the video + attach_media(active_element ?? state.audio); + + if (status === 'PLAYING' && get(player_window_status) === 'HIDDEN') { + player_window_status.set('ACTIVE'); + } + }); + return { ontimeupdate, + ondurationchange, subscribe, set, update, @@ -157,46 +329,38 @@ const new_player_state = () => { onpause, onplay, onended, + play_timestamp, - // The main method for playing a show + // The main method for playing a show. + // Plays the YouTube video when there is one (and the user prefers video), otherwise the audio. + // play_from_position is on the audio timeline (timestamps, share links). async start_show(requested_show: Show, play_from_position?: number) { - const incoming_show = await load_show(requested_show, false, play_from_position); - try { - // Analytics - Sentry.metrics.increment('episode_start', 1, { tags: { episode: incoming_show.number } }); - Sentry.metrics.increment('all_episode_start', 1); - - // Load incomming show into media session - // Side note: the mediaSession API is neat - // https://developer.mozilla.org/en-US/docs/Web/API/MediaSession - load_media_session(incoming_show); - - save_state_to_indexed_db(); - - // This opens the UI Player drawer - player_window_status.set('ACTIVE'); - - // Finally Start Playing - this.play(); - } catch (error) { - console.log('setting initial...'); - update((state) => ({ ...state, status: 'INITIAL' })); + if (play_from_position != null) { + return play_timestamp(requested_show, play_from_position); + } + if (should_play_video(requested_show)) { + return start_video(requested_show); } + return start_audio(requested_show); }, play() { - // On play, update the state writable and play audio - update((state) => { - if (state.audio) { - state.audio.play(); - } - state.status = 'PLAYING'; - return state; - }); + const { current_show, media_kind } = get(player_state); + if (media_kind === 'VIDEO' && current_show) { + void start_video(current_show, undefined, { resume_in_place: true }); + return; + } + play_audio(); }, pause() { - // On pause, update the state writable and play audio + const { current_show, media_kind } = get(player_state); + if (media_kind === 'VIDEO' && is_video_active_for(current_show)) { + youtube_player.pause(); + return; + } + + // On pause, update the state writable and pause audio update((state) => { if (state.audio) { state.audio.pause(); @@ -206,10 +370,51 @@ const new_player_state = () => { }); }, + /** + * Switches between the video and audio versions of the current show. + * Carries the position over when the timelines match, otherwise each resumes from its own saved spot. + */ + async set_media_kind(kind: MediaKind) { + media_preference.set(kind); + const state = get(player_state); + const show = state.current_show; + if (!show || state.media_kind === kind || !get_youtube_id(show.youtube_url)) return; + if (kind === 'VIDEO' && !should_play_video(show)) return; + + const was_playing = state.status === 'PLAYING'; + const from_time = + kind === 'VIDEO' ? (state.audio?.currentTime ?? 0) : youtube_player.get_current_time(); + const position = (await timelines_match(show, youtube_player.find_embed(show.number))) + ? from_time + : undefined; + + if (kind === 'AUDIO') { + await start_audio(show, position, was_playing); + } else if (was_playing) { + await start_video(show, position); + } else { + // Paused: the next play picks up the video from here + if (position != null) save_media_position(show.number, 'VIDEO', position, true); + update((s) => ({ ...s, media_kind: 'VIDEO' })); + } + }, + + /** + * Current position on the audio timeline, or null if the video is playing and + * we don't know that its timeline matches the audio (transcripts, timestamps). + */ + get_audio_timeline_time(): number | null { + const { current_show, media_kind, audio } = get(player_state); + if (!current_show) return null; + if (media_kind === 'AUDIO') return audio?.currentTime ?? 0; + return timelines_match_cached(current_show.number) ? youtube_player.get_current_time() : null; + }, + reset() { // Resetting the player state. // Reset the player state and pause audio // Set currentTime to 0 (probably doesn't need to happen) + youtube_player.close(); update((state) => { if (state.audio) { state.audio.pause(); @@ -217,10 +422,17 @@ const new_player_state = () => { } return { ...initial_state, audio: state.audio, media_controller: state.media_controller }; }); + attach_media(get(player_state).audio); }, - // Jumps the time in the playing show + // Jumps the time in the playing show, on the timeline of whichever media is playing update_time(time: number) { + const { current_show, media_kind } = get(player_state); + if (media_kind === 'VIDEO' && current_show && is_video_active_for(current_show)) { + void youtube_player.seek(current_show, time); + return; + } + update((state) => { if (state.audio) { state.audio.currentTime = time; @@ -230,19 +442,18 @@ const new_player_state = () => { }, close() { + youtube_player.close(); update((state) => { if (state.audio) { - if (state.current_show) { - localStorage.setItem( - `last_played_position_${state.current_show.number}`, - state.audio.currentTime.toString() - ); + if (state.current_show && state.media_kind === 'AUDIO') { + save_media_position(state.current_show.number, 'AUDIO', state.audio.currentTime, true); } state.audio.pause(); state.audio.removeAttribute('src'); } return { ...initial_state, audio: state.audio, media_controller: state.media_controller }; }); + attach_media(get(player_state).audio); player_window_status.set('HIDDEN'); }, diff --git a/src/state/player_utils.ts b/src/state/player_utils.ts index ca2cb8f76..c0af8bb96 100644 --- a/src/state/player_utils.ts +++ b/src/state/player_utils.ts @@ -1,4 +1,5 @@ import type { Show } from '@prisma/client'; +import type { MediaKind } from './media_timeline'; export interface PlayerState { current_show: null | Show; @@ -7,6 +8,8 @@ export interface PlayerState { duration: number; status: 'INITIAL' | 'LOADED' | 'LOADING' | 'PAUSED' | 'PLAYING'; initial_load: boolean; + // Whether the current show is playing from the audio file or the YouTube video + media_kind: MediaKind; } export const DB_NAME = 'SyntaxDB'; diff --git a/src/state/youtube_player.test.ts b/src/state/youtube_player.test.ts new file mode 100644 index 000000000..498fdae09 --- /dev/null +++ b/src/state/youtube_player.test.ts @@ -0,0 +1,293 @@ +import type { Show } from '@prisma/client'; +import { get } from 'svelte/store'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { youtube_player, type YoutubeVideoElementLike } from './youtube_player'; + +function create_mock_video(initial_time = 0) { + return { + src: 'https://www.youtube.com/watch?v=dj6tUUTDXAo', + currentTime: initial_time, + duration: 3600, + paused: false, + isConnected: true, + dispatchEvent: vi.fn(), + play: vi.fn().mockResolvedValue(undefined), + pause: vi.fn().mockResolvedValue(undefined) + } as unknown as YoutubeVideoElementLike & { isConnected: boolean }; +} + +function create_mock_container(top = 100, bottom = 300, width = 400) { + return { + isConnected: true, + getBoundingClientRect: () => ({ + top, + bottom, + left: 0, + right: width, + width, + height: bottom - top + }) + } as unknown as HTMLElement; +} + +function register( + show_number: number, + video_el: YoutubeVideoElementLike, + container_el = create_mock_container(), + is_show_page = false +) { + return youtube_player.register_embed({ show_number, video_el, container_el, is_show_page }); +} + +const show_790 = { + number: 790, + title: 'State of JS', + slug: 'state-of-js', + youtube_url: 'https://www.youtube.com/watch?v=dj6tUUTDXAo' +} as Show; + +const show_760 = { + number: 760, + title: 'Pro VSCode Setups', + slug: 'pro-vscode-setups', + youtube_url: 'https://www.youtube.com/watch?v=VIdM5VSlJVw' +} as Show; + +describe('youtube_player store', () => { + beforeEach(() => { + youtube_player.close(); + }); + + it('plays the inline embed when it is in view', async () => { + const inline_el = create_mock_video(0); + const unregister = register(790, inline_el); + + await youtube_player.start_show(show_790, 30); + + const state = get(youtube_player); + expect(state.active_show?.number).toBe(790); + expect(state.status).toBe('PLAYING'); + expect(state.in_mini_player).toBe(false); + expect(state.active_element).toBe(inline_el); + expect(inline_el.currentTime).toBe(30); + expect(inline_el.play).toHaveBeenCalled(); + + unregister(); + }); + + it('plays in the mini-player when the embed is hidden (zero size)', async () => { + const hidden_el = create_mock_video(0); + const unregister = register(790, hidden_el, create_mock_container(0, 0, 0)); + + await youtube_player.start_show(show_790); + + const state = get(youtube_player); + expect(state.in_mini_player).toBe(true); + expect(state.active_element).toBeNull(); + expect(hidden_el.play).not.toHaveBeenCalled(); + + unregister(); + }); + + it('show page: pops out when scrolled away and docks back when scrolled into view', () => { + const inline_el = create_mock_video(42.5); + const mini_el = create_mock_video(42.5); + const unregister = register(790, inline_el, undefined, true); + + youtube_player.set_playing(show_790, inline_el); + expect(get(youtube_player).origin_element).toBe(inline_el); + + inline_el.currentTime = 65; + youtube_player.set_embed_visibility(inline_el, false); + // The resulting pause event from the inline element must not pause playback + youtube_player.set_paused(790, inline_el); + + let state = get(youtube_player); + expect(state.in_mini_player).toBe(true); + expect(state.status).toBe('PLAYING'); + expect(state.current_time).toBe(65); + expect(inline_el.pause).toHaveBeenCalled(); + + youtube_player.attach_mini_player(mini_el); + mini_el.currentTime = 95.5; + + youtube_player.set_embed_visibility(inline_el, true); + + state = get(youtube_player); + expect(state.in_mini_player).toBe(false); + expect(state.status).toBe('PLAYING'); + expect(state.active_element).toBe(inline_el); + expect(state.current_time).toBe(95.5); + expect(mini_el.pause).toHaveBeenCalled(); + + unregister(); + }); + + it('list card: keeps the mini-player when scrolled back into view until the card is played', async () => { + const card_el = create_mock_video(10); + const mini_el = create_mock_video(10); + const unregister = register(790, card_el); + + youtube_player.set_playing(show_790, card_el); + card_el.currentTime = 30; + youtube_player.set_embed_visibility(card_el, false); + youtube_player.attach_mini_player(mini_el); + mini_el.currentTime = 50; + + // Scrolling the card back into view doesn't take playback back + youtube_player.set_embed_visibility(card_el, true); + let state = get(youtube_player); + expect(state.in_mini_player).toBe(true); + expect(state.active_element).toBe(mini_el); + + // The card's Play button moves playback back into the card from the mini-player's position + await youtube_player.start_show(show_790); + state = get(youtube_player); + expect(state.in_mini_player).toBe(false); + expect(state.active_element).toBe(card_el); + expect(card_el.currentTime).toBe(50); + expect(mini_el.pause).toHaveBeenCalled(); + + unregister(); + }); + + it('keeps the mini-player going across navigation and hands off between episodes', () => { + const show_page_el = create_mock_video(50); + const mini_el = create_mock_video(50); + + const unregister_show_page = register(790, show_page_el, undefined, true); + youtube_player.set_playing(show_790, show_page_el); + + // Navigate from /show/790 to /shows + show_page_el.currentTime = 72; + youtube_player.before_navigate(); + show_page_el.isConnected = false; + unregister_show_page(); + youtube_player.after_navigate(); + + let state = get(youtube_player); + expect(state.in_mini_player).toBe(true); + expect(state.status).toBe('PLAYING'); + expect(state.current_time).toBe(72); + expect(state.origin_element).toBeNull(); + + youtube_player.attach_mini_player(mini_el); + mini_el.currentTime = 110; + + // A card for the same episode scrolling into view doesn't hijack the mini-player + const card_790_el = create_mock_video(0); + youtube_player.dock_to_inline(790, card_790_el); + expect(get(youtube_player).in_mini_player).toBe(true); + + // Playing that card continues from the mini-player's position + youtube_player.set_playing(show_790, card_790_el); + state = get(youtube_player); + expect(state.in_mini_player).toBe(false); + expect(state.origin_element).toBe(card_790_el); + expect(state.current_time).toBe(110); + expect(card_790_el.currentTime).toBe(110); + expect(mini_el.pause).toHaveBeenCalled(); + + // Playing a different episode starts it fresh + youtube_player.enter_mini_player_from_scroll(790, card_790_el); + const card_760_el = create_mock_video(0); + youtube_player.set_playing(show_760, card_760_el); + + state = get(youtube_player); + expect(state.in_mini_player).toBe(false); + expect(state.active_show?.number).toBe(760); + expect(state.origin_element).toBe(card_760_el); + expect(state.current_time).toBe(0); + }); + + it('the show page embed takes over playback of its episode when it mounts', async () => { + const mini_el = create_mock_video(0); + await youtube_player.start_show(show_790, 20); + youtube_player.attach_mini_player(mini_el); + mini_el.currentTime = 40; + + const show_page_el = create_mock_video(0); + const unregister = register(790, show_page_el, undefined, true); + + const state = get(youtube_player); + expect(state.in_mini_player).toBe(false); + expect(state.active_element).toBe(show_page_el); + expect(state.current_time).toBe(40); + + unregister(); + }); + + it('pauses without restarting and resumes from the same position', async () => { + const inline_el = create_mock_video(0); + const unregister = register(790, inline_el, undefined, true); + await youtube_player.start_show(show_790, 120); + + inline_el.currentTime = 185; + youtube_player.pause(); + + let state = get(youtube_player); + expect(state.status).toBe('PAUSED'); + expect(state.current_time).toBe(185); + expect(inline_el.pause).toHaveBeenCalled(); + + await youtube_player.play(); + state = get(youtube_player); + expect(state.status).toBe('PLAYING'); + expect(inline_el.currentTime).toBe(185); + expect(inline_el.play).toHaveBeenCalledTimes(2); + + unregister(); + }); + + it('resuming with nothing mounted opens the mini-player', async () => { + const inline_el = create_mock_video(0); + const unregister = register(790, inline_el); + await youtube_player.start_show(show_790, 10); + inline_el.currentTime = 30; + youtube_player.pause(); + + // Navigated away while paused + inline_el.isConnected = false; + unregister(); + + await youtube_player.play(); + const state = get(youtube_player); + expect(state.status).toBe('PLAYING'); + expect(state.in_mini_player).toBe(true); + expect(state.current_time).toBe(30); + }); + + it('minimizes, dismisses, and resumes minimized', async () => { + const mini_el = create_mock_video(60); + + await youtube_player.start_show(show_790, 60); + youtube_player.attach_mini_player(mini_el); + + let state = get(youtube_player); + expect(state.in_mini_player).toBe(true); + expect(state.is_minimized).toBe(false); + + youtube_player.toggle_minimize(); + expect(get(youtube_player).is_minimized).toBe(true); + expect(get(youtube_player).status).toBe('PLAYING'); + youtube_player.toggle_minimize(); + expect(get(youtube_player).is_minimized).toBe(false); + + mini_el.currentTime = 95; + youtube_player.dismiss_mini_player(); + state = get(youtube_player); + expect(state.status).toBe('PAUSED'); + expect(state.is_minimized).toBe(true); + expect(state.is_dismissed).toBe(true); + expect(state.current_time).toBe(95); + expect(mini_el.pause).toHaveBeenCalled(); + + await youtube_player.play(); + state = get(youtube_player); + expect(state.status).toBe('PLAYING'); + expect(state.in_mini_player).toBe(true); + expect(state.is_minimized).toBe(true); + expect(state.is_dismissed).toBe(false); + expect(mini_el.play).toHaveBeenCalled(); + }); +}); diff --git a/src/state/youtube_player.ts b/src/state/youtube_player.ts new file mode 100644 index 000000000..00d12fe56 --- /dev/null +++ b/src/state/youtube_player.ts @@ -0,0 +1,519 @@ +import type { Show } from '@prisma/client'; +import { get, writable } from 'svelte/store'; +import { get_youtube_id } from '$utilities/youtube'; +import { get_saved_position, record_duration, save_position } from './media_timeline'; + +// The subset of (youtube-video-element) we rely on +export interface YoutubeVideoElementLike extends HTMLElement { + src: string | null; + currentTime: number; + duration: number; + paused: boolean; + isLoaded?: boolean; + loadComplete?: Promise; + play: () => Promise; + pause: () => Promise | void; +} + +interface EmbedEntry { + show_number: number; + video_el: YoutubeVideoElementLike; + container_el: HTMLElement; + is_show_page: boolean; +} + +export interface YoutubePlayerState { + active_show: Show | null; + status: 'IDLE' | 'PLAYING' | 'PAUSED'; + // Snapshot of the playback position, taken whenever playback moves between elements + current_time: number; + // The currently playing (inline embed or mini-player) + active_element: YoutubeVideoElementLike | null; + // The inline embed playback started from, which it docks back into when scrolled into view + origin_element: YoutubeVideoElementLike | null; + in_mini_player: boolean; + is_minimized: boolean; + is_dismissed: boolean; + is_navigating: boolean; +} + +// Pop out to the mini-player as soon as the embed is a few pixels out of the viewport +const VIEWPORT_TOLERANCE = 4; +const TICK_INTERVAL = 250; + +function element_time(el: YoutubeVideoElementLike | null | undefined): number | null { + if (!el) return null; + const time = el.currentTime; + return Number.isFinite(time) && time > 0 ? time : null; +} + +function live_time(state: YoutubePlayerState) { + return element_time(state.active_element) ?? state.current_time; +} + +function safe_pause(el: YoutubeVideoElementLike | null | undefined) { + if (!el) return; + try { + void Promise.resolve(el.pause()).catch(() => {}); + } catch { + // The YouTube API throws if the iframe has gone away, nothing to pause + } +} + +async function seek_and_play(el: YoutubeVideoElementLike, time: number, should_play: boolean) { + try { + if (el.loadComplete) await el.loadComplete; + if (time > 0 && Math.abs((el.currentTime || 0) - time) > 1) { + el.currentTime = time; + } + if (should_play) await el.play(); + } catch { + // Autoplay can be blocked by the browser, the user can still hit play + } +} + +function is_container_in_view(container: HTMLElement): boolean { + if (!container.isConnected) return false; + const rect = container.getBoundingClientRect(); + // Zero size means it's hidden, so it can't be in view + if (rect.width === 0 && rect.height === 0) return false; + const viewport_height = + typeof window !== 'undefined' + ? window.innerHeight || document.documentElement.clientHeight + : Infinity; + return rect.top >= -VIEWPORT_TOLERANCE && rect.bottom <= viewport_height + VIEWPORT_TOLERANCE; +} + +function create_youtube_player() { + const initial_state: YoutubePlayerState = { + active_show: null, + status: 'IDLE', + current_time: 0, + active_element: null, + origin_element: null, + in_mini_player: false, + is_minimized: false, + is_dismissed: false, + is_navigating: false + }; + + const store = writable(initial_state); + const { subscribe, update } = store; + + const embeds = new Map(); + let observer: IntersectionObserver | null = null; + let ticker: ReturnType | null = null; + + // TICKER + // One interval for the playing element only. Saves position (throttled) and keeps + // media-chrome's bottom bar in sync, as the YouTube iframe doesn't reliably fire timeupdate. + function tick() { + const { active_show, active_element } = get(store); + if (!active_show || !active_element) return; + record_duration(active_show.number, 'VIDEO', active_element.duration); + const time = element_time(active_element); + if (time === null) return; + save_position(active_show.number, 'VIDEO', time); + active_element.dispatchEvent(new Event('timeupdate')); + } + + subscribe((state) => { + const should_tick = state.status === 'PLAYING' && Boolean(state.active_element); + if (should_tick && !ticker) { + ticker = setInterval(tick, TICK_INTERVAL); + } else if (!should_tick && ticker) { + clearInterval(ticker); + ticker = null; + } + }); + + function save_now(state: YoutubePlayerState) { + if (state.active_show) { + save_position(state.active_show.number, 'VIDEO', live_time(state), true); + } + } + + // VISIBILITY + function get_observer() { + if (!observer && typeof IntersectionObserver !== 'undefined') { + observer = new IntersectionObserver(on_intersect, { + // Intermediate thresholds so an embed that's already partly out of view still pops out on scroll + threshold: [0, 0.25, 0.5, 0.75, 1], + rootMargin: `${VIEWPORT_TOLERANCE}px 0px` + }); + } + return observer; + } + + function on_intersect(entries: IntersectionObserverEntry[]) { + for (const entry of entries) { + const embed = [...embeds.values()].find((e) => e.container_el === entry.target); + if (!embed) continue; + // Rounding can leave a fully visible element at 0.99something + const in_view = entry.isIntersecting && entry.intersectionRatio >= 0.99; + set_embed_visibility(embed.video_el, in_view); + } + } + + /** + * Scrolling a playing embed out of view pops it out to the mini-player. + * Scrolling back only docks into the show page embed. On list pages the mini-player + * keeps playing until the user plays the card directly. + */ + function set_embed_visibility(video_el: YoutubeVideoElementLike, in_view: boolean) { + const embed = embeds.get(video_el); + if (!embed) return; + if (!in_view) { + enter_mini_player_from_scroll(embed.show_number, video_el); + } else if (embed.is_show_page) { + dock_to_inline(embed.show_number, video_el); + } + } + + function active_show_is(state: YoutubePlayerState, show_number: number) { + return state.active_show?.number === show_number; + } + + /** + * Registers an inline . Returns a cleanup function to call on unmount. + */ + function register_embed(entry: EmbedEntry) { + embeds.set(entry.video_el, entry); + get_observer()?.observe(entry.container_el); + + // The show page embed takes over playback of its episode (from a card or the mini-player) + if (entry.is_show_page && active_show_is(get(store), entry.show_number)) { + dock_to_inline(entry.show_number, entry.video_el, true); + } + + return () => unregister_embed(entry); + } + + function unregister_embed({ show_number, video_el, container_el }: EmbedEntry) { + observer?.unobserve(container_el); + embeds.delete(video_el); + + const state = get(store); + if (!active_show_is(state, show_number)) return; + const was_active = state.active_element === video_el; + if (!was_active && state.origin_element !== video_el) return; + + const time = was_active ? (element_time(video_el) ?? state.current_time) : state.current_time; + if (was_active) save_position(show_number, 'VIDEO', time, true); + + update((s) => ({ + ...s, + current_time: time, + active_element: was_active ? null : s.active_element, + origin_element: s.origin_element === video_el ? null : s.origin_element, + // Keep playing in the mini-player when a playing embed goes away (navigation) + in_mini_player: s.in_mini_player || (was_active && s.status === 'PLAYING') + })); + } + + // The embed a show should play in: the show page's if mounted, otherwise the latest mounted card + function find_embed_entry(show_number: number) { + const candidates = [...embeds.values()].filter( + (e) => e.show_number === show_number && e.video_el.isConnected + ); + return candidates.find((e) => e.is_show_page) ?? candidates.at(-1) ?? null; + } + + function find_embed(show_number: number) { + return find_embed_entry(show_number)?.video_el ?? null; + } + + /** + * Starts or resumes a show's video from the "Play" buttons. + * Plays in the mounted inline embed if it's in view, otherwise in the mini-player. + * `play_from_position` is on the video's timeline. + */ + async function start_show(show: Show, play_from_position?: number) { + if (!get_youtube_id(show.youtube_url)) return false; + + const current = get(store); + const is_same_show = active_show_is(current, show.number); + const resume_time = + play_from_position ?? + (is_same_show ? live_time(current) : get_saved_position(show.number, 'VIDEO')); + + const target = find_embed_entry(show.number); + const play_inline = target ? is_container_in_view(target.container_el) : false; + + const previous = current.active_element; + const reuse_mini = + !play_inline && is_same_show && current.in_mini_player && previous?.isConnected + ? previous + : null; + const next_element = play_inline && target ? target.video_el : reuse_mini; + + if (previous && previous !== next_element) { + save_now(current); + safe_pause(previous); + } + + update((s) => ({ + ...s, + active_show: show, + status: 'PLAYING', + current_time: resume_time, + active_element: next_element, + origin_element: target?.video_el ?? null, + in_mini_player: !play_inline, + is_minimized: !play_inline && is_same_show ? s.is_minimized : false, + is_dismissed: false + })); + + // Otherwise the mini-player mounts and picks up playback in attach_mini_player + if (next_element) { + await seek_and_play(next_element, resume_time, true); + } + return true; + } + + /** + * Called when an inline starts playing from its own controls. + */ + function set_playing(show: Show, element: YoutubeVideoElementLike) { + if (!get_youtube_id(show.youtube_url)) return; + + const current = get(store); + const is_same_show = active_show_is(current, show.number); + + if (is_same_show && current.active_element === element && !current.in_mini_player) { + if (current.status !== 'PLAYING') { + update((s) => ({ ...s, status: 'PLAYING', is_dismissed: false })); + } + return; + } + + let resume_time: number; + if (is_same_show) { + // Same episode playing elsewhere (mini-player or another card), continue from there + resume_time = live_time(current); + } else { + resume_time = element_time(element) ?? get_saved_position(show.number, 'VIDEO'); + } + if (resume_time > 0 && Math.abs((element.currentTime || 0) - resume_time) > 1) { + element.currentTime = resume_time; + } + + if (current.active_element && current.active_element !== element) { + save_now(current); + safe_pause(current.active_element); + } + + update((s) => ({ + ...s, + active_show: show, + status: 'PLAYING', + current_time: resume_time, + active_element: element, + origin_element: element, + in_mini_player: false, + is_minimized: false, + is_dismissed: false + })); + } + + /** + * The mini-player's mounted (or switched shows). Picks up playback where it left off. + */ + function attach_mini_player(element: YoutubeVideoElementLike) { + const state = get(store); + update((s) => ({ ...s, active_element: element })); + void seek_and_play(element, state.current_time, state.status === 'PLAYING'); + } + + /** + * The mini-player's is unmounting, snapshot its position. + */ + function detach_mini_player(element: YoutubeVideoElementLike) { + const state = get(store); + if (state.active_element !== element) return; + save_now(state); + update((s) => ({ ...s, current_time: live_time(s), active_element: null })); + } + + /** + * Called when the mini-player starts playing from its own controls. + */ + function set_mini_player_playing(element: YoutubeVideoElementLike) { + update((s) => ({ + ...s, + status: 'PLAYING', + active_element: element, + in_mini_player: true, + is_dismissed: false + })); + } + + function enter_mini_player_from_scroll(show_number: number, inline_el: YoutubeVideoElementLike) { + const state = get(store); + if (state.is_navigating || state.in_mini_player || state.status !== 'PLAYING') return; + if (!active_show_is(state, show_number) || state.origin_element !== inline_el) return; + + update((s) => ({ + ...s, + current_time: element_time(inline_el) ?? s.current_time, + active_element: null, + in_mini_player: true, + is_dismissed: false + })); + safe_pause(inline_el); + } + + /** + * Moves playback back into an inline embed. Only the embed playback came from can reclaim it, + * unless `force_claim` is set (the show page embed for the playing episode). + */ + function dock_to_inline( + show_number: number, + inline_el: YoutubeVideoElementLike, + force_claim = false + ) { + const state = get(store); + if (!active_show_is(state, show_number)) return; + if (!force_claim && (!state.in_mini_player || state.origin_element !== inline_el)) return; + if (state.active_element === inline_el && !state.in_mini_player) return; + + // Dismissed mini-player: remember where to dock but don't take over playback + if (state.is_dismissed) { + update((s) => ({ ...s, origin_element: inline_el })); + return; + } + + const previous = state.active_element; + const resume_time = live_time(state); + + update((s) => ({ + ...s, + current_time: resume_time, + active_element: inline_el, + origin_element: inline_el, + in_mini_player: false + })); + + if (previous && previous !== inline_el) safe_pause(previous); + void seek_and_play(inline_el, resume_time, state.status === 'PLAYING'); + } + + function set_paused(show_number: number, element: YoutubeVideoElementLike) { + const state = get(store); + if (state.is_navigating || !active_show_is(state, show_number)) return; + // Ignore pauses from elements we've moved playback away from + if (!element.isConnected || state.active_element !== element) return; + + const time = element_time(element) ?? state.current_time; + save_position(show_number, 'VIDEO', time, true); + update((s) => ({ ...s, status: 'PAUSED', current_time: time })); + } + + async function play() { + const state = get(store); + if (!state.active_show) return; + // Nowhere to play (navigated away while paused), find an embed or open the mini-player + if (!state.active_element) { + await start_show(state.active_show, state.current_time); + return; + } + update((s) => ({ ...s, status: 'PLAYING', is_dismissed: false })); + await seek_and_play(state.active_element, state.current_time, true); + } + + function pause() { + const state = get(store); + if (!state.active_show) return; + save_now(state); + update((s) => ({ ...s, status: 'PAUSED', current_time: live_time(state) })); + safe_pause(state.active_element); + } + + async function seek(show: Show, seconds: number) { + const state = get(store); + if (active_show_is(state, show.number) && state.active_element) { + update((s) => ({ ...s, current_time: seconds, status: 'PLAYING', is_dismissed: false })); + await seek_and_play(state.active_element, seconds, true); + } else { + await start_show(show, seconds); + } + } + + function toggle_minimize() { + update((s) => ({ ...s, is_minimized: !s.is_minimized, is_dismissed: false })); + } + + /** + * Hides the mini-player and pauses, but keeps the element mounted + * so the bottom player bar can resume it. + */ + function dismiss_mini_player() { + const state = get(store); + if (!state.active_show) return; + save_now(state); + update((s) => ({ + ...s, + status: 'PAUSED', + current_time: live_time(state), + in_mini_player: true, + is_minimized: true, + is_dismissed: true + })); + safe_pause(state.active_element); + } + + function close() { + const state = get(store); + save_now(state); + update(() => ({ ...initial_state })); + safe_pause(state.active_element); + } + + function before_navigate() { + const state = get(store); + save_now(state); + update((s) => ({ ...s, current_time: live_time(s), is_navigating: true })); + } + + function after_navigate() { + update((s) => { + // The playing element went away with the old page, keep going in the mini-player + const lost_element = s.status === 'PLAYING' && !s.active_element?.isConnected; + return { + ...s, + is_navigating: false, + in_mini_player: s.in_mini_player || (Boolean(s.active_show) && lost_element) + }; + }); + } + + function get_current_time() { + return live_time(get(store)); + } + + return { + subscribe, + register_embed, + find_embed, + set_embed_visibility, + start_show, + set_playing, + attach_mini_player, + detach_mini_player, + set_mini_player_playing, + enter_mini_player_from_scroll, + dock_to_inline, + set_paused, + play, + pause, + seek, + toggle_minimize, + dismiss_mini_player, + close, + before_navigate, + after_navigate, + get_current_time + }; +} + +export const youtube_player = create_youtube_player(); diff --git a/src/utilities/youtube.test.ts b/src/utilities/youtube.test.ts new file mode 100644 index 000000000..c04e5f56c --- /dev/null +++ b/src/utilities/youtube.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { get_youtube_id, get_youtube_watch_url } from './youtube'; + +describe('YouTube URL utilities', () => { + it('extracts video ID from standard watch URLs', () => { + expect(get_youtube_id('https://www.youtube.com/watch?v=dj6tUUTDXAo')).toBe('dj6tUUTDXAo'); + }); + + it('extracts video ID from youtu.be short URLs', () => { + expect(get_youtube_id('https://youtu.be/lYXYmcDjGMA')).toBe('lYXYmcDjGMA'); + }); + + it('extracts video ID from youtube.com/live/ URLs', () => { + expect(get_youtube_id('https://www.youtube.com/live/hZCBtPDe8-g')).toBe('hZCBtPDe8-g'); + }); + + it('extracts video ID from youtube.com/watch/ URLs', () => { + expect(get_youtube_id('https://www.youtube.com/watch/pMwTR2KeuaU')).toBe('pMwTR2KeuaU'); + }); + + it('returns null for missing or invalid URLs', () => { + expect(get_youtube_id(null)).toBeNull(); + expect(get_youtube_id(undefined)).toBeNull(); + expect(get_youtube_id('')).toBeNull(); + expect(get_youtube_id('https://syntax.fm')).toBeNull(); + }); + + it('normalizes watch URLs', () => { + expect(get_youtube_watch_url('https://youtu.be/lYXYmcDjGMA')).toBe( + 'https://www.youtube.com/watch?v=lYXYmcDjGMA' + ); + expect(get_youtube_watch_url('https://www.youtube.com/live/hZCBtPDe8-g')).toBe( + 'https://www.youtube.com/watch?v=hZCBtPDe8-g' + ); + expect(get_youtube_watch_url(null)).toBeNull(); + }); +}); diff --git a/src/utilities/youtube.ts b/src/utilities/youtube.ts new file mode 100644 index 000000000..6eb0e1597 --- /dev/null +++ b/src/utilities/youtube.ts @@ -0,0 +1,13 @@ +const YOUTUBE_ID_REGEX = + /(?:youtu\.be\/|youtube(?:-nocookie)?\.com\/(?:embed\/|v\/|watch\/|watch\?v=|watch\?.+&v=|shorts\/|live\/))([\w-]{11})/; + +export function get_youtube_id(url: string | null | undefined): string | null { + if (!url) return null; + const match = url.match(YOUTUBE_ID_REGEX); + return match ? match[1] : null; +} + +export function get_youtube_watch_url(url: string | null | undefined): string | null { + const id = get_youtube_id(url); + return id ? `https://www.youtube.com/watch?v=${id}` : null; +}