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