feat: add player main entry wiring all modules together
This commit is contained in:
193
static/player/main.ts
Normal file
193
static/player/main.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { state, initState } from './state'
|
||||
import { invalidateBounds, updateTimeline } from './timeline'
|
||||
import { setupControls, showControls } from './controls'
|
||||
import { setupKeyboard } from './keyboard'
|
||||
import { setupSubtitles, updateSubtitleOptions, updateSubtitleRender } from './subtitles'
|
||||
import { setupSkip, updateSkipButton, updateAutoSkipButton } from './skip'
|
||||
import { setupQuality, updateQualityOptions } from './quality'
|
||||
import { setupMode, updateModeButtons } from './mode'
|
||||
import { setupAutoplayButton, updateEpisodeHighlight, switchEpisodeRange } from './episodes/ui'
|
||||
import { goToNextEpisode } from './episodes/nav'
|
||||
import { resolveActiveSegments, renderSegments } from './skip/segments'
|
||||
import { setupThumbnails } from './episodes/thumbnails'
|
||||
import { markEpisodeTransition } from './progress'
|
||||
import { absoluteTimeFromRatio, getBounds, displayTimeFromAbsolute } from './timeline'
|
||||
import { formatTime } from './controls'
|
||||
|
||||
let initialized = false
|
||||
|
||||
const hidePreviewPopover = (): void => {
|
||||
state.previewPopover?.classList.remove('block')
|
||||
state.previewPopover?.classList.add('hidden')
|
||||
state.previewPopover!.style.left = '0px'
|
||||
}
|
||||
|
||||
const showPreviewPopover = (): void => {
|
||||
state.previewPopover?.classList.remove('hidden')
|
||||
state.previewPopover?.classList.add('block')
|
||||
}
|
||||
|
||||
const updatePreviewUI = (ratio: number): void => {
|
||||
const progressWrap = state.container.querySelector('[data-progress-wrap]') as HTMLElement | null
|
||||
if (!progressWrap || !state.previewPopover || !state.previewTime) { hidePreviewPopover(); return }
|
||||
const b = getBounds()
|
||||
if (b.duration <= 0) { hidePreviewPopover(); return }
|
||||
|
||||
state.previewTime.textContent = formatTime(Math.max(0, Math.min(b.duration, ratio * b.duration)))
|
||||
|
||||
const barWidth = progressWrap.clientWidth
|
||||
if (barWidth <= 0) { hidePreviewPopover(); return }
|
||||
|
||||
showPreviewPopover()
|
||||
const popoverWidth = state.previewPopover.offsetWidth || 72
|
||||
state.previewPopover.style.left = `${Math.max(popoverWidth / 2, Math.min(barWidth - popoverWidth / 2, ratio * barWidth))}px`
|
||||
}
|
||||
|
||||
const initPlayer = (): void => {
|
||||
const container = document.querySelector('[data-video-player]') as HTMLElement | null
|
||||
if (!container || initialized) return
|
||||
initialized = true
|
||||
|
||||
initState(container)
|
||||
|
||||
const loading = container.querySelector('[data-loading]') as HTMLElement | null
|
||||
const progressWrap = container.querySelector('[data-progress-wrap]') as HTMLElement | null
|
||||
|
||||
const preferredQuality = localStorage.getItem('mal:preferred-quality') || 'best'
|
||||
const streamToken = state.modeSources[state.currentMode]?.token
|
||||
if (streamToken) {
|
||||
state.video.src = `${state.streamURL}?mode=${encodeURIComponent(state.currentMode)}&token=${encodeURIComponent(streamToken)}${preferredQuality !== 'best' ? `&quality=${encodeURIComponent(preferredQuality)}` : ''}`
|
||||
}
|
||||
|
||||
setupProgress()
|
||||
setupControls()
|
||||
setupKeyboard()
|
||||
setupSkip()
|
||||
setupSubtitles()
|
||||
setupQuality()
|
||||
setupMode()
|
||||
|
||||
updateSubtitleOptions()
|
||||
updateQualityOptions()
|
||||
updateModeButtons()
|
||||
setupAutoplayButton()
|
||||
updateAutoSkipButton()
|
||||
showControls()
|
||||
|
||||
state.video.addEventListener('loadedmetadata', () => {
|
||||
loading && (loading.style.display = 'none')
|
||||
invalidateBounds()
|
||||
|
||||
resolveActiveSegments()
|
||||
renderSegments()
|
||||
|
||||
const startTime = Number(container.dataset.startTimeSeconds ?? '0')
|
||||
if (startTime > 0 && state.video.currentTime <= 0.5 && state.video.duration > startTime) {
|
||||
state.video.currentTime = startTime
|
||||
}
|
||||
if (state.pendingSeekTime !== null) {
|
||||
state.video.currentTime = state.pendingSeekTime
|
||||
state.pendingSeekTime = null
|
||||
}
|
||||
if (state.shouldAutoPlay) state.video.play().catch(() => {})
|
||||
|
||||
updateTimeline(state.video.currentTime)
|
||||
updateSkipButton(state.video.currentTime)
|
||||
})
|
||||
|
||||
state.video.addEventListener('waiting', () => { loading && (loading.style.display = 'flex') })
|
||||
state.video.addEventListener('playing', () => { loading && (loading.style.display = 'none') })
|
||||
state.video.addEventListener('progress', () => { updateTimeline(state.video.currentTime) })
|
||||
|
||||
state.video.addEventListener('timeupdate', () => {
|
||||
updateTimeline(state.video.currentTime)
|
||||
updateSubtitleRender(displayTimeFromAbsolute(state.video.currentTime))
|
||||
updateSkipButton(state.video.currentTime)
|
||||
})
|
||||
|
||||
state.video.addEventListener('ended', () => { goToNextEpisode() })
|
||||
|
||||
progressWrap?.addEventListener('mousedown', (e) => {
|
||||
state.isScrubbing = true
|
||||
const rect = progressWrap.getBoundingClientRect()
|
||||
state.video.currentTime = absoluteTimeFromRatio(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)))
|
||||
updateTimeline(state.video.currentTime)
|
||||
updateSkipButton(state.video.currentTime)
|
||||
showControls()
|
||||
})
|
||||
|
||||
progressWrap?.addEventListener('mousemove', (e) => {
|
||||
const rect = progressWrap.getBoundingClientRect()
|
||||
updatePreviewUI(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)))
|
||||
})
|
||||
|
||||
progressWrap?.addEventListener('mouseleave', hidePreviewPopover)
|
||||
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
if (!state.isScrubbing || !progressWrap) return
|
||||
const rect = progressWrap.getBoundingClientRect()
|
||||
state.video.currentTime = absoluteTimeFromRatio(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)))
|
||||
updateTimeline(state.video.currentTime)
|
||||
updateSkipButton(state.video.currentTime)
|
||||
})
|
||||
|
||||
container.addEventListener('click', (e) => {
|
||||
const anchor = (e.target as Node).parentElement?.closest('a[href]')
|
||||
if (!(anchor instanceof HTMLAnchorElement)) return
|
||||
const parts = new URL(anchor.href, location.origin).pathname.split('/').filter(Boolean)
|
||||
if (parts[0] === 'watch' && Number.parseInt(parts[2], 10) > 0) {
|
||||
markEpisodeTransition(Number.parseInt(parts[2], 10))
|
||||
}
|
||||
})
|
||||
|
||||
state.video.addEventListener('click', showControls)
|
||||
|
||||
const searchInput = document.querySelector('[data-episode-search]') as HTMLInputElement | null
|
||||
const dropdown = container.querySelector('[data-episode-dropdown]') as HTMLElement | null
|
||||
let searchDebounce: number | undefined
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', () => {
|
||||
clearTimeout(searchDebounce)
|
||||
searchDebounce = window.setTimeout(() => {
|
||||
const val = searchInput.value.replace(/\D/g, '')
|
||||
if (!val) {
|
||||
const cur = Number.parseInt(state.currentEpisode, 10)
|
||||
switchEpisodeRange(Math.floor((cur - 1) / 100))
|
||||
updateEpisodeHighlight(cur)
|
||||
return
|
||||
}
|
||||
const ep = Number.parseInt(val, 10)
|
||||
if (!ep || ep <= 0) return
|
||||
const maxEp = state.totalEpisodes > 0 ? state.totalEpisodes : 500
|
||||
const clamped = Math.min(ep, maxEp)
|
||||
searchInput.value = String(clamped)
|
||||
if (state.episodeGrid) {
|
||||
switchEpisodeRange(Math.floor((clamped - 1) / 100))
|
||||
updateEpisodeHighlight(clamped)
|
||||
}
|
||||
}, 300)
|
||||
})
|
||||
}
|
||||
|
||||
if (dropdown) {
|
||||
dropdown.querySelectorAll('.episode-range-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const idx = Number.parseInt((btn as HTMLElement).dataset.rangeIndex ?? '0', 10)
|
||||
switchEpisodeRange(idx)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (state.episodeGrid && state.totalEpisodes > 100) {
|
||||
switchEpisodeRange(Math.floor((Number.parseInt(state.currentEpisode, 10) - 1) / 100))
|
||||
}
|
||||
|
||||
setupThumbnails()
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initPlayer)
|
||||
document.body.addEventListener('htmx:afterSwap', (e: Event) => {
|
||||
const target = (e as CustomEvent).detail?.target as HTMLElement | null
|
||||
if (target?.querySelector('[data-video-player]')) initPlayer()
|
||||
})
|
||||
Reference in New Issue
Block a user