mirror of
https://github.com/ThaUnknown/miru.git
synced 2026-03-11 22:15:35 +00:00
feat: subtitles, miniplayer, burn-in
This commit is contained in:
parent
0fb7535cee
commit
e95aef43dc
18 changed files with 616 additions and 103 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
14
src/app.d.ts
vendored
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export { default as Player } from './player.svelte'
|
||||
export { default as Player } from './page.svelte'
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
49
src/lib/components/ui/player/page.svelte
Normal file
49
src/lib/components/ui/player/page.svelte
Normal 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>
|
||||
|
|
@ -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' />
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
296
src/lib/components/ui/player/subtitles.ts
Normal file
296
src/lib/components/ui/player/subtitles.ts
Normal 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(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/ /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)
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
|
@ -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 = []
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>)
|
||||
|
|
|
|||
|
|
@ -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>>()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
"tadaTurboLocation": "./src/lib/modules/anilist/graphql-turbo.d.ts"
|
||||
}
|
||||
],
|
||||
"maxNodeModuleJsDepth": 2
|
||||
"maxNodeModuleJsDepth": 3
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
|
|
|
|||
Loading…
Reference in a new issue