UI changes (now responsive) && fixed YT recommendations method
This commit is contained in:
11
src/app.css
11
src/app.css
@@ -9,17 +9,6 @@ img {
|
|||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
|
||||||
.no-scrollbar::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide scrollbar for IE, Edge and Firefox */
|
|
||||||
.no-scrollbar {
|
|
||||||
-ms-overflow-style: none; /* IE and Edge */
|
|
||||||
scrollbar-width: none; /* Firefox */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Default scrollbar for Chrome, Safari, Edge and Opera */
|
/* Default scrollbar for Chrome, Safari, Edge and Opera */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="overflow-hidden p-3">
|
<div class="overflow-hidden">
|
||||||
<div id="thumbnail-wrapper" class="relative aspect-square w-full overflow-clip rounded">
|
<div id="thumbnail-wrapper" class="relative aspect-square w-full overflow-clip rounded">
|
||||||
<button id="thumbnail" class="h-full w-full" on:click={() => goto(`/details/album?id=${album.id}&connection=${album.connection.id}`)}>
|
<button id="thumbnail" class="h-full w-full" on:click={() => goto(`/details/album?id=${album.id}&connection=${album.connection.id}`)}>
|
||||||
<LazyImage thumbnailUrl={album.thumbnailUrl} alt={`${album.name} jacket`} objectFit={'cover'} />
|
<LazyImage thumbnailUrl={album.thumbnailUrl} alt={`${album.name} jacket`} objectFit={'cover'} />
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
object. Formatting of the text such as font size, weight, and line clamps can be specified in a wrapper element.
|
object. Formatting of the text such as font size, weight, and line clamps can be specified in a wrapper element.
|
||||||
|
|
||||||
@param mediaItem Either a Song, Album, or Playlist object.
|
@param mediaItem Either a Song, Album, or Playlist object.
|
||||||
@param linked Boolean. If true artists will be linked with anchor tags. Defaults to true.
|
@param linked Boolean. If true, artists will be linked with anchor tags. Defaults to true.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
|||||||
@@ -1,341 +1,191 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte'
|
import ScrollingText from '$lib/components/util/scrollingText.svelte'
|
||||||
import { fade, slide, fly } from 'svelte/transition'
|
import ArtistList from '$lib/components/media/artistList.svelte'
|
||||||
import { queue } from '$lib/stores'
|
import IconButton from '$lib/components/util/iconButton.svelte'
|
||||||
import Services from '$lib/services.json'
|
|
||||||
// import { FastAverageColor } from 'fast-average-color'
|
|
||||||
import Slider from '$lib/components/util/slider.svelte'
|
import Slider from '$lib/components/util/slider.svelte'
|
||||||
import Loader from '$lib/components/util/loader.svelte'
|
import Loader from '$lib/components/util/loader.svelte'
|
||||||
import LazyImage from './lazyImage.svelte'
|
import { onMount, createEventDispatcher } from 'svelte'
|
||||||
import IconButton from '$lib/components/util/iconButton.svelte'
|
import { slide } from 'svelte/transition'
|
||||||
import ScrollingText from '$lib/components/util/scrollingText.svelte'
|
|
||||||
import ArtistList from './artistList.svelte'
|
|
||||||
import ServiceLogo from '$lib/components/util/serviceLogo.svelte'
|
|
||||||
|
|
||||||
// NEW IDEA: Only have the miniplayer for controls and for the expanded view just make it one large Videoplayer.
|
const dispatch = createEventDispatcher()
|
||||||
// That way we can target the player to be the size of YouTube's default player. Then move the Queue view to it's own
|
|
||||||
// dedicated sidebar like in spotify.
|
|
||||||
|
|
||||||
$: currentlyPlaying = $queue.current
|
export let mediaItem: Song,
|
||||||
|
shuffled: boolean,
|
||||||
|
mediaSession: MediaSession | null = null
|
||||||
|
|
||||||
let expanded = false
|
let loop = false,
|
||||||
|
favorite = false
|
||||||
|
|
||||||
let paused = true,
|
const MAX_VOLUME = 0.5
|
||||||
loop = false
|
let volume: number, paused: boolean, waiting: boolean
|
||||||
|
|
||||||
$: shuffled = $queue.isShuffled
|
onMount(() => {
|
||||||
|
volume = getStoredVolume()
|
||||||
|
|
||||||
const maxVolume = 0.5
|
if (mediaSession) {
|
||||||
let volume: number
|
mediaSession.setActionHandler('play', () => (paused = false))
|
||||||
|
mediaSession.setActionHandler('pause', () => (paused = true))
|
||||||
|
mediaSession.setActionHandler('stop', () => dispatch('stop'))
|
||||||
|
mediaSession.setActionHandler('nexttrack', () => dispatch('next'))
|
||||||
|
mediaSession.setActionHandler('previoustrack', () => dispatch('previous'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
let waiting: boolean
|
function getStoredVolume(): number {
|
||||||
|
const storedVolume = Number.parseFloat(localStorage.getItem('volume') ?? '-1')
|
||||||
|
if (storedVolume >= 0 && storedVolume <= MAX_VOLUME) return storedVolume
|
||||||
|
|
||||||
|
const defaultVolume = MAX_VOLUME / 2
|
||||||
|
localStorage.setItem('volume', defaultVolume.toString())
|
||||||
|
return defaultVolume
|
||||||
|
}
|
||||||
|
|
||||||
|
$: if (mediaSession) updateMediaSession(mediaItem, mediaSession)
|
||||||
|
function updateMediaSession(media: Song, mediaSession: MediaSession) {
|
||||||
|
const mediaImage = (size: number): MediaImage => ({ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=${size}`, sizes: `${size}x${size}` })
|
||||||
|
|
||||||
|
const title = media.name
|
||||||
|
const artist = media.artists?.map((artist) => artist.name).join(', ') ?? media.uploader?.name
|
||||||
|
const album = media.album?.name
|
||||||
|
const artwork: MediaImage[] = [mediaImage(96), mediaImage(128), mediaImage(192), mediaImage(256), mediaImage(384), mediaImage(512)]
|
||||||
|
|
||||||
|
mediaSession.metadata = new MediaMetadata({ title, artist, album, artwork })
|
||||||
|
}
|
||||||
|
|
||||||
|
$: paused && mediaSession ? (mediaSession.playbackState = 'paused') : mediaSession ? (mediaSession.playbackState = 'playing') : null
|
||||||
|
|
||||||
function formatTime(seconds: number) {
|
function formatTime(seconds: number) {
|
||||||
seconds = Math.round(seconds)
|
seconds = Math.round(seconds)
|
||||||
const hours = Math.floor(seconds / 3600)
|
const hours = Math.floor(seconds / 3600)
|
||||||
seconds = seconds - hours * 3600
|
seconds -= hours * 3600
|
||||||
const minutes = Math.floor(seconds / 60)
|
const minutes = Math.floor(seconds / 60)
|
||||||
seconds = seconds - minutes * 60
|
seconds -= minutes * 60
|
||||||
return hours > 0 ? `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` : `${minutes}:${seconds.toString().padStart(2, '0')}`
|
return hours > 0 ? `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` : `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
$: updateMediaSession(currentlyPlaying)
|
let seeking = false,
|
||||||
function updateMediaSession(media: Song | null) {
|
currentTime: number = 0,
|
||||||
if (!('mediaSession' in navigator)) return
|
duration: number = 0
|
||||||
|
|
||||||
if (!media) {
|
let audioElement: HTMLAudioElement, currentTimestamp: string, durationTimestamp: string, progressBarValue: number, progressBar: Slider
|
||||||
navigator.mediaSession.metadata = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
navigator.mediaSession.metadata = new MediaMetadata({
|
$: currentTimestamp = formatTime(seeking ? progressBarValue : currentTime)
|
||||||
title: media.name,
|
$: durationTimestamp = formatTime(duration)
|
||||||
artist: media.artists?.map((artist) => artist.name).join(', ') ?? media.uploader?.name,
|
|
||||||
album: media.album?.name,
|
|
||||||
artwork: [
|
|
||||||
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=96`, sizes: '96x96' },
|
|
||||||
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=128`, sizes: '128x128' },
|
|
||||||
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=192`, sizes: '192x192' },
|
|
||||||
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=256`, sizes: '256x256' },
|
|
||||||
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=384`, sizes: '384x384' },
|
|
||||||
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=512`, sizes: '512x512' },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
const storedVolume = Number(localStorage.getItem('volume'))
|
|
||||||
if (storedVolume >= 0 && storedVolume <= maxVolume) {
|
|
||||||
volume = storedVolume
|
|
||||||
} else {
|
|
||||||
localStorage.setItem('volume', (maxVolume / 2).toString())
|
|
||||||
volume = maxVolume / 2
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('mediaSession' in navigator) {
|
|
||||||
navigator.mediaSession.setActionHandler('play', () => (paused = false))
|
|
||||||
navigator.mediaSession.setActionHandler('pause', () => (paused = true))
|
|
||||||
navigator.mediaSession.setActionHandler('stop', () => $queue.clear())
|
|
||||||
navigator.mediaSession.setActionHandler('nexttrack', () => $queue.next())
|
|
||||||
navigator.mediaSession.setActionHandler('previoustrack', () => $queue.previous())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
let currentTime: number = 0
|
|
||||||
let duration: number = 0
|
|
||||||
|
|
||||||
let currentTimeTimestamp: HTMLSpanElement
|
|
||||||
let progressBar: Slider
|
|
||||||
let durationTimestamp: HTMLSpanElement
|
|
||||||
|
|
||||||
let expandedCurrentTimeTimestamp: HTMLSpanElement
|
|
||||||
let expandedProgressBar: Slider
|
|
||||||
let expandedDurationTimestamp: HTMLSpanElement
|
|
||||||
|
|
||||||
let seeking: boolean = false
|
|
||||||
$: if (!seeking && currentTimeTimestamp) currentTimeTimestamp.innerText = formatTime(currentTime)
|
|
||||||
$: if (!seeking && progressBar) progressBar.$set({ value: currentTime })
|
$: if (!seeking && progressBar) progressBar.$set({ value: currentTime })
|
||||||
$: if (!seeking && durationTimestamp) durationTimestamp.innerText = formatTime(duration)
|
|
||||||
$: if (!seeking && expandedCurrentTimeTimestamp) expandedCurrentTimeTimestamp.innerText = formatTime(currentTime)
|
|
||||||
$: if (!seeking && expandedProgressBar) expandedProgressBar.$set({ value: currentTime })
|
|
||||||
$: if (!seeking && expandedDurationTimestamp) expandedDurationTimestamp.innerText = formatTime(duration)
|
|
||||||
|
|
||||||
let audioElement: HTMLAudioElement
|
let playerWidth: number
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if currentlyPlaying}
|
<div
|
||||||
<div
|
bind:clientWidth={playerWidth}
|
||||||
id="player-wrapper"
|
transition:slide
|
||||||
transition:slide
|
id="player"
|
||||||
class="{expanded ? 'h-full w-full' : 'm-3 h-20 w-[calc(100%_-_24px)] rounded-xl'} absolute bottom-0 z-40 overflow-clip bg-neutral-925 transition-all ease-in-out"
|
class="fixed {playerWidth > 800 ? 'bottom-0 left-0 right-0' : 'bottom-3 left-3 right-3 rounded-lg'} flex h-20 items-center gap-4 overflow-clip bg-neutral-925 px-2 text-neutral-400 transition-all"
|
||||||
style="transition-duration: 400ms;"
|
>
|
||||||
>
|
<div id="details" class="flex h-full items-center gap-3 overflow-clip py-2" style="backgrounds: linear-gradient(to right, red, blue); flex-grow: 1; flex-basis: 18rem">
|
||||||
{#if !expanded}
|
<div class="relative aspect-square h-full">
|
||||||
<main in:fade={{ duration: 75, delay: 500 }} out:fade={{ duration: 75 }} class="flex h-20 w-full gap-10">
|
<img src="/api/remoteImage?url={mediaItem.thumbnailUrl}&maxHeight=96" alt="jacket" class="h-full w-full rounded object-cover" />
|
||||||
<section class="flex w-96 min-w-64 gap-3">
|
<div id="jacket-play-button" class:hidden={playerWidth > 650} class="absolute bottom-0 left-0 right-0 top-0 backdrop-brightness-50">
|
||||||
<div class="relative h-full w-20 min-w-20 overflow-clip rounded-xl">
|
<IconButton on:click={() => (paused = !paused)}>
|
||||||
<LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={`${currentlyPlaying.name} jacket`} objectFit={'cover'} />
|
<i slot="icon" class="fa-solid {paused ? 'fa-play' : 'fa-pause'} text-2xl text-neutral-200" />
|
||||||
</div>
|
</IconButton>
|
||||||
<section class="flex flex-grow flex-col justify-center gap-1">
|
</div>
|
||||||
<div class="h-6">
|
</div>
|
||||||
<ScrollingText>
|
<div class="flex flex-col justify-center gap-1">
|
||||||
<div slot="text" class="line-clamp-1 font-medium">{currentlyPlaying.name}</div>
|
<ScrollingText>
|
||||||
</ScrollingText>
|
<span slot="text" class="line-clamp-1 text-sm font-semibold text-neutral-200">{mediaItem.name}</span>
|
||||||
</div>
|
</ScrollingText>
|
||||||
<div class="line-clamp-1 text-xs font-extralight">
|
<div class="line-clamp-1 text-xs">
|
||||||
<ArtistList mediaItem={currentlyPlaying} />
|
<ArtistList {mediaItem} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
</section>
|
<div class="h-8">
|
||||||
<section class="flex flex-grow items-center gap-1 py-4">
|
<IconButton color={'#ec4899'} on:click={() => (favorite = !favorite)}>
|
||||||
<IconButton on:click={() => $queue.previous()}>
|
<i slot="icon" class={favorite ? 'fa-solid fa-heart text-pink-500' : 'fa-regular fa-heart'} />
|
||||||
<i slot="icon" class="fa-solid fa-backward-step text-xl" />
|
</IconButton>
|
||||||
</IconButton>
|
</div>
|
||||||
<div class="relative aspect-square h-full rounded-full border border-neutral-700">
|
<span class:hidden={playerWidth > 700 || playerWidth < 400} class="ml-auto whitespace-nowrap text-xs">{currentTimestamp} / {durationTimestamp}</span>
|
||||||
{#if waiting}
|
|
||||||
<Loader size={1.5} />
|
|
||||||
{:else}
|
|
||||||
<IconButton on:click={() => (paused = !paused)}>
|
|
||||||
<i slot="icon" class="fa-solid {paused ? 'fa-play' : 'fa-pause'}" />
|
|
||||||
</IconButton>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<IconButton on:click={() => $queue.clear()}>
|
|
||||||
<i slot="icon" class="fa-solid fa-stop text-xl" />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton on:click={() => $queue.next()}>
|
|
||||||
<i slot="icon" class="fa-solid fa-forward-step text-xl" />
|
|
||||||
</IconButton>
|
|
||||||
<div class="flex min-w-56 flex-grow items-center justify-items-center gap-3 font-light">
|
|
||||||
<span bind:this={currentTimeTimestamp} class="w-16 text-right" />
|
|
||||||
<Slider
|
|
||||||
bind:this={progressBar}
|
|
||||||
max={duration}
|
|
||||||
on:seeking={(event) => {
|
|
||||||
currentTimeTimestamp.innerText = formatTime(event.detail.value)
|
|
||||||
seeking = true
|
|
||||||
}}
|
|
||||||
on:seeked={(event) => {
|
|
||||||
currentTime = event.detail.value
|
|
||||||
seeking = false
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span bind:this={durationTimestamp} class="w-16 text-left" />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section class="flex items-center justify-end gap-2.5 py-6 pr-8 text-lg">
|
|
||||||
<div class="mx-4 flex h-10 w-40 items-center gap-3">
|
|
||||||
<IconButton on:click={() => (volume = volume > 0 ? 0 : Number(localStorage.getItem('volume')))}>
|
|
||||||
<i slot="icon" class="fa-solid {volume > maxVolume / 2 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'}" />
|
|
||||||
</IconButton>
|
|
||||||
<Slider
|
|
||||||
bind:value={volume}
|
|
||||||
max={maxVolume}
|
|
||||||
on:seeked={() => {
|
|
||||||
if (volume > 0) localStorage.setItem('volume', volume.toString())
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<IconButton on:click={() => (shuffled ? $queue.reorder() : $queue.shuffle())}>
|
|
||||||
<i slot="icon" class="fa-solid fa-shuffle {shuffled ? 'text-lazuli-primary' : 'text-white'}" />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton on:click={() => (loop = !loop)}>
|
|
||||||
<i slot="icon" class="fa-solid fa-repeat {loop ? 'text-lazuli-primary' : 'text-white'}" />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton on:click={() => (expanded = true)}>
|
|
||||||
<i slot="icon" class="fa-solid fa-chevron-up" />
|
|
||||||
</IconButton>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
{:else}
|
|
||||||
<main id="expanded-player" in:fade={{ delay: 500 }} out:fade={{ duration: 75 }} class="relative h-full">
|
|
||||||
<div class="absolute -z-10 h-full w-full blur-2xl brightness-[25%]">
|
|
||||||
<LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={''} objectFit={'cover'} />
|
|
||||||
</div>
|
|
||||||
<section class="relative grid h-full grid-rows-[1fr_4fr] gap-4 px-24 py-16">
|
|
||||||
<div class="grid grid-cols-[2fr_1fr]">
|
|
||||||
<div class="flex h-14 flex-row items-center gap-5">
|
|
||||||
<ServiceLogo type={currentlyPlaying.connection.type} />
|
|
||||||
<div>
|
|
||||||
<h1 class="text-neutral-400">STREAMING FROM</h1>
|
|
||||||
<strong class="text-2xl text-neutral-300">{Services[currentlyPlaying.connection.type].displayName}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<section>
|
|
||||||
{#if $queue.upNext}
|
|
||||||
{@const next = $queue.upNext}
|
|
||||||
<strong transition:fade class="ml-2 text-2xl">UP NEXT</strong>
|
|
||||||
<div transition:fly={{ x: 300 }} class="mt-3 flex h-20 w-full items-center gap-3 rounded-lg border border-neutral-300 bg-neutral-900 pr-3">
|
|
||||||
<div class="aspect-square h-full">
|
|
||||||
<LazyImage thumbnailUrl={next.thumbnailUrl} alt={`${next.name} jacket`} objectFit={'cover'} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="mb-0.5 line-clamp-1 font-medium">{next.name}</div>
|
|
||||||
<div class="line-clamp-1 text-sm font-light text-neutral-300">
|
|
||||||
<ArtistList mediaItem={next} linked={false} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
<LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={`${currentlyPlaying.name} jacket`} objectFit={'contain'} objectPosition={'left'} />
|
|
||||||
</section>
|
|
||||||
<section class="self-center px-16">
|
|
||||||
<div class="mb-7 flex min-w-56 flex-grow items-center justify-items-center gap-3 font-light">
|
|
||||||
<span bind:this={expandedCurrentTimeTimestamp} />
|
|
||||||
<Slider
|
|
||||||
bind:this={expandedProgressBar}
|
|
||||||
max={duration}
|
|
||||||
on:seeking={(event) => {
|
|
||||||
expandedCurrentTimeTimestamp.innerText = formatTime(event.detail.value)
|
|
||||||
seeking = true
|
|
||||||
}}
|
|
||||||
on:seeked={(event) => {
|
|
||||||
currentTime = event.detail.value
|
|
||||||
seeking = false
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span bind:this={expandedDurationTimestamp} />
|
|
||||||
</div>
|
|
||||||
<div id="expanded-controls">
|
|
||||||
<div class="flex min-w-56 flex-col gap-1.5 overflow-hidden">
|
|
||||||
<div class="h-10">
|
|
||||||
<ScrollingText>
|
|
||||||
<strong slot="text" class="text-4xl">{currentlyPlaying.name}</strong>
|
|
||||||
</ScrollingText>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-3 text-lg font-medium text-neutral-300">
|
|
||||||
{#if (currentlyPlaying.artists && currentlyPlaying.artists.length > 0) || currentlyPlaying.uploader}
|
|
||||||
<ArtistList mediaItem={currentlyPlaying} />
|
|
||||||
{/if}
|
|
||||||
{#if currentlyPlaying.album}
|
|
||||||
<strong>•</strong>
|
|
||||||
<a
|
|
||||||
on:click={() => (expanded = false)}
|
|
||||||
class="line-clamp-1 hover:underline focus:underline"
|
|
||||||
href="/details/album?id={currentlyPlaying.album.id}&connection={currentlyPlaying.connection.id}">{currentlyPlaying.album.name}</a
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex h-16 w-full items-center justify-center gap-2 text-2xl">
|
|
||||||
<IconButton on:click={() => (shuffled ? $queue.reorder() : $queue.shuffle())}>
|
|
||||||
<i slot="icon" class="fa-solid fa-shuffle {shuffled ? 'text-lazuli-primary' : 'text-white'}" />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton on:click={() => $queue.previous()}>
|
|
||||||
<i slot="icon" class="fa-solid fa-backward-step text-xl" />
|
|
||||||
</IconButton>
|
|
||||||
<div class="relative aspect-square h-full rounded-full bg-white text-black">
|
|
||||||
{#if waiting}
|
|
||||||
<Loader size={1.5} />
|
|
||||||
{:else}
|
|
||||||
<IconButton on:click={() => (paused = !paused)}>
|
|
||||||
<i slot="icon" class="fa-solid {paused ? 'fa-play' : 'fa-pause'}" />
|
|
||||||
</IconButton>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<IconButton on:click={() => $queue.clear()}>
|
|
||||||
<i slot="icon" class="fa-solid fa-stop" />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton on:click={() => $queue.next()}>
|
|
||||||
<i slot="icon" class="fa-solid fa-forward-step" />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton on:click={() => (loop = !loop)}>
|
|
||||||
<i slot="icon" class="fa-solid fa-repeat {loop ? 'text-lazuli-primary' : 'text-white'}" />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
<section class="flex h-min items-center justify-end gap-2 text-xl">
|
|
||||||
<div class="mx-4 flex h-10 w-40 items-center gap-3">
|
|
||||||
<IconButton on:click={() => (volume = volume > 0 ? 0 : Number(localStorage.getItem('volume')))}>
|
|
||||||
<i slot="icon" class="fa-solid {volume > maxVolume / 2 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'}" />
|
|
||||||
</IconButton>
|
|
||||||
<Slider
|
|
||||||
bind:value={volume}
|
|
||||||
max={maxVolume}
|
|
||||||
on:seeked={() => {
|
|
||||||
if (volume > 0) localStorage.setItem('volume', volume.toString())
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<IconButton on:click={() => (expanded = false)}>
|
|
||||||
<i slot="icon" class="fa-solid fa-chevron-down" />
|
|
||||||
</IconButton>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
{/if}
|
|
||||||
<audio
|
|
||||||
bind:this={audioElement}
|
|
||||||
autoplay
|
|
||||||
bind:paused
|
|
||||||
bind:volume
|
|
||||||
bind:currentTime
|
|
||||||
bind:duration
|
|
||||||
on:canplay={() => (waiting = false)}
|
|
||||||
on:loadstart={() => (waiting = true)}
|
|
||||||
on:waiting={() => (waiting = true)}
|
|
||||||
on:ended={() => $queue.next()}
|
|
||||||
on:error={() => setTimeout(() => audioElement.load(), 5000)}
|
|
||||||
src="/api/v1/audio?connection={currentlyPlaying.connection.id}&id={currentlyPlaying.id}"
|
|
||||||
{loop}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{#if playerWidth > 700}
|
||||||
|
<!-- Change the flex-grow value to adjust the difference in rate of expansion between the details and controls -->
|
||||||
<style>
|
<div id="controls" class="flex h-full items-center gap-1 py-4 pr-8 text-neutral-200" style="backgrounds: linear-gradient(to right, green, yellow); flex-grow: 10;">
|
||||||
#player-wrapper {
|
<IconButton on:click={() => dispatch('previous')}>
|
||||||
filter: drop-shadow(0px 20px 20px #000000);
|
<i slot="icon" class="fa-solid fa-backward-step text-xl" />
|
||||||
}
|
</IconButton>
|
||||||
#expanded-player {
|
<div class="relative aspect-square h-full rounded-full border border-neutral-700">
|
||||||
display: grid;
|
{#if waiting}
|
||||||
grid-template-rows: 4fr 1fr;
|
<Loader size={1.5} />
|
||||||
}
|
{:else}
|
||||||
#expanded-controls {
|
<IconButton on:click={() => (paused = !paused)}>
|
||||||
display: grid;
|
<i slot="icon" class="fa-solid {paused ? 'fa-play' : 'fa-pause'}" />
|
||||||
gap: 3rem;
|
</IconButton>
|
||||||
align-items: center;
|
{/if}
|
||||||
grid-template-columns: 1fr min-content 1fr !important;
|
</div>
|
||||||
}
|
<IconButton on:click={() => dispatch('stop')}>
|
||||||
</style>
|
<i slot="icon" class="fa-solid fa-stop text-xl" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton on:click={() => dispatch('next')}>
|
||||||
|
<i slot="icon" class="fa-solid fa-forward-step text-xl" />
|
||||||
|
</IconButton>
|
||||||
|
{#if playerWidth > 800}
|
||||||
|
<div class="flex flex-grow items-center justify-items-center gap-3 text-xs font-light">
|
||||||
|
<span>{currentTimestamp}</span>
|
||||||
|
<Slider
|
||||||
|
bind:this={progressBar}
|
||||||
|
bind:value={progressBarValue}
|
||||||
|
max={duration}
|
||||||
|
on:seeking={() => (seeking = true)}
|
||||||
|
on:seeked={() => {
|
||||||
|
currentTime = progressBarValue
|
||||||
|
seeking = false
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{durationTimestamp}</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<span class="whitespace-nowrap text-xs font-light text-neutral-400">{currentTimestamp} / {durationTimestamp}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if playerWidth > 450}
|
||||||
|
<div id="tools" class="flex h-full justify-end gap-0.5 py-6" style="backgrounds: linear-gradient(to right, purple, orange);">
|
||||||
|
{#if playerWidth > 1100}
|
||||||
|
<IconButton on:click={() => dispatch('toggleShuffle')}>
|
||||||
|
<i slot="icon" class:text-lazuli-primary={shuffled} class="fa-solid fa-shuffle" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton on:click={() => (loop = !loop)}>
|
||||||
|
<i slot="icon" class:text-lazuli-primary={loop} class="fa-solid fa-repeat" />
|
||||||
|
</IconButton>
|
||||||
|
<div class="flex h-full items-center gap-1">
|
||||||
|
<IconButton on:click={() => (volume = volume > 0 ? 0 : getStoredVolume())}>
|
||||||
|
<i slot="icon" class="fa-solid {volume > MAX_VOLUME / 2 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'}" />
|
||||||
|
</IconButton>
|
||||||
|
<div class="mr-2 w-20">
|
||||||
|
<Slider bind:value={volume} max={MAX_VOLUME} on:seeked={() => (volume > 0 ? localStorage.setItem('volume', volume.toString()) : null)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<IconButton>
|
||||||
|
<i slot="icon" class="fa-solid fa-up-right-and-down-left-from-center" />
|
||||||
|
</IconButton>
|
||||||
|
{/if}
|
||||||
|
<IconButton>
|
||||||
|
<i slot="icon" class="fa-solid fa-ellipsis-vertical" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<audio
|
||||||
|
{loop}
|
||||||
|
autoplay
|
||||||
|
src="/api/v1/audio?connection={mediaItem.connection.id}&id={mediaItem.id}"
|
||||||
|
bind:paused
|
||||||
|
bind:volume
|
||||||
|
bind:duration
|
||||||
|
bind:currentTime
|
||||||
|
bind:this={audioElement}
|
||||||
|
on:ended={() => dispatch('next')}
|
||||||
|
on:waiting={() => (waiting = true)}
|
||||||
|
on:canplay={() => (waiting = false)}
|
||||||
|
on:loadstart={() => (waiting = true)}
|
||||||
|
on:error={() => setTimeout(() => audioElement.load(), 5000)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|||||||
333
src/lib/components/media/mediaPlayerOLD.svelte
Normal file
333
src/lib/components/media/mediaPlayerOLD.svelte
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import { fade, slide, fly } from 'svelte/transition'
|
||||||
|
import { queue } from '$lib/stores'
|
||||||
|
import Services from '$lib/services.json'
|
||||||
|
// import { FastAverageColor } from 'fast-average-color'
|
||||||
|
import Slider from '$lib/components/util/slider.svelte'
|
||||||
|
import Loader from '$lib/components/util/loader.svelte'
|
||||||
|
import LazyImage from './lazyImage.svelte'
|
||||||
|
import IconButton from '$lib/components/util/iconButton.svelte'
|
||||||
|
import ScrollingText from '$lib/components/util/scrollingText.svelte'
|
||||||
|
import ArtistList from './artistList.svelte'
|
||||||
|
import ServiceLogo from '$lib/components/util/serviceLogo.svelte'
|
||||||
|
|
||||||
|
// NEW IDEA: Only have the miniplayer for controls and for the expanded view just make it one large Videoplayer.
|
||||||
|
// That way we can target the player to be the size of YouTube's default player. Then move the Queue view to it's own
|
||||||
|
// dedicated sidebar like in spotify.
|
||||||
|
|
||||||
|
$: currentlyPlaying = $queue.current
|
||||||
|
|
||||||
|
let expanded = false
|
||||||
|
|
||||||
|
let paused = true,
|
||||||
|
loop = false
|
||||||
|
|
||||||
|
$: shuffled = $queue.isShuffled
|
||||||
|
|
||||||
|
const maxVolume = 0.5
|
||||||
|
let volume: number
|
||||||
|
|
||||||
|
let waiting: boolean
|
||||||
|
|
||||||
|
function formatTime(seconds: number) {
|
||||||
|
seconds = Math.round(seconds)
|
||||||
|
const hours = Math.floor(seconds / 3600)
|
||||||
|
seconds = seconds - hours * 3600
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
seconds = seconds - minutes * 60
|
||||||
|
return hours > 0 ? `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` : `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
$: updateMediaSession(currentlyPlaying)
|
||||||
|
function updateMediaSession(media: Song | null) {
|
||||||
|
if (!('mediaSession' in navigator)) return
|
||||||
|
|
||||||
|
if (!media) {
|
||||||
|
navigator.mediaSession.metadata = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
navigator.mediaSession.metadata = new MediaMetadata({
|
||||||
|
title: media.name,
|
||||||
|
artist: media.artists?.map((artist) => artist.name).join(', ') ?? media.uploader?.name,
|
||||||
|
album: media.album?.name,
|
||||||
|
artwork: [
|
||||||
|
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=96`, sizes: '96x96' },
|
||||||
|
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=128`, sizes: '128x128' },
|
||||||
|
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=192`, sizes: '192x192' },
|
||||||
|
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=256`, sizes: '256x256' },
|
||||||
|
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=384`, sizes: '384x384' },
|
||||||
|
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=512`, sizes: '512x512' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const storedVolume = Number(localStorage.getItem('volume'))
|
||||||
|
if (storedVolume >= 0 && storedVolume <= maxVolume) {
|
||||||
|
volume = storedVolume
|
||||||
|
} else {
|
||||||
|
localStorage.setItem('volume', (maxVolume / 2).toString())
|
||||||
|
volume = maxVolume / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('mediaSession' in navigator) {
|
||||||
|
navigator.mediaSession.setActionHandler('play', () => (paused = false))
|
||||||
|
navigator.mediaSession.setActionHandler('pause', () => (paused = true))
|
||||||
|
navigator.mediaSession.setActionHandler('stop', () => $queue.clear())
|
||||||
|
navigator.mediaSession.setActionHandler('nexttrack', () => $queue.next())
|
||||||
|
navigator.mediaSession.setActionHandler('previoustrack', () => $queue.previous())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let currentTime: number = 0
|
||||||
|
let duration: number = 0
|
||||||
|
|
||||||
|
let currentTimeTimestamp: HTMLSpanElement
|
||||||
|
let progressBar: Slider
|
||||||
|
let durationTimestamp: HTMLSpanElement
|
||||||
|
|
||||||
|
let expandedCurrentTimeTimestamp: HTMLSpanElement
|
||||||
|
let expandedProgressBar: Slider
|
||||||
|
let expandedDurationTimestamp: HTMLSpanElement
|
||||||
|
|
||||||
|
let seeking: boolean = false
|
||||||
|
$: if (!seeking && currentTimeTimestamp) currentTimeTimestamp.innerText = formatTime(currentTime)
|
||||||
|
$: if (!seeking && progressBar) progressBar.$set({ value: currentTime })
|
||||||
|
$: if (!seeking && durationTimestamp) durationTimestamp.innerText = formatTime(duration)
|
||||||
|
$: if (!seeking && expandedCurrentTimeTimestamp) expandedCurrentTimeTimestamp.innerText = formatTime(currentTime)
|
||||||
|
$: if (!seeking && expandedProgressBar) expandedProgressBar.$set({ value: currentTime })
|
||||||
|
$: if (!seeking && expandedDurationTimestamp) expandedDurationTimestamp.innerText = formatTime(duration)
|
||||||
|
|
||||||
|
let audioElement: HTMLAudioElement
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if currentlyPlaying}
|
||||||
|
<div id="player-wrapper" transition:slide class="{expanded ? 'h-full' : 'h-20'} absolute bottom-0 z-40 w-full overflow-clip bg-neutral-925 transition-all ease-in-out" style="transition-duration: 400ms;">
|
||||||
|
{#if !expanded}
|
||||||
|
<main in:fade={{ duration: 75, delay: 500 }} out:fade={{ duration: 75 }} class="flex h-20 w-full gap-10">
|
||||||
|
<section class="flex w-96 min-w-64 gap-2">
|
||||||
|
<div class="relative h-full w-20 min-w-20 overflow-clip rounded-xl p-2">
|
||||||
|
<LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={`${currentlyPlaying.name} jacket`} objectFit={'cover'} />
|
||||||
|
</div>
|
||||||
|
<section class="flex flex-grow flex-col justify-center gap-1">
|
||||||
|
<div class="h-6">
|
||||||
|
<ScrollingText>
|
||||||
|
<div slot="text" class="line-clamp-1 font-medium">{currentlyPlaying.name}</div>
|
||||||
|
</ScrollingText>
|
||||||
|
</div>
|
||||||
|
<div class="line-clamp-1 text-xs font-extralight">
|
||||||
|
<ArtistList mediaItem={currentlyPlaying} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
<section class="flex flex-grow items-center gap-1 py-4">
|
||||||
|
<IconButton on:click={() => $queue.previous()}>
|
||||||
|
<i slot="icon" class="fa-solid fa-backward-step text-xl" />
|
||||||
|
</IconButton>
|
||||||
|
<div class="relative aspect-square h-full rounded-full border border-neutral-700">
|
||||||
|
{#if waiting}
|
||||||
|
<Loader size={1.5} />
|
||||||
|
{:else}
|
||||||
|
<IconButton on:click={() => (paused = !paused)}>
|
||||||
|
<i slot="icon" class="fa-solid {paused ? 'fa-play' : 'fa-pause'}" />
|
||||||
|
</IconButton>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<IconButton on:click={() => $queue.clear()}>
|
||||||
|
<i slot="icon" class="fa-solid fa-stop text-xl" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton on:click={() => $queue.next()}>
|
||||||
|
<i slot="icon" class="fa-solid fa-forward-step text-xl" />
|
||||||
|
</IconButton>
|
||||||
|
<div class="flex min-w-56 flex-grow items-center justify-items-center gap-3 font-light">
|
||||||
|
<span bind:this={currentTimeTimestamp} class="w-16 text-right" />
|
||||||
|
<Slider
|
||||||
|
bind:this={progressBar}
|
||||||
|
max={duration}
|
||||||
|
on:seeking={(event) => {
|
||||||
|
currentTimeTimestamp.innerText = formatTime(event.detail.value)
|
||||||
|
seeking = true
|
||||||
|
}}
|
||||||
|
on:seeked={(event) => {
|
||||||
|
currentTime = event.detail.value
|
||||||
|
seeking = false
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span bind:this={durationTimestamp} class="w-16 text-left" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="flex items-center justify-end gap-2.5 py-6 pr-8 text-lg">
|
||||||
|
<div class="mx-4 flex h-10 w-40 items-center gap-3">
|
||||||
|
<IconButton on:click={() => (volume = volume > 0 ? 0 : Number(localStorage.getItem('volume')))}>
|
||||||
|
<i slot="icon" class="fa-solid {volume > maxVolume / 2 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'}" />
|
||||||
|
</IconButton>
|
||||||
|
<Slider
|
||||||
|
bind:value={volume}
|
||||||
|
max={maxVolume}
|
||||||
|
on:seeked={() => {
|
||||||
|
if (volume > 0) localStorage.setItem('volume', volume.toString())
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<IconButton on:click={() => (shuffled ? $queue.reorder() : $queue.shuffle())}>
|
||||||
|
<i slot="icon" class="fa-solid fa-shuffle {shuffled ? 'text-lazuli-primary' : 'text-white'}" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton on:click={() => (loop = !loop)}>
|
||||||
|
<i slot="icon" class="fa-solid fa-repeat {loop ? 'text-lazuli-primary' : 'text-white'}" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton on:click={() => (expanded = true)}>
|
||||||
|
<i slot="icon" class="fa-solid fa-chevron-up" />
|
||||||
|
</IconButton>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
{:else}
|
||||||
|
<main id="expanded-player" in:fade={{ delay: 500 }} out:fade={{ duration: 75 }} class="relative h-full">
|
||||||
|
<div class="absolute -z-10 h-full w-full blur-2xl brightness-[25%]">
|
||||||
|
<LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={''} objectFit={'cover'} />
|
||||||
|
</div>
|
||||||
|
<section class="relative grid h-full grid-rows-[1fr_4fr] gap-4 px-24 py-16">
|
||||||
|
<div class="grid grid-cols-[2fr_1fr]">
|
||||||
|
<div class="flex h-14 flex-row items-center gap-5">
|
||||||
|
<ServiceLogo type={currentlyPlaying.connection.type} />
|
||||||
|
<div>
|
||||||
|
<h1 class="text-neutral-400">STREAMING FROM</h1>
|
||||||
|
<strong class="text-2xl text-neutral-300">{Services[currentlyPlaying.connection.type].displayName}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<section>
|
||||||
|
{#if $queue.upNext}
|
||||||
|
{@const next = $queue.upNext}
|
||||||
|
<strong transition:fade class="ml-2 text-2xl">UP NEXT</strong>
|
||||||
|
<div transition:fly={{ x: 300 }} class="mt-3 flex h-20 w-full items-center gap-3 overflow-clip rounded-lg border border-neutral-300 bg-neutral-900 pr-3">
|
||||||
|
<div class="aspect-square h-full">
|
||||||
|
<LazyImage thumbnailUrl={next.thumbnailUrl} alt={`${next.name} jacket`} objectFit={'cover'} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="mb-0.5 line-clamp-1 font-medium">{next.name}</div>
|
||||||
|
<div class="line-clamp-1 text-sm font-light text-neutral-300">
|
||||||
|
<ArtistList mediaItem={next} linked={false} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={`${currentlyPlaying.name} jacket`} objectFit={'contain'} objectPosition={'left'} />
|
||||||
|
</section>
|
||||||
|
<section class="self-center px-16">
|
||||||
|
<div class="mb-7 flex min-w-56 flex-grow items-center justify-items-center gap-3 font-light">
|
||||||
|
<span bind:this={expandedCurrentTimeTimestamp} />
|
||||||
|
<Slider
|
||||||
|
bind:this={expandedProgressBar}
|
||||||
|
max={duration}
|
||||||
|
on:seeking={(event) => {
|
||||||
|
expandedCurrentTimeTimestamp.innerText = formatTime(event.detail.value)
|
||||||
|
seeking = true
|
||||||
|
}}
|
||||||
|
on:seeked={(event) => {
|
||||||
|
currentTime = event.detail.value
|
||||||
|
seeking = false
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span bind:this={expandedDurationTimestamp} />
|
||||||
|
</div>
|
||||||
|
<div id="expanded-controls">
|
||||||
|
<div class="flex min-w-56 flex-col gap-1.5 overflow-hidden">
|
||||||
|
<div class="h-10">
|
||||||
|
<ScrollingText>
|
||||||
|
<strong slot="text" class="text-4xl">{currentlyPlaying.name}</strong>
|
||||||
|
</ScrollingText>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 text-lg font-medium text-neutral-300">
|
||||||
|
{#if (currentlyPlaying.artists && currentlyPlaying.artists.length > 0) || currentlyPlaying.uploader}
|
||||||
|
<ArtistList mediaItem={currentlyPlaying} />
|
||||||
|
{/if}
|
||||||
|
{#if currentlyPlaying.album}
|
||||||
|
<strong>•</strong>
|
||||||
|
<a
|
||||||
|
on:click={() => (expanded = false)}
|
||||||
|
class="line-clamp-1 hover:underline focus:underline"
|
||||||
|
href="/details/album?id={currentlyPlaying.album.id}&connection={currentlyPlaying.connection.id}">{currentlyPlaying.album.name}</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex h-16 w-full items-center justify-center gap-2 text-2xl">
|
||||||
|
<IconButton on:click={() => (shuffled ? $queue.reorder() : $queue.shuffle())}>
|
||||||
|
<i slot="icon" class="fa-solid fa-shuffle {shuffled ? 'text-lazuli-primary' : 'text-white'}" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton on:click={() => $queue.previous()}>
|
||||||
|
<i slot="icon" class="fa-solid fa-backward-step text-xl" />
|
||||||
|
</IconButton>
|
||||||
|
<div class="relative aspect-square h-full rounded-full bg-white text-black">
|
||||||
|
{#if waiting}
|
||||||
|
<Loader size={1.5} />
|
||||||
|
{:else}
|
||||||
|
<IconButton on:click={() => (paused = !paused)}>
|
||||||
|
<i slot="icon" class="fa-solid {paused ? 'fa-play' : 'fa-pause'}" />
|
||||||
|
</IconButton>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<IconButton on:click={() => $queue.clear()}>
|
||||||
|
<i slot="icon" class="fa-solid fa-stop" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton on:click={() => $queue.next()}>
|
||||||
|
<i slot="icon" class="fa-solid fa-forward-step" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton on:click={() => (loop = !loop)}>
|
||||||
|
<i slot="icon" class="fa-solid fa-repeat {loop ? 'text-lazuli-primary' : 'text-white'}" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<section class="flex h-min items-center justify-end gap-2 text-xl">
|
||||||
|
<div class="mx-4 flex h-10 w-40 items-center gap-3">
|
||||||
|
<IconButton on:click={() => (volume = volume > 0 ? 0 : Number(localStorage.getItem('volume')))}>
|
||||||
|
<i slot="icon" class="fa-solid {volume > maxVolume / 2 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'}" />
|
||||||
|
</IconButton>
|
||||||
|
<Slider
|
||||||
|
bind:value={volume}
|
||||||
|
max={maxVolume}
|
||||||
|
on:seeked={() => {
|
||||||
|
if (volume > 0) localStorage.setItem('volume', volume.toString())
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<IconButton on:click={() => (expanded = false)}>
|
||||||
|
<i slot="icon" class="fa-solid fa-chevron-down" />
|
||||||
|
</IconButton>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
{/if}
|
||||||
|
<audio
|
||||||
|
bind:this={audioElement}
|
||||||
|
autoplay
|
||||||
|
bind:paused
|
||||||
|
bind:volume
|
||||||
|
bind:currentTime
|
||||||
|
bind:duration
|
||||||
|
on:canplay={() => (waiting = false)}
|
||||||
|
on:loadstart={() => (waiting = true)}
|
||||||
|
on:waiting={() => (waiting = true)}
|
||||||
|
on:ended={() => $queue.next()}
|
||||||
|
on:error={() => setTimeout(() => audioElement.load(), 5000)}
|
||||||
|
src="/api/v1/audio?connection={currentlyPlaying.connection.id}&id={currentlyPlaying.id}"
|
||||||
|
{loop}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#expanded-player {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 4fr 1fr;
|
||||||
|
}
|
||||||
|
#expanded-controls {
|
||||||
|
display: grid;
|
||||||
|
gap: 3rem;
|
||||||
|
align-items: center;
|
||||||
|
grid-template-columns: 1fr min-content 1fr !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if show}
|
{#if show}
|
||||||
<div in:fly={{ x: 500 }} out:slide={{ axis: 'y' }} class="py-1">
|
<div in:fly={{ x: 500 }} out:slide={{ axis: 'y' }} class="m-2">
|
||||||
<div class="flex gap-1 overflow-hidden rounded-md">
|
<div class="flex gap-1 overflow-hidden rounded-md">
|
||||||
<div class="flex w-full items-center p-4 {bgColors[alertType]}">
|
<div class="flex w-full items-center p-4 {bgColors[alertType]}">
|
||||||
{alertMessage}
|
{alertMessage}
|
||||||
|
|||||||
@@ -23,4 +23,4 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div bind:this={alertBox} class="fixed right-0 top-0 z-50 max-h-screen w-full max-w-sm overflow-hidden p-4"></div>
|
<div bind:this={alertBox} class="fixed right-0 top-0 z-50 max-h-screen w-full max-w-sm overflow-hidden"></div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let disabled = false
|
export let disabled = false
|
||||||
export let halo = false
|
export let halo = false
|
||||||
|
export let color = 'var(--lazuli-primary)'
|
||||||
|
|
||||||
import { createEventDispatcher } from 'svelte'
|
import { createEventDispatcher } from 'svelte'
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@
|
|||||||
class:disabled
|
class:disabled
|
||||||
class:halo
|
class:halo
|
||||||
class="relative grid aspect-square h-full place-items-center transition-transform duration-75 active:scale-90"
|
class="relative grid aspect-square h-full place-items-center transition-transform duration-75 active:scale-90"
|
||||||
|
style="--button-color: {color}"
|
||||||
on:click|preventDefault|stopPropagation={() => dispatch('click')}
|
on:click|preventDefault|stopPropagation={() => dispatch('click')}
|
||||||
{disabled}
|
{disabled}
|
||||||
>
|
>
|
||||||
@@ -25,7 +27,7 @@
|
|||||||
content: '';
|
content: '';
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 0;
|
height: 0;
|
||||||
background-color: color-mix(in srgb, var(--lazuli-primary) 20%, transparent);
|
background-color: color-mix(in srgb, var(--button-color) 20%, transparent);
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
transition-property: width height;
|
transition-property: width height;
|
||||||
transition-duration: 200ms;
|
transition-duration: 200ms;
|
||||||
@@ -39,6 +41,6 @@
|
|||||||
transition: color 200ms;
|
transition: color 200ms;
|
||||||
}
|
}
|
||||||
button:not(.disabled):hover :global(> :first-child) {
|
button:not(.disabled):hover :global(> :first-child) {
|
||||||
color: var(--lazuli-primary);
|
color: var(--button-color);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
102
src/lib/components/util/navbar.svelte
Normal file
102
src/lib/components/util/navbar.svelte
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import IconButton from './iconButton.svelte'
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
import { createEventDispatcher } from 'svelte'
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
|
let searchBar: HTMLElement, searchInput: HTMLInputElement, searchBarWidth: number
|
||||||
|
|
||||||
|
const HIDE_SEARCHBAR_BREAKPOINT_PX = 300
|
||||||
|
$: showSearchbar = searchBarWidth > HIDE_SEARCHBAR_BREAKPOINT_PX
|
||||||
|
|
||||||
|
let miniSearchOpen: boolean = false
|
||||||
|
|
||||||
|
function search() {
|
||||||
|
if (searchInput.value.replace(/\s/g, '').length === 0) return
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams({ query: searchInput.value })
|
||||||
|
goto(`/search?${searchParams.toString()}`)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav id="navbar" class="grid h-[4.5rem] items-center gap-2.5 p-3.5">
|
||||||
|
{#if !miniSearchOpen}
|
||||||
|
<div class="mr-4 flex h-full items-center">
|
||||||
|
<div class="h-full p-1">
|
||||||
|
<IconButton halo={true} on:click={() => dispatch('opensidebar')}>
|
||||||
|
<i slot="icon" class="fa-solid fa-bars text-xl" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<!-- --------------This is a placeholder image-------------- -->
|
||||||
|
<button on:click={() => goto('/')} class="mx-2.5 h-full w-20 bg-center bg-no-repeat" style="background-image: url(https://music.youtube.com/img/on_platform_logo_dark.svg);" />
|
||||||
|
</div>
|
||||||
|
<div bind:clientWidth={searchBarWidth} class="h-full">
|
||||||
|
{#if showSearchbar}
|
||||||
|
<search
|
||||||
|
role="search"
|
||||||
|
bind:this={searchBar}
|
||||||
|
class="relative flex h-full w-full max-w-xl items-center gap-2.5 rounded-lg border border-[rgba(255,255,255,0.1)] px-4 py-2 text-neutral-400"
|
||||||
|
style="background-color: rgba(255,255,255, 0.07);"
|
||||||
|
>
|
||||||
|
<IconButton on:click={search}>
|
||||||
|
<i slot="icon" class="fa-solid fa-magnifying-glass" />
|
||||||
|
</IconButton>
|
||||||
|
<input
|
||||||
|
bind:this={searchInput}
|
||||||
|
type="search"
|
||||||
|
name="search"
|
||||||
|
class="h-full w-full text-ellipsis bg-transparent text-neutral-300 outline-none placeholder:text-neutral-400"
|
||||||
|
placeholder="Let's find some music"
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"
|
||||||
|
on:keypress={(event) => (event.key === 'Enter' ? search() : null)}
|
||||||
|
/>
|
||||||
|
<IconButton on:click={() => (searchInput.value = '')}>
|
||||||
|
<i slot="icon" class="fa-solid fa-xmark" />
|
||||||
|
</IconButton>
|
||||||
|
</search>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex h-full gap-3 justify-self-end p-1">
|
||||||
|
<IconButton halo={true} on:click={() => goto('/user')}>
|
||||||
|
<i slot="icon" class="fa-solid fa-user text-lg" />
|
||||||
|
</IconButton>
|
||||||
|
{#if !showSearchbar}
|
||||||
|
<IconButton on:click={() => (miniSearchOpen = true)}>
|
||||||
|
<i slot="icon" class="fa-solid fa-magnifying-glass text-lg" />
|
||||||
|
</IconButton>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<IconButton on:click={() => (miniSearchOpen = false)}>
|
||||||
|
<i slot="icon" class="fa-solid fa-arrow-left text-lg" />
|
||||||
|
</IconButton>
|
||||||
|
<input
|
||||||
|
bind:this={searchInput}
|
||||||
|
type="search"
|
||||||
|
name="search"
|
||||||
|
class="h-full w-full text-ellipsis bg-transparent font-medium text-neutral-300 caret-lazuli-primary outline-none placeholder:text-neutral-300"
|
||||||
|
placeholder="Let's find some music"
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"
|
||||||
|
on:keypress={(event) => (event.key === 'Enter' ? search() : null)}
|
||||||
|
/>
|
||||||
|
<IconButton on:click={() => (searchInput.value = '')}>
|
||||||
|
<i slot="icon" class="fa-solid fa-xmark text-lg" />
|
||||||
|
</IconButton>
|
||||||
|
{/if}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#navbar {
|
||||||
|
grid-template-columns: min-content auto min-content;
|
||||||
|
}
|
||||||
|
input[type='search']::-webkit-search-cancel-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
<!--
|
<!--
|
||||||
@component
|
@component
|
||||||
A component that can be injected with a text to display it in a single line that clips if overflowing and intermittently scrolls
|
A component that can be injected with a text to display it in a single line that clips if overflowing and intermittently scrolls
|
||||||
from one end to the other. The scrolling text area is set to take up the maximum width and height that it can. Constrain the available
|
from one end to the other.
|
||||||
scrolling area with a wrapper element.
|
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<slot name="text" /> // An HTML element to wrap and style the text you want to scroll (e.g. div, spans, strongs)
|
<slot name="text" /> // An HTML element to wrap and style the text you want to scroll (e.g. div, spans, strongs)
|
||||||
@@ -27,6 +26,10 @@
|
|||||||
>
|
>
|
||||||
<slot name="text" />
|
<slot name="text" />
|
||||||
</span>
|
</span>
|
||||||
|
<!-- This is so the wrapper can calculate how big it should be based on the text -->
|
||||||
|
<span class="pointer-events-none line-clamp-1 opacity-0">
|
||||||
|
<slot name="text" />
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { goto } from '$app/navigation'
|
|
||||||
|
|
||||||
let searchBar: HTMLElement, searchInput: HTMLInputElement
|
|
||||||
|
|
||||||
const triggerSearch = (query: string) => {
|
|
||||||
const searchParams = new URLSearchParams({ query })
|
|
||||||
goto(`/search?${searchParams.toString()}`)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<search
|
|
||||||
role="search"
|
|
||||||
bind:this={searchBar}
|
|
||||||
class="relative flex h-10 w-full min-w-60 max-w-screen-sm items-center gap-2.5 rounded-lg border-2 border-transparent px-4 py-2"
|
|
||||||
style="background-color: rgba(82, 82, 82, 0.25);"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="aspect-square h-6 transition-colors duration-200 hover:text-lazuli-primary"
|
|
||||||
on:click|preventDefault={() => {
|
|
||||||
if (searchInput.value.trim() !== '') {
|
|
||||||
triggerSearch(searchInput.value)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-magnifying-glass" />
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
bind:this={searchInput}
|
|
||||||
type="text"
|
|
||||||
name="search"
|
|
||||||
class="w-full bg-transparent outline-none"
|
|
||||||
placeholder="Let's find some music"
|
|
||||||
autocomplete="off"
|
|
||||||
on:keypress={(event) => {
|
|
||||||
if (event.key === 'Enter') triggerSearch(searchInput.value)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button class="aspect-square h-6 transition-colors duration-200 hover:text-lazuli-primary" on:click|preventDefault={() => (searchInput.value = '')}>
|
|
||||||
<i class="fa-solid fa-xmark" />
|
|
||||||
</button>
|
|
||||||
</search>
|
|
||||||
64
src/lib/components/util/sidebar.svelte
Normal file
64
src/lib/components/util/sidebar.svelte
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { slide, fade } from 'svelte/transition'
|
||||||
|
import { sineOut } from 'svelte/easing'
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
import IconButton from './iconButton.svelte'
|
||||||
|
|
||||||
|
type NavButton = {
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
icon: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const navButtons: NavButton[] = [
|
||||||
|
{ name: 'Home', path: '/', icon: 'fa-solid fa-house' },
|
||||||
|
{ name: 'Library', path: '/library', icon: 'fa-solid fa-book' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const OPEN_CLOSE_DURATION = 250
|
||||||
|
|
||||||
|
export function open() {
|
||||||
|
isOpen = true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function close() {
|
||||||
|
isOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
let isOpen: boolean = false
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<div class="fixed isolate z-50">
|
||||||
|
<div transition:fade={{ duration: OPEN_CLOSE_DURATION }} aria-hidden="true" class="fixed bottom-0 left-0 right-0 top-0" style="background-color: rgba(0,0,0,0.3);" />
|
||||||
|
<section id="sidebar-wrapper" class="fixed bottom-0 left-0 right-0 top-0">
|
||||||
|
<div transition:slide={{ duration: OPEN_CLOSE_DURATION, axis: 'x', easing: sineOut }} class="relative h-full w-full overflow-clip bg-neutral-950 py-4 text-neutral-300 shadow-2xl">
|
||||||
|
{#each navButtons as tab}
|
||||||
|
<button
|
||||||
|
on:click={() => {
|
||||||
|
goto(tab.path)
|
||||||
|
close()
|
||||||
|
}}
|
||||||
|
class="flex w-full items-center gap-6 px-10 py-3.5 text-left transition-colors hover:bg-[rgba(255,255,255,0.1)]"
|
||||||
|
>
|
||||||
|
<i class={tab.icon} />
|
||||||
|
{tab.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<div class="absolute bottom-3 right-3 aspect-square h-10">
|
||||||
|
<IconButton on:click={close}>
|
||||||
|
<i slot="icon" class="fa-solid fa-arrow-left" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div aria-hidden="true" on:click={close} class="h-full w-full" />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#sidebar-wrapper {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(auto, 20rem) minmax(4rem, auto);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,8 +3,10 @@
|
|||||||
|
|
||||||
export let value = 0
|
export let value = 0
|
||||||
export let max = 100
|
export let max = 100
|
||||||
|
export let thickness: 'thick' | 'thin' = 'thick'
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const seekingDispatch = createEventDispatcher<{ seeking: { value: number } }>()
|
||||||
|
const seekedDispatch = createEventDispatcher<{ seeked: { value: number } }>()
|
||||||
|
|
||||||
let sliderThumb: HTMLSpanElement, sliderTrail: HTMLSpanElement
|
let sliderThumb: HTMLSpanElement, sliderTrail: HTMLSpanElement
|
||||||
|
|
||||||
@@ -26,7 +28,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
id="slider-track"
|
id="slider-track"
|
||||||
class="relative isolate h-1 w-full rounded bg-neutral-600"
|
class="relative isolate {thickness === 'thick' ? 'h-1' : 'h-0.5'} w-full rounded bg-neutral-800"
|
||||||
style="--slider-color: var(--lazuli-primary)"
|
style="--slider-color: var(--lazuli-primary)"
|
||||||
role="slider"
|
role="slider"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@@ -36,10 +38,10 @@
|
|||||||
on:keydown={(event) => handleKeyPress(event.key)}
|
on:keydown={(event) => handleKeyPress(event.key)}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
on:input={(event) => dispatch('seeking', { value: event.currentTarget.value })}
|
on:input={(event) => seekingDispatch('seeking', { value: Number(event.currentTarget.value) })}
|
||||||
on:change={(event) => dispatch('seeked', { value: event.currentTarget.value })}
|
on:change={(event) => seekedDispatch('seeked', { value: Number(event.currentTarget.value) })}
|
||||||
type="range"
|
type="range"
|
||||||
class="absolute z-10 h-1 w-full"
|
class="absolute z-10 {thickness === 'thick' ? 'h-1' : 'h-0.5'} w-full"
|
||||||
step="any"
|
step="any"
|
||||||
min="0"
|
min="0"
|
||||||
{max}
|
{max}
|
||||||
@@ -48,8 +50,8 @@
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
aria-disabled="true"
|
aria-disabled="true"
|
||||||
/>
|
/>
|
||||||
<span bind:this={sliderTrail} id="slider-trail" class="absolute left-0 h-1 rounded-full bg-white transition-colors" />
|
<span bind:this={sliderTrail} id="slider-trail" class="absolute left-0 {thickness === 'thick' ? 'h-1' : 'h-0.5'} rounded-full bg-white transition-colors" />
|
||||||
<span bind:this={sliderThumb} id="slider-thumb" class="absolute top-1/2 aspect-square h-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white opacity-0 transition-opacity duration-300" />
|
<span bind:this={sliderThumb} id="slider-thumb" class="absolute top-1/2 aspect-square {thickness === 'thick' ? 'h-3.5' : 'h-2.5'} -translate-x-1/2 -translate-y-1/2 rounded-full bg-white opacity-0" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -103,8 +103,6 @@ class Database {
|
|||||||
return this.db<Schemas.Songs>('Songs')
|
return this.db<Schemas.Songs>('Songs')
|
||||||
}
|
}
|
||||||
|
|
||||||
private exists() {}
|
|
||||||
|
|
||||||
public static async createUsersTable(db: knex.Knex<'better-sqlite3'>) {
|
public static async createUsersTable(db: knex.Knex<'better-sqlite3'>) {
|
||||||
const exists = await db.schema.hasTable('Users')
|
const exists = await db.schema.hasTable('Users')
|
||||||
if (exists) return
|
if (exists) return
|
||||||
|
|||||||
@@ -105,10 +105,10 @@ export class Jellyfin implements Connection {
|
|||||||
return mostPlayedResponse.Items.map(this.parsers.parseSong)
|
return mostPlayedResponse.Items.map(this.parsers.parseSong)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Figure out why seeking a jellyfin song takes so much longer than ytmusic (hls?)
|
// ! OK apparently some just don't work at all sometimes?
|
||||||
public async getAudioStream(id: string, headers: Headers) {
|
public async getAudioStream(id: string, headers: Headers) {
|
||||||
const audoSearchParams = new URLSearchParams({
|
const audoSearchParams = new URLSearchParams({
|
||||||
MaxStreamingBitrate: '2000000',
|
MaxStreamingBitrate: '140000000',
|
||||||
Container: 'opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg',
|
Container: 'opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg',
|
||||||
TranscodingContainer: 'ts',
|
TranscodingContainer: 'ts',
|
||||||
TranscodingProtocol: 'hls',
|
TranscodingProtocol: 'hls',
|
||||||
|
|||||||
584
src/lib/server/youtube-music-types.d.ts
vendored
584
src/lib/server/youtube-music-types.d.ts
vendored
@@ -1642,9 +1642,587 @@ export namespace InnerTube {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Need to fix this & it's corresponding method & add appropriate namespace
|
namespace Home {
|
||||||
interface HomeResponse {
|
interface Response {
|
||||||
contents: unknown
|
contents: {
|
||||||
|
singleColumnBrowseResultsRenderer: {
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
tabRenderer: {
|
||||||
|
content: {
|
||||||
|
sectionListRenderer: {
|
||||||
|
contents: Array<{
|
||||||
|
musicCarouselShelfRenderer: MusicCarouselShelfRenderer
|
||||||
|
}>
|
||||||
|
continuations?: [
|
||||||
|
{
|
||||||
|
nextContinuationData: {
|
||||||
|
continuation: string
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContinuationResponse {
|
||||||
|
continuationContents: {
|
||||||
|
sectionListContinuation: {
|
||||||
|
contents: Array<{
|
||||||
|
musicCarouselShelfRenderer: MusicCarouselShelfRenderer
|
||||||
|
}>
|
||||||
|
continuations?: [
|
||||||
|
{
|
||||||
|
nextContinuationData: {
|
||||||
|
continuation: string
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MusicCarouselShelfRenderer {
|
||||||
|
header: {
|
||||||
|
musicCarouselShelfBasicHeaderRenderer: {
|
||||||
|
title: {
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
text: string // Can be just about anything but some consistent ones are: Listen again, New releases, From your library, etc.
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contents:
|
||||||
|
| Array<{
|
||||||
|
musicTwoRowItemRenderer:
|
||||||
|
| SongMusicTwoRowItemRenderer
|
||||||
|
| VideoMusicTwoRowItemRenderer
|
||||||
|
| AlbumMusicTwoRowItemRenderer
|
||||||
|
| ArtistMusicTwoRowItemRenderer
|
||||||
|
| PlaylistMusicTwoRowItemRenderer
|
||||||
|
| ShowMusicTwoRowItemRenderer
|
||||||
|
}>
|
||||||
|
| Array<{
|
||||||
|
musicResponsiveListItemRenderer: SongMusicResponsiveListItemRenderer | VideoMusicResponsiveListItemRenderer
|
||||||
|
}>
|
||||||
|
| Array<{
|
||||||
|
musicMultiRowListItemRenderer: MusicMultiRowListItemRenderer
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SongMusicTwoRowItemRenderer {
|
||||||
|
thumbnailRenderer: {
|
||||||
|
musicThumbnailRenderer: {
|
||||||
|
thumbnail: {
|
||||||
|
thumbnails: Array<{
|
||||||
|
url: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
title: {
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
text: string // Item Name
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
subtitle: {
|
||||||
|
runs: Array<{
|
||||||
|
text: string
|
||||||
|
navigationEndpoint?: {
|
||||||
|
browseEndpoint: {
|
||||||
|
browseId: string
|
||||||
|
browseEndpointContextSupportedConfigs: {
|
||||||
|
browseEndpointContextMusicConfig: {
|
||||||
|
pageType: 'MUSIC_PAGE_TYPE_ARTIST'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
navigationEndpoint: {
|
||||||
|
watchEndpoint: {
|
||||||
|
videoId: string
|
||||||
|
playlistId: string
|
||||||
|
watchEndpointMusicSupportedConfigs: {
|
||||||
|
watchEndpointMusicConfig: {
|
||||||
|
musicVideoType: 'MUSIC_VIDEO_TYPE_ATV'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VideoMusicTwoRowItemRenderer {
|
||||||
|
thumbnailRenderer: {
|
||||||
|
musicThumbnailRenderer: {
|
||||||
|
thumbnail: {
|
||||||
|
thumbnails: Array<{
|
||||||
|
url: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
title: {
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
text: string // Item Name
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
subtitle: {
|
||||||
|
runs: Array<{
|
||||||
|
text: string
|
||||||
|
navigationEndpoint?: {
|
||||||
|
browseEndpoint: {
|
||||||
|
browseId: string
|
||||||
|
browseEndpointContextSupportedConfigs: {
|
||||||
|
browseEndpointContextMusicConfig: {
|
||||||
|
pageType: 'MUSIC_PAGE_TYPE_ARTIST' | 'MUSIC_PAGE_TYPE_USER_CHANNEL'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
navigationEndpoint: {
|
||||||
|
watchEndpoint: {
|
||||||
|
videoId: string
|
||||||
|
playlistId: string
|
||||||
|
watchEndpointMusicSupportedConfigs: {
|
||||||
|
watchEndpointMusicConfig: {
|
||||||
|
musicVideoType: 'MUSIC_VIDEO_TYPE_OMV' | 'MUSIC_VIDEO_TYPE_UGC' | 'MUSIC_VIDEO_TYPE_OFFICIAL_SOURCE_MUSIC'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlbumMusicTwoRowItemRenderer {
|
||||||
|
thumbnailRenderer: {
|
||||||
|
musicThumbnailRenderer: {
|
||||||
|
thumbnail: {
|
||||||
|
thumbnails: Array<{
|
||||||
|
url: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
title: {
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
text: string // Item Name
|
||||||
|
navigationEndpoint: {
|
||||||
|
browseEndpoint: {
|
||||||
|
browseId: string
|
||||||
|
browseEndpointContextSupportedConfigs: {
|
||||||
|
browseEndpointContextMusicConfig: {
|
||||||
|
pageType: 'MUSIC_PAGE_TYPE_ALBUM'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
subtitle: {
|
||||||
|
runs: Array<{
|
||||||
|
text: string
|
||||||
|
navigationEndpoint?: {
|
||||||
|
browseEndpoint: {
|
||||||
|
browseId: string
|
||||||
|
browseEndpointContextSupportedConfigs: {
|
||||||
|
browseEndpointContextMusicConfig: {
|
||||||
|
pageType: 'MUSIC_PAGE_TYPE_ARTIST'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
navigationEndpoint: {
|
||||||
|
browseEndpoint: {
|
||||||
|
browseId: string
|
||||||
|
browseEndpointContextSupportedConfigs: {
|
||||||
|
browseEndpointContextMusicConfig: {
|
||||||
|
pageType: 'MUSIC_PAGE_TYPE_ALBUM'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArtistMusicTwoRowItemRenderer {
|
||||||
|
thumbnailRenderer: {
|
||||||
|
musicThumbnailRenderer: {
|
||||||
|
thumbnail: {
|
||||||
|
thumbnails: Array<{
|
||||||
|
url: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
title: {
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
text: string // Artist name
|
||||||
|
navigationEndpoint: {
|
||||||
|
browseEndpoint: {
|
||||||
|
browseId: string
|
||||||
|
browseEndpointContextSupportedConfigs: {
|
||||||
|
browseEndpointContextMusicConfig: {
|
||||||
|
pageType: 'MUSIC_PAGE_TYPE_ARTIST'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
subtitle: {
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
text: string // Number of subscribers
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
navigationEndpoint: {
|
||||||
|
browseEndpoint: {
|
||||||
|
browseId: string
|
||||||
|
browseEndpointContextSupportedConfigs: {
|
||||||
|
browseEndpointContextMusicConfig: {
|
||||||
|
pageType: 'MUSIC_PAGE_TYPE_ARTIST'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlaylistMusicTwoRowItemRenderer {
|
||||||
|
thumbnailRenderer: {
|
||||||
|
musicThumbnailRenderer: {
|
||||||
|
thumbnail: {
|
||||||
|
thumbnails: Array<{
|
||||||
|
url: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
title: {
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
text: string
|
||||||
|
navigationEndpoint: {
|
||||||
|
browseEndpoint: {
|
||||||
|
browseId: string
|
||||||
|
browseEndpointContextSupportedConfigs: {
|
||||||
|
browseEndpointContextMusicConfig: {
|
||||||
|
pageType: 'MUSIC_PAGE_TYPE_PLAYLIST'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
subtitle: {
|
||||||
|
runs: Array<{
|
||||||
|
text: string
|
||||||
|
navigationEndpoint?: {
|
||||||
|
browseEndpoint: {
|
||||||
|
browseId: string
|
||||||
|
browseEndpointContextSupportedConfigs: {
|
||||||
|
browseEndpointContextMusicConfig: {
|
||||||
|
pageType: 'MUSIC_PAGE_TYPE_USER_CHANNEL'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
navigationEndpoint: {
|
||||||
|
browseEndpoint: {
|
||||||
|
browseId: string
|
||||||
|
browseEndpointContextSupportedConfigs: {
|
||||||
|
browseEndpointContextMusicConfig: {
|
||||||
|
pageType: 'MUSIC_PAGE_TYPE_PLAYLIST'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShowMusicTwoRowItemRenderer {
|
||||||
|
thumbnailRenderer: {
|
||||||
|
musicThumbnailRenderer: {
|
||||||
|
thumbnail: {
|
||||||
|
thumbnails: Array<{
|
||||||
|
url: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
title: {
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
text: string
|
||||||
|
navigationEndpoint: {
|
||||||
|
browseEndpoint: {
|
||||||
|
browseId: string
|
||||||
|
browseEndpointContextSupportedConfigs: {
|
||||||
|
browseEndpointContextMusicConfig: {
|
||||||
|
pageType: 'MUSIC_PAGE_TYPE_PODCAST_SHOW_DETAIL_PAGE'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
subtitle: {
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
text: string
|
||||||
|
navigationEndpoint: {
|
||||||
|
browseEndpoint: {
|
||||||
|
browseId: string
|
||||||
|
browseEndpointContextSupportedConfigs: {
|
||||||
|
browseEndpointContextMusicConfig: {
|
||||||
|
pageType: 'MUSIC_PAGE_TYPE_USER_CHANNEL'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
navigationEndpoint: {
|
||||||
|
browseEndpoint: {
|
||||||
|
browseId: string
|
||||||
|
browseEndpointContextSupportedConfigs: {
|
||||||
|
browseEndpointContextMusicConfig: {
|
||||||
|
pageType: 'MUSIC_PAGE_TYPE_PODCAST_SHOW_DETAIL_PAGE'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SongMusicResponsiveListItemRenderer {
|
||||||
|
thumbnail: {
|
||||||
|
musicThumbnailRenderer: {
|
||||||
|
thumbnail: {
|
||||||
|
thumbnails: Array<{
|
||||||
|
url: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flexColumns: [
|
||||||
|
{
|
||||||
|
musicResponsiveListItemFlexColumnRenderer: {
|
||||||
|
text: {
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
text: string
|
||||||
|
navigationEndpoint: {
|
||||||
|
watchEndpoint: {
|
||||||
|
videoId: string
|
||||||
|
playlistId: string
|
||||||
|
watchEndpointMusicSupportedConfigs: {
|
||||||
|
watchEndpointMusicConfig: {
|
||||||
|
musicVideoType: 'MUSIC_VIDEO_TYPE_ATV'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
musicResponsiveListItemFlexColumnRenderer: {
|
||||||
|
text: {
|
||||||
|
runs: Array<{
|
||||||
|
text: string
|
||||||
|
navigationEndpoint?: {
|
||||||
|
browseEndpoint: {
|
||||||
|
browseId: string
|
||||||
|
browseEndpointContextSupportedConfigs: {
|
||||||
|
browseEndpointContextMusicConfig: {
|
||||||
|
pageType: 'MUSIC_PAGE_TYPE_ARTIST'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
musicResponsiveListItemFlexColumnRenderer: {
|
||||||
|
text: {
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
text: string
|
||||||
|
navigationEndpoint: {
|
||||||
|
browseEndpoint: {
|
||||||
|
browseId: string
|
||||||
|
browseEndpointContextSupportedConfigs: {
|
||||||
|
browseEndpointContextMusicConfig: {
|
||||||
|
pageType: 'MUSIC_PAGE_TYPE_ALBUM'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VideoMusicResponsiveListItemRenderer {
|
||||||
|
thumbnail: {
|
||||||
|
musicThumbnailRenderer: {
|
||||||
|
thumbnail: {
|
||||||
|
thumbnails: Array<{
|
||||||
|
url: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flexColumns: [
|
||||||
|
{
|
||||||
|
musicResponsiveListItemFlexColumnRenderer: {
|
||||||
|
text: {
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
text: string
|
||||||
|
navigationEndpoint: {
|
||||||
|
watchEndpoint: {
|
||||||
|
videoId: string
|
||||||
|
playlistId: string
|
||||||
|
watchEndpointMusicSupportedConfigs: {
|
||||||
|
watchEndpointMusicConfig: {
|
||||||
|
musicVideoType: 'MUSIC_VIDEO_TYPE_OMV' | 'MUSIC_VIDEO_TYPE_UGC' | 'MUSIC_VIDEO_TYPE_OFFICIAL_SOURCE_MUSIC'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
musicResponsiveListItemFlexColumnRenderer: {
|
||||||
|
text: {
|
||||||
|
runs: Array<{
|
||||||
|
text: string
|
||||||
|
navigationEndpoint?: {
|
||||||
|
browseEndpoint: {
|
||||||
|
browseId: string
|
||||||
|
browseEndpointContextSupportedConfigs: {
|
||||||
|
browseEndpointContextMusicConfig: {
|
||||||
|
pageType: 'MUSIC_PAGE_TYPE_USER_CHANNEL'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MusicMultiRowListItemRenderer {
|
||||||
|
thumbnail: {
|
||||||
|
musicThumbnailRenderer: {
|
||||||
|
thumbnail: {
|
||||||
|
thumbnails: Array<{
|
||||||
|
url: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
title: {
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
text: string
|
||||||
|
navigationEndpoint: {
|
||||||
|
browseEndpoint: {
|
||||||
|
browseId: string
|
||||||
|
browseEndpointContextSupportedConfigs: {
|
||||||
|
browseEndpointContextMusicConfig: {
|
||||||
|
pageType: 'MUSIC_PAGE_TYPE_NON_MUSIC_AUDIO_TRACK_PAGE'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
secondTitle: {
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
text: string
|
||||||
|
navigationEndpoint: {
|
||||||
|
browseEndpoint: {
|
||||||
|
browseId: string
|
||||||
|
browseEndpointContextSupportedConfigs: {
|
||||||
|
browseEndpointContextMusicConfig: {
|
||||||
|
pageType: 'MUSIC_PAGE_TYPE_PODCAST_SHOW_DETAIL_PAGE'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
subtitle: {
|
||||||
|
runs: Array<{
|
||||||
|
text: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
description: {
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
text: string
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,19 +9,19 @@ export class YouTubeMusic implements Connection {
|
|||||||
private readonly userId: string
|
private readonly userId: string
|
||||||
private readonly youtubeUserId: string
|
private readonly youtubeUserId: string
|
||||||
|
|
||||||
private readonly api: APIManager
|
private readonly api: API
|
||||||
private libraryManager?: LibaryManager
|
private libraryManager?: YouTubeMusicLibrary
|
||||||
|
|
||||||
constructor(id: string, userId: string, youtubeUserId: string, accessToken: string, refreshToken: string, expiry: number) {
|
constructor(id: string, userId: string, youtubeUserId: string, accessToken: string, refreshToken: string, expiry: number) {
|
||||||
this.id = id
|
this.id = id
|
||||||
this.userId = userId
|
this.userId = userId
|
||||||
this.youtubeUserId = youtubeUserId
|
this.youtubeUserId = youtubeUserId
|
||||||
|
|
||||||
this.api = new APIManager(id, accessToken, refreshToken, expiry)
|
this.api = new API(id, accessToken, refreshToken, expiry)
|
||||||
}
|
}
|
||||||
|
|
||||||
public get library() {
|
public get library() {
|
||||||
if (!this.libraryManager) this.libraryManager = new LibaryManager(this.id, this.youtubeUserId, this.api)
|
if (!this.libraryManager) this.libraryManager = new YouTubeMusicLibrary(this.api)
|
||||||
|
|
||||||
return this.libraryManager
|
return this.libraryManager
|
||||||
}
|
}
|
||||||
@@ -222,7 +222,7 @@ export class YouTubeMusic implements Connection {
|
|||||||
|
|
||||||
const parseCommunityPlaylistResponsiveListItemRenderer = (item: InnerTube.Search.CommunityPlaylistMusicResponsiveListItemRenderer): Playlist => {
|
const parseCommunityPlaylistResponsiveListItemRenderer = (item: InnerTube.Search.CommunityPlaylistMusicResponsiveListItemRenderer): Playlist => {
|
||||||
const connection = { id: this.id, type: 'youtube-music' } satisfies Playlist['connection']
|
const connection = { id: this.id, type: 'youtube-music' } satisfies Playlist['connection']
|
||||||
const id = item.navigationEndpoint.browseEndpoint.browseId
|
const id = item.navigationEndpoint.browseEndpoint.browseId.slice(2)
|
||||||
const name = item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text
|
const name = item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text
|
||||||
const thumbnailUrl = extractLargestThumbnailUrl(item.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
|
const thumbnailUrl = extractLargestThumbnailUrl(item.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
|
||||||
|
|
||||||
@@ -298,11 +298,119 @@ export class YouTubeMusic implements Connection {
|
|||||||
return extractedItems.filter((item): item is MediaItemTypeMap[T] => types.has(item.type as T))
|
return extractedItems.filter((item): item is MediaItemTypeMap[T] => types.has(item.type as T))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ! Need to completely rework this method - Currently returns empty array
|
public async getRecommendations(): Promise<(Song | Album | Artist | Playlist)[]> {
|
||||||
public async getRecommendations() {
|
const parseAlbumMusicTwoRowItemRenderer = (item: InnerTube.Home.AlbumMusicTwoRowItemRenderer): Album => {
|
||||||
// const response = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'FEmusic_home' } }).json()
|
const connection = { id: this.id, type: 'youtube-music' } satisfies Album['connection']
|
||||||
// console.log(JSON.stringify(response))
|
const id = item.navigationEndpoint.browseEndpoint.browseId
|
||||||
return []
|
const name = item.title.runs[0].text
|
||||||
|
const thumbnailUrl = extractLargestThumbnailUrl(item.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails)
|
||||||
|
|
||||||
|
let artists: Album['artists'] = 'Various Artists'
|
||||||
|
item.subtitle.runs.forEach((run) => {
|
||||||
|
if (!run.navigationEndpoint) return
|
||||||
|
|
||||||
|
const artistData = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
|
||||||
|
typeof artists === 'string' ? (artists = [artistData]) : artists.push(artistData)
|
||||||
|
})
|
||||||
|
|
||||||
|
return { connection, id, name, type: 'album', thumbnailUrl, artists }
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseArtistMusicTwoRowItemRenderer = (item: InnerTube.Home.ArtistMusicTwoRowItemRenderer): Artist => {
|
||||||
|
const connection = { id: this.id, type: 'youtube-music' } satisfies Artist['connection']
|
||||||
|
const id = item.navigationEndpoint.browseEndpoint.browseId
|
||||||
|
const name = item.title.runs[0].text
|
||||||
|
const profilePicture = extractLargestThumbnailUrl(item.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails)
|
||||||
|
|
||||||
|
return { connection, id, name, type: 'artist', profilePicture }
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsePlaylistMusicTwoRowItemRenderer = (item: InnerTube.Home.PlaylistMusicTwoRowItemRenderer): Playlist => {
|
||||||
|
const connection = { id: this.id, type: 'youtube-music' } satisfies Artist['connection']
|
||||||
|
const id = item.navigationEndpoint.browseEndpoint.browseId.slice(2)
|
||||||
|
const name = item.title.runs[0].text
|
||||||
|
const thumbnailUrl = extractLargestThumbnailUrl(item.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails)
|
||||||
|
|
||||||
|
let createdBy: Playlist['createdBy']
|
||||||
|
item.subtitle.runs.forEach((run) => {
|
||||||
|
if (!run.navigationEndpoint) return
|
||||||
|
|
||||||
|
createdBy = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
|
||||||
|
})
|
||||||
|
|
||||||
|
return { connection, id, name, type: 'playlist', thumbnailUrl, createdBy }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the ids of songs in place of Song objects becasue full details need to be fetched with getSongs()
|
||||||
|
const parseMusicCarouselShelfRenderer = (carousel: InnerTube.Home.MusicCarouselShelfRenderer): (string | Album | Artist | Playlist)[] => {
|
||||||
|
const results: (string | Album | Artist | Playlist)[] = []
|
||||||
|
for (const item of carousel.contents) {
|
||||||
|
if ('musicMultiRowListItemRenderer' in item) continue
|
||||||
|
|
||||||
|
if ('musicResponsiveListItemRenderer' in item) {
|
||||||
|
results.push(item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.videoId)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageType =
|
||||||
|
'watchEndpoint' in item.musicTwoRowItemRenderer.navigationEndpoint
|
||||||
|
? item.musicTwoRowItemRenderer.navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType
|
||||||
|
: item.musicTwoRowItemRenderer.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
|
||||||
|
|
||||||
|
switch (pageType) {
|
||||||
|
case 'MUSIC_VIDEO_TYPE_ATV':
|
||||||
|
case 'MUSIC_VIDEO_TYPE_OMV':
|
||||||
|
case 'MUSIC_VIDEO_TYPE_UGC':
|
||||||
|
case 'MUSIC_VIDEO_TYPE_OFFICIAL_SOURCE_MUSIC':
|
||||||
|
const songItem = item.musicTwoRowItemRenderer as InnerTube.Home.SongMusicTwoRowItemRenderer | InnerTube.Home.VideoMusicTwoRowItemRenderer
|
||||||
|
results.push(songItem.navigationEndpoint.watchEndpoint.videoId)
|
||||||
|
break
|
||||||
|
case 'MUSIC_PAGE_TYPE_ALBUM':
|
||||||
|
const albumItem = item.musicTwoRowItemRenderer as InnerTube.Home.AlbumMusicTwoRowItemRenderer
|
||||||
|
results.push(parseAlbumMusicTwoRowItemRenderer(albumItem))
|
||||||
|
break
|
||||||
|
case 'MUSIC_PAGE_TYPE_ARTIST':
|
||||||
|
const artistItem = item.musicTwoRowItemRenderer as InnerTube.Home.ArtistMusicTwoRowItemRenderer
|
||||||
|
results.push(parseArtistMusicTwoRowItemRenderer(artistItem))
|
||||||
|
break
|
||||||
|
case 'MUSIC_PAGE_TYPE_PLAYLIST':
|
||||||
|
const playlistItem = item.musicTwoRowItemRenderer as InnerTube.Home.PlaylistMusicTwoRowItemRenderer
|
||||||
|
results.push(parsePlaylistMusicTwoRowItemRenderer(playlistItem))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'FEmusic_home' } }).json<InnerTube.Home.Response>()
|
||||||
|
|
||||||
|
const MAX_RECOMMENDATIONS = 20 // Temporary Implementation
|
||||||
|
const goodSections = ['Listen again', 'Recommended albums', 'From your library', 'Recommended music videos', 'Forgotten favorites', 'Quick picks', 'Long listening']
|
||||||
|
|
||||||
|
const contents = response.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents
|
||||||
|
.filter((section) => goodSections.includes(section.musicCarouselShelfRenderer.header.musicCarouselShelfBasicHeaderRenderer.title.runs[0].text))
|
||||||
|
.map((section) => parseMusicCarouselShelfRenderer(section.musicCarouselShelfRenderer))
|
||||||
|
.flat()
|
||||||
|
|
||||||
|
let continuation = response.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.continuations?.[0].nextContinuationData.continuation
|
||||||
|
|
||||||
|
while (continuation && contents.length < MAX_RECOMMENDATIONS) {
|
||||||
|
const continuationResponse = await this.api.v1.WEB_REMIX(`browse?ctoken=${continuation}&continuation=${continuation}`).json<InnerTube.Home.ContinuationResponse>()
|
||||||
|
|
||||||
|
const continuationContents = continuationResponse.continuationContents.sectionListContinuation.contents
|
||||||
|
.filter((section) => goodSections.includes(section.musicCarouselShelfRenderer.header.musicCarouselShelfBasicHeaderRenderer.title.runs[0].text))
|
||||||
|
.map((section) => parseMusicCarouselShelfRenderer(section.musicCarouselShelfRenderer))
|
||||||
|
.flat()
|
||||||
|
|
||||||
|
contents.push(...continuationContents)
|
||||||
|
continuation = continuationResponse.continuationContents.sectionListContinuation.continuations?.[0].nextContinuationData.continuation
|
||||||
|
}
|
||||||
|
|
||||||
|
let songsIndex = 0
|
||||||
|
const songs = await this.getSongs(contents.filter((item) => typeof item === 'string'))
|
||||||
|
|
||||||
|
return Array.from(contents, (item) => (typeof item === 'string' ? songs[songsIndex++] : item))
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAudioStream(id: string, headers: Headers) {
|
public async getAudioStream(id: string, headers: Headers) {
|
||||||
@@ -396,10 +504,9 @@ export class YouTubeMusic implements Connection {
|
|||||||
public async getPlaylist(id: string): Promise<Playlist> {
|
public async getPlaylist(id: string): Promise<Playlist> {
|
||||||
const playlistResponse = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'VL'.concat(id) } }).json<InnerTube.Playlist.Response>()
|
const playlistResponse = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'VL'.concat(id) } }).json<InnerTube.Playlist.Response>()
|
||||||
|
|
||||||
|
const sectionContent = playlistResponse.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0]
|
||||||
const header =
|
const header =
|
||||||
'musicEditablePlaylistDetailHeaderRenderer' in playlistResponse.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0]
|
'musicEditablePlaylistDetailHeaderRenderer' in sectionContent ? sectionContent.musicEditablePlaylistDetailHeaderRenderer.header.musicResponsiveHeaderRenderer : sectionContent.musicResponsiveHeaderRenderer
|
||||||
? playlistResponse.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicEditablePlaylistDetailHeaderRenderer.header.musicResponsiveHeaderRenderer
|
|
||||||
: playlistResponse.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicResponsiveHeaderRenderer
|
|
||||||
|
|
||||||
const connection = { id: this.id, type: 'youtube-music' } satisfies Playlist['connection']
|
const connection = { id: this.id, type: 'youtube-music' } satisfies Playlist['connection']
|
||||||
const name = header.title.runs[0].text
|
const name = header.title.runs[0].text
|
||||||
@@ -509,8 +616,107 @@ export class YouTubeMusic implements Connection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class APIManager {
|
class YouTubeMusicLibrary {
|
||||||
private readonly connectionId: string
|
private readonly api: API
|
||||||
|
|
||||||
|
constructor(api: API) {
|
||||||
|
this.api = api
|
||||||
|
}
|
||||||
|
|
||||||
|
public async albums(): Promise<Album[]> {
|
||||||
|
const albumData = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'FEmusic_liked_albums' } }).json<InnerTube.Library.AlbumResponse>()
|
||||||
|
|
||||||
|
const { items, continuations } = albumData.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].gridRenderer
|
||||||
|
let continuation = continuations?.[0].nextContinuationData.continuation
|
||||||
|
|
||||||
|
while (continuation) {
|
||||||
|
const continuationData = await this.api.v1.WEB_REMIX(`browse?ctoken=${continuation}&continuation=${continuation}`).json<InnerTube.Library.AlbumContinuationResponse>()
|
||||||
|
|
||||||
|
items.push(...continuationData.continuationContents.gridContinuation.items)
|
||||||
|
continuation = continuationData.continuationContents.gridContinuation.continuations?.[0].nextContinuationData.continuation
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = { id: this.api.connectionId, type: 'youtube-music' } satisfies Album['connection']
|
||||||
|
return items.map((item) => {
|
||||||
|
const id = item.musicTwoRowItemRenderer.navigationEndpoint.browseEndpoint.browseId
|
||||||
|
const name = item.musicTwoRowItemRenderer.title.runs[0].text
|
||||||
|
const thumbnailUrl = extractLargestThumbnailUrl(item.musicTwoRowItemRenderer.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails)
|
||||||
|
|
||||||
|
let artists: Album['artists'] = []
|
||||||
|
item.musicTwoRowItemRenderer.subtitle.runs.forEach((run) => {
|
||||||
|
if (run.text === 'Various Artists') return (artists = 'Various Artists')
|
||||||
|
if (run.navigationEndpoint && artists instanceof Array) artists.push({ id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text })
|
||||||
|
})
|
||||||
|
|
||||||
|
const releaseYear = item.musicTwoRowItemRenderer.subtitle.runs.at(-1)?.text!
|
||||||
|
|
||||||
|
return { connection, id, name, type: 'album', thumbnailUrl, artists, releaseYear } satisfies Album
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public async artists(): Promise<Artist[]> {
|
||||||
|
const artistsData = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'FEmusic_library_corpus_track_artists' } }).json<InnerTube.Library.ArtistResponse>()
|
||||||
|
|
||||||
|
const { contents, continuations } = artistsData.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicShelfRenderer
|
||||||
|
let continuation = continuations?.[0].nextContinuationData.continuation
|
||||||
|
|
||||||
|
while (continuation) {
|
||||||
|
const continuationData = await this.api.v1.WEB_REMIX(`browse?ctoken=${continuation}&continuation=${continuation}`).json<InnerTube.Library.ArtistContinuationResponse>()
|
||||||
|
|
||||||
|
contents.push(...continuationData.continuationContents.musicShelfContinuation.contents)
|
||||||
|
continuation = continuationData.continuationContents.musicShelfContinuation.continuations?.[0].nextContinuationData.continuation
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = { id: this.api.connectionId, type: 'youtube-music' } satisfies Album['connection']
|
||||||
|
return contents.map((item) => {
|
||||||
|
const id = item.musicResponsiveListItemRenderer.navigationEndpoint.browseEndpoint.browseId
|
||||||
|
const name = item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text
|
||||||
|
const profilePicture = extractLargestThumbnailUrl(item.musicResponsiveListItemRenderer.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
|
||||||
|
|
||||||
|
return { connection, id, name, type: 'artist', profilePicture } satisfies Artist
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public async playlists(): Promise<Playlist[]> {
|
||||||
|
const playlistData = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'FEmusic_liked_playlists' } }).json<InnerTube.Library.PlaylistResponse>()
|
||||||
|
|
||||||
|
const { items, continuations } = playlistData.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].gridRenderer
|
||||||
|
let continuation = continuations?.[0].nextContinuationData.continuation
|
||||||
|
|
||||||
|
while (continuation) {
|
||||||
|
const continuationData = await this.api.v1.WEB_REMIX(`browse?ctoken=${continuation}&continuation=${continuation}`).json<InnerTube.Library.PlaylistContinuationResponse>()
|
||||||
|
|
||||||
|
items.push(...continuationData.continuationContents.gridContinuation.items)
|
||||||
|
continuation = continuationData.continuationContents.gridContinuation.continuations?.[0].nextContinuationData.continuation
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlists = items.filter(
|
||||||
|
(item): item is { musicTwoRowItemRenderer: InnerTube.Library.PlaylistMusicTwoRowItemRenderer } =>
|
||||||
|
'browseEndpoint' in item.musicTwoRowItemRenderer.navigationEndpoint &&
|
||||||
|
item.musicTwoRowItemRenderer.navigationEndpoint.browseEndpoint.browseId !== 'VLLM' &&
|
||||||
|
item.musicTwoRowItemRenderer.navigationEndpoint.browseEndpoint.browseId !== 'VLSE',
|
||||||
|
)
|
||||||
|
|
||||||
|
const connection = { id: this.api.connectionId, type: 'youtube-music' } satisfies Album['connection']
|
||||||
|
return playlists.map((item) => {
|
||||||
|
const id = item.musicTwoRowItemRenderer.navigationEndpoint.browseEndpoint.browseId.slice(2)
|
||||||
|
const name = item.musicTwoRowItemRenderer.title.runs[0].text
|
||||||
|
const thumbnailUrl = extractLargestThumbnailUrl(item.musicTwoRowItemRenderer.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails)
|
||||||
|
|
||||||
|
let createdBy: Playlist['createdBy']
|
||||||
|
item.musicTwoRowItemRenderer.subtitle.runs.forEach((run) => {
|
||||||
|
if (!run.navigationEndpoint) return
|
||||||
|
|
||||||
|
createdBy = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
|
||||||
|
})
|
||||||
|
|
||||||
|
return { connection, id, name, type: 'playlist', thumbnailUrl, createdBy } satisfies Playlist
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class API {
|
||||||
|
public readonly connectionId: string
|
||||||
private currentAccessToken: string
|
private currentAccessToken: string
|
||||||
private readonly refreshToken: string
|
private readonly refreshToken: string
|
||||||
private expiry: number
|
private expiry: number
|
||||||
@@ -610,107 +816,6 @@ class APIManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class LibaryManager {
|
|
||||||
private readonly connectionId: string
|
|
||||||
private readonly api: APIManager
|
|
||||||
private readonly youtubeUserId: string
|
|
||||||
|
|
||||||
constructor(connectionId: string, youtubeUserId: string, apiManager: APIManager) {
|
|
||||||
this.connectionId = connectionId
|
|
||||||
this.api = apiManager
|
|
||||||
this.youtubeUserId = youtubeUserId
|
|
||||||
}
|
|
||||||
|
|
||||||
public async albums(): Promise<Album[]> {
|
|
||||||
const albumData = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'FEmusic_liked_albums' } }).json<InnerTube.Library.AlbumResponse>()
|
|
||||||
|
|
||||||
const { items, continuations } = albumData.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].gridRenderer
|
|
||||||
let continuation = continuations?.[0].nextContinuationData.continuation
|
|
||||||
|
|
||||||
while (continuation) {
|
|
||||||
const continuationData = await this.api.v1.WEB_REMIX(`browse?ctoken=${continuation}&continuation=${continuation}`).json<InnerTube.Library.AlbumContinuationResponse>()
|
|
||||||
|
|
||||||
items.push(...continuationData.continuationContents.gridContinuation.items)
|
|
||||||
continuation = continuationData.continuationContents.gridContinuation.continuations?.[0].nextContinuationData.continuation
|
|
||||||
}
|
|
||||||
|
|
||||||
const connection = { id: this.connectionId, type: 'youtube-music' } satisfies Album['connection']
|
|
||||||
return items.map((item) => {
|
|
||||||
const id = item.musicTwoRowItemRenderer.navigationEndpoint.browseEndpoint.browseId
|
|
||||||
const name = item.musicTwoRowItemRenderer.title.runs[0].text
|
|
||||||
const thumbnailUrl = extractLargestThumbnailUrl(item.musicTwoRowItemRenderer.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails)
|
|
||||||
|
|
||||||
let artists: Album['artists'] = []
|
|
||||||
item.musicTwoRowItemRenderer.subtitle.runs.forEach((run) => {
|
|
||||||
if (run.text === 'Various Artists') return (artists = 'Various Artists')
|
|
||||||
if (run.navigationEndpoint && artists instanceof Array) artists.push({ id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text })
|
|
||||||
})
|
|
||||||
|
|
||||||
const releaseYear = item.musicTwoRowItemRenderer.subtitle.runs.at(-1)?.text!
|
|
||||||
|
|
||||||
return { connection, id, name, type: 'album', thumbnailUrl, artists, releaseYear } satisfies Album
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
public async artists(): Promise<Artist[]> {
|
|
||||||
const artistsData = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'FEmusic_library_corpus_track_artists' } }).json<InnerTube.Library.ArtistResponse>()
|
|
||||||
|
|
||||||
const { contents, continuations } = artistsData.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicShelfRenderer
|
|
||||||
let continuation = continuations?.[0].nextContinuationData.continuation
|
|
||||||
|
|
||||||
while (continuation) {
|
|
||||||
const continuationData = await this.api.v1.WEB_REMIX(`browse?ctoken=${continuation}&continuation=${continuation}`).json<InnerTube.Library.ArtistContinuationResponse>()
|
|
||||||
|
|
||||||
contents.push(...continuationData.continuationContents.musicShelfContinuation.contents)
|
|
||||||
continuation = continuationData.continuationContents.musicShelfContinuation.continuations?.[0].nextContinuationData.continuation
|
|
||||||
}
|
|
||||||
|
|
||||||
const connection = { id: this.connectionId, type: 'youtube-music' } satisfies Album['connection']
|
|
||||||
return contents.map((item) => {
|
|
||||||
const id = item.musicResponsiveListItemRenderer.navigationEndpoint.browseEndpoint.browseId
|
|
||||||
const name = item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text
|
|
||||||
const profilePicture = extractLargestThumbnailUrl(item.musicResponsiveListItemRenderer.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
|
|
||||||
|
|
||||||
return { connection, id, name, type: 'artist', profilePicture } satisfies Artist
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
public async playlists(): Promise<Playlist[]> {
|
|
||||||
const playlistData = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'FEmusic_liked_playlists' } }).json<InnerTube.Library.PlaylistResponse>()
|
|
||||||
|
|
||||||
const { items, continuations } = playlistData.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].gridRenderer
|
|
||||||
let continuation = continuations?.[0].nextContinuationData.continuation
|
|
||||||
|
|
||||||
while (continuation) {
|
|
||||||
const continuationData = await this.api.v1.WEB_REMIX(`browse?ctoken=${continuation}&continuation=${continuation}`).json<InnerTube.Library.PlaylistContinuationResponse>()
|
|
||||||
|
|
||||||
items.push(...continuationData.continuationContents.gridContinuation.items)
|
|
||||||
continuation = continuationData.continuationContents.gridContinuation.continuations?.[0].nextContinuationData.continuation
|
|
||||||
}
|
|
||||||
|
|
||||||
const playlists = items.filter(
|
|
||||||
(item): item is { musicTwoRowItemRenderer: InnerTube.Library.PlaylistMusicTwoRowItemRenderer } =>
|
|
||||||
'browseEndpoint' in item.musicTwoRowItemRenderer.navigationEndpoint &&
|
|
||||||
item.musicTwoRowItemRenderer.navigationEndpoint.browseEndpoint.browseId !== 'VLLM' &&
|
|
||||||
item.musicTwoRowItemRenderer.navigationEndpoint.browseEndpoint.browseId !== 'VLSE',
|
|
||||||
)
|
|
||||||
|
|
||||||
const connection = { id: this.connectionId, type: 'youtube-music' } satisfies Album['connection']
|
|
||||||
return playlists.map((item) => {
|
|
||||||
const id = item.musicTwoRowItemRenderer.navigationEndpoint.browseEndpoint.browseId.slice(2)
|
|
||||||
const name = item.musicTwoRowItemRenderer.title.runs[0].text
|
|
||||||
const thumbnailUrl = extractLargestThumbnailUrl(item.musicTwoRowItemRenderer.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails)
|
|
||||||
|
|
||||||
let createdBy: Playlist['createdBy']
|
|
||||||
item.musicTwoRowItemRenderer.subtitle.runs.forEach((run) => {
|
|
||||||
if (run.navigationEndpoint && run.navigationEndpoint.browseEndpoint.browseId !== this.youtubeUserId) createdBy = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
|
|
||||||
})
|
|
||||||
|
|
||||||
return { connection, id, name, type: 'playlist', thumbnailUrl, createdBy } satisfies Playlist
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param duration Timestamp in standard ISO8601 format PnDTnHnMnS
|
* @param duration Timestamp in standard ISO8601 format PnDTnHnMnS
|
||||||
* @returns The duration of the timestamp in seconds
|
* @returns The duration of the timestamp in seconds
|
||||||
@@ -741,7 +846,7 @@ function secondsFromISO8601(duration: string): number {
|
|||||||
*/
|
*/
|
||||||
function extractLargestThumbnailUrl(thumbnails: Array<{ url: string; width: number; height: number }>): string {
|
function extractLargestThumbnailUrl(thumbnails: Array<{ url: string; width: number; height: number }>): string {
|
||||||
const bestThumbnailURL = thumbnails.reduce((prev, current) => (prev.width * prev.height > current.width * current.height ? prev : current)).url
|
const bestThumbnailURL = thumbnails.reduce((prev, current) => (prev.width * prev.height > current.width * current.height ? prev : current)).url
|
||||||
if (!URL.canParse(bestThumbnailURL)) throw new Error('Invalid thumbnail url')
|
if (!URL.canParse(bestThumbnailURL)) throw Error('Invalid thumbnail url')
|
||||||
|
|
||||||
switch (new URL(bestThumbnailURL).origin) {
|
switch (new URL(bestThumbnailURL).origin) {
|
||||||
case 'https://lh3.googleusercontent.com':
|
case 'https://lh3.googleusercontent.com':
|
||||||
@@ -752,10 +857,11 @@ function extractLargestThumbnailUrl(thumbnails: Array<{ url: string; width: numb
|
|||||||
return bestThumbnailURL
|
return bestThumbnailURL
|
||||||
case 'https://www.gstatic.com':
|
case 'https://www.gstatic.com':
|
||||||
case 'https://i.ytimg.com':
|
case 'https://i.ytimg.com':
|
||||||
return bestThumbnailURL.slice(0, bestThumbnailURL.indexOf('?'))
|
const queryParamStartIndex = bestThumbnailURL.indexOf('?')
|
||||||
|
return queryParamStartIndex > 0 ? bestThumbnailURL.slice(0, queryParamStartIndex) : bestThumbnailURL
|
||||||
default:
|
default:
|
||||||
console.error('Tried to clean invalid url: ' + bestThumbnailURL)
|
console.error('Tried to clean invalid url: ' + bestThumbnailURL)
|
||||||
throw new Error('Invalid thumbnail url origin')
|
throw Error('Invalid thumbnail url origin')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class Queue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get upNext() {
|
get upNext() {
|
||||||
if (this.currentSongs.length === 0 && this.currentPosition >= this.currentSongs.length) return null
|
if (this.currentSongs.length === 0 || this.currentPosition >= this.currentSongs.length) return null
|
||||||
|
|
||||||
return this.currentSongs[this.currentPosition + 1]
|
return this.currentSongs[this.currentPosition + 1]
|
||||||
}
|
}
|
||||||
@@ -85,6 +85,11 @@ class Queue {
|
|||||||
this.updateQueue()
|
this.updateQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Re-orders the queue if shuffled, shuffles if not */
|
||||||
|
public toggleShuffle() {
|
||||||
|
this.shuffled ? this.reorder() : this.shuffle()
|
||||||
|
}
|
||||||
|
|
||||||
/** Starts the next song */
|
/** Starts the next song */
|
||||||
public next() {
|
public next() {
|
||||||
if (this.currentSongs.length === 0 || this.currentSongs.length <= this.currentPosition + 1) return
|
if (this.currentSongs.length === 0 || this.currentSongs.length <= this.currentPosition + 1) return
|
||||||
|
|||||||
@@ -1,91 +1,38 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import SearchBar from '$lib/components/util/searchBar.svelte'
|
|
||||||
import type { LayoutData } from './$types'
|
|
||||||
import NavTab from '$lib/components/util/navTab.svelte'
|
|
||||||
import MixTab from '$lib/components/util/mixTab.svelte'
|
|
||||||
import MediaPlayer from '$lib/components/media/mediaPlayer.svelte'
|
import MediaPlayer from '$lib/components/media/mediaPlayer.svelte'
|
||||||
import { goto } from '$app/navigation'
|
import Navbar from '$lib/components/util/navbar.svelte'
|
||||||
import IconButton from '$lib/components/util/iconButton.svelte'
|
import Sidebar from '$lib/components/util/sidebar.svelte'
|
||||||
|
import { queue } from '$lib/stores'
|
||||||
export let data: LayoutData
|
|
||||||
|
|
||||||
let mixData = [
|
|
||||||
{
|
|
||||||
name: 'J-Core Mix',
|
|
||||||
color: 'red',
|
|
||||||
id: 'SomeId',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Best of: 葉月ゆら',
|
|
||||||
color: 'purple',
|
|
||||||
id: 'SomeId',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
$: currentPathname = data.url.pathname
|
|
||||||
|
|
||||||
let newMixNameInputOpen = false
|
|
||||||
|
|
||||||
// I'm thinking I might want to make /albums, /artists, and /playlists all there own routes and just wrap them in a (library) layout
|
// I'm thinking I might want to make /albums, /artists, and /playlists all there own routes and just wrap them in a (library) layout
|
||||||
|
let sidebar: Sidebar
|
||||||
|
|
||||||
|
$: currentlyPlaying = $queue.current
|
||||||
|
$: shuffled = $queue.isShuffled
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main id="grid-wrapper" class="h-full">
|
<main id="grid-wrapper" class="h-full">
|
||||||
<nav id="navbar" class="items-center">
|
<Navbar on:opensidebar={sidebar.open} />
|
||||||
<strong class="pl-6 text-3xl">
|
<Sidebar bind:this={sidebar} />
|
||||||
<i class="fa-solid fa-record-vinyl mr-1" />
|
<section id="content-wrapper" class="overflow-y-scroll no-scrollbar">
|
||||||
Lazuli
|
|
||||||
</strong>
|
|
||||||
<SearchBar />
|
|
||||||
<div class="flex h-full justify-end p-4">
|
|
||||||
<IconButton halo={true} on:click={() => goto('/user')}>
|
|
||||||
<i slot="icon" class="fa-solid fa-user text-lg" />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
<section id="sidebar" class="relative pt-4 text-sm font-normal">
|
|
||||||
<div class="mb-10">
|
|
||||||
<NavTab label="Home" icon="fa-solid fa-wave-square" redirect="/" disabled={currentPathname === '/'} />
|
|
||||||
<NavTab label="Playlists" icon="fa-solid fa-bars-staggered" redirect="/playlists" disabled={/^\/playlists.*$/.test(currentPathname)} />
|
|
||||||
<NavTab label="Library" icon="fa-solid fa-book" redirect="/library" disabled={/^\/library.*$/.test(currentPathname)} />
|
|
||||||
</div>
|
|
||||||
<h1 class="mb-1 flex h-5 items-center justify-between pl-6 text-sm text-neutral-400">
|
|
||||||
Your Mixes
|
|
||||||
<IconButton halo={true} on:click={() => (mixData = [{ name: 'New Mix', color: 'grey', id: 'SomeId' }, ...mixData])}>
|
|
||||||
<i slot="icon" class="fa-solid fa-plus" />
|
|
||||||
</IconButton>
|
|
||||||
</h1>
|
|
||||||
<div>
|
|
||||||
{#each mixData as mix}
|
|
||||||
<MixTab {...mix} />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section id="content-wrapper" class="no-scrollbar overflow-x-clip overflow-y-scroll pr-8">
|
|
||||||
<slot />
|
<slot />
|
||||||
</section>
|
</section>
|
||||||
<MediaPlayer />
|
{#if currentlyPlaying}
|
||||||
|
<MediaPlayer
|
||||||
|
mediaItem={currentlyPlaying}
|
||||||
|
{shuffled}
|
||||||
|
mediaSession={'mediaSession' in navigator ? navigator.mediaSession : null}
|
||||||
|
on:stop={() => $queue.clear()}
|
||||||
|
on:next={() => $queue.next()}
|
||||||
|
on:previous={() => $queue.previous()}
|
||||||
|
on:toggleShuffle={() => $queue.toggleShuffle()}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
#grid-wrapper,
|
|
||||||
#navbar {
|
|
||||||
display: grid;
|
|
||||||
column-gap: 3rem;
|
|
||||||
grid-template-columns: 12rem auto 12rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#grid-wrapper {
|
#grid-wrapper {
|
||||||
row-gap: 1rem;
|
display: grid;
|
||||||
grid-template-rows: 4.5rem auto;
|
grid-template-rows: min-content auto;
|
||||||
}
|
|
||||||
|
|
||||||
#navbar {
|
|
||||||
grid-area: 1 / 1 / 2 / 4;
|
|
||||||
}
|
|
||||||
#sidebar {
|
|
||||||
grid-area: 2 / 1 / 3 / 2;
|
|
||||||
}
|
|
||||||
#content-wrapper {
|
|
||||||
grid-area: 2 / 2 / 3 / 4;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,15 +1,68 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ScrollableCardMenu from '$lib/components/media/scrollableCardMenu.svelte'
|
|
||||||
import type { PageData } from './$types'
|
import type { PageData } from './$types'
|
||||||
import Loader from '$lib/components/util/loader.svelte'
|
import Loader from '$lib/components/util/loader.svelte'
|
||||||
|
import MediaCard from '$lib/components/media/mediaCard.svelte'
|
||||||
|
import AlbumCard from '$lib/components/media/albumCard.svelte'
|
||||||
|
|
||||||
export let data: PageData
|
export let data: PageData
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div id="main">
|
<div id="main" class="grid">
|
||||||
{#await data.recommendations}
|
{#await data.recommendations}
|
||||||
<Loader />
|
<Loader />
|
||||||
{:then recommendations}
|
{:then recommendations}
|
||||||
<ScrollableCardMenu header={'Listen Again'} cardDataList={recommendations} />
|
<div id="card-wrapper" class="grid w-full gap-4 justify-self-center px-[5%] pt-8">
|
||||||
|
{#each recommendations.filter((item) => item.type === 'album') as album}
|
||||||
|
<AlbumCard {album} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
{/await}
|
{/await}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@media (min-width: 350px) {
|
||||||
|
#card-wrapper {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 550px) {
|
||||||
|
#card-wrapper {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 750px) {
|
||||||
|
#card-wrapper {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 950px) {
|
||||||
|
#card-wrapper {
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 1150px) {
|
||||||
|
#card-wrapper {
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 1350px) {
|
||||||
|
#card-wrapper {
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 1550px) {
|
||||||
|
#card-wrapper {
|
||||||
|
grid-template-columns: repeat(8, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 2200px) {
|
||||||
|
#card-wrapper {
|
||||||
|
grid-template-columns: repeat(9, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 3000px) {
|
||||||
|
#card-wrapper {
|
||||||
|
grid-template-columns: repeat(10, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation'
|
import { goto } from '$app/navigation'
|
||||||
import { itemDisplayState } from '$lib/stores'
|
import { itemDisplayState } from '$lib/stores'
|
||||||
import type { LayoutData } from './$types.js'
|
|
||||||
import { fade } from 'svelte/transition'
|
import { fade } from 'svelte/transition'
|
||||||
import IconButton from '$lib/components/util/iconButton.svelte'
|
import IconButton from '$lib/components/util/iconButton.svelte'
|
||||||
|
import { page } from '$app/stores'
|
||||||
|
|
||||||
export let data: LayoutData
|
$: currentPathname = $page.url.pathname
|
||||||
|
|
||||||
$: currentPathname = data.url.pathname
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="py-4">
|
<main class="py-4">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import type { PageServerData } from './$types'
|
import type { PageServerData } from './$types'
|
||||||
import { itemDisplayState } from '$lib/stores'
|
import { itemDisplayState } from '$lib/stores'
|
||||||
import Loader from '$lib/components/util/loader.svelte'
|
import Loader from '$lib/components/util/loader.svelte'
|
||||||
import AlbumCard from './albumCard.svelte'
|
import AlbumCard from '$lib/components/media/albumCard.svelte'
|
||||||
import ListItem from '$lib/components/media/listItem.svelte'
|
import ListItem from '$lib/components/media/listItem.svelte'
|
||||||
|
|
||||||
export let data: PageServerData
|
export let data: PageServerData
|
||||||
|
|||||||
@@ -2,13 +2,8 @@ import type { LayoutServerLoad } from './$types'
|
|||||||
|
|
||||||
export const ssr = false
|
export const ssr = false
|
||||||
|
|
||||||
export const load: LayoutServerLoad = ({ url, locals }) => {
|
export const load: LayoutServerLoad = ({ locals }) => {
|
||||||
const { pathname, search } = url
|
|
||||||
return {
|
return {
|
||||||
url: {
|
|
||||||
pathname,
|
|
||||||
search,
|
|
||||||
},
|
|
||||||
user: locals.user,
|
user: locals.user,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,14 @@
|
|||||||
import '../app.css'
|
import '../app.css'
|
||||||
import '@fortawesome/fontawesome-free/css/all.min.css'
|
import '@fortawesome/fontawesome-free/css/all.min.css'
|
||||||
import AlertBox from '$lib/components/util/alertBox.svelte'
|
import AlertBox from '$lib/components/util/alertBox.svelte'
|
||||||
import { newestAlert, backgroundImage, pageWidth } from '$lib/stores'
|
import { newestAlert, backgroundImage } from '$lib/stores'
|
||||||
import { fade } from 'svelte/transition'
|
import { fade } from 'svelte/transition'
|
||||||
|
|
||||||
let alertBox: AlertBox
|
let alertBox: AlertBox
|
||||||
$: if ($newestAlert && alertBox) alertBox.addAlert(...$newestAlert)
|
$: if ($newestAlert && alertBox) alertBox.addAlert(...$newestAlert)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window bind:innerWidth={$pageWidth} />
|
<div class="no-scrollbar relative h-screen overflow-x-clip font-notoSans text-white">
|
||||||
<div class="no-scrollbar relative h-screen font-notoSans text-white">
|
|
||||||
<div class="fixed isolate -z-10 h-full w-screen bg-black">
|
<div class="fixed isolate -z-10 h-full w-screen bg-black">
|
||||||
<div id="background-gradient" class="absolute z-10 h-1/2 w-full bg-cover" />
|
<div id="background-gradient" class="absolute z-10 h-1/2 w-full bg-cover" />
|
||||||
{#key $backgroundImage}
|
{#key $backgroundImage}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const defaultTheme = require('tailwindcss/defaultTheme')
|
const defaultTheme = require('tailwindcss/defaultTheme')
|
||||||
|
const plugin = require('tailwindcss/plugin')
|
||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
@@ -17,5 +18,19 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [
|
||||||
|
plugin(function ({ addUtilities }) {
|
||||||
|
addUtilities({
|
||||||
|
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||||
|
'.no-scrollbar::-webkit-scrollbar': {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
/* Hide scrollbar for IE, Edge and Firefox */
|
||||||
|
'.no-scrollbar': {
|
||||||
|
'-ms-overflow-style': 'none' /* IE and Edge */,
|
||||||
|
'scrollbar-width': 'none' /* Firefox */,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user