feat(player): implement instant and seamless autoplay
This commit is contained in:
127
static/player.ts
127
static/player.ts
@@ -118,33 +118,26 @@ const initPlayer = (): void => {
|
|||||||
const start = Number(segment.start || 0)
|
const start = Number(segment.start || 0)
|
||||||
const end = Number(segment.end || 0)
|
const end = Number(segment.end || 0)
|
||||||
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) {
|
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) {
|
||||||
return null
|
return { ...segment, start: 0, end: 0 }
|
||||||
}
|
}
|
||||||
const rawType = String(segment.type || '').toLowerCase()
|
return { ...segment, start, end }
|
||||||
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)
|
.filter((s: SkipSegment) => s.start > 0 || s.end > 0)
|
||||||
.sort((a: { start: number }, b: { start: number }) => a.start - b.start)
|
|
||||||
|
|
||||||
let currentMode = availableModes.includes(initialMode) ? initialMode : (availableModes[0] || 'dub')
|
|
||||||
const fallbackMode = Object.keys(modeSources).find((mode) => typeof modeSources[mode]?.token === 'string' && modeSources[mode].token !== '')
|
|
||||||
if ((!modeSources[currentMode] || !modeSources[currentMode].token) && fallbackMode) {
|
|
||||||
currentMode = fallbackMode
|
|
||||||
}
|
|
||||||
let controlsTimeout: number | undefined
|
|
||||||
let isScrubbing = 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 }> = []
|
let activeSegments: Array<{ type: string, start: number, end: number }> = []
|
||||||
let lastSavedProgress = { episode: currentEpisode, seconds: -1 }
|
let lastSavedProgress = { episode: currentEpisode, seconds: -1 }
|
||||||
let progressSaveTimer: number | undefined
|
let progressSaveTimer: number | undefined
|
||||||
let transitionEpisode: number | null = null
|
let transitionEpisode: number | null = null
|
||||||
let completionSent = false
|
let completionSent = false
|
||||||
let completionAttempts = 0
|
let completionAttempts = 0
|
||||||
|
let playerControlsTimeout: number | undefined
|
||||||
|
let playerInitialized = false
|
||||||
|
let lastKnownVolume = 1
|
||||||
|
let pendingSeekTime: number | null = null
|
||||||
|
let preloadedNextEpisodeData: EpisodeData | null = null
|
||||||
|
let preloadAttemptedForEpisode: number | null = null
|
||||||
|
let activeSkipSegment: { type: string, start: number, end: number } | null = null
|
||||||
|
let activeSubtitles: Array<{ start: number, end: number, text: string }> = []
|
||||||
const watchProgressURL = '/api/watch-progress'
|
const watchProgressURL = '/api/watch-progress'
|
||||||
|
|
||||||
const previewPopover = container.querySelector('[data-preview-popover]') as HTMLElement
|
const previewPopover = container.querySelector('[data-preview-popover]') as HTMLElement
|
||||||
@@ -183,14 +176,8 @@ const initPlayer = (): void => {
|
|||||||
const updateAutoplayButton = (): void => {
|
const updateAutoplayButton = (): void => {
|
||||||
if (!autoplayBtn) return
|
if (!autoplayBtn) return
|
||||||
const enabled = isAutoplayEnabled()
|
const enabled = isAutoplayEnabled()
|
||||||
const label = enabled ? 'Autoplay: On' : 'Autoplay: Off'
|
const checkbox = autoplayBtn as unknown as HTMLInputElement
|
||||||
autoplayBtn.title = label
|
checkbox.checked = enabled
|
||||||
autoplayBtn.classList.remove('opacity-40', 'opacity-50')
|
|
||||||
if (!enabled) autoplayBtn.classList.add('opacity-50')
|
|
||||||
const lastChild = autoplayBtn.lastChild
|
|
||||||
if (lastChild && lastChild.nodeType === Node.TEXT_NODE) {
|
|
||||||
lastChild.textContent = label
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const timelineBounds = (): { start: number, end: number, duration: number } => {
|
const timelineBounds = (): { start: number, end: number, duration: number } => {
|
||||||
@@ -809,40 +796,63 @@ const initPlayer = (): void => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const goToNextEpisode = (): void => {
|
const preloadNextEpisode = async (): Promise<void> => {
|
||||||
const pathParts = window.location.pathname.split('/')
|
const currentEpNum = Number.parseInt(currentEpisode, 10)
|
||||||
if (pathParts.length < 4) return
|
if (Number.isNaN(currentEpNum)) return
|
||||||
|
|
||||||
const animeID = pathParts[2]
|
const nextEpisode = currentEpNum + 1
|
||||||
const currentEpisodeNumber = Number.parseInt(pathParts[3], 10)
|
if (totalEpisodes > 0 && nextEpisode > totalEpisodes) return
|
||||||
if (Number.isNaN(currentEpisodeNumber)) return
|
if (preloadAttemptedForEpisode === nextEpisode) return
|
||||||
|
|
||||||
if (Number.isInteger(totalEpisodes) && totalEpisodes > 0 && currentEpisodeNumber >= totalEpisodes) {
|
preloadAttemptedForEpisode = nextEpisode
|
||||||
completeAnime(currentEpisodeNumber)
|
|
||||||
|
if (!Number.isInteger(malID) || malID <= 0) return
|
||||||
|
|
||||||
|
const url = `/api/watch/episode/${malID}/${nextEpisode}`
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url)
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = await resp.json() as EpisodeData
|
||||||
|
preloadedNextEpisodeData = data
|
||||||
|
|
||||||
|
// Prefetch the m3u8 playlist to the browser cache
|
||||||
|
const streamMode = data.initial_mode
|
||||||
|
const modeSource = data.mode_sources[streamMode]
|
||||||
|
const token = modeSource?.token || data.token
|
||||||
|
if (token) {
|
||||||
|
const streamURL = container.getAttribute('data-stream-url') || '/watch/proxy/stream'
|
||||||
|
const src = `${streamURL}?mode=${encodeURIComponent(streamMode)}&token=${encodeURIComponent(token)}`
|
||||||
|
fetch(src).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToNextEpisode = async (): Promise<void> => {
|
||||||
|
const currentEpNum = Number.parseInt(currentEpisode, 10)
|
||||||
|
if (Number.isNaN(currentEpNum)) return
|
||||||
|
|
||||||
|
if (Number.isInteger(totalEpisodes) && totalEpisodes > 0 && currentEpNum >= totalEpisodes) {
|
||||||
|
completeAnime(currentEpNum)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAutoplayEnabled()) return
|
if (!isAutoplayEnabled()) return
|
||||||
|
|
||||||
const nextEpisode = currentEpisodeNumber + 1
|
const nextEpisode = currentEpNum + 1
|
||||||
markEpisodeTransition(nextEpisode)
|
markEpisodeTransition(nextEpisode)
|
||||||
|
|
||||||
if (document.fullscreenElement) {
|
await loadNextEpisodeInPlace(Number(malID), nextEpisode)
|
||||||
loadNextEpisodeInPlace(Number(animeID), nextEpisode)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextUrl = `/watch/${animeID}/${nextEpisode}`
|
|
||||||
sessionStorage.setItem('mal:autoplay-next', 'true')
|
|
||||||
window.location.href = nextUrl
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadNextEpisodeInPlace = async (animeID: number, nextEpisode: number): Promise<void> => {
|
const loadNextEpisodeInPlace = async (animeID: number, nextEpisode: number): Promise<void> => {
|
||||||
if (!Number.isInteger(animeID) || animeID <= 0) return
|
if (!Number.isInteger(animeID) || animeID <= 0) return
|
||||||
|
|
||||||
|
let data = preloadedNextEpisodeData
|
||||||
|
if (!data || preloadAttemptedForEpisode !== nextEpisode) {
|
||||||
const url = `/api/watch/episode/${animeID}/${nextEpisode}`
|
const url = `/api/watch/episode/${animeID}/${nextEpisode}`
|
||||||
let data: EpisodeData | null = null
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(url)
|
const resp = await fetch(url)
|
||||||
if (!resp.ok) return
|
if (!resp.ok) return
|
||||||
@@ -850,9 +860,26 @@ const loadNextEpisodeInPlace = async (animeID: number, nextEpisode: number): Pro
|
|||||||
} catch {
|
} catch {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!data) return
|
if (!data) return
|
||||||
|
|
||||||
|
// Update URL without reloading
|
||||||
|
const newUrl = new URL(window.location.href)
|
||||||
|
newUrl.searchParams.set('ep', String(nextEpisode))
|
||||||
|
window.history.pushState({}, '', newUrl.toString())
|
||||||
|
|
||||||
|
// Highlight the current episode in the UI
|
||||||
|
const episodeLinks = document.querySelectorAll('a[href*="ep="]')
|
||||||
|
episodeLinks.forEach(link => {
|
||||||
|
const linkUrl = new URL((link as HTMLAnchorElement).href)
|
||||||
|
if (linkUrl.searchParams.get('ep') === String(nextEpisode)) {
|
||||||
|
link.classList.add('ring-accent', 'ring-2')
|
||||||
|
} else {
|
||||||
|
link.classList.remove('ring-accent', 'ring-2')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const container = document.querySelector('[data-video-player]') as HTMLElement | null
|
const container = document.querySelector('[data-video-player]') as HTMLElement | null
|
||||||
if (!container) return
|
if (!container) return
|
||||||
|
|
||||||
@@ -872,6 +899,10 @@ const loadNextEpisodeInPlace = async (animeID: number, nextEpisode: number): Pro
|
|||||||
currentEpisode = String(nextEpisode)
|
currentEpisode = String(nextEpisode)
|
||||||
totalEpisodes = data.total_episodes
|
totalEpisodes = data.total_episodes
|
||||||
|
|
||||||
|
// Reset preloader for next time
|
||||||
|
preloadedNextEpisodeData = null
|
||||||
|
preloadAttemptedForEpisode = null
|
||||||
|
|
||||||
const newStreamURL = container.getAttribute('data-stream-url') || '/watch/proxy/stream'
|
const newStreamURL = container.getAttribute('data-stream-url') || '/watch/proxy/stream'
|
||||||
const streamMode = data.initial_mode
|
const streamMode = data.initial_mode
|
||||||
const modeSource = data.mode_sources[streamMode]
|
const modeSource = data.mode_sources[streamMode]
|
||||||
@@ -1059,9 +1090,9 @@ const loadNextEpisodeInPlace = async (animeID: number, nextEpisode: number): Pro
|
|||||||
modeDub?.addEventListener('click', toggleDub)
|
modeDub?.addEventListener('click', toggleDub)
|
||||||
modeSub?.addEventListener('click', toggleSub)
|
modeSub?.addEventListener('click', toggleSub)
|
||||||
|
|
||||||
autoplayBtn?.addEventListener('click', () => {
|
autoplayBtn?.addEventListener('change', (e) => {
|
||||||
localStorage.setItem('mal:autoplay-enabled', isAutoplayEnabled() ? 'false' : 'true')
|
const isChecked = (e.target as HTMLInputElement).checked
|
||||||
updateAutoplayButton()
|
localStorage.setItem('mal:autoplay-enabled', isChecked ? 'true' : 'false')
|
||||||
showControls()
|
showControls()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user