diff --git a/internal/templates/layout.templ b/internal/templates/layout.templ
index 905c86b..7af871f 100644
--- a/internal/templates/layout.templ
+++ b/internal/templates/layout.templ
@@ -16,6 +16,7 @@ templ Layout(title string, showHeader bool) {
+
if showHeader {
diff --git a/internal/templates/watch.templ b/internal/templates/watch.templ
new file mode 100644
index 0000000..399f547
--- /dev/null
+++ b/internal/templates/watch.templ
@@ -0,0 +1,252 @@
+package templates
+
+import (
+ "encoding/json"
+ "fmt"
+ "mal/internal/jikan"
+ "net/url"
+)
+
+// WatchPageData holds the data needed for the watch page
+type WatchPageData struct {
+ MalID int
+ Title string
+ CurrentEpisode string
+ InitialMode string
+ AvailableModes []string
+ ModeSources map[string]ModeSource
+ Segments []SkipSegment
+}
+
+// ModeSource represents a stream source for a specific mode (dub/sub)
+type ModeSource struct {
+ URL string `json:"url"`
+ Referer string `json:"referer"`
+ Subtitles []SubtitleItem `json:"subtitles"`
+}
+
+// SubtitleItem represents a subtitle track
+type SubtitleItem struct {
+ Lang string `json:"lang"`
+ URL string `json:"url"`
+ Referer string `json:"referer"`
+}
+
+// SkipSegment represents a skippable segment (intro/outro)
+type SkipSegment struct {
+ Type string `json:"type"`
+ Start float64 `json:"start"`
+ End float64 `json:"end"`
+}
+
+templ WatchPage(anime jikan.Anime, data WatchPageData) {
+ @Layout(fmt.Sprintf("%s - episode %s", anime.DisplayTitle(), data.CurrentEpisode), true) {
+
+
+ @VideoPlayer(data)
+
+
+ { anime.DisplayTitle() }
+
+
+ Episode { data.CurrentEpisode }
+ if anime.Episodes > 0 {
+ / { fmt.Sprintf("%d", anime.Episodes) }
+ }
+
+
+
+
+
+
+ }
+}
+
+templ LoadingIndicatorSmall() {
+
+}
+
+templ EpisodeList(episodes []jikan.Episode, currentEpisode string, animeID int) {
+ if len(episodes) == 0 {
+ No episodes available
+ } else {
+
+ for _, ep := range episodes {
+ @EpisodeItem(ep, currentEpisode, animeID)
+ }
+
+ }
+}
+
+templ EpisodeItem(episode jikan.Episode, currentEpisode string, animeID int) {
+ {{ isCurrent := fmt.Sprintf("%d", episode.MalID) == currentEpisode }}
+
+
+ { fmt.Sprintf("%d", episode.MalID) }
+
+
+ if episode.Title != "" {
+ { episode.Title }
+ } else {
+ Episode { fmt.Sprintf("%d", episode.MalID) }
+ }
+
+ if episode.Filler {
+ Filler
+ }
+ if episode.Recap {
+ Recap
+ }
+
+}
+
+templ VideoPlayer(data WatchPageData) {
+ {{ streamURL := buildStreamURL(data.InitialMode, data.ModeSources) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
00:00 / 00:00
+
+
+
+
+
+
+
+
+
+
+}
+
+func buildStreamURL(mode string, modeSources map[string]ModeSource) string {
+ stateJSON, _ := json.Marshal(modeSources)
+ return fmt.Sprintf("/watch/proxy/stream?mode=%s&state=%s", url.QueryEscape(mode), url.QueryEscape(string(stateJSON)))
+}
+
+func toJSON(v interface{}) string {
+ b, _ := json.Marshal(v)
+ return string(b)
+}
diff --git a/static/player.ts b/static/player.ts
new file mode 100644
index 0000000..0fed034
--- /dev/null
+++ b/static/player.ts
@@ -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> => {
+ 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)
diff --git a/static/style.css b/static/style.css
index ec08138..72cfe5f 100644
--- a/static/style.css
+++ b/static/style.css
@@ -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;
+}