Now playing
This commit is contained in:
parent
cc6eba7df1
commit
199abb294f
5 changed files with 220 additions and 11 deletions
|
|
@ -2,6 +2,7 @@
|
||||||
import { spring } from 'svelte/motion'
|
import { spring } from 'svelte/motion'
|
||||||
import type { Album } from '$lib/types/lastfm'
|
import type { Album } from '$lib/types/lastfm'
|
||||||
import { audioPreview } from '$lib/stores/audio-preview'
|
import { audioPreview } from '$lib/stores/audio-preview'
|
||||||
|
import NowPlaying from './NowPlaying.svelte'
|
||||||
|
|
||||||
interface AlbumProps {
|
interface AlbumProps {
|
||||||
album?: Album
|
album?: Album
|
||||||
|
|
@ -109,6 +110,9 @@
|
||||||
style="transform: scale({$scale})"
|
style="transform: scale({$scale})"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
|
{#if album.isNowPlaying}
|
||||||
|
<NowPlaying trackName={album.nowPlayingTrack !== album.name ? album.nowPlayingTrack : undefined} />
|
||||||
|
{/if}
|
||||||
{#if hasPreview && isHovering}
|
{#if hasPreview && isHovering}
|
||||||
<button
|
<button
|
||||||
class="preview-button"
|
class="preview-button"
|
||||||
|
|
|
||||||
105
src/lib/components/NowPlaying.svelte
Normal file
105
src/lib/components/NowPlaying.svelte
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
trackName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let { trackName }: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="now-playing">
|
||||||
|
<div class="equalizer" aria-label="Now playing">
|
||||||
|
<span class="bar"></span>
|
||||||
|
<span class="bar"></span>
|
||||||
|
<span class="bar"></span>
|
||||||
|
</div>
|
||||||
|
{#if trackName}
|
||||||
|
<span class="track-name">{trackName}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.now-playing {
|
||||||
|
position: absolute;
|
||||||
|
top: $unit;
|
||||||
|
left: $unit;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit-half;
|
||||||
|
padding: $unit-half $unit;
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
color: white;
|
||||||
|
border-radius: $unit * 2;
|
||||||
|
font-size: $font-size-small;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
z-index: 10;
|
||||||
|
animation: fadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.equalizer {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
width: 3px;
|
||||||
|
background: $accent-color;
|
||||||
|
animation: dance 0.6s ease-in-out infinite;
|
||||||
|
transform-origin: bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar:nth-child(1) {
|
||||||
|
height: 40%;
|
||||||
|
animation-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar:nth-child(2) {
|
||||||
|
height: 60%;
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar:nth-child(3) {
|
||||||
|
height: 50%;
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dance {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scaleY(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scaleY(1.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-name {
|
||||||
|
max-width: 150px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: $font-weight-med;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include breakpoint('phone') {
|
||||||
|
.now-playing {
|
||||||
|
font-size: $font-size-extra-small;
|
||||||
|
padding: $unit-fourth $unit-half;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-name {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -175,7 +175,7 @@ export async function transformAlbumData(appleMusicAlbum: AppleMusicAlbum) {
|
||||||
|
|
||||||
// Get preview URL from tracks if album doesn't have one
|
// Get preview URL from tracks if album doesn't have one
|
||||||
let previewUrl = attributes.previews?.[0]?.url
|
let previewUrl = attributes.previews?.[0]?.url
|
||||||
let tracks: Array<{ name: string; previewUrl?: string }> = []
|
let tracks: Array<{ name: string; previewUrl?: string; durationMs?: number }> = []
|
||||||
|
|
||||||
// Always fetch tracks to get preview URLs
|
// Always fetch tracks to get preview URLs
|
||||||
if (appleMusicAlbum.id) {
|
if (appleMusicAlbum.id) {
|
||||||
|
|
@ -209,12 +209,13 @@ export async function transformAlbumData(appleMusicAlbum: AppleMusicAlbum) {
|
||||||
.filter((item: any) => item.type === 'songs')
|
.filter((item: any) => item.type === 'songs')
|
||||||
.map((track: any) => ({
|
.map((track: any) => ({
|
||||||
name: track.attributes?.name || 'Unknown',
|
name: track.attributes?.name || 'Unknown',
|
||||||
previewUrl: track.attributes?.previews?.[0]?.url
|
previewUrl: track.attributes?.previews?.[0]?.url,
|
||||||
|
durationMs: track.attributes?.durationInMillis
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Log track details
|
// Log track details
|
||||||
tracks.forEach((track, index) => {
|
tracks.forEach((track, index) => {
|
||||||
console.log(`Track ${index + 1}: ${track.name} - Preview: ${track.previewUrl ? 'Yes' : 'No'}`)
|
console.log(`Track ${index + 1}: ${track.name} - Preview: ${track.previewUrl ? 'Yes' : 'No'} - Duration: ${track.durationMs}ms`)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Find the first track with a preview if we don't have one
|
// Find the first track with a preview if we don't have one
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ export interface Album {
|
||||||
url: string
|
url: string
|
||||||
rank: number
|
rank: number
|
||||||
images: AlbumImages
|
images: AlbumImages
|
||||||
|
isNowPlaying?: boolean
|
||||||
|
nowPlayingTrack?: string
|
||||||
appleMusicData?: {
|
appleMusicData?: {
|
||||||
appleMusicId?: string
|
appleMusicId?: string
|
||||||
highResArtwork?: string
|
highResArtwork?: string
|
||||||
|
|
@ -40,6 +42,7 @@ export interface Album {
|
||||||
tracks?: Array<{
|
tracks?: Array<{
|
||||||
name: string
|
name: string
|
||||||
previewUrl?: string
|
previewUrl?: string
|
||||||
|
durationMs?: number
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,24 @@ const LASTFM_API_KEY = process.env.LASTFM_API_KEY
|
||||||
const USERNAME = 'jedmund'
|
const USERNAME = 'jedmund'
|
||||||
const ALBUM_LIMIT = 10
|
const ALBUM_LIMIT = 10
|
||||||
|
|
||||||
|
// Store last played tracks with timestamps
|
||||||
|
interface TrackPlayInfo {
|
||||||
|
albumName: string
|
||||||
|
trackName: string
|
||||||
|
scrobbleTime: Date
|
||||||
|
durationMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
let recentTracks: TrackPlayInfo[] = []
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ url }) => {
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
const client = new LastClient(LASTFM_API_KEY || '')
|
const client = new LastClient(LASTFM_API_KEY || '')
|
||||||
|
const testMode = url.searchParams.get('test') === 'nowplaying'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// const albums = await getWeeklyAlbumChart(client, USERNAME)
|
// const albums = await getWeeklyAlbumChart(client, USERNAME)
|
||||||
|
|
||||||
const albums = await getRecentAlbums(client, USERNAME, ALBUM_LIMIT)
|
const albums = await getRecentAlbums(client, USERNAME, ALBUM_LIMIT, testMode)
|
||||||
// console.log(albums)
|
// console.log(albums)
|
||||||
const enrichedAlbums = await Promise.all(
|
const enrichedAlbums = await Promise.all(
|
||||||
albums.slice(0, ALBUM_LIMIT).map(async (album) => {
|
albums.slice(0, ALBUM_LIMIT).map(async (album) => {
|
||||||
|
|
@ -58,16 +69,39 @@ async function getWeeklyAlbumChart(client: LastClient, username: string): Promis
|
||||||
async function getRecentAlbums(
|
async function getRecentAlbums(
|
||||||
client: LastClient,
|
client: LastClient,
|
||||||
username: string,
|
username: string,
|
||||||
limit: number
|
limit: number,
|
||||||
|
testMode: boolean = false
|
||||||
): Promise<Album[]> {
|
): Promise<Album[]> {
|
||||||
const recentTracks = await client.user.getRecentTracks(username, { limit: 50, extended: true })
|
const recentTracksResponse = await client.user.getRecentTracks(username, { limit: 50, extended: true })
|
||||||
const uniqueAlbums = new Map<string, Album>()
|
const uniqueAlbums = new Map<string, Album>()
|
||||||
|
let nowPlayingTrack: string | undefined
|
||||||
|
let isFirstAlbum = true
|
||||||
|
|
||||||
|
// Clear old tracks and collect new track play information
|
||||||
|
recentTracks = []
|
||||||
|
|
||||||
|
for (const track of recentTracksResponse.tracks) {
|
||||||
|
// Store track play information for now playing calculation
|
||||||
|
if (track.date) {
|
||||||
|
recentTracks.push({
|
||||||
|
albumName: track.album.name,
|
||||||
|
trackName: track.name,
|
||||||
|
scrobbleTime: track.date
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
for (const track of recentTracks.tracks) {
|
|
||||||
if (uniqueAlbums.size >= limit) break
|
if (uniqueAlbums.size >= limit) break
|
||||||
|
|
||||||
|
// Check if this is the currently playing track
|
||||||
|
if (track.nowPlaying && !nowPlayingTrack) {
|
||||||
|
nowPlayingTrack = track.name
|
||||||
|
}
|
||||||
|
|
||||||
const albumKey = `${track.album.mbid || track.album.name}`
|
const albumKey = `${track.album.mbid || track.album.name}`
|
||||||
if (!uniqueAlbums.has(albumKey)) {
|
if (!uniqueAlbums.has(albumKey)) {
|
||||||
|
// For testing: mark first album as now playing
|
||||||
|
const isNowPlaying = testMode && isFirstAlbum ? true : (track.nowPlaying || false)
|
||||||
|
|
||||||
uniqueAlbums.set(albumKey, {
|
uniqueAlbums.set(albumKey, {
|
||||||
name: track.album.name,
|
name: track.album.name,
|
||||||
artist: {
|
artist: {
|
||||||
|
|
@ -78,7 +112,19 @@ async function getRecentAlbums(
|
||||||
images: transformImages(track.images),
|
images: transformImages(track.images),
|
||||||
mbid: track.album.mbid || '',
|
mbid: track.album.mbid || '',
|
||||||
url: track.url,
|
url: track.url,
|
||||||
rank: uniqueAlbums.size + 1
|
rank: uniqueAlbums.size + 1,
|
||||||
|
// Mark if this album contains the now playing track
|
||||||
|
isNowPlaying: isNowPlaying,
|
||||||
|
nowPlayingTrack: isNowPlaying ? track.name : undefined
|
||||||
|
})
|
||||||
|
isFirstAlbum = false
|
||||||
|
} else if (track.nowPlaying) {
|
||||||
|
// If album already exists but this track is now playing, update it
|
||||||
|
const existingAlbum = uniqueAlbums.get(albumKey)!
|
||||||
|
uniqueAlbums.set(albumKey, {
|
||||||
|
...existingAlbum,
|
||||||
|
isNowPlaying: true,
|
||||||
|
nowPlayingTrack: track.name
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -111,8 +157,12 @@ async function searchAppleMusicForAlbum(album: Album): Promise<Album> {
|
||||||
hasPreview: !!cachedData.previewUrl,
|
hasPreview: !!cachedData.previewUrl,
|
||||||
trackCount: cachedData.tracks?.length || 0
|
trackCount: cachedData.tracks?.length || 0
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Check if this album is currently playing based on track durations
|
||||||
|
const updatedAlbum = checkNowPlaying(album, cachedData)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...album,
|
...updatedAlbum,
|
||||||
images: {
|
images: {
|
||||||
...album.images,
|
...album.images,
|
||||||
itunes: cachedData.highResArtwork || album.images.itunes
|
itunes: cachedData.highResArtwork || album.images.itunes
|
||||||
|
|
@ -130,8 +180,11 @@ async function searchAppleMusicForAlbum(album: Album): Promise<Album> {
|
||||||
// Cache the result for 24 hours
|
// Cache the result for 24 hours
|
||||||
await redis.set(cacheKey, JSON.stringify(transformedData), 'EX', 86400)
|
await redis.set(cacheKey, JSON.stringify(transformedData), 'EX', 86400)
|
||||||
|
|
||||||
|
// Check if this album is currently playing based on track durations
|
||||||
|
const updatedAlbum = checkNowPlaying(album, transformedData)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...album,
|
...updatedAlbum,
|
||||||
images: {
|
images: {
|
||||||
...album.images,
|
...album.images,
|
||||||
itunes: transformedData.highResArtwork || album.images.itunes
|
itunes: transformedData.highResArtwork || album.images.itunes
|
||||||
|
|
@ -182,3 +235,46 @@ function transformImages(images: LastfmImage[]): AlbumImages {
|
||||||
|
|
||||||
return transformedImages
|
return transformedImages
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkNowPlaying(album: Album, appleMusicData: any): Album {
|
||||||
|
// Don't override if already marked as now playing by Last.fm
|
||||||
|
if (album.isNowPlaying) {
|
||||||
|
return album
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any recent track from this album could still be playing
|
||||||
|
const now = new Date()
|
||||||
|
const SCROBBLE_LAG = 3 * 60 * 1000 // 3 minutes in milliseconds
|
||||||
|
|
||||||
|
for (const trackInfo of recentTracks) {
|
||||||
|
if (trackInfo.albumName !== album.name) continue
|
||||||
|
|
||||||
|
// Find the track duration from Apple Music data
|
||||||
|
const trackData = appleMusicData.tracks?.find((t: any) =>
|
||||||
|
t.name.toLowerCase() === trackInfo.trackName.toLowerCase()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (trackData?.durationMs) {
|
||||||
|
// Calculate when the track should end (scrobble time + duration + lag)
|
||||||
|
const trackEndTime = new Date(trackInfo.scrobbleTime.getTime() + trackData.durationMs + SCROBBLE_LAG)
|
||||||
|
|
||||||
|
// If current time is before track end time, it's likely still playing
|
||||||
|
if (now < trackEndTime) {
|
||||||
|
console.log(`Detected now playing: "${trackInfo.trackName}" from "${album.name}"`, {
|
||||||
|
scrobbleTime: trackInfo.scrobbleTime,
|
||||||
|
durationMs: trackData.durationMs,
|
||||||
|
estimatedEndTime: trackEndTime,
|
||||||
|
currentTime: now
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
...album,
|
||||||
|
isNowPlaying: true,
|
||||||
|
nowPlayingTrack: trackInfo.trackName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return album
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue