feat(player-stats): local listening history tab with heatmap and summaries (#849)

* feat(player-stats): local listening history tab with heatmap and summaries

Record play sessions in library.sqlite when the library index is enabled,
add Rust read APIs and Tauri commands for year/day aggregates, and ship the
Player stats UI with session clustering, event-driven live refresh, and a
notice when some servers are excluded from indexing.

* test(player-stats): split play_session repo and expand test coverage

Move the repository into play_session/ (completion, cluster, integration tests),
add remap/purge/FK coverage in Rust, and cover ingestion gates plus live-refresh
hooks on the frontend per spec v0.3.

* docs(release): CHANGELOG and credits for player stats (PR #849)

* fix(player-stats): satisfy tsc and clippy CI gates

Use InternetRadioStation field names in the radio skip test and replace
manual month/day range checks with RangeInclusive::contains.
This commit is contained in:
cucadmuh
2026-05-22 14:07:38 +03:00
committed by GitHub
parent 7afddf7b84
commit 23f7ba02d6
49 changed files with 3059 additions and 19 deletions
+89
View File
@@ -464,6 +464,95 @@ export function libraryDeleteServerData(serverId: string): Promise<void> {
return invoke<void>('library_delete_server_data', { serverId });
}
// ── Player stats (local listening history) ────────────────────────────
export type PlaySessionEndReason = 'ended' | 'skip' | 'stop' | 'switch' | 'close';
export type PlaySessionInput = {
serverId: string;
trackId: string;
startedAtMs: number;
listenedSec: number;
positionMaxSec: number;
endReason: PlaySessionEndReason;
/** Player-known track duration when the library index has none. */
durationSecHint?: number;
};
export type PlaySessionYearSummary = {
totalListenedSec: number;
sessionCount: number;
trackPlayCount: number;
uniqueTrackCount: number;
listeningDayCount: number;
fullCount: number;
partialCount: number;
};
export type PlaySessionHeatmapDay = {
date: string;
trackPlayCount: number;
};
export type PlaySessionDayTrack = {
serverId: string;
trackId: string;
title: string;
artist: string | null;
listenedSec: number;
completion: 'partial' | 'full' | string;
startedAtMs: number;
};
export type PlaySessionDayDetail = {
totals: {
totalListenedSec: number;
sessionCount: number;
trackPlayCount: number;
fullCount: number;
partialCount: number;
};
tracks: PlaySessionDayTrack[];
};
export type PlaySessionYearBounds = {
minYear: number | null;
maxYear: number | null;
};
export type PlaySessionRecentDay = {
date: string;
totalListenedSec: number;
sessionCount: number;
trackPlayCount: number;
fullCount: number;
partialCount: number;
};
export function libraryRecordPlaySession(input: PlaySessionInput): Promise<void> {
return invoke<void>('library_record_play_session', { input });
}
export function libraryGetPlayerStatsYearSummary(year: number): Promise<PlaySessionYearSummary> {
return invoke<PlaySessionYearSummary>('library_get_player_stats_year_summary', { year });
}
export function libraryGetPlayerStatsHeatmap(year: number): Promise<PlaySessionHeatmapDay[]> {
return invoke<PlaySessionHeatmapDay[]>('library_get_player_stats_heatmap', { year });
}
export function libraryGetPlayerStatsDayDetail(dateIso: string): Promise<PlaySessionDayDetail> {
return invoke<PlaySessionDayDetail>('library_get_player_stats_day_detail', { dateIso });
}
export function libraryGetPlayerStatsYearBounds(): Promise<PlaySessionYearBounds> {
return invoke<PlaySessionYearBounds>('library_get_player_stats_year_bounds');
}
export function libraryGetPlayerStatsRecentDays(limit = 30): Promise<PlaySessionRecentDay[]> {
return invoke<PlaySessionRecentDay[]>('library_get_player_stats_recent_days', { limit });
}
// ── Event subscriptions ───────────────────────────────────────────────
export interface LibrarySyncProgressPayload {
+1
View File
@@ -66,6 +66,7 @@ export default function AppRoutes() {
<Route path="/search" element={<SearchResults />} />
<Route path="/search/advanced" element={<AdvancedSearch />} />
<Route path="/statistics" element={<Statistics />} />
<Route path="/player-stats" element={<Statistics />} />
<Route path="/most-played" element={<MostPlayed />} />
<Route path="/lossless-albums" element={<LosslessAlbums />} />
<Route path="/now-playing" element={isMobile ? <MobilePlayerView /> : <NowPlayingPage />} />
@@ -0,0 +1,163 @@
import React, { useCallback, useEffect, useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
libraryGetPlayerStatsHeatmap,
libraryGetPlayerStatsYearBounds,
libraryGetPlayerStatsYearSummary,
type PlaySessionYearBounds,
type PlaySessionYearSummary,
} from '../../api/library';
import { usePlayerStatsLiveRefresh } from '../../hooks/usePlayerStatsLiveRefresh';
import PlayerStatsHeatmap from './PlayerStatsHeatmap';
import PlayerStatsPartialIndexNotice from './PlayerStatsPartialIndexNotice';
import PlayerStatsRecentDays from './PlayerStatsRecentDays';
import { formatPlayerStatsListeningTotal } from '../../utils/format/formatHumanDuration';
const currentCalendarYear = () => new Date().getFullYear();
export default function PlayerStatisticsPanel() {
const { t } = useTranslation();
const [year, setYear] = useState(currentCalendarYear);
const [yearBounds, setYearBounds] = useState<PlaySessionYearBounds | null>(null);
const [summary, setSummary] = useState<PlaySessionYearSummary | null>(null);
const [dayCounts, setDayCounts] = useState<Map<string, number>>(new Map());
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [recentRefreshKey, setRecentRefreshKey] = useState(0);
const [liveRefreshKey, setLiveRefreshKey] = useState(0);
useEffect(() => {
let cancelled = false;
setLoading(true);
setSelectedDate(null);
Promise.all([
libraryGetPlayerStatsYearSummary(year),
libraryGetPlayerStatsHeatmap(year),
libraryGetPlayerStatsYearBounds(),
])
.then(([s, heat, bounds]) => {
if (cancelled) return;
setSummary(s);
setDayCounts(new Map(heat.map(h => [h.date, h.trackPlayCount])));
setYearBounds(bounds);
setLoading(false);
setRecentRefreshKey(k => k + 1);
})
.catch(() => {
if (!cancelled) {
setSummary(null);
setDayCounts(new Map());
setLoading(false);
}
});
return () => { cancelled = true; };
}, [year]);
const refreshLive = useCallback(async () => {
try {
const [s, heat] = await Promise.all([
libraryGetPlayerStatsYearSummary(year),
libraryGetPlayerStatsHeatmap(year),
]);
setSummary(s);
setDayCounts(new Map(heat.map(h => [h.date, h.trackPlayCount])));
setLiveRefreshKey(k => k + 1);
} catch {
/* ignore transient read errors during live refresh */
}
}, [year]);
usePlayerStatsLiveRefresh(refreshLive);
const empty = !loading && (summary?.trackPlayCount ?? 0) === 0;
const calYear = currentCalendarYear();
const maxNavYear = yearBounds?.maxYear != null
? Math.min(calYear, yearBounds.maxYear)
: calYear;
const canGoPrev = !loading && yearBounds?.minYear != null && year > yearBounds.minYear;
const canGoNext = !loading && year < maxNavYear;
return (
<div className="stats-page">
<PlayerStatsPartialIndexNotice />
<div className="player-stats-year-nav">
<button
type="button"
className="btn btn-surface btn-sm"
onClick={() => canGoPrev && setYear(y => y - 1)}
disabled={!canGoPrev}
aria-label={t('statistics.playerYearPrev')}
aria-disabled={!canGoPrev}
>
<ChevronLeft size={16} />
</button>
<span style={{ fontWeight: 700, fontSize: '1.1rem' }}>{year}</span>
<button
type="button"
className="btn btn-surface btn-sm"
onClick={() => canGoNext && setYear(y => y + 1)}
disabled={!canGoNext}
aria-label={t('statistics.playerYearNext')}
aria-disabled={!canGoNext}
>
<ChevronRight size={16} />
</button>
</div>
{loading ? (
<div className="loading-center"><div className="spinner" /></div>
) : (
<>
{!empty && summary && (
<div className="stats-overview" style={{ marginBottom: '1.25rem' }}>
<div className="stats-card">
<span className="stats-card-value">{formatPlayerStatsListeningTotal(summary.totalListenedSec)}</span>
<span className="stats-card-label">{t('statistics.playerSummaryTime')}</span>
</div>
<div className="stats-card">
<span className="stats-card-value">{summary.sessionCount.toLocaleString()}</span>
<span className="stats-card-label">{t('statistics.playerSummarySessions')}</span>
</div>
<div className="stats-card">
<span className="stats-card-value">{summary.trackPlayCount.toLocaleString()}</span>
<span className="stats-card-label">{t('statistics.playerSummaryTracks')}</span>
</div>
<div className="stats-card">
<span className="stats-card-value">{summary.uniqueTrackCount.toLocaleString()}</span>
<span className="stats-card-label">{t('statistics.playerSummaryUniqueTracks')}</span>
</div>
<div className="stats-card">
<span className="stats-card-value">{summary.listeningDayCount.toLocaleString()}</span>
<span className="stats-card-label">{t('statistics.playerSummaryDays')}</span>
</div>
<div className="stats-card">
<span className="stats-card-value">{summary.fullCount} / {summary.partialCount}</span>
<span className="stats-card-label">{t('statistics.playerSummaryCompletion')}</span>
</div>
</div>
)}
{empty && (
<p style={{ color: 'var(--text-muted)', marginBottom: '1rem' }}>{t('statistics.playerEmpty')}</p>
)}
<PlayerStatsHeatmap
year={year}
dayCounts={dayCounts}
selectedDate={selectedDate}
onSelectDate={setSelectedDate}
/>
</>
)}
{!loading && (
<PlayerStatsRecentDays
heatmapSelectedDate={selectedDate}
refreshKey={recentRefreshKey}
liveRefreshKey={liveRefreshKey}
/>
)}
</div>
);
}
@@ -0,0 +1,30 @@
import { useTranslation } from 'react-i18next';
import type { PlaySessionDayDetail } from '../../api/library';
import { formatPlayerStatsListenedSec } from '../../utils/format/formatHumanDuration';
type Props = {
detail: PlaySessionDayDetail;
};
export default function PlayerStatsDayTracks({ detail }: Props) {
const { t } = useTranslation();
return (
<ul className="player-stats-day-tracks">
{detail.tracks.map(tr => (
<li key={`${tr.serverId}:${tr.trackId}:${tr.startedAtMs}`}>
<div className="player-stats-day-track-title">{tr.title}</div>
<div className="player-stats-day-track-meta">
{tr.artist ?? '—'}
{' · '}
{formatPlayerStatsListenedSec(tr.listenedSec)}
{' · '}
{tr.completion === 'full'
? t('statistics.completionFull')
: t('statistics.completionPartial')}
</div>
</li>
))}
</ul>
);
}
@@ -0,0 +1,141 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import {
HEATMAP_LEVEL_COUNT,
heatmapCellMetrics,
heatmapLevel,
heatmapMonthLabels,
heatmapWeekColumns,
heatmapWeekdayLabels,
type HeatmapCell,
} from '../../utils/playerStats/heatmapLevels';
const LEVEL_OPACITY = [0.14, 0.32, 0.52, 0.72, 1] as const;
type Props = {
year: number;
dayCounts: Map<string, number>;
selectedDate: string | null;
onSelectDate: (date: string) => void;
};
function cellClass(cell: HeatmapCell, level: number, active: boolean): string {
const parts = ['player-heatmap-cell'];
if (!cell.date) parts.push('player-heatmap-cell--pad');
else if (cell.count <= 0) parts.push('player-heatmap-cell--empty');
else parts.push(`player-heatmap-cell--l${level}`);
if (active) parts.push('player-heatmap-cell--selected');
return parts.join(' ');
}
function cellStyle(cell: HeatmapCell, level: number): CSSProperties | undefined {
if (!cell.date || cell.count <= 0) return undefined;
return {
background: `color-mix(in srgb, var(--accent) ${Math.round(LEVEL_OPACITY[level] * 100)}%, transparent)`,
};
}
export default function PlayerStatsHeatmap({ year, dayCounts, selectedDate, onSelectDate }: Props) {
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language;
const wrapRef = useRef<HTMLDivElement>(null);
const [layoutWidth, setLayoutWidth] = useState(0);
const { weeks, maxCount } = useMemo(
() => heatmapWeekColumns(year, dayCounts),
[year, dayCounts],
);
const monthLabels = useMemo(() => heatmapMonthLabels(year, locale), [year, locale]);
const weekdayLabels = useMemo(() => heatmapWeekdayLabels(locale), [locale]);
useEffect(() => {
const el = wrapRef.current;
if (!el) return;
const measure = () => setLayoutWidth(el.clientWidth);
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [weeks.length]);
const { cell, gap, labelW, bodyGap } = useMemo(
() => heatmapCellMetrics(layoutWidth, weeks.length),
[layoutWidth, weeks.length],
);
const heatmapVars = {
'--hm-weeks': weeks.length,
'--hm-cell': `${cell}px`,
'--hm-gap': `${gap}px`,
'--hm-label-w': `${labelW}px`,
'--hm-body-gap': `${bodyGap}px`,
'--hm-pitch': `${cell + gap}px`,
'--hm-grid-w': `${weeks.length * (cell + gap) - gap}px`,
} as CSSProperties;
return (
<div ref={wrapRef} className="player-heatmap-wrap">
<div className="player-heatmap" style={heatmapVars}>
<div className="player-heatmap-months" aria-hidden="true">
{monthLabels.map(m => (
<span
key={`${m.columnIndex}-${m.label}`}
className="player-heatmap-month"
style={{ '--column-index': m.columnIndex } as CSSProperties}
>
{m.label}
</span>
))}
</div>
<div className="player-heatmap-body">
<div className="player-heatmap-weekdays" aria-hidden="true">
{weekdayLabels.map((label, i) => (
<span key={i}>{label}</span>
))}
</div>
<div className="player-heatmap-columns">
{weeks.map((week, wi) => (
<div key={wi} className="player-heatmap-col">
{week.map((cellItem, di) => {
if (!cellItem.date) {
return <span key={di} className="player-heatmap-cell player-heatmap-cell--pad" />;
}
const level = heatmapLevel(cellItem.count, maxCount);
const active = selectedDate === cellItem.date;
return (
<button
key={cellItem.date}
type="button"
className={cellClass(cellItem, level, active)}
style={cellStyle(cellItem, level)}
title={`${cellItem.date}: ${t('statistics.playerDayTrackPlays', { count: cellItem.count })}`}
aria-label={`${cellItem.date}: ${t('statistics.playerDayTrackPlays', { count: cellItem.count })}`}
aria-pressed={active}
onClick={() => onSelectDate(cellItem.date)}
/>
);
})}
</div>
))}
</div>
</div>
<div className="player-heatmap-legend" aria-hidden="true">
<span>{t('statistics.playerHeatmapLess')}</span>
{Array.from({ length: HEATMAP_LEVEL_COUNT }, (_, level) => (
<span
key={level}
className={`player-heatmap-cell player-heatmap-cell--sample${level === 0 ? ' player-heatmap-cell--empty' : ''}`}
style={level === 0 ? undefined : {
background: `color-mix(in srgb, var(--accent) ${Math.round(LEVEL_OPACITY[level as 0 | 1 | 2 | 3 | 4] * 100)}%, transparent)`,
}}
/>
))}
<span>{t('statistics.playerHeatmapMore')}</span>
</div>
</div>
</div>
);
}
@@ -0,0 +1,40 @@
import { Info } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../store/authStore';
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
export default function PlayerStatsPartialIndexNotice() {
const { t } = useTranslation();
const navigate = useNavigate();
const servers = useAuthStore(s => s.servers);
const masterEnabled = useLibraryIndexStore(s => s.masterEnabled);
const syncExcludedByServer = useLibraryIndexStore(s => s.syncExcludedByServer);
const excludedCount = useMemo(
() => servers.filter(s => syncExcludedByServer[s.id] === true).length,
[servers, syncExcludedByServer],
);
if (!masterEnabled || excludedCount === 0 || servers.length <= 1) {
return null;
}
return (
<div className="settings-hint settings-hint-info player-stats-partial-index-notice" role="status">
<Info size={16} aria-hidden style={{ flexShrink: 0, marginTop: 2 }} />
<span>
{t('statistics.playerPartialIndexNotice')}
{' '}
<button
type="button"
className="player-stats-partial-index-link"
onClick={() => navigate('/settings', { state: { tab: 'library' } })}
>
{t('statistics.playerPartialIndexSettings')}
</button>
</span>
</div>
);
}
@@ -0,0 +1,197 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
libraryGetPlayerStatsDayDetail,
libraryGetPlayerStatsRecentDays,
type PlaySessionDayDetail,
type PlaySessionRecentDay,
} from '../../api/library';
import { formatPlayerStatsListeningTotal } from '../../utils/format/formatHumanDuration';
import {
formatPlayerStatsDayLabel,
PLAYER_STATS_RECENT_DAYS_LIMIT,
} from '../../utils/playerStats/formatPlayerStatsDay';
import PlayerStatsDayTracks from './PlayerStatsDayTracks';
type Props = {
/** Day selected on the heatmap — auto-expand when present. */
heatmapSelectedDate: string | null;
/** Full reload (year change) — shows section spinner. */
refreshKey: number;
/** Silent poll while listening — updates list and expanded day details. */
liveRefreshKey: number;
};
function formatDayMeta(
summary: PlaySessionRecentDay,
t: (key: string, opts?: Record<string, unknown>) => string,
): string {
return [
formatPlayerStatsListeningTotal(summary.totalListenedSec),
t('statistics.playerDaySessions', { count: summary.sessionCount }),
t('statistics.playerDayTrackPlays', { count: summary.trackPlayCount }),
].join(' · ');
}
export default function PlayerStatsRecentDays({
heatmapSelectedDate,
refreshKey,
liveRefreshKey,
}: Props) {
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language;
const [days, setDays] = useState<PlaySessionRecentDay[]>([]);
const [loading, setLoading] = useState(true);
const [expandedDates, setExpandedDates] = useState<Set<string>>(() => new Set());
const [details, setDetails] = useState<Map<string, PlaySessionDayDetail>>(() => new Map());
const [loadingDates, setLoadingDates] = useState<Set<string>>(() => new Set());
const detailsRef = useRef(details);
detailsRef.current = details;
const expandedRef = useRef(expandedDates);
expandedRef.current = expandedDates;
const loadDetail = useCallback(async (date: string) => {
setLoadingDates(prev => new Set(prev).add(date));
try {
const detail = await libraryGetPlayerStatsDayDetail(date);
setDetails(prev => new Map(prev).set(date, detail));
} catch {
/* ignore */
} finally {
setLoadingDates(prev => {
const next = new Set(prev);
next.delete(date);
return next;
});
}
}, []);
useEffect(() => {
let cancelled = false;
setLoading(true);
libraryGetPlayerStatsRecentDays(PLAYER_STATS_RECENT_DAYS_LIMIT)
.then(rows => {
if (!cancelled) {
setDays(rows);
setLoading(false);
}
})
.catch(() => {
if (!cancelled) {
setDays([]);
setLoading(false);
}
});
return () => { cancelled = true; };
}, [refreshKey]);
useEffect(() => {
if (liveRefreshKey === 0) return;
let cancelled = false;
libraryGetPlayerStatsRecentDays(PLAYER_STATS_RECENT_DAYS_LIMIT)
.then(rows => {
if (cancelled) return;
setDays(rows);
const refreshDates = new Set(expandedRef.current);
if (heatmapSelectedDate && expandedRef.current.has(heatmapSelectedDate)) {
refreshDates.add(heatmapSelectedDate);
}
setDetails(prev => {
const next = new Map(prev);
for (const date of refreshDates) next.delete(date);
return next;
});
for (const date of refreshDates) {
void loadDetail(date);
}
})
.catch(() => { /* ignore */ });
return () => { cancelled = true; };
}, [liveRefreshKey, heatmapSelectedDate, loadDetail]);
const daySet = useMemo(() => new Set(days.map(d => d.date)), [days]);
const ensureDetail = useCallback(async (date: string) => {
if (detailsRef.current.has(date)) return;
await loadDetail(date);
}, [loadDetail]);
const toggleDate = useCallback((date: string) => {
setExpandedDates(prev => {
const next = new Set(prev);
if (next.has(date)) next.delete(date);
else next.add(date);
return next;
});
void ensureDetail(date);
}, [ensureDetail]);
useEffect(() => {
if (!heatmapSelectedDate) return;
setExpandedDates(prev => new Set(prev).add(heatmapSelectedDate));
void ensureDetail(heatmapSelectedDate);
}, [heatmapSelectedDate, ensureDetail]);
const extraHeatmapDay = heatmapSelectedDate && !daySet.has(heatmapSelectedDate)
? heatmapSelectedDate
: null;
if (loading) {
return (
<section className="player-stats-recent">
<h2 className="section-title">{t('statistics.playerRecentDaysTitle')}</h2>
<div className="loading-center" style={{ minHeight: '4rem' }}>
<div className="spinner" />
</div>
</section>
);
}
if (days.length === 0 && !extraHeatmapDay) return null;
const renderRow = (date: string, summary: PlaySessionRecentDay | null) => {
const open = expandedDates.has(date);
const detail = details.get(date);
const pending = loadingDates.has(date);
const label = formatPlayerStatsDayLabel(date, t, locale);
const meta = summary ? formatDayMeta(summary, t) : null;
return (
<div key={date} className={`player-stats-day-item${open ? ' player-stats-day-item--open' : ''}`}>
<button
type="button"
className="player-stats-day-header"
onClick={() => toggleDate(date)}
aria-expanded={open}
>
<span className="player-stats-day-header-text">
<span className="player-stats-day-label">{label}</span>
{meta && <span className="player-stats-day-summary">{meta}</span>}
</span>
<ChevronDown size={16} className="player-stats-day-chevron" aria-hidden />
</button>
{open && (
<div className="player-stats-day-body">
{pending && !detail && (
<div className="loading-center" style={{ minHeight: '2.5rem' }}>
<div className="spinner" style={{ width: 16, height: 16 }} />
</div>
)}
{detail && <PlayerStatsDayTracks detail={detail} />}
</div>
)}
</div>
);
};
return (
<section className="player-stats-recent">
<h2 className="section-title">{t('statistics.playerRecentDaysTitle')}</h2>
<div className="player-stats-day-list">
{extraHeatmapDay && renderRow(extraHeatmapDay, null)}
{days.map(day => renderRow(day.date, day))}
</div>
</section>
);
}
@@ -0,0 +1,38 @@
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../store/authStore';
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
export default function StatisticsTabBar() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const servers = useAuthStore(s => s.servers);
const masterEnabled = useLibraryIndexStore(s => s.masterEnabled);
const syncExcludedByServer = useLibraryIndexStore(s => s.syncExcludedByServer);
const showPlayerTab =
masterEnabled && servers.some(s => syncExcludedByServer[s.id] !== true);
if (!showPlayerTab) return null;
const isPlayer = location.pathname === '/player-stats';
return (
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.25rem', flexWrap: 'wrap' }}>
<button
type="button"
className={`btn btn-sm ${!isPlayer ? 'btn-primary' : 'btn-surface'}`}
onClick={() => navigate('/statistics')}
>
{t('statistics.tabServer')}
</button>
<button
type="button"
className={`btn btn-sm ${isPlayer ? 'btn-primary' : 'btn-surface'}`}
onClick={() => navigate('/player-stats')}
>
{t('statistics.tabPlayer')}
</button>
</div>
);
}
+1
View File
@@ -124,6 +124,7 @@ const CONTRIBUTOR_ENTRIES = [
'Home album rails: stable play/enqueue hover on WebKitGTK/Wayland (PR #787)',
'Local library index: multi-server settings UI, serial sync queue, music-library-scoped local search, parallel initial ingest, i18n across 9 locales (PR #846)',
'Library browse: local-vs-network text search race, All Albums/Artists catalog from index, DevTools browse-race logging (PR #847)',
'Player stats: local listening history tab with heatmap, year summary, recent days, and day drill-down (PR #849)',
],
},
{
@@ -4,6 +4,7 @@ import { invoke } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
import type { NavigateFunction } from 'react-router-dom';
import { flushPlayQueuePosition } from '../../store/queueSync';
import { playListenSessionFinalize } from '../../store/playListenSession';
import { getPlaybackProgressSnapshot } from '../../store/playbackProgress';
import { usePlayerStore } from '../../store/playerStore';
import { useAuthStore } from '../../store/authStore';
@@ -107,6 +108,10 @@ export function useMediaAndWindowBridge(navigate: NavigateFunction) {
// server can't keep the app hanging on quit; the playback heartbeat
// is the safety net for anything that didn't make it out in time.
const performExit = async () => {
await Promise.race([
playListenSessionFinalize('close'),
new Promise(r => setTimeout(r, 1500)),
]);
await Promise.race([
flushPlayQueuePosition(),
new Promise(r => setTimeout(r, 1500)),
@@ -0,0 +1,39 @@
import { renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { usePlayerStatsLiveRefresh } from './usePlayerStatsLiveRefresh';
import { emitPlaySessionRecorded } from '../store/playSessionRecorded';
describe('usePlayerStatsLiveRefresh', () => {
it('refreshes when a play session is recorded and the tab is visible', () => {
Object.defineProperty(document, 'visibilityState', {
configurable: true,
get: () => 'visible',
});
const onRefresh = vi.fn();
renderHook(() => usePlayerStatsLiveRefresh(onRefresh));
emitPlaySessionRecorded({ serverId: 's1', trackId: 't1', startedAtMs: 1 });
expect(onRefresh).toHaveBeenCalledTimes(1);
});
it('does not refresh on record while the tab is hidden', () => {
Object.defineProperty(document, 'visibilityState', {
configurable: true,
get: () => 'hidden',
});
const onRefresh = vi.fn();
renderHook(() => usePlayerStatsLiveRefresh(onRefresh));
emitPlaySessionRecorded({ serverId: 's1', trackId: 't1', startedAtMs: 1 });
expect(onRefresh).not.toHaveBeenCalled();
});
it('refreshes when the tab becomes visible again', () => {
Object.defineProperty(document, 'visibilityState', {
configurable: true,
get: () => 'visible',
});
const onRefresh = vi.fn();
renderHook(() => usePlayerStatsLiveRefresh(onRefresh));
document.dispatchEvent(new Event('visibilitychange'));
expect(onRefresh).toHaveBeenCalledTimes(1);
});
});
+32
View File
@@ -0,0 +1,32 @@
import { useEffect, useRef } from 'react';
import { onPlaySessionRecorded } from '../store/playSessionRecorded';
/** Refresh player stats when a listen is persisted or the tab becomes visible again. */
export function usePlayerStatsLiveRefresh(onRefresh: () => void) {
const onRefreshRef = useRef(onRefresh);
onRefreshRef.current = onRefresh;
useEffect(() => {
const refreshIfVisible = () => {
if (document.visibilityState === 'visible') {
onRefreshRef.current();
}
};
const unsubRecorded = onPlaySessionRecorded(() => {
refreshIfVisible();
});
const onVisibility = () => {
if (document.visibilityState === 'visible') {
onRefreshRef.current();
}
};
document.addEventListener('visibilitychange', onVisibility);
return () => {
unsubRecorded();
document.removeEventListener('visibilitychange', onVisibility);
};
}, []);
}
+29
View File
@@ -62,4 +62,33 @@ export const statistics = {
exportSaving: 'Speichere…',
exportSaved: 'Gespeichert.',
exportSaveFailed: 'Bild konnte nicht gespeichert werden.',
tabServer: 'Server-Statistik',
tabPlayer: 'Player-Statistik',
playerEmpty: 'Hör zu — dein lokaler Verlauf erscheint hier, sobald der Library-Index aktiv ist.',
playerSummaryTime: 'Hörzeit',
playerListeningDayShort: '{{count}}d',
playerListeningHourShort: '{{count}}h',
playerListeningMinuteShort: '{{count}}min',
playerSummarySessions: 'Sitzungen',
playerSummaryTracks: 'Titel',
playerSummaryUniqueTracks: 'Einzigartige Titel',
playerSummaryDays: 'Days',
playerPartialIndexNotice: 'Einige Server sind vom lokalen Bibliotheksindex ausgeschlossen. Wiedergaben auf diesen Servern werden nicht in der Player-Statistik erfasst.',
playerPartialIndexSettings: 'Bibliothekseinstellungen',
playerSummaryCompletion: 'Voll / teilweise',
playerYearPrev: 'Vorheriges Jahr',
playerYearNext: 'Nächstes Jahr',
playerHeatmapLegend: 'Dunkler = mehr gehörte Titel an diesem Tag.',
playerHeatmapLess: 'Weniger',
playerHeatmapMore: 'Mehr',
playerDaySessions: '{{count}} Sitzungen',
playerDayTrackPlays: '{{count}} Titel',
playerDayFullPartial: '{{full}} voll · {{partial}} teilweise',
playerRecentDaysTitle: 'Letzte Tage',
playerDayToday: 'Heute',
playerDayYesterday: 'Gestern',
playerListenedSecShort: '{{seconds}} s',
playerListenedMinDecimal: '{{minutes}} Min.',
completionFull: 'Vollständig',
completionPartial: 'Teilweise',
};
+29
View File
@@ -62,4 +62,33 @@ export const statistics = {
exportSaving: 'Saving…',
exportSaved: 'Saved.',
exportSaveFailed: 'Could not save the image.',
tabServer: 'Server stats',
tabPlayer: 'Player stats',
playerEmpty: 'Start listening — your local play history will appear here once the library index is enabled.',
playerSummaryTime: 'Listening time',
playerListeningDayShort: '{{count}}d',
playerListeningHourShort: '{{count}}h',
playerListeningMinuteShort: '{{count}}m',
playerSummarySessions: 'Sessions',
playerSummaryTracks: 'Track plays',
playerSummaryUniqueTracks: 'Unique tracks',
playerSummaryDays: 'Days',
playerPartialIndexNotice: 'Some servers are excluded from the local library index. Listening on those servers is not recorded in player statistics.',
playerPartialIndexSettings: 'Library settings',
playerSummaryCompletion: 'Full / partial',
playerYearPrev: 'Previous year',
playerYearNext: 'Next year',
playerHeatmapLegend: 'Darker cells = more tracks played that day.',
playerHeatmapLess: 'Less',
playerHeatmapMore: 'More',
playerDaySessions: '{{count}} sessions',
playerDayTrackPlays: '{{count}} tracks',
playerDayFullPartial: '{{full}} full · {{partial}} partial',
playerRecentDaysTitle: 'Recent days',
playerDayToday: 'Today',
playerDayYesterday: 'Yesterday',
playerListenedSecShort: '{{seconds}}s',
playerListenedMinDecimal: '{{minutes}} min',
completionFull: 'Full listen',
completionPartial: 'Partial',
};
+29
View File
@@ -44,4 +44,33 @@ export const statistics = {
topRatedArtists: 'Artistas Mejor Calificados',
noRatedSongs: 'Aún no hay canciones calificadas. Califica canciones en la vista de álbum o playlist.',
noRatedArtists: 'Aún no hay artistas calificados.',
tabServer: 'Estadísticas del servidor',
tabPlayer: 'Estadísticas del reproductor',
playerEmpty: 'Empieza a escuchar — tu historial local aparecerá aquí con el índice de biblioteca activo.',
playerSummaryTime: 'Tiempo de escucha',
playerListeningDayShort: '{{count}}d',
playerListeningHourShort: '{{count}}h',
playerListeningMinuteShort: '{{count}}m',
playerSummarySessions: 'Sesiones',
playerSummaryTracks: 'Pistas',
playerSummaryUniqueTracks: 'Pistas únicas',
playerSummaryDays: 'Days',
playerPartialIndexNotice: 'Algunos servidores están excluidos del índice local de biblioteca. La escucha en esos servidores no se registra en las estadísticas del reproductor.',
playerPartialIndexSettings: 'Ajustes de biblioteca',
playerSummaryCompletion: 'Completas / parciales',
playerYearPrev: 'Año anterior',
playerYearNext: 'Año siguiente',
playerHeatmapLegend: 'Más oscuro = más pistas escuchadas ese día.',
playerHeatmapLess: 'Menos',
playerHeatmapMore: 'Más',
playerDaySessions: '{{count}} sesiones',
playerDayTrackPlays: '{{count}} pistas',
playerDayFullPartial: '{{full}} completas · {{partial}} parciales',
playerRecentDaysTitle: 'Días recientes',
playerDayToday: 'Hoy',
playerDayYesterday: 'Ayer',
playerListenedSecShort: '{{seconds}} s',
playerListenedMinDecimal: '{{minutes}} min',
completionFull: 'Escucha completa',
completionPartial: 'Parcial',
};
+29
View File
@@ -44,4 +44,33 @@ export const statistics = {
topRatedArtists: 'Artistes les mieux notés',
noRatedSongs: 'Aucun morceau noté. Notez des morceaux dans la vue album ou playlist.',
noRatedArtists: 'Aucun artiste noté.',
tabServer: 'Stats serveur',
tabPlayer: 'Stats lecteur',
playerEmpty: 'Commencez à écouter — lhistorique local apparaîtra ici avec lindex bibliothèque activé.',
playerSummaryTime: 'Temps d’écoute',
playerListeningDayShort: '{{count}}j',
playerListeningHourShort: '{{count}}h',
playerListeningMinuteShort: '{{count}}m',
playerSummarySessions: 'Sessions',
playerSummaryTracks: 'Morceaux',
playerSummaryUniqueTracks: 'Morceaux uniques',
playerSummaryDays: 'Days',
playerPartialIndexNotice: 'Certains serveurs sont exclus de lindex local de bibliothèque. L’écoute sur ces serveurs nest pas enregistrée dans les statistiques du lecteur.',
playerPartialIndexSettings: 'Paramètres bibliothèque',
playerSummaryCompletion: 'Complets / partiels',
playerYearPrev: 'Année précédente',
playerYearNext: 'Année suivante',
playerHeatmapLegend: 'Plus foncé = plus de morceaux écoutés ce jour-là.',
playerHeatmapLess: 'Moins',
playerHeatmapMore: 'Plus',
playerDaySessions: '{{count}} sessions',
playerDayTrackPlays: '{{count}} morceaux',
playerDayFullPartial: '{{full}} complets · {{partial}} partiels',
playerRecentDaysTitle: 'Jours récents',
playerDayToday: "Aujourd'hui",
playerDayYesterday: 'Hier',
playerListenedSecShort: '{{seconds}} s',
playerListenedMinDecimal: '{{minutes}} min',
completionFull: 'Écoute complète',
completionPartial: 'Partiel',
};
+29
View File
@@ -44,4 +44,33 @@ export const statistics = {
topRatedArtists: 'Høyest vurderte artister',
noRatedSongs: 'Ingen vurderte spor ennå. Vurder spor i album- eller spillelistevisning.',
noRatedArtists: 'Ingen vurderte artister ennå.',
tabServer: 'Serverstatistikk',
tabPlayer: 'Spillerstatistikk',
playerEmpty: 'Begynn å lytte — lokal historikk vises her når bibliotekindeks er aktivert.',
playerSummaryTime: 'Lyttetid',
playerListeningDayShort: '{{count}}d',
playerListeningHourShort: '{{count}}t',
playerListeningMinuteShort: '{{count}}m',
playerSummarySessions: 'Økter',
playerSummaryTracks: 'Spor',
playerSummaryUniqueTracks: 'Unike spor',
playerSummaryDays: 'Days',
playerPartialIndexNotice: 'Noen servere er ekskludert fra det lokale biblioteksindeks. Lytting på disse serverne registreres ikke i spillerstatistikken.',
playerPartialIndexSettings: 'Bibliotekinnstillinger',
playerSummaryCompletion: 'Full / delvis',
playerYearPrev: 'Forrige år',
playerYearNext: 'Neste år',
playerHeatmapLegend: 'Mørkere = flere spor den dagen.',
playerHeatmapLess: 'Mindre',
playerHeatmapMore: 'Mer',
playerDaySessions: '{{count}} økter',
playerDayTrackPlays: '{{count}} spor',
playerDayFullPartial: '{{full}} full · {{partial}} delvis',
playerRecentDaysTitle: 'Siste dager',
playerDayToday: 'I dag',
playerDayYesterday: 'I går',
playerListenedSecShort: '{{seconds}} s',
playerListenedMinDecimal: '{{minutes}} min',
completionFull: 'Full lytting',
completionPartial: 'Delvis',
};
+29
View File
@@ -44,4 +44,33 @@ export const statistics = {
topRatedArtists: 'Best beoordeelde artiesten',
noRatedSongs: 'Nog geen beoordeelde nummers. Beoordeel nummers in album- of afspeellijstweergave.',
noRatedArtists: 'Nog geen beoordeelde artiesten.',
tabServer: 'Serverstatistieken',
tabPlayer: 'Spelerstatistieken',
playerEmpty: 'Begin te luisteren — je lokale geschiedenis verschijnt hier zodra de bibliotheekindex aan staat.',
playerSummaryTime: 'Luistertijd',
playerListeningDayShort: '{{count}}d',
playerListeningHourShort: '{{count}}u',
playerListeningMinuteShort: '{{count}}m',
playerSummarySessions: 'Sessies',
playerSummaryTracks: 'Nummers',
playerSummaryUniqueTracks: 'Unieke nummers',
playerSummaryDays: 'Days',
playerPartialIndexNotice: 'Sommige servers zijn uitgesloten van de lokale bibliotheekindex. Luisteren op die servers wordt niet vastgelegd in de playerstatistieken.',
playerPartialIndexSettings: 'Bibliotheekinstellingen',
playerSummaryCompletion: 'Volledig / gedeeltelijk',
playerYearPrev: 'Vorig jaar',
playerYearNext: 'Volgend jaar',
playerHeatmapLegend: 'Donkerder = meer nummers geluisterd op die dag.',
playerHeatmapLess: 'Minder',
playerHeatmapMore: 'Meer',
playerDaySessions: '{{count}} sessies',
playerDayTrackPlays: '{{count}} nummers',
playerDayFullPartial: '{{full}} volledig · {{partial}} gedeeltelijk',
playerRecentDaysTitle: 'Recente dagen',
playerDayToday: 'Vandaag',
playerDayYesterday: 'Gisteren',
playerListenedSecShort: '{{seconds}} s',
playerListenedMinDecimal: '{{minutes}} min',
completionFull: 'Volledig geluisterd',
completionPartial: 'Gedeeltelijk',
};
+29
View File
@@ -62,4 +62,33 @@ export const statistics = {
exportSaving: 'Se salvează…',
exportSaved: 'Salvat.',
exportSaveFailed: 'Nu s-a putut salva imaginea.',
tabServer: 'Statistici server',
tabPlayer: 'Statistici player',
playerEmpty: 'Începe să asculți — istoricul local va apărea aici când indexul bibliotecii este activ.',
playerSummaryTime: 'Timp de ascultare',
playerListeningDayShort: '{{count}}z',
playerListeningHourShort: '{{count}}h',
playerListeningMinuteShort: '{{count}}m',
playerSummarySessions: 'Sesiuni',
playerSummaryTracks: 'Piese',
playerSummaryUniqueTracks: 'Piese unice',
playerSummaryDays: 'Days',
playerPartialIndexNotice: 'Unele servere sunt excluse din indexul local al bibliotecii. Ascultarea pe aceste servere nu este înregistrată în statisticile playerului.',
playerPartialIndexSettings: 'Setări bibliotecă',
playerSummaryCompletion: 'Complete / parțiale',
playerYearPrev: 'Anul anterior',
playerYearNext: 'Anul următor',
playerHeatmapLegend: 'Mai închis = mai multe piese ascultate în acea zi.',
playerHeatmapLess: 'Mai puțin',
playerHeatmapMore: 'Mai mult',
playerDaySessions: '{{count}} sesiuni',
playerDayTrackPlays: '{{count}} piese',
playerDayFullPartial: '{{full}} complete · {{partial}} parțiale',
playerRecentDaysTitle: 'Zile recente',
playerDayToday: 'Astăzi',
playerDayYesterday: 'Ieri',
playerListenedSecShort: '{{seconds}} s',
playerListenedMinDecimal: '{{minutes}} min',
completionFull: 'Ascultare completă',
completionPartial: 'Parțial',
};
+29
View File
@@ -55,4 +55,33 @@ export const statistics = {
topRatedArtists: 'Лучшие по рейтингу исполнители',
noRatedSongs: 'Нет оценённых треков. Ставьте оценки в альбомах или плейлистах.',
noRatedArtists: 'Нет оценённых исполнителей.',
tabServer: 'Статистика сервера',
tabPlayer: 'Статистика плеера',
playerEmpty: 'Начните слушать — локальная история появится здесь при включённом индексе библиотеки.',
playerSummaryTime: 'Время прослушивания',
playerListeningDayShort: '{{count}}д',
playerListeningHourShort: '{{count}}ч',
playerListeningMinuteShort: '{{count}}мин',
playerSummarySessions: 'Сессии',
playerSummaryTracks: 'Треки',
playerSummaryUniqueTracks: 'Уникальные треки',
playerSummaryDays: 'Days',
playerPartialIndexNotice: 'Не все серверы включены в локальный индекс библиотеки. Прослушивание с выключенных серверов не попадает в эту статистику.',
playerPartialIndexSettings: 'Настройки библиотеки',
playerSummaryCompletion: 'Полные / частичные',
playerYearPrev: 'Предыдущий год',
playerYearNext: 'Следующий год',
playerHeatmapLegend: 'Темнее — больше прослушанных треков за день.',
playerHeatmapLess: 'Меньше',
playerHeatmapMore: 'Больше',
playerDaySessions: '{{count}} сессий',
playerDayTrackPlays: '{{count}} треков',
playerDayFullPartial: '{{full}} полных · {{partial}} частичных',
playerRecentDaysTitle: 'Недавние дни',
playerDayToday: 'Сегодня',
playerDayYesterday: 'Вчера',
playerListenedSecShort: '{{seconds}} с',
playerListenedMinDecimal: '{{minutes}} мин',
completionFull: 'Прослушано',
completionPartial: 'Частично',
};
+29
View File
@@ -44,4 +44,33 @@ export const statistics = {
topRatedArtists: '最高评分艺人',
noRatedSongs: '暂无已评分歌曲。请在专辑或播放列表中为歌曲评分。',
noRatedArtists: '暂无已评分艺人。',
tabServer: '服务器统计',
tabPlayer: '播放器统计',
playerEmpty: '开始收听 — 启用媒体库索引后,本地播放历史将显示在这里。',
playerSummaryTime: '收听时长',
playerListeningDayShort: '{{count}}天',
playerListeningHourShort: '{{count}}小时',
playerListeningMinuteShort: '{{count}}分钟',
playerSummarySessions: '会话数',
playerSummaryTracks: '曲目',
playerSummaryUniqueTracks: '独立曲目',
playerSummaryDays: 'Days',
playerPartialIndexNotice: '部分服务器未纳入本地库索引,在这些服务器上的收听不会计入播放器统计。',
playerPartialIndexSettings: '库设置',
playerSummaryCompletion: '完整 / 部分',
playerYearPrev: '上一年',
playerYearNext: '下一年',
playerHeatmapLegend: '颜色越深表示当天收听的曲目越多。',
playerHeatmapLess: '少',
playerHeatmapMore: '多',
playerDaySessions: '{{count}} 次会话',
playerDayTrackPlays: '{{count}} 首曲目',
playerDayFullPartial: '{{full}} 完整 · {{partial}} 部分',
playerRecentDaysTitle: '最近几天',
playerDayToday: '今天',
playerDayYesterday: '昨天',
playerListenedSecShort: '{{seconds}} 秒',
playerListenedMinDecimal: '{{minutes}} 分钟',
completionFull: '完整收听',
completionPartial: '部分',
};
+11 -3
View File
@@ -6,9 +6,11 @@ import { Share2 } from 'lucide-react';
import { formatHumanHoursMinutes } from '../utils/format/formatHumanDuration';
import AlbumRow from '../components/AlbumRow';
import StatsExportModal from '../components/StatsExportModal';
import PlayerStatisticsPanel from '../components/statistics/PlayerStatisticsPanel';
import StatisticsTabBar from '../components/statistics/StatisticsTabBar';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
import { useNavigate } from 'react-router-dom';
import { useLocation } from 'react-router-dom';
import { lastfmIsConfigured, lastfmGetTopArtists, lastfmGetTopAlbums, lastfmGetTopTracks, lastfmGetRecentTracks, LastfmPeriod, LastfmTopArtist, LastfmTopAlbum, LastfmTopTrack, LastfmRecentTrack } from '../api/lastfm';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -31,7 +33,8 @@ const PERIODS: { key: LastfmPeriod; label: string }[] = [
export default function Statistics() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const isPlayerStats = location.pathname === '/player-stats';
const { lastfmSessionKey, lastfmUsername } = useAuthStore();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const [recent, setRecent] = useState<SubsonicAlbum[]>([]);
@@ -172,8 +175,11 @@ export default function Statistics() {
return (
<div className="content-body animate-fade-in">
<h1 className="page-title">{t('statistics.title')}</h1>
<StatisticsTabBar />
{loading ? (
{isPlayerStats ? (
<PlayerStatisticsPanel />
) : loading ? (
<div className="loading-center"><div className="spinner" /></div>
) : (
<div className="stats-page">
@@ -394,11 +400,13 @@ export default function Statistics() {
</div>
)}
{!isPlayerStats && (
<StatsExportModal
open={exportOpen}
albums={frequent}
onClose={() => setExportOpen(false)}
/>
)}
</div>
);
}
+16 -7
View File
@@ -4,6 +4,12 @@ import { invoke } from '@tauri-apps/api/core';
import { lastfmGetTrackLoved, lastfmScrobble, lastfmUpdateNowPlaying } from '../api/lastfm';
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
import { notifyLibraryPlaybackHint } from './libraryPlaybackHint';
import {
playListenSessionFinalize,
playListenSessionOnProgress,
playListenSessionOnTrackSwitched,
playListenSessionOpen,
} from './playListenSession';
import { getPerfProbeFlags } from '../utils/perf/perfFlags';
import { bumpPerfCounter } from '../utils/perf/perfTelemetry';
import { getPlaybackServerId } from '../utils/playback/playbackServer';
@@ -75,13 +81,15 @@ export type NormalizationStatePayload = {
targetLufs: number;
};
export function handleAudioPlaying(_duration: number): void {
export function handleAudioPlaying(duration: number): void {
setDeferHotCachePrefetch(false);
resetProgressEmitThrottles();
usePlayerStore.setState({ isPlaying: true, isPlaybackBuffering: false });
// Tell the library scheduler to throttle bulk crawl while a stream
// is active (spec §6.2.4). No-op unless the index is enabled.
notifyLibraryPlaybackHint('playing');
const track = usePlayerStore.getState().currentTrack;
if (track) {
void playListenSessionOpen(track, getPlaybackServerId(), duration);
}
}
export function handleAudioProgress(
@@ -142,6 +150,7 @@ export function handleAudioProgress(
const dur = duration > 0 ? duration : track.duration;
if (dur <= 0) return;
const progress = displayTime / dur;
playListenSessionOnProgress(current_time, buffering, dur).catch(() => {});
if (!progressUiDisabled) {
const nowLive = Date.now();
const live = getPlaybackProgressSnapshot();
@@ -311,16 +320,14 @@ export function handleAudioProgress(
}
export function handleAudioEnded(): void {
// Playback stopped — let the library scheduler resume normal crawl
// parallelism (spec §6.2.4). No-op unless the index is enabled.
notifyLibraryPlaybackHint('idle');
// If a gapless switch happened recently, this ended event is stale — the
// progress task fired it for the OLD source before seeing the chained one.
if (Date.now() - getLastGaplessSwitchTime() < 600) {
return;
}
void playListenSessionFinalize('ended');
// Radio stream disconnected — just stop; don't advance queue.
if (usePlayerStore.getState().currentRadio) {
setIsAudioPaused(false);
@@ -392,6 +399,8 @@ export function handleAudioTrackSwitched(_duration: number): void {
if (!nextTrack) return;
void playListenSessionOnTrackSwitched(nextTrack);
const switchServerId = getPlaybackServerId();
const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId);
const switchPlaybackSource = playbackSourceHintForResolvedUrl(nextTrack.id, switchServerId, switchResolvedUrl);
+187
View File
@@ -0,0 +1,187 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { invoke } from '@tauri-apps/api/core';
import { onInvoke } from '../test/mocks/tauri';
import { useLibraryIndexStore } from './libraryIndexStore';
import {
_resetPlayListenSessionForTest,
playListenSessionFinalize,
playListenSessionOnProgress,
playListenSessionOpen,
resolveDurationSecHint,
} from './playListenSession';
import { onPlaySessionRecorded } from './playSessionRecorded';
vi.mock('../utils/library/libraryReady', () => ({
libraryIsReady: vi.fn(async () => true),
}));
vi.mock('../utils/playback/playbackServer', () => ({
getPlaybackServerId: vi.fn(() => 'server-1'),
}));
import type { Track } from './playerStoreTypes';
const testTrack: Track = {
id: 't1',
title: 'A',
artist: 'B',
album: '',
albumId: '',
duration: 180,
};
describe('playListenSession', () => {
beforeEach(async () => {
_resetPlayListenSessionForTest();
useLibraryIndexStore.setState({
masterEnabled: true,
syncExcludedByServer: {},
});
const { usePlayerStore } = await import('./playerStore');
const { usePreviewStore } = await import('./previewStore');
usePlayerStore.setState({
currentRadio: null,
isPlaying: true,
currentTrack: testTrack,
});
usePreviewStore.setState({ previewingId: null });
onInvoke('library_record_play_session', () => undefined);
});
it('does not invoke when listenedSec <= 10', async () => {
await playListenSessionOpen(testTrack, 'server-1');
await playListenSessionOnProgress(5, false);
await playListenSessionFinalize('ended');
expect(invoke).not.toHaveBeenCalledWith('library_record_play_session', expect.anything());
});
it('invokes once after >10s listened', async () => {
vi.useFakeTimers();
await playListenSessionOpen(testTrack, 'server-1');
vi.setSystemTime(Date.now() + 15_000);
await playListenSessionOnProgress(12, false);
await playListenSessionFinalize('ended');
vi.useRealTimers();
expect(invoke).toHaveBeenCalledWith(
'library_record_play_session',
expect.objectContaining({
input: expect.objectContaining({
trackId: 't1',
serverId: 'server-1',
endReason: 'ended',
durationSecHint: 180,
}),
}),
);
});
it('picks up engine duration from progress ticks', async () => {
const { usePlayerStore } = await import('./playerStore');
usePlayerStore.setState({
currentTrack: { ...testTrack, duration: 0 },
});
vi.useFakeTimers();
await playListenSessionOpen({ ...testTrack, duration: 0 }, 'server-1', 240);
vi.setSystemTime(Date.now() + 15_000);
await playListenSessionOnProgress(12, false, 240);
await playListenSessionFinalize('ended');
vi.useRealTimers();
expect(invoke).toHaveBeenCalledWith(
'library_record_play_session',
expect.objectContaining({
input: expect.objectContaining({ durationSecHint: 240 }),
}),
);
});
it('skips when index disabled', async () => {
useLibraryIndexStore.setState({ masterEnabled: false });
await playListenSessionOpen(testTrack, 'server-1');
vi.useFakeTimers();
vi.setSystemTime(Date.now() + 20_000);
await playListenSessionOnProgress(15, false);
await playListenSessionFinalize('ended');
vi.useRealTimers();
expect(invoke).not.toHaveBeenCalledWith('library_record_play_session', expect.anything());
});
it('skips preview playback', async () => {
const { usePreviewStore } = await import('./previewStore');
usePreviewStore.setState({ previewingId: 'preview-1' });
vi.useFakeTimers();
await playListenSessionOpen(testTrack, 'server-1');
vi.setSystemTime(Date.now() + 20_000);
await playListenSessionOnProgress(15, false);
await playListenSessionFinalize('ended');
vi.useRealTimers();
expect(invoke).not.toHaveBeenCalledWith('library_record_play_session', expect.anything());
});
it('skips radio playback', async () => {
const { usePlayerStore } = await import('./playerStore');
usePlayerStore.setState({
currentRadio: { id: 'r1', name: 'Radio', streamUrl: 'http://x' },
});
vi.useFakeTimers();
await playListenSessionOpen(testTrack, 'server-1');
vi.setSystemTime(Date.now() + 20_000);
await playListenSessionOnProgress(15, false);
await playListenSessionFinalize('ended');
vi.useRealTimers();
expect(invoke).not.toHaveBeenCalledWith('library_record_play_session', expect.anything());
});
it('does not accumulate listened time while paused or buffering', async () => {
const { usePlayerStore } = await import('./playerStore');
vi.useFakeTimers();
await playListenSessionOpen(testTrack, 'server-1');
vi.setSystemTime(Date.now() + 15_000);
usePlayerStore.setState({ isPlaying: false });
await playListenSessionOnProgress(12, false);
vi.setSystemTime(Date.now() + 30_000);
await playListenSessionOnProgress(12, true);
await playListenSessionFinalize('ended');
vi.useRealTimers();
expect(invoke).not.toHaveBeenCalledWith('library_record_play_session', expect.anything());
});
it('skips when library is not ready', async () => {
const { libraryIsReady } = await import('../utils/library/libraryReady');
vi.mocked(libraryIsReady).mockResolvedValueOnce(false);
vi.useFakeTimers();
await playListenSessionOpen(testTrack, 'server-1');
vi.setSystemTime(Date.now() + 20_000);
await playListenSessionOnProgress(15, false);
await playListenSessionFinalize('ended');
vi.useRealTimers();
expect(invoke).not.toHaveBeenCalledWith('library_record_play_session', expect.anything());
});
it('emits play-session-recorded after a persisted listen', async () => {
const listener = vi.fn();
const unsub = onPlaySessionRecorded(listener);
vi.useFakeTimers();
await playListenSessionOpen(testTrack, 'server-1');
vi.setSystemTime(Date.now() + 15_000);
await playListenSessionOnProgress(12, false);
await playListenSessionFinalize('ended');
vi.useRealTimers();
unsub();
expect(listener).toHaveBeenCalledWith({
serverId: 'server-1',
trackId: 't1',
startedAtMs: expect.any(Number),
});
});
});
describe('resolveDurationSecHint', () => {
it('returns zero when no positive durations are available', () => {
expect(resolveDurationSecHint(null)).toBe(0);
expect(resolveDurationSecHint({ duration: 0 }, 0, undefined)).toBe(0);
});
it('prefers the largest finite positive hint', () => {
expect(resolveDurationSecHint({ duration: 180 }, 240, 200)).toBe(240);
});
});
+170
View File
@@ -0,0 +1,170 @@
import type { Track } from './playerStoreTypes';
import {
libraryRecordPlaySession,
type PlaySessionEndReason,
} from '../api/library';
import { libraryIsReady } from '../utils/library/libraryReady';
import { getPlaybackServerId } from '../utils/playback/playbackServer';
import { emitPlaySessionRecorded } from './playSessionRecorded';
import { useLibraryIndexStore } from './libraryIndexStore';
const MIN_LISTENED_SEC = 10;
type OpenSession = {
serverId: string;
trackId: string;
startedAtMs: number;
listenedSec: number;
positionMaxSec: number;
durationSecHint: number;
lastTickMs: number;
recordingEnabled: boolean;
};
let open: OpenSession | null = null;
let finalizeInFlight: Promise<void> | null = null;
function clearOpen(): void {
open = null;
}
/** Best-known track length in seconds from player metadata and/or engine. */
export function resolveDurationSecHint(
track: Pick<Track, 'duration'> | null | undefined,
...extraSec: (number | undefined)[]
): number {
const values = [track?.duration, ...extraSec].filter(
(d): d is number => typeof d === 'number' && Number.isFinite(d) && d > 0,
);
if (values.length === 0) return 0;
return Math.round(Math.max(...values));
}
function noteDurationHint(durationSec?: number): void {
if (!open || !durationSec || durationSec <= 0) return;
const rounded = Math.round(durationSec);
if (rounded > open.durationSecHint) {
open.durationSecHint = rounded;
}
}
async function playerGateBlocks(): Promise<boolean> {
const { usePlayerStore } = await import('./playerStore');
const { usePreviewStore } = await import('./previewStore');
if (usePreviewStore.getState().previewingId) return true;
if (usePlayerStore.getState().currentRadio) return true;
return false;
}
async function recordingEnabledForServer(serverId: string): Promise<boolean> {
if (!serverId) return false;
if (!useLibraryIndexStore.getState().isIndexEnabled(serverId)) return false;
if (await playerGateBlocks()) return false;
return libraryIsReady(serverId);
}
export async function playListenSessionOpen(
track: Track,
serverId: string,
engineDurationSec?: number,
): Promise<void> {
if (open && open.trackId === track.id && open.serverId === serverId) {
noteDurationHint(resolveDurationSecHint(track, engineDurationSec));
return;
}
await playListenSessionFinalize('skip');
const enabled = await recordingEnabledForServer(serverId);
if (!enabled) return;
open = {
serverId,
trackId: track.id,
startedAtMs: Date.now(),
listenedSec: 0,
positionMaxSec: 0,
durationSecHint: resolveDurationSecHint(track, engineDurationSec),
lastTickMs: Date.now(),
recordingEnabled: true,
};
}
export async function playListenSessionOnProgress(
currentTime: number,
buffering: boolean,
durationSecHint?: number,
): Promise<void> {
if (!open?.recordingEnabled) return;
noteDurationHint(durationSecHint);
const { usePlayerStore } = await import('./playerStore');
const store = usePlayerStore.getState();
const track = store.currentTrack;
if (track?.id === open.trackId) {
noteDurationHint(resolveDurationSecHint(track, durationSecHint));
}
if (!store.isPlaying || buffering) {
open.lastTickMs = Date.now();
return;
}
const now = Date.now();
const deltaSec = Math.max(0, (now - open.lastTickMs) / 1000);
open.lastTickMs = now;
open.listenedSec += deltaSec;
if (Number.isFinite(currentTime) && currentTime > open.positionMaxSec) {
open.positionMaxSec = currentTime;
}
}
export async function playListenSessionFinalize(reason: PlaySessionEndReason): Promise<void> {
if (finalizeInFlight) {
await finalizeInFlight;
}
if (!open) return;
const session = open;
clearOpen();
if (!session.recordingEnabled || session.listenedSec <= MIN_LISTENED_SEC) {
return;
}
const { usePlayerStore } = await import('./playerStore');
const track = usePlayerStore.getState().currentTrack;
const durationSecHint = resolveDurationSecHint(
track?.id === session.trackId ? track : null,
session.durationSecHint,
);
finalizeInFlight = libraryRecordPlaySession({
serverId: session.serverId,
trackId: session.trackId,
startedAtMs: session.startedAtMs,
listenedSec: session.listenedSec,
positionMaxSec: session.positionMaxSec,
endReason: reason,
durationSecHint: durationSecHint > 0 ? durationSecHint : undefined,
})
.then(() => {
emitPlaySessionRecorded({
serverId: session.serverId,
trackId: session.trackId,
startedAtMs: session.startedAtMs,
});
})
.catch(() => undefined)
.finally(() => {
finalizeInFlight = null;
});
await finalizeInFlight;
}
export async function playListenSessionOnTrackSwitched(nextTrack: Track): Promise<void> {
const serverId = getPlaybackServerId();
await playListenSessionFinalize('switch');
await playListenSessionOpen(nextTrack, serverId, nextTrack.duration);
}
/** Test-only reset */
export function _resetPlayListenSessionForTest(): void {
open = null;
finalizeInFlight = null;
}
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it, vi } from 'vitest';
import { emitPlaySessionRecorded, onPlaySessionRecorded } from './playSessionRecorded';
describe('playSessionRecorded', () => {
it('notifies subscribers when a listen is persisted', () => {
const listener = vi.fn();
const unsub = onPlaySessionRecorded(listener);
const detail = { serverId: 's1', trackId: 't1', startedAtMs: 123 };
emitPlaySessionRecorded(detail);
expect(listener).toHaveBeenCalledWith(detail);
unsub();
listener.mockClear();
emitPlaySessionRecorded(detail);
expect(listener).not.toHaveBeenCalled();
});
});
+25
View File
@@ -0,0 +1,25 @@
export type PlaySessionRecordedDetail = {
serverId: string;
trackId: string;
startedAtMs: number;
};
const EVENT_NAME = 'psysonic:play-session-recorded';
export function emitPlaySessionRecorded(detail: PlaySessionRecordedDetail): void {
if (typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent<PlaySessionRecordedDetail>(EVENT_NAME, { detail }));
}
export function onPlaySessionRecorded(
listener: (detail: PlaySessionRecordedDetail) => void,
): () => void {
if (typeof window === 'undefined') return () => {};
const wrapped = (evt: Event) => {
const ce = evt as CustomEvent<PlaySessionRecordedDetail>;
if (!ce?.detail) return;
listener(ce.detail);
};
window.addEventListener(EVENT_NAME, wrapped as EventListener);
return () => window.removeEventListener(EVENT_NAME, wrapped as EventListener);
}
+3
View File
@@ -36,6 +36,7 @@ import {
import type { PlayerState, Track } from './playerStoreTypes';
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
import { syncQueueToServer } from './queueSync';
import { playListenSessionFinalize } from './playListenSession';
import { pushQueueUndoFromGetter } from './queueUndo';
import { stopRadio } from './radioPlayer';
import { clearAllPlaybackScheduleTimers } from './scheduleTimers';
@@ -152,6 +153,8 @@ export function runPlayTrack(
return;
}
void playListenSessionFinalize('skip');
clearAllPlaybackScheduleTimers();
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
+2
View File
@@ -18,6 +18,7 @@ import { clearSeekDebounce } from './seekDebounce';
import { clearSeekFallbackRetry } from './seekFallbackState';
import { clearSeekTarget } from './seekTargetState';
import i18n from '../i18n';
import { playListenSessionFinalize } from './playListenSession';
import { playbackServerDiffersFromActive, clearQueueServerForPlayback } from '../utils/playback/playbackServer';
import { useLuckyMixStore } from './luckyMixStore';
import { showToast } from '../utils/ui/toast';
@@ -207,6 +208,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
},
clearQueue: () => {
void playListenSessionFinalize('stop');
invoke('audio_stop').catch(console.error);
setIsAudioPaused(false);
clearSeekFallbackRetry();
+2
View File
@@ -2,6 +2,7 @@ import { invoke } from '@tauri-apps/api/core';
import { setIsAudioPaused } from './engineState';
import type { PlayerState } from './playerStoreTypes';
import { flushQueueSyncToServer } from './queueSync';
import { playListenSessionFinalize } from './playListenSession';
import { pauseRadio, stopRadio } from './radioPlayer';
import { clearAllPlaybackScheduleTimers } from './scheduleTimers';
import { clearSeekDebounce } from './seekDebounce';
@@ -28,6 +29,7 @@ export function createTransportLightActions(set: SetState, get: GetState): Pick<
> {
return {
stop: () => {
void playListenSessionFinalize('stop');
clearAllPlaybackScheduleTimers();
if (get().currentRadio) {
stopRadio();
+270 -5
View File
@@ -3,6 +3,8 @@
display: flex;
flex-direction: column;
gap: 3rem;
min-width: 0;
max-width: 100%;
}
.stats-overview {
@@ -103,10 +105,273 @@
overflow: hidden;
}
.genre-bar-fill {
height: 100%;
background: var(--accent);
border-radius: 3px;
transition: width 0.8s ease-out;
.player-heatmap-wrap {
width: 100%;
max-width: 100%;
min-width: 0;
margin-bottom: var(--space-4);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.player-heatmap {
--hm-cell: 12px;
--hm-gap: 2px;
--hm-label-w: 24px;
--hm-body-gap: 8px;
--hm-pitch: calc(var(--hm-cell) + var(--hm-gap));
--hm-grid-w: calc(var(--hm-weeks) * var(--hm-pitch) - var(--hm-gap));
display: flex;
flex-direction: column;
gap: var(--space-2);
width: 100%;
max-width: 100%;
}
.player-heatmap-months {
position: relative;
height: calc(var(--hm-cell) + 2px);
width: var(--hm-grid-w);
margin-left: calc(var(--hm-label-w) + var(--hm-body-gap));
flex-shrink: 0;
}
.player-heatmap-month {
position: absolute;
top: 0;
left: calc(var(--column-index, 0) * var(--hm-pitch));
font-size: max(8px, calc(var(--hm-cell) * 0.72));
line-height: calc(var(--hm-cell) + 2px);
color: var(--text-muted);
white-space: nowrap;
}
.player-heatmap-body {
display: flex;
gap: var(--hm-body-gap);
align-items: flex-start;
min-width: 0;
max-width: 100%;
}
.player-heatmap-weekdays {
display: flex;
flex-direction: column;
gap: var(--hm-gap);
width: var(--hm-label-w);
flex-shrink: 0;
}
.player-heatmap-weekdays span {
height: var(--hm-cell);
line-height: var(--hm-cell);
font-size: max(8px, calc(var(--hm-cell) * 0.72));
color: var(--text-muted);
text-align: right;
}
.player-heatmap-columns {
display: flex;
gap: var(--hm-gap);
width: var(--hm-grid-w);
flex-shrink: 1;
min-width: 0;
}
.player-heatmap-col {
display: flex;
flex-direction: column;
gap: var(--hm-gap);
flex: 0 0 var(--hm-cell);
width: var(--hm-cell);
}
.player-heatmap-cell {
width: var(--hm-cell);
height: var(--hm-cell);
flex: 0 0 var(--hm-cell);
border-radius: clamp(2px, calc(var(--hm-cell) * 0.22), 3px);
border: 1px solid transparent;
padding: 0;
box-sizing: border-box;
cursor: default;
}
.player-heatmap-cell--pad {
background: transparent;
border-color: transparent;
pointer-events: none;
}
.player-heatmap-cell--empty {
background: color-mix(in srgb, var(--text-muted) 14%, transparent);
border-color: color-mix(in srgb, var(--text-muted) 10%, transparent);
}
button.player-heatmap-cell {
cursor: pointer;
}
button.player-heatmap-cell:hover:not(.player-heatmap-cell--pad) {
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
}
.player-heatmap-cell--selected {
border-color: var(--accent) !important;
outline: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
outline-offset: 1px;
}
.player-heatmap-legend {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
font-size: max(8px, calc(var(--hm-cell) * 0.72));
color: var(--text-muted);
padding-left: calc(var(--hm-label-w) + var(--hm-body-gap));
}
.player-heatmap-cell--sample {
cursor: default;
}
.player-stats-partial-index-notice {
display: flex;
align-items: flex-start;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.player-stats-partial-index-link {
background: none;
border: none;
padding: 0;
font: inherit;
color: var(--accent);
text-decoration: underline;
cursor: pointer;
}
.player-stats-partial-index-link:hover {
color: var(--accent-hover, var(--accent));
}
.player-stats-year-nav {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
gap: var(--space-3);
}
.player-stats-year-nav .btn:disabled {
opacity: 0.38;
cursor: not-allowed;
pointer-events: none;
filter: grayscale(0.35);
border-color: var(--border-subtle);
color: var(--text-muted);
background: var(--bg-card);
transform: none;
box-shadow: none;
}
.player-stats-year-nav .btn:disabled:hover {
background: var(--bg-card);
border-color: var(--border-subtle);
transform: none;
}
.player-stats-recent {
margin-top: var(--space-5);
}
.player-stats-day-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.player-stats-day-item {
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
overflow: hidden;
}
.player-stats-day-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
width: 100%;
padding: var(--space-3) var(--space-4);
background: transparent;
border: none;
color: inherit;
text-align: left;
cursor: pointer;
}
.player-stats-day-header:hover {
background: var(--bg-hover);
}
.player-stats-day-header-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.player-stats-day-label {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
}
.player-stats-day-summary {
font-size: 0.75rem;
color: var(--text-muted);
}
.player-stats-day-chevron {
flex-shrink: 0;
color: var(--text-muted);
transition: transform 0.15s ease;
}
.player-stats-day-item--open .player-stats-day-chevron {
transform: rotate(180deg);
}
.player-stats-day-body {
padding: 0 var(--space-4) var(--space-4);
border-top: 1px solid var(--border-subtle);
}
.player-stats-day-meta {
font-size: 0.875rem;
color: var(--text-muted);
margin: var(--space-3) 0 var(--space-3);
}
.player-stats-day-tracks {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.player-stats-day-track-title {
font-size: 0.875rem;
font-weight: 500;
}
.player-stats-day-track-meta {
font-size: 0.75rem;
color: var(--text-muted);
}
+34 -1
View File
@@ -5,10 +5,12 @@ import { describe, it, expect, vi } from 'vitest';
vi.mock('../../i18n', () => ({
default: {
t: (key: string, params: Record<string, unknown>) => `${key}|${JSON.stringify(params)}`,
resolvedLanguage: 'en',
language: 'en',
},
}));
import { formatHumanHoursMinutes } from './formatHumanDuration';
import { formatHumanHoursMinutes, formatPlayerStatsListeningTotal, formatPlayerStatsListenedSec } from './formatHumanDuration';
describe('formatHumanHoursMinutes', () => {
it('rounds to the nearest minute instead of truncating', () => {
@@ -33,3 +35,34 @@ describe('formatHumanHoursMinutes', () => {
expect(formatHumanHoursMinutes(-5)).toBe('common.durationMinutesOnly|{"minutes":0}');
});
});
describe('formatPlayerStatsListeningTotal', () => {
it('formats 25.5 hours as compact day hour minute parts', () => {
expect(formatPlayerStatsListeningTotal(25.5 * 3600)).toBe(
'statistics.playerListeningDayShort|{"count":1} statistics.playerListeningHourShort|{"count":1} statistics.playerListeningMinuteShort|{"count":30}',
);
});
it('shows minutes only for sub-hour totals', () => {
expect(formatPlayerStatsListeningTotal(45 * 60)).toBe(
'statistics.playerListeningMinuteShort|{"count":45}',
);
});
it('omits zero day and hour parts', () => {
expect(formatPlayerStatsListeningTotal(2 * 24 * 3600)).toBe(
'statistics.playerListeningDayShort|{"count":2}',
);
});
});
describe('formatPlayerStatsListenedSec', () => {
it('uses seconds below one minute', () => {
expect(formatPlayerStatsListenedSec(45.6)).toBe('statistics.playerListenedSecShort|{"seconds":46}');
});
it('uses decimal minutes from one minute upward', () => {
expect(formatPlayerStatsListenedSec(90)).toBe('statistics.playerListenedMinDecimal|{"minutes":"1.5"}');
expect(formatPlayerStatsListenedSec(125)).toBe('statistics.playerListenedMinDecimal|{"minutes":"2.1"}');
});
});
+33
View File
@@ -16,3 +16,36 @@ export function formatHumanHoursMinutes(seconds: number): string {
}
return i18n.t('common.durationMinutesOnly', { minutes: totalMin });
}
/** Player stats totals: compact days, hours, minutes (omit zero parts). */
export function formatPlayerStatsListeningTotal(seconds: number): string {
const totalMin = Math.max(0, Math.round(seconds / 60));
const days = Math.floor(totalMin / 1440);
const hours = Math.floor((totalMin % 1440) / 60);
const minutes = totalMin % 60;
const parts: string[] = [];
if (days > 0) {
parts.push(i18n.t('statistics.playerListeningDayShort', { count: days }));
}
if (hours > 0) {
parts.push(i18n.t('statistics.playerListeningHourShort', { count: hours }));
}
if (minutes > 0 || parts.length === 0) {
parts.push(i18n.t('statistics.playerListeningMinuteShort', { count: minutes }));
}
return parts.join(' ');
}
/** Per-track listened time in player stats drill-down. */
export function formatPlayerStatsListenedSec(seconds: number): string {
const sec = Math.max(0, seconds);
if (sec >= 60) {
const minutes = (sec / 60).toLocaleString(i18n.resolvedLanguage ?? i18n.language, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
return i18n.t('statistics.playerListenedMinDecimal', { minutes });
}
return i18n.t('statistics.playerListenedSecShort', { seconds: Math.round(sec) });
}
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { formatPlayerStatsDayLabel } from './formatPlayerStatsDay';
const t = (key: string) => {
if (key === 'statistics.playerDayToday') return 'Today';
if (key === 'statistics.playerDayYesterday') return 'Yesterday';
return key;
};
describe('formatPlayerStatsDayLabel', () => {
it('labels today and yesterday', () => {
const today = new Date();
const y = today.getFullYear();
const m = String(today.getMonth() + 1).padStart(2, '0');
const d = String(today.getDate()).padStart(2, '0');
expect(formatPlayerStatsDayLabel(`${y}-${m}-${d}`, t, 'en')).toBe('Today');
const yest = new Date(today);
yest.setDate(yest.getDate() - 1);
const ym = String(yest.getMonth() + 1).padStart(2, '0');
const yd = String(yest.getDate()).padStart(2, '0');
expect(formatPlayerStatsDayLabel(`${yest.getFullYear()}-${ym}-${yd}`, t, 'en')).toBe('Yesterday');
});
});
@@ -0,0 +1,41 @@
export const PLAYER_STATS_RECENT_DAYS_LIMIT = 30;
export function localTodayIso(): string {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
function parseLocalDate(dateIso: string): Date {
const [y, m, d] = dateIso.split('-').map(Number);
return new Date(y, m - 1, d);
}
function startOfLocalDay(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
/** Human-readable day header for recent-days accordion. */
export function formatPlayerStatsDayLabel(
dateIso: string,
t: (key: string, opts?: Record<string, unknown>) => string,
locale?: string,
): string {
const date = parseLocalDate(dateIso);
const today = startOfLocalDay(new Date());
const day = startOfLocalDay(date);
const diffDays = Math.round((today.getTime() - day.getTime()) / 86_400_000);
if (diffDays === 0) return t('statistics.playerDayToday');
if (diffDays === 1) return t('statistics.playerDayYesterday');
const fmt = new Intl.DateTimeFormat(locale, {
weekday: 'long',
month: 'short',
day: 'numeric',
year: date.getFullYear() !== today.getFullYear() ? 'numeric' : undefined,
});
return fmt.format(date);
}
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { heatmapCellMetrics } from './heatmapLevels';
describe('heatmapCellMetrics', () => {
it('shrinks cells to fit narrow containers', () => {
const m = heatmapCellMetrics(400, 53);
expect(m.cell).toBeLessThan(14);
expect(m.cell).toBeGreaterThanOrEqual(4);
const total = m.labelW + m.bodyGap + 53 * m.cell + 52 * m.gap;
expect(total).toBeLessThanOrEqual(400 + 1);
});
it('caps at 14px on wide containers', () => {
const m = heatmapCellMetrics(1200, 53);
expect(m.cell).toBe(14);
});
});
+110
View File
@@ -0,0 +1,110 @@
/** GitHub-style intensity bucket from track play count vs year max. */
export function heatmapLevel(count: number, maxCount: number): 0 | 1 | 2 | 3 | 4 {
if (count <= 0 || maxCount <= 0) return 0;
const ratio = count / maxCount;
if (ratio >= 0.75) return 4;
if (ratio >= 0.5) return 3;
if (ratio >= 0.25) return 2;
return 1;
}
export const HEATMAP_LEVEL_COUNT = 5;
export type HeatmapCell = { date: string; count: number };
export function yearDayKeys(year: number): string[] {
const out: string[] = [];
const start = new Date(year, 0, 1);
const end = new Date(year, 11, 31);
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
out.push(`${y}-${m}-${day}`);
}
return out;
}
/** Week columns (Sun→Sat rows) with leading/trailing padding cells. */
export function heatmapWeekColumns(year: number, dayCounts: Map<string, number>): {
weeks: HeatmapCell[][];
maxCount: number;
} {
const days = yearDayKeys(year);
const maxCount = Math.max(0, ...days.map(d => dayCounts.get(d) ?? 0));
const firstDow = new Date(year, 0, 1).getDay();
const cells: HeatmapCell[] = [];
for (let i = 0; i < firstDow; i++) {
cells.push({ date: '', count: 0 });
}
for (const date of days) {
cells.push({ date, count: dayCounts.get(date) ?? 0 });
}
while (cells.length % 7 !== 0) {
cells.push({ date: '', count: 0 });
}
const weeks: HeatmapCell[][] = [];
for (let i = 0; i < cells.length; i += 7) {
weeks.push(cells.slice(i, i + 7));
}
return { weeks, maxCount };
}
/** Month labels aligned to week columns (first day of each month). */
export function heatmapMonthLabels(
year: number,
locale?: string,
): { columnIndex: number; label: string }[] {
const fmt = new Intl.DateTimeFormat(locale, { month: 'short' });
const firstDow = new Date(year, 0, 1).getDay();
const labels: { columnIndex: number; label: string }[] = [];
let lastMonth = -1;
const yearStart = new Date(year, 0, 1).getTime();
const end = new Date(year, 11, 31);
for (let d = new Date(year, 0, 1); d <= end; d.setDate(d.getDate() + 1)) {
const month = d.getMonth();
if (month === lastMonth) continue;
const dayOfYear = Math.floor((d.getTime() - yearStart) / 86_400_000);
labels.push({
columnIndex: Math.floor((firstDow + dayOfYear) / 7),
label: fmt.format(new Date(year, month, 1)),
});
lastMonth = month;
}
return labels;
}
/** Weekday row labels (SunSat); empty string hides a row label like GitHub. */
export function heatmapWeekdayLabels(locale?: string): string[] {
const fmt = new Intl.DateTimeFormat(locale, { weekday: 'narrow' });
// Jan 4 2026 is Sunday — anchor week for stable weekday order.
const anchor = new Date(2026, 0, 4);
return Array.from({ length: 7 }, (_, i) => {
if (i % 2 === 0) return '';
const d = new Date(anchor);
d.setDate(anchor.getDate() + i);
return fmt.format(d);
});
}
const HEATMAP_LABEL_W = 24;
const HEATMAP_BODY_GAP = 8;
const HEATMAP_CELL_MIN = 4;
const HEATMAP_CELL_MAX = 14;
/** Fit square cells to the heatmap container width (week columns only). */
export function heatmapCellMetrics(
containerWidth: number,
weekCount: number,
): { cell: number; gap: number; labelW: number; bodyGap: number } {
const gap = containerWidth > 640 ? 3 : 2;
const available = containerWidth - HEATMAP_LABEL_W - HEATMAP_BODY_GAP;
if (available <= 0 || weekCount <= 0) {
return { cell: HEATMAP_CELL_MIN, gap, labelW: HEATMAP_LABEL_W, bodyGap: HEATMAP_BODY_GAP };
}
const cell = Math.min(
HEATMAP_CELL_MAX,
Math.max(HEATMAP_CELL_MIN, (available - (weekCount - 1) * gap) / weekCount),
);
return { cell, gap, labelW: HEATMAP_LABEL_W, bodyGap: HEATMAP_BODY_GAP };
}