fix: improve bad PiP code

This commit is contained in:
ThaUnknown 2025-05-01 21:51:35 +02:00
parent a095f91d37
commit 2fca2c4d4f
No known key found for this signature in database
10 changed files with 155 additions and 107 deletions

View file

@ -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"
}

View file

@ -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

1
src/app.d.ts vendored
View file

@ -70,6 +70,7 @@ export interface Native {
share: Navigator['share']
minimise: () => Promise<void>
maximise: () => Promise<void>
focus: () => Promise<void>
close: () => Promise<void>
selectPlayer: () => Promise<string>
selectDownload: () => Promise<string>

View file

@ -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 @@
<Tree.Item on:click={fullscreen} active={!!fullscreenElement}>
Fullscreen
</Tree.Item>
<Tree.Item on:click={pip}>
<Tree.Item on:click={() => pip.pip()} active={!!$pipElement}>
Picture in Picture
</Tree.Item>
<Tree.Item on:click={deband} active={$settings.playerDeband}>

View file

@ -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<HTMLVideoElement | null>(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 <T extends HTMLElement> (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)
}
}

View file

@ -1,6 +1,6 @@
<script lang='ts'>
import { Cast, FastForward, Maximize, Minimize, Pause, Rewind, SkipBack, SkipForward, Captions, Contrast, List, PictureInPicture2, Proportions, RefreshCcw, RotateCcw, RotateCw, ScreenShare, Volume1, Volume2, VolumeX, ChevronDown, ChevronUp, Users } from 'lucide-svelte'
import { onMount } from 'svelte'
import { onDestroy, onMount } from 'svelte'
import { fade } from 'svelte/transition'
import { persisted } from 'svelte-persisted-store'
import { toast } from 'svelte-sonner'
@ -8,10 +8,11 @@
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 { autoPiP, burnIn, getChaptersAniSkip, getChapterTitle, sanitizeChapters, type Chapter, type MediaInfo } from './util'
import { getChaptersAniSkip, getChapterTitle, sanitizeChapters, type Chapter, type MediaInfo } from './util'
import Volume from './volume.svelte'
import type { ResolvedFile } from './resolver'
@ -21,7 +22,7 @@
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import EpisodesList from '$lib/components/EpisodesList.svelte'
import PictureInPicture from '$lib/components/icons/PictureInPicture.svelte'
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'
@ -60,10 +61,20 @@
// elements
let fullscreenElement: HTMLElement | null = null
let pictureInPictureElement: Promise<void> | undefined
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
onDestroy(() => {
pip.destroy()
})
// state
let seeking = false
let ended = false
@ -85,14 +96,6 @@
return fullscreenElement ? document.exitFullscreen() : wrapper.requestFullscreen()
}
async function pip () {
// TODO: this is shit code
pictureInPictureElement = (async () => {
await pictureInPictureElement
document.pictureInPictureElement ? await document.exitPictureInPicture() : await burnIn(video, subtitles, deband)
})()
}
function toggleCast () {
// TODO: never
}
@ -209,8 +212,6 @@
}
$: loadChapters(chaptersPromise, safeduration)
let subtitles: Subs | undefined
function createSubtitles (video: HTMLVideoElement) {
subtitles = new Subs(video, otherFiles, mediaInfo.file)
return {
@ -220,18 +221,18 @@
}
}
let deband: VideoDeband | undefined
function cleanupDeband () {
deband?.destroy()
deband?.canvas.remove()
deband = undefined
pip._setElements(video, subtitles, deband)
}
function createDeband (video: HTMLVideoElement | undefined, playerDeband: boolean) {
if (!playerDeband || !video) return cleanupDeband()
if (deband) cleanupDeband()
deband = new VideoDeband(video)
pip._setElements(video, subtitles, deband)
deband.canvas.classList.add('deband-canvas', 'w-full', 'h-full', 'pointer-events-none', 'object-contain')
video.before(deband.canvas)
}
@ -387,7 +388,7 @@
native.setActionHandler('previoustrack', () => prev?.())
native.setActionHandler('nexttrack', () => next?.())
// about://flags/#auto-picture-in-picture-for-video-playback
native.setActionHandler('enterpictureinpicture', () => pip())
native.setActionHandler('enterpictureinpicture', () => pip.pip(true))
let openSubs: () => Promise<void>
@ -468,7 +469,7 @@
desc: 'Toggle Mute'
},
KeyP: {
fn: () => pip(),
fn: () => pip.pip(),
id: 'picture_in_picture',
icon: PictureInPicture2,
type: 'icon',
@ -608,7 +609,6 @@
<div class='w-full h-full relative content-center bg-black overflow-clip text-left' class:fitWidth bind:this={wrapper}>
<video class='w-full h-full' preload='auto' class:cursor-none={immersed} class:cursor-pointer={isMiniplayer} class:object-cover={fitWidth} class:opacity-0={$settings.playerDeband} class:absolute={$settings.playerDeband} class:top-0={$settings.playerDeband}
use:createSubtitles
use:autoPiP={pip}
use:holdToFF={'pointer'}
crossorigin='anonymous'
src={mediaInfo.file.url}
@ -758,11 +758,11 @@
<Subtitles size='24px' fill='currentColor' strokeWidth='0' />
</Button>
{/if}
<Button class='p-3 w-12 h-12' variant='ghost' on:click={() => pip()}>
<Button class='p-3 w-12 h-12' variant='ghost' on:click={() => pip.pip()}>
{#if pictureInPictureElement}
<PictureInPictureExit size='24px' strokeWidth='2' />
{:else}
<PictureInPicture size='24px' strokeWidth='2' />
<PictureInPictureOff size='24px' strokeWidth='2' />
{/if}
</Button>
{#if false}

View file

@ -1,12 +1,6 @@
import { get } from 'svelte/store'
import type { Media } from '$lib/modules/anilist'
import type { ResolvedFile } from './resolver'
import type Subtitles from './subtitles'
import type { Track } from '../../../../app'
import type VideoDeband from 'video-deband'
import { settings } from '$lib/modules/settings'
export interface Chapter {
start: number
@ -185,68 +179,3 @@ export function normalizeSubs (_tracks?: Record<number | string, { meta: { langu
return acc
}, {})
}
export function autoPiP (video: HTMLVideoElement, pipfn: (enable: boolean) => void) {
const signal = new AbortController()
window.addEventListener('visibilitychange', () => {
if (get(settings).playerAutoPiP) pipfn(document.visibilityState !== 'visible')
}, { signal: signal.signal })
return {
destroy: () => signal.abort()
}
}
// TODO: de-shittify this code, abort signal, play/pause sync
export function burnIn (video: HTMLVideoElement, subtitles?: Subtitles, deband?: VideoDeband) {
if (!subtitles?.renderer) return video.requestPictureInPicture()
const canvasVideo = document.createElement('video')
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
if (!context) return
let loop: number
canvas.width = video.videoWidth
canvas.height = video.videoHeight
subtitles.renderer.resize(video.videoWidth, video.videoHeight)
const renderFrame = () => {
context.drawImage(deband?.canvas ?? video, 0, 0)
// @ts-expect-error internal call on canvas
if (canvas.width && canvas.height && subtitles.renderer?._canvas) context.drawImage(subtitles.renderer._canvas, 0, 0, canvas.width, canvas.height)
loop = video.requestVideoFrameCallback(renderFrame)
}
renderFrame()
const destroy = () => {
subtitles.renderer?.resize()
video.cancelVideoFrameCallback(loop)
canvas.remove()
}
const stream = canvas.captureStream()
const cleanup = () => {
destroy()
canvasVideo.remove()
}
return new Promise<PictureInPictureWindow>(resolve => {
canvasVideo.srcObject = stream
canvasVideo.onloadedmetadata = () => {
canvasVideo.play()
canvasVideo.requestPictureInPicture().then(pipwindow => {
resolve(pipwindow)
pipwindow.onresize = () => {
const { width, height } = pipwindow
if (isNaN(width) || isNaN(height)) return
if (!isFinite(width) || !isFinite(height)) return
subtitles.renderer?.resize(width, height)
}
}).catch(e => {
cleanup()
console.warn('Failed To Burn In Subtitles ' + e)
})
}
canvasVideo.onleavepictureinpicture = cleanup
})
}

View file

@ -57,6 +57,7 @@ export default Object.assign<Native, Partial<Native>>({
openTorrentDevtools: async () => undefined,
minimise: async () => undefined,
maximise: async () => undefined,
focus: async () => undefined,
close: async () => undefined,
checkUpdate: async () => undefined,
toggleDiscordDetails: async () => undefined,

View file

@ -26,6 +26,7 @@
$: media = $anime.Media!
$: bannerSrc.value = media
hideBanner.value = false
onDestroy(() => {
bannerSrc.value = oldBanner

View file

@ -39,10 +39,10 @@
<SettingCard let:id title='Auto-Play Next Episode' description='Automatically starts playing next episode when a video ends.'>
<Switch {id} bind:checked={$settings.playerAutoplay} />
</SettingCard>
<SettingCard let:id title='Pause On Lost Visibility' description='Pauses/Resumes video playback when the app loses visibility.'>
<SettingCard let:id title='Pause On Lost Visibility' description='Pauses/Resumes video playback when the app loses visibility.'>
<Switch {id} bind:checked={$settings.playerPause} />
</SettingCard>
<SettingCard let:id title='PiP On Lost Visibility' description='Automatically enters Picture in Picture mode when the app loses visibility.'>
<SettingCard let:id title='PiP On Lost Visibility' description='Automatically enters Picture in Picture mode when the app loses visibility.'>
<Switch {id} bind:checked={$settings.playerAutoPiP} />
</SettingCard>
<SettingCard let:id title='Auto-Complete Episodes' description='Automatically marks episodes as complete when you finish watching them. Requires Account login.'>