summaryrefslogtreecommitdiff
path: root/src/routes/watch
diff options
context:
space:
mode:
Diffstat (limited to 'src/routes/watch')
-rw-r--r--src/routes/watch/[id]/+page.svelte381
1 files changed, 381 insertions, 0 deletions
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>