UI changes (now responsive) && fixed YT recommendations method

This commit is contained in:
Eclypsed
2024-07-18 22:52:08 -04:00
parent 8453e51d3f
commit f10a184284
25 changed files with 1602 additions and 605 deletions

View File

@@ -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;

View File

@@ -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'} />

View File

@@ -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">

View File

@@ -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>&bullet;</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>

View 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>&bullet;</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>

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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',

View File

@@ -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
},
]
}
}
}
}

View File

@@ -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')
}
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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

View File

@@ -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,
}
}

View File

@@ -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}

View File

@@ -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 */,
},
})
}),
],
}