Added playlist creation & adding support
This commit is contained in:
@@ -27,7 +27,6 @@
|
||||
- PWA support.
|
||||
|
||||
## Todo
|
||||
- Playlist support.
|
||||
- Docker building & publishing workflow.
|
||||
- Launching 'materialio.us'.
|
||||
- Locking down git branches & setup PR requirements.
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user