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%; max-height: 100%;
} }
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/* Default scrollbar for Chrome, Safari, Edge and Opera */ /* Default scrollbar for Chrome, Safari, Edge and Opera */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 20px; width: 20px;

View File

@@ -23,7 +23,7 @@
} }
</script> </script>
<div class="overflow-hidden p-3"> <div class="overflow-hidden">
<div id="thumbnail-wrapper" class="relative aspect-square w-full overflow-clip rounded"> <div id="thumbnail-wrapper" class="relative aspect-square w-full overflow-clip rounded">
<button id="thumbnail" class="h-full w-full" on:click={() => goto(`/details/album?id=${album.id}&connection=${album.connection.id}`)}> <button id="thumbnail" class="h-full w-full" on:click={() => goto(`/details/album?id=${album.id}&connection=${album.connection.id}`)}>
<LazyImage thumbnailUrl={album.thumbnailUrl} alt={`${album.name} jacket`} objectFit={'cover'} /> <LazyImage thumbnailUrl={album.thumbnailUrl} alt={`${album.name} jacket`} objectFit={'cover'} />

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. object. Formatting of the text such as font size, weight, and line clamps can be specified in a wrapper element.
@param mediaItem Either a Song, Album, or Playlist object. @param mediaItem Either a Song, Album, or Playlist object.
@param linked Boolean. If true artists will be linked with anchor tags. Defaults to true. @param linked Boolean. If true, artists will be linked with anchor tags. Defaults to true.
--> -->
<script lang="ts"> <script lang="ts">

View File

@@ -1,134 +1,115 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte' import ScrollingText from '$lib/components/util/scrollingText.svelte'
import { fade, slide, fly } from 'svelte/transition' import ArtistList from '$lib/components/media/artistList.svelte'
import { queue } from '$lib/stores' import IconButton from '$lib/components/util/iconButton.svelte'
import Services from '$lib/services.json'
// import { FastAverageColor } from 'fast-average-color'
import Slider from '$lib/components/util/slider.svelte' import Slider from '$lib/components/util/slider.svelte'
import Loader from '$lib/components/util/loader.svelte' import Loader from '$lib/components/util/loader.svelte'
import LazyImage from './lazyImage.svelte' import { onMount, createEventDispatcher } from 'svelte'
import IconButton from '$lib/components/util/iconButton.svelte' import { slide } from 'svelte/transition'
import ScrollingText from '$lib/components/util/scrollingText.svelte'
import ArtistList from './artistList.svelte'
import ServiceLogo from '$lib/components/util/serviceLogo.svelte'
// NEW IDEA: Only have the miniplayer for controls and for the expanded view just make it one large Videoplayer. const dispatch = createEventDispatcher()
// That way we can target the player to be the size of YouTube's default player. Then move the Queue view to it's own
// dedicated sidebar like in spotify.
$: currentlyPlaying = $queue.current export let mediaItem: Song,
shuffled: boolean,
mediaSession: MediaSession | null = null
let expanded = false let loop = false,
favorite = false
let paused = true, const MAX_VOLUME = 0.5
loop = false let volume: number, paused: boolean, waiting: boolean
$: shuffled = $queue.isShuffled onMount(() => {
volume = getStoredVolume()
const maxVolume = 0.5 if (mediaSession) {
let volume: number mediaSession.setActionHandler('play', () => (paused = false))
mediaSession.setActionHandler('pause', () => (paused = true))
mediaSession.setActionHandler('stop', () => dispatch('stop'))
mediaSession.setActionHandler('nexttrack', () => dispatch('next'))
mediaSession.setActionHandler('previoustrack', () => dispatch('previous'))
}
})
let waiting: boolean function getStoredVolume(): number {
const storedVolume = Number.parseFloat(localStorage.getItem('volume') ?? '-1')
if (storedVolume >= 0 && storedVolume <= MAX_VOLUME) return storedVolume
const defaultVolume = MAX_VOLUME / 2
localStorage.setItem('volume', defaultVolume.toString())
return defaultVolume
}
$: if (mediaSession) updateMediaSession(mediaItem, mediaSession)
function updateMediaSession(media: Song, mediaSession: MediaSession) {
const mediaImage = (size: number): MediaImage => ({ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=${size}`, sizes: `${size}x${size}` })
const title = media.name
const artist = media.artists?.map((artist) => artist.name).join(', ') ?? media.uploader?.name
const album = media.album?.name
const artwork: MediaImage[] = [mediaImage(96), mediaImage(128), mediaImage(192), mediaImage(256), mediaImage(384), mediaImage(512)]
mediaSession.metadata = new MediaMetadata({ title, artist, album, artwork })
}
$: paused && mediaSession ? (mediaSession.playbackState = 'paused') : mediaSession ? (mediaSession.playbackState = 'playing') : null
function formatTime(seconds: number) { function formatTime(seconds: number) {
seconds = Math.round(seconds) seconds = Math.round(seconds)
const hours = Math.floor(seconds / 3600) const hours = Math.floor(seconds / 3600)
seconds = seconds - hours * 3600 seconds -= hours * 3600
const minutes = Math.floor(seconds / 60) const minutes = Math.floor(seconds / 60)
seconds = seconds - minutes * 60 seconds -= minutes * 60
return hours > 0 ? `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` : `${minutes}:${seconds.toString().padStart(2, '0')}` return hours > 0 ? `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` : `${minutes}:${seconds.toString().padStart(2, '0')}`
} }
$: updateMediaSession(currentlyPlaying) let seeking = false,
function updateMediaSession(media: Song | null) { currentTime: number = 0,
if (!('mediaSession' in navigator)) return duration: number = 0
if (!media) { let audioElement: HTMLAudioElement, currentTimestamp: string, durationTimestamp: string, progressBarValue: number, progressBar: Slider
navigator.mediaSession.metadata = null
return
}
navigator.mediaSession.metadata = new MediaMetadata({ $: currentTimestamp = formatTime(seeking ? progressBarValue : currentTime)
title: media.name, $: durationTimestamp = formatTime(duration)
artist: media.artists?.map((artist) => artist.name).join(', ') ?? media.uploader?.name,
album: media.album?.name,
artwork: [
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=96`, sizes: '96x96' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=128`, sizes: '128x128' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=192`, sizes: '192x192' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=256`, sizes: '256x256' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=384`, sizes: '384x384' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=512`, sizes: '512x512' },
],
})
}
onMount(() => {
const storedVolume = Number(localStorage.getItem('volume'))
if (storedVolume >= 0 && storedVolume <= maxVolume) {
volume = storedVolume
} else {
localStorage.setItem('volume', (maxVolume / 2).toString())
volume = maxVolume / 2
}
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', () => (paused = false))
navigator.mediaSession.setActionHandler('pause', () => (paused = true))
navigator.mediaSession.setActionHandler('stop', () => $queue.clear())
navigator.mediaSession.setActionHandler('nexttrack', () => $queue.next())
navigator.mediaSession.setActionHandler('previoustrack', () => $queue.previous())
}
})
let currentTime: number = 0
let duration: number = 0
let currentTimeTimestamp: HTMLSpanElement
let progressBar: Slider
let durationTimestamp: HTMLSpanElement
let expandedCurrentTimeTimestamp: HTMLSpanElement
let expandedProgressBar: Slider
let expandedDurationTimestamp: HTMLSpanElement
let seeking: boolean = false
$: if (!seeking && currentTimeTimestamp) currentTimeTimestamp.innerText = formatTime(currentTime)
$: if (!seeking && progressBar) progressBar.$set({ value: currentTime }) $: if (!seeking && progressBar) progressBar.$set({ value: currentTime })
$: if (!seeking && durationTimestamp) durationTimestamp.innerText = formatTime(duration)
$: if (!seeking && expandedCurrentTimeTimestamp) expandedCurrentTimeTimestamp.innerText = formatTime(currentTime)
$: if (!seeking && expandedProgressBar) expandedProgressBar.$set({ value: currentTime })
$: if (!seeking && expandedDurationTimestamp) expandedDurationTimestamp.innerText = formatTime(duration)
let audioElement: HTMLAudioElement let playerWidth: number
</script> </script>
{#if currentlyPlaying}
<div <div
id="player-wrapper" bind:clientWidth={playerWidth}
transition:slide 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" id="player"
style="transition-duration: 400ms;" 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} <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">
<main in:fade={{ duration: 75, delay: 500 }} out:fade={{ duration: 75 }} class="flex h-20 w-full gap-10"> <div class="relative aspect-square h-full">
<section class="flex w-96 min-w-64 gap-3"> <img src="/api/remoteImage?url={mediaItem.thumbnailUrl}&maxHeight=96" alt="jacket" class="h-full w-full rounded object-cover" />
<div class="relative h-full w-20 min-w-20 overflow-clip rounded-xl"> <div id="jacket-play-button" class:hidden={playerWidth > 650} class="absolute bottom-0 left-0 right-0 top-0 backdrop-brightness-50">
<LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={`${currentlyPlaying.name} jacket`} objectFit={'cover'} /> <IconButton on:click={() => (paused = !paused)}>
<i slot="icon" class="fa-solid {paused ? 'fa-play' : 'fa-pause'} text-2xl text-neutral-200" />
</IconButton>
</div> </div>
<section class="flex flex-grow flex-col justify-center gap-1"> </div>
<div class="h-6"> <div class="flex flex-col justify-center gap-1">
<ScrollingText> <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> </ScrollingText>
<div class="line-clamp-1 text-xs">
<ArtistList {mediaItem} />
</div> </div>
<div class="line-clamp-1 text-xs font-extralight">
<ArtistList mediaItem={currentlyPlaying} />
</div> </div>
</section> <div class="h-8">
</section> <IconButton color={'#ec4899'} on:click={() => (favorite = !favorite)}>
<section class="flex flex-grow items-center gap-1 py-4"> <i slot="icon" class={favorite ? 'fa-solid fa-heart text-pink-500' : 'fa-regular fa-heart'} />
<IconButton on:click={() => $queue.previous()}> </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" /> <i slot="icon" class="fa-solid fa-backward-step text-xl" />
</IconButton> </IconButton>
<div class="relative aspect-square h-full rounded-full border border-neutral-700"> <div class="relative aspect-square h-full rounded-full border border-neutral-700">
@@ -140,202 +121,71 @@
</IconButton> </IconButton>
{/if} {/if}
</div> </div>
<IconButton on:click={() => $queue.clear()}> <IconButton on:click={() => dispatch('stop')}>
<i slot="icon" class="fa-solid fa-stop text-xl" /> <i slot="icon" class="fa-solid fa-stop text-xl" />
</IconButton> </IconButton>
<IconButton on:click={() => $queue.next()}> <IconButton on:click={() => dispatch('next')}>
<i slot="icon" class="fa-solid fa-forward-step text-xl" /> <i slot="icon" class="fa-solid fa-forward-step text-xl" />
</IconButton> </IconButton>
<div class="flex min-w-56 flex-grow items-center justify-items-center gap-3 font-light"> {#if playerWidth > 800}
<span bind:this={currentTimeTimestamp} class="w-16 text-right" /> <div class="flex flex-grow items-center justify-items-center gap-3 text-xs font-light">
<span>{currentTimestamp}</span>
<Slider <Slider
bind:this={progressBar} bind:this={progressBar}
bind:value={progressBarValue}
max={duration} max={duration}
on:seeking={(event) => { on:seeking={() => (seeking = true)}
currentTimeTimestamp.innerText = formatTime(event.detail.value) on:seeked={() => {
seeking = true currentTime = progressBarValue
}}
on:seeked={(event) => {
currentTime = event.detail.value
seeking = false seeking = false
}} }}
/> />
<span bind:this={durationTimestamp} class="w-16 text-left" /> <span>{durationTimestamp}</span>
</div> </div>
</section> {:else}
<section class="flex items-center justify-end gap-2.5 py-6 pr-8 text-lg"> <span class="whitespace-nowrap text-xs font-light text-neutral-400">{currentTimestamp} / {durationTimestamp}</span>
<div class="mx-4 flex h-10 w-40 items-center gap-3"> {/if}
<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> </div>
<IconButton on:click={() => (shuffled ? $queue.reorder() : $queue.shuffle())}> {/if}
<i slot="icon" class="fa-solid fa-shuffle {shuffled ? 'text-lazuli-primary' : 'text-white'}" /> {#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>
<IconButton on:click={() => (loop = !loop)}> <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>
<IconButton on:click={() => (expanded = true)}> <div class="flex h-full items-center gap-1">
<i slot="icon" class="fa-solid fa-chevron-up" /> <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> </IconButton>
</section> <div class="mr-2 w-20">
</main> <Slider bind:value={volume} max={MAX_VOLUME} on:seeked={() => (volume > 0 ? localStorage.setItem('volume', volume.toString()) : null)} />
{: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>
</div> </div>
<section> <IconButton>
{#if $queue.upNext} <i slot="icon" class="fa-solid fa-up-right-and-down-left-from-center" />
{@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> </IconButton>
{/if} {/if}
</div> <IconButton>
<IconButton on:click={() => $queue.clear()}> <i slot="icon" class="fa-solid fa-ellipsis-vertical" />
<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> </IconButton>
</div> </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} {/if}
<audio <audio
bind:this={audioElement} {loop}
autoplay autoplay
src="/api/v1/audio?connection={mediaItem.connection.id}&id={mediaItem.id}"
bind:paused bind:paused
bind:volume bind:volume
bind:currentTime
bind:duration bind:duration
bind:currentTime
bind:this={audioElement}
on:ended={() => dispatch('next')}
on:waiting={() => (waiting = true)}
on:canplay={() => (waiting = false)} on:canplay={() => (waiting = false)}
on:loadstart={() => (waiting = true)} on:loadstart={() => (waiting = true)}
on:waiting={() => (waiting = true)}
on:ended={() => $queue.next()}
on:error={() => setTimeout(() => audioElement.load(), 5000)} on:error={() => setTimeout(() => audioElement.load(), 5000)}
src="/api/v1/audio?connection={currentlyPlaying.connection.id}&id={currentlyPlaying.id}"
{loop}
/> />
</div> </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> </script>
{#if show} {#if show}
<div in:fly={{ x: 500 }} out:slide={{ axis: 'y' }} class="py-1"> <div in:fly={{ x: 500 }} out:slide={{ axis: 'y' }} class="m-2">
<div class="flex gap-1 overflow-hidden rounded-md"> <div class="flex gap-1 overflow-hidden rounded-md">
<div class="flex w-full items-center p-4 {bgColors[alertType]}"> <div class="flex w-full items-center p-4 {bgColors[alertType]}">
{alertMessage} {alertMessage}

View File

@@ -23,4 +23,4 @@
} }
</script> </script>
<div bind:this={alertBox} class="fixed right-0 top-0 z-50 max-h-screen w-full max-w-sm overflow-hidden p-4"></div> <div bind:this={alertBox} class="fixed right-0 top-0 z-50 max-h-screen w-full max-w-sm overflow-hidden"></div>

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
export let disabled = false export let disabled = false
export let halo = false export let halo = false
export let color = 'var(--lazuli-primary)'
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
@@ -11,6 +12,7 @@
class:disabled class:disabled
class:halo class:halo
class="relative grid aspect-square h-full place-items-center transition-transform duration-75 active:scale-90" class="relative grid aspect-square h-full place-items-center transition-transform duration-75 active:scale-90"
style="--button-color: {color}"
on:click|preventDefault|stopPropagation={() => dispatch('click')} on:click|preventDefault|stopPropagation={() => dispatch('click')}
{disabled} {disabled}
> >
@@ -25,7 +27,7 @@
content: ''; content: '';
width: 0; width: 0;
height: 0; height: 0;
background-color: color-mix(in srgb, var(--lazuli-primary) 20%, transparent); background-color: color-mix(in srgb, var(--button-color) 20%, transparent);
border-radius: 100%; border-radius: 100%;
transition-property: width height; transition-property: width height;
transition-duration: 200ms; transition-duration: 200ms;
@@ -39,6 +41,6 @@
transition: color 200ms; transition: color 200ms;
} }
button:not(.disabled):hover :global(> :first-child) { button:not(.disabled):hover :global(> :first-child) {
color: var(--lazuli-primary); color: var(--button-color);
} }
</style> </style>

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 @component
A component that can be injected with a text to display it in a single line that clips if overflowing and intermittently scrolls A component that can be injected with a text to display it in a single line that clips if overflowing and intermittently scrolls
from one end to the other. The scrolling text area is set to take up the maximum width and height that it can. Constrain the available from one end to the other.
scrolling area with a wrapper element.
```tsx ```tsx
<slot name="text" /> // An HTML element to wrap and style the text you want to scroll (e.g. div, spans, strongs) <slot name="text" /> // An HTML element to wrap and style the text you want to scroll (e.g. div, spans, strongs)
@@ -27,6 +26,10 @@
> >
<slot name="text" /> <slot name="text" />
</span> </span>
<!-- This is so the wrapper can calculate how big it should be based on the text -->
<span class="pointer-events-none line-clamp-1 opacity-0">
<slot name="text" />
</span>
</div> </div>
<style> <style>

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 value = 0
export let max = 100 export let max = 100
export let thickness: 'thick' | 'thin' = 'thick'
const dispatch = createEventDispatcher() const seekingDispatch = createEventDispatcher<{ seeking: { value: number } }>()
const seekedDispatch = createEventDispatcher<{ seeked: { value: number } }>()
let sliderThumb: HTMLSpanElement, sliderTrail: HTMLSpanElement let sliderThumb: HTMLSpanElement, sliderTrail: HTMLSpanElement
@@ -26,7 +28,7 @@
<div <div
id="slider-track" id="slider-track"
class="relative isolate h-1 w-full rounded bg-neutral-600" class="relative isolate {thickness === 'thick' ? 'h-1' : 'h-0.5'} w-full rounded bg-neutral-800"
style="--slider-color: var(--lazuli-primary)" style="--slider-color: var(--lazuli-primary)"
role="slider" role="slider"
tabindex="0" tabindex="0"
@@ -36,10 +38,10 @@
on:keydown={(event) => handleKeyPress(event.key)} on:keydown={(event) => handleKeyPress(event.key)}
> >
<input <input
on:input={(event) => dispatch('seeking', { value: event.currentTarget.value })} on:input={(event) => seekingDispatch('seeking', { value: Number(event.currentTarget.value) })}
on:change={(event) => dispatch('seeked', { value: event.currentTarget.value })} on:change={(event) => seekedDispatch('seeked', { value: Number(event.currentTarget.value) })}
type="range" type="range"
class="absolute z-10 h-1 w-full" class="absolute z-10 {thickness === 'thick' ? 'h-1' : 'h-0.5'} w-full"
step="any" step="any"
min="0" min="0"
{max} {max}
@@ -48,8 +50,8 @@
aria-hidden="true" aria-hidden="true"
aria-disabled="true" aria-disabled="true"
/> />
<span bind:this={sliderTrail} id="slider-trail" class="absolute left-0 h-1 rounded-full bg-white transition-colors" /> <span bind:this={sliderTrail} id="slider-trail" class="absolute left-0 {thickness === 'thick' ? 'h-1' : 'h-0.5'} rounded-full bg-white transition-colors" />
<span bind:this={sliderThumb} id="slider-thumb" class="absolute top-1/2 aspect-square h-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white opacity-0 transition-opacity duration-300" /> <span bind:this={sliderThumb} id="slider-thumb" class="absolute top-1/2 aspect-square {thickness === 'thick' ? 'h-3.5' : 'h-2.5'} -translate-x-1/2 -translate-y-1/2 rounded-full bg-white opacity-0" />
</div> </div>
<style> <style>

View File

@@ -103,8 +103,6 @@ class Database {
return this.db<Schemas.Songs>('Songs') return this.db<Schemas.Songs>('Songs')
} }
private exists() {}
public static async createUsersTable(db: knex.Knex<'better-sqlite3'>) { public static async createUsersTable(db: knex.Knex<'better-sqlite3'>) {
const exists = await db.schema.hasTable('Users') const exists = await db.schema.hasTable('Users')
if (exists) return if (exists) return

View File

@@ -105,10 +105,10 @@ export class Jellyfin implements Connection {
return mostPlayedResponse.Items.map(this.parsers.parseSong) return mostPlayedResponse.Items.map(this.parsers.parseSong)
} }
// TODO: Figure out why seeking a jellyfin song takes so much longer than ytmusic (hls?) // ! OK apparently some just don't work at all sometimes?
public async getAudioStream(id: string, headers: Headers) { public async getAudioStream(id: string, headers: Headers) {
const audoSearchParams = new URLSearchParams({ const audoSearchParams = new URLSearchParams({
MaxStreamingBitrate: '2000000', MaxStreamingBitrate: '140000000',
Container: 'opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg', Container: 'opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg',
TranscodingContainer: 'ts', TranscodingContainer: 'ts',
TranscodingProtocol: 'hls', TranscodingProtocol: 'hls',

View File

@@ -1642,9 +1642,587 @@ export namespace InnerTube {
} }
} }
// TODO: Need to fix this & it's corresponding method & add appropriate namespace namespace Home {
interface HomeResponse { interface Response {
contents: unknown contents: {
singleColumnBrowseResultsRenderer: {
tabs: [
{
tabRenderer: {
content: {
sectionListRenderer: {
contents: Array<{
musicCarouselShelfRenderer: MusicCarouselShelfRenderer
}>
continuations?: [
{
nextContinuationData: {
continuation: string
}
},
]
}
}
}
},
]
}
}
}
interface ContinuationResponse {
continuationContents: {
sectionListContinuation: {
contents: Array<{
musicCarouselShelfRenderer: MusicCarouselShelfRenderer
}>
continuations?: [
{
nextContinuationData: {
continuation: string
}
},
]
}
}
}
interface MusicCarouselShelfRenderer {
header: {
musicCarouselShelfBasicHeaderRenderer: {
title: {
runs: [
{
text: string // Can be just about anything but some consistent ones are: Listen again, New releases, From your library, etc.
},
]
}
}
}
contents:
| Array<{
musicTwoRowItemRenderer:
| SongMusicTwoRowItemRenderer
| VideoMusicTwoRowItemRenderer
| AlbumMusicTwoRowItemRenderer
| ArtistMusicTwoRowItemRenderer
| PlaylistMusicTwoRowItemRenderer
| ShowMusicTwoRowItemRenderer
}>
| Array<{
musicResponsiveListItemRenderer: SongMusicResponsiveListItemRenderer | VideoMusicResponsiveListItemRenderer
}>
| Array<{
musicMultiRowListItemRenderer: MusicMultiRowListItemRenderer
}>
}
interface SongMusicTwoRowItemRenderer {
thumbnailRenderer: {
musicThumbnailRenderer: {
thumbnail: {
thumbnails: Array<{
url: string
width: number
height: number
}>
}
}
}
title: {
runs: [
{
text: string // Item Name
},
]
}
subtitle: {
runs: Array<{
text: string
navigationEndpoint?: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_ARTIST'
}
}
}
}
}>
}
navigationEndpoint: {
watchEndpoint: {
videoId: string
playlistId: string
watchEndpointMusicSupportedConfigs: {
watchEndpointMusicConfig: {
musicVideoType: 'MUSIC_VIDEO_TYPE_ATV'
}
}
}
}
}
interface VideoMusicTwoRowItemRenderer {
thumbnailRenderer: {
musicThumbnailRenderer: {
thumbnail: {
thumbnails: Array<{
url: string
width: number
height: number
}>
}
}
}
title: {
runs: [
{
text: string // Item Name
},
]
}
subtitle: {
runs: Array<{
text: string
navigationEndpoint?: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_ARTIST' | 'MUSIC_PAGE_TYPE_USER_CHANNEL'
}
}
}
}
}>
}
navigationEndpoint: {
watchEndpoint: {
videoId: string
playlistId: string
watchEndpointMusicSupportedConfigs: {
watchEndpointMusicConfig: {
musicVideoType: 'MUSIC_VIDEO_TYPE_OMV' | 'MUSIC_VIDEO_TYPE_UGC' | 'MUSIC_VIDEO_TYPE_OFFICIAL_SOURCE_MUSIC'
}
}
}
}
}
interface AlbumMusicTwoRowItemRenderer {
thumbnailRenderer: {
musicThumbnailRenderer: {
thumbnail: {
thumbnails: Array<{
url: string
width: number
height: number
}>
}
}
}
title: {
runs: [
{
text: string // Item Name
navigationEndpoint: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_ALBUM'
}
}
}
}
},
]
}
subtitle: {
runs: Array<{
text: string
navigationEndpoint?: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_ARTIST'
}
}
}
}
}>
}
navigationEndpoint: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_ALBUM'
}
}
}
}
}
interface ArtistMusicTwoRowItemRenderer {
thumbnailRenderer: {
musicThumbnailRenderer: {
thumbnail: {
thumbnails: Array<{
url: string
width: number
height: number
}>
}
}
}
title: {
runs: [
{
text: string // Artist name
navigationEndpoint: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_ARTIST'
}
}
}
}
},
]
}
subtitle: {
runs: [
{
text: string // Number of subscribers
},
]
}
navigationEndpoint: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_ARTIST'
}
}
}
}
}
interface PlaylistMusicTwoRowItemRenderer {
thumbnailRenderer: {
musicThumbnailRenderer: {
thumbnail: {
thumbnails: Array<{
url: string
width: number
height: number
}>
}
}
}
title: {
runs: [
{
text: string
navigationEndpoint: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_PLAYLIST'
}
}
}
}
},
]
}
subtitle: {
runs: Array<{
text: string
navigationEndpoint?: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_USER_CHANNEL'
}
}
}
}
}>
}
navigationEndpoint: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_PLAYLIST'
}
}
}
}
}
interface ShowMusicTwoRowItemRenderer {
thumbnailRenderer: {
musicThumbnailRenderer: {
thumbnail: {
thumbnails: Array<{
url: string
width: number
height: number
}>
}
}
}
title: {
runs: [
{
text: string
navigationEndpoint: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_PODCAST_SHOW_DETAIL_PAGE'
}
}
}
}
},
]
}
subtitle: {
runs: [
{
text: string
navigationEndpoint: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_USER_CHANNEL'
}
}
}
}
},
]
}
navigationEndpoint: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_PODCAST_SHOW_DETAIL_PAGE'
}
}
}
}
}
interface SongMusicResponsiveListItemRenderer {
thumbnail: {
musicThumbnailRenderer: {
thumbnail: {
thumbnails: Array<{
url: string
width: number
height: number
}>
}
}
}
flexColumns: [
{
musicResponsiveListItemFlexColumnRenderer: {
text: {
runs: [
{
text: string
navigationEndpoint: {
watchEndpoint: {
videoId: string
playlistId: string
watchEndpointMusicSupportedConfigs: {
watchEndpointMusicConfig: {
musicVideoType: 'MUSIC_VIDEO_TYPE_ATV'
}
}
}
}
},
]
}
}
},
{
musicResponsiveListItemFlexColumnRenderer: {
text: {
runs: Array<{
text: string
navigationEndpoint?: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_ARTIST'
}
}
}
}
}>
}
}
},
{
musicResponsiveListItemFlexColumnRenderer: {
text: {
runs: [
{
text: string
navigationEndpoint: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_ALBUM'
}
}
}
}
},
]
}
}
},
]
}
interface VideoMusicResponsiveListItemRenderer {
thumbnail: {
musicThumbnailRenderer: {
thumbnail: {
thumbnails: Array<{
url: string
width: number
height: number
}>
}
}
}
flexColumns: [
{
musicResponsiveListItemFlexColumnRenderer: {
text: {
runs: [
{
text: string
navigationEndpoint: {
watchEndpoint: {
videoId: string
playlistId: string
watchEndpointMusicSupportedConfigs: {
watchEndpointMusicConfig: {
musicVideoType: 'MUSIC_VIDEO_TYPE_OMV' | 'MUSIC_VIDEO_TYPE_UGC' | 'MUSIC_VIDEO_TYPE_OFFICIAL_SOURCE_MUSIC'
}
}
}
}
},
]
}
}
},
{
musicResponsiveListItemFlexColumnRenderer: {
text: {
runs: Array<{
text: string
navigationEndpoint?: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_USER_CHANNEL'
}
}
}
}
}>
}
}
},
]
}
interface MusicMultiRowListItemRenderer {
thumbnail: {
musicThumbnailRenderer: {
thumbnail: {
thumbnails: Array<{
url: string
width: number
height: number
}>
}
}
}
title: {
runs: [
{
text: string
navigationEndpoint: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_NON_MUSIC_AUDIO_TRACK_PAGE'
}
}
}
}
},
]
}
secondTitle: {
runs: [
{
text: string
navigationEndpoint: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_PODCAST_SHOW_DETAIL_PAGE'
}
}
}
}
},
]
}
subtitle: {
runs: Array<{
text: string
}>
}
description: {
runs: [
{
text: string
},
]
}
}
} }
} }

View File

@@ -9,19 +9,19 @@ export class YouTubeMusic implements Connection {
private readonly userId: string private readonly userId: string
private readonly youtubeUserId: string private readonly youtubeUserId: string
private readonly api: APIManager private readonly api: API
private libraryManager?: LibaryManager private libraryManager?: YouTubeMusicLibrary
constructor(id: string, userId: string, youtubeUserId: string, accessToken: string, refreshToken: string, expiry: number) { constructor(id: string, userId: string, youtubeUserId: string, accessToken: string, refreshToken: string, expiry: number) {
this.id = id this.id = id
this.userId = userId this.userId = userId
this.youtubeUserId = youtubeUserId this.youtubeUserId = youtubeUserId
this.api = new APIManager(id, accessToken, refreshToken, expiry) this.api = new API(id, accessToken, refreshToken, expiry)
} }
public get library() { public get library() {
if (!this.libraryManager) this.libraryManager = new LibaryManager(this.id, this.youtubeUserId, this.api) if (!this.libraryManager) this.libraryManager = new YouTubeMusicLibrary(this.api)
return this.libraryManager return this.libraryManager
} }
@@ -222,7 +222,7 @@ export class YouTubeMusic implements Connection {
const parseCommunityPlaylistResponsiveListItemRenderer = (item: InnerTube.Search.CommunityPlaylistMusicResponsiveListItemRenderer): Playlist => { const parseCommunityPlaylistResponsiveListItemRenderer = (item: InnerTube.Search.CommunityPlaylistMusicResponsiveListItemRenderer): Playlist => {
const connection = { id: this.id, type: 'youtube-music' } satisfies Playlist['connection'] const connection = { id: this.id, type: 'youtube-music' } satisfies Playlist['connection']
const id = item.navigationEndpoint.browseEndpoint.browseId const id = item.navigationEndpoint.browseEndpoint.browseId.slice(2)
const name = item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text const name = item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text
const thumbnailUrl = extractLargestThumbnailUrl(item.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails) const thumbnailUrl = extractLargestThumbnailUrl(item.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
@@ -298,11 +298,119 @@ export class YouTubeMusic implements Connection {
return extractedItems.filter((item): item is MediaItemTypeMap[T] => types.has(item.type as T)) return extractedItems.filter((item): item is MediaItemTypeMap[T] => types.has(item.type as T))
} }
// ! Need to completely rework this method - Currently returns empty array public async getRecommendations(): Promise<(Song | Album | Artist | Playlist)[]> {
public async getRecommendations() { const parseAlbumMusicTwoRowItemRenderer = (item: InnerTube.Home.AlbumMusicTwoRowItemRenderer): Album => {
// const response = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'FEmusic_home' } }).json() const connection = { id: this.id, type: 'youtube-music' } satisfies Album['connection']
// console.log(JSON.stringify(response)) const id = item.navigationEndpoint.browseEndpoint.browseId
return [] const name = item.title.runs[0].text
const thumbnailUrl = extractLargestThumbnailUrl(item.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails)
let artists: Album['artists'] = 'Various Artists'
item.subtitle.runs.forEach((run) => {
if (!run.navigationEndpoint) return
const artistData = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
typeof artists === 'string' ? (artists = [artistData]) : artists.push(artistData)
})
return { connection, id, name, type: 'album', thumbnailUrl, artists }
}
const parseArtistMusicTwoRowItemRenderer = (item: InnerTube.Home.ArtistMusicTwoRowItemRenderer): Artist => {
const connection = { id: this.id, type: 'youtube-music' } satisfies Artist['connection']
const id = item.navigationEndpoint.browseEndpoint.browseId
const name = item.title.runs[0].text
const profilePicture = extractLargestThumbnailUrl(item.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails)
return { connection, id, name, type: 'artist', profilePicture }
}
const parsePlaylistMusicTwoRowItemRenderer = (item: InnerTube.Home.PlaylistMusicTwoRowItemRenderer): Playlist => {
const connection = { id: this.id, type: 'youtube-music' } satisfies Artist['connection']
const id = item.navigationEndpoint.browseEndpoint.browseId.slice(2)
const name = item.title.runs[0].text
const thumbnailUrl = extractLargestThumbnailUrl(item.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails)
let createdBy: Playlist['createdBy']
item.subtitle.runs.forEach((run) => {
if (!run.navigationEndpoint) return
createdBy = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
})
return { connection, id, name, type: 'playlist', thumbnailUrl, createdBy }
}
// Returns the ids of songs in place of Song objects becasue full details need to be fetched with getSongs()
const parseMusicCarouselShelfRenderer = (carousel: InnerTube.Home.MusicCarouselShelfRenderer): (string | Album | Artist | Playlist)[] => {
const results: (string | Album | Artist | Playlist)[] = []
for (const item of carousel.contents) {
if ('musicMultiRowListItemRenderer' in item) continue
if ('musicResponsiveListItemRenderer' in item) {
results.push(item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.videoId)
continue
}
const pageType =
'watchEndpoint' in item.musicTwoRowItemRenderer.navigationEndpoint
? item.musicTwoRowItemRenderer.navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType
: item.musicTwoRowItemRenderer.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
switch (pageType) {
case 'MUSIC_VIDEO_TYPE_ATV':
case 'MUSIC_VIDEO_TYPE_OMV':
case 'MUSIC_VIDEO_TYPE_UGC':
case 'MUSIC_VIDEO_TYPE_OFFICIAL_SOURCE_MUSIC':
const songItem = item.musicTwoRowItemRenderer as InnerTube.Home.SongMusicTwoRowItemRenderer | InnerTube.Home.VideoMusicTwoRowItemRenderer
results.push(songItem.navigationEndpoint.watchEndpoint.videoId)
break
case 'MUSIC_PAGE_TYPE_ALBUM':
const albumItem = item.musicTwoRowItemRenderer as InnerTube.Home.AlbumMusicTwoRowItemRenderer
results.push(parseAlbumMusicTwoRowItemRenderer(albumItem))
break
case 'MUSIC_PAGE_TYPE_ARTIST':
const artistItem = item.musicTwoRowItemRenderer as InnerTube.Home.ArtistMusicTwoRowItemRenderer
results.push(parseArtistMusicTwoRowItemRenderer(artistItem))
break
case 'MUSIC_PAGE_TYPE_PLAYLIST':
const playlistItem = item.musicTwoRowItemRenderer as InnerTube.Home.PlaylistMusicTwoRowItemRenderer
results.push(parsePlaylistMusicTwoRowItemRenderer(playlistItem))
break
}
}
return results
}
const response = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'FEmusic_home' } }).json<InnerTube.Home.Response>()
const MAX_RECOMMENDATIONS = 20 // Temporary Implementation
const goodSections = ['Listen again', 'Recommended albums', 'From your library', 'Recommended music videos', 'Forgotten favorites', 'Quick picks', 'Long listening']
const contents = response.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents
.filter((section) => goodSections.includes(section.musicCarouselShelfRenderer.header.musicCarouselShelfBasicHeaderRenderer.title.runs[0].text))
.map((section) => parseMusicCarouselShelfRenderer(section.musicCarouselShelfRenderer))
.flat()
let continuation = response.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.continuations?.[0].nextContinuationData.continuation
while (continuation && contents.length < MAX_RECOMMENDATIONS) {
const continuationResponse = await this.api.v1.WEB_REMIX(`browse?ctoken=${continuation}&continuation=${continuation}`).json<InnerTube.Home.ContinuationResponse>()
const continuationContents = continuationResponse.continuationContents.sectionListContinuation.contents
.filter((section) => goodSections.includes(section.musicCarouselShelfRenderer.header.musicCarouselShelfBasicHeaderRenderer.title.runs[0].text))
.map((section) => parseMusicCarouselShelfRenderer(section.musicCarouselShelfRenderer))
.flat()
contents.push(...continuationContents)
continuation = continuationResponse.continuationContents.sectionListContinuation.continuations?.[0].nextContinuationData.continuation
}
let songsIndex = 0
const songs = await this.getSongs(contents.filter((item) => typeof item === 'string'))
return Array.from(contents, (item) => (typeof item === 'string' ? songs[songsIndex++] : item))
} }
public async getAudioStream(id: string, headers: Headers) { public async getAudioStream(id: string, headers: Headers) {
@@ -396,10 +504,9 @@ export class YouTubeMusic implements Connection {
public async getPlaylist(id: string): Promise<Playlist> { public async getPlaylist(id: string): Promise<Playlist> {
const playlistResponse = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'VL'.concat(id) } }).json<InnerTube.Playlist.Response>() const playlistResponse = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'VL'.concat(id) } }).json<InnerTube.Playlist.Response>()
const sectionContent = playlistResponse.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0]
const header = const header =
'musicEditablePlaylistDetailHeaderRenderer' in playlistResponse.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0] 'musicEditablePlaylistDetailHeaderRenderer' in sectionContent ? sectionContent.musicEditablePlaylistDetailHeaderRenderer.header.musicResponsiveHeaderRenderer : sectionContent.musicResponsiveHeaderRenderer
? playlistResponse.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicEditablePlaylistDetailHeaderRenderer.header.musicResponsiveHeaderRenderer
: playlistResponse.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicResponsiveHeaderRenderer
const connection = { id: this.id, type: 'youtube-music' } satisfies Playlist['connection'] const connection = { id: this.id, type: 'youtube-music' } satisfies Playlist['connection']
const name = header.title.runs[0].text const name = header.title.runs[0].text
@@ -509,8 +616,107 @@ export class YouTubeMusic implements Connection {
} }
} }
class APIManager { class YouTubeMusicLibrary {
private readonly connectionId: string private readonly api: API
constructor(api: API) {
this.api = api
}
public async albums(): Promise<Album[]> {
const albumData = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'FEmusic_liked_albums' } }).json<InnerTube.Library.AlbumResponse>()
const { items, continuations } = albumData.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].gridRenderer
let continuation = continuations?.[0].nextContinuationData.continuation
while (continuation) {
const continuationData = await this.api.v1.WEB_REMIX(`browse?ctoken=${continuation}&continuation=${continuation}`).json<InnerTube.Library.AlbumContinuationResponse>()
items.push(...continuationData.continuationContents.gridContinuation.items)
continuation = continuationData.continuationContents.gridContinuation.continuations?.[0].nextContinuationData.continuation
}
const connection = { id: this.api.connectionId, type: 'youtube-music' } satisfies Album['connection']
return items.map((item) => {
const id = item.musicTwoRowItemRenderer.navigationEndpoint.browseEndpoint.browseId
const name = item.musicTwoRowItemRenderer.title.runs[0].text
const thumbnailUrl = extractLargestThumbnailUrl(item.musicTwoRowItemRenderer.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails)
let artists: Album['artists'] = []
item.musicTwoRowItemRenderer.subtitle.runs.forEach((run) => {
if (run.text === 'Various Artists') return (artists = 'Various Artists')
if (run.navigationEndpoint && artists instanceof Array) artists.push({ id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text })
})
const releaseYear = item.musicTwoRowItemRenderer.subtitle.runs.at(-1)?.text!
return { connection, id, name, type: 'album', thumbnailUrl, artists, releaseYear } satisfies Album
})
}
public async artists(): Promise<Artist[]> {
const artistsData = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'FEmusic_library_corpus_track_artists' } }).json<InnerTube.Library.ArtistResponse>()
const { contents, continuations } = artistsData.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicShelfRenderer
let continuation = continuations?.[0].nextContinuationData.continuation
while (continuation) {
const continuationData = await this.api.v1.WEB_REMIX(`browse?ctoken=${continuation}&continuation=${continuation}`).json<InnerTube.Library.ArtistContinuationResponse>()
contents.push(...continuationData.continuationContents.musicShelfContinuation.contents)
continuation = continuationData.continuationContents.musicShelfContinuation.continuations?.[0].nextContinuationData.continuation
}
const connection = { id: this.api.connectionId, type: 'youtube-music' } satisfies Album['connection']
return contents.map((item) => {
const id = item.musicResponsiveListItemRenderer.navigationEndpoint.browseEndpoint.browseId
const name = item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text
const profilePicture = extractLargestThumbnailUrl(item.musicResponsiveListItemRenderer.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
return { connection, id, name, type: 'artist', profilePicture } satisfies Artist
})
}
public async playlists(): Promise<Playlist[]> {
const playlistData = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'FEmusic_liked_playlists' } }).json<InnerTube.Library.PlaylistResponse>()
const { items, continuations } = playlistData.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].gridRenderer
let continuation = continuations?.[0].nextContinuationData.continuation
while (continuation) {
const continuationData = await this.api.v1.WEB_REMIX(`browse?ctoken=${continuation}&continuation=${continuation}`).json<InnerTube.Library.PlaylistContinuationResponse>()
items.push(...continuationData.continuationContents.gridContinuation.items)
continuation = continuationData.continuationContents.gridContinuation.continuations?.[0].nextContinuationData.continuation
}
const playlists = items.filter(
(item): item is { musicTwoRowItemRenderer: InnerTube.Library.PlaylistMusicTwoRowItemRenderer } =>
'browseEndpoint' in item.musicTwoRowItemRenderer.navigationEndpoint &&
item.musicTwoRowItemRenderer.navigationEndpoint.browseEndpoint.browseId !== 'VLLM' &&
item.musicTwoRowItemRenderer.navigationEndpoint.browseEndpoint.browseId !== 'VLSE',
)
const connection = { id: this.api.connectionId, type: 'youtube-music' } satisfies Album['connection']
return playlists.map((item) => {
const id = item.musicTwoRowItemRenderer.navigationEndpoint.browseEndpoint.browseId.slice(2)
const name = item.musicTwoRowItemRenderer.title.runs[0].text
const thumbnailUrl = extractLargestThumbnailUrl(item.musicTwoRowItemRenderer.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails)
let createdBy: Playlist['createdBy']
item.musicTwoRowItemRenderer.subtitle.runs.forEach((run) => {
if (!run.navigationEndpoint) return
createdBy = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
})
return { connection, id, name, type: 'playlist', thumbnailUrl, createdBy } satisfies Playlist
})
}
}
class API {
public readonly connectionId: string
private currentAccessToken: string private currentAccessToken: string
private readonly refreshToken: string private readonly refreshToken: string
private expiry: number private expiry: number
@@ -610,107 +816,6 @@ class APIManager {
} }
} }
class LibaryManager {
private readonly connectionId: string
private readonly api: APIManager
private readonly youtubeUserId: string
constructor(connectionId: string, youtubeUserId: string, apiManager: APIManager) {
this.connectionId = connectionId
this.api = apiManager
this.youtubeUserId = youtubeUserId
}
public async albums(): Promise<Album[]> {
const albumData = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'FEmusic_liked_albums' } }).json<InnerTube.Library.AlbumResponse>()
const { items, continuations } = albumData.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].gridRenderer
let continuation = continuations?.[0].nextContinuationData.continuation
while (continuation) {
const continuationData = await this.api.v1.WEB_REMIX(`browse?ctoken=${continuation}&continuation=${continuation}`).json<InnerTube.Library.AlbumContinuationResponse>()
items.push(...continuationData.continuationContents.gridContinuation.items)
continuation = continuationData.continuationContents.gridContinuation.continuations?.[0].nextContinuationData.continuation
}
const connection = { id: this.connectionId, type: 'youtube-music' } satisfies Album['connection']
return items.map((item) => {
const id = item.musicTwoRowItemRenderer.navigationEndpoint.browseEndpoint.browseId
const name = item.musicTwoRowItemRenderer.title.runs[0].text
const thumbnailUrl = extractLargestThumbnailUrl(item.musicTwoRowItemRenderer.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails)
let artists: Album['artists'] = []
item.musicTwoRowItemRenderer.subtitle.runs.forEach((run) => {
if (run.text === 'Various Artists') return (artists = 'Various Artists')
if (run.navigationEndpoint && artists instanceof Array) artists.push({ id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text })
})
const releaseYear = item.musicTwoRowItemRenderer.subtitle.runs.at(-1)?.text!
return { connection, id, name, type: 'album', thumbnailUrl, artists, releaseYear } satisfies Album
})
}
public async artists(): Promise<Artist[]> {
const artistsData = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'FEmusic_library_corpus_track_artists' } }).json<InnerTube.Library.ArtistResponse>()
const { contents, continuations } = artistsData.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicShelfRenderer
let continuation = continuations?.[0].nextContinuationData.continuation
while (continuation) {
const continuationData = await this.api.v1.WEB_REMIX(`browse?ctoken=${continuation}&continuation=${continuation}`).json<InnerTube.Library.ArtistContinuationResponse>()
contents.push(...continuationData.continuationContents.musicShelfContinuation.contents)
continuation = continuationData.continuationContents.musicShelfContinuation.continuations?.[0].nextContinuationData.continuation
}
const connection = { id: this.connectionId, type: 'youtube-music' } satisfies Album['connection']
return contents.map((item) => {
const id = item.musicResponsiveListItemRenderer.navigationEndpoint.browseEndpoint.browseId
const name = item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text
const profilePicture = extractLargestThumbnailUrl(item.musicResponsiveListItemRenderer.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
return { connection, id, name, type: 'artist', profilePicture } satisfies Artist
})
}
public async playlists(): Promise<Playlist[]> {
const playlistData = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'FEmusic_liked_playlists' } }).json<InnerTube.Library.PlaylistResponse>()
const { items, continuations } = playlistData.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].gridRenderer
let continuation = continuations?.[0].nextContinuationData.continuation
while (continuation) {
const continuationData = await this.api.v1.WEB_REMIX(`browse?ctoken=${continuation}&continuation=${continuation}`).json<InnerTube.Library.PlaylistContinuationResponse>()
items.push(...continuationData.continuationContents.gridContinuation.items)
continuation = continuationData.continuationContents.gridContinuation.continuations?.[0].nextContinuationData.continuation
}
const playlists = items.filter(
(item): item is { musicTwoRowItemRenderer: InnerTube.Library.PlaylistMusicTwoRowItemRenderer } =>
'browseEndpoint' in item.musicTwoRowItemRenderer.navigationEndpoint &&
item.musicTwoRowItemRenderer.navigationEndpoint.browseEndpoint.browseId !== 'VLLM' &&
item.musicTwoRowItemRenderer.navigationEndpoint.browseEndpoint.browseId !== 'VLSE',
)
const connection = { id: this.connectionId, type: 'youtube-music' } satisfies Album['connection']
return playlists.map((item) => {
const id = item.musicTwoRowItemRenderer.navigationEndpoint.browseEndpoint.browseId.slice(2)
const name = item.musicTwoRowItemRenderer.title.runs[0].text
const thumbnailUrl = extractLargestThumbnailUrl(item.musicTwoRowItemRenderer.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails)
let createdBy: Playlist['createdBy']
item.musicTwoRowItemRenderer.subtitle.runs.forEach((run) => {
if (run.navigationEndpoint && run.navigationEndpoint.browseEndpoint.browseId !== this.youtubeUserId) createdBy = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
})
return { connection, id, name, type: 'playlist', thumbnailUrl, createdBy } satisfies Playlist
})
}
}
/** /**
* @param duration Timestamp in standard ISO8601 format PnDTnHnMnS * @param duration Timestamp in standard ISO8601 format PnDTnHnMnS
* @returns The duration of the timestamp in seconds * @returns The duration of the timestamp in seconds
@@ -741,7 +846,7 @@ function secondsFromISO8601(duration: string): number {
*/ */
function extractLargestThumbnailUrl(thumbnails: Array<{ url: string; width: number; height: number }>): string { function extractLargestThumbnailUrl(thumbnails: Array<{ url: string; width: number; height: number }>): string {
const bestThumbnailURL = thumbnails.reduce((prev, current) => (prev.width * prev.height > current.width * current.height ? prev : current)).url const bestThumbnailURL = thumbnails.reduce((prev, current) => (prev.width * prev.height > current.width * current.height ? prev : current)).url
if (!URL.canParse(bestThumbnailURL)) throw new Error('Invalid thumbnail url') if (!URL.canParse(bestThumbnailURL)) throw Error('Invalid thumbnail url')
switch (new URL(bestThumbnailURL).origin) { switch (new URL(bestThumbnailURL).origin) {
case 'https://lh3.googleusercontent.com': case 'https://lh3.googleusercontent.com':
@@ -752,10 +857,11 @@ function extractLargestThumbnailUrl(thumbnails: Array<{ url: string; width: numb
return bestThumbnailURL return bestThumbnailURL
case 'https://www.gstatic.com': case 'https://www.gstatic.com':
case 'https://i.ytimg.com': case 'https://i.ytimg.com':
return bestThumbnailURL.slice(0, bestThumbnailURL.indexOf('?')) const queryParamStartIndex = bestThumbnailURL.indexOf('?')
return queryParamStartIndex > 0 ? bestThumbnailURL.slice(0, queryParamStartIndex) : bestThumbnailURL
default: default:
console.error('Tried to clean invalid url: ' + bestThumbnailURL) console.error('Tried to clean invalid url: ' + bestThumbnailURL)
throw new Error('Invalid thumbnail url origin') throw Error('Invalid thumbnail url origin')
} }
} }

View File

@@ -47,7 +47,7 @@ class Queue {
} }
get upNext() { get upNext() {
if (this.currentSongs.length === 0 && this.currentPosition >= this.currentSongs.length) return null if (this.currentSongs.length === 0 || this.currentPosition >= this.currentSongs.length) return null
return this.currentSongs[this.currentPosition + 1] return this.currentSongs[this.currentPosition + 1]
} }
@@ -85,6 +85,11 @@ class Queue {
this.updateQueue() this.updateQueue()
} }
/** Re-orders the queue if shuffled, shuffles if not */
public toggleShuffle() {
this.shuffled ? this.reorder() : this.shuffle()
}
/** Starts the next song */ /** Starts the next song */
public next() { public next() {
if (this.currentSongs.length === 0 || this.currentSongs.length <= this.currentPosition + 1) return if (this.currentSongs.length === 0 || this.currentSongs.length <= this.currentPosition + 1) return

View File

@@ -1,91 +1,38 @@
<script lang="ts"> <script lang="ts">
import SearchBar from '$lib/components/util/searchBar.svelte'
import type { LayoutData } from './$types'
import NavTab from '$lib/components/util/navTab.svelte'
import MixTab from '$lib/components/util/mixTab.svelte'
import MediaPlayer from '$lib/components/media/mediaPlayer.svelte' import MediaPlayer from '$lib/components/media/mediaPlayer.svelte'
import { goto } from '$app/navigation' import Navbar from '$lib/components/util/navbar.svelte'
import IconButton from '$lib/components/util/iconButton.svelte' import Sidebar from '$lib/components/util/sidebar.svelte'
import { queue } from '$lib/stores'
export let data: LayoutData
let mixData = [
{
name: 'J-Core Mix',
color: 'red',
id: 'SomeId',
},
{
name: 'Best of: 葉月ゆら',
color: 'purple',
id: 'SomeId',
},
]
$: currentPathname = data.url.pathname
let newMixNameInputOpen = false
// I'm thinking I might want to make /albums, /artists, and /playlists all there own routes and just wrap them in a (library) layout // I'm thinking I might want to make /albums, /artists, and /playlists all there own routes and just wrap them in a (library) layout
let sidebar: Sidebar
$: currentlyPlaying = $queue.current
$: shuffled = $queue.isShuffled
</script> </script>
<main id="grid-wrapper" class="h-full"> <main id="grid-wrapper" class="h-full">
<nav id="navbar" class="items-center"> <Navbar on:opensidebar={sidebar.open} />
<strong class="pl-6 text-3xl"> <Sidebar bind:this={sidebar} />
<i class="fa-solid fa-record-vinyl mr-1" /> <section id="content-wrapper" class="overflow-y-scroll no-scrollbar">
Lazuli
</strong>
<SearchBar />
<div class="flex h-full justify-end p-4">
<IconButton halo={true} on:click={() => goto('/user')}>
<i slot="icon" class="fa-solid fa-user text-lg" />
</IconButton>
</div>
</nav>
<section id="sidebar" class="relative pt-4 text-sm font-normal">
<div class="mb-10">
<NavTab label="Home" icon="fa-solid fa-wave-square" redirect="/" disabled={currentPathname === '/'} />
<NavTab label="Playlists" icon="fa-solid fa-bars-staggered" redirect="/playlists" disabled={/^\/playlists.*$/.test(currentPathname)} />
<NavTab label="Library" icon="fa-solid fa-book" redirect="/library" disabled={/^\/library.*$/.test(currentPathname)} />
</div>
<h1 class="mb-1 flex h-5 items-center justify-between pl-6 text-sm text-neutral-400">
Your Mixes
<IconButton halo={true} on:click={() => (mixData = [{ name: 'New Mix', color: 'grey', id: 'SomeId' }, ...mixData])}>
<i slot="icon" class="fa-solid fa-plus" />
</IconButton>
</h1>
<div>
{#each mixData as mix}
<MixTab {...mix} />
{/each}
</div>
</section>
<section id="content-wrapper" class="no-scrollbar overflow-x-clip overflow-y-scroll pr-8">
<slot /> <slot />
</section> </section>
<MediaPlayer /> {#if currentlyPlaying}
<MediaPlayer
mediaItem={currentlyPlaying}
{shuffled}
mediaSession={'mediaSession' in navigator ? navigator.mediaSession : null}
on:stop={() => $queue.clear()}
on:next={() => $queue.next()}
on:previous={() => $queue.previous()}
on:toggleShuffle={() => $queue.toggleShuffle()}
/>
{/if}
</main> </main>
<style> <style>
#grid-wrapper,
#navbar {
display: grid;
column-gap: 3rem;
grid-template-columns: 12rem auto 12rem;
}
#grid-wrapper { #grid-wrapper {
row-gap: 1rem; display: grid;
grid-template-rows: 4.5rem auto; grid-template-rows: min-content auto;
}
#navbar {
grid-area: 1 / 1 / 2 / 4;
}
#sidebar {
grid-area: 2 / 1 / 3 / 2;
}
#content-wrapper {
grid-area: 2 / 2 / 3 / 4;
} }
</style> </style>

View File

@@ -1,15 +1,68 @@
<script lang="ts"> <script lang="ts">
import ScrollableCardMenu from '$lib/components/media/scrollableCardMenu.svelte'
import type { PageData } from './$types' import type { PageData } from './$types'
import Loader from '$lib/components/util/loader.svelte' import Loader from '$lib/components/util/loader.svelte'
import MediaCard from '$lib/components/media/mediaCard.svelte'
import AlbumCard from '$lib/components/media/albumCard.svelte'
export let data: PageData export let data: PageData
</script> </script>
<div id="main"> <div id="main" class="grid">
{#await data.recommendations} {#await data.recommendations}
<Loader /> <Loader />
{:then recommendations} {:then recommendations}
<ScrollableCardMenu header={'Listen Again'} cardDataList={recommendations} /> <div id="card-wrapper" class="grid w-full gap-4 justify-self-center px-[5%] pt-8">
{#each recommendations.filter((item) => item.type === 'album') as album}
<AlbumCard {album} />
{/each}
</div>
{/await} {/await}
</div> </div>
<style>
@media (min-width: 350px) {
#card-wrapper {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 550px) {
#card-wrapper {
grid-template-columns: repeat(3, 1fr);
}
}
@media (min-width: 750px) {
#card-wrapper {
grid-template-columns: repeat(4, 1fr);
}
}
@media (min-width: 950px) {
#card-wrapper {
grid-template-columns: repeat(5, 1fr);
}
}
@media (min-width: 1150px) {
#card-wrapper {
grid-template-columns: repeat(6, 1fr);
}
}
@media (min-width: 1350px) {
#card-wrapper {
grid-template-columns: repeat(7, 1fr);
}
}
@media (min-width: 1550px) {
#card-wrapper {
grid-template-columns: repeat(8, 1fr);
}
}
@media (min-width: 2200px) {
#card-wrapper {
grid-template-columns: repeat(9, 1fr);
}
}
@media (min-width: 3000px) {
#card-wrapper {
grid-template-columns: repeat(10, 1fr);
}
}
</style>

View File

@@ -1,13 +1,11 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { itemDisplayState } from '$lib/stores' import { itemDisplayState } from '$lib/stores'
import type { LayoutData } from './$types.js'
import { fade } from 'svelte/transition' import { fade } from 'svelte/transition'
import IconButton from '$lib/components/util/iconButton.svelte' import IconButton from '$lib/components/util/iconButton.svelte'
import { page } from '$app/stores'
export let data: LayoutData $: currentPathname = $page.url.pathname
$: currentPathname = data.url.pathname
</script> </script>
<main class="py-4"> <main class="py-4">

View File

@@ -2,7 +2,7 @@
import type { PageServerData } from './$types' import type { PageServerData } from './$types'
import { itemDisplayState } from '$lib/stores' import { itemDisplayState } from '$lib/stores'
import Loader from '$lib/components/util/loader.svelte' import Loader from '$lib/components/util/loader.svelte'
import AlbumCard from './albumCard.svelte' import AlbumCard from '$lib/components/media/albumCard.svelte'
import ListItem from '$lib/components/media/listItem.svelte' import ListItem from '$lib/components/media/listItem.svelte'
export let data: PageServerData export let data: PageServerData

View File

@@ -2,13 +2,8 @@ import type { LayoutServerLoad } from './$types'
export const ssr = false export const ssr = false
export const load: LayoutServerLoad = ({ url, locals }) => { export const load: LayoutServerLoad = ({ locals }) => {
const { pathname, search } = url
return { return {
url: {
pathname,
search,
},
user: locals.user, user: locals.user,
} }
} }

View File

@@ -2,15 +2,14 @@
import '../app.css' import '../app.css'
import '@fortawesome/fontawesome-free/css/all.min.css' import '@fortawesome/fontawesome-free/css/all.min.css'
import AlertBox from '$lib/components/util/alertBox.svelte' import AlertBox from '$lib/components/util/alertBox.svelte'
import { newestAlert, backgroundImage, pageWidth } from '$lib/stores' import { newestAlert, backgroundImage } from '$lib/stores'
import { fade } from 'svelte/transition' import { fade } from 'svelte/transition'
let alertBox: AlertBox let alertBox: AlertBox
$: if ($newestAlert && alertBox) alertBox.addAlert(...$newestAlert) $: if ($newestAlert && alertBox) alertBox.addAlert(...$newestAlert)
</script> </script>
<svelte:window bind:innerWidth={$pageWidth} /> <div class="no-scrollbar relative h-screen overflow-x-clip font-notoSans text-white">
<div class="no-scrollbar relative h-screen font-notoSans text-white">
<div class="fixed isolate -z-10 h-full w-screen bg-black"> <div class="fixed isolate -z-10 h-full w-screen bg-black">
<div id="background-gradient" class="absolute z-10 h-1/2 w-full bg-cover" /> <div id="background-gradient" class="absolute z-10 h-1/2 w-full bg-cover" />
{#key $backgroundImage} {#key $backgroundImage}

View File

@@ -1,4 +1,5 @@
const defaultTheme = require('tailwindcss/defaultTheme') const defaultTheme = require('tailwindcss/defaultTheme')
const plugin = require('tailwindcss/plugin')
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
@@ -17,5 +18,19 @@ export default {
}, },
}, },
}, },
plugins: [], plugins: [
plugin(function ({ addUtilities }) {
addUtilities({
/* Hide scrollbar for Chrome, Safari and Opera */
'.no-scrollbar::-webkit-scrollbar': {
display: 'none',
},
/* Hide scrollbar for IE, Edge and Firefox */
'.no-scrollbar': {
'-ms-overflow-style': 'none' /* IE and Edge */,
'scrollbar-width': 'none' /* Firefox */,
},
})
}),
],
} }