mirror of
https://github.com/ThaUnknown/miru.git
synced 2026-08-19 20:15:37 +00:00
Merge branch 'master' into ui-episode-ends-at-timer
This commit is contained in:
commit
b941289438
13 changed files with 221 additions and 193 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "ui",
|
||||
"version": "6.4.119",
|
||||
"version": "6.4.126",
|
||||
"license": "BUSL-1.1",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.15.5",
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@
|
|||
import * as Dialog from '$lib/components/ui/dialog'
|
||||
import { Input } from '$lib/components/ui/input'
|
||||
import * as Select from '$lib/components/ui/select'
|
||||
import { cover, title, type Media } from '$lib/modules/anilist'
|
||||
import { banner, cover, title, type Media } from '$lib/modules/anilist'
|
||||
import { list, progress as _progress, score as _score, repeat as _repeat, authAggregator, lists } from '$lib/modules/auth'
|
||||
import { dragScroll } from '$lib/modules/navigate'
|
||||
import { breakpoints } from '$lib/utils'
|
||||
|
||||
export let media: Media
|
||||
|
||||
|
|
@ -41,10 +42,10 @@
|
|||
<PencilLine class='size-4' />
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content class='flex justify-center max-h-[80%] p-0'>
|
||||
<div class='flex flex-col md:flex-row w-full overflow-y-auto' use:dragScroll>
|
||||
<div class='relative w-full h-[120px] md:w-[260px] md:h-[400px] shrink-0'>
|
||||
<img alt='images' loading='lazy' decoding='async' class='object-cover w-full h-full' style:background={media.coverImage?.color ?? '#000'} src={cover(media)} />
|
||||
<Dialog.Content class='flex justify-center max-h-[80%] max-w-3xl p-0 overflow-clip'>
|
||||
<div class='flex flex-col sm:flex-row w-full overflow-y-auto' use:dragScroll>
|
||||
<div class='relative w-full h-[150px] sm:w-[260px] sm:h-[400px] shrink-0'>
|
||||
<img alt='images' loading='lazy' decoding='async' class='object-cover w-full h-full' style:background={media.coverImage?.color ?? '#000'} src={$breakpoints.sm ? cover(media) : banner(media)} />
|
||||
</div>
|
||||
<form class='flex flex-col w-full rounded-r-lg h-full'>
|
||||
<div class='pt-4 px-5 w-full'>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang='ts' context='module'>
|
||||
import BadgeCheck from 'lucide-svelte/icons/badge-check'
|
||||
import Database from 'lucide-svelte/icons/database'
|
||||
import Download from 'svelte-radix/Download.svelte'
|
||||
import File from 'svelte-radix/File.svelte'
|
||||
import MagnifyingGlass from 'svelte-radix/MagnifyingGlass.svelte'
|
||||
|
||||
import { SingleCombo } from './ui/combobox'
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
import { extensions } from '$lib/modules/extensions/extensions'
|
||||
import { click, dragScroll } from '$lib/modules/navigate'
|
||||
import { settings, videoResolutions } from '$lib/modules/settings'
|
||||
import { fastPrettyBytes, since } from '$lib/utils'
|
||||
import { cn, colors, fastPrettyBytes, since } from '$lib/utils'
|
||||
|
||||
const termMapping: Record<string, {text: string, color: string}> = {}
|
||||
termMapping['5.1'] = termMapping['5.1CH'] = { text: '5.1', color: '#f67255' }
|
||||
|
|
@ -32,11 +32,12 @@
|
|||
termMapping.AV1 = { text: 'AV1', color: '#0c8ce9' }
|
||||
termMapping.BD = termMapping.BDRIP = termMapping.BLURAY = termMapping['BLU-RAY'] = { text: 'BD', color: '#ab1b31' }
|
||||
termMapping.DVD5 = termMapping.DVD9 = termMapping['DVD-R2J'] = termMapping.DVDRIP = termMapping.DVD = termMapping['DVD-RIP'] = termMapping.R2DVD = termMapping.R2J = termMapping.R2JDVD = termMapping.R2JDVDRIP = { text: 'DVD', color: '#ab1b31' }
|
||||
termMapping.MULTISUB = termMapping['MULTI-SUB'] = termMapping['MULTI SUB'] = termMapping.MULTISUBS = termMapping['MULTI-SUBS'] = termMapping['MULTI SUBS'] = { text: 'Multi Sub', color: '#ffcb3b' }
|
||||
// termMapping.HDTV = termMapping.HDTVRIP = termMapping.TVRIP = termMapping['TV-RIP'] = { text: 'TV', color: '#ab1b31' }
|
||||
// termMapping.WEBCAST = termMapping.WEBRIP = { text: 'WEB', color: '#ab1b31' }
|
||||
|
||||
function sanitiseTerms ({ video_term: video, audio_term: audio, video_resolution: resolution, source }: AnitomyResult) {
|
||||
const terms = [...new Set([...video ?? [], ...audio ?? [], ...source ?? []].map(term => termMapping[term.toUpperCase() ?? '']).filter(t => t))] as Array<{text: string, color: string}>
|
||||
function sanitiseTerms ({ video_term: video, audio_term: audio, video_resolution: resolution, source, subtitles }: AnitomyResult) {
|
||||
const terms = [...new Set([...video ?? [], ...audio ?? [], ...source ?? [], ...subtitles ?? []].map(term => termMapping[term.toUpperCase() ?? '']).filter(t => t))] as Array<{text: string, color: string}>
|
||||
if (resolution.length) terms.unshift({ text: resolution[0]!, color: '#c6ec58' })
|
||||
|
||||
return terms
|
||||
|
|
@ -47,13 +48,18 @@
|
|||
if (group.length) simpleName = simpleName.replace(group[0]!, '')
|
||||
if (resolution.length) simpleName = simpleName.replace(resolution[0]!, '')
|
||||
if (checksum.length) simpleName = simpleName.replace(checksum[0]!, '')
|
||||
for (const term of video ?? []) simpleName = simpleName.replace(term[0]!, '')
|
||||
for (const term of audio ?? []) simpleName = simpleName.replace(term[0]!, '')
|
||||
for (const term of video ?? []) simpleName = simpleName.replace(term, '')
|
||||
for (const term of audio ?? []) simpleName = simpleName.replace(term, '')
|
||||
return simpleName.replace(/[[{(]\s*[\]})]/g, '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function getGroup ({ release_group: group, file_name: name }: AnitomyResult) {
|
||||
return group[0] && group[0].length < 20 ? group[0] : /^(?!\[[^\]]*\]).*-(\w+)(?=\s\(|\.\w+$|$)/.exec(name[0] ?? '')?.[1] ?? 'No Group'
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang='ts'>
|
||||
import Folder from 'lucide-svelte/icons/folder'
|
||||
import { getContext } from 'svelte'
|
||||
|
||||
import ProgressButton from './ui/button/progress-button.svelte'
|
||||
|
|
@ -159,24 +165,26 @@
|
|||
stop()
|
||||
}
|
||||
})
|
||||
|
||||
$: ({ r, g, b } = colors($searchStore?.media.coverImage?.color ?? undefined))
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={close} portal='#episodeListTarget'>
|
||||
<Dialog.Content class='bg-black h-full lg:border-x-4 border-b-0 max-w-5xl w-full max-h-[calc(100%-1rem)] mt-2 p-0 items-center flex-col flex lg:rounded-t-xl overflow-hidden z-[100]'>
|
||||
<Dialog.Content class='bg-black h-full max-w-5xl w-full max-h-[calc(100%-1rem)] border-b-0 !rounded-b-none mt-2 p-0 items-center flex-col flex lg:rounded-t-xl overflow-clip z-[100] gap-0'>
|
||||
<!-- this hacky thing is required for dialog root focus trap... pitiful -->
|
||||
<div class='size-0' tabindex='0' />
|
||||
{#if $searchStore}
|
||||
<div class='absolute top-0 left-0 w-full h-full max-h-28 overflow-hidden'>
|
||||
<Banner media={$searchStore.media} class='object-cover w-full h-full absolute bottom-[0.5px] left-0 -z-10' />
|
||||
<div class='w-full h-full banner-2' />
|
||||
<div class='absolute top-0 left-0 w-full h-full max-h-36 overflow-hidden flex items-end'>
|
||||
<Banner media={$searchStore.media} class='object-cover w-full h-full absolute bottom-[0.5px] left-0 -z-10 opacity-40' />
|
||||
<div class='w-full h-[70%] bg-gradient-to-t from-black/80 to-transparent' />
|
||||
</div>
|
||||
<div class='gap-4 w-full relative h-full flex flex-col pt-6'>
|
||||
<div class='gap-4 w-full relative h-full flex flex-col pt-8' style:--custom={$searchStore.media.coverImage?.color ?? '#fff'} style:--red={r} style:--green={g} style:--blue={b}>
|
||||
<div class='px-4 sm:px-6 space-y-4'>
|
||||
<div class='font-weight-bold text-2xl font-bold text-ellipsis text-nowrap overflow-hidden pb-2'>{title($searchStore.media) ?? ''}</div>
|
||||
<div class='font-weight-bold text-2xl font-bold text-ellipsis text-nowrap overflow-hidden'>{title($searchStore.media)}</div>
|
||||
<div class='flex items-center relative scale-parent'>
|
||||
<Input
|
||||
class='pl-9 bg-background select:bg-accent select:text-accent-foreground shadow-sm no-scale placeholder:opacity-50'
|
||||
placeholder='Filter by text, or paste a magnet link or torrent file to specify a torrent manually'
|
||||
placeholder='Filter by text, or paste a magnet link or torrent file here to specify a torrent manually'
|
||||
bind:value={inputText} />
|
||||
<MagnifyingGlass class='h-4 w-4 shrink-0 opacity-50 absolute left-3 text-muted-foreground z-10 pointer-events-none' />
|
||||
</div>
|
||||
|
|
@ -193,15 +201,15 @@
|
|||
<ProgressButton
|
||||
onclick={playBest}
|
||||
size='default'
|
||||
class='w-full font-bold'
|
||||
class='w-full font-bold bg-custom select:bg-custom-600 text-contrast'
|
||||
bind:animating>
|
||||
Auto Select Torrent
|
||||
</ProgressButton>
|
||||
</div>
|
||||
<div class='h-full overflow-y-auto px-4 sm:px-6 pt-2' role='menu' tabindex='-1' on:keydown={stopAnimation} on:focusin={stopAnimation} on:pointerenter={stopAnimation} on:pointermove={stopAnimation} use:dragScroll>
|
||||
<div class='h-full overflow-y-auto px-4 sm:px-6 pt-2' role='menu' tabindex='-1' on:keydown={stopAnimation} on:focusin={stopAnimation} on:pointerenter={stopAnimation} on:pointermove={stopAnimation} use:dragScroll style:--custom={$searchStore.media.coverImage?.color ?? '#fff'} style:--red={r} style:--green={g} style:--blue={b}>
|
||||
{#await Promise.all([searchResult, $downloaded])}
|
||||
{#each Array.from({ length: 12 }) as _, i (i)}
|
||||
<div class='p-3 h-[106px] flex cursor-pointer mb-2 relative rounded-md overflow-hidden border border-border flex-col justify-between [content-visibility:auto] [contain-intrinsic-height:auto_106px]'>
|
||||
<div class='p-3 h-[106px] flex cursor-pointer mb-2 relative rounded-md overflow-hidden bg-neutral-950 flex-col justify-between [content-visibility:auto] [contain-intrinsic-height:auto_106px]'>
|
||||
<div class='h-4 w-40 bg-primary/5 animate-pulse rounded mt-2' />
|
||||
<div class='bg-primary/5 animate-pulse rounded h-2 w-28 mt-1' />
|
||||
<div class='flex justify-between mb-1'>
|
||||
|
|
@ -218,23 +226,22 @@
|
|||
{#if search && media}
|
||||
{@const { results, errors } = search}
|
||||
{#each filterAndSortResults(results, inputText, downloaded) as result (result.hash)}
|
||||
<div class='p-3 flex cursor-pointer mb-2 relative rounded-md overflow-hidden border border-border select:ring-1 select:ring-ring select:bg-accent select:text-accent-foreground select:scale-[1.02] select:shadow-lg scale-100 transition-all [content-visibility:auto] [contain-intrinsic-height:auto_106px]' class:opacity-40={result.accuracy === 'low'} use:click={() => play(result)} title={result.parseObject.file_name[0]}>
|
||||
{#if result.accuracy === 'high'}
|
||||
<div class='absolute top-0 left-0 w-full h-full -z-10'>
|
||||
<Banner {media} class='object-cover w-full h-full' />
|
||||
<div class='absolute top-0 left-0 w-full h-full banner' />
|
||||
</div>
|
||||
{/if}
|
||||
<div class='p-3 flex cursor-pointer mb-2 relative rounded-md overflow-hidden bg-neutral-950 group/card select:ring-1 select:ring-custom select:bg-neutral-900 select:scale-[1.02] select:shadow-lg scale-100 transition-all [content-visibility:auto] [contain-intrinsic-height:auto_106px]' class:opacity-40={result.accuracy === 'low'} use:click={() => play(result)} title={result.parseObject.file_name[0]}>
|
||||
<div class='size-20 relative shrink-0 flex items-center justify-center text-xs px-1 text-wrap break-all font-bold text-center overflow-clip'>
|
||||
{#if result.accuracy === 'high' || result.accuracy === 'medium'}
|
||||
<BadgeCheck class={cn('absolute top-0 left-0 mix-blend-difference', result.accuracy === 'high' ? 'text-[#53da33]' : 'text-muted-foreground/20')} fill='currentColor' color='#000' size='1.2rem' />
|
||||
{/if}
|
||||
{#if downloaded.has(result.hash)}
|
||||
<Download class='text-[#53da33] size-12 opacity-80' stroke-width='0.5' color='currentColor' stroke='currentColor' />
|
||||
{:else if result.type}
|
||||
<Folder class='text-yellow-300 size-12 opacity-80' fill='currentColor' />
|
||||
{:else}
|
||||
<File class='text-muted-foreground size-12 opacity-80' />
|
||||
{/if}
|
||||
</div>
|
||||
<div class='flex pl-2 flex-col justify-between w-full h-20 relative min-w-0 text-[.7rem]'>
|
||||
<div class='flex w-full items-center'>
|
||||
{#if downloaded.has(result.hash)}
|
||||
<Download class='mr-2 text-[#53da33]' size='1.2rem' />
|
||||
{:else if result.type === 'batch'}
|
||||
<Database class='mr-2' size='1.2rem' />
|
||||
{:else if result.accuracy === 'high'}
|
||||
<BadgeCheck class='mr-2 text-[#53da33]' size='1.2rem' />
|
||||
{/if}
|
||||
<div class='text-xl font-bold text-nowrap'>{result.parseObject.release_group[0] && result.parseObject.release_group[0].length < 20 ? result.parseObject.release_group[0] : 'No Group'}</div>
|
||||
<div class='text-xl font-bold text-nowrap group-select/card:text-custom transition-colors'>{getGroup(result.parseObject)}</div>
|
||||
<div class='ml-auto flex gap-2 self-start'>
|
||||
{#each result.extension as id (id)}
|
||||
{#if $saved[id]}
|
||||
|
|
@ -246,20 +253,24 @@
|
|||
<div class='text-muted-foreground text-ellipsis text-nowrap overflow-hidden'>{simplifyFilename(result.parseObject)}</div>
|
||||
<div class='flex flex-row leading-none'>
|
||||
<div class='details text-light flex'>
|
||||
<span class='text-nowrap flex items-center'>{fastPrettyBytes(result.size)}</span>
|
||||
<span class='text-nowrap flex items-center'>{result.seeders} Seeders</span>
|
||||
<span class='text-nowrap flex items-center'>{since(new Date(result.date))}</span>
|
||||
{#if result.type === 'best'}
|
||||
<span class='rounded px-3 py-1 mr-0.5 border text-nowrap flex items-center' style='background: #1d2d1e; border-color: #53da33 !important; color: #53da33'>
|
||||
Best Release
|
||||
</span>
|
||||
{:else if result.type === 'alt'}
|
||||
<span class='rounded px-3 py-1 mr-0.5 border text-nowrap flex items-center' style='background: #391d20; border-color: #c52d2d !important; color: #c52d2d'>
|
||||
Alt Release
|
||||
</span>
|
||||
{:else if result.type === 'batch'}
|
||||
<span class='rounded px-3 py-1 mr-0.5 border text-nowrap flex items-center' style='background: #1d2031; border-color: #2d5ec5 !important; color: #2d5ec5'>
|
||||
Batch
|
||||
</span>
|
||||
{/if}
|
||||
<span class={cn('text-nowrap flex items-center', result.seeders > 20 ? 'text-green-600' : result.seeders < 5 ? 'text-red-600' : 'text-yellow-600')}>{result.seeders} Seeders</span>
|
||||
<span class='text-nowrap flex items-center text-white/80'>{fastPrettyBytes(result.size)}</span>
|
||||
<span class='text-nowrap flex items-center text-white/80'>{since(new Date(result.date))}</span>
|
||||
</div>
|
||||
<div class='flex ml-auto flex-row-reverse'>
|
||||
{#if result.type === 'best'}
|
||||
<div class='rounded px-3 py-1 ml-2 border text-nowrap flex items-center' style='background: #1d2d1e; border-color: #53da33 !important; color: #53da33'>
|
||||
Best Release
|
||||
</div>
|
||||
{:else if result.type === 'alt'}
|
||||
<div class='rounded px-3 py-1 ml-2 border text-nowrap flex items-center' style='background: #391d20; border-color: #c52d2d !important; color: #c52d2d'>
|
||||
Alt Release
|
||||
</div>
|
||||
{/if}
|
||||
{#each sanitiseTerms(result.parseObject) as { text, color }, i (i)}
|
||||
<div class='rounded px-3 py-1 ml-2 text-nowrap font-bold flex items-center' style:background={color}>
|
||||
<div class='text-contrast-filter'>
|
||||
|
|
|
|||
|
|
@ -36,15 +36,15 @@
|
|||
|
||||
<ButtonPrimitive.Root
|
||||
class={cn(
|
||||
buttonVariants({ variant, size, className }),
|
||||
'relative overflow-hidden'
|
||||
'relative overflow-hidden',
|
||||
buttonVariants({ variant, size, className })
|
||||
)}
|
||||
type='button'
|
||||
on:click={stopAnimation}
|
||||
on:click={onclick}>
|
||||
<slot />
|
||||
<div
|
||||
class='absolute inset-0 bg-current opacity-20 pointer-events-none'
|
||||
class='absolute inset-0 bg-black/20 pointer-events-none translate-x-full'
|
||||
class:animate-progress={animating}
|
||||
style='animation-duration: {duration}ms;'
|
||||
on:animationend={handleAnimationEnd} />
|
||||
|
|
|
|||
|
|
@ -22,16 +22,16 @@
|
|||
{transition}
|
||||
{transitionConfig}
|
||||
class={cn(
|
||||
'bg-background absolute top-[50%] left-[50%] z-50 grid w-full translate-y-[-50%] translate-x-[-50%] p-6 shadow-2xl border-neutral-700/60 border-y-4 bg-clip-padding',
|
||||
'bg-background fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg md:w-full',
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
<DialogPrimitive.Close
|
||||
class='ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity select:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none'
|
||||
class='ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm transition-opacity select:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none'
|
||||
>
|
||||
<Cross2 class='h-4 w-4' />
|
||||
<Cross2 class='size-5' />
|
||||
<span class='sr-only'>Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
<slot />
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content tabindex={null} class='gap-4 bottom-0 border-b-0 !translate-y-[unset] p-0 top-[unset] !pb-4 flex flex-col h-[90%] sm:h-1/2'>
|
||||
<Dialog.Content tabindex={null} class='gap-4 bottom-0 border-b-0 !translate-y-[unset] p-0 top-[unset] !pb-4 flex flex-col h-[90%] sm:h-1/2 max-w-full border-0 border-t !rounded-none'>
|
||||
<Markdown class='form-control w-full shrink-0 min-h-56 rounded-none flex-grow' {placeholder} bind:value />
|
||||
<div class='flex gap-2 justify-end flex-grow-0 px-4'>
|
||||
<Dialog.Close asChild let:builder>
|
||||
|
|
|
|||
30
src/lib/components/ui/player/downloadstats.svelte
Normal file
30
src/lib/components/ui/player/downloadstats.svelte
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<script lang='ts'>
|
||||
import ChevronDown from 'lucide-svelte/icons/chevron-down'
|
||||
import ChevronUp from 'lucide-svelte/icons/chevron-up'
|
||||
import Users from 'lucide-svelte/icons/users'
|
||||
|
||||
import { settings } from '$lib/modules/settings'
|
||||
import { server } from '$lib/modules/torrent'
|
||||
import { fastPrettyBits } from '$lib/utils'
|
||||
|
||||
const torrentstats = server.stats
|
||||
|
||||
export let immersed: boolean
|
||||
</script>
|
||||
|
||||
{#if !$settings.minimalPlayerUI}
|
||||
<div class='absolute top-0 flex w-full pointer-events-none justify-center gap-4 pt-3 items-center font-bold text-lg transition-opacity gradient-to-bottom delay-150' class:opacity-0={immersed}>
|
||||
<div class='flex justify-center items-center gap-2'>
|
||||
<Users size={18} />
|
||||
{$torrentstats.peers.seeders}
|
||||
</div>
|
||||
<div class='flex justify-center items-center gap-2'>
|
||||
<ChevronDown size={18} />
|
||||
{fastPrettyBits($torrentstats.speed.down * 8)}/s
|
||||
</div>
|
||||
<div class='flex justify-center items-center gap-2'>
|
||||
<ChevronUp size={18} />
|
||||
{fastPrettyBits($torrentstats.speed.up * 8)}/s
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
40
src/lib/components/ui/player/episodesmodal.svelte
Normal file
40
src/lib/components/ui/player/episodesmodal.svelte
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<script lang='ts'>
|
||||
import { getContext } from 'svelte'
|
||||
|
||||
import type { MediaInfo } from './util'
|
||||
|
||||
import { beforeNavigate, goto } from '$app/navigation'
|
||||
import EpisodesList from '$lib/components/EpisodesList.svelte'
|
||||
import * as Sheet from '$lib/components/ui/sheet'
|
||||
import { client } from '$lib/modules/anilist'
|
||||
import { episodes } from '$lib/modules/anizip'
|
||||
import { click } from '$lib/modules/navigate'
|
||||
|
||||
export let portal: HTMLElement
|
||||
let episodeListOpen = false
|
||||
|
||||
export let mediaInfo: MediaInfo
|
||||
|
||||
const stopProgressBar = getContext<() => void>('stop-progress-bar')
|
||||
beforeNavigate(({ cancel }) => {
|
||||
if (episodeListOpen) {
|
||||
episodeListOpen = false
|
||||
cancel()
|
||||
stopProgressBar()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class='text-white text-lg font-normal leading-none line-clamp-1 hover:text-neutral-300 hover:underline cursor-pointer' use:click={() => goto(`/app/anime/${mediaInfo.media.id}`)}>{mediaInfo.session.title}</div>
|
||||
<Sheet.Root {portal} bind:open={episodeListOpen}>
|
||||
<Sheet.Trigger id='episode-list-button' data-down='#player-seekbar' class='text-[rgba(217,217,217,0.6)] hover:text-neutral-500 text-sm leading-none font-light line-clamp-1 text-left hover:underline bg-transparent'>{mediaInfo.session.description}</Sheet.Trigger>
|
||||
<Sheet.Content class='w-full sm:w-[550px] p-3 sm:p-6 max-w-full sm:max-w-full h-full overflow-y-scroll flex flex-col !pb-0 shrink-0 gap-0 bg-black justify-between overflow-x-clip'>
|
||||
{#if mediaInfo.media}
|
||||
{#await Promise.all([episodes(mediaInfo.media.id), client.single(mediaInfo.media.id)]) then [eps, media]}
|
||||
{#if media.data?.Media}
|
||||
<EpisodesList {eps} media={media.data.Media} />
|
||||
{/if}
|
||||
{/await}
|
||||
{/if}
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
|
|
@ -4,7 +4,8 @@
|
|||
import { writable } from 'svelte/store'
|
||||
|
||||
import { Button } from '../button'
|
||||
import * as Sheet from '../sheet'
|
||||
|
||||
import EpisodesModal from './episodesmodal.svelte'
|
||||
|
||||
import type { ResolvedFile } from './resolver'
|
||||
import type { MediaInfo } from './util'
|
||||
|
|
@ -12,12 +13,9 @@
|
|||
|
||||
import { goto } from '$app/navigation'
|
||||
import { page } from '$app/stores'
|
||||
import EpisodesList from '$lib/components/EpisodesList.svelte'
|
||||
import * as Dialog from '$lib/components/ui/dialog'
|
||||
import { episodes } from '$lib/modules/anizip'
|
||||
import { authAggregator } from '$lib/modules/auth'
|
||||
import native from '$lib/modules/native'
|
||||
import { click } from '$lib/modules/navigate'
|
||||
import { settings } from '$lib/modules/settings'
|
||||
import { toTS } from '$lib/utils'
|
||||
|
||||
|
|
@ -67,17 +65,7 @@
|
|||
<div class='flex-col w-full flex-shrink-0 relative overflow-clip flex justify-center items-center bg-black {isMiniplayer ? 'aspect-video cursor-pointer' : 'h-full' } px-8' on:click={openPlayer} bind:this={wrapper}>
|
||||
<div class='flex flex-col gap-2 text-left' class:min-w-[320px]={!isMiniplayer}>
|
||||
<div class='text-white text-2xl font-bold leading-none line-clamp-1 mb-2'>Now Watching</div>
|
||||
<div class='text-white text-lg font-normal leading-none line-clamp-1 hover:text-neutral-300 cursor-pointer' 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}</Sheet.Trigger>
|
||||
<Sheet.Content class='w-full sm:w-[550px] p-3 sm:p-6 max-w-full sm:max-w-full h-full overflow-y-scroll flex flex-col !pb-0 shrink-0 gap-0 bg-black justify-between overflow-x-clip'>
|
||||
{#if mediaInfo.media}
|
||||
{#await episodes(mediaInfo.media.id) then eps}
|
||||
<EpisodesList {eps} media={mediaInfo.media} />
|
||||
{/await}
|
||||
{/if}
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
<EpisodesModal portal={wrapper} {mediaInfo} />
|
||||
{#await player}
|
||||
<div class='ml-auto self-end text-sm leading-none font-light text-nowrap mt-3'>{toTS(Math.min($elapsed, duration))} / {toTS(duration)}</div>
|
||||
<div class='relative w-full h-1 flex items-center justify-center overflow-clip rounded-[2px]'>
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@
|
|||
<EllipsisVertical size='24px' class='p-[1px]' />
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content class='absolute bg-transparent border-none p-0 shadow-none size-full overflow-hidden'>
|
||||
<Dialog.Content class='absolute bg-transparent border-none p-0 shadow-none size-full overflow-hidden max-w-full'>
|
||||
<div on:pointerdown|self={close} class='size-full flex justify-center items-center flex-col overflow-y-scroll text-[6px] lg:text-xs' use:dragScroll>
|
||||
{#if showKeybinds}
|
||||
<div class='bg-black py-3 px-4 rounded-md text-sm lg:text-lg font-bold mb-4'>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
<script lang='ts'>
|
||||
import Captions from 'lucide-svelte/icons/captions'
|
||||
import Cast from 'lucide-svelte/icons/cast'
|
||||
import ChevronDown from 'lucide-svelte/icons/chevron-down'
|
||||
import ChevronUp from 'lucide-svelte/icons/chevron-up'
|
||||
// import Cast from 'lucide-svelte/icons/cast'
|
||||
import Contrast from 'lucide-svelte/icons/contrast'
|
||||
import DecimalsArrowLeft from 'lucide-svelte/icons/decimals-arrow-left'
|
||||
import DecimalsArrowRight from 'lucide-svelte/icons/decimals-arrow-right'
|
||||
|
|
@ -18,25 +16,27 @@
|
|||
import ScreenShare from 'lucide-svelte/icons/screen-share'
|
||||
import SkipBack from 'lucide-svelte/icons/skip-back'
|
||||
import SkipForward from 'lucide-svelte/icons/skip-forward'
|
||||
import Users from 'lucide-svelte/icons/users'
|
||||
import Volume1 from 'lucide-svelte/icons/volume-1'
|
||||
import Volume2 from 'lucide-svelte/icons/volume-2'
|
||||
import VolumeX from 'lucide-svelte/icons/volume-x'
|
||||
import { getContext, onDestroy, onMount } from 'svelte'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import { fade } from 'svelte/transition'
|
||||
import { persisted } from 'svelte-persisted-store'
|
||||
import { toast } from 'svelte-sonner'
|
||||
import VideoDeband from 'video-deband'
|
||||
|
||||
import Episodetimer from './episodetimer.svelte'
|
||||
import ProgressButton from '../button/progress-button.svelte'
|
||||
import Animations, { playAnimation } from './animations.svelte'
|
||||
import DownloadStats from './downloadstats.svelte'
|
||||
import EpisodesModal from './episodesmodal.svelte'
|
||||
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 { getChaptersAniSkip, getChapterTitle, sanitizeChapters, type Chapter, type MediaInfo } from './util'
|
||||
import { findChapter, getChaptersAniSkip, getChapterTitle, isChapterSkippable, sanitizeChapters, screenshot, type Chapter, type MediaInfo } from './util'
|
||||
import Volume from './volume.svelte'
|
||||
|
||||
import type { ResolvedFile } from './resolver'
|
||||
|
|
@ -45,25 +45,20 @@
|
|||
|
||||
import { beforeNavigate, goto } from '$app/navigation'
|
||||
import { page } from '$app/stores'
|
||||
import EpisodesList from '$lib/components/EpisodesList.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'
|
||||
import { Maximize, Minimize } from '$lib/components/icons/animated'
|
||||
import { Button, iconSizes } from '$lib/components/ui/button'
|
||||
import * as Sheet from '$lib/components/ui/sheet'
|
||||
import { client } from '$lib/modules/anilist'
|
||||
import { episodes } from '$lib/modules/anizip'
|
||||
import { authAggregator } from '$lib/modules/auth'
|
||||
import { isPlaying } from '$lib/modules/idle'
|
||||
import native from '$lib/modules/native'
|
||||
import { click, inputType, keywrap } from '$lib/modules/navigate'
|
||||
import { settings, SUPPORTS } from '$lib/modules/settings'
|
||||
import { server } from '$lib/modules/torrent'
|
||||
import { w2globby } from '$lib/modules/w2g/lobby'
|
||||
import { getAnimeProgress, setAnimeProgress } from '$lib/modules/watchProgress'
|
||||
import { toTS, fastPrettyBits, scaleBlurFade } from '$lib/utils'
|
||||
import { toTS, scaleBlurFade, cn } from '$lib/utils'
|
||||
|
||||
export let mediaInfo: MediaInfo
|
||||
export let otherFiles: TorrentFile[]
|
||||
|
|
@ -118,7 +113,7 @@
|
|||
let paused = true
|
||||
let pointerMoving = false
|
||||
let fastForwarding = false
|
||||
const cast = false
|
||||
// const cast = false
|
||||
|
||||
$: $isPlaying = !paused
|
||||
|
||||
|
|
@ -146,9 +141,9 @@
|
|||
return fullscreenElement ? document.exitFullscreen() : document.getElementById('episodeListTarget')!.requestFullscreen()
|
||||
}
|
||||
|
||||
function toggleCast () {
|
||||
// TODO: never
|
||||
}
|
||||
// function toggleCast () {
|
||||
// // TODO: never
|
||||
// }
|
||||
|
||||
$: fullscreenElement ? screen.orientation.lock('landscape') : screen.orientation.unlock()
|
||||
|
||||
|
|
@ -222,27 +217,6 @@
|
|||
if (!wasPaused) video.play()
|
||||
}
|
||||
|
||||
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
|
||||
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.'
|
||||
})
|
||||
}
|
||||
|
||||
let chapters: Chapter[] = []
|
||||
const chaptersPromise = native.chapters(mediaInfo.file.hash, mediaInfo.file.id)
|
||||
async function loadChapters (pr: typeof chaptersPromise, safeduration: number) {
|
||||
|
|
@ -344,40 +318,27 @@
|
|||
|
||||
let currentSkippable: string | null = null
|
||||
function checkSkippableChapters () {
|
||||
const current = findChapter(currentTime)
|
||||
const current = findChapter(currentTime, chapters)
|
||||
const wasSkippable = currentSkippable
|
||||
if (current) {
|
||||
currentSkippable = isChapterSkippable(current)
|
||||
if ($settings.playerSkip && !wasSkippable) animating = true
|
||||
}
|
||||
}
|
||||
|
||||
$: if (currentSkippable && $settings.playerSkip) skip()
|
||||
|
||||
const skippableChaptersRx: Array<[string, RegExp]> = [
|
||||
['Opening', /^op$|opening$|^ncop|^opening /mi],
|
||||
['Ending', /^ed$|ending$|^nced|^ending /mi],
|
||||
['Recap', /recap/mi]
|
||||
]
|
||||
function isChapterSkippable (chapter: Chapter) {
|
||||
for (const [name, regex] of skippableChaptersRx) {
|
||||
if (regex.test(chapter.text)) {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return null
|
||||
function stopAnimation () {
|
||||
animating = false
|
||||
}
|
||||
|
||||
function findChapter (time: number) {
|
||||
return chapters.find(({ start, end }) => time >= start && time <= end)
|
||||
}
|
||||
let animating = false
|
||||
|
||||
function skip () {
|
||||
const current = findChapter(currentTime)
|
||||
const current = findChapter(currentTime, chapters)
|
||||
if (current) {
|
||||
if (!isChapterSkippable(current) && (current.end - current.start) > 100) {
|
||||
currentTime = currentTime + 85
|
||||
} else {
|
||||
const endtime = current.end
|
||||
if ((safeduration - endtime | 0) === 0) return next?.()
|
||||
const endtime = current.end + 0.5
|
||||
currentTime = endtime
|
||||
currentSkippable = null
|
||||
}
|
||||
|
|
@ -388,7 +349,6 @@
|
|||
} else {
|
||||
currentTime = currentTime + 85
|
||||
}
|
||||
video.currentTime = currentTime
|
||||
}
|
||||
|
||||
let stats: {
|
||||
|
|
@ -496,7 +456,7 @@
|
|||
let fitWidth = false
|
||||
loadWithDefaults({
|
||||
KeyX: {
|
||||
fn: () => screenshot(),
|
||||
fn: () => screenshot(video, subtitles),
|
||||
id: 'screenshot_monitor',
|
||||
icon: ScreenShare,
|
||||
type: 'icon',
|
||||
|
|
@ -577,13 +537,13 @@
|
|||
type: 'icon',
|
||||
desc: 'Toggle Video Cover'
|
||||
},
|
||||
KeyD: {
|
||||
fn: () => toggleCast(),
|
||||
id: 'cast',
|
||||
icon: Cast,
|
||||
type: 'icon',
|
||||
desc: 'Toggle Cast [broken]'
|
||||
},
|
||||
// KeyD: {
|
||||
// fn: () => toggleCast(),
|
||||
// id: 'cast',
|
||||
// icon: Cast,
|
||||
// type: 'icon',
|
||||
// desc: 'Toggle Cast [broken]'
|
||||
// },
|
||||
KeyC: {
|
||||
fn: () => cycleSubtitles(),
|
||||
id: 'subtitles',
|
||||
|
|
@ -680,8 +640,6 @@
|
|||
}
|
||||
})
|
||||
|
||||
const torrentstats = server.stats
|
||||
|
||||
$condition = () => !isMiniplayer
|
||||
|
||||
function holdToFF (document: HTMLElement, type: 'key' | 'pointer') {
|
||||
|
|
@ -760,17 +718,6 @@
|
|||
const saveProgressLoop = setInterval(saveAnimeProgress, 10000)
|
||||
onDestroy(() => clearInterval(saveProgressLoop))
|
||||
|
||||
let episodeListOpen = false
|
||||
|
||||
const stopProgressBar = getContext<() => void>('stop-progress-bar')
|
||||
beforeNavigate(({ cancel }) => {
|
||||
if (episodeListOpen) {
|
||||
episodeListOpen = false
|
||||
cancel()
|
||||
stopProgressBar()
|
||||
}
|
||||
})
|
||||
|
||||
function handleWheel ({ shiftKey, deltaY }: WheelEvent) {
|
||||
const sign = Math.sign(deltaY)
|
||||
if (shiftKey) {
|
||||
|
|
@ -783,7 +730,7 @@
|
|||
|
||||
<svelte:document bind:fullscreenElement bind:visibilityState use:holdToFF={'key'} />
|
||||
|
||||
<div class='w-full h-full relative content-center bg-black overflow-clip text-left touch-none' class:fitWidth class:seeking class:pip={pictureInPictureElement} bind:this={wrapper} on:navigate={() => resetMove(2000)} on:wheel={handleWheel}>
|
||||
<div class='w-full h-full relative content-center bg-black overflow-clip text-left touch-none' class:fitWidth class:seeking class:pip={pictureInPictureElement} bind:this={wrapper} on:navigate={() => resetMove(2000)} on:wheel={handleWheel} on:keydown={stopAnimation} on:focusin={stopAnimation} on:pointerenter={stopAnimation} on:pointermove={stopAnimation}>
|
||||
<video class='w-full h-full touch-none' preload='metadata' class:cursor-none={immersed} class:cursor-pointer={isMiniplayer} class:object-cover={fitWidth} class:opacity-0={$settings.playerDeband || seeking || pictureInPictureElement} class:absolute={$settings.playerDeband} class:top-0={$settings.playerDeband}
|
||||
use:createSubtitles
|
||||
use:createDeband={$settings.playerDeband}
|
||||
|
|
@ -813,22 +760,7 @@
|
|||
/>
|
||||
{#if !isMiniplayer}
|
||||
<div class='absolute w-full h-full flex items-center justify-center top-0 pointer-events-none'>
|
||||
{#if !$settings.minimalPlayerUI}
|
||||
<div class='absolute top-0 flex w-full pointer-events-none justify-center gap-4 pt-3 items-center font-bold text-lg transition-opacity gradient-to-bottom delay-150' class:opacity-0={immersed}>
|
||||
<div class='flex justify-center items-center gap-2'>
|
||||
<Users size={18} />
|
||||
{$torrentstats.peers.seeders}
|
||||
</div>
|
||||
<div class='flex justify-center items-center gap-2'>
|
||||
<ChevronDown size={18} />
|
||||
{fastPrettyBits($torrentstats.speed.down * 8)}/s
|
||||
</div>
|
||||
<div class='flex justify-center items-center gap-2'>
|
||||
<ChevronUp size={18} />
|
||||
{fastPrettyBits($torrentstats.speed.up * 8)}/s
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<DownloadStats {immersed} />
|
||||
{#if seeking}
|
||||
{#await thumbnailer.getThumbnail(seekIndex) then src}
|
||||
{#if src}
|
||||
|
|
@ -851,7 +783,7 @@
|
|||
</div>
|
||||
{/if}
|
||||
<Options {wrapper} bind:openSubs {video} {seekTo} {selectAudio} {selectVideo} {fullscreen} {chapters} {subtitles} {videoFiles} {selectFile} {pip} bind:playbackRate={$playbackRate} bind:subtitleDelay id='player-options-button-top'
|
||||
class='{($settings.minimalPlayerUI || SUPPORTS.isAndroid) ? 'inline-flex' : 'mobile:inline-flex hidden'} p-3 size-12 absolute z-[1] top-4 left-4 bg-black/20 pointer-events-auto transition-opacity select:opacity-100 delay-150 {immersed && 'opacity-0'}' />
|
||||
class='{($settings.minimalPlayerUI || SUPPORTS.isAndroid) ? 'inline-flex' : 'mobile:inline-flex hidden'} p-3 size-12 absolute z-[1] top-4 left-4 bg-black/20 pointer-events-auto transition-opacity delay-150 select:opacity-100 {immersed && 'opacity-0'}' />
|
||||
{#if fastForwarding}
|
||||
<div class='absolute top-10 font-bold text-sm animate-[fade-in_.4s_ease] flex items-center leading-none bg-black/60 px-4 py-2 rounded-2xl'>x2 <FastForward class='ml-2' size='12' fill='currentColor' /></div>
|
||||
{/if}
|
||||
|
|
@ -883,29 +815,17 @@
|
|||
{/if}
|
||||
<Animations />
|
||||
</div>
|
||||
{#if currentSkippable}
|
||||
<ProgressButton onclick={skip} bind:animating size='default' duration={3000} class={cn('px-7 font-bold absolute bottom-40 right-10 transition-opacity delay-150', immersed && !animating && 'opacity-0')}>
|
||||
Skip {currentSkippable}
|
||||
</ProgressButton>
|
||||
{/if}
|
||||
<div class='absolute w-full bottom-0 flex flex-col gradient px-6 py-3 transition-opacity delay-150 select:opacity-100' class:opacity-0={immersed}>
|
||||
<div class='flex justify-between gap-12 items-end'>
|
||||
<div class='flex flex-col gap-2 text-left cursor-pointer'>
|
||||
<a class='text-white text-lg font-normal leading-none line-clamp-1 hover:text-neutral-300 hover:underline' href='/app/anime/{mediaInfo.media.id}' data-up='#player-options-button-top'>{mediaInfo.session.title}</a>
|
||||
<Sheet.Root portal={wrapper} bind:open={episodeListOpen}>
|
||||
<Sheet.Trigger id='episode-list-button' data-down='#player-seekbar' class='text-[rgba(217,217,217,0.6)] hover:text-neutral-500 text-sm leading-none font-light line-clamp-1 text-left hover:underline bg-transparent'>{mediaInfo.session.description}</Sheet.Trigger>
|
||||
<Sheet.Content class='w-full sm:w-[550px] p-3 sm:p-6 max-w-full sm:max-w-full h-full overflow-y-scroll flex flex-col !pb-0 shrink-0 gap-0 bg-black justify-between overflow-x-clip'>
|
||||
{#if mediaInfo.media}
|
||||
{#await Promise.all([episodes(mediaInfo.media.id), client.single(mediaInfo.media.id)]) then [eps, media]}
|
||||
{#if media.data?.Media}
|
||||
<EpisodesList {eps} media={media.data.Media} />
|
||||
{/if}
|
||||
{/await}
|
||||
{/if}
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
<EpisodesModal portal={wrapper} {mediaInfo} />
|
||||
</div>
|
||||
<div class='flex flex-col gap-2 grow-0 items-end self-end'>
|
||||
{#if currentSkippable}
|
||||
<Button on:click={skip} class='font-bold mb-2'>
|
||||
Skip {currentSkippable}
|
||||
</Button>
|
||||
{/if}
|
||||
<div class='text-[rgba(217,217,217,0.6)] text-sm leading-none font-light line-clamp-1 capitalize'>{getChapterTitle(seeking ? seekPercent * safeduration / 100 : currentTime, chapters) || ''}</div>
|
||||
<div class='ml-auto self-end text-sm leading-none font-light text-nowrap'>{toTS(seeking ? seekPercent * safeduration / 100 : currentTime)} / {toTS(safeduration)}</div>
|
||||
</div>
|
||||
|
|
@ -966,7 +886,7 @@
|
|||
</div>
|
||||
{/if}
|
||||
</Button>
|
||||
{#if false}
|
||||
<!-- {#if false}
|
||||
<Button class='p-3 size-12' variant='ghost' on:click={toggleCast} on:keydown={keywrap(toggleCast)} data-up='#player-seekbar'>
|
||||
{#if cast}
|
||||
<Cast size='24px' fill='white' strokeWidth='2' />
|
||||
|
|
@ -974,7 +894,7 @@
|
|||
<Cast size='24px' strokeWidth='2' />
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
{/if} -->
|
||||
<Button class='p-3 size-12 relative animated-icon shrink-0' variant='ghost' on:click={fullscreen} on:keydown={keywrap(fullscreen)} data-up='#player-seekbar'>
|
||||
{#if fullscreenElement}
|
||||
<div transition:scaleBlurFade class='absolute'>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { toast } from 'svelte-sonner'
|
||||
|
||||
import type { Media } from '$lib/modules/anilist'
|
||||
import type { ResolvedFile } from './resolver'
|
||||
import type Subtitles from './subtitles'
|
||||
import type { Track } from '../../../../app'
|
||||
import type { SessionMetadata } from 'native'
|
||||
|
||||
|
|
@ -174,3 +177,38 @@ export function normalizeSubs (_tracks?: Record<number | string, { meta: { langu
|
|||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
export async function screenshot (video: HTMLVideoElement, subtitles?: Subtitles) {
|
||||
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
|
||||
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.' })
|
||||
}
|
||||
|
||||
const skippableChaptersRx: Array<[string, RegExp]> = [
|
||||
['Opening', /^op$|opening$|^ncop|^opening /mi],
|
||||
['Ending', /^ed$|ending$|^nced|^ending /mi],
|
||||
['Recap', /recap/mi]
|
||||
]
|
||||
export function isChapterSkippable ({ text }: Chapter) {
|
||||
for (const [name, regex] of skippableChaptersRx) {
|
||||
if (regex.test(text)) return name
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function findChapter (time: number, chapters: Chapter[]) {
|
||||
return chapters.find(({ start, end }) => time >= start && time <= end)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,8 +86,8 @@
|
|||
</div>
|
||||
<Load src={cover(media)} color={media.coverImage?.color} class='w-full h-full object-cover' />
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content class='flex justify-center'>
|
||||
<Load src={cover(media)} color={media.coverImage?.color} class='h-full object-cover rounded' />
|
||||
<Dialog.Content class='flex justify-center p-0 overflow-clip'>
|
||||
<Load src={cover(media)} color={media.coverImage?.color} class='h-full w-full object-cover' />
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
<div class='flex flex-col gap-4 items-center md:items-start justify-end w-full'>
|
||||
|
|
@ -145,7 +145,7 @@
|
|||
<Clapperboard class='size-4' />
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content class='flex justify-center max-h-[80%] h-full'>
|
||||
<Dialog.Content class='flex justify-center max-h-[80%] h-full max-w-max'>
|
||||
<iframe class='h-full max-w-full aspect-video max-h-full rounded' src={`https://www.youtube-nocookie.com/embed/${media.trailer.id}?autoplay=1`} frameborder='0' allow='autoplay' allowfullscreen title={media.title?.userPreferred ?? ''} />
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
|
|
|||
Loading…
Reference in a new issue