diff --git a/package.json b/package.json index 36c01ad..6939e5c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ab3a8f..a42f32d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/app.css b/src/app.css index 8da3cb1..8fb94b5 100644 --- a/src/app.css +++ b/src/app.css @@ -193,6 +193,10 @@ details:active, filter: invert(1) grayscale(1) contrast(100) } +.svelte-keybinds { + background: black !important; +} + /* Backplate related things */ body { diff --git a/src/lib/components/ui/player/options.svelte b/src/lib/components/ui/player/options.svelte index cdf681f..e46a36c 100644 --- a/src/lib/components/ui/player/options.svelte +++ b/src/lib/components/ui/player/options.svelte @@ -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['class'] = '' export { className as class } + + export let showKeybinds = false + function close () { + if (showKeybinds) { + showKeybinds = false + } else { + open = false + } + } @@ -36,55 +50,117 @@ - -
{ open = false }} class='h-full flex w-full justify-center items-center overflow-y-scroll'> - - - Audio - - {#each Object.entries(normalizeTracks(video.audioTracks ?? [])) as [lang, tracks] (lang)} + +
+ {#if showKeybinds} +
+ Drag and drop binds to change them +
+ + {#if item?.type} +
+ {#if item.icon} + + {/if} +
+ {:else} +
{item?.id ?? ''}
+ {/if} +
+ {:else} + + + Audio + + {#each Object.entries(normalizeTracks(video.audioTracks ?? [])) as [lang, tracks] (lang)} + + {lang} + + {#each tracks as track (track.id)} + { selectAudio(track.id); open = false }}> + {track.label} + + {/each} + + + {/each} + + + + Video + + {#each Object.entries(normalizeTracks(video.videoTracks ?? [])) as [lang, tracks] (lang)} + + {lang} + + {#each tracks as track (track.id)} + { selectVideo(track.id); open = false }}> + {track.label} + + {/each} + + + {/each} + + + + Subtitles + - {lang} - - {#each tracks as track (track.id)} - { selectAudio(track.id); open = false }}> - {track.label} - - {/each} - + Consulting - {/each} - - - - Video - - {#each Object.entries(normalizeTracks(video.videoTracks ?? [])) as [lang, tracks] (lang)} - {lang} - - {#each tracks as track (track.id)} - { selectVideo(track.id); open = false }}> - {track.label} - - {/each} - + Support - {/each} - - - - Subtitles - - - Consulting - - - Support - - - - + + + + Chapters + + {#each chapters as { text, start }, i (i)} + { seekTo(start); open = false }}> +
+ {text || '?'} + {toTS(start || 0)} +
+
+ {/each} +
+
+ + Playback Rate + + { playbackRate = 0.5; open = false }}> + 0.5x + + { playbackRate = 0.75; open = false }}> + 0.75x + + { playbackRate = 1; open = false }}> + 1x + + { playbackRate = 1.25; open = false }}> + 1.25x + + { playbackRate = 1.5; open = false }}> + 1.5x + + { playbackRate = 1.75; open = false }}> + 1.75x + + { playbackRate = 2; open = false }}> + 2x + + + + (showKeybinds = !showKeybinds)}> + Keybinds + + + Fullscreen + + + {/if}
diff --git a/src/lib/components/ui/player/player.svelte b/src/lib/components/ui/player/player.svelte index 2fa7683..4b730f3 100644 --- a/src/lib/components/ui/player/player.svelte +++ b/src/lib/components/ui/player/player.svelte @@ -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' + } + })
-