feat(ui): add custom watch player
This commit is contained in:
609
static/player.ts
Normal file
609
static/player.ts
Normal file
@@ -0,0 +1,609 @@
|
||||
export {}
|
||||
|
||||
interface ModeSource {
|
||||
url: string
|
||||
referer: string
|
||||
subtitles: SubtitleItem[]
|
||||
}
|
||||
|
||||
interface SubtitleItem {
|
||||
lang: string
|
||||
url: string
|
||||
referer: string
|
||||
}
|
||||
|
||||
interface SkipSegment {
|
||||
type: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
const initPlayer = (): void => {
|
||||
const container = document.querySelector('[data-video-player]')
|
||||
if (!container) return
|
||||
|
||||
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 volumeWrap = container.querySelector('[data-volume-wrap]') 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 timeDisplay = container.querySelector('[data-time]') as HTMLElement
|
||||
const progressWrap = container.querySelector('[data-progress-wrap]') as HTMLElement
|
||||
const progress = container.querySelector('[data-progress]') 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 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 subtitleText = container.querySelector('[data-subtitle-text]') as HTMLElement
|
||||
|
||||
const streamURL = container.getAttribute('data-stream-url') || '/watch/proxy/stream'
|
||||
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 maxIntroStartSeconds = 180
|
||||
const minOutroStartRatio = 0.5
|
||||
const minSegmentDurationSeconds = 20
|
||||
const maxSegmentDurationSeconds = 240
|
||||
|
||||
const 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 null
|
||||
}
|
||||
const rawType = String(segment.type || '').toLowerCase()
|
||||
const type = rawType === 'ed' || rawType === 'outro' ? 'ed' : 'op'
|
||||
return { type, start: Math.max(0, start), end: Math.max(0, end) }
|
||||
})
|
||||
.filter((s: unknown): s is { type: string, start: number, end: number } => s !== null)
|
||||
.sort((a: { start: number }, b: { start: number }) => a.start - b.start)
|
||||
|
||||
let currentMode = availableModes.includes(initialMode) ? initialMode : (availableModes[0] || 'dub')
|
||||
let controlsTimeout: number | undefined
|
||||
let isScrubbing = false
|
||||
let isHoveringVolume = false
|
||||
let lastKnownVolume = 1
|
||||
let activeSubtitles: Array<{ start: number, end: number, text: string }> = []
|
||||
let currentSubtitleTracks: Array<{ lang: string, label: string, url: string }> = []
|
||||
let pendingSeekTime: number | null = null
|
||||
let activeSkipSegment: { type: string, start: number, end: number } | null = null
|
||||
let activeSegments: Array<{ type: string, start: number, end: number }> = []
|
||||
|
||||
const streamUrlForMode = (mode: string): string => {
|
||||
const modeParam = encodeURIComponent(mode)
|
||||
const stateParam = encodeURIComponent(JSON.stringify(modeSources))
|
||||
return `${streamURL}?mode=${modeParam}&state=${stateParam}`
|
||||
}
|
||||
|
||||
const subtitleProxyURL = (track: SubtitleItem): string => {
|
||||
if (!track || !track.url) return ''
|
||||
let proxied = `/watch/proxy/subtitle?u=${encodeURIComponent(track.url)}`
|
||||
if (track.referer) {
|
||||
proxied += `&r=${encodeURIComponent(track.referer)}`
|
||||
}
|
||||
return proxied
|
||||
}
|
||||
|
||||
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 resolveActiveSegments = (): void => {
|
||||
if (!Number.isFinite(video.duration) || video.duration <= 0) {
|
||||
activeSegments = []
|
||||
return
|
||||
}
|
||||
activeSegments = parsedSegments.filter((segment: { start: number, end: number, type: string }) => {
|
||||
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 > video.duration + 1) return false
|
||||
if (segment.type === 'op') {
|
||||
if (start > maxIntroStartSeconds) return false
|
||||
if (start > video.duration * 0.5) return false
|
||||
return true
|
||||
}
|
||||
if (segment.type === 'ed') {
|
||||
return start >= video.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 updateSkipButton = (currentTime: number): void => {
|
||||
const segment = activeSegments.find((item: { start: number, end: number }) => {
|
||||
const activationTime = skipActivationTime(item)
|
||||
return currentTime >= activationTime && currentTime < item.end
|
||||
})
|
||||
if (!segment) {
|
||||
activeSkipSegment = null
|
||||
skipSegmentBtn?.classList.add('hidden')
|
||||
skipSegmentBtn?.classList.remove('block')
|
||||
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
|
||||
segmentsTrack.innerHTML = ''
|
||||
if (!video.duration || !Number.isFinite(video.duration)) return
|
||||
activeSegments.forEach((segment: { start: number, end: number }) => {
|
||||
const left = (segment.start / video.duration) * 100
|
||||
const width = ((segment.end - segment.start) / video.duration) * 100
|
||||
const bar = document.createElement('div')
|
||||
bar.className = 'absolute top-0 h-full bg-yellow-400'
|
||||
bar.style.left = `${left}%`
|
||||
bar.style.width = `${width}%`
|
||||
segmentsTrack.appendChild(bar)
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
if (!video.duration || !Number.isFinite(video.duration)) {
|
||||
progress.style.width = '0%'
|
||||
if (scrubber) scrubber.style.left = '0%'
|
||||
timeDisplay.textContent = `00:00 / 00:00`
|
||||
return
|
||||
}
|
||||
const pct = Math.max(0, Math.min(100, (currentTime / video.duration) * 100))
|
||||
progress.style.width = `${pct}%`
|
||||
if (scrubber) scrubber.style.left = `${pct}%`
|
||||
timeDisplay.textContent = `${formatTime(currentTime)} / ${formatTime(video.duration)}`
|
||||
}
|
||||
|
||||
const seekBy = (delta: number): void => {
|
||||
if (!Number.isFinite(video.duration)) return
|
||||
const next = Math.max(0, Math.min(video.duration, video.currentTime + delta))
|
||||
video.currentTime = next
|
||||
updateTimeline(video.currentTime)
|
||||
updateSkipButton(video.currentTime)
|
||||
showControls()
|
||||
}
|
||||
|
||||
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 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 }
|
||||
let timeLine = line
|
||||
if (!line.includes('-->') && i + 1 < lines.length) {
|
||||
timeLine = lines[i + 1].trim()
|
||||
i += 1
|
||||
}
|
||||
if (!timeLine.includes('-->')) { i += 1; continue }
|
||||
const [startRaw, endRaw] = timeLine.split('-->')
|
||||
const start = parseVttTime(startRaw)
|
||||
const end = parseVttTime(endRaw)
|
||||
i += 1
|
||||
const payload: string[] = []
|
||||
while (i < lines.length && lines[i].trim() !== '') {
|
||||
payload.push(lines[i])
|
||||
i += 1
|
||||
}
|
||||
const textContent = payload.join('\n').replace(/<[^>]+>/g, '').trim()
|
||||
if (textContent) cues.push({ start, end, text: textContent })
|
||||
}
|
||||
return cues
|
||||
}
|
||||
|
||||
const loadSubtitle = async (url: string): Promise<Array<{ start: number, end: number, text: string }>> => {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) return []
|
||||
const text = await response.text()
|
||||
return parseVtt(text)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const updateSubtitleRender = (currentTime: number): void => {
|
||||
if (!subtitleText) return
|
||||
if (!activeSubtitles.length) {
|
||||
subtitleText.textContent = ''
|
||||
subtitleText.classList.remove('block')
|
||||
subtitleText.classList.add('hidden')
|
||||
return
|
||||
}
|
||||
const cue = activeSubtitles.find(item => currentTime >= item.start && currentTime <= item.end)
|
||||
if (!cue) {
|
||||
subtitleText.textContent = ''
|
||||
subtitleText.classList.remove('block')
|
||||
subtitleText.classList.add('hidden')
|
||||
return
|
||||
}
|
||||
subtitleText.textContent = cue.text
|
||||
subtitleText.classList.remove('hidden')
|
||||
subtitleText.classList.add('block')
|
||||
}
|
||||
|
||||
const updateSubtitleOptions = (): void => {
|
||||
if (!subtitleSelect) return
|
||||
currentSubtitleTracks = subtitlesForMode(currentMode)
|
||||
subtitleSelect.innerHTML = ''
|
||||
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)
|
||||
})
|
||||
subtitleSelect.style.display = currentSubtitleTracks.length > 0 ? 'block' : 'none'
|
||||
activeSubtitles = []
|
||||
if (subtitleText) {
|
||||
subtitleText.textContent = ''
|
||||
subtitleText.classList.remove('block')
|
||||
subtitleText.classList.add('hidden')
|
||||
}
|
||||
}
|
||||
|
||||
const switchMode = (mode: string): void => {
|
||||
if (!availableModes.includes(mode) || mode === currentMode) return
|
||||
const wasPlaying = !video.paused
|
||||
const previousTime = video.currentTime
|
||||
currentMode = mode
|
||||
video.src = streamUrlForMode(currentMode)
|
||||
video.load()
|
||||
pendingSeekTime = previousTime
|
||||
if (wasPlaying) video.play().catch(() => {})
|
||||
updateSubtitleOptions()
|
||||
}
|
||||
|
||||
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 => {
|
||||
if (volumeRange) {
|
||||
const volumeValue = video.muted ? 0 : Math.round(video.volume * 100)
|
||||
volumeRange.value = String(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(controlsTimeout)
|
||||
controlsTimeout = window.setTimeout(() => {
|
||||
if (!isScrubbing && !isHoveringVolume && !video.paused) {
|
||||
container.classList.remove('show-controls')
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
// Initialize
|
||||
updateSubtitleOptions()
|
||||
|
||||
if (video) {
|
||||
video.src = streamUrlForMode(currentMode)
|
||||
|
||||
video.addEventListener('loadedmetadata', () => {
|
||||
if (loading) loading.style.display = 'none'
|
||||
resolveActiveSegments()
|
||||
renderSegments()
|
||||
if (pendingSeekTime !== null && Number.isFinite(pendingSeekTime)) {
|
||||
try {
|
||||
video.currentTime = pendingSeekTime
|
||||
} catch {}
|
||||
pendingSeekTime = null
|
||||
}
|
||||
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('timeupdate', () => {
|
||||
updateTimeline(video.currentTime)
|
||||
updateSubtitleRender(video.currentTime)
|
||||
updateSkipButton(video.currentTime)
|
||||
})
|
||||
|
||||
video.addEventListener('play', () => {
|
||||
updatePlayPauseIcons(true)
|
||||
showControls()
|
||||
})
|
||||
|
||||
video.addEventListener('pause', () => {
|
||||
updatePlayPauseIcons(false)
|
||||
showControls()
|
||||
})
|
||||
|
||||
video.addEventListener('volumechange', () => {
|
||||
syncVolumeUI()
|
||||
})
|
||||
|
||||
video.addEventListener('ended', () => {
|
||||
goToNextEpisode()
|
||||
})
|
||||
}
|
||||
|
||||
const goToNextEpisode = (): void => {
|
||||
const pathParts = window.location.pathname.split('/')
|
||||
if (pathParts.length < 4) return
|
||||
|
||||
const animeID = pathParts[2]
|
||||
const currentEpisode = Number.parseInt(pathParts[3], 10)
|
||||
if (Number.isNaN(currentEpisode)) return
|
||||
|
||||
const nextEpisode = currentEpisode + 1
|
||||
const nextUrl = `/watch/${animeID}/${nextEpisode}`
|
||||
|
||||
window.location.href = nextUrl
|
||||
}
|
||||
|
||||
playPause?.addEventListener('click', () => {
|
||||
if (video.paused) {
|
||||
video.play()
|
||||
} else {
|
||||
video.pause()
|
||||
}
|
||||
showControls()
|
||||
})
|
||||
|
||||
video.addEventListener('click', () => {
|
||||
if (video.paused) {
|
||||
video.play()
|
||||
} else {
|
||||
video.pause()
|
||||
}
|
||||
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()
|
||||
})
|
||||
|
||||
volumeWrap?.addEventListener('mouseenter', () => {
|
||||
isHoveringVolume = true
|
||||
showControls()
|
||||
})
|
||||
|
||||
volumeWrap?.addEventListener('mouseleave', () => {
|
||||
isHoveringVolume = false
|
||||
showControls()
|
||||
})
|
||||
|
||||
volumeWrap?.addEventListener('focusin', () => {
|
||||
isHoveringVolume = true
|
||||
showControls()
|
||||
})
|
||||
|
||||
volumeWrap?.addEventListener('focusout', () => {
|
||||
isHoveringVolume = false
|
||||
showControls()
|
||||
})
|
||||
|
||||
backwardBtn?.addEventListener('click', () => seekBy(-10))
|
||||
forwardBtn?.addEventListener('click', () => seekBy(10))
|
||||
|
||||
fullscreenBtn?.addEventListener('click', () => {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen()
|
||||
} else {
|
||||
container.requestFullscreen()
|
||||
}
|
||||
showControls()
|
||||
})
|
||||
|
||||
skipSegmentBtn?.addEventListener('click', () => {
|
||||
if (!activeSkipSegment) return
|
||||
const target = activeSkipSegment.end + 0.01
|
||||
if (Number.isFinite(video.duration)) {
|
||||
video.currentTime = Math.min(video.duration, target)
|
||||
} else {
|
||||
video.currentTime = target
|
||||
}
|
||||
updateTimeline(video.currentTime)
|
||||
updateSkipButton(video.currentTime)
|
||||
showControls()
|
||||
})
|
||||
|
||||
const modeDub = container.querySelector('[data-mode-dub]') as HTMLButtonElement
|
||||
const modeSub = container.querySelector('[data-mode-sub]') as HTMLButtonElement
|
||||
modeDub?.addEventListener('click', toggleDub)
|
||||
modeSub?.addEventListener('click', toggleSub)
|
||||
|
||||
subtitleSelect?.addEventListener('change', async () => {
|
||||
const selected = subtitleSelect.value
|
||||
if (selected === 'none') {
|
||||
activeSubtitles = []
|
||||
if (subtitleText) {
|
||||
subtitleText.textContent = ''
|
||||
subtitleText.classList.remove('block')
|
||||
subtitleText.classList.add('hidden')
|
||||
}
|
||||
return
|
||||
}
|
||||
const idx = Number(selected)
|
||||
const track = currentSubtitleTracks[idx]
|
||||
if (!track) {
|
||||
activeSubtitles = []
|
||||
return
|
||||
}
|
||||
activeSubtitles = await loadSubtitle(track.url)
|
||||
})
|
||||
|
||||
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))
|
||||
if (Number.isFinite(video.duration)) {
|
||||
video.currentTime = ratio * video.duration
|
||||
}
|
||||
updateTimeline(video.currentTime)
|
||||
updateSkipButton(video.currentTime)
|
||||
showControls()
|
||||
})
|
||||
|
||||
window.addEventListener('mouseup', () => {
|
||||
isScrubbing = false
|
||||
})
|
||||
|
||||
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))
|
||||
if (Number.isFinite(video.duration)) {
|
||||
video.currentTime = ratio * video.duration
|
||||
}
|
||||
updateTimeline(video.currentTime)
|
||||
updateSkipButton(video.currentTime)
|
||||
})
|
||||
|
||||
container.addEventListener('mousemove', showControls)
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.code === 'Space') {
|
||||
event.preventDefault()
|
||||
if (video.paused) {
|
||||
video.play()
|
||||
} else {
|
||||
video.pause()
|
||||
}
|
||||
}
|
||||
if (event.code === 'ArrowLeft') seekBy(-10)
|
||||
if (event.code === 'ArrowRight') seekBy(10)
|
||||
if (event.code === 'KeyM') video.muted = !video.muted
|
||||
if (event.code === 'KeyF') {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen()
|
||||
} else {
|
||||
container.requestFullscreen()
|
||||
}
|
||||
}
|
||||
showControls()
|
||||
})
|
||||
|
||||
updatePlayPauseIcons(false)
|
||||
syncVolumeUI()
|
||||
updateSkipButton(0)
|
||||
showControls()
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initPlayer)
|
||||
@@ -33,3 +33,63 @@
|
||||
--poster-max-height: 360px;
|
||||
--font: 'Verdana', 'Tahoma', 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.volume-range {
|
||||
writing-mode: vertical-lr;
|
||||
direction: rtl;
|
||||
accent-color: #ffffff;
|
||||
}
|
||||
|
||||
.volume-range::-webkit-slider-runnable-track {
|
||||
width: 4px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.volume-range::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 9999px;
|
||||
background: #ffffff;
|
||||
border: 0;
|
||||
margin-left: -5px;
|
||||
}
|
||||
|
||||
.volume-range::-moz-range-track {
|
||||
width: 4px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.volume-range::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 9999px;
|
||||
background: #ffffff;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.volume-wrap::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -10px;
|
||||
right: -10px;
|
||||
bottom: 100%;
|
||||
height: 130px;
|
||||
}
|
||||
|
||||
.volume-wrap:hover .volume-panel,
|
||||
.volume-wrap:focus-within .volume-panel,
|
||||
.volume-panel:hover,
|
||||
.volume-panel:focus-within {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.volume-wrap:hover .volume-underline,
|
||||
.volume-wrap:focus-within .volume-underline {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user