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%;
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
::-webkit-scrollbar {
|
||||
width: 20px;
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="overflow-hidden p-3">
|
||||
<div class="overflow-hidden">
|
||||
<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}`)}>
|
||||
<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.
|
||||
|
||||
@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">
|
||||
|
||||
@@ -1,134 +1,115 @@
|
||||
<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 ScrollingText from '$lib/components/util/scrollingText.svelte'
|
||||
import ArtistList from '$lib/components/media/artistList.svelte'
|
||||
import IconButton from '$lib/components/util/iconButton.svelte'
|
||||
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'
|
||||
import { onMount, createEventDispatcher } from 'svelte'
|
||||
import { slide } from 'svelte/transition'
|
||||
|
||||
// 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.
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
$: currentlyPlaying = $queue.current
|
||||
export let mediaItem: Song,
|
||||
shuffled: boolean,
|
||||
mediaSession: MediaSession | null = null
|
||||
|
||||
let expanded = false
|
||||
let loop = false,
|
||||
favorite = false
|
||||
|
||||
let paused = true,
|
||||
loop = false
|
||||
const MAX_VOLUME = 0.5
|
||||
let volume: number, paused: boolean, waiting: boolean
|
||||
|
||||
$: shuffled = $queue.isShuffled
|
||||
onMount(() => {
|
||||
volume = getStoredVolume()
|
||||
|
||||
const maxVolume = 0.5
|
||||
let volume: number
|
||||
if (mediaSession) {
|
||||
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) {
|
||||
seconds = Math.round(seconds)
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
seconds = seconds - hours * 3600
|
||||
seconds -= hours * 3600
|
||||
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')}`
|
||||
}
|
||||
|
||||
$: updateMediaSession(currentlyPlaying)
|
||||
function updateMediaSession(media: Song | null) {
|
||||
if (!('mediaSession' in navigator)) return
|
||||
let seeking = false,
|
||||
currentTime: number = 0,
|
||||
duration: number = 0
|
||||
|
||||
if (!media) {
|
||||
navigator.mediaSession.metadata = null
|
||||
return
|
||||
}
|
||||
let audioElement: HTMLAudioElement, currentTimestamp: string, durationTimestamp: string, progressBarValue: number, progressBar: Slider
|
||||
|
||||
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)
|
||||
$: currentTimestamp = formatTime(seeking ? progressBarValue : currentTime)
|
||||
$: durationTimestamp = formatTime(duration)
|
||||
$: 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>
|
||||
|
||||
{#if currentlyPlaying}
|
||||
<div
|
||||
id="player-wrapper"
|
||||
bind:clientWidth={playerWidth}
|
||||
transition:slide
|
||||
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"
|
||||
style="transition-duration: 400ms;"
|
||||
id="player"
|
||||
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"
|
||||
>
|
||||
{#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-3">
|
||||
<div class="relative h-full w-20 min-w-20 overflow-clip rounded-xl">
|
||||
<LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={`${currentlyPlaying.name} jacket`} objectFit={'cover'} />
|
||||
<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">
|
||||
<div class="relative aspect-square h-full">
|
||||
<img src="/api/remoteImage?url={mediaItem.thumbnailUrl}&maxHeight=96" alt="jacket" class="h-full w-full rounded object-cover" />
|
||||
<div id="jacket-play-button" class:hidden={playerWidth > 650} class="absolute bottom-0 left-0 right-0 top-0 backdrop-brightness-50">
|
||||
<IconButton on:click={() => (paused = !paused)}>
|
||||
<i slot="icon" class="fa-solid {paused ? 'fa-play' : 'fa-pause'} text-2xl text-neutral-200" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<section class="flex flex-grow flex-col justify-center gap-1">
|
||||
<div class="h-6">
|
||||
</div>
|
||||
<div class="flex flex-col justify-center gap-1">
|
||||
<ScrollingText>
|
||||
<div slot="text" class="line-clamp-1 font-medium">{currentlyPlaying.name}</div>
|
||||
<span slot="text" class="line-clamp-1 text-sm font-semibold text-neutral-200">{mediaItem.name}</span>
|
||||
</ScrollingText>
|
||||
<div class="line-clamp-1 text-xs">
|
||||
<ArtistList {mediaItem} />
|
||||
</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()}>
|
||||
<div class="h-8">
|
||||
<IconButton color={'#ec4899'} on:click={() => (favorite = !favorite)}>
|
||||
<i slot="icon" class={favorite ? 'fa-solid fa-heart text-pink-500' : 'fa-regular fa-heart'} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<span class:hidden={playerWidth > 700 || playerWidth < 400} class="ml-auto whitespace-nowrap text-xs">{currentTimestamp} / {durationTimestamp}</span>
|
||||
</div>
|
||||
{#if playerWidth > 700}
|
||||
<!-- Change the flex-grow value to adjust the difference in rate of expansion between the details and controls -->
|
||||
<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;">
|
||||
<IconButton on:click={() => dispatch('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">
|
||||
@@ -140,202 +121,71 @@
|
||||
</IconButton>
|
||||
{/if}
|
||||
</div>
|
||||
<IconButton on:click={() => $queue.clear()}>
|
||||
<IconButton on:click={() => dispatch('stop')}>
|
||||
<i slot="icon" class="fa-solid fa-stop text-xl" />
|
||||
</IconButton>
|
||||
<IconButton on:click={() => $queue.next()}>
|
||||
<IconButton on:click={() => dispatch('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" />
|
||||
{#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={(event) => {
|
||||
currentTimeTimestamp.innerText = formatTime(event.detail.value)
|
||||
seeking = true
|
||||
}}
|
||||
on:seeked={(event) => {
|
||||
currentTime = event.detail.value
|
||||
on:seeking={() => (seeking = true)}
|
||||
on:seeked={() => {
|
||||
currentTime = progressBarValue
|
||||
seeking = false
|
||||
}}
|
||||
/>
|
||||
<span bind:this={durationTimestamp} class="w-16 text-left" />
|
||||
<span>{durationTimestamp}</span>
|
||||
</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())
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<span class="whitespace-nowrap text-xs font-light text-neutral-400">{currentTimestamp} / {durationTimestamp}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<IconButton on:click={() => (shuffled ? $queue.reorder() : $queue.shuffle())}>
|
||||
<i slot="icon" class="fa-solid fa-shuffle {shuffled ? 'text-lazuli-primary' : 'text-white'}" />
|
||||
{/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="fa-solid fa-repeat {loop ? 'text-lazuli-primary' : 'text-white'}" />
|
||||
<i slot="icon" class:text-lazuli-primary={loop} class="fa-solid fa-repeat" />
|
||||
</IconButton>
|
||||
<IconButton on:click={() => (expanded = true)}>
|
||||
<i slot="icon" class="fa-solid fa-chevron-up" />
|
||||
<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>
|
||||
</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 class="mr-2 w-20">
|
||||
<Slider bind:value={volume} max={MAX_VOLUME} on:seeked={() => (volume > 0 ? localStorage.setItem('volume', volume.toString()) : null)} />
|
||||
</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>
|
||||
<i slot="icon" class="fa-solid fa-up-right-and-down-left-from-center" />
|
||||
</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>
|
||||
<i slot="icon" class="fa-solid fa-ellipsis-vertical" />
|
||||
</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}
|
||||
{loop}
|
||||
autoplay
|
||||
src="/api/v1/audio?connection={mediaItem.connection.id}&id={mediaItem.id}"
|
||||
bind:paused
|
||||
bind:volume
|
||||
bind:currentTime
|
||||
bind:duration
|
||||
bind:currentTime
|
||||
bind:this={audioElement}
|
||||
on:ended={() => dispatch('next')}
|
||||
on:waiting={() => (waiting = true)}
|
||||
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>
|
||||
#player-wrapper {
|
||||
filter: drop-shadow(0px 20px 20px #000000);
|
||||
}
|
||||
#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>
|
||||
|
||||
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>
|
||||
|
||||
{#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 w-full items-center p-4 {bgColors[alertType]}">
|
||||
{alertMessage}
|
||||
|
||||
@@ -23,4 +23,4 @@
|
||||
}
|
||||
</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">
|
||||
export let disabled = false
|
||||
export let halo = false
|
||||
export let color = 'var(--lazuli-primary)'
|
||||
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
@@ -11,6 +12,7 @@
|
||||
class:disabled
|
||||
class:halo
|
||||
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')}
|
||||
{disabled}
|
||||
>
|
||||
@@ -25,7 +27,7 @@
|
||||
content: '';
|
||||
width: 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%;
|
||||
transition-property: width height;
|
||||
transition-duration: 200ms;
|
||||
@@ -39,6 +41,6 @@
|
||||
transition: color 200ms;
|
||||
}
|
||||
button:not(.disabled):hover :global(> :first-child) {
|
||||
color: var(--lazuli-primary);
|
||||
color: var(--button-color);
|
||||
}
|
||||
</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
|
||||
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
|
||||
scrolling area with a wrapper element.
|
||||
from one end to the other.
|
||||
|
||||
```tsx
|
||||
<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" />
|
||||
</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>
|
||||
|
||||
<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 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
|
||||
|
||||
@@ -26,7 +28,7 @@
|
||||
|
||||
<div
|
||||
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)"
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
@@ -36,10 +38,10 @@
|
||||
on:keydown={(event) => handleKeyPress(event.key)}
|
||||
>
|
||||
<input
|
||||
on:input={(event) => dispatch('seeking', { value: event.currentTarget.value })}
|
||||
on:change={(event) => dispatch('seeked', { value: event.currentTarget.value })}
|
||||
on:input={(event) => seekingDispatch('seeking', { value: Number(event.currentTarget.value) })}
|
||||
on:change={(event) => seekedDispatch('seeked', { value: Number(event.currentTarget.value) })}
|
||||
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"
|
||||
min="0"
|
||||
{max}
|
||||
@@ -48,8 +50,8 @@
|
||||
aria-hidden="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={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={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 {thickness === 'thick' ? 'h-3.5' : 'h-2.5'} -translate-x-1/2 -translate-y-1/2 rounded-full bg-white opacity-0" />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -103,8 +103,6 @@ class Database {
|
||||
return this.db<Schemas.Songs>('Songs')
|
||||
}
|
||||
|
||||
private exists() {}
|
||||
|
||||
public static async createUsersTable(db: knex.Knex<'better-sqlite3'>) {
|
||||
const exists = await db.schema.hasTable('Users')
|
||||
if (exists) return
|
||||
|
||||
@@ -105,10 +105,10 @@ export class Jellyfin implements Connection {
|
||||
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) {
|
||||
const audoSearchParams = new URLSearchParams({
|
||||
MaxStreamingBitrate: '2000000',
|
||||
MaxStreamingBitrate: '140000000',
|
||||
Container: 'opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg',
|
||||
TranscodingContainer: 'ts',
|
||||
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
|
||||
interface HomeResponse {
|
||||
contents: unknown
|
||||
namespace Home {
|
||||
interface Response {
|
||||
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 youtubeUserId: string
|
||||
|
||||
private readonly api: APIManager
|
||||
private libraryManager?: LibaryManager
|
||||
private readonly api: API
|
||||
private libraryManager?: YouTubeMusicLibrary
|
||||
|
||||
constructor(id: string, userId: string, youtubeUserId: string, accessToken: string, refreshToken: string, expiry: number) {
|
||||
this.id = id
|
||||
this.userId = userId
|
||||
this.youtubeUserId = youtubeUserId
|
||||
|
||||
this.api = new APIManager(id, accessToken, refreshToken, expiry)
|
||||
this.api = new API(id, accessToken, refreshToken, expiry)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -222,7 +222,7 @@ export class YouTubeMusic implements Connection {
|
||||
|
||||
const parseCommunityPlaylistResponsiveListItemRenderer = (item: InnerTube.Search.CommunityPlaylistMusicResponsiveListItemRenderer): Playlist => {
|
||||
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 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))
|
||||
}
|
||||
|
||||
// ! Need to completely rework this method - Currently returns empty array
|
||||
public async getRecommendations() {
|
||||
// const response = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'FEmusic_home' } }).json()
|
||||
// console.log(JSON.stringify(response))
|
||||
return []
|
||||
public async getRecommendations(): Promise<(Song | Album | Artist | Playlist)[]> {
|
||||
const parseAlbumMusicTwoRowItemRenderer = (item: InnerTube.Home.AlbumMusicTwoRowItemRenderer): Album => {
|
||||
const connection = { id: this.id, type: 'youtube-music' } satisfies Album['connection']
|
||||
const id = item.navigationEndpoint.browseEndpoint.browseId
|
||||
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) {
|
||||
@@ -396,10 +504,9 @@ export class YouTubeMusic implements Connection {
|
||||
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 sectionContent = playlistResponse.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0]
|
||||
const header =
|
||||
'musicEditablePlaylistDetailHeaderRenderer' in playlistResponse.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0]
|
||||
? playlistResponse.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicEditablePlaylistDetailHeaderRenderer.header.musicResponsiveHeaderRenderer
|
||||
: playlistResponse.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicResponsiveHeaderRenderer
|
||||
'musicEditablePlaylistDetailHeaderRenderer' in sectionContent ? sectionContent.musicEditablePlaylistDetailHeaderRenderer.header.musicResponsiveHeaderRenderer : sectionContent.musicResponsiveHeaderRenderer
|
||||
|
||||
const connection = { id: this.id, type: 'youtube-music' } satisfies Playlist['connection']
|
||||
const name = header.title.runs[0].text
|
||||
@@ -509,8 +616,107 @@ export class YouTubeMusic implements Connection {
|
||||
}
|
||||
}
|
||||
|
||||
class APIManager {
|
||||
private readonly connectionId: string
|
||||
class YouTubeMusicLibrary {
|
||||
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 readonly refreshToken: string
|
||||
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
|
||||
* @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 {
|
||||
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) {
|
||||
case 'https://lh3.googleusercontent.com':
|
||||
@@ -752,10 +857,11 @@ function extractLargestThumbnailUrl(thumbnails: Array<{ url: string; width: numb
|
||||
return bestThumbnailURL
|
||||
case 'https://www.gstatic.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:
|
||||
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() {
|
||||
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]
|
||||
}
|
||||
@@ -85,6 +85,11 @@ class Queue {
|
||||
this.updateQueue()
|
||||
}
|
||||
|
||||
/** Re-orders the queue if shuffled, shuffles if not */
|
||||
public toggleShuffle() {
|
||||
this.shuffled ? this.reorder() : this.shuffle()
|
||||
}
|
||||
|
||||
/** Starts the next song */
|
||||
public next() {
|
||||
if (this.currentSongs.length === 0 || this.currentSongs.length <= this.currentPosition + 1) return
|
||||
|
||||
@@ -1,91 +1,38 @@
|
||||
<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 { goto } from '$app/navigation'
|
||||
import IconButton from '$lib/components/util/iconButton.svelte'
|
||||
|
||||
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
|
||||
import Navbar from '$lib/components/util/navbar.svelte'
|
||||
import Sidebar from '$lib/components/util/sidebar.svelte'
|
||||
import { queue } from '$lib/stores'
|
||||
|
||||
// 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>
|
||||
|
||||
<main id="grid-wrapper" class="h-full">
|
||||
<nav id="navbar" class="items-center">
|
||||
<strong class="pl-6 text-3xl">
|
||||
<i class="fa-solid fa-record-vinyl mr-1" />
|
||||
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">
|
||||
<Navbar on:opensidebar={sidebar.open} />
|
||||
<Sidebar bind:this={sidebar} />
|
||||
<section id="content-wrapper" class="overflow-y-scroll no-scrollbar">
|
||||
<slot />
|
||||
</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>
|
||||
|
||||
<style>
|
||||
#grid-wrapper,
|
||||
#navbar {
|
||||
display: grid;
|
||||
column-gap: 3rem;
|
||||
grid-template-columns: 12rem auto 12rem;
|
||||
}
|
||||
|
||||
#grid-wrapper {
|
||||
row-gap: 1rem;
|
||||
grid-template-rows: 4.5rem auto;
|
||||
}
|
||||
|
||||
#navbar {
|
||||
grid-area: 1 / 1 / 2 / 4;
|
||||
}
|
||||
#sidebar {
|
||||
grid-area: 2 / 1 / 3 / 2;
|
||||
}
|
||||
#content-wrapper {
|
||||
grid-area: 2 / 2 / 3 / 4;
|
||||
display: grid;
|
||||
grid-template-rows: min-content auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,68 @@
|
||||
<script lang="ts">
|
||||
import ScrollableCardMenu from '$lib/components/media/scrollableCardMenu.svelte'
|
||||
import type { PageData } from './$types'
|
||||
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
|
||||
</script>
|
||||
|
||||
<div id="main">
|
||||
<div id="main" class="grid">
|
||||
{#await data.recommendations}
|
||||
<Loader />
|
||||
{: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}
|
||||
</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">
|
||||
import { goto } from '$app/navigation'
|
||||
import { itemDisplayState } from '$lib/stores'
|
||||
import type { LayoutData } from './$types.js'
|
||||
import { fade } from 'svelte/transition'
|
||||
import IconButton from '$lib/components/util/iconButton.svelte'
|
||||
import { page } from '$app/stores'
|
||||
|
||||
export let data: LayoutData
|
||||
|
||||
$: currentPathname = data.url.pathname
|
||||
$: currentPathname = $page.url.pathname
|
||||
</script>
|
||||
|
||||
<main class="py-4">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { PageServerData } from './$types'
|
||||
import { itemDisplayState } from '$lib/stores'
|
||||
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'
|
||||
|
||||
export let data: PageServerData
|
||||
|
||||
@@ -2,13 +2,8 @@ import type { LayoutServerLoad } from './$types'
|
||||
|
||||
export const ssr = false
|
||||
|
||||
export const load: LayoutServerLoad = ({ url, locals }) => {
|
||||
const { pathname, search } = url
|
||||
export const load: LayoutServerLoad = ({ locals }) => {
|
||||
return {
|
||||
url: {
|
||||
pathname,
|
||||
search,
|
||||
},
|
||||
user: locals.user,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,14 @@
|
||||
import '../app.css'
|
||||
import '@fortawesome/fontawesome-free/css/all.min.css'
|
||||
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'
|
||||
|
||||
let alertBox: AlertBox
|
||||
$: if ($newestAlert && alertBox) alertBox.addAlert(...$newestAlert)
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerWidth={$pageWidth} />
|
||||
<div class="no-scrollbar relative h-screen font-notoSans text-white">
|
||||
<div class="no-scrollbar relative h-screen overflow-x-clip font-notoSans text-white">
|
||||
<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" />
|
||||
{#key $backgroundImage}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const defaultTheme = require('tailwindcss/defaultTheme')
|
||||
const plugin = require('tailwindcss/plugin')
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
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