mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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:
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export const statistics = {
|
|||||||
playerSummaryUniqueTracks: 'Morceaux uniques',
|
playerSummaryUniqueTracks: 'Morceaux uniques',
|
||||||
playerSummaryDays: 'Days',
|
playerSummaryDays: 'Days',
|
||||||
playerPartialIndexNotice: 'Certains serveurs sont exclus de l’index local de bibliothèque. L’écoute sur ces serveurs n’est pas enregistrée dans les statistiques du lecteur.',
|
playerPartialIndexNotice: 'Certains serveurs sont exclus de l’index local de bibliothèque. L’écoute sur ces serveurs n’est pas enregistrée dans les statistiques du lecteur.',
|
||||||
|
playerIndexRequired: 'Les statistiques du lecteur ne sont pas disponibles tant que l’index local de bibliothèque n’est 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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export const statistics = {
|
|||||||
playerSummaryUniqueTracks: 'Уникальные треки',
|
playerSummaryUniqueTracks: 'Уникальные треки',
|
||||||
playerSummaryDays: 'Days',
|
playerSummaryDays: 'Days',
|
||||||
playerPartialIndexNotice: 'Не все серверы включены в локальный индекс библиотеки. Прослушивание с выключенных серверов не попадает в эту статистику.',
|
playerPartialIndexNotice: 'Не все серверы включены в локальный индекс библиотеки. Прослушивание с выключенных серверов не попадает в эту статистику.',
|
||||||
|
playerIndexRequired: 'Статистика плеера недоступна, пока не включён локальный индекс библиотеки.',
|
||||||
playerPartialIndexSettings: 'Настройки библиотеки',
|
playerPartialIndexSettings: 'Настройки библиотеки',
|
||||||
playerSummaryCompletion: 'Полные / частичные',
|
playerSummaryCompletion: 'Полные / частичные',
|
||||||
playerYearPrev: 'Предыдущий год',
|
playerYearPrev: 'Предыдущий год',
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export const statistics = {
|
|||||||
playerSummaryUniqueTracks: '独立曲目',
|
playerSummaryUniqueTracks: '独立曲目',
|
||||||
playerSummaryDays: 'Days',
|
playerSummaryDays: 'Days',
|
||||||
playerPartialIndexNotice: '部分服务器未纳入本地库索引,在这些服务器上的收听不会计入播放器统计。',
|
playerPartialIndexNotice: '部分服务器未纳入本地库索引,在这些服务器上的收听不会计入播放器统计。',
|
||||||
|
playerIndexRequired: '启用本地库索引后才会显示播放器统计。',
|
||||||
playerPartialIndexSettings: '库设置',
|
playerPartialIndexSettings: '库设置',
|
||||||
playerSummaryCompletion: '完整 / 部分',
|
playerSummaryCompletion: '完整 / 部分',
|
||||||
playerYearPrev: '上一年',
|
playerYearPrev: '上一年',
|
||||||
|
|||||||
Reference in New Issue
Block a user