feat(ui): add custom watch player
This commit is contained in:
@@ -16,6 +16,7 @@ templ Layout(title string, showHeader bool) {
|
||||
<script src="/dist/anime.js" defer></script>
|
||||
<script src="/dist/timezone.js" defer></script>
|
||||
<script src="/dist/auth.js" defer></script>
|
||||
<script src="/dist/player.js" defer></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-(--bg) text-(--text) font-(--font) text-sm leading-normal">
|
||||
if showHeader {
|
||||
|
||||
252
internal/templates/watch.templ
Normal file
252
internal/templates/watch.templ
Normal file
@@ -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) {
|
||||
<div class="grid gap-5 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div class="grid min-w-0 gap-5">
|
||||
@VideoPlayer(data)
|
||||
<div class="grid gap-2">
|
||||
<h1 class="text-lg font-semibold tracking-wide text-(--text)">
|
||||
{ anime.DisplayTitle() }
|
||||
</h1>
|
||||
<p class="text-sm text-(--text-muted)">
|
||||
Episode { data.CurrentEpisode }
|
||||
if anime.Episodes > 0 {
|
||||
<span class="text-(--text-faint)"> / { fmt.Sprintf("%d", anime.Episodes) }</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<a
|
||||
href={ templ.URL(fmt.Sprintf("/anime/%d", anime.MalID)) }
|
||||
class="inline-flex h-8 items-center bg-(--panel-soft) px-3 text-xs text-(--text) no-underline hover:bg-(--panel) hover:text-(--text) hover:no-underline"
|
||||
>
|
||||
← Back to anime
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="grid gap-4">
|
||||
<div class="bg-(--panel) p-3">
|
||||
<h3 class="mb-3 text-base font-semibold tracking-wide text-(--text)">Episodes</h3>
|
||||
<div
|
||||
hx-get={ string(templ.URL(fmt.Sprintf("/api/anime/%d/episodes?current=%s", anime.MalID, data.CurrentEpisode))) }
|
||||
hx-trigger="load"
|
||||
class="max-h-[600px] overflow-y-auto"
|
||||
>
|
||||
@LoadingIndicatorSmall()
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ LoadingIndicatorSmall() {
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="h-5 w-5 animate-spin rounded-full border-2 border-(--panel-soft) border-t-(--accent)"></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ EpisodeList(episodes []jikan.Episode, currentEpisode string, animeID int) {
|
||||
if len(episodes) == 0 {
|
||||
<p class="py-4 text-center text-sm text-(--text-muted)">No episodes available</p>
|
||||
} else {
|
||||
<div class="grid gap-1">
|
||||
for _, ep := range episodes {
|
||||
@EpisodeItem(ep, currentEpisode, animeID)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ EpisodeItem(episode jikan.Episode, currentEpisode string, animeID int) {
|
||||
{{ isCurrent := fmt.Sprintf("%d", episode.MalID) == currentEpisode }}
|
||||
<a
|
||||
href={ templ.URL(fmt.Sprintf("/watch/%d/%d", animeID, episode.MalID)) }
|
||||
class={
|
||||
"flex items-center gap-3 px-2 py-2 text-xs no-underline transition-colors",
|
||||
templ.KV("bg-(--panel-soft) text-(--text)", isCurrent),
|
||||
templ.KV("text-(--text-muted) hover:bg-(--panel-soft) hover:text-(--text)", !isCurrent),
|
||||
}
|
||||
>
|
||||
<span
|
||||
class={
|
||||
"flex h-6 w-6 shrink-0 items-center justify-center text-[10px] font-semibold",
|
||||
templ.KV("bg-(--accent) text-white", isCurrent),
|
||||
templ.KV("bg-(--panel) text-(--text-faint)", !isCurrent),
|
||||
}
|
||||
>
|
||||
{ fmt.Sprintf("%d", episode.MalID) }
|
||||
</span>
|
||||
<span class="min-w-0 truncate">
|
||||
if episode.Title != "" {
|
||||
{ episode.Title }
|
||||
} else {
|
||||
Episode { fmt.Sprintf("%d", episode.MalID) }
|
||||
}
|
||||
</span>
|
||||
if episode.Filler {
|
||||
<span class="ml-auto shrink-0 rounded px-1.5 py-0.5 text-[9px] uppercase tracking-wider bg-yellow-900/50 text-yellow-400">Filler</span>
|
||||
}
|
||||
if episode.Recap {
|
||||
<span class="ml-auto shrink-0 rounded px-1.5 py-0.5 text-[9px] uppercase tracking-wider bg-blue-900/50 text-blue-400">Recap</span>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
|
||||
templ VideoPlayer(data WatchPageData) {
|
||||
{{ streamURL := buildStreamURL(data.InitialMode, data.ModeSources) }}
|
||||
<div
|
||||
class="group relative aspect-video w-full overflow-hidden bg-black"
|
||||
data-video-player
|
||||
data-stream-url="/watch/proxy/stream"
|
||||
data-initial-mode={ data.InitialMode }
|
||||
data-available-modes={ toJSON(data.AvailableModes) }
|
||||
data-mode-sources={ toJSON(data.ModeSources) }
|
||||
data-segments={ toJSON(data.Segments) }
|
||||
>
|
||||
<video
|
||||
class="h-full w-full"
|
||||
preload="metadata"
|
||||
crossorigin="anonymous"
|
||||
playsinline
|
||||
src={ streamURL }
|
||||
></video>
|
||||
<div data-loading class="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-2 border-(--panel-soft) border-t-(--accent)"></div>
|
||||
</div>
|
||||
<div data-subtitle-text class="absolute bottom-20 left-1/2 z-20 hidden max-w-[88vw] -translate-x-1/2 px-4 text-center text-xl font-semibold text-white drop-shadow-lg"></div>
|
||||
<button data-skip class="absolute bottom-24 right-5 z-20 hidden border border-white bg-transparent px-4 py-2 text-base font-semibold text-white transition-opacity hover:opacity-85">
|
||||
Skip intro
|
||||
</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-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 -translate-x-1/2 rounded-full bg-blue-500 opacity-0 transition-opacity group-hover/progress:opacity-100" style="left: 0%"></div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<button data-play-pause data-state="paused" class="flex h-10 w-10 items-center justify-center text-white" title="Play">
|
||||
<svg data-icon-play class="h-6 w-6" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<polygon points="8 5 19 12 8 19" fill="white" stroke="none"></polygon>
|
||||
</svg>
|
||||
<svg data-icon-pause class="hidden h-6 w-6" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<line x1="9" y1="6" x2="9" y2="18" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="15" y1="6" x2="15" y2="18" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div data-volume-wrap class="volume-wrap relative flex h-10 w-10 items-center justify-center overflow-visible">
|
||||
<div class="volume-panel pointer-events-none absolute bottom-[calc(100%+26px)] left-1/2 z-30 -translate-x-1/2 opacity-0 invisible transition-opacity">
|
||||
<input
|
||||
data-volume-range
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
value="100"
|
||||
class="volume-range h-24 w-4 cursor-pointer appearance-none bg-transparent"
|
||||
aria-label="Volume"
|
||||
/>
|
||||
</div>
|
||||
<button data-mute class="relative flex h-10 w-10 items-center justify-center pb-1 text-white" aria-label="Mute">
|
||||
<svg data-icon-volume class="h-6 w-6" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<polygon points="5 10 9 10 13 6 13 18 9 14 5 14" fill="none" stroke="white" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M16 9c1.3 1.3 1.3 4.7 0 6" stroke="white" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
<path d="M18.8 6.5c3 2.9 3 8.1 0 11" stroke="white" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</svg>
|
||||
<svg data-icon-muted class="hidden h-6 w-6" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<polygon points="5 10 9 10 13 6 13 18 9 14 5 14" fill="none" stroke="white" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<line x1="16" y1="9" x2="20" y2="15" stroke="white" stroke-width="1.85" stroke-linecap="round"/>
|
||||
<line x1="20" y1="9" x2="16" y2="15" stroke="white" stroke-width="1.85" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="volume-underline pointer-events-none absolute bottom-0 left-1/2 h-0.5 w-6 -translate-x-1/2 bg-white opacity-0 transition-opacity"></span>
|
||||
</div>
|
||||
<span data-time class="text-base text-white tabular-nums">00:00 / 00:00</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div data-mode-switch class="flex items-center gap-1">
|
||||
<button data-mode-dub class="flex h-10 w-10 items-center justify-center text-white" title="Dub">
|
||||
<svg class="h-6 w-6" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6 9h6M6 15h4M12 9v6M17 7.5c2.2 2 2.2 7 0 9M19.2 5.5c3.4 3.2 3.4 10 0 13" stroke="white" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button data-mode-sub class="flex h-10 w-10 items-center justify-center text-white" title="Sub">
|
||||
<svg class="h-6 w-6" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="3.5" y="5.5" width="17" height="13" rx="2" stroke="white" stroke-width="1.85" fill="none"/>
|
||||
<path d="M8 11.5h8M8 14.5h5" stroke="white" stroke-width="1.85" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button data-backward class="flex h-10 w-10 items-center justify-center text-white" title="-10s">
|
||||
<svg class="h-6 w-6" viewBox="0 0 50 50" aria-hidden="true">
|
||||
<path d="M29.9199 45H25.2051V26.5391L20.6064 28.3154V24.3975L29.4219 20.7949H29.9199V45ZM48.1013 35.0059C48.1013 38.3483 47.4926 40.9049 46.2751 42.6758C45.0687 44.4466 43.3422 45.332 41.0954 45.332C38.8708 45.332 37.1498 44.4743 35.9323 42.7588C34.726 41.0322 34.1006 38.5641 34.0564 35.3545V30.7891C34.0564 27.4577 34.6596 24.9121 35.8659 23.1523C37.0723 21.3815 38.8044 20.4961 41.0622 20.4961C43.32 20.4961 45.0521 21.3704 46.2585 23.1191C47.4649 24.8678 48.0792 27.3636 48.1013 30.6064V35.0059ZM43.3864 30.1084C43.3864 28.2048 43.1983 26.777 42.822 25.8252C42.4457 24.8734 41.8591 24.3975 41.0622 24.3975C39.5681 24.3975 38.7933 26.1406 38.738 29.627V35.6533C38.738 37.6012 38.9262 39.0511 39.3025 40.0029C39.6898 40.9548 40.2875 41.4307 41.0954 41.4307C41.8591 41.4307 42.4236 40.988 42.7888 40.1025C43.1651 39.2061 43.3643 37.8392 43.3864 36.002V30.1084Z" fill="white"/>
|
||||
<path d="M40.0106 5.45398V0L50 7.79529L40.0106 15.5914V10.3033H4.9114V40.1506H18.7558V45H2.01875e-06V5.45398H40.0106Z" fill="white"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button data-forward class="flex h-10 w-10 items-center justify-center text-white" title="+10s">
|
||||
<svg class="h-6 w-6" viewBox="0 0 52 50" aria-hidden="true">
|
||||
<path d="M11.9199 45H7.20508V26.5391L2.60645 28.3154V24.3975L11.4219 20.7949H11.9199V45ZM30.1013 35.0059C30.1013 38.3483 29.4926 40.9049 28.2751 42.6758C27.0687 44.4466 25.3422 45.332 23.0954 45.332C20.8708 45.332 19.1498 44.4743 17.9323 42.7588C16.726 41.0322 16.1006 38.5641 16.0564 35.3545V30.7891C16.0564 27.4577 16.6596 24.9121 17.8659 23.1523C19.0723 21.3815 20.8044 20.4961 23.0622 20.4961C25.32 20.4961 27.0521 21.3704 28.2585 23.1191C29.4649 24.8678 30.0792 27.3636 30.1013 30.6064V35.0059ZM25.3864 30.1084C25.3864 28.2048 25.1983 26.777 24.822 25.8252C24.4457 24.8734 23.8591 24.3975 23.0622 24.3975C21.5681 24.3975 20.7933 26.1406 20.738 29.627V35.6533C20.738 37.6012 20.9262 39.0511 21.3025 40.0029C21.6898 40.9548 22.2875 41.4307 23.0954 41.4307C23.8591 41.4307 24.4236 40.988 24.7888 40.1025C25.1651 39.2061 25.3643 37.8392 25.3864 36.002V30.1084Z" fill="white"/>
|
||||
<path d="M11.9894 5.45398V0L2 7.79529L11.9894 15.5914V10.3033H47.0886V40.1506H33.2442V45H52V5.45398H11.9894Z" fill="white"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button data-fullscreen class="flex h-10 w-10 items-center justify-center text-white" title="Fullscreen">
|
||||
<svg class="h-6 w-6" viewBox="0 0 240 240" aria-hidden="true">
|
||||
<path d="M96.3,186.1c1.9,1.9,1.3,4-1.4,4.4l-50.6,8.4c-1.8,0.5-3.7-0.6-4.2-2.4c-0.2-0.6-0.2-1.2,0-1.7l8.4-50.6c0.4-2.7,2.4-3.4,4.4-1.4l14.5,14.5l28.2-28.2l14.3,14.3l-28.2,28.2L96.3,186.1z M195.8,39.1l-50.6,8.4c-2.7,0.4-3.4,2.4-1.4,4.4l14.5,14.5l-28.2,28.2l14.3,14.3l28.2-28.2l14.5,14.5c1.9,1.9,4,1.3,4.4-1.4l8.4-50.6c0.5-1.8-0.6-3.6-2.4-4.2C197,39,196.4,39,195.8,39.1L195.8,39.1z" fill="white"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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