feat: subtitles, miniplayer, burn-in

This commit is contained in:
ThaUnknown 2025-04-09 01:16:36 +02:00
parent 0fb7535cee
commit e95aef43dc
No known key found for this signature in database
18 changed files with 616 additions and 103 deletions

View file

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

View file

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

14
src/app.d.ts vendored
View file

@ -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<AuthResponse>
restart: () => Promise<void>
@ -53,9 +60,12 @@ export interface Native {
setActionHandler: (action: MediaSessionAction | 'enterpictureinpicture', handler: MediaSessionActionHandler | null) => void
checkAvailableSpace: (_?: unknown) => Promise<number>
checkIncomingConnections: (_?: unknown) => Promise<boolean>
updatePeerCounts: (hashes: string[]) => Promise<Array<{ hash, complete, downloaded, incomplete }>>
updatePeerCounts: (hashes: string[]) => Promise<Array<{ hash: string, complete: string, downloaded: string, incomplete: string }>>
playTorrent: (id: string) => Promise<TorrentFile[]>
getAttachmentsURL: () => Promise<string>
attachments: (hash: string, id: number) => Promise<Attachment[]>
tracks: (hash: string, id: number) => Promise<Array<{ number: string, language?: string, type: string, header: string, name?: string }>>
subtitles: (hash: string, id: number, cb: (subtitle: { text: string, time: number, duration: number }, trackNumber: number) => void) => Promise<void>
chapters: (hash: string, id: number) => Promise<Array<{ start: number, end: number, text: string }>>
isApp: boolean
}

View file

@ -1 +1 @@
export { default as Player } from './player.svelte'
export { default as Player } from './page.svelte'

View file

@ -1,15 +1,18 @@
<script lang='ts'>
import type { resolveFilesPoorly, ResolvedFile } from './resolver'
import Player from './player.svelte'
import type { resolveFilesPoorly, ResolvedFile } from './resolver'
import type { MediaInfo } from '$lib/components/ui/player/util'
import { Player } from '$lib/components/ui/player'
import { banner, episodes, title } from '$lib/modules/anilist'
import { searchStore } from '$lib/components/SearchModal.svelte'
export let mediaInfo: NonNullable<Awaited<ReturnType<typeof resolveFilesPoorly>>>
function fileToMedaInfo (file: ResolvedFile) {
function fileToMedaInfo (file: ResolvedFile): MediaInfo {
return {
url: file.url,
episode: file.metadata.episode,
file,
episode: Number(file.metadata.episode),
media: file.metadata.media,
session: {
title: title(file.metadata.media),
@ -32,15 +35,21 @@
return Number(file.metadata.episode) > 1
}
function playNext () {
const nextFile = findEpisode(parseInt('' + mediaInfo.target.metadata.episode) + 1)
const episode = parseInt('' + mediaInfo.target.metadata.episode) + 1
const nextFile = findEpisode(episode)
if (nextFile) {
current = fileToMedaInfo(nextFile)
} else {
searchStore.set({ media: mediaInfo.target.metadata.media, episode })
}
}
function playPrev () {
const prevFile = findEpisode(parseInt('' + mediaInfo.target.metadata.episode) - 1)
const episode = parseInt('' + mediaInfo.target.metadata.episode) - 1
const prevFile = findEpisode(episode)
if (prevFile) {
current = fileToMedaInfo(prevFile)
} else {
searchStore.set({ media: mediaInfo.target.metadata.media, episode })
}
}
@ -53,4 +62,4 @@
: undefined
</script>
<Player mediaInfo={current} {prev} {next} />
<Player mediaInfo={current} files={mediaInfo.otherFiles} {prev} {next} />

View file

@ -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}
</Tree.Sub>
</Tree.Item>
<Tree.Item id='subs'>
<span slot='trigger'>Subtitles</span>
<Tree.Sub>
<Tree.Item>
<span>Consulting</span>
</Tree.Item>
<Tree.Item>
<span>Support</span>
</Tree.Item>
</Tree.Sub>
</Tree.Item>
{#if subtitles}
<Tree.Item id='subs'>
<span slot='trigger'>Subtitles</span>
<Tree.Sub>
<Tree.Item active={Number($current) === -1} on:click={() => { $current = -1; open = false }}>
<span>OFF</span>
</Tree.Item>
{#each Object.entries(normalizeSubs($tracks)) as [lang, tracks] (lang)}
<Tree.Item>
<span slot='trigger' class='capitalize'>{lang}</span>
<Tree.Sub>
{#each tracks as { number, language, name }, i (i)}
<Tree.Item active={Number(number) === Number($current)} on:click={() => { $current = number; open = false }}>
<span>{name}</span>
</Tree.Item>
{/each}
</Tree.Sub>
</Tree.Item>
{/each}
</Tree.Sub>
</Tree.Item>
{/if}
<Tree.Item>
<span slot='trigger'>Chapters</span>
<Tree.Sub>

View file

@ -0,0 +1,49 @@
<script lang='ts'>
import { onMount, tick } from 'svelte'
import { resolveFilesPoorly } from './resolver'
import Mediahandler from './mediahandler.svelte'
import { hideBanner } from '$lib/components/ui/banner'
import { server } from '$lib/modules/torrent'
import { page } from '$app/stores'
onMount(async () => {
await tick()
hideBanner.value = true
})
const act = server.active
$: active = resolveFilesPoorly($act)
$: isMiniplayer = $page.route.id !== '/app/player'
</script>
<div class='w-full {isMiniplayer ? 'z-[100] max-w-80 absolute bottom-4 right-4 rounded-lg overflow-clip' : 'h-full'}'>
{#if active}
{#await active}
<div class='w-full flex justify-center items-center bg-black aspect-video'>
<div class='border-[3px] rounded-[50%] w-10 h-10 drop-shadow-lg border-transparent border-t-white animate-spin' />
</div>
{:then mediaInfo}
{#if mediaInfo}
<Mediahandler {mediaInfo} />
{:else}
<div class='w-full flex justify-center items-center bg-black aspect-video'>
<div class='border-[3px] rounded-[50%] w-10 h-10 drop-shadow-lg border-transparent border-t-white animate-spin' />
</div>
{/if}
{/await}
{:else}
<div class='w-full flex justify-center items-center bg-black aspect-video'>
<div class='border-[3px] rounded-[50%] w-10 h-10 drop-shadow-lg border-transparent border-t-white animate-spin' />
</div>
{/if}
</div>
<style>
.text-webkit-center {
text-align: -webkit-center;
}
</style>

View file

@ -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<Blob>(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<void>
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'
</script>
<svelte:document bind:fullscreenElement use:bindPiP={pictureInPictureElement} />
<div class='w-full h-full relative content-center fullscreen:bg-black overflow-clip text-left' bind:this={wrapper}>
<video class='w-full h-full grow bg-black' preload='auto' class:cursor-none={immersed} class:object-cover={fitWidth}
src={mediaInfo?.url}
<video class='w-full h-full grow bg-black' preload='auto' class:cursor-none={immersed} class:cursor-pointer={isMiniplayer} class:object-cover={fitWidth}
crossorigin='anonymous'
src={mediaInfo.file.url}
bind:videoHeight
bind:videoWidth
bind:currentTime
@ -471,7 +506,7 @@
bind:playbackRate
bind:volume={exponentialVolume}
bind:this={video}
on:click={playPause}
on:click={() => isMiniplayer ? goto('/app/player') : playPause()}
on:dblclick={fullscreen}
on:loadeddata={checkAudio}
on:timeupdate={checkSkippableChapters}
@ -480,7 +515,7 @@
<div class='absolute w-full h-full flex items-center justify-center top-0 pointer-events-none'>
{#if seeking}
{#await thumbnailer.getThumbnail(seekIndex) then src}
<img {src} alt='thumbnail' class='w-full h-full bg-black absolute top-0 right-0' loading='lazy' decoding='async' />
<img {src} alt='thumbnail' class='w-full h-full bg-black absolute top-0 right-0 object-contain' loading='lazy' decoding='async' />
{/await}
{/if}
{#if stats}
@ -496,7 +531,7 @@
Playback speed: x{stats.speed?.toFixed(1)}<br />
</div>
{/if}
<Options {wrapper} bind:openSubs {video} {seekTo} {selectAudio} {selectVideo} {fullscreen} {chapters} bind:playbackRate class='mobile:inline-flex hidden p-3 w-12 h-12 absolute top-10 right-10 backdrop-blur-lg border-white/15 border bg-black/20 pointer-events-auto select:opacity-100 cursor-default {immersed && 'opacity-0'}' />
<Options {wrapper} bind:openSubs {video} {seekTo} {selectAudio} {selectVideo} {fullscreen} {chapters} {subtitles} bind:playbackRate class='mobile:inline-flex hidden p-3 w-12 h-12 absolute top-10 right-10 backdrop-blur-lg border-white/15 border bg-black/20 pointer-events-auto select:opacity-100 cursor-default {immersed && 'opacity-0'}' />
<div class='mobile:flex hidden gap-4 absolute items-center select:opacity-100 cursor-default' class:opacity-0={immersed}>
<Button class='p-3 w-16 h-16 pointer-events-auto rounded-[50%] backdrop-blur-lg border-white/15 border bg-black/20' variant='ghost' disabled={!prev}>
<SkipBack size='24px' fill='currentColor' strokeWidth='1' />
@ -512,7 +547,7 @@
<SkipForward size='24px' fill='currentColor' strokeWidth='1' />
</Button>
</div>
{#if readyState < 3}
{#if buffering}
<div in:fade={{ duration: 200, delay: 500 }} out:fade={{ duration: 200 }}>
<div class='border-[3px] rounded-[50%] w-10 h-10 drop-shadow-lg border-transparent border-t-white animate-spin' />
</div>
@ -531,14 +566,14 @@
</div>
{/each}
</div>
<div class='absolute w-full bottom-0 flex flex-col gradient px-4 py-3 transition-opacity select:opacity-100 cursor-default' class:opacity-0={immersed}>
<div class='absolute w-full bottom-0 flex flex-col gradient px-4 py-3 transition-opacity select:opacity-100 cursor-default' class:opacity-0={immersed} class:hidden={isMiniplayer}>
<div class='flex justify-between gap-12 items-end'>
<div class='flex flex-col gap-2 text-left cursor-pointer'>
<div class='text-white text-lg font-normal leading-none line-clamp-1 hover:text-neutral-300' use:click={() => goto(`/app/anime/${mediaInfo?.media.id}`)}>{mediaInfo?.session.title ?? 'Unknown Anime'}</div>
<div class='text-white text-lg font-normal leading-none line-clamp-1 hover:text-neutral-300' use:click={() => goto(`/app/anime/${mediaInfo.media.id}`)}>{mediaInfo.session.title}</div>
<Sheet.Root portal={wrapper}>
<Sheet.Trigger id='episode-list-button' class='text-[rgba(217,217,217,0.6)] hover:text-neutral-500 text-sm leading-none font-light line-clamp-1 text-left'>{mediaInfo?.session.description ?? 'Unknown Episode'}</Sheet.Trigger>
<Sheet.Trigger id='episode-list-button' class='text-[rgba(217,217,217,0.6)] hover:text-neutral-500 text-sm leading-none font-light line-clamp-1 text-left'>{mediaInfo.session.description}</Sheet.Trigger>
<Sheet.Content class='w-[550px] sm:max-w-full h-full overflow-y-scroll flex flex-col pb-0 shrink-0 gap-0 bg-black'>
{#if mediaInfo?.media}
{#if mediaInfo.media}
{#await episodes(mediaInfo.media.id) then eps}
<EpisodesList {eps} media={mediaInfo.media} />
{/await}
@ -579,10 +614,12 @@
<Volume bind:volume={$volume} bind:muted />
</div>
<div class='flex gap-2'>
<Options {fullscreen} {wrapper} {seekTo} bind:openSubs {video} {selectAudio} {selectVideo} {chapters} bind:playbackRate />
<Button class='p-3 w-12 h-12' variant='ghost' on:click={openSubs}>
<Subtitles size='24px' fill='currentColor' strokeWidth='0' />
</Button>
<Options {fullscreen} {wrapper} {seekTo} bind:openSubs {video} {selectAudio} {selectVideo} {chapters} {subtitles} bind:playbackRate />
{#if subtitles}
<Button class='p-3 w-12 h-12' variant='ghost' on:click={openSubs}>
<Subtitles size='24px' fill='currentColor' strokeWidth='0' />
</Button>
{/if}
<Button class='p-3 w-12 h-12' variant='ghost' on:click={() => pip()}>
{#if $pictureInPictureElement}
<PictureInPictureExit size='24px' strokeWidth='2' />

View file

@ -1,9 +1,9 @@
import anitomyscript from 'anitomyscript'
import type { AnitomyResult } from 'anitomyscript'
import type { TorrentFile } from '../../../app'
import type { ResultOf } from 'gql.tada'
import type { MediaEdgeFrag } from '$lib/modules/anilist/queries'
import type { TorrentFile } from '../../../../app'
import { client, episodes, type Media } from '$lib/modules/anilist'

View file

@ -0,0 +1,296 @@
import JASSUB, { type ASS_Event as ASSEvent } from 'jassub'
import { get } from 'svelte/store'
import { writable } from 'simple-store-svelte'
import { fontRx, type ResolvedFile } from './resolver'
import type { TorrentFile } from '../../../../app'
import { HashMap } from '$lib/utils'
import { settings, SUPPORTS } from '$lib/modules/settings'
// import { toTS } from '$lib/utils'
import native from '$lib/modules/native'
const defaultHeader = `[Script Info]
Title: English (US)
ScriptType: v4.00+
WrapStyle: 0
PlayResX: 1280
PlayResY: 720
ScaledBorderAndShadow: yes
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default, Roboto Medium,52,&H00FFFFFF,&H00FFFFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2.6,0,2,20,20,46,1
[Events]
`
const stylesRx = /^Style:[^,]*/gm
export default class Subtitles {
video: HTMLVideoElement
selected: ResolvedFile
files: TorrentFile[]
fonts: string[]
renderer: JASSUB | null = null
current = writable<number | string>(-1)
set = get(settings)
_tracks = writable<Record<number | string, { events: HashMap<{ text: string, time: number, duration: number, style?: string }, ASSEvent>, meta: { language?: string, type: string, header: string, number: string, name?: string }, styles: Record<string | number, number> }>>({})
constructor (video: HTMLVideoElement, files: TorrentFile[], selected: ResolvedFile) {
this.video = video
this.selected = selected
this.files = files
this.fonts = ['/Roboto.ttf', ...files.filter(file => fontRx.test(file.name)).map(file => file.url)]
this.current.subscribe(value => {
this.selectCaptions(value)
})
native.subtitles(this.selected.hash, this.selected.id, (subtitle: { text: string, time: number, duration: number, style?: string }, trackNumber) => {
const { events, meta, styles } = this.track(trackNumber)
if (events.has(subtitle)) return
const event = this.constructSub(subtitle, meta.type !== 'ass', events.size, '' + (styles[subtitle.style ?? 'Default'] ?? 0))
events.add(subtitle, event)
if (Number(this.current.value) === trackNumber) this.renderer?.createEvent(event)
})
native.tracks(this.selected.hash, this.selected.id).then(tracklist => {
for (const track of tracklist) {
const newtrack = this.track(track.number)
newtrack.styles.Default = 0
if (track.type !== 'ass') track.header = defaultHeader
newtrack.meta = track
const styleMatches = track.header.match(stylesRx)
if (!styleMatches) continue
for (let i = 0; i < styleMatches.length; ++i) {
newtrack.styles[styleMatches[i]!.replace('Style:', '').trim()] = i + 1
}
}
this.initSubtitleRenderer()
const tracks = Object.entries(this._tracks.value)
if (tracks.length) {
if (tracks.length === 1) {
this.selectCaptions(tracks[0]![0])
} else {
const wantedTrack = tracks.find(([_, { meta }]) => {
return (meta.language ?? 'eng') === this.set.subtitleLanguage
})
if (wantedTrack) return this.selectCaptions(wantedTrack[0])
const englishTrack = tracks.find(([_, { meta }]) => meta.language == null || meta.language === 'eng')
if (englishTrack) return this.selectCaptions(englishTrack[0])
this.selectCaptions(tracks[0]![0])
}
}
})
native.attachments(this.selected.hash, this.selected.id).then(attachments => {
for (const attachment of attachments) {
if (fontRx.test(attachment.filename) || attachment.mimetype.toLowerCase().includes('font')) {
this.fonts.push(attachment.url)
this.renderer?.addFont(attachment.url)
}
}
})
}
initSubtitleRenderer () {
if (this.renderer) return
// @ts-expect-error yeah, patching the library
if (SUPPORTS.isAndroid) JASSUB._hasBitmapBug = true
this.renderer = new JASSUB({
video: this.video,
subContent: defaultHeader,
fonts: this.fonts,
offscreenRender: !SUPPORTS.isAndroid,
libassMemoryLimit: 1024, // TODO: more? check how much MPV uses
libassGlyphLimit: 80000,
maxRenderHeight: parseInt(this.set.subtitleRenderHeight) || 0,
fallbackFont: 'roboto medium',
availableFonts: {
'roboto medium': './Roboto.ttf'
},
workerUrl: new URL('jassub/dist/jassub-worker.js', import.meta.url).toString(),
wasmUrl: new URL('jassub/dist/jassub-worker.wasm', import.meta.url).toString(),
legacyWasmUrl: new URL('jassub/dist/jassub-worker.wasm.js', import.meta.url).toString(),
modernWasmUrl: new URL('jassub/dist/jassub-worker-modern.wasm', import.meta.url).toString(),
useLocalFonts: this.set.missingFont,
dropAllBlur: this.set.disableSubtitleBlur
})
}
track (trackNumber: number | string) {
const tracks = this._tracks.value
if (tracks[trackNumber]) {
return tracks[trackNumber]
} else {
tracks[trackNumber] = {
events: new HashMap(),
meta: {},
styles: {}
}
return tracks[trackNumber]!
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
constructSub (subtitle: any, isNotAss: boolean, subtitleIndex: number, Style: string) {
if (isNotAss) { // converts VTT or other to SSA
const matches: string[] | null = subtitle.text.match(/<[^>]+>/g) // create array of all tags
if (matches) {
matches.forEach(match => {
if (match.includes('</')) { // check if its a closing tag
subtitle.text = subtitle.text.replace(match, match.replace('</', '{\\').replace('>', '0}'))
} else {
subtitle.text = subtitle.text.replace(match, match.replace('<', '{\\').replace('>', '1}'))
}
})
}
// replace all html special tags with normal ones
subtitle.text = subtitle.text.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&nbsp;/g, '\\h').replace(/\r?\n/g, '\\N')
}
return {
Start: subtitle.time,
Duration: subtitle.duration,
Style,
Name: subtitle.name || '',
MarginL: Number(subtitle.marginL) || 0,
MarginR: Number(subtitle.marginR) || 0,
MarginV: Number(subtitle.marginV) || 0,
Effect: subtitle.effect || '',
Text: subtitle.text || '',
ReadOrder: 1,
Layer: Number(subtitle.layer) || 0,
_index: subtitleIndex
}
}
selectCaptions (trackNumber: number | string) {
this.current.value = trackNumber
if (!this.renderer) return
if (trackNumber === -1) {
this.renderer.setTrack(defaultHeader)
return this.renderer.resize()
}
const track = this._tracks.value[trackNumber]
if (!track) return
this.renderer.setTrack(track.meta.header.slice(0, -1))
for (const subtitle of track.events) this.renderer.createEvent(subtitle)
this.renderer.resize()
}
destroy () {
this.renderer?.destroy()
this.files = []
for (const { events } of Object.values(this._tracks.value)) {
events.clear()
}
}
// async addSingleSubtitleFile (file: File) {
// // lets hope there's no more than 100 subtitle tracks in a file
// const index = 100 + this.headers.length
// this.subtitleFiles[index] = file
// const type = file.name.substring(file.name.lastIndexOf('.') + 1).toLowerCase()
// const subname = file.name.slice(0, file.name.lastIndexOf('.'))
// // sub name could contain video name with or without extension, possibly followed by lang, or not.
// const name = subname.includes(this.selected.name)
// ? subname.replace(this.selected.name, '')
// : subname.replace(this.selected.name.slice(0, this.selected.name.lastIndexOf('.')), '')
// this.headers[index] = {
// header: defaultHeader,
// language: name.replace(/[,._-]/g, ' ').trim() || 'Track ' + index,
// number: index,
// type
// }
// this.tracks[index] = []
// const subtitles = Subtitles.convertSubText(await file.text(), type) ?? ['']
// if (type === 'ass') {
// this.headers[index].header = subtitles
// } else {
// this.headers[index].header += subtitles.join('\n')
// }
// if (!this.current) {
// this.current = index
// this.initSubtitleRenderer()
// this.selectCaptions(this.current)
// }
// }
// static convertSubText (text: string, type: string) {
// const srtRx = /(?:\d+\r?\n)?(\S{9,12})\s?-->\s?(\S{9,12})(.*)\r?\n([\s\S]*)$/i
// const srt = (text: string) => {
// const subtitles = []
// const replaced = text.replace(/\r/g, '')
// for (const split of replaced.split(/\r?\n\r?\n/)) {
// const match: string[] | null = split.match(srtRx)
// if (match?.length !== 5) continue
// // timestamps
// match[1] = match[1]!.match(/.*[.,]\d{2}/)![0]
// match[2] = match[2]!.match(/.*[.,]\d{2}/)![0]
// if (match[1]?.length === 9) {
// match[1] = '0:' + match[1]
// } else {
// if (match[1]?.[0] === '0') {
// match[1] = match[1].substring(1)
// }
// }
// match[1]?.replace(',', '.')
// if (match[2]?.length === 9) {
// match[2] = '0:' + match[2]
// } else {
// if (match[2]?.[0] === '0') {
// match[2] = match[2].substring(1)
// }
// }
// match[2]?.replace(',', '.')
// // create array of all tags
// const matches = match[4]?.match(/<[^>]+>/g)
// if (matches) {
// matches.forEach(matched => {
// if (matched.includes('</')) { // check if its a closing tag
// match[4] = match[4]!.replace(matched, matched.replace('</', '{\\').replace('>', '0}'))
// } else {
// match[4] = match[4]!.replace(matched, matched.replace('<', '{\\').replace('>', '1}'))
// }
// })
// }
// subtitles.push('Dialogue: 0,' + match[1].replace(',', '.') + ',' + match[2].replace(',', '.') + ',Default,,0,0,0,,' + match[4]!.replace(/\r?\n/g, '\\N'))
// }
// return subtitles
// }
// const subRx = /[{[](\d+)[}\]][{[](\d+)[}\]](.+)/i
// const sub = (text: string) => {
// const subtitles = []
// const replaced = text.replace(/\r/g, '')
// let frames = 1000 / Number(replaced.match(subRx)?.[3])
// if (!frames || isNaN(frames)) frames = 41.708
// for (const split of replaced.split('\r?\n')) {
// const match = split.match(subRx)
// if (match) subtitles.push('Dialogue: 0,' + toTS((Number(match[1]) * frames) / 1000, 1) + ',' + toTS((Number(match[2]) * frames) / 1000, 1) + ',Default,,0,0,0,,' + match[3]?.replace('|', '\\N'))
// }
// return subtitles
// }
// const subtitles = type === 'ass' ? text : []
// if (type === 'ass') {
// return subtitles
// } else if (type === 'srt' || type === 'vtt') {
// return srt(text)
// } else if (type === 'sub') {
// return sub(text)
// } else {
// // subbers have a tendency to not set the extensions properly
// if (srtRx.test(text)) return srt(text)
// if (subRx.test(text)) return sub(text)
// }
// }
}

View file

@ -19,6 +19,7 @@ export default class Thumbnailer {
this.video.preload = 'none'
this.video.playbackRate = 0
this.video.muted = true
this.video.crossOrigin = 'anonymous'
if (src) {
this.video.src = this.src = src
this.video.load()
@ -100,4 +101,9 @@ export default class Thumbnailer {
this.video.src = this.src = src
this.video.load()
}
destroy () {
this.video.remove()
this.thumbnails = []
}
}

View file

@ -2,6 +2,8 @@ import { get } from 'svelte/store'
import type { Media } from '$lib/modules/anilist'
import type { Track } from '../../../../app'
import type Subtitles from './subtitles'
import type { ResolvedFile } from './resolver'
import { settings } from '$lib/modules/settings'
@ -18,7 +20,7 @@ export interface SessionMetadata {
}
export interface MediaInfo {
url: string
file: ResolvedFile
media: Media
episode: number
session: SessionMetadata
@ -169,6 +171,21 @@ export function normalizeTracks (_tracks: Track[]) {
}, {})
}
export function normalizeSubs (_tracks?: Record<number | string, { meta: { language?: string, type: string, header: string, number: string, name?: string } }>) {
if (!_tracks) return {}
const hasEng = Object.values(_tracks).some(({ meta }) => meta.language === 'eng' || meta.language === 'en')
const lang = Object.values(_tracks).map(({ meta }) => ({
language: meta.language ?? !hasEng ? 'eng' : 'unk',
number: meta.number,
name: meta.name ?? meta.language ?? !hasEng ? 'eng' : 'unk'
}))
return lang.reduce<Record<string, typeof lang>>((acc, track) => {
if (!acc[track.language]) acc[track.language] = []
acc[track.language]!.push(track)
return acc
}, {})
}
export function autoPiP (video: HTMLVideoElement, pipfn: (enable: boolean) => void) {
const signal = new AbortController()
@ -180,3 +197,54 @@ export function autoPiP (video: HTMLVideoElement, pipfn: (enable: boolean) => vo
destroy: () => signal.abort()
}
}
export function burnIn (video: HTMLVideoElement, subtitles?: Subtitles) {
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 ? deband.canvas : video, 0, 0)
context.drawImage(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()
}
canvasVideo.srcObject = stream
canvasVideo.onloadedmetadata = () => {
canvasVideo.play()
canvasVideo.requestPictureInPicture().then(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

@ -60,6 +60,14 @@ export default Object.assign<Native, Partial<Native>>({
updatePeerCounts: async () => [],
isApp: false,
playTorrent: async () => dummyFiles,
getAttachmentsURL: async () => location.origin
attachments: async () => [],
tracks: async () => [],
subtitles: async () => undefined,
chapters: async () => [
{ 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 ' }
]
// @ts-expect-error idk
}, globalThis.native as Partial<Native>)

View file

@ -8,7 +8,6 @@ import type { Media } from '../anilist'
import type { TorrentFile } from '../../../app'
export const server = new class ServerClient {
attachmentsURL = native.getAttachmentsURL()
last = persisted<{media: Media, id: string, episode: number} | null>('last-torrent', null)
active = writable<Promise<{media: Media, id: string, episode: number, files: TorrentFile[]}| null>>()

View file

@ -215,3 +215,39 @@ export async function traceAnime (image: File) { // WAIT lookup logic
throw new Error('Search Failed \n Couldn\'t find anime for specified image! Try to remove black bars, or use a more detailed image.')
}
}
export class HashMap<K extends object, T> {
map = new Map<string, T>()
_id (k: K): string {
return JSON.stringify(k, Object.keys(k).sort())
}
has (k: K): boolean {
return this.map.has(this._id(k))
}
add (k: K, o: T) {
return this.map.set(this._id(k), o)
}
delete (k: K): boolean {
return this.map.delete(this._id(k))
}
clear () {
this.map.clear()
}
get size (): number {
return this.map.size
}
values (): IterableIterator<T> {
return this.map.values()
}
[Symbol.iterator] (): IterableIterator<T> {
return this.values()
}
}

View file

@ -3,6 +3,8 @@
import { Sidebar } from '$lib/components/ui/sidebar'
import SearchModal from '$lib/components/SearchModal.svelte'
import Sidebarlist from '$lib/components/ui/sidebar/sidebarlist.svelte'
import { version, dev } from '$app/environment'
import { Player } from '$lib/components/ui/player'
</script>
<BannerImage class='absolute top-0 left-0' />
@ -12,6 +14,7 @@
<Sidebar>
<Sidebarlist />
</Sidebar>
<Player />
<slot />
</div>

View file

@ -1,41 +1 @@
<script lang='ts'>
import { onMount, tick } from 'svelte'
import { resolveFilesPoorly } from './resolver'
import Mediahandler from './mediahandler.svelte'
import { Player } from '$lib/components/ui/player'
import { hideBanner } from '$lib/components/ui/banner'
import { server } from '$lib/modules/torrent'
onMount(async () => {
await tick()
hideBanner.value = true
})
const act = server.active
$: active = resolveFilesPoorly($act)
</script>
<div class='w-full h-full'>
{#if active}
{#await active}
<Player />
{:then mediaInfo}
{#if mediaInfo}
<Mediahandler {mediaInfo} />
{:else}
<Player />
{/if}
{/await}
{:else}
<Player />
{/if}
</div>
<style>
.text-webkit-center {
text-align: -webkit-center;
}
</style>
<slot />

View file

@ -22,7 +22,7 @@
"tadaTurboLocation": "./src/lib/modules/anilist/graphql-turbo.d.ts"
}
],
"maxNodeModuleJsDepth": 2
"maxNodeModuleJsDepth": 3
},
"include": [
"src/**/*.ts",