feat: add timeline preview hover
This commit is contained in:
@@ -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>
|
||||
|
||||
223
static/player.ts
223
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<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
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user