summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-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
25 files changed, 3249 insertions, 0 deletions
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>