wetalk/src/stores/player.ts
ordinarthur 5ea5a390ce
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 38s
add offline part
2026-04-13 13:45:04 +02:00

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)
}