diff --git a/_data/funkwhaleActivity.js b/_data/funkwhaleActivity.js index 779a67e..c82b01c 100644 --- a/_data/funkwhaleActivity.js +++ b/_data/funkwhaleActivity.js @@ -1,159 +1,19 @@ /** - * Funkwhale Activity Data - * Fetches from Indiekit's endpoint-funkwhale public API + * Funkwhale Activity Data — build-time stub + * + * Data is now fetched client-side via Alpine.js (js/listening.js). + * This stub returns the minimal shape needed so Nunjucks templates + * (slashes.njk, widget guard) continue to render the listening sections, + * while the actual data is populated by the browser after page load. */ -import { cachedFetch } from "../lib/data-fetch.js"; -import { cacheCoverUrls, cacheFunkwhaleImage, gcFunkwhaleImages } from "../lib/cache-funkwhale-image.js"; - -const INDIEKIT_URL = - process.env.INDIEKIT_URL || process.env.SITE_URL || "https://example.com"; -const FUNKWHALE_INSTANCE = process.env.FUNKWHALE_INSTANCE || ""; -const DEFAULT_FETCH_CACHE_DURATION = "5m"; -const LISTENING_FETCH_CACHE_DURATION = - (process.env.LISTENING_FETCH_CACHE_DURATION || "").trim() || DEFAULT_FETCH_CACHE_DURATION; -const FUNKWHALE_FETCH_CACHE_DURATION = - (process.env.FUNKWHALE_FETCH_CACHE_DURATION || "").trim() || LISTENING_FETCH_CACHE_DURATION; - -/** - * Fetch from Indiekit's public Funkwhale API endpoint - */ -async function fetchFromIndiekit(endpoint) { - const urls = [ - `${INDIEKIT_URL}/funkwhale/api/${endpoint}`, - `${INDIEKIT_URL}/funkwhaleapi/api/${endpoint}`, - ]; - - for (const url of urls) { - try { - console.log(`[funkwhaleActivity] Fetching from Indiekit: ${url}`); - const data = await cachedFetch(url, { - duration: FUNKWHALE_FETCH_CACHE_DURATION, - type: "json", - }); - console.log(`[funkwhaleActivity] Indiekit ${endpoint} success via ${url}`); - return data; - } catch (error) { - console.log( - `[funkwhaleActivity] Indiekit API unavailable for ${endpoint} at ${url}: ${error.message}` - ); - } - } - - return null; -} - -/** - * Format duration in seconds to human-readable string - */ -function formatDuration(seconds) { - if (!seconds || seconds < 0) return "0:00"; - - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - - if (hours > 24) { - const days = Math.floor(hours / 24); - return `${days}d`; - } - - if (hours > 0) { - return `${hours}h ${minutes}m`; - } - - return `${minutes}m`; -} - -export default async function () { - try { - console.log("[funkwhaleActivity] Fetching Funkwhale data..."); - console.log( - `[funkwhaleActivity] EleventyFetch cache duration: ${FUNKWHALE_FETCH_CACHE_DURATION}` - ); - - // Fetch all data from Indiekit API - const [nowPlaying, listenings, favorites, stats] = await Promise.all([ - fetchFromIndiekit("now-playing"), - fetchFromIndiekit("listenings"), - fetchFromIndiekit("favorites"), - fetchFromIndiekit("stats"), - ]); - - // Check if we got data - const hasData = nowPlaying || listenings?.listenings?.length || stats?.summary; - - if (!hasData) { - console.log("[funkwhaleActivity] No data available from Indiekit"); - return { - nowPlaying: null, - listenings: [], - favorites: [], - stats: null, - instanceUrl: FUNKWHALE_INSTANCE, - source: "unavailable", - }; - } - - console.log("[funkwhaleActivity] Using Indiekit API data"); - - // Format stats with human-readable durations - let formattedStats = null; - if (stats?.summary) { - formattedStats = { - ...stats, - summary: { - all: { - ...stats.summary.all, - totalDurationFormatted: formatDuration(stats.summary.all?.totalDuration || 0), - }, - month: { - ...stats.summary.month, - totalDurationFormatted: formatDuration(stats.summary.month?.totalDuration || 0), - }, - week: { - ...stats.summary.week, - totalDurationFormatted: formatDuration(stats.summary.week?.totalDuration || 0), - }, - }, - }; - } - - // Mark listenings that appear in favorites - const favSet = new Set( - (favorites?.favorites || []).map((f) => `${f.track}\0${f.artist}`) - ); - const enrichedListenings = (listenings?.listenings || []).map((l) => ({ - ...l, - favorite: favSet.has(`${l.track}\0${l.artist}`), - })); - - // Cache cover images locally to avoid serving expiring presigned S3 URLs - const [cachedNowPlaying, cachedListenings, cachedFavorites] = await Promise.all([ - nowPlaying ? { ...nowPlaying, coverUrl: await cacheFunkwhaleImage(nowPlaying.coverUrl) } : null, - cacheCoverUrls(enrichedListenings), - cacheCoverUrls(favorites?.favorites || []), - ]); - - // Remove cached images that are no longer referenced by any current item - gcFunkwhaleImages(); - - return { - nowPlaying: cachedNowPlaying, - listenings: cachedListenings, - favorites: cachedFavorites, - stats: formattedStats, - instanceUrl: FUNKWHALE_INSTANCE, - source: "indiekit", - }; - } catch (error) { - console.error("[funkwhaleActivity] Error:", error.message); - return { - nowPlaying: null, - listenings: [], - favorites: [], - stats: null, - instanceUrl: FUNKWHALE_INSTANCE, - source: "error", - }; - } +export default function () { + return { + source: "indiekit", + nowPlaying: null, + listenings: [], + favorites: [], + stats: null, + instanceUrl: process.env.FUNKWHALE_INSTANCE || "", + }; } diff --git a/_data/lastfmActivity.js b/_data/lastfmActivity.js index e684f1c..47fe634 100644 --- a/_data/lastfmActivity.js +++ b/_data/lastfmActivity.js @@ -1,99 +1,22 @@ /** - * Last.fm Activity Data - * Fetches from Indiekit's endpoint-lastfm public API + * Last.fm Activity Data — build-time stub + * + * Data is now fetched client-side via Alpine.js (js/listening.js). + * This stub returns the minimal shape needed so Nunjucks templates + * (slashes.njk, widget guard) continue to render the listening sections, + * while the actual data is populated by the browser after page load. */ -import { cachedFetch } from "../lib/data-fetch.js"; - -const INDIEKIT_URL = - process.env.INDIEKIT_URL || process.env.SITE_URL || "https://example.com"; -const LASTFM_USERNAME = process.env.LASTFM_USERNAME || ""; -const DEFAULT_FETCH_CACHE_DURATION = "5m"; -const LISTENING_FETCH_CACHE_DURATION = - (process.env.LISTENING_FETCH_CACHE_DURATION || "").trim() || DEFAULT_FETCH_CACHE_DURATION; -const LASTFM_FETCH_CACHE_DURATION = - (process.env.LASTFM_FETCH_CACHE_DURATION || "").trim() || LISTENING_FETCH_CACHE_DURATION; - -/** - * Fetch from Indiekit's public Last.fm API endpoint - */ -async function fetchFromIndiekit(path) { - const urls = [ - `${INDIEKIT_URL}/lastfmapi/api/${path}`, - `${INDIEKIT_URL}/lastfm/api/${path}`, - ]; - - for (const url of urls) { - try { - console.log(`[lastfmActivity] Fetching from Indiekit: ${url}`); - const data = await cachedFetch(url, { - duration: LASTFM_FETCH_CACHE_DURATION, - type: "json", - }); - console.log(`[lastfmActivity] Indiekit ${path} success via ${url}`); - return data; - } catch (error) { - console.log( - `[lastfmActivity] Indiekit API unavailable for ${path} at ${url}: ${error.message}` - ); - } - } - - return null; -} - -export default async function () { - try { - console.log("[lastfmActivity] Fetching Last.fm data..."); - console.log( - `[lastfmActivity] EleventyFetch cache duration: ${LASTFM_FETCH_CACHE_DURATION}` - ); - - // Fetch all data from Indiekit API - const [nowPlaying, scrobbles, loved, stats] = await Promise.all([ - fetchFromIndiekit("now-playing"), - fetchFromIndiekit("scrobbles?period=alltime&limit=10"), - fetchFromIndiekit("loved?limit=10"), - fetchFromIndiekit("stats?period=alltime"), - ]); - - // Check if we got data - const hasData = nowPlaying || scrobbles?.scrobbles?.length || stats?.summary; - - if (!hasData) { - console.log("[lastfmActivity] No data available from Indiekit"); - return { - nowPlaying: null, - scrobbles: [], - loved: [], - stats: null, - username: LASTFM_USERNAME, - profileUrl: LASTFM_USERNAME ? `https://www.last.fm/user/${LASTFM_USERNAME}` : null, - source: "unavailable", - }; - } - - console.log("[lastfmActivity] Using Indiekit API data"); - - return { - nowPlaying: nowPlaying || null, - scrobbles: scrobbles?.scrobbles || [], - loved: loved?.loved || [], - stats: stats || null, - username: LASTFM_USERNAME, - profileUrl: LASTFM_USERNAME ? `https://www.last.fm/user/${LASTFM_USERNAME}` : null, - source: "indiekit", - }; - } catch (error) { - console.error("[lastfmActivity] Error:", error.message); - return { - nowPlaying: null, - scrobbles: [], - loved: [], - stats: null, - username: LASTFM_USERNAME, - profileUrl: null, - source: "error", - }; - } +export default function () { + return { + source: "indiekit", + nowPlaying: null, + scrobbles: [], + loved: [], + stats: null, + username: process.env.LASTFM_USERNAME || "", + profileUrl: process.env.LASTFM_USERNAME + ? `https://www.last.fm/user/${process.env.LASTFM_USERNAME}` + : null, + }; } diff --git a/_includes/components/widgets/funkwhale.njk b/_includes/components/widgets/funkwhale.njk index 843e52a..9779ff8 100644 --- a/_includes/components/widgets/funkwhale.njk +++ b/_includes/components/widgets/funkwhale.njk @@ -1,8 +1,8 @@ -{# Listening Widget — combined Funkwhale + Last.fm recent tracks #} -{% set hasListening = (funkwhaleActivity and (funkwhaleActivity.nowPlaying or funkwhaleActivity.listenings.length)) or (lastfmActivity and (lastfmActivity.nowPlaying or lastfmActivity.scrobbles.length)) %} +{# Listening Widget — combined Funkwhale + Last.fm recent tracks (client-side) #} +{% set hasListening = (funkwhaleActivity and funkwhaleActivity.source == 'indiekit') or (lastfmActivity and lastfmActivity.source == 'indiekit') %} {% if hasListening %} -
+

@@ -10,100 +10,80 @@ Listening

- {# Now Playing — show if either source is actively playing #} - {% set fwNow = funkwhaleActivity.nowPlaying if funkwhaleActivity and funkwhaleActivity.nowPlaying and funkwhaleActivity.nowPlaying.status == 'now-playing' else null %} - {% set lfmNow = lastfmActivity.nowPlaying if lastfmActivity and lastfmActivity.nowPlaying and lastfmActivity.nowPlaying.status == 'now-playing' else null %} - - {% if fwNow or lfmNow %} - {% set np = fwNow or lfmNow %} - {% set npSource = "Funkwhale" if fwNow else "Last.fm" %} - {% set npColor = "purple" if fwNow else "red" %} -
-
- - Now Playing - ({{ npSource }}) -
-
- {% if np.coverUrl %} - - {% endif %} -
-

- {% if np.trackUrl %} - {{ np.track }} - {% else %} - {{ np.track }} - {% endif %} -

-

{{ np.artist }}

-
-
+ {# Loading skeleton #} +
+
+
+
- {% endif %} - {# Recent tracks — 2 from each source #} -
    - {% if funkwhaleActivity and funkwhaleActivity.listenings.length %} - {% for listening in funkwhaleActivity.listenings | head(2) %} -
  • - {% if listening.coverUrl %} - - {% else %} -
    - - - -
    - {% endif %} -
    -

    - {% if listening.trackUrl %} - {{ listening.track }} - {% else %} - {{ listening.track }} - {% endif %} -

    -

    {{ listening.artist }} - Funkwhale -

    -
    -
  • - {% endfor %} - {% endif %} + {# Error state #} +

    Unavailable

    - {% if lastfmActivity and lastfmActivity.scrobbles.length %} - {% for scrobble in lastfmActivity.scrobbles | head(2) %} -
  • - {% if scrobble.coverUrl %} - - {% else %} -
    - - - + {# Now Playing #} + + + {# Recent tracks #} +
      +
    diff --git a/_includes/layouts/base.njk b/_includes/layouts/base.njk index 8ae590a..3d092d8 100644 --- a/_includes/layouts/base.njk +++ b/_includes/layouts/base.njk @@ -162,6 +162,7 @@ {# Alpine.js components — MUST load before Alpine core (Alpine.data() registration via alpine:init) #} + diff --git a/funkwhale.njk b/funkwhale.njk index 3056918..d0e6ef5 100644 --- a/funkwhale.njk +++ b/funkwhale.njk @@ -4,179 +4,193 @@ title: Funkwhale Listening Activity permalink: /funkwhale/ withSidebar: true --- -
    +

    Listening Activity

    -

    - What I've been listening to. -

    +

    What I've been listening to.

    - {# Now Playing / Recently Played Hero #} - {% if funkwhaleActivity.nowPlaying and funkwhaleActivity.nowPlaying.status %} -
    -
    -
    - {% if funkwhaleActivity.nowPlaying.coverUrl %} - - {% else %} -
    - - - -
    - {% endif %} + {# Loading skeleton #} +
    +
    +
    +
    +
    +
    +
    +
    +
    -
    -
    - {% if funkwhaleActivity.nowPlaying.status == 'now-playing' %} - - - - - - - Now Playing - - {% else %} - - - Recently Played - - {% endif %} + {# Error #} +

    Could not load listening data.

    + + {# Now Playing #} + - {# Stats Section with Tabs #} - {% if funkwhaleActivity.stats %} -
    -

    - - - - Listening Statistics -

    + {# Stats Section #} + {# Recent Listenings #}
    @@ -186,89 +200,79 @@ withSidebar: true Recent Listens - - {% if funkwhaleActivity.listenings.length %}
    - {% for listening in funkwhaleActivity.listenings | head(15) %} -
    - {% if listening.coverUrl %} - - {% else %} -
    - - - -
    - {% endif %} - -
    -

    - {% if listening.trackUrl %} - - {{ listening.track }} - - {% else %} - {{ listening.track }} - {% endif %} -

    -

    {{ listening.artist }}

    -
    - -
    - {{ listening.relativeTime }} - {% if listening.duration %} - {{ listening.duration }} - {% endif %} -
    -
    - {% endfor %} + +

    No recent listening history available.

    - {% else %} -

    No recent listening history available.

    - {% endif %}
    {# Favorites #} - {% if funkwhaleActivity.favorites.length %} -
    -

    - - - - Favorite Tracks -

    - -
    - {% for favorite in funkwhaleActivity.favorites | head(10) %} -
    - {% if favorite.coverUrl %} - - {% else %} -
    - - - -
    - {% endif %} - -
    -

    - {% if favorite.trackUrl %} - - {{ favorite.track }} - - {% else %} - {{ favorite.track }} - {% endif %} -

    -

    {{ favorite.artist }}

    - {% if favorite.album %} -

    {{ favorite.album }}

    - {% endif %} -
    +
    diff --git a/js/listening.js b/js/listening.js new file mode 100644 index 0000000..14e58e0 --- /dev/null +++ b/js/listening.js @@ -0,0 +1,197 @@ +/** + * Client-side listening components (Alpine.js) + * Replaces build-time Eleventy data fetches for Funkwhale and Last.fm. + * Data is fetched live from the Indiekit API on the same origin. + */ + +document.addEventListener("alpine:init", () => { + // ── helpers ────────────────────────────────────────────────────────────── + + async function apiFetch(path) { + const r = await fetch(path); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json(); + } + + function mergeListens(fw = [], lfm = []) { + const fwItems = fw.map((l) => ({ + ...l, + _source: "funkwhale", + _ts: new Date(l.listenedAt || l.creation_date || l.listened_at || 0).getTime(), + })); + const lfmItems = lfm.map((s) => ({ + ...s, + _source: "lastfm", + _ts: new Date(s.scrobbledAt || 0).getTime(), + })); + return [...fwItems, ...lfmItems].sort((a, b) => b._ts - a._ts).slice(0, 20); + } + + function formatDuration(seconds) { + if (!seconds || seconds < 0) return "0:00"; + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + if (hours > 24) return `${Math.floor(hours / 24)}d`; + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; + } + + // ── sidebar widget ──────────────────────────────────────────────────────── + // Used on every page that includes widgets/funkwhale.njk. + // defers init until the widget scrolls into view. + + Alpine.data("listeningWidget", () => ({ + loading: true, + error: false, + fwNowPlaying: null, + fwListenings: [], + lfmNowPlaying: null, + lfmScrobbles: [], + + async init() { + try { + const [fwNp, fwList, lfmNp, lfmList] = await Promise.allSettled([ + apiFetch("/funkwhale/api/now-playing"), + apiFetch("/funkwhale/api/listenings?limit=2"), + apiFetch("/lastfmapi/api/now-playing"), + apiFetch("/lastfmapi/api/scrobbles?limit=2"), + ]); + if (fwNp.status === "fulfilled") this.fwNowPlaying = fwNp.value; + if (fwList.status === "fulfilled") this.fwListenings = fwList.value.listenings || []; + if (lfmNp.status === "fulfilled") this.lfmNowPlaying = lfmNp.value; + if (lfmList.status === "fulfilled") this.lfmScrobbles = lfmList.value.scrobbles || []; + } catch { + this.error = true; + } finally { + this.loading = false; + } + }, + + get nowPlaying() { + if (this.fwNowPlaying?.status === "now-playing") + return { ...this.fwNowPlaying, _source: "funkwhale" }; + if (this.lfmNowPlaying?.status === "now-playing") + return { ...this.lfmNowPlaying, _source: "lastfm" }; + return null; + }, + + get recentTracks() { + const fw = this.fwListenings.slice(0, 2).map((l) => ({ ...l, _source: "funkwhale" })); + const lfm = this.lfmScrobbles.slice(0, 2).map((s) => ({ ...s, _source: "lastfm" })); + return [...fw, ...lfm].slice(0, 4); + }, + })); + + // ── Funkwhale full page ─────────────────────────────────────────────────── + + Alpine.data("funkwhalePage", () => ({ + loading: true, + error: false, + nowPlaying: null, + listenings: [], + favorites: [], + stats: null, + activeTab: "all", + + async init() { + try { + const [np, list, fav, st] = await Promise.allSettled([ + apiFetch("/funkwhale/api/now-playing"), + apiFetch("/funkwhale/api/listenings"), + apiFetch("/funkwhale/api/favorites"), + apiFetch("/funkwhale/api/stats"), + ]); + if (np.status === "fulfilled") this.nowPlaying = np.value; + if (list.status === "fulfilled") this.listenings = list.value.listenings || []; + if (fav.status === "fulfilled") this.favorites = fav.value.favorites || []; + if (st.status === "fulfilled") this.stats = st.value; + } catch { + this.error = true; + } finally { + this.loading = false; + } + }, + + // computed stats helpers + get summary() { + const m = { all: this.stats?.summary?.all, month: this.stats?.summary?.month, week: this.stats?.summary?.week }; + return m[this.activeTab] || {}; + }, + get topArtists() { + const m = { all: this.stats?.topArtists?.all, month: this.stats?.topArtists?.month, week: this.stats?.topArtists?.week }; + return (m[this.activeTab] || []).slice(0, 5); + }, + get topAlbums() { + const m = { all: this.stats?.topAlbums?.all, month: this.stats?.topAlbums?.month, week: this.stats?.topAlbums?.week }; + return (m[this.activeTab] || []).slice(0, 5); + }, + get trendsData() { return this.stats?.trends || []; }, + get trendsMax() { return Math.max(1, ...this.trendsData.map((d) => d.count)); }, + get totalDurationFormatted() { + return formatDuration(this.summary.totalDuration || 0); + }, + })); + + // ── combined listening page ─────────────────────────────────────────────── + + Alpine.data("listeningPage", () => ({ + fwLoading: true, + lfmLoading: true, + fw: { nowPlaying: null, listenings: [], favorites: [], stats: null }, + lfm: { nowPlaying: null, scrobbles: [], loved: [], stats: null }, + activeSource: "all", + + async init() { + await Promise.all([this._fetchFunkwhale(), this._fetchLastfm()]); + }, + + async _fetchFunkwhale() { + try { + const [np, list, fav, st] = await Promise.allSettled([ + apiFetch("/funkwhale/api/now-playing"), + apiFetch("/funkwhale/api/listenings"), + apiFetch("/funkwhale/api/favorites"), + apiFetch("/funkwhale/api/stats"), + ]); + if (np.status === "fulfilled") this.fw.nowPlaying = np.value; + if (list.status === "fulfilled") this.fw.listenings = list.value.listenings || []; + if (fav.status === "fulfilled") this.fw.favorites = fav.value.favorites || []; + if (st.status === "fulfilled") this.fw.stats = st.value; + } finally { + this.fwLoading = false; + } + }, + + async _fetchLastfm() { + try { + const [np, sc, lv, st] = await Promise.allSettled([ + apiFetch("/lastfmapi/api/now-playing"), + apiFetch("/lastfmapi/api/scrobbles?period=alltime&limit=20"), + apiFetch("/lastfmapi/api/loved?limit=10"), + apiFetch("/lastfmapi/api/stats?period=alltime"), + ]); + if (np.status === "fulfilled") this.lfm.nowPlaying = np.value; + if (sc.status === "fulfilled") this.lfm.scrobbles = sc.value.scrobbles || []; + if (lv.status === "fulfilled") this.lfm.loved = lv.value.loved || []; + if (st.status === "fulfilled") this.lfm.stats = st.value; + } finally { + this.lfmLoading = false; + } + }, + + get loading() { return this.fwLoading || this.lfmLoading; }, + get fwNowPlaying() { return this.fw.nowPlaying?.status ? this.fw.nowPlaying : null; }, + get lfmNowPlaying() { return this.lfm.nowPlaying?.status ? this.lfm.nowPlaying : null; }, + get hasFunkwhale() { return !this.fwLoading; }, + get hasLastfm() { return !this.lfmLoading; }, + + get combinedListens() { + return mergeListens(this.fw.listenings, this.lfm.scrobbles); + }, + + get fwStatsAll() { return this.fw.stats?.summary?.all || {}; }, + get lfmStatsAll() { return this.lfm.stats?.summary?.all || {}; }, + get fwTopArtists() { return (this.fw.stats?.topArtists?.all || []).slice(0, 5); }, + get lfmTopArtists() { return (this.lfm.stats?.topArtists?.all || []).slice(0, 5); }, + })); +}); diff --git a/listening.njk b/listening.njk index 4a3b7a3..3a306d3 100644 --- a/listening.njk +++ b/listening.njk @@ -4,20 +4,10 @@ title: Listening Activity permalink: /listening/ withSidebar: true --- -
    +

    Listening Activity

    -

    - What I've been listening to. -{# {% if funkwhaleActivity.instanceUrl %} - Funkwhale - {% endif %} - {% if funkwhaleActivity.instanceUrl and lastfmActivity.profileUrl %} and {% endif %} - {% if lastfmActivity.profileUrl %} - Last.fm - {% endif %} -#} -

    +

    What I've been listening to.

    {# Source Filter Tabs #} @@ -26,238 +16,221 @@ withSidebar: true @click="activeSource = 'all'" :class="activeSource === 'all' ? 'bg-purple-600 text-white' : 'bg-surface-100 dark:bg-surface-800 text-surface-700 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-700'" class="px-4 py-2 rounded-full text-sm font-medium transition-colors" - > - All Sources - - {% if funkwhaleActivity.source == 'indiekit' %} + >All Sources - {% endif %} - {% if lastfmActivity.source == 'indiekit' %} + >Funkwhale - {% endif %} + >Last.fm
    - {# Now Playing Section - Combined #} - {% set fwNowPlaying = funkwhaleActivity.nowPlaying if funkwhaleActivity.nowPlaying and funkwhaleActivity.nowPlaying.status else null %} - {% set lfmNowPlaying = lastfmActivity.nowPlaying if lastfmActivity.nowPlaying and lastfmActivity.nowPlaying.status else null %} + {# Loading skeleton #} +
    +
    +
    +
    +
    +
    +
    +
    +
    - {% if fwNowPlaying or lfmNowPlaying %} -
    - {# Funkwhale Now Playing #} - {% if fwNowPlaying %} -
    -
    + {# Now Playing — Funkwhale #} + - {# Last.fm Now Playing #} - {% if lfmNowPlaying %} -
    -
    + {# Now Playing — Last.fm #} + {# Combined Stats Section #} - {% if funkwhaleActivity.stats or lastfmActivity.stats %} -
    -

    - - - - Listening Statistics -

    + + + {# Combined Recent Listens Timeline #}

    @@ -265,195 +238,127 @@ withSidebar: true Recent Listens

    - - {% set combinedListens = funkwhaleActivity.listenings | mergeListens(lastfmActivity.scrobbles) | head(20) %} -
    - {% if combinedListens.length %} - {% for item in combinedListens %} -
    - {% if item.coverUrl %} - - {% else %} -
    - - - -
    - {% endif %} - -
    -

    - {% if item.trackUrl %} - {{ item.track }} - {% else %} - {{ item.track }} - {% endif %} - {% if item.favorite or item.loved %} - - {% endif %} -

    -

    {{ item.artist }}

    -
    - -
    - {% if item._source == 'funkwhale' %} - Funkwhale - {% else %} - Last.fm - {% endif %} - {{ item.relativeTime }} - - -
    -
    - {% endfor %} - {% else %} -

    No recent listening history available.

    - {% endif %} + +

    No recent listening history available.

    - {# Loved Tracks from Last.fm #} - {% if lastfmActivity.loved.length %} -
    -

    - - - - Loved Tracks - (Last.fm) -

    - -
    - {% for track in lastfmActivity.loved | head(10) %} -
    - {% if track.coverUrl %} - - {% else %} -
    - - - -
    - {% endif %} - -
    -

    - {% if track.trackUrl %} - {{ track.track }} - {% else %} - {{ track.track }} - {% endif %} -

    -

    {{ track.artist }}

    -
    - - - - + {# Loved Tracks — Last.fm #} + - {# Funkwhale Favorites #} - {% if funkwhaleActivity.favorites.length %} -
    -

    - - - - Favorite Tracks - (Funkwhale) -

    - -
    - {% for favorite in funkwhaleActivity.favorites | head(10) %} -
    - {% if favorite.coverUrl %} - - {% else %} -
    - - - -
    - {% endif %} - -
    -

    - {% if favorite.trackUrl %} - {{ favorite.track }} - {% else %} - {{ favorite.track }} - {% endif %} -

    -

    {{ favorite.artist }}

    - {% if favorite.album %} -

    {{ favorite.album }}

    - {% endif %} -
    - - + {# Favorites — Funkwhale #} +