diff --git a/static/player.ts b/static/player.ts deleted file mode 100644 index 2b16a41..0000000 --- a/static/player.ts +++ /dev/null @@ -1,1577 +0,0 @@ -import DOMPurify from 'dompurify' - -interface ModeSource { - token: string - subtitles: SubtitleItem[] - qualities?: string[] -} - -interface SubtitleItem { - lang: string - token: string -} - -interface SkipSegment { - type: string - start: number - end: number -} - -let playerInitialized = false - -const initPlayer = (): void => { - const container = document.querySelector('[data-video-player]') - if (!container) return - - if (playerInitialized) return - - const shouldAutoPlay = sessionStorage.getItem('mal:autoplay-next') === 'true' - sessionStorage.removeItem('mal:autoplay-next') - - const video = container.querySelector('video') as HTMLVideoElement - const loading = container.querySelector('[data-loading]') as HTMLElement - const playPause = container.querySelector('[data-play-pause]') as HTMLButtonElement - const iconPlay = container.querySelector('[data-icon-play]') as SVGElement - const iconPause = container.querySelector('[data-icon-pause]') as SVGElement - const muteBtn = container.querySelector('[data-mute]') as HTMLButtonElement - const volumePanel = container.querySelector('[data-volume-panel]') as HTMLElement - const volumeRange = container.querySelector('[data-volume-range]') as HTMLInputElement - const iconVolume = container.querySelector('[data-icon-volume]') as SVGElement - const iconMuted = container.querySelector('[data-icon-muted]') as SVGElement - const volumeUnderline = container.querySelector('[data-volume-underline]') as HTMLElement - const timeDisplay = container.querySelector('[data-time]') as HTMLElement - const durationDisplay = container.querySelector('[data-duration]') as HTMLElement - const progressWrap = container.querySelector('[data-progress-wrap]') as HTMLElement - const progress = container.querySelector('[data-progress]') as HTMLElement - const buffered = container.querySelector('[data-buffered]') as HTMLElement - const scrubber = container.querySelector('[data-scrubber]') as HTMLElement - const segmentsTrack = container.querySelector('[data-segments]') as HTMLElement - const subtitleSelect = container.querySelector('[data-subtitle-select]') as HTMLSelectElement - const qualitySelect = container.querySelector('[data-quality-select]') as HTMLSelectElement - const backwardBtn = container.querySelector('[data-backward]') as HTMLButtonElement - const forwardBtn = container.querySelector('[data-forward]') as HTMLButtonElement - const fullscreenBtn = container.querySelector('[data-fullscreen]') as HTMLButtonElement - const skipSegmentBtn = container.querySelector('[data-skip]') as HTMLButtonElement - const autoplayBtn = document.querySelector('[data-autoplay]') as HTMLButtonElement - const subtitleText = container.querySelector('[data-subtitle-text]') as HTMLElement - - const streamURL = container.getAttribute('data-stream-url') || '/watch/proxy/stream' - const initialStreamToken = container.getAttribute('data-stream-token') || '' - let currentEpisode = container.getAttribute('data-current-episode') || '1' - const malID = Number.parseInt(container.getAttribute('data-mal-id') || '', 10) - let totalEpisodes = Number.parseInt(container.getAttribute('data-total-episodes') || '0', 10) - const animeTitle = container.getAttribute('data-anime-title') || '' - const animeTitleEnglish = container.getAttribute('data-anime-title-english') || '' - const animeTitleJapanese = container.getAttribute('data-anime-title-japanese') || '' - const animeImage = container.getAttribute('data-anime-image') || '' - const animeAiring = (container.getAttribute('data-anime-airing') || '').toLowerCase() === 'true' - const safeJsonParse = (raw: string | null, fallback: T): T => { - if (!raw) { - return fallback - } - try { - return JSON.parse(raw) as T - } catch { - return fallback - } - } - - const clearElement = (el: HTMLElement): void => { - while (el.firstChild) { - el.removeChild(el.firstChild) - } - } - - let modeSources = safeJsonParse(container.getAttribute('data-mode-sources'), {} as Record) - let availableModes = safeJsonParse(container.getAttribute('data-available-modes'), [] as string[]) - const backendInitialMode = container.getAttribute('data-initial-mode') || 'dub' - const storedMode = localStorage.getItem('player-audio-mode') - const initialMode = (storedMode && availableModes.includes(storedMode)) ? storedMode : backendInitialMode - - const segments = safeJsonParse(container.getAttribute('data-segments'), [] as SkipSegment[]) - const maxIntroStartSeconds = 180 - const minOutroStartRatio = 0.5 - const minSegmentDurationSeconds = 20 - const maxSegmentDurationSeconds = 240 - - let parsedSegments = (segments || []) - .map((segment: SkipSegment) => { - const start = Number(segment.start || 0) - const end = Number(segment.end || 0) - if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) { - return { ...segment, start: 0, end: 0 } - } - return { ...segment, start, end } - }) - .filter((s: SkipSegment) => s.start > 0 || s.end > 0) - - let activeSegments: Array<{ type: string, start: number, end: number }> = [] - let lastSavedProgress = { episode: currentEpisode, seconds: -1 } - let progressSaveTimer: number | undefined - let transitionEpisode: number | null = null - let completionSent = false - let completionAttempts = 0 - let playerControlsTimeout: number | undefined - let isFullscreen = false - let isScrubbing = false - let lastKnownVolume = 1 - let pendingSeekTime: number | null = null - let activeSkipSegment: { type: string, start: number, end: number } | null = null - let activeSubtitles: Array<{ start: number, end: number, text: string }> = [] - let currentSubtitleTracks: Array<{ lang: string, label: string, url: string }> = [] - - let currentMode = availableModes.includes(initialMode) ? initialMode : (availableModes[0] || 'dub') - const fallbackMode = Object.keys(modeSources).find((mode) => typeof modeSources[mode]?.token === 'string' && modeSources[mode].token !== '') - if ((!modeSources[currentMode] || !modeSources[currentMode].token) && fallbackMode) { - currentMode = fallbackMode - } - const watchProgressURL = '/api/watch-progress' - - const previewPopover = container.querySelector('[data-preview-popover]') as HTMLElement - const previewTime = container.querySelector('[data-preview-time]') as HTMLElement - const videoOverlay = container.querySelector('[data-video-overlay]') as HTMLElement - const streamUrlForMode = (mode: string, quality?: string): string => { - const modeParam = encodeURIComponent(mode) - const modeSource = modeSources[mode] - const token = modeSource?.token - if (!token) return '' - const tokenParam = encodeURIComponent(token) - let url = `${streamURL}?mode=${modeParam}&token=${tokenParam}` - if (quality && quality !== 'best') { - url += `&quality=${encodeURIComponent(quality)}` - } - return url - } - - const subtitleProxyURL = (track: SubtitleItem): string => { - if (!track || !track.token) return '' - return `/watch/proxy/subtitle?token=${encodeURIComponent(track.token)}` - } - - const subtitlesForMode = (mode: string): Array<{ lang: string, label: string, url: string }> => { - const modeSource = modeSources[mode] - if (!modeSource || !Array.isArray(modeSource.subtitles)) return [] - return modeSource.subtitles - .map((track: SubtitleItem) => ({ - lang: (track.lang || 'unknown').toLowerCase(), - label: track.lang || 'Unknown', - url: subtitleProxyURL(track), - })) - .filter((track: { url: string }) => track.url !== '') - } - - const skipLabel = (segmentType: string): string => segmentType === 'ed' ? 'Skip outro' : 'Skip intro' - - const isAutoplayEnabled = (): boolean => localStorage.getItem('mal:autoplay-enabled') !== 'false' - const isAutoSkipEnabled = (): boolean => localStorage.getItem('mal:autoskip-enabled') === 'true' - const getPreferredQuality = (): string => localStorage.getItem('mal:preferred-quality') || 'best' - - const updateAutoplayButton = (): void => { - if (!autoplayBtn) return - const enabled = isAutoplayEnabled() - const checkbox = autoplayBtn as unknown as HTMLInputElement - checkbox.checked = enabled - } - - const updateAutoSkipButton = (): void => { - const autoSkipBtn = document.querySelector('[data-autoskip]') as HTMLInputElement | null - if (!autoSkipBtn) return - autoSkipBtn.checked = isAutoSkipEnabled() - } - - const timelineBounds = (): { start: number, end: number, duration: number } => { - const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : 0 - - let start = 0 - if (video.seekable.length > 0) { - const seekableStart = video.seekable.start(0) - if (Number.isFinite(seekableStart) && seekableStart > 0) { - start = seekableStart - } - } - - if (duration > start) { - return { - start, - end: duration, - duration: duration - start, - } - } - - if (video.seekable.length > 0) { - const seekableEnd = video.seekable.end(video.seekable.length - 1) - if (Number.isFinite(seekableEnd) && seekableEnd > start) { - return { - start, - end: seekableEnd, - duration: seekableEnd - start, - } - } - } - - return { - start: 0, - end: duration, - duration, - } - } - - const displayTimeFromAbsolute = (absoluteTime: number): number => { - const bounds = timelineBounds() - if (!Number.isFinite(absoluteTime) || bounds.duration <= 0) { - return 0 - } - - const safeAbsoluteTime = Math.max(bounds.start, Math.min(bounds.end, absoluteTime)) - return safeAbsoluteTime - bounds.start - } - - const absoluteTimeFromDisplay = (displayTime: number): number => { - const bounds = timelineBounds() - if (!Number.isFinite(displayTime) || bounds.duration <= 0) { - return 0 - } - - const safeDisplayTime = Math.max(0, Math.min(bounds.duration, displayTime)) - return bounds.start + safeDisplayTime - } - - const absoluteTimeFromRatio = (ratio: number): number => { - const bounds = timelineBounds() - if (!Number.isFinite(ratio) || bounds.duration <= 0) { - return 0 - } - - const safeRatio = Math.max(0, Math.min(1, ratio)) - return bounds.start + (safeRatio * bounds.duration) - } - - const resolveActiveSegments = (): void => { - const bounds = timelineBounds() - if (bounds.duration <= 0) { - activeSegments = [] - return - } - - activeSegments = parsedSegments.filter((segment) => { - const start = segment.start - const end = segment.end - const segmentDuration = end - start - - if (segmentDuration < minSegmentDurationSeconds || segmentDuration > maxSegmentDurationSeconds) { - return false - } - if (start < 0 || end <= start || end > bounds.duration + 1) { - return false - } - - if (segment.type === 'op') { - if (start > maxIntroStartSeconds) { - return false - } - if (start > bounds.duration * 0.5) { - return false - } - return true - } - - if (segment.type === 'ed') { - return start >= bounds.duration * minOutroStartRatio - } - - return false - }) - } - - const skipActivationTime = (segment: { start: number, end: number }): number => { - const length = Math.max(0, segment.end - segment.start) - const delay = Math.min(1, Math.max(0.25, length * 0.02)) - const boundedDelay = Math.min(delay, length * 0.5) - return segment.start + boundedDelay - } - - const findActiveSegment = (time: number): typeof activeSegments[number] | undefined => { - return activeSegments.find((segment) => { - const activationTime = skipActivationTime(segment) - return time >= activationTime && time < segment.end - }) - } - - const updateSkipButton = (currentTime: number): void => { - const currentDisplayTime = displayTimeFromAbsolute(currentTime) - const segment = findActiveSegment(currentDisplayTime) - - if (!segment) { - activeSkipSegment = null - skipSegmentBtn?.classList.add('hidden') - skipSegmentBtn?.classList.remove('block') - return - } - - // Auto-skip logic - if (isAutoSkipEnabled() && currentDisplayTime >= segment.start && currentDisplayTime < segment.end) { - const target = absoluteTimeFromDisplay(segment.end + 0.01) - video.currentTime = target - return - } - - activeSkipSegment = segment - if (skipSegmentBtn) { - skipSegmentBtn.textContent = skipLabel(segment.type) - skipSegmentBtn.title = skipLabel(segment.type) - skipSegmentBtn.classList.remove('hidden') - skipSegmentBtn.classList.add('block') - } - } - - const renderSegments = (): void => { - if (!segmentsTrack) { - return - } - clearElement(segmentsTrack) - - const bounds = timelineBounds() - if (bounds.duration <= 0) return - - activeSegments.forEach((segment: { start: number, end: number }) => { - const left = (segment.start / bounds.duration) * 100 - const width = ((segment.end - segment.start) / bounds.duration) * 100 - const bar = document.createElement('div') - bar.className = 'absolute top-0 h-full bg-white/80' - bar.style.left = `${left}%` - bar.style.width = `${width}%` - segmentsTrack.appendChild(bar) - }) - } - - const updateVideoOverlay = (episode: string, episodeTitle: string): void => { - if (!videoOverlay) return - const episodeText = episodeTitle - ? `Episode ${episode}, ${episodeTitle}` - : `Episode ${episode}` - const secondLine = videoOverlay.querySelector('p') - if (secondLine) { - secondLine.textContent = episodeText - } - } - - const formatTime = (seconds: number): string => { - if (!Number.isFinite(seconds) || seconds < 0) return '00:00' - const mins = Math.floor(seconds / 60) - const secs = Math.floor(seconds % 60) - return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` - } - - const updateTimeline = (currentTime: number): void => { - if (!timeDisplay || !progress) return - - const bounds = timelineBounds() - if (bounds.duration <= 0) { - progress.style.width = '0%' - if (buffered) buffered.style.width = '0%' - if (scrubber) scrubber.style.left = '0%' - timeDisplay.textContent = '00:00' - if (durationDisplay) durationDisplay.textContent = '00:00' - return - } - - const currentDisplayTime = displayTimeFromAbsolute(currentTime) - const pct = Math.max(0, Math.min(100, (currentDisplayTime / bounds.duration) * 100)) - progress.style.width = `${pct}%` - if (scrubber) scrubber.style.left = `${pct}%` - timeDisplay.textContent = formatTime(currentDisplayTime) - if (durationDisplay) durationDisplay.textContent = formatTime(bounds.duration) - - if (buffered && video) { - let bufferedEnd = 0 - for (let i = 0; i < video.buffered.length; i++) { - if (video.buffered.start(i) <= currentTime && video.buffered.end(i) >= currentTime) { - bufferedEnd = video.buffered.end(i) - break - } - } - // If we couldn't find a range containing current time (can happen immediately after seeking), - // fallback to the highest buffered end that is greater than current time - if (bufferedEnd === 0) { - for (let i = 0; i < video.buffered.length; i++) { - if (video.buffered.end(i) > currentTime) { - bufferedEnd = Math.max(bufferedEnd, video.buffered.end(i)) - } - } - } - - const bufferedDisplayTime = displayTimeFromAbsolute(bufferedEnd) - const bufferedPct = Math.max(0, Math.min(100, (bufferedDisplayTime / bounds.duration) * 100)) - buffered.style.width = `${bufferedPct}%` - } - } - - const seekBy = (delta: number): void => { - const bounds = timelineBounds() - if (bounds.duration <= 0) return - - const next = Math.max(bounds.start, Math.min(bounds.end, video.currentTime + delta)) - video.currentTime = next - updateTimeline(video.currentTime) - updateSkipButton(video.currentTime) - showControls() - } - - const hidePreviewPopover = (): void => { - if (!previewPopover) return - previewPopover.style.left = '0px' - previewPopover.classList.remove('block') - previewPopover.classList.add('hidden') - } - - const showPreviewPopover = (): void => { - if (!previewPopover) return - previewPopover.classList.remove('hidden') - previewPopover.classList.add('block') - } - - const updatePreviewUI = (ratio: number): void => { - if (!progressWrap || !previewPopover || !previewTime) return - - const bounds = timelineBounds() - if (bounds.duration <= 0) { - hidePreviewPopover() - return - } - - const targetTime = Math.max(0, Math.min(bounds.duration, ratio * bounds.duration)) - previewTime.textContent = formatTime(targetTime) - const barWidth = progressWrap.clientWidth - if (barWidth <= 0) { - hidePreviewPopover() - return - } - - showPreviewPopover() - let popoverWidth = 72 - if (previewPopover.offsetWidth > 0) { - popoverWidth = previewPopover.offsetWidth - } - - const popoverOffset = ratio * barWidth - const halfWidth = popoverWidth / 2 - const clampedOffset = Math.max(halfWidth, Math.min(barWidth - halfWidth, popoverOffset)) - previewPopover.style.left = `${clampedOffset}px` - } - - const buildWatchProgressPayload = (episodeNumber: number, timeSeconds: number): string => { - return JSON.stringify({ - mal_id: malID, - episode: episodeNumber, - time_seconds: timeSeconds, - }) - } - - const sendWatchProgressBeacon = (payload: string): boolean => { - if (!navigator.sendBeacon) { - return false - } - - const blob = new Blob([payload], { type: 'application/json' }) - navigator.sendBeacon(watchProgressURL, blob) - return true - } - - const saveProgress = async (): Promise => { - if (!Number.isInteger(malID) || malID <= 0) return - - const bounds = timelineBounds() - if (bounds.duration <= 0) return - - // Don't save if we are at the very beginning (unless explicit reset) - // This prevents accidental progress saves when a video just started loading - if (video.currentTime < 1) return - - const episodeNumber = Number.parseInt(currentEpisode, 10) - if (!Number.isInteger(episodeNumber) || episodeNumber <= 0) return - - const safeTime = displayTimeFromAbsolute(video.currentTime) - if (lastSavedProgress.episode === currentEpisode && Math.abs(lastSavedProgress.seconds - safeTime) < 5) { - return - } - - const payload = buildWatchProgressPayload(episodeNumber, safeTime) - - try { - const response = await fetch(watchProgressURL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: payload, - }) - if (!response.ok) return - lastSavedProgress = { - episode: currentEpisode, - seconds: safeTime, - } - } catch { - return - } - } - - const scheduleProgressSave = (): void => { - if (progressSaveTimer !== undefined) return - progressSaveTimer = window.setTimeout(() => { - progressSaveTimer = undefined - saveProgress() - }, 30000) - } - - const parseEpisodeFromWatchHref = (href: string): number | null => { - if (!Number.isInteger(malID) || malID <= 0) return null - - try { - const targetURL = new URL(href, window.location.origin) - const pathParts = targetURL.pathname.split('/').filter(Boolean) - if (pathParts.length < 3 || pathParts[0] !== 'watch') return null - - const targetMalID = Number.parseInt(pathParts[1] || '', 10) - const targetEpisode = Number.parseInt(pathParts[2] || '', 10) - if (!Number.isInteger(targetMalID) || targetMalID !== malID) return null - if (!Number.isInteger(targetEpisode) || targetEpisode <= 0) return null - - return targetEpisode - } catch { - return null - } - } - - const markEpisodeTransition = (episodeNumber: number): void => { - if (!Number.isInteger(malID) || malID <= 0) return - if (!Number.isInteger(episodeNumber) || episodeNumber <= 0) return - - // Explicitly clear progress save timer when transitioning - if (progressSaveTimer !== undefined) { - window.clearTimeout(progressSaveTimer) - progressSaveTimer = undefined - } - - transitionEpisode = episodeNumber - const payload = buildWatchProgressPayload(episodeNumber, 0) - - if (sendWatchProgressBeacon(payload)) { - return - } - - fetch(watchProgressURL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - keepalive: true, - body: payload, - }).catch(() => { }) - } - - const parseVttTime = (raw: string): number => { - const parts = raw.trim().split(':') - if (parts.length < 2) { - return 0 - } - const secPart = parts.pop() || '0' - const minPart = parts.pop() || '0' - const hourPart = parts.pop() || '0' - const seconds = Number(secPart.replace(',', '.')) - const minutes = Number(minPart) - const hours = Number(hourPart) - return (hours * 3600) + (minutes * 60) + seconds - } - - const parseVttCue = (timeLine: string, lines: string[], startIndex: number): { start: number; end: number; text: string } | null => { - if (!timeLine.includes('-->')) { - return null - } - - const [startRaw, endRaw] = timeLine.split('-->') - const start = parseVttTime(startRaw) - const end = parseVttTime(endRaw) - - const payload: string[] = [] - let i = startIndex + 1 - while (i < lines.length && lines[i].trim() !== '') { - payload.push(lines[i]) - i += 1 - } - - const textContent = payload.join('\n').replace(/<[^>]+>/g, '').trim() - if (!textContent) { - return null - } - - return { start, end, text: textContent } - } - - const parseVtt = (text: string): Array<{ start: number; end: number; text: string }> => { - const lines = text.replace(/\r/g, '').split('\n') - const cues: Array<{ start: number; end: number; text: string }> = [] - let i = 0 - - while (i < lines.length) { - const line = lines[i].trim() - if (!line) { - i += 1 - continue - } - - if (i + 1 < lines.length && !line.includes('-->') && lines[i + 1].includes('-->')) { - const cue = parseVttCue(lines[i + 1].trim(), lines, i + 1) - if (cue) { - cues.push(cue) - } - i += 2 - continue - } - - const cue = parseVttCue(line, lines, i) - if (cue) { - cues.push(cue) - } - i += 1 - } - - return cues - } - - const loadSubtitle = async (url: string): Promise> => { - try { - const response = await fetch(url) - if (!response.ok) return [] - const text = await response.text() - return parseVtt(text) - } catch { - return [] - } - } - - const hideSubtitleText = (): void => { - if (!subtitleText) return - subtitleText.textContent = '' - subtitleText.classList.remove('block') - subtitleText.classList.add('hidden') - } - - const findActiveCue = (time: number): { start: number; end: number; text: string } | undefined => { - return activeSubtitles.find((item) => { - return time >= item.start && time <= item.end - }) - } - - const updateSubtitleRender = (currentTime: number): void => { - if (!subtitleText) { - return - } - if (!activeSubtitles.length) { - hideSubtitleText() - return - } - - const cue = findActiveCue(currentTime) - if (!cue) { - hideSubtitleText() - return - } - - subtitleText.textContent = cue.text - subtitleText.classList.remove('hidden') - subtitleText.classList.add('block') - } - - const updateSubtitleOptions = (): void => { - if (!subtitleSelect) { - return - } - currentSubtitleTracks = subtitlesForMode(currentMode) - clearElement(subtitleSelect) - const none = document.createElement('option') - none.value = 'none' - none.textContent = 'Off' - subtitleSelect.appendChild(none) - subtitleSelect.value = 'none' - currentSubtitleTracks.forEach((track, idx) => { - const option = document.createElement('option') - option.value = String(idx) - option.textContent = track.label - subtitleSelect.appendChild(option) - }) - const wrapper = subtitleSelect.parentElement - if (wrapper) { - wrapper.classList.toggle('hidden', currentSubtitleTracks.length === 0) - } - activeSubtitles = [] - hideSubtitleText() - } - - const updateQualityOptions = (): void => { - if (!qualitySelect) { - return - } - const modeSource = modeSources[currentMode] - const qualities = modeSource?.qualities || [] - - clearElement(qualitySelect) - const best = document.createElement('option') - best.value = 'best' - best.textContent = 'Auto / Best' - qualitySelect.appendChild(best) - - qualities.forEach(q => { - const option = document.createElement('option') - option.value = q - option.textContent = q - qualitySelect.appendChild(option) - }) - - const preferred = getPreferredQuality() - if (qualities.includes(preferred)) { - qualitySelect.value = preferred - } else { - qualitySelect.value = 'best' - } - - const wrapper = qualitySelect.parentElement - if (wrapper) { - wrapper.classList.toggle('hidden', qualities.length === 0) - } - } - - const modeDub = container.querySelector('[data-mode-dub]') as HTMLButtonElement - const modeSub = container.querySelector('[data-mode-sub]') as HTMLButtonElement - - const updateModeButtons = (mode: string): void => { - if (modeDub) { - modeDub.disabled = !availableModes.includes('dub') - modeDub.classList.toggle('text-white', mode !== 'dub') - modeDub.classList.toggle('text-accent', mode === 'dub') - modeDub.classList.toggle('opacity-50', !availableModes.includes('dub')) - modeDub.classList.toggle('cursor-not-allowed', !availableModes.includes('dub')) - } - if (modeSub) { - modeSub.disabled = !availableModes.includes('sub') - modeSub.classList.toggle('text-white', mode !== 'sub') - modeSub.classList.toggle('text-accent', mode === 'sub') - modeSub.classList.toggle('opacity-50', !availableModes.includes('sub')) - modeSub.classList.toggle('cursor-not-allowed', !availableModes.includes('sub')) - } - } - - const switchMode = (mode: string): void => { - if (!availableModes.includes(mode) || mode === currentMode) return - const nextURL = streamUrlForMode(mode, qualitySelect?.value) - if (!nextURL) return - const wasPlaying = video.ended || !video.paused - const previousTime = displayTimeFromAbsolute(video.currentTime) - currentMode = mode - localStorage.setItem('player-audio-mode', mode) - hidePreviewPopover() - video.src = nextURL - video.load() - pendingSeekTime = previousTime - if (wasPlaying) video.play().catch(() => { }) - updateSubtitleOptions() - updateQualityOptions() - updateModeButtons(currentMode) - } - - const updatePlayPauseIcons = (isPlaying: boolean): void => { - if (iconPlay && iconPause) { - if (isPlaying) { - iconPlay.classList.add('hidden') - iconPause.classList.remove('hidden') - } else { - iconPlay.classList.remove('hidden') - iconPause.classList.add('hidden') - } - } - } - - const updateMuteIcons = (isMuted: boolean): void => { - if (iconVolume && iconMuted) { - if (isMuted) { - iconVolume.classList.add('hidden') - iconMuted.classList.remove('hidden') - } else { - iconVolume.classList.remove('hidden') - iconMuted.classList.add('hidden') - } - } - } - - const syncVolumeUI = (): void => { - const volumeValue = video.muted ? 0 : Math.round(video.volume * 100) - if (volumeRange) { - volumeRange.value = String(volumeValue) - volumeRange.style.setProperty('--volume-percent', `${volumeValue}%`) - } - if (volumeUnderline) { - volumeUnderline.style.height = `${volumeValue}%` - } - if (!video.muted && video.volume > 0) { - lastKnownVolume = video.volume - } - updateMuteIcons(video.muted || video.volume === 0) - } - - const toggleDub = (): void => { - if (availableModes.includes('dub')) { - switchMode('dub') - } - showControls() - } - - const toggleSub = (): void => { - if (availableModes.includes('sub')) { - switchMode('sub') - } - showControls() - } - - const showControls = (): void => { - container.classList.add('show-controls') - window.clearTimeout(playerControlsTimeout) - playerControlsTimeout = window.setTimeout(() => { - if (!isScrubbing && !video.paused) { - container.classList.remove('show-controls') - } - }, 2000) - } - - const togglePlayPause = (): void => { - if (video.paused) { - video.play() - return - } - - video.pause() - } - - const toggleFullscreen = (): void => { - if (document.fullscreenElement) { - if (document.exitFullscreen) document.exitFullscreen() - return - } - - if ('requestFullscreen' in container && typeof container.requestFullscreen === 'function') { - container.requestFullscreen() - } - } - - const switchQuality = (quality: string): void => { - const nextURL = streamUrlForMode(currentMode, quality) - if (!nextURL) return - const wasPlaying = video.ended || !video.paused - const previousTime = displayTimeFromAbsolute(video.currentTime) - hidePreviewPopover() - video.src = nextURL - video.load() - pendingSeekTime = previousTime - if (wasPlaying) video.play().catch(() => { }) - } - - // Initialize - updateSubtitleOptions() - updateQualityOptions() - updateModeButtons(currentMode) - - const startingURL = streamUrlForMode(currentMode, getPreferredQuality()) - if (startingURL) { - video.src = startingURL - } else if (initialStreamToken) { - video.src = `${streamURL}?mode=${encodeURIComponent(currentMode)}&token=${encodeURIComponent(initialStreamToken)}` - } - - if (video) { - video.addEventListener('loadedmetadata', () => { - if (loading) loading.style.display = 'none' - resolveActiveSegments() - renderSegments() - - const startTimeSeconds = Number.parseFloat(container.getAttribute('data-start-time-seconds') || '0') - const currentDisplayTime = displayTimeFromAbsolute(video.currentTime) - if (Number.isFinite(startTimeSeconds) && startTimeSeconds > 0 && currentDisplayTime <= 0.5) { - const nextStart = absoluteTimeFromDisplay(startTimeSeconds) - if (nextStart > 0) { - try { - video.currentTime = nextStart - } catch { } - } - } - if (pendingSeekTime !== null && Number.isFinite(pendingSeekTime)) { - try { - video.currentTime = absoluteTimeFromDisplay(pendingSeekTime) - } catch { } - pendingSeekTime = null - } - if (shouldAutoPlay) { - video.play().catch(() => { }) - } - updateTimeline(video.currentTime) - updateSkipButton(video.currentTime) - }) - - video.addEventListener('waiting', () => { - if (loading) loading.style.display = 'flex' - }) - - video.addEventListener('playing', () => { - if (loading) loading.style.display = 'none' - }) - - video.addEventListener('progress', () => { - updateTimeline(video.currentTime) - }) - - video.addEventListener('timeupdate', () => { - updateTimeline(video.currentTime) - updateSubtitleRender(displayTimeFromAbsolute(video.currentTime)) - updateSkipButton(video.currentTime) - scheduleProgressSave() - }) - - video.addEventListener('play', () => { - updatePlayPauseIcons(true) - showControls() - }) - - video.addEventListener('pause', () => { - updatePlayPauseIcons(false) - showControls() - window.clearTimeout(progressSaveTimer) - progressSaveTimer = undefined - saveProgress() - }) - - video.addEventListener('volumechange', () => { - syncVolumeUI() - }) - - video.addEventListener('ended', () => { - goToNextEpisode() - }) - } - - const goToNextEpisode = async (): Promise => { - const currentEpNum = Number.parseInt(currentEpisode, 10) - if (Number.isNaN(currentEpNum)) return - - if (Number.isInteger(totalEpisodes) && totalEpisodes > 0 && currentEpNum >= totalEpisodes) { - completeAnime(currentEpNum) - return - } - - if (!isAutoplayEnabled()) return - - const nextEpisode = currentEpNum + 1 - markEpisodeTransition(nextEpisode) - - try { - const response = await fetch(`/api/watch/episode/${malID}/${nextEpisode}`) - if (!response.ok) { - sessionStorage.setItem('mal:autoplay-next', 'true') - const newUrl = new URL(window.location.href) - newUrl.searchParams.set('ep', String(nextEpisode)) - window.location.href = newUrl.toString() - return - } - - const data = await response.json() - - modeSources = data.mode_sources || {} - availableModes = data.available_modes || [] - - if (availableModes.includes(currentMode) && modeSources[currentMode]?.token) { - } else { - const fallbackMode = availableModes.find(m => modeSources[m]?.token) - if (fallbackMode) { - currentMode = fallbackMode - } else { - sessionStorage.setItem('mal:autoplay-next', 'true') - const newUrl = new URL(window.location.href) - newUrl.searchParams.set('ep', String(nextEpisode)) - window.location.href = newUrl.toString() - return - } - } - - const streamUrl = streamUrlForMode(currentMode) - const wasPlaying = video.ended || !video.paused - video.src = streamUrl - video.load() - if (wasPlaying) video.play().catch(() => { }) - - currentEpisode = String(nextEpisode) - pendingSeekTime = null - completionSent = false - completionAttempts = 0 - activeSubtitles = [] - hideSubtitleText() - updateSubtitleOptions() - updateQualityOptions() - updateModeButtons(currentMode) - updateVideoOverlay(currentEpisode, data.episode_title || '') - - // Update skip segments for new episode - if (data.segments && Array.isArray(data.segments)) { - parsedSegments = data.segments.map((segment: SkipSegment) => { - const start = Number(segment.start || 0) - const end = Number(segment.end || 0) - if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) { - return { ...segment, start: 0, end: 0 } - } - return { ...segment, start, end } - }).filter((s: SkipSegment) => s.start > 0 || s.end > 0) - } - - const episodeList = document.querySelector('[data-episode-list]') - if (episodeList) { - episodeList.querySelectorAll('[data-episode-id]').forEach((el) => { - el.classList.remove('bg-accent/20') - }) - const newEpEl = episodeList.querySelector(`[data-episode-id="${nextEpisode}"]`) - if (newEpEl) { - newEpEl.classList.add('bg-accent/20') - } - } - - // Update episode grid highlight for >100 episodes - const episodeGrid = document.querySelector('[data-episode-grid]') - if (episodeGrid) { - episodeGrid.querySelectorAll('[data-episode-id]').forEach((el) => { - el.classList.remove('bg-accent/20', 'ring-2', 'ring-accent', 'text-accent') - }) - const rangeIndex = Math.floor((nextEpisode - 1) / 100) - switchEpisodeRange(rangeIndex) - const gridEpEl = episodeGrid.querySelector(`[data-episode-id="${nextEpisode}"]`) as HTMLElement | null - if (gridEpEl) { - gridEpEl.classList.add('bg-accent/20', 'ring-2', 'ring-accent', 'text-accent') - } - } - - const newUrl = new URL(window.location.href) - newUrl.searchParams.set('ep', String(nextEpisode)) - history.pushState(null, '', newUrl.toString()) - - // Reset transitionEpisode so beforeunload handler saves progress - transitionEpisode = null - } catch { - sessionStorage.setItem('mal:autoplay-next', 'true') - const newUrl = new URL(window.location.href) - newUrl.searchParams.set('ep', String(nextEpisode)) - window.location.href = newUrl.toString() - } - } - - - - const completeAnime = async (episodeNumber: number): Promise => { - if (completionSent) return - if (!Number.isInteger(malID) || malID <= 0) return - if (!Number.isInteger(episodeNumber) || episodeNumber <= 0) return - - completionSent = true - - try { - const response = await fetch('/api/watch-complete', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - keepalive: true, - body: JSON.stringify({ - mal_id: malID, - episode: episodeNumber, - }), - }) - - if (!response.ok) { - completionSent = false - if (completionAttempts < 2) { - completionAttempts += 1 - window.setTimeout(() => { - completeAnime(episodeNumber) - }, 1000) - } - return - } - - const dropdownTrigger = document.querySelector('[data-dropdown-trigger]') as HTMLButtonElement | null - if (dropdownTrigger) { - dropdownTrigger.textContent = 'Completed ' - const caret = document.createElement('span') - caret.className = 'text-xs' - caret.textContent = '▾' - dropdownTrigger.appendChild(caret) - } - - const watchStatusDropdown = document.getElementById('watch-status-dropdown') - if (watchStatusDropdown) { - const payload = { - anime_id: String(malID), - anime_title: animeTitle, - anime_title_english: animeTitleEnglish, - anime_title_japanese: animeTitleJapanese, - anime_image: animeImage, - status: 'completed', - airing: animeAiring, - } - - fetch('/api/watchlist', { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'HX-Request': 'true', - }, - body: `anime_id=${encodeURIComponent(payload.anime_id)}&anime_title=${encodeURIComponent(payload.anime_title)}&anime_title_english=${encodeURIComponent(payload.anime_title_english)}&anime_title_japanese=${encodeURIComponent(payload.anime_title_japanese)}&anime_image=${encodeURIComponent(payload.anime_image)}&status=${encodeURIComponent(payload.status)}&airing=${encodeURIComponent(String(payload.airing))}`, - credentials: 'same-origin', - }).then(async (res) => { - if (!res.ok) return - if (!watchStatusDropdown || !watchStatusDropdown.isConnected) return - const html = await res.text() - const wrapper = document.createElement('span') - wrapper.id = 'watch-status-dropdown' - wrapper.innerHTML = DOMPurify.sanitize(html) - watchStatusDropdown.replaceWith(wrapper) - }).catch(() => { }) - } - } catch { - completionSent = false - if (completionAttempts < 2) { - completionAttempts += 1 - window.setTimeout(() => { - completeAnime(episodeNumber) - }, 1000) - } - return - } - } - - playPause?.addEventListener('click', () => { - togglePlayPause() - showControls() - }) - - video.addEventListener('click', () => { - togglePlayPause() - showControls() - }) - - muteBtn?.addEventListener('click', () => { - if (video.muted || video.volume === 0) { - const restoredVolume = lastKnownVolume > 0 ? lastKnownVolume : 1 - video.muted = false - video.volume = restoredVolume - } else { - lastKnownVolume = video.volume > 0 ? video.volume : lastKnownVolume - video.muted = true - } - showControls() - }) - - volumeRange?.addEventListener('input', () => { - const sliderValue = Number(volumeRange.value) - if (!Number.isFinite(sliderValue)) return - const nextVolume = Math.max(0, Math.min(1, sliderValue / 100)) - video.volume = nextVolume - video.muted = nextVolume === 0 - if (nextVolume > 0) { - lastKnownVolume = nextVolume - } - showControls() - }) - - volumeRange?.addEventListener('pointerdown', () => { - volumePanel?.classList.add('is-dragging') - }) - - window.addEventListener('pointerup', () => { - volumePanel?.classList.remove('is-dragging') - }) - - backwardBtn?.addEventListener('click', () => seekBy(-10)) - forwardBtn?.addEventListener('click', () => seekBy(10)) - - fullscreenBtn?.addEventListener('click', () => { - toggleFullscreen() - showControls() - }) - - skipSegmentBtn?.addEventListener('click', () => { - if (!activeSkipSegment) return - - const target = absoluteTimeFromDisplay(activeSkipSegment.end + 0.01) - video.currentTime = target - - updateTimeline(video.currentTime) - updateSkipButton(video.currentTime) - showControls() - }) - - modeDub?.addEventListener('click', toggleDub) - modeSub?.addEventListener('click', toggleSub) - - autoplayBtn?.addEventListener('change', (e) => { - const isChecked = (e.target as HTMLInputElement).checked - localStorage.setItem('mal:autoplay-enabled', isChecked ? 'true' : 'false') - showControls() - }) - - document.addEventListener('change', (e) => { - const target = e.target as HTMLElement - if (target.hasAttribute('data-autoskip')) { - const isChecked = (target as HTMLInputElement).checked - localStorage.setItem('mal:autoskip-enabled', isChecked ? 'true' : 'false') - showControls() - } - }) - - subtitleSelect?.addEventListener('change', async () => { - const selected = subtitleSelect.value - if (selected === 'none') { - activeSubtitles = [] - hideSubtitleText() - return - } - const idx = Number(selected) - const track = currentSubtitleTracks[idx] - if (!track) { - activeSubtitles = [] - return - } - activeSubtitles = await loadSubtitle(track.url) - }) - - qualitySelect?.addEventListener('change', () => { - const selected = qualitySelect.value - localStorage.setItem('mal:preferred-quality', selected) - switchQuality(selected) - showControls() - }) - - progressWrap?.addEventListener('mousedown', (event) => { - isScrubbing = true - const rect = progressWrap.getBoundingClientRect() - const ratio = Math.max(0, Math.min(1, ((event as MouseEvent).clientX - rect.left) / rect.width)) - - video.currentTime = absoluteTimeFromRatio(ratio) - - updateTimeline(video.currentTime) - updateSkipButton(video.currentTime) - showControls() - }) - - progressWrap?.addEventListener('mousemove', (event) => { - const rect = progressWrap.getBoundingClientRect() - const ratio = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)) - updatePreviewUI(ratio) - }) - - progressWrap?.addEventListener('mouseleave', () => { - hidePreviewPopover() - }) - - container.addEventListener('click', (event: Event) => { - const target = event.target - if (!(target instanceof Node)) return - - const targetElement = target instanceof Element ? target : target.parentElement - if (!targetElement) return - - const anchor = targetElement.closest('a[href]') - if (!(anchor instanceof HTMLAnchorElement)) return - - const nextEpisode = parseEpisodeFromWatchHref(anchor.href) - if (nextEpisode === null) return - markEpisodeTransition(nextEpisode) - }) - - window.addEventListener('mouseup', () => { - isScrubbing = false - saveProgress() - }) - - window.addEventListener('mousemove', (event) => { - if (!isScrubbing || !progressWrap) return - const rect = progressWrap.getBoundingClientRect() - const ratio = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)) - - video.currentTime = absoluteTimeFromRatio(ratio) - - updateTimeline(video.currentTime) - updateSkipButton(video.currentTime) - }) - - document.addEventListener('fullscreenchange', () => { - isFullscreen = !!document.fullscreenElement - container.classList.toggle('fullscreen', isFullscreen) - if (isFullscreen) showControls() - }) - - container.addEventListener('mousemove', showControls) - - document.addEventListener('keydown', (event) => { - const target = event.target as HTMLElement - if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return - - // Key codes - const code = event.code - const key = event.key - - // Space or K: Toggle Play/Pause - if (code === 'Space' || code === 'KeyK') { - event.preventDefault() - togglePlayPause() - showControls() - } - - // ArrowLeft or J: Seek Backward 10s - if (code === 'ArrowLeft' || code === 'KeyJ') { - event.preventDefault() - seekBy(-10) - } - - // ArrowRight or L: Seek Forward 10s - if (code === 'ArrowRight' || code === 'KeyL') { - event.preventDefault() - seekBy(10) - } - - // ArrowUp: Volume Up - if (code === 'ArrowUp') { - event.preventDefault() - const nextVolume = Math.min(1, video.volume + 0.05) - video.volume = nextVolume - video.muted = false - showControls() - } - - // ArrowDown: Volume Down - if (code === 'ArrowDown') { - event.preventDefault() - const nextVolume = Math.max(0, video.volume - 0.05) - video.volume = nextVolume - video.muted = nextVolume === 0 - showControls() - } - - // KeyM: Toggle Mute - if (code === 'KeyM') { - event.preventDefault() - if (video.muted || video.volume === 0) { - const restoredVolume = lastKnownVolume > 0 ? lastKnownVolume : 1 - video.muted = false - video.volume = restoredVolume - } else { - lastKnownVolume = video.volume > 0 ? video.volume : lastKnownVolume - video.muted = true - } - showControls() - } - - // KeyF: Toggle Fullscreen - if (code === 'KeyF') { - event.preventDefault() - toggleFullscreen() - showControls() - } - - // Numbers 0-9: Jump to percentage (0% to 90%) - if (/^\d$/.test(key)) { - const bounds = timelineBounds() - if (bounds.duration > 0) { - event.preventDefault() - const percent = parseInt(key, 10) * 10 - video.currentTime = absoluteTimeFromRatio(percent / 100) - showControls() - } - } - }) - - window.addEventListener('beforeunload', () => { - if (transitionEpisode !== null) return - if (completionSent) return - if (!Number.isInteger(malID) || malID <= 0) return - - const bounds = timelineBounds() - if (bounds.duration <= 0) return - - const episodeNumber = Number.parseInt(currentEpisode, 10) - if (!Number.isInteger(episodeNumber) || episodeNumber <= 0) return - - const safeTime = displayTimeFromAbsolute(video.currentTime) - const payload = buildWatchProgressPayload(episodeNumber, safeTime) - sendWatchProgressBeacon(payload) - }) - - updatePlayPauseIcons(false) - syncVolumeUI() - updateSkipButton(0) - updateAutoplayButton() - updateAutoSkipButton() - showControls() - - // Episode search and range dropdown logic - const episodeSearchInput = document.querySelector('[data-episode-search]') as HTMLInputElement | null - const episodeDropdown = document.querySelector('[data-episode-dropdown]') as HTMLElement | null - const episodeGrid = document.querySelector('[data-episode-grid]') as HTMLElement | null - const episodeList = document.querySelector('[data-episode-list]') as HTMLElement | null - let searchDebounceTimer: number | undefined - - const updateEpisodeHighlight = (episodeNum: number): void => { - if (episodeGrid) { - episodeGrid.querySelectorAll('[data-episode-id]').forEach((el) => { - el.classList.remove('ring-2', 'ring-accent', 'text-accent') - const isCurrent = el.classList.contains('bg-accent/20') - if (!isCurrent) { - el.classList.remove('bg-accent/20') - } - }) - const targetEp = episodeGrid.querySelector(`[data-episode-id="${episodeNum}"]`) as HTMLElement | null - if (targetEp) { - targetEp.classList.add('ring-2', 'ring-accent') - targetEp.scrollIntoView({ behavior: 'smooth', block: 'center' }) - } - } - if (episodeList) { - episodeList.querySelectorAll('[data-episode-id]').forEach((el) => { - el.classList.remove('ring-2', 'ring-accent') - }) - const targetEp = episodeList.querySelector(`[data-episode-id="${episodeNum}"]`) as HTMLElement | null - if (targetEp) { - targetEp.classList.add('ring-2', 'ring-accent') - targetEp.scrollIntoView({ behavior: 'smooth', block: 'center' }) - } - } - } - - const switchEpisodeRange = (rangeIndex: number): void => { - if (!episodeGrid || !episodeDropdown) return - const rangeBtns = episodeDropdown.querySelectorAll('.episode-range-btn') as NodeListOf - const allRanges = Array.from(rangeBtns) - const targetRange = allRanges[rangeIndex] - if (!targetRange) return - const rangeStart = parseInt(targetRange.getAttribute('data-range-start') || '1', 10) - const rangeEnd = parseInt(targetRange.getAttribute('data-range-end') || '100', 10) - const dropdownLabel = episodeDropdown.querySelector('[data-dropdown-label]') as HTMLElement | null - if (dropdownLabel) { - const startStr = rangeStart.toString().padStart(2, '0') - const endStr = rangeEnd.toString().padStart(2, '0') - dropdownLabel.textContent = `${startStr}-${endStr}` - } - episodeGrid.querySelectorAll('[data-episode-id]').forEach((el) => { - const epNum = parseInt(el.getAttribute('data-episode-id') || '0', 10) - if (epNum >= rangeStart && epNum <= rangeEnd) { - el.classList.remove('hidden') - } else { - el.classList.add('hidden') - } - }) - const dropdown = episodeDropdown as unknown as { close: () => void } - if (dropdown.close) dropdown.close() - } - - if (episodeSearchInput) { - episodeSearchInput.addEventListener('input', () => { - clearTimeout(searchDebounceTimer) - searchDebounceTimer = window.setTimeout(() => { - const inputVal = episodeSearchInput.value.replace(/\D/g, '') - if (inputVal === '') { - const currentEpNum = parseInt(currentEpisode, 10) - if (episodeGrid) { - switchEpisodeRange(Math.floor((currentEpNum - 1) / 100)) - episodeGrid.querySelectorAll('[data-episode-id]').forEach((el) => { - el.classList.remove('ring-2', 'ring-accent') - }) - } - if (episodeList) { - episodeList.querySelectorAll('[data-episode-id]').forEach((el) => { - el.classList.remove('ring-2', 'ring-accent') - }) - } - updateEpisodeHighlight(currentEpNum) - return - } - const episodeNum = parseInt(inputVal, 10) - if (Number.isNaN(episodeNum) || episodeNum <= 0) return - const maxEp = totalEpisodes > 0 ? totalEpisodes : 500 - const clampedEp = Math.min(episodeNum, maxEp) - if (episodeGrid) { - const rangeIndex = Math.floor((clampedEp - 1) / 100) - switchEpisodeRange(rangeIndex) - episodeSearchInput.value = clampedEp.toString() - } - updateEpisodeHighlight(clampedEp) - }, 300) - }) - } - - if (episodeDropdown) { - const rangeBtns = episodeDropdown.querySelectorAll('.episode-range-btn') - rangeBtns.forEach((btn) => { - btn.addEventListener('click', () => { - const rangeIndex = parseInt(btn.getAttribute('data-range-index') || '0', 10) - switchEpisodeRange(rangeIndex) - }) - }) - } - - // Initialize grid to show the range containing the current episode - if (episodeGrid && totalEpisodes > 100) { - const currentEpNum = parseInt(currentEpisode, 10) - const initialRangeIndex = !Number.isNaN(currentEpNum) && currentEpNum > 0 - ? Math.floor((currentEpNum - 1) / 100) - : 0 - switchEpisodeRange(initialRangeIndex) - } - - playerInitialized = true - - // Fetch thumbnails and metadata in the background - fetch(`/api/watch/thumbnails/${malID}`) - .then((res) => res.json()) - .then((data: Array<{ mal_id: number, url: string, title?: string }>) => { - const episodeList = document.querySelector('[data-episode-list]') - if (!episodeList) return - - data.forEach((item) => { - const epCard = episodeList.querySelector(`[data-episode-id="${item.mal_id}"]`) - if (!epCard) return - - if (item.url) { - const imgContainer = epCard.querySelector('.relative.aspect-video') - if (imgContainer) { - let img = imgContainer.querySelector('img') - if (!img) { - img = document.createElement('img') - img.className = 'h-full w-full object-cover transition-transform group-hover:scale-105' - img.loading = 'lazy' - const placeholder = imgContainer.querySelector('.flex.h-full.w-full.items-center.justify-center') - if (placeholder) placeholder.remove() - imgContainer.prepend(img) - } - img.src = item.url - img.alt = item.title || `Episode ${item.mal_id}` - } - } - - if (item.title) { - const titleSpan = epCard.querySelector('[data-episode-title]') - if (titleSpan) titleSpan.textContent = item.title - } - }) - }) - .catch((err) => console.error('Failed to fetch thumbnails:', err)) -} - -document.addEventListener('DOMContentLoaded', initPlayer) -document.body.addEventListener('htmx:afterSwap', (e: Event) => { - const target = (e as CustomEvent).detail?.target as HTMLElement | null - if (!target) return - if (target.querySelector('[data-video-player]')) { - initPlayer() - } -})