From e302b7c06be3bd9b83f9e82ee5abd012435e374c Mon Sep 17 00:00:00 2001 From: w3cj Date: Wed, 7 Oct 2026 13:41:31 -0600 Subject: [PATCH 1/3] feat: add YouTube episode embeds, mini-player, and synchronized player controls --- src/lib/ShowCard.svelte | 85 ++- src/lib/videos/YoutubeEmbed.svelte | 151 ++++ src/lib/videos/YoutubeMiniPlayer.svelte | 242 ++++++ src/routes/(site)/+layout.svelte | 13 +- src/routes/(site)/+page.svelte | 10 +- .../show/[show_number]/[slug]/+layout.svelte | 143 +++- src/state/player.ts | 173 ++++- src/state/youtube_player.ts | 709 ++++++++++++++++++ src/utilities/youtube.test.ts | 280 +++++++ src/utilities/youtube.ts | 13 + 10 files changed, 1757 insertions(+), 62 deletions(-) create mode 100644 src/lib/videos/YoutubeEmbed.svelte create mode 100644 src/lib/videos/YoutubeMiniPlayer.svelte create mode 100644 src/state/youtube_player.ts create mode 100644 src/utilities/youtube.test.ts create mode 100644 src/utilities/youtube.ts diff --git a/src/lib/ShowCard.svelte b/src/lib/ShowCard.svelte index c1b90dc4c..9b9335d26 100644 --- a/src/lib/ShowCard.svelte +++ b/src/lib/ShowCard.svelte @@ -1,14 +1,16 @@ -
+ + + diff --git a/src/lib/videos/YoutubeMiniPlayer.svelte b/src/lib/videos/YoutubeMiniPlayer.svelte new file mode 100644 index 000000000..aa8e99096 --- /dev/null +++ b/src/lib/videos/YoutubeMiniPlayer.svelte @@ -0,0 +1,242 @@ + + +{#if show_mini_player && active_show && watch_url} + +{/if} + + + diff --git a/src/routes/(site)/+layout.svelte b/src/routes/(site)/+layout.svelte index 23ba755ae..f7c29c968 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,14 @@ let { data, children } = $props(); let { user, user_theme, latest } = $derived(data); + beforeNavigate(() => { + youtube_player.before_navigate(); + }); + + afterNavigate(() => { + youtube_player.after_navigate(); + }); + onNavigate(async (navigation) => { if (!document.startViewTransition) return; @@ -55,6 +65,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..6743b6026 100644 --- a/src/routes/(site)/show/[show_number]/[slug]/+layout.svelte +++ b/src/routes/(site)/show/[show_number]/[slug]/+layout.svelte @@ -9,6 +9,9 @@ import Tabs from '$lib/Tabs.svelte'; import ShareWindow from '$lib/share/ShareWindow.svelte'; import { player } from '$state/player'; + import { youtube_player } from '$state/youtube_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'; @@ -16,6 +19,7 @@ let { data, children } = $props(); let { show, time_start } = $derived(data); let downloadName = $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,6 +27,10 @@ e.preventDefault(); const href = target.getAttribute('href'); const timestamp = href ? tsToS(href.replace('#t=', '')) : 0; + if ($youtube_player.active_show?.number === show.number) { + await youtube_player.seek(show, timestamp); + return; + } // 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); @@ -72,35 +80,71 @@ {@html ``} -
- {show.number} -

- {format(new Date(show.date), 'MMMM do, yyyy')} - × - - {#each show.aiShowNote?.topics?.slice(0, 5) || [] as topic} - {topic.name.startsWith('#') ? '' : '#'}{topic.name} - {/each} - -

- -

- {show.title} -

- - {#if show.aiShowNote?.description} -

{show.aiShowNote?.description}

+
+ {#if has_youtube} +
+

+ {format(new Date(show.date), 'MMMM do, yyyy')} + × + + {#each show.aiShowNote?.topics?.slice(0, 5) || [] as topic} + {topic.name.startsWith('#') ? '' : '#'}{topic.name} + {/each} + +

+ +

+ {show.title} +

+ + {#if show.aiShowNote?.description} +

{show.aiShowNote?.description}

+ {/if} + + +
+ +
+ #{show.number} + +
+ {:else} + {show.number} +

+ {format(new Date(show.date), 'MMMM do, yyyy')} + × + + {#each show.aiShowNote?.topics?.slice(0, 5) || [] as topic} + {topic.name.startsWith('#') ? '' : '#'}{topic.name} + {/each} + +

+ +

+ {show.title} +

+ + {#if show.aiShowNote?.description} +

{show.aiShowNote?.description}

+ {/if} {/if}
-
- -
+{#if !has_youtube} +
+ +
+{/if}
@@ -177,6 +221,53 @@ 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; + } + } + + .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; + position: absolute; + top: 0; + right: 0; + + @media (--above-med) { + position: static; + } } h1 { diff --git a/src/state/player.ts b/src/state/player.ts index 89ccef8e6..7d805140b 100644 --- a/src/state/player.ts +++ b/src/state/player.ts @@ -2,9 +2,11 @@ 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 { youtube_player } from './youtube_player'; export interface Timestamp { label: string; @@ -17,6 +19,15 @@ export interface Timestamp { export const episode_share_status = writable(false); +function set_controller_media(media_controller: any, media_el: HTMLElement | null) { + if (!media_controller || !media_el) return; + if (typeof media_controller.handleMediaUpdated === 'function') { + media_controller.handleMediaUpdated(media_el); + } else if (typeof media_controller.mediaSetCallback === 'function') { + media_controller.mediaSetCallback(media_el); + } +} + const new_player_state = () => { const initial_state: PlayerState = { current_show: null, @@ -125,16 +136,36 @@ const new_player_state = () => { } // EVENTS - // Add these new functions function onplay() { - update((state) => ({ ...state, status: 'PLAYING' })); const current_state = get(player_state); + if (current_state.current_show && get_youtube_id(current_state.current_show.youtube_url)) { + if (current_state.audio && !current_state.audio.paused) { + current_state.audio.pause(); + } + const yt = get(youtube_player); + if (yt.active_show?.number === current_state.current_show.number) { + void youtube_player.play(); + } else { + void youtube_player.start_show(current_state.current_show); + } + return; + } + update((state) => ({ ...state, status: 'PLAYING' })); if (current_state.current_show) { save_position(); } } function onpause() { + const yt_state = get(youtube_player); + const current_state = get(player_state); + if ( + yt_state.active_show && + current_state.current_show?.number === yt_state.active_show.number && + yt_state.status === 'PLAYING' + ) { + return; + } update((state) => ({ ...state, status: 'PAUSED' })); save_position(); } @@ -160,15 +191,46 @@ const new_player_state = () => { // The main method for playing a show async start_show(requested_show: Show, play_from_position?: number) { + // If episode has a YouTube video, play the YouTube video instead of the audio file + if (get_youtube_id(requested_show.youtube_url)) { + const current = get(player_state); + if (current.audio && !current.audio.paused) { + current.audio.pause(); + } + + update((state) => ({ + ...state, + initial_load: false, + current_show: requested_show, + status: 'PLAYING' + })); + + try { + Sentry.metrics.increment('episode_start', 1, { + tags: { episode: requested_show.number } + }); + Sentry.metrics.increment('all_episode_start', 1); + load_media_session(requested_show); + save_state_to_indexed_db(); + } catch { + // ignore analytics/mediaSession errors + } + + player_window_status.set('ACTIVE'); + await youtube_player.start_show(requested_show, play_from_position); + return; + } + + // Fallback to audio file when no YouTube video exists + youtube_player.close(); + const current = get(player_state); + set_controller_media(current.media_controller, current.audio); + 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(); @@ -185,51 +247,88 @@ const new_player_state = () => { }, play() { + const state = get(player_state); + const yt = get(youtube_player); + if (state.current_show && get_youtube_id(state.current_show.youtube_url)) { + if (yt.active_show?.number === state.current_show.number) { + void youtube_player.play(); + update((s) => ({ ...s, status: 'PLAYING' })); + } else { + void this.start_show(state.current_show); + } + return; + } + + youtube_player.pause(); // On play, update the state writable and play audio - update((state) => { - if (state.audio) { - state.audio.play(); + update((s) => { + if (s.audio) { + set_controller_media(s.media_controller, s.audio); + s.audio.play(); } - state.status = 'PLAYING'; - return state; + s.status = 'PLAYING'; + return s; }); }, pause() { - // On pause, update the state writable and play audio - update((state) => { - if (state.audio) { - state.audio.pause(); + const state = get(player_state); + const yt = get(youtube_player); + if ( + state.current_show && + yt.active_show?.number === state.current_show.number && + get_youtube_id(state.current_show.youtube_url) + ) { + youtube_player.pause(); + update((s) => ({ ...s, status: 'PAUSED' })); + return; + } + + // On pause, update the state writable and pause audio + update((s) => { + if (s.audio) { + s.audio.pause(); } - state.status = 'PAUSED'; - return state; + s.status = 'PAUSED'; + return s; }); }, 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(); state.audio.currentTime = 0; } + set_controller_media(state.media_controller, state.audio); return { ...initial_state, audio: state.audio, media_controller: state.media_controller }; }); }, // Jumps the time in the playing show update_time(time: number) { - update((state) => { - if (state.audio) { - state.audio.currentTime = time; + const state = get(player_state); + const yt = get(youtube_player); + if ( + state.current_show && + yt.active_show?.number === state.current_show.number && + get_youtube_id(state.current_show.youtube_url) + ) { + youtube_player.seek(state.current_show, time); + return; + } + + update((s) => { + if (s.audio) { + s.audio.currentTime = time; } - return state; + return s; }); }, close() { + youtube_player.close(); update((state) => { if (state.audio) { if (state.current_show) { @@ -241,6 +340,7 @@ const new_player_state = () => { state.audio.pause(); state.audio.removeAttribute('src'); } + set_controller_media(state.media_controller, state.audio); return { ...initial_state, audio: state.audio, media_controller: state.media_controller }; }); player_window_status.set('HIDDEN'); @@ -252,3 +352,28 @@ const new_player_state = () => { }; export const player = new_player_state(); + +youtube_player.on_play_pause_audio(() => { + const current = get(player); + if (current.audio && !current.audio.paused) { + current.audio.pause(); + } +}); + +youtube_player.on_sync_player(({ show, status, element }) => { + if (!show || status === 'IDLE') return; + player.update((state) => { + if (element && state.media_controller) { + set_controller_media(state.media_controller, element); + } + return { + ...state, + initial_load: false, + current_show: show as Show, + status: status === 'PLAYING' ? 'PLAYING' : 'PAUSED' + }; + }); + if (status === 'PLAYING' && get(player_window_status) === 'HIDDEN') { + player_window_status.set('ACTIVE'); + } +}); diff --git a/src/state/youtube_player.ts b/src/state/youtube_player.ts new file mode 100644 index 000000000..6af0a9499 --- /dev/null +++ b/src/state/youtube_player.ts @@ -0,0 +1,709 @@ +import { get, writable } from 'svelte/store'; +import { get_youtube_id } from '$utilities/youtube'; + +export interface YoutubeShowInfo { + number: number; + title: string; + slug: string; + youtube_url: string; + video_id: string; + raw_show?: any; +} + +export interface YoutubeVideoElementLike extends HTMLElement { + src: string | null; + currentTime: number; + duration: number; + paused: boolean; + autoplay: boolean; + config?: Record | null; + isLoaded?: boolean; + loadComplete?: Promise; + play: () => Promise; + pause: () => Promise; +} + +interface MountedEmbedEntry { + video_el: YoutubeVideoElementLike; + container_el: HTMLElement; + is_show_page: boolean; +} + +export interface YoutubePlayerState { + active_show: YoutubeShowInfo | null; + status: 'IDLE' | 'PLAYING' | 'PAUSED'; + current_time: number; + active_element: YoutubeVideoElementLike | null; + origin_element: YoutubeVideoElementLike | null; + in_mini_player: boolean; + is_minimized: boolean; + is_dismissed: boolean; + show_page_number: number | null; + is_navigating: boolean; +} + +function is_container_in_view(container: HTMLElement): boolean { + if (!container.isConnected) return false; + const rect = container.getBoundingClientRect(); + const viewport_height = + typeof window !== 'undefined' + ? window.innerHeight || document.documentElement.clientHeight + : 800; + if (rect.width === 0 && rect.height === 0) return true; + return rect.top >= -4 && rect.bottom <= viewport_height + 4; +} + +function get_saved_position(show_number: number): number { + if (typeof localStorage === 'undefined') return 0; + const saved = localStorage.getItem(`last_played_position_${show_number}`); + if (!saved) return 0; + const parsed = parseFloat(saved); + return !Number.isNaN(parsed) && parsed > 0 ? parsed : 0; +} + +async function seek_and_play( + el: YoutubeVideoElementLike, + resume_time: number, + should_play: boolean +) { + try { + if (el.loadComplete) { + await el.loadComplete; + } + if (resume_time > 0 && Math.abs((el.currentTime || 0) - resume_time) > 1) { + el.currentTime = resume_time; + } + if (should_play) { + await el.play(); + } + } catch { + // ignore play promise rejections + } +} + +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, + show_page_number: null, + is_navigating: false + }; + + const store = writable(initial_state); + const { subscribe, update, set } = store; + + const mounted_embeds = new Map(); + + let pause_audio_cb: (() => void) | null = null; + let sync_player_cb: + | ((info: { + show: any; + status: 'PLAYING' | 'PAUSED' | 'IDLE'; + element: YoutubeVideoElementLike | null; + current_time: number; + }) => void) + | null = null; + + function notify_player_sync() { + if (!sync_player_cb) return; + const state = get(store); + sync_player_cb({ + show: state.active_show?.raw_show ?? state.active_show, + status: state.status, + element: state.active_element, + current_time: state.current_time + }); + } + + return { + subscribe, + set, + update, + + on_play_pause_audio(cb: () => void) { + pause_audio_cb = cb; + }, + + on_sync_player( + cb: (info: { + show: any; + status: 'PLAYING' | 'PAUSED' | 'IDLE'; + element: YoutubeVideoElementLike | null; + current_time: number; + }) => void + ) { + sync_player_cb = cb; + }, + + register_embed( + show_number: number, + video_el: YoutubeVideoElementLike, + container_el: HTMLElement, + is_show_page = false + ) { + const current_list: MountedEmbedEntry[] = mounted_embeds.get(show_number) ?? []; + const existing = current_list.filter( + (e: MountedEmbedEntry) => e.video_el !== video_el && e.video_el.isConnected + ); + mounted_embeds.set(show_number, [ + ...existing, + { video_el, container_el, is_show_page } + ]); + + if (is_show_page) { + this.register_show_page(show_number, video_el); + } + }, + + register_show_page(show_number: number, element: YoutubeVideoElementLike) { + update((state) => { + const is_active = state.active_show?.number === show_number; + return { + ...state, + show_page_number: show_number, + origin_element: is_active ? element : state.origin_element + }; + }); + }, + + unregister_embed( + show_number: number, + element: YoutubeVideoElementLike | null, + is_show_page = false + ) { + const current_list: MountedEmbedEntry[] = mounted_embeds.get(show_number) ?? []; + const existing = current_list.filter( + (e: MountedEmbedEntry) => e.video_el !== element && e.video_el.isConnected + ); + if (existing.length > 0) { + mounted_embeds.set(show_number, existing); + } else { + mounted_embeds.delete(show_number); + } + + update((state) => { + let next_time = state.current_time; + if ( + element && + state.active_element === element && + typeof element.currentTime === 'number' && + element.currentTime > 0 + ) { + next_time = element.currentTime; + } + const is_current_show_page = is_show_page && state.show_page_number === show_number; + const was_active_inline = + state.active_show?.number === show_number && + (state.active_element === element || state.origin_element === element); + const should_enter_mini = was_active_inline && state.status === 'PLAYING'; + + return { + ...state, + current_time: next_time, + active_element: state.active_element === element ? null : state.active_element, + origin_element: state.origin_element === element ? null : state.origin_element, + show_page_number: is_current_show_page ? null : state.show_page_number, + in_mini_player: should_enter_mini ? true : state.in_mini_player + }; + }); + }, + + /** + * Starts or resumes playing a show's YouTube video from "Play #XXXX" / "Play/Resume Episode XXXX" buttons. + * Plays the mounted inline embed if it is in view on the page; otherwise plays in the mini-player. + */ + async start_show( + show: { number: number; title: string; slug: string; youtube_url?: string | null }, + play_from_position?: number + ) { + const video_id = get_youtube_id(show.youtube_url); + if (!video_id || !show.youtube_url) return false; + + if (pause_audio_cb) { + pause_audio_cb(); + } + + const current = get(store); + const is_same_show = current.active_show?.number === show.number; + + let resume_time = + play_from_position != null && play_from_position > 0 + ? play_from_position + : is_same_show && current.current_time > 0 + ? current.current_time + : get_saved_position(show.number); + + const current_list: MountedEmbedEntry[] = mounted_embeds.get(show.number) ?? []; + const entries = current_list.filter( + (e: MountedEmbedEntry) => e.video_el.isConnected + ); + const show_page_entry = entries.find((e: MountedEmbedEntry) => e.is_show_page); + const target_entry = show_page_entry ?? entries.at(-1) ?? null; + const target_in_view = target_entry + ? is_container_in_view(target_entry.container_el) + : false; + + if (current.active_element && current.active_element !== target_entry?.video_el) { + const prev_el = current.active_element; + update((s) => ({ ...s, active_element: null })); + try { + prev_el.pause(); + } catch { + // ignore + } + } + + if (target_entry && target_in_view) { + const el = target_entry.video_el; + update((state) => ({ + ...state, + active_show: { + number: show.number, + title: show.title, + slug: show.slug, + youtube_url: show.youtube_url!, + video_id, + raw_show: show + }, + status: 'PLAYING', + current_time: resume_time, + active_element: el, + origin_element: el, + in_mini_player: false, + is_minimized: false, + is_dismissed: false + })); + notify_player_sync(); + await seek_and_play(el, resume_time, true); + } else { + const keep_existing_mini_el = + is_same_show && current.in_mini_player && current.active_element?.isConnected + ? current.active_element + : null; + update((state) => ({ + ...state, + active_show: { + number: show.number, + title: show.title, + slug: show.slug, + youtube_url: show.youtube_url!, + video_id, + raw_show: show + }, + status: 'PLAYING', + current_time: resume_time, + active_element: keep_existing_mini_el, + origin_element: target_entry?.video_el ?? null, + in_mini_player: true, + is_minimized: is_same_show ? state.is_minimized : false, + is_dismissed: false + })); + notify_player_sync(); + if (keep_existing_mini_el) { + await seek_and_play(keep_existing_mini_el, resume_time, true); + } + } + + return true; + }, + + /** + * Called when an inline starts playing directly. + */ + set_playing( + show: { number: number; title: string; slug: string; youtube_url?: string | null }, + element: YoutubeVideoElementLike | null + ) { + const video_id = get_youtube_id(show.youtube_url); + if (!video_id || !show.youtube_url) return; + + if (pause_audio_cb) { + pause_audio_cb(); + } + + const current = get(store); + const is_same_show = current.active_show?.number === show.number; + const was_in_mini_player = current.in_mini_player; + + // If this element is already the active playing element, just ensure status is PLAYING + if (is_same_show && current.active_element === element && !was_in_mini_player) { + if (current.status !== 'PLAYING') { + update((state) => ({ + ...state, + status: 'PLAYING', + is_dismissed: false + })); + notify_player_sync(); + } + return; + } + + let saved_time = is_same_show ? current.current_time : get_saved_position(show.number); + if ( + is_same_show && + current.active_element && + current.active_element !== element && + typeof current.active_element.currentTime === 'number' && + current.active_element.currentTime > 0 + ) { + saved_time = current.active_element.currentTime; + } + + if (current.active_element && current.active_element !== element) { + const prev_el = current.active_element; + update((s) => ({ ...s, active_element: element })); + try { + prev_el.pause(); + } catch { + // ignore + } + } + + let next_time = 0; + if (is_same_show) { + if (was_in_mini_player && saved_time > 0) { + next_time = saved_time; + if (element && Math.abs((element.currentTime || 0) - saved_time) > 1) { + element.currentTime = saved_time; + } + } else if (element && typeof element.currentTime === 'number' && element.currentTime > 0) { + next_time = element.currentTime; + } else { + next_time = saved_time; + if (element && saved_time > 0 && Math.abs((element.currentTime || 0) - saved_time) > 1) { + element.currentTime = saved_time; + } + } + } else if (element && typeof element.currentTime === 'number' && element.currentTime > 1) { + next_time = element.currentTime; + } else if (saved_time > 0) { + next_time = saved_time; + if (element && Math.abs((element.currentTime || 0) - saved_time) > 1) { + element.currentTime = saved_time; + } + } + + update((state) => ({ + ...state, + active_show: { + number: show.number, + title: show.title, + slug: show.slug, + youtube_url: show.youtube_url!, + video_id, + raw_show: show + }, + status: 'PLAYING', + current_time: next_time, + active_element: element, + origin_element: element, + in_mini_player: false, + is_minimized: false, + is_dismissed: false + })); + + notify_player_sync(); + }, + + set_mini_player_playing(element: YoutubeVideoElementLike | null) { + if (pause_audio_cb) { + pause_audio_cb(); + } + update((state) => ({ + ...state, + status: 'PLAYING', + active_element: element, + in_mini_player: true, + is_dismissed: false + })); + notify_player_sync(); + }, + + enter_mini_player_from_scroll(show_number: number, inline_el: YoutubeVideoElementLike | null) { + const state = get(store); + if (state.is_navigating) return; + if (state.active_show?.number !== show_number || state.status !== 'PLAYING') return; + if (state.in_mini_player) return; + if (inline_el && state.origin_element && state.origin_element !== inline_el) return; + + let next_time = state.current_time; + if ( + inline_el && + typeof inline_el.currentTime === 'number' && + inline_el.currentTime > 0 + ) { + next_time = inline_el.currentTime; + } + + update((s) => ({ + ...s, + current_time: next_time, + active_element: null, + origin_element: inline_el ?? s.origin_element, + in_mini_player: true, + is_dismissed: false, + status: 'PLAYING' + })); + + if (inline_el) { + try { + inline_el.pause(); + } catch { + // ignore + } + } + }, + + dock_to_inline( + show_number: number, + inline_el: YoutubeVideoElementLike | null, + force_claim = false + ) { + const state = get(store); + if (state.active_show?.number !== show_number) return; + if (!inline_el) return; + if (!force_claim && state.origin_element !== inline_el) return; + if (state.is_dismissed) return; + + let resume_time = state.current_time; + if ( + state.active_element && + state.active_element !== inline_el && + typeof state.active_element.currentTime === 'number' && + state.active_element.currentTime > 0 + ) { + resume_time = state.active_element.currentTime; + } + + const was_playing = state.status === 'PLAYING'; + const prev_el = state.active_element; + + update((s) => ({ + ...s, + current_time: resume_time, + active_element: inline_el, + origin_element: inline_el, + in_mini_player: false, + is_dismissed: false + })); + + if (prev_el && prev_el !== inline_el) { + try { + prev_el.pause(); + } catch { + // ignore + } + } + + void seek_and_play(inline_el, resume_time, was_playing); + notify_player_sync(); + }, + + dock_to_show_page(show_number: number, show_page_el: YoutubeVideoElementLike | null) { + this.dock_to_inline(show_number, show_page_el, true); + }, + + set_active_element(element: YoutubeVideoElementLike | null) { + update((state) => ({ + ...state, + active_element: element + })); + notify_player_sync(); + }, + + update_time(show_number: number, seconds: number, element?: YoutubeVideoElementLike | null) { + if (typeof seconds !== 'number' || Number.isNaN(seconds) || seconds <= 0) return; + update((state) => { + if (state.active_show?.number !== show_number) return state; + if (element && state.active_element && state.active_element !== element) return state; + if (typeof localStorage !== 'undefined') { + localStorage.setItem(`last_played_position_${show_number}`, seconds.toString()); + } + return { + ...state, + current_time: seconds + }; + }); + }, + + before_navigate() { + update((state) => { + let next_time = state.current_time; + if ( + state.active_element && + typeof state.active_element.currentTime === 'number' && + state.active_element.currentTime > 0 + ) { + next_time = state.active_element.currentTime; + } + return { + ...state, + current_time: next_time, + is_navigating: true + }; + }); + }, + + after_navigate() { + update((state) => { + const active_num = state.active_show?.number; + const on_matching_show_page = + active_num != null && state.show_page_number === active_num; + const active_el_still_connected = Boolean(state.active_element?.isConnected); + const should_be_in_mini = + active_num != null && + state.status === 'PLAYING' && + !on_matching_show_page && + !active_el_still_connected + ? true + : state.in_mini_player; + + return { + ...state, + is_navigating: false, + in_mini_player: should_be_in_mini + }; + }); + }, + + set_paused(show_number: number, element?: YoutubeVideoElementLike | null) { + let did_pause = false; + update((state) => { + if (state.is_navigating) return state; + if (state.active_show?.number !== show_number) return state; + if (element && (!element.isConnected || state.active_element !== element)) { + return state; + } + const next_time = + element && typeof element.currentTime === 'number' && element.currentTime > 0 + ? element.currentTime + : state.current_time; + did_pause = true; + return { + ...state, + status: 'PAUSED', + current_time: next_time + }; + }); + if (did_pause) { + notify_player_sync(); + } + }, + + async play() { + const state = get(store); + if (!state.active_show) return; + if (pause_audio_cb) { + pause_audio_cb(); + } + update((s) => ({ + ...s, + status: 'PLAYING', + is_dismissed: false + })); + if (state.active_element) { + await seek_and_play(state.active_element, state.current_time, true); + } + notify_player_sync(); + }, + + pause() { + const state = get(store); + const active_el = state.active_element; + const next_time = + active_el && typeof active_el.currentTime === 'number' && active_el.currentTime > 0 + ? active_el.currentTime + : state.current_time; + update((s) => + s.active_show ? { ...s, status: 'PAUSED', current_time: next_time } : s + ); + if (active_el) { + try { + active_el.pause(); + } catch { + // ignore + } + } + notify_player_sync(); + }, + + async seek( + show: { number: number; title: string; slug: string; youtube_url?: string | null }, + seconds: number + ) { + const state = get(store); + if (state.active_show?.number === 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); + notify_player_sync(); + } else if (get_youtube_id(show.youtube_url)) { + await this.start_show(show, seconds); + } + }, + + toggle_minimize() { + update((state) => ({ + ...state, + is_minimized: !state.is_minimized, + is_dismissed: false + })); + }, + + dismiss_mini_player() { + const state = get(store); + const active_el = state.active_element; + const next_time = + active_el && typeof active_el.currentTime === 'number' && active_el.currentTime > 0 + ? active_el.currentTime + : state.current_time; + update((s) => + s.active_show + ? { + ...s, + status: 'PAUSED', + current_time: next_time, + in_mini_player: true, + is_minimized: true, + is_dismissed: true + } + : s + ); + if (active_el) { + try { + active_el.pause(); + } catch { + // ignore + } + } + notify_player_sync(); + }, + + close() { + const state = get(store); + const prev_el = state.active_element; + update(() => ({ + ...initial_state + })); + if (prev_el) { + try { + prev_el.pause(); + } catch { + // ignore + } + } + } + }; +} + +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..3e6dd4f16 --- /dev/null +++ b/src/utilities/youtube.test.ts @@ -0,0 +1,280 @@ +import { get } from 'svelte/store'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { youtube_player, type YoutubeVideoElementLike } from '$state/youtube_player'; +import { get_youtube_id, get_youtube_watch_url } from './youtube'; + +function createMockVideoElement(initialTime = 0): YoutubeVideoElementLike { + return { + src: 'https://www.youtube.com/watch?v=dj6tUUTDXAo', + currentTime: initialTime, + duration: 3600, + paused: false, + autoplay: false, + config: null, + isConnected: true, + play: vi.fn().mockResolvedValue(undefined), + pause: vi.fn().mockResolvedValue(undefined) + } as unknown as YoutubeVideoElementLike; +} + +function createMockContainer(top = 100, bottom = 300): HTMLElement { + return { + isConnected: true, + getBoundingClientRect: () => ({ + top, + bottom, + left: 0, + right: 400, + width: 400, + height: bottom - top + }) + } as unknown as HTMLElement; +} + +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(); + }); +}); + +describe('youtube_player store', () => { + const show790 = { + number: 790, + title: 'State of JS', + slug: 'state-of-js', + youtube_url: 'https://www.youtube.com/watch?v=dj6tUUTDXAo' + }; + + const show760 = { + number: 760, + title: 'Pro VSCode Setups', + slug: 'pro-vscode-setups', + youtube_url: 'https://www.youtube.com/watch?v=VIdM5VSlJVw' + }; + + beforeEach(() => { + youtube_player.close(); + }); + + it('starts in-view inline video when start_show is called and syncs with bottom player', async () => { + const inlineEl = createMockVideoElement(0); + const containerEl = createMockContainer(100, 300); + const syncSpy = vi.fn(); + + youtube_player.on_sync_player(syncSpy); + youtube_player.register_embed(790, inlineEl, containerEl, false); + + await youtube_player.start_show(show790, 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(inlineEl); + expect(inlineEl.currentTime).toBe(30); + expect(inlineEl.play).toHaveBeenCalled(); + expect(syncSpy).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'PLAYING', + element: inlineEl, + current_time: 30 + }) + ); + + youtube_player.unregister_embed(790, inlineEl, false); + }); + + it('opens mini-player when scrolling down on show page or list page and closes when scrolling back into view', () => { + const inlineEl = createMockVideoElement(42.5); + const miniPlayerEl = createMockVideoElement(42.5); + + youtube_player.set_playing(show790, inlineEl); + + let state = get(youtube_player); + expect(state.in_mini_player).toBe(false); + expect(state.status).toBe('PLAYING'); + expect(state.origin_element).toBe(inlineEl); + + // User scrolls past the inline video + inlineEl.currentTime = 65.0; + youtube_player.enter_mini_player_from_scroll(790, inlineEl); + // Simulated pause event from inlineEl should not mark player as PAUSED + youtube_player.set_paused(790, inlineEl); + + state = get(youtube_player); + expect(state.in_mini_player).toBe(true); + expect(state.status).toBe('PLAYING'); + expect(state.current_time).toBe(65.0); + expect(inlineEl.pause).toHaveBeenCalled(); + + // Mini-player mounts and advances time + youtube_player.set_active_element(miniPlayerEl); + miniPlayerEl.currentTime = 95.5; + + // User scrolls back so origin inlineEl is in view again + youtube_player.dock_to_inline(790, inlineEl); + + state = get(youtube_player); + expect(state.in_mini_player).toBe(false); + expect(state.status).toBe('PLAYING'); + expect(state.current_time).toBe(95.5); + expect(inlineEl.currentTime).toBe(95.5); + expect(inlineEl.play).toHaveBeenCalled(); + }); + + it('keeps mini-player going on list pages (/ or /shows), closes on other video play, and resumes from saved point on same video play', () => { + const showPageEl = createMockVideoElement(50); + const miniPlayerEl = createMockVideoElement(50); + + youtube_player.register_show_page(790, showPageEl); + youtube_player.set_playing(show790, showPageEl); + + // Navigate from /show/790/state-of-js to /shows (where 790 is in the list) + showPageEl.currentTime = 72; + youtube_player.before_navigate(); + (showPageEl as { isConnected: boolean }).isConnected = false; + youtube_player.unregister_embed(790, showPageEl, true); + 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(); + + // Mini-player continues playing on /shows and reaches 110s + youtube_player.set_active_element(miniPlayerEl); + miniPlayerEl.currentTime = 110; + + // A card for 790 in /shows scrolling into view must NOT hijack the mini-player since it is not origin_element + const card790El = createMockVideoElement(0); + youtube_player.dock_to_inline(790, card790El); + expect(get(youtube_player).in_mini_player).toBe(true); + + // Clicking Play on the SAME episode (790) in the /shows card list closes mini-player and starts from saved point (110s) + youtube_player.set_playing(show790, card790El); + + state = get(youtube_player); + expect(state.in_mini_player).toBe(false); + expect(state.active_show?.number).toBe(790); + expect(state.origin_element).toBe(card790El); + expect(state.current_time).toBe(110); + expect(card790El.currentTime).toBe(110); + expect(miniPlayerEl.pause).toHaveBeenCalled(); + + // Now scroll card790El out of view on /shows -> pops out mini-player + youtube_player.enter_mini_player_from_scroll(790, card790El); + expect(get(youtube_player).in_mini_player).toBe(true); + + // Click Play on a DIFFERENT episode (760) -> closes mini-player and starts 760 + const card760El = createMockVideoElement(0); + youtube_player.set_playing(show760, card760El); + + state = get(youtube_player); + expect(state.in_mini_player).toBe(false); + expect(state.active_show?.number).toBe(760); + expect(state.origin_element).toBe(card760El); + expect(state.current_time).toBe(0); + }); + + it('pauses without restarting and resumes from saved position', async () => { + const inlineEl = createMockVideoElement(0); + const containerEl = createMockContainer(100, 300); + + youtube_player.register_embed(790, inlineEl, containerEl, true); + await youtube_player.start_show(show790, 120); + + expect(get(youtube_player).status).toBe('PLAYING'); + expect(inlineEl.currentTime).toBe(120); + + // Advance video time and click "Playing Episode 790" -> pauses at 185s + inlineEl.currentTime = 185; + youtube_player.pause(); + + let state = get(youtube_player); + expect(state.status).toBe('PAUSED'); + expect(state.current_time).toBe(185); + expect(inlineEl.pause).toHaveBeenCalled(); + + // Click "Resume Episode 790" -> plays from 185s without resetting to 0 + youtube_player.play(); + state = get(youtube_player); + expect(state.status).toBe('PLAYING'); + expect(inlineEl.currentTime).toBe(185); + expect(inlineEl.play).toHaveBeenCalledTimes(2); + + youtube_player.unregister_embed(790, inlineEl, true); + }); + + it('minimizes mini-player while keeping playback going and resumes in minimized state after closing mini-player', async () => { + const miniPlayerEl = createMockVideoElement(60); + + await youtube_player.start_show(show790, 60); + youtube_player.set_active_element(miniPlayerEl); + + let state = get(youtube_player); + expect(state.in_mini_player).toBe(true); + expect(state.is_minimized).toBe(false); + expect(state.status).toBe('PLAYING'); + + // Minimize mini-player while keeping episode playing + youtube_player.toggle_minimize(); + state = get(youtube_player); + expect(state.is_minimized).toBe(true); + expect(state.status).toBe('PLAYING'); + + // Maximize back + youtube_player.toggle_minimize(); + expect(get(youtube_player).is_minimized).toBe(false); + + // Click x on the mini-player -> pauses and dismisses + miniPlayerEl.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(miniPlayerEl.pause).toHaveBeenCalled(); + + // Click play on the bottom player bar -> resumes playing in minimized state + 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(miniPlayerEl.play).toHaveBeenCalled(); + }); +}); + + 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; +} From 84d987f4685a27f95cd764814a0add54d0034278 Mon Sep 17 00:00:00 2001 From: w3cj Date: Wed, 7 Oct 2026 16:34:17 -0600 Subject: [PATCH 2/3] refactor: harden YouTube embeds, add video/audio preference and timeline handling - Replace per-embed scroll listeners and intervals with a shared IntersectionObserver and a single playback ticker - Keep the mini-player mounted (hidden) to avoid iframe reloads; on list pages it keeps playing until the card is played directly - Add video/audio preference toggle (default video, audio when offline) - Track audio and video positions separately; route audio-timeline timestamps (show notes, transcript, ?t=) to video only when durations match - Throttle position saves, sync player with youtube_player via store subscription, type the media-chrome media swap - Mini-player follows the bottom player height; layout fixes scoped to video cards; dedupe show header with a snippet - Move store tests to src/state and add media_timeline tests Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/Icon.svelte | 39 +- src/lib/ShowCard.svelte | 54 +- src/lib/player/Player.svelte | 21 +- src/lib/transcript/Transcript.svelte | 47 +- src/lib/videos/YoutubeEmbed.svelte | 100 +- src/lib/videos/YoutubeMiniPlayer.svelte | 99 +- src/routes/(site)/+layout.svelte | 5 +- .../show/[show_number]/[slug]/+layout.svelte | 92 +- src/state/media_preference.ts | 29 + src/state/media_timeline.test.ts | 86 ++ src/state/media_timeline.ts | 160 +++ src/state/player.ts | 450 ++++--- src/state/player_utils.ts | 3 + src/state/youtube_player.test.ts | 293 +++++ src/state/youtube_player.ts | 1076 +++++++---------- src/utilities/youtube.test.ts | 245 +--- 16 files changed, 1487 insertions(+), 1312 deletions(-) create mode 100644 src/state/media_preference.ts create mode 100644 src/state/media_timeline.test.ts create mode 100644 src/state/media_timeline.ts create mode 100644 src/state/youtube_player.test.ts 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'; -
+
import { player } from '$state/player'; import { player_window_status } from '$state/player_window_status'; + import { get_youtube_id } from '$utilities/youtube'; import AlbumArt from './AlbumArt.svelte'; import get_show_path from '$utilities/slug'; import Icon from '../Icon.svelte'; @@ -20,10 +21,27 @@ }); let mix_max_verb = $derived($player_window_status === 'MINI' ? 'Maximize' : 'Minimize'); + let has_video = $derived(Boolean(get_youtube_id($player.current_show?.youtube_url))); + let is_video = $derived($player.media_kind === 'VIDEO'); + let media_kind_label = $derived(is_video ? 'Switch to Audio' : 'Switch to Video'); + let player_height = $state(0); + + // Lets the YouTube mini-player sit on top of the player in both its full and minimized sizes + $effect(() => { + document.documentElement.style.setProperty('--player-height', `${player_height}px`); + }); -
+
+ {#if has_video} + + {/if} {#if $player.current_show}{/if} player.play_timestamp(show, utterance.start)} + >{format_time(utterance.start)}

{utterance.speakerName || `Guest ${utterance.speakerId}`} diff --git a/src/lib/videos/YoutubeEmbed.svelte b/src/lib/videos/YoutubeEmbed.svelte index a41a5b57b..8d5c368ab 100644 --- a/src/lib/videos/YoutubeEmbed.svelte +++ b/src/lib/videos/YoutubeEmbed.svelte @@ -1,15 +1,11 @@ {#if watch_url} +

{/if} diff --git a/src/lib/videos/YoutubeMiniPlayer.svelte b/src/lib/videos/YoutubeMiniPlayer.svelte index aa8e99096..0d75386c4 100644 --- a/src/lib/videos/YoutubeMiniPlayer.svelte +++ b/src/lib/videos/YoutubeMiniPlayer.svelte @@ -1,5 +1,4 @@ -{#if show_mini_player && active_show && watch_url} +{#if is_mounted && active_show && watch_url}
@@ -157,7 +102,8 @@ transition: bottom 0.2s ease; &.audio-player-open { - bottom: 110px; + /* --player-height is set by Player.svelte and follows its minimized state */ + bottom: calc(var(--player-height, 110px) + 20px); } &.MINI { @@ -172,6 +118,12 @@ } } + &.HIDDEN { + /* Not display: none, the YouTube iframe needs to stay rendered to stay ready */ + visibility: hidden; + pointer-events: none; + } + &.DISMISSED { height: 0; overflow: hidden; @@ -239,4 +191,3 @@ aspect-ratio: 16 / 9; } - diff --git a/src/routes/(site)/+layout.svelte b/src/routes/(site)/+layout.svelte index f7c29c968..731fea2a1 100644 --- a/src/routes/(site)/+layout.svelte +++ b/src/routes/(site)/+layout.svelte @@ -21,8 +21,11 @@ let { data, children } = $props(); let { user, user_theme, latest } = $derived(data); - beforeNavigate(() => { + 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(() => { diff --git a/src/routes/(site)/show/[show_number]/[slug]/+layout.svelte b/src/routes/(site)/show/[show_number]/[slug]/+layout.svelte index 6743b6026..aa3db1374 100644 --- a/src/routes/(site)/show/[show_number]/[slug]/+layout.svelte +++ b/src/routes/(site)/show/[show_number]/[slug]/+layout.svelte @@ -9,7 +9,6 @@ import Tabs from '$lib/Tabs.svelte'; import ShareWindow from '$lib/share/ShareWindow.svelte'; import { player } from '$state/player'; - import { youtube_player } from '$state/youtube_player'; import YoutubeEmbed from '$lib/videos/YoutubeEmbed.svelte'; import { get_youtube_id } from '$utilities/youtube'; import { format } from 'date-fns'; @@ -27,24 +26,16 @@ e.preventDefault(); const href = target.getAttribute('href'); const timestamp = href ? tsToS(href.replace('#t=', '')) : 0; - if ($youtube_player.active_show?.number === show.number) { - await youtube_player.seek(show, timestamp); - return; - } - // 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 { @@ -80,27 +71,30 @@ {@html ``} +{#snippet show_details()} +

+ {format(new Date(show.date), 'MMMM do, yyyy')} + × + + {#each show.aiShowNote?.topics?.slice(0, 5) || [] as topic} + {topic.name.startsWith('#') ? '' : '#'}{topic.name} + {/each} + +

+ +

+ {show.title} +

+ + {#if show.aiShowNote?.description} +

{show.aiShowNote?.description}

+ {/if} +{/snippet} +
{#if has_youtube}
-

- {format(new Date(show.date), 'MMMM do, yyyy')} - × - - {#each show.aiShowNote?.topics?.slice(0, 5) || [] as topic} - {topic.name.startsWith('#') ? '' : '#'}{topic.name} - {/each} - -

- -

- {show.title} -

- - {#if show.aiShowNote?.description} -

{show.aiShowNote?.description}

- {/if} - + {@render show_details()}
@@ -120,23 +114,7 @@ style:--transition-name="show-date-{show.number}" class="show-number fst-900 grit">{show.number} -

- {format(new Date(show.date), 'MMMM do, yyyy')} - × - - {#each show.aiShowNote?.topics?.slice(0, 5) || [] as topic} - {topic.name.startsWith('#') ? '' : '#'}{topic.name} - {/each} - -

- -

- {show.title} -

- - {#if show.aiShowNote?.description} -

{show.aiShowNote?.description}

- {/if} + {@render show_details()} {/if}
@@ -242,6 +220,13 @@ :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 { @@ -261,12 +246,11 @@ color: var(--primary); line-height: 1; text-align: right; - position: absolute; - top: 0; - right: 0; - @media (--above-med) { - position: static; + @media (--below-med) { + position: absolute; + top: 0; + right: 0; } } 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 7d805140b..3cd5771a9 100644 --- a/src/state/player.ts +++ b/src/state/player.ts @@ -6,6 +6,15 @@ 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 { @@ -19,13 +28,14 @@ export interface Timestamp { export const episode_share_status = writable(false); -function set_controller_media(media_controller: any, media_el: HTMLElement | null) { - if (!media_controller || !media_el) return; - if (typeof media_controller.handleMediaUpdated === 'function') { - media_controller.handleMediaUpdated(media_el); - } else if (typeof media_controller.mediaSetCallback === 'function') { - media_controller.mediaSetCallback(media_el); - } +// 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 = () => { @@ -35,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 { @@ -70,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, @@ -108,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) => { @@ -135,51 +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 (error) { + 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 function onplay() { const current_state = get(player_state); - if (current_state.current_show && get_youtube_id(current_state.current_show.youtube_url)) { - if (current_state.audio && !current_state.audio.paused) { - current_state.audio.pause(); - } - const yt = get(youtube_player); - if (yt.active_show?.number === current_state.current_show.number) { - void youtube_player.play(); - } else { - void youtube_player.start_show(current_state.current_show); - } + // 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() { - const yt_state = get(youtube_player); - const current_state = get(player_state); - if ( - yt_state.active_show && - current_state.current_show?.number === yt_state.active_show.number && - yt_state.status === 'PLAYING' - ) { - return; - } + // 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, @@ -188,142 +329,115 @@ 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) { - // If episode has a YouTube video, play the YouTube video instead of the audio file - if (get_youtube_id(requested_show.youtube_url)) { - const current = get(player_state); - if (current.audio && !current.audio.paused) { - current.audio.pause(); - } - - update((state) => ({ - ...state, - initial_load: false, - current_show: requested_show, - status: 'PLAYING' - })); - - try { - Sentry.metrics.increment('episode_start', 1, { - tags: { episode: requested_show.number } - }); - Sentry.metrics.increment('all_episode_start', 1); - load_media_session(requested_show); - save_state_to_indexed_db(); - } catch { - // ignore analytics/mediaSession errors - } - - player_window_status.set('ACTIVE'); - await youtube_player.start_show(requested_show, play_from_position); - return; + if (play_from_position != null) { + return play_timestamp(requested_show, play_from_position); } - - // Fallback to audio file when no YouTube video exists - youtube_player.close(); - const current = get(player_state); - set_controller_media(current.media_controller, current.audio); - - const incoming_show = await load_show(requested_show, false, play_from_position); - try { - Sentry.metrics.increment('episode_start', 1, { tags: { episode: incoming_show.number } }); - Sentry.metrics.increment('all_episode_start', 1); - - 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 (should_play_video(requested_show)) { + return start_video(requested_show); } + return start_audio(requested_show); }, play() { - const state = get(player_state); - const yt = get(youtube_player); - if (state.current_show && get_youtube_id(state.current_show.youtube_url)) { - if (yt.active_show?.number === state.current_show.number) { - void youtube_player.play(); - update((s) => ({ ...s, status: 'PLAYING' })); - } else { - void this.start_show(state.current_show); - } + 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; } - - youtube_player.pause(); - // On play, update the state writable and play audio - update((s) => { - if (s.audio) { - set_controller_media(s.media_controller, s.audio); - s.audio.play(); - } - s.status = 'PLAYING'; - return s; - }); + play_audio(); }, pause() { - const state = get(player_state); - const yt = get(youtube_player); - if ( - state.current_show && - yt.active_show?.number === state.current_show.number && - get_youtube_id(state.current_show.youtube_url) - ) { + const { current_show, media_kind } = get(player_state); + if (media_kind === 'VIDEO' && is_video_active_for(current_show)) { youtube_player.pause(); - update((s) => ({ ...s, status: 'PAUSED' })); return; } // On pause, update the state writable and pause audio - update((s) => { - if (s.audio) { - s.audio.pause(); + update((state) => { + if (state.audio) { + state.audio.pause(); } - s.status = 'PAUSED'; - return s; + state.status = 'PAUSED'; + return 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(); state.audio.currentTime = 0; } - set_controller_media(state.media_controller, state.audio); 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 state = get(player_state); - const yt = get(youtube_player); - if ( - state.current_show && - yt.active_show?.number === state.current_show.number && - get_youtube_id(state.current_show.youtube_url) - ) { - youtube_player.seek(state.current_show, time); + 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((s) => { - if (s.audio) { - s.audio.currentTime = time; + update((state) => { + if (state.audio) { + state.audio.currentTime = time; } - return s; + return state; }); }, @@ -331,18 +445,15 @@ const new_player_state = () => { 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'); } - set_controller_media(state.media_controller, state.audio); return { ...initial_state, audio: state.audio, media_controller: state.media_controller }; }); + attach_media(get(player_state).audio); player_window_status.set('HIDDEN'); }, @@ -352,28 +463,3 @@ const new_player_state = () => { }; export const player = new_player_state(); - -youtube_player.on_play_pause_audio(() => { - const current = get(player); - if (current.audio && !current.audio.paused) { - current.audio.pause(); - } -}); - -youtube_player.on_sync_player(({ show, status, element }) => { - if (!show || status === 'IDLE') return; - player.update((state) => { - if (element && state.media_controller) { - set_controller_media(state.media_controller, element); - } - return { - ...state, - initial_load: false, - current_show: show as Show, - status: status === 'PLAYING' ? 'PLAYING' : 'PAUSED' - }; - }); - if (status === 'PLAYING' && get(player_window_status) === 'HIDDEN') { - player_window_status.set('ACTIVE'); - } -}); 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 index 6af0a9499..00d12fe56 100644 --- a/src/state/youtube_player.ts +++ b/src/state/youtube_player.ts @@ -1,86 +1,89 @@ +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'; -export interface YoutubeShowInfo { - number: number; - title: string; - slug: string; - youtube_url: string; - video_id: string; - raw_show?: any; -} - +// The subset of (youtube-video-element) we rely on export interface YoutubeVideoElementLike extends HTMLElement { src: string | null; currentTime: number; duration: number; paused: boolean; - autoplay: boolean; - config?: Record | null; isLoaded?: boolean; loadComplete?: Promise; play: () => Promise; - pause: () => Promise; + pause: () => Promise | void; } -interface MountedEmbedEntry { +interface EmbedEntry { + show_number: number; video_el: YoutubeVideoElementLike; container_el: HTMLElement; is_show_page: boolean; } export interface YoutubePlayerState { - active_show: YoutubeShowInfo | null; + 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; - show_page_number: number | null; is_navigating: boolean; } -function is_container_in_view(container: HTMLElement): boolean { - if (!container.isConnected) return false; - const rect = container.getBoundingClientRect(); - const viewport_height = - typeof window !== 'undefined' - ? window.innerHeight || document.documentElement.clientHeight - : 800; - if (rect.width === 0 && rect.height === 0) return true; - return rect.top >= -4 && rect.bottom <= viewport_height + 4; +// 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 get_saved_position(show_number: number): number { - if (typeof localStorage === 'undefined') return 0; - const saved = localStorage.getItem(`last_played_position_${show_number}`); - if (!saved) return 0; - const parsed = parseFloat(saved); - return !Number.isNaN(parsed) && parsed > 0 ? parsed : 0; +function live_time(state: YoutubePlayerState) { + return element_time(state.active_element) ?? state.current_time; } -async function seek_and_play( - el: YoutubeVideoElementLike, - resume_time: number, - should_play: boolean -) { +function safe_pause(el: YoutubeVideoElementLike | null | undefined) { + if (!el) return; try { - if (el.loadComplete) { - await el.loadComplete; - } - if (resume_time > 0 && Math.abs((el.currentTime || 0) - resume_time) > 1) { - el.currentTime = resume_time; - } - if (should_play) { - await el.play(); + 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 { - // ignore play promise rejections + // 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, @@ -91,618 +94,425 @@ function create_youtube_player() { in_mini_player: false, is_minimized: false, is_dismissed: false, - show_page_number: null, is_navigating: false }; const store = writable(initial_state); - const { subscribe, update, set } = store; - - const mounted_embeds = new Map(); - - let pause_audio_cb: (() => void) | null = null; - let sync_player_cb: - | ((info: { - show: any; - status: 'PLAYING' | 'PAUSED' | 'IDLE'; - element: YoutubeVideoElementLike | null; - current_time: number; - }) => void) - | null = null; - - function notify_player_sync() { - if (!sync_player_cb) return; - const state = get(store); - sync_player_cb({ - show: state.active_show?.raw_show ?? state.active_show, - status: state.status, - element: state.active_element, - current_time: state.current_time - }); + 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')); } - return { - subscribe, - set, - update, - - on_play_pause_audio(cb: () => void) { - pause_audio_cb = cb; - }, - - on_sync_player( - cb: (info: { - show: any; - status: 'PLAYING' | 'PAUSED' | 'IDLE'; - element: YoutubeVideoElementLike | null; - current_time: number; - }) => void - ) { - sync_player_cb = cb; - }, - - register_embed( - show_number: number, - video_el: YoutubeVideoElementLike, - container_el: HTMLElement, - is_show_page = false - ) { - const current_list: MountedEmbedEntry[] = mounted_embeds.get(show_number) ?? []; - const existing = current_list.filter( - (e: MountedEmbedEntry) => e.video_el !== video_el && e.video_el.isConnected - ); - mounted_embeds.set(show_number, [ - ...existing, - { video_el, container_el, is_show_page } - ]); - - if (is_show_page) { - this.register_show_page(show_number, video_el); - } - }, - - register_show_page(show_number: number, element: YoutubeVideoElementLike) { - update((state) => { - const is_active = state.active_show?.number === show_number; - return { - ...state, - show_page_number: show_number, - origin_element: is_active ? element : state.origin_element - }; - }); - }, - - unregister_embed( - show_number: number, - element: YoutubeVideoElementLike | null, - is_show_page = false - ) { - const current_list: MountedEmbedEntry[] = mounted_embeds.get(show_number) ?? []; - const existing = current_list.filter( - (e: MountedEmbedEntry) => e.video_el !== element && e.video_el.isConnected - ); - if (existing.length > 0) { - mounted_embeds.set(show_number, existing); - } else { - mounted_embeds.delete(show_number); - } + 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); + } + } - update((state) => { - let next_time = state.current_time; - if ( - element && - state.active_element === element && - typeof element.currentTime === 'number' && - element.currentTime > 0 - ) { - next_time = element.currentTime; - } - const is_current_show_page = is_show_page && state.show_page_number === show_number; - const was_active_inline = - state.active_show?.number === show_number && - (state.active_element === element || state.origin_element === element); - const should_enter_mini = was_active_inline && state.status === 'PLAYING'; - - return { - ...state, - current_time: next_time, - active_element: state.active_element === element ? null : state.active_element, - origin_element: state.origin_element === element ? null : state.origin_element, - show_page_number: is_current_show_page ? null : state.show_page_number, - in_mini_player: should_enter_mini ? true : state.in_mini_player - }; + // 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` }); - }, - - /** - * Starts or resumes playing a show's YouTube video from "Play #XXXX" / "Play/Resume Episode XXXX" buttons. - * Plays the mounted inline embed if it is in view on the page; otherwise plays in the mini-player. - */ - async start_show( - show: { number: number; title: string; slug: string; youtube_url?: string | null }, - play_from_position?: number - ) { - const video_id = get_youtube_id(show.youtube_url); - if (!video_id || !show.youtube_url) return false; - - if (pause_audio_cb) { - pause_audio_cb(); - } + } + return observer; + } - const current = get(store); - const is_same_show = current.active_show?.number === show.number; - - let resume_time = - play_from_position != null && play_from_position > 0 - ? play_from_position - : is_same_show && current.current_time > 0 - ? current.current_time - : get_saved_position(show.number); - - const current_list: MountedEmbedEntry[] = mounted_embeds.get(show.number) ?? []; - const entries = current_list.filter( - (e: MountedEmbedEntry) => e.video_el.isConnected - ); - const show_page_entry = entries.find((e: MountedEmbedEntry) => e.is_show_page); - const target_entry = show_page_entry ?? entries.at(-1) ?? null; - const target_in_view = target_entry - ? is_container_in_view(target_entry.container_el) - : false; - - if (current.active_element && current.active_element !== target_entry?.video_el) { - const prev_el = current.active_element; - update((s) => ({ ...s, active_element: null })); - try { - prev_el.pause(); - } catch { - // ignore - } - } + 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); + } + } - if (target_entry && target_in_view) { - const el = target_entry.video_el; - update((state) => ({ - ...state, - active_show: { - number: show.number, - title: show.title, - slug: show.slug, - youtube_url: show.youtube_url!, - video_id, - raw_show: show - }, - status: 'PLAYING', - current_time: resume_time, - active_element: el, - origin_element: el, - in_mini_player: false, - is_minimized: false, - is_dismissed: false - })); - notify_player_sync(); - await seek_and_play(el, resume_time, true); - } else { - const keep_existing_mini_el = - is_same_show && current.in_mini_player && current.active_element?.isConnected - ? current.active_element - : null; - update((state) => ({ - ...state, - active_show: { - number: show.number, - title: show.title, - slug: show.slug, - youtube_url: show.youtube_url!, - video_id, - raw_show: show - }, - status: 'PLAYING', - current_time: resume_time, - active_element: keep_existing_mini_el, - origin_element: target_entry?.video_el ?? null, - in_mini_player: true, - is_minimized: is_same_show ? state.is_minimized : false, - is_dismissed: false - })); - notify_player_sync(); - if (keep_existing_mini_el) { - await seek_and_play(keep_existing_mini_el, resume_time, true); - } - } + /** + * 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); + } + } - return true; - }, - - /** - * Called when an inline starts playing directly. - */ - set_playing( - show: { number: number; title: string; slug: string; youtube_url?: string | null }, - element: YoutubeVideoElementLike | null - ) { - const video_id = get_youtube_id(show.youtube_url); - if (!video_id || !show.youtube_url) return; - - if (pause_audio_cb) { - pause_audio_cb(); - } + function active_show_is(state: YoutubePlayerState, show_number: number) { + return state.active_show?.number === show_number; + } - const current = get(store); - const is_same_show = current.active_show?.number === show.number; - const was_in_mini_player = current.in_mini_player; - - // If this element is already the active playing element, just ensure status is PLAYING - if (is_same_show && current.active_element === element && !was_in_mini_player) { - if (current.status !== 'PLAYING') { - update((state) => ({ - ...state, - status: 'PLAYING', - is_dismissed: false - })); - notify_player_sync(); - } - return; - } + /** + * 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); - let saved_time = is_same_show ? current.current_time : get_saved_position(show.number); - if ( - is_same_show && - current.active_element && - current.active_element !== element && - typeof current.active_element.currentTime === 'number' && - current.active_element.currentTime > 0 - ) { - saved_time = current.active_element.currentTime; - } + // 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); + } - if (current.active_element && current.active_element !== element) { - const prev_el = current.active_element; - update((s) => ({ ...s, active_element: element })); - try { - prev_el.pause(); - } catch { - // ignore - } - } + return () => unregister_embed(entry); + } - let next_time = 0; - if (is_same_show) { - if (was_in_mini_player && saved_time > 0) { - next_time = saved_time; - if (element && Math.abs((element.currentTime || 0) - saved_time) > 1) { - element.currentTime = saved_time; - } - } else if (element && typeof element.currentTime === 'number' && element.currentTime > 0) { - next_time = element.currentTime; - } else { - next_time = saved_time; - if (element && saved_time > 0 && Math.abs((element.currentTime || 0) - saved_time) > 1) { - element.currentTime = saved_time; - } - } - } else if (element && typeof element.currentTime === 'number' && element.currentTime > 1) { - next_time = element.currentTime; - } else if (saved_time > 0) { - next_time = saved_time; - if (element && Math.abs((element.currentTime || 0) - saved_time) > 1) { - element.currentTime = saved_time; - } - } + function unregister_embed({ show_number, video_el, container_el }: EmbedEntry) { + observer?.unobserve(container_el); + embeds.delete(video_el); - update((state) => ({ - ...state, - active_show: { - number: show.number, - title: show.title, - slug: show.slug, - youtube_url: show.youtube_url!, - video_id, - raw_show: show - }, - status: 'PLAYING', - current_time: next_time, - active_element: element, - origin_element: element, - in_mini_player: false, - is_minimized: false, - is_dismissed: false - })); - - notify_player_sync(); - }, - - set_mini_player_playing(element: YoutubeVideoElementLike | null) { - if (pause_audio_cb) { - pause_audio_cb(); - } - update((state) => ({ - ...state, - status: 'PLAYING', - active_element: element, - in_mini_player: true, - is_dismissed: false - })); - notify_player_sync(); - }, - - enter_mini_player_from_scroll(show_number: number, inline_el: YoutubeVideoElementLike | null) { - const state = get(store); - if (state.is_navigating) return; - if (state.active_show?.number !== show_number || state.status !== 'PLAYING') return; - if (state.in_mini_player) return; - if (inline_el && state.origin_element && state.origin_element !== inline_el) return; - - let next_time = state.current_time; - if ( - inline_el && - typeof inline_el.currentTime === 'number' && - inline_el.currentTime > 0 - ) { - next_time = inline_el.currentTime; - } + 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') + })); + } - update((s) => ({ - ...s, - current_time: next_time, - active_element: null, - origin_element: inline_el ?? s.origin_element, - in_mini_player: true, - is_dismissed: false, - status: 'PLAYING' - })); - - if (inline_el) { - try { - inline_el.pause(); - } catch { - // ignore - } - } - }, - - dock_to_inline( - show_number: number, - inline_el: YoutubeVideoElementLike | null, - force_claim = false - ) { - const state = get(store); - if (state.active_show?.number !== show_number) return; - if (!inline_el) return; - if (!force_claim && state.origin_element !== inline_el) return; - if (state.is_dismissed) return; - - let resume_time = state.current_time; - if ( - state.active_element && - state.active_element !== inline_el && - typeof state.active_element.currentTime === 'number' && - state.active_element.currentTime > 0 - ) { - resume_time = state.active_element.currentTime; - } + // 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; + } - const was_playing = state.status === 'PLAYING'; - const prev_el = state.active_element; + function find_embed(show_number: number) { + return find_embed_entry(show_number)?.video_el ?? null; + } - update((s) => ({ - ...s, - current_time: resume_time, - active_element: inline_el, - origin_element: inline_el, - in_mini_player: false, - is_dismissed: false - })); - - if (prev_el && prev_el !== inline_el) { - try { - prev_el.pause(); - } catch { - // ignore - } - } + /** + * 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); + } - void seek_and_play(inline_el, resume_time, was_playing); - notify_player_sync(); - }, - - dock_to_show_page(show_number: number, show_page_el: YoutubeVideoElementLike | null) { - this.dock_to_inline(show_number, show_page_el, true); - }, - - set_active_element(element: YoutubeVideoElementLike | null) { - update((state) => ({ - ...state, - active_element: element - })); - notify_player_sync(); - }, - - update_time(show_number: number, seconds: number, element?: YoutubeVideoElementLike | null) { - if (typeof seconds !== 'number' || Number.isNaN(seconds) || seconds <= 0) return; - update((state) => { - if (state.active_show?.number !== show_number) return state; - if (element && state.active_element && state.active_element !== element) return state; - if (typeof localStorage !== 'undefined') { - localStorage.setItem(`last_played_position_${show_number}`, seconds.toString()); - } - return { - ...state, - current_time: seconds - }; - }); - }, - - before_navigate() { - update((state) => { - let next_time = state.current_time; - if ( - state.active_element && - typeof state.active_element.currentTime === 'number' && - state.active_element.currentTime > 0 - ) { - next_time = state.active_element.currentTime; - } - return { - ...state, - current_time: next_time, - is_navigating: true - }; - }); - }, - - after_navigate() { - update((state) => { - const active_num = state.active_show?.number; - const on_matching_show_page = - active_num != null && state.show_page_number === active_num; - const active_el_still_connected = Boolean(state.active_element?.isConnected); - const should_be_in_mini = - active_num != null && - state.status === 'PLAYING' && - !on_matching_show_page && - !active_el_still_connected - ? true - : state.in_mini_player; - - return { - ...state, - is_navigating: false, - in_mini_player: should_be_in_mini - }; - }); - }, - - set_paused(show_number: number, element?: YoutubeVideoElementLike | null) { - let did_pause = false; - update((state) => { - if (state.is_navigating) return state; - if (state.active_show?.number !== show_number) return state; - if (element && (!element.isConnected || state.active_element !== element)) { - return state; - } - const next_time = - element && typeof element.currentTime === 'number' && element.currentTime > 0 - ? element.currentTime - : state.current_time; - did_pause = true; - return { - ...state, - status: 'PAUSED', - current_time: next_time - }; - }); - if (did_pause) { - notify_player_sync(); - } - }, + 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; + } - async play() { - const state = get(store); - if (!state.active_show) return; - if (pause_audio_cb) { - pause_audio_cb(); - } - update((s) => ({ - ...s, - status: 'PLAYING', - is_dismissed: false - })); - if (state.active_element) { - await seek_and_play(state.active_element, state.current_time, true); - } - notify_player_sync(); - }, - - pause() { - const state = get(store); - const active_el = state.active_element; - const next_time = - active_el && typeof active_el.currentTime === 'number' && active_el.currentTime > 0 - ? active_el.currentTime - : state.current_time; - update((s) => - s.active_show ? { ...s, status: 'PAUSED', current_time: next_time } : s - ); - if (active_el) { - try { - active_el.pause(); - } catch { - // ignore - } - } - notify_player_sync(); - }, - - async seek( - show: { number: number; title: string; slug: string; youtube_url?: string | null }, - seconds: number - ) { - const state = get(store); - if (state.active_show?.number === 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); - notify_player_sync(); - } else if (get_youtube_id(show.youtube_url)) { - await this.start_show(show, seconds); - } - }, - - toggle_minimize() { - update((state) => ({ - ...state, - is_minimized: !state.is_minimized, - is_dismissed: false - })); - }, - - dismiss_mini_player() { - const state = get(store); - const active_el = state.active_element; - const next_time = - active_el && typeof active_el.currentTime === 'number' && active_el.currentTime > 0 - ? active_el.currentTime - : state.current_time; - update((s) => - s.active_show - ? { - ...s, - status: 'PAUSED', - current_time: next_time, - in_mini_player: true, - is_minimized: true, - is_dismissed: true - } - : s - ); - if (active_el) { - try { - active_el.pause(); - } catch { - // ignore - } - } - notify_player_sync(); - }, - - close() { - const state = get(store); - const prev_el = state.active_element; - update(() => ({ - ...initial_state - })); - if (prev_el) { - try { - prev_el.pause(); - } catch { - // ignore - } + /** + * 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 }; } diff --git a/src/utilities/youtube.test.ts b/src/utilities/youtube.test.ts index 3e6dd4f16..c04e5f56c 100644 --- a/src/utilities/youtube.test.ts +++ b/src/utilities/youtube.test.ts @@ -1,36 +1,6 @@ -import { get } from 'svelte/store'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { youtube_player, type YoutubeVideoElementLike } from '$state/youtube_player'; +import { describe, expect, it } from 'vitest'; import { get_youtube_id, get_youtube_watch_url } from './youtube'; -function createMockVideoElement(initialTime = 0): YoutubeVideoElementLike { - return { - src: 'https://www.youtube.com/watch?v=dj6tUUTDXAo', - currentTime: initialTime, - duration: 3600, - paused: false, - autoplay: false, - config: null, - isConnected: true, - play: vi.fn().mockResolvedValue(undefined), - pause: vi.fn().mockResolvedValue(undefined) - } as unknown as YoutubeVideoElementLike; -} - -function createMockContainer(top = 100, bottom = 300): HTMLElement { - return { - isConnected: true, - getBoundingClientRect: () => ({ - top, - bottom, - left: 0, - right: 400, - width: 400, - height: bottom - top - }) - } as unknown as HTMLElement; -} - 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'); @@ -65,216 +35,3 @@ describe('YouTube URL utilities', () => { expect(get_youtube_watch_url(null)).toBeNull(); }); }); - -describe('youtube_player store', () => { - const show790 = { - number: 790, - title: 'State of JS', - slug: 'state-of-js', - youtube_url: 'https://www.youtube.com/watch?v=dj6tUUTDXAo' - }; - - const show760 = { - number: 760, - title: 'Pro VSCode Setups', - slug: 'pro-vscode-setups', - youtube_url: 'https://www.youtube.com/watch?v=VIdM5VSlJVw' - }; - - beforeEach(() => { - youtube_player.close(); - }); - - it('starts in-view inline video when start_show is called and syncs with bottom player', async () => { - const inlineEl = createMockVideoElement(0); - const containerEl = createMockContainer(100, 300); - const syncSpy = vi.fn(); - - youtube_player.on_sync_player(syncSpy); - youtube_player.register_embed(790, inlineEl, containerEl, false); - - await youtube_player.start_show(show790, 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(inlineEl); - expect(inlineEl.currentTime).toBe(30); - expect(inlineEl.play).toHaveBeenCalled(); - expect(syncSpy).toHaveBeenCalledWith( - expect.objectContaining({ - status: 'PLAYING', - element: inlineEl, - current_time: 30 - }) - ); - - youtube_player.unregister_embed(790, inlineEl, false); - }); - - it('opens mini-player when scrolling down on show page or list page and closes when scrolling back into view', () => { - const inlineEl = createMockVideoElement(42.5); - const miniPlayerEl = createMockVideoElement(42.5); - - youtube_player.set_playing(show790, inlineEl); - - let state = get(youtube_player); - expect(state.in_mini_player).toBe(false); - expect(state.status).toBe('PLAYING'); - expect(state.origin_element).toBe(inlineEl); - - // User scrolls past the inline video - inlineEl.currentTime = 65.0; - youtube_player.enter_mini_player_from_scroll(790, inlineEl); - // Simulated pause event from inlineEl should not mark player as PAUSED - youtube_player.set_paused(790, inlineEl); - - state = get(youtube_player); - expect(state.in_mini_player).toBe(true); - expect(state.status).toBe('PLAYING'); - expect(state.current_time).toBe(65.0); - expect(inlineEl.pause).toHaveBeenCalled(); - - // Mini-player mounts and advances time - youtube_player.set_active_element(miniPlayerEl); - miniPlayerEl.currentTime = 95.5; - - // User scrolls back so origin inlineEl is in view again - youtube_player.dock_to_inline(790, inlineEl); - - state = get(youtube_player); - expect(state.in_mini_player).toBe(false); - expect(state.status).toBe('PLAYING'); - expect(state.current_time).toBe(95.5); - expect(inlineEl.currentTime).toBe(95.5); - expect(inlineEl.play).toHaveBeenCalled(); - }); - - it('keeps mini-player going on list pages (/ or /shows), closes on other video play, and resumes from saved point on same video play', () => { - const showPageEl = createMockVideoElement(50); - const miniPlayerEl = createMockVideoElement(50); - - youtube_player.register_show_page(790, showPageEl); - youtube_player.set_playing(show790, showPageEl); - - // Navigate from /show/790/state-of-js to /shows (where 790 is in the list) - showPageEl.currentTime = 72; - youtube_player.before_navigate(); - (showPageEl as { isConnected: boolean }).isConnected = false; - youtube_player.unregister_embed(790, showPageEl, true); - 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(); - - // Mini-player continues playing on /shows and reaches 110s - youtube_player.set_active_element(miniPlayerEl); - miniPlayerEl.currentTime = 110; - - // A card for 790 in /shows scrolling into view must NOT hijack the mini-player since it is not origin_element - const card790El = createMockVideoElement(0); - youtube_player.dock_to_inline(790, card790El); - expect(get(youtube_player).in_mini_player).toBe(true); - - // Clicking Play on the SAME episode (790) in the /shows card list closes mini-player and starts from saved point (110s) - youtube_player.set_playing(show790, card790El); - - state = get(youtube_player); - expect(state.in_mini_player).toBe(false); - expect(state.active_show?.number).toBe(790); - expect(state.origin_element).toBe(card790El); - expect(state.current_time).toBe(110); - expect(card790El.currentTime).toBe(110); - expect(miniPlayerEl.pause).toHaveBeenCalled(); - - // Now scroll card790El out of view on /shows -> pops out mini-player - youtube_player.enter_mini_player_from_scroll(790, card790El); - expect(get(youtube_player).in_mini_player).toBe(true); - - // Click Play on a DIFFERENT episode (760) -> closes mini-player and starts 760 - const card760El = createMockVideoElement(0); - youtube_player.set_playing(show760, card760El); - - state = get(youtube_player); - expect(state.in_mini_player).toBe(false); - expect(state.active_show?.number).toBe(760); - expect(state.origin_element).toBe(card760El); - expect(state.current_time).toBe(0); - }); - - it('pauses without restarting and resumes from saved position', async () => { - const inlineEl = createMockVideoElement(0); - const containerEl = createMockContainer(100, 300); - - youtube_player.register_embed(790, inlineEl, containerEl, true); - await youtube_player.start_show(show790, 120); - - expect(get(youtube_player).status).toBe('PLAYING'); - expect(inlineEl.currentTime).toBe(120); - - // Advance video time and click "Playing Episode 790" -> pauses at 185s - inlineEl.currentTime = 185; - youtube_player.pause(); - - let state = get(youtube_player); - expect(state.status).toBe('PAUSED'); - expect(state.current_time).toBe(185); - expect(inlineEl.pause).toHaveBeenCalled(); - - // Click "Resume Episode 790" -> plays from 185s without resetting to 0 - youtube_player.play(); - state = get(youtube_player); - expect(state.status).toBe('PLAYING'); - expect(inlineEl.currentTime).toBe(185); - expect(inlineEl.play).toHaveBeenCalledTimes(2); - - youtube_player.unregister_embed(790, inlineEl, true); - }); - - it('minimizes mini-player while keeping playback going and resumes in minimized state after closing mini-player', async () => { - const miniPlayerEl = createMockVideoElement(60); - - await youtube_player.start_show(show790, 60); - youtube_player.set_active_element(miniPlayerEl); - - let state = get(youtube_player); - expect(state.in_mini_player).toBe(true); - expect(state.is_minimized).toBe(false); - expect(state.status).toBe('PLAYING'); - - // Minimize mini-player while keeping episode playing - youtube_player.toggle_minimize(); - state = get(youtube_player); - expect(state.is_minimized).toBe(true); - expect(state.status).toBe('PLAYING'); - - // Maximize back - youtube_player.toggle_minimize(); - expect(get(youtube_player).is_minimized).toBe(false); - - // Click x on the mini-player -> pauses and dismisses - miniPlayerEl.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(miniPlayerEl.pause).toHaveBeenCalled(); - - // Click play on the bottom player bar -> resumes playing in minimized state - 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(miniPlayerEl.play).toHaveBeenCalled(); - }); -}); - - From 7352dcb6e59f8ded8cf3ee0b4a2c96edbd5070ab Mon Sep 17 00:00:00 2001 From: w3cj Date: Wed, 7 Oct 2026 16:44:15 -0600 Subject: [PATCH 3/3] fix: resolve lint errors in files touched on this branch - snake_case renames in Transcript (incl. ai_show_note prop), ShowCard, and show layout - Remove unused vars/catch bindings - Make show page JSON-LD schema $derived so it updates when navigating between episodes, and fix the parse error from the inline template Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/ShowCard.svelte | 8 ++-- src/lib/transcript/Transcript.svelte | 48 +++++++++---------- .../show/[show_number]/[slug]/+layout.svelte | 14 ++++-- .../[slug]/transcript/+page.svelte | 2 +- src/state/player.ts | 2 +- 5 files changed, 38 insertions(+), 36 deletions(-) diff --git a/src/lib/ShowCard.svelte b/src/lib/ShowCard.svelte index 59af4d726..e17dcf53e 100644 --- a/src/lib/ShowCard.svelte +++ b/src/lib/ShowCard.svelte @@ -25,16 +25,16 @@ let has_youtube = $derived(Boolean(get_youtube_id(show.youtube_url))); function format_date(date: Date, baseDate: Date = new Date()) { - const timeFormatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); + const time_formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); const diff = date.getTime() - baseDate.getTime(); const days = (diff / (1000 * 60 * 60 * 24)) * -1; switch (true) { case days < 1: - return timeFormatter.format(-Math.round(days * 24), 'hour'); + return time_formatter.format(-Math.round(days * 24), 'hour'); case days < 12: - return timeFormatter.format(-Math.floor(days), 'day'); + return time_formatter.format(-Math.floor(days), 'day'); case days < 30: - return timeFormatter.format(-Math.floor(days / 7), 'week'); + return time_formatter.format(-Math.floor(days / 7), 'week'); default: return format(date, 'MMMM do, yyyy'); } diff --git a/src/lib/transcript/Transcript.svelte b/src/lib/transcript/Transcript.svelte index 2824e5f70..8adb64a2f 100644 --- a/src/lib/transcript/Transcript.svelte +++ b/src/lib/transcript/Transcript.svelte @@ -16,11 +16,11 @@ interface Props { transcript: TranscriptWithUtterances; - aiShowNote: AINoteWithFriends | null; + ai_show_note: AINoteWithFriends | null; show: Show; } - let { transcript, aiShowNote, show }: Props = $props(); + let { transcript, ai_show_note, show }: Props = $props(); const slim_transcript: SlimUtterance[] = getSlimUtterances(transcript.utterances, 1) // .filter((utterance) => utterance.speakerId !== 99) @@ -43,9 +43,7 @@ slim_transcript, (utterance: Utterance) => { const start = utterance.start; - const summary = aiShowNote?.summary?.findLast((summary, i) => { - const nextSummary = aiShowNote?.summary?.at(i + 1); - const end = nextSummary ? tsToS(nextSummary.time) : Infinity; + const summary = ai_show_note?.summary?.findLast((summary) => { const timestamp = tsToS(summary.time); return start >= timestamp; }); @@ -59,19 +57,19 @@ ); let current_time = $derived(audio_timeline_time ?? 0); - let currentUtterance = $derived( + let current_utterance = $derived( slim_transcript.find((utterance, index) => { - const nextUtteranceStart = slim_transcript[index + 1]?.start || utterance.end; - return current_time >= utterance.start && current_time <= nextUtteranceStart; + const next_utterance_start = slim_transcript[index + 1]?.start || utterance.end; + return current_time >= utterance.start && current_time <= next_utterance_start; }) ); - let currentTopic = $derived( - aiShowNote?.summary.find((summary, index) => { - const nextSummary = aiShowNote?.summary[index + 1]; - const topicEnd = nextSummary ? tsToS(nextSummary.time) : Infinity; - const topicStart = tsToS(summary.time); - return current_time >= topicStart && current_time <= topicEnd; + let current_topic = $derived( + ai_show_note?.summary.find((summary, index) => { + const next_summary = ai_show_note?.summary[index + 1]; + const topic_end = next_summary ? tsToS(next_summary.time) : Infinity; + const topic_start = tsToS(summary.time); + return current_time >= topic_start && current_time <= topic_end; }) ); @@ -99,22 +97,22 @@ // .map((word) => word.word) // .join(' '); - let labelUtterance = $derived(function (utterance: SlimUtterance) { + let label_utterance = $derived(function (utterance: SlimUtterance) { if (!playing_show_is_this_show) return ''; // not playing this show - if (utterance === currentUtterance) { + if (utterance === current_utterance) { return 'current'; - } else if (currentUtterance && currentUtterance?.end > utterance.end) { + } else if (current_utterance && current_utterance?.end > utterance.end) { return 'past'; } else { return 'future'; } }); - let placeTopic = $derived(function (summary: SummaryTitle, utterances: SlimUtterance[]) { - const summaryEnd = utterances.at(-1)?.end || Infinity; + let place_topic = $derived(function (summary: SummaryTitle, utterances: SlimUtterance[]) { + const summary_end = utterances.at(-1)?.end || Infinity; if (!playing_show_is_this_show) return ''; // not playing this show - if (currentTopic?.id === summary.id) { + if (current_topic?.id === summary.id) { return 'current'; - } else if (current_time > summaryEnd) { + } else if (current_time > summary_end) { return 'past'; } else { return 'future'; @@ -122,14 +120,14 @@ }); -{#if aiShowNote} - +{#if ai_show_note} + {/if}
{#each Array.from(utterances_by_summary) as [summary, utterances], i}
-
+
Topic {i} {summary.time} @@ -151,7 +149,7 @@ style=" --progress: {progress > 0 && progress < 100 ? `${progress}%` : '100%'}; " - class="utterance {labelUtterance(utterance)}" + class="utterance {label_utterance(utterance)}" >