Trying out some UI changes. Added resizing support to remoteImage and LazyImage component

This commit is contained in:
Eclypsed
2024-06-10 03:22:12 -04:00
parent cb4cc1d949
commit 9dab826e53
20 changed files with 462 additions and 288 deletions

View File

@@ -1,4 +1,4 @@
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+HK:wght@500&family=Noto+Sans+JP:wght@500&family=Noto+Sans+KR:wght@500&family=Noto+Sans+SC:wght@500&family=Noto+Sans+TC:wght@500&family=Noto+Sans:wght@500&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+HK:wght@100..900&family=Noto+Sans+JP:wght@100..900&family=Noto+Sans+KR:wght@100..900&family=Noto+Sans+SC:wght@100..900&family=Noto+Sans+TC:wght@100..900&family=Noto+Sans:wght@100..900&display=swap');
@tailwind base;
@tailwind components;

View File

@@ -0,0 +1,64 @@
<!--
@component
A component to help render images in a smooth and efficient way. The url passed will be fetched via Lazuli's
remoteImage API endpoint with size parameters that are dynamically calculated base off of the image's container's
width and height. Images are lazily loaded unless 'eager' loading is specified.
@param thumbnailUrl A string of a URL that points to the desired image.
@param alt Supplementary text in the event the image fails to load.
@param loadingMethod Optional. Either the string 'lazy' or 'eager', defaults to lazy. The method by which to load the image.
-->
<script lang="ts">
import { onMount } from 'svelte'
export let thumbnailUrl: string
export let alt: string
export let loadingMethod: 'lazy' | 'eager' = 'lazy'
let imageContainer: HTMLDivElement
function removeOldImage() {
if (imageContainer.childElementCount > 1) {
const oldImage = imageContainer.firstChild! as HTMLImageElement
oldImage.style.opacity = '0'
setTimeout(() => imageContainer.removeChild(oldImage), 500)
}
}
function updateImage(newThumbnailURL: string) {
if (!imageContainer) return
const width = imageContainer.clientWidth
const height = imageContainer.clientHeight
const newImage = new Image(width, height)
imageContainer.appendChild(newImage)
newImage.loading = loadingMethod
newImage.src = `/api/remoteImage?url=${newThumbnailURL}&`.concat(width > height ? `maxWidth=${width}` : `maxHeight=${height}`)
newImage.alt = alt
newImage.style.width = '100%'
newImage.style.height = '100%'
newImage.style.objectFit = 'cover'
newImage.style.opacity = '0'
newImage.style.position = 'absolute'
newImage.onload = () => {
removeOldImage()
newImage.style.transition = 'opacity 500ms ease'
newImage.style.opacity = '1'
}
newImage.onerror = () => {
removeOldImage()
newImage.style.opacity = '1'
}
}
onMount(() => updateImage(thumbnailUrl))
$: updateImage(thumbnailUrl)
</script>
<div bind:this={imageContainer} class="relative h-full w-full"></div>

View File

@@ -0,0 +1,53 @@
<script lang="ts">
import LazyImage from './lazyImage.svelte'
export let mediaItem: Song | Album | Artist | Playlist
const thumbnailUrl = 'thumbnailUrl' in mediaItem ? mediaItem.thumbnailUrl : mediaItem.profilePicture
const date = 'releaseDate' in mediaItem && mediaItem.releaseDate ? new Date(mediaItem.releaseDate).getFullYear().toString() : 'releaseYear' in mediaItem ? mediaItem.releaseYear : undefined
</script>
<div id="list-item" class="h-16 w-full">
<div class="h-full overflow-clip rounded-md">
{#if thumbnailUrl}
<LazyImage {thumbnailUrl} alt={`${mediaItem.name} thumbnial`} />
{:else}
<div id="thumbnail-placeholder" class="grid h-full w-full place-items-center bg-lazuli-primary">
<i class="fa-solid {mediaItem.type === 'artist' ? 'fa-user' : 'fa-play'} text-2xl" />
</div>
{/if}
</div>
<div class="line-clamp-1">{mediaItem.name}</div>
<span class="line-clamp-1 flex text-neutral-400">
{#if 'artists' in mediaItem && mediaItem.artists}
{#if mediaItem.artists === 'Various Artists'}
<span>Various Artists</span>
{:else}
{#each mediaItem.artists as artist, index}
<a class="hover:underline focus:underline" href="/details/artist?id={artist.id}&connection={mediaItem.connection.id}">{artist.name}</a>
{#if index < mediaItem.artists.length - 1}
&#44&#160
{/if}
{/each}
{/if}
{:else if 'uploader' in mediaItem && mediaItem.uploader}
<span>{mediaItem.uploader.name}</span>
{:else if 'createdBy' in mediaItem && mediaItem.createdBy}
<span>{mediaItem.createdBy.name}</span>
{/if}
</span>
<div class="justify-self-center text-neutral-400">{date ?? ''}</div>
</div>
<style>
#list-item {
display: grid;
column-gap: 1rem;
align-items: center;
grid-template-columns: 4rem 1fr 1fr 5rem;
}
#thumbnail-placeholder {
background: radial-gradient(circle, rgba(0, 0, 0, 0) 25%, rgba(35, 40, 50, 1) 100%);
}
</style>

View File

@@ -15,7 +15,7 @@
}).then((response) => response.json() as Promise<{ items: Song[] }>)
const items = itemsResponse.items
queueRef.setQueue({ songs: items })
queueRef.setQueue(items)
}
</script>
@@ -41,7 +41,7 @@
on:click={() => {
switch (mediaItem.type) {
case 'song':
queueRef.setQueue({ songs: [mediaItem] })
queueRef.setQueue([mediaItem])
break
case 'album':
case 'playlist':

View File

@@ -5,6 +5,7 @@
// 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'
$: currentlyPlaying = $queue.current
@@ -43,9 +44,9 @@
}
onMount(() => {
const storedVolume = localStorage.getItem('volume')
if (storedVolume) {
volume = Number(storedVolume)
const storedVolume = Number(localStorage.getItem('volume'))
if (storedVolume >= 0 && storedVolume <= maxVolume) {
volume = storedVolume
} else {
localStorage.setItem('volume', (maxVolume / 2).toString())
volume = maxVolume / 2
@@ -89,14 +90,12 @@
</script>
{#if currentlyPlaying}
<div transition:slide class="{expanded ? 'h-full w-full' : 'm-4 h-20 w-[calc(100%_-_32px)] rounded-xl'} absolute bottom-0 z-40 overflow-clip bg-neutral-925 transition-all duration-500">
<div id="player-wrapper" transition:slide class="{expanded ? 'h-full w-full' : 'm-4 h-20 w-[calc(100%_-_32px)] rounded-xl'} absolute bottom-0 z-40 overflow-clip bg-neutral-925 transition-all duration-500">
{#if !expanded}
<main in:fade={{ duration: 75, delay: 500 }} out:fade={{ duration: 75 }} class="relative grid h-20 w-full grid-cols-[minmax(auto,_20rem)_auto_minmax(auto,_20rem)] gap-4">
<section class="flex gap-3">
<div class="relative h-full w-20 min-w-20">
{#key currentlyPlaying}
<div transition:fade={{ duration: 500 }} class="absolute h-full w-full bg-cover bg-center bg-no-repeat" style="background-image: url(/api/remoteImage?url={currentlyPlaying.thumbnailUrl});" />
{/key}
<LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={`${currentlyPlaying.name} jacket`} loadingMethod={'eager'} />
</div>
<section class="flex flex-col justify-center gap-1">
<div class="line-clamp-2 text-sm">{currentlyPlaying.name}</div>
@@ -168,9 +167,9 @@
</section>
</main>
{:else}
<main in:fade={{ delay: 500 }} out:fade={{ duration: 75 }} class="expanded-player relative h-full" style="--currentlyPlayingImage: url(/api/remoteImage?url={currentlyPlaying.thumbnailUrl});">
<main id="expanded-player" in:fade={{ delay: 500 }} out:fade={{ duration: 75 }} class="relative h-full" style="--currentlyPlayingImage: url(/api/remoteImage?url={currentlyPlaying.thumbnailUrl});">
<img class="absolute -z-10 h-full w-full object-cover object-center blur-xl brightness-[25%]" src="/api/remoteImage?url={currentlyPlaying.thumbnailUrl}" alt="" />
<section class="song-queue-wrapper h-full px-24 py-20">
<section id="song-queue-wrapper" class="h-full px-24 py-20">
<section class="relative">
{#key currentlyPlaying}
<img transition:fade={{ duration: 300 }} class="absolute h-full max-w-full object-contain py-8" src="/api/remoteImage?url={currentlyPlaying.thumbnailUrl}" alt="" />
@@ -199,7 +198,7 @@
</section>
</section>
<section class="px-8">
<div class="progress-bar-expanded mb-8">
<div id="progress-bar-expanded" class="mb-8">
<span bind:this={expandedCurrentTimeTimestamp} class="text-right" />
<Slider
bind:this={expandedProgressBar}
@@ -215,14 +214,15 @@
/>
<span bind:this={expandedDurationTimestamp} class="text-left" />
</div>
<div class="expanded-controls">
<div id="expanded-controls">
<div class="flex flex-col gap-2 overflow-hidden">
<div bind:clientWidth={slidingTextWrapperWidth} class="relative h-9 w-full">
<strong
bind:this={slidingText}
bind:clientWidth={slidingTextWidth}
on:animationend={() => (scrollDirection *= -1)}
class="{scrollDistance > 0 ? (scrollDirection > 0 ? 'scrollLeft' : 'scrollRight') : ''} scrollingText absolute whitespace-nowrap text-3xl">{currentlyPlaying.name}</strong
id="scrollingText"
class="{scrollDistance > 0 ? (scrollDirection > 0 ? 'scrollLeft' : 'scrollRight') : ''} absolute whitespace-nowrap text-3xl">{currentlyPlaying.name}</strong
>
</div>
<div class="line-clamp-1 flex flex-nowrap" style="font-size: 0;">
@@ -320,11 +320,14 @@
{/if}
<style>
.expanded-player {
#player-wrapper {
filter: drop-shadow(0px 20px 20px #000000);
}
#expanded-player {
display: grid;
grid-template-rows: calc(100% - 12rem) 12rem;
}
.song-queue-wrapper {
#song-queue-wrapper {
display: grid;
grid-template-columns: 3fr 2fr;
gap: 4rem;
@@ -333,24 +336,24 @@
display: grid;
grid-template-columns: 5rem auto min-content;
}
.progress-bar-expanded {
#progress-bar-expanded {
display: grid;
grid-template-columns: min-content auto min-content;
align-items: center;
gap: 1rem;
}
.expanded-controls {
#expanded-controls {
display: grid;
gap: 1rem;
grid-template-columns: 1fr min-content 1fr !important;
}
.scrollingText {
#scrollingText {
animation-timing-function: linear;
animation-fill-mode: both;
animation-delay: 10s;
}
.scrollingText:hover {
#scrollingText:hover {
animation-play-state: paused;
}
.scrollLeft {

View File

@@ -1,39 +1,23 @@
<script lang="ts" context="module">
export interface NavTab {
pathname: string
name: string
icon: string
}
</script>
<script lang="ts">
export let disabled = false
export let nav: NavTab
import { goto } from '$app/navigation'
let button: HTMLButtonElement
export let icon: string
export let label: string
export let redirect: string
export let disabled: boolean
</script>
<button bind:this={button} class="relative grid aspect-square w-full place-items-center transition-colors" {disabled} on:click={() => goto(nav.pathname)}>
<span class="pointer-events-none flex flex-col gap-2 text-xs">
<i class="{nav.icon} text-xl" />
{nav.name}
<button {disabled} class="block w-full py-2 text-left" on:click={() => goto(redirect)}>
<span class:disabled class="py-1 pl-8 {disabled ? 'text-lazuli-primary' : 'text-neutral-300'}">
<i class="{icon} mr-1.5 h-5 w-5" />
{label}
</span>
<div class="absolute left-0 top-1/2 h-0 w-[0.2rem] -translate-x-2 -translate-y-1/2 rounded-lg bg-white transition-all" />
</button>
<style>
button:disabled > div {
height: 80%;
}
button:not(:disabled) {
color: rgb(163 163, 163);
}
button:not(:disabled):hover {
color: var(--lazuli-primary);
}
button:not(:disabled):hover > div {
height: 40%;
span.disabled {
border-left: 2px solid var(--lazuli-primary);
background: linear-gradient(to right, var(--lazuli-primary) -25%, transparent 25%);
}
</style>

View File

@@ -1,51 +0,0 @@
<script lang="ts" context="module">
export interface PlaylistTab {
id: string
name: string
thumbnail: string
}
</script>
<script lang="ts">
export let disabled = false
export let playlist: PlaylistTab
import { createEventDispatcher } from 'svelte'
import { goto } from '$app/navigation'
const dispatch = createEventDispatcher()
let button: HTMLButtonElement
type ButtonCenter = {
x: number
y: number
}
const calculateCenter = (button: HTMLButtonElement): ButtonCenter => {
const rect = button.getBoundingClientRect()
const x = (rect.left + rect.right) / 2
const y = (rect.top + rect.bottom) / 2
return { x, y }
}
</script>
<button
{disabled}
bind:this={button}
class="relative aspect-square w-full flex-shrink-0 rounded-lg bg-cover bg-center transition-all"
style="background-image: url({playlist.thumbnail});"
on:mouseenter={() => dispatch('mouseenter', { ...calculateCenter(button), content: playlist.name })}
on:mouseleave={() => dispatch('mouseleave')}
on:click={() => {
dispatch('click')
goto(`/library?playlist=${playlist.id}`)
}}
>
</button>
<style>
button:not(:disabled):not(:hover) {
filter: brightness(50%);
}
</style>

View File

@@ -9,7 +9,12 @@
}
</script>
<search role="search" bind:this={searchBar} class="relative flex h-12 w-full items-center gap-2.5 justify-self-center rounded-full border-2 border-transparent px-4 py-2" style="background-color: rgba(82, 82, 82, 0.25);">
<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={() => {

View File

@@ -25,8 +25,8 @@ export class Jellyfin implements Connection {
}
public async getConnectionInfo() {
const userEndpoint = `Users/${this.jellyfinUserId}`
const systemEndpoint = 'System/Info'
const userEndpoint = `/Users/${this.jellyfinUserId}`
const systemEndpoint = '/System/Info'
const getUserData = () =>
this.services
@@ -42,8 +42,8 @@ export class Jellyfin implements Connection {
const [userData, systemData] = await Promise.all([getUserData(), getSystemData()])
if (!userData) console.error(`Fetch to ${userEndpoint.toString()} failed`)
if (!systemData) console.error(`Fetch to ${systemEndpoint.toString()} failed`)
if (!userData) console.error(`Fetch to ${userEndpoint} failed`)
if (!systemData) console.error(`Fetch to ${systemEndpoint} failed`)
return {
id: this.id,
@@ -206,9 +206,11 @@ class JellyfinServices {
}
}
private getBestThumbnail = (item: JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Artist | JellyfinAPI.Playlist) => {
private getBestThumbnail(item: JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Artist | JellyfinAPI.Playlist, placeholder: string): string
private getBestThumbnail(item: JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Artist | JellyfinAPI.Playlist, placeholder?: string): string | undefined
private getBestThumbnail(item: JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Artist | JellyfinAPI.Playlist, placeholder?: string): string | undefined {
const imageItemId = item.ImageTags?.Primary ? item.Id : 'AlbumPrimaryImageTag' in item && item.AlbumPrimaryImageTag ? item.AlbumId : undefined
return imageItemId ? this.serverUrl(`Items/${imageItemId}/Images/Primary`).toString() : jellyfinLogo
return imageItemId ? this.serverUrl(`Items/${imageItemId}/Images/Primary`).toString() : placeholder
}
public parseSong = (song: JellyfinAPI.Song): Song => ({
@@ -216,8 +218,8 @@ class JellyfinServices {
id: song.Id,
name: song.Name,
type: 'song',
duration: Math.floor(song.RunTimeTicks / 10000000),
thumbnailUrl: this.getBestThumbnail(song),
duration: Math.round(song.RunTimeTicks / 10000000),
thumbnailUrl: this.getBestThumbnail(song, jellyfinLogo),
releaseDate: song.PremiereDate ? new Date(song.PremiereDate).toISOString() : undefined,
artists: song.ArtistItems?.map((artist) => ({ id: artist.Id, name: artist.Name })),
album: song.AlbumId && song.Album ? { id: song.AlbumId, name: song.Album } : undefined,
@@ -229,7 +231,7 @@ class JellyfinServices {
id: album.Id,
name: album.Name,
type: 'album',
thumbnailUrl: this.getBestThumbnail(album),
thumbnailUrl: this.getBestThumbnail(album, jellyfinLogo),
artists: album.AlbumArtists?.map((artist) => ({ id: artist.Id, name: artist.Name })) ?? 'Various Artists',
releaseYear: album.ProductionYear?.toString(),
})
@@ -247,7 +249,7 @@ class JellyfinServices {
id: playlist.Id,
name: playlist.Name,
type: 'playlist',
thumbnailUrl: this.getBestThumbnail(playlist),
thumbnailUrl: this.getBestThumbnail(playlist, jellyfinLogo),
})
}
@@ -268,6 +270,7 @@ class JellyfinLibraryManager {
}
public async artists(): Promise<Artist[]> {
// ? This returns just album artists instead of all artists like in finamp, but I might decide that I want to return all artists instead
return this.services
.request('/Artists/AlbumArtists?sortBy=SortName&sortOrder=Ascending&recursive=true')
.then((response) => response.json() as Promise<{ Items: JellyfinAPI.Artist[] }>)

View File

@@ -168,7 +168,7 @@ export class YouTubeMusic implements Connection {
const playerResponse = await fetch('https://www.youtube.com/youtubei/v1/player', {
headers: {
// 'user-agent': 'com.google.android.youtube/17.36.4 (Linux; U; Android 12; GB) gzip', <-- I thought this was necessary but it appears it might not be?
authorization: `Bearer ${await this.requestManager.accessToken}`, // * Including the access token is what enables access to premium content
authorization: `Bearer ${await this.requestManager.accessToken}`, // * Including the access token is what enables access to premium content for some reason
},
method: 'POST',
body: JSON.stringify({
@@ -935,7 +935,8 @@ function secondsFromISO8601(duration: string): number {
* - https://yt3.googleusercontent.com
* - https://yt3.ggpht.com
* - https://music.youtube.com
* - https://i.ytimg.com
* - https://www.gstatic.com - Static images (e.g. a placeholder artist profile picture)
* - https://i.ytimg.com - Video Thumbnails
*
* NOTE:
* https://i.ytimg.com corresponds to videos, which follow the mqdefault...maxres resolutions scale. It is generally bad practice to use these as there is no way to scale them with query params, and there is no way to tell if a maxres.jpg exists or not.
@@ -953,7 +954,7 @@ function extractLargestThumbnailUrl(thumbnails: Array<{ url: string; width: numb
return bestThumbnailURL.slice(0, bestThumbnailURL.indexOf('='))
case 'https://music.youtube.com':
return bestThumbnailURL
case 'https://www.gstatic.com': // This url will usually contain static images like a placeholder artist profile picture for example
case 'https://www.gstatic.com':
case 'https://i.ytimg.com':
return bestThumbnailURL.slice(0, bestThumbnailURL.indexOf('?'))
default:

View File

@@ -8,6 +8,8 @@ export const newestAlert: Writable<[AlertType, string]> = writable()
const youtubeMusicBackground: string = 'https://www.gstatic.com/youtube/media/ytm/images/sbg/wsbg@4000x2250.png' // Default Youtube music background
export const backgroundImage: Writable<string> = writable(youtubeMusicBackground)
export const itemDisplayState: Writable<'list' | 'grid'> = writable('grid')
function fisherYatesShuffle<T>(items: T[]) {
for (let currentIndex = items.length - 1; currentIndex >= 0; currentIndex--) {
let randomIndex = Math.floor(Math.random() * (currentIndex + 1))
@@ -17,10 +19,6 @@ function fisherYatesShuffle<T>(items: T[]) {
return items
}
// ? New idea for how to handle mixing. Keep originalSongs and currentSongs but also add playedSongs. Add the previous song to played songs whenever next() is called.
// ? Whenever a song is mixed, set currentSongs = [...playedSongs, currentSongs[currentPosition], ...fisherYatesShuffle(everything else)]. Reorder method would stay the same.
// ? IDK it's a thought
class Queue {
private currentPosition: number // -1 means no song is playing
private originalSongs: Song[]
@@ -36,6 +34,11 @@ class Queue {
this.shuffled = false
}
private updateQueue() {
writableQueue.set(this)
// TODO: Implement Queue Saver
}
get current() {
if (this.currentSongs.length === 0) return null
@@ -52,7 +55,7 @@ class Queue {
if (queuePosition >= 0) this.currentPosition = queuePosition
}
writableQueue.set(this)
this.updateQueue()
}
get list() {
@@ -68,7 +71,7 @@ class Queue {
const shuffledSongs = fisherYatesShuffle(this.currentSongs.slice(this.currentPosition + 1))
this.currentSongs = this.currentSongs.slice(0, this.currentPosition + 1).concat(shuffledSongs)
this.shuffled = true
writableQueue.set(this)
this.updateQueue()
}
/** Restores the queue to its original ordered state, while maintaining whatever song is currently playing */
@@ -77,7 +80,7 @@ class Queue {
this.currentSongs = [...this.originalSongs]
this.currentPosition = originalPosition
this.shuffled = false
writableQueue.set(this)
this.updateQueue()
}
/** Starts the next song */
@@ -85,7 +88,7 @@ class Queue {
if (this.currentSongs.length === 0 || this.currentSongs.length <= this.currentPosition + 1) return
this.currentPosition += 1
writableQueue.set(this)
this.updateQueue()
}
/** Plays the previous song */
@@ -93,27 +96,27 @@ class Queue {
if (this.currentSongs.length === 0 || this.currentPosition <= 0) return
this.currentPosition -= 1
writableQueue.set(this)
this.updateQueue()
}
/** Add songs to the end of the queue */
public enqueue(...songs: Song[]) {
this.originalSongs.push(...songs)
this.currentSongs.push(...songs)
writableQueue.set(this)
this.updateQueue()
}
/**
* @param songs An ordered array of Songs
* @param shuffled Whether or not to shuffle the queue before starting playback. False if not specified
*/
public setQueue(params: { songs: Song[]; shuffled?: boolean }) {
if (params.songs.length === 0) return // Should not set a queue with no songs, use clear()
this.originalSongs = params.songs
this.currentSongs = params.shuffled ? fisherYatesShuffle(params.songs) : params.songs
public setQueue(songs: Song[], shuffled?: boolean) {
if (songs.length === 0) return // Should not set a queue with no songs, use clear()
this.originalSongs = songs
this.currentSongs = shuffled ? fisherYatesShuffle(songs) : songs
this.currentPosition = 0
this.shuffled = params.shuffled ?? false
writableQueue.set(this)
this.shuffled = shuffled ?? false
this.updateQueue()
}
/** Clears all items from the queue */
@@ -121,7 +124,7 @@ class Queue {
this.currentPosition = -1
this.originalSongs = []
this.currentSongs = []
writableQueue.set(this)
this.updateQueue()
}
}

View File

@@ -2,47 +2,59 @@
import SearchBar from '$lib/components/util/searchBar.svelte'
import type { LayoutData } from './$types'
import NavTab from '$lib/components/navbar/navTab.svelte'
import PlaylistTab from '$lib/components/navbar/playlistTab.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
const inPathnameHeirarchy = (pathname: string, rootPathname: string): boolean => {
return (pathname.startsWith(rootPathname) && rootPathname !== '/') || (pathname === '/' && rootPathname === '/')
}
let playlistTooltip: HTMLDivElement
const setTooltip = (x: number, y: number, content: string): void => {
const textWrapper = playlistTooltip.firstChild! as HTMLDivElement
textWrapper.innerText = content
playlistTooltip.style.display = 'block'
playlistTooltip.style.left = `${x}px`
playlistTooltip.style.top = `${y}px`
}
$: currentPathname = data.url.pathname
</script>
<div class="h-full overflow-hidden">
<div class="no-scrollbar fixed left-0 top-0 z-10 grid h-full w-20 grid-cols-1 grid-rows-[min-content_auto] gap-5 px-3 py-12">
<div class="flex flex-col gap-4">
{#each data.navTabs as nav}
<NavTab {nav} disabled={inPathnameHeirarchy(data.url.pathname, nav.pathname)} />
{/each}
</div>
<div class="no-scrollbar flex flex-col gap-5 overflow-y-scroll px-1.5">
{#each data.playlistTabs as playlist}
<PlaylistTab {playlist} on:mouseenter={(event) => setTooltip(event.detail.x, event.detail.y, event.detail.content)} on:mouseleave={() => (playlistTooltip.style.display = 'none')} />
{/each}
</div>
<div bind:this={playlistTooltip} class="absolute hidden max-w-48 -translate-y-1/2 translate-x-10 whitespace-nowrap rounded bg-neutral-800 px-2 py-1.5 text-sm">
<div class="overflow-clip text-ellipsis">PLAYLIST_NAME</div>
<div class="overflow-clip text-ellipsis text-neutral-400">Playlist &bull; {data.user.username}</div>
</div>
</div>
<section class="no-scrollbar h-full overflow-y-scroll px-[max(7rem,_7vw)]">
<div class="sticky top-0 max-w-xl py-6">
<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="pt-4 font-light">
<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)} />
</section>
<section id="content-wrapper" class="no-scrollbar overflow-x-clip overflow-y-scroll pr-8">
<slot />
</section>
<MediaPlayer />
</div>
</main>
<style>
#grid-wrapper,
#navbar {
display: grid;
column-gap: 1rem;
grid-template-columns: 14rem auto 14rem;
}
#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;
}
</style>

View File

@@ -1,83 +0,0 @@
import type { LayoutLoad } from './$types'
import type { NavTab } from '$lib/components/navbar/navTab.svelte'
import type { PlaylistTab } from '$lib/components/navbar/playlistTab.svelte'
export const load: LayoutLoad = () => {
const navTabs: NavTab[] = [
{
pathname: '/',
name: 'Home',
icon: 'fa-solid fa-house',
},
{
pathname: '/user',
name: 'User',
icon: 'fa-solid fa-user', // This would be a cool spot for a user-uploaded pfp
},
{
pathname: '/search',
name: 'Search',
icon: 'fa-solid fa-search',
},
{
pathname: '/library',
name: 'Libray',
icon: 'fa-solid fa-bars-staggered',
},
]
const playlistTabs: PlaylistTab[] = [
{
id: 'AD:TRANCE 10',
name: 'AD:TRANCE 10',
thumbnail: 'https://www.diverse.direct/wp/wp-content/uploads/470_artwork.jpg',
},
{
id: 'Fionaredica',
name: 'Fionaredica',
thumbnail: 'https://f4.bcbits.com/img/a2436961975_10.jpg',
},
{
id: 'Machinate',
name: 'Machinate',
thumbnail: 'https://f4.bcbits.com/img/a3587136348_10.jpg',
},
{
id: 'MAGGOD',
name: 'MAGGOD',
thumbnail: 'https://f4.bcbits.com/img/a3641603617_10.jpg',
},
{
id: 'The Requiem',
name: 'The Requiem',
thumbnail: 'https://f4.bcbits.com/img/a2458067285_10.jpg',
},
{
id: 'IRREPARABLE HARDCORE IS BACK 2 -Horai Gekka-',
name: 'IRREPARABLE HARDCORE IS BACK 2 -Horai Gekka-',
thumbnail: 'https://f4.bcbits.com/img/a1483629734_10.jpg',
},
{
id: '妄殺オタクティクス',
name: '妄殺オタクティクス',
thumbnail: 'https://f4.bcbits.com/img/a1653481367_10.jpg',
},
{
id: 'Collapse',
name: 'Collapse',
thumbnail: 'https://f4.bcbits.com/img/a0524413952_10.jpg',
},
{
id: 'Fleurix',
name: 'Fleurix',
thumbnail: 'https://f4.bcbits.com/img/a1856993876_10.jpg',
},
{
id: '天​才​失​格 -No Longer Prodigy-',
name: '天​才​失​格 -No Longer Prodigy-',
thumbnail: 'https://f4.bcbits.com/img/a2186643420_10.jpg',
},
]
return { navTabs, playlistTabs }
}

View File

@@ -0,0 +1,45 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { itemDisplayState } from '$lib/stores'
import type { LayoutData } from './$types.js'
import { fly, fade } from 'svelte/transition'
export let data: LayoutData
$: currentPathname = data.url.pathname
</script>
<main class="py-8">
<nav id="nav-options" class="mb-8 flex h-12 justify-between">
<section class="relative flex h-full gap-4">
<button disabled={/^\/library$/.test(currentPathname)} class="library-tab h-full px-1" on:click={() => goto('/library')}>History</button>
<button disabled={/^\/library\/albums.*$/.test(currentPathname)} class="library-tab h-full px-1" on:click={() => goto('/library/albums')}>Albums</button>
<button disabled={/^\/library\/artists.*$/.test(currentPathname)} class="library-tab h-full px-1" on:click={() => goto('/library/artists')}>Artists</button>
<button disabled={/^\/library\/collection.*$/.test(currentPathname)} class="library-tab h-full px-1" on:click={() => goto('/library/collection')}>My Collection</button>
</section>
<section class="h-full justify-self-end">
<button disabled={$itemDisplayState === 'list'} class="view-toggle aspect-square h-full" on:click={() => ($itemDisplayState = 'list')}>
<i class="fa-solid fa-list" />
</button>
<button disabled={$itemDisplayState === 'grid'} class="view-toggle aspect-square h-full" on:click={() => ($itemDisplayState = 'grid')}>
<i class="fa-solid fa-grip" />
</button>
</section>
</nav>
{#key currentPathname}
<div in:fade={{ duration: 200, delay: 200 }} out:fade={{ duration: 200 }}>
<slot />
</div>
{/key}
</main>
<style>
button.view-toggle[disabled] {
color: var(--lazuli-primary);
}
button.library-tab[disabled] {
color: var(--lazuli-primary);
border-top: 2px solid var(--lazuli-primary);
background: linear-gradient(to bottom, var(--lazuli-primary) -150%, transparent 50%);
}
</style>

View File

@@ -1,22 +0,0 @@
import type { PageServerLoad } from '../$types'
export const load: PageServerLoad = async ({ fetch, url }) => {
const connectionId = url.searchParams.get('connection')
const id = url.searchParams.get('id')
async function getPlaylist() {
const playlistResponse = (await fetch(`/api/connections/${connectionId}/playlist?id=${id}`, {
credentials: 'include',
}).then((response) => response.json())) as { playlist: Playlist }
return playlistResponse.playlist
}
async function getPlaylistItems() {
const itemsResponse = (await fetch(`/api/connections/${connectionId}/playlist/${id}/items`, {
credentials: 'include',
}).then((response) => response.json())) as { items: Song[] }
return itemsResponse.items
}
return { playlistDetails: Promise.all([getPlaylist(), getPlaylistItems()]) }
}

View File

@@ -1 +1 @@
<h1>Welcome to the library page!</h1>
<h1>This would be a good place for listen history</h1>

View File

@@ -0,0 +1,11 @@
import type { PageServerLoad } from './$types'
export const load: PageServerLoad = async ({ fetch, locals }) => {
const getLibraryAlbums = async () =>
fetch(`/api/users/${locals.user.id}/library/albums`)
.then((response) => response.json() as Promise<{ items: Album[] }>)
.then((data) => data.items)
.catch(() => ({ error: 'Failed to retrieve library albums' }))
return { albums: getLibraryAlbums() }
}

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import type { PageServerData } from './$types'
import { itemDisplayState } from '$lib/stores'
import Loader from '$lib/components/util/loader.svelte'
import AlbumCard from './albumCard.svelte'
import ListItem from '$lib/components/media/listItem.svelte'
export let data: PageServerData
</script>
<section>
{#await data.albums}
<Loader />
{:then albums}
{#if 'error' in albums}
<h1>{albums.error}</h1>
{:else if $itemDisplayState === 'list'}
<div class="text-md flex flex-col gap-4">
{#each albums as album}
<ListItem mediaItem={album} />
{/each}
</div>
{:else}
<div id="library-wrapper">
{#each albums as album}
<AlbumCard {album} />
{/each}
</div>
{/if}
{/await}
</section>
<style>
#library-wrapper {
display: grid;
/* gap: 1.5rem; */
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
}
</style>

View File

@@ -0,0 +1,67 @@
<script lang="ts">
import LazyImage from '$lib/components/media/lazyImage.svelte'
import IconButton from '$lib/components/util/iconButton.svelte'
import { goto } from '$app/navigation'
import { queue, newestAlert } from '$lib/stores'
export let album: Album
const queueRef = $queue // This nonsense is to prevent an bug that causes svelte to throw an error when setting a property of the queue directly
async function playAlbum() {
const itemsResponse = await fetch(`/api/connections/${album.connection.id}/album/${album.id}/items`, {
credentials: 'include',
}).catch(() => null)
if (!itemsResponse || !itemsResponse.ok) {
$newestAlert = ['warning', 'Failed to play album']
return
}
const data = (await itemsResponse.json()) as { items: Song[] }
queueRef.setQueue(data.items)
}
</script>
<div class="p-3">
<div id="thumbnail-wrapper" class="relative aspect-square w-full overflow-clip rounded-lg">
<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`} />
</button>
<div id="play-button" class="absolute left-1/2 top-1/2 h-1/4 -translate-x-1/2 -translate-y-1/2 opacity-0">
<IconButton halo={true} on:click={playAlbum}>
<i slot="icon" class="fa-solid fa-play text-2xl" />
</IconButton>
</div>
</div>
<div class="py-2 text-center text-sm">
<div class="line-clamp-2">{album.name}</div>
<div class="line-clamp-2 flex justify-center text-neutral-400">
{#if album.artists === 'Various Artists'}
<span>Various Artists</span>
{:else}
{#each album.artists as artist, index}
<a class="hover:underline focus:underline" href="/details/artist?id={artist.id}&connection={album.connection.id}">{artist.name}</a>
{#if index < album.artists.length - 1}
&#44&#160
{/if}
{/each}
{/if}
</div>
</div>
</div>
<style>
#thumbnail-wrapper:hover > #thumbnail {
filter: brightness(40%);
}
#thumbnail-wrapper:hover > #play-button {
opacity: 100%;
}
#thumbnail {
transition: filter 150ms ease;
}
#play-button {
transition: opacity 150ms ease;
}
</style>

View File

@@ -1,27 +1,67 @@
import type { RequestHandler } from '@sveltejs/kit'
// This endpoint exists to act as a proxy for images, bypassing any CORS or other issues
// that could arise from using images from another origin
export const GET: RequestHandler = async ({ url }) => {
const imageUrl = url.searchParams.get('url')
if (!imageUrl || !URL.canParse(imageUrl)) return new Response('Missing or invalid url parameter', { status: 400 })
const MAX_YOUTUBE_THUMBNAIL_SCALAR_SIZE = 16383
const fetchImage = async (): Promise<Response> => {
const MAX_TRIES = 3
let tries = 0
while (tries < MAX_TRIES) {
++tries
const response = await fetch(imageUrl).catch(() => null)
// TODO: It is possible to get images through many paths in the jellyfin API. To add support for a path, add a regex for it
const jellyfinImagePathnames = [/^\/Items\/([0-9a-f]{32})\/Images\/(Primary|Art|Backdrop|Banner|Logo|Thumb|Disc|Box|Screenshot|Menu|Chapter|BoxRear|Profile)$/]
function modifyImageURL(imageURL: URL, options?: { maxWidth?: number; maxHeight?: number }): string | null {
const maxWidth = options?.maxWidth
const maxHeight = options?.maxHeight
const baseURL = imageURL.origin.concat(imageURL.pathname)
// * YouTube Check
switch (imageURL.origin) {
case 'https://i.ytimg.com':
case 'https://www.gstatic.com':
// These two origins correspond to images that can't have their size modified with search params, so we just return them at the default res
return baseURL
case 'https://lh3.googleusercontent.com':
case 'https://yt3.googleusercontent.com':
case 'https://yt3.ggpht.com':
case 'https://music.youtube.com':
const fakeQueryParams = []
if (maxWidth) fakeQueryParams.push(`w${Math.min(maxWidth, MAX_YOUTUBE_THUMBNAIL_SCALAR_SIZE)}`)
if (maxHeight) fakeQueryParams.push(`h${Math.min(maxHeight, MAX_YOUTUBE_THUMBNAIL_SCALAR_SIZE)}`)
return fakeQueryParams.length > 0 ? baseURL.concat(`=${fakeQueryParams.join('-')}`) : baseURL
}
// * YouTube Check
// * Jellyfin Check
if (jellyfinImagePathnames.some((regex) => regex.test(imageURL.pathname))) {
const imageParams = new URLSearchParams()
if (maxWidth) imageParams.append('maxWidth', maxWidth.toString())
if (maxHeight) imageParams.append('maxHeight', maxHeight.toString())
return imageParams.size > 0 ? baseURL.concat(`?${imageParams.toString()}`) : baseURL
}
// * Jellyfin Check
// * By this point the URL does not match any of the expected formats, so we return null
return null
}
export const GET: RequestHandler = async ({ url }) => {
const imageUrlString = url.searchParams.get('url')
if (!imageUrlString || !URL.canParse(imageUrlString)) return new Response('Missing or invalid url parameter', { status: 400 })
const maxWidthInput = Number(url.searchParams.get('maxWidth'))
const maxHeightInput = Number(url.searchParams.get('maxHeight'))
const maxWidth = !Number.isNaN(maxWidthInput) && maxWidthInput > 0 ? Math.ceil(maxWidthInput) : undefined
const maxHeight = !Number.isNaN(maxHeightInput) && maxHeightInput > 0 ? Math.ceil(maxHeightInput) : undefined
const imageURL = modifyImageURL(new URL(imageUrlString), { maxWidth, maxHeight })
if (!imageURL) return new Response('Unrecognized external image url format: ' + imageUrlString, { status: 400 })
for (let tries = 0; tries < 3; ++tries) {
const response = await fetch(imageURL).catch(() => null)
if (!response || !response.ok) continue
const contentType = response.headers.get('content-type')
if (!contentType || !contentType.startsWith('image')) throw Error(`Url ${imageUrl} does not link to an image`)
if (!contentType || !contentType.startsWith('image')) return new Response(`Url ${imageUrlString} does not link to an image`, { status: 400 })
return response
}
throw Error(`Failed to fetch image at ${url} Exceed Max Retires`)
}
return await fetchImage()
return new Response(`Failed to fetch image at ${imageURL}: Exceed Max Retires`, { status: 502 })
}