mirror of
https://github.com/ThaUnknown/miru.git
synced 2026-03-11 22:15:35 +00:00
fix: improve bad PiP code
This commit is contained in:
parent
a095f91d37
commit
2fca2c4d4f
10 changed files with 155 additions and 107 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
1
src/app.d.ts
vendored
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
113
src/lib/components/ui/player/pip.ts
Normal file
113
src/lib/components/ui/player/pip.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
|
||||
$: media = $anime.Media!
|
||||
|
||||
$: bannerSrc.value = media
|
||||
hideBanner.value = false
|
||||
onDestroy(() => {
|
||||
bannerSrc.value = oldBanner
|
||||
|
|
|
|||
|
|
@ -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.'>
|
||||
|
|
|
|||
Loading…
Reference in a new issue