fix(statistics): keep player stats tab visible without local index (#851)

* fix(statistics): keep player stats tab visible without local index

Show the tab always and explain that player statistics require the local
library index, with a link to library settings, instead of hiding the tab.

* docs(release): CHANGELOG and credits for player stats tab UX (PR #851)

* docs(credits): drop minor library index and player stats tab fixes

Per team policy, small UX fixes (PR #850, #851) stay in CHANGELOG only.
This commit is contained in:
cucadmuh
2026-05-22 14:41:04 +03:00
committed by GitHub
parent 589afe9b7e
commit d54eceaf3b
15 changed files with 73 additions and 12 deletions
+8
View File
@@ -164,6 +164,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Statistics — player stats tab without local index
**By [@cucadmuh](https://github.com/cucadmuh), PR [#851](https://github.com/Psychotoxical/psysonic/pull/851)**
* **Statistics → Player stats** tab stays visible when the local library index is off; an info notice explains that player statistics require the index and links to **Settings → Library**.
## [1.46.0] - 2026-05-18 ## [1.46.0] - 2026-05-18
> **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages. > **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages.
@@ -9,7 +9,9 @@ import {
type PlaySessionYearSummary, type PlaySessionYearSummary,
} from '../../api/library'; } from '../../api/library';
import { usePlayerStatsLiveRefresh } from '../../hooks/usePlayerStatsLiveRefresh'; import { usePlayerStatsLiveRefresh } from '../../hooks/usePlayerStatsLiveRefresh';
import { usePlayerStatsRecordingEnabled } from '../../hooks/usePlayerStatsRecordingEnabled';
import PlayerStatsHeatmap from './PlayerStatsHeatmap'; import PlayerStatsHeatmap from './PlayerStatsHeatmap';
import PlayerStatsIndexRequiredNotice from './PlayerStatsIndexRequiredNotice';
import PlayerStatsPartialIndexNotice from './PlayerStatsPartialIndexNotice'; import PlayerStatsPartialIndexNotice from './PlayerStatsPartialIndexNotice';
import PlayerStatsRecentDays from './PlayerStatsRecentDays'; import PlayerStatsRecentDays from './PlayerStatsRecentDays';
import { formatPlayerStatsListeningTotal } from '../../utils/format/formatHumanDuration'; import { formatPlayerStatsListeningTotal } from '../../utils/format/formatHumanDuration';
@@ -18,6 +20,7 @@ const currentCalendarYear = () => new Date().getFullYear();
export default function PlayerStatisticsPanel() { export default function PlayerStatisticsPanel() {
const { t } = useTranslation(); const { t } = useTranslation();
const recordingEnabled = usePlayerStatsRecordingEnabled();
const [year, setYear] = useState(currentCalendarYear); const [year, setYear] = useState(currentCalendarYear);
const [yearBounds, setYearBounds] = useState<PlaySessionYearBounds | null>(null); const [yearBounds, setYearBounds] = useState<PlaySessionYearBounds | null>(null);
const [summary, setSummary] = useState<PlaySessionYearSummary | null>(null); const [summary, setSummary] = useState<PlaySessionYearSummary | null>(null);
@@ -28,6 +31,13 @@ export default function PlayerStatisticsPanel() {
const [liveRefreshKey, setLiveRefreshKey] = useState(0); const [liveRefreshKey, setLiveRefreshKey] = useState(0);
useEffect(() => { useEffect(() => {
if (!recordingEnabled) {
setLoading(false);
setSummary(null);
setDayCounts(new Map());
setSelectedDate(null);
return;
}
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
setSelectedDate(null); setSelectedDate(null);
@@ -52,9 +62,10 @@ export default function PlayerStatisticsPanel() {
} }
}); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [year]); }, [year, recordingEnabled]);
const refreshLive = useCallback(async () => { const refreshLive = useCallback(async () => {
if (!recordingEnabled) return;
try { try {
const [s, heat] = await Promise.all([ const [s, heat] = await Promise.all([
libraryGetPlayerStatsYearSummary(year), libraryGetPlayerStatsYearSummary(year),
@@ -66,10 +77,18 @@ export default function PlayerStatisticsPanel() {
} catch { } catch {
/* ignore transient read errors during live refresh */ /* ignore transient read errors during live refresh */
} }
}, [year]); }, [year, recordingEnabled]);
usePlayerStatsLiveRefresh(refreshLive); usePlayerStatsLiveRefresh(refreshLive);
if (!recordingEnabled) {
return (
<div className="stats-page">
<PlayerStatsIndexRequiredNotice />
</div>
);
}
const empty = !loading && (summary?.trackPlayCount ?? 0) === 0; const empty = !loading && (summary?.trackPlayCount ?? 0) === 0;
const calYear = currentCalendarYear(); const calYear = currentCalendarYear();
const maxNavYear = yearBounds?.maxYear != null const maxNavYear = yearBounds?.maxYear != null
@@ -0,0 +1,25 @@
import { Info } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
export default function PlayerStatsIndexRequiredNotice() {
const { t } = useTranslation();
const navigate = useNavigate();
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.playerIndexRequired')}
{' '}
<button
type="button"
className="player-stats-partial-index-link"
onClick={() => navigate('/settings', { state: { tab: 'library' } })}
>
{t('statistics.playerPartialIndexSettings')}
</button>
</span>
</div>
);
}
@@ -1,19 +1,10 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../store/authStore';
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
export default function StatisticsTabBar() { export default function StatisticsTabBar() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); 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'; const isPlayer = location.pathname === '/player-stats';
-1
View File
@@ -125,7 +125,6 @@ const CONTRIBUTOR_ENTRIES = [
'Local library index: multi-server settings UI, serial sync queue, music-library-scoped local search, parallel initial ingest, i18n across 9 locales (PR #846)', '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)', '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)', 'Player stats: local listening history tab with heatmap, year summary, recent days, and day drill-down (PR #849)',
'Settings → Library: exclude/include index buttons show busy state and block repeat clicks (PR #850)',
], ],
}, },
{ {
@@ -0,0 +1,10 @@
import { useAuthStore } from '../store/authStore';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
/** True when local play history is recorded (master index on + ≥1 server included). */
export function usePlayerStatsRecordingEnabled(): boolean {
const servers = useAuthStore(s => s.servers);
const masterEnabled = useLibraryIndexStore(s => s.masterEnabled);
const syncExcludedByServer = useLibraryIndexStore(s => s.syncExcludedByServer);
return masterEnabled && servers.some(s => syncExcludedByServer[s.id] !== true);
}
+1
View File
@@ -74,6 +74,7 @@ export const statistics = {
playerSummaryUniqueTracks: 'Einzigartige Titel', playerSummaryUniqueTracks: 'Einzigartige Titel',
playerSummaryDays: 'Days', playerSummaryDays: 'Days',
playerPartialIndexNotice: 'Einige Server sind vom lokalen Bibliotheksindex ausgeschlossen. Wiedergaben auf diesen Servern werden nicht in der Player-Statistik erfasst.', playerPartialIndexNotice: 'Einige Server sind vom lokalen Bibliotheksindex ausgeschlossen. Wiedergaben auf diesen Servern werden nicht in der Player-Statistik erfasst.',
playerIndexRequired: 'Player-Statistiken sind erst verfügbar, wenn der lokale Bibliotheksindex aktiviert ist.',
playerPartialIndexSettings: 'Bibliothekseinstellungen', playerPartialIndexSettings: 'Bibliothekseinstellungen',
playerSummaryCompletion: 'Voll / teilweise', playerSummaryCompletion: 'Voll / teilweise',
playerYearPrev: 'Vorheriges Jahr', playerYearPrev: 'Vorheriges Jahr',
+1
View File
@@ -74,6 +74,7 @@ export const statistics = {
playerSummaryUniqueTracks: 'Unique tracks', playerSummaryUniqueTracks: 'Unique tracks',
playerSummaryDays: 'Days', playerSummaryDays: 'Days',
playerPartialIndexNotice: 'Some servers are excluded from the local library index. Listening on those servers is not recorded in player statistics.', playerPartialIndexNotice: 'Some servers are excluded from the local library index. Listening on those servers is not recorded in player statistics.',
playerIndexRequired: 'Player statistics are not available until you enable the local library index.',
playerPartialIndexSettings: 'Library settings', playerPartialIndexSettings: 'Library settings',
playerSummaryCompletion: 'Full / partial', playerSummaryCompletion: 'Full / partial',
playerYearPrev: 'Previous year', playerYearPrev: 'Previous year',
+1
View File
@@ -56,6 +56,7 @@ export const statistics = {
playerSummaryUniqueTracks: 'Pistas únicas', playerSummaryUniqueTracks: 'Pistas únicas',
playerSummaryDays: 'Days', 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.', 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.',
playerIndexRequired: 'Las estadísticas del reproductor no están disponibles hasta que actives el índice local de biblioteca.',
playerPartialIndexSettings: 'Ajustes de biblioteca', playerPartialIndexSettings: 'Ajustes de biblioteca',
playerSummaryCompletion: 'Completas / parciales', playerSummaryCompletion: 'Completas / parciales',
playerYearPrev: 'Año anterior', playerYearPrev: 'Año anterior',
+1
View File
@@ -56,6 +56,7 @@ export const statistics = {
playerSummaryUniqueTracks: 'Morceaux uniques', playerSummaryUniqueTracks: 'Morceaux uniques',
playerSummaryDays: 'Days', 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.', 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.',
playerIndexRequired: 'Les statistiques du lecteur ne sont pas disponibles tant que lindex local de bibliothèque nest pas activé.',
playerPartialIndexSettings: 'Paramètres bibliothèque', playerPartialIndexSettings: 'Paramètres bibliothèque',
playerSummaryCompletion: 'Complets / partiels', playerSummaryCompletion: 'Complets / partiels',
playerYearPrev: 'Année précédente', playerYearPrev: 'Année précédente',
+1
View File
@@ -56,6 +56,7 @@ export const statistics = {
playerSummaryUniqueTracks: 'Unike spor', playerSummaryUniqueTracks: 'Unike spor',
playerSummaryDays: 'Days', playerSummaryDays: 'Days',
playerPartialIndexNotice: 'Noen servere er ekskludert fra det lokale biblioteksindeks. Lytting på disse serverne registreres ikke i spillerstatistikken.', playerPartialIndexNotice: 'Noen servere er ekskludert fra det lokale biblioteksindeks. Lytting på disse serverne registreres ikke i spillerstatistikken.',
playerIndexRequired: 'Spillerstatistikk er ikke tilgjengelig før du aktiverer det lokale biblioteksindeks.',
playerPartialIndexSettings: 'Bibliotekinnstillinger', playerPartialIndexSettings: 'Bibliotekinnstillinger',
playerSummaryCompletion: 'Full / delvis', playerSummaryCompletion: 'Full / delvis',
playerYearPrev: 'Forrige år', playerYearPrev: 'Forrige år',
+1
View File
@@ -56,6 +56,7 @@ export const statistics = {
playerSummaryUniqueTracks: 'Unieke nummers', playerSummaryUniqueTracks: 'Unieke nummers',
playerSummaryDays: 'Days', playerSummaryDays: 'Days',
playerPartialIndexNotice: 'Sommige servers zijn uitgesloten van de lokale bibliotheekindex. Luisteren op die servers wordt niet vastgelegd in de playerstatistieken.', playerPartialIndexNotice: 'Sommige servers zijn uitgesloten van de lokale bibliotheekindex. Luisteren op die servers wordt niet vastgelegd in de playerstatistieken.',
playerIndexRequired: 'Playerstatistieken zijn niet beschikbaar totdat je de lokale bibliotheekindex inschakelt.',
playerPartialIndexSettings: 'Bibliotheekinstellingen', playerPartialIndexSettings: 'Bibliotheekinstellingen',
playerSummaryCompletion: 'Volledig / gedeeltelijk', playerSummaryCompletion: 'Volledig / gedeeltelijk',
playerYearPrev: 'Vorig jaar', playerYearPrev: 'Vorig jaar',
+1
View File
@@ -74,6 +74,7 @@ export const statistics = {
playerSummaryUniqueTracks: 'Piese unice', playerSummaryUniqueTracks: 'Piese unice',
playerSummaryDays: 'Days', playerSummaryDays: 'Days',
playerPartialIndexNotice: 'Unele servere sunt excluse din indexul local al bibliotecii. Ascultarea pe aceste servere nu este înregistrată în statisticile playerului.', playerPartialIndexNotice: 'Unele servere sunt excluse din indexul local al bibliotecii. Ascultarea pe aceste servere nu este înregistrată în statisticile playerului.',
playerIndexRequired: 'Statisticile playerului nu sunt disponibile până când activezi indexul local al bibliotecii.',
playerPartialIndexSettings: 'Setări bibliotecă', playerPartialIndexSettings: 'Setări bibliotecă',
playerSummaryCompletion: 'Complete / parțiale', playerSummaryCompletion: 'Complete / parțiale',
playerYearPrev: 'Anul anterior', playerYearPrev: 'Anul anterior',
+1
View File
@@ -67,6 +67,7 @@ export const statistics = {
playerSummaryUniqueTracks: 'Уникальные треки', playerSummaryUniqueTracks: 'Уникальные треки',
playerSummaryDays: 'Days', playerSummaryDays: 'Days',
playerPartialIndexNotice: 'Не все серверы включены в локальный индекс библиотеки. Прослушивание с выключенных серверов не попадает в эту статистику.', playerPartialIndexNotice: 'Не все серверы включены в локальный индекс библиотеки. Прослушивание с выключенных серверов не попадает в эту статистику.',
playerIndexRequired: 'Статистика плеера недоступна, пока не включён локальный индекс библиотеки.',
playerPartialIndexSettings: 'Настройки библиотеки', playerPartialIndexSettings: 'Настройки библиотеки',
playerSummaryCompletion: 'Полные / частичные', playerSummaryCompletion: 'Полные / частичные',
playerYearPrev: 'Предыдущий год', playerYearPrev: 'Предыдущий год',
+1
View File
@@ -56,6 +56,7 @@ export const statistics = {
playerSummaryUniqueTracks: '独立曲目', playerSummaryUniqueTracks: '独立曲目',
playerSummaryDays: 'Days', playerSummaryDays: 'Days',
playerPartialIndexNotice: '部分服务器未纳入本地库索引,在这些服务器上的收听不会计入播放器统计。', playerPartialIndexNotice: '部分服务器未纳入本地库索引,在这些服务器上的收听不会计入播放器统计。',
playerIndexRequired: '启用本地库索引后才会显示播放器统计。',
playerPartialIndexSettings: '库设置', playerPartialIndexSettings: '库设置',
playerSummaryCompletion: '完整 / 部分', playerSummaryCompletion: '完整 / 部分',
playerYearPrev: '上一年', playerYearPrev: '上一年',