add stats tracking + stats page + fix scrolling bug in dynamic playlists tray

This commit is contained in:
Boof2015
2026-07-28 19:25:30 -04:00
parent b28a018ca0
commit 00d0fc4b2f
40 changed files with 5280 additions and 126 deletions
+11 -2
View File
@@ -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>
);
}
+39 -9
View File
@@ -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}>
+7 -1
View File
@@ -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
+684
View File
@@ -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,
},
}));
+108
View File
@@ -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,
},
}));