Big improvements to the media cards, scrollable card menu in the works

This commit is contained in:
Eclypsed
2024-01-12 02:44:06 -05:00
parent 838b2fa062
commit 8acf9b3c46
12 changed files with 133 additions and 57 deletions

View File

@@ -32,7 +32,8 @@
:root { :root {
scrollbar-width: thin; /* Default scrollbar width for Firefox */ scrollbar-width: thin; /* Default scrollbar width for Firefox */
scrollbar-color: grey transparent; /* Default scrollbar colors for Firefox */ scrollbar-color: grey transparent; /* Default scrollbar colors for Firefox */
--lazuli-primary: #00a4dc;
--jellyfin-purple: #aa5cc3; --jellyfin-purple: #aa5cc3;
--jellyfin-blue: #00a4dc; --jellyfin-blue: #00a4dc;
--lazuli-primary: #ed6713; --youtube-red: #ff0000;
} }

View File

@@ -1,8 +1,22 @@
<script> <script>
export let mediaData export let mediaData
import Services from '$lib/services.json'
import IconButton from '$lib/components/utility/iconButton.svelte'
import { onMount } from 'svelte' import { onMount } from 'svelte'
let card, cardGlare, cardWidth, cardHeight const iconClasses = {
song: 'fa-solid fa-music',
album: 'fa-solid fa-compact-disc',
artist: 'fa-solid fa-user',
playlist: 'fa-solid fa-forward-fast',
}
let card,
cardGlare,
cardWidth,
cardHeight,
icon = iconClasses[mediaData.mediaType]
onMount(() => { onMount(() => {
const cardRect = card.getBoundingClientRect() const cardRect = card.getBoundingClientRect()
@@ -17,45 +31,65 @@
const x = ((event.x - cardCenterX) * 2) / cardWidth const x = ((event.x - cardCenterX) * 2) / cardWidth
const y = ((cardCenterY - event.y) * 2) / cardHeight const y = ((cardCenterY - event.y) * 2) / cardHeight
let angle = Math.atan(x / y) // <-- This is NOT how you convert to polar cooridates, well it kinda is exept now x is y and y is x because we want to calculate the direction the glare should be comming from let angle = Math.atan(x / y) // You'd think it should be y / x but it's actually the inverse
const distanceFromCorner = Math.sqrt((x - 1) ** 2 + (y - 1) ** 2) // <-- This is a cool little trick, the -1 on the x an y coordinate is effective the same as saying "make the origin of the glare [1, 1]" const distanceFromCorner = Math.sqrt((x - 1) ** 2 + (y - 1) ** 2) // This is a cool little trick, the -1 on the x an y coordinate is effective the same as saying "make the origin of the glare [1, 1]"
cardGlare.style.backgroundImage = `linear-gradient(${angle}rad, rgba(255, 255, 255, 0) ${distanceFromCorner * 50 + 50}%, rgba(255, 255, 255, 0.15) ${
distanceFromCorner * 50 + 60
}%, rgba(255, 255, 255, 0.05) 100%)`
cardGlare.style.backgroundImage = `linear-gradient(${angle}rad, transparent ${distanceFromCorner * 50 + 50}%, rgba(255, 255, 255, 0.1) ${distanceFromCorner * 50 + 60}%, transparent 100%)`
card.style.transform = `rotateX(${y * 10}deg) rotateY(${x * 10}deg)` card.style.transform = `rotateX(${y * 10}deg) rotateY(${x * 10}deg)`
} }
// TEST IMAGES (Remember to refresh! Vite doesn't retrigger the onMount calculation) ---> https://f4.bcbits.com/img/a2436961975_10.jpg | {mediaData.image} | https://i.ytimg.com/vi/yvFgNP9iqd4/maxresdefault.jpg
</script> </script>
<div id="song-card-wrapper" class="h-fit" on:mousemove={(event) => rotateCard(event)} role="button" tabindex="0" on:mouseleave={() => (card.style.transform = null)}> <a id="card-wrapper" on:mousedown|preventDefault on:mousemove={(event) => rotateCard(event)} on:mouseleave={() => (card.style.transform = null)} href="/details?id={mediaData.id}&service={mediaData.connectionId}">
<div bind:this={card} id="song-card" class="relative w-56 transition-all duration-200 ease-out"> <div bind:this={card} id="card" class="relative h-56 transition-all duration-200 ease-out">
<img id="card-image" class="aspect-square w-full rounded-md object-cover" src="{mediaData.image}?width=224&height=224" alt="{mediaData.name} art" /> {#if mediaData.image}
<div bind:this={cardGlare} id="song-glare" class="absolute top-0 aspect-square w-full opacity-0 transition-opacity duration-1000 ease-out"></div> <img id="card-image" class="h-full max-w-none rounded-lg transition-all" src={mediaData.image} alt="{mediaData.name} thumbnail" />
<div id="card-label" class="items-end p-2 text-sm"> {:else}
<span> <div id="card-image" class="grid aspect-square h-full place-items-center rounded-lg bg-lazuli-primary transition-all">
{mediaData.name} <i class="fa-solid fa-compact-disc text-7xl" />
<div class="text-neutral-400">{Array.from(mediaData.artists, (artist) => artist.name).join(', ')}</div> </div>
{/if}
<div bind:this={cardGlare} id="card-glare" class="absolute top-0 grid h-full w-full place-items-center rounded-lg opacity-0 transition-opacity duration-200 ease-out">
<span class="relative h-14">
<IconButton on:click={() => console.log(`Play ${mediaData.name}`)}>
<i slot="icon" class="fa-solid fa-play text-xl" />
</IconButton>
</span> </span>
</div> </div>
<div id="card-label" class="absolute -bottom-3 w-full px-2.5 text-sm">
<div class="overflow-hidden text-ellipsis whitespace-nowrap" title={mediaData.name}>{mediaData.name}</div>
<div class="flex w-full items-center gap-1.5 overflow-hidden text-neutral-400">
<span class="overflow-hidden text-ellipsis">{Array.from(mediaData.artists, (artist) => artist.name).join(', ')}</span>
{#if mediaData.mediaType}
<span>&bull;</span>
<i class="{icon} text-xs" style="color: var({Services[mediaData.serviceType].primaryColor});" />
{/if}
</div>
</div>
</div> </div>
</div> </a>
<style> <style>
#song-card-wrapper { #card-wrapper {
perspective: 1000px; perspective: 1000px;
perspective-origin: center;
} }
#song-card:hover { #card-wrapper:focus-within #card-image {
scale: 1.05; filter: brightness(50%);
} }
#song-card:hover > #song-glare { #card-wrapper:focus-within #card-glare {
opacity: 1; opacity: 1;
} }
/* #card-image { #card:hover {
mask-image: linear-gradient(to bottom, black, rgba(0, 0, 0, 0)); scale: 1.05;
}
#card:hover > #card-image {
filter: brightness(50%);
}
#card:hover > #card-glare {
opacity: 1;
}
#card-image {
mask-image: linear-gradient(to bottom, black 50%, transparent 95%);
} }
#card-label {
background-image: linear-gradient(to top, black 0%, transparent 30%);
} */
</style> </style>

View File

@@ -0,0 +1,46 @@
<script>
export let header = null
export let cardDataList
import Card from '$lib/components/media/mediaCard.svelte'
import { onMount } from 'svelte'
let scrollableWrapper,
scrollableWrapperWidth,
scrollable,
scrollableWidth,
isHovered,
scrollpos = 0
onMount(() => {
scrollableWrapperWidth = scrollableWrapper.clientWidth - 96 // Account for x padding
scrollableWidth = Math.abs(scrollableWrapperWidth - scrollable.scrollWidth)
})
</script>
<section>
{#if header}
<h1 class="px-12 text-4xl"><strong>{header}</strong></h1>
{/if}
<div
bind:this={scrollableWrapper}
role="menu"
tabindex="-1"
class="overflow-hidden px-12 py-4"
on:focus={() => (isHovered = true)}
on:blur={() => (isHovered = false)}
on:wheel={(event) => {
if (isHovered) {
scrollpos += event.deltaY / 2 // Change divisor to adjust speed
scrollpos = Math.min(Math.max(0, scrollpos), scrollableWidth)
scrollable.style.transform = `translateX(-${scrollpos}px)`
}
}}
>
<div id="scrollable" bind:this={scrollable} class="no-scrollbar flex gap-6 transition-transform duration-200 ease-out">
{#each cardDataList as cardData}
<Card mediaData={cardData} />
{/each}
</div>
</div>
</section>

View File

@@ -4,7 +4,7 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
</script> </script>
<button class="relative grid aspect-square h-full place-items-center transition-transform duration-75 active:scale-90" on:click={() => dispatch('click')}> <button class="relative grid aspect-square h-full place-items-center transition-transform duration-75 active:scale-90" on:click|preventDefault={() => dispatch('click')}>
<slot name="icon" /> <slot name="icon" />
</button> </button>

View File

@@ -2,11 +2,13 @@
"jellyfin": { "jellyfin": {
"displayName": "Jellyfin", "displayName": "Jellyfin",
"type": ["streaming"], "type": ["streaming"],
"icon": "https://raw.githubusercontent.com/jellyfin/jellyfin-ux/55616553b692b1a6c7d8e786eeb7d8216e9b50df/branding/SVG/icon-transparent.svg" "icon": "https://raw.githubusercontent.com/jellyfin/jellyfin-ux/55616553b692b1a6c7d8e786eeb7d8216e9b50df/branding/SVG/icon-transparent.svg",
"primaryColor": "--jellyfin-blue"
}, },
"youtube-music": { "youtube-music": {
"displayName": "YouTube Music", "displayName": "YouTube Music",
"type": ["streaming"], "type": ["streaming"],
"icon": "https://upload.wikimedia.org/wikipedia/commons/6/6a/Youtube_Music_icon.svg" "icon": "https://upload.wikimedia.org/wikipedia/commons/6/6a/Youtube_Music_icon.svg",
"primaryColor": "--youtube-red"
} }
} }

View File

@@ -75,11 +75,7 @@ export class JellyfinUtils {
} }
: null : null
const imageSource = songData?.ImageTags?.Primary const imageSource = songData?.ImageTags?.Primary ? new URL(`Items/${songData.Id}/Images/Primary`, serviceUrl).href : albumData?.image
? new URL(`Items/${songData.Id}/Images/Primary`, serviceUrl).href
: songData?.AlbumPrimaryImageTag
? new URL(`Items/${songData.AlbumId}/Images/Primary`, serviceUrl).href
: null
const audioSearchParams = new URLSearchParams(this.#AUDIO_PRESETS.default) const audioSearchParams = new URLSearchParams(this.#AUDIO_PRESETS.default)
audioSearchParams.append('userId', serviceUserId) audioSearchParams.append('userId', serviceUserId)

View File

@@ -1,5 +1,7 @@
import { SECRET_INTERNAL_API_KEY } from '$env/static/private' import { SECRET_INTERNAL_API_KEY } from '$env/static/private'
export const prerender = false
/** @type {import('./$types').PageServerLoad} */ /** @type {import('./$types').PageServerLoad} */
export const load = async ({ locals, fetch }) => { export const load = async ({ locals, fetch }) => {
const recommendationResponse = await fetch(`/api/user/recommendations?userId=${locals.userId}&limit=10`, { const recommendationResponse = await fetch(`/api/user/recommendations?userId=${locals.userId}&limit=10`, {

View File

@@ -1,7 +1,7 @@
<script> <script>
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { newestAlert } from '$lib/stores/alertStore.js' import { newestAlert } from '$lib/stores/alertStore.js'
import Card from '$lib/components/media/mediaCard.svelte' import ScrollableCardMenu from '$lib/components/media/scrollableCardMenu.svelte'
export let data export let data
@@ -26,13 +26,6 @@
</main> </main>
{:else} {:else}
<main id="recommendations-wrapper" class="pt-24"> <main id="recommendations-wrapper" class="pt-24">
<section> <ScrollableCardMenu header={'Listen Again'} cardDataList={data.recommendations} />
<h1 class="px-12 text-4xl"><strong>Listen Again</strong></h1>
<div class="no-scrollbar flex gap-6 overflow-scroll px-12 py-4">
{#each data.recommendations as recommendation}
<Card mediaData={recommendation} />
{/each}
</div>
</section>
</main> </main>
{/if} {/if}

View File

@@ -0,0 +1 @@
<div>Hello, this is where info about the music would go!</div>

View File

@@ -1,17 +1,17 @@
{ {
"connectionId": "Id of the connection that provides the song", "connectionId": "Id of the connection that provides the song [ALL REQ]",
"serviceType": "The type of service that provides the song", "serviceType": "The type of service that provides the song [ALL REQ]",
"mediaType": "song", "mediaType": "song || album || playlist || artist [ALL REQ]",
"name": "Name of song", "name": "Name of media [ALL OPT]",
"id": "whatever unique identifier the service provides", "id": "whatever unique identifier the service provides [ALL REQ]",
"duration": "length of song in milliseconds", "duration": "length of song in milliseconds [song OPT, album OPT, playlist OPT]",
"artists": [ "artists [song OPT]": [
{ {
"name": "Name of artist", "name": "Name of artist",
"id": "service's unique identifier for the artist" "id": "service's unique identifier for the artist"
} }
], ],
"album": { "album [song OPT]": {
"name": "Name of album", "name": "Name of album",
"id": "service's unique identifier for the album", "id": "service's unique identifier for the album",
"artists": [ "artists": [
@@ -22,10 +22,10 @@
], ],
"image": "source url of the album art" "image": "source url of the album art"
}, },
"image": "source url of image unique to the song, if one does not exist this will be the album art or in the case of videos the thumbnail", "image": "source url of image unique to the song, if one does not exist this will be the album art or in the case of videos the thumbnail [ALL OPT]",
"audio": "source url of the audio stream", "audio": "source url of the audio stream [song REQ]",
"video": "source url of the video stream (if this is not null then player will allow for video mode, otherwise use image)", "video": "source url of the video stream (if this is not null then player will allow for video mode, otherwise use image) [song OPT]",
"releaseDate": "Either the date the MV was upload or the release date/year of the album", "releaseDate": "Either the date the MV was upload or the release date/year of the album [song OPT, album OPT]",
"All of the above data": "Is comprised solely of data that has been read from the respective service and processed by a factory", "All of the above data": "Is comprised solely of data that has been read from the respective service and processed by a factory",
"the above data should not contain or rely on any information that has to be read from the lazuli server": "save for the data that was read to fetch it in the first place (connectionId, serviceType, etc.)", "the above data should not contain or rely on any information that has to be read from the lazuli server": "save for the data that was read to fetch it in the first place (connectionId, serviceType, etc.)",

View File

@@ -9,10 +9,11 @@ export default {
notoSans: ["'Noto Sans', 'Noto Sans HK', 'Noto Sans JP', 'Noto Sans KR', 'Noto Sans SC', 'Noto Sans TC'", ...defaultTheme.fontFamily.sans], notoSans: ["'Noto Sans', 'Noto Sans HK', 'Noto Sans JP', 'Noto Sans KR', 'Noto Sans SC', 'Noto Sans TC'", ...defaultTheme.fontFamily.sans],
}, },
colors: { colors: {
'lazuli-primary': '#ed6713', 'lazuli-primary': '#00a4dc',
'neutral-925': 'rgb(16, 16, 16)', 'neutral-925': 'rgb(16, 16, 16)',
'jellyfin-purple': '#aa5cc3', 'jellyfin-purple': '#aa5cc3',
'jellyfin-blue': '#00a4dc', 'jellyfin-blue': '#00a4dc',
'youtube-red': '#ff0000',
}, },
}, },
}, },