mirror of
https://github.com/ThaUnknown/miru.git
synced 2026-03-11 22:15:35 +00:00
943 lines
34 KiB
Svelte
943 lines
34 KiB
Svelte
<script lang='ts'>
|
||
import Captions from 'lucide-svelte/icons/captions'
|
||
// import Cast from 'lucide-svelte/icons/cast'
|
||
import Contrast from 'lucide-svelte/icons/contrast'
|
||
import DecimalsArrowLeft from 'lucide-svelte/icons/decimals-arrow-left'
|
||
import DecimalsArrowRight from 'lucide-svelte/icons/decimals-arrow-right'
|
||
import FastForward from 'lucide-svelte/icons/fast-forward'
|
||
import List from 'lucide-svelte/icons/list'
|
||
import Pause from 'lucide-svelte/icons/pause'
|
||
import PictureInPicture2 from 'lucide-svelte/icons/picture-in-picture-2'
|
||
import Proportions from 'lucide-svelte/icons/proportions'
|
||
import RefreshCcw from 'lucide-svelte/icons/refresh-ccw'
|
||
import Rewind from 'lucide-svelte/icons/rewind'
|
||
import RotateCcw from 'lucide-svelte/icons/rotate-ccw'
|
||
import RotateCw from 'lucide-svelte/icons/rotate-cw'
|
||
import ScreenShare from 'lucide-svelte/icons/screen-share'
|
||
import SkipBack from 'lucide-svelte/icons/skip-back'
|
||
import SkipForward from 'lucide-svelte/icons/skip-forward'
|
||
import Volume1 from 'lucide-svelte/icons/volume-1'
|
||
import Volume2 from 'lucide-svelte/icons/volume-2'
|
||
import VolumeX from 'lucide-svelte/icons/volume-x'
|
||
import { onDestroy, onMount } from 'svelte'
|
||
import { fade } from 'svelte/transition'
|
||
import { persisted } from 'svelte-persisted-store'
|
||
import { toast } from 'svelte-sonner'
|
||
import VideoDeband from 'video-deband'
|
||
|
||
import ProgressButton from '../button/progress-button.svelte'
|
||
|
||
import Animations, { playAnimation } from './animations.svelte'
|
||
import DownloadStats from './downloadstats.svelte'
|
||
import EpisodesModal from './episodesmodal.svelte'
|
||
import { condition, loadWithDefaults } from './keybinds.svelte'
|
||
import Options from './options.svelte'
|
||
import PictureInPicture from './pip'
|
||
import Seekbar from './seekbar.svelte'
|
||
import Subs from './subtitles'
|
||
import Thumbnailer from './thumbnailer'
|
||
import { findChapter, getChaptersAniSkip, getChapterTitle, isChapterSkippable, sanitizeChapters, screenshot, type Chapter, type MediaInfo } from './util'
|
||
import Volume from './volume.svelte'
|
||
|
||
import type { ResolvedFile } from './resolver'
|
||
import type { TorrentFile } from 'native'
|
||
import type { SvelteMediaTimeRange } from 'svelte/elements'
|
||
|
||
import { beforeNavigate, goto } from '$app/navigation'
|
||
import { page } from '$app/stores'
|
||
import PictureInPictureOff from '$lib/components/icons/PictureInPicture.svelte'
|
||
import PictureInPictureExit from '$lib/components/icons/PictureInPictureExit.svelte'
|
||
import Play from '$lib/components/icons/Play.svelte'
|
||
import Subtitles from '$lib/components/icons/Subtitles.svelte'
|
||
import { Maximize, Minimize } from '$lib/components/icons/animated'
|
||
import { Button, iconSizes } from '$lib/components/ui/button'
|
||
import { authAggregator } from '$lib/modules/auth'
|
||
import { isPlaying } from '$lib/modules/idle'
|
||
import native from '$lib/modules/native'
|
||
import { click, inputType, keywrap } from '$lib/modules/navigate'
|
||
import { settings, SUPPORTS } from '$lib/modules/settings'
|
||
import { w2globby } from '$lib/modules/w2g/lobby'
|
||
import { getAnimeProgress, setAnimeProgress } from '$lib/modules/watchProgress'
|
||
import { toTS, scaleBlurFade, cn } from '$lib/utils'
|
||
|
||
export let mediaInfo: MediaInfo
|
||
export let otherFiles: TorrentFile[]
|
||
export let videoFiles: ResolvedFile[]
|
||
export let selectFile: (file: ResolvedFile) => void
|
||
export let prev: (() => void) | undefined = undefined
|
||
export let next: (() => void) | undefined = undefined
|
||
// bindings
|
||
// values
|
||
let videoHeight = 9
|
||
let videoWidth = 16
|
||
let currentTime = 0
|
||
let seekPercent = 0
|
||
let duration = 1
|
||
const playbackRate = persisted('playbackRate', 1, {
|
||
serializer: {
|
||
stringify: (value) => value.toString(),
|
||
parse: (value) => Math.min(16, Math.max(0.1, parseFloat(value)))
|
||
}
|
||
})
|
||
let buffered: SvelteMediaTimeRange[] = []
|
||
let subtitleDelay = 0
|
||
$: buffer = Math.max(...buffered.map(({ end }) => end))
|
||
let readyState = 0
|
||
$: safeduration = isFinite(duration) ? duration : currentTime
|
||
const volume = persisted('volume', 1)
|
||
$: exponentialVolume = SUPPORTS.isAndroid ? 1 : $volume ** 3
|
||
let muted = false
|
||
|
||
// elements
|
||
let fullscreenElement: HTMLElement | null = null
|
||
let video: HTMLVideoElement
|
||
let wrapper: HTMLDivElement
|
||
|
||
let subtitles: Subs | undefined
|
||
let deband: VideoDeband | undefined
|
||
|
||
const pip = new PictureInPicture()
|
||
$: pip._setElements(video, subtitles, deband)
|
||
const pipElementStore = pip.element
|
||
$: pictureInPictureElement = $pipElementStore
|
||
|
||
const thumbnailer = new Thumbnailer(mediaInfo.file.url)
|
||
onMount(() => thumbnailer.setVideo(video))
|
||
|
||
onDestroy(() => {
|
||
pip.destroy()
|
||
thumbnailer.destroy()
|
||
})
|
||
|
||
// @ts-expect-error bad type infer
|
||
$: if (subtitles?.renderer) subtitles.renderer.timeOffset = Number(subtitleDelay)
|
||
|
||
// state
|
||
let seeking = false
|
||
let ended = false
|
||
let paused = true
|
||
let pointerMoving = false
|
||
let fastForwarding = false
|
||
// const cast = false
|
||
|
||
$: $isPlaying = !paused
|
||
|
||
$: buffering = readyState < 3
|
||
$: immersed = (!buffering && !paused && !ended && !pictureInPictureElement && !pointerMoving) || fastForwarding
|
||
$: isMiniplayer = $page.route.id !== '/app/player'
|
||
|
||
$: if (!isMiniplayer && SUPPORTS.isAndroidTV) fullscreen()
|
||
|
||
let pointerMoveTimeout = 0
|
||
function resetMove (time = 300) {
|
||
clearTimeout(pointerMoveTimeout)
|
||
pointerMoving = true
|
||
pointerMoveTimeout = setTimeout(() => {
|
||
pointerMoving = false
|
||
}, time)
|
||
}
|
||
|
||
// functions
|
||
function playPause () {
|
||
playAnimation(paused ? 'play' : 'pause')
|
||
return paused ? video.play() : video.pause()
|
||
}
|
||
function fullscreen () {
|
||
return fullscreenElement ? document.exitFullscreen() : document.getElementById('episodeListTarget')!.requestFullscreen()
|
||
}
|
||
|
||
// function toggleCast () {
|
||
// // TODO: never
|
||
// }
|
||
|
||
$: fullscreenElement ? screen.orientation.lock('landscape') : screen.orientation.unlock()
|
||
|
||
beforeNavigate(({ to }) => {
|
||
if (fullscreenElement && to?.route.id !== '/app/player') fullscreen()
|
||
})
|
||
|
||
function checkAudio () {
|
||
if ('audioTracks' in HTMLVideoElement.prototype && video.audioTracks) {
|
||
if (!video.audioTracks.length) {
|
||
toast.error('Audio Codec Unsupported', {
|
||
description: "This torrent's audio codec is not supported, try a different release by disabling Autoplay Torrents in Torrent settings. You can also use external players like MPV."
|
||
})
|
||
} else if (video.audioTracks.length > 1) {
|
||
const preferredTrack = [...video.audioTracks].find(({ language }) => language === $settings.audioLanguage)
|
||
if (preferredTrack) return selectAudio(preferredTrack.id)
|
||
|
||
const japaneseTrack = [...video.audioTracks].find(({ language }) => language === 'jpn')
|
||
if (japaneseTrack) return selectAudio(japaneseTrack.id)
|
||
}
|
||
} else {
|
||
video.requestVideoFrameCallback(() => {
|
||
// using capturestream.getAudioTracks() could work too
|
||
if ('webkitAudioDecodedByteCount' in video && video.webkitAudioDecodedByteCount === 0) {
|
||
toast.error('Audio Codec Unsupported', {
|
||
description: "This torrent's audio codec is not supported, try a different release by disabling Autoplay Torrents in Torrent settings. You can also use external players like MPV."
|
||
})
|
||
}
|
||
})
|
||
}
|
||
}
|
||
function changeVolume (delta: number) {
|
||
playAnimation(delta > 0 ? 'volumeup' : 'volumedown')
|
||
$volume = Math.min(1, Math.max(0, $volume + delta))
|
||
}
|
||
function selectAudio (id: string) {
|
||
if (id) {
|
||
for (const track of video.audioTracks ?? []) {
|
||
track.enabled = track.id === id
|
||
playAnimation(track.label)
|
||
}
|
||
seek(-0.2) // stupid fix because video freezes up when chaging tracks
|
||
}
|
||
}
|
||
function selectVideo (id: string) {
|
||
if (id) {
|
||
for (const track of video.videoTracks ?? []) {
|
||
track.selected = track.id === id
|
||
playAnimation(track.label)
|
||
}
|
||
}
|
||
}
|
||
function seek (time: number) {
|
||
// WARN: this causes all subscriptions to video to re-run!!!
|
||
video.currentTime = currentTime = currentTime + time
|
||
// currentTime = currentTime + time
|
||
playAnimation(time > 0 ? 'seekforw' : 'seekback')
|
||
}
|
||
function seekTo (time: number) {
|
||
video.currentTime = currentTime = time
|
||
playAnimation(time > currentTime ? 'seekforw' : 'seekback')
|
||
}
|
||
let wasPaused = false
|
||
function startSeek () {
|
||
wasPaused = paused
|
||
if (!paused) video.pause()
|
||
}
|
||
|
||
function finishSeek () {
|
||
seekTo(seekPercent * safeduration / 100)
|
||
if (!wasPaused) video.play()
|
||
}
|
||
|
||
let chapters: Chapter[] = []
|
||
const chaptersPromise = native.chapters(mediaInfo.file.hash, mediaInfo.file.id)
|
||
async function loadChapters (pr: typeof chaptersPromise, safeduration: number) {
|
||
const nativeChapters = await pr
|
||
if (nativeChapters.length) {
|
||
chapters = sanitizeChapters(nativeChapters, safeduration)
|
||
return
|
||
}
|
||
|
||
const idMal = mediaInfo.media.idMal
|
||
if (!idMal) return
|
||
const aniSkipChapters = await getChaptersAniSkip(idMal, mediaInfo.episode, safeduration)
|
||
if (!aniSkipChapters.length) return
|
||
chapters = sanitizeChapters(aniSkipChapters, safeduration)
|
||
}
|
||
$: loadChapters(chaptersPromise, safeduration)
|
||
|
||
function createSubtitles (video: HTMLVideoElement) {
|
||
subtitles = new Subs(video, otherFiles, mediaInfo.file)
|
||
return {
|
||
destroy: () => {
|
||
subtitles?.destroy()
|
||
}
|
||
}
|
||
}
|
||
|
||
function createDeband (video: HTMLVideoElement, playerDeband: boolean) {
|
||
const create = () => {
|
||
if (deband) return
|
||
try {
|
||
deband = new VideoDeband(video)
|
||
deband.canvas.classList.add('deband-canvas', 'w-full', 'h-full', 'pointer-events-none', 'object-contain')
|
||
video.before(deband.canvas)
|
||
} catch (e) {
|
||
console.error('Failed to create video deband:', e)
|
||
destroy()
|
||
}
|
||
}
|
||
|
||
const destroy = () => {
|
||
deband?.destroy()
|
||
deband?.canvas.remove()
|
||
deband = undefined
|
||
}
|
||
|
||
if (playerDeband) create()
|
||
|
||
return {
|
||
destroy,
|
||
update: (playerDeband: boolean) => {
|
||
if (playerDeband) {
|
||
create()
|
||
} else {
|
||
destroy()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
let completed = false
|
||
async function checkCompletion () {
|
||
if (!completed && $settings.playerAutocomplete) {
|
||
const fromend = Math.max(180, safeduration / 10)
|
||
if (safeduration && currentTime && readyState && safeduration - fromend < currentTime) {
|
||
authAggregator.watch(mediaInfo.media, mediaInfo.episode)
|
||
completed = true
|
||
}
|
||
}
|
||
}
|
||
|
||
// other
|
||
$: if (ended && $settings.playerAutoplay && !isMiniplayer) next?.()
|
||
|
||
function handleVisibility (visibility: DocumentVisibilityState) {
|
||
if (!ended && $settings.playerPause && !pictureInPictureElement) {
|
||
if (visibility === 'hidden') {
|
||
visibilityPaused = paused
|
||
paused = true
|
||
} else {
|
||
if (!visibilityPaused) paused = false
|
||
}
|
||
}
|
||
}
|
||
let visibilityPaused = true
|
||
let visibilityState: DocumentVisibilityState
|
||
$: handleVisibility(visibilityState)
|
||
|
||
function autoPlay () {
|
||
if (!isMiniplayer) video.play()
|
||
}
|
||
|
||
const interval = setInterval(() => {
|
||
video.load()
|
||
}, 10_000)
|
||
|
||
onDestroy(() => clearInterval(interval))
|
||
|
||
$: if (readyState > 0) clearInterval(interval)
|
||
|
||
let currentSkippable: string | null = null
|
||
function checkSkippableChapters () {
|
||
const current = findChapter(currentTime, chapters)
|
||
const wasSkippable = currentSkippable
|
||
if (current) {
|
||
currentSkippable = isChapterSkippable(current)
|
||
if ($settings.playerSkip && !wasSkippable) animating = true
|
||
}
|
||
}
|
||
|
||
function stopAnimation () {
|
||
animating = false
|
||
}
|
||
|
||
let animating = false
|
||
|
||
function skip () {
|
||
const current = findChapter(currentTime, chapters)
|
||
if (current) {
|
||
if (!isChapterSkippable(current) && (current.end - current.start) > 100) {
|
||
currentTime = currentTime + 85
|
||
} else {
|
||
const endtime = current.end + 0.5
|
||
currentTime = endtime
|
||
currentSkippable = null
|
||
}
|
||
} else if (currentTime < 10) {
|
||
currentTime = 90
|
||
} else if (safeduration - currentTime < 90) {
|
||
currentTime = safeduration
|
||
} else {
|
||
currentTime = currentTime + 85
|
||
}
|
||
}
|
||
|
||
let stats: {
|
||
fps?: string
|
||
presented?: number
|
||
dropped?: number
|
||
processing?: string
|
||
viewport?: string
|
||
resolution?: string
|
||
buffer?: string
|
||
speed?: number
|
||
} | null = null
|
||
let requestCallback: number | null = null
|
||
function toggleStats () {
|
||
if (requestCallback) {
|
||
stats = null
|
||
video.cancelVideoFrameCallback(requestCallback)
|
||
requestCallback = null
|
||
} else {
|
||
requestCallback = video.requestVideoFrameCallback((a, b) => {
|
||
stats = {}
|
||
handleStats(a, b, b)
|
||
})
|
||
}
|
||
}
|
||
async function handleStats (now: number, metadata: VideoFrameCallbackMetadata, lastmeta: VideoFrameCallbackMetadata) {
|
||
if (stats) {
|
||
const msbf = (metadata.mediaTime - lastmeta.mediaTime) / (metadata.presentedFrames - lastmeta.presentedFrames)
|
||
const fps = (1 / msbf).toFixed(3)
|
||
stats = {
|
||
fps,
|
||
presented: metadata.presentedFrames,
|
||
dropped: video.getVideoPlaybackQuality().droppedVideoFrames,
|
||
processing: metadata.processingDuration + ' ms',
|
||
viewport: video.clientWidth + 'x' + video.clientHeight,
|
||
resolution: videoWidth + 'x' + videoHeight,
|
||
buffer: getBufferHealth(metadata.mediaTime) + ' s',
|
||
speed: video.playbackRate || 1
|
||
}
|
||
setTimeout(() => video.requestVideoFrameCallback((n, m) => handleStats(n, m, metadata)), 200)
|
||
}
|
||
}
|
||
function getBufferHealth (time: number) {
|
||
for (let index = video.buffered.length; index--;) {
|
||
if (time < video.buffered.end(index) && time >= video.buffered.start(index)) {
|
||
return (video.buffered.end(index) - time) | 0
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
$: seekIndex = Math.max(0, Math.floor(seekPercent * safeduration / 100 / thumbnailer.interval))
|
||
|
||
$: playbackIndex = Math.max(0, Math.floor(currentTime / thumbnailer.interval))
|
||
|
||
$: if (readyState && !seekIndex) thumbnailer._paintThumbnail(video, playbackIndex)
|
||
|
||
$: native.setMediaSession(mediaInfo.session, mediaInfo.media.id, safeduration)
|
||
$: native.setPositionState({ duration: safeduration, position: Math.min(Math.max(0, currentTime), safeduration), playbackRate: $playbackRate }, readyState === 0 ? 'none' : paused ? 'paused' : 'playing')
|
||
$: native.setPlayBackState(readyState === 0 ? 'none' : paused ? 'paused' : 'playing')
|
||
native.setActionHandler('play', playPause)
|
||
native.setActionHandler('pause', playPause)
|
||
native.setActionHandler('seekto', ({ seekTime }) => seekTo(seekTime ?? 0))
|
||
native.setActionHandler('seekbackward', () => seek(-Number($settings.playerSeek)))
|
||
native.setActionHandler('seekforward', () => seek(Number($settings.playerSeek)))
|
||
native.setActionHandler('previoustrack', () => prev?.())
|
||
native.setActionHandler('nexttrack', () => next?.())
|
||
// about://flags/#auto-picture-in-picture-for-video-playback
|
||
native.setActionHandler('enterpictureinpicture', () => {
|
||
goto('/app/player')
|
||
pip.pip(true)
|
||
})
|
||
|
||
let openSubs: () => Promise<void>
|
||
|
||
function cycleSubtitles () {
|
||
if (!subtitles) return
|
||
const entries = Object.entries(subtitles._tracks.value)
|
||
if (!entries.length) return
|
||
const index = entries.findIndex(([index]) => index === subtitles!.current.value) + 1
|
||
const [id, info] = entries[index] ?? [-1, { meta: { name: 'Off', language: 'Eng' } }]
|
||
playAnimation(info.meta.name ?? info.meta.language ?? 'Eng')
|
||
subtitles.selectCaptions(id)
|
||
}
|
||
|
||
function seekBarKey (event: KeyboardEvent) {
|
||
if (['ArrowLeft', 'ArrowRight'].includes(event.key)) {
|
||
event.preventDefault()
|
||
event.stopImmediatePropagation()
|
||
event.stopPropagation()
|
||
}
|
||
|
||
switch (event.key) {
|
||
case 'ArrowLeft':
|
||
seek(-Number($settings.playerSeek))
|
||
break
|
||
case 'ArrowRight':
|
||
seek(Number($settings.playerSeek))
|
||
break
|
||
case 'Enter':
|
||
playPause()
|
||
break
|
||
}
|
||
}
|
||
let fitWidth = false
|
||
loadWithDefaults({
|
||
KeyX: {
|
||
fn: () => screenshot(video, subtitles),
|
||
id: 'screenshot_monitor',
|
||
icon: ScreenShare,
|
||
type: 'icon',
|
||
desc: 'Save Screenshot to Clipboard'
|
||
},
|
||
KeyI: {
|
||
fn: () => toggleStats(),
|
||
icon: List,
|
||
id: 'list',
|
||
type: 'icon',
|
||
desc: 'Toggle Stats'
|
||
},
|
||
Space: {
|
||
fn: (e) => {
|
||
e.preventDefault()
|
||
e.stopImmediatePropagation()
|
||
e.stopPropagation()
|
||
playPause()
|
||
},
|
||
id: 'play_arrow',
|
||
icon: Play,
|
||
type: 'icon',
|
||
desc: 'Play/Pause'
|
||
},
|
||
KeyN: {
|
||
fn: () => next?.(),
|
||
id: 'skip_next',
|
||
icon: SkipForward,
|
||
type: 'icon',
|
||
desc: 'Next Episode'
|
||
},
|
||
KeyB: {
|
||
fn: () => prev?.(),
|
||
id: 'skip_previous',
|
||
icon: SkipBack,
|
||
type: 'icon',
|
||
desc: 'Previous Episode'
|
||
},
|
||
KeyA: {
|
||
fn: () => {
|
||
$settings.playerDeband = !$settings.playerDeband
|
||
},
|
||
id: 'deblur',
|
||
icon: Contrast,
|
||
type: 'icon',
|
||
desc: 'Toggle Video Debanding'
|
||
},
|
||
KeyM: {
|
||
fn: () => (muted = !muted),
|
||
id: 'volume_off',
|
||
icon: VolumeX,
|
||
type: 'icon',
|
||
desc: 'Toggle Mute'
|
||
},
|
||
KeyP: {
|
||
fn: () => pip.pip(),
|
||
id: 'picture_in_picture',
|
||
icon: PictureInPicture2,
|
||
type: 'icon',
|
||
desc: 'Toggle Picture in Picture'
|
||
},
|
||
KeyF: {
|
||
fn: () => fullscreen(),
|
||
id: 'fullscreen',
|
||
icon: Maximize,
|
||
type: 'icon',
|
||
desc: 'Toggle Fullscreen'
|
||
},
|
||
KeyS: {
|
||
fn: () => skip(),
|
||
id: '+90',
|
||
desc: 'Skip Intro/90s'
|
||
},
|
||
KeyW: {
|
||
fn: () => { fitWidth = !fitWidth },
|
||
id: 'fit_width',
|
||
icon: Proportions,
|
||
type: 'icon',
|
||
desc: 'Toggle Video Cover'
|
||
},
|
||
// KeyD: {
|
||
// fn: () => toggleCast(),
|
||
// id: 'cast',
|
||
// icon: Cast,
|
||
// type: 'icon',
|
||
// desc: 'Toggle Cast [broken]'
|
||
// },
|
||
KeyC: {
|
||
fn: () => cycleSubtitles(),
|
||
id: 'subtitles',
|
||
icon: Captions,
|
||
type: 'icon',
|
||
desc: 'Cycle Subtitles'
|
||
},
|
||
ArrowLeft: {
|
||
fn: (e) => {
|
||
if ($inputType === 'dpad') return
|
||
e.preventDefault()
|
||
e.stopImmediatePropagation()
|
||
e.stopPropagation()
|
||
seek(-Number($settings.playerSeek))
|
||
},
|
||
id: 'fast_rewind',
|
||
icon: Rewind,
|
||
type: 'icon',
|
||
desc: 'Rewind'
|
||
},
|
||
ArrowRight: {
|
||
fn: (e) => {
|
||
if ($inputType === 'dpad') return
|
||
e.preventDefault()
|
||
e.stopImmediatePropagation()
|
||
e.stopPropagation()
|
||
seek(Number($settings.playerSeek))
|
||
},
|
||
id: 'fast_forward',
|
||
icon: FastForward,
|
||
type: 'icon',
|
||
desc: 'Seek'
|
||
},
|
||
ArrowUp: {
|
||
fn: (e) => {
|
||
if ($inputType === 'dpad') return
|
||
e.preventDefault()
|
||
e.stopImmediatePropagation()
|
||
e.stopPropagation()
|
||
changeVolume(0.05)
|
||
},
|
||
id: 'volume_up',
|
||
icon: Volume2,
|
||
type: 'icon',
|
||
desc: 'Volume Up'
|
||
},
|
||
ArrowDown: {
|
||
fn: (e) => {
|
||
if ($inputType === 'dpad') return
|
||
e.preventDefault()
|
||
e.stopImmediatePropagation()
|
||
e.stopPropagation()
|
||
changeVolume(-0.05)
|
||
},
|
||
id: 'volume_down',
|
||
icon: Volume1,
|
||
type: 'icon',
|
||
desc: 'Volume Down'
|
||
},
|
||
BracketLeft: {
|
||
fn: () => { $playbackRate = Math.min(16, Math.max(0.1, $playbackRate - 0.1)) },
|
||
id: 'history',
|
||
icon: RotateCcw,
|
||
type: 'icon',
|
||
desc: 'Decrease Playback Rate'
|
||
},
|
||
BracketRight: {
|
||
fn: () => { $playbackRate = Math.min(16, Math.max(0.1, $playbackRate + 0.1)) },
|
||
id: 'update',
|
||
icon: RotateCw,
|
||
type: 'icon',
|
||
desc: 'Increase Playback Rate'
|
||
},
|
||
Backslash: {
|
||
fn: () => { $playbackRate = 1 },
|
||
icon: RefreshCcw,
|
||
id: 'schedule',
|
||
type: 'icon',
|
||
desc: 'Reset Playback Rate'
|
||
},
|
||
Semicolon: {
|
||
fn: () => { subtitleDelay -= 0.1 },
|
||
icon: DecimalsArrowLeft,
|
||
type: 'icon',
|
||
id: 'subtitle_delay_minus',
|
||
desc: 'Decrease Subtitle Delay'
|
||
},
|
||
Quote: {
|
||
fn: () => { subtitleDelay += 0.1 },
|
||
icon: DecimalsArrowRight,
|
||
type: 'icon',
|
||
id: 'subtitle_delay_plus',
|
||
desc: 'Increase Subtitle Delay'
|
||
}
|
||
})
|
||
|
||
$condition = () => !isMiniplayer
|
||
|
||
function holdToFF (document: HTMLElement, type: 'key' | 'pointer') {
|
||
const ctrl = new AbortController()
|
||
let timeout = 0
|
||
let oldPlaybackRate = $playbackRate
|
||
let wasPaused = paused
|
||
const startFF = () => {
|
||
clearTimeout(timeout)
|
||
timeout = setTimeout(() => {
|
||
if (fastForwarding) return
|
||
wasPaused = paused
|
||
paused = false
|
||
fastForwarding = true
|
||
oldPlaybackRate = $playbackRate
|
||
$playbackRate = 2
|
||
}, 1000)
|
||
}
|
||
const endFF = () => {
|
||
clearTimeout(timeout)
|
||
if (!fastForwarding) return
|
||
fastForwarding = false
|
||
$playbackRate = oldPlaybackRate
|
||
paused = wasPaused
|
||
}
|
||
document.addEventListener(type + 'down' as 'keydown' | 'pointerdown', event => {
|
||
if (isMiniplayer) return
|
||
if ('code' in event && (event.code !== 'Space')) return
|
||
if ('button' in event && event.button !== 0) return
|
||
if ('pointerId' in event) {
|
||
document.setPointerCapture(event.pointerId)
|
||
}
|
||
startFF()
|
||
}, { signal: ctrl.signal })
|
||
document.addEventListener(type + 'up' as 'keyup' | 'pointerup', event => {
|
||
if (isMiniplayer) return
|
||
if ('code' in event && event.code !== 'Space') return
|
||
if ('pointerId' in event) document.releasePointerCapture(event.pointerId)
|
||
endFF()
|
||
}, { signal: ctrl.signal })
|
||
|
||
if (type === 'pointer') {
|
||
document.addEventListener('pointercancel', event => {
|
||
if ('pointerId' in event) document.releasePointerCapture(event.pointerId)
|
||
endFF()
|
||
}, { signal: ctrl.signal })
|
||
}
|
||
|
||
return { destroy: () => ctrl.abort() }
|
||
}
|
||
|
||
function updateState (state: { paused: boolean, time: number }) {
|
||
currentTime = state.time
|
||
paused = state.paused
|
||
}
|
||
|
||
$: $w2globby?.playerStateChanged({ paused, time: Math.floor(currentTime) })
|
||
$: $w2globby?.on('player', updateState)
|
||
|
||
function loadAnimeProgress () {
|
||
if (!mediaInfo.media.id || !mediaInfo.episode) return
|
||
|
||
const animeProgress = getAnimeProgress(mediaInfo.media.id)
|
||
if (!animeProgress || animeProgress.episode !== mediaInfo.episode) return
|
||
|
||
currentTime = Math.max(animeProgress.currentTime - 5, 0)
|
||
}
|
||
|
||
function saveAnimeProgress () {
|
||
if (!mediaInfo.media.id || !mediaInfo.episode) return
|
||
|
||
if (buffering || paused || video.readyState < 4) return
|
||
|
||
setAnimeProgress(mediaInfo.media.id, { episode: mediaInfo.episode, currentTime: video.currentTime, safeduration })
|
||
}
|
||
const saveProgressLoop = setInterval(saveAnimeProgress, 10000)
|
||
onDestroy(() => clearInterval(saveProgressLoop))
|
||
|
||
function handleWheel ({ shiftKey, deltaY }: WheelEvent) {
|
||
const sign = Math.sign(deltaY)
|
||
if (shiftKey) {
|
||
seek(Number($settings.playerSeek) * sign * -1)
|
||
} else {
|
||
changeVolume(-0.05 * sign)
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<svelte:document bind:fullscreenElement bind:visibilityState use:holdToFF={'key'} />
|
||
|
||
<div class='w-full h-full relative content-center bg-black overflow-clip text-left touch-none' class:fitWidth class:seeking class:pip={pictureInPictureElement} bind:this={wrapper} on:navigate={() => resetMove(2000)} on:wheel={handleWheel} on:keydown={stopAnimation} on:focusin={stopAnimation} on:pointerenter={stopAnimation} on:pointermove={stopAnimation}>
|
||
<video class='w-full h-full touch-none' preload='metadata' class:cursor-none={immersed} class:cursor-pointer={isMiniplayer} class:object-cover={fitWidth} class:opacity-0={$settings.playerDeband || seeking || pictureInPictureElement} class:absolute={$settings.playerDeband} class:top-0={$settings.playerDeband}
|
||
use:createSubtitles
|
||
use:createDeband={$settings.playerDeband}
|
||
use:holdToFF={'pointer'}
|
||
crossorigin='anonymous'
|
||
src={mediaInfo.file.url}
|
||
bind:videoHeight
|
||
bind:videoWidth
|
||
bind:currentTime
|
||
bind:duration
|
||
bind:ended
|
||
bind:paused
|
||
bind:muted
|
||
bind:readyState
|
||
bind:buffered
|
||
bind:playbackRate={$playbackRate}
|
||
bind:volume={exponentialVolume}
|
||
bind:this={video}
|
||
on:click={() => isMiniplayer ? goto('/app/player') : playPause()}
|
||
on:dblclick={fullscreen}
|
||
on:loadeddata={checkAudio}
|
||
on:loadedmetadata={loadAnimeProgress}
|
||
on:timeupdate={checkSkippableChapters}
|
||
on:timeupdate={checkCompletion}
|
||
on:loadedmetadata={autoPlay}
|
||
on:pointermove={() => resetMove()}
|
||
/>
|
||
{#if !isMiniplayer}
|
||
<div class='absolute w-full h-full flex items-center justify-center top-0 pointer-events-none'>
|
||
<DownloadStats {immersed} />
|
||
{#if seeking}
|
||
{#await thumbnailer.getThumbnail(seekIndex) then src}
|
||
{#if src}
|
||
<img {src} alt='thumbnail' class='w-full h-full bg-black absolute top-0 right-0 object-contain' loading='lazy' decoding='async' class:!object-cover={fitWidth} />
|
||
{/if}
|
||
{/await}
|
||
{/if}
|
||
{#if stats}
|
||
<div class='absolute top-10 left-10 border-white/15 border bg-black/60 pointer-events-auto px-3 py-2 rounded'>
|
||
<button class='absolute right-3 top-1' type='button' use:click={toggleStats}>×</button>
|
||
FPS: {stats.fps}<br />
|
||
Presented frames: {stats.presented}<br />
|
||
Dropped frames: {stats.dropped}<br />
|
||
Frame time: {stats.processing}<br />
|
||
Viewport: {stats.viewport}<br />
|
||
Resolution: {stats.resolution}<br />
|
||
Buffer health: {stats.buffer}<br />
|
||
Playback speed: x{stats.speed?.toFixed(1)}<br />
|
||
Subtitle delay: {subtitleDelay} sec
|
||
</div>
|
||
{/if}
|
||
<Options {wrapper} bind:openSubs {video} {seekTo} {selectAudio} {selectVideo} {fullscreen} {chapters} {subtitles} {videoFiles} {selectFile} {pip} bind:playbackRate={$playbackRate} bind:subtitleDelay id='player-options-button-top'
|
||
class='{($settings.minimalPlayerUI || SUPPORTS.isAndroid) ? 'inline-flex' : 'mobile:inline-flex hidden'} p-3 size-12 absolute z-[1] top-4 left-4 bg-black/20 pointer-events-auto transition-opacity delay-150 select:opacity-100 {immersed && 'opacity-0'}' />
|
||
{#if fastForwarding}
|
||
<div class='absolute top-10 font-bold text-sm animate-[fade-in_.4s_ease] flex items-center leading-none bg-black/60 px-4 py-2 rounded-2xl'>x2 <FastForward class='ml-2' size='12' fill='currentColor' /></div>
|
||
{/if}
|
||
{#if !SUPPORTS.isAndroidTV}
|
||
<div class='mobile:flex hidden gap-10 absolute items-center transition-opacity select:opacity-100 z-[0]' class:opacity-0={immersed || seeking}>
|
||
<Button class='p-3 size-10 pointer-events-auto rounded-[50%] bg-black/20' variant='ghost' disabled={!prev} on:click={() => prev?.()}>
|
||
<SkipBack fill='currentColor' strokeWidth='1' />
|
||
</Button>
|
||
<Button class='p-2.5 size-12 pointer-events-auto rounded-[50%] bg-black/20' variant='ghost' on:click={playPause}>
|
||
{#if paused}
|
||
<Play fill='currentColor' class='p-0.5' />
|
||
{:else}
|
||
<Pause fill='currentColor' strokeWidth='1' />
|
||
{/if}
|
||
</Button>
|
||
<Button class='p-3 size-10 pointer-events-auto rounded-[50%] bg-black/20' variant='ghost' disabled={!next} on:click={() => next?.()}>
|
||
<SkipForward fill='currentColor' strokeWidth='1' />
|
||
</Button>
|
||
</div>
|
||
<div class='size-full mobile:flex hidden justify-between absolute'>
|
||
<div class='h-full w-1/4 pointer-events-auto' on:dblclick|stopPropagation={() => seek(-Number($settings.playerSeek))} use:holdToFF={'pointer'} />
|
||
<div class='h-full w-1/4 pointer-events-auto' on:dblclick|stopPropagation={() => seek(Number($settings.playerSeek))} use:holdToFF={'pointer'} />
|
||
</div>
|
||
{/if}
|
||
{#if buffering}
|
||
<div in:fade={{ duration: 200, delay: 500 }} out:fade={{ duration: 200 }}>
|
||
<div class='border-[3px] rounded-[50%] w-10 h-10 drop-shadow-lg border-transparent border-t-white animate-spin' />
|
||
</div>
|
||
{/if}
|
||
<Animations />
|
||
</div>
|
||
{#if currentSkippable}
|
||
<ProgressButton onclick={skip} bind:animating size='default' duration={3000} class={cn('px-7 font-bold absolute bottom-40 right-10 transition-opacity delay-150', immersed && !animating && 'opacity-0')}>
|
||
Skip {currentSkippable}
|
||
</ProgressButton>
|
||
{/if}
|
||
<div class='absolute w-full bottom-0 flex flex-col gradient px-6 py-3 transition-opacity delay-150 select:opacity-100' class:opacity-0={immersed}>
|
||
<div class='flex justify-between gap-12 items-end'>
|
||
<div class='flex flex-col gap-2 text-left cursor-pointer'>
|
||
<EpisodesModal portal={wrapper} {mediaInfo} />
|
||
</div>
|
||
<div class='flex flex-col gap-2 grow-0 items-end self-end'>
|
||
<div class='text-[rgba(217,217,217,0.6)] text-sm leading-none font-light line-clamp-1 capitalize'>{getChapterTitle(seeking ? seekPercent * safeduration / 100 : currentTime, chapters) || ''}</div>
|
||
<div class='ml-auto self-end text-sm leading-none font-light text-nowrap'>{toTS(seeking ? seekPercent * safeduration / 100 : currentTime)} / {toTS(safeduration)}</div>
|
||
</div>
|
||
</div>
|
||
<Seekbar {duration} {currentTime} buffer={buffer / duration * 100} {chapters} bind:seeking bind:seek={seekPercent} on:seeked={finishSeek} on:seeking={startSeek} {thumbnailer} on:keydown={seekBarKey} on:dblclick={fullscreen} />
|
||
<div class='justify-between gap-2 {($settings.minimalPlayerUI || SUPPORTS.isAndroid) ? 'hidden' : 'mobile:hidden flex'}'>
|
||
<div class='flex text-white gap-2'>
|
||
<Button class='p-3 size-12 relative shrink-0' variant='ghost' on:click={playPause} on:keydown={keywrap(playPause)} id='player-play-pause-button' data-up='#player-seekbar'>
|
||
{#if paused}
|
||
<div transition:scaleBlurFade class='absolute'>
|
||
<Play size='24px' fill='currentColor' class='p-0.5' />
|
||
</div>
|
||
{:else}
|
||
<div transition:scaleBlurFade class='absolute'>
|
||
<Pause size='24px' fill='currentColor' strokeWidth='1' />
|
||
</div>
|
||
{/if}
|
||
</Button>
|
||
{#if prev}
|
||
<Button class='p-3 size-12' variant='ghost' on:click={prev} on:keydown={keywrap(prev)} id='player-prev-button' data-up='#player-seekbar' data-right='#player-next-button, #player-volume-button, #player-options-button'>
|
||
<SkipBack size='24px' fill='currentColor' strokeWidth='1' />
|
||
</Button>
|
||
{/if}
|
||
{#if next}
|
||
<Button class='p-3 size-12' variant='ghost' on:click={next} on:keydown={keywrap(next)} id='player-next-button' data-up='#player-seekbar' data-right='#player-volume-button, #player-options-button'>
|
||
<SkipForward size='24px' fill='currentColor' strokeWidth='1' />
|
||
</Button>
|
||
{/if}
|
||
<Volume bind:volume={$volume} bind:muted />
|
||
</div>
|
||
<div class='flex gap-2'>
|
||
{#if $playbackRate !== 1 && $playbackRate}
|
||
<div class='flex justify-center items-center leading-none text-base font-bold px-1 pt-0.5'>
|
||
x{$playbackRate?.toFixed(1)}
|
||
</div>
|
||
{/if}
|
||
<Options {fullscreen} {wrapper} {seekTo} bind:openSubs {video} {selectAudio} {selectVideo} {chapters} {subtitles} {videoFiles} {selectFile} {pip} bind:playbackRate={$playbackRate} bind:subtitleDelay id='player-options-button' />
|
||
{#if subtitles}
|
||
<Button class='p-3 size-12' variant='ghost' on:click={openSubs} on:keydown={keywrap(openSubs)} data-up='#player-seekbar'>
|
||
<Subtitles size='24px' fill='currentColor' strokeWidth='0' />
|
||
</Button>
|
||
{/if}
|
||
<Button class='p-3 size-12 relative shrink-0' variant='ghost' on:click={() => pip.pip()} on:keydown={keywrap(() => pip.pip())} data-up='#player-seekbar'>
|
||
{#if pictureInPictureElement}
|
||
<div transition:scaleBlurFade class='absolute'>
|
||
<PictureInPictureExit size='24px' strokeWidth='2' />
|
||
</div>
|
||
{:else}
|
||
<div transition:scaleBlurFade class='absolute'>
|
||
<PictureInPictureOff size='24px' strokeWidth='2' />
|
||
</div>
|
||
{/if}
|
||
</Button>
|
||
<!-- {#if false}
|
||
<Button class='p-3 size-12' variant='ghost' on:click={toggleCast} on:keydown={keywrap(toggleCast)} data-up='#player-seekbar'>
|
||
{#if cast}
|
||
<Cast size='24px' fill='white' strokeWidth='2' />
|
||
{:else}
|
||
<Cast size='24px' strokeWidth='2' />
|
||
{/if}
|
||
</Button>
|
||
{/if} -->
|
||
<Button class='p-3 size-12 relative animated-icon shrink-0' variant='ghost' on:click={fullscreen} on:keydown={keywrap(fullscreen)} data-up='#player-seekbar'>
|
||
{#if fullscreenElement}
|
||
<div transition:scaleBlurFade class='absolute'>
|
||
<Minimize size='24px' class='p-0.5' strokeWidth='2.5' />
|
||
</div>
|
||
{:else}
|
||
<div transition:scaleBlurFade class='absolute'>
|
||
<Maximize size='24px' class='p-0.5' strokeWidth='2.5' />
|
||
</div>
|
||
{/if}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{:else}
|
||
<div class='absolute w-full left-0 bottom-0 flex justify-center'>
|
||
<Button variant='ghost' class='drop-shadow-[0_0_7px_#000] mb-1 relative' size='icon' on:pointerdown={e => { e.stopPropagation(); playPause() }}>
|
||
{#if paused}
|
||
<div transition:scaleBlurFade class='absolute'>
|
||
<Play size={iconSizes.lg} fill='currentColor' class='px-0.5' />
|
||
</div>
|
||
{:else}
|
||
<div transition:scaleBlurFade class='absolute'>
|
||
<Pause size={iconSizes.lg} fill='currentColor' strokeWidth='1' />
|
||
</div>
|
||
{/if}
|
||
</Button>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<style>
|
||
.fitWidth :global(.deband-canvas) {
|
||
object-fit: cover !important;
|
||
}
|
||
|
||
.seeking :global(.deband-canvas), .pip :global(.deband-canvas), .seeking :global(.JASSUB) {
|
||
display: none;
|
||
}
|
||
|
||
.gradient {
|
||
background: linear-gradient(to top, oklab(0 0 0 / 0.85) 0%, oklab(0 0 0 / 0.7) 35%, oklab(0 0 0 / 0) 100%);
|
||
}
|
||
|
||
.gradient-to-bottom {
|
||
background: linear-gradient(to bottom, oklab(0 0 0 / 0.85) 0%, oklab(0 0 0 / 0.7) 35%, oklab(0 0 0 / 0) 100%);
|
||
}
|
||
</style>
|