AutoImage replaced LazyImage && general improvements to components with style props
This commit is contained in:
2
src/app.d.ts
vendored
2
src/app.d.ts
vendored
@@ -1,3 +1,5 @@
|
||||
import 'unplugin-icons/types/svelte4.d.ts'
|
||||
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { redirect, type Handle, type RequestEvent } from '@sveltejs/kit'
|
||||
import { SECRET_INTERNAL_API_KEY, SECRET_JWT_KEY } from '$env/static/private'
|
||||
import { userExists, mixExists } from '$lib/server/api-helper'
|
||||
import { userExists, connectionExists, mixExists } from '$lib/server/api-helper'
|
||||
import jwt from 'jsonwebtoken'
|
||||
|
||||
function verifyAuthToken(event: RequestEvent) {
|
||||
@@ -23,6 +23,9 @@ const handleAPIRequest: Handle = async ({ event, resolve }) => {
|
||||
const userId = event.params.userId
|
||||
if (userId && !(await userExists(userId))) return new Response(`User ${userId} not found`, { status: 404 })
|
||||
|
||||
const connectionId = event.params.connectionId
|
||||
if (connectionId && !(await connectionExists(connectionId))) return new Response(`Connection ${connectionId} not found`, { status: 404 })
|
||||
|
||||
const mixId = event.params.mixId
|
||||
if (mixId && !(await mixExists(mixId))) return new Response(`Mix ${mixId} not found`, { status: 404 })
|
||||
|
||||
|
||||
6
src/lib/api-helper.ts
Normal file
6
src/lib/api-helper.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import ky from 'ky'
|
||||
|
||||
export const apiV1 = ky.create({
|
||||
prefixUrl: '/api/v1/',
|
||||
credentials: 'include',
|
||||
})
|
||||
@@ -1,39 +1,35 @@
|
||||
<script lang="ts">
|
||||
import LazyImage from '$lib/components/media/lazyImage.svelte'
|
||||
import AutoImage from './autoImage.svelte'
|
||||
import IconButton from '$lib/components/util/iconButton.svelte'
|
||||
import ArtistList from '$lib/components/media/artistList.svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import { queue, newestAlert } from '$lib/stores'
|
||||
import { apiV1 } from '$lib/api-helper'
|
||||
import ServiceLogo from '$lib/components/util/serviceLogo.svelte'
|
||||
|
||||
export let album: Album
|
||||
|
||||
async function playAlbum() {
|
||||
const itemsResponse = await fetch(`/api/connections/${album.connection.id}/album/${album.id}/items`, {
|
||||
credentials: 'include',
|
||||
}).catch(() => null)
|
||||
|
||||
if (!itemsResponse || !itemsResponse.ok) {
|
||||
try {
|
||||
const response = await apiV1.get(`connections/${album.connection.id}/album/${album.id}/items`).json<{ items: Song[] }>()
|
||||
$queue.setQueue(response.items)
|
||||
} catch {
|
||||
$newestAlert = ['warning', 'Failed to play album']
|
||||
return
|
||||
}
|
||||
|
||||
const data = (await itemsResponse.json()) as { items: Song[] }
|
||||
$queue.setQueue(data.items)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="overflow-hidden">
|
||||
<div id="thumbnail-wrapper" class="relative aspect-square w-full overflow-clip rounded">
|
||||
<button id="thumbnail" class="h-full w-full" on:click={() => goto(`/details/album?id=${album.id}&connection=${album.connection.id}`)}>
|
||||
<LazyImage thumbnailUrl={album.thumbnailUrl} alt={`${album.name} jacket`} objectFit={'cover'} />
|
||||
<AutoImage thumbnailUrl={album.thumbnailUrl} alt={`${album.name} jacket`} --object-fit="cover" --border-radius="0.25rem" --height="100%" />
|
||||
</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 transition-opacity">
|
||||
<IconButton halo={true} on:click={playAlbum}>
|
||||
<i slot="icon" class="fa-solid fa-play text-2xl" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div id="connection-type-icon" class="absolute left-2 top-2 h-9 w-9 opacity-0 transition-opacity">
|
||||
<div id="connection-type-icon" class="absolute left-2 top-2 h-6 w-6 opacity-0 transition-opacity">
|
||||
<ServiceLogo type={album.connection.type} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,9 +48,6 @@
|
||||
#thumbnail-wrapper:hover > #play-button {
|
||||
opacity: 1;
|
||||
}
|
||||
/* #connection-type-icon {
|
||||
filter: grayscale();
|
||||
} */
|
||||
#thumbnail-wrapper:hover > #connection-type-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
57
src/lib/components/media/autoImage.svelte
Normal file
57
src/lib/components/media/autoImage.svelte
Normal file
@@ -0,0 +1,57 @@
|
||||
<!--
|
||||
@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 loading 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 loading: 'lazy' | 'eager' = 'lazy'
|
||||
|
||||
let currentSlot: number = 1 // 1 | 2
|
||||
|
||||
let imageContainer: HTMLDivElement | undefined
|
||||
let slot1: HTMLImageElement | undefined
|
||||
let slot2: HTMLImageElement | undefined
|
||||
|
||||
const SIZE_TO_PIXEL_FACTOR = 1.5 // Images will be fetched with a pixel density 1.5x the size of its container. This is a good compromise between sharpness and performance
|
||||
|
||||
// ? Maybe implement auto-resizing
|
||||
function updateImage(newThumbnailURL: string) {
|
||||
if (!(slot1 && slot2 && imageContainer)) return
|
||||
|
||||
const maxWidth = imageContainer.clientWidth * SIZE_TO_PIXEL_FACTOR
|
||||
const maxHeight = imageContainer.clientHeight * SIZE_TO_PIXEL_FACTOR
|
||||
|
||||
const imageSrc = `/api/remoteImage?url=${newThumbnailURL}&${maxWidth > maxHeight ? `maxWidth=${maxWidth}` : `maxHeight=${maxHeight}`}`
|
||||
currentSlot === 1 ? (slot2.src = imageSrc) : (slot1.src = imageSrc)
|
||||
}
|
||||
|
||||
onMount(() => updateImage(thumbnailUrl))
|
||||
$: updateImage(thumbnailUrl)
|
||||
</script>
|
||||
|
||||
<div id="image-container" bind:this={imageContainer} class="grid">
|
||||
<img bind:this={slot1} {alt} {loading} class:opacity-0={currentSlot === 2} class:hidden={!slot1 || slot1.src === ''} class="h-full transition-opacity duration-500" on:load={() => (currentSlot = 1)} />
|
||||
<img bind:this={slot2} {alt} {loading} class:opacity-0={currentSlot === 1} class:hidden={!slot1 || slot2.src === ''} class="h-full transition-opacity duration-500" on:load={() => (currentSlot = 2)} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#image-container {
|
||||
height: var(--height);
|
||||
}
|
||||
img {
|
||||
grid-area: 1 / 1;
|
||||
object-fit: var(--object-fit);
|
||||
object-position: var(--object-position);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
</style>
|
||||
@@ -1,70 +0,0 @@
|
||||
<!--
|
||||
@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.
|
||||
@param objectFit One of the following fill, contain, cover, none, scale-down. Specifies the object-fit styling of the image
|
||||
@param objectPosistion Optional. Specifies the object-position styling of the image. Defaults to 'center'
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
export let thumbnailUrl: string
|
||||
export let alt: string
|
||||
export let loadingMethod: 'lazy' | 'eager' = 'lazy'
|
||||
export let objectFit: 'fill' | 'contain' | 'cover' | 'none' | 'scale-down'
|
||||
export let objectPosition: string = 'center'
|
||||
|
||||
let imageContainer: HTMLDivElement
|
||||
|
||||
// TODO: Implement auto-resizing
|
||||
function updateImage(newThumbnailURL: string) {
|
||||
if (!imageContainer) return
|
||||
|
||||
const width = imageContainer.clientWidth * 1.5 // 1.5x is a good compromise between sharpness and performance
|
||||
const height = imageContainer.clientHeight * 1.5
|
||||
|
||||
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 = objectFit
|
||||
newImage.style.objectPosition = objectPosition
|
||||
newImage.style.opacity = '0'
|
||||
newImage.style.position = 'absolute'
|
||||
|
||||
function removeOldImage() {
|
||||
if (imageContainer.childElementCount > 1) {
|
||||
const oldImage = imageContainer.firstChild! as HTMLImageElement
|
||||
oldImage.style.opacity = '0'
|
||||
setTimeout(() => imageContainer.removeChild(oldImage), 500)
|
||||
}
|
||||
}
|
||||
|
||||
newImage.onload = () => {
|
||||
removeOldImage()
|
||||
newImage.style.transition = 'opacity 500ms ease'
|
||||
newImage.style.opacity = '1'
|
||||
}
|
||||
|
||||
newImage.onerror = () => {
|
||||
console.error(`Image from url: ${newThumbnailURL} failed to update`)
|
||||
imageContainer.removeChild(newImage)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => updateImage(thumbnailUrl))
|
||||
$: updateImage(thumbnailUrl)
|
||||
</script>
|
||||
|
||||
<div bind:this={imageContainer} class="relative h-full w-full"></div>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import LazyImage from './lazyImage.svelte'
|
||||
import AutoImage from './autoImage.svelte'
|
||||
import ArtistList from './artistList.svelte'
|
||||
|
||||
export let mediaItem: Song | Album | Artist | Playlist
|
||||
@@ -12,7 +12,7 @@
|
||||
<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`} objectFit={'cover'} />
|
||||
<AutoImage {thumbnailUrl} alt="{mediaItem.name} jacket" --object-fit="cover" />
|
||||
{: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" />
|
||||
|
||||
@@ -4,8 +4,16 @@
|
||||
import IconButton from '$lib/components/util/iconButton.svelte'
|
||||
import Slider from '$lib/components/util/slider.svelte'
|
||||
import Loader from '$lib/components/util/loader.svelte'
|
||||
import AutoImage from '$lib/components/media/autoImage.svelte'
|
||||
import PhQueueBold from '~icons/ph/queue-bold'
|
||||
import PhShuffleBold from '~icons/ph/shuffle-bold'
|
||||
import PhRepeatBold from '~icons/ph/repeat-bold'
|
||||
import BxVolumeFull from '~icons/bx/volume-full'
|
||||
import BxVolumeLow from '~icons/bx/volume-low'
|
||||
import BxVolume from '~icons/bx/volume'
|
||||
import MiExpand from '~icons/mi/expand'
|
||||
import { onMount, createEventDispatcher } from 'svelte'
|
||||
import { slide } from 'svelte/transition'
|
||||
import { slide, fade } from 'svelte/transition'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
@@ -76,15 +84,10 @@
|
||||
let playerWidth: number
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:clientWidth={playerWidth}
|
||||
transition:slide
|
||||
id="player"
|
||||
class="fixed {playerWidth > 800 ? 'bottom-0 left-0 right-0' : 'bottom-3 left-3 right-3 rounded-lg'} flex h-20 items-center gap-4 overflow-clip bg-neutral-925 px-2 text-neutral-400 transition-all"
|
||||
>
|
||||
<div id="details" class="flex h-full items-center gap-3 overflow-clip py-2" style="backgrounds: linear-gradient(to right, red, blue); flex-grow: 1; flex-basis: 18rem">
|
||||
<div bind:clientWidth={playerWidth} transition:slide id="player" class="flex h-20 items-center gap-4 overflow-clip bg-neutral-925 px-2.5 text-neutral-400">
|
||||
<div id="details" class="flex h-full items-center gap-3 overflow-clip py-2.5" style="backgrounds: linear-gradient(to right, red, blue); flex-grow: 1; flex-basis: 16rem">
|
||||
<div class="relative aspect-square h-full">
|
||||
<img src="/api/remoteImage?url={mediaItem.thumbnailUrl}&maxHeight=96" alt="jacket" class="h-full w-full rounded object-cover" />
|
||||
<AutoImage thumbnailUrl={mediaItem.thumbnailUrl} alt="{mediaItem.name} jacket" loading="eager" --object-fit="cover" --height="100%" --border-radius="0.25rem" />
|
||||
<div id="jacket-play-button" class:hidden={playerWidth > 650} class="absolute bottom-0 left-0 right-0 top-0 backdrop-brightness-50">
|
||||
<IconButton on:click={() => (paused = !paused)}>
|
||||
<i slot="icon" class="fa-solid {paused ? 'fa-play' : 'fa-pause'} text-2xl text-neutral-200" />
|
||||
@@ -93,15 +96,15 @@
|
||||
</div>
|
||||
<div class="flex flex-col justify-center gap-1">
|
||||
<ScrollingText>
|
||||
<span slot="text" class="line-clamp-1 text-sm font-semibold text-neutral-200">{mediaItem.name}</span>
|
||||
<span slot="text" title={mediaItem.name} class="line-clamp-1 text-sm font-medium text-neutral-200">{mediaItem.name}</span>
|
||||
</ScrollingText>
|
||||
<div class="line-clamp-1 text-xs">
|
||||
<ArtistList {mediaItem} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-8">
|
||||
<IconButton color={'#ec4899'} on:click={() => (favorite = !favorite)}>
|
||||
<i slot="icon" class={favorite ? 'fa-solid fa-heart text-pink-500' : 'fa-regular fa-heart'} />
|
||||
<IconButton --color="#ec4899" toggled={favorite} on:click={() => (favorite = !favorite)}>
|
||||
<i slot="icon" class={favorite ? 'fa-solid fa-heart' : 'fa-regular fa-heart'} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<span class:hidden={playerWidth > 700 || playerWidth < 400} class="ml-auto whitespace-nowrap text-xs">{currentTimestamp} / {durationTimestamp}</span>
|
||||
@@ -150,25 +153,36 @@
|
||||
{#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 --hover-color="#e5e5e5" toggled={shuffled} on:click={() => dispatch('toggleShuffle')}>
|
||||
<PhShuffleBold slot="icon" />
|
||||
</IconButton>
|
||||
<IconButton on:click={() => (loop = !loop)}>
|
||||
<i slot="icon" class:text-lazuli-primary={loop} class="fa-solid fa-repeat" />
|
||||
<IconButton --hover-color="#e5e5e5" toggled={loop} on:click={() => (loop = !loop)}>
|
||||
<PhRepeatBold slot="icon" />
|
||||
</IconButton>
|
||||
<IconButton --hover-color="#e5e5e5">
|
||||
<PhQueueBold slot="icon" />
|
||||
</IconButton>
|
||||
<div class="flex h-full items-center gap-1">
|
||||
<IconButton on:click={() => (volume = volume > 0 ? 0 : getStoredVolume())}>
|
||||
<i slot="icon" class="fa-solid {volume > MAX_VOLUME / 2 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'}" />
|
||||
<IconButton --hover-color="#e5e5e5" on:click={() => (volume = volume > 0 ? 0 : getStoredVolume())}>
|
||||
<span slot="icon" class="relative grid place-items-center">
|
||||
{#if volume > MAX_VOLUME / 2}
|
||||
<span class="absolute" in:fade={{ duration: 200 }} out:fade={{ duration: 200, delay: 100 }}><BxVolumeFull /></span>
|
||||
{:else if volume > 0}
|
||||
<span class="absolute" in:fade={{ duration: 200 }} out:fade={{ duration: 200, delay: 100 }}><BxVolumeLow /></span>
|
||||
{:else}
|
||||
<span class="absolute" in:fade={{ duration: 200 }} out:fade={{ duration: 200, delay: 100 }}><BxVolume /></span>
|
||||
{/if}
|
||||
</span>
|
||||
</IconButton>
|
||||
<div class="mr-2 w-20">
|
||||
<Slider bind:value={volume} max={MAX_VOLUME} on:seeked={() => (volume > 0 ? localStorage.setItem('volume', volume.toString()) : null)} />
|
||||
</div>
|
||||
</div>
|
||||
<IconButton>
|
||||
<i slot="icon" class="fa-solid fa-up-right-and-down-left-from-center" />
|
||||
<IconButton --hover-color="#e5e5e5">
|
||||
<MiExpand slot="icon" />
|
||||
</IconButton>
|
||||
{/if}
|
||||
<IconButton>
|
||||
<IconButton --hover-color="#e5e5e5">
|
||||
<i slot="icon" class="fa-solid fa-ellipsis-vertical" />
|
||||
</IconButton>
|
||||
</div>
|
||||
@@ -189,3 +203,13 @@
|
||||
on:error={() => setTimeout(() => audioElement.load(), 5000)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#player {
|
||||
border-radius: var(--border-radius);
|
||||
transition: border-radius 150ms linear;
|
||||
-webkit-box-shadow: 0px 0px 80px 0px rgba(0, 0, 0, 0.75);
|
||||
-moz-box-shadow: 0px 0px 80px 0px rgba(0, 0, 0, 0.75);
|
||||
box-shadow: 0px 0px 80px 0px rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
import { queue } from '$lib/stores'
|
||||
import Services from '$lib/services.json'
|
||||
// import { FastAverageColor } from 'fast-average-color'
|
||||
import AutoImage from './autoImage.svelte'
|
||||
import Slider from '$lib/components/util/slider.svelte'
|
||||
import Loader from '$lib/components/util/loader.svelte'
|
||||
import LazyImage from './lazyImage.svelte'
|
||||
import IconButton from '$lib/components/util/iconButton.svelte'
|
||||
import ScrollingText from '$lib/components/util/scrollingText.svelte'
|
||||
import ArtistList from './artistList.svelte'
|
||||
@@ -109,7 +109,7 @@
|
||||
<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'} />
|
||||
<AutoImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt="{currentlyPlaying.name} jacket" --object-fit="cover" />
|
||||
</div>
|
||||
<section class="flex flex-grow flex-col justify-center gap-1">
|
||||
<div class="h-6">
|
||||
@@ -185,7 +185,7 @@
|
||||
{: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'} />
|
||||
<AutoImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt="" --object-fit="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]">
|
||||
@@ -202,7 +202,7 @@
|
||||
<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'} />
|
||||
<AutoImage thumbnailUrl={next.thumbnailUrl} alt="{next.name} jacket" --object-fit="cover" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-0.5 line-clamp-1 font-medium">{next.name}</div>
|
||||
@@ -214,7 +214,7 @@
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
<LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={`${currentlyPlaying.name} jacket`} objectFit={'contain'} objectPosition={'left'} />
|
||||
<AutoImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt="{currentlyPlaying.name} jacket" --object-fit="contain" --object-position="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">
|
||||
|
||||
62
src/lib/components/media/playlistCard.svelte
Normal file
62
src/lib/components/media/playlistCard.svelte
Normal file
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import AutoImage from './autoImage.svelte'
|
||||
import IconButton from '$lib/components/util/iconButton.svelte'
|
||||
import ArtistList from '$lib/components/media/artistList.svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import { queue, newestAlert } from '$lib/stores'
|
||||
import { apiV1 } from '$lib/api-helper'
|
||||
import ServiceLogo from '$lib/components/util/serviceLogo.svelte'
|
||||
|
||||
export let playlist: Playlist
|
||||
|
||||
async function playPlaylist() {
|
||||
try {
|
||||
const initialResponse = await apiV1.get(`connections/${playlist.connection.id}/playlist/${playlist.id}/items?startIndex=0&limit=50`).json<{ items: Song[] }>()
|
||||
$queue.setQueue(initialResponse.items)
|
||||
|
||||
if (initialResponse.items.length < 50) return
|
||||
|
||||
const remainderResponse = await apiV1.get(`connections/${playlist.connection.id}/playlist/${playlist.id}/items?startIndex=50`).json<{ items: Song[] }>()
|
||||
$queue.enqueue(...remainderResponse.items)
|
||||
} catch {
|
||||
$newestAlert = ['warning', 'Failed to play playlist']
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="overflow-hidden">
|
||||
<div id="thumbnail-wrapper" class="relative aspect-square w-full overflow-clip rounded">
|
||||
<button id="thumbnail" class="h-full w-full" on:click={() => goto(`/details/playlist?id=${playlist.id}&connection=${playlist.connection.id}`)}>
|
||||
<AutoImage thumbnailUrl={playlist.thumbnailUrl} alt="{playlist.name} jacket" --object-fit="cover" --height="100%" --border-radius="0.25rem" />
|
||||
</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 transition-opacity">
|
||||
<IconButton halo={true} on:click={playPlaylist}>
|
||||
<i slot="icon" class="fa-solid fa-play text-2xl" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div id="connection-type-icon" class="absolute left-2 top-2 h-6 w-6 opacity-0 transition-opacity">
|
||||
<ServiceLogo type={playlist.connection.type} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-2 text-center text-sm">
|
||||
<div class="line-clamp-2">{playlist.name}</div>
|
||||
<div class="line-clamp-2 text-neutral-400">
|
||||
<ArtistList mediaItem={playlist} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#thumbnail-wrapper:hover > #thumbnail {
|
||||
filter: brightness(35%);
|
||||
}
|
||||
#thumbnail-wrapper:hover > #play-button {
|
||||
opacity: 1;
|
||||
}
|
||||
#thumbnail-wrapper:hover > #connection-type-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
#thumbnail {
|
||||
transition: filter 150ms ease;
|
||||
}
|
||||
</style>
|
||||
0
src/lib/components/media/songCard.svelte
Normal file
0
src/lib/components/media/songCard.svelte
Normal file
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
export let toggled = false
|
||||
export let disabled = false
|
||||
export let halo = false
|
||||
export let color = 'var(--lazuli-primary)'
|
||||
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
</script>
|
||||
|
||||
<button
|
||||
class:toggled
|
||||
class:disabled
|
||||
class:halo
|
||||
class="relative grid aspect-square h-full place-items-center transition-transform duration-75 active:scale-90"
|
||||
style="--button-color: {color}"
|
||||
on:click|preventDefault|stopPropagation={() => dispatch('click')}
|
||||
{disabled}
|
||||
>
|
||||
@@ -27,7 +27,7 @@
|
||||
content: '';
|
||||
width: 0;
|
||||
height: 0;
|
||||
background-color: color-mix(in srgb, var(--button-color) 20%, transparent);
|
||||
background-color: color-mix(in srgb, var(--color, var(--lazuli-primary)) 20%, transparent);
|
||||
border-radius: 100%;
|
||||
transition-property: width height;
|
||||
transition-duration: 200ms;
|
||||
@@ -40,7 +40,10 @@
|
||||
button :global(> :first-child) {
|
||||
transition: color 200ms;
|
||||
}
|
||||
button:not(.disabled):hover :global(> :first-child) {
|
||||
color: var(--button-color);
|
||||
button:not(.disabled).toggled :global(> :first-child) {
|
||||
color: var(--color, var(--lazuli-primary));
|
||||
}
|
||||
button:not(.disabled):not(.toggled):hover :global(> :first-child) {
|
||||
color: var(--hover-color, var(--color, var(--lazuli-primary)));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts">
|
||||
import IconButton from './iconButton.svelte'
|
||||
import MingcuteMenuLine from '~icons/mingcute/menu-line'
|
||||
import { goto } from '$app/navigation'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let searchBar: HTMLElement, searchInput: HTMLInputElement, searchBarWidth: number
|
||||
let searchInput: HTMLInputElement, searchBarWidth: number
|
||||
|
||||
const HIDE_SEARCHBAR_BREAKPOINT_PX = 300
|
||||
$: showSearchbar = searchBarWidth > HIDE_SEARCHBAR_BREAKPOINT_PX
|
||||
@@ -25,7 +26,7 @@
|
||||
<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" />
|
||||
<MingcuteMenuLine slot="icon" class="text-lg" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<!-- --------------This is a placeholder image-------------- -->
|
||||
@@ -35,7 +36,6 @@
|
||||
{#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);"
|
||||
>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
let slidingTextWidth: number, slidingTextWrapperWidth: number
|
||||
let scrollDirection: 1 | -1 = 1
|
||||
$: scrollDistance = slidingTextWidth - slidingTextWrapperWidth
|
||||
$: if (slidingText && scrollDistance > 0) slidingText.style.animationDuration = `${scrollDistance / 40}s`
|
||||
$: if (slidingText && scrollDistance > 0) slidingText.style.animationDuration = `${scrollDistance / 30}s`
|
||||
</script>
|
||||
|
||||
<div bind:clientWidth={slidingTextWrapperWidth} class="relative h-full w-full overflow-clip">
|
||||
|
||||
@@ -3,16 +3,20 @@
|
||||
import { sineOut } from 'svelte/easing'
|
||||
import { goto } from '$app/navigation'
|
||||
import IconButton from './iconButton.svelte'
|
||||
import PhPlaylistBold from '~icons/ph/playlist-bold'
|
||||
import MaterialSymbolsHome from '~icons/material-symbols/home'
|
||||
import IcSharpVideoLibrary from '~icons/ic/sharp-video-library'
|
||||
|
||||
type NavButton = {
|
||||
name: string
|
||||
path: string
|
||||
icon: string
|
||||
icon: any
|
||||
}
|
||||
|
||||
const navButtons: NavButton[] = [
|
||||
{ name: 'Home', path: '/', icon: 'fa-solid fa-house' },
|
||||
{ name: 'Library', path: '/library', icon: 'fa-solid fa-book' },
|
||||
{ name: 'Home', path: '/', icon: MaterialSymbolsHome },
|
||||
{ name: 'Mixes', path: '/mixes', icon: PhPlaylistBold },
|
||||
{ name: 'Library', path: '/library', icon: IcSharpVideoLibrary },
|
||||
]
|
||||
|
||||
const OPEN_CLOSE_DURATION = 250
|
||||
@@ -41,7 +45,7 @@
|
||||
}}
|
||||
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} />
|
||||
<svelte:component this={tab.icon} class="text-lg" />
|
||||
{tab.name}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
@@ -6,7 +6,11 @@ export async function userExists(userId: string): Promise<boolean> {
|
||||
return Boolean(await DB.users.where('id', userId).first(DB.db.raw('EXISTS(SELECT 1)')))
|
||||
}
|
||||
|
||||
export async function mixExists(mixId: string): Promise<Boolean> {
|
||||
export async function connectionExists(connectionId: string): Promise<boolean> {
|
||||
return Boolean(await DB.connections.where('id', connectionId).first(DB.db.raw('EXISTS(SELECT 1)')))
|
||||
}
|
||||
|
||||
export async function mixExists(mixId: string): Promise<boolean> {
|
||||
return Boolean(await DB.mixes.where('id', mixId).first(DB.db.raw('EXISTS(SELECT 1)')))
|
||||
}
|
||||
|
||||
|
||||
@@ -570,6 +570,7 @@ export class YouTubeMusic implements Connection {
|
||||
*/
|
||||
public async getSongs(ids: Iterable<string>): Promise<Song[]> {
|
||||
const uniqueIds = new Set(ids)
|
||||
if (uniqueIds.size === 0) return []
|
||||
|
||||
const response = await this.api.v1.WEB_REMIX('music/get_queue', { json: { videoIds: Array.from(uniqueIds) } }).json<InnerTube.Queue.Response>()
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
export const generateUUID = (): string => {
|
||||
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c: any) => (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16))
|
||||
}
|
||||
|
||||
export const getDeviceUUID = (): string => {
|
||||
const existingUUID = localStorage.getItem('deviceUUID')
|
||||
if (existingUUID) return existingUUID
|
||||
|
||||
const newUUID = generateUUID()
|
||||
localStorage.setItem('deviceUUID', newUUID)
|
||||
return newUUID
|
||||
}
|
||||
@@ -9,25 +9,32 @@
|
||||
|
||||
$: currentlyPlaying = $queue.current
|
||||
$: shuffled = $queue.isShuffled
|
||||
|
||||
let playerWidth: number
|
||||
</script>
|
||||
|
||||
<main id="grid-wrapper" class="h-full">
|
||||
<main id="grid-wrapper" class="relative h-full">
|
||||
<Navbar on:opensidebar={sidebar.open} />
|
||||
<Sidebar bind:this={sidebar} />
|
||||
<section id="content-wrapper" class="overflow-y-scroll no-scrollbar">
|
||||
<slot />
|
||||
<div class="my-8">
|
||||
<slot />
|
||||
</div>
|
||||
{#if currentlyPlaying}
|
||||
<div bind:clientWidth={playerWidth} class="sticky {playerWidth > 800 ? 'bottom-0' : 'bottom-3 mx-3'} transition-all">
|
||||
<MediaPlayer
|
||||
mediaItem={currentlyPlaying}
|
||||
{shuffled}
|
||||
mediaSession={'mediaSession' in navigator ? navigator.mediaSession : null}
|
||||
--border-radius={playerWidth > 800 ? '0' : '0.5rem'}
|
||||
on:stop={() => $queue.clear()}
|
||||
on:next={() => $queue.next()}
|
||||
on:previous={() => $queue.previous()}
|
||||
on:toggleShuffle={() => $queue.toggleShuffle()}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{#if currentlyPlaying}
|
||||
<MediaPlayer
|
||||
mediaItem={currentlyPlaying}
|
||||
{shuffled}
|
||||
mediaSession={'mediaSession' in navigator ? navigator.mediaSession : null}
|
||||
on:stop={() => $queue.clear()}
|
||||
on:next={() => $queue.next()}
|
||||
on:previous={() => $queue.previous()}
|
||||
on:toggleShuffle={() => $queue.toggleShuffle()}
|
||||
/>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@@ -35,4 +42,8 @@
|
||||
display: grid;
|
||||
grid-template-rows: min-content auto;
|
||||
}
|
||||
#content-wrapper {
|
||||
display: grid;
|
||||
grid-template-rows: auto min-content;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types'
|
||||
import Loader from '$lib/components/util/loader.svelte'
|
||||
import MediaCard from '$lib/components/media/mediaCard.svelte'
|
||||
import AlbumCard from '$lib/components/media/albumCard.svelte'
|
||||
import PlaylistCard from '$lib/components/media/playlistCard.svelte'
|
||||
|
||||
export let data: PageData
|
||||
</script>
|
||||
@@ -11,9 +11,13 @@
|
||||
{#await data.recommendations}
|
||||
<Loader />
|
||||
{:then 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} />
|
||||
<div id="card-wrapper" class="grid w-full gap-4 justify-self-center px-[5%]">
|
||||
{#each recommendations as mediaItem}
|
||||
{#if mediaItem.type === 'album'}
|
||||
<AlbumCard album={mediaItem} />
|
||||
{:else if mediaItem.type === 'playlist'}
|
||||
<PlaylistCard playlist={mediaItem} />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/await}
|
||||
|
||||
14
src/routes/(app)/mixes/+page.svelte
Normal file
14
src/routes/(app)/mixes/+page.svelte
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import IconButton from '$lib/components/util/iconButton.svelte'
|
||||
import IcBaselinePlus from '~icons/ic/baseline-plus'
|
||||
</script>
|
||||
|
||||
<main class="flex flex-wrap justify-center">
|
||||
<div class="grid aspect-square h-56 place-items-center">
|
||||
<div class="aspect-square h-16">
|
||||
<IconButton halo={true}>
|
||||
<IcBaselinePlus slot="icon" class="text-4xl text-neutral-300" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -7,7 +7,6 @@
|
||||
import { newestAlert } from '$lib/stores.js'
|
||||
import type { PageServerData } from './$types.js'
|
||||
import type { SubmitFunction } from '@sveltejs/kit'
|
||||
import { getDeviceUUID } from '$lib/utils'
|
||||
import { SvelteComponent, type ComponentType } from 'svelte'
|
||||
import ConnectionProfile from './connectionProfile.svelte'
|
||||
import { enhance } from '$app/forms'
|
||||
@@ -21,6 +20,15 @@
|
||||
|
||||
data.connections.then((userConnections) => ('error' in userConnections ? (errorMessage = userConnections.error) : (connections = userConnections)))
|
||||
|
||||
function getDeviceUUID(): string {
|
||||
const existingUUID = localStorage.getItem('deviceUUID')
|
||||
if (existingUUID) return existingUUID
|
||||
|
||||
const newUUID = '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c: any) => (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16))
|
||||
localStorage.setItem('deviceUUID', newUUID)
|
||||
return newUUID
|
||||
}
|
||||
|
||||
const authenticateJellyfin: SubmitFunction = ({ formData, cancel }) => {
|
||||
const { serverUrl, username, password } = Object.fromEntries(formData)
|
||||
|
||||
|
||||
@@ -64,12 +64,12 @@ function modifyImageURL(imageURL: URL, options?: { maxWidth?: number; maxHeight?
|
||||
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
|
||||
case 'https://music.youtube.com':
|
||||
// These 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)}`)
|
||||
|
||||
19
src/routes/api/v1/connections/+server.ts
Normal file
19
src/routes/api/v1/connections/+server.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit'
|
||||
import { ConnectionFactory } from '$lib/server/api-helper'
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const ids = url.searchParams.get('id')?.replace(/\s/g, '').split(',')
|
||||
if (!ids) return new Response('Missing id query parameter', { status: 400 })
|
||||
|
||||
const connections = (await Promise.all(ids.map((id) => ConnectionFactory.getConnection(id).catch(() => null)))).filter((result): result is Connection => result !== null)
|
||||
|
||||
const getConnectionInfo = (connection: Connection) =>
|
||||
connection.getConnectionInfo().catch((reason) => {
|
||||
console.error(`Failed to fetch connection info: ${reason}`)
|
||||
return null
|
||||
})
|
||||
|
||||
const connectionInfo = (await Promise.all(connections.map(getConnectionInfo))).filter((connectionInfo): connectionInfo is ConnectionInfo => connectionInfo !== null)
|
||||
|
||||
return Response.json({ connections: connectionInfo })
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit'
|
||||
import { ConnectionFactory } from '$lib/server/api-helper'
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url }) => {
|
||||
const connection = await ConnectionFactory.getConnection(params.connectionId!)
|
||||
|
||||
const albumId = url.searchParams.get('id')
|
||||
if (!albumId) return new Response(`Missing id search parameter`, { status: 400 })
|
||||
|
||||
const album = await connection.getAlbum(albumId).catch(() => undefined)
|
||||
if (!album) return new Response(`Failed to fetch album with id: ${albumId}`, { status: 400 })
|
||||
|
||||
return Response.json({ album })
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit'
|
||||
import { ConnectionFactory } from '$lib/server/api-helper'
|
||||
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
const connection = await ConnectionFactory.getConnection(params.connectionId!)
|
||||
|
||||
const items = await connection.getAlbumItems(params.albumId!).catch(() => null)
|
||||
if (!items) return new Response(`Failed to fetch album with id: ${params.albumId!}`, { status: 400 })
|
||||
|
||||
return Response.json({ items })
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit'
|
||||
import { ConnectionFactory } from '$lib/server/api-helper'
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url }) => {
|
||||
const connection = await ConnectionFactory.getConnection(params.connectionId!)
|
||||
|
||||
const playlistId = url.searchParams.get('id')
|
||||
if (!playlistId) return new Response(`Missing id search parameter`, { status: 400 })
|
||||
|
||||
const response = await connection
|
||||
.getPlaylistItems(playlistId)
|
||||
.then((playlist) => Response.json({ playlist }))
|
||||
.catch((error: TypeError | Error) => {
|
||||
if (error instanceof TypeError) return new Response('Bad Request', { status: 400 })
|
||||
return new Response('Failed to fetch playlist items', { status: 502 })
|
||||
})
|
||||
|
||||
return response
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit'
|
||||
import { ConnectionFactory } from '$lib/server/api-helper'
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url }) => {
|
||||
const connection = await ConnectionFactory.getConnection(params.connectionId!)
|
||||
|
||||
const startIndexString = url.searchParams.get('startIndex')
|
||||
const limitString = url.searchParams.get('limit')
|
||||
|
||||
const numberStartIndex = Number(startIndexString)
|
||||
const numberLimit = Number(limitString)
|
||||
|
||||
const startIndex = Number.isInteger(numberStartIndex) && numberStartIndex > 0 ? numberStartIndex : undefined
|
||||
const limit = Number.isInteger(numberLimit) && numberLimit > 0 ? numberLimit : undefined
|
||||
|
||||
const response = await connection
|
||||
.getPlaylistItems(params.playlistId!, { startIndex, limit })
|
||||
.then((items) => Response.json({ items }))
|
||||
.catch((error: TypeError | Error) => {
|
||||
if (error instanceof TypeError) return new Response('Bad Request', { status: 400 })
|
||||
return new Response('Failed to fetch playlist items', { status: 502 })
|
||||
})
|
||||
|
||||
return response
|
||||
}
|
||||
Reference in New Issue
Block a user