diff --git a/internal/templates/watch.templ b/internal/templates/watch.templ index 704c09a..9d82925 100644 --- a/internal/templates/watch.templ +++ b/internal/templates/watch.templ @@ -194,6 +194,8 @@ templ VideoPlayer(data WatchPageData) { class="flex flex-col gap-4 w-full" data-video-player data-stream-url="/watch/proxy/stream" + data-preview-map-url="/watch/proxy/preview-map" + data-current-episode={ data.CurrentEpisode } data-initial-mode={ data.InitialMode } data-available-modes={ toJSON(data.AvailableModes) } data-mode-sources={ toJSON(data.ModeSources) } @@ -216,6 +218,12 @@ templ VideoPlayer(data WatchPageData) {
+
diff --git a/static/player.ts b/static/player.ts index 25c4bf3..81aab5b 100644 --- a/static/player.ts +++ b/static/player.ts @@ -18,6 +18,73 @@ interface SkipSegment { end: number } +interface PreviewCue { + start: number + end: number + sprite: string + x: number + y: number + width: number + height: number +} + +interface PreviewMap { + width: number + height: number + columns: number + rows: number + interval: number + duration: number + cues: PreviewCue[] +} + +interface PreviewPayload { + spriteURL: string + map: PreviewMap +} + +interface PreviewMapResponse { + sprite_url: string + map: PreviewMap +} + +const isObjectRecord = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null +} + +const isPreviewCue = (value: unknown): value is PreviewCue => { + if (!isObjectRecord(value)) return false + return Number.isFinite(value.start) + && Number.isFinite(value.end) + && typeof value.sprite === 'string' + && Number.isFinite(value.x) + && Number.isFinite(value.y) + && Number.isFinite(value.width) + && Number.isFinite(value.height) +} + +const isPreviewMap = (value: unknown): value is PreviewMap => { + if (!isObjectRecord(value)) return false + if (!Array.isArray(value.cues)) return false + if (!value.cues.every((cue: unknown) => isPreviewCue(cue))) return false + return Number.isFinite(value.width) + && Number.isFinite(value.height) + && Number.isFinite(value.columns) + && Number.isFinite(value.rows) + && Number.isFinite(value.interval) + && Number.isFinite(value.duration) +} + +const parsePreviewMapResponse = (value: unknown): PreviewMapResponse | null => { + if (!isObjectRecord(value)) return null + if (typeof value.sprite_url !== 'string') return null + if (!isPreviewMap(value.map)) return null + return { + sprite_url: value.sprite_url, + map: value.map, + } +} + const initPlayer = (): void => { const container = document.querySelector('[data-video-player]') if (!container) return @@ -46,10 +113,18 @@ const initPlayer = (): void => { const subtitleText = container.querySelector('[data-subtitle-text]') as HTMLElement const streamURL = container.getAttribute('data-stream-url') || '/watch/proxy/stream' + const previewMapURL = container.getAttribute('data-preview-map-url') || '/watch/proxy/preview-map' + const currentEpisode = container.getAttribute('data-current-episode') || '1' const modeSources = JSON.parse(container.getAttribute('data-mode-sources') || '{}') const availableModes = JSON.parse(container.getAttribute('data-available-modes') || '[]') const initialMode = container.getAttribute('data-initial-mode') || 'dub' const segments = JSON.parse(container.getAttribute('data-segments') || '[]') + const malIDFromPath = (() => { + const pathParts = window.location.pathname.split('/').filter(Boolean) + if (pathParts.length < 2) return '' + if (pathParts[0] !== 'watch') return '' + return pathParts[1] || '' + })() const maxIntroStartSeconds = 180 const minOutroStartRatio = 0.5 @@ -80,6 +155,12 @@ const initPlayer = (): void => { let pendingSeekTime: number | null = null let activeSkipSegment: { type: string, start: number, end: number } | null = null let activeSegments: Array<{ type: string, start: number, end: number }> = [] + let previewState: { [key: string]: PreviewPayload } = {} + let previewRequestToken = 0 + + const previewPopover = container.querySelector('[data-preview-popover]') as HTMLElement + const previewFrame = container.querySelector('[data-preview-frame]') as HTMLElement + const previewTime = container.querySelector('[data-preview-time]') as HTMLElement const streamUrlForMode = (mode: string): string => { const modeParam = encodeURIComponent(mode) @@ -205,6 +286,131 @@ const initPlayer = (): void => { showControls() } + const streamSourceForMode = (mode: string): { url: string, referer: string } | null => { + const modeSource = modeSources[mode] + if (!modeSource) return null + const sourceURL = String(modeSource.url || '').trim() + if (sourceURL === '') return null + return { + url: sourceURL, + referer: String(modeSource.referer || ''), + } + } + + const previewCacheKey = (mode: string, sourceURL: string, sourceReferer: string): string => { + const normalizedReferer = sourceReferer.trim() + return `${mode}|${sourceURL}|${normalizedReferer}` + } + + 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 cueForTime = (map: PreviewMap, time: number): PreviewCue | null => { + if (!map.cues.length) return null + const match = map.cues.find((cue: PreviewCue) => time >= cue.start && time < cue.end) + if (match) return match + const first = map.cues[0] + const last = map.cues[map.cues.length - 1] + if (time <= first.start) return first + if (time >= last.end) return last + return null + } + + const updatePreviewUI = (ratio: number): void => { + if (!progressWrap || !previewPopover || !previewFrame || !previewTime) return + if (!video.duration || !Number.isFinite(video.duration)) { + hidePreviewPopover() + return + } + + const targetTime = Math.max(0, Math.min(video.duration, ratio * video.duration)) + previewTime.textContent = formatTime(targetTime) + + const source = streamSourceForMode(currentMode) + if (!source || malIDFromPath === '') { + hidePreviewPopover() + return + } + + const cacheKey = previewCacheKey(currentMode, source.url, source.referer) + const cached = previewState[cacheKey] + if (!cached || !cached.map || !cached.spriteURL) { + hidePreviewPopover() + return + } + + const cue = cueForTime(cached.map, targetTime) + if (!cue) { + hidePreviewPopover() + return + } + + previewFrame.style.width = `${cue.width}px` + previewFrame.style.height = `${cue.height}px` + previewFrame.style.backgroundImage = `url('${cached.spriteURL}')` + previewFrame.style.backgroundRepeat = 'no-repeat' + previewFrame.style.backgroundPosition = `-${cue.x}px -${cue.y}px` + previewFrame.style.backgroundSize = `${cached.map.columns * cue.width}px ${cached.map.rows * cue.height}px` + + const barWidth = progressWrap.clientWidth + const popoverOffset = ratio * barWidth + const halfWidth = cue.width / 2 + const clampedOffset = Math.max(halfWidth, Math.min(barWidth - halfWidth, popoverOffset)) + previewPopover.style.left = `${clampedOffset}px` + + showPreviewPopover() + } + + const loadPreviewMap = async (): Promise => { + if (!video.duration || !Number.isFinite(video.duration)) return + const source = streamSourceForMode(currentMode) + if (!source || malIDFromPath === '') return + + const cacheKey = previewCacheKey(currentMode, source.url, source.referer) + if (previewState[cacheKey]) return + + const token = previewRequestToken + 1 + previewRequestToken = token + + const query = new URLSearchParams({ + mal_id: malIDFromPath, + ep: currentEpisode, + mode: currentMode, + u: source.url, + r: source.referer, + d: String(video.duration), + }) + + try { + const response = await fetch(`${previewMapURL}?${query.toString()}`) + if (!response.ok) return + const payloadRaw: unknown = await response.json() + if (token !== previewRequestToken) return + const payload = parsePreviewMapResponse(payloadRaw) + if (!payload) return + + previewState = { + ...previewState, + [cacheKey]: { + spriteURL: payload.sprite_url, + map: payload.map, + }, + } + } catch { + return + } + } + const parseVttTime = (raw: string): number => { const parts = raw.trim().split(':') if (parts.length < 2) return 0 @@ -325,6 +531,8 @@ const initPlayer = (): void => { const wasPlaying = !video.paused const previousTime = video.currentTime currentMode = mode + previewRequestToken += 1 + hidePreviewPopover() video.src = streamUrlForMode(currentMode) video.load() pendingSeekTime = previousTime @@ -411,6 +619,7 @@ const initPlayer = (): void => { } updateTimeline(video.currentTime) updateSkipButton(video.currentTime) + loadPreviewMap() }) video.addEventListener('waiting', () => { @@ -593,6 +802,20 @@ const initPlayer = (): void => { showControls() }) + progressWrap?.addEventListener('mouseenter', () => { + loadPreviewMap() + }) + + 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() + }) + window.addEventListener('mouseup', () => { isScrubbing = false })