Compare commits

..

No commits in common. "master" and "v6.4.11" have entirely different histories.

171 changed files with 2328 additions and 6538 deletions

View file

@ -12,17 +12,17 @@ body:
options: options:
- label: >- - label: >-
I have searched the [issue I have searched the [issue
tracker](https://github.com/hayase-app/ui/issues) for a bug report tracker](https://github.com/ThaUnknown/miru/issues) for a bug report
that matches the one I want to file, without success. that matches the one I want to file, without success.
required: true required: true
- label: >- - label: >-
I have searched the [frequently asked I have searched the [frequently asked
questions](https://hayase.watch/faq) for a solution to my problem, questions](https://miru.watch/faq) for a solution to my problem,
for a solution that fixes this problem, without success. for a solution that fixes this problem, without success.
required: true required: true
- label: >- - label: >-
I have checked that I'm using the [latest I have checked that I'm using the [latest
stable](https://github.com/hayase-app/ui/releases/latest) version stable](https://github.com/ThaUnknown/miru/releases/latest) version
of the app. of the app.
required: true required: true
- type: input - type: input

View file

@ -12,17 +12,17 @@ body:
options: options:
- label: >- - label: >-
I have searched the [issue I have searched the [issue
tracker](https://github.com/hayase-app/ui/issues) for a bug report tracker](https://github.com/ThaUnknown/miru/issues) for a bug report
that matches the one I want to file, without success. that matches the one I want to file, without success.
required: true required: true
- label: >- - label: >-
I have searched the [features I have searched the [features
list](https://hayase.watch/features) for this feature, list](https://github.com/ThaUnknown/miru#features) for this feature,
and I couldn't find it. and I couldn't find it.
required: true required: true
- label: >- - label: >-
I have checked that I'm using the [latest I have checked that I'm using the [latest
stable](https://github.com/hayase-app/ui/releases/latest) version stable](https://github.com/ThaUnknown/miru/releases/latest) version
of the app. of the app.
required: true required: true
- type: textarea - type: textarea

View file

@ -20,7 +20,7 @@ jobs:
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22.19 node-version: 22.9
cache: 'pnpm' cache: 'pnpm'
- name: Install dependencies - name: Install dependencies

11
.vscode/launch.json vendored
View file

@ -12,17 +12,6 @@
"presentation": { "presentation": {
"hidden": true "hidden": true
} }
},
{
"name": "Debug Renderer Process",
"port": 9222,
"request": "attach",
"type": "chrome",
"webRoot": "${workspaceFolder}/src",
"timeout": 60000,
"presentation": {
"hidden": false
}
} }
], ],
"compounds": [ "compounds": [

View file

@ -1,5 +1,5 @@
<p align="center"> <p align="center">
<a href="https://github.com/hayase-app/ui"> <a href="https://github.com/ThaUnknown/miru">
<img src="./static/logo_white.svg" width="300"> <img src="./static/logo_white.svg" width="300">
</a> </a>
</p> </p>
@ -18,7 +18,7 @@
<img src="https://img.shields.io/discord/953341991134064651?style=flat-square" alt="chat"> <img src="https://img.shields.io/discord/953341991134064651?style=flat-square" alt="chat">
</a> </a>
<a href="https://hayase.watch/download/"> <a href="https://hayase.watch/download/">
<img alt="Download" src="https://img.shields.io/github/downloads/hayase-app/ui/total?style=flat-square"> <img alt="Download" src="https://img.shields.io/github/downloads/ThaUnknown/miru/total?style=flat-square">
</a> </a>
</p> </p>
@ -110,7 +110,6 @@ It is meant to feel look, work and perform like a premium streaming service, but
* Support for most popular BEP's. * Support for most popular BEP's.
* Persist torrents, cache progress, and rescan instantly. * Persist torrents, cache progress, and rescan instantly.
* View detailed torrent and peer info. * View detailed torrent and peer info.
* Batch downloads.
<p align="center"> <p align="center">
<img src='https://raw.githubusercontent.com/hayase-app/website/main/static/modal.webp' width='400px'></img> <img src='https://raw.githubusercontent.com/hayase-app/website/main/static/modal.webp' width='400px'></img>

View file

@ -11,17 +11,6 @@ export default tseslint.config(
tsconfigRootDir: import.meta.dirname, tsconfigRootDir: import.meta.dirname,
svelteConfig svelteConfig
} }
},
ignores: ['build/', '.svelte-kit/', 'node_modules/'],
rules: {
'@typescript-eslint/prefer-nullish-coalescing': [
'error',
{
ignoreConditionalTests: true,
ignoreMixedLogicalExpressions: false,
ignorePrimitives: true
}
]
} }
} }
) )

View file

@ -1,9 +1,9 @@
{ {
"name": "ui", "name": "ui",
"version": "6.4.159", "version": "6.4.23",
"license": "BUSL-1.1", "license": "BUSL-1.1",
"private": true, "private": true,
"packageManager": "pnpm@9.15.5", "packageManager": "pnpm@9.14.4",
"scripts": { "scripts": {
"dev": "vite dev --open", "dev": "vite dev --open",
"build": "vite build && scopy ./build/index.html ./build/offline.html", "build": "vite build && scopy ./build/index.html ./build/offline.html",
@ -11,7 +11,6 @@
"sync": "svelte-kit sync", "sync": "svelte-kit sync",
"check": "svelte-check --threshold error --tsconfig ./tsconfig.web.json", "check": "svelte-check --threshold error --tsconfig ./tsconfig.web.json",
"check:watch": "svelte-check -threshold error --tsconfig ./tsconfig.web.json --watch", "check:watch": "svelte-check -threshold error --tsconfig ./tsconfig.web.json --watch",
"test": "pnpm run sync && pnpm run lint && pnpm run gql:check && pnpm run check",
"lint": "eslint --quiet -c eslint.config.js", "lint": "eslint --quiet -c eslint.config.js",
"lint:fix": "eslint --quiet -c eslint.config.js --fix", "lint:fix": "eslint --quiet -c eslint.config.js --fix",
"gql:turbo": "node ./node_modules/gql.tada/bin/cli.js turbo -c ./tsconfig.web.json", "gql:turbo": "node ./node_modules/gql.tada/bin/cli.js turbo -c ./tsconfig.web.json",
@ -20,8 +19,8 @@
}, },
"devDependencies": { "devDependencies": {
"@gql.tada/svelte-support": "^1.0.1", "@gql.tada/svelte-support": "^1.0.1",
"@sveltejs/adapter-static": "^3.0.9", "@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.37.0", "@sveltejs/kit": "^2.21.0",
"@sveltejs/vite-plugin-svelte": "^3.1.2", "@sveltejs/vite-plugin-svelte": "^3.1.2",
"@types/debug": "^4.1.12", "@types/debug": "^4.1.12",
"@types/semver": "^7.7.0", "@types/semver": "^7.7.0",
@ -29,43 +28,37 @@
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"bits-ui": "^0.22.0", "bits-ui": "^0.22.0",
"cmdk-sv": "^0.0.19", "cmdk-sv": "^0.0.19",
"eslint-config-standard-universal": "^1.0.9", "eslint-config-standard-universal": "^1.0.8",
"gql.tada": "^1.8.13", "gql.tada": "^1.8.10",
"hayase-extensions": "github:hayase-app/extensions", "hayase-extensions": "github:hayase-app/extensions",
"jassub": "^1.8.6", "jassub": "^1.8.6",
"jiti": "^2.5.1",
"ms": "^2.1.3",
"native": "github:hayase-app/native",
"rollup-plugin-license": "^3.6.0", "rollup-plugin-license": "^3.6.0",
"simple-copy": "^2.2.1", "simple-copy": "^2.2.1",
"svelte": "^4.2.19", "svelte": "^4.2.19",
"svelte-check": "^4.3.1", "svelte-check": "^4.2.1",
"svelte-radix": "^1.1.1", "svelte-radix": "^1.1.1",
"svelte-sonner": "^0.3.28", "svelte-sonner": "^0.3.28",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5.9.2", "typescript": "^5.8.3",
"vaul-svelte": "^0.3.2", "vaul-svelte": "^0.3.2",
"vite": "^5.4.11", "vite": "^5.4.11",
"vite-plugin-devtools-json": "^1.0.0", "vite-plugin-static-copy": "^3.0.2"
"vite-plugin-static-copy": "^3.1.2"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@cloudflare/speedtest": "^1.6.0", "@cloudflare/speedtest": "^1.4.1",
"@dagrejs/dagre": "^1.1.5", "@fontsource-variable/nunito": "^5.2.5",
"@fontsource-variable/nunito": "^5.2.6",
"@fontsource/geist-mono": "^5.2.6", "@fontsource/geist-mono": "^5.2.6",
"@prgm/sveltekit-progress-bar": "2.0.0", "@prgm/sveltekit-progress-bar": "2.0.0",
"@thaunknown/web-irc": "^1.0.3", "@thaunknown/web-irc": "^1.0.3",
"@urql/core": "^6.0.1", "@urql/core": "^5.2.0",
"@urql/exchange-auth": "^3.0.0", "@urql/exchange-auth": "^2.2.1",
"@urql/exchange-graphcache": "^8.1.0", "@urql/exchange-graphcache": "^7.2.3",
"@urql/exchange-refocus": "^2.0.0", "@urql/exchange-refocus": "^1.1.1",
"@urql/exchange-request-policy": "^2.0.0", "@urql/exchange-request-policy": "^1.2.1",
"@urql/exchange-retry": "^2.0.0", "@urql/exchange-retry": "^1.3.1",
"@urql/svelte": "^5.0.0", "@urql/svelte": "^4.2.3",
"@xyflow/svelte": "^0.1.36", "abslink": "^1.1.0",
"abslink": "^1.1.2",
"anitomyscript": "github:thaunknown/anitomyscript", "anitomyscript": "github:thaunknown/anitomyscript",
"bittorrent-tracker": "10.0.12", "bittorrent-tracker": "10.0.12",
"bottleneck": "^2.19.5", "bottleneck": "^2.19.5",
@ -73,25 +66,24 @@
"cobe": "0.6.3", "cobe": "0.6.3",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"debug": "^4.4.1", "debug": "^4.4.1",
"doc999tor-fast-geoip": "^1.1.360", "doc999tor-fast-geoip": "^1.1.335",
"dompurify": "^3.2.6", "dompurify": "^3.2.5",
"events": "^3.3.0", "events": "^3.3.0",
"idb-keyval": "^6.2.2", "idb-keyval": "^6.2.2",
"js-levenshtein": "^1.1.6", "js-levenshtein": "^1.1.6",
"lucide-svelte": "^0.542.0", "lucide-svelte": "^0.511.0",
"marked": "^16.2.1", "marked": "^15.0.11",
"overtype": "^1.2.3",
"p2pt": "github:ThaUnknown/p2pt#modernise", "p2pt": "github:ThaUnknown/p2pt#modernise",
"semver": "^7.7.2", "semver": "^7.7.2",
"simple-store-svelte": "^1.0.6", "simple-store-svelte": "^1.0.6",
"svelte-headless-table": "^0.18.3", "svelte-headless-table": "^0.18.3",
"svelte-keybinds": "^1.0.9", "svelte-keybinds": "^1.0.9",
"svelte-persisted-store": "^0.12.0", "svelte-persisted-store": "^0.12.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.0",
"tailwind-variants": "^1.0.0", "tailwind-variants": "^1.0.0",
"uint8-util": "^2.2.5", "uint8-util": "^2.2.5",
"urql": "^5.0.1", "urql": "^4.2.2",
"video-deband": "^1.0.9", "video-deband": "^1.0.8",
"wonka": "^6.3.5", "wonka": "^6.3.5",
"workbox-core": "^7.3.0", "workbox-core": "^7.3.0",
"workbox-precaching": "^7.3.0", "workbox-precaching": "^7.3.0",

File diff suppressed because it is too large Load diff

View file

@ -14,11 +14,11 @@
--padding-left: unset !important; --padding-left: unset !important;
} }
.custom-draggable { .draggable {
-webkit-app-region: drag; -webkit-app-region: drag;
} }
.custom-not-draggable { .not-draggable {
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
} }
@ -26,39 +26,6 @@
font-family: "Geist Mono", ui-monospace, SFMono-Regular, Roboto Mono, Menlo, Monaco, Liberation Mono, DejaVu Sans Mono, Courier New, monospace !important; font-family: "Geist Mono", ui-monospace, SFMono-Regular, Roboto Mono, Menlo, Monaco, Liberation Mono, DejaVu Sans Mono, Courier New, monospace !important;
} }
.exclude-transition {
view-transition-class: disabled;
}
::view-transition-group(.disabled) {
animation-duration: 0s !important;
}
@supports not (overflow: clip) {
.overflow-clip {
overflow: hidden;
}
.overflow-x-clip {
overflow-x: hidden;
}
.overflow-y-clip {
overflow-y: hidden;
}
}
.text-contrast {
--accessible-color: calc(((((var(--red) * 299) + (var(--green) * 587) + (var(--blue) * 114)) / 1000) - 128) * -1000);
color: rgb(var(--accessible-color),
var(--accessible-color),
var(--accessible-color));
fill: rgb(var(--accessible-color),
var(--accessible-color),
var(--accessible-color));
}
:root { :root {
--background: 0 0% 100%; --background: 0 0% 100%;
--foreground: 240 10% 3.9%; --foreground: 240 10% 3.9%;
@ -90,11 +57,9 @@
--ring: 240 10% 3.9%; --ring: 240 10% 3.9%;
--radius: 0.5rem; --radius: 0.5rem;
--custom: #fff;
} }
html.dark { .dark {
--background: 240 10% 3.9%; --background: 240 10% 3.9%;
--foreground: 0 0% 98%; --foreground: 0 0% 98%;
@ -123,8 +88,6 @@
--destructive-foreground: 0 0% 98%; --destructive-foreground: 0 0% 98%;
--ring: 240 4.9% 83.9%; --ring: 240 4.9% 83.9%;
--custom: #fff;
} }
} }
@ -138,10 +101,10 @@
} }
} }
/* @font-face { @font-face {
font-family: 'molotregular'; font-family: 'molotregular';
src: url('/Molot-webfont-subset.woff') format('woff'); src: url('/Molot-webfont-subset.woff') format('woff');
} */ }
@font-face { @font-face {
font-family: "Twemoji"; font-family: "Twemoji";
@ -167,7 +130,7 @@ a {
-webkit-user-drag: none; -webkit-user-drag: none;
} }
#episodeListTarget:fullscreen { :fullscreen {
user-select: none; user-select: none;
} }
@ -199,7 +162,7 @@ a {
*:focus-visible { *:focus-visible {
outline: none; outline: none;
border-image: fill 0 linear-gradient(#8885, #8885); border-image: fill 0 linear-gradient(#8883, #8883);
} }
*::-webkit-scrollbar { *::-webkit-scrollbar {
@ -261,7 +224,7 @@ details,
align-items: center; align-items: center;
} }
.text-contrast-filter { .text-contrast {
filter: invert(1) grayscale(1) brightness(1.2) contrast(9000); filter: invert(1) grayscale(1) brightness(1.2) contrast(9000);
mix-blend-mode: luminosity; mix-blend-mode: luminosity;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
@ -282,12 +245,6 @@ details,
height: 4px !important; height: 4px !important;
} }
/* animated SVG icons */
.animated-icon:not(:hover):not(:focus-visible):not(:active) .target-animated-icon {
animation: none !important;
transform: translateX(0) translateX(0) translateZ(0) translate(0, 0) !important;
}
/* Backplate related things */ /* Backplate related things */
body { body {
@ -312,76 +269,6 @@ body {
transform: perspective(100vw) translate3d(0, 0, 0vw) rotateY(0deg) rotateX(0deg); transform: perspective(100vw) translate3d(0, 0, 0vw) rotateY(0deg) rotateX(0deg);
} }
::view-transition-old(my-root),
::view-transition-new(my-root) {
animation-duration: 200ms;
animation-timing-function: ease-in-out;
}
::view-transition-old(my-root) {
animation-name: fade-out;
}
::view-transition-new(my-root) {
animation-name: fade-in;
}
@media (prefers-reduced-motion) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
/* @media (max-width: 768px) {
::view-transition-old(*),
::view-transition-new(*) {
animation-duration: 600ms !important;
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1) !important;
}
::view-transition-old(*) {
animation-name: slide-out-left !important;
}
::view-transition-new(*) {
animation-name: slide-in-right !important;
}
} */
/* @keyframes slide-in-right {
from {
transform: translateX(40%);
}
to {
transform: translateX(0);
}
}
@keyframes slide-out-left {
from {
transform: translateX(0);
}
to {
transform: translateX(-10%);
}
} */
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes fade-in { @keyframes fade-in {
from { from {
opacity: 0; opacity: 0;
@ -475,9 +362,9 @@ body {
background-size: 119px; background-size: 119px;
} }
/* .font-molot { .font-molot {
font-family: 'molotregular'; font-family: 'molotregular';
} */ }
.font-twemoji { .font-twemoji {
font-family: 'Twemoji'; font-family: 'Twemoji';

165
src/app.d.ts vendored
View file

@ -1,8 +1,16 @@
// See https://kit.svelte.dev/docs/types#app // See https://kit.svelte.dev/docs/types#app
import type { SessionMetadata } from '$lib/components/ui/player/util'
import type { Search } from '$lib/modules/anilist/queries' import type { Search } from '$lib/modules/anilist/queries'
import type { VariablesOf } from 'gql.tada' import type { VariablesOf } from 'gql.tada'
import type { CompositionEventHandler } from 'svelte/elements' import type { CompositionEventHandler } from 'svelte/elements'
// for information about these interfaces
export interface AuthResponse {
access_token: string
expires_in: string // seconds
token_type: 'Bearer'
}
export interface Track { export interface Track {
selected: boolean selected: boolean
enabled: boolean enabled: boolean
@ -12,6 +20,159 @@ export interface Track {
language: string language: string
} }
export interface TorrentFile {
name: string
hash: string
type: string
size: number
path: string
url: string
id: number
}
export interface Attachment {
filename: string
mimetype: string
id: number
url: string
}
export interface TorrentInfo {
name: string
progress: number
size: {
total: number
downloaded: number
uploaded: number
}
speed: {
down: number
up: number
}
time: {
remaining: number
elapsed: number
}
peers: {
seeders: number
leechers: number
wires: number
}
pieces: {
total: number
size: number
}
hash: string
}
export interface PeerInfo {
ip: string
seeder: boolean
client: string
progress: number
size: {
downloaded: number
uploaded: number
}
speed: {
down: number
up: number
}
flags: Array<'incoming' | 'outgoing' | 'utp' | 'encrypted'>
time: number
}
export interface FileInfo {
name: string
size: number
progress: number
selections: number
}
export interface TorrentSettings {
torrentPersist: boolean
torrentDHT: boolean
torrentStreamedDownload: boolean
torrentSpeed: number
maxConns: number
torrentPort: number
dhtPort: number
torrentPeX: boolean
}
export interface LibraryEntry {
mediaID: number
episode: number
files: number
hash: string
progress: number
date: number
size: number
name: string
}
export interface Native {
authAL: (url: string) => Promise<AuthResponse>
restart: () => Promise<void>
openURL: (url: string) => Promise<void>
share: Navigator['share']
minimise: () => Promise<void>
maximise: () => Promise<void>
focus: () => Promise<void>
close: () => Promise<void>
selectPlayer: () => Promise<string>
selectDownload: () => Promise<string>
setAngle: (angle: string) => Promise<void>
getLogs: () => Promise<string>
getDeviceInfo: () => Promise<unknown>
openUIDevtools: () => Promise<void>
openTorrentDevtools: () => Promise<void>
checkUpdate: () => Promise<void>
updateAndRestart: () => Promise<void>
updateReady: () => Promise<void>
toggleDiscordDetails: (enabled: boolean) => Promise<void>
setMediaSession: (metadata: SessionMetadata, mediaId: number) => Promise<void>
setPositionState: (state?: MediaPositionState) => Promise<void>
setPlayBackState: (paused: 'none' | 'paused' | 'playing') => Promise<void>
setActionHandler: (action: MediaSessionAction | 'enterpictureinpicture', handler: MediaSessionActionHandler | null) => void
checkAvailableSpace: (_?: unknown) => Promise<number>
checkIncomingConnections: (port: number) => Promise<boolean>
updatePeerCounts: (hashes: string[]) => Promise<Array<{ hash: string, complete: string, downloaded: string, incomplete: string }>>
playTorrent: (id: string, mediaID: number, episode: number) => Promise<TorrentFile[]>
library: () => Promise<LibraryEntry[]>
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>
errors: (cb: (error: Error) => void) => Promise<void>
chapters: (hash: string, id: number) => Promise<Array<{ start: number, end: number, text: string }>>
torrentInfo: (hash: string) => Promise<TorrentInfo>
peerInfo: (hash: string) => Promise<PeerInfo[]>
fileInfo: (hash: string) => Promise<FileInfo[]>
protocolStatus: (hash: string) => Promise<{
dht: boolean
lsd: boolean
pex: boolean
nat: boolean
forwarding: boolean
persisting: boolean
streaming: boolean
}>
setDOH: (dns: string) => Promise<void>
cachedTorrents: () => Promise<string[]>
downloadProgress: (percent: number) => Promise<void>
updateSettings: (settings: TorrentSettings) => Promise<void>
updateProgress: (cb: (progress: number) => void) => Promise<void>
spawnPlayer: (url: string) => Promise<void>
setHideToTray: (enabled: boolean) => Promise<void>
transparency: (enabled: boolean) => Promise<void>
setZoom: (scale: number) => Promise<void>
isApp: boolean
version: () => Promise<string>
navigate: (cb: (data: { target: string, value: string | undefined }) => void) => Promise<void>
defaultTransparency: () => boolean
debug: (levels: string) => Promise<void>
}
declare global { declare global {
namespace App { namespace App {
@ -36,8 +197,8 @@ declare global {
} }
interface Navigator { interface Navigator {
userAgentData?: { userAgentData: {
getHighEntropyValues?: (keys: string[]) => Promise<Record<string, string>> getHighEntropyValues: (keys: string[]) => Promise<Record<string, string>>
} }
} }

View file

@ -1,16 +1,14 @@
<!doctype html> <!doctype html>
<html lang="en" class="dark bg-black" style="color-scheme: dark;"> <html lang="en" class="dark bg-transparent" style="color-scheme: dark;">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Hayase</title> <title>Hayase</title>
<link rel="preconnect" href="https://graphql.anilist.co/">
<link rel="preconnect" href="https://www.youtube-nocookie.com">
<link rel="icon" href="%sveltekit.assets%/logo_white_fit.svg" /> <link rel="icon" href="%sveltekit.assets%/logo_white_fit.svg" />
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="off" class="!bg-black !relative" data-vaul-drawer-wrapper> <body data-sveltekit-preload-data="off" class="!bg-transparent !relative" data-vaul-drawer-wrapper>
%sveltekit.body% %sveltekit.body%
</body> </body>

View file

@ -48,8 +48,6 @@
} }
$: checkIdleState(active, $settings.idleAnimation) $: checkIdleState(active, $settings.idleAnimation)
// MOLOT font was commented out in app.css since this feature was shelved
</script> </script>
<svelte:document bind:visibilityState /> <svelte:document bind:visibilityState />

View file

@ -1,14 +1,13 @@
<script lang='ts'> <script lang='ts'>
import { PencilLine } from './icons/animated' import PencilLine from 'lucide-svelte/icons/pencil-line'
import { Button } from '$lib/components/ui/button' import { Button } from '$lib/components/ui/button'
import * as Dialog from '$lib/components/ui/dialog' import * as Dialog from '$lib/components/ui/dialog'
import { Input } from '$lib/components/ui/input' import { Input } from '$lib/components/ui/input'
import * as Select from '$lib/components/ui/select' import * as Select from '$lib/components/ui/select'
import { banner, cover, title, type Media } from '$lib/modules/anilist' import { 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 { list, progress as _progress, score as _score, repeat as _repeat, authAggregator, lists } from '$lib/modules/auth'
import { dragScroll } from '$lib/modules/navigate' import { dragScroll } from '$lib/modules/navigate'
import { breakpoints } from '$lib/utils'
export let media: Media export let media: Media
@ -38,14 +37,14 @@
<Dialog.Root portal='#root'> <Dialog.Root portal='#root'>
<Dialog.Trigger let:builder asChild> <Dialog.Trigger let:builder asChild>
<Button size='icon' class='rounded-l-none bg-custom-400 select:!bg-custom-700 shrink-0 text-contrast animated-icon' builders={[builder]}> <Button size='icon' class='rounded-l-none bg-primary/85 select:bg-primary/75 shrink-0' builders={[builder]}>
<PencilLine class='size-4' /> <PencilLine class='size-4' />
</Button> </Button>
</Dialog.Trigger> </Dialog.Trigger>
<Dialog.Content class='flex justify-center max-h-[80%] max-w-3xl p-0'> <Dialog.Content class='flex justify-center max-h-[80%] p-0'>
<div class='flex flex-col sm:flex-row w-full overflow-y-auto' use:dragScroll> <div class='flex flex-col md:flex-row w-full overflow-y-auto' use:dragScroll>
<div class='relative w-full h-[150px] sm:w-[260px] sm:h-[400px] shrink-0'> <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 sm:rounded-l-lg overflow-clip' style:background={media.coverImage?.color ?? '#000'} src={$breakpoints.sm ? cover(media) : banner(media)} /> <img alt='images' loading='lazy' decoding='async' class='object-cover w-full h-full' style:background={media.coverImage?.color ?? '#000'} src={cover(media)} />
</div> </div>
<form class='flex flex-col w-full rounded-r-lg h-full'> <form class='flex flex-col w-full rounded-r-lg h-full'>
<div class='pt-4 px-5 w-full'> <div class='pt-4 px-5 w-full'>

View file

@ -1,8 +1,17 @@
<script context='module' lang='ts'>
export let fillerEpisodes: Record<number, number[] | undefined> = {}
fetch('https://raw.githubusercontent.com/ThaUnknown/filler-scrape/master/filler.json').then(async res => {
fillerEpisodes = await res.json()
})
</script>
<script lang='ts'> <script lang='ts'>
import ChevronLeft from 'lucide-svelte/icons/chevron-left'
import ChevronRight from 'lucide-svelte/icons/chevron-right'
import Play from 'lucide-svelte/icons/play' import Play from 'lucide-svelte/icons/play'
import Pagination from './Pagination.svelte' import Pagination from './Pagination.svelte'
import { ChevronLeft, ChevronRight } from './icons/animated'
import { Button } from './ui/button' import { Button } from './ui/button'
import { Load } from './ui/img' import { Load } from './ui/img'
import { playEp } from './ui/player/mediahandler.svelte' import { playEp } from './ui/player/mediahandler.svelte'
@ -10,9 +19,8 @@
import type { EpisodesResponse } from '$lib/modules/anizip/types' import type { EpisodesResponse } from '$lib/modules/anizip/types'
import { episodes as _episodes, notes, type Media } from '$lib/modules/anilist' import { episodes as _episodes, dedupeAiring, episodeByAirDate, notes, type Media } from '$lib/modules/anilist'
import { authAggregator, list, progress } from '$lib/modules/auth' import { authAggregator, list, progress } from '$lib/modules/auth'
import { makeEpisodeList } from '$lib/modules/extensions'
import { click, dragScroll } from '$lib/modules/navigate' import { click, dragScroll } from '$lib/modules/navigate'
import { liveAnimeProgress } from '$lib/modules/watchProgress' import { liveAnimeProgress } from '$lib/modules/watchProgress'
import { breakpoints, cn, since } from '$lib/utils' import { breakpoints, cn, since } from '$lib/utils'
@ -20,13 +28,38 @@
export let eps: EpisodesResponse | null export let eps: EpisodesResponse | null
export let media: Media export let media: Media
$: episodeCount = _episodes(media) ?? eps?.episodeCount ?? 0 $: episodeCount = Math.max(_episodes(media) ?? 0, eps?.episodeCount ?? 0)
$: episodeList = media && makeEpisodeList(media, eps) $: ({ episodes, specialCount } = eps ?? {})
const alSchedule: Record<number, Date | undefined> = {}
$: {
for (const { a: airingAt, e: episode } of dedupeAiring(media)) {
alSchedule[episode] = new Date(airingAt * 1000)
}
}
$: episodeList = media && Array.from({ length: episodeCount }, (_, i) => {
const episode = i + 1
const airingAt = alSchedule[episode]
// TODO handle special cases where anilist reports that 3 episodes aired at the same time because of pre-releases, simply don't allow the same episode to be re-used
const hasSpecial = !!specialCount
const hasEpisode = episodes?.[Number(episode)]
const hasCountMatch = (_episodes(media) ?? 0) === (eps?.episodeCount ?? 0)
const needsValidation = !(!hasSpecial || (hasEpisode && hasCountMatch))
const { image, summary, overview, rating, title, length, airdate } = (needsValidation ? episodeByAirDate(airingAt, episodes ?? {}, episode) : episodes?.[Number(episode)]) ?? {}
return {
episode, image, summary: summary ?? overview, rating, title, length, airdate, airingAt, filler: !!fillerEpisodes[media.id]?.includes(i + 1)
}
})
const perPage = 16 const perPage = 16
function getPage (page: number, list = episodeList) { function getPage (page: number, list: typeof episodeList = episodeList) {
return list.slice((page - 1) * perPage, page * perPage) return list.slice((page - 1) * perPage, page * perPage)
} }
@ -47,7 +80,7 @@
</script> </script>
<Pagination count={episodeCount} {perPage} bind:currentPage let:pages let:hasNext let:hasPrev let:range let:setPage siblingCount={1}> <Pagination count={episodeCount} {perPage} bind:currentPage let:pages let:hasNext let:hasPrev let:range let:setPage siblingCount={1}>
<div class='overflow-y-auto pt-3 -ml-14 pl-14 -mr-3 pr-3 pointer-events-none -mb-3 pb-3' use:dragScroll> <div class='overflow-y-auto pt-3 -mx-14 px-14 pointer-events-none -mb-3 pb-3' use:dragScroll>
<div class='grid grid-cols-1 sm:grid-cols-[repeat(auto-fit,minmax(500px,1fr))] place-items-center gap-x-4 gap-y-7 justify-center align-middle pointer-events-auto'> <div class='grid grid-cols-1 sm:grid-cols-[repeat(auto-fit,minmax(500px,1fr))] place-items-center gap-x-4 gap-y-7 justify-center align-middle pointer-events-auto'>
{#each getPage(currentPage, episodeList) as { episode, image, title, summary, airingAt, airdate, filler, length } (episode)} {#each getPage(currentPage, episodeList) as { episode, image, title, summary, airingAt, airdate, filler, length } (episode)}
{@const watched = _progress >= episode && !completed} {@const watched = _progress >= episode && !completed}
@ -56,7 +89,7 @@
<div use:click={() => play(episode)} <div use:click={() => play(episode)}
class={cn( class={cn(
'select:scale-[1.05] select:shadow-lg scale-100 transition-[transform,box-shadow] duration-200 shrink-0 ease-out focus-visible:ring-ring focus-visible:ring-1 rounded-md bg-neutral-950 text-secondary-foreground select:bg-neutral-900 flex w-full max-h-28 cursor-pointer relative overflow-hidden group', 'select:scale-[1.05] select:shadow-lg scale-100 transition-[transform,box-shadow] duration-200 shrink-0 ease-out focus-visible:ring-ring focus-visible:ring-1 rounded-md bg-neutral-950 text-secondary-foreground select:bg-neutral-900 flex w-full max-h-28 cursor-pointer relative overflow-hidden group',
target && 'ring-custom ring-1', target && 'ring-ring ring-1',
filler && '!ring-yellow-400 ring-1' filler && '!ring-yellow-400 ring-1'
)}> )}>
{#if image} {#if image}
@ -77,10 +110,10 @@
{episode}. {title?.en ?? 'Episode ' + episode} {episode}. {title?.en ?? 'Episode ' + episode}
</div> </div>
{#if watched || completed} {#if watched || completed}
<div class='mb-2 h-0.5 overflow-hidden w-full bg-custom shrink-0' /> <div class='mb-2 h-0.5 overflow-hidden w-full bg-blue-600 shrink-0' />
{:else if $watchProgress?.episode === episode} {:else if $watchProgress?.episode === episode}
<div class='w-full bg-neutral-800 mb-2'> <div class='w-full bg-neutral-800 mb-2'>
<div class='h-0.5 overflow-hidden bg-custom shrink-0' style:width={$watchProgress.progress + '%'} /> <div class='h-0.5 overflow-hidden bg-blue-600 shrink-0' style:width={$watchProgress.progress + '%'} />
</div> </div>
{/if} {/if}
<div class='text-[9.6px] text-muted-foreground overflow-hidden'> <div class='text-[9.6px] text-muted-foreground overflow-hidden'>
@ -114,8 +147,8 @@
Showing <span class='font-bold'>{range.start + 1}</span> to <span class='font-bold'>{range.end}</span> of <span class='font-bold'>{episodeCount}</span> episodes Showing <span class='font-bold'>{range.start + 1}</span> to <span class='font-bold'>{range.end}</span> of <span class='font-bold'>{episodeCount}</span> episodes
</p> </p>
<div class='w-full md:w-auto gap-2 flex items-center'> <div class='w-full md:w-auto gap-2 flex items-center'>
<Button size='icon' variant='ghost' class='animated-icon' on:click={() => setPage(currentPage - 1)} disabled={!hasPrev}> <Button size='icon' variant='ghost' on:click={() => setPage(currentPage - 1)} disabled={!hasPrev}>
<ChevronLeft class='size-4' /> <ChevronLeft class='h-4 w-4' />
</Button> </Button>
{#if $breakpoints.md} {#if $breakpoints.md}
{#each pages as { page, type } (page)} {#each pages as { page, type } (page)}
@ -132,8 +165,8 @@
Showing <span class='font-bold'>{range.start + 1}</span> to <span class='font-bold'>{range.end}</span> of <span class='font-bold'>{episodeCount}</span> episodes Showing <span class='font-bold'>{range.start + 1}</span> to <span class='font-bold'>{range.end}</span> of <span class='font-bold'>{episodeCount}</span> episodes
</p> </p>
{/if} {/if}
<Button size='icon' variant='ghost' class='animated-icon' on:click={() => setPage(currentPage + 1)} disabled={!hasNext}> <Button size='icon' variant='ghost' on:click={() => setPage(currentPage + 1)} disabled={!hasNext}>
<ChevronRight class='size-4' /> <ChevronRight class='h-4 w-4' />
</Button> </Button>
</div> </div>
</div> </div>

View file

@ -1,7 +1,7 @@
<script lang='ts' context='module'> <script lang='ts' context='module'>
import BadgeCheck from 'lucide-svelte/icons/badge-check' import BadgeCheck from 'lucide-svelte/icons/badge-check'
import Database from 'lucide-svelte/icons/database'
import Download from 'svelte-radix/Download.svelte' import Download from 'svelte-radix/Download.svelte'
import File from 'svelte-radix/File.svelte'
import MagnifyingGlass from 'svelte-radix/MagnifyingGlass.svelte' import MagnifyingGlass from 'svelte-radix/MagnifyingGlass.svelte'
import { SingleCombo } from './ui/combobox' import { SingleCombo } from './ui/combobox'
@ -15,7 +15,7 @@
import { extensions } from '$lib/modules/extensions/extensions' import { extensions } from '$lib/modules/extensions/extensions'
import { click, dragScroll } from '$lib/modules/navigate' import { click, dragScroll } from '$lib/modules/navigate'
import { settings, videoResolutions } from '$lib/modules/settings' import { settings, videoResolutions } from '$lib/modules/settings'
import { cn, colors, fastPrettyBytes, since } from '$lib/utils' import { fastPrettyBytes, since } from '$lib/utils'
const termMapping: Record<string, {text: string, color: string}> = {} const termMapping: Record<string, {text: string, color: string}> = {}
termMapping['5.1'] = termMapping['5.1CH'] = { text: '5.1', color: '#f67255' } termMapping['5.1'] = termMapping['5.1CH'] = { text: '5.1', color: '#f67255' }
@ -32,12 +32,11 @@
termMapping.AV1 = { text: 'AV1', color: '#0c8ce9' } termMapping.AV1 = { text: 'AV1', color: '#0c8ce9' }
termMapping.BD = termMapping.BDRIP = termMapping.BLURAY = termMapping['BLU-RAY'] = { text: 'BD', color: '#ab1b31' } 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.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.HDTV = termMapping.HDTVRIP = termMapping.TVRIP = termMapping['TV-RIP'] = { text: 'TV', color: '#ab1b31' }
// termMapping.WEBCAST = termMapping.WEBRIP = { text: 'WEB', color: '#ab1b31' } // termMapping.WEBCAST = termMapping.WEBRIP = { text: 'WEB', color: '#ab1b31' }
function sanitiseTerms ({ video_term: video, audio_term: audio, video_resolution: resolution, source, subtitles }: AnitomyResult) { function sanitiseTerms ({ video_term: video, audio_term: audio, video_resolution: resolution, source }: AnitomyResult) {
const terms = [...new Set([...video ?? [], ...audio ?? [], ...source ?? [], ...subtitles ?? []].map(term => termMapping[term.toUpperCase() ?? '']).filter(t => t))] as Array<{text: string, color: string}> const terms = [...new Set([...video ?? [], ...audio ?? [], ...source ?? []].map(term => termMapping[term.toUpperCase() ?? '']).filter(t => t))] as Array<{text: string, color: string}>
if (resolution.length) terms.unshift({ text: resolution[0]!, color: '#c6ec58' }) if (resolution.length) terms.unshift({ text: resolution[0]!, color: '#c6ec58' })
return terms return terms
@ -48,35 +47,28 @@
if (group.length) simpleName = simpleName.replace(group[0]!, '') if (group.length) simpleName = simpleName.replace(group[0]!, '')
if (resolution.length) simpleName = simpleName.replace(resolution[0]!, '') if (resolution.length) simpleName = simpleName.replace(resolution[0]!, '')
if (checksum.length) simpleName = simpleName.replace(checksum[0]!, '') if (checksum.length) simpleName = simpleName.replace(checksum[0]!, '')
for (const term of video ?? []) simpleName = simpleName.replace(term, '') for (const term of video ?? []) simpleName = simpleName.replace(term[0]!, '')
for (const term of audio ?? []) simpleName = simpleName.replace(term, '') for (const term of audio ?? []) simpleName = simpleName.replace(term[0]!, '')
return simpleName.replace(/[[{(]\s*[\]})]/g, '').replace(/\s+/g, ' ').trim() 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>
<script lang='ts'> <script lang='ts'>
import Folder from 'lucide-svelte/icons/folder'
import { getContext } from 'svelte'
import ProgressButton from './ui/button/progress-button.svelte' import ProgressButton from './ui/button/progress-button.svelte'
import { Banner } from './ui/img' import { Banner } from './ui/img'
import { beforeNavigate, goto } from '$app/navigation' import { goto } from '$app/navigation'
import { searchStore } from '$lib' import { searchStore } from '$lib'
import { saved } from '$lib/modules/extensions' import { saved } from '$lib/modules/extensions'
import { server } from '$lib/modules/torrent' import { server } from '$lib/modules/torrent'
$: open = !!$searchStore?.media $: open = !!$searchStore.media
$: searchResult = !!$searchStore?.media && extensions.getResultsFromExtensions({ media: $searchStore.media, episode: $searchStore.episode, resolution: $settings.searchQuality }) $: searchResult = !!$searchStore.media && extensions.getResultsFromExtensions({ media: $searchStore.media, episode: $searchStore.episode, resolution: $settings.searchQuality })
function close (state = false) { function close (state = false) {
if (!state) { if (!state) {
searchStore.set(undefined) searchStore.set({})
open = false open = false
inputText = '' inputText = ''
} }
@ -85,7 +77,7 @@
let inputText = '' let inputText = ''
function play (result: Pick<TorrentResult, 'hash'>) { function play (result: Pick<TorrentResult, 'hash'>) {
server.play(result.hash, $searchStore!.media, $searchStore!.episode) server.play(result.hash, $searchStore.media!, $searchStore.episode!)
goto('/app/player/') goto('/app/player/')
close() close()
} }
@ -155,173 +147,156 @@
$: searchResult && startAnimation(searchResult) $: searchResult && startAnimation(searchResult)
const downloaded = server.downloaded const downloaded = server.downloaded
const stop = getContext<() => void>('stop-progress-bar')
beforeNavigate(({ cancel }) => {
if (open) {
cancel()
close()
stop()
}
})
$: ({ r, g, b } = colors($searchStore?.media.coverImage?.color ?? undefined))
</script> </script>
<Dialog.Root bind:open onOpenChange={close} portal='#episodeListTarget'> <Dialog.Root bind:open onOpenChange={close} portal='#episodeListTarget'>
<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'> <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 lg:rounded-t-xl overflow-hidden z-[100]'>
<!-- this hacky thing is required for dialog root focus trap... pitiful --> <div class='absolute top-0 left-0 w-full h-full max-h-28 overflow-hidden'>
<div class='size-0' tabindex='0' /> {#if $searchStore.media}
{#if $searchStore} <Banner media={$searchStore.media} class='object-cover w-full h-full absolute bottom-[0.5px] left-0 -z-10' />
<div class='absolute top-0 left-0 w-full h-full max-h-36 overflow-hidden flex items-end'> {/if}
<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-full banner-2' />
<div class='w-full h-[70%] bg-gradient-to-t from-black/80 to-transparent' /> </div>
</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='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'>{$searchStore.media ? 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'>
<div class='flex items-center relative scale-parent'> <Input
<Input class='pl-9 bg-background select:bg-accent select:text-accent-foreground shadow-sm no-scale placeholder:opacity-50'
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} />
bind:value={inputText} /> <MagnifyingGlass class='h-4 w-4 shrink-0 opacity-50 absolute left-3 text-muted-foreground z-10 pointer-events-none' />
<MagnifyingGlass class='h-4 w-4 shrink-0 opacity-50 absolute left-3 text-muted-foreground z-10 pointer-events-none' />
</div>
<div class='flex items-center gap-4 justify-around flex-wrap'>
<div class='flex items-center space-x-2 grow'>
<span>Episode</span>
<Input type='number' inputmode='numeric' pattern='[0-9]*' min='0' max='65536' bind:value={$searchStore.episode} class='w-32 shrink-0 bg-background grow' />
</div>
<div class='flex items-center space-x-2 grow'>
<span>Resolution</span>
<SingleCombo bind:value={$settings.searchQuality} items={videoResolutions} portal='#episodeListTarget' class='w-32 shrink-0 grow border-border border' />
</div>
</div>
<ProgressButton
onclick={playBest}
size='default'
class='w-full font-bold bg-custom select:bg-custom-600 text-contrast'
bind:animating>
Auto Select Torrent
</ProgressButton>
</div> </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 style:--custom={$searchStore.media.coverImage?.color ?? '#fff'} style:--red={r} style:--green={g} style:--blue={b}> <div class='flex items-center gap-4 justify-around flex-wrap'>
{#await Promise.all([searchResult, $downloaded])} <div class='flex items-center space-x-2 grow'>
{#each Array.from({ length: 12 }) as _, i (i)} <span>Episode</span>
<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]'> <Input type='number' inputmode='numeric' pattern='[0-9]*' min='0' max='65536' bind:value={$searchStore.episode} class='w-32 shrink-0 bg-background grow' />
<div class='h-4 w-40 bg-primary/5 animate-pulse rounded mt-2' /> </div>
<div class='bg-primary/5 animate-pulse rounded h-2 w-28 mt-1' /> <div class='flex items-center space-x-2 grow'>
<div class='flex justify-between mb-1'> <span>Resolution</span>
<div class='flex gap-2'> <SingleCombo bind:value={$settings.searchQuality} items={videoResolutions} portal='#episodeListTarget' class='w-32 shrink-0 grow border-border border' />
<div class='mt-2 bg-primary/5 animate-pulse rounded h-2 w-20' /> </div>
<div class='mt-2 bg-primary/5 animate-pulse rounded h-2 w-20' /> </div>
</div> <ProgressButton
onclick={playBest}
size='default'
class='w-full font-bold'
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:pointerenter={stopAnimation} on:pointermove={stopAnimation} use:dragScroll>
{#await Promise.all([searchResult, $downloaded])}
{#each Array.from({ length: 12 }) as _, i (i)}
<div class='p-3 h-[104px] flex cursor-pointer mb-2 relative rounded-md overflow-hidden border border-border flex-col justify-between'>
<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'>
<div class='flex gap-2'>
<div class='mt-2 bg-primary/5 animate-pulse rounded h-2 w-20' /> <div class='mt-2 bg-primary/5 animate-pulse rounded h-2 w-20' />
<div class='mt-2 bg-primary/5 animate-pulse rounded h-2 w-20' />
</div>
<div class='mt-2 bg-primary/5 animate-pulse rounded h-2 w-20' />
</div>
</div>
{/each}
{:then [search, downloaded]}
{@const media = $searchStore.media}
{#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' 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='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='ml-auto flex gap-2 self-start'>
{#each result.extension as id (id)}
{#if $saved[id]}
<img src={$saved[id].icon} alt={id} class='size-4' title='Provided by {id}' decoding='async' loading='lazy' />
{/if}
{/each}
</div>
</div>
<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>
</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'>
{text}
</div>
</div>
{/each}
</div>
</div>
</div>
</div>
{:else}
<div class='p-5 flex items-center justify-center w-full h-80'>
<div>
<div class='mb-3 font-bold text-4xl text-center '>
Ooops!
</div>
<div class='text-lg text-center text-muted-foreground'>
No results found.<br />Try specifying a torrent manually by pasting a magnet link or torrent file into the filter bar.
</div>
</div> </div>
</div> </div>
{/each} {/each}
{:then [search, downloaded]} {#each errors as error, i (i)}
{@const media = $searchStore.media} <div class='p-5 flex items-center justify-center w-full h-80'>
{#if search && media} <div>
{@const { results, errors } = search} <div class='mb-1 font-bold text-2xl text-center '>
{#each filterAndSortResults(results, inputText, downloaded) as result (result.hash)} Extensions {error.extension} encountered an error
<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>
<div class='flex pl-2 flex-col justify-between w-full h-20 relative min-w-0 text-[.7rem]'> <div class='text-md text-center text-muted-foreground whitespace-pre-wrap'>
<div class='flex w-full items-center'> {error.error.stack}
<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]}
<img src={$saved[id].icon} alt={id} class='size-4' title='Provided by {id}' decoding='async' loading='lazy' />
{/if}
{/each}
</div>
</div>
<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'>
{#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'>
{#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'>
{text}
</div>
</div>
{/each}
</div>
</div>
</div> </div>
</div> </div>
{:else} </div>
<div class='p-5 flex items-center justify-center w-full h-80'> {/each}
<div> {/if}
<div class='mb-3 font-bold text-4xl text-center '> {:catch error}
Ooops! <div class='p-5 flex items-center justify-center w-full h-80'>
</div> <div>
<div class='text-lg text-center text-muted-foreground'> <div class='mb-3 font-bold text-4xl text-center '>
No results found.<br />Try specifying a torrent manually by pasting a magnet link or torrent file into the filter bar. Ooops!
</div> </div>
</div> <div class='text-lg text-center text-muted-foreground whitespace-pre-wrap'>
</div> {error.message}
{/each}
{#each errors as error, i (i)}
<div class='p-5 flex items-center justify-center w-full h-80'>
<div>
<div class='mb-1 font-bold text-2xl text-center '>
Extensions {error.extension} encountered an error
</div>
<div class='text-md text-center text-muted-foreground whitespace-pre-wrap'>
{error.error.stack}
</div>
</div>
</div>
{/each}
{/if}
{:catch error}
<div class='p-5 flex items-center justify-center w-full h-80'>
<div>
<div class='mb-3 font-bold text-4xl text-center '>
Ooops!
</div>
<div class='text-lg text-center text-muted-foreground whitespace-pre-wrap'>
{error.message}
</div>
</div> </div>
</div> </div>
{/await} </div>
</div> {/await}
</div> </div>
{/if} </div>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>

View file

@ -19,10 +19,10 @@
const key = 'active-settings-tab' const key = 'active-settings-tab'
</script> </script>
<nav class={cn('md:flex grid grid-cols-2 md:flex-row lg:flex-col gap-y-1 gap-x-2 pb-2 sm:pb-0', className)}> <nav class={cn('flex flex-col md:flex-row lg:flex-col gap-y-1 gap-x-2', className)}>
{#each items as { href, title }, i (i)} {#each items as { href, title }, i (i)}
{@const isActive = $page.url.pathname === href} {@const isActive = $page.url.pathname === href}
<Button {href} variant='ghost' data-sveltekit-noscroll class='relative font-semibold justify-start last:odd:col-span-2'> <Button {href} variant='ghost' data-sveltekit-noscroll class='relative font-semibold justify-start'>
{#if isActive} {#if isActive}
<div class='bg-white absolute inset-0 rounded-md' in:send={{ key }} out:receive={{ key }} /> <div class='bg-white absolute inset-0 rounded-md' in:send={{ key }} out:receive={{ key }} />
{/if} {/if}

View file

@ -26,7 +26,7 @@
font-weight: bold; font-weight: bold;
cursor: pointer; cursor: pointer;
list-style: none; list-style: none;
background: #0003; background: #0f0f0f;
display: inline-block; display: inline-block;
padding: 0.4em 0.8em; padding: 0.4em 0.8em;
border-radius: 0.5em; border-radius: 0.5em;

View file

@ -1 +1 @@
<svg name='logo' {...$$props} on:click xml:space='preserve' viewBox='0 0 66.145833 66.145833'><path fill='currentColor' d='M.00000117 61.5156237V4.6302097l66.145831 37.041664v19.84375l-47.624995-26.72291v16.40416zm66.145831-30.42707-23.547916-13.229174 23.547916-13.22917Z' /></svg> <svg name='logo' {...$$props} xml:space='preserve' viewBox='0 0 66.145833 66.145833'><path fill='currentColor' d='M.00000117 61.5156237V4.6302097l66.145831 37.041664v19.84375l-47.624995-26.72291v16.40416zm66.145831-30.42707-23.547916-13.229174 23.547916-13.22917Z' /></svg>

Before

Width:  |  Height:  |  Size: 283 B

After

Width:  |  Height:  |  Size: 274 B

View file

@ -1,43 +0,0 @@
<script lang='ts'>
import { cn } from '$lib/utils'
export let color = 'currentColor'
export let size = 24
export let strokeWidth = 2
let className = ''
export { className as class }
</script>
<svg
xmlns='http://www.w3.org/2000/svg'
width={size}
height={size}
viewBox='0 0 24 24'
fill='none'
stroke={color}
stroke-width={strokeWidth}
stroke-linecap='round'
stroke-linejoin='round'
class={cn(className, 'overflow-visible [transform-origin:center] target-animated-icon')}
{...$$restProps}
>
<path
d='M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z'
/><circle cx='12' cy='12' r='4' />
</svg>
<style>
.target-animated-icon {
animation: screw-rotate 1s ease 3;
}
@keyframes screw-rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(180deg);
}
}
</style>

View file

@ -1,47 +0,0 @@
<script lang='ts'>
import { cn } from '$lib/utils'
export let color = 'currentColor'
export let size: number | string = 24
export let strokeWidth = 2
let className = ''
export { className as class }
</script>
<svg
xmlns='http://www.w3.org/2000/svg'
width={size}
height={size}
viewBox='0 0 24 24'
fill='none'
stroke={color}
stroke-width={strokeWidth}
stroke-linecap='round'
stroke-linejoin='round'
class={cn(className, 'overflow-visible target-animated-icon')}
{...$$restProps}
>
<path d='m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z' />
</svg>
<style>
.target-animated-icon {
animation: primaryAnimation 0.5s ease-in-out;
}
@keyframes primaryAnimation {
0% {
transform: scale(1) rotate(0deg);
}
20% {
transform: scale(1.05) rotate(-7deg);
}
40% {
transform: scale(1.05) rotate(7deg);
}
100% {
transform: scale(1) rotate(0deg);
}
}
</style>

View file

@ -1,68 +0,0 @@
<script lang='ts'>
import { cn } from '$lib/utils'
export let color = 'currentColor'
export let size = 24
export let strokeWidth = 2
let className = ''
export { className as class }
const DOTS = [
{ cx: 8, cy: 14 },
{ cx: 12, cy: 14 },
{ cx: 16, cy: 14 },
{ cx: 8, cy: 18 },
{ cx: 12, cy: 18 },
{ cx: 16, cy: 18 }
]
</script>
<svg
xmlns='http://www.w3.org/2000/svg'
width={size}
height={size}
viewBox='0 0 24 24'
fill='none'
stroke={color}
stroke-width={strokeWidth}
stroke-linecap='round'
stroke-linejoin='round'
class={cn(className, 'overflow-visible')}
{...$$restProps}
>
<path d='M8 2v4' />
<path d='M16 2v4' />
<rect width='18' height='18' x='3' y='4' rx='2' />
<path d='M3 10h18' />
{#each DOTS as dot, index (index)}
<circle
cx={dot.cx}
cy={dot.cy}
r='1'
fill={color}
stroke='none'
class='target-animated-icon'
style='animation-delay: {index * 0.17}s'
/>
{/each}
</svg>
<style>
.target-animated-icon {
animation: pulse 0.8s;
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.3;
}
100% {
opacity: 1;
}
}
</style>

View file

@ -1,33 +0,0 @@
<script lang='ts'>
import { cn } from '$lib/utils'
export let color = 'currentColor'
export let size: number | string = 24
export let strokeWidth = 2
let className = ''
export { className as class }
</script>
<svg
xmlns='http://www.w3.org/2000/svg'
width={size}
height={size}
viewBox='0 0 24 24'
fill='none'
stroke={color}
stroke-width={strokeWidth}
stroke-linecap='round'
stroke-linejoin='round'
class={cn(className, 'overflow-visible')}
{...$$restProps}
>
<path d='m15 18-6-6 6-6' class='target-animated-icon' />
</svg>
<style>
.target-animated-icon {
transition: transform 0.2s ease-in;
transform: translateX(-3px);
}
</style>

View file

@ -1,33 +0,0 @@
<script lang='ts'>
import { cn } from '$lib/utils'
export let color = 'currentColor'
export let size: number | string = 24
export let strokeWidth = 2
let className = ''
export { className as class }
</script>
<svg
xmlns='http://www.w3.org/2000/svg'
width={size}
height={size}
viewBox='0 0 24 24'
fill='none'
stroke={color}
stroke-width={strokeWidth}
stroke-linecap='round'
stroke-linejoin='round'
class={cn(className, 'overflow-visible')}
{...$$restProps}
>
<path d='m9 18 6-6-6-6' class='target-animated-icon' />
</svg>
<style>
.target-animated-icon {
transition: transform 0.2s ease-in;
transform: translateX(3px);
}
</style>

View file

@ -1,76 +0,0 @@
<script lang='ts'>
import { cn } from '$lib/utils'
export let color = 'currentColor'
export let size = 24
export let strokeWidth = 2
let className = ''
export { className as class }
</script>
<svg
xmlns='http://www.w3.org/2000/svg'
width={size}
height={size}
viewBox='0 0 24 24'
fill='none'
stroke={color}
stroke-width={strokeWidth}
stroke-linecap='round'
stroke-linejoin='round'
class={cn(className, 'overflow-visible')}
{...$$restProps}
>
<g class='target-animated-icon clapperboard-outer'>
<g class='target-animated-icon clapperboard-inner'>
<path d='M20.2 6 3 11l-.9-2.4c-.3-1.1.3-2.2 1.3-2.5l13.5-4c1.1-.3 2.2.3 2.5 1.3Z' />
<path d='m6.2 5.3 3.1 3.9' />
<path d='m12.4 3.4 3.1 4' />
</g>
<path d='M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z' />
</g>
</svg>
<style>
.clapperboard-outer {
transform-origin: 4px 20px;
}
.clapperboard-inner {
transform-origin: 3px 11px;
}
.target-animated-icon {
animation: clapperboardOuter 0.8s ease-in-out;
}
.target-animated-icon {
animation: clapperboardInner 0.4s ease-in-out;
}
@keyframes clapperboardOuter {
0%,
50% {
transform: rotate(-10deg);
}
100% {
transform: rotate(0deg);
}
}
@keyframes clapperboardInner {
0% {
transform: rotate(0deg);
}
30% {
transform: rotate(-10deg);
}
60% {
transform: rotate(16deg);
}
100% {
transform: rotate(0deg);
}
}
</style>

View file

@ -1,37 +0,0 @@
<script lang='ts'>
import { cn } from '$lib/utils'
export let color = 'currentColor'
export let size = 24
export let strokeWidth = 2
let className = ''
export { className as class }
</script>
<svg
xmlns='http://www.w3.org/2000/svg'
width={size}
height={size}
viewBox='0 0 24 24'
fill='none'
stroke={color}
stroke-width={strokeWidth}
stroke-linecap='round'
stroke-linejoin='round'
class={cn(className, 'overflow-visible')}
{...$$restProps}
>
<path d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4' />
<g>
<polyline points='7 10 12 15 17 10' class='target-animated-icon' />
<line x1='12' x2='12' y1='15' y2='3' class='target-animated-icon' />
</g>
</svg>
<style>
.target-animated-icon {
transform: translateY(2px);
transition: transform 0.3s cubic-bezier(0.68, -0.6, 0.32, 1.6);
}
</style>

View file

@ -1,50 +0,0 @@
<script lang='ts'>
import { cn } from '$lib/utils'
export let color = 'currentColor'
export let size = 24
export let strokeWidth = 2
let className = ''
export { className as class }
</script>
<svg
xmlns='http://www.w3.org/2000/svg'
width={size}
height={size}
viewBox='0 0 24 24'
fill='none'
stroke={color}
stroke-width={strokeWidth}
stroke-linecap='round'
stroke-linejoin='round'
class={cn(className, 'overflow-visible target-animated-icon')}
{...$$restProps}
>
<path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' />
<path d='M14 2v4a2 2 0 0 0 2 2h4' />
<circle cx='10' cy='12' r='2' />
<path d='m20 17-1.296-1.296a2.41 2.41 0 0 0-3.408 0L9 22' />
</svg>
<style>
.target-animated-icon {
animation: primaryAnimation 0.5s ease-in-out;
}
@keyframes primaryAnimation {
0% {
transform: scale(1) rotate(0deg);
}
20% {
transform: scale(1.05) rotate(-7deg);
}
40% {
transform: scale(1.05) rotate(7deg);
}
100% {
transform: scale(1) rotate(0deg);
}
}
</style>

View file

@ -1,43 +0,0 @@
<script lang='ts'>
import { cn } from '$lib/utils'
export let color = 'currentColor'
export let size = 24
export let strokeWidth = 2
let className = ''
export { className as class }
</script>
<svg
xmlns='http://www.w3.org/2000/svg'
width={size}
height={size}
viewBox='0 0 24 24'
fill='none'
stroke={color}
stroke-width={strokeWidth}
stroke-linecap='round'
stroke-linejoin='round'
class={cn(className, 'overflow-visible')}
{...$$restProps}
>
<path
d='M9 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v.5'
/>
<g class='target-animated-icon'>
<path d='M12 10v4h4' />
<path d='m12 14 1.535-1.605a5 5 0 0 1 8 1.5' />
<path d='M22 22v-4h-4' />
<path d='m22 18-1.535 1.605a5 5 0 0 1-8-1.5' />
</g>
</svg>
<style>
.target-animated-icon {
transform-origin: center;
transform-box: fill-box;
transition: transform 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
transform: rotate(-50deg);
}
</style>

View file

@ -1,60 +0,0 @@
<script lang='ts'>
import { cn } from '$lib/utils'
export let color = 'currentColor'
export let size: number | string = 24
export let strokeWidth = 2
let className = ''
export { className as class }
</script>
<svg
xmlns='http://www.w3.org/2000/svg'
width={size}
height={size}
viewBox='0 0 24 24'
fill='none'
stroke={color}
stroke-width={strokeWidth}
stroke-linecap='round'
stroke-linejoin='round'
class={cn(className, 'overflow-visible')}
{...$$restProps}
>
<path
d='M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z'
class='target-animated-icon'
/>
</svg>
<style>
.target-animated-icon {
transform-origin: center;
animation: heartBeat 1.2s ease-in-out;
}
@keyframes heartBeat {
0% {
transform: scale(1);
}
16.67% {
transform: scale(1.1);
}
33.33% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
66.67% {
transform: scale(1);
}
83.33% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
</style>

View file

@ -1,52 +0,0 @@
<script lang='ts'>
import { cn } from '$lib/utils'
export let color = 'currentColor'
export let size = 24
export let strokeWidth = 2
let className = ''
export { className as class }
</script>
<svg
xmlns='http://www.w3.org/2000/svg'
width={size}
height={size}
viewBox='0 0 24 24'
fill='none'
stroke={color}
stroke-width={strokeWidth}
stroke-linecap='round'
stroke-linejoin='round'
class={cn(className, 'overflow-visible')}
{...$$restProps}
>
<path
d='M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z'
/>
<path d='M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8' class='target-animated-icon' />
</svg>
<style>
.target-animated-icon {
stroke-dasharray: 22;
stroke-dashoffset: 0;
animation: doorAnimation 0.6s ease-out forwards;
}
@keyframes doorAnimation {
0% {
stroke-dashoffset: 22;
opacity: 0;
}
15% {
stroke-dashoffset: 22;
opacity: 0;
}
100% {
stroke-dashoffset: 0;
opacity: 1;
}
}
</style>

View file

@ -1,19 +0,0 @@
export { default as Home } from './home.svelte'
export { default as Search } from './search.svelte'
export { default as Calendar } from './calendar.svelte'
export { default as Users } from './users.svelte'
export { default as Download } from './download.svelte'
export { default as Bolt } from './bolt.svelte'
export { default as LogIn } from './login.svelte'
export { default as Messages } from './messages.svelte'
export { default as PencilLine } from './pencilline.svelte'
export { default as Heart } from './heart.svelte'
export { default as Bookmark } from './bookmark.svelte'
export { default as Clapperboard } from './clapperboard.svelte'
export { default as ChevronRight } from './chevronright.svelte'
export { default as ChevronLeft } from './chevronleft.svelte'
export { default as Trash } from './trash.svelte'
export { default as FileImage } from './fileimage.svelte'
export { default as Minimize } from './minimize.svelte'
export { default as Maximize } from './maximize.svelte'
export { default as FolderSync } from './foldersync.svelte'

View file

@ -1,37 +0,0 @@
<script lang='ts'>
import { cn } from '$lib/utils'
export let color = 'currentColor'
export let size = 24
export let strokeWidth = 2
let className = ''
export { className as class }
</script>
<svg
xmlns='http://www.w3.org/2000/svg'
width={size}
height={size}
viewBox='0 0 24 24'
fill='none'
stroke={color}
stroke-width={strokeWidth}
stroke-linecap='round'
stroke-linejoin='round'
class={cn(className, 'overflow-visible -rotate-90')}
{...$$restProps}
>
<path d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4' />
<g>
<polyline points='7 10 12 15 17 10' class='target-animated-icon' />
<line x1='12' x2='12' y1='15' y2='3' class='target-animated-icon' />
</g>
</svg>
<style>
.target-animated-icon {
transform: translateY(2px);
transition: transform 0.3s cubic-bezier(0.68, -0.6, 0.32, 1.6);
}
</style>

View file

@ -1,55 +0,0 @@
<script lang='ts'>
import { cn } from '$lib/utils'
export let color = 'currentColor'
export let size: string | number = 24
export let strokeWidth: string | number = 2
let className = ''
export { className as class }
</script>
<svg
xmlns='http://www.w3.org/2000/svg'
width={size}
height={size}
viewBox='0 0 24 24'
fill='none'
stroke={color}
stroke-width={strokeWidth}
stroke-linecap='round'
stroke-linejoin='round'
class={cn(className, 'overflow-visible')}
{...$$restProps}
>
<path d='M8 3H5a2 2 0 0 0-2 2v3' class='top-left target-animated-icon' />
<path d='M21 8V5a2 2 0 0 0-2-2h-3' class='top-right target-animated-icon' />
<path d='M3 16v3a2 2 0 0 0 2 2h3' class='bottom-left target-animated-icon' />
<path d='M16 21h3a2 2 0 0 0 2-2v-3' class='bottom-right target-animated-icon' />
</svg>
<style>
path {
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.bottom-right {
transform: translate(2px, 2px);
}
.bottom-left {
transform: translate(-2px, 2px);
}
.top-right {
transform: translate(2px, -2px);
}
.top-left {
transform: translate(-2px, -2px);
}
svg {
overflow: visible;
}
</style>

View file

@ -1,48 +0,0 @@
<script lang='ts'>
import { cn } from '$lib/utils'
export let color = 'currentColor'
export let size = 24
export let strokeWidth = 2
let className = ''
export { className as class }
</script>
<svg
xmlns='http://www.w3.org/2000/svg'
width={size}
height={size}
viewBox='0 0 24 24'
fill='none'
stroke={color}
stroke-width={strokeWidth}
stroke-linecap='round'
stroke-linejoin='round'
class={cn(className, 'overflow-visible target-animated-icon')}
{...$$restProps}
>
<path d='M14 9a2 2 0 0 1-2 2H6l-4 4V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2z' />
<path d='M18 9h2a2 2 0 0 1 2 2v11l-4-4h-6a2 2 0 0 1-2-2v-1' />
</svg>
<style>
.target-animated-icon {
animation: primaryAnimation 0.5s ease-in-out;
}
@keyframes primaryAnimation {
0% {
transform: scale(1) rotate(0deg);
}
20% {
transform: scale(1.05) rotate(-7deg);
}
40% {
transform: scale(1.05) rotate(7deg);
}
100% {
transform: scale(1) rotate(0deg);
}
}
</style>

View file

@ -1,51 +0,0 @@
<script lang='ts'>
import { cn } from '$lib/utils'
export let color = 'currentColor'
export let size: string | number = 24
export let strokeWidth: string | number = 2
let className = ''
export { className as class }
</script>
<svg
xmlns='http://www.w3.org/2000/svg'
width={size}
height={size}
viewBox='0 0 24 24'
fill='none'
stroke={color}
stroke-width={strokeWidth}
stroke-linecap='round'
stroke-linejoin='round'
class={cn(className, 'overflow-visible')}
{...$$restProps}
>
<path d='M8 3v3a2 2 0 0 1-2 2H3' class='top-left target-animated-icon' />
<path d='M21 8h-3a2 2 0 0 1-2-2V3' class='top-right target-animated-icon' />
<path d='M3 16h3a2 2 0 0 1 2 2v3' class='bottom-left target-animated-icon' />
<path d='M16 21v-3a2 2 0 0 1 2-2h3' class='bottom-right target-animated-icon' />
</svg>
<style>
path {
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.bottom-right {
transform: translate(-1px, -1px);
}
.top-left {
transform: translate(1px, 1px);
}
.bottom-left {
transform: translate(1px, -1px);
}
.top-right {
transform: translate(-1px, 1px);
}
</style>

View file

@ -1,51 +0,0 @@
<script lang='ts'>
import { cn } from '$lib/utils'
export let color = 'currentColor'
export let size = 24
export let strokeWidth = 2
let className = ''
export { className as class }
</script>
<svg
xmlns='http://www.w3.org/2000/svg'
width={size}
height={size}
viewBox='0 0 24 24'
fill='none'
stroke={color}
stroke-width={strokeWidth}
stroke-linecap='round'
stroke-linejoin='round'
class={cn(className, 'overflow-visible')}
{...$$restProps}
>
<path d='M12 20h9' />
<path
d='M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z'
class='target-animated-icon'
/>
<path d='m15 5 3 3' class='target-animated-icon' />
</svg>
<style>
.target-animated-icon {
transform-origin: 16.376px 3.622px;
animation: penWiggle 0.5s ease-in-out 2;
}
@keyframes penWiggle {
0%,
100% {
transform: rotate(0deg) translate(0px, 0px);
}
25% {
transform: rotate(-0.5deg) translate(-1px, 1.5px);
}
75% {
transform: rotate(0.5deg) translate(1.5px, -1px);
}
}
</style>

View file

@ -1,46 +0,0 @@
<script lang='ts'>
import { cn } from '$lib/utils'
export let color = 'currentColor'
export let size = 24
export let strokeWidth = 2
let className = ''
export { className as class }
</script>
<svg
xmlns='http://www.w3.org/2000/svg'
width={size}
height={size}
viewBox='0 0 24 24'
fill='none'
stroke={color}
stroke-width={strokeWidth}
stroke-linecap='round'
stroke-linejoin='round'
class={cn(className, 'overflow-visible target-animated-icon')}
{...$$restProps}
>
<circle cx='11' cy='11' r='8' />
<path d='m21 21-4.3-4.3' />
</svg>
<style>
.target-animated-icon {
animation: search-bounce 1s ease-in-out;
}
@keyframes search-bounce {
0%,
100% {
transform: translateX(0) translateY(0);
}
25% {
transform: translateX(0) translateY(-4px);
}
50% {
transform: translateX(-3px) translateY(0);
}
}
</style>

View file

@ -1,41 +0,0 @@
<script lang='ts'>
import { cn } from '$lib/utils'
export let color = 'currentColor'
export let size = 24
export let strokeWidth = 2
let className = ''
export { className as class }
</script>
<svg
xmlns='http://www.w3.org/2000/svg'
width={size}
height={size}
viewBox='0 0 24 24'
fill='none'
stroke={color}
stroke-width={strokeWidth}
stroke-linecap='round'
stroke-linejoin='round'
class={cn(className, 'overflow-visible [transform-origin:center]')}
{...$$restProps}
>
<g class='target-animated-icon'>
<path d='M3 6h18' />
<path d='M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2' />
</g>
<path d='M19 8v12c0 1-1 2-2 2H7c-1 0-2-1-2-2V8' class='target-animated-icon' />
</svg>
<style>
g.target-animated-icon {
transform: translateY(-1px);
transition: transform 0.2s ease-in;
}
path.target-animated-icon {
transform: translateY(1px);
transition: transform 0.2s ease-in;
}
</style>

View file

@ -1,47 +0,0 @@
<script lang='ts'>
import { cn } from '$lib/utils'
export let color = 'currentColor'
export let size = 24
export let strokeWidth = 2
let className = ''
export { className as class }
</script>
<svg
xmlns='http://www.w3.org/2000/svg'
width={size}
height={size}
viewBox='0 0 24 24'
fill='none'
stroke={color}
stroke-width={strokeWidth}
stroke-linecap='round'
stroke-linejoin='round'
class={cn(className, 'overflow-visible')}
{...$$restProps}
>
<path d='M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2' />
<circle cx='9' cy='7' r='4' />
<path d='M22 21v-2a4 4 0 0 0-3-3.87' class='target-animated-icon' />
<path d='M16 3.13a4 4 0 0 1 0 7.75' class='target-animated-icon' />
</svg>
<style>
.target-animated-icon {
animation: users-slide 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
}
@keyframes users-slide {
0% {
transform: translateX(0);
}
50% {
transform: translateX(-6px);
}
100% {
transform: translateX(0);
}
}
</style>

View file

@ -59,9 +59,6 @@
current = media current = media
timeout = schedule(currentIndex() + 1) timeout = schedule(currentIndex() + 1)
} }
function tabindex (node: HTMLElement) {
node.tabIndex = -1
}
</script> </script>
<div class='pl-5 pb-5 justify-end flex flex-col h-full max-w-full'> <div class='pl-5 pb-5 justify-end flex flex-col h-full max-w-full'>
@ -99,7 +96,7 @@
<div class='flex'> <div class='flex'>
{#each shuffled as media (media.id)} {#each shuffled as media (media.id)}
{@const active = current === media} {@const active = current === media}
<div class='pt-2 pb-1' class:cursor-pointer={!active} use:click={() => setCurrent(media)} use:tabindex> <div class='pt-2 pb-1' class:cursor-pointer={!active} use:click={() => setCurrent(media)}>
<div class='bg-neutral-800 mr-2 progress-badge overflow-clip rounded' class:active style='height: 4px;' style:width={active ? '3rem' : '1.5rem'}> <div class='bg-neutral-800 mr-2 progress-badge overflow-clip rounded' class:active style='height: 4px;' style:width={active ? '3rem' : '1.5rem'}>
<div class='progress-content h-full transform-gpu w-full' class:bg-white={active} /> <div class='progress-content h-full transform-gpu w-full' class:bg-white={active} />
</div> </div>

View file

@ -1,11 +1,11 @@
<script lang='ts'> <script lang='ts'>
import Bookmark from 'lucide-svelte/icons/bookmark'
import type { Media } from '$lib/modules/anilist' import type { Media } from '$lib/modules/anilist'
import { Bookmark } from '$lib/components/icons/animated'
import { Button, iconSizes, type Props } from '$lib/components/ui/button' import { Button, iconSizes, type Props } from '$lib/components/ui/button'
import { list, authAggregator, lists } from '$lib/modules/auth' import { list, authAggregator, lists } from '$lib/modules/auth'
import { clickwrap, keywrap } from '$lib/modules/navigate' import { clickwrap, keywrap } from '$lib/modules/navigate'
import { cn } from '$lib/utils'
type $$Props = Props & { media: Media } type $$Props = Props & { media: Media }
@ -27,6 +27,6 @@
let key = 1 let key = 1
</script> </script>
<Button {size} {variant} class={cn(className, 'animated-icon')} on:click={clickwrap(toggleBookmark)} on:keydown={keywrap(toggleBookmark)}> <Button {size} {variant} class={className} on:click={clickwrap(toggleBookmark)} on:keydown={keywrap(toggleBookmark)}>
<Bookmark fill={key && list(media) ? 'currentColor' : 'transparent'} size={iconSizes[size]} /> <Bookmark fill={key && list(media) ? 'currentColor' : 'transparent'} size={iconSizes[size]} />
</Button> </Button>

View file

@ -1,11 +1,9 @@
import Bookmark from './bookmark.svelte' import Bookmark from './bookmark.svelte'
import Favorite from './favorite.svelte' import Favorite from './favorite.svelte'
import Play from './play.svelte' import Play from './play.svelte'
import Transition from './transition.svelte'
export { export {
Play as PlayButton, Play as PlayButton,
Favorite as FavoriteButton, Favorite as FavoriteButton,
Bookmark as BookmarkButton, Bookmark as BookmarkButton
Transition as TransitionButton
} }

View file

@ -1,11 +1,11 @@
<script lang='ts'> <script lang='ts'>
import Heart from 'lucide-svelte/icons/heart'
import type { Media } from '$lib/modules/anilist' import type { Media } from '$lib/modules/anilist'
import { Heart } from '$lib/components/icons/animated'
import { Button, iconSizes, type Props } from '$lib/components/ui/button' import { Button, iconSizes, type Props } from '$lib/components/ui/button'
import { authAggregator, fav } from '$lib/modules/auth' import { authAggregator, fav } from '$lib/modules/auth'
import { clickwrap, keywrap } from '$lib/modules/navigate' import { clickwrap, keywrap } from '$lib/modules/navigate'
import { cn } from '$lib/utils'
type $$Props = Props & { media: Media } type $$Props = Props & { media: Media }
@ -23,6 +23,6 @@
} }
</script> </script>
<Button {size} {variant} class={cn(className, 'animated-icon')} on:click={clickwrap(toggleFav)} on:keydown={keywrap(toggleFav)} on:click={() => ++key}> <Button {size} {variant} class={className} on:click={clickwrap(toggleFav)} on:keydown={keywrap(toggleFav)} on:click={() => ++key}>
<Heart fill={key && fav(media) ? 'currentColor' : 'transparent'} size={iconSizes[size]} /> <Heart fill={key && fav(media) ? 'currentColor' : 'transparent'} size={iconSizes[size]} />
</Button> </Button>

View file

@ -5,14 +5,14 @@ import Root from './button.svelte'
import type { Button as ButtonPrimitive } from 'bits-ui' import type { Button as ButtonPrimitive } from 'bits-ui'
const buttonVariants = tv({ const buttonVariants = tv({
base: 'bg-transparent focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50', base: 'focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50',
variants: { variants: {
variant: { variant: {
default: 'bg-primary text-primary-foreground select:bg-neutral-500 shadow', default: 'bg-primary text-primary-foreground select:bg-primary/90 shadow',
destructive: 'bg-destructive text-destructive-foreground select:bg-destructive/90 shadow-sm', destructive: 'bg-destructive text-destructive-foreground select:bg-destructive/90 shadow-sm',
outline: 'border-input bg-background select:bg-accent select:text-accent-foreground border shadow-sm', outline: 'border-input bg-background select:bg-accent select:text-accent-foreground border shadow-sm',
secondary: 'bg-secondary text-secondary-foreground select:bg-secondary/70 shadow-sm', secondary: 'bg-secondary text-secondary-foreground select:bg-secondary/80 shadow-sm',
ghost: 'select:bg-secondary-foreground/30 select:text-accent-foreground', ghost: 'select:bg-secondary-foreground/10 select:text-accent-foreground',
link: 'text-primary underline-offset-4 select:underline' link: 'text-primary underline-offset-4 select:underline'
}, },
size: { size: {

View file

@ -36,15 +36,15 @@
<ButtonPrimitive.Root <ButtonPrimitive.Root
class={cn( class={cn(
'relative overflow-hidden', buttonVariants({ variant, size, className }),
buttonVariants({ variant, size, className }) 'relative overflow-hidden'
)} )}
type='button' type='button'
on:click={stopAnimation} on:click={stopAnimation}
on:click={onclick}> on:click={onclick}>
<slot /> <slot />
<div <div
class='absolute inset-0 bg-black/20 pointer-events-none translate-x-full' class='absolute inset-0 bg-current opacity-20 pointer-events-none'
class:animate-progress={animating} class:animate-progress={animating}
style='animation-duration: {duration}ms;' style='animation-duration: {duration}ms;'
on:animationend={handleAnimationEnd} /> on:animationend={handleAnimationEnd} />

View file

@ -1,39 +0,0 @@
<script lang='ts'>
import { Button, type Props } from '$lib/components/ui/button'
import { clickwrap, keywrap } from '$lib/modules/navigate'
import { cn, scaleBlurFade } from '$lib/utils'
type $$Props = Props & { duration?: number }
export let duration: $$Props['duration'] = 300
let className: $$Props['class'] = ''
export { className as class }
let toggled = false
let timeout: ReturnType<typeof setTimeout>
export let size: NonNullable<$$Props['size']> = 'icon-sm'
export let variant: NonNullable<$$Props['variant']> = 'ghost'
function handleClick () {
if (toggled) return
toggled = true
clearTimeout(timeout)
timeout = setTimeout(() => {
toggled = false
}, duration! + 500)
}
</script>
<Button {size} {variant} class={cn(className, 'relative')} on:click={clickwrap(handleClick)} on:click on:keydown={keywrap(handleClick)}>
{#if toggled}
<div class='absolute inset-0 flex items-center justify-center' transition:scaleBlurFade={{ duration }}>
<slot name='transition' />
</div>
{:else}
<div class='absolute inset-0 flex items-center justify-center' transition:scaleBlurFade={{ duration }}>
<slot name='base' />
</div>
{/if}
</Button>

View file

@ -8,7 +8,6 @@
import { desc, duration, format, season, title, type Media } from '$lib/modules/anilist' import { desc, duration, format, season, title, type Media } from '$lib/modules/anilist'
import { of } from '$lib/modules/auth' import { of } from '$lib/modules/auth'
import { SUPPORTS } from '$lib/modules/settings'
import { cn, type TraceAnime } from '$lib/utils' import { cn, type TraceAnime } from '$lib/utils'
export let media: Media export let media: Media
@ -24,26 +23,22 @@
<div class='!absolute w-[17.5rem] h-80 left-1/2 right-1/2 top-0 bottom-0 m-auto bg-neutral-950 z-30 rounded cursor-pointer absolute-container'> <div class='!absolute w-[17.5rem] h-80 left-1/2 right-1/2 top-0 bottom-0 m-auto bg-neutral-950 z-30 rounded cursor-pointer absolute-container'>
<div class='h-[45%] banner relative bg-black rounded-t'> <div class='h-[45%] banner relative bg-black rounded-t'>
{#if trace} {#if trace}
{#if !SUPPORTS.isUnderPowered} <Load src={trace.image} alt={media.title?.english} class={cn('object-cover w-full h-full blur-2xl saturate-200 absolute -z-10', hideFrame === false && 'hidden')} />
<Load src={trace.image} alt={media.title?.english} class={cn('object-cover w-full h-full blur-2xl saturate-200 absolute -z-10', hideFrame === false && 'hidden')} />
{/if}
<Load src={trace.image} alt={media.title?.english} class='object-cover w-full h-full rounded-t' /> <Load src={trace.image} alt={media.title?.english} class='object-cover w-full h-full rounded-t' />
<Videoframe src={trace.video} on:hide={hide} /> <Videoframe src={trace.video} on:hide={hide} />
{:else} {:else}
{#if !SUPPORTS.isUnderPowered} <Banner {media} class={cn('object-cover w-full h-full blur-2xl saturate-200 absolute -z-10', hideFrame === false && 'hidden')} />
<Banner {media} class={cn('object-cover w-full h-full blur-2xl saturate-200 absolute -z-10', hideFrame === false && 'hidden')} />
{/if}
<Banner {media} class='object-cover w-full h-full rounded-t' /> <Banner {media} class='object-cover w-full h-full rounded-t' />
{#if media.trailer?.id && !hideFrame && !SUPPORTS.isUnderPowered} {#if media.trailer?.id && !hideFrame}
<YoutubeIframe id={media.trailer.id} on:hide={hide} /> <YoutubeIframe id={media.trailer.id} on:hide={hide} />
{/if} {/if}
{/if} {/if}
</div> </div>
<div class='w-full px-4 bg-neutral-950'> <div class='w-full px-4 bg-neutral-950'>
<div class='text-lg font-bold truncate inline-block w-full text-white pt-2' title={title(media)}> <div class='text-lg font-bold truncate inline-block w-full text-white' title={title(media)}>
{title(media)} {title(media)}
</div> </div>
<div class='flex flex-row'> <div class='flex flex-row pt-1'>
<PlayButton {media} class='grow' /> <PlayButton {media} class='grow' />
<FavoriteButton {media} class='ml-2' /> <FavoriteButton {media} class='ml-2' />
<BookmarkButton {media} class='ml-2' /> <BookmarkButton {media} class='ml-2' />

View file

@ -28,7 +28,7 @@
$: status = list(media) $: status = list(media)
</script> </script>
<div class='text-white p-4 cursor-pointer shrink-0 relative pointer-events-auto [content-visibility:auto] [contain-intrinsic-size:auto_152px_auto_290.4px]' class:![content-visibility:visible]={!hidden} class:z-40={!hidden} use:hover={[onclick, onhover]}> <div class='text-white p-4 cursor-pointer shrink-0 relative pointer-events-auto' class:z-40={!hidden} use:hover={[onclick, onhover]}>
{#if !hidden} {#if !hidden}
<PreviewCard {media} /> <PreviewCard {media} />
{/if} {/if}

View file

@ -4,7 +4,6 @@
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
import { click } from '$lib/modules/navigate' import { click } from '$lib/modules/navigate'
import { SUPPORTS } from '$lib/modules/settings'
export let src: string export let src: string
@ -42,18 +41,16 @@
{src} {src}
/> />
</div> </div>
{#if !SUPPORTS.isUnderPowered} <div class='h-full w-full overflow-clip absolute top-0 rounded-t blur-2xl saturate-200 -z-10 pointer-events-none'>
<div class='h-full w-full overflow-clip absolute top-0 rounded-t blur-2xl saturate-200 -z-10 pointer-events-none'> <video
<video class='w-full border-0 absolute left-0 h-[calc(100%+200px)] top-1/2 transform-gpu -translate-y-1/2'
class='w-full border-0 absolute left-0 h-[calc(100%+200px)] top-1/2 transform-gpu -translate-y-1/2' class:hide
class:hide muted
muted autoplay
autoplay loop
loop {src}
{src} />
/> </div>
</div>
{/if}
<style> <style>
.absolute { .absolute {

View file

@ -3,7 +3,6 @@
import Check from 'svelte-radix/Check.svelte' import Check from 'svelte-radix/Check.svelte'
import Minus from 'svelte-radix/Minus.svelte' import Minus from 'svelte-radix/Minus.svelte'
import { keywrap } from '$lib/modules/navigate'
import { cn } from '$lib/utils.js' import { cn } from '$lib/utils.js'
type $$Props = CheckboxPrimitive.Props type $$Props = CheckboxPrimitive.Props
@ -12,10 +11,6 @@
let className: $$Props['class'] = undefined let className: $$Props['class'] = undefined
export let checked: $$Props['checked'] = false export let checked: $$Props['checked'] = false
export { className as class } export { className as class }
const wr = keywrap(() => {
checked = !checked
})
</script> </script>
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
@ -25,7 +20,6 @@
)} )}
bind:checked bind:checked
on:click on:click
on:keydown={e => wr(e.detail.originalEvent)}
{...$$restProps} {...$$restProps}
> >
<CheckboxPrimitive.Indicator <CheckboxPrimitive.Indicator

View file

@ -21,7 +21,7 @@
import { Button } from '$lib/components/ui/button' import { Button } from '$lib/components/ui/button'
import * as Command from '$lib/components/ui/command' import * as Command from '$lib/components/ui/command'
import * as Popover from '$lib/components/ui/popover' import * as Popover from '$lib/components/ui/popover'
import { inputType, navigate } from '$lib/modules/navigate' import { inputType } from '$lib/modules/navigate'
import { cn } from '$lib/utils.js' import { cn } from '$lib/utils.js'
export let items: readonly value[] = [] export let items: readonly value[] = []
@ -84,10 +84,8 @@
</Button> </Button>
</Popover.Trigger> </Popover.Trigger>
<Popover.Content class={cn('p-0 border-0 z-[1000]')} sameWidth={true}> <Popover.Content class={cn('p-0 border-0 z-[1000]')} sameWidth={true}>
<Command.Root onKeydown={navigate}> <Command.Root>
<!-- this hacky thing is required for dialog root focus trap... pitiful --> <Command.Input {placeholder} class='h-9 placeholder:opacity-50' />
<div class='h-0 w-full' tabindex='0' />
<Command.Input {placeholder} autofocus={false} class='h-9 placeholder:opacity-50' />
<Command.Empty>No results found.</Command.Empty> <Command.Empty>No results found.</Command.Empty>
{#if $inputType === 'dpad'} {#if $inputType === 'dpad'}
<Command.Group class='shrink-0' alwaysRender={true}> <Command.Group class='shrink-0' alwaysRender={true}>

View file

@ -1,30 +1,25 @@
<script lang='ts'> <script lang='ts'>
import { Command as CommandPrimitive } from 'cmdk-sv' import { Command as CommandPrimitive } from 'cmdk-sv'
import { keywrap } from '$lib/modules/navigate'
import { cn } from '$lib/utils.js' import { cn } from '$lib/utils.js'
type $$Props = CommandPrimitive.ItemProps type $$Props = CommandPrimitive.ItemProps
export let asChild = false
let className: $$Props['class'] = '' let className: $$Props['class'] = ''
export { className as class } export { className as class }
function click (e: KeyboardEvent) {
const target = e.target as HTMLElement
target.click()
}
</script> </script>
<CommandPrimitive.Item <CommandPrimitive.Item
{asChild}
class={cn(
'aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...$$restProps} {...$$restProps}
asChild={true}
let:action let:action
let:attrs let:attrs
> >
<div {...$$restProps} on:keydown={keywrap(click)} use:action {...attrs} tabindex={0} class={cn( <slot {action} {attrs} />
'select:bg-accent select:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}>
<slot {action} {attrs} />
</div>
</CommandPrimitive.Item> </CommandPrimitive.Item>

View file

@ -22,16 +22,16 @@
{transition} {transition}
{transitionConfig} {transitionConfig}
class={cn( class={cn(
'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', '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',
className className
)} )}
{...$$restProps} {...$$restProps}
> >
<slot /> <slot />
<DialogPrimitive.Close <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 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 opacity-70 transition-opacity select:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none'
> >
<Cross2 class='size-5' /> <Cross2 class='h-4 w-4' />
<span class='sr-only'>Close</span> <span class='sr-only'>Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>

View file

@ -2,7 +2,6 @@
import { Dialog as DialogPrimitive } from 'bits-ui' import { Dialog as DialogPrimitive } from 'bits-ui'
import { fade } from 'svelte/transition' import { fade } from 'svelte/transition'
import { SUPPORTS } from '$lib/modules/settings'
import { cn } from '$lib/utils.js' import { cn } from '$lib/utils.js'
type $$Props = DialogPrimitive.OverlayProps type $$Props = DialogPrimitive.OverlayProps
@ -18,6 +17,6 @@
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
{transition} {transition}
{transitionConfig} {transitionConfig}
class={cn('custom-bg absolute inset-0 z-50', !SUPPORTS.isUnderPowered && 'backdrop-blur-sm', className)} class={cn('custom-bg absolute inset-0 z-50 backdrop-blur-sm', className)}
{...$$restProps} {...$$restProps}
/> />

View file

@ -1,7 +1,6 @@
<script lang='ts'> <script lang='ts'>
import { Drawer as DrawerPrimitive } from 'vaul-svelte' import { Drawer as DrawerPrimitive } from 'vaul-svelte'
import { SUPPORTS } from '$lib/modules/settings'
import { cn } from '$lib/utils.js' import { cn } from '$lib/utils.js'
type $$Props = DrawerPrimitive.OverlayProps type $$Props = DrawerPrimitive.OverlayProps
@ -13,7 +12,7 @@
<DrawerPrimitive.Overlay <DrawerPrimitive.Overlay
bind:el bind:el
class={cn('custom-bg fixed inset-0 z-50', !SUPPORTS.isUnderPowered && 'backdrop-blur-sm', className)} class={cn('custom-bg fixed inset-0 z-50 backdrop-blur-sm', className)}
{...$$restProps} {...$$restProps}
> >
<slot /> <slot />

View file

@ -2,6 +2,7 @@
import Github from 'lucide-svelte/icons/github' import Github from 'lucide-svelte/icons/github'
import Globe from 'lucide-svelte/icons/globe' import Globe from 'lucide-svelte/icons/globe'
import Plus from 'lucide-svelte/icons/plus' import Plus from 'lucide-svelte/icons/plus'
import MagnifyingGlass from 'svelte-radix/MagnifyingGlass.svelte'
import { toast } from 'svelte-sonner' import { toast } from 'svelte-sonner'
import { Button, iconSizes } from '../button' import { Button, iconSizes } from '../button'
@ -20,6 +21,12 @@
url: 'URL' url: 'URL'
} }
let value = 'extensions' let value = 'extensions'
let inputText = ''
function filterSearch <T extends Array<[string, unknown]>> (repositories: T, input: string): T {
if (!input) return repositories
return repositories.filter(([id]) => id.toLowerCase().includes(input.toLowerCase())) as T
}
let extensionInput = '' let extensionInput = ''
@ -41,13 +48,20 @@
<Tabs.Root bind:value class='w-full'> <Tabs.Root bind:value class='w-full'>
<div class='flex justify-between items-center gap-3 sm:flex-row flex-col'> <div class='flex justify-between items-center gap-3 sm:flex-row flex-col'>
<Tabs.List class='grid w-full grid-cols-2 md:max-w-72'> <Tabs.List class='grid w-full grid-cols-2 md:max-w-72'>
<Tabs.Trigger tabindex={0} value='extensions'>Extensions</Tabs.Trigger> <Tabs.Trigger value='extensions'>Extensions</Tabs.Trigger>
<Tabs.Trigger tabindex={0} value='repositories'>Repositories</Tabs.Trigger> <Tabs.Trigger value='repositories'>Repositories</Tabs.Trigger>
</Tabs.List> </Tabs.List>
<div class='flex items-center relative scale-parent md:max-w-72 w-full'>
<Input
class='pl-9 bg-neutral-950 select:bg-accent select:text-accent-foreground shadow-sm no-scale placeholder:opacity-50'
placeholder='Search {value}...'
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>
</div> </div>
<Tabs.Content value='extensions' tabindex={-1}> <Tabs.Content value='extensions' tabindex={-1}>
<div class='flex flex-col gap-y-2 justify-center py-3'> <div class='flex flex-col gap-y-2 justify-center py-3'>
{#each Object.entries($saved) as [id, config] (id)} {#each filterSearch(Object.entries($saved), inputText) as [id, config] (id)}
<div class='bg-neutral-950 px-4 py-3 rounded-md flex flex-row space-x-3 justify-between w-full border border-border'> <div class='bg-neutral-950 px-4 py-3 rounded-md flex flex-row space-x-3 justify-between w-full border border-border'>
<div class='flex flex-col space-y-3'> <div class='flex flex-col space-y-3'>
<div class='flex flex-row space-x-3'> <div class='flex flex-row space-x-3'>
@ -105,7 +119,7 @@
<Tabs.Content value='repositories' tabindex={-1}> <Tabs.Content value='repositories' tabindex={-1}>
<div class='gap-3 flex py-3 sm:flex-row flex-col'> <div class='gap-3 flex py-3 sm:flex-row flex-col'>
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger class='w-full' tabindex={-1}> <Tooltip.Trigger class='w-full'>
<Input class='bg-neutral-950' type='url' placeholder='https://example.com/manifest.json' bind:value={extensionInput} /> <Input class='bg-neutral-950' type='url' placeholder='https://example.com/manifest.json' bind:value={extensionInput} />
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content class='max-w-full w-52'> <Tooltip.Content class='max-w-full w-52'>
@ -125,7 +139,7 @@
{/await} {/await}
</div> </div>
<div class='flex flex-col gap-y-2 justify-center py-3'> <div class='flex flex-col gap-y-2 justify-center py-3'>
{#each Object.entries(Object.groupBy(Object.values($saved), saved => saved.update ?? '')) as [id, extensions] (id) } {#each filterSearch(Object.entries(Object.groupBy(Object.values($saved), saved => saved.update ?? '')), inputText) as [id, extensions] (id) }
{@const url = new URL(id)} {@const url = new URL(id)}
<div class='bg-neutral-950 px-4 py-3 rounded-md flex flex-row space-x-3 justify-between items-center w-full border border-border'> <div class='bg-neutral-950 px-4 py-3 rounded-md flex flex-row space-x-3 justify-between items-center w-full border border-border'>
<div class='flex space-x-2 items-center'> <div class='flex space-x-2 items-center'>

View file

@ -25,11 +25,11 @@
let childComments: Array<ResultOf<typeof CommentFrag>> let childComments: Array<ResultOf<typeof CommentFrag>>
$: childComments = comment.childComments as Array<ResultOf<typeof CommentFrag>> | null ?? [] $: childComments = comment.childComments as Array<ResultOf<typeof CommentFrag>> | null ?? []
const viewer = client.client.viewer const viewer = client.viewer
</script> </script>
<div class='rounded-md {depth % 2 === 1 ? 'bg-black' : 'bg-neutral-950'} text-secondary-foreground flex w-full py-4 flex-col'> <div class='rounded-md {depth % 2 === 1 ? 'bg-black' : 'bg-neutral-950'} text-secondary-foreground flex w-full py-4 px-6 flex-col'>
<div class='flex w-full justify-between text-xl px-6'> <div class='flex w-full justify-between text-xl'>
<div class='font-bold mb-2 line-clamp-1 flex leading-none items-center text-[16px]'> <div class='font-bold mb-2 line-clamp-1 flex leading-none items-center text-[16px]'>
{#if comment.user} {#if comment.user}
<Profile user={comment.user} class='size-5 mr-2' /> <Profile user={comment.user} class='size-5 mr-2' />
@ -41,15 +41,15 @@
{comment.likeCount} {comment.likeCount}
</div> </div>
</div> </div>
<Shadow html={comment.comment ?? ''} class='text-muted-foreground text-sm [&_*]:flex [&_*]:flex-col [&_br]:hidden w-full overflow-clip px-6' /> <Shadow html={comment.comment ?? ''} class='text-muted-foreground text-sm [&_*]:flex [&_*]:flex-col [&_br]:hidden w-full overflow-clip' />
{#each childComments as comment (comment.id)} {#each childComments as comment (comment.id)}
{#if comment} {#if comment}
<div class='pl-4 py-2 pr-2'> <div class='py-2'>
<svelte:self {comment} depth={depth + 1} {isLocked} {threadId} {rootCommentId} /> <svelte:self {comment} depth={depth + 1} {isLocked} {threadId} {rootCommentId} />
</div> </div>
{/if} {/if}
{/each} {/each}
<div class='flex w-full justify-between mt-auto text-[9.6px] px-6'> <div class='flex w-full justify-between mt-auto text-[9.6px]'>
<div class='flex items-center leading-none'> <div class='flex items-center leading-none'>
<Button size='icon-sm' variant='ghost' class='mr-1' on:click={() => client.toggleLike(comment.id, 'THREAD_COMMENT', !!comment.isLiked)} disabled={isLocked || !$viewer?.viewer}> <Button size='icon-sm' variant='ghost' class='mr-1' on:click={() => client.toggleLike(comment.id, 'THREAD_COMMENT', !!comment.isLiked)} disabled={isLocked || !$viewer?.viewer}>
<Heart fill={comment.isLiked ? 'currentColor' : 'transparent'} size={iconSizes['icon-sm']} /> <Heart fill={comment.isLiked ? 'currentColor' : 'transparent'} size={iconSizes['icon-sm']} />

View file

@ -1,13 +1,13 @@
<script lang='ts'> <script lang='ts'>
import { Button } from '../button' import { Button } from '../button'
import { Markdown } from '../markdown' import { Textarea } from '../textarea'
import * as Dialog from '$lib/components/ui/dialog' import * as Drawer from '$lib/components/ui/drawer'
import { client } from '$lib/modules/anilist' import { client } from '$lib/modules/anilist'
export let isLocked = false export let isLocked = false
const viewer = client.client.viewer const viewer = client.viewer
export let threadId: number | undefined = undefined export let threadId: number | undefined = undefined
export let parentCommentId: number | undefined = undefined export let parentCommentId: number | undefined = undefined
@ -23,25 +23,25 @@
} }
</script> </script>
<Dialog.Root portal='#root'> <Drawer.Root portal='html'>
<Dialog.Trigger asChild let:builder> <Drawer.Trigger asChild let:builder>
<Button size='icon-sm' variant='ghost' class='mr-1' disabled={isLocked || !$viewer?.viewer} builders={[builder]}> <Button size='icon-sm' variant='ghost' class='mr-1' disabled={isLocked || !$viewer?.viewer} builders={[builder]}>
<slot /> <slot />
</Button> </Button>
</Dialog.Trigger> </Drawer.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 max-w-full border-0 border-t !rounded-none'> <Drawer.Content tabindex={null} class='px-20 py-10 gap-4'>
<Markdown class='form-control w-full shrink-0 min-h-56 rounded-none flex-grow' {placeholder} bind:value /> <Textarea class='form-control w-full shrink-0 min-h-56 bg-dark !transform-none !scale-100' {placeholder} bind:value />
<div class='flex gap-2 justify-end flex-grow-0 px-4'> <div class='flex gap-2 justify-end'>
<Dialog.Close asChild let:builder> <Drawer.Close asChild let:builder>
<Button variant='secondary' builders={[builder]}> <Button variant='secondary' builders={[builder]}>
Close Close
</Button> </Button>
</Dialog.Close> </Drawer.Close>
<Dialog.Close asChild let:builder> <Drawer.Close asChild let:builder>
<Button builders={[builder]} on:click={comment}> <Button builders={[builder]} on:click={comment}>
Send Send
</Button> </Button>
</Dialog.Close> </Drawer.Close>
</div> </div>
</Dialog.Content> </Drawer.Content>
</Dialog.Root> </Drawer.Root>

View file

@ -49,7 +49,7 @@
</script> </script>
<div class='flex flex-col w-full relative h-full overflow-clip'> <div class='flex flex-col w-full relative h-full overflow-clip'>
<div class='space-y-0.5 p-3 md:p-10 md:pb-0 pb-0'> <div class='space-y-0.5 px-10 pt-10'>
<h2 class='text-2xl font-bold'>Global App Chat</h2> <h2 class='text-2xl font-bold'>Global App Chat</h2>
<p class='text-muted-foreground'> <p class='text-muted-foreground'>
Chat with other users of the app, share your thoughts, ask questions and have fun! Chat with other users of the app, share your thoughts, ask questions and have fun!
@ -58,7 +58,7 @@
</div> </div>
<div class='flex md:flex-row flex-col-reverse w-full h-full min-h-0'> <div class='flex md:flex-row flex-col-reverse w-full h-full min-h-0'>
<div class='flex flex-col justify-end overflow-clip flex-grow px-4 pb-4 h-full min-h-0'> <div class='flex flex-col justify-end overflow-clip flex-grow px-4 pb-4 h-full min-h-0'>
<div class='h-full overflow-y-scroll min-h-0 w-full [overflow-anchor:auto] content-end'> <div class='h-full overflow-y-scroll min-h-0 w-full'>
<Messages messages={client.messages} /> <Messages messages={client.messages} />
</div> </div>
<div class='flex mt-4 gap-2'> <div class='flex mt-4 gap-2'>

View file

@ -10,7 +10,7 @@
<script lang='ts'> <script lang='ts'>
import Interface from './interface.svelte' import Interface from './interface.svelte'
const viewer = client.client.viewer.value const viewer = client.viewer.value
let ident: { nick: string, id: string, pfpid: string, type: 'al' | 'guest' } let ident: { nick: string, id: string, pfpid: string, type: 'al' | 'guest' }

View file

@ -1,6 +0,0 @@
import Root from './markdown.svelte'
export {
Root,
Root as Markdown
}

View file

@ -1,62 +0,0 @@
<script lang='ts'>
import { OverType } from 'overtype'
import { cn } from '$lib/utils'
let className: string | undefined | null = ''
export let value = ''
export { className as class }
export let placeholder: string | undefined | null = undefined
function markdown (el: HTMLDivElement) {
const [editor] = new OverType(el, {
toolbar: true,
placeholder,
value,
autoResize: false,
theme: {
name: 'custom',
colors: {
bgPrimary: '#282c34',
bgSecondary: '#21252b',
text: '#abb2bf',
h1: '#e06c75',
h2: '#e5c07b',
h3: '#98c379',
strong: '#e5c07b',
em: '#c678dd',
link: '#61afef',
code: '#98c379',
codeBg: 'rgba(40, 44, 52, 0.5)',
blockquote: '#5c6370',
hr: '#3b4048',
syntaxMarker: 'rgba(97, 175, 239, 0.5)',
cursor: '#61afef',
selection: 'rgba(97, 175, 239, 0.3)',
listMarker: '#e5c07b',
toolbarBg: '#21252b',
toolbarBorder: '#3b4048',
toolbarIcon: '#abb2bf',
toolbarHover: '#333842',
toolbarActive: '#282c34'
}
},
onChange: (val: string) => {
value = val
}
}) as unknown as [InstanceType<typeof OverType>]
return {
destroy () {
editor.destroy()
}
}
}
</script>
<div use:markdown
class={cn(
'border-input placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[60px] w-full overflow-clip rounded-md border bg-transparent text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50',
className
)} />

View file

@ -1,11 +1,12 @@
<script lang='ts'> <script lang='ts'>
import { persisted } from 'svelte-persisted-store'
import Wrapper from './wrapper.svelte' import Wrapper from './wrapper.svelte'
import native from '$lib/modules/native' import native from '$lib/modules/native'
import { click } from '$lib/modules/navigate' import { click } from '$lib/modules/navigate'
import { debug, SUPPORTS } from '$lib/modules/settings'
const debug = persisted('debug', '')
function tabindex (node: HTMLElement) { function tabindex (node: HTMLElement) {
node.tabIndex = -1 node.tabIndex = -1
} }
@ -14,26 +15,24 @@
<svelte:document bind:fullscreenElement /> <svelte:document bind:fullscreenElement />
{#if !SUPPORTS.isAndroid} <Wrapper let:platform>
<Wrapper let:platform> <div class='w-[calc(100%-3.5rem)] left-[3.5rem] top-0 z-[2000] flex navbar absolute h-8'>
<div class='w-[calc(100%-3.5rem)] left-[3.5rem] top-0 z-[2000] flex navbar absolute h-8'> <div class='w-full {fullscreenElement ? 'not-draggable' : 'draggable'}' />
<div class='w-full {fullscreenElement ? 'custom-not-draggable' : 'custom-draggable'}' on:contextmenu|preventDefault /> {#if platform !== 'macOS'}
{#if platform !== 'macOS'} <div class='window-controls not-draggable flex text-white backdrop-blur'>
<div class='window-controls custom-not-draggable flex text-white backdrop-blur'> <button class='max-button flex items-center justify-center h-8 w-[46px]' use:click={native.minimise} use:tabindex>
<button class='max-button flex items-center justify-center h-8 w-[46px]' use:click={native.minimise} use:tabindex> <svg class='svg-controls w-3 h-3' role='img' viewBox='0 0 12 12'><rect fill='currentColor' height='1' width='10' x='1' y='6' />
<svg class='svg-controls w-3 h-3' role='img' viewBox='0 0 12 12'><rect fill='currentColor' height='1' width='10' x='1' y='6' /> </button>
</button> <button class='restore-button flex items-center justify-center h-8 w-[46px]' use:click={native.maximise} use:tabindex>
<button class='restore-button flex items-center justify-center h-8 w-[46px]' use:click={native.maximise} use:tabindex> <svg class='svg-controls w-3 h-3' role='img' viewBox='0 0 12 12'><rect fill='none' height='9' stroke='currentColor' width='9' x='1.5' y='1.5' />
<svg class='svg-controls w-3 h-3' role='img' viewBox='0 0 12 12'><rect fill='none' height='9' stroke='currentColor' width='9' x='1.5' y='1.5' /> </button>
</button> <button class='close-button flex items-center justify-center h-8 w-[46px]' use:click={native.close} use:tabindex>
<button class='close-button flex items-center justify-center h-8 w-[46px]' use:click={native.close} use:tabindex> <svg class='svg-controls w-3 h-3' role='img' viewBox='0 0 12 12'><polygon fill='currentColor' fill-rule='evenodd' points='11 1.576 6.583 6 11 10.424 10.424 11 6 6.583 1.576 11 1 10.424 5.417 6 1 1.576 1.576 1 6 5.417 10.424 1' />
<svg class='svg-controls w-3 h-3' role='img' viewBox='0 0 12 12'><polygon fill='currentColor' fill-rule='evenodd' points='11 1.576 6.583 6 11 10.424 10.424 11 6 6.583 1.576 11 1 10.424 5.417 6 1 1.576 1.576 1 6 5.417 10.424 1' /> </button>
</button> </div>
</div> {/if}
{/if} </div>
</div> </Wrapper>
</Wrapper>
{/if}
{#if $debug} {#if $debug}
<div class='ribbon z-[1000] text-center fixed font-bold pointer-events-none'>Debug Mode!</div> <div class='ribbon z-[1000] text-center fixed font-bold pointer-events-none'>Debug Mode!</div>
{/if} {/if}

View file

@ -1,71 +0,0 @@
<script lang='ts' context='module'>
import FastForward from 'lucide-svelte/icons/fast-forward'
import Pause from 'lucide-svelte/icons/pause'
import Rewind from 'lucide-svelte/icons/rewind'
import Volume1 from 'lucide-svelte/icons/volume-1'
import Volume2 from 'lucide-svelte/icons/volume-2'
import { writable } from 'simple-store-svelte'
import Play from '$lib/components/icons/Play.svelte'
import { settings } from '$lib/modules/settings'
type AnimationType = 'play' | 'pause' | 'seekforw' | 'seekback' | 'volumeup' | 'volumedown' | (string & {})
export function playAnimation (type: AnimationType) {
animations.value = [...animations.value, { type, id: crypto.randomUUID() }]
}
function endAnimation (id: string) {
const animationList = animations.value
const index = animationList.findIndex(animation => animation.id === id)
if (index !== -1) animationList.splice(index, 1)
animations.value = animationList
}
interface Animation {
type: AnimationType
id: string
}
const animations = writable<Animation[]>([])
</script>
{#if !$settings.minimalPlayerUI}
{#each $animations as { type, id } (id)}
<div class='absolute animate-pulse-once' on:animationend={() => endAnimation(id)}>
{#if type === 'play'}
<Play size='64px' fill='white' />
{:else if type === 'pause'}
<Pause size='64px' fill='white' />
{:else if type === 'seekforw'}
<FastForward size='64px' fill='white' />
{:else if type === 'seekback'}
<Rewind size='64px' fill='white' />
{:else if type === 'volumeup'}
<Volume2 size='64px' fill='white' />
{:else if type === 'volumedown'}
<Volume1 size='64px' fill='white' />
{:else}
<div class='text-4xl font-bold text-white'>{type}</div>
{/if}
</div>
{/each}
{/if}
<style>
.animate-pulse-once {
animation: pulse-once .4s linear;
}
@keyframes pulse-once {
0% {
opacity: 1;
scale: 1;
}
100% {
opacity: 0;
scale: 1.2;
}
}
</style>

View file

@ -1,30 +0,0 @@
<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

@ -1,42 +0,0 @@
<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, episodes } from '$lib/modules/anilist'
import { episodes as eps } 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} / {episodes(mediaInfo.media)}</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'>
<div class='contents' on:wheel|stopPropagation>
{#if mediaInfo.media}
{#await Promise.all([eps(mediaInfo.media.id), client.single(mediaInfo.media.id)]) then [eps, media]}
{#if media.data?.Media}
<EpisodesList {eps} media={media.data.Media} />
{/if}
{/await}
{/if}
</div>
</Sheet.Content>
</Sheet.Root>

View file

@ -4,18 +4,20 @@
import { writable } from 'svelte/store' import { writable } from 'svelte/store'
import { Button } from '../button' import { Button } from '../button'
import * as Sheet from '../sheet'
import EpisodesModal from './episodesmodal.svelte'
import type { ResolvedFile } from './resolver' import type { ResolvedFile } from './resolver'
import type { MediaInfo } from './util' import type { MediaInfo } from './util'
import type { TorrentFile } from 'native' import type { TorrentFile } from '../../../../app'
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { page } from '$app/stores' import { page } from '$app/stores'
import EpisodesList from '$lib/components/EpisodesList.svelte'
import * as Dialog from '$lib/components/ui/dialog' import * as Dialog from '$lib/components/ui/dialog'
import { episodes } from '$lib/modules/anizip'
import { authAggregator } from '$lib/modules/auth' import { authAggregator } from '$lib/modules/auth'
import native from '$lib/modules/native' import native from '$lib/modules/native'
import { click } from '$lib/modules/navigate'
import { settings } from '$lib/modules/settings' import { settings } from '$lib/modules/settings'
import { toTS } from '$lib/utils' import { toTS } from '$lib/utils'
@ -32,7 +34,7 @@
function openPlayer () { function openPlayer () {
if (isMiniplayer) goto('/app/player/') if (isMiniplayer) goto('/app/player/')
} }
const player = $page.route.id !== '/app/player' ? Promise.resolve() : native.spawnPlayer(mediaInfo.file.url) const player = native.spawnPlayer(mediaInfo.file.url)
const startTime = Date.now() const startTime = Date.now()
const elapsed = writable(0, (set) => { const elapsed = writable(0, (set) => {
@ -65,7 +67,17 @@
<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-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='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-2xl font-bold leading-none line-clamp-1 mb-2'>Now Watching</div>
<EpisodesModal portal={wrapper} {mediaInfo} /> <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-[550px] sm:max-w-full h-full overflow-y-scroll flex flex-col pb-0 shrink-0 gap-0 bg-black'>
{#if mediaInfo.media}
{#await episodes(mediaInfo.media.id) then eps}
<EpisodesList {eps} media={mediaInfo.media} />
{/await}
{/if}
</Sheet.Content>
</Sheet.Root>
{#await player} {#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='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]'> <div class='relative w-full h-1 flex items-center justify-center overflow-clip rounded-[2px]'>

View file

@ -3,7 +3,7 @@
import { get } from 'svelte/store' import { get } from 'svelte/store'
import { persisted } from 'svelte-persisted-store' import { persisted } from 'svelte-persisted-store'
import { keys, layout, type KeyCode, codeMap } from './maps.ts' import { keys, layout, type KeyCode } from './maps.ts'
type Bind <T extends Record<string, unknown> = Record<string, unknown>> = T & { type Bind <T extends Record<string, unknown> = Record<string, unknown>> = T & {
fn: (e: MouseEvent | KeyboardEvent) => void fn: (e: MouseEvent | KeyboardEvent) => void
@ -27,10 +27,9 @@
}) })
async function runBind (e: MouseEvent | KeyboardEvent, code: KeyCode) { async function runBind (e: MouseEvent | KeyboardEvent, code: KeyCode) {
if (!code && 'key' in e) code = codeMap[e.key] ?? '' if ('repeat' in e && e.repeat) return
const kbn = get(binds) const kbn = get(binds)
if (cnd(code)) kbn[layout[code] ?? code]?.fn?.(e) if (cnd(code)) kbn[layout[code] ?? code]?.fn(e)
} }
export function loadWithDefaults (defaults: Partial<Record<string, Bind>>) { export function loadWithDefaults (defaults: Partial<Record<string, Bind>>) {

View file

@ -1,4 +1,4 @@
export type KeyCode = '' | 'Again' | 'AltLeft' | 'AltRight' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight' | 'ArrowUp' | 'AudioVolumeDown' | 'AudioVolumeMute' | 'AudioVolumeUp' | 'Backquote' | 'Backslash' | 'Backspace' | 'BracketLeft' | 'BracketRight' | 'BrowserBack' | 'BrowserFavorites' | 'BrowserForward' | 'BrowserHome' | 'BrowserRefresh' | 'BrowserSearch' | 'BrowserStop' | 'CapsLock' | 'Comma' | 'ContextMenu' | 'ControlLeft' | 'ControlRight' | 'Convert' | 'Copy' | 'Cut' | 'Delete' | 'Digit0' | 'Digit1' | 'Digit2' | 'Digit3' | 'Digit4' | 'Digit5' | 'Digit6' | 'Digit7' | 'Digit8' | 'Digit9' | 'Eject' | 'End' | 'Enter' | 'Equal' | 'F1' | 'F10' | 'F11' | 'F12' | 'F13' | 'F14' | 'F15' | 'F16' | 'F17' | 'F18' | 'F19' | 'F2' | 'F20' | 'F21' | 'F22' | 'F23' | 'F24' | 'F3' | 'F4' | 'F5' | 'F6' | 'F7' | 'F8' | 'F9' | 'Find' | 'Help' | 'Home' | 'Insert' | 'IntlBackslash' | 'IntlRo' | 'IntlYen' | 'KanaMode' | 'KeyA' | 'KeyB' | 'KeyC' | 'KeyD' | 'KeyE' | 'KeyF' | 'KeyG' | 'KeyH' | 'KeyI' | 'KeyJ' | 'KeyK' | 'KeyL' | 'KeyM' | 'KeyN' | 'KeyO' | 'KeyP' | 'KeyQ' | 'KeyR' | 'KeyS' | 'KeyT' | 'KeyU' | 'KeyV' | 'KeyW' | 'KeyX' | 'KeyY' | 'KeyZ' | 'Lang1' | 'Lang2' | 'Lang3' | 'Lang4' | 'Lang5' | 'LaunchApp1' | 'LaunchApp2' | 'LaunchMail' | 'MediaPlayPause' | 'MediaSelect' | 'MediaStop' | 'MediaTrackNext' | 'MediaTrackPrevious' | 'MetaLeft' | 'MetaRight' | 'Minus' | 'NonConvert' | 'NumLock' | 'Numpad0' | 'Numpad1' | 'Numpad2' | 'Numpad3' | 'Numpad4' | 'Numpad5' | 'Numpad6' | 'Numpad7' | 'Numpad8' | 'Numpad9' | 'NumpadAdd' | 'NumpadComma' | 'NumpadDecimal' | 'NumpadDivide' | 'NumpadEnter' | 'NumpadEqual' | 'NumpadMultiply' | 'NumpadParenLeft' | 'NumpadParenRight' | 'NumpadSubtract' | 'Open' | 'PageDown' | 'PageUp' | 'Paste' | 'Pause' | 'Period' | 'Power' | 'PrintScreen' | 'Quote' | 'ScrollLock' | 'Select' | 'Semicolon' | 'ShiftLeft' | 'ShiftRight' | 'Slash' | 'Sleep' | 'Space' | 'Tab' | 'Undo' | 'WakeUp' | 'Escape' export type KeyCode = 'Again' | 'AltLeft' | 'AltRight' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight' | 'ArrowUp' | 'AudioVolumeDown' | 'AudioVolumeMute' | 'AudioVolumeUp' | 'Backquote' | 'Backslash' | 'Backspace' | 'BracketLeft' | 'BracketRight' | 'BrowserBack' | 'BrowserFavorites' | 'BrowserForward' | 'BrowserHome' | 'BrowserRefresh' | 'BrowserSearch' | 'BrowserStop' | 'CapsLock' | 'Comma' | 'ContextMenu' | 'ControlLeft' | 'ControlRight' | 'Convert' | 'Copy' | 'Cut' | 'Delete' | 'Digit0' | 'Digit1' | 'Digit2' | 'Digit3' | 'Digit4' | 'Digit5' | 'Digit6' | 'Digit7' | 'Digit8' | 'Digit9' | 'Eject' | 'End' | 'Enter' | 'Equal' | 'F1' | 'F10' | 'F11' | 'F12' | 'F13' | 'F14' | 'F15' | 'F16' | 'F17' | 'F18' | 'F19' | 'F2' | 'F20' | 'F21' | 'F22' | 'F23' | 'F24' | 'F3' | 'F4' | 'F5' | 'F6' | 'F7' | 'F8' | 'F9' | 'Find' | 'Help' | 'Home' | 'Insert' | 'IntlBackslash' | 'IntlRo' | 'IntlYen' | 'KanaMode' | 'KeyA' | 'KeyB' | 'KeyC' | 'KeyD' | 'KeyE' | 'KeyF' | 'KeyG' | 'KeyH' | 'KeyI' | 'KeyJ' | 'KeyK' | 'KeyL' | 'KeyM' | 'KeyN' | 'KeyO' | 'KeyP' | 'KeyQ' | 'KeyR' | 'KeyS' | 'KeyT' | 'KeyU' | 'KeyV' | 'KeyW' | 'KeyX' | 'KeyY' | 'KeyZ' | 'Lang1' | 'Lang2' | 'Lang3' | 'Lang4' | 'Lang5' | 'LaunchApp1' | 'LaunchApp2' | 'LaunchMail' | 'MediaPlayPause' | 'MediaSelect' | 'MediaStop' | 'MediaTrackNext' | 'MediaTrackPrevious' | 'MetaLeft' | 'MetaRight' | 'Minus' | 'NonConvert' | 'NumLock' | 'Numpad0' | 'Numpad1' | 'Numpad2' | 'Numpad3' | 'Numpad4' | 'Numpad5' | 'Numpad6' | 'Numpad7' | 'Numpad8' | 'Numpad9' | 'NumpadAdd' | 'NumpadComma' | 'NumpadDecimal' | 'NumpadDivide' | 'NumpadEnter' | 'NumpadEqual' | 'NumpadMultiply' | 'NumpadParenLeft' | 'NumpadParenRight' | 'NumpadSubtract' | 'Open' | 'PageDown' | 'PageUp' | 'Paste' | 'Pause' | 'Period' | 'Power' | 'PrintScreen' | 'Quote' | 'ScrollLock' | 'Select' | 'Semicolon' | 'ShiftLeft' | 'ShiftRight' | 'Slash' | 'Sleep' | 'Space' | 'Tab' | 'Undo' | 'WakeUp' | 'Escape'
declare class KeyboardLayoutMap extends Map<KeyCode, string> { } declare class KeyboardLayoutMap extends Map<KeyCode, string> { }
@ -8,7 +8,7 @@ interface Keyboard {
} }
declare global { declare global {
// eslint-disable-next-line no-unused-vars
interface Navigator { interface Navigator {
keyboard?: Keyboard keyboard?: Keyboard
} }
@ -253,7 +253,7 @@ export const keys: Partial<Record<KeyCode, {dark?: boolean, name: KeyCode, size?
} }
} }
// char => code for navigator.keyboard API // char => code for navigator.keyboard API
export const codeMap: Record<string, KeyCode> = { const codeMap: Record<string, KeyCode> = {
0: 'Digit0', 0: 'Digit0',
1: 'Digit1', 1: 'Digit1',
2: 'Digit2', 2: 'Digit2',

View file

@ -15,8 +15,8 @@
import type { resolveFilesPoorly, ResolvedFile } from './resolver' import type { resolveFilesPoorly, ResolvedFile } from './resolver'
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { fillerEpisodes } from '$lib/components/EpisodesList.svelte'
import { cover, episodes, title, type Media } from '$lib/modules/anilist' import { cover, episodes, title, type Media } from '$lib/modules/anilist'
import { fillerEpisodes } from '$lib/modules/extensions'
import { settings } from '$lib/modules/settings' import { settings } from '$lib/modules/settings'
import { server } from '$lib/modules/torrent' import { server } from '$lib/modules/torrent'
import { w2globby } from '$lib/modules/w2g/lobby' import { w2globby } from '$lib/modules/w2g/lobby'

View file

@ -1,6 +1,6 @@
<script lang='ts'> <script lang='ts'>
import EllipsisVertical from 'lucide-svelte/icons/ellipsis-vertical' import EllipsisVertical from 'lucide-svelte/icons/ellipsis-vertical'
import { getContext, tick } from 'svelte' import { tick } from 'svelte'
import { Input } from '../input' import { Input } from '../input'
@ -13,7 +13,6 @@
import type { Writable } from 'simple-store-svelte' import type { Writable } from 'simple-store-svelte'
import type { HTMLAttributes } from 'svelte/elements' import type { HTMLAttributes } from 'svelte/elements'
import { beforeNavigate } from '$app/navigation'
import { Button } from '$lib/components/ui/button' import { Button } from '$lib/components/ui/button'
import * as Dialog from '$lib/components/ui/dialog' import * as Dialog from '$lib/components/ui/dialog'
import * as Tooltip from '$lib/components/ui/tooltip' import * as Tooltip from '$lib/components/ui/tooltip'
@ -73,15 +72,6 @@
export let id = '' export let id = ''
let keybindDesc: unknown = null let keybindDesc: unknown = null
const stopProgressBar = getContext<() => void>('stop-progress-bar')
beforeNavigate(({ cancel }) => {
if (open) {
open = false
cancel()
stopProgressBar()
}
})
</script> </script>
<Dialog.Root portal={wrapper} bind:open> <Dialog.Root portal={wrapper} bind:open>
@ -90,8 +80,8 @@
<EllipsisVertical size='24px' class='p-[1px]' /> <EllipsisVertical size='24px' class='p-[1px]' />
</Button> </Button>
</Dialog.Trigger> </Dialog.Trigger>
<Dialog.Content class='absolute bg-transparent border-none p-0 shadow-none size-full overflow-hidden max-w-full'> <Dialog.Content class='absolute bg-transparent border-none p-0 shadow-none size-full overflow-hidden'>
<div on:pointerdown|self={close} on:wheel|stopPropagation class='size-full flex justify-center items-center flex-col overflow-y-scroll text-[6px] lg:text-xs' use:dragScroll> <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} {#if showKeybinds}
<div class='bg-black py-3 px-4 rounded-md text-sm lg:text-lg font-bold mb-4'> <div class='bg-black py-3 px-4 rounded-md text-sm lg:text-lg font-bold mb-4'>
{keybindDesc ?? 'Drag and drop binds to change them'} {keybindDesc ?? 'Drag and drop binds to change them'}
@ -109,44 +99,40 @@
</Keybinds> </Keybinds>
{:else} {:else}
<Tree.Root bind:state={treeState}> <Tree.Root bind:state={treeState}>
{#if 'audioTracks' in HTMLVideoElement.prototype} <Tree.Item>
<Tree.Item> <span slot='trigger'>Audio</span>
<span slot='trigger'>Audio</span> <Tree.Sub>
<Tree.Sub> {#each Object.entries(normalizeTracks(video.audioTracks ?? [])) as [lang, tracks] (lang)}
{#each Object.entries(normalizeTracks(video.audioTracks ?? [])) as [lang, tracks] (lang)} <Tree.Item>
<Tree.Item> <span slot='trigger' class='capitalize'>{lang}</span>
<span slot='trigger' class='capitalize'>{lang}</span> <Tree.Sub>
<Tree.Sub> {#each tracks as track (track.id)}
{#each tracks as track (track.id)} <Tree.Item active={track.enabled} on:click={() => { selectAudio(track.id); open = false }}>
<Tree.Item active={track.enabled} on:click={() => { selectAudio(track.id); open = false }}> <span>{track.label}</span>
<span>{track.label}</span> </Tree.Item>
</Tree.Item> {/each}
{/each} </Tree.Sub>
</Tree.Sub> </Tree.Item>
</Tree.Item> {/each}
{/each} </Tree.Sub>
</Tree.Sub> </Tree.Item>
</Tree.Item> <Tree.Item>
{/if} <span slot='trigger'>Video</span>
{#if 'videoTracks' in HTMLVideoElement.prototype} <Tree.Sub>
<Tree.Item> {#each Object.entries(normalizeTracks(video.videoTracks ?? [])) as [lang, tracks] (lang)}
<span slot='trigger'>Video</span> <Tree.Item>
<Tree.Sub> <span slot='trigger' class='capitalize'>{lang}</span>
{#each Object.entries(normalizeTracks(video.videoTracks ?? [])) as [lang, tracks] (lang)} <Tree.Sub>
<Tree.Item> {#each tracks as track (track.id)}
<span slot='trigger' class='capitalize'>{lang}</span> <Tree.Item active={track.enabled} on:click={() => { selectVideo(track.id); open = false }}>
<Tree.Sub> <span>{track.label}</span>
{#each tracks as track (track.id)} </Tree.Item>
<Tree.Item active={track.enabled} on:click={() => { selectVideo(track.id); open = false }}> {/each}
<span>{track.label}</span> </Tree.Sub>
</Tree.Item> </Tree.Item>
{/each} {/each}
</Tree.Sub> </Tree.Sub>
</Tree.Item> </Tree.Item>
{/each}
</Tree.Sub>
</Tree.Item>
{/if}
{#if subtitles} {#if subtitles}
<Tree.Item id='subs'> <Tree.Item id='subs'>
<span slot='trigger'>Subtitles</span> <span slot='trigger'>Subtitles</span>
@ -183,7 +169,7 @@
{#each chapters as { text, start }, i (i)} {#each chapters as { text, start }, i (i)}
<Tree.Item on:click={() => { seekTo(start); open = false }}> <Tree.Item on:click={() => { seekTo(start); open = false }}>
<div class='flex justify-between w-full pr-2'> <div class='flex justify-between w-full pr-2'>
<span class='capitalize'>{text || '?'}</span> <span>{text || '?'}</span>
<span class='text-muted-foreground'>{toTS(start || 0)}</span> <span class='text-muted-foreground'>{toTS(start || 0)}</span>
</div> </div>
</Tree.Item> </Tree.Item>
@ -193,36 +179,36 @@
<Tree.Item> <Tree.Item>
<span slot='trigger'>Playback Rate</span> <span slot='trigger'>Playback Rate</span>
<Tree.Sub> <Tree.Sub>
<Tree.Item active={playbackRate === 0.5} on:click={() => { playbackRate = 0.5; open = false }}> <Tree.Item on:click={() => { playbackRate = 0.5; open = false }}>
<span>0.5x</span> <span>0.5x</span>
</Tree.Item> </Tree.Item>
<Tree.Item active={playbackRate === 0.75} on:click={() => { playbackRate = 0.75; open = false }}> <Tree.Item on:click={() => { playbackRate = 0.75; open = false }}>
<span>0.75x</span> <span>0.75x</span>
</Tree.Item> </Tree.Item>
<Tree.Item active={playbackRate === 1} on:click={() => { playbackRate = 1; open = false }}> <Tree.Item on:click={() => { playbackRate = 1; open = false }}>
<span>1x</span> <span>1x</span>
</Tree.Item> </Tree.Item>
<Tree.Item active={playbackRate === 1.25} on:click={() => { playbackRate = 1.25; open = false }}> <Tree.Item on:click={() => { playbackRate = 1.25; open = false }}>
<span>1.25x</span> <span>1.25x</span>
</Tree.Item> </Tree.Item>
<Tree.Item active={playbackRate === 1.5} on:click={() => { playbackRate = 1.5; open = false }}> <Tree.Item on:click={() => { playbackRate = 1.5; open = false }}>
<span>1.5x</span> <span>1.5x</span>
</Tree.Item> </Tree.Item>
<Tree.Item active={playbackRate === 1.75} on:click={() => { playbackRate = 1.75; open = false }}> <Tree.Item on:click={() => { playbackRate = 1.75; open = false }}>
<span>1.75x</span> <span>1.75x</span>
</Tree.Item> </Tree.Item>
<Tree.Item active={playbackRate === 2} on:click={() => { playbackRate = 2; open = false }}> <Tree.Item on:click={() => { playbackRate = 2; open = false }}>
<span>2x</span> <span>2x</span>
</Tree.Item> </Tree.Item>
</Tree.Sub> </Tree.Sub>
</Tree.Item> </Tree.Item>
<Tree.Item> <Tree.Item>
<span slot='trigger'>Playlist</span> <span slot='trigger'>Playlist</span>
<Tree.Sub class='w-auto max-w-xl'> <Tree.Sub class='w-auto max-w-96'>
{#each videoFiles as file, i (i)} {#each videoFiles as file, i (i)}
<Tree.Item on:click={() => selectFile(file)}> <Tree.Item on:click={() => selectFile(file)}>
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger class='text-ellipsis text-nowrap overflow-clip w-full text-xs text-left' tabindex={-1}> <Tooltip.Trigger class='text-ellipsis text-nowrap overflow-clip w-full'>
{file.name} {file.name}
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content> <Tooltip.Content>
@ -239,7 +225,7 @@
<Tree.Item on:click={fullscreen} active={!!fullscreenElement}> <Tree.Item on:click={fullscreen} active={!!fullscreenElement}>
Fullscreen Fullscreen
</Tree.Item> </Tree.Item>
<Tree.Item on:click={() => { pip.pip(); close() }} active={!!$pipElement}> <Tree.Item on:click={() => pip.pip()} active={!!$pipElement}>
Picture in Picture Picture in Picture
</Tree.Item> </Tree.Item>
<Tree.Item on:click={deband} active={$settings.playerDeband}> <Tree.Item on:click={deband} active={$settings.playerDeband}>

View file

@ -75,7 +75,8 @@ export default class PictureInPicture {
const renderFrame = (noskip?: number) => { const renderFrame = (noskip?: number) => {
if (noskip) this.video!.paused ? video.pause() : video.play() if (noskip) this.video!.paused ? video.pause() : video.play()
context.drawImage(this.deband?.canvas ?? this.video!, 0, 0) context.drawImage(this.deband?.canvas ?? this.video!, 0, 0)
if (canvas.width && canvas.height && this.subtitles?.renderer?._canvas) context.drawImage(this.subtitles.renderer._canvas, 0, 0, canvas.width, canvas.height) // @ts-expect-error internal call on canvas
if (canvas.width && canvas.height && this.subtitles.renderer?._canvas) context.drawImage(this.subtitles.renderer._canvas, 0, 0, canvas.width, canvas.height)
loop = this.video!.requestVideoFrameCallback(renderFrame) loop = this.video!.requestVideoFrameCallback(renderFrame)
} }
ctrl.signal.addEventListener('abort', () => { ctrl.signal.addEventListener('abort', () => {

View file

@ -1,11 +1,15 @@
<script lang='ts'> <script lang='ts'>
import Captions from 'lucide-svelte/icons/captions' import Captions from 'lucide-svelte/icons/captions'
// import Cast from 'lucide-svelte/icons/cast' import Cast from 'lucide-svelte/icons/cast'
import ChevronDown from 'lucide-svelte/icons/chevron-down'
import ChevronUp from 'lucide-svelte/icons/chevron-up'
import Contrast from 'lucide-svelte/icons/contrast' import Contrast from 'lucide-svelte/icons/contrast'
import DecimalsArrowLeft from 'lucide-svelte/icons/decimals-arrow-left' import DecimalsArrowLeft from 'lucide-svelte/icons/decimals-arrow-left'
import DecimalsArrowRight from 'lucide-svelte/icons/decimals-arrow-right' import DecimalsArrowRight from 'lucide-svelte/icons/decimals-arrow-right'
import FastForward from 'lucide-svelte/icons/fast-forward' import FastForward from 'lucide-svelte/icons/fast-forward'
import List from 'lucide-svelte/icons/list' import List from 'lucide-svelte/icons/list'
import Maximize from 'lucide-svelte/icons/maximize'
import Minimize from 'lucide-svelte/icons/minimize'
import Pause from 'lucide-svelte/icons/pause' import Pause from 'lucide-svelte/icons/pause'
import PictureInPicture2 from 'lucide-svelte/icons/picture-in-picture-2' import PictureInPicture2 from 'lucide-svelte/icons/picture-in-picture-2'
import Proportions from 'lucide-svelte/icons/proportions' import Proportions from 'lucide-svelte/icons/proportions'
@ -16,6 +20,7 @@
import ScreenShare from 'lucide-svelte/icons/screen-share' import ScreenShare from 'lucide-svelte/icons/screen-share'
import SkipBack from 'lucide-svelte/icons/skip-back' import SkipBack from 'lucide-svelte/icons/skip-back'
import SkipForward from 'lucide-svelte/icons/skip-forward' import SkipForward from 'lucide-svelte/icons/skip-forward'
import Users from 'lucide-svelte/icons/users'
import Volume1 from 'lucide-svelte/icons/volume-1' import Volume1 from 'lucide-svelte/icons/volume-1'
import Volume2 from 'lucide-svelte/icons/volume-2' import Volume2 from 'lucide-svelte/icons/volume-2'
import VolumeX from 'lucide-svelte/icons/volume-x' import VolumeX from 'lucide-svelte/icons/volume-x'
@ -25,40 +30,39 @@
import { toast } from 'svelte-sonner' import { toast } from 'svelte-sonner'
import VideoDeband from 'video-deband' import VideoDeband from 'video-deband'
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 { condition, loadWithDefaults } from './keybinds.svelte'
import Options from './options.svelte' import Options from './options.svelte'
import PictureInPicture from './pip' import PictureInPicture from './pip'
import Seekbar from './seekbar.svelte' import Seekbar from './seekbar.svelte'
import Subs from './subtitles' import Subs from './subtitles'
import Thumbnailer from './thumbnailer' import Thumbnailer from './thumbnailer'
import { findChapter, getChaptersAniSkip, getChapterTitle, isChapterSkippable, sanitizeChapters, screenshot, type Chapter, type MediaInfo } from './util' import { getChaptersAniSkip, getChapterTitle, sanitizeChapters, type Chapter, type MediaInfo } from './util'
import Volume from './volume.svelte' import Volume from './volume.svelte'
import type { ResolvedFile } from './resolver' import type { ResolvedFile } from './resolver'
import type { TorrentFile } from 'native' import type { TorrentFile } from '../../../../app'
import type { SvelteMediaTimeRange } from 'svelte/elements' import type { SvelteMediaTimeRange } from 'svelte/elements'
import { beforeNavigate, goto } from '$app/navigation' import { beforeNavigate, goto } from '$app/navigation'
import { page } from '$app/stores' import { page } from '$app/stores'
import EpisodesList from '$lib/components/EpisodesList.svelte'
import PictureInPictureOff from '$lib/components/icons/PictureInPicture.svelte' import PictureInPictureOff from '$lib/components/icons/PictureInPicture.svelte'
import PictureInPictureExit from '$lib/components/icons/PictureInPictureExit.svelte' import PictureInPictureExit from '$lib/components/icons/PictureInPictureExit.svelte'
import Play from '$lib/components/icons/Play.svelte' import Play from '$lib/components/icons/Play.svelte'
import Subtitles from '$lib/components/icons/Subtitles.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 { 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 { authAggregator } from '$lib/modules/auth'
import { isPlaying } from '$lib/modules/idle' import { isPlaying } from '$lib/modules/idle'
import native from '$lib/modules/native' import native from '$lib/modules/native'
import { click, inputType, keywrap } from '$lib/modules/navigate' import { click, inputType, keywrap } from '$lib/modules/navigate'
import { settings, SUPPORTS } from '$lib/modules/settings' import { settings, SUPPORTS } from '$lib/modules/settings'
import { server } from '$lib/modules/torrent'
import { w2globby } from '$lib/modules/w2g/lobby' import { w2globby } from '$lib/modules/w2g/lobby'
import { getAnimeProgress, setAnimeProgress } from '$lib/modules/watchProgress' import { getAnimeProgress, setAnimeProgress } from '$lib/modules/watchProgress'
import { toTS, scaleBlurFade, cn } from '$lib/utils' import { toTS, fastPrettyBits } from '$lib/utils'
export let mediaInfo: MediaInfo export let mediaInfo: MediaInfo
export let otherFiles: TorrentFile[] export let otherFiles: TorrentFile[]
@ -73,12 +77,7 @@
let currentTime = 0 let currentTime = 0
let seekPercent = 0 let seekPercent = 0
let duration = 1 let duration = 1
const playbackRate = persisted('playbackRate', 1, { let playbackRate = 1
serializer: {
stringify: (value) => value.toString(),
parse: (value) => Math.min(16, Math.max(0.1, parseFloat(value)))
}
})
let buffered: SvelteMediaTimeRange[] = [] let buffered: SvelteMediaTimeRange[] = []
let subtitleDelay = 0 let subtitleDelay = 0
$: buffer = Math.max(...buffered.map(({ end }) => end)) $: buffer = Math.max(...buffered.map(({ end }) => end))
@ -117,24 +116,21 @@
let ended = false let ended = false
let paused = true let paused = true
let pointerMoving = false let pointerMoving = false
let fastForwarding = false const cast = false
// const cast = false
$: $isPlaying = !paused $: $isPlaying = !paused
$: buffering = readyState < 3 $: buffering = readyState < 3
$: immersed = (!buffering && !paused && !ended && !pictureInPictureElement && !pointerMoving) || fastForwarding $: immersed = !buffering && !paused && !ended && !pictureInPictureElement && !pointerMoving
$: isMiniplayer = $page.route.id !== '/app/player' $: isMiniplayer = $page.route.id !== '/app/player'
$: if (!isMiniplayer && SUPPORTS.isAndroidTV) fullscreen()
let pointerMoveTimeout = 0 let pointerMoveTimeout = 0
function resetMove (time = 300) { function resetMove () {
clearTimeout(pointerMoveTimeout) clearTimeout(pointerMoveTimeout)
pointerMoving = true pointerMoving = true
pointerMoveTimeout = setTimeout(() => { pointerMoveTimeout = setTimeout(() => {
pointerMoving = false pointerMoving = false
}, time) }, 300)
} }
// functions // functions
@ -146,21 +142,21 @@
return fullscreenElement ? document.exitFullscreen() : document.getElementById('episodeListTarget')!.requestFullscreen() return fullscreenElement ? document.exitFullscreen() : document.getElementById('episodeListTarget')!.requestFullscreen()
} }
// function toggleCast () { function toggleCast () {
// // TODO: never // TODO: never
// } }
$: fullscreenElement ? screen.orientation.lock('landscape') : screen.orientation.unlock() $: fullscreenElement ? screen.orientation.lock('landscape') : screen.orientation.unlock()
beforeNavigate(({ to }) => { beforeNavigate(() => {
if (fullscreenElement && to?.route.id !== '/app/player') fullscreen() if (fullscreenElement) fullscreen()
}) })
function checkAudio () { function checkAudio () {
if ('audioTracks' in HTMLVideoElement.prototype && video.audioTracks) { if (video.audioTracks) {
if (!video.audioTracks.length) { if (!video.audioTracks.length) {
toast.error('Audio Codec Unsupported', { toast.error('Audio Codec Unsupported', {
description: "This torrent's audio codec is not supported, try a different release by disabling Autoplay Torrents in Torrent settings. You can also use external players like MPV." description: "This torrent's audio codec is not supported, try a different release by disabling Autoplay Torrents in RSS settings."
}) })
} else if (video.audioTracks.length > 1) { } else if (video.audioTracks.length > 1) {
const preferredTrack = [...video.audioTracks].find(({ language }) => language === $settings.audioLanguage) const preferredTrack = [...video.audioTracks].find(({ language }) => language === $settings.audioLanguage)
@ -169,26 +165,12 @@
const japaneseTrack = [...video.audioTracks].find(({ language }) => language === 'jpn') const japaneseTrack = [...video.audioTracks].find(({ language }) => language === 'jpn')
if (japaneseTrack) return selectAudio(japaneseTrack.id) if (japaneseTrack) return selectAudio(japaneseTrack.id)
} }
} else {
video.requestVideoFrameCallback(() => {
// using capturestream.getAudioTracks() could work too
if ('webkitAudioDecodedByteCount' in video && video.webkitAudioDecodedByteCount === 0) {
toast.error('Audio Codec Unsupported', {
description: "This torrent's audio codec is not supported, try a different release by disabling Autoplay Torrents in Torrent settings. You can also use external players like MPV."
})
}
})
} }
} }
function changeVolume (delta: number) {
playAnimation(delta > 0 ? 'volumeup' : 'volumedown')
$volume = Math.min(1, Math.max(0, $volume + delta))
}
function selectAudio (id: string) { function selectAudio (id: string) {
if (id) { if (id) {
for (const track of video.audioTracks ?? []) { for (const track of video.audioTracks ?? []) {
track.enabled = track.id === id track.enabled = track.id === id
playAnimation(track.label)
} }
seek(-0.2) // stupid fix because video freezes up when chaging tracks seek(-0.2) // stupid fix because video freezes up when chaging tracks
} }
@ -197,7 +179,6 @@
if (id) { if (id) {
for (const track of video.videoTracks ?? []) { for (const track of video.videoTracks ?? []) {
track.selected = track.id === id track.selected = track.id === id
playAnimation(track.label)
} }
} }
} }
@ -208,8 +189,8 @@
playAnimation(time > 0 ? 'seekforw' : 'seekback') playAnimation(time > 0 ? 'seekforw' : 'seekback')
} }
function seekTo (time: number) { function seekTo (time: number) {
video.currentTime = currentTime = time
playAnimation(time > currentTime ? 'seekforw' : 'seekback') playAnimation(time > currentTime ? 'seekforw' : 'seekback')
video.currentTime = currentTime = time
} }
let wasPaused = false let wasPaused = false
function startSeek () { function startSeek () {
@ -222,6 +203,46 @@
if (!wasPaused) video.play() 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.'
})
}
// animations
function playAnimation (type: 'play' | 'pause' | 'seekforw' | 'seekback') {
animations.push({ type, id: crypto.randomUUID() })
// eslint-disable-next-line no-self-assign
animations = animations
}
function endAnimation (id: string) {
const index = animations.findIndex(animation => animation.id === id)
if (index !== -1) animations.splice(index, 1)
// eslint-disable-next-line no-self-assign
animations = animations
}
interface Animation {
type: 'play' | 'pause' | 'seekforw' | 'seekback'
id: string
}
let animations: Animation[] = []
let chapters: Chapter[] = [] let chapters: Chapter[] = []
const chaptersPromise = native.chapters(mediaInfo.file.hash, mediaInfo.file.id) const chaptersPromise = native.chapters(mediaInfo.file.hash, mediaInfo.file.id)
async function loadChapters (pr: typeof chaptersPromise, safeduration: number) { async function loadChapters (pr: typeof chaptersPromise, safeduration: number) {
@ -250,15 +271,10 @@
function createDeband (video: HTMLVideoElement, playerDeband: boolean) { function createDeband (video: HTMLVideoElement, playerDeband: boolean) {
const create = () => { const create = () => {
if (deband) return destroy()
try { deband = new VideoDeband(video)
deband = new VideoDeband(video) deband.canvas.classList.add('deband-canvas', 'w-full', 'h-full', 'pointer-events-none', 'object-contain')
deband.canvas.classList.add('deband-canvas', 'w-full', 'h-full', 'pointer-events-none', 'object-contain') video.before(deband.canvas)
video.before(deband.canvas)
} catch (e) {
console.error('Failed to create video deband:', e)
destroy()
}
} }
const destroy = () => { const destroy = () => {
@ -282,13 +298,16 @@
} }
let completed = false let completed = false
async function checkCompletion () { function checkCompletion () {
if (!completed && $settings.playerAutocomplete) { if (!completed && $settings.playerAutocomplete) {
const fromend = Math.max(180, safeduration / 10) checkCompletionByTime(currentTime, safeduration)
if (safeduration && currentTime && readyState && safeduration - fromend < currentTime) { }
authAggregator.watch(mediaInfo.media, mediaInfo.episode) }
completed = true function checkCompletionByTime (currentTime: number, safeduration: number) {
} const fromend = Math.max(180, safeduration / 10)
if (safeduration && currentTime && readyState && safeduration - fromend < currentTime) {
authAggregator.watch(mediaInfo.media, mediaInfo.episode)
completed = true
} }
} }
@ -313,37 +332,42 @@
if (!isMiniplayer) video.play() if (!isMiniplayer) video.play()
} }
const interval = setInterval(() => {
video.load()
}, 10_000)
onDestroy(() => clearInterval(interval))
$: if (readyState > 0) clearInterval(interval)
let currentSkippable: string | null = null let currentSkippable: string | null = null
function checkSkippableChapters () { function checkSkippableChapters () {
const current = findChapter(currentTime, chapters) const current = findChapter(currentTime)
const wasSkippable = currentSkippable
if (current) { if (current) {
currentSkippable = isChapterSkippable(current) currentSkippable = isChapterSkippable(current)
if ($settings.playerSkip && !wasSkippable) animating = true
} }
} }
function stopAnimation () { $: if (currentSkippable && $settings.playerSkip) skip()
animating = false
const skippableChaptersRx: Array<[string, RegExp]> = [
['Opening', /^op$|opening$|^ncop/mi],
['Ending', /^ed$|ending$|^nced/mi],
['Recap', /recap/mi]
]
function isChapterSkippable (chapter: Chapter) {
for (const [name, regex] of skippableChaptersRx) {
if (regex.test(chapter.text)) {
return name
}
}
return null
} }
let animating = false function findChapter (time: number) {
return chapters.find(({ start, end }) => time >= start && time <= end)
}
function skip () { function skip () {
const current = findChapter(currentTime, chapters) const current = findChapter(currentTime)
if (current) { if (current) {
if (!isChapterSkippable(current) && (current.end - current.start) > 100) { if (!isChapterSkippable(current) && (current.end - current.start) > 100) {
currentTime = currentTime + 85 currentTime = currentTime + 85
} else { } else {
const endtime = current.end + 0.5 const endtime = current.end
if ((safeduration - endtime | 0) === 0) return next?.()
currentTime = endtime currentTime = endtime
currentSkippable = null currentSkippable = null
} }
@ -354,6 +378,7 @@
} else { } else {
currentTime = currentTime + 85 currentTime = currentTime + 85
} }
video.currentTime = currentTime
} }
let stats: { let stats: {
@ -411,42 +436,34 @@
$: if (readyState && !seekIndex) thumbnailer._paintThumbnail(video, playbackIndex) $: if (readyState && !seekIndex) thumbnailer._paintThumbnail(video, playbackIndex)
$: native.setMediaSession(mediaInfo.session, mediaInfo.media.id, safeduration) $: native.setMediaSession(mediaInfo.session, mediaInfo.media.id)
$: native.setPositionState({ duration: safeduration, position: Math.min(Math.max(0, currentTime), safeduration), playbackRate: $playbackRate }, readyState === 0 ? 'none' : paused ? 'paused' : 'playing') $: native.setPositionState({ duration: safeduration, position: Math.min(Math.max(0, currentTime), safeduration), playbackRate })
$: native.setPlayBackState(readyState === 0 ? 'none' : paused ? 'paused' : 'playing') $: native.setPlayBackState(readyState === 0 ? 'none' : paused ? 'paused' : 'playing')
native.setActionHandler('play', playPause) native.setActionHandler('play', playPause)
native.setActionHandler('pause', playPause) native.setActionHandler('pause', playPause)
native.setActionHandler('seekto', ({ seekTime }) => seekTo(seekTime ?? 0)) native.setActionHandler('seekto', ({ seekTime }) => seekTo(seekTime ?? 0))
native.setActionHandler('seekbackward', () => seek(-Number($settings.playerSeek))) native.setActionHandler('seekbackward', () => seek(-2))
native.setActionHandler('seekforward', () => seek(Number($settings.playerSeek))) native.setActionHandler('seekforward', () => seek(2))
native.setActionHandler('previoustrack', () => prev?.()) native.setActionHandler('previoustrack', () => prev?.())
native.setActionHandler('nexttrack', () => next?.()) native.setActionHandler('nexttrack', () => next?.())
// about://flags/#auto-picture-in-picture-for-video-playback // about://flags/#auto-picture-in-picture-for-video-playback
native.setActionHandler('enterpictureinpicture', () => { native.setActionHandler('enterpictureinpicture', () => pip.pip(true))
goto('/app/player')
pip.pip(true)
})
let openSubs: () => Promise<void> let openSubs: () => Promise<void>
function cycleSubtitles (e: KeyboardEvent | MouseEvent) { function cycleSubtitles () {
if (!subtitles) return if (!subtitles) return
const entries = Object.entries(subtitles._tracks.value) const entries = Object.entries(subtitles._tracks.value)
if (!entries.length) return const index = entries.findIndex(([index]) => index === subtitles!.current.value)
const offset = e.shiftKey ? -1 : 1 const nextIndex = (index + 1) % entries.length
const index = entries.findIndex(([index]) => index === subtitles!.current.value) + offset subtitles.selectCaptions(entries[nextIndex]![0])
const [id, info] = entries.at(index) ?? [-1, { meta: { name: 'Off', language: 'Eng' } }]
playAnimation(info.meta.name ?? info.meta.language ?? 'Eng')
subtitles.selectCaptions(id)
} }
function seekBarKey (event: KeyboardEvent) { function seekBarKey (event: KeyboardEvent) {
if (['ArrowLeft', 'ArrowRight'].includes(event.key)) { // left right up down return preventdefault
event.preventDefault() if (['ArrowLeft', 'ArrowRight'].includes(event.key)) event.stopPropagation()
event.stopImmediatePropagation()
event.stopPropagation()
}
if (event.repeat) return
switch (event.key) { switch (event.key) {
case 'ArrowLeft': case 'ArrowLeft':
seek(-Number($settings.playerSeek)) seek(-Number($settings.playerSeek))
@ -462,7 +479,7 @@
let fitWidth = false let fitWidth = false
loadWithDefaults({ loadWithDefaults({
KeyX: { KeyX: {
fn: () => screenshot(video, subtitles), fn: () => screenshot(),
id: 'screenshot_monitor', id: 'screenshot_monitor',
icon: ScreenShare, icon: ScreenShare,
type: 'icon', type: 'icon',
@ -477,7 +494,9 @@
}, },
Space: { Space: {
fn: (e) => { fn: (e) => {
if ('repeat' in e && e.repeat) return e.preventDefault()
e.stopImmediatePropagation()
e.stopPropagation()
playPause() playPause()
}, },
id: 'play_arrow', id: 'play_arrow',
@ -541,15 +560,15 @@
type: 'icon', type: 'icon',
desc: 'Toggle Video Cover' desc: 'Toggle Video Cover'
}, },
// KeyD: { KeyD: {
// fn: () => toggleCast(), fn: () => toggleCast(),
// id: 'cast', id: 'cast',
// icon: Cast, icon: Cast,
// type: 'icon', type: 'icon',
// desc: 'Toggle Cast [broken]' desc: 'Toggle Cast [broken]'
// }, },
KeyC: { KeyC: {
fn: (e) => cycleSubtitles(e), fn: () => cycleSubtitles(),
id: 'subtitles', id: 'subtitles',
icon: Captions, icon: Captions,
type: 'icon', type: 'icon',
@ -587,7 +606,7 @@
e.preventDefault() e.preventDefault()
e.stopImmediatePropagation() e.stopImmediatePropagation()
e.stopPropagation() e.stopPropagation()
changeVolume(0.05) $volume = Math.min(1, $volume + 0.05)
}, },
id: 'volume_up', id: 'volume_up',
icon: Volume2, icon: Volume2,
@ -600,7 +619,7 @@
e.preventDefault() e.preventDefault()
e.stopImmediatePropagation() e.stopImmediatePropagation()
e.stopPropagation() e.stopPropagation()
changeVolume(-0.05) $volume = Math.max(0, $volume - 0.05)
}, },
id: 'volume_down', id: 'volume_down',
icon: Volume1, icon: Volume1,
@ -608,21 +627,21 @@
desc: 'Volume Down' desc: 'Volume Down'
}, },
BracketLeft: { BracketLeft: {
fn: () => { $playbackRate = Math.min(16, Math.max(0.1, $playbackRate - 0.1)) }, fn: () => { playbackRate = video.defaultPlaybackRate -= 0.1 },
id: 'history', id: 'history',
icon: RotateCcw, icon: RotateCcw,
type: 'icon', type: 'icon',
desc: 'Decrease Playback Rate' desc: 'Decrease Playback Rate'
}, },
BracketRight: { BracketRight: {
fn: () => { $playbackRate = Math.min(16, Math.max(0.1, $playbackRate + 0.1)) }, fn: () => { playbackRate = video.defaultPlaybackRate += 0.1 },
id: 'update', id: 'update',
icon: RotateCw, icon: RotateCw,
type: 'icon', type: 'icon',
desc: 'Increase Playback Rate' desc: 'Increase Playback Rate'
}, },
Backslash: { Backslash: {
fn: () => { $playbackRate = 1 }, fn: () => { playbackRate = video.defaultPlaybackRate = 1 },
icon: RefreshCcw, icon: RefreshCcw,
id: 'schedule', id: 'schedule',
type: 'icon', type: 'icon',
@ -644,55 +663,45 @@
} }
}) })
const torrentstats = server.stats
$condition = () => !isMiniplayer $condition = () => !isMiniplayer
let ff = false
function holdToFF (document: HTMLElement, type: 'key' | 'pointer') { function holdToFF (document: HTMLElement, type: 'key' | 'pointer') {
const ctrl = new AbortController() const ctrl = new AbortController()
let timeout = 0 let timeout = 0
let oldPlaybackRate = $playbackRate let oldPlaybackRate = playbackRate
let wasPaused = paused
const startFF = () => { const startFF = () => {
clearTimeout(timeout)
timeout = setTimeout(() => { timeout = setTimeout(() => {
if (fastForwarding) return
wasPaused = paused
paused = false paused = false
fastForwarding = true ff = true
oldPlaybackRate = $playbackRate oldPlaybackRate = playbackRate
$playbackRate = 2 playbackRate = 2
}, 1000) }, 1000)
} }
const endFF = () => { const endFF = () => {
clearTimeout(timeout) clearTimeout(timeout)
if (!fastForwarding) return if (ff) {
fastForwarding = false ff = false
$playbackRate = oldPlaybackRate playbackRate = oldPlaybackRate
paused = wasPaused paused = true
}
document.addEventListener(type + 'down' as 'keydown' | 'pointerdown', event => {
if (isMiniplayer) return
if ('code' in event && (event.code !== 'Space')) return
if ('button' in event && event.button !== 0) return
if ('repeat' in event && event.repeat) return
if ('pointerId' in event) {
document.setPointerCapture(event.pointerId)
} }
}
document.addEventListener(type + 'down' as 'keydown' | 'pointerdown', (event) => {
if (isMiniplayer) return
if ('code' in event && (event.code !== 'Space' || event.repeat)) return
if ('pointerId' in event) document.setPointerCapture(event.pointerId)
startFF() startFF()
}, { signal: ctrl.signal }) }, { signal: ctrl.signal })
document.addEventListener(type + 'up' as 'keyup' | 'pointerup', event => { document.addEventListener(type + 'up' as 'keyup' | 'pointerup', (event) => {
if (isMiniplayer) return if (isMiniplayer) return
if ('code' in event && event.code !== 'Space') return if ('code' in event && event.code !== 'Space') return
if ('pointerId' in event) document.releasePointerCapture(event.pointerId) if ('pointerId' in event) document.releasePointerCapture(event.pointerId)
endFF() endFF()
}, { signal: ctrl.signal }) }, { signal: ctrl.signal })
if (type === 'pointer') {
document.addEventListener('pointercancel', event => {
if ('pointerId' in event) document.releasePointerCapture(event.pointerId)
endFF()
}, { signal: ctrl.signal })
}
return { destroy: () => ctrl.abort() } return { destroy: () => ctrl.abort() }
} }
@ -721,22 +730,15 @@
setAnimeProgress(mediaInfo.media.id, { episode: mediaInfo.episode, currentTime: video.currentTime, safeduration }) setAnimeProgress(mediaInfo.media.id, { episode: mediaInfo.episode, currentTime: video.currentTime, safeduration })
} }
const saveProgressLoop = setInterval(saveAnimeProgress, 10000) const saveProgressLoop = setInterval(saveAnimeProgress, 10000)
onDestroy(() => clearInterval(saveProgressLoop)) onDestroy(() => {
clearInterval(saveProgressLoop)
function handleWheel ({ shiftKey, deltaY }: WheelEvent) { })
const sign = Math.sign(deltaY)
if (shiftKey) {
seek(Number($settings.playerSeek) * sign * -1)
} else {
changeVolume(-0.05 * sign)
}
}
</script> </script>
<svelte:document bind:fullscreenElement bind:visibilityState use:holdToFF={'key'} /> <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} on:keydown={stopAnimation} on:focusin={stopAnimation} on:pointerenter={stopAnimation} on:pointermove={stopAnimation}> <div class='w-full h-full relative content-center bg-black overflow-clip text-left' class:fitWidth class:seeking class:pip={pictureInPictureElement} bind:this={wrapper} on:navigate={resetMove}>
<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} <video class='w-full h-full' preload='auto' 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:createSubtitles
use:createDeband={$settings.playerDeband} use:createDeband={$settings.playerDeband}
use:holdToFF={'pointer'} use:holdToFF={'pointer'}
@ -751,7 +753,7 @@
bind:muted bind:muted
bind:readyState bind:readyState
bind:buffered bind:buffered
bind:playbackRate={$playbackRate} bind:playbackRate
bind:volume={exponentialVolume} bind:volume={exponentialVolume}
bind:this={video} bind:this={video}
on:click={() => isMiniplayer ? goto('/app/player') : playPause()} on:click={() => isMiniplayer ? goto('/app/player') : playPause()}
@ -761,20 +763,33 @@
on:timeupdate={checkSkippableChapters} on:timeupdate={checkSkippableChapters}
on:timeupdate={checkCompletion} on:timeupdate={checkCompletion}
on:loadedmetadata={autoPlay} on:loadedmetadata={autoPlay}
on:pointermove={() => resetMove()} on:pointermove={resetMove}
/> />
{#if !isMiniplayer} {#if !isMiniplayer}
<div class='absolute w-full h-full flex items-center justify-center top-0 pointer-events-none'> <div class='absolute w-full h-full flex items-center justify-center top-0 pointer-events-none'>
<DownloadStats {immersed} /> {#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' 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}
{#if seeking} {#if seeking}
{#await thumbnailer.getThumbnail(seekIndex) then src} {#await thumbnailer.getThumbnail(seekIndex) then src}
{#if src} <img {src} alt='thumbnail' class='w-full h-full bg-black absolute top-0 right-0 object-contain' loading='lazy' decoding='async' class:!object-cover={fitWidth} />
<img {src} alt='thumbnail' class='w-full h-full bg-black absolute top-0 right-0 object-contain' loading='lazy' decoding='async' class:!object-cover={fitWidth} />
{/if}
{/await} {/await}
{/if} {/if}
{#if stats} {#if stats}
<div class='absolute top-10 left-10 border-white/15 border bg-black/60 pointer-events-auto px-3 py-2 rounded'> <div class='absolute top-10 left-10 backdrop-blur-lg border-white/15 border bg-black/20 pointer-events-auto transition-opacity select:opacity-100 px-3 py-2 rounded'>
<button class='absolute right-3 top-1' type='button' use:click={toggleStats}>×</button> <button class='absolute right-3 top-1' type='button' use:click={toggleStats}>×</button>
FPS: {stats.fps}<br /> FPS: {stats.fps}<br />
Presented frames: {stats.presented}<br /> Presented frames: {stats.presented}<br />
@ -787,121 +802,127 @@
Subtitle delay: {subtitleDelay} sec Subtitle delay: {subtitleDelay} sec
</div> </div>
{/if} {/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' <Options {wrapper} bind:openSubs {video} {seekTo} {selectAudio} {selectVideo} {fullscreen} {chapters} {subtitles} {videoFiles} {selectFile} {pip} bind:playbackRate bind:subtitleDelay
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'}' /> class='{$settings.minimalPlayerUI ? 'inline-flex' : 'mobile:inline-flex hidden'} p-3 w-12 h-12 absolute top-4 left-4 backdrop-blur-lg border-white/15 border bg-black/20 pointer-events-auto transition-opacity select:opacity-100 {immersed && 'opacity-0'}' />
{#if fastForwarding} {#if ff}
<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> <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} {/if}
{#if !SUPPORTS.isAndroidTV} <div class='mobile:flex hidden gap-4 absolute items-center transition-opacity select:opacity-100' class:opacity-0={immersed}>
<div class='mobile:flex hidden gap-10 absolute items-center transition-opacity select:opacity-100 z-[0]' class:opacity-0={immersed || seeking}> <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}>
<Button class='p-3 size-10 pointer-events-auto rounded-[50%] bg-black/20' variant='ghost' disabled={!prev} on:click={() => prev?.()}> <SkipBack size='24px' fill='currentColor' strokeWidth='1' />
<SkipBack fill='currentColor' strokeWidth='1' /> </Button>
</Button> <Button class='p-3 w-24 h-24 pointer-events-auto rounded-[50%] backdrop-blur-lg border-white/15 border bg-black/20' variant='ghost' on:click={playPause}>
<Button class='p-2.5 size-12 pointer-events-auto rounded-[50%] bg-black/20' variant='ghost' on:click={playPause}> {#if paused}
{#if paused} <Play size='42px' fill='currentColor' class='p-0.5' />
<Play fill='currentColor' class='p-0.5' /> {:else}
{:else} <Pause size='42px' fill='currentColor' strokeWidth='1' />
<Pause fill='currentColor' strokeWidth='1' /> {/if}
{/if} </Button>
</Button> <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={!next}>
<Button class='p-3 size-10 pointer-events-auto rounded-[50%] bg-black/20' variant='ghost' disabled={!next} on:click={() => next?.()}> <SkipForward size='24px' fill='currentColor' strokeWidth='1' />
<SkipForward fill='currentColor' strokeWidth='1' /> </Button>
</Button> </div>
</div>
<div class='size-full mobile:flex hidden justify-between absolute'>
<div class='h-full w-1/4 pointer-events-auto' on:dblclick|stopPropagation={() => seek(-Number($settings.playerSeek))} use:holdToFF={'pointer'} />
<div class='h-full w-1/4 pointer-events-auto' on:dblclick|stopPropagation={() => seek(Number($settings.playerSeek))} use:holdToFF={'pointer'} />
</div>
{/if}
{#if buffering} {#if buffering}
<div in:fade={{ duration: 200, delay: 500 }} out:fade={{ duration: 200 }}> <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 class='border-[3px] rounded-[50%] w-10 h-10 drop-shadow-lg border-transparent border-t-white animate-spin' />
</div> </div>
{/if} {/if}
<Animations /> {#each animations as { type, id } (id)}
<div class='absolute animate-pulse-once' on:animationend={() => endAnimation(id)}>
{#if type === 'play'}
<Play size='64px' fill='white' />
{:else if type === 'pause'}
<Pause size='64px' fill='white' />
{:else if type === 'seekforw'}
<FastForward size='64px' fill='white' />
{:else if type === 'seekback'}
<Rewind size='64px' fill='white' />
{/if}
</div>
{/each}
</div> </div>
{#if currentSkippable} <div class='absolute w-full bottom-0 flex flex-col gradient px-6 py-3 transition-opacity select:opacity-100' class:opacity-0={immersed}>
<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 justify-between gap-12 items-end'>
<div class='flex flex-col gap-2 text-left cursor-pointer'> <div class='flex flex-col gap-2 text-left cursor-pointer'>
<EpisodesModal portal={wrapper} {mediaInfo} /> <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}'>{mediaInfo.session.title}</a>
<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 hover:underline'>{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 justify-between'>
{#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>
</div> </div>
<div class='flex flex-col gap-2 grow-0 items-end self-end'> <div class='flex flex-col gap-2 grow-0 items-end self-end'>
<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> {#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'>{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 class='ml-auto self-end text-sm leading-none font-light text-nowrap'>{toTS(seeking ? seekPercent * safeduration / 100 : currentTime)} / {toTS(safeduration)}</div>
</div> </div>
</div> </div>
<Seekbar {duration} {currentTime} buffer={buffer / duration * 100} {chapters} bind:seeking bind:seek={seekPercent} on:seeked={finishSeek} on:seeking={startSeek} {thumbnailer} on:keydown={seekBarKey} on:dblclick={fullscreen} /> <Seekbar {duration} {currentTime} buffer={buffer / duration * 100} {chapters} bind:seeking bind:seek={seekPercent} on:seeked={finishSeek} on:seeking={startSeek} {thumbnailer} on:keydown={seekBarKey} on:dblclick={fullscreen} />
<div class='justify-between gap-2 {($settings.minimalPlayerUI || SUPPORTS.isAndroid) ? 'hidden' : 'mobile:hidden flex'}'> <div class='justify-between gap-2 {$settings.minimalPlayerUI ? 'hidden' : 'mobile:hidden flex'}'>
<div class='flex text-white gap-2'> <div class='flex text-white gap-2'>
<Button class='p-3 size-12 relative shrink-0' variant='ghost' on:click={playPause} on:keydown={keywrap(playPause)} id='player-play-pause-button' data-up='#player-seekbar'> <Button class='p-3 w-12 h-12' variant='ghost' on:click={playPause} on:keydown={keywrap(playPause)} id='player-play-pause-button' data-up='#player-seekbar'>
{#if paused} {#if paused}
<div transition:scaleBlurFade class='absolute'> <Play size='24px' fill='currentColor' class='p-0.5' />
<Play size='24px' fill='currentColor' class='p-0.5' />
</div>
{:else} {:else}
<div transition:scaleBlurFade class='absolute'> <Pause size='24px' fill='currentColor' strokeWidth='1' />
<Pause size='24px' fill='currentColor' strokeWidth='1' />
</div>
{/if} {/if}
</Button> </Button>
{#if prev} {#if prev}
<Button class='p-3 size-12' variant='ghost' on:click={prev} on:keydown={keywrap(prev)} id='player-prev-button' data-up='#player-seekbar' data-right='#player-next-button, #player-volume-button, #player-options-button'> <Button class='p-3 w-12 h-12' variant='ghost' on:click={prev} on:keydown={keywrap(prev)} id='player-prev-button' data-up='#player-seekbar' data-right='#player-next-button, #player-volume-button, #player-options-button'>
<SkipBack size='24px' fill='currentColor' strokeWidth='1' /> <SkipBack size='24px' fill='currentColor' strokeWidth='1' />
</Button> </Button>
{/if} {/if}
{#if next} {#if next}
<Button class='p-3 size-12' variant='ghost' on:click={next} on:keydown={keywrap(next)} id='player-next-button' data-up='#player-seekbar' data-right='#player-volume-button, #player-options-button'> <Button class='p-3 w-12 h-12' variant='ghost' on:click={next} on:keydown={keywrap(next)} id='player-next-button' data-up='#player-seekbar' data-right='#player-volume-button, #player-options-button'>
<SkipForward size='24px' fill='currentColor' strokeWidth='1' /> <SkipForward size='24px' fill='currentColor' strokeWidth='1' />
</Button> </Button>
{/if} {/if}
<Volume bind:volume={$volume} bind:muted /> <Volume bind:volume={$volume} bind:muted />
</div> </div>
<div class='flex gap-2'> <div class='flex gap-2'>
{#if $playbackRate !== 1 && $playbackRate} {#if playbackRate !== 1}
<div class='flex justify-center items-center leading-none text-base font-bold px-1 pt-0.5'> <div class='flex justify-center items-center leading-none text-base font-bold px-1 pt-0.5'>
x{$playbackRate?.toFixed(1)} x{playbackRate.toFixed(1)}
</div> </div>
{/if} {/if}
<Options {fullscreen} {wrapper} {seekTo} bind:openSubs {video} {selectAudio} {selectVideo} {chapters} {subtitles} {videoFiles} {selectFile} {pip} bind:playbackRate={$playbackRate} bind:subtitleDelay id='player-options-button' /> <Options {fullscreen} {wrapper} {seekTo} bind:openSubs {video} {selectAudio} {selectVideo} {chapters} {subtitles} {videoFiles} {selectFile} {pip} bind:playbackRate bind:subtitleDelay id='player-options-button' />
{#if subtitles} {#if subtitles}
<Button class='p-3 size-12' variant='ghost' on:click={openSubs} on:keydown={keywrap(openSubs)} data-up='#player-seekbar'> <Button class='p-3 w-12 h-12' variant='ghost' on:click={openSubs} on:keydown={keywrap(openSubs)} data-up='#player-seekbar'>
<Subtitles size='24px' fill='currentColor' strokeWidth='0' /> <Subtitles size='24px' fill='currentColor' strokeWidth='0' />
</Button> </Button>
{/if} {/if}
<Button class='p-3 size-12 relative shrink-0' variant='ghost' on:click={() => pip.pip()} on:keydown={keywrap(() => pip.pip())} data-up='#player-seekbar'> <Button class='p-3 w-12 h-12' variant='ghost' on:click={() => pip.pip()} on:keydown={keywrap(() => pip.pip())} data-up='#player-seekbar'>
{#if pictureInPictureElement} {#if pictureInPictureElement}
<div transition:scaleBlurFade class='absolute'> <PictureInPictureExit size='24px' strokeWidth='2' />
<PictureInPictureExit size='24px' strokeWidth='2' />
</div>
{:else} {:else}
<div transition:scaleBlurFade class='absolute'> <PictureInPictureOff size='24px' strokeWidth='2' />
<PictureInPictureOff size='24px' strokeWidth='2' />
</div>
{/if} {/if}
</Button> </Button>
<!-- {#if false} {#if false}
<Button class='p-3 size-12' variant='ghost' on:click={toggleCast} on:keydown={keywrap(toggleCast)} data-up='#player-seekbar'> <Button class='p-3 w-12 h-12' variant='ghost' on:click={toggleCast} on:keydown={keywrap(toggleCast)} data-up='#player-seekbar'>
{#if cast} {#if cast}
<Cast size='24px' fill='white' strokeWidth='2' /> <Cast size='24px' fill='white' strokeWidth='2' />
{:else} {:else}
<Cast size='24px' strokeWidth='2' /> <Cast size='24px' strokeWidth='2' />
{/if} {/if}
</Button> </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'> <Button class='p-3 w-12 h-12' variant='ghost' on:click={fullscreen} on:keydown={keywrap(fullscreen)} data-up='#player-seekbar'>
{#if fullscreenElement} {#if fullscreenElement}
<div transition:scaleBlurFade class='absolute'> <Minimize size='24px' class='p-0.5' strokeWidth='2.5' />
<Minimize size='24px' class='p-0.5' strokeWidth='2.5' />
</div>
{:else} {:else}
<div transition:scaleBlurFade class='absolute'> <Maximize size='24px' class='p-0.5' strokeWidth='2.5' />
<Maximize size='24px' class='p-0.5' strokeWidth='2.5' />
</div>
{/if} {/if}
</Button> </Button>
</div> </div>
@ -909,15 +930,11 @@
</div> </div>
{:else} {:else}
<div class='absolute w-full left-0 bottom-0 flex justify-center'> <div class='absolute w-full left-0 bottom-0 flex justify-center'>
<Button variant='ghost' class='drop-shadow-[0_0_7px_#000] mb-1 relative' size='icon' on:pointerdown={e => { e.stopPropagation(); playPause() }}> <Button variant='ghost' class='drop-shadow-[0_0_7px_#000] mb-1' size='icon' on:pointerdown={e => { e.stopPropagation(); playPause() }}>
{#if paused} {#if paused}
<div transition:scaleBlurFade class='absolute'> <Play size={iconSizes.lg} fill='currentColor' class='px-0.5' />
<Play size={iconSizes.lg} fill='currentColor' class='px-0.5' />
</div>
{:else} {:else}
<div transition:scaleBlurFade class='absolute'> <Pause size={iconSizes.lg} fill='currentColor' strokeWidth='1' />
<Pause size={iconSizes.lg} fill='currentColor' strokeWidth='1' />
</div>
{/if} {/if}
</Button> </Button>
</div> </div>
@ -929,7 +946,7 @@
object-fit: cover !important; object-fit: cover !important;
} }
.seeking :global(.deband-canvas), .pip :global(.deband-canvas), .seeking :global(.JASSUB) { .seeking :global(.deband-canvas), .pip :global(.deband-canvas){
display: none; display: none;
} }
@ -940,4 +957,19 @@
.gradient-to-bottom { .gradient-to-bottom {
background: linear-gradient(to bottom, oklab(0 0 0 / 0.85) 0%, oklab(0 0 0 / 0.7) 35%, oklab(0 0 0 / 0) 100%); background: linear-gradient(to bottom, oklab(0 0 0 / 0.85) 0%, oklab(0 0 0 / 0.7) 35%, oklab(0 0 0 / 0) 100%);
} }
.animate-pulse-once {
animation: pulse-once .4s linear;
}
@keyframes pulse-once {
0% {
opacity: 1;
scale: 1;
}
100% {
opacity: 0;
scale: 1.2;
}
}
</style> </style>

View file

@ -1,11 +1,9 @@
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
import anitomyscript from 'anitomyscript' import anitomyscript from 'anitomyscript'
// import Debug from 'debug'
import type { TorrentFile } from '$lib/../app'
import type { MediaEdgeFrag } from '$lib/modules/anilist/queries' import type { MediaEdgeFrag } from '$lib/modules/anilist/queries'
import type { AnitomyResult } from 'anitomyscript' import type { AnitomyResult } from 'anitomyscript'
import type { ResultOf } from 'gql.tada' import type { ResultOf } from 'gql.tada'
import type { TorrentFile } from 'native'
import { client, episodes, type Media } from '$lib/modules/anilist' import { client, episodes, type Media } from '$lib/modules/anilist'
import { videoRx } from '$lib/utils' import { videoRx } from '$lib/utils'
@ -17,15 +15,13 @@ async function toResolvedFile (file: TorrentFile, media: Media): Promise<Resolve
return { return {
...file, ...file,
metadata: { metadata: {
episode: Number(parseObject.episode_number[0]), episode: parseObject.episode_number[0] ?? undefined,
parseObject, parseObject,
media, media,
failed: false failed: false
} }
} }
} }
// const debug = Debug('ui:resolver')
// Debug.enable('ui:resolver')
export async function resolveFilesPoorly (promise: Promise<{media: Media, id: string, episode: number, files: TorrentFile[]}| null>) { export async function resolveFilesPoorly (promise: Promise<{media: Media, id: string, episode: number, files: TorrentFile[]}| null>) {
const list = await promise const list = await promise
@ -164,7 +160,7 @@ const AnimeResolver = new class AnimeResolver {
getCacheKeyForTitle (obj: AnitomyResult): string { getCacheKeyForTitle (obj: AnitomyResult): string {
let key = obj.anime_title[0] ?? '' let key = obj.anime_title[0] ?? ''
if (obj.anime_year.length) key += obj.anime_year[0] if (obj.anime_year) key += obj.anime_year[0]
return key return key
} }
@ -234,6 +230,8 @@ const AnimeResolver = new class AnimeResolver {
if (!fileName.length) return [] if (!fileName.length) return []
const parseObjs = await anitomyscript(fileName) const parseObjs = await anitomyscript(fileName)
const TYPE_EXCLUSIONS = ['ED', 'ENDING', 'NCED', 'NCOP', 'OP', 'OPENING', 'PREVIEW', 'PV']
const uniq: Record<string, AnitomyResult> = {} const uniq: Record<string, AnitomyResult> = {}
for (const obj of parseObjs) { for (const obj of parseObjs) {
const key = this.getCacheKeyForTitle(obj) const key = this.getCacheKeyForTitle(obj)
@ -271,7 +269,7 @@ const AnimeResolver = new class AnimeResolver {
// debug(`Root ${root?.id}:${root?.title.userPreferred}`) // debug(`Root ${root?.id}:${root?.title.userPreferred}`)
// if highest value is bigger than episode count or latest streamed episode +1 for safety, parseint to math.floor a number like 12.5 - specials - in 1 go // if highest value is bigger than episode count or latest streamed episode +1 for safety, parseint to math.floor a number like 12.5 - specials - in 1 go
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const result = await this.resolveSeason({ media: root || media, episode: Number(parseObj.episode_number[1]!), increment: !parseObj.anime_season[0] ? null : true }) const result = await this.resolveSeason({ media: root || media, episode: Number(parseObj.episode_number[1]!), increment: !parseObj.anime_season[0] ? null : true })
// debug(`Found rootMedia for ${parseObj.anime_title}: ${result.rootMedia.id}:${result.rootMedia.title.userPreferred} from ${media.id}:${media.title.userPreferred}`) // debug(`Found rootMedia for ${parseObj.anime_title}: ${result.rootMedia.id}:${result.rootMedia.title.userPreferred} from ${media.id}:${media.title.userPreferred}`)
media = result.rootMedia media = result.rootMedia
@ -288,18 +286,18 @@ const AnimeResolver = new class AnimeResolver {
if (maxep && parseInt(parseObj.episode_number[0]!) > maxep) { if (maxep && parseInt(parseObj.episode_number[0]!) > maxep) {
// see big comment above // see big comment above
const prequel = !parseObj.anime_season[0] && (this.findEdge(media, 'PREQUEL')?.node ?? ((media.format === 'OVA' || media.format === 'ONA') && this.findEdge(media, 'PARENT')?.node)) const prequel = !parseObj.anime_season[0] && (this.findEdge(media, 'PREQUEL')?.node ?? ((media.format === 'OVA' || media.format === 'ONA') && this.findEdge(media, 'PARENT')?.node))
// debug(`Prequel ${prequel.id}:${prequel.title?.userPreferred}`) // debug(`Prequel ${prequel?.id}:${prequel?.title.userPreferred}`)
const root = prequel && (await this.resolveSeason({ media: await this.getAnimeById(prequel.id), force: true })).media const root = prequel && (await this.resolveSeason({ media: await this.getAnimeById(prequel.id), force: true })).media
// debug(`Root ${root.id}:${root.title?.userPreferred}`) // debug(`Root ${root?.id}:${root?.title.userPreferred}`)
// value bigger than episode count // value bigger than episode count
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const result = await this.resolveSeason({ media: root || media, episode: parseInt(parseObj.episode_number[0]!), increment: !parseObj.anime_season[0] ? null : true }) const result = await this.resolveSeason({ media: root || media, episode: parseInt(parseObj.episode_number[0]!), increment: !parseObj.anime_season[0] ? null : true })
// debug(`Found rootMedia for ${parseObj.anime_title[0]}: ${result.rootMedia.id}:${result.rootMedia.title?.userPreferred} from ${media.id}:${media.title?.userPreferred}`) // debug(`Found rootMedia for ${parseObj.anime_title}: ${result.rootMedia.id}:${result.rootMedia.title.userPreferred} from ${media.id}:${media.title.userPreferred}`)
media = result.rootMedia media = result.rootMedia
episode = result.episode episode = result.episode
failed = !!result.failed failed = !!result.failed
// if (failed) debug(`Failed to resolve ${parseObj.anime_title[0]} ${parseObj.episode_number[0]} ${media.title?.userPreferred}`) // if (failed) debug(`Failed to resolve ${parseObj.anime_title} ${parseObj.episode_number} ${media.title.userPreferred}`)
} else { } else {
// cant find ep count or episode seems fine // cant find ep count or episode seems fine
episode = Number(parseObj.episode_number[0]) episode = Number(parseObj.episode_number[0])
@ -308,7 +306,7 @@ const AnimeResolver = new class AnimeResolver {
} }
// debug(`Resolved ${parseObj.anime_title} ${parseObj.episode_number} ${episode} ${media.id}:${media.title.userPreferred}`) // debug(`Resolved ${parseObj.anime_title} ${parseObj.episode_number} ${episode} ${media.id}:${media.title.userPreferred}`)
fileAnimes.push({ fileAnimes.push({
episode: episode ?? Number(parseObj.episode_number[0]), episode: episode ?? parseObj.episode_number[0],
parseObject: parseObj, parseObject: parseObj,
media, media,
failed failed
@ -332,7 +330,7 @@ const AnimeResolver = new class AnimeResolver {
// note: this doesnt cover anime which uses partially relative and partially absolute episode number, BUT IT COULD! // note: this doesnt cover anime which uses partially relative and partially absolute episode number, BUT IT COULD!
async resolveSeason (opts: {media?: Media, episode?: number, increment?: boolean | null, offset?: number, rootMedia?: Media, force?: boolean}): Promise<{ media: Media, episode: number, offset: number, increment: boolean, rootMedia: Media, failed?: boolean }> { async resolveSeason (opts: {media?: Media, episode?: number, increment?: boolean | null, offset?: number, rootMedia?: Media, force?: boolean}): Promise<{ media: Media, episode: number, offset: number, increment: boolean, rootMedia: Media, failed?: boolean }> {
// media, episode, increment, offset, force // media, episode, increment, offset, force
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
if (!opts.media || !(opts.episode || opts.force)) throw new Error('No episode or media for season resolve!') if (!opts.media || !(opts.episode || opts.force)) throw new Error('No episode or media for season resolve!')
let { media, episode = 1, increment, offset = 0, rootMedia = opts.media, force } = opts let { media, episode = 1, increment, offset = 0, rootMedia = opts.media, force } = opts
@ -340,10 +338,10 @@ const AnimeResolver = new class AnimeResolver {
const rootHighest = episodes(rootMedia) ?? 1 const rootHighest = episodes(rootMedia) ?? 1
const prequel = !increment && this.findEdge(media, 'PREQUEL')?.node const prequel = !increment && this.findEdge(media, 'PREQUEL')?.node
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const sequel = !prequel && (increment || increment == null) && this.findEdge(media, 'SEQUEL')?.node const sequel = !prequel && (increment || increment == null) && this.findEdge(media, 'SEQUEL')?.node
const edge = prequel || sequel const edge = prequel ?? sequel
increment = increment || !prequel increment = increment ?? !prequel
if (!edge) { if (!edge) {
const obj = { media, episode: episode - offset, offset, increment, rootMedia, failed: true } const obj = { media, episode: episode - offset, offset, increment, rootMedia, failed: true }

View file

@ -33,13 +33,11 @@
import type Thumbnailer from './thumbnailer' import type Thumbnailer from './thumbnailer'
import { SUPPORTS } from '$lib/modules/settings'
import { toTS } from '$lib/utils' import { toTS } from '$lib/utils'
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
seeking: null seeking: null
seeked: null seeked: null
dblclick: MouseEvent
}>() }>()
// state // state
export let chapters: Chapter[] = [] export let chapters: Chapter[] = []
@ -127,15 +125,6 @@
export let thumbnailer: Thumbnailer export let thumbnailer: Thumbnailer
$: seekIndex = Math.max(0, Math.floor(seekTime / thumbnailer.interval)) $: seekIndex = Math.max(0, Math.floor(seekTime / thumbnailer.interval))
let lastDbl = 0
function customDoubleClick (e: MouseEvent) {
const now = Date.now()
if (now - lastDbl < (SUPPORTS.isAndroid ? 500 : 200)) {
dispatch('dblclick', e)
}
lastDbl = now
}
</script> </script>
<div class='w-full flex cursor-pointer relative group/seekbar touch-none !transform-none' class:!cursor-grab={seeking} <div class='w-full flex cursor-pointer relative group/seekbar touch-none !transform-none' class:!cursor-grab={seeking}
@ -143,15 +132,13 @@
id='player-seekbar' id='player-seekbar'
data-down='#player-play-pause-button' data-down='#player-play-pause-button'
data-up='#episode-list-button' data-up='#episode-list-button'
on:dblclick
on:keydown on:keydown
on:click={customDoubleClick}
bind:this={seekbar} bind:this={seekbar}
on:pointerdown={startSeeking} on:pointerdown={startSeeking}
on:pointerup={endSeeking} on:pointerup={endSeeking}
on:pointermove={calculatePositionProgress} on:pointermove={calculatePositionProgress}
on:pointerleave={endHover} on:pointerleave={endHover}>
on:pointercancel={endSeeking}
on:pointercancel={endHover}>
{#each segments as chapter, i (chapter)} {#each segments as chapter, i (chapter)}
{@const { size, scale, offset } = chapter} {@const { size, scale, offset } = chapter}
{@const active = seek && seek > offset && seek < offset + size} {@const active = seek && seek > offset && seek < offset + size}
@ -173,18 +160,11 @@
{/if} {/if}
<div>{toTS(seekTime)}</div> <div>{toTS(seekTime)}</div>
{:then src} {:then src}
{#if src} <img {src} alt='thumbnail' class='w-40 rounded-lg min-h-10' loading='lazy' decoding='async' />
<img {src} alt='thumbnail' class='w-40 rounded-lg min-h-10' loading='lazy' decoding='async' /> {#if title}
{#if title} <div class='max-w-24 text-ellipsis overflow-clip absolute top-0 bg-white py-1 px-2 rounded-b-lg'>{title}</div>
<div class='max-w-24 text-ellipsis overflow-clip absolute top-0 bg-white py-1 px-2 rounded-b-lg'>{title}</div>
{/if}
<div class='absolute bottom-0 bg-white py-1 px-2 rounded-t-lg'>{toTS(seekTime)}</div>
{:else}
{#if title}
<div class='max-w-24 text-ellipsis overflow-clip'>{title}</div>
{/if}
<div>{toTS(seekTime)}</div>
{/if} {/if}
<div class='absolute bottom-0 bg-white py-1 px-2 rounded-t-lg'>{toTS(seekTime)}</div>
{/await} {/await}
</div> </div>
</div> </div>

View file

@ -3,7 +3,7 @@ import { writable } from 'simple-store-svelte'
import { get } from 'svelte/store' import { get } from 'svelte/store'
import type { ResolvedFile } from './resolver' import type { ResolvedFile } from './resolver'
import type { TorrentFile } from 'native' import type { TorrentFile } from '../../../../app'
import native from '$lib/modules/native' import native from '$lib/modules/native'
import { type defaults, settings, SUPPORTS } from '$lib/modules/settings' import { type defaults, settings, SUPPORTS } from '$lib/modules/settings'
@ -138,9 +138,7 @@ export default class Subtitles {
this.initSubtitleRenderer() this.initSubtitleRenderer()
const tracks = Object.entries(this._tracks.value) const tracks = Object.entries(this._tracks.value)
if (tracks.length) { if (tracks.length) {
if (!this.set.subtitleLanguage) return // if lang set to none dont autoselect
if (tracks.length === 1) { if (tracks.length === 1) {
this.selectCaptions(tracks[0]![0]) this.selectCaptions(tracks[0]![0])
} else { } else {
@ -348,9 +346,7 @@ export default class Subtitles {
}) })
} }
// replace all html special tags with normal ones // replace all html special tags with normal ones
Text = Text.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&nbsp;/g, '\\h').replace(/\r?\n/g, '\\N') Text = Text.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&nbsp;/g, '\\h').replace(/1?\n/g, '\\N')
} else {
Text = Text.replace(/\r?\n/g, '')
} }
return { return {
Start: subtitle.time, Start: subtitle.time,

View file

@ -16,7 +16,7 @@ export default class Thumbnailer {
src src
constructor (src?: string) { constructor (src?: string) {
this.video.preload = 'metadata' this.video.preload = 'none'
this.video.playbackRate = 0 this.video.playbackRate = 0
this.video.muted = true this.video.muted = true
this.video.crossOrigin = 'anonymous' this.video.crossOrigin = 'anonymous'
@ -38,31 +38,19 @@ export default class Thumbnailer {
}, { signal: this.timeUpdateCtrl.signal }) }, { signal: this.timeUpdateCtrl.signal })
} }
_nextTask () {
this.currentTask = undefined
if (this.nextTask) {
this.currentTask = this.nextTask
this.nextTask = undefined
this.currentTask.run()
}
}
_createTask (index: number): RenderItem { _createTask (index: number): RenderItem {
const { promise, resolve } = Promise.withResolvers<string | undefined>() const { promise, resolve } = Promise.withResolvers<string | undefined>()
const run = () => { const run = () => {
const vfc = this.video.requestVideoFrameCallback(async (_now, meta) => { this.video.requestVideoFrameCallback(async (_now, meta) => {
clearTimeout(timeout)
resolve(await this._paintThumbnail(this.video, index, meta.width, meta.height)) resolve(await this._paintThumbnail(this.video, index, meta.width, meta.height))
this._nextTask() this.currentTask = undefined
if (this.nextTask) {
this.currentTask = this.nextTask
this.nextTask = undefined
this.currentTask.run()
}
}) })
const timeout = setTimeout(() => {
this.video.cancelVideoFrameCallback(vfc)
// this cancels the current load request, in case something bad is happening like long loads or mass seeking
this.video.load()
resolve(undefined)
this._nextTask()
}, 3000)
this.video.currentTime = index * this.interval this.video.currentTime = index * this.interval
} }

View file

@ -1,10 +1,6 @@
import { toast } from 'svelte-sonner'
import type { Media } from '$lib/modules/anilist' import type { Media } from '$lib/modules/anilist'
import type { ResolvedFile } from './resolver' import type { ResolvedFile } from './resolver'
import type Subtitles from './subtitles'
import type { Track } from '../../../../app' import type { Track } from '../../../../app'
import type { SessionMetadata } from 'native'
export interface Chapter { export interface Chapter {
start: number start: number
@ -12,6 +8,12 @@ export interface Chapter {
text: string text: string
} }
export interface SessionMetadata {
title: string
description: string
image: string
}
export interface MediaInfo { export interface MediaInfo {
file: ResolvedFile file: ResolvedFile
media: Media media: Media
@ -177,38 +179,3 @@ export function normalizeSubs (_tracks?: Record<number | string, { meta: { langu
return acc 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)
}

View file

@ -60,8 +60,7 @@
bind:this={seekbar} bind:this={seekbar}
on:pointerdown={startSeeking} on:pointerdown={startSeeking}
on:pointerup={endSeeking} on:pointerup={endSeeking}
on:pointermove={calculatePositionProgress} on:pointermove={calculatePositionProgress}>
on:pointercancel={endSeeking}>
<div class='w-full h-0.5 overflow-clip rounded-[2px] relative transform-gpu transition-transform -translate-x-full group-select/volume:translate-x-0'> <div class='w-full h-0.5 overflow-clip rounded-[2px] relative transform-gpu transition-transform -translate-x-full group-select/volume:translate-x-0'>
<div class='w-full bg-[rgba(217,217,217,0.4)] h-full' /> <div class='w-full bg-[rgba(217,217,217,0.4)] h-full' />
<div class='w-full bg-white h-full absolute top-0 left-0 transform-gpu' style:--tw-translate-x='{volume * 100 - 100}%' /> <div class='w-full bg-white h-full absolute top-0 left-0 transform-gpu' style:--tw-translate-x='{volume * 100 - 100}%' />

View file

@ -29,17 +29,9 @@
let bottom = '0px' let bottom = '0px'
let right = '100%' let right = '100%'
let firstX = 0
let firstY = 0
function calculatePosition (e: PointerEvent) { function calculatePosition (e: PointerEvent) {
if (!isMiniplayer) return if (!isMiniplayer) return
if (firstX === 0) { dragging = true
firstX = e.offsetX
firstY = e.offsetY
} else if (!dragging && Math.abs(firstX - e.offsetX) > 3 && Math.abs(firstY - e.offsetY) > 3) {
dragging = true
}
bottom = e.offsetY - initialY + 'px' bottom = e.offsetY - initialY + 'px'
right = e.offsetX - initialX + 'px' right = e.offsetX - initialX + 'px'
} }
@ -47,8 +39,6 @@
function endHover () { function endHover () {
if (!isMiniplayer) return if (!isMiniplayer) return
dragging = false dragging = false
firstX = 0
firstY = 0
} }
let initialX = 0 let initialX = 0
@ -73,18 +63,17 @@
} }
</script> </script>
<div class={cn('w-full h-full', isMiniplayer && 'z-[49] absolute top-0 left-0 pointer-events-none cursor-grabbing touch-none')} <div class={cn('w-full h-full', isMiniplayer && 'z-[49] absolute top-0 left-0 pointer-events-none cursor-grabbing')}
bind:this={wrapper} bind:this={wrapper}
on:pointerdown={startDragging} on:pointerdown={startDragging}
on:pointerup|self={endDragging} on:pointerup|self={endDragging}
on:pointermove|self={calculatePosition} on:pointermove|self={calculatePosition}
on:pointerleave|self={endHover} on:pointerleave|self={endHover}>
on:pointercancel|self={endHover}>
<div class={cn( <div class={cn(
'pointer-events-auto w-full', 'pointer-events-auto w-full',
isMiniplayer ? 'max-w-80 absolute bottom-0 right-0 rounded-lg overflow-clip miniplayer transition-transform duration-[500ms] ease-[cubic-bezier(0.3,1.5,0.8,1)]' : 'h-full w-full', isMiniplayer ? 'max-w-80 absolute bottom-0 right-0 rounded-lg overflow-clip miniplayer transition-transform duration-[500ms] ease-[cubic-bezier(0.3,1.5,0.8,1)]' : 'h-full w-full',
dragging && isMiniplayer && 'dragging', dragging && isMiniplayer && 'dragging',
!$isPlaying && 'paused select:paused-show' !$isPlaying && 'paused hover:paused-show'
)} style:--top={bottom} style:--left={right}> )} style:--top={bottom} style:--left={right}>
{#if $active} {#if $active}
{#await $active} {#await $active}

View file

@ -5,7 +5,6 @@
import Shadow from '$lib/components/Shadow.svelte' import Shadow from '$lib/components/Shadow.svelte'
import * as Avatar from '$lib/components/ui/avatar' import * as Avatar from '$lib/components/ui/avatar'
import { Load } from '$lib/components/ui/img'
import * as Popover from '$lib/components/ui/popover' import * as Popover from '$lib/components/ui/popover'
import { cn, since } from '$lib/utils' import { cn, since } from '$lib/utils'
@ -25,7 +24,7 @@
</script> </script>
<div class='flex'> <div class='flex'>
<Popover.Root disableFocusTrap> <Popover.Root>
<Popover.Trigger class='flex group/profile'> <Popover.Trigger class='flex group/profile'>
<Avatar.Root class={cn('group-focus-visible/profile:border border-white', className)}> <Avatar.Root class={cn('group-focus-visible/profile:border border-white', className)}>
<Avatar.Image src={avatar} alt={name} /> <Avatar.Image src={avatar} alt={name} />
@ -36,7 +35,7 @@
<div class='w-[300px] rounded core-bg gap-2 flex flex-col pb-2'> <div class='w-[300px] rounded core-bg gap-2 flex flex-col pb-2'>
<div class={cn('w-full h-[105px] relative p-3 flex items-end', !banner && 'bg-white/10')}> <div class={cn('w-full h-[105px] relative p-3 flex items-end', !banner && 'bg-white/10')}>
{#if banner} {#if banner}
<Load src={banner} alt='banner' class='absolute top-0 left-0 w-full h-full rounded-t opacity-50 pointer-events-none object-cover' /> <img src={banner} alt='banner' class='absolute top-0 left-0 w-full h-full rounded-t opacity-50 pointer-events-none object-cover' />
{/if} {/if}
<Avatar.Root class='inline-block size-20'> <Avatar.Root class='inline-block size-20'>
<Avatar.Image src={avatar} alt={name} /> <Avatar.Image src={avatar} alt={name} />
@ -56,7 +55,7 @@
{#if bubble && bubble !== 'Donator'} {#if bubble && bubble !== 'Donator'}
<div class='-left-5 -top-11 absolute text-sm'> <div class='-left-5 -top-11 absolute text-sm'>
<div class='px-4 py-2 rounded-2xl bg-mix bubbles relative leading-tight'> <div class='px-4 py-2 rounded-2xl bg-mix bubbles relative leading-tight'>
<span class='text-contrast-filter'> <span class='text-contrast'>
{bubble} {bubble}
</span> </span>
</div> </div>

View file

@ -1,144 +0,0 @@
<script lang='ts'>
import Dagre from '@dagrejs/dagre'
import { SvelteFlow, Background, useSvelteFlow, type Node, type Edge, Controls, ControlButton, type NodeTypes } from '@xyflow/svelte'
import '@xyflow/svelte/dist/style.css'
import Maximize2 from 'lucide-svelte/icons/maximize-2'
import Minimize2 from 'lucide-svelte/icons/minimize-2'
import { writable } from 'simple-store-svelte'
import { onMount } from 'svelte'
import TextNode from './TextNode.svelte'
import type { Media } from '$lib/modules/anilist'
import { client } from '$lib/modules/anilist'
export let media: Media
export let expanded: boolean
// WARN: this is non-reactive, only set on init, but it shouldn't matter as the anime page can only navigate to entries already visible in the graph
// this is done to make sure the graph doesn't reset when navigating to a relation
const nodesStore = client.relationsTree(media)
const nodes = writable<Node[]>([])
const edges = writable<Edge[]>([])
$: $nodes = [...$nodesStore.nodes.values()]
$: $edges = [...$nodesStore.edges.values()]
const { fitView } = useSvelteFlow()
$: media && onLayout()
$: $nodesStore && fitAndLayout()
function getLayoutedElements (nodes: Node[], edges: Edge[]) {
const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}))
g.setGraph({ rankdir: 'LR', edgesep: 50, nodesep: 50, ranksep: 120, ranker: 'tight-tree' })
// TODO: switch between longest-path and tight-tree based on number of nodes?
edges.forEach((edge) => g.setEdge(edge.source, edge.target))
nodes.forEach((node) =>
g.setNode(node.id, {
...node,
width: node.measured?.width ?? 120,
height: node.measured?.height ?? 32
})
)
Dagre.layout(g)
return {
nodes: nodes.map((node) => {
const position = g.node(node.id)
// We are shifting the dagre node position (anchor=center center) to the top left
// so it matches the Svelte Flow node anchor point (top left).
const x = position.x - (node.measured?.width ?? 0) / 2
const y = position.y - (node.measured?.height ?? 0) / 2
return {
...node,
data: {
...node.data,
current: node.data.id === media.id
},
type: 'customText',
position: { x, y },
sourcePosition: 'right',
targetPosition: 'left'
}
}) as Node[],
edges: edges.map(e => ({
...e,
style: (e.data?.ids as number[]).includes(media.id) ? '--xy-edge-stroke: var(--custom)' : '',
labelStyle: (e.data?.ids as number[]).includes(media.id) ? '--xy-edge-label-color: var(--custom)' : ''
}))
}
}
function onLayout () {
const { nodes, edges } = getLayoutedElements($nodes, $edges)
$nodes = nodes
$edges = edges
}
function fitAndLayout () {
onLayout()
fitView()
}
// turbo hacky but cba
let frameId: number
function loopFitView () {
cancelAnimationFrame(frameId)
fitView()
frameId = requestAnimationFrame(loopFitView)
}
let timeoutId: ReturnType<typeof setTimeout>
function expand () {
expanded = !expanded
loopFitView()
clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
cancelAnimationFrame(frameId)
if (expanded) document.querySelector('.svelte-flow')?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' })
fitView()
}, 150)
}
onMount(() => {
fitAndLayout()
setTimeout(fitAndLayout)
})
const nodeTypes = {
customText: TextNode as NodeTypes['customText']
}
</script>
<SvelteFlow {nodes} {edges} colorMode='dark'
proOptions={{ hideAttribution: true }}
nodesConnectable={false}
nodesDraggable={false}
panOnScroll={false}
zoomOnScroll={expanded}
preventScrolling={expanded}
zoomActivationKey={['Control', 'Meta', 'Ctrl', 'Shift', 'ShiftLeft']}
onlyRenderVisibleElements={true}
minZoom={0}
maxZoom={1.2}
{nodeTypes}
elementsSelectable={false}>
<Background bgColor='black' />
<Controls showLock={false} orientation='horizontal'>
<ControlButton on:click={expand}>
{#if expanded}
<Minimize2 />
{:else}
<Maximize2 />
{/if}
</ControlButton>
</Controls>
</SvelteFlow>

View file

@ -1,47 +0,0 @@
<script lang='ts'>
import { Handle, Position } from '@xyflow/svelte'
import { format, status, type RelationTreeMedia } from '$lib/modules/anilist'
import { cn } from '$lib/utils'
export let data: { media: RelationTreeMedia, id: number, current?: boolean }
export let id: string
export let targetPosition: Position = Position.Left
export let sourcePosition: Position = Position.Right
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
$$restProps
</script>
<a class={cn('node w-[150px] text-xs text-center border bg-[#111] rounded-sm cursor-pointer block font-semibold transition-colors overflow-clip', data.current ? 'border-custom text-custom' : 'border-[#111] text-white')} href='/app/anime/{data.id}'>
<div class='relative'>
<Handle type='target' position={targetPosition} />
{#if data.media}
{@const media = data.media}
{@const episodes = media.episodes}
<div class='font-bold p-2.5 pb-2 bg-[#1e1e1e]'>
{media.title?.userPreferred ?? 'TBA'}
</div>
<div class='flex justify-between text-[8.5px] leading-none px-2 py-1.5'>
<div>
{format(media)}
</div>
<div>
{#if episodes}
{episodes} Episodes
{:else}
{status(media)}
{/if}
</div>
</div>
{/if}
<Handle type='source' position={sourcePosition} />
</div>
</a>
<style>
.node {
--xy-handle-background-color: none;
--xy-handle-border-color: none;
}
</style>

View file

@ -1,2 +0,0 @@
export { default as Relations } from './Relations.svelte'
export { default as TextNode } from './TextNode.svelte'

View file

@ -2,7 +2,6 @@
import { Dialog as SheetPrimitive } from 'bits-ui' import { Dialog as SheetPrimitive } from 'bits-ui'
import { fade } from 'svelte/transition' import { fade } from 'svelte/transition'
import { SUPPORTS } from '$lib/modules/settings'
import { cn } from '$lib/utils.js' import { cn } from '$lib/utils.js'
type $$Props = SheetPrimitive.OverlayProps type $$Props = SheetPrimitive.OverlayProps
@ -18,6 +17,6 @@
<SheetPrimitive.Overlay <SheetPrimitive.Overlay
{transition} {transition}
{transitionConfig} {transitionConfig}
class={cn('custom-bg absolute inset-0 z-50', !SUPPORTS.isUnderPowered && 'backdrop-blur-sm', className)} class={cn('custom-bg absolute inset-0 z-50 backdrop-blur-sm', className)}
{...$$restProps} {...$$restProps}
/> />

View file

@ -28,9 +28,11 @@
$: isActive = href && matchPath(href, $page) $: isActive = href && matchPath(href, $page)
</script> </script>
<Button variant='ghost' {href} class={cn(className, 'px-2 w-10 relative md:pl-4 md:w-12 md:rounded-l-none group/sidebar transition-colors duration-300', isActive ? '!text-black' : 'text-white')} {...$$restProps}> <Button variant='ghost' {href} class={cn(className, 'px-2 w-10 relative md:pl-4 md:w-12 md:rounded-l-none')} {...$$restProps}>
{#if isActive} {#if isActive}
<div class='bg-white absolute inset-0 rounded-md md:rounded-l-none group-select/sidebar:bg-primary/70 -z-[1]' in:send={{ key }} out:receive={{ key }} /> <div class='bg-white absolute inset-0 rounded-md md:rounded-l-none' in:send={{ key }} out:receive={{ key }} />
{/if} {/if}
<slot /> <div class='relative text-white transition-colors duration-300' class:!text-black={isActive}>
<slot />
</div>
</Button> </Button>

View file

@ -4,40 +4,20 @@
import { Button } from '../button' import { Button } from '../button'
import { onNavigate } from '$app/navigation'
import { breakpoints } from '$lib/utils' import { breakpoints } from '$lib/utils'
let open = false // 152 x 140 let open = false // 152 x 140
onNavigate(() => {
open = false
})
let container: HTMLDivElement | undefined
function outsideclick (node: HTMLDivElement) {
const ctrl = new AbortController()
node.addEventListener('click', e => {
if (!container || container.contains(e.target as Node)) return
open = false
}, { signal: ctrl.signal })
return { destroy: () => ctrl.abort() }
}
</script> </script>
<svelte:window use:outsideclick />
{#if !$breakpoints.md} {#if !$breakpoints.md}
<div class='shrink-0 z-50 bg-black absolute left-4 bottom-4 w-14 h-[52px] flex rounded-md items-end justify-end overflow-clip transition-[width,height] group-fullscreen/fullscreen:hidden' class:!w-[152px]={open} class:!h-[140px]={open} bind:this={container}> <div class='shrink-0 z-50 bg-black absolute left-4 bottom-4 w-14 h-[52px] flex rounded-md items-end justify-end overflow-clip transition-[width,height] group-fullscreen/fullscreen:hidden' class:!w-[152px]={open} class:!h-[140px]={open}>
<div class='p-2 grid grid-cols-3 gap-2 shrink-0'> <div class='p-2 grid grid-cols-3 gap-2 shrink-0'>
<slot /> <slot />
<Button variant='ghost' class='px-2 w-full relative' on:click={() => { open = !open }}> <Button variant='ghost' class='px-2 w-full relative' on:click={() => { open = !open }}>
{#if open} {#if open}
<X size={18} fill='currentColor' class='pointer-events-none' /> <X size={18} fill='currentColor' />
{:else} {:else}
<Menu size={18} fill='currentColor' class='pointer-events-none' /> <Menu size={18} fill='currentColor' />
{/if} {/if}
</Button> </Button>
</div> </div>

View file

@ -1,21 +1,25 @@
<script lang='ts'> <script lang='ts'>
import Calendar from 'lucide-svelte/icons/calendar'
import Heart from 'lucide-svelte/icons/heart' import Heart from 'lucide-svelte/icons/heart'
import Play from 'lucide-svelte/icons/play' import House from 'lucide-svelte/icons/house'
import LogIn from 'lucide-svelte/icons/log-in'
import MessagesSquare from 'lucide-svelte/icons/messages-square'
import Search from 'lucide-svelte/icons/search'
import Settings from 'lucide-svelte/icons/settings'
import Users from 'lucide-svelte/icons/users'
import Download from 'svelte-radix/Download.svelte'
import { BannerImage } from '../banner' import { BannerImage } from '../banner'
import { Button } from '../button' import { Button } from '../button'
import SidebarButton from './SidebarButton.svelte' import SidebarButton from './SidebarButton.svelte'
import { goto } from '$app/navigation'
import { page } from '$app/stores' import { page } from '$app/stores'
import Logo from '$lib/components/icons/Logo.svelte' import Logo from '$lib/components/icons/Logo.svelte'
import { Home, Search, Calendar, Users, Download, Bolt, LogIn } from '$lib/components/icons/animated'
import * as Avatar from '$lib/components/ui/avatar' import * as Avatar from '$lib/components/ui/avatar'
import client from '$lib/modules/auth/client' import client from '$lib/modules/auth/client'
import { lockedState, idleState, activityState } from '$lib/modules/idle' import { lockedState, idleState, activityState } from '$lib/modules/idle'
import native from '$lib/modules/native' import native from '$lib/modules/native'
import { SUPPORTS } from '$lib/modules/settings'
import { cn, highEntropyValues } from '$lib/utils' import { cn, highEntropyValues } from '$lib/utils'
const auth = client.hasAuth const auth = client.hasAuth
@ -24,48 +28,42 @@
let visibilityState: DocumentVisibilityState let visibilityState: DocumentVisibilityState
$: active = ($lockedState === 'locked' || visibilityState === 'hidden' || ($idleState === 'active' && $activityState === 'active')) && $page.route.id !== '/app/player' && !SUPPORTS.isUnderPowered $: active = ($lockedState === 'locked' || visibilityState === 'hidden' || ($idleState === 'active' && $activityState === 'active')) && $page.route.id !== '/app/player'
let isMac = false let isMac = false
if (highEntropyValues) highEntropyValues.then(({ platform }) => { isMac = platform === 'macOS' }) if (highEntropyValues) highEntropyValues.then(({ platform }) => { isMac = platform === 'MacOS' })
</script> </script>
<svelte:document bind:visibilityState /> <svelte:document bind:visibilityState />
<BannerImage class='absolute top-0 left-0 w-14 -z-10 hidden md:block' /> <BannerImage class='absolute top-0 left-0 w-14 -z-10 hidden md:block' />
<Logo class={cn('mb-3 h-10 object-contain px-2.5 hidden md:block text-white ml-2 cursor-pointer', isMac && 'mt-3')} on:click={() => goto('/app/home/')} /> <Logo class={cn('mb-3 h-10 object-contain px-2.5 hidden md:block text-white ml-2', isMac && 'mt-3')} />
{#if SUPPORTS.isAndroidTV} <SidebarButton href='/app/home/'>
<SidebarButton href='/app/player/' class='hidden md:flex py-0'> <House size={18} />
<Play size={16} />
</SidebarButton>
{/if}
<SidebarButton href='/app/home/' class='animated-icon'>
<Home size={18} />
</SidebarButton> </SidebarButton>
<SidebarButton href='/app/search/' class='animated-icon'> <SidebarButton href='/app/search/'>
<Search size={18} /> <Search size={18} />
</SidebarButton> </SidebarButton>
<SidebarButton href='/app/schedule/' class='animated-icon'> <SidebarButton href='/app/schedule/'>
<Calendar size={18} /> <Calendar size={18} />
</SidebarButton> </SidebarButton>
<SidebarButton href='/app/w2g/' class='animated-icon'> <SidebarButton href='/app/w2g/'>
<Users size={18} /> <Users size={18} />
</SidebarButton> </SidebarButton>
<!-- <SidebarButton href='/app/chat/' class='animated-icon'> <SidebarButton href='/app/chat/'>
<Messages size={18} /> <MessagesSquare size={18} />
</SidebarButton> --> </SidebarButton>
<SidebarButton href='/app/client/' id='sidebar-client' data-down='#sidebar-donate' class='animated-icon'> <SidebarButton href='/app/client/' id='sidebar-client' data-down='#sidebar-donate'>
<Download size={18} /> <Download size={18} />
</SidebarButton> </SidebarButton>
<Button variant='ghost' id='sidebar-donate' data-up='#sidebar-client' 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] md:pl-4 md:w-12 md:rounded-l-none'> <Button variant='ghost' id='sidebar-donate' data-up='#sidebar-client' 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] md:pl-4 md:w-12 md:rounded-l-none'>
<Heart size={18} fill='currentColor' class={cn('drop-shadow-[0_0_1rem_#fa68b6]', active && 'animate-[hearbeat_1s_ease-in-out_infinite_alternate]')} /> <Heart size={18} fill='currentColor' class={cn('drop-shadow-[0_0_1rem_#fa68b6]', active && 'animate-[hearbeat_1s_ease-in-out_infinite_alternate]')} />
</Button> </Button>
<SidebarButton href='/app/settings/' class='animated-icon'> <SidebarButton href='/app/settings/'>
<Bolt size={18} /> <Settings size={18} />
</SidebarButton> </SidebarButton>
<SidebarButton href='/app/profile/'> <SidebarButton href='/app/profile/' class='hidden md:flex py-0'>
<!-- <SidebarButton href='/app/profile/' class='hidden md:flex py-0 animated-icon'> -->
{#if hasAuth} {#if hasAuth}
{@const viewer = client.profile()} {@const viewer = client.profile()}
<Avatar.Root class='size-6 rounded-md'> <Avatar.Root class='size-6 rounded-md'>

View file

@ -1,7 +0,0 @@
import Root from './slider.svelte'
export {
Root,
//
Root as Slider
}

View file

@ -1,40 +0,0 @@
<script lang='ts'>
import { Slider as SliderPrimitive } from 'bits-ui'
import { cn } from '$lib/utils.js'
type $$Props = SliderPrimitive.Props
let className: $$Props['class'] = undefined
export let value: $$Props['value'] = [0]
export { className as class }
let wrapper: HTMLDivElement
function capture (e: PointerEvent) {
wrapper.setPointerCapture(e.pointerId)
}
function release (e: PointerEvent) {
wrapper.releasePointerCapture(e.pointerId)
}
</script>
<div class='contents' on:pointerup on:pointerdown={capture} on:pointerup={release} on:pointercancel={release} bind:this={wrapper}>
<SliderPrimitive.Root
bind:value
class={cn('relative flex w-full touch-none select-none items-center', className)}
{...$$restProps}
let:thumbs
>
<span class='bg-primary/20 relative h-1.5 w-full grow overflow-hidden rounded-full'>
<SliderPrimitive.Range class='bg-primary absolute h-full' />
</span>
{#each thumbs as thumb (thumb)}
<SliderPrimitive.Thumb
{thumb}
class='border-primary/50 bg-background focus-visible:ring-ring block h-4 w-4 rounded-full border shadow transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50'
/>
{/each}
</SliderPrimitive.Root>
</div>

View file

@ -71,7 +71,7 @@
Episodes {entry.episodes} Episodes {entry.episodes}
</div> </div>
{#if entry.videos?.length} {#if entry.videos?.length}
<Button size='icon-sm' class='ml-auto font-bold rounded-full bg-custom select:!bg-custom-600 text-contrast' on:click={() => playVideo(url)}><Play fill='currentColor' size={iconSizes['icon-sm']} /></Button> <Button size='icon-sm' class='ml-auto font-bold rounded-full' on:click={() => playVideo(url)}><Play fill='currentColor' size={iconSizes['icon-sm']} /></Button>
{/if} {/if}
</div> </div>
{#if src === url} {#if src === url}

View file

@ -27,7 +27,6 @@
className className
)} )}
{value} {value}
tabindex={0}
{...$$restProps} {...$$restProps}
> >
<slot /> <slot />

View file

@ -6,20 +6,13 @@ export const toggleVariants = tv({
base: 'hover:bg-muted hover:text-muted-foreground focus-visible:ring-ring data-[state=on]:bg-accent data-[state=on]:text-accent-foreground inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50', base: 'hover:bg-muted hover:text-muted-foreground focus-visible:ring-ring data-[state=on]:bg-accent data-[state=on]:text-accent-foreground inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50',
variants: { variants: {
variant: { variant: {
default: 'bg-primary text-primary-foreground select:bg-primary/70 shadow', default: 'bg-transparent',
destructive: 'bg-destructive text-destructive-foreground select:bg-destructive/90 shadow-sm', outline: 'border-input hover:bg-accent hover:text-accent-foreground border bg-transparent shadow-sm'
outline: 'border-input bg-background select:bg-accent select:text-accent-foreground border shadow-sm',
secondary: 'bg-secondary text-secondary-foreground select:bg-secondary/70 shadow-sm',
ghost: 'select:bg-secondary-foreground/30 select:text-accent-foreground',
link: 'text-primary underline-offset-4 select:underline'
}, },
size: { size: {
default: 'h-9 px-4 py-2', default: 'h-9 px-3',
sm: 'h-8 rounded-md px-3 text-xs', sm: 'h-8 px-2',
xs: 'h-[1.6rem] rounded-sm px-2 text-xs', lg: 'h-10 px-3'
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
'icon-sm': 'h-[1.6rem] w-[1.6rem] rounded-sm text-xs'
} }
}, },
defaultVariants: { defaultVariants: {

View file

@ -1,9 +1,7 @@
<script lang='ts'> <script lang='ts'>
import { Render, Subscribe, createRender, createTable } from 'svelte-headless-table' import { Render, Subscribe, createRender, createTable } from 'svelte-headless-table'
import { addSortBy, addTableFilter } from 'svelte-headless-table/plugins' import { addSortBy } from 'svelte-headless-table/plugins'
import MagnifyingGlass from 'svelte-radix/MagnifyingGlass.svelte'
import { Input } from '../../input'
import Columnheader from '../columnheader.svelte' import Columnheader from '../columnheader.svelte'
import { NameCell, ProgressCell } from './cells' import { NameCell, ProgressCell } from './cells'
@ -13,10 +11,7 @@
import { cn, fastPrettyBytes } from '$lib/utils' import { cn, fastPrettyBytes } from '$lib/utils'
const table = createTable(server.files, { const table = createTable(server.files, {
sort: addSortBy({ toggleOrder: ['asc', 'desc'] }), sort: addSortBy({ toggleOrder: ['asc', 'desc'] })
filter: addTableFilter({
fn: ({ filterValue, value }) => value.toLowerCase().includes(filterValue.toLowerCase())
})
}) })
const columns = table.createColumns([ const columns = table.createColumns([
@ -30,34 +25,23 @@
accessor: 'size', accessor: 'size',
header: 'Size', header: 'Size',
id: 'size', id: 'size',
plugins: { filter: { exclude: true } },
cell: ({ value }) => fastPrettyBytes(value) cell: ({ value }) => fastPrettyBytes(value)
}), }),
table.column({ table.column({
accessor: 'progress', accessor: 'progress',
header: 'Progress', header: 'Progress',
id: 'progress', id: 'progress',
plugins: { filter: { exclude: true } },
cell: ({ value }) => createRender(ProgressCell, { value }) cell: ({ value }) => createRender(ProgressCell, { value })
}), }),
table.column({ accessor: 'selections', header: 'Streams', id: 'selections' }) table.column({ accessor: 'selections', header: 'Selections', id: 'selections' })
]) ])
const tableModel = table.createViewModel(columns) const tableModel = table.createViewModel(columns)
const { headerRows, pageRows, tableAttrs, tableBodyAttrs, pluginStates } = tableModel const { headerRows, pageRows, tableAttrs, tableBodyAttrs } = tableModel
const filterValue = pluginStates.filter.filterValue
</script> </script>
<div class='flex items-center scale-parent relative pb-2 overflow-visible'> <div class='rounded-md border max-w-screen-xl h-full overflow-clip contain-strict'>
<Input
class='pl-9 bg-black select:bg-accent select:text-accent-foreground shadow-sm no-scale placeholder:opacity-50'
placeholder='Search by File Name...'
bind:value={$filterValue} />
<MagnifyingGlass class='h-4 w-4 shrink-0 opacity-50 absolute left-3 text-muted-foreground z-10 pointer-events-none' />
</div>
<div class='rounded-md border size-full overflow-clip contain-strict'>
<Table.Root {...$tableAttrs} class='max-h-full'> <Table.Root {...$tableAttrs} class='max-h-full'>
<Table.Header class='px-5'> <Table.Header class='px-5'>
{#each $headerRows as headerRow, i (i)} {#each $headerRows as headerRow, i (i)}
@ -90,10 +74,10 @@
{#if $pageRows.length} {#if $pageRows.length}
{#each $pageRows as row (row.id)} {#each $pageRows as row (row.id)}
<Subscribe rowAttrs={row.attrs()} let:rowAttrs> <Subscribe rowAttrs={row.attrs()} let:rowAttrs>
<Table.Row {...rowAttrs} class='h-12 [content-visibility:auto] [contain-intrinsic-height:auto_48px] contain-strict'> <Table.Row {...rowAttrs} class='h-12'>
{#each row.cells as cell (cell.id)} {#each row.cells as cell (cell.id)}
<Subscribe attrs={cell.attrs()} let:attrs> <Subscribe attrs={cell.attrs()} let:attrs>
<Table.Cell {...attrs} class={cn('px-4 h-14 first:pl-6 last:pr-6 text-nowrap', cell.id === 'name' && 'text-wrap break-all')}> <Table.Cell {...attrs} class={cn('px-4 h-14 first:pl-6 last:pr-6 text-nowrap', (cell.id === 'downloaded' || cell.id === 'episode') && 'text-muted-foreground')}>
<Render of={cell.render()} /> <Render of={cell.render()} />
</Table.Cell> </Table.Cell>
</Subscribe> </Subscribe>

View file

@ -66,43 +66,36 @@
const oneOverScale = 1 / scale const oneOverScale = 1 / scale
function makeGlobe (canvas: HTMLCanvasElement) { function makeGlobe (canvas: HTMLCanvasElement) {
try { const globe = createGlobe(canvas, {
const globe = createGlobe(canvas, { devicePixelRatio: window.devicePixelRatio,
devicePixelRatio: window.devicePixelRatio, width: size,
width: size, height: size,
height: size, phi: 0,
phi: 0, theta: 0.1,
theta: 0.1, dark: 1,
dark: 1, diffuse: 1.4,
diffuse: 1.4, mapSamples: 19000,
mapSamples: 19000, mapBrightness: 6,
mapBrightness: 6, opacity: 0.8,
opacity: 0.8, baseColor: [0.23, 0.23, 0.23],
baseColor: [0.23, 0.23, 0.23], markerColor: [0.05, 1, 0],
markerColor: [0.05, 1, 0], glowColor: [0, 0, 0],
glowColor: [0, 0, 0], markers: [],
markers: [], scale,
scale, offset: [size * 0.8 * oneOverScale, size * oneOverScale * 0.4],
offset: [size * 0.8 * oneOverScale, size * oneOverScale * 0.4], onRender: state => {
onRender: state => { state.phi = Date.now() * 0.0002 % (Math.PI * 2)
state.phi = Date.now() * 0.0002 % (Math.PI * 2) state.width = size
state.width = size state.height = size
state.height = size state.offset = [size * 0.8 * oneOverScale, size * oneOverScale * 0.4]
state.offset = [size * 0.8 * oneOverScale, size * oneOverScale * 0.4]
state.markers = Object.values(markers).filter(m => m) state.markers = Object.values(markers).filter(m => m)
}
})
return {
destroy () {
globe.destroy()
}
} }
} catch (e) { })
console.error('Failed to create globe', e)
return { return {
destroy () {} destroy () {
globe.destroy()
} }
} }
} }

View file

@ -1,19 +0,0 @@
<script lang='ts'>
import type { HTMLButtonAttributes } from 'svelte/elements'
import type { Writable } from 'svelte/store'
import { Checkbox } from '$lib/components/ui/checkbox'
type $$Props = HTMLButtonAttributes & {
checked: Writable<boolean>
}
export let checked: Writable<boolean>
function check () {
checked.update(value => !value)
}
</script>
<div class='w-full inset-0 h-full flex justify-center items-center' on:click|self={check} on:click|stopPropagation|stopImmediatePropagation>
<Checkbox bind:checked={$checked} {...$$restProps} class='mx-4' />
</div>

View file

@ -1,28 +1,5 @@
<script lang='ts'> <script lang='ts'>
import ClockFading from 'lucide-svelte/icons/clock-fading'
import * as Tooltip from '$lib/components/ui/tooltip'
import { cn } from '$lib/utils'
export let value: number export let value: number
const day = 24 * 60 * 60 * 1000 // milliseconds in a day
$: date = new Date(value)
$: moreThan30Days = date.getTime() < Date.now() - 30 * day
$: moreThan21Days = date.getTime() < Date.now() - 21 * day
</script> </script>
{#if moreThan30Days || moreThan21Days} <div class='text-sm'>{new Date(value).toLocaleDateString()}</div>
<Tooltip.Root>
<Tooltip.Trigger class={cn('text-sm flex items-center gap-1', moreThan30Days && '!text-red-400', moreThan21Days && 'text-yellow-200')}>
<ClockFading class='w-4 h-4' />{value ? date.toLocaleDateString() : '?'}
</Tooltip.Trigger>
<Tooltip.Content class='whitespace-pre-wrap'>
{moreThan30Days ? 'Played more than 30 days ago.\nCached metadata might have expired.\nPlay this torrent again to refresh.' : 'Played more than 21 days ago.\nCached metadata might soon expire.'}
</Tooltip.Content>
</Tooltip.Root>
{:else}
<div class={cn('text-sm', moreThan30Days && '!text-red-400', moreThan21Days && 'text-yellow-200')}>{value ? date.toLocaleDateString() : '?'}</div>
{/if}

View file

@ -1,5 +1,3 @@
export { default as StatusCell } from './status.svelte' export { default as StatusCell } from './status.svelte'
export { default as NameCell } from './name.svelte' export { default as NameCell } from './name.svelte'
export { default as MediaCell } from './mediatitle.svelte' export { default as MediaCell } from './mediatitle.svelte'
export { default as DateCell } from './date.svelte'
export { default as CheckboxCell } from './checkboxcell.svelte'

View file

@ -2,4 +2,4 @@
export let value: string export let value: string
</script> </script>
<div class='text-xs font-mono text-wrap'>{value}</div> <div class='text-xs font-mono'>{value}</div>

View file

@ -1,34 +1,23 @@
<script lang='ts'> <script lang='ts'>
import { DataBodyRow, Render, Subscribe, createRender, createTable } from 'svelte-headless-table' import { DataBodyRow, Render, Subscribe, createRender, createTable } from 'svelte-headless-table'
import { addSelectedRows, addSortBy, addTableFilter } from 'svelte-headless-table/plugins' import { addSortBy } from 'svelte-headless-table/plugins'
import MagnifyingGlass from 'svelte-radix/MagnifyingGlass.svelte'
import { toast } from 'svelte-sonner'
import { Button } from '../../button'
import Columnheader from '../columnheader.svelte' import Columnheader from '../columnheader.svelte'
import { MediaCell, NameCell, StatusCell, DateCell, CheckboxCell } from './cells' import { MediaCell, NameCell, StatusCell } from './cells'
import type { LibraryEntry } from 'native' import type { LibraryEntry } from '$lib/../app'
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { FolderSync, Trash } from '$lib/components/icons/animated'
import * as Dialog from '$lib/components/ui/dialog'
import { Input } from '$lib/components/ui/input'
import * as Table from '$lib/components/ui/table' import * as Table from '$lib/components/ui/table'
import { client } from '$lib/modules/anilist' import { client } from '$lib/modules/anilist'
import native from '$lib/modules/native'
import { server } from '$lib/modules/torrent' import { server } from '$lib/modules/torrent'
import { cn, fastPrettyBytes } from '$lib/utils' import { cn, fastPrettyBytes } from '$lib/utils'
const lib = server.library const lib = server.library
const table = createTable(lib, { const table = createTable(lib, {
select: addSelectedRows(), sort: addSortBy({ toggleOrder: ['asc', 'desc'] })
sort: addSortBy({ toggleOrder: ['asc', 'desc'] }),
filter: addTableFilter({
fn: ({ filterValue, value }) => value.toLowerCase().includes(filterValue.toLowerCase())
})
}) })
const columns = table.createColumns([ const columns = table.createColumns([
@ -36,167 +25,57 @@
accessor: 'mediaID', accessor: 'mediaID',
header: 'Series', header: 'Series',
id: 'series', id: 'series',
plugins: { sort: { getSortValue: e => e ?? 0 }, filter: { exclude: true } },
cell: ({ value }) => value ? createRender(MediaCell, { value }) : '?' cell: ({ value }) => value ? createRender(MediaCell, { value }) : '?'
}), }),
table.column({ table.column({
accessor: 'episode', accessor: 'episode',
header: 'Episode', header: 'Episode',
id: 'episode', id: 'episode',
plugins: { sort: { getSortValue: e => e ?? 0 }, filter: { exclude: true } },
cell: ({ value }) => value?.toString() ?? '?' cell: ({ value }) => value?.toString() ?? '?'
}), }),
table.column({ accessor: 'files', header: 'Files', id: 'files', plugins: { filter: { exclude: true } } }), table.column({ accessor: 'files', header: 'Files', id: 'files' }),
table.column({ table.column({
accessor: 'size', accessor: 'size',
header: 'Size', header: 'Size',
id: 'size', id: 'size',
plugins: { sort: { getSortValue: e => e ?? 0 }, filter: { exclude: true } },
cell: ({ value }) => value ? fastPrettyBytes(value) : '?' cell: ({ value }) => value ? fastPrettyBytes(value) : '?'
}), }),
table.column({ table.column({
accessor: 'progress', accessor: 'progress',
header: 'Status', header: 'Status',
id: 'completed', id: 'completed',
plugins: { sort: { getSortValue: e => e ?? 0 }, filter: { exclude: true } },
cell: ({ value }) => value ? createRender(StatusCell, { value: value === 1 }) : '?' cell: ({ value }) => value ? createRender(StatusCell, { value: value === 1 }) : '?'
}), }),
table.column({ table.column({
accessor: 'date', accessor: 'date',
header: 'Date', header: 'Date',
id: 'date', id: 'date',
plugins: { sort: { getSortValue: e => e ?? 0 }, filter: { exclude: true } }, cell: ({ value }) => value ? new Date(value).toLocaleDateString() : '?'
cell: ({ value }) => value ? createRender(DateCell, { value }) : '?'
}), }),
table.column({ table.column({
accessor: e => e?.name ?? e.hash, accessor: e => e?.name ?? e.hash,
header: 'Torrent Name', header: 'Torrent Name',
id: 'name', id: 'name',
plugins: { sort: { getSortValue: e => e ?? '' } },
cell: ({ value }) => createRender(NameCell, { value }) cell: ({ value }) => createRender(NameCell, { value })
}),
table.display({
id: 'select',
header: (_, { pluginStates }) => {
const { allPageRowsSelected } = pluginStates.select
return createRender(CheckboxCell, {
checked: allPageRowsSelected,
'aria-label': 'Select all'
})
},
cell: ({ row }, { pluginStates }) => {
const { getRowState } = pluginStates.select
const { isSelected } = getRowState(row)
return createRender(CheckboxCell, {
checked: isSelected,
'aria-label': 'Select row'
})
},
plugins: {
sort: {
disable: true
}
}
}) })
]) ])
const tableModel = table.createViewModel(columns) const tableModel = table.createViewModel(columns)
const { headerRows, pageRows, rows, tableAttrs, tableBodyAttrs, pluginStates } = tableModel const { headerRows, pageRows, tableAttrs, tableBodyAttrs } = tableModel
async function playEntry ({ mediaID, episode, hash }: LibraryEntry) { async function playEntry ({ mediaID, episode, hash }: LibraryEntry) {
if (!mediaID || !hash) return if (!mediaID || !hash) return
const media = await client.single(mediaID) const media = await client.single(mediaID)
if (!media.data?.Media) return // TODO: log this? server.play(hash, media.data!.Media!, episode)
server.play(hash, media.data.Media, episode)
goto('/app/player/') goto('/app/player/')
} }
const { filterValue } = pluginStates.filter // TODO
const { selectedDataIds, someRowsSelected } = pluginStates.select
function getSelected () {
return Object.keys($selectedDataIds).map(id => $lib[id as unknown as number]).filter(e => e) as LibraryEntry[]
}
function rescanTorrents () {
toast.promise(native.rescanTorrents(getSelected().map(e => e.hash)), {
loading: 'Rescanning torrents...',
success: 'Rescan complete',
error: e => {
console.error(e)
return 'Failed to rescan torrents\n' + ('stack' in (e as object) ? (e as Error).stack : 'Unknown error')
},
description: 'This may take a VERY long while depending on the number of torrents.'
})
}
function deleteTorrents () {
toast.promise(
native.deleteTorrents(getSelected().map(e => e.hash))
.then(() => server.updateLibrary()
.then(() => pluginStates.select.selectedDataIds.clear())
), {
loading: 'Deleting torrents...',
success: 'Torrents deleted',
error: e => {
console.error(e)
return 'Failed to delete torrents\n' + ('stack' in (e as object) ? (e as Error).stack : 'Unknown error')
},
description: 'This may take a while depending on the library size.'
})
}
// TODO once new resolver is implemented
// $: allIDsPromise = client.multiple($lib.map(e => e.mediaID)) // $: allIDsPromise = client.multiple($lib.map(e => e.mediaID))
</script> </script>
<div class='flex gap-2'> <div class='rounded-md border max-w-screen-xl h-full overflow-clip contain-strict'>
<div class='flex items-center scale-parent relative pb-2 overflow-visible grow'>
<Input
class='pl-9 bg-black select:bg-accent select:text-accent-foreground shadow-sm no-scale placeholder:opacity-50'
placeholder='Search by Torrent Name...'
bind:value={$filterValue} />
<MagnifyingGlass class='h-4 w-4 shrink-0 opacity-50 absolute left-3 text-muted-foreground z-10 pointer-events-none' />
</div>
<Button variant='secondary' size='icon' class='border-0 animated-icon' on:click={rescanTorrents} disabled={!$someRowsSelected}>
<FolderSync class={cn('size-4')} />
</Button>
<Dialog.Root portal='#root'>
<Dialog.Trigger asChild let:builder>
<Button variant='destructive' size='icon' class='border-0 animated-icon' builders={[builder]} disabled={!$someRowsSelected}>
<Trash class={cn('size-4')} />
</Button>
</Dialog.Trigger>
<Dialog.Content class='max-w-5xl flex flex-col !w-auto'>
<Dialog.Header>
<Dialog.Title>Are you absolutely sure?</Dialog.Title>
<Dialog.Description>
You are about to permanently delete {$someRowsSelected ? Object.keys($selectedDataIds).length : '0'} torrent(s) from your library. This action cannot be undone.
</Dialog.Description>
<ul class='text-xs text-muted-foreground pl-5 space-y-2 py-4 list-disc overflow-clip max-h-[50vh] overflow-y-auto'>
{#each getSelected() as entry (entry.hash)}
<li class='text-ellipsis text-nowrap max-w-full'>{entry.name}</li>
{/each}
</ul>
</Dialog.Header>
<Dialog.Footer>
<Dialog.Close let:builder asChild>
<Button variant='destructive' builders={[builder]} on:click={deleteTorrents}>
Delete
</Button>
</Dialog.Close>
<Dialog.Close let:builder asChild>
<Button variant='secondary' builders={[builder]}>Cancel</Button>
</Dialog.Close>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
</div>
<div class='text-muted-foreground flex-1 text-sm text-right mb-1'>
{Object.keys($selectedDataIds).length} of {$rows.length} row(s) selected.
</div>
<div class='rounded-md border size-full overflow-clip contain-strict'>
<Table.Root {...$tableAttrs} class='max-h-full'> <Table.Root {...$tableAttrs} class='max-h-full'>
<Table.Header class='px-5'> <Table.Header class='px-5'>
{#each $headerRows as headerRow, i (i)} {#each $headerRows as headerRow, i (i)}
@ -208,10 +87,16 @@
props={cell.props()} props={cell.props()}
let:attrs let:attrs
let:props> let:props>
<Table.Head {...attrs} class={cn('px-0 first:pl-2 h-12 last:pr-2')}> <Table.Head {...attrs} class={cn('px-0 first:pl-2 h-12 last:pr-2', cell.id === 'name' && 'w-full')}>
<Columnheader {props}> {#if cell.id !== 'flags'}
<Render of={cell.render()} /> <Columnheader {props}>
</Columnheader> <Render of={cell.render()} />
</Columnheader>
{:else}
<div class='text-sm px-4'>
<Render of={cell.render()} />
</div>
{/if}
</Table.Head> </Table.Head>
</Subscribe> </Subscribe>
{/each} {/each}
@ -223,15 +108,10 @@
{#if $pageRows.length} {#if $pageRows.length}
{#each $pageRows as row (row.id)} {#each $pageRows as row (row.id)}
<Subscribe rowAttrs={row.attrs()} let:rowAttrs> <Subscribe rowAttrs={row.attrs()} let:rowAttrs>
<Table.Row {...rowAttrs} class={cn('h-14 [content-visibility:auto] [contain-intrinsic-height:auto_56px] contain-strict', (row instanceof DataBodyRow) && row.original.mediaID ? 'cursor-pointer' : 'cursor-not-allowed')} on:click={() => { if (row instanceof DataBodyRow) playEntry(row.original) }}> <Table.Row {...rowAttrs} class={cn('h-12', (row instanceof DataBodyRow) && row.original.mediaID ? 'cursor-pointer' : 'cursor-not-allowed')} on:click={() => { if (row instanceof DataBodyRow) playEntry(row.original) }}>
{#each row.cells as cell (cell.id)} {#each row.cells as cell (cell.id)}
<Subscribe attrs={cell.attrs()} let:attrs> <Subscribe attrs={cell.attrs()} let:attrs>
<Table.Cell {...attrs} class={cn( <Table.Cell {...attrs} class={cn('px-4 h-14 first:pl-6 last:pr-6 text-nowrap', (cell.id === 'downloaded' || cell.id === 'episode') && 'text-muted-foreground')}>
'px-4 min-h-14 first:pl-6 last:pr-6 text-nowrap',
(cell.id === 'episode') && 'text-muted-foreground',
(cell.id === 'series' || cell.id === 'name') && 'min-w-80 text-wrap break-all',
cell.id === 'select' && 'p-0 relative [&>div]:absolute'
)}>
<Render of={cell.render()} /> <Render of={cell.render()} />
</Table.Cell> </Table.Cell>
</Subscribe> </Subscribe>

View file

@ -31,7 +31,7 @@
const forwarding = safeLocalStorage<boolean>('torrent-port-forwarding') ?? false const forwarding = safeLocalStorage<boolean>('torrent-port-forwarding') ?? false
</script> </script>
<div class='max-w-6xl flex flex-col gap-12 min-[2000px]:max-w-full'> <div class='max-w-6xl flex flex-col gap-12'>
<div class='flex items-center gap-4'> <div class='flex items-center gap-4'>
<div class='flex-1 w-full'> <div class='flex-1 w-full'>
<h1 class='text-2xl font-bold truncate text-nowrap'>{torrent.name || 'No Name Provided'}</h1> <h1 class='text-2xl font-bold truncate text-nowrap'>{torrent.name || 'No Name Provided'}</h1>

Some files were not shown because too many files have changed in this diff Show more