summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorThomas Grothe <grothe.tr@gmail.com>2026-03-07 23:32:05 -0500
committerThomas Grothe <grothe.tr@gmail.com>2026-03-07 23:32:05 -0500
commitdbd1386a43ae9e7013809be2e0bd0e1c049059fc (patch)
tree22588cb21dfa1cc941e13031e73cb85cdfb7f402
good startHEADmain
-rw-r--r--.gitignore1
-rw-r--r--.svelte-kit/ambient.d.ts326
-rw-r--r--.svelte-kit/generated/client/app.js41
-rw-r--r--.svelte-kit/generated/client/matchers.js1
-rw-r--r--.svelte-kit/generated/client/nodes/0.js1
-rw-r--r--.svelte-kit/generated/client/nodes/1.js1
-rw-r--r--.svelte-kit/generated/client/nodes/2.js1
-rw-r--r--.svelte-kit/generated/client/nodes/3.js1
-rw-r--r--.svelte-kit/generated/client/nodes/4.js1
-rw-r--r--.svelte-kit/generated/client/nodes/5.js1
-rw-r--r--.svelte-kit/generated/client/nodes/6.js1
-rw-r--r--.svelte-kit/generated/client/nodes/7.js1
-rw-r--r--.svelte-kit/generated/client/nodes/8.js1
-rw-r--r--.svelte-kit/generated/root.js3
-rw-r--r--.svelte-kit/generated/root.svelte68
-rw-r--r--.svelte-kit/generated/server/internal.js53
-rw-r--r--.svelte-kit/non-ambient.d.ts64
-rw-r--r--.svelte-kit/tsconfig.json55
-rw-r--r--.svelte-kit/types/route_meta_data.json27
-rw-r--r--.svelte-kit/types/src/routes/$types.d.ts24
-rw-r--r--.svelte-kit/types/src/routes/api/channel/[id]/$types.d.ts11
-rw-r--r--.svelte-kit/types/src/routes/api/playlist/$types.d.ts10
-rw-r--r--.svelte-kit/types/src/routes/api/related/[id]/$types.d.ts11
-rw-r--r--.svelte-kit/types/src/routes/api/search/$types.d.ts10
-rw-r--r--.svelte-kit/types/src/routes/api/trending/$types.d.ts10
-rw-r--r--.svelte-kit/types/src/routes/api/video/[id]/$types.d.ts11
-rw-r--r--.svelte-kit/types/src/routes/channel/[id]/$types.d.ts19
-rw-r--r--.svelte-kit/types/src/routes/playlists/$types.d.ts18
-rw-r--r--.svelte-kit/types/src/routes/playlists/[id]/$types.d.ts19
-rw-r--r--.svelte-kit/types/src/routes/search/$types.d.ts18
-rw-r--r--.svelte-kit/types/src/routes/subscriptions/$types.d.ts18
-rw-r--r--.svelte-kit/types/src/routes/watch/[id]/$types.d.ts19
-rw-r--r--bun.lock241
-rw-r--r--package.json26
-rw-r--r--src/app.css159
-rw-r--r--src/app.d.ts13
-rw-r--r--src/app.html12
-rw-r--r--src/lib/api/youtube.ts253
-rw-r--r--src/lib/components/ChannelCard.svelte160
-rw-r--r--src/lib/components/SearchBar.svelte76
-rw-r--r--src/lib/components/VideoCard.svelte133
-rw-r--r--src/lib/components/VideoPlayer.svelte185
-rw-r--r--src/lib/server/ytdlp.ts329
-rw-r--r--src/lib/stores/playlists.ts123
-rw-r--r--src/lib/stores/subscriptions.ts65
-rw-r--r--src/routes/+layout.svelte151
-rw-r--r--src/routes/+page.svelte50
-rw-r--r--src/routes/api/channel/[id]/+server.ts18
-rw-r--r--src/routes/api/playlist/+server.ts23
-rw-r--r--src/routes/api/related/[id]/+server.ts19
-rw-r--r--src/routes/api/search/+server.ts18
-rw-r--r--src/routes/api/trending/+server.ts13
-rw-r--r--src/routes/api/video/[id]/+server.ts18
-rw-r--r--src/routes/channel/[id]/+page.svelte175
-rw-r--r--src/routes/playlists/+page.svelte353
-rw-r--r--src/routes/playlists/[id]/+page.svelte294
-rw-r--r--src/routes/search/+page.svelte77
-rw-r--r--src/routes/subscriptions/+page.svelte151
-rw-r--r--src/routes/watch/[id]/+page.svelte381
-rw-r--r--svelte.config.js12
-rw-r--r--tsconfig.json14
-rw-r--r--vite.config.ts6
62 files changed, 4394 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c2658d7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+node_modules/
diff --git a/.svelte-kit/ambient.d.ts b/.svelte-kit/ambient.d.ts
new file mode 100644
index 0000000..338b8ec
--- /dev/null
+++ b/.svelte-kit/ambient.d.ts
@@ -0,0 +1,326 @@
+
+// this file is generated — do not edit it
+
+
+/// <reference types="@sveltejs/kit" />
+
+/**
+ * This module provides access to environment variables that are injected _statically_ into your bundle at build time and are limited to _private_ access.
+ *
+ * | | Runtime | Build time |
+ * | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
+ * | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) |
+ * | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) |
+ *
+ * Static environment variables are [loaded by Vite](https://vitejs.dev/guide/env-and-mode.html#env-files) from `.env` files and `process.env` at build time and then statically injected into your bundle at build time, enabling optimisations like dead code elimination.
+ *
+ * **_Private_ access:**
+ *
+ * - This module cannot be imported into client-side code
+ * - This module only includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) _and do_ start with [`config.kit.env.privatePrefix`](https://svelte.dev/docs/kit/configuration#env) (if configured)
+ *
+ * For example, given the following build time environment:
+ *
+ * ```env
+ * ENVIRONMENT=production
+ * PUBLIC_BASE_URL=http://site.com
+ * ```
+ *
+ * With the default `publicPrefix` and `privatePrefix`:
+ *
+ * ```ts
+ * import { ENVIRONMENT, PUBLIC_BASE_URL } from '$env/static/private';
+ *
+ * console.log(ENVIRONMENT); // => "production"
+ * console.log(PUBLIC_BASE_URL); // => throws error during build
+ * ```
+ *
+ * The above values will be the same _even if_ different values for `ENVIRONMENT` or `PUBLIC_BASE_URL` are set at runtime, as they are statically replaced in your code with their build time values.
+ */
+declare module '$env/static/private' {
+ export const SHELL: string;
+ export const npm_command: string;
+ export const COREPACK_ENABLE_AUTO_PIN: string;
+ export const WINDOWID: string;
+ export const COLORTERM: string;
+ export const XDG_SESSION_PATH: string;
+ export const TERM_PROGRAM_VERSION: string;
+ export const TMUX: string;
+ export const NODE: string;
+ export const LC_ADDRESS: string;
+ export const LC_NAME: string;
+ export const npm_config_local_prefix: string;
+ export const DESKTOP_SESSION: string;
+ export const LC_MONETARY: string;
+ export const GTK_MODULES: string;
+ export const XDG_SEAT: string;
+ export const PWD: string;
+ export const XDG_SESSION_DESKTOP: string;
+ export const LOGNAME: string;
+ export const XDG_SESSION_TYPE: string;
+ export const XAUTHORITY: string;
+ export const NoDefaultCurrentDirectoryInExePath: string;
+ export const XDG_GREETER_DATA_DIR: string;
+ export const CLAUDECODE: string;
+ export const MOTD_SHOWN: string;
+ export const HOME: string;
+ export const LC_PAPER: string;
+ export const LANG: string;
+ export const LS_COLORS: string;
+ export const npm_package_version: string;
+ export const VTE_VERSION: string;
+ export const XDG_SEAT_PATH: string;
+ export const npm_lifecycle_script: string;
+ export const NVM_DIR: string;
+ export const XDG_SESSION_CLASS: string;
+ export const TERM: string;
+ export const LC_IDENTIFICATION: string;
+ export const npm_package_name: string;
+ export const USER: string;
+ export const TMUX_PANE: string;
+ export const PAM_KWALLET5_LOGIN: string;
+ export const DISPLAY: string;
+ export const npm_lifecycle_event: string;
+ export const SHLVL: string;
+ export const GIT_EDITOR: string;
+ export const LC_TELEPHONE: string;
+ export const LC_MEASUREMENT: string;
+ export const XDG_VTNR: string;
+ export const XDG_SESSION_ID: string;
+ export const npm_config_user_agent: string;
+ export const OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: string;
+ export const npm_execpath: string;
+ export const LD_LIBRARY_PATH: string;
+ export const XDG_RUNTIME_DIR: string;
+ export const CLAUDE_CODE_ENTRYPOINT: string;
+ export const DEBUGINFOD_URLS: string;
+ export const npm_package_json: string;
+ export const LC_TIME: string;
+ export const CUDA_HOME: string;
+ export const GTK3_MODULES: string;
+ export const GCC_COLORS: string;
+ export const PATH: string;
+ export const GDMSESSION: string;
+ export const DBUS_SESSION_BUS_ADDRESS: string;
+ export const MAIL: string;
+ export const npm_node_execpath: string;
+ export const LC_NUMERIC: string;
+ export const OLDPWD: string;
+ export const TERM_PROGRAM: string;
+ export const _: string;
+ export const NODE_ENV: string;
+}
+
+/**
+ * This module provides access to environment variables that are injected _statically_ into your bundle at build time and are _publicly_ accessible.
+ *
+ * | | Runtime | Build time |
+ * | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
+ * | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) |
+ * | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) |
+ *
+ * Static environment variables are [loaded by Vite](https://vitejs.dev/guide/env-and-mode.html#env-files) from `.env` files and `process.env` at build time and then statically injected into your bundle at build time, enabling optimisations like dead code elimination.
+ *
+ * **_Public_ access:**
+ *
+ * - This module _can_ be imported into client-side code
+ * - **Only** variables that begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) (which defaults to `PUBLIC_`) are included
+ *
+ * For example, given the following build time environment:
+ *
+ * ```env
+ * ENVIRONMENT=production
+ * PUBLIC_BASE_URL=http://site.com
+ * ```
+ *
+ * With the default `publicPrefix` and `privatePrefix`:
+ *
+ * ```ts
+ * import { ENVIRONMENT, PUBLIC_BASE_URL } from '$env/static/public';
+ *
+ * console.log(ENVIRONMENT); // => throws error during build
+ * console.log(PUBLIC_BASE_URL); // => "http://site.com"
+ * ```
+ *
+ * The above values will be the same _even if_ different values for `ENVIRONMENT` or `PUBLIC_BASE_URL` are set at runtime, as they are statically replaced in your code with their build time values.
+ */
+declare module '$env/static/public' {
+
+}
+
+/**
+ * This module provides access to environment variables set _dynamically_ at runtime and that are limited to _private_ access.
+ *
+ * | | Runtime | Build time |
+ * | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
+ * | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) |
+ * | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) |
+ *
+ * Dynamic environment variables are defined by the platform you're running on. For example if you're using [`adapter-node`](https://github.com/sveltejs/kit/tree/main/packages/adapter-node) (or running [`vite preview`](https://svelte.dev/docs/kit/cli)), this is equivalent to `process.env`.
+ *
+ * **_Private_ access:**
+ *
+ * - This module cannot be imported into client-side code
+ * - This module includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) _and do_ start with [`config.kit.env.privatePrefix`](https://svelte.dev/docs/kit/configuration#env) (if configured)
+ *
+ * > [!NOTE] In `dev`, `$env/dynamic` includes environment variables from `.env`. In `prod`, this behavior will depend on your adapter.
+ *
+ * > [!NOTE] To get correct types, environment variables referenced in your code should be declared (for example in an `.env` file), even if they don't have a value until the app is deployed:
+ * >
+ * > ```env
+ * > MY_FEATURE_FLAG=
+ * > ```
+ * >
+ * > You can override `.env` values from the command line like so:
+ * >
+ * > ```sh
+ * > MY_FEATURE_FLAG="enabled" npm run dev
+ * > ```
+ *
+ * For example, given the following runtime environment:
+ *
+ * ```env
+ * ENVIRONMENT=production
+ * PUBLIC_BASE_URL=http://site.com
+ * ```
+ *
+ * With the default `publicPrefix` and `privatePrefix`:
+ *
+ * ```ts
+ * import { env } from '$env/dynamic/private';
+ *
+ * console.log(env.ENVIRONMENT); // => "production"
+ * console.log(env.PUBLIC_BASE_URL); // => undefined
+ * ```
+ */
+declare module '$env/dynamic/private' {
+ export const env: {
+ SHELL: string;
+ npm_command: string;
+ COREPACK_ENABLE_AUTO_PIN: string;
+ WINDOWID: string;
+ COLORTERM: string;
+ XDG_SESSION_PATH: string;
+ TERM_PROGRAM_VERSION: string;
+ TMUX: string;
+ NODE: string;
+ LC_ADDRESS: string;
+ LC_NAME: string;
+ npm_config_local_prefix: string;
+ DESKTOP_SESSION: string;
+ LC_MONETARY: string;
+ GTK_MODULES: string;
+ XDG_SEAT: string;
+ PWD: string;
+ XDG_SESSION_DESKTOP: string;
+ LOGNAME: string;
+ XDG_SESSION_TYPE: string;
+ XAUTHORITY: string;
+ NoDefaultCurrentDirectoryInExePath: string;
+ XDG_GREETER_DATA_DIR: string;
+ CLAUDECODE: string;
+ MOTD_SHOWN: string;
+ HOME: string;
+ LC_PAPER: string;
+ LANG: string;
+ LS_COLORS: string;
+ npm_package_version: string;
+ VTE_VERSION: string;
+ XDG_SEAT_PATH: string;
+ npm_lifecycle_script: string;
+ NVM_DIR: string;
+ XDG_SESSION_CLASS: string;
+ TERM: string;
+ LC_IDENTIFICATION: string;
+ npm_package_name: string;
+ USER: string;
+ TMUX_PANE: string;
+ PAM_KWALLET5_LOGIN: string;
+ DISPLAY: string;
+ npm_lifecycle_event: string;
+ SHLVL: string;
+ GIT_EDITOR: string;
+ LC_TELEPHONE: string;
+ LC_MEASUREMENT: string;
+ XDG_VTNR: string;
+ XDG_SESSION_ID: string;
+ npm_config_user_agent: string;
+ OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: string;
+ npm_execpath: string;
+ LD_LIBRARY_PATH: string;
+ XDG_RUNTIME_DIR: string;
+ CLAUDE_CODE_ENTRYPOINT: string;
+ DEBUGINFOD_URLS: string;
+ npm_package_json: string;
+ LC_TIME: string;
+ CUDA_HOME: string;
+ GTK3_MODULES: string;
+ GCC_COLORS: string;
+ PATH: string;
+ GDMSESSION: string;
+ DBUS_SESSION_BUS_ADDRESS: string;
+ MAIL: string;
+ npm_node_execpath: string;
+ LC_NUMERIC: string;
+ OLDPWD: string;
+ TERM_PROGRAM: string;
+ _: string;
+ NODE_ENV: string;
+ [key: `PUBLIC_${string}`]: undefined;
+ [key: `${string}`]: string | undefined;
+ }
+}
+
+/**
+ * This module provides access to environment variables set _dynamically_ at runtime and that are _publicly_ accessible.
+ *
+ * | | Runtime | Build time |
+ * | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
+ * | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) |
+ * | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) |
+ *
+ * Dynamic environment variables are defined by the platform you're running on. For example if you're using [`adapter-node`](https://github.com/sveltejs/kit/tree/main/packages/adapter-node) (or running [`vite preview`](https://svelte.dev/docs/kit/cli)), this is equivalent to `process.env`.
+ *
+ * **_Public_ access:**
+ *
+ * - This module _can_ be imported into client-side code
+ * - **Only** variables that begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) (which defaults to `PUBLIC_`) are included
+ *
+ * > [!NOTE] In `dev`, `$env/dynamic` includes environment variables from `.env`. In `prod`, this behavior will depend on your adapter.
+ *
+ * > [!NOTE] To get correct types, environment variables referenced in your code should be declared (for example in an `.env` file), even if they don't have a value until the app is deployed:
+ * >
+ * > ```env
+ * > MY_FEATURE_FLAG=
+ * > ```
+ * >
+ * > You can override `.env` values from the command line like so:
+ * >
+ * > ```sh
+ * > MY_FEATURE_FLAG="enabled" npm run dev
+ * > ```
+ *
+ * For example, given the following runtime environment:
+ *
+ * ```env
+ * ENVIRONMENT=production
+ * PUBLIC_BASE_URL=http://example.com
+ * ```
+ *
+ * With the default `publicPrefix` and `privatePrefix`:
+ *
+ * ```ts
+ * import { env } from '$env/dynamic/public';
+ * console.log(env.ENVIRONMENT); // => undefined, not public
+ * console.log(env.PUBLIC_BASE_URL); // => "http://example.com"
+ * ```
+ *
+ * ```
+ *
+ * ```
+ */
+declare module '$env/dynamic/public' {
+ export const env: {
+ [key: `PUBLIC_${string}`]: string | undefined;
+ }
+}
diff --git a/.svelte-kit/generated/client/app.js b/.svelte-kit/generated/client/app.js
new file mode 100644
index 0000000..cb58f81
--- /dev/null
+++ b/.svelte-kit/generated/client/app.js
@@ -0,0 +1,41 @@
+export { matchers } from './matchers.js';
+
+export const nodes = [
+ () => import('./nodes/0'),
+ () => import('./nodes/1'),
+ () => import('./nodes/2'),
+ () => import('./nodes/3'),
+ () => import('./nodes/4'),
+ () => import('./nodes/5'),
+ () => import('./nodes/6'),
+ () => import('./nodes/7'),
+ () => import('./nodes/8')
+];
+
+export const server_loads = [];
+
+export const dictionary = {
+ "/": [2],
+ "/channel/[id]": [3],
+ "/playlists": [4],
+ "/playlists/[id]": [5],
+ "/search": [6],
+ "/subscriptions": [7],
+ "/watch/[id]": [8]
+ };
+
+export const hooks = {
+ handleError: (({ error }) => { console.error(error) }),
+
+ reroute: (() => {}),
+ transport: {}
+};
+
+export const decoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.decode]));
+export const encoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.encode]));
+
+export const hash = false;
+
+export const decode = (type, value) => decoders[type](value);
+
+export { default as root } from '../root.js'; \ No newline at end of file
diff --git a/.svelte-kit/generated/client/matchers.js b/.svelte-kit/generated/client/matchers.js
new file mode 100644
index 0000000..f6bd30a
--- /dev/null
+++ b/.svelte-kit/generated/client/matchers.js
@@ -0,0 +1 @@
+export const matchers = {}; \ No newline at end of file
diff --git a/.svelte-kit/generated/client/nodes/0.js b/.svelte-kit/generated/client/nodes/0.js
new file mode 100644
index 0000000..fed1375
--- /dev/null
+++ b/.svelte-kit/generated/client/nodes/0.js
@@ -0,0 +1 @@
+export { default as component } from "../../../../src/routes/+layout.svelte"; \ No newline at end of file
diff --git a/.svelte-kit/generated/client/nodes/1.js b/.svelte-kit/generated/client/nodes/1.js
new file mode 100644
index 0000000..bf58bad
--- /dev/null
+++ b/.svelte-kit/generated/client/nodes/1.js
@@ -0,0 +1 @@
+export { default as component } from "../../../../node_modules/@sveltejs/kit/src/runtime/components/svelte-5/error.svelte"; \ No newline at end of file
diff --git a/.svelte-kit/generated/client/nodes/2.js b/.svelte-kit/generated/client/nodes/2.js
new file mode 100644
index 0000000..1cb4f85
--- /dev/null
+++ b/.svelte-kit/generated/client/nodes/2.js
@@ -0,0 +1 @@
+export { default as component } from "../../../../src/routes/+page.svelte"; \ No newline at end of file
diff --git a/.svelte-kit/generated/client/nodes/3.js b/.svelte-kit/generated/client/nodes/3.js
new file mode 100644
index 0000000..dc86f74
--- /dev/null
+++ b/.svelte-kit/generated/client/nodes/3.js
@@ -0,0 +1 @@
+export { default as component } from "../../../../src/routes/channel/[id]/+page.svelte"; \ No newline at end of file
diff --git a/.svelte-kit/generated/client/nodes/4.js b/.svelte-kit/generated/client/nodes/4.js
new file mode 100644
index 0000000..fc083af
--- /dev/null
+++ b/.svelte-kit/generated/client/nodes/4.js
@@ -0,0 +1 @@
+export { default as component } from "../../../../src/routes/playlists/+page.svelte"; \ No newline at end of file
diff --git a/.svelte-kit/generated/client/nodes/5.js b/.svelte-kit/generated/client/nodes/5.js
new file mode 100644
index 0000000..9e322be
--- /dev/null
+++ b/.svelte-kit/generated/client/nodes/5.js
@@ -0,0 +1 @@
+export { default as component } from "../../../../src/routes/playlists/[id]/+page.svelte"; \ No newline at end of file
diff --git a/.svelte-kit/generated/client/nodes/6.js b/.svelte-kit/generated/client/nodes/6.js
new file mode 100644
index 0000000..f0df5ad
--- /dev/null
+++ b/.svelte-kit/generated/client/nodes/6.js
@@ -0,0 +1 @@
+export { default as component } from "../../../../src/routes/search/+page.svelte"; \ No newline at end of file
diff --git a/.svelte-kit/generated/client/nodes/7.js b/.svelte-kit/generated/client/nodes/7.js
new file mode 100644
index 0000000..c06163f
--- /dev/null
+++ b/.svelte-kit/generated/client/nodes/7.js
@@ -0,0 +1 @@
+export { default as component } from "../../../../src/routes/subscriptions/+page.svelte"; \ No newline at end of file
diff --git a/.svelte-kit/generated/client/nodes/8.js b/.svelte-kit/generated/client/nodes/8.js
new file mode 100644
index 0000000..6e8ceff
--- /dev/null
+++ b/.svelte-kit/generated/client/nodes/8.js
@@ -0,0 +1 @@
+export { default as component } from "../../../../src/routes/watch/[id]/+page.svelte"; \ No newline at end of file
diff --git a/.svelte-kit/generated/root.js b/.svelte-kit/generated/root.js
new file mode 100644
index 0000000..4d1e892
--- /dev/null
+++ b/.svelte-kit/generated/root.js
@@ -0,0 +1,3 @@
+import { asClassComponent } from 'svelte/legacy';
+import Root from './root.svelte';
+export default asClassComponent(Root); \ No newline at end of file
diff --git a/.svelte-kit/generated/root.svelte b/.svelte-kit/generated/root.svelte
new file mode 100644
index 0000000..0795183
--- /dev/null
+++ b/.svelte-kit/generated/root.svelte
@@ -0,0 +1,68 @@
+<!-- This file is generated by @sveltejs/kit — do not edit it! -->
+<svelte:options runes={true} />
+<script>
+ import { setContext, onMount, tick } from 'svelte';
+ import { browser } from '$app/environment';
+
+ // stores
+ let { stores, page, constructors, components = [], form, data_0 = null, data_1 = null } = $props();
+
+ if (!browser) {
+ // svelte-ignore state_referenced_locally
+ setContext('__svelte__', stores);
+ }
+
+ if (browser) {
+ $effect.pre(() => stores.page.set(page));
+ } else {
+ // svelte-ignore state_referenced_locally
+ stores.page.set(page);
+ }
+ $effect(() => {
+ stores;page;constructors;components;form;data_0;data_1;
+ stores.page.notify();
+ });
+
+ let mounted = $state(false);
+ let navigated = $state(false);
+ let title = $state(null);
+
+ onMount(() => {
+ const unsubscribe = stores.page.subscribe(() => {
+ if (mounted) {
+ navigated = true;
+ tick().then(() => {
+ title = document.title || 'untitled page';
+ });
+ }
+ });
+
+ mounted = true;
+ return unsubscribe;
+ });
+
+ const Pyramid_1=$derived(constructors[1])
+</script>
+
+{#if constructors[1]}
+ {@const Pyramid_0 = constructors[0]}
+ <!-- svelte-ignore binding_property_non_reactive -->
+ <Pyramid_0 bind:this={components[0]} data={data_0} {form} params={page.params}>
+ <!-- svelte-ignore binding_property_non_reactive -->
+ <Pyramid_1 bind:this={components[1]} data={data_1} {form} params={page.params} />
+ </Pyramid_0>
+
+{:else}
+ {@const Pyramid_0 = constructors[0]}
+ <!-- svelte-ignore binding_property_non_reactive -->
+ <Pyramid_0 bind:this={components[0]} data={data_0} {form} params={page.params} />
+
+{/if}
+
+{#if mounted}
+ <div id="svelte-announcer" aria-live="assertive" aria-atomic="true" style="position: absolute; left: 0; top: 0; clip: rect(0 0 0 0); clip-path: inset(50%); overflow: hidden; white-space: nowrap; width: 1px; height: 1px">
+ {#if navigated}
+ {title}
+ {/if}
+ </div>
+{/if} \ No newline at end of file
diff --git a/.svelte-kit/generated/server/internal.js b/.svelte-kit/generated/server/internal.js
new file mode 100644
index 0000000..bc65535
--- /dev/null
+++ b/.svelte-kit/generated/server/internal.js
@@ -0,0 +1,53 @@
+
+import root from '../root.js';
+import { set_building, set_prerendering } from '__sveltekit/environment';
+import { set_assets } from '$app/paths/internal/server';
+import { set_manifest, set_read_implementation } from '__sveltekit/server';
+import { set_private_env, set_public_env } from '../../../node_modules/@sveltejs/kit/src/runtime/shared-server.js';
+
+export const options = {
+ app_template_contains_nonce: false,
+ async: false,
+ csp: {"mode":"auto","directives":{"upgrade-insecure-requests":false,"block-all-mixed-content":false},"reportOnly":{"upgrade-insecure-requests":false,"block-all-mixed-content":false}},
+ csrf_check_origin: true,
+ csrf_trusted_origins: [],
+ embedded: false,
+ env_public_prefix: 'PUBLIC_',
+ env_private_prefix: '',
+ hash_routing: false,
+ hooks: null, // added lazily, via `get_hooks`
+ preload_strategy: "modulepreload",
+ root,
+ service_worker: false,
+ service_worker_options: undefined,
+ templates: {
+ app: ({ head, body, assets, nonce, env }) => "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <link rel=\"icon\" href=\"" + assets + "/favicon.png\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n " + head + "\n </head>\n <body data-sveltekit-preload-data=\"hover\">\n <div style=\"display: contents\">" + body + "</div>\n </body>\n</html>\n",
+ error: ({ status, message }) => "<!doctype html>\n<html lang=\"en\">\n\t<head>\n\t\t<meta charset=\"utf-8\" />\n\t\t<title>" + message + "</title>\n\n\t\t<style>\n\t\t\tbody {\n\t\t\t\t--bg: white;\n\t\t\t\t--fg: #222;\n\t\t\t\t--divider: #ccc;\n\t\t\t\tbackground: var(--bg);\n\t\t\t\tcolor: var(--fg);\n\t\t\t\tfont-family:\n\t\t\t\t\tsystem-ui,\n\t\t\t\t\t-apple-system,\n\t\t\t\t\tBlinkMacSystemFont,\n\t\t\t\t\t'Segoe UI',\n\t\t\t\t\tRoboto,\n\t\t\t\t\tOxygen,\n\t\t\t\t\tUbuntu,\n\t\t\t\t\tCantarell,\n\t\t\t\t\t'Open Sans',\n\t\t\t\t\t'Helvetica Neue',\n\t\t\t\t\tsans-serif;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tjustify-content: center;\n\t\t\t\theight: 100vh;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t.error {\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tmax-width: 32rem;\n\t\t\t\tmargin: 0 1rem;\n\t\t\t}\n\n\t\t\t.status {\n\t\t\t\tfont-weight: 200;\n\t\t\t\tfont-size: 3rem;\n\t\t\t\tline-height: 1;\n\t\t\t\tposition: relative;\n\t\t\t\ttop: -0.05rem;\n\t\t\t}\n\n\t\t\t.message {\n\t\t\t\tborder-left: 1px solid var(--divider);\n\t\t\t\tpadding: 0 0 0 1rem;\n\t\t\t\tmargin: 0 0 0 1rem;\n\t\t\t\tmin-height: 2.5rem;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t}\n\n\t\t\t.message h1 {\n\t\t\t\tfont-weight: 400;\n\t\t\t\tfont-size: 1em;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t@media (prefers-color-scheme: dark) {\n\t\t\t\tbody {\n\t\t\t\t\t--bg: #222;\n\t\t\t\t\t--fg: #ddd;\n\t\t\t\t\t--divider: #666;\n\t\t\t\t}\n\t\t\t}\n\t\t</style>\n\t</head>\n\t<body>\n\t\t<div class=\"error\">\n\t\t\t<span class=\"status\">" + status + "</span>\n\t\t\t<div class=\"message\">\n\t\t\t\t<h1>" + message + "</h1>\n\t\t\t</div>\n\t\t</div>\n\t</body>\n</html>\n"
+ },
+ version_hash: "v2dt08"
+};
+
+export async function get_hooks() {
+ let handle;
+ let handleFetch;
+ let handleError;
+ let handleValidationError;
+ let init;
+
+
+ let reroute;
+ let transport;
+
+
+ return {
+ handle,
+ handleFetch,
+ handleError,
+ handleValidationError,
+ init,
+ reroute,
+ transport
+ };
+}
+
+export { set_assets, set_building, set_manifest, set_prerendering, set_private_env, set_public_env, set_read_implementation };
diff --git a/.svelte-kit/non-ambient.d.ts b/.svelte-kit/non-ambient.d.ts
new file mode 100644
index 0000000..a76a2ec
--- /dev/null
+++ b/.svelte-kit/non-ambient.d.ts
@@ -0,0 +1,64 @@
+
+// this file is generated — do not edit it
+
+
+declare module "svelte/elements" {
+ export interface HTMLAttributes<T> {
+ 'data-sveltekit-keepfocus'?: true | '' | 'off' | undefined | null;
+ 'data-sveltekit-noscroll'?: true | '' | 'off' | undefined | null;
+ 'data-sveltekit-preload-code'?:
+ | true
+ | ''
+ | 'eager'
+ | 'viewport'
+ | 'hover'
+ | 'tap'
+ | 'off'
+ | undefined
+ | null;
+ 'data-sveltekit-preload-data'?: true | '' | 'hover' | 'tap' | 'off' | undefined | null;
+ 'data-sveltekit-reload'?: true | '' | 'off' | undefined | null;
+ 'data-sveltekit-replacestate'?: true | '' | 'off' | undefined | null;
+ }
+}
+
+export {};
+
+
+declare module "$app/types" {
+ export interface AppTypes {
+ RouteId(): "/" | "/api" | "/api/channel" | "/api/channel/[id]" | "/api/playlist" | "/api/related" | "/api/related/[id]" | "/api/search" | "/api/trending" | "/api/video" | "/api/video/[id]" | "/channel" | "/channel/[id]" | "/playlists" | "/playlists/[id]" | "/search" | "/subscriptions" | "/watch" | "/watch/[id]";
+ RouteParams(): {
+ "/api/channel/[id]": { id: string };
+ "/api/related/[id]": { id: string };
+ "/api/video/[id]": { id: string };
+ "/channel/[id]": { id: string };
+ "/playlists/[id]": { id: string };
+ "/watch/[id]": { id: string }
+ };
+ LayoutParams(): {
+ "/": { id?: string };
+ "/api": { id?: string };
+ "/api/channel": { id?: string };
+ "/api/channel/[id]": { id: string };
+ "/api/playlist": Record<string, never>;
+ "/api/related": { id?: string };
+ "/api/related/[id]": { id: string };
+ "/api/search": Record<string, never>;
+ "/api/trending": Record<string, never>;
+ "/api/video": { id?: string };
+ "/api/video/[id]": { id: string };
+ "/channel": { id?: string };
+ "/channel/[id]": { id: string };
+ "/playlists": { id?: string };
+ "/playlists/[id]": { id: string };
+ "/search": Record<string, never>;
+ "/subscriptions": Record<string, never>;
+ "/watch": { id?: string };
+ "/watch/[id]": { id: string }
+ };
+ Pathname(): "/" | `/api/channel/${string}` & {} | "/api/playlist" | `/api/related/${string}` & {} | "/api/search" | "/api/trending" | `/api/video/${string}` & {} | `/channel/${string}` & {} | "/playlists" | `/playlists/${string}` & {} | "/search" | "/subscriptions" | `/watch/${string}` & {};
+ ResolvedPathname(): `${"" | `/${string}`}${ReturnType<AppTypes['Pathname']>}`;
+ Asset(): string & {};
+ }
+} \ No newline at end of file
diff --git a/.svelte-kit/tsconfig.json b/.svelte-kit/tsconfig.json
new file mode 100644
index 0000000..7692388
--- /dev/null
+++ b/.svelte-kit/tsconfig.json
@@ -0,0 +1,55 @@
+{
+ "compilerOptions": {
+ "paths": {
+ "$lib": [
+ "../src/lib"
+ ],
+ "$lib/*": [
+ "../src/lib/*"
+ ],
+ "$app/types": [
+ "./types/index.d.ts"
+ ]
+ },
+ "rootDirs": [
+ "..",
+ "./types"
+ ],
+ "verbatimModuleSyntax": true,
+ "isolatedModules": true,
+ "lib": [
+ "esnext",
+ "DOM",
+ "DOM.Iterable"
+ ],
+ "moduleResolution": "bundler",
+ "module": "esnext",
+ "noEmit": true,
+ "target": "esnext"
+ },
+ "include": [
+ "ambient.d.ts",
+ "non-ambient.d.ts",
+ "./types/**/$types.d.ts",
+ "../vite.config.js",
+ "../vite.config.ts",
+ "../src/**/*.js",
+ "../src/**/*.ts",
+ "../src/**/*.svelte",
+ "../test/**/*.js",
+ "../test/**/*.ts",
+ "../test/**/*.svelte",
+ "../tests/**/*.js",
+ "../tests/**/*.ts",
+ "../tests/**/*.svelte"
+ ],
+ "exclude": [
+ "../node_modules/**",
+ "../src/service-worker.js",
+ "../src/service-worker/**/*.js",
+ "../src/service-worker.ts",
+ "../src/service-worker/**/*.ts",
+ "../src/service-worker.d.ts",
+ "../src/service-worker/**/*.d.ts"
+ ]
+} \ No newline at end of file
diff --git a/.svelte-kit/types/route_meta_data.json b/.svelte-kit/types/route_meta_data.json
new file mode 100644
index 0000000..8069098
--- /dev/null
+++ b/.svelte-kit/types/route_meta_data.json
@@ -0,0 +1,27 @@
+{
+ "/": [],
+ "/api/channel/[id]": [
+ "src/routes/api/channel/[id]/+server.ts"
+ ],
+ "/api/playlist": [
+ "src/routes/api/playlist/+server.ts"
+ ],
+ "/api/related/[id]": [
+ "src/routes/api/related/[id]/+server.ts"
+ ],
+ "/api/search": [
+ "src/routes/api/search/+server.ts"
+ ],
+ "/api/trending": [
+ "src/routes/api/trending/+server.ts"
+ ],
+ "/api/video/[id]": [
+ "src/routes/api/video/[id]/+server.ts"
+ ],
+ "/channel/[id]": [],
+ "/playlists": [],
+ "/playlists/[id]": [],
+ "/search": [],
+ "/subscriptions": [],
+ "/watch/[id]": []
+} \ No newline at end of file
diff --git a/.svelte-kit/types/src/routes/$types.d.ts b/.svelte-kit/types/src/routes/$types.d.ts
new file mode 100644
index 0000000..8f940ba
--- /dev/null
+++ b/.svelte-kit/types/src/routes/$types.d.ts
@@ -0,0 +1,24 @@
+import type * as Kit from '@sveltejs/kit';
+
+type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
+// @ts-ignore
+type MatcherParam<M> = M extends (param : string) => param is infer U ? U extends string ? U : string : string;
+type RouteParams = { };
+type RouteId = '/';
+type MaybeWithVoid<T> = {} extends T ? T | void : T;
+export type RequiredKeys<T> = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T];
+type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Partial<Pick<App.PageData, keyof T & keyof App.PageData>> & Record<string, any>>
+type EnsureDefined<T> = T extends null | undefined ? {} : T;
+type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never;
+export type Snapshot<T = any> = Kit.Snapshot<T>;
+type PageParentData = EnsureDefined<LayoutData>;
+type LayoutRouteId = RouteId | "/" | "/channel/[id]" | "/playlists" | "/playlists/[id]" | "/search" | "/subscriptions" | "/watch/[id]" | null
+type LayoutParams = RouteParams & { id?: string }
+type LayoutParentData = EnsureDefined<{}>;
+
+export type PageServerData = null;
+export type PageData = Expand<PageParentData>;
+export type PageProps = { params: RouteParams; data: PageData }
+export type LayoutServerData = null;
+export type LayoutData = Expand<LayoutParentData>;
+export type LayoutProps = { params: LayoutParams; data: LayoutData; children: import("svelte").Snippet } \ No newline at end of file
diff --git a/.svelte-kit/types/src/routes/api/channel/[id]/$types.d.ts b/.svelte-kit/types/src/routes/api/channel/[id]/$types.d.ts
new file mode 100644
index 0000000..0b6f3de
--- /dev/null
+++ b/.svelte-kit/types/src/routes/api/channel/[id]/$types.d.ts
@@ -0,0 +1,11 @@
+import type * as Kit from '@sveltejs/kit';
+
+type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
+// @ts-ignore
+type MatcherParam<M> = M extends (param : string) => param is infer U ? U extends string ? U : string : string;
+type RouteParams = { id: string };
+type RouteId = '/api/channel/[id]';
+
+export type EntryGenerator = () => Promise<Array<RouteParams>> | Array<RouteParams>;
+export type RequestHandler = Kit.RequestHandler<RouteParams, RouteId>;
+export type RequestEvent = Kit.RequestEvent<RouteParams, RouteId>; \ No newline at end of file
diff --git a/.svelte-kit/types/src/routes/api/playlist/$types.d.ts b/.svelte-kit/types/src/routes/api/playlist/$types.d.ts
new file mode 100644
index 0000000..b91f2ee
--- /dev/null
+++ b/.svelte-kit/types/src/routes/api/playlist/$types.d.ts
@@ -0,0 +1,10 @@
+import type * as Kit from '@sveltejs/kit';
+
+type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
+// @ts-ignore
+type MatcherParam<M> = M extends (param : string) => param is infer U ? U extends string ? U : string : string;
+type RouteParams = { };
+type RouteId = '/api/playlist';
+
+export type RequestHandler = Kit.RequestHandler<RouteParams, RouteId>;
+export type RequestEvent = Kit.RequestEvent<RouteParams, RouteId>; \ No newline at end of file
diff --git a/.svelte-kit/types/src/routes/api/related/[id]/$types.d.ts b/.svelte-kit/types/src/routes/api/related/[id]/$types.d.ts
new file mode 100644
index 0000000..a24dedb
--- /dev/null
+++ b/.svelte-kit/types/src/routes/api/related/[id]/$types.d.ts
@@ -0,0 +1,11 @@
+import type * as Kit from '@sveltejs/kit';
+
+type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
+// @ts-ignore
+type MatcherParam<M> = M extends (param : string) => param is infer U ? U extends string ? U : string : string;
+type RouteParams = { id: string };
+type RouteId = '/api/related/[id]';
+
+export type EntryGenerator = () => Promise<Array<RouteParams>> | Array<RouteParams>;
+export type RequestHandler = Kit.RequestHandler<RouteParams, RouteId>;
+export type RequestEvent = Kit.RequestEvent<RouteParams, RouteId>; \ No newline at end of file
diff --git a/.svelte-kit/types/src/routes/api/search/$types.d.ts b/.svelte-kit/types/src/routes/api/search/$types.d.ts
new file mode 100644
index 0000000..4a127e5
--- /dev/null
+++ b/.svelte-kit/types/src/routes/api/search/$types.d.ts
@@ -0,0 +1,10 @@
+import type * as Kit from '@sveltejs/kit';
+
+type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
+// @ts-ignore
+type MatcherParam<M> = M extends (param : string) => param is infer U ? U extends string ? U : string : string;
+type RouteParams = { };
+type RouteId = '/api/search';
+
+export type RequestHandler = Kit.RequestHandler<RouteParams, RouteId>;
+export type RequestEvent = Kit.RequestEvent<RouteParams, RouteId>; \ No newline at end of file
diff --git a/.svelte-kit/types/src/routes/api/trending/$types.d.ts b/.svelte-kit/types/src/routes/api/trending/$types.d.ts
new file mode 100644
index 0000000..c7b3fb9
--- /dev/null
+++ b/.svelte-kit/types/src/routes/api/trending/$types.d.ts
@@ -0,0 +1,10 @@
+import type * as Kit from '@sveltejs/kit';
+
+type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
+// @ts-ignore
+type MatcherParam<M> = M extends (param : string) => param is infer U ? U extends string ? U : string : string;
+type RouteParams = { };
+type RouteId = '/api/trending';
+
+export type RequestHandler = Kit.RequestHandler<RouteParams, RouteId>;
+export type RequestEvent = Kit.RequestEvent<RouteParams, RouteId>; \ No newline at end of file
diff --git a/.svelte-kit/types/src/routes/api/video/[id]/$types.d.ts b/.svelte-kit/types/src/routes/api/video/[id]/$types.d.ts
new file mode 100644
index 0000000..477abba
--- /dev/null
+++ b/.svelte-kit/types/src/routes/api/video/[id]/$types.d.ts
@@ -0,0 +1,11 @@
+import type * as Kit from '@sveltejs/kit';
+
+type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
+// @ts-ignore
+type MatcherParam<M> = M extends (param : string) => param is infer U ? U extends string ? U : string : string;
+type RouteParams = { id: string };
+type RouteId = '/api/video/[id]';
+
+export type EntryGenerator = () => Promise<Array<RouteParams>> | Array<RouteParams>;
+export type RequestHandler = Kit.RequestHandler<RouteParams, RouteId>;
+export type RequestEvent = Kit.RequestEvent<RouteParams, RouteId>; \ No newline at end of file
diff --git a/.svelte-kit/types/src/routes/channel/[id]/$types.d.ts b/.svelte-kit/types/src/routes/channel/[id]/$types.d.ts
new file mode 100644
index 0000000..46c79b1
--- /dev/null
+++ b/.svelte-kit/types/src/routes/channel/[id]/$types.d.ts
@@ -0,0 +1,19 @@
+import type * as Kit from '@sveltejs/kit';
+
+type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
+// @ts-ignore
+type MatcherParam<M> = M extends (param : string) => param is infer U ? U extends string ? U : string : string;
+type RouteParams = { id: string };
+type RouteId = '/channel/[id]';
+type MaybeWithVoid<T> = {} extends T ? T | void : T;
+export type RequiredKeys<T> = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T];
+type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Partial<Pick<App.PageData, keyof T & keyof App.PageData>> & Record<string, any>>
+type EnsureDefined<T> = T extends null | undefined ? {} : T;
+type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never;
+export type Snapshot<T = any> = Kit.Snapshot<T>;
+type PageParentData = EnsureDefined<import('../../$types.js').LayoutData>;
+
+export type EntryGenerator = () => Promise<Array<RouteParams>> | Array<RouteParams>;
+export type PageServerData = null;
+export type PageData = Expand<PageParentData>;
+export type PageProps = { params: RouteParams; data: PageData } \ No newline at end of file
diff --git a/.svelte-kit/types/src/routes/playlists/$types.d.ts b/.svelte-kit/types/src/routes/playlists/$types.d.ts
new file mode 100644
index 0000000..c58d33a
--- /dev/null
+++ b/.svelte-kit/types/src/routes/playlists/$types.d.ts
@@ -0,0 +1,18 @@
+import type * as Kit from '@sveltejs/kit';
+
+type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
+// @ts-ignore
+type MatcherParam<M> = M extends (param : string) => param is infer U ? U extends string ? U : string : string;
+type RouteParams = { };
+type RouteId = '/playlists';
+type MaybeWithVoid<T> = {} extends T ? T | void : T;
+export type RequiredKeys<T> = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T];
+type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Partial<Pick<App.PageData, keyof T & keyof App.PageData>> & Record<string, any>>
+type EnsureDefined<T> = T extends null | undefined ? {} : T;
+type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never;
+export type Snapshot<T = any> = Kit.Snapshot<T>;
+type PageParentData = EnsureDefined<import('../$types.js').LayoutData>;
+
+export type PageServerData = null;
+export type PageData = Expand<PageParentData>;
+export type PageProps = { params: RouteParams; data: PageData } \ No newline at end of file
diff --git a/.svelte-kit/types/src/routes/playlists/[id]/$types.d.ts b/.svelte-kit/types/src/routes/playlists/[id]/$types.d.ts
new file mode 100644
index 0000000..34da3e5
--- /dev/null
+++ b/.svelte-kit/types/src/routes/playlists/[id]/$types.d.ts
@@ -0,0 +1,19 @@
+import type * as Kit from '@sveltejs/kit';
+
+type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
+// @ts-ignore
+type MatcherParam<M> = M extends (param : string) => param is infer U ? U extends string ? U : string : string;
+type RouteParams = { id: string };
+type RouteId = '/playlists/[id]';
+type MaybeWithVoid<T> = {} extends T ? T | void : T;
+export type RequiredKeys<T> = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T];
+type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Partial<Pick<App.PageData, keyof T & keyof App.PageData>> & Record<string, any>>
+type EnsureDefined<T> = T extends null | undefined ? {} : T;
+type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never;
+export type Snapshot<T = any> = Kit.Snapshot<T>;
+type PageParentData = EnsureDefined<import('../../$types.js').LayoutData>;
+
+export type EntryGenerator = () => Promise<Array<RouteParams>> | Array<RouteParams>;
+export type PageServerData = null;
+export type PageData = Expand<PageParentData>;
+export type PageProps = { params: RouteParams; data: PageData } \ No newline at end of file
diff --git a/.svelte-kit/types/src/routes/search/$types.d.ts b/.svelte-kit/types/src/routes/search/$types.d.ts
new file mode 100644
index 0000000..1edc981
--- /dev/null
+++ b/.svelte-kit/types/src/routes/search/$types.d.ts
@@ -0,0 +1,18 @@
+import type * as Kit from '@sveltejs/kit';
+
+type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
+// @ts-ignore
+type MatcherParam<M> = M extends (param : string) => param is infer U ? U extends string ? U : string : string;
+type RouteParams = { };
+type RouteId = '/search';
+type MaybeWithVoid<T> = {} extends T ? T | void : T;
+export type RequiredKeys<T> = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T];
+type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Partial<Pick<App.PageData, keyof T & keyof App.PageData>> & Record<string, any>>
+type EnsureDefined<T> = T extends null | undefined ? {} : T;
+type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never;
+export type Snapshot<T = any> = Kit.Snapshot<T>;
+type PageParentData = EnsureDefined<import('../$types.js').LayoutData>;
+
+export type PageServerData = null;
+export type PageData = Expand<PageParentData>;
+export type PageProps = { params: RouteParams; data: PageData } \ No newline at end of file
diff --git a/.svelte-kit/types/src/routes/subscriptions/$types.d.ts b/.svelte-kit/types/src/routes/subscriptions/$types.d.ts
new file mode 100644
index 0000000..ffccbab
--- /dev/null
+++ b/.svelte-kit/types/src/routes/subscriptions/$types.d.ts
@@ -0,0 +1,18 @@
+import type * as Kit from '@sveltejs/kit';
+
+type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
+// @ts-ignore
+type MatcherParam<M> = M extends (param : string) => param is infer U ? U extends string ? U : string : string;
+type RouteParams = { };
+type RouteId = '/subscriptions';
+type MaybeWithVoid<T> = {} extends T ? T | void : T;
+export type RequiredKeys<T> = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T];
+type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Partial<Pick<App.PageData, keyof T & keyof App.PageData>> & Record<string, any>>
+type EnsureDefined<T> = T extends null | undefined ? {} : T;
+type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never;
+export type Snapshot<T = any> = Kit.Snapshot<T>;
+type PageParentData = EnsureDefined<import('../$types.js').LayoutData>;
+
+export type PageServerData = null;
+export type PageData = Expand<PageParentData>;
+export type PageProps = { params: RouteParams; data: PageData } \ No newline at end of file
diff --git a/.svelte-kit/types/src/routes/watch/[id]/$types.d.ts b/.svelte-kit/types/src/routes/watch/[id]/$types.d.ts
new file mode 100644
index 0000000..5b87eac
--- /dev/null
+++ b/.svelte-kit/types/src/routes/watch/[id]/$types.d.ts
@@ -0,0 +1,19 @@
+import type * as Kit from '@sveltejs/kit';
+
+type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
+// @ts-ignore
+type MatcherParam<M> = M extends (param : string) => param is infer U ? U extends string ? U : string : string;
+type RouteParams = { id: string };
+type RouteId = '/watch/[id]';
+type MaybeWithVoid<T> = {} extends T ? T | void : T;
+export type RequiredKeys<T> = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T];
+type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Partial<Pick<App.PageData, keyof T & keyof App.PageData>> & Record<string, any>>
+type EnsureDefined<T> = T extends null | undefined ? {} : T;
+type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never;
+export type Snapshot<T = any> = Kit.Snapshot<T>;
+type PageParentData = EnsureDefined<import('../../$types.js').LayoutData>;
+
+export type EntryGenerator = () => Promise<Array<RouteParams>> | Array<RouteParams>;
+export type PageServerData = null;
+export type PageData = Expand<PageParentData>;
+export type PageProps = { params: RouteParams; data: PageData } \ No newline at end of file
diff --git a/bun.lock b/bun.lock
new file mode 100644
index 0000000..3cee9bb
--- /dev/null
+++ b/bun.lock
@@ -0,0 +1,241 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "actualyt",
+ "dependencies": {
+ "idb-keyval": "^6.2.1",
+ },
+ "devDependencies": {
+ "@sveltejs/adapter-auto": "^3.0.0",
+ "@sveltejs/kit": "^2.0.0",
+ "@sveltejs/vite-plugin-svelte": "^5.0.0",
+ "@types/node": "^25.3.5",
+ "svelte": "^5.0.0",
+ "svelte-check": "^4.0.0",
+ "typescript": "^5.0.0",
+ "vite": "^6.0.0",
+ },
+ },
+ },
+ "packages": {
+ "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
+
+ "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
+
+ "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
+
+ "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
+
+ "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
+
+ "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
+
+ "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
+
+ "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
+
+ "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
+
+ "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
+
+ "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
+
+ "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
+
+ "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
+
+ "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
+
+ "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
+
+ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
+
+ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
+
+ "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
+
+ "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
+
+ "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
+
+ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
+
+ "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
+
+ "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
+
+ "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
+
+ "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
+
+ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
+
+ "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
+
+ "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
+
+ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
+
+ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
+
+ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+
+ "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
+
+ "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
+
+ "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
+
+ "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="],
+
+ "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="],
+
+ "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="],
+
+ "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="],
+
+ "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="],
+
+ "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="],
+
+ "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="],
+
+ "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="],
+
+ "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="],
+
+ "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="],
+
+ "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="],
+
+ "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="],
+
+ "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="],
+
+ "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="],
+
+ "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="],
+
+ "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="],
+
+ "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="],
+
+ "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="],
+
+ "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="],
+
+ "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="],
+
+ "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="],
+
+ "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="],
+
+ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="],
+
+ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+
+ "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.9", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="],
+
+ "@sveltejs/adapter-auto": ["@sveltejs/adapter-auto@3.3.1", "", { "dependencies": { "import-meta-resolve": "^4.1.0" }, "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-5Sc7WAxYdL6q9j/+D0jJKjGREGlfIevDyHSQ2eNETHcB1TKlQWHcAo8AS8H1QdjNvSXpvOwNjykDUHPEAyGgdQ=="],
+
+ "@sveltejs/kit": ["@sveltejs/kit@2.53.4", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.6.3", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-iAIPEahFgDJJyvz8g0jP08KvqnM6JvdW8YfsygZ+pMeMvyM2zssWMltcsotETvjSZ82G3VlitgDtBIvpQSZrTA=="],
+
+ "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@5.1.1", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.17", "vitefu": "^1.0.6" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ=="],
+
+ "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@4.0.1", "", { "dependencies": { "debug": "^4.3.7" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.0", "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw=="],
+
+ "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
+
+ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
+
+ "@types/node": ["@types/node@25.3.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA=="],
+
+ "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
+
+ "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
+
+ "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
+
+ "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
+
+ "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
+
+ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
+
+ "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
+
+ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+
+ "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
+
+ "devalue": ["devalue@5.6.3", "", {}, "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg=="],
+
+ "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
+
+ "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
+
+ "esrap": ["esrap@2.2.3", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ=="],
+
+ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
+
+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "idb-keyval": ["idb-keyval@6.2.2", "", {}, "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg=="],
+
+ "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
+
+ "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
+
+ "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
+
+ "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
+
+ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+
+ "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
+
+ "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
+
+ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
+ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
+
+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
+
+ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
+
+ "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
+
+ "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
+
+ "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
+
+ "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
+
+ "set-cookie-parser": ["set-cookie-parser@3.0.1", "", {}, "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q=="],
+
+ "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
+
+ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+
+ "svelte": ["svelte@5.53.7", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.3", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-uxck1KI7JWtlfP3H6HOWi/94soAl23jsGJkBzN2BAWcQng0+lTrRNhxActFqORgnO9BHVd1hKJhG+ljRuIUWfQ=="],
+
+ "svelte-check": ["svelte-check@4.4.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw=="],
+
+ "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
+
+ "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
+
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+ "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
+
+ "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
+
+ "vitefu": ["vitefu@1.1.2", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw=="],
+
+ "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..d255b18
--- /dev/null
+++ b/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "actualyt",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "vite dev",
+ "build": "vite build",
+ "preview": "vite preview",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
+ },
+ "devDependencies": {
+ "@sveltejs/adapter-auto": "^3.0.0",
+ "@sveltejs/kit": "^2.0.0",
+ "@sveltejs/vite-plugin-svelte": "^5.0.0",
+ "@types/node": "^25.3.5",
+ "svelte": "^5.0.0",
+ "svelte-check": "^4.0.0",
+ "typescript": "^5.0.0",
+ "vite": "^6.0.0"
+ },
+ "dependencies": {
+ "idb-keyval": "^6.2.1"
+ },
+ "type": "module"
+}
diff --git a/src/app.css b/src/app.css
new file mode 100644
index 0000000..dd23a9e
--- /dev/null
+++ b/src/app.css
@@ -0,0 +1,159 @@
+:root {
+ --bg-primary: #0f0f0f;
+ --bg-secondary: #1a1a1a;
+ --bg-hover: #252525;
+ --text-primary: #f1f1f1;
+ --text-secondary: #aaa;
+ --text-muted: #717171;
+ --border-color: #303030;
+ --accent-color: #3ea6ff;
+ --error-color: #ff4444;
+ --max-width: 1400px;
+}
+
+[data-theme="light"] {
+ --bg-primary: #ffffff;
+ --bg-secondary: #f9f9f9;
+ --bg-hover: #f0f0f0;
+ --text-primary: #0f0f0f;
+ --text-secondary: #606060;
+ --text-muted: #909090;
+ --border-color: #e5e5e5;
+}
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+html {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+body {
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ min-height: 100vh;
+ line-height: 1.5;
+}
+
+a {
+ color: inherit;
+}
+
+button {
+ font-family: inherit;
+}
+
+img {
+ max-width: 100%;
+ height: auto;
+}
+
+.container {
+ max-width: var(--max-width);
+ margin: 0 auto;
+ padding: 0 1rem;
+}
+
+.video-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 1.5rem;
+}
+
+.section-title {
+ font-size: 1.25rem;
+ font-weight: 600;
+ margin-bottom: 1.5rem;
+ color: var(--text-primary);
+}
+
+.loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 4rem 0;
+ color: var(--text-muted);
+}
+
+.loading::after {
+ content: '';
+ width: 24px;
+ height: 24px;
+ margin-left: 0.75rem;
+ border: 2px solid var(--border-color);
+ border-top-color: var(--accent-color);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.error {
+ padding: 2rem;
+ text-align: center;
+ color: var(--error-color);
+}
+
+.empty {
+ padding: 4rem 2rem;
+ text-align: center;
+ color: var(--text-muted);
+}
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.5rem 1rem;
+ border: none;
+ border-radius: 4px;
+ font-size: 0.9rem;
+ font-weight: 500;
+ cursor: pointer;
+ text-decoration: none;
+ transition: opacity 0.15s ease;
+}
+
+.btn:hover {
+ opacity: 0.9;
+}
+
+.btn-primary {
+ background: var(--accent-color);
+ color: #fff;
+}
+
+.btn-secondary {
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ border: 1px solid var(--border-color);
+}
+
+.btn-danger {
+ background: var(--error-color);
+ color: #fff;
+}
+
+@media (max-width: 768px) {
+ .video-grid {
+ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
+ gap: 1rem;
+ }
+}
+
+@media (max-width: 480px) {
+ .video-grid {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/src/app.d.ts b/src/app.d.ts
new file mode 100644
index 0000000..e6bfd9f
--- /dev/null
+++ b/src/app.d.ts
@@ -0,0 +1,13 @@
+/// <reference types="@sveltejs/kit" />
+
+declare global {
+ namespace App {
+ // interface Error {}
+ // interface Locals {}
+ // interface PageData {}
+ // interface PageState {}
+ // interface Platform {}
+ }
+}
+
+export {};
diff --git a/src/app.html b/src/app.html
new file mode 100644
index 0000000..84ffad1
--- /dev/null
+++ b/src/app.html
@@ -0,0 +1,12 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <link rel="icon" href="%sveltekit.assets%/favicon.png" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ %sveltekit.head%
+ </head>
+ <body data-sveltekit-preload-data="hover">
+ <div style="display: contents">%sveltekit.body%</div>
+ </body>
+</html>
diff --git a/src/lib/api/youtube.ts b/src/lib/api/youtube.ts
new file mode 100644
index 0000000..8ade3c5
--- /dev/null
+++ b/src/lib/api/youtube.ts
@@ -0,0 +1,253 @@
+export interface VideoThumbnail {
+ url: string;
+ width?: number;
+ height?: number;
+}
+
+export interface VideoInfo {
+ videoId: string;
+ title: string;
+ author: string;
+ authorId: string;
+ videoThumbnails: VideoThumbnail[];
+ description: string;
+ viewCount: number;
+ publishedText: string;
+ lengthSeconds: number;
+}
+
+export interface VideoFormat {
+ formatId: string;
+ url: string;
+ ext: string;
+ resolution?: string;
+ height?: number;
+ width?: number;
+ qualityLabel?: string;
+}
+
+export interface VideoDetails extends VideoInfo {
+ likeCount?: number;
+ formats: VideoFormat[];
+}
+
+export interface ChannelInfo {
+ author: string;
+ authorId: string;
+ authorThumbnails: VideoThumbnail[];
+ subCount: number;
+ description: string;
+ videos: VideoInfo[];
+}
+
+interface YtdlpSearchResult {
+ id: string;
+ title: string;
+ channel: string;
+ channel_id: string;
+ thumbnail: string;
+ thumbnails: { url: string; width?: number; height?: number }[];
+ duration: number;
+ view_count: number;
+ upload_date: string;
+}
+
+interface YtdlpVideo {
+ id: string;
+ title: string;
+ description: string;
+ channel: string;
+ channel_id: string;
+ thumbnail: string;
+ thumbnails: { url: string; width?: number; height?: number }[];
+ duration: number;
+ view_count: number;
+ upload_date: string;
+ like_count?: number;
+ formats: {
+ format_id: string;
+ url: string;
+ ext: string;
+ resolution?: string;
+ height?: number;
+ width?: number;
+ format_note?: string;
+ }[];
+}
+
+interface YtdlpChannel {
+ id: string;
+ channel: string;
+ channel_id: string;
+ description: string;
+ channel_follower_count: number;
+ thumbnails: { url: string; width?: number; height?: number }[];
+ entries?: YtdlpSearchResult[];
+}
+
+function formatUploadDate(dateStr: string): string {
+ if (!dateStr || dateStr.length !== 8) return '';
+ const year = dateStr.slice(0, 4);
+ const month = dateStr.slice(4, 6);
+ const day = dateStr.slice(6, 8);
+ const date = new Date(`${year}-${month}-${day}`);
+ const now = new Date();
+ const diff = now.getTime() - date.getTime();
+ const days = Math.floor(diff / (1000 * 60 * 60 * 24));
+
+ if (days === 0) return 'Today';
+ if (days === 1) return 'Yesterday';
+ if (days < 7) return `${days} days ago`;
+ if (days < 30) return `${Math.floor(days / 7)} weeks ago`;
+ if (days < 365) return `${Math.floor(days / 30)} months ago`;
+ return `${Math.floor(days / 365)} years ago`;
+}
+
+function transformSearchResult(item: YtdlpSearchResult): VideoInfo {
+ return {
+ videoId: item.id,
+ title: item.title,
+ author: item.channel,
+ authorId: item.channel_id,
+ videoThumbnails: item.thumbnails?.length
+ ? item.thumbnails
+ : [{ url: item.thumbnail }],
+ description: '',
+ viewCount: item.view_count || 0,
+ publishedText: formatUploadDate(item.upload_date),
+ lengthSeconds: item.duration || 0
+ };
+}
+
+function transformVideo(item: YtdlpVideo): VideoDetails {
+ return {
+ videoId: item.id,
+ title: item.title,
+ author: item.channel,
+ authorId: item.channel_id,
+ videoThumbnails: item.thumbnails?.length
+ ? item.thumbnails
+ : [{ url: item.thumbnail }],
+ description: item.description || '',
+ viewCount: item.view_count || 0,
+ publishedText: formatUploadDate(item.upload_date),
+ lengthSeconds: item.duration || 0,
+ likeCount: item.like_count,
+ formats: item.formats.map(f => ({
+ formatId: f.format_id,
+ url: f.url,
+ ext: f.ext,
+ resolution: f.resolution,
+ height: f.height,
+ width: f.width,
+ qualityLabel: f.format_note || (f.height ? `${f.height}p` : undefined)
+ }))
+ };
+}
+
+function transformChannel(item: YtdlpChannel): ChannelInfo {
+ return {
+ author: item.channel,
+ authorId: item.channel_id,
+ authorThumbnails: item.thumbnails || [],
+ subCount: item.channel_follower_count || 0,
+ description: item.description || '',
+ videos: (item.entries || []).map(transformSearchResult)
+ };
+}
+
+export async function search(query: string): Promise<VideoInfo[]> {
+ const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
+ if (!response.ok) {
+ throw new Error('Search failed');
+ }
+ const results: YtdlpSearchResult[] = await response.json();
+ return results.map(transformSearchResult);
+}
+
+export async function getVideo(videoId: string): Promise<VideoDetails> {
+ const response = await fetch(`/api/video/${videoId}`);
+ if (!response.ok) {
+ throw new Error('Failed to fetch video');
+ }
+ const video: YtdlpVideo = await response.json();
+ return transformVideo(video);
+}
+
+export async function getChannel(channelId: string): Promise<ChannelInfo> {
+ const response = await fetch(`/api/channel/${encodeURIComponent(channelId)}`);
+ if (!response.ok) {
+ throw new Error('Failed to fetch channel');
+ }
+ const channel: YtdlpChannel = await response.json();
+ return transformChannel(channel);
+}
+
+export async function getTrending(): Promise<VideoInfo[]> {
+ const response = await fetch('/api/trending');
+ if (!response.ok) {
+ throw new Error('Failed to fetch trending');
+ }
+ const results: YtdlpSearchResult[] = await response.json();
+ return results.map(transformSearchResult);
+}
+
+export async function getRelatedVideos(videoId: string): Promise<VideoInfo[]> {
+ const response = await fetch(`/api/related/${videoId}`);
+ if (!response.ok) {
+ throw new Error('Failed to fetch related videos');
+ }
+ const results: YtdlpSearchResult[] = await response.json();
+ return results.map(transformSearchResult);
+}
+
+export interface ImportedPlaylist {
+ id: string;
+ title: string;
+ channel: string;
+ channelId: string;
+ videos: VideoInfo[];
+}
+
+export async function importPlaylist(playlistUrl: string): Promise<ImportedPlaylist> {
+ const response = await fetch(`/api/playlist?url=${encodeURIComponent(playlistUrl)}`);
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.error || 'Failed to import playlist');
+ }
+ const data = await response.json();
+ return {
+ id: data.id,
+ title: data.title,
+ channel: data.channel,
+ channelId: data.channel_id,
+ videos: data.entries.map(transformSearchResult)
+ };
+}
+
+export function formatDuration(seconds: number): string {
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const secs = seconds % 60;
+
+ if (hours > 0) {
+ return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
+ }
+ return `${minutes}:${secs.toString().padStart(2, '0')}`;
+}
+
+export function formatViews(views: number): string {
+ if (views >= 1000000) {
+ return `${(views / 1000000).toFixed(1)}M views`;
+ }
+ if (views >= 1000) {
+ return `${(views / 1000).toFixed(1)}K views`;
+ }
+ return `${views} views`;
+}
+
+export function getBestThumbnail(thumbnails: VideoThumbnail[]): string {
+ if (!thumbnails || thumbnails.length === 0) return '';
+ const sorted = [...thumbnails].sort((a, b) => (b.width || 0) - (a.width || 0));
+ return sorted[0]?.url || '';
+}
diff --git a/src/lib/components/ChannelCard.svelte b/src/lib/components/ChannelCard.svelte
new file mode 100644
index 0000000..8747efa
--- /dev/null
+++ b/src/lib/components/ChannelCard.svelte
@@ -0,0 +1,160 @@
+<script lang="ts">
+ import type { VideoThumbnail } from '$lib/api/youtube';
+ import { subscriptions } from '$lib/stores/subscriptions';
+
+ interface Props {
+ channelId: string;
+ channelName: string;
+ thumbnails: VideoThumbnail[];
+ subCount?: number;
+ videoCount?: number;
+ showSubscribeButton?: boolean;
+ }
+
+ let {
+ channelId,
+ channelName,
+ thumbnails,
+ subCount,
+ videoCount,
+ showSubscribeButton = true
+ }: Props = $props();
+
+ const thumbnail = $derived(thumbnails[0]?.url || '');
+
+ const isSubscribed = $derived(subscriptions.isSubscribed($subscriptions, channelId));
+
+ function formatSubCount(count: number | undefined): string {
+ if (!count) return '';
+ if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M subscribers`;
+ if (count >= 1000) return `${(count / 1000).toFixed(1)}K subscribers`;
+ return `${count} subscribers`;
+ }
+
+ function handleSubscribe(e: Event) {
+ e.preventDefault();
+ e.stopPropagation();
+ if (isSubscribed) {
+ subscriptions.remove(channelId);
+ } else {
+ subscriptions.add(channelId, channelName, thumbnails);
+ }
+ }
+</script>
+
+<a href="/channel/{channelId}" class="channel-card">
+ <div class="thumbnail">
+ {#if thumbnail}
+ <img src={thumbnail} alt={channelName} loading="lazy" />
+ {:else}
+ <div class="placeholder">{channelName[0]}</div>
+ {/if}
+ </div>
+ <div class="info">
+ <h3 class="name">{channelName}</h3>
+ <div class="meta">
+ {#if subCount}
+ <span>{formatSubCount(subCount)}</span>
+ {/if}
+ {#if videoCount}
+ <span class="separator">•</span>
+ <span>{videoCount} videos</span>
+ {/if}
+ </div>
+ </div>
+ {#if showSubscribeButton}
+ <button
+ class="subscribe-btn"
+ class:subscribed={isSubscribed}
+ onclick={handleSubscribe}
+ >
+ {isSubscribed ? 'Subscribed' : 'Subscribe'}
+ </button>
+ {/if}
+</a>
+
+<style>
+ .channel-card {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ padding: 1rem;
+ text-decoration: none;
+ color: inherit;
+ border-radius: 8px;
+ transition: background 0.15s ease;
+ }
+
+ .channel-card:hover {
+ background: var(--bg-hover);
+ }
+
+ .thumbnail {
+ width: 80px;
+ height: 80px;
+ border-radius: 50%;
+ overflow: hidden;
+ background: var(--bg-secondary);
+ flex-shrink: 0;
+ }
+
+ .thumbnail img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+
+ .placeholder {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 2rem;
+ font-weight: 600;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ }
+
+ .info {
+ flex: 1;
+ min-width: 0;
+ }
+
+ .name {
+ font-size: 1rem;
+ font-weight: 500;
+ margin: 0 0 0.25rem;
+ color: var(--text-primary);
+ }
+
+ .meta {
+ font-size: 0.85rem;
+ color: var(--text-muted);
+ }
+
+ .separator {
+ margin: 0 0.25rem;
+ }
+
+ .subscribe-btn {
+ padding: 0.5rem 1rem;
+ border-radius: 20px;
+ border: none;
+ font-size: 0.9rem;
+ font-weight: 500;
+ cursor: pointer;
+ background: var(--accent-color);
+ color: #fff;
+ transition: opacity 0.15s ease;
+ }
+
+ .subscribe-btn:hover {
+ opacity: 0.9;
+ }
+
+ .subscribe-btn.subscribed {
+ background: var(--bg-secondary);
+ color: var(--text-secondary);
+ }
+</style>
diff --git a/src/lib/components/SearchBar.svelte b/src/lib/components/SearchBar.svelte
new file mode 100644
index 0000000..d81ba14
--- /dev/null
+++ b/src/lib/components/SearchBar.svelte
@@ -0,0 +1,76 @@
+<script lang="ts">
+ import { goto } from '$app/navigation';
+
+ let query = $state('');
+
+ function handleSubmit(e: Event) {
+ e.preventDefault();
+ if (query.trim()) {
+ goto(`/search?q=${encodeURIComponent(query.trim())}`);
+ }
+ }
+
+ function handleKeydown(e: KeyboardEvent) {
+ if (e.key === 'Escape') {
+ query = '';
+ }
+ }
+</script>
+
+<form class="search-bar" onsubmit={handleSubmit}>
+ <input
+ type="text"
+ bind:value={query}
+ placeholder="Search videos..."
+ onkeydown={handleKeydown}
+ />
+ <button type="submit" aria-label="Search">
+ <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
+ <path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
+ </svg>
+ </button>
+</form>
+
+<style>
+ .search-bar {
+ display: flex;
+ max-width: 600px;
+ width: 100%;
+ }
+
+ input {
+ flex: 1;
+ padding: 0.75rem 1rem;
+ font-size: 1rem;
+ border: 1px solid var(--border-color);
+ border-right: none;
+ border-radius: 4px 0 0 4px;
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ outline: none;
+ }
+
+ input:focus {
+ border-color: var(--accent-color);
+ }
+
+ input::placeholder {
+ color: var(--text-muted);
+ }
+
+ button {
+ padding: 0.75rem 1.25rem;
+ border: 1px solid var(--border-color);
+ border-radius: 0 4px 4px 0;
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ button:hover {
+ background: var(--bg-hover);
+ }
+</style>
diff --git a/src/lib/components/VideoCard.svelte b/src/lib/components/VideoCard.svelte
new file mode 100644
index 0000000..61bf975
--- /dev/null
+++ b/src/lib/components/VideoCard.svelte
@@ -0,0 +1,133 @@
+<script lang="ts">
+ import { formatDuration, formatViews, getBestThumbnail, type VideoThumbnail } from '$lib/api/youtube';
+
+ interface Props {
+ videoId: string;
+ title: string;
+ author: string;
+ authorId: string;
+ thumbnails: VideoThumbnail[];
+ viewCount: number;
+ publishedText: string;
+ lengthSeconds: number;
+ }
+
+ let {
+ videoId,
+ title,
+ author,
+ authorId,
+ thumbnails,
+ viewCount,
+ publishedText,
+ lengthSeconds
+ }: Props = $props();
+
+ const thumbnail = $derived(getBestThumbnail(thumbnails));
+ const duration = $derived(formatDuration(lengthSeconds));
+ const views = $derived(formatViews(viewCount));
+</script>
+
+<a href="/watch/{videoId}" class="video-card">
+ <div class="thumbnail">
+ <img src={thumbnail} alt={title} loading="lazy" />
+ <span class="duration">{duration}</span>
+ </div>
+ <div class="info">
+ <h3 class="title">{title}</h3>
+ <span
+ class="author"
+ role="link"
+ tabindex="0"
+ onclick={(e) => { e.preventDefault(); e.stopPropagation(); window.location.href = `/channel/${authorId}`; }}
+ onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); window.location.href = `/channel/${authorId}`; } }}
+ >
+ {author}
+ </span>
+ <div class="meta">
+ <span>{views}</span>
+ <span class="separator">•</span>
+ <span>{publishedText}</span>
+ </div>
+ </div>
+</a>
+
+<style>
+ .video-card {
+ display: block;
+ text-decoration: none;
+ color: inherit;
+ border-radius: 8px;
+ overflow: hidden;
+ transition: transform 0.15s ease;
+ }
+
+ .video-card:hover {
+ transform: translateY(-2px);
+ }
+
+ .thumbnail {
+ position: relative;
+ aspect-ratio: 16 / 9;
+ background: var(--bg-secondary);
+ border-radius: 8px;
+ overflow: hidden;
+ }
+
+ .thumbnail img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+
+ .duration {
+ position: absolute;
+ bottom: 8px;
+ right: 8px;
+ background: rgba(0, 0, 0, 0.8);
+ color: #fff;
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 0.8rem;
+ font-weight: 500;
+ }
+
+ .info {
+ padding: 0.75rem 0;
+ }
+
+ .title {
+ font-size: 0.95rem;
+ font-weight: 500;
+ line-height: 1.3;
+ margin: 0 0 0.5rem;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ color: var(--text-primary);
+ }
+
+ .author {
+ font-size: 0.85rem;
+ color: var(--text-secondary);
+ text-decoration: none;
+ display: block;
+ margin-bottom: 0.25rem;
+ cursor: pointer;
+ }
+
+ .author:hover {
+ color: var(--text-primary);
+ }
+
+ .meta {
+ font-size: 0.8rem;
+ color: var(--text-muted);
+ }
+
+ .separator {
+ margin: 0 0.25rem;
+ }
+</style>
diff --git a/src/lib/components/VideoPlayer.svelte b/src/lib/components/VideoPlayer.svelte
new file mode 100644
index 0000000..0829c8b
--- /dev/null
+++ b/src/lib/components/VideoPlayer.svelte
@@ -0,0 +1,185 @@
+<script lang="ts">
+ import type { VideoFormat } from '$lib/api/youtube';
+
+ interface Props {
+ formats: VideoFormat[];
+ title: string;
+ }
+
+ let { formats, title }: Props = $props();
+
+ let videoElement: HTMLVideoElement | undefined = $state();
+ let currentQuality = $state('');
+
+ const sortedFormats = $derived(
+ [...formats]
+ .filter(f => f.url && f.height)
+ .sort((a, b) => (b.height || 0) - (a.height || 0))
+ );
+
+ const selectedFormat = $derived(
+ currentQuality
+ ? sortedFormats.find(f => f.qualityLabel === currentQuality) || sortedFormats[0]
+ : sortedFormats[0]
+ );
+
+ function handleQualityChange(e: Event) {
+ const select = e.target as HTMLSelectElement;
+ const newQuality = select.value;
+ const currentTime = videoElement?.currentTime || 0;
+ const wasPlaying = videoElement && !videoElement.paused;
+
+ currentQuality = newQuality;
+
+ if (videoElement) {
+ videoElement.load();
+ videoElement.currentTime = currentTime;
+ if (wasPlaying) {
+ videoElement.play();
+ }
+ }
+ }
+
+ function handleKeydown(e: KeyboardEvent) {
+ if (!videoElement) return;
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
+
+ switch (e.key) {
+ case ' ':
+ case 'k':
+ e.preventDefault();
+ if (videoElement.paused) {
+ videoElement.play();
+ } else {
+ videoElement.pause();
+ }
+ break;
+ case 'f':
+ e.preventDefault();
+ if (document.fullscreenElement) {
+ document.exitFullscreen();
+ } else {
+ videoElement.requestFullscreen();
+ }
+ break;
+ case 'm':
+ e.preventDefault();
+ videoElement.muted = !videoElement.muted;
+ break;
+ case 'ArrowLeft':
+ e.preventDefault();
+ videoElement.currentTime -= 5;
+ break;
+ case 'ArrowRight':
+ e.preventDefault();
+ videoElement.currentTime += 5;
+ break;
+ case 'ArrowUp':
+ e.preventDefault();
+ videoElement.volume = Math.min(1, videoElement.volume + 0.1);
+ break;
+ case 'ArrowDown':
+ e.preventDefault();
+ videoElement.volume = Math.max(0, videoElement.volume - 0.1);
+ break;
+ case '0':
+ case '1':
+ case '2':
+ case '3':
+ case '4':
+ case '5':
+ case '6':
+ case '7':
+ case '8':
+ case '9':
+ e.preventDefault();
+ videoElement.currentTime = (parseInt(e.key) / 10) * videoElement.duration;
+ break;
+ }
+ }
+
+ function getMimeType(ext: string): string {
+ const types: Record<string, string> = {
+ mp4: 'video/mp4',
+ webm: 'video/webm',
+ mkv: 'video/x-matroska'
+ };
+ return types[ext] || 'video/mp4';
+ }
+</script>
+
+<svelte:window onkeydown={handleKeydown} />
+
+<div class="player-container">
+ {#if selectedFormat}
+ <video
+ bind:this={videoElement}
+ controls
+ autoplay
+ {title}
+ >
+ <source src={selectedFormat.url} type={getMimeType(selectedFormat.ext)} />
+ Your browser does not support the video tag.
+ </video>
+ {:else}
+ <div class="no-video">
+ <p>No playable video format found</p>
+ </div>
+ {/if}
+
+ {#if sortedFormats.length > 1}
+ <div class="quality-selector">
+ <label for="quality">Quality:</label>
+ <select id="quality" onchange={handleQualityChange} value={selectedFormat?.qualityLabel}>
+ {#each sortedFormats as format}
+ <option value={format.qualityLabel}>{format.qualityLabel || format.height + 'p'}</option>
+ {/each}
+ </select>
+ </div>
+ {/if}
+</div>
+
+<style>
+ .player-container {
+ width: 100%;
+ background: #000;
+ border-radius: 8px;
+ overflow: hidden;
+ }
+
+ video {
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ display: block;
+ }
+
+ .no-video {
+ aspect-ratio: 16 / 9;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--text-muted);
+ }
+
+ .quality-selector {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.75rem;
+ background: var(--bg-secondary);
+ }
+
+ .quality-selector label {
+ font-size: 0.9rem;
+ color: var(--text-secondary);
+ }
+
+ .quality-selector select {
+ padding: 0.25rem 0.5rem;
+ border-radius: 4px;
+ border: 1px solid var(--border-color);
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ font-size: 0.9rem;
+ }
+</style>
diff --git a/src/lib/server/ytdlp.ts b/src/lib/server/ytdlp.ts
new file mode 100644
index 0000000..2f6f47e
--- /dev/null
+++ b/src/lib/server/ytdlp.ts
@@ -0,0 +1,329 @@
+import { spawn } from 'child_process';
+
+export interface YtdlpVideo {
+ id: string;
+ title: string;
+ description: string;
+ channel: string;
+ channel_id: string;
+ channel_url: string;
+ thumbnail: string;
+ thumbnails: { url: string; width?: number; height?: number }[];
+ duration: number;
+ view_count: number;
+ upload_date: string;
+ like_count?: number;
+ formats: YtdlpFormat[];
+}
+
+export interface YtdlpFormat {
+ format_id: string;
+ url: string;
+ ext: string;
+ resolution?: string;
+ height?: number;
+ width?: number;
+ vcodec?: string;
+ acodec?: string;
+ format_note?: string;
+}
+
+export interface YtdlpSearchResult {
+ id: string;
+ title: string;
+ channel: string;
+ channel_id: string;
+ channel_url: string;
+ thumbnail: string;
+ thumbnails: { url: string; width?: number; height?: number }[];
+ duration: number;
+ view_count: number;
+ upload_date: string;
+}
+
+export interface YtdlpChannel {
+ id: string;
+ channel: string;
+ channel_id: string;
+ description: string;
+ channel_follower_count: number;
+ thumbnails: { url: string; width?: number; height?: number }[];
+ entries?: YtdlpSearchResult[];
+}
+
+function runYtdlp(args: string[]): Promise<string> {
+ return new Promise((resolve, reject) => {
+ const proc = spawn('yt-dlp', args);
+ let stdout = '';
+ let stderr = '';
+
+ proc.stdout.on('data', (data: Buffer) => {
+ stdout += data.toString();
+ });
+
+ proc.stderr.on('data', (data: Buffer) => {
+ stderr += data.toString();
+ });
+
+ proc.on('close', (code: number | null) => {
+ if (code === 0) {
+ resolve(stdout);
+ } else {
+ reject(new Error(stderr || `yt-dlp exited with code ${code}`));
+ }
+ });
+
+ proc.on('error', (err: Error) => {
+ reject(err);
+ });
+ });
+}
+
+export async function search(query: string, limit = 20): Promise<YtdlpSearchResult[]> {
+ const output = await runYtdlp([
+ `ytsearch${limit}:${query}`,
+ '--dump-json',
+ '--flat-playlist',
+ '--no-warnings',
+ '--ignore-errors'
+ ]);
+
+ const results: YtdlpSearchResult[] = [];
+ for (const line of output.trim().split('\n')) {
+ if (!line) continue;
+ try {
+ const item = JSON.parse(line);
+ if (item.duration && item.duration >= 60) {
+ results.push({
+ id: item.id,
+ title: item.title,
+ channel: item.channel || item.uploader || '',
+ channel_id: item.channel_id || item.uploader_id || '',
+ channel_url: item.channel_url || item.uploader_url || '',
+ thumbnail: item.thumbnail || item.thumbnails?.[0]?.url || '',
+ thumbnails: item.thumbnails || [],
+ duration: item.duration || 0,
+ view_count: item.view_count || 0,
+ upload_date: item.upload_date || ''
+ });
+ }
+ } catch {
+ // Skip invalid JSON lines
+ }
+ }
+
+ return results;
+}
+
+export async function getVideo(videoId: string): Promise<YtdlpVideo> {
+ const output = await runYtdlp([
+ `https://www.youtube.com/watch?v=${videoId}`,
+ '--dump-json',
+ '--no-warnings'
+ ]);
+
+ const data = JSON.parse(output);
+ return {
+ id: data.id,
+ title: data.title,
+ description: data.description || '',
+ channel: data.channel || data.uploader || '',
+ channel_id: data.channel_id || data.uploader_id || '',
+ channel_url: data.channel_url || data.uploader_url || '',
+ thumbnail: data.thumbnail || data.thumbnails?.[0]?.url || '',
+ thumbnails: data.thumbnails || [],
+ duration: data.duration || 0,
+ view_count: data.view_count || 0,
+ upload_date: data.upload_date || '',
+ like_count: data.like_count,
+ formats: (data.formats || []).filter((f: YtdlpFormat) =>
+ f.vcodec !== 'none' && f.acodec !== 'none' && f.url
+ ).map((f: YtdlpFormat) => ({
+ format_id: f.format_id,
+ url: f.url,
+ ext: f.ext,
+ resolution: f.resolution,
+ height: f.height,
+ width: f.width,
+ vcodec: f.vcodec,
+ acodec: f.acodec,
+ format_note: f.format_note
+ }))
+ };
+}
+
+export async function getChannel(channelId: string): Promise<YtdlpChannel> {
+ const url = channelId.startsWith('@')
+ ? `https://www.youtube.com/${channelId}`
+ : `https://www.youtube.com/channel/${channelId}`;
+
+ const output = await runYtdlp([
+ `${url}/videos`,
+ '--dump-json',
+ '--flat-playlist',
+ '--playlist-end', '30',
+ '--no-warnings',
+ '--ignore-errors'
+ ]);
+
+ const lines = output.trim().split('\n').filter(Boolean);
+ const entries: YtdlpSearchResult[] = [];
+ let channelInfo: Partial<YtdlpChannel> = {};
+
+ for (const line of lines) {
+ try {
+ const item = JSON.parse(line);
+ if (item.channel_id || item.uploader_id) {
+ channelInfo = {
+ id: item.channel_id || item.uploader_id,
+ channel: item.channel || item.uploader || '',
+ channel_id: item.channel_id || item.uploader_id || '',
+ description: item.description || '',
+ channel_follower_count: item.channel_follower_count || 0,
+ thumbnails: item.thumbnails || []
+ };
+ }
+ if (item.id && item.title && item.duration && item.duration >= 60) {
+ entries.push({
+ id: item.id,
+ title: item.title,
+ channel: item.channel || item.uploader || '',
+ channel_id: item.channel_id || item.uploader_id || '',
+ channel_url: item.channel_url || item.uploader_url || '',
+ thumbnail: item.thumbnail || item.thumbnails?.[0]?.url || '',
+ thumbnails: item.thumbnails || [],
+ duration: item.duration || 0,
+ view_count: item.view_count || 0,
+ upload_date: item.upload_date || ''
+ });
+ }
+ } catch {
+ // Skip invalid JSON
+ }
+ }
+
+ return {
+ id: channelInfo.channel_id || channelId,
+ channel: channelInfo.channel || '',
+ channel_id: channelInfo.channel_id || channelId,
+ description: channelInfo.description || '',
+ channel_follower_count: channelInfo.channel_follower_count || 0,
+ thumbnails: channelInfo.thumbnails || [],
+ entries
+ };
+}
+
+export interface YtdlpPlaylist {
+ id: string;
+ title: string;
+ channel: string;
+ channel_id: string;
+ entries: YtdlpSearchResult[];
+}
+
+export async function getRelatedVideos(title: string, excludeId: string): Promise<YtdlpSearchResult[]> {
+ // Extract key words from title for search
+ const keywords = title
+ .replace(/[^\w\s]/g, '')
+ .split(/\s+/)
+ .filter(w => w.length > 3)
+ .slice(0, 5)
+ .join(' ');
+
+ if (!keywords) return [];
+
+ const results = await search(keywords, 15);
+ return results.filter(r => r.id !== excludeId).slice(0, 10);
+}
+
+export async function getPlaylist(playlistUrl: string): Promise<YtdlpPlaylist> {
+ const output = await runYtdlp([
+ playlistUrl,
+ '--dump-json',
+ '--flat-playlist',
+ '--no-warnings',
+ '--ignore-errors'
+ ]);
+
+ const lines = output.trim().split('\n').filter(Boolean);
+ const entries: YtdlpSearchResult[] = [];
+ let playlistInfo: Partial<YtdlpPlaylist> = {};
+
+ for (const line of lines) {
+ try {
+ const item = JSON.parse(line);
+
+ // Capture playlist metadata from first item
+ if (!playlistInfo.id && item.playlist_id) {
+ playlistInfo = {
+ id: item.playlist_id,
+ title: item.playlist_title || item.playlist || 'Imported Playlist',
+ channel: item.playlist_uploader || item.channel || item.uploader || '',
+ channel_id: item.playlist_uploader_id || item.channel_id || item.uploader_id || ''
+ };
+ }
+
+ if (item.id && item.title) {
+ entries.push({
+ id: item.id,
+ title: item.title,
+ channel: item.channel || item.uploader || '',
+ channel_id: item.channel_id || item.uploader_id || '',
+ channel_url: item.channel_url || item.uploader_url || '',
+ thumbnail: item.thumbnail || item.thumbnails?.[0]?.url || `https://i.ytimg.com/vi/${item.id}/hqdefault.jpg`,
+ thumbnails: item.thumbnails || [],
+ duration: item.duration || 0,
+ view_count: item.view_count || 0,
+ upload_date: item.upload_date || ''
+ });
+ }
+ } catch {
+ // Skip invalid JSON
+ }
+ }
+
+ return {
+ id: playlistInfo.id || '',
+ title: playlistInfo.title || 'Imported Playlist',
+ channel: playlistInfo.channel || '',
+ channel_id: playlistInfo.channel_id || '',
+ entries
+ };
+}
+
+export async function getTrending(): Promise<YtdlpSearchResult[]> {
+ const output = await runYtdlp([
+ 'https://www.youtube.com/feed/trending',
+ '--dump-json',
+ '--flat-playlist',
+ '--playlist-end', '30',
+ '--no-warnings',
+ '--ignore-errors'
+ ]);
+
+ const results: YtdlpSearchResult[] = [];
+ for (const line of output.trim().split('\n')) {
+ if (!line) continue;
+ try {
+ const item = JSON.parse(line);
+ if (item.duration && item.duration >= 60) {
+ results.push({
+ id: item.id,
+ title: item.title,
+ channel: item.channel || item.uploader || '',
+ channel_id: item.channel_id || item.uploader_id || '',
+ channel_url: item.channel_url || item.uploader_url || '',
+ thumbnail: item.thumbnail || item.thumbnails?.[0]?.url || '',
+ thumbnails: item.thumbnails || [],
+ duration: item.duration || 0,
+ view_count: item.view_count || 0,
+ upload_date: item.upload_date || ''
+ });
+ }
+ } catch {
+ // Skip invalid JSON
+ }
+ }
+
+ return results;
+}
diff --git a/src/lib/stores/playlists.ts b/src/lib/stores/playlists.ts
new file mode 100644
index 0000000..ee516dc
--- /dev/null
+++ b/src/lib/stores/playlists.ts
@@ -0,0 +1,123 @@
+import { writable } from 'svelte/store';
+import { get, set } from 'idb-keyval';
+
+export interface PlaylistVideo {
+ videoId: string;
+ title: string;
+ author: string;
+ authorId: string;
+ thumbnail: string;
+ lengthSeconds: number;
+}
+
+export interface Playlist {
+ id: string;
+ name: string;
+ videos: PlaylistVideo[];
+ createdAt: number;
+ updatedAt: number;
+}
+
+const STORAGE_KEY = 'actualyt-playlists';
+
+function generateId(): string {
+ return Date.now().toString(36) + Math.random().toString(36).substring(2);
+}
+
+function createPlaylistStore() {
+ const { subscribe, set: setStore, update } = writable<Playlist[]>([]);
+ let initialized = false;
+
+ async function init() {
+ if (initialized) return;
+ try {
+ const stored = await get<Playlist[]>(STORAGE_KEY);
+ if (stored) {
+ setStore(stored);
+ }
+ initialized = true;
+ } catch (e) {
+ console.error('Failed to load playlists:', e);
+ }
+ }
+
+ async function persist(playlists: Playlist[]) {
+ try {
+ await set(STORAGE_KEY, playlists);
+ } catch (e) {
+ console.error('Failed to save playlists:', e);
+ }
+ }
+
+ return {
+ subscribe,
+ init,
+ create: async (name: string): Promise<string> => {
+ const id = generateId();
+ const now = Date.now();
+ const newPlaylist: Playlist = {
+ id,
+ name,
+ videos: [],
+ createdAt: now,
+ updatedAt: now
+ };
+ update(playlists => {
+ const newPlaylists = [...playlists, newPlaylist];
+ persist(newPlaylists);
+ return newPlaylists;
+ });
+ return id;
+ },
+ delete: async (id: string) => {
+ update(playlists => {
+ const newPlaylists = playlists.filter(p => p.id !== id);
+ persist(newPlaylists);
+ return newPlaylists;
+ });
+ },
+ rename: async (id: string, name: string) => {
+ update(playlists => {
+ const newPlaylists = playlists.map(p =>
+ p.id === id ? { ...p, name, updatedAt: Date.now() } : p
+ );
+ persist(newPlaylists);
+ return newPlaylists;
+ });
+ },
+ addVideo: async (playlistId: string, video: PlaylistVideo) => {
+ update(playlists => {
+ const newPlaylists = playlists.map(p => {
+ if (p.id !== playlistId) return p;
+ if (p.videos.some(v => v.videoId === video.videoId)) return p;
+ return {
+ ...p,
+ videos: [...p.videos, video],
+ updatedAt: Date.now()
+ };
+ });
+ persist(newPlaylists);
+ return newPlaylists;
+ });
+ },
+ removeVideo: async (playlistId: string, videoId: string) => {
+ update(playlists => {
+ const newPlaylists = playlists.map(p => {
+ if (p.id !== playlistId) return p;
+ return {
+ ...p,
+ videos: p.videos.filter(v => v.videoId !== videoId),
+ updatedAt: Date.now()
+ };
+ });
+ persist(newPlaylists);
+ return newPlaylists;
+ });
+ },
+ getById: (playlists: Playlist[], id: string): Playlist | undefined => {
+ return playlists.find(p => p.id === id);
+ }
+ };
+}
+
+export const playlists = createPlaylistStore();
diff --git a/src/lib/stores/subscriptions.ts b/src/lib/stores/subscriptions.ts
new file mode 100644
index 0000000..91f3d36
--- /dev/null
+++ b/src/lib/stores/subscriptions.ts
@@ -0,0 +1,65 @@
+import { writable } from 'svelte/store';
+import { get, set } from 'idb-keyval';
+import type { VideoThumbnail } from '$lib/api/youtube';
+
+export interface Subscription {
+ channelId: string;
+ channelName: string;
+ thumbnail: string;
+}
+
+const STORAGE_KEY = 'actualyt-subscriptions';
+
+function createSubscriptionStore() {
+ const { subscribe, set: setStore, update } = writable<Subscription[]>([]);
+ let initialized = false;
+
+ async function init() {
+ if (initialized) return;
+ try {
+ const stored = await get<Subscription[]>(STORAGE_KEY);
+ if (stored) {
+ setStore(stored);
+ }
+ initialized = true;
+ } catch (e) {
+ console.error('Failed to load subscriptions:', e);
+ }
+ }
+
+ async function persist(subs: Subscription[]) {
+ try {
+ await set(STORAGE_KEY, subs);
+ } catch (e) {
+ console.error('Failed to save subscriptions:', e);
+ }
+ }
+
+ return {
+ subscribe,
+ init,
+ add: async (channelId: string, channelName: string, thumbnails: VideoThumbnail[]) => {
+ update(subs => {
+ if (subs.some(s => s.channelId === channelId)) {
+ return subs;
+ }
+ const thumbnail = thumbnails[0]?.url || '';
+ const newSubs = [...subs, { channelId, channelName, thumbnail }];
+ persist(newSubs);
+ return newSubs;
+ });
+ },
+ remove: async (channelId: string) => {
+ update(subs => {
+ const newSubs = subs.filter(s => s.channelId !== channelId);
+ persist(newSubs);
+ return newSubs;
+ });
+ },
+ isSubscribed: (subs: Subscription[], channelId: string): boolean => {
+ return subs.some(s => s.channelId === channelId);
+ }
+ };
+}
+
+export const subscriptions = createSubscriptionStore();
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
new file mode 100644
index 0000000..24c72c6
--- /dev/null
+++ b/src/routes/+layout.svelte
@@ -0,0 +1,151 @@
+<script lang="ts">
+ import '../app.css';
+ import SearchBar from '$lib/components/SearchBar.svelte';
+ import { subscriptions } from '$lib/stores/subscriptions';
+ import { playlists } from '$lib/stores/playlists';
+ import { onMount } from 'svelte';
+ import { browser } from '$app/environment';
+
+ let { children } = $props();
+ let theme = $state<'dark' | 'light'>('dark');
+
+ onMount(() => {
+ subscriptions.init();
+ playlists.init();
+
+ const stored = localStorage.getItem('actualyt-theme');
+ if (stored === 'light' || stored === 'dark') {
+ theme = stored;
+ }
+ });
+
+ function toggleTheme() {
+ theme = theme === 'dark' ? 'light' : 'dark';
+ if (browser) {
+ localStorage.setItem('actualyt-theme', theme);
+ }
+ }
+</script>
+
+<svelte:head>
+ <title>ActualYT</title>
+</svelte:head>
+
+<div class="app" data-theme={theme}>
+ <header class="header">
+ <div class="header-content container">
+ <a href="/" class="logo">ActualYT</a>
+ <SearchBar />
+ <nav class="nav">
+ <a href="/subscriptions" class="nav-link">Subscriptions</a>
+ <a href="/playlists" class="nav-link">Playlists</a>
+ <button class="theme-toggle" onclick={toggleTheme} aria-label="Toggle theme">
+ {#if theme === 'dark'}
+ <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
+ <path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58a.996.996 0 0 0-1.41 0 .996.996 0 0 0 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37a.996.996 0 0 0-1.41 0 .996.996 0 0 0 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0a.996.996 0 0 0 0-1.41l-1.06-1.06zm1.06-10.96a.996.996 0 0 0 0-1.41.996.996 0 0 0-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36a.996.996 0 0 0 0-1.41.996.996 0 0 0-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z"/>
+ </svg>
+ {:else}
+ <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
+ <path d="M12 3a9 9 0 1 0 9 9c0-.46-.04-.92-.1-1.36a5.389 5.389 0 0 1-4.4 2.26 5.403 5.403 0 0 1-3.14-9.8c-.44-.06-.9-.1-1.36-.1z"/>
+ </svg>
+ {/if}
+ </button>
+ </nav>
+ </div>
+ </header>
+
+ <main class="main">
+ {@render children()}
+ </main>
+</div>
+
+<style>
+ .app {
+ min-height: 100vh;
+ background: var(--bg-primary);
+ }
+
+ .header {
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ background: var(--bg-primary);
+ border-bottom: 1px solid var(--border-color);
+ }
+
+ .header-content {
+ display: flex;
+ align-items: center;
+ gap: 2rem;
+ padding-top: 0.75rem;
+ padding-bottom: 0.75rem;
+ }
+
+ .logo {
+ font-size: 1.25rem;
+ font-weight: 700;
+ text-decoration: none;
+ color: var(--accent-color);
+ flex-shrink: 0;
+ }
+
+ .nav {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ margin-left: auto;
+ }
+
+ .nav-link {
+ text-decoration: none;
+ color: var(--text-secondary);
+ font-size: 0.9rem;
+ padding: 0.5rem;
+ border-radius: 4px;
+ transition: color 0.15s ease;
+ }
+
+ .nav-link:hover {
+ color: var(--text-primary);
+ }
+
+ .theme-toggle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ border: none;
+ border-radius: 50%;
+ background: transparent;
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: background 0.15s ease, color 0.15s ease;
+ }
+
+ .theme-toggle:hover {
+ background: var(--bg-hover);
+ color: var(--text-primary);
+ }
+
+ .main {
+ padding: 2rem 0;
+ }
+
+ @media (max-width: 768px) {
+ .header-content {
+ flex-wrap: wrap;
+ gap: 1rem;
+ }
+
+ .nav {
+ order: -1;
+ width: 100%;
+ justify-content: flex-end;
+ }
+
+ .logo {
+ order: -2;
+ }
+ }
+</style>
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
new file mode 100644
index 0000000..8477c7c
--- /dev/null
+++ b/src/routes/+page.svelte
@@ -0,0 +1,50 @@
+<script lang="ts">
+ import { onMount } from 'svelte';
+ import { getTrending, type VideoInfo } from '$lib/api/youtube';
+ import VideoCard from '$lib/components/VideoCard.svelte';
+
+ let videos = $state<VideoInfo[]>([]);
+ let loading = $state(true);
+ let error = $state('');
+
+ onMount(async () => {
+ try {
+ videos = await getTrending();
+ } catch (e) {
+ error = e instanceof Error ? e.message : 'Failed to load videos';
+ } finally {
+ loading = false;
+ }
+ });
+</script>
+
+<svelte:head>
+ <title>ActualYT - Home</title>
+</svelte:head>
+
+<div class="container">
+ <h1 class="section-title">Trending</h1>
+
+ {#if loading}
+ <div class="loading">Loading videos</div>
+ {:else if error}
+ <div class="error">{error}</div>
+ {:else if videos.length === 0}
+ <div class="empty">No videos found</div>
+ {:else}
+ <div class="video-grid">
+ {#each videos as video (video.videoId)}
+ <VideoCard
+ videoId={video.videoId}
+ title={video.title}
+ author={video.author}
+ authorId={video.authorId}
+ thumbnails={video.videoThumbnails}
+ viewCount={video.viewCount}
+ publishedText={video.publishedText}
+ lengthSeconds={video.lengthSeconds}
+ />
+ {/each}
+ </div>
+ {/if}
+</div>
diff --git a/src/routes/api/channel/[id]/+server.ts b/src/routes/api/channel/[id]/+server.ts
new file mode 100644
index 0000000..c9aa67f
--- /dev/null
+++ b/src/routes/api/channel/[id]/+server.ts
@@ -0,0 +1,18 @@
+import { json } from '@sveltejs/kit';
+import { getChannel } from '$lib/server/ytdlp';
+import type { RequestHandler } from './$types';
+
+export const GET: RequestHandler = async ({ params }) => {
+ const { id } = params;
+ if (!id) {
+ return json({ error: 'Missing channel ID' }, { status: 400 });
+ }
+
+ try {
+ const channel = await getChannel(id);
+ return json(channel);
+ } catch (e) {
+ console.error('Channel fetch error:', e);
+ return json({ error: 'Failed to fetch channel' }, { status: 500 });
+ }
+};
diff --git a/src/routes/api/playlist/+server.ts b/src/routes/api/playlist/+server.ts
new file mode 100644
index 0000000..b70ac26
--- /dev/null
+++ b/src/routes/api/playlist/+server.ts
@@ -0,0 +1,23 @@
+import { json } from '@sveltejs/kit';
+import { getPlaylist } from '$lib/server/ytdlp';
+import type { RequestHandler } from './$types';
+
+export const GET: RequestHandler = async ({ url }) => {
+ const playlistUrl = url.searchParams.get('url');
+ if (!playlistUrl) {
+ return json({ error: 'Missing playlist URL' }, { status: 400 });
+ }
+
+ // Validate it's a YouTube playlist URL
+ if (!playlistUrl.includes('youtube.com/playlist') && !playlistUrl.includes('list=')) {
+ return json({ error: 'Invalid YouTube playlist URL' }, { status: 400 });
+ }
+
+ try {
+ const playlist = await getPlaylist(playlistUrl);
+ return json(playlist);
+ } catch (e) {
+ console.error('Playlist fetch error:', e);
+ return json({ error: 'Failed to fetch playlist' }, { status: 500 });
+ }
+};
diff --git a/src/routes/api/related/[id]/+server.ts b/src/routes/api/related/[id]/+server.ts
new file mode 100644
index 0000000..9fcd9c3
--- /dev/null
+++ b/src/routes/api/related/[id]/+server.ts
@@ -0,0 +1,19 @@
+import { json } from '@sveltejs/kit';
+import { getVideo, getRelatedVideos } from '$lib/server/ytdlp';
+import type { RequestHandler } from './$types';
+
+export const GET: RequestHandler = async ({ params }) => {
+ const { id } = params;
+ if (!id) {
+ return json({ error: 'Missing video ID' }, { status: 400 });
+ }
+
+ try {
+ const video = await getVideo(id);
+ const related = await getRelatedVideos(video.title, id);
+ return json(related);
+ } catch (e) {
+ console.error('Related videos fetch error:', e);
+ return json({ error: 'Failed to fetch related videos' }, { status: 500 });
+ }
+};
diff --git a/src/routes/api/search/+server.ts b/src/routes/api/search/+server.ts
new file mode 100644
index 0000000..57bf347
--- /dev/null
+++ b/src/routes/api/search/+server.ts
@@ -0,0 +1,18 @@
+import { json } from '@sveltejs/kit';
+import { search } from '$lib/server/ytdlp';
+import type { RequestHandler } from './$types';
+
+export const GET: RequestHandler = async ({ url }) => {
+ const query = url.searchParams.get('q');
+ if (!query) {
+ return json({ error: 'Missing query parameter' }, { status: 400 });
+ }
+
+ try {
+ const results = await search(query);
+ return json(results);
+ } catch (e) {
+ console.error('Search error:', e);
+ return json({ error: 'Search failed' }, { status: 500 });
+ }
+};
diff --git a/src/routes/api/trending/+server.ts b/src/routes/api/trending/+server.ts
new file mode 100644
index 0000000..b19ed21
--- /dev/null
+++ b/src/routes/api/trending/+server.ts
@@ -0,0 +1,13 @@
+import { json } from '@sveltejs/kit';
+import { getTrending } from '$lib/server/ytdlp';
+import type { RequestHandler } from './$types';
+
+export const GET: RequestHandler = async () => {
+ try {
+ const videos = await getTrending();
+ return json(videos);
+ } catch (e) {
+ console.error('Trending fetch error:', e);
+ return json({ error: 'Failed to fetch trending' }, { status: 500 });
+ }
+};
diff --git a/src/routes/api/video/[id]/+server.ts b/src/routes/api/video/[id]/+server.ts
new file mode 100644
index 0000000..9a64c36
--- /dev/null
+++ b/src/routes/api/video/[id]/+server.ts
@@ -0,0 +1,18 @@
+import { json } from '@sveltejs/kit';
+import { getVideo } from '$lib/server/ytdlp';
+import type { RequestHandler } from './$types';
+
+export const GET: RequestHandler = async ({ params }) => {
+ const { id } = params;
+ if (!id) {
+ return json({ error: 'Missing video ID' }, { status: 400 });
+ }
+
+ try {
+ const video = await getVideo(id);
+ return json(video);
+ } catch (e) {
+ console.error('Video fetch error:', e);
+ return json({ error: 'Failed to fetch video' }, { status: 500 });
+ }
+};
diff --git a/src/routes/channel/[id]/+page.svelte b/src/routes/channel/[id]/+page.svelte
new file mode 100644
index 0000000..05ba2d7
--- /dev/null
+++ b/src/routes/channel/[id]/+page.svelte
@@ -0,0 +1,175 @@
+<script lang="ts">
+ import { page } from '$app/stores';
+ import { getChannel, type ChannelInfo } from '$lib/api/youtube';
+ import { subscriptions } from '$lib/stores/subscriptions';
+ import VideoCard from '$lib/components/VideoCard.svelte';
+
+ let channel = $state<ChannelInfo | null>(null);
+ let loading = $state(true);
+ let error = $state('');
+
+ const channelId = $derived($page.params.id);
+ const isSubscribed = $derived(
+ channel ? subscriptions.isSubscribed($subscriptions, channel.authorId) : false
+ );
+
+ $effect(() => {
+ const id = $page.params.id;
+ if (id) loadChannel(id);
+ });
+
+ async function loadChannel(id: string) {
+ loading = true;
+ error = '';
+
+ try {
+ channel = await getChannel(id);
+ } catch (e) {
+ error = e instanceof Error ? e.message : 'Failed to load channel';
+ channel = null;
+ } finally {
+ loading = false;
+ }
+ }
+
+ function handleSubscribe() {
+ if (!channel) return;
+ if (isSubscribed) {
+ subscriptions.remove(channel.authorId);
+ } else {
+ subscriptions.add(channel.authorId, channel.author, channel.authorThumbnails);
+ }
+ }
+
+ function formatSubCount(count: number): string {
+ if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M subscribers`;
+ if (count >= 1000) return `${(count / 1000).toFixed(1)}K subscribers`;
+ return `${count} subscribers`;
+ }
+</script>
+
+<svelte:head>
+ <title>{channel?.author || 'Channel'} - ActualYT</title>
+</svelte:head>
+
+<div class="container">
+ {#if loading}
+ <div class="loading">Loading channel</div>
+ {:else if error}
+ <div class="error">{error}</div>
+ {:else if channel}
+ <div class="channel-page">
+ <div class="channel-header">
+ <div class="channel-info">
+ {#if channel.authorThumbnails && channel.authorThumbnails.length > 0}
+ <img
+ src={channel.authorThumbnails[0].url}
+ alt={channel.author}
+ class="avatar"
+ />
+ {/if}
+ <div class="channel-text">
+ <h1 class="channel-name">{channel.author}</h1>
+ {#if channel.subCount > 0}
+ <p class="sub-count">{formatSubCount(channel.subCount)}</p>
+ {/if}
+ </div>
+ </div>
+ <button
+ class="btn"
+ class:btn-primary={!isSubscribed}
+ class:btn-secondary={isSubscribed}
+ onclick={handleSubscribe}
+ >
+ {isSubscribed ? 'Subscribed' : 'Subscribe'}
+ </button>
+ </div>
+
+ {#if channel.description}
+ <p class="description">{channel.description}</p>
+ {/if}
+
+ <h2 class="section-title">Videos</h2>
+
+ {#if channel.videos.length === 0}
+ <div class="empty">No videos found</div>
+ {:else}
+ <div class="video-grid">
+ {#each channel.videos as video (video.videoId)}
+ <VideoCard
+ videoId={video.videoId}
+ title={video.title}
+ author={video.author}
+ authorId={video.authorId}
+ thumbnails={video.videoThumbnails}
+ viewCount={video.viewCount}
+ publishedText={video.publishedText}
+ lengthSeconds={video.lengthSeconds}
+ />
+ {/each}
+ </div>
+ {/if}
+ </div>
+ {/if}
+</div>
+
+<style>
+ .channel-page {
+ max-width: 1200px;
+ }
+
+ .channel-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+ }
+
+ .channel-info {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ }
+
+ .avatar {
+ width: 80px;
+ height: 80px;
+ border-radius: 50%;
+ object-fit: cover;
+ }
+
+ .channel-name {
+ font-size: 1.5rem;
+ font-weight: 600;
+ margin: 0 0 0.25rem;
+ }
+
+ .sub-count {
+ color: var(--text-muted);
+ margin: 0;
+ }
+
+ .description {
+ color: var(--text-secondary);
+ margin-bottom: 2rem;
+ white-space: pre-wrap;
+ line-height: 1.6;
+ }
+
+ @media (max-width: 600px) {
+ .channel-header {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .avatar {
+ width: 60px;
+ height: 60px;
+ }
+
+ .channel-name {
+ font-size: 1.25rem;
+ }
+ }
+</style>
diff --git a/src/routes/playlists/+page.svelte b/src/routes/playlists/+page.svelte
new file mode 100644
index 0000000..eb06e69
--- /dev/null
+++ b/src/routes/playlists/+page.svelte
@@ -0,0 +1,353 @@
+<script lang="ts">
+ import { playlists } from '$lib/stores/playlists';
+ import { importPlaylist, getBestThumbnail } from '$lib/api/youtube';
+
+ let newPlaylistName = $state('');
+ let showCreateForm = $state(false);
+ let showImportForm = $state(false);
+ let importUrl = $state('');
+ let importing = $state(false);
+ let importError = $state('');
+
+ async function createPlaylist() {
+ if (!newPlaylistName.trim()) return;
+ await playlists.create(newPlaylistName.trim());
+ newPlaylistName = '';
+ showCreateForm = false;
+ }
+
+ function handleCreateKeydown(e: KeyboardEvent) {
+ if (e.key === 'Enter') {
+ createPlaylist();
+ } else if (e.key === 'Escape') {
+ showCreateForm = false;
+ newPlaylistName = '';
+ }
+ }
+
+ function handleImportKeydown(e: KeyboardEvent) {
+ if (e.key === 'Enter') {
+ handleImport();
+ } else if (e.key === 'Escape') {
+ showImportForm = false;
+ importUrl = '';
+ importError = '';
+ }
+ }
+
+ async function handleImport() {
+ if (!importUrl.trim()) return;
+
+ importing = true;
+ importError = '';
+
+ try {
+ const imported = await importPlaylist(importUrl.trim());
+
+ // Create the playlist locally
+ const playlistId = await playlists.create(imported.title);
+
+ // Add all videos to it
+ for (const video of imported.videos) {
+ await playlists.addVideo(playlistId, {
+ videoId: video.videoId,
+ title: video.title,
+ author: video.author,
+ authorId: video.authorId,
+ thumbnail: getBestThumbnail(video.videoThumbnails) || `https://i.ytimg.com/vi/${video.videoId}/hqdefault.jpg`,
+ lengthSeconds: video.lengthSeconds
+ });
+ }
+
+ importUrl = '';
+ showImportForm = false;
+ } catch (e) {
+ importError = e instanceof Error ? e.message : 'Failed to import playlist';
+ } finally {
+ importing = false;
+ }
+ }
+
+ async function deletePlaylist(id: string, name: string) {
+ if (confirm(`Delete playlist "${name}"?`)) {
+ await playlists.delete(id);
+ }
+ }
+</script>
+
+<svelte:head>
+ <title>Playlists - ActualYT</title>
+</svelte:head>
+
+<div class="container">
+ <div class="header">
+ <h1 class="section-title">Playlists</h1>
+ <div class="header-actions">
+ {#if !showCreateForm && !showImportForm}
+ <button class="btn btn-secondary" onclick={() => showImportForm = true}>
+ Import from YouTube
+ </button>
+ <button class="btn btn-primary" onclick={() => showCreateForm = true}>
+ + New Playlist
+ </button>
+ {/if}
+ </div>
+ </div>
+
+ {#if showCreateForm}
+ <div class="form-section">
+ <h3 class="form-title">Create New Playlist</h3>
+ <div class="form-row">
+ <input
+ type="text"
+ bind:value={newPlaylistName}
+ placeholder="Playlist name"
+ onkeydown={handleCreateKeydown}
+ />
+ <button class="btn btn-primary" onclick={createPlaylist}>Create</button>
+ <button class="btn btn-secondary" onclick={() => { showCreateForm = false; newPlaylistName = ''; }}>
+ Cancel
+ </button>
+ </div>
+ </div>
+ {/if}
+
+ {#if showImportForm}
+ <div class="form-section">
+ <h3 class="form-title">Import YouTube Playlist</h3>
+ <p class="form-hint">Paste a YouTube playlist URL to import all its videos</p>
+ <div class="form-row">
+ <input
+ type="text"
+ bind:value={importUrl}
+ placeholder="https://www.youtube.com/playlist?list=..."
+ onkeydown={handleImportKeydown}
+ disabled={importing}
+ />
+ <button class="btn btn-primary" onclick={handleImport} disabled={importing}>
+ {importing ? 'Importing...' : 'Import'}
+ </button>
+ <button class="btn btn-secondary" onclick={() => { showImportForm = false; importUrl = ''; importError = ''; }} disabled={importing}>
+ Cancel
+ </button>
+ </div>
+ {#if importError}
+ <p class="form-error">{importError}</p>
+ {/if}
+ </div>
+ {/if}
+
+ {#if $playlists.length === 0 && !showCreateForm && !showImportForm}
+ <div class="empty">
+ <p>You haven't created any playlists yet.</p>
+ <p>Create a playlist or import one from YouTube.</p>
+ </div>
+ {:else if $playlists.length > 0}
+ <div class="playlist-grid">
+ {#each $playlists as playlist (playlist.id)}
+ <a href="/playlists/{playlist.id}" class="playlist-card">
+ <div class="thumbnail">
+ {#if playlist.videos.length > 0}
+ <img src={playlist.videos[0].thumbnail} alt="" />
+ <span class="count">{playlist.videos.length} videos</span>
+ {:else}
+ <div class="empty-thumb">
+ <svg viewBox="0 0 24 24" width="48" height="48" fill="currentColor">
+ <path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z"/>
+ </svg>
+ </div>
+ {/if}
+ </div>
+ <div class="info">
+ <h3 class="name">{playlist.name}</h3>
+ <p class="meta">{playlist.videos.length} videos</p>
+ </div>
+ <button
+ class="delete-btn"
+ onclick={(e) => { e.preventDefault(); deletePlaylist(playlist.id, playlist.name); }}
+ aria-label="Delete playlist"
+ >
+ <svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
+ <path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
+ </svg>
+ </button>
+ </a>
+ {/each}
+ </div>
+ {/if}
+</div>
+
+<style>
+ .header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+ }
+
+ .section-title {
+ margin-bottom: 0;
+ }
+
+ .header-actions {
+ display: flex;
+ gap: 0.5rem;
+ }
+
+ .form-section {
+ background: var(--bg-secondary);
+ border-radius: 8px;
+ padding: 1.5rem;
+ margin-bottom: 2rem;
+ }
+
+ .form-title {
+ font-size: 1rem;
+ font-weight: 600;
+ margin: 0 0 0.5rem;
+ }
+
+ .form-hint {
+ font-size: 0.9rem;
+ color: var(--text-muted);
+ margin: 0 0 1rem;
+ }
+
+ .form-row {
+ display: flex;
+ gap: 0.5rem;
+ }
+
+ .form-row input {
+ flex: 1;
+ padding: 0.5rem 1rem;
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ font-size: 1rem;
+ }
+
+ .form-row input:focus {
+ outline: none;
+ border-color: var(--accent-color);
+ }
+
+ .form-row input:disabled {
+ opacity: 0.6;
+ }
+
+ .form-error {
+ color: var(--error-color);
+ font-size: 0.9rem;
+ margin: 0.75rem 0 0;
+ }
+
+ .playlist-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 1.5rem;
+ }
+
+ .playlist-card {
+ display: block;
+ text-decoration: none;
+ color: inherit;
+ border-radius: 8px;
+ overflow: hidden;
+ position: relative;
+ transition: transform 0.15s ease;
+ }
+
+ .playlist-card:hover {
+ transform: translateY(-2px);
+ }
+
+ .thumbnail {
+ position: relative;
+ aspect-ratio: 16 / 9;
+ background: var(--bg-secondary);
+ border-radius: 8px;
+ overflow: hidden;
+ }
+
+ .thumbnail img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+
+ .count {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ background: rgba(0, 0, 0, 0.8);
+ color: #fff;
+ padding: 4px 8px;
+ font-size: 0.8rem;
+ }
+
+ .empty-thumb {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--text-muted);
+ }
+
+ .info {
+ padding: 0.75rem 0;
+ }
+
+ .name {
+ font-size: 1rem;
+ font-weight: 500;
+ margin: 0 0 0.25rem;
+ }
+
+ .meta {
+ font-size: 0.85rem;
+ color: var(--text-muted);
+ margin: 0;
+ }
+
+ .delete-btn {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ width: 32px;
+ height: 32px;
+ border: none;
+ border-radius: 50%;
+ background: rgba(0, 0, 0, 0.7);
+ color: #fff;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ transition: opacity 0.15s ease;
+ }
+
+ .playlist-card:hover .delete-btn {
+ opacity: 1;
+ }
+
+ .delete-btn:hover {
+ background: var(--error-color);
+ }
+
+ @media (max-width: 600px) {
+ .header {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .form-row {
+ flex-direction: column;
+ }
+ }
+</style>
diff --git a/src/routes/playlists/[id]/+page.svelte b/src/routes/playlists/[id]/+page.svelte
new file mode 100644
index 0000000..21de34f
--- /dev/null
+++ b/src/routes/playlists/[id]/+page.svelte
@@ -0,0 +1,294 @@
+<script lang="ts">
+ import { page } from '$app/stores';
+ import { goto } from '$app/navigation';
+ import { playlists, type Playlist } from '$lib/stores/playlists';
+ import { formatDuration } from '$lib/api/youtube';
+
+ const playlistId = $derived($page.params.id ?? '');
+ const playlist = $derived(playlists.getById($playlists, playlistId));
+
+ let editing = $state(false);
+ let editName = $state('');
+
+ function startEditing() {
+ if (!playlist) return;
+ editName = playlist.name;
+ editing = true;
+ }
+
+ function saveEdit() {
+ if (!editName.trim() || !playlist) return;
+ playlists.rename(playlist.id, editName.trim());
+ editing = false;
+ }
+
+ function cancelEdit() {
+ editing = false;
+ editName = '';
+ }
+
+ function handleKeydown(e: KeyboardEvent) {
+ if (e.key === 'Enter') saveEdit();
+ else if (e.key === 'Escape') cancelEdit();
+ }
+
+ function removeVideo(videoId: string) {
+ if (!playlist) return;
+ playlists.removeVideo(playlist.id, videoId);
+ }
+
+ async function deletePlaylist() {
+ if (!playlist) return;
+ if (confirm(`Delete playlist "${playlist.name}"?`)) {
+ await playlists.delete(playlist.id);
+ goto('/playlists');
+ }
+ }
+</script>
+
+<svelte:head>
+ <title>{playlist?.name || 'Playlist'} - ActualYT</title>
+</svelte:head>
+
+<div class="container">
+ {#if !playlist}
+ <div class="error">Playlist not found</div>
+ {:else}
+ <div class="playlist-page">
+ <div class="playlist-header">
+ {#if editing}
+ <input
+ type="text"
+ bind:value={editName}
+ onkeydown={handleKeydown}
+ class="edit-input"
+ autofocus
+ />
+ <button class="btn btn-primary" onclick={saveEdit}>Save</button>
+ <button class="btn btn-secondary" onclick={cancelEdit}>Cancel</button>
+ {:else}
+ <h1 class="title">{playlist.name}</h1>
+ <div class="actions">
+ <button class="btn btn-secondary" onclick={startEditing}>Rename</button>
+ <button class="btn btn-danger" onclick={deletePlaylist}>Delete</button>
+ </div>
+ {/if}
+ </div>
+
+ <p class="meta">{playlist.videos.length} videos</p>
+
+ {#if playlist.videos.length === 0}
+ <div class="empty">
+ <p>This playlist is empty.</p>
+ <p>Add videos from the watch page using the "Save" button.</p>
+ </div>
+ {:else}
+ <div class="video-list">
+ {#each playlist.videos as video, index (video.videoId)}
+ <div class="video-item">
+ <span class="index">{index + 1}</span>
+ <a href="/watch/{video.videoId}" class="video-link">
+ <div class="thumbnail">
+ <img src={video.thumbnail} alt="" />
+ <span class="duration">{formatDuration(video.lengthSeconds)}</span>
+ </div>
+ <div class="info">
+ <h3 class="video-title">{video.title}</h3>
+ <span
+ class="author"
+ role="link"
+ tabindex="0"
+ onclick={(e) => { e.preventDefault(); e.stopPropagation(); window.location.href = `/channel/${video.authorId}`; }}
+ onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); window.location.href = `/channel/${video.authorId}`; } }}
+ >
+ {video.author}
+ </span>
+ </div>
+ </a>
+ <button
+ class="remove-btn"
+ onclick={() => removeVideo(video.videoId)}
+ aria-label="Remove from playlist"
+ >
+ <svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
+ <path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
+ </svg>
+ </button>
+ </div>
+ {/each}
+ </div>
+ {/if}
+ </div>
+ {/if}
+</div>
+
+<style>
+ .playlist-page {
+ max-width: 900px;
+ }
+
+ .playlist-header {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ margin-bottom: 0.5rem;
+ }
+
+ .title {
+ font-size: 1.5rem;
+ font-weight: 600;
+ margin: 0;
+ flex: 1;
+ }
+
+ .edit-input {
+ flex: 1;
+ padding: 0.5rem 1rem;
+ font-size: 1.25rem;
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ }
+
+ .edit-input:focus {
+ outline: none;
+ border-color: var(--accent-color);
+ }
+
+ .actions {
+ display: flex;
+ gap: 0.5rem;
+ }
+
+ .meta {
+ color: var(--text-muted);
+ margin-bottom: 2rem;
+ }
+
+ .video-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ }
+
+ .video-item {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ padding: 0.5rem;
+ border-radius: 8px;
+ transition: background 0.15s ease;
+ }
+
+ .video-item:hover {
+ background: var(--bg-hover);
+ }
+
+ .index {
+ width: 24px;
+ text-align: center;
+ color: var(--text-muted);
+ font-size: 0.9rem;
+ flex-shrink: 0;
+ }
+
+ .video-link {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ flex: 1;
+ text-decoration: none;
+ color: inherit;
+ min-width: 0;
+ }
+
+ .thumbnail {
+ position: relative;
+ width: 120px;
+ aspect-ratio: 16 / 9;
+ background: var(--bg-secondary);
+ border-radius: 4px;
+ overflow: hidden;
+ flex-shrink: 0;
+ }
+
+ .thumbnail img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+
+ .duration {
+ position: absolute;
+ bottom: 4px;
+ right: 4px;
+ background: rgba(0, 0, 0, 0.8);
+ color: #fff;
+ padding: 1px 4px;
+ border-radius: 2px;
+ font-size: 0.75rem;
+ }
+
+ .info {
+ flex: 1;
+ min-width: 0;
+ }
+
+ .video-title {
+ font-size: 0.95rem;
+ font-weight: 500;
+ margin: 0 0 0.25rem;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ }
+
+ .author {
+ font-size: 0.85rem;
+ color: var(--text-secondary);
+ text-decoration: none;
+ cursor: pointer;
+ }
+
+ .author:hover {
+ color: var(--text-primary);
+ }
+
+ .remove-btn {
+ width: 36px;
+ height: 36px;
+ border: none;
+ border-radius: 50%;
+ background: transparent;
+ color: var(--text-muted);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ transition: opacity 0.15s ease, background 0.15s ease;
+ flex-shrink: 0;
+ }
+
+ .video-item:hover .remove-btn {
+ opacity: 1;
+ }
+
+ .remove-btn:hover {
+ background: var(--bg-secondary);
+ color: var(--error-color);
+ }
+
+ @media (max-width: 600px) {
+ .thumbnail {
+ width: 100px;
+ }
+
+ .index {
+ display: none;
+ }
+ }
+</style>
diff --git a/src/routes/search/+page.svelte b/src/routes/search/+page.svelte
new file mode 100644
index 0000000..29bf7dc
--- /dev/null
+++ b/src/routes/search/+page.svelte
@@ -0,0 +1,77 @@
+<script lang="ts">
+ import { page } from '$app/stores';
+ import { search, type VideoInfo } from '$lib/api/youtube';
+ import VideoCard from '$lib/components/VideoCard.svelte';
+
+ let results = $state<VideoInfo[]>([]);
+ let loading = $state(true);
+ let error = $state('');
+ let currentQuery = $state('');
+
+ $effect(() => {
+ const query = $page.url.searchParams.get('q') || '';
+ if (query !== currentQuery) {
+ currentQuery = query;
+ loadResults(query);
+ }
+ });
+
+ async function loadResults(query: string) {
+ if (!query) {
+ results = [];
+ loading = false;
+ return;
+ }
+
+ loading = true;
+ error = '';
+
+ try {
+ results = await search(query);
+ } catch (e) {
+ error = e instanceof Error ? e.message : 'Search failed';
+ results = [];
+ } finally {
+ loading = false;
+ }
+ }
+</script>
+
+<svelte:head>
+ <title>{currentQuery ? `${currentQuery} - Search` : 'Search'} - ActualYT</title>
+</svelte:head>
+
+<div class="container">
+ {#if currentQuery}
+ <h1 class="section-title">Results for "{currentQuery}"</h1>
+ {:else}
+ <h1 class="section-title">Search</h1>
+ {/if}
+
+ {#if loading}
+ <div class="loading">Searching</div>
+ {:else if error}
+ <div class="error">{error}</div>
+ {:else if !currentQuery}
+ <div class="empty">Enter a search term to find videos</div>
+ {:else if results.length === 0}
+ <div class="empty">No results found for "{currentQuery}"</div>
+ {:else}
+ <div class="video-grid">
+ {#each results as result (result.videoId)}
+ {#if result.videoId && result.title}
+ <VideoCard
+ videoId={result.videoId}
+ title={result.title}
+ author={result.author || ''}
+ authorId={result.authorId || ''}
+ thumbnails={result.videoThumbnails || []}
+ viewCount={result.viewCount || 0}
+ publishedText={result.publishedText || ''}
+ lengthSeconds={result.lengthSeconds || 0}
+ />
+ {/if}
+ {/each}
+ </div>
+ {/if}
+</div>
diff --git a/src/routes/subscriptions/+page.svelte b/src/routes/subscriptions/+page.svelte
new file mode 100644
index 0000000..9f318ac
--- /dev/null
+++ b/src/routes/subscriptions/+page.svelte
@@ -0,0 +1,151 @@
+<script lang="ts">
+ import { subscriptions } from '$lib/stores/subscriptions';
+ import { getChannel, type VideoInfo } from '$lib/api/youtube';
+ import VideoCard from '$lib/components/VideoCard.svelte';
+ import ChannelCard from '$lib/components/ChannelCard.svelte';
+
+ let videos = $state<VideoInfo[]>([]);
+ let loading = $state(true);
+ let error = $state('');
+ let activeTab = $state<'feed' | 'channels'>('feed');
+
+ $effect(() => {
+ if ($subscriptions.length > 0 && activeTab === 'feed') {
+ loadFeed();
+ } else if ($subscriptions.length === 0) {
+ loading = false;
+ }
+ });
+
+ async function loadFeed() {
+ loading = true;
+ error = '';
+
+ try {
+ const results = await Promise.all(
+ $subscriptions.map(sub =>
+ getChannel(sub.channelId).catch(() => ({ videos: [] }))
+ )
+ );
+
+ const allVideos = results.flatMap(r => r.videos);
+ videos = allVideos.slice(0, 50);
+ } catch (e) {
+ error = e instanceof Error ? e.message : 'Failed to load feed';
+ } finally {
+ loading = false;
+ }
+ }
+</script>
+
+<svelte:head>
+ <title>Subscriptions - ActualYT</title>
+</svelte:head>
+
+<div class="container">
+ <div class="header">
+ <h1 class="section-title">Subscriptions</h1>
+ <div class="tabs">
+ <button
+ class="tab"
+ class:active={activeTab === 'feed'}
+ onclick={() => activeTab = 'feed'}
+ >
+ Feed
+ </button>
+ <button
+ class="tab"
+ class:active={activeTab === 'channels'}
+ onclick={() => activeTab = 'channels'}
+ >
+ Channels ({$subscriptions.length})
+ </button>
+ </div>
+ </div>
+
+ {#if $subscriptions.length === 0}
+ <div class="empty">
+ <p>You haven't subscribed to any channels yet.</p>
+ <p>Find channels you like and click Subscribe to see their videos here.</p>
+ </div>
+ {:else if activeTab === 'feed'}
+ {#if loading}
+ <div class="loading">Loading your feed</div>
+ {:else if error}
+ <div class="error">{error}</div>
+ {:else if videos.length === 0}
+ <div class="empty">No recent videos from your subscriptions</div>
+ {:else}
+ <div class="video-grid">
+ {#each videos as video (video.videoId)}
+ <VideoCard
+ videoId={video.videoId}
+ title={video.title}
+ author={video.author}
+ authorId={video.authorId}
+ thumbnails={video.videoThumbnails}
+ viewCount={video.viewCount}
+ publishedText={video.publishedText}
+ lengthSeconds={video.lengthSeconds}
+ />
+ {/each}
+ </div>
+ {/if}
+ {:else}
+ <div class="channel-list">
+ {#each $subscriptions as sub (sub.channelId)}
+ <ChannelCard
+ channelId={sub.channelId}
+ channelName={sub.channelName}
+ thumbnails={[{ url: sub.thumbnail, width: 88, height: 88 }]}
+ />
+ {/each}
+ </div>
+ {/if}
+</div>
+
+<style>
+ .header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+ }
+
+ .section-title {
+ margin-bottom: 0;
+ }
+
+ .tabs {
+ display: flex;
+ gap: 0.5rem;
+ }
+
+ .tab {
+ padding: 0.5rem 1rem;
+ border: none;
+ border-radius: 20px;
+ background: var(--bg-secondary);
+ color: var(--text-secondary);
+ font-size: 0.9rem;
+ cursor: pointer;
+ transition: background 0.15s ease, color 0.15s ease;
+ }
+
+ .tab:hover {
+ background: var(--bg-hover);
+ }
+
+ .tab.active {
+ background: var(--text-primary);
+ color: var(--bg-primary);
+ }
+
+ .channel-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ }
+</style>
diff --git a/src/routes/watch/[id]/+page.svelte b/src/routes/watch/[id]/+page.svelte
new file mode 100644
index 0000000..f96d54a
--- /dev/null
+++ b/src/routes/watch/[id]/+page.svelte
@@ -0,0 +1,381 @@
+<script lang="ts">
+ import { page } from '$app/stores';
+ import { getVideo, getRelatedVideos, formatViews, getBestThumbnail, type VideoDetails, type VideoInfo } from '$lib/api/youtube';
+ import { subscriptions } from '$lib/stores/subscriptions';
+ import { playlists, type PlaylistVideo } from '$lib/stores/playlists';
+ import VideoPlayer from '$lib/components/VideoPlayer.svelte';
+ import VideoCard from '$lib/components/VideoCard.svelte';
+
+ let video = $state<VideoDetails | null>(null);
+ let relatedVideos = $state<VideoInfo[]>([]);
+ let loading = $state(true);
+ let loadingRelated = $state(false);
+ let error = $state('');
+ let showDescription = $state(false);
+ let showPlaylistMenu = $state(false);
+
+ const videoId = $derived($page.params.id);
+ const isSubscribed = $derived(
+ video ? subscriptions.isSubscribed($subscriptions, video.authorId) : false
+ );
+
+ $effect(() => {
+ const id = $page.params.id;
+ if (id) loadVideo(id);
+ });
+
+ async function loadVideo(id: string) {
+ loading = true;
+ error = '';
+ showDescription = false;
+ relatedVideos = [];
+
+ try {
+ video = await getVideo(id);
+ loadRelated(id);
+ } catch (e) {
+ error = e instanceof Error ? e.message : 'Failed to load video';
+ video = null;
+ } finally {
+ loading = false;
+ }
+ }
+
+ async function loadRelated(id: string) {
+ loadingRelated = true;
+ try {
+ relatedVideos = await getRelatedVideos(id);
+ } catch (e) {
+ console.error('Failed to load related videos:', e);
+ } finally {
+ loadingRelated = false;
+ }
+ }
+
+ function handleSubscribe() {
+ if (!video) return;
+ if (isSubscribed) {
+ subscriptions.remove(video.authorId);
+ } else {
+ subscriptions.add(video.authorId, video.author, video.videoThumbnails);
+ }
+ }
+
+ function addToPlaylist(playlistId: string) {
+ if (!video) return;
+ const playlistVideo: PlaylistVideo = {
+ videoId: video.videoId,
+ title: video.title,
+ author: video.author,
+ authorId: video.authorId,
+ thumbnail: getBestThumbnail(video.videoThumbnails),
+ lengthSeconds: video.lengthSeconds
+ };
+ playlists.addVideo(playlistId, playlistVideo);
+ showPlaylistMenu = false;
+ }
+
+ async function createAndAddToPlaylist() {
+ if (!video) return;
+ const name = prompt('Playlist name:');
+ if (!name) return;
+ const id = await playlists.create(name);
+ addToPlaylist(id);
+ }
+</script>
+
+<svelte:head>
+ <title>{video?.title || 'Watch'} - ActualYT</title>
+</svelte:head>
+
+<div class="container">
+ {#if loading}
+ <div class="loading">Loading video</div>
+ {:else if error}
+ <div class="error">{error}</div>
+ {:else if video}
+ <div class="watch-page">
+ <div class="main-content">
+ <div class="player-section">
+ <VideoPlayer
+ formats={video.formats}
+ title={video.title}
+ />
+
+ <div class="video-info">
+ <h1 class="title">{video.title}</h1>
+
+ <div class="meta-row">
+ <div class="stats">
+ <span>{formatViews(video.viewCount)}</span>
+ <span class="separator">-</span>
+ <span>{video.publishedText}</span>
+ {#if video.likeCount}
+ <span class="separator">-</span>
+ <span>{formatViews(video.likeCount).replace('views', 'likes')}</span>
+ {/if}
+ </div>
+
+ <div class="actions">
+ <div class="playlist-dropdown">
+ <button
+ class="btn btn-secondary"
+ onclick={() => showPlaylistMenu = !showPlaylistMenu}
+ >
+ + Save
+ </button>
+ {#if showPlaylistMenu}
+ <div class="playlist-menu">
+ {#each $playlists as playlist (playlist.id)}
+ <button onclick={() => addToPlaylist(playlist.id)}>
+ {playlist.name}
+ </button>
+ {/each}
+ <button class="new-playlist" onclick={createAndAddToPlaylist}>
+ + Create new playlist
+ </button>
+ </div>
+ {/if}
+ </div>
+ </div>
+ </div>
+
+ <div class="channel-row">
+ <a href="/channel/{video.authorId}" class="channel">
+ <span class="channel-name">{video.author}</span>
+ </a>
+ <button
+ class="btn"
+ class:btn-primary={!isSubscribed}
+ class:btn-secondary={isSubscribed}
+ onclick={handleSubscribe}
+ >
+ {isSubscribed ? 'Subscribed' : 'Subscribe'}
+ </button>
+ </div>
+
+ <div class="description" class:expanded={showDescription}>
+ <p>{video.description || 'No description'}</p>
+ {#if video.description && video.description.length > 200}
+ <button class="show-more" onclick={() => showDescription = !showDescription}>
+ {showDescription ? 'Show less' : 'Show more'}
+ </button>
+ {/if}
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <aside class="related">
+ <h2 class="related-title">Related Videos</h2>
+ {#if loadingRelated}
+ <div class="loading-small">Loading related videos...</div>
+ {:else if relatedVideos.length > 0}
+ <div class="related-list">
+ {#each relatedVideos as related (related.videoId)}
+ <VideoCard
+ videoId={related.videoId}
+ title={related.title}
+ author={related.author}
+ authorId={related.authorId}
+ thumbnails={related.videoThumbnails}
+ viewCount={related.viewCount}
+ publishedText={related.publishedText}
+ lengthSeconds={related.lengthSeconds}
+ />
+ {/each}
+ </div>
+ {:else}
+ <p class="no-related">No related videos found</p>
+ {/if}
+ </aside>
+ </div>
+ {/if}
+</div>
+
+<style>
+ .watch-page {
+ display: grid;
+ grid-template-columns: 1fr 350px;
+ gap: 2rem;
+ align-items: start;
+ }
+
+ .main-content {
+ min-width: 0;
+ }
+
+ .player-section {
+ width: 100%;
+ }
+
+ .video-info {
+ margin-top: 1rem;
+ }
+
+ .title {
+ font-size: 1.25rem;
+ font-weight: 600;
+ line-height: 1.4;
+ margin-bottom: 0.75rem;
+ }
+
+ .meta-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 1rem;
+ padding-bottom: 1rem;
+ border-bottom: 1px solid var(--border-color);
+ }
+
+ .stats {
+ color: var(--text-secondary);
+ font-size: 0.9rem;
+ }
+
+ .separator {
+ margin: 0 0.5rem;
+ }
+
+ .actions {
+ display: flex;
+ gap: 0.5rem;
+ }
+
+ .playlist-dropdown {
+ position: relative;
+ }
+
+ .playlist-menu {
+ position: absolute;
+ top: 100%;
+ right: 0;
+ margin-top: 0.5rem;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ min-width: 200px;
+ z-index: 10;
+ overflow: hidden;
+ }
+
+ .playlist-menu button {
+ display: block;
+ width: 100%;
+ padding: 0.75rem 1rem;
+ text-align: left;
+ border: none;
+ background: transparent;
+ color: var(--text-primary);
+ font-size: 0.9rem;
+ cursor: pointer;
+ }
+
+ .playlist-menu button:hover {
+ background: var(--bg-hover);
+ }
+
+ .playlist-menu .new-playlist {
+ border-top: 1px solid var(--border-color);
+ color: var(--accent-color);
+ }
+
+ .channel-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 1rem 0;
+ }
+
+ .channel {
+ text-decoration: none;
+ }
+
+ .channel-name {
+ font-weight: 500;
+ color: var(--text-primary);
+ }
+
+ .description {
+ padding: 1rem;
+ background: var(--bg-secondary);
+ border-radius: 8px;
+ font-size: 0.9rem;
+ line-height: 1.6;
+ }
+
+ .description:not(.expanded) p {
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ }
+
+ .description p {
+ white-space: pre-wrap;
+ word-break: break-word;
+ }
+
+ .show-more {
+ display: block;
+ margin-top: 0.5rem;
+ padding: 0;
+ border: none;
+ background: transparent;
+ color: var(--text-secondary);
+ font-size: 0.85rem;
+ cursor: pointer;
+ }
+
+ .show-more:hover {
+ color: var(--text-primary);
+ }
+
+ .related {
+ position: sticky;
+ top: 80px;
+ }
+
+ .related-title {
+ font-size: 1rem;
+ font-weight: 600;
+ margin-bottom: 1rem;
+ }
+
+ .related-list {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ }
+
+ .loading-small {
+ color: var(--text-muted);
+ font-size: 0.9rem;
+ padding: 1rem 0;
+ }
+
+ .no-related {
+ color: var(--text-muted);
+ font-size: 0.9rem;
+ }
+
+ @media (max-width: 1024px) {
+ .watch-page {
+ grid-template-columns: 1fr;
+ }
+
+ .related {
+ position: static;
+ margin-top: 2rem;
+ }
+
+ .related-list {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
+ }
+ }
+</style>
diff --git a/svelte.config.js b/svelte.config.js
new file mode 100644
index 0000000..e7f0869
--- /dev/null
+++ b/svelte.config.js
@@ -0,0 +1,12 @@
+import adapter from '@sveltejs/adapter-auto';
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
+
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ preprocess: vitePreprocess(),
+ kit: {
+ adapter: adapter()
+ }
+};
+
+export default config;
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..4344710
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "moduleResolution": "bundler"
+ }
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..3406f32
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,6 @@
+import { sveltekit } from '@sveltejs/kit/vite';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [sveltekit()]
+});