Updates for now playing
This commit is contained in:
parent
090b2b95bd
commit
4ffcb25a67
13 changed files with 10069 additions and 9395 deletions
18354
package-lock.json
generated
18354
package-lock.json
generated
File diff suppressed because it is too large
Load diff
251
package.json
251
package.json
|
|
@ -1,127 +1,128 @@
|
|||
{
|
||||
"name": "jedmund-svelte",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"start": "node build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write .",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:seed": "prisma db seed",
|
||||
"db:studio": "prisma studio",
|
||||
"db:init": "tsx scripts/init-db.ts",
|
||||
"db:deploy": "prisma migrate deploy",
|
||||
"setup:local": "./scripts/setup-local.sh",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@musicorum/lastfm": "github:jedmund/lastfm",
|
||||
"@poppanator/sveltekit-svg": "^5.0.0-svelte5.4",
|
||||
"@storybook/addon-a11y": "^9.0.1",
|
||||
"@storybook/addon-docs": "^9.0.1",
|
||||
"@storybook/addon-svelte-csf": "^5.0.3",
|
||||
"@storybook/sveltekit": "^9.0.1",
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.6",
|
||||
"@types/eslint": "^8.56.7",
|
||||
"@types/node": "^22.0.2",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-storybook": "^9.0.1",
|
||||
"eslint-plugin-svelte": "^2.36.0",
|
||||
"globals": "^15.0.0",
|
||||
"postcss": "^8.4.39",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
"sass": "^1.77.8",
|
||||
"storybook": "^9.0.1",
|
||||
"svelte": "^5.0.0-next.1",
|
||||
"svelte-check": "^3.6.0",
|
||||
"tslib": "^2.4.1",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.0.0-alpha.20",
|
||||
"vite": "^5.0.3"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@aarkue/tiptap-math-extension": "^1.3.6",
|
||||
"@prisma/client": "^6.8.2",
|
||||
"@sveltejs/adapter-node": "^5.2.0",
|
||||
"@tiptap/core": "^2.12.0",
|
||||
"@tiptap/extension-bubble-menu": "^2.12.0",
|
||||
"@tiptap/extension-character-count": "^2.12.0",
|
||||
"@tiptap/extension-code-block-lowlight": "^2.12.0",
|
||||
"@tiptap/extension-color": "^2.12.0",
|
||||
"@tiptap/extension-floating-menu": "^2.12.0",
|
||||
"@tiptap/extension-highlight": "^2.12.0",
|
||||
"@tiptap/extension-image": "^2.12.0",
|
||||
"@tiptap/extension-link": "^2.12.0",
|
||||
"@tiptap/extension-placeholder": "^2.12.0",
|
||||
"@tiptap/extension-subscript": "^2.12.0",
|
||||
"@tiptap/extension-superscript": "^2.12.0",
|
||||
"@tiptap/extension-table": "^2.12.0",
|
||||
"@tiptap/extension-table-header": "^2.12.0",
|
||||
"@tiptap/extension-table-row": "^2.12.0",
|
||||
"@tiptap/extension-task-item": "^2.12.0",
|
||||
"@tiptap/extension-task-list": "^2.12.0",
|
||||
"@tiptap/extension-text": "^2.12.0",
|
||||
"@tiptap/extension-text-align": "^2.12.0",
|
||||
"@tiptap/extension-text-style": "^2.12.0",
|
||||
"@tiptap/extension-typography": "^2.12.0",
|
||||
"@tiptap/extension-underline": "^2.12.0",
|
||||
"@tiptap/pm": "^2.12.0",
|
||||
"@tiptap/starter-kit": "^2.12.0",
|
||||
"@tiptap/suggestion": "^2.12.0",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/redis": "^4.0.10",
|
||||
"@types/steamapi": "^2.2.5",
|
||||
"cloudinary": "^2.6.1",
|
||||
"dotenv": "^16.5.0",
|
||||
"exifr": "^7.1.3",
|
||||
"giantbombing-api": "^1.0.4",
|
||||
"gray-matter": "^4.0.3",
|
||||
"ioredis": "^5.4.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"katex": "^0.16.22",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-svelte": "^0.511.0",
|
||||
"marked": "^15.0.12",
|
||||
"multer": "^2.0.0",
|
||||
"node-itunes-search": "^1.2.3",
|
||||
"prisma": "^6.8.2",
|
||||
"psn-api": "github:jedmund/psn-api",
|
||||
"redis": "^4.7.0",
|
||||
"sharp": "^0.34.2",
|
||||
"steamapi": "^3.0.11",
|
||||
"svelte-medium-image-zoom": "^0.2.6",
|
||||
"svelte-portal": "^2.2.1",
|
||||
"svelte-tiptap": "^2.1.0",
|
||||
"svgo": "^3.3.2",
|
||||
"tinyduration": "^3.3.1",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-extension-auto-joiner": "^0.1.3",
|
||||
"tiptap-extension-global-drag-handle": "^0.1.18",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
"zod": "^3.25.30"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
"npm": ">=10.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.6"
|
||||
}
|
||||
"name": "jedmund-svelte",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"start": "node build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write .",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:seed": "prisma db seed",
|
||||
"db:studio": "prisma studio",
|
||||
"db:init": "tsx scripts/init-db.ts",
|
||||
"db:deploy": "prisma migrate deploy",
|
||||
"setup:local": "./scripts/setup-local.sh",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@musicorum/lastfm": "github:jedmund/lastfm",
|
||||
"@poppanator/sveltekit-svg": "^5.0.0-svelte5.4",
|
||||
"@storybook/addon-a11y": "^9.0.9",
|
||||
"@storybook/addon-docs": "^9.0.9",
|
||||
"@storybook/addon-svelte-csf": "^5.0.3",
|
||||
"@storybook/sveltekit": "^9.0.9",
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.6",
|
||||
"@types/eslint": "^8.56.7",
|
||||
"@types/node": "^22.0.2",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-storybook": "^9.0.9",
|
||||
"eslint-plugin-svelte": "^2.36.0",
|
||||
"globals": "^15.0.0",
|
||||
"postcss": "^8.4.39",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
"sass": "^1.77.8",
|
||||
"storybook": "^9.0.9",
|
||||
"svelte": "^5.0.0-next.1",
|
||||
"svelte-check": "^3.6.0",
|
||||
"tslib": "^2.4.1",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.0.0-alpha.20",
|
||||
"vite": "^5.0.3"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@aarkue/tiptap-math-extension": "^1.3.6",
|
||||
"@prisma/client": "^6.8.2",
|
||||
"@sveltejs/adapter-node": "^5.2.0",
|
||||
"@tiptap/core": "^2.12.0",
|
||||
"@tiptap/extension-bubble-menu": "^2.12.0",
|
||||
"@tiptap/extension-character-count": "^2.12.0",
|
||||
"@tiptap/extension-code-block-lowlight": "^2.12.0",
|
||||
"@tiptap/extension-color": "^2.12.0",
|
||||
"@tiptap/extension-floating-menu": "^2.12.0",
|
||||
"@tiptap/extension-highlight": "^2.12.0",
|
||||
"@tiptap/extension-image": "^2.12.0",
|
||||
"@tiptap/extension-link": "^2.12.0",
|
||||
"@tiptap/extension-placeholder": "^2.12.0",
|
||||
"@tiptap/extension-subscript": "^2.12.0",
|
||||
"@tiptap/extension-superscript": "^2.12.0",
|
||||
"@tiptap/extension-table": "^2.12.0",
|
||||
"@tiptap/extension-table-header": "^2.12.0",
|
||||
"@tiptap/extension-table-row": "^2.12.0",
|
||||
"@tiptap/extension-task-item": "^2.12.0",
|
||||
"@tiptap/extension-task-list": "^2.12.0",
|
||||
"@tiptap/extension-text": "^2.12.0",
|
||||
"@tiptap/extension-text-align": "^2.12.0",
|
||||
"@tiptap/extension-text-style": "^2.12.0",
|
||||
"@tiptap/extension-typography": "^2.12.0",
|
||||
"@tiptap/extension-underline": "^2.12.0",
|
||||
"@tiptap/pm": "^2.12.0",
|
||||
"@tiptap/starter-kit": "^2.12.0",
|
||||
"@tiptap/suggestion": "^2.12.0",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/redis": "^4.0.10",
|
||||
"@types/steamapi": "^2.2.5",
|
||||
"cloudinary": "^2.6.1",
|
||||
"dotenv": "^16.5.0",
|
||||
"exifr": "^7.1.3",
|
||||
"giantbombing-api": "^1.0.4",
|
||||
"gray-matter": "^4.0.3",
|
||||
"ioredis": "^5.4.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"katex": "^0.16.22",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-svelte": "^0.511.0",
|
||||
"marked": "^15.0.12",
|
||||
"multer": "^2.0.0",
|
||||
"node-itunes-search": "^1.2.3",
|
||||
"prisma": "^6.8.2",
|
||||
"psn-api": "github:jedmund/psn-api",
|
||||
"redis": "^4.7.0",
|
||||
"sharp": "^0.34.2",
|
||||
"steamapi": "^3.0.11",
|
||||
"svelte-medium-image-zoom": "^0.2.6",
|
||||
"svelte-portal": "^2.2.1",
|
||||
"svelte-tiptap": "^2.1.0",
|
||||
"svgo": "^3.3.2",
|
||||
"tinyduration": "^3.3.1",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-extension-auto-joiner": "^0.1.3",
|
||||
"tiptap-extension-global-drag-handle": "^0.1.18",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
"zod": "^3.25.30"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
"npm": ">=10.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.6",
|
||||
"storybook": "$storybook"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
183
src/lib/components/Album.stories.js
Normal file
183
src/lib/components/Album.stories.js
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import Album from './Album.svelte'
|
||||
|
||||
// Mock album data
|
||||
const mockAlbum = {
|
||||
name: 'In Rainbows',
|
||||
artist: {
|
||||
name: 'Radiohead',
|
||||
mbid: 'a74b1b7f-71a5-4011-9441-d0b5e4122711'
|
||||
},
|
||||
playCount: 156,
|
||||
url: 'https://www.last.fm/music/Radiohead/In+Rainbows',
|
||||
rank: 1,
|
||||
images: {
|
||||
small: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
medium: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
large: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
extralarge: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
mega: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
default: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp'
|
||||
},
|
||||
isNowPlaying: false,
|
||||
appleMusicData: {
|
||||
appleMusicId: '1109714933',
|
||||
highResArtwork: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
previewUrl: 'https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview125/v4/5c/e8/e3/5ce8e347-3bea-3bb0-0664-a6e1c9125d3a/mzaf_7638610958907470670.plus.aac.p.m4a',
|
||||
genres: ['Alternative', 'Music'],
|
||||
releaseDate: '2007-10-10',
|
||||
trackCount: 10,
|
||||
recordLabel: 'XL Recordings'
|
||||
}
|
||||
}
|
||||
|
||||
const mockAlbumWithLongName = {
|
||||
...mockAlbum,
|
||||
name: 'The Rise and Fall of Ziggy Stardust and the Spiders from Mars',
|
||||
artist: {
|
||||
name: 'David Bowie',
|
||||
mbid: '5441c29d-3602-4898-b1a1-b77fa23b8e50'
|
||||
},
|
||||
nowPlayingTrack: 'Starman (2012 Remaster)',
|
||||
isNowPlaying: true
|
||||
}
|
||||
|
||||
const mockAlbumNoPreview = {
|
||||
...mockAlbum,
|
||||
name: 'Unknown Pleasures',
|
||||
artist: {
|
||||
name: 'Joy Division',
|
||||
mbid: '9e1ff9b2-25fc-4c59-b8e8-8b9d9e3d3a2a'
|
||||
},
|
||||
appleMusicData: {
|
||||
...mockAlbum.appleMusicData,
|
||||
previewUrl: undefined
|
||||
}
|
||||
}
|
||||
|
||||
const mockAlbumNoArtwork = {
|
||||
name: 'Demo Album',
|
||||
artist: {
|
||||
name: 'Unknown Artist',
|
||||
mbid: ''
|
||||
},
|
||||
playCount: 10,
|
||||
url: '#',
|
||||
rank: 1,
|
||||
images: {
|
||||
small: '',
|
||||
medium: '',
|
||||
large: '',
|
||||
extralarge: '',
|
||||
mega: '',
|
||||
default: ''
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
title: 'Components/Album',
|
||||
component: Album,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Album component displaying album artwork, artist info, and playback controls.'
|
||||
}
|
||||
},
|
||||
layout: 'centered'
|
||||
},
|
||||
argTypes: {
|
||||
album: {
|
||||
control: 'object',
|
||||
description: 'Album data object'
|
||||
},
|
||||
albumId: {
|
||||
control: 'text',
|
||||
description: 'Unique identifier for the album'
|
||||
},
|
||||
hoveredAlbumId: {
|
||||
control: 'text',
|
||||
description: 'ID of currently hovered album (for shrink effect)'
|
||||
},
|
||||
onHover: {
|
||||
action: 'hovered',
|
||||
description: 'Callback when album is hovered'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Template function to wrap Album in a container
|
||||
const Template = (args) => ({
|
||||
Component: Album,
|
||||
props: args
|
||||
})
|
||||
|
||||
export const Default = {
|
||||
args: {
|
||||
album: mockAlbum
|
||||
},
|
||||
render: Template,
|
||||
decorators: [
|
||||
(story) => ({
|
||||
Component: story,
|
||||
target: document.createElement('div'),
|
||||
props: {
|
||||
style: 'width: 200px; display: block;'
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export const NowPlaying = {
|
||||
args: {
|
||||
album: {
|
||||
...mockAlbum,
|
||||
isNowPlaying: true,
|
||||
nowPlayingTrack: '15 Step'
|
||||
}
|
||||
},
|
||||
render: Template
|
||||
}
|
||||
|
||||
export const NowPlayingLongTrackName = {
|
||||
args: {
|
||||
album: mockAlbumWithLongName
|
||||
},
|
||||
render: Template
|
||||
}
|
||||
|
||||
export const WithoutPreview = {
|
||||
args: {
|
||||
album: mockAlbumNoPreview
|
||||
},
|
||||
render: Template
|
||||
}
|
||||
|
||||
export const WithoutArtwork = {
|
||||
args: {
|
||||
album: mockAlbumNoArtwork
|
||||
},
|
||||
render: Template
|
||||
}
|
||||
|
||||
export const Shrunk = {
|
||||
args: {
|
||||
album: mockAlbum,
|
||||
albumId: 'radiohead-in-rainbows',
|
||||
hoveredAlbumId: 'some-other-album'
|
||||
},
|
||||
render: Template
|
||||
}
|
||||
|
||||
export const Interactive = {
|
||||
args: {
|
||||
album: mockAlbum
|
||||
},
|
||||
render: Template,
|
||||
parameters: {
|
||||
docs: {
|
||||
story: {
|
||||
inline: false,
|
||||
height: '300px'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -87,7 +87,14 @@
|
|||
|
||||
// Use high-res artwork if available
|
||||
const artworkUrl = $derived(
|
||||
album?.appleMusicData?.highResArtwork || album?.images.itunes || album?.images.mega || ''
|
||||
album?.appleMusicData?.highResArtwork ||
|
||||
album?.images?.itunes ||
|
||||
album?.images?.mega ||
|
||||
album?.images?.extralarge ||
|
||||
album?.images?.large ||
|
||||
album?.images?.medium ||
|
||||
album?.images?.small ||
|
||||
''
|
||||
)
|
||||
|
||||
const hasPreview = $derived(!!album?.appleMusicData?.previewUrl)
|
||||
|
|
@ -110,6 +117,17 @@
|
|||
// Combine initial state with real-time updates
|
||||
const isNowPlaying = $derived(realtimeNowPlaying?.isNowPlaying ?? album?.isNowPlaying ?? false)
|
||||
const nowPlayingTrack = $derived(realtimeNowPlaying?.nowPlayingTrack ?? album?.nowPlayingTrack)
|
||||
|
||||
// Debug logging
|
||||
$effect(() => {
|
||||
if (album && isNowPlaying) {
|
||||
console.log(`Album "${album.name}" is now playing:`, {
|
||||
fromRealtime: realtimeNowPlaying?.isNowPlaying,
|
||||
fromAlbum: album?.isNowPlaying,
|
||||
track: nowPlayingTrack
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="album">
|
||||
|
|
@ -136,7 +154,7 @@
|
|||
loading="lazy"
|
||||
/>
|
||||
{#if isNowPlaying}
|
||||
<NowPlaying trackName={nowPlayingTrack !== album.name ? nowPlayingTrack : undefined} />
|
||||
<NowPlaying trackName={nowPlayingTrack} />
|
||||
{/if}
|
||||
{#if hasPreview && (isHovering || isPlaying)}
|
||||
<button
|
||||
|
|
@ -194,6 +212,9 @@
|
|||
.artwork-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
background-color: $grey-5;
|
||||
border-radius: $unit;
|
||||
}
|
||||
|
||||
img {
|
||||
|
|
@ -201,8 +222,9 @@
|
|||
border-radius: $unit;
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
height: auto;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.preview-button {
|
||||
|
|
|
|||
8
src/lib/components/AlbumSimple.stories.js
Normal file
8
src/lib/components/AlbumSimple.stories.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import AlbumSimpleStory from './AlbumSimple.stories.svelte'
|
||||
|
||||
export default {
|
||||
title: 'Components/Album Simple',
|
||||
component: AlbumSimpleStory
|
||||
}
|
||||
|
||||
export const Default = {}
|
||||
42
src/lib/components/AlbumSimple.stories.svelte
Normal file
42
src/lib/components/AlbumSimple.stories.svelte
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<script>
|
||||
import Album from './Album.svelte'
|
||||
|
||||
const album = {
|
||||
name: 'In Rainbows',
|
||||
artist: {
|
||||
name: 'Radiohead',
|
||||
mbid: 'a74b1b7f-71a5-4011-9441-d0b5e4122711'
|
||||
},
|
||||
playCount: 156,
|
||||
url: 'https://www.last.fm/music/Radiohead/In+Rainbows',
|
||||
rank: 1,
|
||||
images: {
|
||||
small: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
medium: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
large: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
extralarge: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
mega: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
default: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp'
|
||||
},
|
||||
isNowPlaying: false,
|
||||
appleMusicData: {
|
||||
appleMusicId: '1109714933',
|
||||
highResArtwork: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
previewUrl: 'https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview125/v4/5c/e8/e3/5ce8e347-3bea-3bb0-0664-a6e1c9125d3a/mzaf_7638610958907470670.plus.aac.p.m4a',
|
||||
genres: ['Alternative', 'Music'],
|
||||
releaseDate: '2007-10-10',
|
||||
trackCount: 10,
|
||||
recordLabel: 'XL Recordings'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div style="width: 200px; padding: 20px; background: #f5f5f5;">
|
||||
<Album {album} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
<script>
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { spring } from 'svelte/motion'
|
||||
import { Spring } from 'svelte/motion'
|
||||
import { nowPlayingStream } from '$lib/stores/now-playing-stream'
|
||||
import { albumStream } from '$lib/stores/album-stream'
|
||||
import AvatarSVG from './AvatarSVG.svelte'
|
||||
import AvatarHeadphones from './AvatarHeadphones.svelte'
|
||||
import { get } from 'svelte/store'
|
||||
|
||||
// Props for testing/forcing states
|
||||
let { forcePlayingMusic = false } = $props()
|
||||
|
|
@ -11,20 +13,24 @@
|
|||
let isHovering = $state(false)
|
||||
let isBlinking = $state(false)
|
||||
let isPlayingMusic = $state(forcePlayingMusic)
|
||||
|
||||
// Track store subscriptions for debugging
|
||||
let nowPlayingStoreState = $state(null)
|
||||
let albumStoreState = $state(null)
|
||||
|
||||
const scale = spring(1, {
|
||||
const scale = new Spring(1, {
|
||||
stiffness: 0.1,
|
||||
damping: 0.125
|
||||
})
|
||||
|
||||
function handleMouseEnter() {
|
||||
isHovering = true
|
||||
scale.set(1.25)
|
||||
scale.target = 1.25
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
isHovering = false
|
||||
scale.set(1)
|
||||
scale.target = 1
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
|
|
@ -60,11 +66,42 @@
|
|||
}
|
||||
}, 4000)
|
||||
|
||||
// Subscribe to now playing updates
|
||||
const unsubscribe = nowPlayingStream.subscribe((state) => {
|
||||
// Subscribe to now playing updates from both sources
|
||||
const unsubscribeNowPlaying = nowPlayingStream.subscribe((state) => {
|
||||
nowPlayingStoreState = state
|
||||
// Check if any album is currently playing, unless forced
|
||||
if (!forcePlayingMusic) {
|
||||
isPlayingMusic = Array.from(state.updates.values()).some((update) => update.isNowPlaying)
|
||||
const nowPlayingFromStream = Array.from(state.updates.values()).some((update) => update.isNowPlaying)
|
||||
console.log('Avatar - nowPlayingStream update:', {
|
||||
updatesCount: state.updates.size,
|
||||
hasNowPlaying: nowPlayingFromStream
|
||||
})
|
||||
// Don't set to false if we haven't received album data yet
|
||||
if (nowPlayingFromStream || albumStoreState !== null) {
|
||||
isPlayingMusic = nowPlayingFromStream || (albumStoreState?.some(album => album.isNowPlaying) ?? false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Also check the album stream
|
||||
const unsubscribeAlbums = albumStream.subscribe((state) => {
|
||||
albumStoreState = state.albums
|
||||
if (!forcePlayingMusic) {
|
||||
const hasNowPlaying = state.albums.some(album => album.isNowPlaying)
|
||||
|
||||
// Get the current state of nowPlayingStream
|
||||
const nowPlayingState = nowPlayingStoreState || get(nowPlayingStream)
|
||||
const nowPlayingFromStream = Array.from(nowPlayingState.updates.values()).some((update) => update.isNowPlaying)
|
||||
|
||||
console.log('Avatar - albumStream update:', {
|
||||
albumsCount: state.albums.length,
|
||||
hasNowPlayingInAlbums: hasNowPlaying,
|
||||
hasNowPlayingInStream: nowPlayingFromStream,
|
||||
albums: state.albums.map(a => ({ name: a.name, isNowPlaying: a.isNowPlaying }))
|
||||
})
|
||||
|
||||
// Update isPlayingMusic based on whether any album is now playing from either source
|
||||
isPlayingMusic = hasNowPlaying || nowPlayingFromStream
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -72,7 +109,8 @@
|
|||
if (blinkInterval) {
|
||||
clearInterval(blinkInterval)
|
||||
}
|
||||
unsubscribe()
|
||||
unsubscribeNowPlaying()
|
||||
unsubscribeAlbums()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
@ -81,7 +119,7 @@
|
|||
class="face-container"
|
||||
onmouseenter={handleMouseEnter}
|
||||
onmouseleave={handleMouseLeave}
|
||||
style="transform: scale({$scale})"
|
||||
style="transform: scale({scale.current})"
|
||||
>
|
||||
<AvatarSVG>
|
||||
<!-- Face group -->
|
||||
|
|
|
|||
|
|
@ -4,6 +4,19 @@
|
|||
}
|
||||
|
||||
let { trackName }: Props = $props()
|
||||
|
||||
let textElement: HTMLSpanElement | null = $state(null)
|
||||
let containerElement: HTMLDivElement | null = $state(null)
|
||||
let shouldMarquee = $state(false)
|
||||
|
||||
$effect(() => {
|
||||
if (textElement && containerElement && trackName) {
|
||||
// Check if text overflows
|
||||
const textWidth = textElement.scrollWidth
|
||||
const containerWidth = containerElement.clientWidth
|
||||
shouldMarquee = textWidth > containerWidth
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="now-playing">
|
||||
|
|
@ -13,7 +26,19 @@
|
|||
<span class="bar"></span>
|
||||
</div>
|
||||
{#if trackName}
|
||||
<span class="track-name">{trackName}</span>
|
||||
<div class="track-name-container" bind:this={containerElement}>
|
||||
<span
|
||||
class="track-name"
|
||||
class:marquee={shouldMarquee}
|
||||
bind:this={textElement}
|
||||
>
|
||||
{trackName}
|
||||
{#if shouldMarquee}
|
||||
<span class="marquee-gap"> </span>
|
||||
{trackName}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
|
@ -22,6 +47,8 @@
|
|||
position: absolute;
|
||||
top: $unit;
|
||||
left: $unit;
|
||||
right: $unit;
|
||||
max-width: calc(100% - #{$unit * 2});
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-half;
|
||||
|
|
@ -33,6 +60,7 @@
|
|||
backdrop-filter: blur(10px);
|
||||
z-index: 10;
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
|
|
@ -85,12 +113,37 @@
|
|||
}
|
||||
}
|
||||
|
||||
.track-name {
|
||||
max-width: 150px;
|
||||
.track-name-container {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.track-name {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
font-weight: $font-weight-med;
|
||||
|
||||
&.marquee {
|
||||
animation: marquee 8s linear infinite;
|
||||
|
||||
&:hover {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.marquee-gap {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@include breakpoint('phone') {
|
||||
|
|
@ -99,7 +152,7 @@
|
|||
padding: $unit-fourth $unit-half;
|
||||
}
|
||||
|
||||
.track-name {
|
||||
.track-name-container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
201
src/lib/components/RecentAlbums.stories.js
Normal file
201
src/lib/components/RecentAlbums.stories.js
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import RecentAlbums from './RecentAlbums.svelte'
|
||||
|
||||
// Mock albums data
|
||||
const mockAlbums = [
|
||||
{
|
||||
name: 'In Rainbows',
|
||||
artist: {
|
||||
name: 'Radiohead',
|
||||
mbid: 'a74b1b7f-71a5-4011-9441-d0b5e4122711'
|
||||
},
|
||||
playCount: 156,
|
||||
url: 'https://www.last.fm/music/Radiohead/In+Rainbows',
|
||||
rank: 1,
|
||||
images: {
|
||||
small: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
medium: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
large: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
extralarge: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
mega: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
default: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp'
|
||||
},
|
||||
isNowPlaying: false,
|
||||
appleMusicData: {
|
||||
appleMusicId: '1109714933',
|
||||
highResArtwork: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
previewUrl: 'https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview125/v4/5c/e8/e3/5ce8e347-3bea-3bb0-0664-a6e1c9125d3a/mzaf_7638610958907470670.plus.aac.p.m4a',
|
||||
genres: ['Alternative', 'Music'],
|
||||
releaseDate: '2007-10-10',
|
||||
trackCount: 10,
|
||||
recordLabel: 'XL Recordings'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'OK Computer',
|
||||
artist: {
|
||||
name: 'Radiohead',
|
||||
mbid: 'a74b1b7f-71a5-4011-9441-d0b5e4122711'
|
||||
},
|
||||
playCount: 234,
|
||||
url: 'https://www.last.fm/music/Radiohead/OK+Computer',
|
||||
rank: 2,
|
||||
images: {
|
||||
small: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
medium: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
large: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
extralarge: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
mega: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
default: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp'
|
||||
},
|
||||
isNowPlaying: true,
|
||||
nowPlayingTrack: 'Paranoid Android',
|
||||
appleMusicData: {
|
||||
appleMusicId: '1097861387',
|
||||
highResArtwork: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
previewUrl: 'https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview125/v4/65/f2/85/65f285d2-5a99-f502-89f8-ca2c4da24d19/mzaf_1760708625972666865.plus.aac.p.m4a'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'The Dark Side of the Moon',
|
||||
artist: {
|
||||
name: 'Pink Floyd',
|
||||
mbid: '83d91898-7763-47d7-b03b-b92132375c47'
|
||||
},
|
||||
playCount: 189,
|
||||
url: 'https://www.last.fm/music/Pink+Floyd/The+Dark+Side+of+the+Moon',
|
||||
rank: 3,
|
||||
images: {
|
||||
small: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
medium: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
large: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
extralarge: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
mega: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
default: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp'
|
||||
},
|
||||
appleMusicData: {
|
||||
appleMusicId: '1065973699',
|
||||
highResArtwork: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
previewUrl: 'https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview115/v4/57/15/fb/5715fb67-0424-8e6e-a1ff-2c0cf09e4bdc/mzaf_3641989451682986919.plus.aac.p.m4a'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Unknown Pleasures',
|
||||
artist: {
|
||||
name: 'Joy Division',
|
||||
mbid: '9e1ff9b2-25fc-4c59-b8e8-8b9d9e3d3a2a'
|
||||
},
|
||||
playCount: 112,
|
||||
url: 'https://www.last.fm/music/Joy+Division/Unknown+Pleasures',
|
||||
rank: 4,
|
||||
images: {
|
||||
small: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
medium: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
large: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
extralarge: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
mega: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp',
|
||||
default: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp'
|
||||
},
|
||||
appleMusicData: {
|
||||
appleMusicId: '659989492',
|
||||
highResArtwork: 'https://is1-ssl.mzstatic.com/image/thumb/Music126/v4/85/2e/2b/852e2b6c-93ec-806a-95b2-8f5eda2f775c/22UMGIM18886.rgb.jpg/592x592bb.webp'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const mockAlbumsWithLongNames = [
|
||||
{
|
||||
...mockAlbums[0],
|
||||
name: 'The Rise and Fall of Ziggy Stardust and the Spiders from Mars',
|
||||
artist: {
|
||||
name: 'David Bowie',
|
||||
mbid: '5441c29d-3602-4898-b1a1-b77fa23b8e50'
|
||||
},
|
||||
isNowPlaying: true,
|
||||
nowPlayingTrack: 'Starman (2012 Remaster) - Extended Version with Additional Notes'
|
||||
},
|
||||
{
|
||||
...mockAlbums[1],
|
||||
name: '!Despertate!',
|
||||
artist: {
|
||||
name: '!deladap',
|
||||
mbid: ''
|
||||
}
|
||||
},
|
||||
{
|
||||
...mockAlbums[2],
|
||||
name: ';PEBFG',
|
||||
artist: {
|
||||
name: 'Various Artists',
|
||||
mbid: ''
|
||||
}
|
||||
},
|
||||
mockAlbums[3]
|
||||
]
|
||||
|
||||
export default {
|
||||
title: 'Components/RecentAlbums',
|
||||
component: RecentAlbums,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Displays a grid of recent albums with hover effects and now playing indicators.'
|
||||
}
|
||||
},
|
||||
layout: 'fullscreen'
|
||||
},
|
||||
argTypes: {
|
||||
albums: {
|
||||
control: 'object',
|
||||
description: 'Array of album objects to display'
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
(Story) => ({
|
||||
Component: Story,
|
||||
props: {
|
||||
style: 'padding: 40px; background: #f9f9f9; min-height: 400px;'
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export const Default = {
|
||||
args: {
|
||||
albums: mockAlbums
|
||||
}
|
||||
}
|
||||
|
||||
export const WithNowPlaying = {
|
||||
args: {
|
||||
albums: mockAlbums
|
||||
}
|
||||
}
|
||||
|
||||
export const WithLongTitles = {
|
||||
args: {
|
||||
albums: mockAlbumsWithLongNames
|
||||
}
|
||||
}
|
||||
|
||||
export const Empty = {
|
||||
args: {
|
||||
albums: []
|
||||
}
|
||||
}
|
||||
|
||||
export const SingleAlbum = {
|
||||
args: {
|
||||
albums: [mockAlbums[0]]
|
||||
}
|
||||
}
|
||||
|
||||
export const MobileView = {
|
||||
args: {
|
||||
albums: mockAlbums
|
||||
},
|
||||
parameters: {
|
||||
viewport: {
|
||||
defaultViewport: 'mobile1'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import { ApiRateLimiter } from './rate-limiter'
|
|||
|
||||
const APPLE_MUSIC_API_BASE = 'https://api.music.apple.com/v1'
|
||||
const DEFAULT_STOREFRONT = 'us' // Default to US storefront
|
||||
const JAPANESE_STOREFRONT = 'jp' // Japanese storefront
|
||||
const RATE_LIMIT_DELAY = 200 // 200ms between requests to stay well under 3000/hour
|
||||
|
||||
let lastRequestTime = 0
|
||||
|
|
@ -88,10 +89,11 @@ async function makeAppleMusicRequest<T>(endpoint: string, identifier?: string):
|
|||
|
||||
export async function searchAlbums(
|
||||
query: string,
|
||||
limit: number = 10
|
||||
limit: number = 10,
|
||||
storefront: string = DEFAULT_STOREFRONT
|
||||
): Promise<AppleMusicSearchResponse> {
|
||||
const encodedQuery = encodeURIComponent(query)
|
||||
const endpoint = `/catalog/${DEFAULT_STOREFRONT}/search?types=albums&term=${encodedQuery}&limit=${limit}`
|
||||
const endpoint = `/catalog/${storefront}/search?types=albums&term=${encodedQuery}&limit=${limit}`
|
||||
|
||||
return makeAppleMusicRequest<AppleMusicSearchResponse>(endpoint, query)
|
||||
}
|
||||
|
|
@ -160,16 +162,21 @@ export async function findAlbum(artist: string, album: string): Promise<AppleMus
|
|||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const searchQuery = `${artist} ${album}`
|
||||
const response = await searchAlbums(searchQuery, 5)
|
||||
// Helper function to remove leading punctuation
|
||||
function removeLeadingPunctuation(str: string): string {
|
||||
// Remove leading punctuation marks like ; ! ? . , : ' " etc.
|
||||
return str.replace(/^[^\w\s]+/, '').trim()
|
||||
}
|
||||
|
||||
console.log(`Search results for "${searchQuery}":`, JSON.stringify(response, null, 2))
|
||||
// Helper function to perform the album search and matching
|
||||
async function searchAndMatch(searchAlbum: string, storefront: string = DEFAULT_STOREFRONT): Promise<{album: AppleMusicAlbum, storefront: string} | null> {
|
||||
const searchQuery = `${artist} ${searchAlbum}`
|
||||
const response = await searchAlbums(searchQuery, 5, storefront)
|
||||
|
||||
console.log(`Search results for "${searchQuery}" in ${storefront} storefront:`, JSON.stringify(response, null, 2))
|
||||
|
||||
if (!response.results?.albums?.data?.length) {
|
||||
console.log('No albums found in search results')
|
||||
// Cache this as not found for 1 hour
|
||||
await rateLimiter.cacheNotFound(identifier, 3600)
|
||||
console.log(`No albums found in ${storefront} storefront`)
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
@ -177,30 +184,71 @@ export async function findAlbum(artist: string, album: string): Promise<AppleMus
|
|||
const albums = response.results.albums.data
|
||||
console.log(`Found ${albums.length} albums`)
|
||||
|
||||
// First try exact match
|
||||
// First try exact match with original album name
|
||||
let match = albums.find(
|
||||
(a) =>
|
||||
a.attributes?.name?.toLowerCase() === album.toLowerCase() &&
|
||||
a.attributes?.artistName?.toLowerCase() === artist.toLowerCase()
|
||||
)
|
||||
|
||||
// If no exact match, try matching with the search term we used
|
||||
if (!match && searchAlbum !== album) {
|
||||
match = albums.find(
|
||||
(a) =>
|
||||
a.attributes?.name?.toLowerCase() === searchAlbum.toLowerCase() &&
|
||||
a.attributes?.artistName?.toLowerCase() === artist.toLowerCase()
|
||||
)
|
||||
}
|
||||
|
||||
// If no exact match, try partial match
|
||||
if (!match) {
|
||||
match = albums.find(
|
||||
(a) =>
|
||||
a.attributes?.name?.toLowerCase().includes(album.toLowerCase()) &&
|
||||
a.attributes?.name?.toLowerCase().includes(searchAlbum.toLowerCase()) &&
|
||||
a.attributes?.artistName?.toLowerCase().includes(artist.toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
return match ? {album: match, storefront} : null
|
||||
}
|
||||
|
||||
try {
|
||||
// First try with the original album name in US storefront
|
||||
let result = await searchAndMatch(album)
|
||||
|
||||
// If no match, try Japanese storefront
|
||||
if (!result) {
|
||||
console.log(`No match found in US storefront, trying Japanese storefront`)
|
||||
result = await searchAndMatch(album, JAPANESE_STOREFRONT)
|
||||
}
|
||||
|
||||
// If no match and album starts with punctuation, try without it in both storefronts
|
||||
if (!result) {
|
||||
const cleanedAlbum = removeLeadingPunctuation(album)
|
||||
if (cleanedAlbum !== album && cleanedAlbum.length > 0) {
|
||||
console.log(`No match found for "${album}", trying without leading punctuation: "${cleanedAlbum}"`)
|
||||
result = await searchAndMatch(cleanedAlbum)
|
||||
|
||||
// Also try Japanese storefront with cleaned album name
|
||||
if (!result) {
|
||||
console.log(`Still no match, trying Japanese storefront with cleaned name`)
|
||||
result = await searchAndMatch(cleanedAlbum, JAPANESE_STOREFRONT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If still no match, cache as not found
|
||||
if (!match) {
|
||||
if (!result) {
|
||||
await rateLimiter.cacheNotFound(identifier, 3600)
|
||||
return null
|
||||
}
|
||||
|
||||
// Store the storefront information with the album
|
||||
const matchedAlbum = result.album as any
|
||||
matchedAlbum._storefront = result.storefront
|
||||
|
||||
// Return the match
|
||||
return match
|
||||
return result.album
|
||||
} catch (error) {
|
||||
console.error(`Failed to find album "${album}" by "${artist}":`, error)
|
||||
// Don't cache as not found on error - might be temporary
|
||||
|
|
@ -219,8 +267,11 @@ export async function transformAlbumData(appleMusicAlbum: AppleMusicAlbum) {
|
|||
// Always fetch tracks to get preview URLs
|
||||
if (appleMusicAlbum.id) {
|
||||
try {
|
||||
// Determine which storefront to use
|
||||
const storefront = (appleMusicAlbum as any)._storefront || DEFAULT_STOREFRONT
|
||||
|
||||
// Fetch album details with tracks
|
||||
const endpoint = `/catalog/${DEFAULT_STOREFRONT}/albums/${appleMusicAlbum.id}?include=tracks`
|
||||
const endpoint = `/catalog/${storefront}/albums/${appleMusicAlbum.id}?include=tracks`
|
||||
const response = await makeAppleMusicRequest<{
|
||||
data: AppleMusicAlbum[]
|
||||
included?: AppleMusicTrack[]
|
||||
|
|
|
|||
|
|
@ -22,6 +22,13 @@ function createAlbumStream() {
|
|||
function connect() {
|
||||
if (!browser || eventSource?.readyState === EventSource.OPEN) return
|
||||
|
||||
// Don't connect in Storybook
|
||||
if (typeof window !== 'undefined' && window.parent !== window) {
|
||||
// We're in an iframe, likely Storybook
|
||||
console.log('Album stream disabled in Storybook')
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up existing connection
|
||||
disconnect()
|
||||
|
||||
|
|
@ -36,6 +43,11 @@ function createAlbumStream() {
|
|||
eventSource.addEventListener('albums', (event) => {
|
||||
try {
|
||||
const albums: Album[] = JSON.parse(event.data)
|
||||
const nowPlayingAlbum = albums.find(a => a.isNowPlaying)
|
||||
console.log('Album stream received albums:', {
|
||||
totalAlbums: albums.length,
|
||||
nowPlayingAlbum: nowPlayingAlbum ? `${nowPlayingAlbum.artist.name} - ${nowPlayingAlbum.name}` : 'none'
|
||||
})
|
||||
update((state) => ({
|
||||
...state,
|
||||
albums,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,13 @@ function createNowPlayingStream() {
|
|||
function connect() {
|
||||
if (!browser || eventSource?.readyState === EventSource.OPEN) return
|
||||
|
||||
// Don't connect in Storybook
|
||||
if (typeof window !== 'undefined' && window.parent !== window) {
|
||||
// We're in an iframe, likely Storybook
|
||||
console.log('Now Playing stream disabled in Storybook')
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up existing connection
|
||||
disconnect()
|
||||
|
||||
|
|
|
|||
|
|
@ -57,78 +57,131 @@ export const GET: RequestHandler = async ({ request }) => {
|
|||
// Fetch full album data
|
||||
const albums = await getRecentAlbums(client)
|
||||
|
||||
// Enrich albums with additional info
|
||||
// Update recentTracks for duration-based now playing detection
|
||||
await getNowPlayingAlbums(client) // This populates recentTracks
|
||||
|
||||
// Enrich albums with additional info and check now playing status
|
||||
const enrichedAlbums = await Promise.all(
|
||||
albums.map(async (album) => {
|
||||
try {
|
||||
const enriched = await enrichAlbumWithInfo(client, album)
|
||||
return await searchAppleMusicForAlbum(enriched)
|
||||
const withAppleMusic = await searchAppleMusicForAlbum(enriched)
|
||||
|
||||
// Check if this album is currently playing using duration-based detection
|
||||
if (withAppleMusic.appleMusicData?.tracks && !withAppleMusic.isNowPlaying) {
|
||||
const nowPlayingCheck = checkWithTracks(withAppleMusic.name, withAppleMusic.appleMusicData.tracks)
|
||||
if (nowPlayingCheck?.isNowPlaying) {
|
||||
withAppleMusic.isNowPlaying = true
|
||||
withAppleMusic.nowPlayingTrack = nowPlayingCheck.nowPlayingTrack
|
||||
}
|
||||
}
|
||||
|
||||
return withAppleMusic
|
||||
} catch (error) {
|
||||
console.error(`Error enriching album ${album.name}:`, error)
|
||||
return album
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Ensure only one album is marked as now playing in the enriched albums
|
||||
const nowPlayingCount = enrichedAlbums.filter(a => a.isNowPlaying).length
|
||||
if (nowPlayingCount > 1) {
|
||||
console.log(`Multiple enriched albums marked as now playing (${nowPlayingCount}), keeping only the most recent one`)
|
||||
|
||||
// The albums are already in order from most recent to oldest
|
||||
// So we keep the first now playing album and mark others as not playing
|
||||
let foundFirst = false
|
||||
enrichedAlbums.forEach((album, index) => {
|
||||
if (album.isNowPlaying) {
|
||||
if (foundFirst) {
|
||||
console.log(`Marking album "${album.name}" at position ${index} as not playing`)
|
||||
album.isNowPlaying = false
|
||||
album.nowPlayingTrack = undefined
|
||||
} else {
|
||||
console.log(`Keeping album "${album.name}" at position ${index} as now playing`)
|
||||
foundFirst = true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Check if album order has changed
|
||||
// Check if album order has changed or now playing status changed
|
||||
const currentAlbumOrder = enrichedAlbums.map(a => `${a.artist.name}:${a.name}`)
|
||||
const albumOrderChanged = JSON.stringify(currentAlbumOrder) !== JSON.stringify(lastAlbumOrder)
|
||||
|
||||
if (albumOrderChanged) {
|
||||
// Also check if any now playing status changed
|
||||
let nowPlayingChanged = false
|
||||
for (const album of enrichedAlbums) {
|
||||
const key = `${album.artist.name}:${album.name}`
|
||||
const lastState = lastNowPlayingState.get(key)
|
||||
if (album.isNowPlaying !== (lastState?.isPlaying || false) ||
|
||||
(album.isNowPlaying && album.nowPlayingTrack !== lastState?.track)) {
|
||||
nowPlayingChanged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (albumOrderChanged || nowPlayingChanged) {
|
||||
lastAlbumOrder = currentAlbumOrder
|
||||
|
||||
// Update now playing state
|
||||
for (const album of enrichedAlbums) {
|
||||
const key = `${album.artist.name}:${album.name}`
|
||||
lastNowPlayingState.set(key, {
|
||||
isPlaying: album.isNowPlaying || false,
|
||||
track: album.nowPlayingTrack
|
||||
})
|
||||
}
|
||||
|
||||
// Send full album update
|
||||
if (!isClosed) {
|
||||
try {
|
||||
const data = JSON.stringify(enrichedAlbums)
|
||||
controller.enqueue(encoder.encode(`event: albums\ndata: ${data}\n\n`))
|
||||
const nowPlayingAlbum = enrichedAlbums.find(a => a.isNowPlaying)
|
||||
console.log('Sent album update with now playing status:', {
|
||||
totalAlbums: enrichedAlbums.length,
|
||||
nowPlayingAlbum: nowPlayingAlbum ? `${nowPlayingAlbum.artist.name} - ${nowPlayingAlbum.name}` : 'none'
|
||||
})
|
||||
} catch (e) {
|
||||
isClosed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now playing updates
|
||||
// Send now playing updates for albums not in the recent list
|
||||
const nowPlayingAlbums = await getNowPlayingAlbums(client)
|
||||
const updates: NowPlayingUpdate[] = []
|
||||
const currentAlbums = new Set<string>()
|
||||
|
||||
// Check for changes
|
||||
// Only send now playing updates for albums that aren't in the recent albums list
|
||||
// (Recent albums already have their now playing status included)
|
||||
for (const album of nowPlayingAlbums) {
|
||||
const key = `${album.artistName}:${album.albumName}`
|
||||
currentAlbums.add(key)
|
||||
const isInRecentAlbums = enrichedAlbums.some(
|
||||
a => a.artist.name === album.artistName && a.name === album.albumName
|
||||
)
|
||||
|
||||
if (!isInRecentAlbums) {
|
||||
const key = `${album.artistName}:${album.albumName}`
|
||||
const lastState = lastNowPlayingState.get(key)
|
||||
const wasPlaying = lastState?.isPlaying || false
|
||||
const lastTrack = lastState?.track
|
||||
|
||||
const lastState = lastNowPlayingState.get(key)
|
||||
const wasPlaying = lastState?.isPlaying || false
|
||||
const lastTrack = lastState?.track
|
||||
// Update if playing status changed OR if the track changed
|
||||
if (
|
||||
album.isNowPlaying !== wasPlaying ||
|
||||
(album.isNowPlaying && album.nowPlayingTrack !== lastTrack)
|
||||
) {
|
||||
updates.push(album)
|
||||
console.log(
|
||||
`Now playing update for non-recent album ${album.albumName}: playing=${album.isNowPlaying}, track=${album.nowPlayingTrack}`
|
||||
)
|
||||
}
|
||||
|
||||
// Update if playing status changed OR if the track changed
|
||||
if (
|
||||
album.isNowPlaying !== wasPlaying ||
|
||||
(album.isNowPlaying && album.nowPlayingTrack !== lastTrack)
|
||||
) {
|
||||
updates.push(album)
|
||||
console.log(
|
||||
`Update for ${album.albumName}: playing=${album.isNowPlaying}, track=${album.nowPlayingTrack}`
|
||||
)
|
||||
}
|
||||
|
||||
lastNowPlayingState.set(key, {
|
||||
isPlaying: album.isNowPlaying,
|
||||
track: album.nowPlayingTrack
|
||||
})
|
||||
}
|
||||
|
||||
// Check for albums that were in the list but aren't anymore (stopped playing)
|
||||
for (const [key, state] of lastNowPlayingState.entries()) {
|
||||
if (!currentAlbums.has(key) && state.isPlaying) {
|
||||
const [artistName, albumName] = key.split(':')
|
||||
updates.push({
|
||||
albumName,
|
||||
artistName,
|
||||
isNowPlaying: false
|
||||
lastNowPlayingState.set(key, {
|
||||
isPlaying: album.isNowPlaying,
|
||||
track: album.nowPlayingTrack
|
||||
})
|
||||
console.log(`Album no longer in recent: ${albumName}`)
|
||||
lastNowPlayingState.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -221,10 +274,19 @@ async function getNowPlayingAlbums(client: LastClient): Promise<NowPlayingUpdate
|
|||
}
|
||||
|
||||
const albums: Map<string, NowPlayingUpdate> = new Map()
|
||||
let hasOfficialNowPlaying = false
|
||||
|
||||
// Clear old tracks and collect new track play information
|
||||
recentTracks = []
|
||||
|
||||
// First pass: check if Last.fm reports any track as now playing
|
||||
for (const track of recentTracksResponse.tracks) {
|
||||
if (track.nowPlaying) {
|
||||
hasOfficialNowPlaying = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for (const track of recentTracksResponse.tracks) {
|
||||
// Store track play information
|
||||
if (track.date) {
|
||||
|
|
@ -245,8 +307,8 @@ async function getNowPlayingAlbums(client: LastClient): Promise<NowPlayingUpdate
|
|||
nowPlayingTrack: track.nowPlaying ? track.name : undefined
|
||||
}
|
||||
|
||||
// If not marked as now playing by Last.fm, check with duration-based detection
|
||||
if (!album.isNowPlaying) {
|
||||
// Only use duration-based detection if Last.fm doesn't report any now playing
|
||||
if (!album.isNowPlaying && !hasOfficialNowPlaying) {
|
||||
const updatedStatus = await checkNowPlayingWithDuration(album.albumName, album.artistName)
|
||||
if (updatedStatus) {
|
||||
album.isNowPlaying = updatedStatus.isNowPlaying
|
||||
|
|
@ -258,6 +320,42 @@ async function getNowPlayingAlbums(client: LastClient): Promise<NowPlayingUpdate
|
|||
}
|
||||
}
|
||||
|
||||
// Ensure only one album is marked as now playing - keep the most recent one
|
||||
const nowPlayingAlbums = Array.from(albums.values()).filter(a => a.isNowPlaying)
|
||||
if (nowPlayingAlbums.length > 1) {
|
||||
console.log(`Multiple albums marked as now playing (${nowPlayingAlbums.length}), keeping only the most recent one`)
|
||||
|
||||
// Find the most recent track
|
||||
let mostRecentTime = new Date(0)
|
||||
let mostRecentAlbum = nowPlayingAlbums[0]
|
||||
|
||||
for (const album of nowPlayingAlbums) {
|
||||
// Find the most recent track for this album
|
||||
const albumTracks = recentTracks.filter(t => t.albumName === album.albumName)
|
||||
if (albumTracks.length > 0) {
|
||||
const latestTrack = albumTracks.reduce((latest, track) =>
|
||||
track.scrobbleTime > latest.scrobbleTime ? track : latest
|
||||
)
|
||||
if (latestTrack.scrobbleTime > mostRecentTime) {
|
||||
mostRecentTime = latestTrack.scrobbleTime
|
||||
mostRecentAlbum = album
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark all others as not playing
|
||||
nowPlayingAlbums.forEach(album => {
|
||||
if (album !== mostRecentAlbum) {
|
||||
const key = `${album.artistName}:${album.albumName}`
|
||||
albums.set(key, {
|
||||
...album,
|
||||
isNowPlaying: false,
|
||||
nowPlayingTrack: undefined
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from(albums.values())
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue