AutoImage replaced LazyImage && general improvements to components with style props
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user