From e95aef43dce233ac9f6dddf99b5c948bb4cc2c3f Mon Sep 17 00:00:00 2001 From: ThaUnknown <6506529+ThaUnknown@users.noreply.github.com> Date: Wed, 9 Apr 2025 01:16:36 +0200 Subject: [PATCH] feat: subtitles, miniplayer, burn-in --- package.json | 1 + pnpm-lock.yaml | 15 + src/app.d.ts | 14 +- src/lib/components/ui/player/index.ts | 2 +- .../components/ui}/player/mediahandler.svelte | 25 +- src/lib/components/ui/player/options.svelte | 40 ++- src/lib/components/ui/player/page.svelte | 49 +++ src/lib/components/ui/player/player.svelte | 105 +++++-- .../components/ui}/player/resolver.ts | 2 +- src/lib/components/ui/player/subtitles.ts | 296 ++++++++++++++++++ src/lib/components/ui/player/thumbnailer.ts | 6 + src/lib/components/ui/player/util.ts | 70 ++++- src/lib/modules/native.ts | 10 +- src/lib/modules/torrent/client.ts | 1 - src/lib/utils.ts | 36 +++ src/routes/app/+layout.svelte | 3 + src/routes/app/player/+page.svelte | 42 +-- tsconfig.json | 2 +- 18 files changed, 616 insertions(+), 103 deletions(-) rename src/{routes/app => lib/components/ui}/player/mediahandler.svelte (62%) create mode 100644 src/lib/components/ui/player/page.svelte rename src/{routes/app => lib/components/ui}/player/resolver.ts (99%) create mode 100644 src/lib/components/ui/player/subtitles.ts diff --git a/package.json b/package.json index 6a41a95..b8e3b1b 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "globals": "^15.11.0", "gql.tada": "^1.8.10", "hayase-extensions": "github:hayase-app/extensions", + "jassub": "^1.7.18", "svelte": "^4.2.19", "svelte-check": "^4.0.5", "svelte-radix": "^1.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68a2589..d4db988 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,6 +138,9 @@ importers: hayase-extensions: specifier: github:hayase-app/extensions version: https://codeload.github.com/hayase-app/extensions/tar.gz/edf2e76fd25e9ed24cde1be03f82ce5703758e7a + jassub: + specifier: ^1.7.18 + version: 1.7.18 svelte: specifier: ^4.2.19 version: 4.2.19 @@ -1617,6 +1620,9 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jassub@1.7.18: + resolution: {integrity: sha512-8cYnJlWuUP7xsdvoYyLVIcvXVa+0NvP1H1//yPck/LOHdnzY4KRwELNVx0khEd5BLN3bfWnMrcqFiHyTINObhA==} + jiti@1.21.6: resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==} hasBin: true @@ -2033,6 +2039,9 @@ packages: rusha@0.8.14: resolution: {integrity: sha512-cLgakCUf6PedEu15t8kbsjnwIFFR2D4RfL+W3iWFJ4iac7z4B0ZI8fxy4R3J956kAI68HclCFGL8MPoUVC3qVA==} + rvfc-polyfill@1.0.7: + resolution: {integrity: sha512-seBl7J1J3/k0LuzW2T9fG6JIOpni5AbU+/87LA+zTYKgTVhsfShmS8K/yOo1eeEjGJHnAdkVAUUM+PEjN9Mpkw==} + sade@1.8.1: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} @@ -4061,6 +4070,10 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jassub@1.7.18: + dependencies: + rvfc-polyfill: 1.0.7 + jiti@1.21.6: {} js-levenshtein@1.1.6: {} @@ -4440,6 +4453,8 @@ snapshots: rusha@0.8.14: {} + rvfc-polyfill@1.0.7: {} + sade@1.8.1: dependencies: mri: 1.2.0 diff --git a/src/app.d.ts b/src/app.d.ts index 5798e8a..f9f3524 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -30,6 +30,13 @@ export interface TorrentFile { id: number } +export interface Attachment { + filename: string + mimetype: string + id: number + url: string +} + export interface Native { authAL: (url: string) => Promise restart: () => Promise @@ -53,9 +60,12 @@ export interface Native { setActionHandler: (action: MediaSessionAction | 'enterpictureinpicture', handler: MediaSessionActionHandler | null) => void checkAvailableSpace: (_?: unknown) => Promise checkIncomingConnections: (_?: unknown) => Promise - updatePeerCounts: (hashes: string[]) => Promise> + updatePeerCounts: (hashes: string[]) => Promise> playTorrent: (id: string) => Promise - getAttachmentsURL: () => Promise + attachments: (hash: string, id: number) => Promise + tracks: (hash: string, id: number) => Promise> + subtitles: (hash: string, id: number, cb: (subtitle: { text: string, time: number, duration: number }, trackNumber: number) => void) => Promise + chapters: (hash: string, id: number) => Promise> isApp: boolean } diff --git a/src/lib/components/ui/player/index.ts b/src/lib/components/ui/player/index.ts index dae87ae..d1135b6 100644 --- a/src/lib/components/ui/player/index.ts +++ b/src/lib/components/ui/player/index.ts @@ -1 +1 @@ -export { default as Player } from './player.svelte' +export { default as Player } from './page.svelte' diff --git a/src/routes/app/player/mediahandler.svelte b/src/lib/components/ui/player/mediahandler.svelte similarity index 62% rename from src/routes/app/player/mediahandler.svelte rename to src/lib/components/ui/player/mediahandler.svelte index 15c6ab5..f6495d0 100644 --- a/src/routes/app/player/mediahandler.svelte +++ b/src/lib/components/ui/player/mediahandler.svelte @@ -1,15 +1,18 @@ - + diff --git a/src/lib/components/ui/player/options.svelte b/src/lib/components/ui/player/options.svelte index e13c11b..630427f 100644 --- a/src/lib/components/ui/player/options.svelte +++ b/src/lib/components/ui/player/options.svelte @@ -3,10 +3,11 @@ import { tick } from 'svelte' import Keybinds from 'svelte-keybinds' - import { normalizeTracks, type Chapter } from './util' + import { normalizeSubs, normalizeTracks, type Chapter } from './util' import type { Writable } from 'simple-store-svelte' import type { HTMLAttributes } from 'svelte/elements' + import type Subtitles from './subtitles' import * as Dialog from '$lib/components/ui/dialog' import * as Tree from '$lib/components/ui/tree' @@ -23,6 +24,10 @@ export let chapters: Chapter[] export let seekTo: (time: number) => void export let playbackRate: number + export let subtitles: Subtitles | undefined + + $: tracks = subtitles?._tracks + $: current = subtitles?.current let open = false @@ -106,17 +111,28 @@ {/each} - - Subtitles - - - Consulting - - - Support - - - + {#if subtitles} + + Subtitles + + { $current = -1; open = false }}> + OFF + + {#each Object.entries(normalizeSubs($tracks)) as [lang, tracks] (lang)} + + {lang} + + {#each tracks as { number, language, name }, i (i)} + { $current = number; open = false }}> + {name} + + {/each} + + + {/each} + + + {/if} Chapters diff --git a/src/lib/components/ui/player/page.svelte b/src/lib/components/ui/player/page.svelte new file mode 100644 index 0000000..05aecdc --- /dev/null +++ b/src/lib/components/ui/player/page.svelte @@ -0,0 +1,49 @@ + + +
+ {#if active} + {#await active} +
+
+
+ {:then mediaInfo} + {#if mediaInfo} + + {:else} +
+
+
+ {/if} + {/await} + {:else} +
+
+
+ {/if} +
+ + diff --git a/src/lib/components/ui/player/player.svelte b/src/lib/components/ui/player/player.svelte index 4f38deb..47ed91b 100644 --- a/src/lib/components/ui/player/player.svelte +++ b/src/lib/components/ui/player/player.svelte @@ -4,16 +4,18 @@ import { persisted } from 'svelte-persisted-store' import { toast } from 'svelte-sonner' import { fade } from 'svelte/transition' - import { onMount } from 'svelte' + import { onDestroy, onMount } from 'svelte' import { loadWithDefaults } from 'svelte-keybinds' import Seekbar from './seekbar.svelte' - import { autoPiP, getChapterTitle, sanitizeChapters, type Chapter, type MediaInfo } from './util' + import { autoPiP, burnIn, getChapterTitle, sanitizeChapters, type Chapter, type MediaInfo } from './util' import Thumbnailer from './thumbnailer' import Options from './options.svelte' import Volume from './volume.svelte' + import Subs from './subtitles' import type { SvelteMediaTimeRange } from 'svelte/elements' + import type { TorrentFile } from '../../../../app' import PictureInPictureExit from '$lib/components/icons/PictureInPictureExit.svelte' import * as Sheet from '$lib/components/ui/sheet' @@ -28,8 +30,10 @@ import { goto } from '$app/navigation' import EpisodesList from '$lib/components/EpisodesList.svelte' import { episodes } from '$lib/modules/anizip' + import { page } from '$app/stores' - export let mediaInfo: MediaInfo | undefined = undefined + export let mediaInfo: MediaInfo + export let files: TorrentFile[] export let prev: (() => void) | undefined = undefined export let next: (() => void) | undefined = undefined // bindings @@ -73,11 +77,11 @@ } function pip (enable = !$pictureInPictureElement) { - return enable ? video.requestPictureInPicture() : document.exitPictureInPicture() + return enable ? burnIn(video, subtitles) : document.exitPictureInPicture() } function toggleCast () { - // TODO + // TODO: never } $: fullscreenElement ? screen.orientation.lock('landscape') : screen.orientation.unlock() @@ -131,8 +135,26 @@ if (!wasPaused) video.play() } - function screenshot () { - // TODO + async function screenshot () { + const canvas = document.createElement('canvas') + const context = canvas.getContext('2d') + if (!context) return + canvas.width = video.videoWidth + canvas.height = video.videoHeight + context.drawImage(video, 0, 0) + if (subtitles?.renderer) { + subtitles.renderer.resize(video.videoWidth, video.videoHeight) + await new Promise(resolve => setTimeout(resolve, 500)) // this is hacky, but TLDR wait for canvas to update and re-render, in practice this will take at MOST 100ms, but just to be safe + // @ts-expect-error internal call on canvas + context.drawImage(subtitles.renderer._canvas, 0, 0, canvas.width, canvas.height) + subtitles.renderer.resize(0, 0, 0, 0) // undo resize + } + const blob = await new Promise(resolve => canvas.toBlob(b => resolve(b!))) + canvas.remove() + await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]) + toast.success('Screenshot', { + description: 'Saved screenshot to clipboard.' + }) } // animations @@ -154,22 +176,28 @@ } let animations: Animation[] = [] - const thumbnailer = new Thumbnailer(mediaInfo?.url) + const thumbnailer = new Thumbnailer(mediaInfo.file.url) + $: thumbnailer.updateSource(mediaInfo.file.url) + onMount(() => thumbnailer.setVideo(video)) - $: thumbnailer.updateSource(mediaInfo?.url) + let chapters: Chapter[] = [] + const chaptersPromise = native.chapters(mediaInfo.file.hash, mediaInfo.file.id) + async function loadChapters (pr: typeof chaptersPromise, safeduration: number) { + chapters = sanitizeChapters(await pr, safeduration) + } + $: loadChapters(chaptersPromise, safeduration) - onMount(() => { - thumbnailer.setVideo(video) + let subtitles: Subs | undefined + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + $: if (video && !subtitles) { + subtitles = new Subs(video, files, mediaInfo.file) + } + + onDestroy(() => { + if (subtitles) subtitles.destroy() }) - // other - $: chapters = sanitizeChapters([ - { 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) @@ -267,7 +295,7 @@ $: seekIndex = Math.max(0, Math.floor(seekPercent * safeduration / 100 / thumbnailer.interval)) - $: if (mediaInfo?.session) native.setMediaSession(mediaInfo.session) + $: native.setMediaSession(mediaInfo.session) $: native.setPositionState({ duration: safeduration, position: Math.max(0, currentTime), playbackRate }) $: native.setPlayBackState(readyState === 0 ? 'none' : paused ? 'paused' : 'playing') native.setActionHandler('play', playPause) @@ -283,7 +311,11 @@ let openSubs: () => Promise function cycleSubtitles () { - // TODO + if (!subtitles) return + const entries = Object.entries(subtitles._tracks.value) + const index = entries.findIndex(([index]) => index === subtitles!.current.value) + const nextIndex = (index + 1) % entries.length + subtitles.selectCaptions(entries[nextIndex]![0]) } function seekBarKey (event: KeyboardEvent) { @@ -452,13 +484,16 @@ desc: 'Reset Playback Rate' } }) + + $: isMiniplayer = $page.route.id !== '/app/player'
-
-
+
-
goto(`/app/anime/${mediaInfo?.media.id}`)}>{mediaInfo?.session.title ?? 'Unknown Anime'}
+
goto(`/app/anime/${mediaInfo.media.id}`)}>{mediaInfo.session.title}
- {mediaInfo?.session.description ?? 'Unknown Episode'} + {mediaInfo.session.description} - {#if mediaInfo?.media} + {#if mediaInfo.media} {#await episodes(mediaInfo.media.id) then eps} {/await} @@ -579,10 +614,12 @@
- - + + {#if subtitles} + + {/if}
diff --git a/src/routes/app/player/+page.svelte b/src/routes/app/player/+page.svelte index 0bb2761..4fa864c 100644 --- a/src/routes/app/player/+page.svelte +++ b/src/routes/app/player/+page.svelte @@ -1,41 +1 @@ - - -
- {#if active} - {#await active} - - {:then mediaInfo} - {#if mediaInfo} - - {:else} - - {/if} - {/await} - {:else} - - {/if} -
- - + diff --git a/tsconfig.json b/tsconfig.json index d7dadef..acf72d9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,7 +22,7 @@ "tadaTurboLocation": "./src/lib/modules/anilist/graphql-turbo.d.ts" } ], - "maxNodeModuleJsDepth": 2 + "maxNodeModuleJsDepth": 3 }, "include": [ "src/**/*.ts",