wip: player keybinds

This commit is contained in:
ThaUnknown 2025-03-22 15:14:47 +01:00
parent 445afd1320
commit 86cfff1ec7
No known key found for this signature in database
5 changed files with 411 additions and 70 deletions

View file

@ -60,6 +60,7 @@
"lucide-svelte": "^0.452.0",
"p2pt": "^1.5.1",
"simple-store-svelte": "^1.0.6",
"svelte-keybinds": "^1.0.9",
"svelte-persisted-store": "^0.12.0",
"tailwind-merge": "^2.5.4",
"tailwind-variants": "^0.2.1",

View file

@ -74,6 +74,9 @@ importers:
simple-store-svelte:
specifier: ^1.0.6
version: 1.0.6
svelte-keybinds:
specifier: ^1.0.9
version: 1.0.9
svelte-persisted-store:
specifier: ^0.12.0
version: 0.12.0(svelte@4.2.19)
@ -125,7 +128,7 @@ importers:
version: 0.0.18(svelte@4.2.19)
eslint-config-standard-universal:
specifier: github:thaunknown/eslint-config-standard-universal
version: https://codeload.github.com/thaunknown/eslint-config-standard-universal/tar.gz/4e269161dc3d4285eb96781a29c13c9c66ded4d3(@typescript-eslint/parser@8.18.0(eslint@9.17.0(jiti@1.21.6))(typescript@5.7.2))(jiti@1.21.6)
version: https://codeload.github.com/thaunknown/eslint-config-standard-universal/tar.gz/c0cd0946f376fa99433109da8553c7c2013f2934(@typescript-eslint/parser@8.18.0(eslint@9.17.0(jiti@1.21.6))(typescript@5.7.2))(jiti@1.21.6)
globals:
specifier: ^15.11.0
version: 15.11.0
@ -1173,8 +1176,8 @@ packages:
peerDependencies:
eslint: '>=6.0.0'
eslint-config-standard-universal@https://codeload.github.com/thaunknown/eslint-config-standard-universal/tar.gz/4e269161dc3d4285eb96781a29c13c9c66ded4d3:
resolution: {tarball: https://codeload.github.com/thaunknown/eslint-config-standard-universal/tar.gz/4e269161dc3d4285eb96781a29c13c9c66ded4d3}
eslint-config-standard-universal@https://codeload.github.com/thaunknown/eslint-config-standard-universal/tar.gz/c0cd0946f376fa99433109da8553c7c2013f2934:
resolution: {tarball: https://codeload.github.com/thaunknown/eslint-config-standard-universal/tar.gz/c0cd0946f376fa99433109da8553c7c2013f2934}
version: 1.0.4
eslint-import-resolver-node@0.3.9:
@ -2198,6 +2201,9 @@ packages:
peerDependencies:
svelte: ^3.19.0 || ^4.0.0
svelte-keybinds@1.0.9:
resolution: {integrity: sha512-bQt9azkXX4SgMJpJzYWQB6D0hj45+Ro2+2Awr4YNtjmuRuKdio+Rxuhky5JJyBBfyRQ7YT63nSR3whH4FACv1A==}
svelte-persisted-store@0.12.0:
resolution: {integrity: sha512-BdBQr2SGSJ+rDWH8/aEV5GthBJDapVP0GP3fuUCA7TjYG5ctcB+O9Mj9ZC0+Jo1oJMfZUd1y9H68NFRR5MyIJA==}
engines: {node: '>=0.14'}
@ -2224,8 +2230,8 @@ packages:
resolution: {integrity: sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==}
engines: {node: '>=16'}
svelte@5.22.6:
resolution: {integrity: sha512-dxHyh3USJyayafSt5I5QD7KuoCM5ZGdIOtLQiKHEro7tymdh0jMcNkiSBVHW+LOA2jEqZEHhyfwN6/pCjx0Fug==}
svelte@5.23.1:
resolution: {integrity: sha512-DUu3e5tQDO+PtKffjqJ548YfeKtw2Rqc9/+nlP26DZ0AopWTJNylkNnTOP/wcgIt1JSnovyISxEZ/lDR1OhbOw==}
engines: {node: '>=18'}
tabbable@6.2.0:
@ -3558,7 +3564,7 @@ snapshots:
eslint: 9.17.0(jiti@1.21.6)
semver: 7.6.3
eslint-config-standard-universal@https://codeload.github.com/thaunknown/eslint-config-standard-universal/tar.gz/4e269161dc3d4285eb96781a29c13c9c66ded4d3(@typescript-eslint/parser@8.18.0(eslint@9.17.0(jiti@1.21.6))(typescript@5.7.2))(jiti@1.21.6):
eslint-config-standard-universal@https://codeload.github.com/thaunknown/eslint-config-standard-universal/tar.gz/c0cd0946f376fa99433109da8553c7c2013f2934(@typescript-eslint/parser@8.18.0(eslint@9.17.0(jiti@1.21.6))(typescript@5.7.2))(jiti@1.21.6):
dependencies:
'@stylistic/eslint-plugin': 4.2.0(eslint@9.17.0(jiti@1.21.6))(typescript@5.7.2)
eslint: 9.17.0(jiti@1.21.6)
@ -3566,9 +3572,9 @@ snapshots:
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.18.0(eslint@9.17.0(jiti@1.21.6))(typescript@5.7.2))(eslint@9.17.0(jiti@1.21.6))
eslint-plugin-n: 17.15.0(eslint@9.17.0(jiti@1.21.6))
eslint-plugin-promise: 7.2.1(eslint@9.17.0(jiti@1.21.6))
eslint-plugin-svelte: 3.1.0(eslint@9.17.0(jiti@1.21.6))(svelte@5.22.6)
eslint-plugin-svelte: 3.1.0(eslint@9.17.0(jiti@1.21.6))(svelte@5.23.1)
globals: 16.0.0
svelte: 5.22.6
svelte: 5.23.1
typescript: 5.7.2
typescript-eslint: 8.18.0(eslint@9.17.0(jiti@1.21.6))(typescript@5.7.2)
transitivePeerDependencies:
@ -3650,7 +3656,7 @@ snapshots:
'@eslint-community/eslint-utils': 4.4.1(eslint@9.17.0(jiti@1.21.6))
eslint: 9.17.0(jiti@1.21.6)
eslint-plugin-svelte@3.1.0(eslint@9.17.0(jiti@1.21.6))(svelte@5.22.6):
eslint-plugin-svelte@3.1.0(eslint@9.17.0(jiti@1.21.6))(svelte@5.23.1):
dependencies:
'@eslint-community/eslint-utils': 4.4.1(eslint@9.17.0(jiti@1.21.6))
'@jridgewell/sourcemap-codec': 1.5.0
@ -3662,9 +3668,9 @@ snapshots:
postcss-load-config: 3.1.4(postcss@8.4.49)
postcss-safe-parser: 7.0.1(postcss@8.4.49)
semver: 7.6.3
svelte-eslint-parser: 1.0.1(svelte@5.22.6)
svelte-eslint-parser: 1.0.1(svelte@5.23.1)
optionalDependencies:
svelte: 5.22.6
svelte: 5.23.1
transitivePeerDependencies:
- ts-node
@ -4634,7 +4640,7 @@ snapshots:
transitivePeerDependencies:
- picomatch
svelte-eslint-parser@1.0.1(svelte@5.22.6):
svelte-eslint-parser@1.0.1(svelte@5.23.1):
dependencies:
eslint-scope: 8.2.0
eslint-visitor-keys: 4.2.0
@ -4643,12 +4649,14 @@ snapshots:
postcss-scss: 4.0.9(postcss@8.4.49)
postcss-selector-parser: 7.1.0
optionalDependencies:
svelte: 5.22.6
svelte: 5.23.1
svelte-hmr@0.16.0(svelte@4.2.19):
dependencies:
svelte: 4.2.19
svelte-keybinds@1.0.9: {}
svelte-persisted-store@0.12.0(svelte@4.2.19):
dependencies:
svelte: 4.2.19
@ -4685,7 +4693,7 @@ snapshots:
magic-string: 0.30.12
periscopic: 3.1.0
svelte@5.22.6:
svelte@5.23.1:
dependencies:
'@ampproject/remapping': 2.3.0
'@jridgewell/sourcemap-codec': 1.5.0

View file

@ -193,6 +193,10 @@ details:active,
filter: invert(1) grayscale(1) contrast(100)
}
.svelte-keybinds {
background: black !important;
}
/* Backplate related things */
body {

View file

@ -3,11 +3,12 @@
import * as Tree from '$lib/components/ui/tree'
import { Button } from '$lib/components/ui/button'
import { EllipsisVertical } from 'lucide-svelte'
import { normalizeTracks } from './util'
import { normalizeTracks, type Chapter } from './util'
import type { Writable } from 'simple-store-svelte'
import { tick } from 'svelte'
import type { HTMLAttributes } from 'svelte/elements'
import { cn } from '$lib/utils'
import { cn, toTS } from '$lib/utils'
import Keybinds from 'svelte-keybinds'
export let wrapper: HTMLDivElement
@ -15,6 +16,10 @@
export let selectAudio: (id: string) => void
export let selectVideo: (id: string) => void
export let fullscreen: () => void
export let chapters: Chapter[]
export let seekTo: (time: number) => void
export let playbackRate: number
let open = false
@ -28,6 +33,15 @@
let className: HTMLAttributes<HTMLDivElement>['class'] = ''
export { className as class }
export let showKeybinds = false
function close () {
if (showKeybinds) {
showKeybinds = false
} else {
open = false
}
}
</script>
<Dialog.Root portal={wrapper} bind:open>
@ -36,55 +50,117 @@
<EllipsisVertical size='24px' class='p-[1px]' />
</Button>
</Dialog.Trigger>
<Dialog.Content class='absolute bg-transparent border-none p-0 shadow-none h-full w-full overflow-hidden'>
<div on:pointerdown|self={() => { open = false }} class='h-full flex w-full justify-center items-center overflow-y-scroll'>
<Tree.Root bind:state={treeState}>
<Tree.Item>
<span slot='trigger'>Audio</span>
<Tree.Sub>
{#each Object.entries(normalizeTracks(video.audioTracks ?? [])) as [lang, tracks] (lang)}
<Dialog.Content class='absolute bg-transparent border-none p-0 shadow-none size-full overflow-hidden'>
<div on:pointerdown|self={close} class='size-full flex justify-center items-center flex-col overflow-y-scroll text-[6px] lg:text-xs'>
{#if showKeybinds}
<div class='bg-black py-3 px-4 rounded-md text-sm lg:text-lg font-bold mb-4'>
Drag and drop binds to change them
</div>
<Keybinds let:prop={item} autosave={true} clickable={true}>
{#if item?.type}
<div class='size-full flex justify-center p-1.5 lg:p-3' title={item.desc}>
{#if item.icon}
<svelte:component this={item.icon} size='2rem' class='h-full' fill={item.id === 'play_arrow' ? 'currentColor' : 'none'} />
{/if}
</div>
{:else}
<div class='size-full content-center text-center lg:text-lg' title={item?.desc}>{item?.id ?? ''}</div>
{/if}
</Keybinds>
{:else}
<Tree.Root bind:state={treeState}>
<Tree.Item>
<span slot='trigger'>Audio</span>
<Tree.Sub>
{#each Object.entries(normalizeTracks(video.audioTracks ?? [])) as [lang, tracks] (lang)}
<Tree.Item>
<span slot='trigger' class='capitalize'>{lang}</span>
<Tree.Sub>
{#each tracks as track (track.id)}
<Tree.Item active={track.enabled} on:click={() => { selectAudio(track.id); open = false }}>
<span>{track.label}</span>
</Tree.Item>
{/each}
</Tree.Sub>
</Tree.Item>
{/each}
</Tree.Sub>
</Tree.Item>
<Tree.Item>
<span slot='trigger'>Video</span>
<Tree.Sub>
{#each Object.entries(normalizeTracks(video.videoTracks ?? [])) as [lang, tracks] (lang)}
<Tree.Item>
<span slot='trigger' class='capitalize'>{lang}</span>
<Tree.Sub>
{#each tracks as track (track.id)}
<Tree.Item active={track.enabled} on:click={() => { selectVideo(track.id); open = false }}>
<span>{track.label}</span>
</Tree.Item>
{/each}
</Tree.Sub>
</Tree.Item>
{/each}
</Tree.Sub>
</Tree.Item>
<Tree.Item id='subs'>
<span slot='trigger'>Subtitles</span>
<Tree.Sub>
<Tree.Item>
<span slot='trigger' class='capitalize'>{lang}</span>
<Tree.Sub>
{#each tracks as track (track.id)}
<Tree.Item active={track.enabled} on:click={() => { selectAudio(track.id); open = false }}>
<span>{track.label}</span>
</Tree.Item>
{/each}
</Tree.Sub>
<span>Consulting</span>
</Tree.Item>
{/each}
</Tree.Sub>
</Tree.Item>
<Tree.Item>
<span slot='trigger'>Video</span>
<Tree.Sub>
{#each Object.entries(normalizeTracks(video.videoTracks ?? [])) as [lang, tracks] (lang)}
<Tree.Item>
<span slot='trigger' class='capitalize'>{lang}</span>
<Tree.Sub>
{#each tracks as track (track.id)}
<Tree.Item active={track.enabled} on:click={() => { selectVideo(track.id); open = false }}>
<span>{track.label}</span>
</Tree.Item>
{/each}
</Tree.Sub>
<span>Support</span>
</Tree.Item>
{/each}
</Tree.Sub>
</Tree.Item>
<Tree.Item id='subs'>
<span slot='trigger'>Subtitles</span>
<Tree.Sub>
<Tree.Item>
<span>Consulting</span>
</Tree.Item>
<Tree.Item>
<span>Support</span>
</Tree.Item>
</Tree.Sub>
</Tree.Item>
</Tree.Root>
</Tree.Sub>
</Tree.Item>
<Tree.Item>
<span slot='trigger'>Chapters</span>
<Tree.Sub>
{#each chapters as { text, start }, i (i)}
<Tree.Item on:click={() => { seekTo(start); open = false }}>
<div class='flex justify-between w-full'>
<span>{text || '?'}</span>
<span class='text-muted-foreground'>{toTS(start || 0)}</span>
</div>
</Tree.Item>
{/each}
</Tree.Sub>
</Tree.Item>
<Tree.Item>
<span slot='trigger'>Playback Rate</span>
<Tree.Sub>
<Tree.Item on:click={() => { playbackRate = 0.5; open = false }}>
<span>0.5x</span>
</Tree.Item>
<Tree.Item on:click={() => { playbackRate = 0.75; open = false }}>
<span>0.75x</span>
</Tree.Item>
<Tree.Item on:click={() => { playbackRate = 1; open = false }}>
<span>1x</span>
</Tree.Item>
<Tree.Item on:click={() => { playbackRate = 1.25; open = false }}>
<span>1.25x</span>
</Tree.Item>
<Tree.Item on:click={() => { playbackRate = 1.5; open = false }}>
<span>1.5x</span>
</Tree.Item>
<Tree.Item on:click={() => { playbackRate = 1.75; open = false }}>
<span>1.75x</span>
</Tree.Item>
<Tree.Item on:click={() => { playbackRate = 2; open = false }}>
<span>2x</span>
</Tree.Item>
</Tree.Sub>
</Tree.Item>
<Tree.Item on:click={() => (showKeybinds = !showKeybinds)}>
Keybinds
</Tree.Item>
<Tree.Item on:click={fullscreen}>
Fullscreen
</Tree.Item>
</Tree.Root>
{/if}
</div>
</Dialog.Content>
</Dialog.Root>

View file

@ -7,14 +7,14 @@
import { Button } from '$lib/components/ui/button'
import { settings } from '$lib/modules/settings'
import { bindPiP, toTS } from '$lib/utils'
import { Cast, FastForward, Maximize, Minimize, Pause, Rewind, SkipBack, SkipForward } from 'lucide-svelte'
import { Cast, FastForward, Maximize, Minimize, Pause, Rewind, SkipBack, SkipForward, Captions, Contrast, List, PictureInPicture2, Proportions, RefreshCcw, RotateCcw, RotateCw, ScreenShare, Volume1, Volume2, VolumeX } from 'lucide-svelte'
import { writable, type Writable } from 'simple-store-svelte'
import { persisted } from 'svelte-persisted-store'
import { toast } from 'svelte-sonner'
import Seekbar from './seekbar.svelte'
import type { SvelteMediaTimeRange } from 'svelte/elements'
import { fade } from 'svelte/transition'
import { autoPiP, getChapterTitle, sanitizeChapters, type MediaInfo } from './util'
import { autoPiP, getChapterTitle, sanitizeChapters, type Chapter, type MediaInfo } from './util'
import Thumbnailer from './thumbnailer'
import { onMount } from 'svelte'
import native from '$lib/modules/native'
@ -24,6 +24,7 @@
import EpisodesList from '$lib/components/EpisodesList.svelte'
import { episodes } from '$lib/modules/anizip'
import Volume from './volume.svelte'
import { loadWithDefaults } from 'svelte-keybinds'
export let mediaInfo: MediaInfo
// bindings
@ -70,6 +71,10 @@
return enable ? video.requestPictureInPicture() : document.exitPictureInPicture()
}
function toggleCast () {
// TODO
}
$: fullscreenElement ? screen.orientation.lock('landscape') : screen.orientation.unlock()
function checkAudio () {
@ -127,6 +132,10 @@
if (!wasPaused) video.play()
}
function screenshot () {
// TODO
}
// animations
function playAnimation (type: 'play' | 'pause' | 'seekforw' | 'seekback') {
@ -157,11 +166,97 @@
// other
$: chapters = sanitizeChapters([
{ start: 5, end: 15, text: 'Chapter 0' },
{ start: 5, end: 15, text: 'OP' },
{ start: 1.0 * 60, end: 1.2 * 60, text: 'Chapter 1' },
{ start: 1.4 * 60, end: 88, text: 'Chapter 2 ' }
], safeduration)
let currentSkippable: string | null = null
function checkSkippableChapters () {
const current = findChapter(currentTime)
if (current) {
currentSkippable = isChapterSkippable(current)
}
}
const skippableChaptersRx: Array<[string, RegExp]> = [
['Opening', /^op$|opening$|^ncop/mi],
['Ending', /^ed$|ending$|^nced/mi],
['Recap', /recap/mi]
]
function isChapterSkippable (chapter: Chapter) {
for (const [name, regex] of skippableChaptersRx) {
if (regex.test(chapter.text)) {
return name
}
}
return null
}
function findChapter (time: number) {
return chapters.find(({ start, end }) => time >= start && time <= end)
}
function skip () {
const current = findChapter(currentTime)
if (current) {
if (!isChapterSkippable(current) && (current.end - current.start) > 100) {
currentTime = currentTime + 85
} else {
const endtime = current.end
if ((safeduration - endtime | 0) === 0) return next()
currentTime = endtime
currentSkippable = null
}
} else if (currentTime < 10) {
currentTime = 90
} else if (safeduration - currentTime < 90) {
currentTime = safeduration
} else {
currentTime = currentTime + 85
}
video.currentTime = currentTime
}
let stats: any | 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))
$: native.setMediaSession(mediaInfo.session)
@ -184,22 +279,173 @@
if (['ArrowLeft', 'ArrowRight'].includes(event.key)) event.stopPropagation()
switch (event.key) {
case 'ArrowLeft':
seek(-5)
seek(-$settings.playerSeek)
break
case 'ArrowRight':
seek(5)
seek($settings.playerSeek)
break
case 'Enter':
playPause()
break
}
}
let fitWidth = false
loadWithDefaults({
KeyX: {
fn: () => screenshot(),
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: () => 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(),
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: () => {
seek(-$settings.playerSeek)
},
id: 'fast_rewind',
icon: Rewind,
type: 'icon',
desc: 'Rewind'
},
ArrowRight: {
fn: () => {
seek($settings.playerSeek)
},
id: 'fast_forward',
icon: FastForward,
type: 'icon',
desc: 'Seek'
},
ArrowUp: {
fn: () => {
$volume = Math.min(1, $volume + 0.05)
},
id: 'volume_up',
icon: Volume2,
type: 'icon',
desc: 'Volume Up'
},
ArrowDown: {
fn: () => {
$volume = Math.max(0, $volume - 0.05)
},
id: 'volume_down',
icon: Volume1,
type: 'icon',
desc: 'Volume Down'
},
BracketLeft: {
fn: () => { playbackRate = video.defaultPlaybackRate -= 0.1 },
id: 'history',
icon: RotateCcw,
type: 'icon',
desc: 'Decrease Playback Rate'
},
BracketRight: {
fn: () => { playbackRate = video.defaultPlaybackRate += 0.1 },
id: 'update',
icon: RotateCw,
type: 'icon',
desc: 'Increase Playback Rate'
},
Backslash: {
fn: () => { playbackRate = video.defaultPlaybackRate = 1 },
icon: RefreshCcw,
id: 'schedule',
type: 'icon',
desc: 'Reset Playback Rate'
}
})
</script>
<svelte:document bind:fullscreenElement use:bindPiP={pictureInPictureElement} />
<div style:aspect-ratio='{videoWidth} / {videoHeight}' class='max-w-full max-h-full min-w-[clamp(0%,700px,100%)] relative content-center fullscreen:bg-black fullscreen:rounded-none rounded-xl overflow-clip text-left' bind:this={wrapper}>
<video class='w-full max-h-full grow bg-black' preload='auto' class:cursor-none={immersed}
<video class='w-full max-h-full grow bg-black' preload='auto' class:cursor-none={immersed} class:object-cover={fitWidth}
src={mediaInfo.url}
bind:videoHeight
bind:videoWidth
@ -216,6 +462,7 @@
on:click={playPause}
on:dblclick={fullscreen}
on:loadeddata={checkAudio}
on:timeupdate={checkSkippableChapters}
use:autoPiP={pip}
/>
<div class='absolute w-full h-full flex items-center justify-center top-0 pointer-events-none'>
@ -224,7 +471,7 @@
<img {src} alt='thumbnail' class='w-full h-full bg-black absolute top-0 right-0' loading='lazy' decoding='async' />
{/await}
{/if}
<Options {wrapper} bind:openSubs {video} {selectAudio} {selectVideo} class='mobile:inline-flex hidden p-3 w-12 h-12 absolute top-10 right-10 backdrop-blur-lg border-white/15 border bg-black/20 pointer-events-auto select:opacity-100 cursor-default {immersed && 'opacity-0'}' />
<Options {wrapper} bind:openSubs {video} {seekTo} {selectAudio} {selectVideo} {fullscreen} {chapters} bind:playbackRate class='mobile:inline-flex hidden p-3 w-12 h-12 absolute top-10 right-10 backdrop-blur-lg border-white/15 border bg-black/20 pointer-events-auto select:opacity-100 cursor-default {immersed && 'opacity-0'}' />
<div class='mobile:flex hidden gap-4 absolute items-center select:opacity-100 cursor-default' class:opacity-0={immersed}>
<Button class='p-3 w-16 h-16 pointer-events-auto rounded-[50%] backdrop-blur-lg border-white/15 border bg-black/20' variant='ghost'>
<SkipBack size='24px' fill='currentColor' strokeWidth='1' />
@ -260,7 +507,7 @@
{/each}
</div>
<div class='absolute w-full bottom-0 flex flex-col gradient px-10 py-4 transition-opacity select:opacity-100 cursor-default' class:opacity-0={immersed}>
<div class='flex justify-between gap-12'>
<div class='flex justify-between gap-12 items-end'>
<div class='flex flex-col gap-2 text-left cursor-pointer'>
<div class='text-white text-lg font-normal leading-none line-clamp-1 hover:text-neutral-300' use:click={() => goto(`/app/anime/${mediaInfo.media.id}`)}>{mediaInfo.session.title}</div>
<Sheet.Root portal={wrapper}>
@ -273,6 +520,11 @@
</Sheet.Root>
</div>
<div class='flex flex-col gap-2 grow-0 items-end self-end'>
{#if currentSkippable}
<Button on:click={skip} class='font-bold mb-2'>
Skip {currentSkippable}
</Button>
{/if}
<div class='text-[rgba(217,217,217,0.6)] text-sm leading-none font-light line-clamp-1'>{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>
@ -296,7 +548,7 @@
<Volume bind:volume={$volume} bind:muted />
</div>
<div class='flex gap-2'>
<Options {wrapper} bind:openSubs {video} {selectAudio} {selectVideo} />
<Options {fullscreen} {wrapper} {seekTo} bind:openSubs {video} {selectAudio} {selectVideo} {chapters} bind:playbackRate />
<Button class='p-3 w-12 h-12' variant='ghost' on:click={openSubs}>
<Subtitles size='24px' fill='currentColor' strokeWidth='0' />
</Button>