import { create } from 'zustand' import type { Podcast } from '@/types' import { isExternalUrl, getEmbedInfo } from '@/lib/embed' import { publicUrl } from '@/lib/storage' import { supabase } from '@/lib/supabase' import { useAuthStore } from '@/stores/auth' import { invalidateProgressCache } from '@/hooks/useListeningProgress' import { getCachedAudioUrl } from '@/lib/offline' // ──── YouTube IFrame API ──── declare global { interface Window { YT: any onYouTubeIframeAPIReady: () => void } } let ytApiReady = false let ytApiCallbacks: (() => void)[] = [] function loadYouTubeAPI(): Promise { if (ytApiReady) return Promise.resolve() return new Promise((resolve) => { if (document.querySelector('script[src*="youtube.com/iframe_api"]')) { ytApiCallbacks.push(resolve) return } ytApiCallbacks.push(resolve) const tag = document.createElement('script') tag.src = 'https://www.youtube.com/iframe_api' document.head.appendChild(tag) window.onYouTubeIframeAPIReady = () => { ytApiReady = true ytApiCallbacks.forEach((cb) => cb()) ytApiCallbacks = [] } }) } // ──── External player managers ──── let ytPlayer: any = null let ytContainer: HTMLDivElement | null = null let progressInterval: ReturnType | null = null let hiddenIframe: HTMLIFrameElement | null = null function clearProgressInterval() { if (progressInterval) { clearInterval(progressInterval); progressInterval = null } } function destroyYtPlayer() { clearProgressInterval() try { ytPlayer?.destroy() } catch { /* ignore */ } ytPlayer = null if (ytContainer) { // If it's the portal, just clear contents; otherwise remove from DOM if (ytContainer.id === 'yt-player-portal') { ytContainer.innerHTML = '' } else { ytContainer.remove() } ytContainer = null } } function destroyIframe() { if (hiddenIframe) { hiddenIframe.remove(); hiddenIframe = null } } function destroyAll() { destroyYtPlayer() destroyIframe() } function createHiddenIframe(embedUrl: string) { destroyAll() const iframe = document.createElement('iframe') iframe.src = embedUrl iframe.allow = 'autoplay; encrypted-media' iframe.style.cssText = 'position:fixed;width:1px;height:1px;left:-10px;top:-10px;opacity:0;pointer-events:none;' document.body.appendChild(iframe) hiddenIframe = iframe } // ──── Offline progress persistence (localStorage fallback) ──── const OFFLINE_PROGRESS_KEY = 'wetalk-offline-progress' function saveProgressLocal(podcastId: string, seconds: number, duration: number, completed = false) { try { const stored = JSON.parse(localStorage.getItem(OFFLINE_PROGRESS_KEY) || '{}') stored[podcastId] = { seconds: Math.floor(seconds), duration, completed, at: Date.now() } localStorage.setItem(OFFLINE_PROGRESS_KEY, JSON.stringify(stored)) } catch { /* ignore */ } } function fetchProgressLocal(podcastId: string): number { try { const stored = JSON.parse(localStorage.getItem(OFFLINE_PROGRESS_KEY) || '{}') const entry = stored[podcastId] return entry?.seconds > 5 ? entry.seconds : 0 } catch { return 0 } } // ──── Progress persistence ──── let saveTimeout: ReturnType | null = null async function saveProgressNow(podcastId: string, seconds: number, duration: number, markCompleted = false): Promise { if (saveTimeout) { clearTimeout(saveTimeout); saveTimeout = null } if (seconds < 1) return // Always save to localStorage (works offline) const completed = markCompleted || (duration > 0 && seconds / duration > 0.95) saveProgressLocal(podcastId, seconds, duration, completed) // Try Supabase if online + authenticated const userId = useAuthStore.getState().user?.id if (!userId || !navigator.onLine) return const { error } = await supabase.from('listen_history').upsert( { user_id: userId, podcast_id: podcastId, progress_seconds: Math.floor(seconds), completed, listened_at: new Date().toISOString() }, { onConflict: 'user_id,podcast_id' } ) if (error) console.error('[player] save progress failed:', error) else { console.log('[player] saved progress:', podcastId, Math.floor(seconds), 'seconds') invalidateProgressCache() } } function debouncedSaveProgress(podcastId: string, seconds: number, duration: number) { if (saveTimeout) clearTimeout(saveTimeout) saveTimeout = setTimeout(() => saveProgressNow(podcastId, seconds, duration), 5000) } async function fetchSavedProgress(podcastId: string): Promise { // Try Supabase first if online const userId = useAuthStore.getState().user?.id if (userId && navigator.onLine) { const { data } = await supabase .from('listen_history') .select('progress_seconds') .eq('user_id', userId) .eq('podcast_id', podcastId) .maybeSingle() const saved = data?.progress_seconds || 0 if (saved > 5) return saved } // Fallback to localStorage return fetchProgressLocal(podcastId) } // ──── Store ──── const PLAYBACK_RATES = [0.5, 0.75, 1, 1.25, 1.5, 2] as const type PlaybackRate = (typeof PLAYBACK_RATES)[number] function getSavedPlaybackRate(): PlaybackRate { const saved = localStorage.getItem('wetalk-playback-rate') if (saved) { const n = parseFloat(saved) if (PLAYBACK_RATES.includes(n as PlaybackRate)) return n as PlaybackRate } return 1 } interface PlayerState { current: Podcast | null isPlaying: boolean isExternal: boolean progress: number duration: number volume: number audio: HTMLAudioElement | null playbackRate: PlaybackRate queue: Podcast[] play: (podcast: Podcast) => void toggle: () => void pause: () => void seek: (time: number) => void setVolume: (vol: number) => void setProgress: (progress: number) => void setDuration: (duration: number) => void cyclePlaybackRate: () => void setPlaybackRate: (rate: PlaybackRate) => void addToQueue: (podcast: Podcast) => void removeFromQueue: (podcastId: string) => void clearQueue: () => void playNext: () => void } function getSavedQueue(): Podcast[] { try { const saved = localStorage.getItem('wetalk-queue') return saved ? JSON.parse(saved) : [] } catch { return [] } } function persistQueue(queue: Podcast[]) { localStorage.setItem('wetalk-queue', JSON.stringify(queue)) } export const usePlayerStore = create((set, get) => ({ current: null, isPlaying: false, isExternal: false, progress: 0, duration: 0, volume: 0.8, audio: null, playbackRate: getSavedPlaybackRate(), queue: getSavedQueue(), play: (podcast) => { const { audio, current, volume } = get() const external = isExternalUrl(podcast.audio_url) const embed = external ? getEmbedInfo(podcast.audio_url) : null // Resume same podcast (already loaded) if (current?.id === podcast.id) { if (external && embed?.platform === 'youtube' && ytPlayer) { try { ytPlayer.playVideo() } catch { /* ignore */ } startYtProgressTracking() set({ isPlaying: true }) return } if (external) { if (embed) createHiddenIframe(embed.embedUrl) set({ isPlaying: true }) return } if (audio) { audio.play() set({ isPlaying: true }) return } } // Stop & save previous const prev = get().current const savePrevPromise = prev ? saveProgressNow(prev.id, get().progress, get().duration) : Promise.resolve() if (audio) { audio.pause(); audio.removeAttribute('src') } destroyAll() // ── YouTube: use IFrame API for full control ── if (external && embed?.platform === 'youtube') { set({ audio: null, current: podcast, isPlaying: false, isExternal: true, progress: 0, duration: podcast.duration_seconds || 0 }) // Wait for previous save to complete, then fetch saved progress savePrevPromise.then(() => fetchSavedProgress(podcast.id)).then((savedTime) => { loadYouTubeAPI().then(() => { // Use portal target if available, otherwise create visible container const portalTarget = document.getElementById('yt-player-portal') if (portalTarget) { portalTarget.innerHTML = '' ytContainer = portalTarget as HTMLDivElement } else { ytContainer = document.createElement('div') ytContainer.style.cssText = 'position:fixed;bottom:90px;right:16px;width:200px;height:200px;z-index:60;border-radius:12px;overflow:hidden;box-shadow:0 4px 20px rgba(0,0,0,0.3);' document.body.appendChild(ytContainer) } const playerDiv = document.createElement('div') playerDiv.id = 'yt-player-' + Date.now() ytContainer.appendChild(playerDiv) ytPlayer = new window.YT.Player(playerDiv.id, { videoId: embed.id, playerVars: { autoplay: 1, controls: 1, fs: 1, modestbranding: 1, rel: 0, start: savedTime > 0 ? Math.floor(savedTime) : undefined, }, events: { onReady: (e: any) => { e.target.setVolume(volume * 100) e.target.setPlaybackRate(get().playbackRate) e.target.playVideo() const dur = e.target.getDuration() if (dur > 0) set({ duration: dur }) if (savedTime > 0) { e.target.seekTo(savedTime, true) set({ progress: savedTime }) } set({ isPlaying: true }) startYtProgressTracking() }, onStateChange: (e: any) => { if (e.data === 0) { // Ended clearProgressInterval() const podId = usePlayerStore.getState().current?.id if (podId) saveProgressNow(podId, e.target.getDuration(), e.target.getDuration(), true) set({ isPlaying: false, progress: 0 }) // Auto-play next from queue setTimeout(() => usePlayerStore.getState().playNext(), 100) } else if (e.data === 1) { const dur = e.target.getDuration() if (dur > 0) set({ duration: dur }) set({ isPlaying: true }) startYtProgressTracking() } else if (e.data === 2) { clearProgressInterval() // Save on pause const state = usePlayerStore.getState() if (state.current) saveProgressNow(state.current.id, state.progress, state.duration) set({ isPlaying: false }) } }, }, }) }) }) return } // ── Spotify: embed is rendered in PlayerBar, no hidden iframe needed ── if (external && embed?.platform === 'spotify') { set({ audio: null, current: podcast, isPlaying: true, isExternal: true, progress: 0, duration: podcast.duration_seconds || 0 }) return } // ── Other external (Dailymotion, SoundCloud): hidden iframe ── if (external && embed) { createHiddenIframe(embed.embedUrl) set({ audio: null, current: podcast, isPlaying: true, isExternal: true, progress: 0, duration: podcast.duration_seconds || 0 }) return } // ── Native audio ── const audioSrc = publicUrl(podcast.audio_url) // Try to use cached version first const setupAudio = (src: string) => { const newAudio = new Audio(src) newAudio.volume = volume newAudio.playbackRate = get().playbackRate newAudio.addEventListener('timeupdate', () => { const currentDur = isFinite(newAudio.duration) ? newAudio.duration : (get().duration || 0) set({ progress: newAudio.currentTime }) debouncedSaveProgress(podcast.id, newAudio.currentTime, currentDur) }) newAudio.addEventListener('loadedmetadata', () => { const dur = isFinite(newAudio.duration) ? newAudio.duration : podcast.duration_seconds || 0 set({ duration: dur }) // Wait for previous save, then restore saved position savePrevPromise.then(() => fetchSavedProgress(podcast.id)).then((savedTime) => { if (savedTime > 0 && savedTime < newAudio.duration - 5) { newAudio.currentTime = savedTime set({ progress: savedTime }) } }) }) newAudio.addEventListener('ended', () => { saveProgressNow(podcast.id, newAudio.duration, newAudio.duration, true) set({ isPlaying: false, progress: 0 }) // Auto-play next from queue setTimeout(() => get().playNext(), 100) }) newAudio.play() set({ audio: newAudio, current: podcast, isPlaying: true, isExternal: false, progress: 0 }) } // Check cache, fall back to network getCachedAudioUrl(audioSrc).then((cachedUrl) => { setupAudio(cachedUrl || audioSrc) }) }, toggle: () => { const { audio, isPlaying, isExternal, current, progress, duration } = get() if (isExternal) { const embed = current ? getEmbedInfo(current.audio_url) : null if (embed?.platform === 'youtube' && ytPlayer) { try { if (isPlaying) { ytPlayer.pauseVideo() clearProgressInterval() if (current) saveProgressNow(current.id, progress, duration) } else { ytPlayer.playVideo() startYtProgressTracking() } } catch { /* ignore */ } set({ isPlaying: !isPlaying }) return } if (isPlaying) { destroyIframe() } else if (embed) { createHiddenIframe(embed.embedUrl) } set({ isPlaying: !isPlaying }) return } if (!audio) return if (isPlaying) { audio.pause() if (current) saveProgressNow(current.id, progress, duration) } else { audio.play() } set({ isPlaying: !isPlaying }) }, pause: () => { const { isExternal, current, progress, duration } = get() if (current) saveProgressNow(current.id, progress, duration) if (isExternal) { const embed = current ? getEmbedInfo(current.audio_url) : null if (embed?.platform === 'youtube' && ytPlayer) { try { ytPlayer.pauseVideo() } catch { /* ignore */ } clearProgressInterval() } else { destroyIframe() } } get().audio?.pause() set({ isPlaying: false }) }, seek: (time) => { const { audio, isExternal, current } = get() if (isExternal) { const embed = current ? getEmbedInfo(current.audio_url) : null if (embed?.platform === 'youtube' && ytPlayer) { try { ytPlayer.seekTo(time, true) } catch { /* ignore */ } set({ progress: time }) return } return } if (!audio) return audio.currentTime = time set({ progress: time }) }, setVolume: (vol) => { const { audio } = get() if (audio) audio.volume = vol if (ytPlayer) try { ytPlayer.setVolume(vol * 100) } catch { /* ignore */ } set({ volume: vol }) }, setProgress: (progress) => set({ progress }), setDuration: (duration) => set({ duration }), cyclePlaybackRate: () => { const { playbackRate } = get() const idx = PLAYBACK_RATES.indexOf(playbackRate) const next = PLAYBACK_RATES[(idx + 1) % PLAYBACK_RATES.length] get().setPlaybackRate(next) }, setPlaybackRate: (rate) => { const { audio } = get() if (audio) audio.playbackRate = rate if (ytPlayer) try { ytPlayer.setPlaybackRate(rate) } catch { /* ignore */ } localStorage.setItem('wetalk-playback-rate', String(rate)) set({ playbackRate: rate }) }, addToQueue: (podcast) => { const { queue } = get() if (queue.some((p) => p.id === podcast.id)) return const next = [...queue, podcast] persistQueue(next) set({ queue: next }) }, removeFromQueue: (podcastId) => { const next = get().queue.filter((p) => p.id !== podcastId) persistQueue(next) set({ queue: next }) }, clearQueue: () => { persistQueue([]) set({ queue: [] }) }, playNext: () => { const { queue, play } = get() if (queue.length === 0) return const [next, ...rest] = queue persistQueue(rest) set({ queue: rest }) play(next) }, })) // Track YouTube progress via polling function startYtProgressTracking() { clearProgressInterval() progressInterval = setInterval(() => { if (ytPlayer?.getCurrentTime) { try { const time = ytPlayer.getCurrentTime() const dur = ytPlayer.getDuration() usePlayerStore.setState({ progress: time }) if (dur > 0) usePlayerStore.setState({ duration: dur }) const podId = usePlayerStore.getState().current?.id if (podId) debouncedSaveProgress(podId, time, dur) } catch { /* ignore */ } } }, 500) }