feat: torrent status

This commit is contained in:
ThaUnknown 2025-04-16 17:50:49 +02:00
parent 0c2614e6c4
commit eb542299fe
No known key found for this signature in database
18 changed files with 300 additions and 17 deletions

16
src/app.d.ts vendored
View file

@ -38,6 +38,20 @@ export interface Attachment {
url: string
}
export interface TorrentInfo {
peers: number
progress: number
down: number
up: number
name: string
hash: string
seeders: number
leechers: number
size: number
downloaded: number
eta: number
}
export interface Native {
authAL: (url: string) => Promise<AuthResponse>
restart: () => Promise<void>
@ -67,6 +81,8 @@ export interface Native {
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 }>>
torrentStats: (hash: string) => Promise<TorrentInfo>
torrents: () => Promise<TorrentInfo[]>
isApp: boolean
version: () => string
}

View file

@ -1,10 +1,10 @@
<script lang='ts'>
import { Cast, FastForward, Maximize, Minimize, Pause, Rewind, SkipBack, SkipForward, Captions, Contrast, List, PictureInPicture2, Proportions, RefreshCcw, RotateCcw, RotateCw, ScreenShare, Volume1, Volume2, VolumeX } from 'lucide-svelte'
import { Cast, FastForward, Maximize, Minimize, Pause, Rewind, SkipBack, SkipForward, Captions, Contrast, List, PictureInPicture2, Proportions, RefreshCcw, RotateCcw, RotateCw, ScreenShare, Volume1, Volume2, VolumeX, ChevronDown, ChevronUp, Users } from 'lucide-svelte'
import { persisted } from 'svelte-persisted-store'
import { toast } from 'svelte-sonner'
import { fade } from 'svelte/transition'
import { onMount } from 'svelte'
import { loadWithDefaults } from 'svelte-keybinds'
import { condition, loadWithDefaults } from 'svelte-keybinds'
import VideoDeband from 'video-deband'
import Seekbar from './seekbar.svelte'
@ -18,6 +18,7 @@
import type { TorrentFile } from '../../../../app'
import type { ResolvedFile } from './resolver'
import { server } from '$lib/modules/torrent'
import PictureInPictureExit from '$lib/components/icons/PictureInPictureExit.svelte'
import * as Sheet from '$lib/components/ui/sheet'
import PictureInPicture from '$lib/components/icons/PictureInPicture.svelte'
@ -25,7 +26,7 @@
import Play from '$lib/components/icons/Play.svelte'
import { Button } from '$lib/components/ui/button'
import { settings } from '$lib/modules/settings'
import { toTS } from '$lib/utils'
import { toTS, fastPrettyBits } from '$lib/utils'
import native from '$lib/modules/native'
import { click } from '$lib/modules/navigate'
import { goto } from '$app/navigation'
@ -543,7 +544,12 @@
}
})
const torrentstats = server.stats
$: isMiniplayer = $page.route.id !== '/app/player'
// @ts-expect-error bad type infer
$condition = () => !isMiniplayer
</script>
<svelte:document bind:fullscreenElement bind:visibilityState />
@ -574,6 +580,21 @@
on:timeupdate={checkCompletion}
/>
<div class='absolute w-full h-full flex items-center justify-center top-0 pointer-events-none'>
<div class='absolute top-0 flex w-full pointer-events-none justify-center z-50 gap-4 pt-3 items-center font-bold text-lg' class:hidden={isMiniplayer}>
<!-- {($torrentstats.progress * 100).toFixed(1)}% -->
<div class='flex justify-center items-center gap-2'>
<Users size={18} />
{$torrentstats.seeders}
</div>
<div class='flex justify-center items-center gap-2'>
<ChevronDown size={18} />
{fastPrettyBits($torrentstats.down)}/s
</div>
<div class='flex justify-center items-center gap-2'>
<ChevronUp size={18} />
{fastPrettyBits($torrentstats.up)}/s
</div>
</div>
{#if seeking}
{#await thumbnailer.getThumbnail(seekIndex) then src}
<img {src} alt='thumbnail' class='w-full h-full bg-black absolute top-0 right-0 object-contain' loading='lazy' decoding='async' />

View file

@ -1,12 +1,12 @@
<script lang='ts'>
import { Home, Search, Calendar, Users, MessagesSquare, Heart, Settings, LogIn } from 'lucide-svelte'
import { Download } from 'svelte-radix'
import { BannerImage } from '../banner'
import { Button } from '../button'
import SidebarButton from './SidebarButton.svelte'
import Hub from '$lib/components/icons/Hub.svelte'
import native from '$lib/modules/native'
import client from '$lib/modules/auth/client'
import * as Avatar from '$lib/components/ui/avatar'
@ -36,14 +36,15 @@
<SidebarButton href='/app/schedule/'>
<Calendar size={18} />
</SidebarButton>
<SidebarButton href='/app/w2g/'>
<!-- <SidebarButton href='/app/w2g/'> -->
<SidebarButton disabled={true}>
<Users size={18} />
</SidebarButton>
<SidebarButton href='/app/chat/'>
<MessagesSquare size={18} />
</SidebarButton>
<SidebarButton href='/app/client/'>
<Hub size={18} fill='currentColor' />
<Download size={18} />
</SidebarButton>
<Button variant='ghost' on:click={() => native.openURL('https://github.com/sponsors/ThaUnknown/')} class='px-2 w-full relative mt-auto select:!bg-transparent text-[#fa68b6] select:text-[#fa68b6]'>
<Heart size={18} fill='currentColor' class='absolute' />

View file

@ -0,0 +1,28 @@
import Root from './table.svelte'
import Body from './table-body.svelte'
import Caption from './table-caption.svelte'
import Cell from './table-cell.svelte'
import Footer from './table-footer.svelte'
import Head from './table-head.svelte'
import Header from './table-header.svelte'
import Row from './table-row.svelte'
export {
Root,
Body,
Caption,
Cell,
Footer,
Head,
Header,
Row,
//
Root as Table,
Body as TableBody,
Caption as TableCaption,
Cell as TableCell,
Footer as TableFooter,
Head as TableHead,
Header as TableHeader,
Row as TableRow
}

View file

@ -0,0 +1,14 @@
<script lang='ts'>
import type { HTMLAttributes } from 'svelte/elements'
import { cn } from '$lib/utils.js'
type $$Props = HTMLAttributes<HTMLTableSectionElement>
let className: $$Props['class'] = undefined
export { className as class }
</script>
<tbody class={cn('[&_tr:last-child]:border-0', className)} {...$$restProps}>
<slot />
</tbody>

View file

@ -0,0 +1,14 @@
<script lang='ts'>
import type { HTMLAttributes } from 'svelte/elements'
import { cn } from '$lib/utils.js'
type $$Props = HTMLAttributes<HTMLTableCaptionElement>
let className: $$Props['class'] = undefined
export { className as class }
</script>
<caption class={cn('text-muted-foreground mt-4 text-sm', className)} {...$$restProps}>
<slot />
</caption>

View file

@ -0,0 +1,22 @@
<script lang='ts'>
import type { HTMLTdAttributes } from 'svelte/elements'
import { cn } from '$lib/utils.js'
type $$Props = HTMLTdAttributes
let className: $$Props['class'] = undefined
export { className as class }
</script>
<td
class={cn(
'p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className
)}
{...$$restProps}
on:click
on:keydown
>
<slot />
</td>

View file

@ -0,0 +1,14 @@
<script lang='ts'>
import type { HTMLAttributes } from 'svelte/elements'
import { cn } from '$lib/utils.js'
type $$Props = HTMLAttributes<HTMLTableSectionElement>
let className: $$Props['class'] = undefined
export { className as class }
</script>
<tfoot class={cn('bg-muted/50 text-primary-foreground font-medium', className)} {...$$restProps}>
<slot />
</tfoot>

View file

@ -0,0 +1,20 @@
<script lang='ts'>
import type { HTMLThAttributes } from 'svelte/elements'
import { cn } from '$lib/utils.js'
type $$Props = HTMLThAttributes
let className: $$Props['class'] = undefined
export { className as class }
</script>
<th
class={cn(
'text-muted-foreground h-10 px-2 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className
)}
{...$$restProps}
>
<slot />
</th>

View file

@ -0,0 +1,15 @@
<script lang='ts'>
import type { HTMLAttributes } from 'svelte/elements'
import { cn } from '$lib/utils.js'
type $$Props = HTMLAttributes<HTMLTableSectionElement>
let className: $$Props['class'] = undefined
export { className as class }
</script>
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<thead class={cn('[&_tr]:border-b', className)} {...$$restProps} on:click on:keydown>
<slot />
</thead>

View file

@ -0,0 +1,24 @@
<script lang='ts'>
import type { HTMLAttributes } from 'svelte/elements'
import { cn } from '$lib/utils.js'
type $$Props = HTMLAttributes<HTMLTableRowElement> & {
'data-state'?: unknown
}
let className: $$Props['class'] = undefined
export { className as class }
</script>
<tr
class={cn(
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
className
)}
{...$$restProps}
on:click
on:keydown
>
<slot />
</tr>

View file

@ -0,0 +1,16 @@
<script lang='ts'>
import type { HTMLTableAttributes } from 'svelte/elements'
import { cn } from '$lib/utils.js'
type $$Props = HTMLTableAttributes
let className: $$Props['class'] = undefined
export { className as class }
</script>
<div class='relative w-full overflow-auto'>
<table class={cn('w-full caption-bottom text-sm', className)} {...$$restProps}>
<slot />
</table>
</div>

View file

@ -1,8 +1,10 @@
import type { AuthResponse, Native } from '../../app'
import type { AuthResponse, Native, TorrentInfo } from '../../app'
const rnd = (range = 100) => Math.floor(Math.random() * range)
const dummyFiles = [
{
name: 'My Happy Marriage Season 2.webm',
name: 'Amebku.webm',
hash: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
type: 'video/webm',
size: 1234567890,
@ -68,7 +70,8 @@ export default Object.assign<Native, Partial<Native>>({
{ start: 1.0 * 60, end: 1.2 * 60, text: 'Chapter 1' },
{ start: 1.4 * 60, end: 88, text: 'Chapter 2 ' }
],
version: () => 'v0.0.2'
version: () => 'v0.0.2',
torrentStats: async (): Promise<TorrentInfo> => ({ peers: rnd(), seeders: rnd(), leechers: rnd(), progress: Math.random(), down: rnd(100000000), up: rnd(100000000), name: 'Amebku.webm', downloaded: rnd(100000), hash: '1234567890abcdef', size: 1234567890, eta: rnd() }),
torrents: async (): Promise<TorrentInfo[]> => [{ peers: rnd(), seeders: rnd(), leechers: rnd(), progress: Math.random(), down: rnd(100000000), up: rnd(100000000), name: 'Amebku.webm', downloaded: rnd(100000), hash: '1234567890abcdef', size: 1234567890, eta: rnd() }]
// @ts-expect-error idk
}, globalThis.native as Partial<Native>)

View file

@ -1,16 +1,41 @@
import { writable } from 'simple-store-svelte'
import { readable, writable } from 'simple-store-svelte'
import { persisted } from 'svelte-persisted-store'
import { get } from 'svelte/store'
import native from '../native'
import type { Media } from '../anilist'
import type { TorrentFile } from '../../../app'
import type { TorrentFile, TorrentInfo } from '../../../app'
export const server = new class ServerClient {
last = persisted<{media: Media, id: string, episode: number} | null>('last-torrent', null)
active = writable<Promise<{media: Media, id: string, episode: number, files: TorrentFile[]}| null>>()
stats = readable<TorrentInfo>({ peers: 0, down: 0, up: 0, progress: 0, downloaded: 0, eta: 0, hash: '', leechers: 0, name: '', seeders: 0, size: 0 }, set => {
let listener = 0
const update = async () => {
const id = (await get(this.active))?.id
if (id) set(await native.torrentStats(id))
listener = setTimeout(update, 1000)
}
update()
return () => clearTimeout(listener)
})
list = readable<TorrentInfo[]>([], set => {
let listener = 0
const update = async () => {
set(await native.torrents())
listener = setTimeout(update, 1000)
}
update()
return () => clearTimeout(listener)
})
constructor () {
const last = get(this.last)
if (last) this.play(last.id, last.media, last.episode)

View file

@ -95,6 +95,7 @@ export const isMobile = readable(!mql?.matches, set => {
})
const formatter = new Intl.RelativeTimeFormat('en')
const formatterShort = new Intl.RelativeTimeFormat('en', { style: 'short' })
const ranges: Partial<Record<Intl.RelativeTimeFormatUnit, number>> = {
years: 3600 * 24 * 365,
months: 3600 * 24 * 30,
@ -115,12 +116,22 @@ export function since (date: Date) {
}
}
}
export function eta (date: Date) {
const secondsElapsed = (date.getTime() - Date.now()) / 1000
for (const _key in ranges) {
const key = _key as Intl.RelativeTimeFormatUnit
if ((ranges[key] ?? 0) < Math.abs(secondsElapsed)) {
const delta = secondsElapsed / (ranges[key] ?? 0)
return formatterShort.format(Math.round(delta), key)
}
}
}
const bytes = [' B', ' kB', ' MB', ' GB', ' TB']
export function fastPrettyBytes (num: number) {
if (isNaN(num)) return '0 B'
if (num < 1) return num + ' B'
const exponent = Math.min(Math.floor(Math.log(num) / Math.log(1000)), bytes.length - 1)
return Number((num / Math.pow(1000, exponent)).toFixed(2)) + bytes[exponent]!
return Number((num / Math.pow(1000, exponent)).toFixed(1)) + bytes[exponent]!
}
const bits = [' b', ' kb', ' Mb', ' Gb', ' Tb']
@ -128,7 +139,7 @@ export function fastPrettyBits (num: number) {
if (isNaN(num)) return '0 b'
if (num < 1) return num + ' b'
const exponent = Math.min(Math.floor(Math.log(num) / Math.log(1000)), bits.length - 1)
return Number((num / Math.pow(1000, exponent)).toFixed(2)) + bits[exponent]!
return Number((num / Math.pow(1000, exponent)).toFixed(1)) + bits[exponent]!
}
export function toTS (sec: number, full?: number) {

View file

@ -9,11 +9,9 @@
<BannerImage class='absolute top-0 left-0' />
<SearchModal />
<div class='flex flex-row grow h-full overflow-hidden relative'>
<Sidebar>
<Sidebarlist />
</Sidebar>
<Player />
<slot />
</div>

View file

@ -113,7 +113,7 @@
</div>
<div class='flex gap-2 items-center justify-center md:justify-start w-full lex-wrap'>
<div class='flex md:mr-3 w-full min-[380px]:w-[180px]'>
<PlayButton size='default' {media} class='rounded-r-none' />
<PlayButton size='default' {media} class='rounded-r-none w-full' />
<EntryEditor {media} />
</div>
<FavoriteButton {media} variant='secondary' size='icon' class='min-[380px]:-order-1 md:order-none' />

View file

@ -0,0 +1,41 @@
<script lang='ts'>
import * as Table from '$lib/components/ui/table'
import { dragScroll } from '$lib/modules/navigate'
import { server } from '$lib/modules/torrent'
import { fastPrettyBits, fastPrettyBytes, eta as _eta } from '$lib/utils'
const list = server.list
</script>
<div class='flex flex-col items-center w-full h-full overflow-y-auto px-5 my-10' use:dragScroll>
<Table.Root>
<Table.Header>
<Table.Row class='[&>*]:p-4 [&>*]:font-bold'>
<Table.Head>Name</Table.Head>
<Table.Head class='w-[100px]'>Progress</Table.Head>
<Table.Head class='w-[100px]'>Size</Table.Head>
<Table.Head class='w-[100px]'>Done</Table.Head>
<Table.Head class='w-[110px]'>Download</Table.Head>
<Table.Head class='w-[110px]'>Upload</Table.Head>
<Table.Head class='w-[110px]'>ETA</Table.Head>
<Table.Head class='w-[100px]'>Seeders</Table.Head>
<Table.Head class='w-[100px]'>Leechers</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each $list as { name, progress, size, down, up, eta, seeders, leechers, peers }, i (i)}
<Table.Row class='[&>*]:p-4'>
<Table.Cell>{name}</Table.Cell>
<Table.Cell>{(progress * 100).toFixed(1)}%</Table.Cell>
<Table.Cell>{fastPrettyBytes(size)}</Table.Cell>
<Table.Cell>{fastPrettyBytes(size * progress)}</Table.Cell>
<Table.Cell>{fastPrettyBits(down)}/s</Table.Cell>
<Table.Cell>{fastPrettyBits(up)}/s</Table.Cell>
<Table.Cell>{_eta(new Date(Date.now() + eta * 1000))}</Table.Cell>
<Table.Cell>{seeders}<span class='text-muted-foreground'>/{peers}</span></Table.Cell>
<Table.Cell>{leechers}<span class='text-muted-foreground'>/{peers}</span></Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>