feat: add timeline preview hover

This commit is contained in:
2026-04-18 07:38:59 +02:00
parent 54ba5eda2d
commit 89c8a41c68
2 changed files with 231 additions and 0 deletions

View File

@@ -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) {
</button>
<div class="absolute inset-x-0 bottom-0 from-black/90 to-transparent bg-gradient-to-t px-4 pb-4 pt-12 opacity-0 transition-opacity group-hover:opacity-100 group-[.show-controls]:opacity-100">
<div data-progress-wrap class="group/progress relative mb-5 h-1 cursor-pointer bg-white/30">
<div data-preview-popover class="pointer-events-none absolute bottom-[calc(100%+10px)] left-0 z-40 hidden -translate-x-1/2">
<div class="overflow-hidden border border-white/20 bg-black shadow-xl">
<div data-preview-frame class="h-[90px] w-[160px] bg-black"></div>
<div data-preview-time class="bg-white px-2 py-1 text-center text-xs font-semibold text-black tabular-nums">00:00</div>
</div>
</div>
<div data-segments class="pointer-events-none absolute inset-0 z-20"></div>
<div data-progress class="pointer-events-none absolute inset-y-0 left-0 z-10 bg-blue-500"></div>
<div data-scrubber class="pointer-events-none absolute -top-1.5 z-30 h-5 w-5 rounded-full -translate-x-1/2 bg-white opacity-0 transition-opacity group-hover/progress:opacity-100" style="left: 0%"></div>

View File

@@ -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<string, unknown> => {
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<void> => {
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
})