diff options
Diffstat (limited to 'src/routes/watch')
| -rw-r--r-- | src/routes/watch/[id]/+page.svelte | 381 |
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> |
