diff --git a/package.json b/package.json index ad06191..8616d83 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ui", - "version": "6.1.9", + "version": "6.1.10", "license": "BUSL-1.1", "private": true, "packageManager": "pnpm@9.14.4", @@ -76,7 +76,7 @@ "tailwind-variants": "^0.2.1", "uint8-util": "^2.2.5", "urql": "^4.2.1", - "video-deband": "^1.0.6", + "video-deband": "^1.0.7", "workbox-core": "^7.3.0", "workbox-precaching": "^7.3.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 00e067a..08eb6ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,8 +105,8 @@ importers: specifier: ^4.2.1 version: 4.2.1(@urql/core@5.1.0(graphql@16.10.0))(react@19.0.0) video-deband: - specifier: ^1.0.6 - version: 1.0.6 + specifier: ^1.0.7 + version: 1.0.7 workbox-core: specifier: ^7.3.0 version: 7.3.0 @@ -152,7 +152,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/a9994824ad3b4528533007eec7f31ed773a89bc0(@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/14c085cecee00b3d56760ea8ff5c8e3486c3c4cf(@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 @@ -1228,8 +1228,8 @@ packages: peerDependencies: eslint: '>=6.0.0' - eslint-config-standard-universal@https://codeload.github.com/thaunknown/eslint-config-standard-universal/tar.gz/a9994824ad3b4528533007eec7f31ed773a89bc0: - resolution: {tarball: https://codeload.github.com/thaunknown/eslint-config-standard-universal/tar.gz/a9994824ad3b4528533007eec7f31ed773a89bc0} + eslint-config-standard-universal@https://codeload.github.com/thaunknown/eslint-config-standard-universal/tar.gz/14c085cecee00b3d56760ea8ff5c8e3486c3c4cf: + resolution: {tarball: https://codeload.github.com/thaunknown/eslint-config-standard-universal/tar.gz/14c085cecee00b3d56760ea8ff5c8e3486c3c4cf} version: 1.0.4 eslint-import-resolver-node@0.3.9: @@ -2491,8 +2491,8 @@ packages: peerDependencies: svelte: ^4.0.0 || ^5.0.0-next.1 - video-deband@1.0.6: - resolution: {integrity: sha512-A/RlvlG+L1BzC78yZpWoOuImZpuBcsFisaBsq6EPeLBUYIJ3U/794k2kKqKRT+bVHr6C2Bmbp2CP5rZxpIcR9w==} + video-deband@1.0.7: + resolution: {integrity: sha512-vwJ2E/e7DfvFlKU5RQ8T8ZEcG7m7A41TIxZ3X57o7Rzw+HSTNyljrtSPJU11UQR2X9wVmAC7WKdOs7zOsxNV6A==} vite@5.4.11: resolution: {integrity: sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==} @@ -3727,7 +3727,7 @@ snapshots: eslint: 9.17.0(jiti@1.21.6) semver: 7.7.1 - eslint-config-standard-universal@https://codeload.github.com/thaunknown/eslint-config-standard-universal/tar.gz/a9994824ad3b4528533007eec7f31ed773a89bc0(@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/14c085cecee00b3d56760ea8ff5c8e3486c3c4cf(@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) @@ -5120,7 +5120,7 @@ snapshots: bits-ui: 0.21.16(svelte@4.2.19) svelte: 4.2.19 - video-deband@1.0.6: + video-deband@1.0.7: dependencies: rvfc-polyfill: 1.0.7 twgl.js: 5.5.4 diff --git a/src/app.d.ts b/src/app.d.ts index 2c11898..f998075 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -70,6 +70,7 @@ export interface Native { share: Navigator['share'] minimise: () => Promise maximise: () => Promise + focus: () => Promise close: () => Promise selectPlayer: () => Promise selectDownload: () => Promise diff --git a/src/lib/components/ui/player/options.svelte b/src/lib/components/ui/player/options.svelte index 6e4a73c..7bf2fbc 100644 --- a/src/lib/components/ui/player/options.svelte +++ b/src/lib/components/ui/player/options.svelte @@ -5,6 +5,7 @@ import Keybinds from './keybinds.svelte' import { normalizeSubs, normalizeTracks, type Chapter } from './util' + import type PictureInPicture from './pip' import type { ResolvedFile } from './resolver' import type Subtitles from './subtitles' import type { Writable } from 'simple-store-svelte' @@ -30,7 +31,9 @@ export let subtitles: Subtitles | undefined export let videoFiles: ResolvedFile[] export let selectFile: (file: ResolvedFile) => void - export let pip: () => void + export let pip: PictureInPicture + + $: pipElement = pip.element $: tracks = subtitles?._tracks $: current = subtitles?.current @@ -199,7 +202,7 @@ Fullscreen - + pip.pip()} active={!!$pipElement}> Picture in Picture diff --git a/src/lib/components/ui/player/pip.ts b/src/lib/components/ui/player/pip.ts new file mode 100644 index 0000000..e6821d0 --- /dev/null +++ b/src/lib/components/ui/player/pip.ts @@ -0,0 +1,113 @@ +import { writable } from 'simple-store-svelte' +import { get } from 'svelte/store' + +import type Subtitles from './subtitles' +import type VideoDeband from 'video-deband' + +import native from '$lib/modules/native' +import { settings } from '$lib/modules/settings' + +export default class PictureInPicture { + element = writable(null) + + video: HTMLVideoElement | null = null + subtitles: Subtitles | null = null + deband: VideoDeband | null = null + + ctrl = new AbortController() + + constructor () { + this._attachListeners(document.documentElement, false) + + window.addEventListener('visibilitychange', () => { + if (get(settings).playerAutoPiP) this.pip(document.visibilityState !== 'visible') + }, { signal: this.ctrl.signal }) + } + + _setElements (video: HTMLVideoElement, subtitles?: Subtitles, deband?: VideoDeband) { + this.video = video + this.subtitles = subtitles ?? null + this.deband = deband ?? null + } + + _attachListeners (element: T, once = true): T { + element.addEventListener('enterpictureinpicture', () => { + this.element.set(document.pictureInPictureElement as HTMLVideoElement | null) + }, { signal: this.ctrl.signal, once }) + element.addEventListener('leavepictureinpicture', () => { + this.element.set(null) + native.focus() + }, { signal: this.ctrl.signal, once }) + return element + } + + pip (enable = !this.element.value) { + enable ? this._on() : this._off() + } + + _off () { + if (this.element.value) document.exitPictureInPicture() + } + + async _on () { + if (this.element.value) return + if (!this.video) return + if (!this.subtitles?.renderer) { + if (!this.deband) return await this.video.requestPictureInPicture() + return await this._attachListeners(await this.deband.getVideo()).requestPictureInPicture() + } + + const canvas = document.createElement('canvas') + const context = canvas.getContext('2d') + if (!context) return + + const video = this._attachListeners(document.createElement('video')) + video.srcObject = canvas.captureStream() + video.muted = true + video.play() + + const ctrl = new AbortController() + + let loop: number + canvas.width = this.video.videoWidth + canvas.height = this.video.videoHeight + this.subtitles.renderer.resize(this.video.videoWidth, this.video.videoHeight) + const renderFrame = () => { + context.drawImage(this.deband?.canvas ?? this.video!, 0, 0) + // @ts-expect-error internal call on canvas + if (canvas.width && canvas.height && this.subtitles.renderer?._canvas) context.drawImage(this.subtitles.renderer._canvas, 0, 0, canvas.width, canvas.height) + loop = this.video!.requestVideoFrameCallback(renderFrame) + } + renderFrame() + ctrl.signal.addEventListener('abort', () => { + this.subtitles?.renderer?.resize() + this.video!.cancelVideoFrameCallback(loop) + canvas.remove() + video.remove() + }) + + this.ctrl.signal.addEventListener('abort', () => ctrl.abort(), { signal: ctrl.signal }) + video.addEventListener('leavepictureinpicture', () => ctrl.abort(), { signal: ctrl.signal }) + + try { + await video.play() + const window = await video.requestPictureInPicture() + window.addEventListener('resize', () => { + const { width, height } = window + if (isNaN(width) || isNaN(height)) return + if (!isFinite(width) || !isFinite(height)) return + this.subtitles?.renderer?.resize(width, height) + }, { signal: ctrl.signal }) + } catch (err) { + const e = err as Error + console.warn('Failed To Burn In Subtitles ' + e.stack) + ctrl.abort() + } + } + + destroy () { + this._off() + this.ctrl.abort() + this.element.set(null) + } +} diff --git a/src/lib/components/ui/player/player.svelte b/src/lib/components/ui/player/player.svelte index 9aae292..f36fa26 100644 --- a/src/lib/components/ui/player/player.svelte +++ b/src/lib/components/ui/player/player.svelte @@ -1,6 +1,6 @@