Remove recently played from music
This commit is contained in:
1 parent
90d61c48e2
commit
f5df946e0b
5 files changed
+7
-206
No files matched your search
@@ -5,7 +5,6 @@
|
|||||||
import Panel from '../../ui/Panel.svelte';
|
import Panel from '../../ui/Panel.svelte';
|
||||||
import ActivityPanel from './ActivityPanel.svelte';
|
import ActivityPanel from './ActivityPanel.svelte';
|
||||||
import ListeningFetch from './ListeningFetch.svelte';
|
import ListeningFetch from './ListeningFetch.svelte';
|
||||||
import RecentTracks from './RecentTracks.svelte';
|
|
||||||
import TopCharts from './TopCharts.svelte';
|
import TopCharts from './TopCharts.svelte';
|
||||||
import TrendingArtists from './TrendingArtists.svelte';
|
import TrendingArtists from './TrendingArtists.svelte';
|
||||||
|
|
||||||
@@ -16,18 +15,13 @@
|
|||||||
<Panel title="last.fm" icon="simple-icons:lastdotfm">
|
<Panel title="last.fm" icon="simple-icons:lastdotfm">
|
||||||
<ListeningFetch {listening} />
|
<ListeningFetch {listening} />
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel title={listening.nowPlaying ? 'Now playing' : 'Recently played'} icon="mdi:headphones">
|
|
||||||
<RecentTracks nowPlaying={listening.nowPlaying} recentTracks={listening.recentTracks} />
|
|
||||||
</Panel>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="overview">
|
|
||||||
<ActivityPanel {listening} />
|
|
||||||
<Panel title="Trending this week" icon="mdi:trending-up">
|
<Panel title="Trending this week" icon="mdi:trending-up">
|
||||||
<TrendingArtists artists={listening.trending} />
|
<TrendingArtists artists={listening.trending} />
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ActivityPanel {listening} />
|
||||||
|
|
||||||
<h3 class="subheading"><Icon icon="mdi:trophy-outline" width="16" /> Top charts</h3>
|
<h3 class="subheading"><Icon icon="mdi:trophy-outline" width="16" /> Top charts</h3>
|
||||||
<TopCharts top={listening.top} />
|
<TopCharts top={listening.top} />
|
||||||
|
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Icon from '@iconify/svelte';
|
|
||||||
import type { PlayedTrack } from '#lib/lastfm/types';
|
|
||||||
|
|
||||||
let { nowPlaying, recentTracks }: { nowPlaying: PlayedTrack | null; recentTracks: PlayedTrack[] } = $props();
|
|
||||||
|
|
||||||
const shownCount = 5;
|
|
||||||
|
|
||||||
const tracks = $derived(
|
|
||||||
[...(nowPlaying ? [nowPlaying] : []), ...recentTracks].slice(0, shownCount)
|
|
||||||
);
|
|
||||||
|
|
||||||
function timeAgo(playedAt: number | null): string {
|
|
||||||
if (playedAt === null) return 'now';
|
|
||||||
const minutes = Math.floor((Date.now() / 1000 - playedAt) / 60);
|
|
||||||
if (minutes < 60) return `${Math.max(1, minutes)}m ago`;
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
if (hours < 24) return `${hours}h ago`;
|
|
||||||
return `${Math.floor(hours / 24)}d ago`;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if tracks.length}
|
|
||||||
<ul class="tracks">
|
|
||||||
{#each tracks as track, index (`${track.playedAt}-${index}`)}
|
|
||||||
<li>
|
|
||||||
<a class="track" class:playing={track.playedAt === null} href={track.url} target="_blank" rel="noopener noreferrer">
|
|
||||||
{#if track.image}
|
|
||||||
<img src={track.image} alt={track.album} loading="lazy" />
|
|
||||||
{:else}
|
|
||||||
<span class="cover-fallback"><Icon icon="mdi:music-note" width="18" /></span>
|
|
||||||
{/if}
|
|
||||||
<span class="text">
|
|
||||||
<span class="name">{track.track}</span>
|
|
||||||
<span class="muted artist">{track.artist}</span>
|
|
||||||
</span>
|
|
||||||
<span class="when">
|
|
||||||
{#if track.playedAt === null}
|
|
||||||
<span class="equalizer" aria-label="Now playing"><span></span><span></span><span></span></span>
|
|
||||||
{:else}
|
|
||||||
<span class="muted">{timeAgo(track.playedAt)}</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{:else}
|
|
||||||
<p class="muted">No scrobbles yet.</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.tracks {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.3rem;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.65rem;
|
|
||||||
padding: 0.3rem;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track:hover {
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.track.playing {
|
|
||||||
border-color: color-mix(in srgb, var(--green) 50%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
img,
|
|
||||||
.cover-fallback {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover-fallback {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
background: color-mix(in srgb, var(--foreground) 8%, transparent);
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.text {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.name,
|
|
||||||
.artist {
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.name {
|
|
||||||
font-size: 0.88rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.artist,
|
|
||||||
.when {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.when {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.equalizer {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 2px;
|
|
||||||
height: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.equalizer span {
|
|
||||||
width: 3px;
|
|
||||||
background: var(--green);
|
|
||||||
animation: equalize 0.9s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.equalizer span:nth-child(2) {
|
|
||||||
animation-delay: 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.equalizer span:nth-child(3) {
|
|
||||||
animation-delay: 0.4s;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes equalize {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
height: 30%;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.equalizer span {
|
|
||||||
animation: none;
|
|
||||||
height: 70%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -48,8 +48,6 @@ export interface ListeningProfile {
|
|||||||
profileUrl: string;
|
profileUrl: string;
|
||||||
registeredAt: number | null;
|
registeredAt: number | null;
|
||||||
totals: ListeningTotals;
|
totals: ListeningTotals;
|
||||||
nowPlaying: PlayedTrack | null;
|
|
||||||
recentTracks: PlayedTrack[];
|
|
||||||
daily: CountBucket[];
|
daily: CountBucket[];
|
||||||
monthly: CountBucket[];
|
monthly: CountBucket[];
|
||||||
hourly: CountBucket[];
|
hourly: CountBucket[];
|
||||||
|
|||||||
@@ -121,13 +121,6 @@ export async function fetchRecentTracksPage(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchNowPlaying(userName: string): Promise<PlayedTrack | null> {
|
|
||||||
const { recenttracks } = await callLastfm<RawRecentTracks>('user.getrecenttracks', { user: userName, limit: '1' });
|
|
||||||
const rawTracks = Array.isArray(recenttracks.track) ? recenttracks.track : [recenttracks.track];
|
|
||||||
const nowPlayingRaw = rawTracks.find((raw) => raw['@attr']?.nowplaying === 'true');
|
|
||||||
return nowPlayingRaw ? toPlayedTrack(nowPlayingRaw) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchTrackImage(artist: string, track: string): Promise<string | null> {
|
export async function fetchTrackImage(artist: string, track: string): Promise<string | null> {
|
||||||
const { track: info } = await callLastfm<RawTrackInfo>('track.getinfo', { artist, track, autocorrect: '1' });
|
const { track: info } = await callLastfm<RawTrackInfo>('track.getinfo', { artist, track, autocorrect: '1' });
|
||||||
return largestImage(info?.album?.image);
|
return largestImage(info?.album?.image);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ListeningProfile, PlayedTrack } from '#lib/lastfm/types';
|
import type { ListeningProfile } from '#lib/lastfm/types';
|
||||||
import { fetchNowPlaying, fetchUserInfo, hasLastfmKey, type UserInfo } from './api';
|
import { fetchUserInfo, hasLastfmKey, type UserInfo } from './api';
|
||||||
import { loadScrobbleStore, storedScrobbles } from './store';
|
import { loadScrobbleStore, storedScrobbles } from './store';
|
||||||
import { coverFor, coversVersion, loadCovers, requestTrackCovers } from './covers';
|
import { coversVersion, loadCovers, requestTrackCovers } from './covers';
|
||||||
import { lastfmSyncedAt } from './sync';
|
import { lastfmSyncedAt } from './sync';
|
||||||
import {
|
import {
|
||||||
countsByDay,
|
countsByDay,
|
||||||
@@ -14,14 +14,11 @@ import {
|
|||||||
} from './trends';
|
} from './trends';
|
||||||
|
|
||||||
const userInfoDuration = 30 * 60 * 1000;
|
const userInfoDuration = 30 * 60 * 1000;
|
||||||
const nowPlayingDuration = 60 * 1000;
|
|
||||||
const summaryDuration = 5 * 60 * 1000;
|
const summaryDuration = 5 * 60 * 1000;
|
||||||
const recentCount = 8;
|
|
||||||
|
|
||||||
type Summary = Omit<ListeningProfile, 'userName' | 'profileUrl' | 'registeredAt' | 'totals' | 'nowPlaying' | 'syncedAt'>;
|
type Summary = Omit<ListeningProfile, 'userName' | 'profileUrl' | 'registeredAt' | 'totals' | 'syncedAt'>;
|
||||||
|
|
||||||
let userInfoCache: { info: UserInfo; fetchedAt: number } | null = null;
|
let userInfoCache: { info: UserInfo; fetchedAt: number } | null = null;
|
||||||
let nowPlayingCache: { track: PlayedTrack | null; fetchedAt: number } | null = null;
|
|
||||||
let summaryCache: { summary: Summary; scrobbleCount: number; coversVersion: number; builtAt: number } | null = null;
|
let summaryCache: { summary: Summary; scrobbleCount: number; coversVersion: number; builtAt: number } | null = null;
|
||||||
|
|
||||||
async function cachedUserInfo(userName: string): Promise<UserInfo | null> {
|
async function cachedUserInfo(userName: string): Promise<UserInfo | null> {
|
||||||
@@ -34,27 +31,6 @@ async function cachedUserInfo(userName: string): Promise<UserInfo | null> {
|
|||||||
return userInfoCache?.info ?? null;
|
return userInfoCache?.info ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cachedNowPlaying(userName: string): Promise<PlayedTrack | null> {
|
|
||||||
if (nowPlayingCache && Date.now() - nowPlayingCache.fetchedAt < nowPlayingDuration) return nowPlayingCache.track;
|
|
||||||
try {
|
|
||||||
nowPlayingCache = { track: await fetchNowPlaying(userName), fetchedAt: Date.now() };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Last.fm now playing failed:', error instanceof Error ? error.message : error);
|
|
||||||
}
|
|
||||||
return nowPlayingCache?.track ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function recentTracks(): PlayedTrack[] {
|
|
||||||
return storedScrobbles()
|
|
||||||
.slice(-recentCount)
|
|
||||||
.reverse()
|
|
||||||
.map((scrobble) => ({
|
|
||||||
...scrobble,
|
|
||||||
url: `https://www.last.fm/music/${encodeURIComponent(scrobble.artist)}/_/${encodeURIComponent(scrobble.track)}`,
|
|
||||||
image: coverFor(scrobble.artist, scrobble.album, scrobble.track)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSummary(): Summary {
|
function buildSummary(): Summary {
|
||||||
const scrobbles = storedScrobbles();
|
const scrobbles = storedScrobbles();
|
||||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||||
@@ -63,7 +39,6 @@ function buildSummary(): Summary {
|
|||||||
const streaks = listeningStreaks(dayCounts);
|
const streaks = listeningStreaks(dayCounts);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
recentTracks: recentTracks(),
|
|
||||||
daily,
|
daily,
|
||||||
monthly: monthlyBuckets(dayCounts),
|
monthly: monthlyBuckets(dayCounts),
|
||||||
hourly: hourlyBuckets(scrobbles),
|
hourly: hourlyBuckets(scrobbles),
|
||||||
@@ -79,7 +54,6 @@ function buildSummary(): Summary {
|
|||||||
function tracksMissingCovers(summary: Summary) {
|
function tracksMissingCovers(summary: Summary) {
|
||||||
return Object.values(summary.top)
|
return Object.values(summary.top)
|
||||||
.flatMap((charts) => charts.tracks)
|
.flatMap((charts) => charts.tracks)
|
||||||
.concat(summary.recentTracks.map((track) => ({ ...track, name: track.track, count: 0 })))
|
|
||||||
.filter((item) => !item.image && item.artist)
|
.filter((item) => !item.image && item.artist)
|
||||||
.map((item) => ({ artist: item.artist!, track: item.name }));
|
.map((item) => ({ artist: item.artist!, track: item.name }));
|
||||||
}
|
}
|
||||||
@@ -105,7 +79,7 @@ export async function getListeningProfile(userName: string): Promise<ListeningPr
|
|||||||
if (!hasLastfmKey()) return null;
|
if (!hasLastfmKey()) return null;
|
||||||
|
|
||||||
await Promise.all([loadScrobbleStore(), loadCovers()]);
|
await Promise.all([loadScrobbleStore(), loadCovers()]);
|
||||||
const [info, nowPlaying] = await Promise.all([cachedUserInfo(userName), cachedNowPlaying(userName)]);
|
const info = await cachedUserInfo(userName);
|
||||||
if (!info && !storedScrobbles().length) return null;
|
if (!info && !storedScrobbles().length) return null;
|
||||||
|
|
||||||
const summary = cachedSummary();
|
const summary = cachedSummary();
|
||||||
@@ -119,7 +93,6 @@ export async function getListeningProfile(userName: string): Promise<ListeningPr
|
|||||||
albums: info?.albums ?? 0,
|
albums: info?.albums ?? 0,
|
||||||
tracks: info?.tracks ?? 0
|
tracks: info?.tracks ?? 0
|
||||||
},
|
},
|
||||||
nowPlaying,
|
|
||||||
syncedAt: lastfmSyncedAt(),
|
syncedAt: lastfmSyncedAt(),
|
||||||
...summary
|
...summary
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in new issue
Block a user