Added playlist creation & adding support

This commit is contained in:
WardPearce
2024-04-01 11:41:58 +13:00
parent abeefe755a
commit b1686daa24
9 changed files with 295 additions and 113 deletions
-1
View File
@@ -27,7 +27,6 @@
- PWA support.
## Todo
- Playlist support.
- Docker building & publishing workflow.
- Launching 'materialio.us'.
- Locking down git branches & setup PR requirements.
+46 -1
View File
@@ -160,6 +160,51 @@ export async function postHistory(videoId: string) {
}
export async function getPlaylist(playlistId: string, page: number = 1): Promise<PlaylistPage> {
const resp = await fetch(buildPath(`playlists/${playlistId}?page=${page}`));
let resp;
if (get(auth)) {
resp = await fetch(buildPath(`auth/playlists/${playlistId}?page=${page}`), buildAuthHeaders());
} else {
resp = await fetch(buildPath(`playlists/${playlistId}?page=${page}`));
}
return await resp.json();
}
export async function getPersonalPlaylists(): Promise<PlaylistPage[]> {
const resp = await fetch(buildPath('auth/playlists'), buildAuthHeaders());
return await resp.json();
}
export async function deletePersonalPlaylist(playlistId: string) {
await fetch(buildPath(`auth/playlists/${playlistId}`), {
method: 'DELETE',
...buildAuthHeaders()
});
}
export async function postPersonalPlaylist(title: string, privacy: 'public' | 'private' | 'unlisted') {
let headers: Record<string, Record<string, string>> = buildAuthHeaders();
headers['headers']['Content-type'] = 'application/json';
await fetch(buildPath('auth/playlists'), {
method: 'POST',
body: JSON.stringify({
title: title,
privacy: privacy
}),
...headers
});
}
export async function addPlaylistVideo(playlistId: string, videoId: string) {
let headers: Record<string, Record<string, string>> = buildAuthHeaders();
headers['headers']['Content-type'] = 'application/json';
await fetch(buildPath(`auth/playlists/${playlistId}/videos`), {
method: 'POST',
body: JSON.stringify({
videoId: videoId
}),
...headers
});
}
+11 -9
View File
@@ -35,15 +35,17 @@
style="width: 100%; overflow: hidden;min-height:100px;"
class="wave"
>
{#if loading}
<progress class="circle"></progress>
{:else}
<img
class="responsive"
style="max-width: 100%;height: 100%;"
src={img.src}
alt="Thumbnail for playlist"
/>
{#if playlist.videoCount > 0}
{#if loading}
<progress class="circle"></progress>
{:else}
<img
class="responsive"
style="max-width: 100%;height: 100%;"
src={img.src}
alt="Thumbnail for playlist"
/>
{/if}
{/if}
<div class="absolute right bottom small-margin black white-text small-text thumbnail-corner">
{playlist.videoCount} videos
+1
View File
@@ -168,6 +168,7 @@
function logout() {
auth.set(null);
goto('/');
}
async function loadNotifications() {
@@ -10,21 +10,26 @@
activePage.set(null);
let videos = data.playlist.videos.sort((a: PlaylistPageVideo, b: PlaylistPageVideo) => {
return a.index < b.index ? -1 : 1;
});
let videos: PlaylistPageVideo[] | undefined;
if (data.playlist.videos) {
videos = data.playlist.videos.sort((a: PlaylistPageVideo, b: PlaylistPageVideo) => {
return a.index < b.index ? -1 : 1;
});
onMount(async () => {
for (let page = 1; page++; ) {
const newVideos = (await getPlaylist(data.playlist.playlistId, page)).videos;
if (newVideos.length === 0) {
break;
onMount(async () => {
for (let page = 1; page++; ) {
const newVideos = (await getPlaylist(data.playlist.playlistId, page)).videos;
if (newVideos.length === 0) {
break;
}
videos = [...(videos as PlaylistPageVideo[]), ...newVideos].sort(
(a: PlaylistPageVideo, b: PlaylistPageVideo) => {
return a.index < b.index ? -1 : 1;
}
);
}
videos = [...videos, ...newVideos].sort((a: PlaylistPageVideo, b: PlaylistPageVideo) => {
return a.index < b.index ? -1 : 1;
});
}
});
});
}
</script>
<div class="space"></div>
@@ -36,4 +41,6 @@
<p style="white-space: pre-line;word-wrap: break-word;">{data.playlist.description}</p>
</article>
<VideoList {videos} playlistId={data.playlist.playlistId} />
{#if videos}
<VideoList {videos} playlistId={data.playlist.playlistId} />
{/if}
@@ -0,0 +1,90 @@
<script lang="ts">
import { deletePersonalPlaylist, getPersonalPlaylists, postPersonalPlaylist } from '$lib/Api';
import PlaylistThumbnail from '$lib/PlaylistThumbnail.svelte';
import { activePage } from '../../store';
export let data;
activePage.set('playlists');
let playlistPrivacy: 'public' | 'private' | 'unlisted' = 'public';
let playlistTitle: string;
function onPrivacyChange(event: any) {
playlistPrivacy = event.currentTarget.value;
}
async function removePlaylistItem(playlistId: string) {
await deletePersonalPlaylist(playlistId);
data.playlists = data.playlists.filter((item) => {
if (item.playlistId !== playlistId) {
return item;
}
});
}
async function createPlaylist() {
await postPersonalPlaylist(playlistTitle, playlistPrivacy);
ui('#create-playlist');
data.playlists = await getPersonalPlaylists();
}
</script>
<div class="page right active">
<div class="space"></div>
<div class="grid">
{#each data.playlists as playlist}
<div class="s12 m6 l2">
<article class="no-padding" style="height: 100%;">
<PlaylistThumbnail {playlist} />
<nav class="right-align padding">
<button
on:click={async () => await removePlaylistItem(playlist.playlistId)}
class="tertiary square round"
>
<i>delete</i>
</button>
</nav>
</article>
</div>
{/each}
<div class="s12 m6 l2">
<article style="height: 100%;display: flex;align-items: center;justify-content: center;">
<button data-ui="#create-playlist" class="round extra">
<i>add_circle</i>
<span>Create playlist</span>
</button>
</article>
</div>
</div>
</div>
<dialog id="create-playlist">
<form on:submit|preventDefault={createPlaylist}>
<div class="field label border">
<input bind:value={playlistTitle} required name="title" type="text" />
<label for="title">Title</label>
</div>
<div class="field middle-align">
<nav>
<label class="radio">
<input checked on:change={onPrivacyChange} value="public" type="radio" name="privacy" />
<span>Public</span>
</label>
<label class="radio">
<input on:change={onPrivacyChange} type="radio" value="unlisted" name="privacy" />
<span>Unlisted</span>
</label>
<label class="radio">
<input on:change={onPrivacyChange} type="radio" value="private" name="privacy" />
<span>Private</span>
</label>
</nav>
</div>
<nav class="right-align">
<button type="button" data-ui="#create-playlist">Cancel</button>
<button type="submit">Create</button>
</nav>
</form>
</dialog>
@@ -0,0 +1,7 @@
import { getPersonalPlaylists } from "$lib/Api";
export async function load() {
return {
playlists: await getPersonalPlaylists()
};
}
+110 -86
View File
@@ -1,5 +1,12 @@
<script lang="ts">
import { deleteUnsubscribe, getComments, getPlaylist, postSubscribe } from '$lib/Api/index.js';
import {
addPlaylistVideo,
deleteUnsubscribe,
getComments,
getPersonalPlaylists,
getPlaylist,
postSubscribe
} from '$lib/Api/index.js';
import type { PlaylistPage, PlaylistPageVideo } from '$lib/Api/model.js';
import Comment from '$lib/Comment.svelte';
import PageLoading from '$lib/PageLoading.svelte';
@@ -23,7 +30,6 @@
if (!data.playlistId) return;
for (let page = 1; page < Infinity; page++) {
console.log(page);
const newPlaylist = await getPlaylist(data.playlistId, page);
if (page === 1) {
playlist = newPlaylist;
@@ -40,6 +46,12 @@
}
});
async function addVideoToPlaylist(playlistId: string) {
await addPlaylistVideo(playlistId, data.video.videoId);
data.personalPlaylists = await getPersonalPlaylists();
}
async function loadMoreComments() {
if (!comments) {
return;
@@ -78,95 +90,107 @@
<h5>{data.video.title}</h5>
<nav>
<a href={`/channel/${data.video.authorId}`}>
<div class="grid no-padding">
<div class="s12 m12 l4">
<nav>
<img
class="circle large"
src={data.video.authorThumbnails[2].url}
alt="Channel profile"
/>
<div>
<p class="bold">{data.video.author}</p>
<p>{data.video.subCountText}</p>
</div>
<a href={`/channel/${data.video.authorId}`}>
<nav>
<img
class="circle large"
src={data.video.authorThumbnails[2].url}
alt="Channel profile"
/>
<div>
<p class="bold">{data.video.author}</p>
<p>{data.video.subCountText}</p>
</div>
</nav>
</a>
<button
on:click={toggleSubscribed}
class:inverse-surface={!data.subscribed}
class:border={data.subscribed}
>
{#if !data.subscribed}
Subscribe
{:else}
Unsubscribe
{/if}
</button>
</nav>
</a>
<button
on:click={toggleSubscribed}
class:inverse-surface={!data.subscribed}
class:border={data.subscribed}
</div>
<div
class="s12 m12 l8"
style="display: flex;align-items: center;justify-content: flex-end;"
>
{#if !data.subscribed}
Subscribe
{:else}
Unsubscribe
{#if data.returnYTDislikes}
<nav class="no-space" style="margin-right: .5em;">
<button style="cursor: default;" class="border left-round">
<i class="small">thumb_up</i>
<span>{cleanNumber(data.returnYTDislikes.likes)}</span>
</button>
<button style="cursor: default;" class="border right-round">
<i class="small">thumb_down_alt</i>
<span>{cleanNumber(data.returnYTDislikes.dislikes)}</span>
</button>
</nav>
{/if}
</button>
<div class="max"></div>
{#if data.returnYTDislikes}
<nav class="no-space m l">
<button style="cursor: default;" class="border left-round">
<i class="small">thumb_up</i>
<span>{cleanNumber(data.returnYTDislikes.likes)}</span>
</button>
<button style="cursor: default;" class="border right-round">
<i class="small">thumb_down_alt</i>
<span>{cleanNumber(data.returnYTDislikes.dislikes)}</span>
</button>
</nav>
{/if}
<button on:click={() => (audioMode = !audioMode)} class:border={!audioMode}>
<i>headphones</i>
<span>Audio only </span>
</button>
<button class="border m l" data-ui="#share"
><i>share</i> Share
<menu class="left no-wrap" id="share" data-ui="#share">
<a
class="row"
href="#copy"
on:click={async () =>
await navigator.clipboard.writeText(
`${import.meta.env.VITE_DEFAULT_FRONTEND_URL}/watch/${data.video.videoId}`
)}
>
<div class="min">Copy Materialious link</div></a
><a
href="#copy"
class="row"
on:click={async () =>
await navigator.clipboard.writeText(
`https://redirect.invidious.io/watch?v=${data.video.videoId}`
)}
>
<div class="min">Copy Invidious redirect link</div></a
><a
class="row"
href="#copy"
on:click={async () =>
await navigator.clipboard.writeText(
`https://www.youtube.com/watch?v=${data.video.videoId}`
)}
>
<div class="min">Copy Youtube link</div></a
></menu
></button
>
</nav>
{#if data.returnYTDislikes}
<nav class="no-space s">
<button style="cursor: default;" class="border left-round">
<i class="small">thumb_up</i>
<span>{cleanNumber(data.returnYTDislikes.likes)}</span>
<button on:click={() => (audioMode = !audioMode)} class:border={!audioMode}>
<i>headphones</i>
<span>Audio only </span>
</button>
<button style="cursor: default;" class="border right-round">
<i class="small">thumb_down_alt</i>
<span>{cleanNumber(data.returnYTDislikes.dislikes)}</span>
</button>
</nav>
{/if}
<button class="border m l" data-ui="#share"
><i>share</i> Share
<menu class="left no-wrap" id="share" data-ui="#share">
<a
class="row"
href="#copy"
on:click={async () =>
await navigator.clipboard.writeText(
`${import.meta.env.VITE_DEFAULT_FRONTEND_URL}/watch/${data.video.videoId}`
)}
>
<div class="min">Copy Materialious link</div></a
><a
href="#copy"
class="row"
on:click={async () =>
await navigator.clipboard.writeText(
`https://redirect.invidious.io/watch?v=${data.video.videoId}`
)}
>
<div class="min">Copy Invidious redirect link</div></a
><a
class="row"
href="#copy"
on:click={async () =>
await navigator.clipboard.writeText(
`https://www.youtube.com/watch?v=${data.video.videoId}`
)}
>
<div class="min">Copy Youtube link</div></a
></menu
></button
>
{#if data.personalPlaylists}
<button class="border">
<span>Add to playlist</span>
<i>arrow_drop_down</i>
<menu>
{#each data.personalPlaylists as personalPlaylist}
<a
href="#add"
on:click={async () => await addVideoToPlaylist(personalPlaylist.playlistId)}
>{personalPlaylist.title}{#if personalPlaylist.videos.filter((item) => {
if (item.videoId === data.video.videoId) return item;
})}<i>done</i>{/if}
</a>
{/each}
</menu>
</button>
{/if}
</div>
</div>
<article class="medium scroll">
<p class="bold">
@@ -1,4 +1,5 @@
import { amSubscribed, getComments, getDislikes, getVideo, postHistory } from '$lib/Api/index.js';
import { amSubscribed, getComments, getDislikes, getPersonalPlaylists, getVideo, postHistory } from '$lib/Api/index.js';
import type { PlaylistPage } from '$lib/Api/model';
import { phaseDescription } from '$lib/misc';
import { error } from '@sveltejs/kit';
import { get } from 'svelte/store';
@@ -12,8 +13,13 @@ export async function load({ params, url }) {
error(500, (video as { errorBacktrace: string; }).errorBacktrace)
);
let personalPlaylists: PlaylistPage[] | null;
if (get(auth)) {
postHistory(video.videoId);
personalPlaylists = await getPersonalPlaylists();
} else {
personalPlaylists = null;
}
let comments;
@@ -40,6 +46,7 @@ export async function load({ params, url }) {
comments: comments,
subscribed: await amSubscribed(video.authorId),
content: phaseDescription(video.description),
playlistId: url.searchParams.get('playlist')
playlistId: url.searchParams.get('playlist'),
personalPlaylists: personalPlaylists
};
};