518 lines
17 KiB
TypeScript
518 lines
17 KiB
TypeScript
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<void> {
|
|
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<typeof setInterval> | 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<typeof setTimeout> | null = null
|
|
|
|
async function saveProgressNow(podcastId: string, seconds: number, duration: number, markCompleted = false): Promise<void> {
|
|
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<number> {
|
|
// 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<PlayerState>((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)
|
|
}
|