summaryrefslogtreecommitdiff
path: root/src/routes
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 /src/routes
good startHEADmain
Diffstat (limited to 'src/routes')
-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
14 files changed, 1741 insertions, 0 deletions
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>