player: add quality selector and theater mode

This commit is contained in:
2026-05-06 14:09:09 +02:00
parent ab05c2dc44
commit 163a1169c1
3 changed files with 208 additions and 13 deletions

View File

@@ -9,6 +9,7 @@ import DOMPurify from 'dompurify'
interface ModeSource {
token: string
subtitles: SubtitleItem[]
qualities?: string[]
}
interface SubtitleItem {
@@ -79,9 +80,11 @@ const initPlayer = (): void => {
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 theaterBtn = container.querySelector('[data-theater]') 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
@@ -149,13 +152,17 @@ const initPlayer = (): void => {
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): string => {
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)
return `${streamURL}?mode=${modeParam}&token=${tokenParam}`
let url = `${streamURL}?mode=${modeParam}&token=${tokenParam}`
if (quality && quality !== 'best') {
url += `&quality=${encodeURIComponent(quality)}`
}
return url
}
const subtitleProxyURL = (track: SubtitleItem): string => {
@@ -178,6 +185,9 @@ const initPlayer = (): void => {
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 isTheaterMode = (): boolean => localStorage.getItem('mal:theater-mode') === 'true'
const getPreferredQuality = (): string => localStorage.getItem('mal:preferred-quality') || 'best'
const updateAutoplayButton = (): void => {
if (!autoplayBtn) return
@@ -186,6 +196,12 @@ const initPlayer = (): void => {
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
@@ -291,12 +307,21 @@ const initPlayer = (): void => {
const activationTime = skipActivationTime(item)
return currentDisplayTime >= activationTime && currentDisplayTime < item.end
})
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)
@@ -644,6 +669,34 @@ const initPlayer = (): void => {
hideSubtitleText()
}
const updateQualityOptions = (): void => {
if (!qualitySelect) return
const modeSource = modeSources[currentMode]
const qualities = modeSource?.qualities || []
qualitySelect.innerHTML = ''
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'
}
qualitySelect.style.display = qualities.length > 0 ? 'block' : 'none'
}
const modeDub = container.querySelector('[data-mode-dub]') as HTMLButtonElement
const modeSub = container.querySelector('[data-mode-sub]') as HTMLButtonElement
@@ -666,7 +719,7 @@ const initPlayer = (): void => {
const switchMode = (mode: string): void => {
if (!availableModes.includes(mode) || mode === currentMode) return
const nextURL = streamUrlForMode(mode)
const nextURL = streamUrlForMode(mode, qualitySelect?.value)
if (!nextURL) return
const wasPlaying = video.ended || !video.paused
const previousTime = displayTimeFromAbsolute(video.currentTime)
@@ -677,6 +730,7 @@ const initPlayer = (): void => {
pendingSeekTime = previousTime
if (wasPlaying) video.play().catch(() => {})
updateSubtitleOptions()
updateQualityOptions()
updateModeButtons(currentMode)
}
@@ -763,11 +817,43 @@ const initPlayer = (): void => {
}
}
const toggleTheaterMode = (): void => {
const layout = document.getElementById('watch-layout')
if (!layout) return
const enabled = !isTheaterMode()
localStorage.setItem('mal:theater-mode', enabled ? 'true' : 'false')
if (enabled) {
layout.classList.remove('lg:flex-row')
layout.classList.add('lg:flex-col')
} else {
layout.classList.add('lg:flex-row')
layout.classList.remove('lg:flex-col')
}
// Smooth scroll back to player
container.scrollIntoView({ behavior: 'smooth' })
}
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)
const startingURL = streamUrlForMode(currentMode, getPreferredQuality())
if (startingURL) {
video.src = startingURL
} else if (initialStreamToken) {
@@ -901,8 +987,8 @@ const initPlayer = (): void => {
completionAttempts = 0
activeSubtitles = []
hideSubtitleText()
updateSubtitleOptions()
updateQualityOptions()
updateModeButtons(currentMode)
updateVideoOverlay(currentEpisode, data.episode_title || '')
@@ -1087,6 +1173,11 @@ const initPlayer = (): void => {
showControls()
})
theaterBtn?.addEventListener('click', () => {
toggleTheaterMode()
showControls()
})
skipSegmentBtn?.addEventListener('click', () => {
if (!activeSkipSegment) return
@@ -1107,6 +1198,15 @@ const initPlayer = (): void => {
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') {
@@ -1123,6 +1223,13 @@ const initPlayer = (): void => {
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()
@@ -1181,17 +1288,86 @@ const initPlayer = (): void => {
document.addEventListener('keydown', (event) => {
const target = event.target as HTMLElement
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return
if (event.code === 'Space') {
// 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()
}
if (event.code === 'ArrowLeft') seekBy(-10)
if (event.code === 'ArrowRight') seekBy(10)
if (event.code === 'KeyM') video.muted = !video.muted
if (event.code === 'KeyF') {
// 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()
}
// KeyT: Toggle Theater Mode
if (code === 'KeyT') {
event.preventDefault()
toggleTheaterMode()
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()
}
}
showControls()
})
window.addEventListener('beforeunload', () => {
@@ -1214,6 +1390,7 @@ const initPlayer = (): void => {
syncVolumeUI()
updateSkipButton(0)
updateAutoplayButton()
updateAutoSkipButton()
showControls()
// Episode search and range dropdown logic

View File

@@ -61,6 +61,13 @@
<div class="peer-checked:bg-accent peer h-5 w-9 rounded-full bg-white/20 transition-colors after:absolute after:top-[2px] after:left-[2px] after:h-4 after:w-4 after:rounded-full after:bg-white after:transition-all peer-checked:after:translate-x-full"></div>
</label>
</div>
<div class="flex items-center justify-between px-5 py-2.5">
<span class="text-[15px] font-medium text-white">Auto-skip</span>
<label class="relative inline-flex cursor-pointer items-center">
<input type="checkbox" data-autoskip class="peer sr-only" />
<div class="peer-checked:bg-accent peer h-5 w-9 rounded-full bg-white/20 transition-colors after:absolute after:top-[2px] after:left-[2px] after:h-4 after:w-4 after:rounded-full after:bg-white after:transition-all peer-checked:after:translate-x-full"></div>
</label>
</div>
<div class="my-1 h-px w-full bg-white/10"></div>
<div class="py-1">
<span class="mb-1 block px-5 text-xs font-medium tracking-wider text-neutral-400 uppercase">Audio / Subtitles</span>
@@ -71,7 +78,14 @@
<button data-mode-sub class="flex items-center justify-between px-5 py-2.5 text-left transition-colors hover:bg-white/10 text-white focus:outline-none">
<span class="text-sm font-medium">Japanese (Sub)</span>
</button>
<select data-subtitle-select class="mt-2 mx-4 bg-black/40 text-white text-xs border border-white/10 px-2 py-1 outline-none hidden"></select>
<div class="px-5 py-2 flex flex-col gap-1">
<span class="text-[10px] text-neutral-500 uppercase font-bold tracking-widest">Subtitles</span>
<select data-subtitle-select class="w-full bg-white/5 text-white text-xs border border-white/10 px-2 py-1.5 outline-none rounded focus:border-accent hidden"></select>
</div>
<div class="px-5 py-2 flex flex-col gap-1">
<span class="text-[10px] text-neutral-500 uppercase font-bold tracking-widest">Quality</span>
<select data-quality-select class="w-full bg-white/5 text-white text-xs border border-white/10 px-2 py-1.5 outline-none rounded focus:border-accent hidden"></select>
</div>
</div>
</div>
</div>
@@ -81,6 +95,10 @@
<button data-fullscreen class="flex items-center justify-center text-white transition-opacity hover:opacity-80 focus:outline-none">
<svg class="size-6 transition-colors duration-200" viewBox="0 0 240 240" aria-hidden="true"><path d="M143.7,53.9c-1.9-1.9-1.3-4,1.4-4.4l50.6-8.4c1.8-0.5,3.7,0.6,4.2,2.4c0.2,0.6,0.2,1.2,0,1.7l-8.4,50.6c-0.4,2.7-2.4,3.4-4.4,1.4l-14.5-14.5l-28.2,28.2l-14.3-14.3l28.2-28.2L143.7,53.9z M44.2,200.9l50.6-8.4c2.7-0.4,3.4-2.4,1.4-4.4l-14.5-14.5l28.2-28.2l-14.3-14.3l-28.2,28.2l-14.5-14.5c-1.9-1.9-4-1.3-4.4,1.4l-8.4,50.6c-0.5,1.8,0.6,3.6,2.4,4.2C43,201,43.6,201,44.2,200.9L44.2,200.9z" fill="currentColor"></path></svg>
</button>
<button data-theater class="hidden lg:flex items-center justify-center text-white transition-opacity hover:opacity-80 focus:outline-none" title="Theater Mode">
<svg class="size-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2" /><line x1="2" y1="15" x2="22" y2="15" /></svg>
</button>
</div>
</div>

View File

@@ -7,7 +7,7 @@
{{if or (not $currentEpID) (eq (printf "%v" $currentEpID) "0") (eq (printf "%v" $currentEpID) "")}}{{$currentEpID = "1"}}{{end}}
{{$totalEpisodes := len $episodes}}
<div class="flex flex-col gap-6 pb-12 lg:flex-row lg:gap-6">
<div id="watch-layout" class="flex flex-col gap-6 pb-12 lg:flex-row lg:gap-6">
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between mb-4">
<a href="/anime/{{$anime.MalID}}" class="inline-flex items-center gap-2 text-sm text-neutral-400 hover:text-white transition-colors">