Completed channel view
This commit is contained in:
@@ -20,7 +20,6 @@ Modern material design for Invidious
|
||||
- Audio only mode.
|
||||
|
||||
## Todo
|
||||
- Complete channel view.
|
||||
- Playlist support.
|
||||
- PWA support.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { get } from 'svelte/store';
|
||||
import { auth, returnYTDislikesInstance } from '../../store';
|
||||
import type { Channel, ChannelPage, Comments, Playlist, PlaylistPage, ReturnYTDislikes, SearchSuggestion, Subscription, Video, VideoPlay } from './model';
|
||||
import type { Channel, ChannelContentPlaylists, ChannelContentVideos, ChannelPage, Comments, Playlist, PlaylistPage, ReturnYTDislikes, SearchSuggestion, Subscription, Video, VideoPlay } from './model';
|
||||
|
||||
export function buildPath(path: string): string {
|
||||
return `${import.meta.env.VITE_DEFAULT_INVIDIOUS_INSTANCE}/api/v1/${path}`;
|
||||
@@ -54,6 +54,22 @@ export async function getChannel(channelId: string): Promise<ChannelPage> {
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
export async function getChannelContent(
|
||||
channelId: string,
|
||||
parameters: {
|
||||
type?: 'videos' | 'playlists' | 'streams' | 'shorts';
|
||||
continuation?: string;
|
||||
}): Promise<ChannelContentVideos | ChannelContentPlaylists> {
|
||||
if (typeof parameters.type === 'undefined') parameters.type = 'videos';
|
||||
|
||||
const url = new URL(buildPath(`channels/${channelId}/${parameters.type}`));
|
||||
|
||||
if (typeof parameters.continuation !== 'undefined') url.searchParams.set('continuation', parameters.continuation);
|
||||
|
||||
const resp = await fetch(url.toString());
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
export async function getSearchSuggestions(search: string): Promise<SearchSuggestion> {
|
||||
const path = new URL(buildPath("search/suggestions"));
|
||||
path.search = new URLSearchParams({ q: search }).toString();
|
||||
|
||||
@@ -185,6 +185,16 @@ export interface PlaylistPageVideo extends PlaylistVideo {
|
||||
viewCount: number;
|
||||
}
|
||||
|
||||
export interface ChannelContentVideos {
|
||||
videos: Video[];
|
||||
continuation: string;
|
||||
}
|
||||
|
||||
export interface ChannelContentPlaylists {
|
||||
playlists: PlaylistPage[];
|
||||
continuation: string;
|
||||
}
|
||||
|
||||
export interface PlaylistPage extends Playlist {
|
||||
description: string;
|
||||
descriptionHtml: string;
|
||||
|
||||
@@ -13,7 +13,11 @@
|
||||
|
||||
onMount(() => {
|
||||
img = new Image();
|
||||
img.src = playlist.videos[0].videoThumbnails[4].url;
|
||||
if (playlist.videos.length > 0) {
|
||||
img.src = playlist.videos[0].videoThumbnails[4].url;
|
||||
} else {
|
||||
img.src = playlist.playlistThumbnail;
|
||||
}
|
||||
|
||||
img.onload = () => {
|
||||
loaded = true;
|
||||
|
||||
@@ -1,11 +1,71 @@
|
||||
<script lang="ts">
|
||||
import { cleanNumber } from '$lib/misc';
|
||||
import { amSubscribed, deleteUnsubscribe, getChannelContent, postSubscribe } from '$lib/Api';
|
||||
import type { ChannelContentPlaylists, ChannelContentVideos } from '$lib/Api/model';
|
||||
import PageLoading from '$lib/PageLoading.svelte';
|
||||
import PlaylistThumbnail from '$lib/PlaylistThumbnail.svelte';
|
||||
import Thumbnail from '$lib/Thumbnail.svelte';
|
||||
import { activePage } from '../../../store.js';
|
||||
import { cleanNumber } from '$lib/misc';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { activePage, auth } from '../../../store';
|
||||
|
||||
export let data;
|
||||
|
||||
activePage.set(null);
|
||||
|
||||
let isSubscribed = false;
|
||||
|
||||
let tab: 'videos' | 'playlists' | 'streams' | 'shorts' = 'videos';
|
||||
|
||||
let displayContent: ChannelContentPlaylists | ChannelContentVideos | undefined = undefined;
|
||||
|
||||
async function handleScroll() {
|
||||
if (typeof displayContent === 'undefined') return;
|
||||
|
||||
const { scrollTop, clientHeight, scrollHeight } = document.documentElement;
|
||||
if (scrollTop + clientHeight >= scrollHeight - 5) {
|
||||
const newContent = await getChannelContent(data.channel.authorId, {
|
||||
type: tab,
|
||||
continuation: displayContent.continuation
|
||||
});
|
||||
if ('videos' in newContent && 'videos' in displayContent) {
|
||||
displayContent.videos = [...displayContent.videos, ...newContent.videos];
|
||||
} else if ('playlists' in displayContent && 'playlists' in newContent) {
|
||||
displayContent.playlists = [...displayContent.playlists, ...newContent.playlists];
|
||||
}
|
||||
displayContent.continuation = newContent.continuation;
|
||||
}
|
||||
}
|
||||
|
||||
async function changeTab(newTab: 'videos' | 'playlists' | 'streams' | 'shorts') {
|
||||
tab = newTab;
|
||||
displayContent = undefined;
|
||||
displayContent = await getChannelContent(data.channel.authorId, { type: tab });
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
displayContent = await getChannelContent(data.channel.authorId, { type: 'videos' });
|
||||
|
||||
if (get(auth)) {
|
||||
isSubscribed = await amSubscribed(data.channel.authorId);
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
});
|
||||
|
||||
async function toggleSubscribed() {
|
||||
if (isSubscribed) {
|
||||
await deleteUnsubscribe(data.channel.authorId);
|
||||
} else {
|
||||
await postSubscribe(data.channel.authorId);
|
||||
}
|
||||
|
||||
isSubscribed = !isSubscribed;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="padding">
|
||||
@@ -24,28 +84,70 @@
|
||||
<p>{cleanNumber(data.channel.subCount)} subscribers</p>
|
||||
<p style="width: 60vw;">{data.channel.description}</p>
|
||||
</div>
|
||||
<button class="inverse-surface large">Subscribe</button>
|
||||
<button
|
||||
on:click={toggleSubscribed}
|
||||
class:inverse-surface={!isSubscribed}
|
||||
class:border={isSubscribed}
|
||||
>
|
||||
{#if !isSubscribed}
|
||||
Subscribe
|
||||
{:else}
|
||||
Unsubscribe
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tabs left-align">
|
||||
{#each data.channel.tabs as tab}
|
||||
<a href={`?tab=${tab}`}>
|
||||
<span>{tab}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="grid padding">
|
||||
{#each data.channel.latestVideos as video}
|
||||
<div class="s12 m6 l2">
|
||||
<Thumbnail {video} />
|
||||
</div>
|
||||
{/each}
|
||||
<a class:active={tab === 'videos'} on:click={() => changeTab('videos')} href={`#video`}>
|
||||
<i>movie</i>
|
||||
<span>Videos</span>
|
||||
</a>
|
||||
<a class:active={tab === 'shorts'} on:click={() => changeTab('shorts')} href={`#short`}>
|
||||
<i>smartphone</i>
|
||||
<span>Shorts</span>
|
||||
</a>
|
||||
<a class:active={tab === 'streams'} on:click={() => changeTab('streams')} href={`#stream`}>
|
||||
<i>stream</i>
|
||||
<span>Streams</span>
|
||||
</a>
|
||||
<a
|
||||
class:active={tab === 'playlists'}
|
||||
on:click={() => changeTab('playlists')}
|
||||
href={`#playlist`}
|
||||
>
|
||||
<i>playlist_add_check</i>
|
||||
<span>Playlists</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if displayContent}
|
||||
<div class="page right active">
|
||||
<div class="space"></div>
|
||||
<div class="grid">
|
||||
{#if 'videos' in displayContent}
|
||||
{#each displayContent.videos as video}
|
||||
<div class="s12 m6 l2">
|
||||
<article class="no-padding" style="height: 100%;">
|
||||
<Thumbnail {video} />
|
||||
</article>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each displayContent.playlists as playlist}
|
||||
<div class="s12 m6 l2">
|
||||
<article class="no-padding" style="height: 100%;">
|
||||
<PlaylistThumbnail {playlist} />
|
||||
</article>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<PageLoading />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.description {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user