feat: better looking modals

chore: split video player into sub components
This commit is contained in:
ThaUnknown 2025-09-04 19:48:16 +02:00
parent 58d9854b79
commit 1cbc29f96a
No known key found for this signature in database
12 changed files with 135 additions and 119 deletions

View file

@ -1,6 +1,6 @@
{
"name": "ui",
"version": "6.4.119",
"version": "6.4.120",
"license": "BUSL-1.1",
"private": true,
"packageManager": "pnpm@9.15.5",

View file

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

View file

@ -162,7 +162,7 @@
</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)] mt-2 p-0 items-center flex-col flex lg:rounded-t-xl overflow-hidden z-[100] gap-0'>
<!-- this hacky thing is required for dialog root focus trap... pitiful -->
<div class='size-0' tabindex='0' />
{#if $searchStore}

View file

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

View file

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

View 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}

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

View file

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

View file

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

View file

@ -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,24 +16,25 @@
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 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 { getChaptersAniSkip, getChapterTitle, sanitizeChapters, screenshot, type Chapter, type MediaInfo } from './util'
import Volume from './volume.svelte'
import type { ResolvedFile } from './resolver'
@ -44,25 +43,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 } from '$lib/utils'
export let mediaInfo: MediaInfo
export let otherFiles: TorrentFile[]
@ -117,7 +111,7 @@
let paused = true
let pointerMoving = false
let fastForwarding = false
const cast = false
// const cast = false
$: $isPlaying = !paused
@ -145,9 +139,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()
@ -221,27 +215,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) {
@ -495,7 +468,7 @@
let fitWidth = false
loadWithDefaults({
KeyX: {
fn: () => screenshot(),
fn: () => screenshot(video, subtitles),
id: 'screenshot_monitor',
icon: ScreenShare,
type: 'icon',
@ -576,13 +549,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',
@ -679,8 +652,6 @@
}
})
const torrentstats = server.stats
$condition = () => !isMiniplayer
function holdToFF (document: HTMLElement, type: 'key' | 'pointer') {
@ -759,17 +730,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) {
@ -812,22 +772,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}
@ -885,19 +830,7 @@
<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}
@ -958,7 +891,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' />
@ -966,7 +899,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'>

View file

@ -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,24 @@ 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.'
})
}

View file

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