From 28207ece3546c73ec5f97f22c225c8f85fc62290 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Sat, 18 Apr 2026 05:55:51 +0200 Subject: [PATCH] feat(ui): add custom watch player --- internal/templates/layout.templ | 1 + internal/templates/watch.templ | 252 +++++++++++++ static/player.ts | 609 ++++++++++++++++++++++++++++++++ static/style.css | 60 ++++ 4 files changed, 922 insertions(+) create mode 100644 internal/templates/watch.templ create mode 100644 static/player.ts 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; +}