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
@@ -0,0 +1,158 @@
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { Pressable, StyleSheet, View } from 'react-native';
import { AstraLogo } from '@/components/AstraLogo';
import { Text } from '@/components/Text';
import { listeningArtworkSource } from '@/library/artwork';
import { formatListeningTime } from '@/listeningStats/format';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import type { ListeningStatsDashboard } from '@/types/listeningStats';
export function ListeningPreviewCard({
dashboard,
onPress,
}: {
dashboard: ListeningStatsDashboard | null;
onPress: () => void;
}) {
const styles = useStyles();
const colors = useColors();
const ripple = useRipple();
if (!dashboard?.status.startedAt) return null;
const topTrack = dashboard.topTracks[0] ?? null;
const artwork = topTrack ? listeningArtworkSource(topTrack, true) : null;
const paused = !dashboard.status.enabled;
return (
<Pressable
style={styles.card}
android_ripple={ripple.tile}
onPress={onPress}
accessibilityRole="button"
accessibilityLabel="Open Listening Stats"
>
<View style={styles.header}>
<View style={styles.titleRow}>
<Ionicons
name={paused ? 'pause-circle-outline' : 'stats-chart'}
size={18}
color={paused ? colors.warning : colors.accent}
/>
<Text variant="heading">Your Listening</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</View>
{paused ? (
<View style={styles.paused}>
<Text variant="label" color={colors.warning}>History paused</Text>
<Text variant="caption" color={colors.textSecondary}>
Existing stats are still available. Resume recording in Playback settings.
</Text>
</View>
) : null}
<View style={styles.metrics}>
<View style={styles.metric}>
<Text variant="title" style={styles.metricValue} numberOfLines={1}>
{formatListeningTime(dashboard.summary.listenedSeconds, true)}
</Text>
<Text variant="caption" color={colors.textSecondary}>Last 7 days</Text>
</View>
<View style={styles.metric}>
<Text variant="title" style={styles.metricValue} numberOfLines={1}>
{dashboard.summary.qualifiedPlays}
</Text>
<Text variant="caption" color={colors.textSecondary}>Qualified plays</Text>
</View>
</View>
{topTrack ? (
<View style={styles.topTrack}>
<View style={styles.art}>
{artwork ? (
<Image source={{ uri: artwork }} style={styles.image} contentFit="cover" />
) : (
<AstraLogo size={24} />
)}
</View>
<View style={styles.trackMeta}>
<Text variant="label" color={colors.textTertiary}>TOP TRACK</Text>
<Text variant="body" numberOfLines={1}>{topTrack.title}</Text>
<Text variant="caption" color={colors.textSecondary} numberOfLines={1}>
{topTrack.artist} · {topTrack.qualifiedPlays} {topTrack.qualifiedPlays === 1 ? 'play' : 'plays'}
</Text>
</View>
</View>
) : (
<Text variant="caption" color={colors.textSecondary}>
No qualified plays in the last 7 days.
</Text>
)}
</Pressable>
);
}
const useStyles = createThemedStyles((colors) => ({
card: {
marginTop: spacing.xl,
padding: spacing.lg,
gap: spacing.md,
borderRadius: radius.md,
backgroundColor: colors.glassBg,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
overflow: 'hidden',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
paused: {
gap: 2,
},
metrics: {
flexDirection: 'row',
gap: spacing.md,
},
metric: {
flex: 1,
minWidth: 0,
},
metricValue: {
fontSize: 22,
lineHeight: 27,
},
topTrack: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
},
art: {
width: 52,
height: 52,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
borderRadius: radius.sm,
backgroundColor: colors.bgTertiary,
},
image: {
width: '100%',
height: '100%',
},
trackMeta: {
flex: 1,
minWidth: 0,
gap: 1,
},
}));
@@ -0,0 +1,268 @@
import { useEffect, useMemo, useState } from 'react';
import { Image, Pressable, StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { cacheDirectory, EncodingType, writeAsStringAsync } from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing';
import { AppSheet, AppSheetTitle } from '@/components/sheets/AppSheet';
import { Text } from '@/components/Text';
import { listeningArtworkSource } from '@/library/artwork';
import {
buildListeningStatsShareModel,
type ListeningStatsShareLens,
} from '@/listeningStats/shareModel';
import { renderListeningStatsSharePng } from '@/listeningStats/shareRenderer';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import type { ListeningStatsDashboard } from '@/types/listeningStats';
const LENSES: { key: ListeningStatsShareLens; label: string }[] = [
{ key: 'overview', label: 'Overview' },
{ key: 'track', label: 'Top Track' },
{ key: 'album', label: 'Top Album' },
];
export function ListeningStatsShareSheet({
snapshot,
onClose,
}: {
snapshot: ListeningStatsDashboard;
onClose: () => void;
}) {
const styles = useStyles();
const colors = useColors();
const ripple = useRipple();
const [lens, setLens] = useState<ListeningStatsShareLens>('overview');
const [sharing, setSharing] = useState(false);
const [shareError, setShareError] = useState<string | null>(null);
const [rendered, setRendered] = useState<{
key: string;
base64: string | null;
error: string | null;
} | null>(null);
const model = useMemo(() => buildListeningStatsShareModel(snapshot, lens), [lens, snapshot]);
const renderKey = `${model.suggestedFileName}:${lens}:${colors.accent}`;
const currentRender = rendered?.key === renderKey ? rendered : null;
const base64 = currentRender?.base64 ?? null;
const rendering = currentRender == null;
const error = shareError ?? currentRender?.error ?? null;
const artworkUris = useMemo(() => {
const map = new Map<string, string>();
snapshot.topTracks.forEach((item, index) => {
const uri = listeningArtworkSource(item);
if (uri) map.set(`track:${index + 1}`, uri);
});
snapshot.topArtists.forEach((item, index) => {
const uri = listeningArtworkSource(item);
if (uri) map.set(`artist:${index + 1}`, uri);
});
snapshot.topAlbums.forEach((item, index) => {
const uri = listeningArtworkSource(item);
if (uri) map.set(`album:${index + 1}`, uri);
});
return map;
}, [snapshot]);
useEffect(() => {
let cancelled = false;
void renderListeningStatsSharePng(model, {
accentColor: colors.accent,
artworkUris,
}).then(
(result) => {
if (!cancelled) setRendered({ key: renderKey, base64: result, error: null });
},
(renderError) => {
if (!cancelled) {
setRendered({
key: renderKey,
base64: null,
error: renderError instanceof Error
? renderError.message
: 'The share card could not be rendered.',
});
}
},
);
return () => {
cancelled = true;
};
}, [artworkUris, colors.accent, model, renderKey]);
const share = async () => {
if (!base64 || !cacheDirectory) {
setShareError('The temporary share image could not be created.');
return;
}
setSharing(true);
setShareError(null);
try {
if (!(await Sharing.isAvailableAsync())) {
throw new Error('No compatible sharing service is available on this device.');
}
const fileUri = `${cacheDirectory}${model.suggestedFileName}`;
await writeAsStringAsync(fileUri, base64, { encoding: EncodingType.Base64 });
await Sharing.shareAsync(fileUri, {
mimeType: 'image/png',
dialogTitle: 'Share Listening Stats',
UTI: 'public.png',
});
} catch (shareError) {
setShareError(
shareError instanceof Error ? shareError.message : 'The share sheet could not be opened.',
);
} finally {
setSharing(false);
}
};
return (
<AppSheet onClose={onClose} scrollable>
<AppSheetTitle
title="Share Listening Stats"
subtitle="This snapshot stays frozen while you choose a card."
/>
<View style={styles.lenses}>
{LENSES.map((option) => {
const disabled =
(option.key === 'track' && snapshot.topTracks.length === 0) ||
(option.key === 'album' && snapshot.topAlbums.length === 0);
const selected = option.key === lens;
return (
<Pressable
key={option.key}
style={[
styles.lens,
selected && styles.lensSelected,
disabled && styles.disabled,
]}
android_ripple={!disabled ? ripple.bounded : undefined}
disabled={disabled}
onPress={() => setLens(option.key)}
accessibilityRole="radio"
accessibilityState={{ selected, disabled }}
>
<Text
variant="label"
color={selected ? colors.accentTextStrong : colors.textSecondary}
numberOfLines={1}
>
{option.label}
</Text>
</Pressable>
);
})}
</View>
<View style={styles.preview}>
{base64 ? (
<Image
source={{ uri: `data:image/png;base64,${base64}` }}
style={styles.previewImage}
resizeMode="contain"
/>
) : (
<View style={styles.previewLoading}>
<Ionicons
name={error ? 'warning-outline' : 'image-outline'}
size={34}
color={error ? colors.warning : colors.textTertiary}
/>
<Text variant="label" color={error ? colors.warning : colors.textSecondary}>
{error ?? (rendering ? 'Rendering 1474 × 1920 PNG…' : 'Preparing preview…')}
</Text>
</View>
)}
</View>
{error && base64 ? (
<Text variant="caption" color={colors.warning} style={styles.error}>
{error}
</Text>
) : null}
<Pressable
style={[styles.shareButton, (!base64 || sharing) && styles.disabled]}
android_ripple={base64 && !sharing ? ripple.onAccent() : undefined}
disabled={!base64 || sharing}
onPress={() => void share()}
accessibilityRole="button"
>
<Ionicons name="share-outline" size={19} color={colors.bgPrimary} />
<Text variant="body" style={styles.shareLabel}>
{sharing ? 'Opening share sheet…' : 'Share PNG'}
</Text>
</Pressable>
</AppSheet>
);
}
const useStyles = createThemedStyles((colors) => ({
lenses: {
flexDirection: 'row',
gap: spacing.xs,
marginVertical: spacing.md,
padding: 3,
borderRadius: radius.pill,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
lens: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.sm,
borderRadius: radius.pill,
overflow: 'hidden',
},
lensSelected: {
backgroundColor: colors.glassHighlight,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.accent,
},
preview: {
width: 246,
height: 320,
alignSelf: 'center',
marginVertical: spacing.sm,
overflow: 'hidden',
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.bgTertiary,
},
previewImage: {
width: '100%',
height: '100%',
},
previewLoading: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
padding: spacing.lg,
},
error: {
textAlign: 'center',
marginBottom: spacing.sm,
},
shareButton: {
minHeight: 46,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
marginTop: spacing.md,
borderRadius: radius.pill,
backgroundColor: colors.accent,
overflow: 'hidden',
},
shareLabel: {
color: colors.bgPrimary,
},
disabled: {
opacity: 0.45,
},
}));