mirror of
https://github.com/ThaUnknown/miru.git
synced 2026-05-24 19:32:27 +00:00
Compare commits
No commits in common. "master" and "v6.4.11" have entirely different histories.
171 changed files with 2328 additions and 6538 deletions
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
6
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
6
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
2
.github/workflows/check.yml
vendored
2
.github/workflows/check.yml
vendored
|
|
@ -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
11
.vscode/launch.json
vendored
|
|
@ -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": [
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
60
package.json
60
package.json
|
|
@ -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",
|
||||||
|
|
|
||||||
1286
pnpm-lock.yaml
1286
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
133
src/app.css
133
src/app.css
|
|
@ -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
165
src/app.d.ts
vendored
|
|
@ -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>>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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'>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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'
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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' />
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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'>
|
||||||
|
|
|
||||||
|
|
@ -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']} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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'>
|
||||||
|
|
|
||||||
|
|
@ -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' }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
import Root from './markdown.svelte'
|
|
||||||
|
|
||||||
export {
|
|
||||||
Root,
|
|
||||||
Root as Markdown
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
)} />
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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}
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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]'>
|
||||||
|
|
|
||||||
|
|
@ -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>>) {
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/ /g, '\\h').replace(/\r?\n/g, '\\N')
|
Text = Text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/ /g, '\\h').replace(/1?\n/g, '\\N')
|
||||||
} else {
|
|
||||||
Text = Text.replace(/\r?\n/g, '')
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
Start: subtitle.time,
|
Start: subtitle.time,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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}%' />
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
export { default as Relations } from './Relations.svelte'
|
|
||||||
export { default as TextNode } from './TextNode.svelte'
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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'>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import Root from './slider.svelte'
|
|
||||||
|
|
||||||
export {
|
|
||||||
Root,
|
|
||||||
//
|
|
||||||
Root as Slider
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{value}
|
{value}
|
||||||
tabindex={0}
|
|
||||||
{...$$restProps}
|
{...$$restProps}
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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}
|
|
||||||
|
|
|
||||||
|
|
@ -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'
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in a new issue