mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-20 20:49:47 +02:00
add stats tracking + stats page + fix scrolling bug in dynamic playlists tray
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
} from '@/navigation/tabTransition';
|
||||
import { popToTop } from '@/navigation/stackActions';
|
||||
import { useColors } from '@/theme/themed';
|
||||
import { isDisplayedTabFocused } from '@/navigation/statsTabState';
|
||||
|
||||
export default function TabsLayout() {
|
||||
const colors = useColors();
|
||||
@@ -31,10 +32,16 @@ export default function TabsLayout() {
|
||||
detachInactiveScreens={false}
|
||||
screenOptions={screenOptions}
|
||||
tabBar={({ state, navigation }) => {
|
||||
const activeRouteName = state.routes[state.index]?.name;
|
||||
const items: TabItem[] = state.routes.map((route, index) => ({
|
||||
key: route.key,
|
||||
name: route.name,
|
||||
focused: state.index === index,
|
||||
focused: isDisplayedTabFocused(
|
||||
route.name,
|
||||
index,
|
||||
state.index,
|
||||
activeRouteName,
|
||||
),
|
||||
}));
|
||||
|
||||
const handlePress = (item: TabItem) => {
|
||||
@@ -50,7 +57,8 @@ export default function TabsLayout() {
|
||||
});
|
||||
if (event.defaultPrevented) return;
|
||||
|
||||
if (item.focused) {
|
||||
const actuallyFocused = state.routes[state.index]?.key === item.key;
|
||||
if (actuallyFocused) {
|
||||
// Re-tapping the active tab resets its nested stack. This is the
|
||||
// one-tap escape from a deep library chain (artist → album →
|
||||
// another artist), which is why back itself only pops one level.
|
||||
@@ -72,6 +80,7 @@ export default function TabsLayout() {
|
||||
<Tabs.Screen name="library" />
|
||||
<Tabs.Screen name="eq" />
|
||||
<Tabs.Screen name="settings" />
|
||||
<Tabs.Screen name="stats" options={{ href: null }} />
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
AppState,
|
||||
Pressable,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useFocusEffect, useRouter } from 'expo-router';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { Text } from '@/components/Text';
|
||||
import { AstraLogo } from '@/components/AstraLogo';
|
||||
@@ -44,6 +44,9 @@ import {
|
||||
} from '@/home/homeGreeting';
|
||||
import { useHomeLibraryNavigation } from '@/navigation/useHomeLibraryNavigation';
|
||||
import type { Album, Artist, DbTrack } from '@/types/library';
|
||||
import { ListeningPreviewCard } from '@/components/listening/ListeningPreviewCard';
|
||||
import { useListeningStatsStore } from '@/stores/listeningStatsStore';
|
||||
import { subscribeToListeningHistory } from '@/listeningStats/events';
|
||||
|
||||
const RECENT_ALBUM_LIMIT = 8;
|
||||
const RECENT_TRACK_LIMIT = 3;
|
||||
@@ -529,6 +532,8 @@ export default function HomeScreen() {
|
||||
const openQuickSearch = useSearchStore((s) => s.openQuickSearch);
|
||||
const homeGreetingTextMode = useSettingsStore((s) => s.homeGreetingTextMode);
|
||||
const artistGroupingMode = useSettingsStore((s) => s.artistGroupingMode);
|
||||
const listeningPreview = useListeningStatsStore((s) => s.homePreview);
|
||||
const loadListeningPreview = useListeningStatsStore((s) => s.loadHomePreview);
|
||||
|
||||
const [spotlightOverride, setSpotlightOverride] = useState<RandomSpotlight | null>(null);
|
||||
const [randomSeeds] = useState(() => [Math.random(), Math.random()] as const);
|
||||
@@ -629,6 +634,20 @@ export default function HomeScreen() {
|
||||
const openSearch = () => openQuickSearch();
|
||||
const openSignalScanner = () => router.push('/signal/scan' as never);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
void loadListeningPreview();
|
||||
const unsubscribe = subscribeToListeningHistory(() => void loadListeningPreview());
|
||||
const subscription = AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') void loadListeningPreview();
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
subscription.remove();
|
||||
};
|
||||
}, [loadListeningPreview]),
|
||||
);
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<PullSearchGesture atTop={scrollTop.atTop} onOpen={openSearch}>
|
||||
@@ -648,13 +667,19 @@ export default function HomeScreen() {
|
||||
<ScanProgress />
|
||||
|
||||
{!hasLibrary ? (
|
||||
<EmptyHomeCard
|
||||
scanError={scanError}
|
||||
status={libraryStatus}
|
||||
onManageFolders={() => router.push(
|
||||
libraryStatus === 'fatalUserData' ? '/settings/troubleshooting' : '/settings'
|
||||
)}
|
||||
/>
|
||||
<>
|
||||
<EmptyHomeCard
|
||||
scanError={scanError}
|
||||
status={libraryStatus}
|
||||
onManageFolders={() => router.push(
|
||||
libraryStatus === 'fatalUserData' ? '/settings/troubleshooting' : '/settings'
|
||||
)}
|
||||
/>
|
||||
<ListeningPreviewCard
|
||||
dashboard={listeningPreview}
|
||||
onPress={() => router.push('/stats' as never)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{spotlightContent ? (
|
||||
@@ -676,6 +701,11 @@ export default function HomeScreen() {
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<ListeningPreviewCard
|
||||
dashboard={listeningPreview}
|
||||
onPress={() => router.push('/stats' as never)}
|
||||
/>
|
||||
|
||||
{recentTracks.length > 0 ? (
|
||||
<View style={styles.section}>
|
||||
<SectionHeader
|
||||
|
||||
@@ -537,7 +537,7 @@ function ConditionEditorSheet({
|
||||
const error = validateCondition(draft);
|
||||
|
||||
return (
|
||||
<AppSheet onClose={onCancel}>
|
||||
<AppSheet onClose={onCancel} scrollable>
|
||||
<AppSheetTitle title={target.mode === 'new' ? 'Add filter' : 'Edit filter'} />
|
||||
<Pressable android_ripple={ripple.bounded} style={styles.sheetSelectRow} onPress={onChangeField} accessibilityRole="button">
|
||||
<View style={styles.sheetSelectText}>
|
||||
@@ -610,7 +610,7 @@ function SortLimitSheet({
|
||||
const applyDisabled = !limitValid;
|
||||
|
||||
return (
|
||||
<AppSheet onClose={onCancel}>
|
||||
<AppSheet onClose={onCancel} scrollable>
|
||||
<AppSheetTitle title="Result order" />
|
||||
<AppSheetSection label="SORT BY" />
|
||||
{SORT_FIELD_OPTIONS.map(([field, label]) => (
|
||||
@@ -983,7 +983,7 @@ export default function DynamicPlaylistEditorScreen() {
|
||||
|
||||
if (sheet.kind === 'field-picker') {
|
||||
return (
|
||||
<AppSheet onClose={() => setSheet(null)}>
|
||||
<AppSheet onClose={() => setSheet(null)} scrollable>
|
||||
<AppSheetTitle title="Choose filter" />
|
||||
{(['text', 'activity', 'library', 'audio'] as FieldGroup[]).map((group) => (
|
||||
<View key={group}>
|
||||
|
||||
@@ -29,6 +29,7 @@ import { createBuildInfo } from '@/release/buildInfo';
|
||||
import { useThemeStore } from '@/stores/themeStore';
|
||||
import { useSleepTimerStore } from '@/stores/sleepTimerStore';
|
||||
import { formatSleepTimerStatus } from '@/audio/sleepTimerState';
|
||||
import { useSettingsStore } from '@/stores/settingsStore';
|
||||
|
||||
function formatEnabled(value: boolean): string {
|
||||
return value ? 'On' : 'Off';
|
||||
@@ -53,6 +54,7 @@ export default function SettingsScreen() {
|
||||
const desktopSyncConflictCount = useDesktopSyncStore((s) => s.conflicts.length);
|
||||
const sleepTimer = useSleepTimerStore((s) => s.timer);
|
||||
const sleepRemainingMs = useSleepTimerStore((s) => s.remainingMs);
|
||||
const listeningHistoryEnabled = useSettingsStore((s) => s.listeningHistoryEnabled);
|
||||
void sleepRemainingMs;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -112,7 +114,11 @@ export default function SettingsScreen() {
|
||||
<SettingsNavRow
|
||||
icon="play-circle-outline"
|
||||
title="Playback"
|
||||
subtitle={sleepTimer ? `Sleep timer: ${formatSleepTimerStatus(sleepTimer)}.` : 'Sleep timer and playback behavior.'}
|
||||
subtitle={
|
||||
sleepTimer
|
||||
? `Sleep timer: ${formatSleepTimerStatus(sleepTimer)}. Listening history ${formatEnabled(listeningHistoryEnabled)}.`
|
||||
: `Listening history ${formatEnabled(listeningHistoryEnabled)}. Sleep timer and playback behavior.`
|
||||
}
|
||||
onPress={() => router.push('/settings/playback' as never)}
|
||||
/>
|
||||
<SettingsNavRow
|
||||
|
||||
@@ -0,0 +1,684 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
AppState,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
View,
|
||||
useWindowDimensions,
|
||||
} from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useFocusEffect, useRouter } from 'expo-router';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { Text } from '@/components/Text';
|
||||
import { SegmentedControl } from '@/components/SegmentedControl';
|
||||
import { ListeningStatsShareSheet } from '@/components/listening/ListeningStatsShareSheet';
|
||||
import { listeningArtworkSource } from '@/library/artwork';
|
||||
import {
|
||||
formatBucketDate,
|
||||
formatListeningTime,
|
||||
formatRecordedSince,
|
||||
} from '@/listeningStats/format';
|
||||
import { useHomeLibraryNavigation } from '@/navigation/useHomeLibraryNavigation';
|
||||
import { useListeningStatsStore } from '@/stores/listeningStatsStore';
|
||||
import { playLibraryQuery } from '@/audio/playbackController';
|
||||
import { fonts, radius, spacing } from '@/theme';
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import { useRipple } from '@/theme/ripple';
|
||||
import type {
|
||||
ListeningStatsCategory,
|
||||
ListeningStatsDashboard,
|
||||
RankedListeningAlbum,
|
||||
RankedListeningArtist,
|
||||
RankedListeningTrack,
|
||||
} from '@/types/listeningStats';
|
||||
import { subscribeToListeningHistory } from '@/listeningStats/events';
|
||||
|
||||
const RANGE_SEGMENTS = [
|
||||
{ key: '7d', label: '7D' },
|
||||
{ key: '30d', label: '30D' },
|
||||
{ key: '1y', label: '1Y' },
|
||||
{ key: 'all', label: 'All' },
|
||||
];
|
||||
const METRIC_SEGMENTS = [
|
||||
{ key: 'plays', label: 'Plays' },
|
||||
{ key: 'time', label: 'Time' },
|
||||
];
|
||||
const CATEGORY_SEGMENTS = [
|
||||
{ key: 'tracks', label: 'Tracks' },
|
||||
{ key: 'artists', label: 'Artists' },
|
||||
{ key: 'albums', label: 'Albums' },
|
||||
];
|
||||
|
||||
function SummaryGrid({
|
||||
dashboard,
|
||||
wide,
|
||||
}: {
|
||||
dashboard: ListeningStatsDashboard;
|
||||
wide: boolean;
|
||||
}) {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
const tiles = [
|
||||
['Listening Time', formatListeningTime(dashboard.summary.listenedSeconds, true)],
|
||||
['Qualified Plays', String(dashboard.summary.qualifiedPlays)],
|
||||
['Tracks Played', String(dashboard.summary.tracksPlayed)],
|
||||
['Active Days', String(dashboard.summary.activeDays)],
|
||||
];
|
||||
return (
|
||||
<View style={[styles.summaryGrid, wide && styles.summaryGridWide]}>
|
||||
{tiles.map(([label, value]) => (
|
||||
<View key={label} style={[styles.summaryTile, wide && styles.summaryTileWide]}>
|
||||
<Text variant="title" style={styles.summaryValue} numberOfLines={1}>{value}</Text>
|
||||
<Text variant="caption" color={colors.textSecondary}>{label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityChart({ dashboard }: { dashboard: ListeningStatsDashboard }) {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
const [selectedStartAt, setSelectedStartAt] = useState<number | null>(null);
|
||||
const selectedIndex = dashboard.activity.findIndex(
|
||||
(bucket) => bucket.startAt === selectedStartAt,
|
||||
);
|
||||
const resolvedSelectedIndex = selectedIndex >= 0
|
||||
? selectedIndex
|
||||
: Math.max(0, dashboard.activity.length - 1);
|
||||
const selected = dashboard.activity[resolvedSelectedIndex] ?? dashboard.activity.at(-1) ?? null;
|
||||
const maxSeconds = Math.max(1, ...dashboard.activity.map((bucket) => bucket.listenedSeconds));
|
||||
const fillsCard = dashboard.range === '7d';
|
||||
|
||||
const bars = dashboard.activity.map((bucket, index) => {
|
||||
const height = Math.max(3, Math.round((bucket.listenedSeconds / maxSeconds) * 118));
|
||||
const focused = index === resolvedSelectedIndex;
|
||||
return (
|
||||
<Pressable
|
||||
key={`${bucket.startAt}-${index}`}
|
||||
style={[styles.barSlot, fillsCard && styles.barSlotFill]}
|
||||
onPress={() => setSelectedStartAt(bucket.startAt)}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: focused }}
|
||||
accessibilityLabel={`${formatBucketDate(bucket.startAt, bucket.endAt)}, ${formatListeningTime(bucket.listenedSeconds)}, ${bucket.qualifiedPlays} qualified plays`}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.bar,
|
||||
{ height, backgroundColor: focused ? colors.accent : colors.accentHover },
|
||||
]}
|
||||
/>
|
||||
{(fillsCard || index % Math.max(1, Math.ceil(dashboard.activity.length / 7)) === 0) ? (
|
||||
<Text variant="caption" color={focused ? colors.textPrimary : colors.textTertiary} numberOfLines={1}>
|
||||
{bucket.label}
|
||||
</Text>
|
||||
) : (
|
||||
<View style={styles.barLabelSpacer} />
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Text variant="heading">Activity</Text>
|
||||
<Text variant="caption" color={colors.textSecondary}>
|
||||
{dashboard.granularity === 'day'
|
||||
? 'Daily'
|
||||
: dashboard.granularity === 'week'
|
||||
? 'Weekly'
|
||||
: 'Monthly'}
|
||||
</Text>
|
||||
</View>
|
||||
{selected ? (
|
||||
<View style={styles.chartDetail}>
|
||||
<Text variant="label">{formatBucketDate(selected.startAt, selected.endAt)}</Text>
|
||||
<Text variant="caption" color={colors.textSecondary}>
|
||||
{formatListeningTime(selected.listenedSeconds)} · {selected.qualifiedPlays}{' '}
|
||||
{selected.qualifiedPlays === 1 ? 'play' : 'plays'}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
horizontal
|
||||
scrollEnabled={!fillsCard}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onContentSizeChange={() => {
|
||||
if (!fillsCard) scrollRef.current?.scrollToEnd({ animated: false });
|
||||
}}
|
||||
contentContainerStyle={[styles.chart, fillsCard && styles.chartFill]}
|
||||
>
|
||||
{bars}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
type RankedItem = RankedListeningTrack | RankedListeningArtist | RankedListeningAlbum;
|
||||
|
||||
function rankingCopy(item: RankedItem, category: ListeningStatsCategory) {
|
||||
if (category === 'tracks') {
|
||||
const track = item as RankedListeningTrack;
|
||||
return { title: track.title, subtitle: track.artist, icon: 'musical-note' as const };
|
||||
}
|
||||
if (category === 'artists') {
|
||||
return {
|
||||
title: (item as RankedListeningArtist).artist,
|
||||
subtitle: 'Artist',
|
||||
icon: 'person' as const,
|
||||
};
|
||||
}
|
||||
const album = item as RankedListeningAlbum;
|
||||
return { title: album.album, subtitle: album.artist, icon: 'disc' as const };
|
||||
}
|
||||
|
||||
function RankingRow({
|
||||
item,
|
||||
index,
|
||||
category,
|
||||
selectedMetric,
|
||||
onPress,
|
||||
}: {
|
||||
item: RankedItem;
|
||||
index: number;
|
||||
category: ListeningStatsCategory;
|
||||
selectedMetric: 'plays' | 'time';
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
const ripple = useRipple();
|
||||
const copy = rankingCopy(item, category);
|
||||
const art = listeningArtworkSource(item, true);
|
||||
return (
|
||||
<Pressable
|
||||
style={[styles.rankingRow, !item.available && styles.unavailable]}
|
||||
android_ripple={item.available ? ripple.bounded : undefined}
|
||||
disabled={!item.available}
|
||||
onPress={onPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ disabled: !item.available }}
|
||||
accessibilityLabel={`${index + 1}. ${copy.title}`}
|
||||
>
|
||||
<Text variant="label" style={styles.rankNumber}>{index + 1}</Text>
|
||||
<View style={styles.rankingArt}>
|
||||
{art ? (
|
||||
<Image source={{ uri: art }} style={styles.artImage} contentFit="cover" />
|
||||
) : (
|
||||
<Ionicons name={copy.icon} size={22} color={colors.textTertiary} />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.rankingMeta}>
|
||||
<Text variant="body" numberOfLines={1}>{copy.title}</Text>
|
||||
<Text variant="caption" color={colors.textSecondary} numberOfLines={1}>
|
||||
{item.available ? copy.subtitle : `${copy.subtitle} · Unavailable`}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.rankingMetrics}>
|
||||
<Text
|
||||
variant="label"
|
||||
color={selectedMetric === 'plays' ? colors.accentText : colors.textSecondary}
|
||||
>
|
||||
{item.qualifiedPlays} {item.qualifiedPlays === 1 ? 'play' : 'plays'}
|
||||
</Text>
|
||||
<Text
|
||||
variant="caption"
|
||||
color={selectedMetric === 'time' ? colors.accentText : colors.textTertiary}
|
||||
>
|
||||
{formatListeningTime(item.listenedSeconds, true)}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
icon,
|
||||
title,
|
||||
body,
|
||||
action,
|
||||
onAction,
|
||||
}: {
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
title: string;
|
||||
body: string;
|
||||
action?: string;
|
||||
onAction?: () => void;
|
||||
}) {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
const ripple = useRipple();
|
||||
return (
|
||||
<View style={styles.empty}>
|
||||
<Ionicons name={icon} size={34} color={colors.textTertiary} />
|
||||
<Text variant="heading">{title}</Text>
|
||||
<Text variant="body" color={colors.textSecondary} style={styles.emptyBody}>{body}</Text>
|
||||
{action && onAction ? (
|
||||
<Pressable style={styles.primaryButton} android_ripple={ripple.onAccent()} onPress={onAction}>
|
||||
<Text variant="body" style={styles.primaryButtonText}>{action}</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ListeningStatsScreen() {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
const ripple = useRipple();
|
||||
const router = useRouter();
|
||||
const openLibrary = useHomeLibraryNavigation();
|
||||
const { width } = useWindowDimensions();
|
||||
const range = useListeningStatsStore((s) => s.range);
|
||||
const metric = useListeningStatsStore((s) => s.rankingMetric);
|
||||
const category = useListeningStatsStore((s) => s.category);
|
||||
const dashboard = useListeningStatsStore((s) => s.dashboard);
|
||||
const loading = useListeningStatsStore((s) => s.loading);
|
||||
const refreshing = useListeningStatsStore((s) => s.refreshing);
|
||||
const error = useListeningStatsStore((s) => s.error);
|
||||
const setRange = useListeningStatsStore((s) => s.setRange);
|
||||
const setMetric = useListeningStatsStore((s) => s.setRankingMetric);
|
||||
const setCategory = useListeningStatsStore((s) => s.setCategory);
|
||||
const load = useListeningStatsStore((s) => s.loadDashboard);
|
||||
const [shareSnapshot, setShareSnapshot] = useState<ListeningStatsDashboard | null>(null);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
void load();
|
||||
const interval = setInterval(() => void load(), 15_000);
|
||||
const unsubscribe = subscribeToListeningHistory(() => void load());
|
||||
const subscription = AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') void load();
|
||||
});
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
unsubscribe();
|
||||
subscription.remove();
|
||||
};
|
||||
}, [load]),
|
||||
);
|
||||
|
||||
const rankings = useMemo<RankedItem[]>(() => {
|
||||
if (!dashboard) return [];
|
||||
if (category === 'artists') return dashboard.topArtists;
|
||||
if (category === 'albums') return dashboard.topAlbums;
|
||||
return dashboard.topTracks;
|
||||
}, [category, dashboard]);
|
||||
|
||||
const openRanking = (item: RankedItem) => {
|
||||
if (!dashboard || !item.available) return;
|
||||
if (category === 'tracks') {
|
||||
const paths = dashboard.topTracks.flatMap((track) =>
|
||||
track.available && track.trackPath ? [track.trackPath] : []
|
||||
);
|
||||
const track = item as RankedListeningTrack;
|
||||
if (!track.trackPath || paths.length === 0) return;
|
||||
void playLibraryQuery(
|
||||
{ kind: 'manual', paths },
|
||||
{
|
||||
anchorPath: track.trackPath,
|
||||
source: { kind: 'listening-stats', label: 'Listening Stats' },
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (category === 'artists') {
|
||||
openLibrary({ kind: 'artist', name: (item as RankedListeningArtist).artist });
|
||||
} else {
|
||||
openLibrary({ kind: 'album', key: item.key });
|
||||
}
|
||||
};
|
||||
|
||||
const noActivity = dashboard
|
||||
? dashboard.summary.listenedSeconds <= 0 && dashboard.summary.qualifiedPlays <= 0
|
||||
: false;
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<View style={styles.header}>
|
||||
<Pressable
|
||||
style={styles.iconButton}
|
||||
android_ripple={ripple.icon(22)}
|
||||
onPress={() => router.back()}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Back to Home"
|
||||
>
|
||||
<Ionicons name="chevron-back" size={23} color={colors.textPrimary} />
|
||||
</Pressable>
|
||||
<View style={styles.headerCopy}>
|
||||
<Text variant="title">Listening Stats</Text>
|
||||
<Text variant="caption" color={colors.textSecondary} numberOfLines={1}>
|
||||
{formatRecordedSince(dashboard?.status.startedAt ?? null)}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
style={[styles.iconButton, (!dashboard || noActivity) && styles.unavailable]}
|
||||
android_ripple={dashboard && !noActivity ? ripple.icon(22) : undefined}
|
||||
disabled={!dashboard || noActivity}
|
||||
onPress={() => setShareSnapshot(dashboard)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Share Listening Stats"
|
||||
>
|
||||
<Ionicons name="share-outline" size={21} color={colors.textPrimary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.content}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={() => void load()}
|
||||
tintColor={colors.accent}
|
||||
colors={[colors.accent]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SegmentedControl
|
||||
segments={RANGE_SEGMENTS}
|
||||
value={range}
|
||||
onChange={(value) => setRange(value as typeof range)}
|
||||
/>
|
||||
|
||||
{loading && !dashboard ? (
|
||||
<EmptyState
|
||||
icon="stats-chart"
|
||||
title="Loading Listening Stats"
|
||||
body="Reading the detailed history recorded on this phone…"
|
||||
/>
|
||||
) : error && !dashboard ? (
|
||||
<EmptyState
|
||||
icon="cloud-offline-outline"
|
||||
title="Stats could not load"
|
||||
body={error}
|
||||
action="Try again"
|
||||
onAction={() => void load()}
|
||||
/>
|
||||
) : !dashboard?.status.startedAt ? (
|
||||
<EmptyState
|
||||
icon={dashboard?.status.enabled === false ? 'pause-circle-outline' : 'headset-outline'}
|
||||
title={dashboard?.status.enabled === false ? 'Listening History is paused' : 'Your stats start here'}
|
||||
body={
|
||||
dashboard?.status.enabled === false
|
||||
? 'Resume Listening History in Playback settings. Play counts and other library data are unchanged.'
|
||||
: 'Play music on this phone to begin detailed listening time, activity, and rankings. Existing play counts are not backfilled.'
|
||||
}
|
||||
action={dashboard?.status.enabled === false ? 'Playback settings' : undefined}
|
||||
onAction={() => router.push('/settings/playback' as never)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{!dashboard.status.enabled ? (
|
||||
<View style={styles.pausedBanner}>
|
||||
<Ionicons name="pause-circle-outline" size={20} color={colors.warning} />
|
||||
<View style={styles.bannerCopy}>
|
||||
<Text variant="label" color={colors.warning}>History paused</Text>
|
||||
<Text variant="caption" color={colors.textSecondary}>
|
||||
Existing history is shown; future listening is not being recorded.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<SummaryGrid dashboard={dashboard} wide={width >= 720} />
|
||||
|
||||
{noActivity ? (
|
||||
<EmptyState
|
||||
icon="calendar-outline"
|
||||
title="No activity in this range"
|
||||
body="Choose another range or keep listening to fill this view."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<ActivityChart dashboard={dashboard} />
|
||||
|
||||
<View style={styles.rankingsSection}>
|
||||
<View style={styles.rankingsHeader}>
|
||||
<Text variant="heading">Rankings</Text>
|
||||
{error ? (
|
||||
<Pressable onPress={() => void load()} accessibilityRole="button">
|
||||
<Text variant="caption" color={colors.warning}>Refresh failed · Retry</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
<SegmentedControl
|
||||
segments={METRIC_SEGMENTS}
|
||||
value={metric}
|
||||
onChange={(value) => setMetric(value as typeof metric)}
|
||||
/>
|
||||
<SegmentedControl
|
||||
segments={CATEGORY_SEGMENTS}
|
||||
value={category}
|
||||
onChange={(value) => setCategory(value as ListeningStatsCategory)}
|
||||
/>
|
||||
<View style={styles.rankingList}>
|
||||
{rankings.map((item, index) => (
|
||||
<RankingRow
|
||||
key={item.key}
|
||||
item={item}
|
||||
index={index}
|
||||
category={category}
|
||||
selectedMetric={metric}
|
||||
onPress={() => openRanking(item)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{shareSnapshot ? (
|
||||
<ListeningStatsShareSheet
|
||||
snapshot={shareSnapshot}
|
||||
onClose={() => setShareSnapshot(null)}
|
||||
/>
|
||||
) : null}
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = createThemedStyles((colors) => ({
|
||||
header: {
|
||||
minHeight: 72,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
headerCopy: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: 2,
|
||||
},
|
||||
iconButton: {
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 21,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
content: {
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.xxl,
|
||||
gap: spacing.xl,
|
||||
},
|
||||
pausedBanner: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.warning,
|
||||
backgroundColor: colors.glassBg,
|
||||
},
|
||||
bannerCopy: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
summaryGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: spacing.md,
|
||||
},
|
||||
summaryGridWide: {
|
||||
flexWrap: 'nowrap',
|
||||
},
|
||||
summaryTile: {
|
||||
width: '47%',
|
||||
flexGrow: 1,
|
||||
padding: spacing.lg,
|
||||
gap: spacing.xs,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
backgroundColor: colors.glassBg,
|
||||
},
|
||||
summaryTileWide: {
|
||||
width: undefined,
|
||||
flex: 1,
|
||||
},
|
||||
summaryValue: {
|
||||
fontSize: 24,
|
||||
lineHeight: 29,
|
||||
},
|
||||
card: {
|
||||
padding: spacing.lg,
|
||||
gap: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
backgroundColor: colors.glassBg,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
chartDetail: {
|
||||
gap: 2,
|
||||
},
|
||||
chart: {
|
||||
minHeight: 156,
|
||||
alignItems: 'flex-end',
|
||||
gap: spacing.xs,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
chartFill: {
|
||||
width: '100%',
|
||||
},
|
||||
barSlot: {
|
||||
width: 36,
|
||||
height: 150,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
barSlotFill: {
|
||||
width: undefined,
|
||||
flex: 1,
|
||||
},
|
||||
bar: {
|
||||
width: 18,
|
||||
minHeight: 3,
|
||||
borderRadius: 4,
|
||||
},
|
||||
barLabelSpacer: {
|
||||
height: 14,
|
||||
},
|
||||
rankingsSection: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
rankingsHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
rankingList: {
|
||||
borderRadius: radius.md,
|
||||
overflow: 'hidden',
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
backgroundColor: colors.glassBg,
|
||||
},
|
||||
rankingRow: {
|
||||
minHeight: 68,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.glassBorder,
|
||||
},
|
||||
rankNumber: {
|
||||
width: 22,
|
||||
textAlign: 'center',
|
||||
fontFamily: fonts.mono.medium,
|
||||
},
|
||||
rankingArt: {
|
||||
width: 46,
|
||||
height: 46,
|
||||
borderRadius: radius.sm,
|
||||
overflow: 'hidden',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bgTertiary,
|
||||
},
|
||||
artImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
rankingMeta: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: 2,
|
||||
},
|
||||
rankingMetrics: {
|
||||
alignItems: 'flex-end',
|
||||
gap: 2,
|
||||
},
|
||||
empty: {
|
||||
minHeight: 220,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
padding: spacing.xl,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
backgroundColor: colors.glassBg,
|
||||
},
|
||||
emptyBody: {
|
||||
maxWidth: 440,
|
||||
textAlign: 'center',
|
||||
lineHeight: 21,
|
||||
},
|
||||
primaryButton: {
|
||||
marginTop: spacing.sm,
|
||||
minHeight: 40,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
borderRadius: radius.pill,
|
||||
backgroundColor: colors.accent,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: colors.bgPrimary,
|
||||
fontFamily: fonts.sans.semibold,
|
||||
},
|
||||
unavailable: {
|
||||
opacity: 0.48,
|
||||
},
|
||||
}));
|
||||
@@ -1,17 +1,125 @@
|
||||
import { SleepTimerControls } from '@/components/player/SleepTimerControls';
|
||||
import { Pressable, StyleSheet, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { AstraLibraryData } from '../../../modules/astra-library-scanner';
|
||||
import {
|
||||
SettingsCard,
|
||||
SettingsSectionLabel,
|
||||
SettingsSectionScreen,
|
||||
SettingsToggleRow,
|
||||
} from '@/components/settings/SettingsSectionScaffold';
|
||||
import { Text } from '@/components/Text';
|
||||
import { showAppDialog } from '@/components/dialogs/AppDialog';
|
||||
import {
|
||||
pauseListeningHistoryTracking,
|
||||
resumeListeningHistoryTracking,
|
||||
} from '@/audio/listeningHistoryTracker';
|
||||
import { useSettingsStore } from '@/stores/settingsStore';
|
||||
import { radius, spacing } from '@/theme';
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import { useRipple } from '@/theme/ripple';
|
||||
import { notifyListeningHistoryChanged } from '@/listeningStats/events';
|
||||
|
||||
export default function PlaybackSettingsScreen() {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
const ripple = useRipple();
|
||||
const historyEnabled = useSettingsStore((s) => s.listeningHistoryEnabled);
|
||||
const setHistoryEnabled = useSettingsStore((s) => s.setListeningHistoryEnabled);
|
||||
|
||||
const confirmClear = () => {
|
||||
showAppDialog({
|
||||
title: 'Clear detailed listening history?',
|
||||
message:
|
||||
'Listening time, activity, and rankings recorded on this phone will be removed. Play counts, recents, favorites, playlists, and last-played dates are preserved.',
|
||||
actions: [
|
||||
{ label: 'Cancel', role: 'cancel' },
|
||||
{
|
||||
label: 'Clear history',
|
||||
role: 'destructive',
|
||||
onPress: () => {
|
||||
void (async () => {
|
||||
await pauseListeningHistoryTracking();
|
||||
try {
|
||||
await AstraLibraryData.clearDetailedListeningHistory();
|
||||
notifyListeningHistoryChanged();
|
||||
} finally {
|
||||
if (useSettingsStore.getState().listeningHistoryEnabled) {
|
||||
resumeListeningHistoryTracking();
|
||||
}
|
||||
}
|
||||
})().catch((error) => {
|
||||
showAppDialog({
|
||||
title: 'Could not clear history',
|
||||
message: error instanceof Error ? error.message : 'Please try again.',
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSectionScreen title="Playback">
|
||||
<SettingsSectionLabel>SLEEP TIMER</SettingsSectionLabel>
|
||||
<SettingsCard>
|
||||
<SleepTimerControls />
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsSectionLabel spaced>LISTENING HISTORY</SettingsSectionLabel>
|
||||
<SettingsCard>
|
||||
<SettingsToggleRow
|
||||
title="Listening History"
|
||||
description="Record detailed listening time and qualified plays on this phone."
|
||||
value={historyEnabled}
|
||||
onValueChange={(enabled) => {
|
||||
void setHistoryEnabled(enabled).catch((error) => {
|
||||
showAppDialog({
|
||||
title: 'Could not update Listening History',
|
||||
message: error instanceof Error ? error.message : 'Please try again.',
|
||||
});
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<View style={styles.divider} />
|
||||
<Pressable
|
||||
android_ripple={ripple.bounded}
|
||||
style={styles.clearRow}
|
||||
onPress={confirmClear}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Ionicons name="trash-outline" size={20} color={colors.warning} />
|
||||
<View style={styles.clearMeta}>
|
||||
<Text variant="body" color={colors.warning}>
|
||||
Clear Detailed Listening History
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.textSecondary}>
|
||||
Keeps play counts, recents, favorites, and playlists.
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
</SettingsCard>
|
||||
</SettingsSectionScreen>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = createThemedStyles((colors) => ({
|
||||
divider: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.glassBorder,
|
||||
marginVertical: spacing.lg,
|
||||
},
|
||||
clearRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.sm,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
clearMeta: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user