updated stats page

This commit is contained in:
Boof2015
2026-07-31 02:21:12 -04:00
parent a42e78017c
commit fd5239daec
12 changed files with 716 additions and 1366 deletions
@@ -15,6 +15,10 @@ private const val SHORT_TRACK_COMPLETION_TOLERANCE_SECONDS = 0.5
private const val SHORT_TRACK_COMPLETION_TOLERANCE_RATIO = 0.1 private const val SHORT_TRACK_COMPLETION_TOLERANCE_RATIO = 0.1
private const val TOP_LIMIT = 10 private const val TOP_LIMIT = 10
private const val CATALOG_BATCH_SIZE = 400 private const val CATALOG_BATCH_SIZE = 400
/** Up to ~2 months of history, the "all" range still buckets by day. */
private const val ALL_RANGE_DAY_SPAN_MS = 60L * 24 * 60 * 60 * 1000
/** Up to ~2 years, by week; beyond that, by month. */
private const val ALL_RANGE_WEEK_SPAN_MS = 730L * 24 * 60 * 60 * 1000
private data class ListeningCheckpointResult( private data class ListeningCheckpointResult(
val accepted: Boolean, val accepted: Boolean,
@@ -379,9 +383,12 @@ internal object ListeningStatsEngine {
tracksPlayed += identity.trackKey tracksPlayed += identity.trackKey
listenedSeconds += overlap listenedSeconds += overlap
buckets.forEach { bucket -> buckets.forEach { bucket ->
// The first bucket is snapped back to a calendar boundary, so it can start
// before rangeStartAt; clamp it or the bars would total more than the
// summary's listening time.
bucket.listenedSeconds += overlapSeconds( bucket.listenedSeconds += overlapSeconds(
segment, segment,
bucket.startAt, max(bucket.startAt, rangeStartAt),
min(bucket.endAt, now + 1), min(bucket.endAt, now + 1),
) )
} }
@@ -693,9 +700,14 @@ private fun buildBuckets(
rangeStartAt: Long?, rangeStartAt: Long?,
now: Long, now: Long,
): Pair<String, List<ActivityBucket>> { ): Pair<String, List<ActivityBucket>> {
val granularity = when (range) { // "all" spans however much history exists, so its granularity follows the span:
"7d", "30d" -> "day" // a fixed month bucket gave a user with two weeks of history a single lonely bar.
"1y" -> "week" val granularity = when {
range == "7d" || range == "30d" -> "day"
range == "1y" -> "week"
rangeStartAt == null -> "month"
now - rangeStartAt <= ALL_RANGE_DAY_SPAN_MS -> "day"
now - rangeStartAt <= ALL_RANGE_WEEK_SPAN_MS -> "week"
else -> "month" else -> "month"
} }
if (rangeStartAt == null) return granularity to emptyList() if (rangeStartAt == null) return granularity to emptyList()
+1 -1
View File
@@ -79,7 +79,7 @@
"test:audio-startup": "node --experimental-strip-types --test src/audio/dspStartupCoordinator.test.mts src/audio/dspStartupGain.test.mts", "test:audio-startup": "node --experimental-strip-types --test src/audio/dspStartupCoordinator.test.mts src/audio/dspStartupGain.test.mts",
"test:seek-bar": "node --experimental-strip-types --test src/audio/playbackClock.test.mts src/audio/playbackNavigation.test.mts src/audio/playbackProgressProjection.test.mts src/components/waveformScrubDetents.test.mts", "test:seek-bar": "node --experimental-strip-types --test src/audio/playbackClock.test.mts src/audio/playbackNavigation.test.mts src/audio/playbackProgressProjection.test.mts src/components/waveformScrubDetents.test.mts",
"test:recent-play": "node --experimental-strip-types --test src/audio/recentPlayTracking.test.mts", "test:recent-play": "node --experimental-strip-types --test src/audio/recentPlayTracking.test.mts",
"test:listening-stats": "node --experimental-strip-types --test src/audio/listeningHistoryState.test.mts src/listeningStats/shareModel.test.mts", "test:listening-stats": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/listeningHistoryState.test.mts src/components/listening/activityChartMath.test.mts",
"test:lyrics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lyrics/parsing.test.mts src/lyrics/presentation.test.mts src/lyrics/displaySettings.test.mts src/lyrics/embedded.test.mts src/lyrics/resolver.test.mts", "test:lyrics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lyrics/parsing.test.mts src/lyrics/presentation.test.mts src/lyrics/displaySettings.test.mts src/lyrics/embedded.test.mts src/lyrics/resolver.test.mts",
"test:sleep": "node --experimental-strip-types --test src/audio/sleepTimerState.test.mts", "test:sleep": "node --experimental-strip-types --test src/audio/sleepTimerState.test.mts",
"test:troubleshooting": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/cacheInvalidation.test.mts", "test:troubleshooting": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/cacheInvalidation.test.mts",
+126 -190
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useMemo } from 'react';
import { import {
AppState, AppState,
Pressable, Pressable,
@@ -6,21 +6,17 @@ import {
ScrollView, ScrollView,
StyleSheet, StyleSheet,
View, View,
useWindowDimensions,
} from 'react-native'; } from 'react-native';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect, useRouter } from 'expo-router'; import { useFocusEffect, useRouter } from 'expo-router';
import { Screen } from '@/components/Screen'; import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text'; import { Text } from '@/components/Text';
import { SegmentedControl } from '@/components/SegmentedControl'; import { SegmentedControl } from '@/components/SegmentedControl';
import { ListeningStatsShareSheet } from '@/components/listening/ListeningStatsShareSheet'; import { ActivityChart } from '@/components/listening/ActivityChart';
import { listeningArtworkSource } from '@/library/artwork'; import { listeningArtworkSource } from '@/library/artwork';
import { import { formatListeningTime, formatRecordedSince } from '@/listeningStats/format';
formatBucketDate,
formatListeningTime,
formatRecordedSince,
} from '@/listeningStats/format';
import { useHomeLibraryNavigation } from '@/navigation/useHomeLibraryNavigation'; import { useHomeLibraryNavigation } from '@/navigation/useHomeLibraryNavigation';
import { useListeningStatsStore } from '@/stores/listeningStatsStore'; import { useListeningStatsStore } from '@/stores/listeningStatsStore';
import { playLibraryQuery } from '@/audio/playbackController'; import { playLibraryQuery } from '@/audio/playbackController';
@@ -30,6 +26,7 @@ import { useRipple } from '@/theme/ripple';
import type { import type {
ListeningStatsCategory, ListeningStatsCategory,
ListeningStatsDashboard, ListeningStatsDashboard,
ListeningStatsRankingMetric,
RankedListeningAlbum, RankedListeningAlbum,
RankedListeningArtist, RankedListeningArtist,
RankedListeningTrack, RankedListeningTrack,
@@ -52,110 +49,48 @@ const CATEGORY_SEGMENTS = [
{ key: 'albums', label: 'Albums' }, { key: 'albums', label: 'Albums' },
]; ];
function SummaryGrid({ /** Checkpoints land every ~10s of playback; collapse a burst into one query. */
dashboard, const HISTORY_REFRESH_DEBOUNCE_MS = 2_000;
wide, const BACKGROUND_REFRESH_MS = 15_000;
}: { /** Sections rise in sequence so the page assembles instead of snapping in. */
dashboard: ListeningStatsDashboard; const SECTION_STEP_MS = 60;
wide: boolean;
}) { function sectionEntering(index: number) {
return FadeInDown.delay(index * SECTION_STEP_MS).duration(260);
}
/** The one number that leads the page; everything else supports it. */
function HeroStat({ dashboard }: { dashboard: ListeningStatsDashboard }) {
const styles = useStyles(); const styles = useStyles();
const colors = useColors(); const colors = useColors();
const tiles = [ const plays = dashboard.summary.qualifiedPlays;
['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 ( return (
<View style={[styles.summaryGrid, wide && styles.summaryGridWide]}> <View style={styles.hero}>
{tiles.map(([label, value]) => ( <Text style={styles.heroValue} numberOfLines={1}>
<View key={label} style={[styles.summaryTile, wide && styles.summaryTileWide]}> {formatListeningTime(dashboard.summary.listenedSeconds, true)}
<Text variant="title" style={styles.summaryValue} numberOfLines={1}>{value}</Text> </Text>
<Text variant="caption" color={colors.textSecondary}>{label}</Text> <Text variant="body" color={colors.textSecondary}>
</View> listening time · {plays} qualified {plays === 1 ? 'play' : 'plays'}
))} </Text>
</View> </View>
); );
} }
function ActivityChart({ dashboard }: { dashboard: ListeningStatsDashboard }) { function SummaryPair({ dashboard }: { dashboard: ListeningStatsDashboard }) {
const styles = useStyles(); const styles = useStyles();
const colors = useColors(); const colors = useColors();
const scrollRef = useRef<ScrollView>(null); const tiles = [
const [selectedStartAt, setSelectedStartAt] = useState<number | null>(null); ['Tracks Played', String(dashboard.summary.tracksPlayed)],
const selectedIndex = dashboard.activity.findIndex( ['Active Days', String(dashboard.summary.activeDays)],
(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 ( return (
<View style={styles.card}> <View style={styles.summaryGrid}>
<View style={styles.cardHeader}> {tiles.map(([label, value]) => (
<Text variant="heading">Activity</Text> <View key={label} style={styles.summaryTile}>
<Text variant="caption" color={colors.textSecondary}> <Text variant="title" style={styles.summaryValue} numberOfLines={1}>{value}</Text>
{dashboard.granularity === 'day' <Text variant="caption" color={colors.textSecondary}>{label}</Text>
? '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> </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> </View>
); );
} }
@@ -178,17 +113,24 @@ function rankingCopy(item: RankedItem, category: ListeningStatsCategory) {
return { title: album.album, subtitle: album.artist, icon: 'disc' as const }; return { title: album.album, subtitle: album.artist, icon: 'disc' as const };
} }
function metricValue(item: RankedItem, metric: ListeningStatsRankingMetric): number {
return metric === 'plays' ? item.qualifiedPlays : item.listenedSeconds;
}
function RankingRow({ function RankingRow({
item, item,
index, index,
category, category,
selectedMetric, selectedMetric,
share,
onPress, onPress,
}: { }: {
item: RankedItem; item: RankedItem;
index: number; index: number;
category: ListeningStatsCategory; category: ListeningStatsCategory;
selectedMetric: 'plays' | 'time'; selectedMetric: ListeningStatsRankingMetric;
/** 01 against the top-ranked entry, for the inline proportion bar. */
share: number;
onPress: () => void; onPress: () => void;
}) { }) {
const styles = useStyles(); const styles = useStyles();
@@ -219,6 +161,16 @@ function RankingRow({
<Text variant="caption" color={colors.textSecondary} numberOfLines={1}> <Text variant="caption" color={colors.textSecondary} numberOfLines={1}>
{item.available ? copy.subtitle : `${copy.subtitle} · Unavailable`} {item.available ? copy.subtitle : `${copy.subtitle} · Unavailable`}
</Text> </Text>
{/* Turns the list into a visible ranking rather than a column of numbers.
Safe as a percentage: the row has a definite width. */}
<View style={styles.shareTrack}>
<View
style={[
styles.shareFill,
{ width: `${Math.round(share * 100)}%`, backgroundColor: colors.accent },
]}
/>
</View>
</View> </View>
<View style={styles.rankingMetrics}> <View style={styles.rankingMetrics}>
<Text <Text
@@ -274,7 +226,6 @@ export default function ListeningStatsScreen() {
const ripple = useRipple(); const ripple = useRipple();
const router = useRouter(); const router = useRouter();
const openLibrary = useHomeLibraryNavigation(); const openLibrary = useHomeLibraryNavigation();
const { width } = useWindowDimensions();
const range = useListeningStatsStore((s) => s.range); const range = useListeningStatsStore((s) => s.range);
const metric = useListeningStatsStore((s) => s.rankingMetric); const metric = useListeningStatsStore((s) => s.rankingMetric);
const category = useListeningStatsStore((s) => s.category); const category = useListeningStatsStore((s) => s.category);
@@ -286,18 +237,25 @@ export default function ListeningStatsScreen() {
const setMetric = useListeningStatsStore((s) => s.setRankingMetric); const setMetric = useListeningStatsStore((s) => s.setRankingMetric);
const setCategory = useListeningStatsStore((s) => s.setCategory); const setCategory = useListeningStatsStore((s) => s.setCategory);
const load = useListeningStatsStore((s) => s.loadDashboard); const load = useListeningStatsStore((s) => s.loadDashboard);
const [shareSnapshot, setShareSnapshot] = useState<ListeningStatsDashboard | null>(null);
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
void load(); // Background reloads stay silent so the pull-to-refresh spinner only ever
const interval = setInterval(() => void load(), 15_000); // appears for an actual pull.
const unsubscribe = subscribeToListeningHistory(() => void load()); const refresh = () => void load({ silent: true });
refresh();
const interval = setInterval(refresh, BACKGROUND_REFRESH_MS);
let debounce: ReturnType<typeof setTimeout> | null = null;
const unsubscribe = subscribeToListeningHistory(() => {
if (debounce) clearTimeout(debounce);
debounce = setTimeout(refresh, HISTORY_REFRESH_DEBOUNCE_MS);
});
const subscription = AppState.addEventListener('change', (state) => { const subscription = AppState.addEventListener('change', (state) => {
if (state === 'active') void load(); if (state === 'active') refresh();
}); });
return () => { return () => {
clearInterval(interval); clearInterval(interval);
if (debounce) clearTimeout(debounce);
unsubscribe(); unsubscribe();
subscription.remove(); subscription.remove();
}; };
@@ -311,6 +269,9 @@ export default function ListeningStatsScreen() {
return dashboard.topTracks; return dashboard.topTracks;
}, [category, dashboard]); }, [category, dashboard]);
// Rankings arrive sorted by the active metric, so the leader sets the scale.
const rankingPeak = rankings.length > 0 ? metricValue(rankings[0], metric) : 0;
const openRanking = (item: RankedItem) => { const openRanking = (item: RankedItem) => {
if (!dashboard || !item.available) return; if (!dashboard || !item.available) return;
if (category === 'tracks') { if (category === 'tracks') {
@@ -352,21 +313,11 @@ export default function ListeningStatsScreen() {
<Ionicons name="chevron-back" size={23} color={colors.textPrimary} /> <Ionicons name="chevron-back" size={23} color={colors.textPrimary} />
</Pressable> </Pressable>
<View style={styles.headerCopy}> <View style={styles.headerCopy}>
<Text variant="title">Listening Stats</Text> <Text variant="title" style={styles.headerTitle}>Listening Stats</Text>
<Text variant="caption" color={colors.textSecondary} numberOfLines={1}> <Text variant="caption" color={colors.textSecondary} numberOfLines={1}>
{formatRecordedSince(dashboard?.status.startedAt ?? null)} {formatRecordedSince(dashboard?.status.startedAt ?? null)}
</Text> </Text>
</View> </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> </View>
<ScrollView <ScrollView
@@ -427,7 +378,16 @@ export default function ListeningStatsScreen() {
</View> </View>
) : null} ) : null}
<SummaryGrid dashboard={dashboard} wide={width >= 720} /> <Animated.View entering={sectionEntering(0)}>
<HeroStat dashboard={dashboard} />
</Animated.View>
{/* Page-level: drives the chart's bars as well as the rankings. */}
<SegmentedControl
segments={METRIC_SEGMENTS}
value={metric}
onChange={(value) => setMetric(value as typeof metric)}
/>
{noActivity ? ( {noActivity ? (
<EmptyState <EmptyState
@@ -437,9 +397,23 @@ export default function ListeningStatsScreen() {
/> />
) : ( ) : (
<> <>
<ActivityChart dashboard={dashboard} /> {/* Keyed so a range or metric change remounts the chart: the reveal
restarts and the bucket selection resets. Same-range refreshes
reuse the mount, so bars glide instead of re-sweeping. */}
<Animated.View entering={sectionEntering(1)}>
<ActivityChart
key={`${dashboard.range}-${metric}`}
buckets={dashboard.activity}
granularity={dashboard.granularity}
metric={metric}
/>
</Animated.View>
<View style={styles.rankingsSection}> <Animated.View entering={sectionEntering(2)}>
<SummaryPair dashboard={dashboard} />
</Animated.View>
<Animated.View entering={sectionEntering(3)} style={styles.rankingsSection}>
<View style={styles.rankingsHeader}> <View style={styles.rankingsHeader}>
<Text variant="heading">Rankings</Text> <Text variant="heading">Rankings</Text>
{error ? ( {error ? (
@@ -448,11 +422,6 @@ export default function ListeningStatsScreen() {
</Pressable> </Pressable>
) : null} ) : null}
</View> </View>
<SegmentedControl
segments={METRIC_SEGMENTS}
value={metric}
onChange={(value) => setMetric(value as typeof metric)}
/>
<SegmentedControl <SegmentedControl
segments={CATEGORY_SEGMENTS} segments={CATEGORY_SEGMENTS}
value={category} value={category}
@@ -466,23 +435,17 @@ export default function ListeningStatsScreen() {
index={index} index={index}
category={category} category={category}
selectedMetric={metric} selectedMetric={metric}
share={rankingPeak > 0 ? metricValue(item, metric) / rankingPeak : 0}
onPress={() => openRanking(item)} onPress={() => openRanking(item)}
/> />
))} ))}
</View> </View>
</View> </Animated.View>
</> </>
)} )}
</> </>
)} )}
</ScrollView> </ScrollView>
{shareSnapshot ? (
<ListeningStatsShareSheet
snapshot={shareSnapshot}
onClose={() => setShareSnapshot(null)}
/>
) : null}
</Screen> </Screen>
); );
} }
@@ -500,6 +463,10 @@ const useStyles = createThemedStyles((colors) => ({
minWidth: 0, minWidth: 0,
gap: 2, gap: 2,
}, },
headerTitle: {
fontSize: 22,
lineHeight: 27,
},
iconButton: { iconButton: {
width: 42, width: 42,
height: 42, height: 42,
@@ -511,7 +478,7 @@ const useStyles = createThemedStyles((colors) => ({
content: { content: {
paddingTop: spacing.md, paddingTop: spacing.md,
paddingBottom: spacing.xxl, paddingBottom: spacing.xxl,
gap: spacing.xl, gap: spacing.lg,
}, },
pausedBanner: { pausedBanner: {
flexDirection: 'row', flexDirection: 'row',
@@ -526,17 +493,23 @@ const useStyles = createThemedStyles((colors) => ({
flex: 1, flex: 1,
gap: 2, gap: 2,
}, },
hero: {
gap: spacing.xs,
paddingTop: spacing.xs,
},
heroValue: {
fontSize: 44,
lineHeight: 50,
color: colors.textPrimary,
fontFamily: fonts.sans.bold,
},
summaryGrid: { summaryGrid: {
flexDirection: 'row', flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.md, gap: spacing.md,
}, },
summaryGridWide: {
flexWrap: 'nowrap',
},
summaryTile: { summaryTile: {
width: '47%', flex: 1,
flexGrow: 1, minWidth: 0,
padding: spacing.lg, padding: spacing.lg,
gap: spacing.xs, gap: spacing.xs,
borderRadius: radius.md, borderRadius: radius.md,
@@ -544,58 +517,10 @@ const useStyles = createThemedStyles((colors) => ({
borderColor: colors.glassBorder, borderColor: colors.glassBorder,
backgroundColor: colors.glassBg, backgroundColor: colors.glassBg,
}, },
summaryTileWide: {
width: undefined,
flex: 1,
},
summaryValue: { summaryValue: {
fontSize: 24, fontSize: 24,
lineHeight: 29, 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: { rankingsSection: {
gap: spacing.md, gap: spacing.md,
}, },
@@ -645,6 +570,17 @@ const useStyles = createThemedStyles((colors) => ({
minWidth: 0, minWidth: 0,
gap: 2, gap: 2,
}, },
shareTrack: {
marginTop: 3,
height: 3,
borderRadius: 2,
overflow: 'hidden',
backgroundColor: colors.bgTertiary,
},
shareFill: {
height: '100%',
borderRadius: 2,
},
rankingMetrics: { rankingMetrics: {
alignItems: 'flex-end', alignItems: 'flex-end',
gap: 2, gap: 2,
+314
View File
@@ -0,0 +1,314 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
StyleSheet,
View,
type GestureResponderEvent,
type LayoutChangeEvent,
} from 'react-native';
import {
Canvas,
Group,
LinearGradient,
Path,
Rect,
Skia,
vec,
type SkPath,
} from '@shopify/react-native-skia';
import {
Easing,
ReduceMotion,
useDerivedValue,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { Text } from '@/components/Text';
import { formatBucketDate, formatListeningTime } from '@/listeningStats/format';
import { playHaptic } from '@/lib/haptics';
import { radius, spacing } from '@/theme';
import { motion } from '@/theme/motion';
import { createThemedStyles, useColors } from '@/theme/themed';
import type {
ListeningStatsActivityBucket,
ListeningStatsGranularity,
ListeningStatsRankingMetric,
} from '@/types/listeningStats';
import {
CHART_PLOT_HEIGHT,
animatedBarHeight,
barHeights,
barSlots,
nearestBarIndex,
type BarSlot,
} from './activityChartMath';
/** Reveal sweep on first paint and on range/metric change. */
const WIPE = {
duration: 520,
easing: Easing.out(Easing.cubic),
reduceMotion: ReduceMotion.System,
} as const;
const GRANULARITY_LABEL: Record<ListeningStatsGranularity, string> = {
day: 'Daily',
week: 'Weekly',
month: 'Monthly',
};
interface ActivityChartProps {
buckets: readonly ListeningStatsActivityBucket[];
granularity: ListeningStatsGranularity;
metric: ListeningStatsRankingMetric;
}
/**
* Listening activity over the selected range, drawn as one Skia canvas measured
* by `onLayout` — the same idiom as EQGraph. Bars are positioned in absolute
* pixels across the measured width, so every range fills the card and nothing
* scrolls. Bar heights animate on the UI thread, so the reveal and subsequent
* value changes cost no React renders.
*
* The caller remounts this on range/metric change (via `key`), which restarts
* the reveal and drops the selection. Refreshes that keep the same range reuse
* the mount, so bars glide to their new heights instead of re-sweeping.
*/
export function ActivityChart({ buckets, granularity, metric }: ActivityChartProps) {
const styles = useStyles();
const colors = useColors();
const [width, setWidth] = useState(0);
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const count = buckets.length;
const values = useMemo(
() =>
buckets.map((bucket) =>
metric === 'plays' ? bucket.qualifiedPlays : bucket.listenedSeconds,
),
[buckets, metric],
);
const slots = useMemo(() => barSlots(count, width), [count, width]);
const heights = useMemo(() => barHeights(values, CHART_PLOT_HEIGHT), [values]);
// `wipe` drives the left-to-right reveal; `morph` cross-fades bar heights when
// a background refresh lands, so new listening slides in without re-sweeping.
const wipe = useSharedValue(0);
const morph = useSharedValue(1);
const fromHeights = useSharedValue<number[]>([]);
const toHeights = useSharedValue<number[]>([]);
useEffect(() => {
wipe.value = 0;
wipe.value = withTiming(1, WIPE);
}, [wipe]);
useEffect(() => {
const settled = toHeights.value;
// Only tween when the bucket count is unchanged; a different count means new
// geometry, which should land immediately rather than morph through it.
fromHeights.value = settled.length === heights.length ? settled : heights;
toHeights.value = heights;
morph.value = 0;
morph.value = withTiming(1, motion.quick);
}, [heights, fromHeights, toHeights, morph]);
// Explicit dependency arrays: the paths must rebuild whenever the measured
// geometry or the selection changes, and relying on inferred dependencies here
// would risk a chart that never repaints after its first layout pass.
const barsPath = useDerivedValue(() => {
const path = Skia.Path.Make();
const from = fromHeights.value;
const to = toHeights.value;
const settle = morph.value;
const reveal = wipe.value;
for (let i = 0; i < slots.length; i++) {
if (i === selectedIndex) continue;
addBar(path, slots[i], animatedBarHeight(from, to, settle, reveal, i, slots.length));
}
return path;
}, [slots, selectedIndex]);
const selectedPath = useDerivedValue(() => {
const path = Skia.Path.Make();
const slot = selectedIndex == null ? undefined : slots[selectedIndex];
if (selectedIndex == null || !slot) return path;
const height = animatedBarHeight(
fromHeights.value,
toHeights.value,
morph.value,
wipe.value,
selectedIndex,
slots.length,
);
addBar(path, slot, height);
return path;
}, [slots, selectedIndex]);
const onLayout = (event: LayoutChangeEvent) => {
setWidth(event.nativeEvent.layout.width);
};
// Dragging scrubs the chart — essential once bars are only a few px wide.
const lastScrubbed = useRef<number | null>(null);
const scrubTo = useCallback(
(x: number) => {
const index = nearestBarIndex(x, count, width);
if (index < 0 || index === lastScrubbed.current) return;
lastScrubbed.current = index;
setSelectedIndex(index);
playHaptic('frequentStep');
},
[count, width],
);
const handleGrant = (event: GestureResponderEvent) => {
lastScrubbed.current = null;
scrubTo(event.nativeEvent.locationX);
};
const handleMove = (event: GestureResponderEvent) => {
scrubTo(event.nativeEvent.locationX);
};
const stepSelection = useCallback(
(delta: number) => {
setSelectedIndex((current) => {
const base = current ?? count - 1;
return Math.max(0, Math.min(count - 1, base + delta));
});
},
[count],
);
const selected = selectedIndex != null ? buckets[selectedIndex] : null;
const readout = selected
? `${formatBucketDate(selected.startAt, selected.endAt)} · ${formatListeningTime(selected.listenedSeconds)} · ${selected.qualifiedPlays} ${selected.qualifiedPlays === 1 ? 'play' : 'plays'}`
: null;
return (
<View style={styles.card}>
<View style={styles.cardHeader}>
<Text variant="heading">Activity</Text>
<Text variant="caption" color={colors.textSecondary}>
{GRANULARITY_LABEL[granularity]}
</Text>
</View>
{/* Reserved so selecting a bucket can't change the card's height. */}
<View style={styles.readout}>
<Text
variant="caption"
color={selected ? colors.textPrimary : colors.textTertiary}
numberOfLines={1}
>
{readout ?? 'Touch the chart for a breakdown'}
</Text>
</View>
<View
style={styles.plot}
onLayout={onLayout}
onStartShouldSetResponder={() => count > 0}
onMoveShouldSetResponder={() => count > 0}
onResponderTerminationRequest={() => false}
onResponderGrant={handleGrant}
onResponderMove={handleMove}
accessibilityRole="adjustable"
accessibilityLabel={`Listening activity, ${GRANULARITY_LABEL[granularity].toLowerCase()}`}
accessibilityValue={{ text: readout ?? 'No period selected' }}
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
onAccessibilityAction={(event) => {
if (event.nativeEvent.actionName === 'increment') stepSelection(1);
else if (event.nativeEvent.actionName === 'decrement') stepSelection(-1);
}}
>
{width > 0 && count > 0 ? (
<Canvas style={styles.canvas}>
{/* Unselected bars sit back; the selected bar reads at full strength.
An opacity split is legible where the previous accent/accentHover
pair differed by only ~8% lightness. */}
<Group opacity={0.34}>
<Path path={barsPath}>
<LinearGradient
start={vec(0, 0)}
end={vec(0, CHART_PLOT_HEIGHT)}
colors={[colors.accentHover, colors.accent]}
/>
</Path>
</Group>
<Path path={selectedPath}>
<LinearGradient
start={vec(0, 0)}
end={vec(0, CHART_PLOT_HEIGHT)}
colors={[colors.accentHover, colors.accent]}
/>
</Path>
<Rect
x={0}
y={CHART_PLOT_HEIGHT}
width={width}
height={1}
color={colors.glassBorder}
/>
</Canvas>
) : null}
</View>
{/* Two labels instead of every Nth bar: legible at 53 buckets, and immune
to the baseline jitter the old per-bar label/spacer pair caused. */}
{count > 0 ? (
<View style={styles.axis}>
<Text variant="caption" color={colors.textTertiary} numberOfLines={1}>
{buckets[0].label}
</Text>
<Text variant="caption" color={colors.textTertiary} numberOfLines={1}>
{buckets[count - 1].label}
</Text>
</View>
) : null}
</View>
);
}
function addBar(path: SkPath, slot: BarSlot, height: number): void {
'worklet';
path.addRRect({
rect: {
x: slot.x,
y: CHART_PLOT_HEIGHT - height,
width: slot.width,
height,
},
rx: slot.radius,
ry: slot.radius,
});
}
const useStyles = createThemedStyles((colors) => ({
card: {
padding: spacing.lg,
gap: spacing.sm,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
readout: {
minHeight: 16,
justifyContent: 'center',
},
plot: {
height: CHART_PLOT_HEIGHT + 1,
},
canvas: {
flex: 1,
},
axis: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: spacing.sm,
},
}));
@@ -1,268 +0,0 @@
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,
},
}));
@@ -0,0 +1,129 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
BAR_EMPTY_HEIGHT,
BAR_MAX_WIDTH,
BAR_MIN_WIDTH,
WIPE_SPREAD,
animatedBarHeight,
barHeights,
barSlots,
nearestBarIndex,
staggerProgress,
} from './activityChartMath.ts';
test('bar slots span the measured width for every supported bucket count', () => {
const width = 320;
for (const count of [1, 3, 7, 30, 53, 120]) {
const slots = barSlots(count, width);
assert.equal(slots.length, count);
const first = slots[0];
const last = slots[count - 1];
assert.ok(first.x >= 0, `count ${count} starts off-canvas at ${first.x}`);
assert.ok(
last.x + last.width <= width + 0.001,
`count ${count} overflows: ${last.x + last.width} > ${width}`,
);
for (const slot of slots) {
assert.ok(slot.width >= BAR_MIN_WIDTH, `bar too thin at count ${count}`);
assert.ok(slot.width <= BAR_MAX_WIDTH, `bar too wide at count ${count}`);
}
}
});
test('bar slots are evenly pitched and centred in their slot', () => {
const slots = barSlots(7, 350);
const pitch = slots[1].x - slots[0].x;
for (let i = 1; i < slots.length; i++) {
assert.ok(Math.abs(slots[i].x - slots[i - 1].x - pitch) < 0.001, 'uneven pitch');
}
const leadingGap = slots[0].x;
const trailingGap = 350 - (slots[6].x + slots[6].width);
assert.ok(Math.abs(leadingGap - trailingGap) < 0.001, 'chart is not centred');
});
test('bar slots degrade to empty rather than NaN for unmeasured layouts', () => {
assert.deepEqual(barSlots(7, 0), []);
assert.deepEqual(barSlots(0, 320), []);
assert.deepEqual(barSlots(7, Number.NaN), []);
assert.deepEqual(barSlots(-3, 320), []);
});
test('bar heights normalise against the largest bucket', () => {
assert.deepEqual(barHeights([0, 50, 100], 100), [0, 50, 100]);
assert.deepEqual(barHeights([10, 10, 10], 60), [60, 60, 60]);
});
test('a single bad bucket cannot flatten the whole chart', () => {
// Regression: `Math.max(1, ...values)` propagated NaN into every bar height,
// which rendered every bar at its 3px minimum.
const heights = barHeights([10, Number.NaN, 20, Infinity, -5], 100);
assert.deepEqual(heights, [50, 0, 100, 0, 0]);
for (const height of heights) {
assert.ok(Number.isFinite(height), 'height must never be NaN or Infinity');
}
});
test('bar heights are all zero when there is nothing to plot', () => {
assert.deepEqual(barHeights([0, 0, 0], 100), [0, 0, 0]);
assert.deepEqual(barHeights([5, 5], 0), [0, 0]);
assert.deepEqual(barHeights([], 100), []);
});
test('touches map to the bucket under the finger and clamp at both edges', () => {
assert.equal(nearestBarIndex(0, 7, 350), 0);
assert.equal(nearestBarIndex(349, 7, 350), 6);
assert.equal(nearestBarIndex(175, 7, 350), 3);
// Overshooting past either end holds the outermost bucket rather than wrapping.
assert.equal(nearestBarIndex(-40, 7, 350), 0);
assert.equal(nearestBarIndex(999, 7, 350), 6);
assert.equal(nearestBarIndex(10, 0, 350), -1);
assert.equal(nearestBarIndex(10, 7, 0), -1);
});
test('the reveal sweeps left to right and every bar finishes together', () => {
const count = 10;
// Partway through, earlier bars lead later ones.
const midway = Array.from({ length: count }, (_, i) =>
staggerProgress(0.5, i, count, WIPE_SPREAD),
);
for (let i = 1; i < count; i++) {
assert.ok(midway[i] <= midway[i - 1], `bar ${i} outran bar ${i - 1}`);
}
assert.ok(midway[0] > midway[count - 1], 'no visible sweep across the chart');
// Nothing is showing at 0, and everything is fully grown at 1.
for (let i = 0; i < count; i++) {
assert.equal(staggerProgress(0, i, count, WIPE_SPREAD), 0);
assert.equal(staggerProgress(1, i, count, WIPE_SPREAD), 1);
}
});
test('bars grow to their settled height once both animations finish', () => {
const from = [0, 0, 0];
const to = [40, 80, 120];
const grown = to.map((_, i) => animatedBarHeight(from, to, 1, 1, i, to.length));
assert.deepEqual(grown, [40, 80, 120]);
});
test('a refresh glides bars between the old and new heights', () => {
const from = [100, 20];
const to = [50, 60];
// Halfway through the settle, with the reveal already finished.
const midway = to.map((_, i) => animatedBarHeight(from, to, 0.5, 1, i, to.length));
assert.deepEqual(midway, [75, 40]);
});
test('empty buckets keep a hairline tick rather than vanishing', () => {
assert.equal(animatedBarHeight([0], [0], 1, 1, 0, 1), BAR_EMPTY_HEIGHT);
// Also holds mid-reveal, and for indices past the end of the data.
assert.equal(animatedBarHeight([0], [90], 1, 0, 0, 1), BAR_EMPTY_HEIGHT);
assert.equal(animatedBarHeight([], [], 1, 1, 7, 3), BAR_EMPTY_HEIGHT);
});
test('the reveal stays bounded for degenerate bucket counts', () => {
assert.equal(staggerProgress(0.5, 0, 1, WIPE_SPREAD), 0.5);
assert.equal(staggerProgress(1, 0, 1, WIPE_SPREAD), 1);
assert.equal(staggerProgress(2, 3, 10, WIPE_SPREAD), 1);
assert.equal(staggerProgress(-1, 3, 10, WIPE_SPREAD), 0);
});
@@ -0,0 +1,115 @@
// Geometry + reveal timing for the listening activity chart. Kept free of React
// Native and Skia imports so the maths stay unit-testable, mirroring the split
// between EQGraph.tsx and eqGraphMath.ts.
//
// Every bar position is an absolute pixel value derived from one measured width.
// The chart deliberately owns no flex/percentage layout: the previous
// implementation nested flexed bar slots inside a horizontal ScrollView whose
// contentContainer used `width: '100%'`, which resolves to auto on an
// unconstrained main axis and collapsed every slot to zero width.
/** Plot area height in px (excludes the axis label row below it). */
export const CHART_PLOT_HEIGHT = 132;
/** Widest a single bar may get, so a 3-bucket "All" range doesn't draw slabs. */
export const BAR_MAX_WIDTH = 22;
/** Narrowest a bar may get, so ~53 weekly buckets stay visible. */
export const BAR_MIN_WIDTH = 2;
/** Height of the tick drawn for a bucket with no listening at all. */
export const BAR_EMPTY_HEIGHT = 2;
/** How far the reveal is spread across the bars; see `staggerProgress`. */
export const WIPE_SPREAD = 0.45;
export interface BarSlot {
x: number;
width: number;
radius: number;
}
function clamp(value: number, min: number, max: number): number {
'worklet';
return Math.max(min, Math.min(max, value));
}
/**
* Bar rectangles spanning the full measured width — bar count drives bar width,
* so no range ever needs to scroll.
*/
export function barSlots(count: number, width: number): BarSlot[] {
if (count <= 0 || !(width > 0)) return [];
const slot = width / count;
const gap = clamp(slot * 0.28, 1, 8);
const barWidth = clamp(slot - gap, BAR_MIN_WIDTH, BAR_MAX_WIDTH);
const radius = Math.min(barWidth / 2, 4);
const slots: BarSlot[] = [];
for (let i = 0; i < count; i++) {
slots.push({ x: i * slot + (slot - barWidth) / 2, width: barWidth, radius });
}
return slots;
}
/**
* Bar heights in px, normalised against the largest value.
*
* Non-finite and negative values are coerced to 0 rather than propagated: the
* old `Math.max(1, ...values)` form turned a single NaN into a NaN maximum,
* which flattened every bar in the chart.
*/
export function barHeights(values: readonly number[], plotHeight: number): number[] {
const usable = plotHeight > 0 ? plotHeight : 0;
let max = 0;
for (const value of values) {
if (Number.isFinite(value) && value > max) max = value;
}
if (max <= 0 || usable <= 0) return values.map(() => 0);
return values.map((value) =>
Number.isFinite(value) && value > 0 ? (value / max) * usable : 0,
);
}
/** Bucket under a touch at `x`, for tap-and-drag scrubbing. -1 when empty. */
export function nearestBarIndex(x: number, count: number, width: number): number {
if (count <= 0 || !(width > 0)) return -1;
const slot = width / count;
return clamp(Math.floor(x / slot), 0, count - 1);
}
/**
* Per-bar reveal progress, so growth sweeps left to right instead of every bar
* inflating together. Bar 0 starts immediately; the last bar starts `spread`
* of the way through; all of them land on 1 when `progress` reaches 1.
*
* Marked as a worklet: this runs inside the Skia path builder on the UI thread.
*/
export function staggerProgress(
progress: number,
index: number,
count: number,
spread: number,
): number {
'worklet';
if (count <= 1) return clamp(progress, 0, 1);
const start = (index / (count - 1)) * spread;
return clamp(progress * (1 + spread) - start, 0, 1);
}
/**
* Drawn height of one bar, combining the two animations the chart runs:
* `settle` (01) glides from the previously drawn heights to the current ones
* when a refresh lands, and `reveal` (01) is the staggered growth sweep.
*
* Empty buckets keep a hairline tick so the baseline stays readable.
*/
export function animatedBarHeight(
from: readonly number[],
to: readonly number[],
settle: number,
reveal: number,
index: number,
count: number,
): number {
'worklet';
const start = from[index] ?? 0;
const end = to[index] ?? 0;
const target = start + (end - start) * settle;
return Math.max(target * staggerProgress(reveal, index, count, WIPE_SPREAD), BAR_EMPTY_HEIGHT);
}
-2
View File
@@ -1,2 +0,0 @@
export const LISTENING_STATS_SHARE_WIDTH = 1474;
export const LISTENING_STATS_SHARE_HEIGHT = 1920;
-90
View File
@@ -1,90 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
LISTENING_STATS_SHARE_HEIGHT,
LISTENING_STATS_SHARE_WIDTH,
} from './shareDimensions.ts';
import {
buildListeningStatsShareModel,
formatCompactListeningDuration,
formatListeningShare,
} from './shareModel.ts';
import type { ListeningStatsDashboard } from '../types/listeningStats.ts';
function dashboard(): ListeningStatsDashboard {
return {
status: { generation: 'generation', startedAt: 1_700_000_000_000, enabled: true },
range: '30d',
rankingMetric: 'plays',
rangeStartAt: 1_700_000_000_000,
rangeEndAt: 1_702_000_000_000,
granularity: 'day',
summary: {
listenedSeconds: 7_200,
qualifiedPlays: 12,
tracksPlayed: 4,
activeDays: 3,
},
activity: [],
topTracks: [{
key: 'track:/private/path.flac',
trackPath: '/private/path.flac',
title: 'Top Track',
artist: 'Artist',
album: 'Album',
artworkHash: null,
sourceType: 'local',
sourceId: null,
artworkSourceId: null,
listenedSeconds: 3_600,
qualifiedPlays: 7,
available: true,
}],
topArtists: [{
key: 'artist',
artist: 'Artist',
artworkHash: null,
sourceType: 'local',
sourceId: null,
artworkSourceId: null,
listenedSeconds: 4_000,
qualifiedPlays: 8,
available: true,
}],
topAlbums: [{
key: 'album-key',
album: 'Album',
artist: 'Artist',
artworkHash: null,
sourceType: 'local',
sourceId: null,
artworkSourceId: null,
listenedSeconds: 3_900,
qualifiedPlays: 8,
available: true,
}],
};
}
test('share model carries range and ranking context without private paths', () => {
const model = buildListeningStatsShareModel(dashboard(), 'track');
assert.equal(model.title, 'YOUR TOP TRACK');
assert.equal(model.rankingLabel, 'RANKED BY PLAYS');
assert.match(model.suggestedFileName, /^astra-listening-30d-plays-\d{4}-\d{2}-\d{2}\.png$/);
assert.equal(JSON.stringify(model).includes('/private/path.flac'), false);
});
test('overview and album lenses use matching ranked data', () => {
assert.deepEqual(
buildListeningStatsShareModel(dashboard(), 'overview').overviewItems.map((item) => item.kind),
['track', 'album', 'artist'],
);
assert.equal(buildListeningStatsShareModel(dashboard(), 'album').hero?.title, 'Album');
});
test('duration, percentages, and canonical PNG dimensions are stable', () => {
assert.equal(formatCompactListeningDuration(7_200), '2h');
assert.equal(formatListeningShare(3_600, 7_200), '50%');
assert.equal(LISTENING_STATS_SHARE_WIDTH, 1474);
assert.equal(LISTENING_STATS_SHARE_HEIGHT, 1920);
});
-233
View File
@@ -1,233 +0,0 @@
import type {
ListeningStatsDashboard,
ListeningStatsRange,
ListeningStatsRankingMetric,
} from '@/types/listeningStats';
export type ListeningStatsShareLens = 'overview' | 'track' | 'album';
export type ListeningStatsShareItemKind = 'track' | 'album' | 'artist';
export interface ListeningStatsShareItem {
kind: ListeningStatsShareItemKind;
rank: number;
available: boolean;
key: string;
title: string;
subtitle: string;
listenedSeconds: number;
qualifiedPlays: number;
}
export interface ListeningStatsShareModel {
lens: ListeningStatsShareLens;
range: ListeningStatsRange;
rankingMetric: ListeningStatsRankingMetric;
rankingLabel: string;
rangeLabel: string;
title: string;
hero: ListeningStatsShareItem | null;
overviewItems: ListeningStatsShareItem[];
secondaryItems: ListeningStatsShareItem[];
summaryStats: { label: string; value: string }[];
personalityValue: string;
personalityText: string;
artworkKeys: string[];
suggestedFileName: string;
}
const COUNT_FORMATTER = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 });
const SHORT_DATE_FORMATTER = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
});
const FULL_DATE_FORMATTER = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
function safeNumber(value: number): number {
return Number.isFinite(value) ? Math.max(0, value) : 0;
}
export function formatCompactListeningDuration(seconds: number): string {
const totalMinutes = Math.floor(safeNumber(seconds) / 60);
if (totalMinutes < 1) return '<1m';
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours < 1) return `${minutes}m`;
if (minutes === 0) return `${hours}h`;
return `${hours}h ${minutes}m`;
}
export function formatListeningShare(partSeconds: number, totalSeconds: number): string {
const total = safeNumber(totalSeconds);
const part = Math.min(safeNumber(partSeconds), total);
if (total <= 0 || part <= 0) return '0%';
const percentage = (part / total) * 100;
if (percentage < 1) return '<1%';
return `${Math.min(100, Math.round(percentage))}%`;
}
function formatRangeLabel(dashboard: ListeningStatsDashboard): string {
if (dashboard.range === 'all') {
const start = dashboard.status.startedAt ?? dashboard.rangeStartAt;
return start == null
? 'ALL RECORDED LISTENING'
: `SINCE ${FULL_DATE_FORMATTER.format(start).toUpperCase()}`;
}
const start = dashboard.rangeStartAt;
if (start == null) return dashboard.range.toUpperCase();
const end = dashboard.rangeEndAt;
if (new Date(start).getFullYear() !== new Date(end).getFullYear()) {
return `${FULL_DATE_FORMATTER.format(start)} ${FULL_DATE_FORMATTER.format(end)}`.toUpperCase();
}
return `${SHORT_DATE_FORMATTER.format(start)} ${FULL_DATE_FORMATTER.format(end)}`.toUpperCase();
}
function createSuggestedFileName(dashboard: ListeningStatsDashboard): string {
const date = new Date(dashboard.rangeEndAt).toISOString().slice(0, 10);
return `astra-listening-${dashboard.range}-${dashboard.rankingMetric}-${date}.png`;
}
function trackItem(
track: ListeningStatsDashboard['topTracks'][number],
rank = 1,
): ListeningStatsShareItem {
return {
kind: 'track',
rank,
available: track.available,
key: `track:${rank}`,
title: track.title,
subtitle: `${track.artist}${track.album}`,
listenedSeconds: track.listenedSeconds,
qualifiedPlays: track.qualifiedPlays,
};
}
function albumItem(
album: ListeningStatsDashboard['topAlbums'][number],
rank = 1,
): ListeningStatsShareItem {
return {
kind: 'album',
rank,
available: album.available,
key: `album:${rank}`,
title: album.album,
subtitle: album.artist,
listenedSeconds: album.listenedSeconds,
qualifiedPlays: album.qualifiedPlays,
};
}
function artistItem(
artist: ListeningStatsDashboard['topArtists'][number],
rank = 1,
): ListeningStatsShareItem {
return {
kind: 'artist',
rank,
available: artist.available,
key: `artist:${rank}`,
title: artist.artist,
subtitle: 'Artist',
listenedSeconds: artist.listenedSeconds,
qualifiedPlays: artist.qualifiedPlays,
};
}
export function buildListeningStatsShareModel(
dashboard: ListeningStatsDashboard,
lens: ListeningStatsShareLens,
): ListeningStatsShareModel {
const rankingLabel =
dashboard.rankingMetric === 'plays' ? 'RANKED BY PLAYS' : 'RANKED BY LISTENING TIME';
const common = {
lens,
range: dashboard.range,
rankingMetric: dashboard.rankingMetric,
rankingLabel,
rangeLabel: formatRangeLabel(dashboard),
summaryStats: [
{
label: 'LISTENED',
value: formatCompactListeningDuration(dashboard.summary.listenedSeconds),
},
{
label: 'PLAYS',
value: COUNT_FORMATTER.format(safeNumber(dashboard.summary.qualifiedPlays)),
},
{
label: 'ACTIVE DAYS',
value: COUNT_FORMATTER.format(safeNumber(dashboard.summary.activeDays)),
},
],
suggestedFileName: createSuggestedFileName(dashboard),
};
if (lens === 'track') {
const items = dashboard.topTracks.map((track, index) => trackItem(track, index + 1));
const hero = items[0] ?? null;
const secondaryItems = items.slice(1, 4);
return {
...common,
title: 'YOUR TOP TRACK',
hero,
overviewItems: [],
secondaryItems,
personalityValue: formatListeningShare(
hero?.listenedSeconds ?? 0,
dashboard.summary.listenedSeconds,
),
personalityText: 'of your listening time went to this track.',
artworkKeys: [hero, ...secondaryItems]
.filter((item): item is ListeningStatsShareItem => item != null)
.map((item) => item.key),
};
}
if (lens === 'album') {
const items = dashboard.topAlbums.map((album, index) => albumItem(album, index + 1));
const hero = items[0] ?? null;
const secondaryItems = items.slice(1, 4);
return {
...common,
title: 'YOUR TOP ALBUM',
hero,
overviewItems: [],
secondaryItems,
personalityValue: formatListeningShare(
hero?.listenedSeconds ?? 0,
dashboard.summary.listenedSeconds,
),
personalityText: 'of your listening time was spent inside this album.',
artworkKeys: [hero, ...secondaryItems]
.filter((item): item is ListeningStatsShareItem => item != null)
.map((item) => item.key),
};
}
const overviewItems = [
dashboard.topTracks[0] ? trackItem(dashboard.topTracks[0]) : null,
dashboard.topAlbums[0] ? albumItem(dashboard.topAlbums[0]) : null,
dashboard.topArtists[0] ? artistItem(dashboard.topArtists[0]) : null,
].filter((item): item is ListeningStatsShareItem => item != null);
const topArtist = overviewItems.find((item) => item.kind === 'artist') ?? null;
return {
...common,
title: 'YOUR LISTENING',
hero: null,
overviewItems,
secondaryItems: [],
personalityValue: formatListeningShare(
topArtist?.listenedSeconds ?? 0,
dashboard.summary.listenedSeconds,
),
personalityText: topArtist
? `of your listening time went to ${topArtist.title}.`
: 'of your listening time is still waiting to be discovered.',
artworkKeys: overviewItems.map((item) => item.key),
};
}
-573
View File
@@ -1,573 +0,0 @@
import { Asset } from 'expo-asset';
import {
Inter_400Regular,
Inter_600SemiBold,
Inter_700Bold,
} from '@expo-google-fonts/inter';
import { JetBrainsMono_500Medium } from '@expo-google-fonts/jetbrains-mono';
import {
ClipOp,
FontWeight,
ImageFormat,
Skia,
TextAlign,
TextDirection,
rect,
type SkCanvas,
type SkFont,
type SkImage,
type SkPaint,
type SkData,
type SkTypeface,
} from '@shopify/react-native-skia';
import type {
ListeningStatsShareItem,
ListeningStatsShareModel,
} from './shareModel';
import {
LISTENING_STATS_SHARE_HEIGHT,
LISTENING_STATS_SHARE_WIDTH,
} from './shareDimensions';
export {
LISTENING_STATS_SHARE_HEIGHT,
LISTENING_STATS_SHARE_WIDTH,
} from './shareDimensions';
const BACKGROUND = '#0f0f10';
const TEXT = '#f5f5f6';
const TEXT_SECONDARY = '#bfc0c8';
const CONTENT_LEFT = 120;
const CONTENT_RIGHT = 1354;
const CENTER_X = LISTENING_STATS_SHARE_WIDTH / 2;
const HERO_X = 437;
const HERO_Y = 190;
const HERO_SIZE = 600;
const NON_LATIN =
/[^\u0000-\u024F\u0370-\u03FF\u0400-\u04FF\u2000-\u206F\u20A0-\u20CF\u2100-\u214F]/;
interface RendererFonts {
regular: SkFont;
semibold: SkFont;
bold: SkFont;
mono: SkFont;
typefaces: SkTypeface[];
fontData: SkData[];
}
export interface ListeningStatsShareRenderOptions {
accentColor: string;
artworkUris: ReadonlyMap<string, string>;
}
async function loadTypeface(moduleId: number): Promise<{ typeface: SkTypeface; data: SkData }> {
const asset = Asset.fromModule(moduleId);
if (!asset.localUri) await asset.downloadAsync();
const data = await Skia.Data.fromURI(asset.localUri ?? asset.uri);
const typeface = Skia.Typeface.MakeFreeTypeFaceFromData(data);
if (!typeface) {
data.dispose();
throw new Error('A bundled share-card font could not be loaded.');
}
return { typeface, data };
}
async function loadFonts(): Promise<RendererFonts> {
const loaded = await Promise.all([
loadTypeface(Inter_400Regular),
loadTypeface(Inter_600SemiBold),
loadTypeface(Inter_700Bold),
loadTypeface(JetBrainsMono_500Medium),
]);
const typefaces = loaded.map((entry) => entry.typeface);
return {
regular: Skia.Font(typefaces[0], 32),
semibold: Skia.Font(typefaces[1], 32),
bold: Skia.Font(typefaces[2], 32),
mono: Skia.Font(typefaces[3], 28),
typefaces,
fontData: loaded.map((entry) => entry.data),
};
}
async function loadArtwork(
artworkUris: ReadonlyMap<string, string>,
): Promise<Map<string, SkImage>> {
const images = new Map<string, SkImage>();
await Promise.all(
[...artworkUris].map(async ([key, uri]) => {
if (!uri) return;
try {
const data = await Skia.Data.fromURI(uri);
try {
const image = Skia.Image.MakeImageFromEncoded(data);
if (image) images.set(key, image);
} finally {
data.dispose();
}
} catch {
// A missing local file or unreachable remote cover gets the branded placeholder.
}
}),
);
return images;
}
function setColor(paint: SkPaint, color: string): void {
paint.setColor(Skia.Color(color));
}
function fittedText(
value: string,
maxWidth: number,
font: SkFont,
paint: SkPaint,
): string {
const clean = value.trim() || 'Unknown';
if (font.measureText(clean, paint).width <= maxWidth) return clean;
const characters = Array.from(clean);
let low = 0;
let high = characters.length;
while (low < high) {
const middle = Math.ceil((low + high) / 2);
const candidate = `${characters.slice(0, middle).join('').trimEnd()}`;
if (font.measureText(candidate, paint).width <= maxWidth) low = middle;
else high = middle - 1;
}
return `${characters.slice(0, low).join('').trimEnd()}`;
}
function drawSystemParagraph(
canvas: SkCanvas,
fonts: RendererFonts,
options: {
text: string;
x: number;
y: number;
maxWidth: number;
size: number;
minSize: number;
color: string;
font: SkFont;
align?: 'left' | 'center' | 'right';
},
): void {
const weight = options.font === fonts.bold
? FontWeight.Bold
: options.font === fonts.semibold
? FontWeight.SemiBold
: options.font === fonts.mono
? FontWeight.Medium
: FontWeight.Normal;
const align = options.align === 'center'
? TextAlign.Center
: options.align === 'right'
? TextAlign.Right
: TextAlign.Left;
const direction = /[\u0590-\u08FF]/.test(options.text)
? TextDirection.RTL
: TextDirection.LTR;
let size = options.size;
let paragraph: ReturnType<ReturnType<typeof Skia.ParagraphBuilder.Make>['build']> | null = null;
while (size >= options.minSize) {
const builder = Skia.ParagraphBuilder.Make({
maxLines: 1,
ellipsis: '…',
textAlign: align,
textDirection: direction,
textStyle: {
color: Skia.Color(options.color),
fontFamilies: ['sans-serif'],
fontSize: size,
fontStyle: { weight },
},
});
builder.addText(options.text.trim() || 'Unknown');
const candidate = builder.build();
builder.dispose();
candidate.layout(options.maxWidth);
paragraph?.dispose();
paragraph = candidate;
if (candidate.getMaxIntrinsicWidth() <= options.maxWidth || size === options.minSize) break;
size -= 1;
}
if (!paragraph) return;
const baseline = paragraph.getLineMetrics()[0]?.baseline ?? size;
const x = options.align === 'center'
? options.x - options.maxWidth / 2
: options.align === 'right'
? options.x - options.maxWidth
: options.x;
paragraph.paint(canvas, x, options.y - baseline);
paragraph.dispose();
}
function drawFittedText(
canvas: SkCanvas,
paint: SkPaint,
fonts: RendererFonts,
options: {
text: string;
x: number;
y: number;
maxWidth: number;
size: number;
minSize: number;
color: string;
font: SkFont;
align?: 'left' | 'center' | 'right';
},
): void {
if (NON_LATIN.test(options.text)) {
drawSystemParagraph(canvas, fonts, options);
return;
}
const font = options.font;
let size = options.size;
font.setSize(size);
while (size > options.minSize && font.measureText(options.text, paint).width > options.maxWidth) {
size -= 1;
font.setSize(size);
}
const text = fittedText(options.text, options.maxWidth, font, paint);
const width = font.measureText(text, paint).width;
const x = options.align === 'center'
? options.x - width / 2
: options.align === 'right'
? options.x - width
: options.x;
setColor(paint, options.color);
canvas.drawText(text, x, options.y, paint, font);
}
function drawCover(
canvas: SkCanvas,
paint: SkPaint,
image: SkImage | undefined,
x: number,
y: number,
width: number,
height: number,
accent: string,
): void {
const rounded = { rect: rect(x, y, width, height), rx: 20, ry: 20 };
const save = canvas.save();
canvas.clipRRect(rounded, ClipOp.Intersect, true);
if (!image) {
setColor(paint, '#222329');
canvas.drawRect(rect(x, y, width, height), paint);
setColor(paint, `${accent}55`);
canvas.drawCircle(x + width * 0.32, y + height * 0.35, width * 0.25, paint);
setColor(paint, `${accent}33`);
canvas.drawCircle(x + width * 0.72, y + height * 0.68, width * 0.33, paint);
} else {
const scale = Math.max(width / image.width(), height / image.height());
const sourceWidth = width / scale;
const sourceHeight = height / scale;
canvas.drawImageRect(
image,
rect(
(image.width() - sourceWidth) / 2,
(image.height() - sourceHeight) / 2,
sourceWidth,
sourceHeight,
),
rect(x, y, width, height),
paint,
);
}
canvas.restoreToCount(save);
}
function itemMetric(item: ListeningStatsShareItem, model: ListeningStatsShareModel): string {
if (model.rankingMetric === 'plays') {
const plays = Math.max(0, Math.round(item.qualifiedPlays));
return `${plays.toLocaleString('en-US')} ${plays === 1 ? 'PLAY' : 'PLAYS'}`;
}
const minutes = Math.floor(Math.max(0, item.listenedSeconds) / 60);
if (minutes < 1) return '<1 MIN';
const hours = Math.floor(minutes / 60);
const remainder = minutes % 60;
if (hours === 0) return `${minutes} MIN`;
return remainder === 0 ? `${hours} HR` : `${hours} HR ${remainder} MIN`;
}
function drawCard(
canvas: SkCanvas,
paint: SkPaint,
fonts: RendererFonts,
model: ListeningStatsShareModel,
accent: string,
images: ReadonlyMap<string, SkImage>,
): void {
canvas.clear(Skia.Color(BACKGROUND));
setColor(paint, `${accent}22`);
canvas.drawCircle(CENTER_X, 360, 530, paint);
setColor(paint, `${accent}12`);
canvas.drawCircle(160, 720, 420, paint);
drawFittedText(canvas, paint, fonts, {
text: 'LISTENING STATS',
x: 64,
y: 82,
maxWidth: 520,
size: 31,
minSize: 24,
color: TEXT_SECONDARY,
font: fonts.mono,
});
drawFittedText(canvas, paint, fonts, {
text: model.rankingLabel.replace('RANKED ', ''),
x: 1410,
y: 82,
maxWidth: 600,
size: 31,
minSize: 22,
color: TEXT_SECONDARY,
font: fonts.mono,
align: 'right',
});
drawFittedText(canvas, paint, fonts, {
text: model.title,
x: CENTER_X,
y: 148,
maxWidth: 1100,
size: 38,
minSize: 28,
color: accent,
font: fonts.bold,
align: 'center',
});
if (model.lens === 'overview') {
const items = model.overviewItems.slice(0, 3);
if (items.length <= 1) {
drawCover(canvas, paint, images.get(items[0]?.key ?? ''), HERO_X, HERO_Y, HERO_SIZE, HERO_SIZE, accent);
} else {
const half = (HERO_SIZE - 6) / 2;
drawCover(canvas, paint, images.get(items[0]?.key ?? ''), HERO_X, HERO_Y, half, HERO_SIZE, accent);
drawCover(canvas, paint, images.get(items[1]?.key ?? ''), HERO_X + half + 6, HERO_Y, half, half, accent);
drawCover(canvas, paint, images.get(items[2]?.key ?? ''), HERO_X + half + 6, HERO_Y + half + 6, half, half, accent);
}
} else {
drawCover(canvas, paint, images.get(model.hero?.key ?? ''), HERO_X, HERO_Y, HERO_SIZE, HERO_SIZE, accent);
}
drawFittedText(canvas, paint, fonts, {
text: model.hero?.title ?? 'YOUR TOP PICKS',
x: CENTER_X,
y: 880,
maxWidth: 1180,
size: 57,
minSize: 34,
color: TEXT,
font: fonts.bold,
align: 'center',
});
drawFittedText(canvas, paint, fonts, {
text: model.hero?.subtitle ?? 'TRACK • ALBUM • ARTIST',
x: CENTER_X,
y: 940,
maxWidth: 1120,
size: 34,
minSize: 24,
color: TEXT_SECONDARY,
font: fonts.regular,
align: 'center',
});
drawFittedText(canvas, paint, fonts, {
text: model.personalityValue,
x: CENTER_X - 16,
y: 1044,
maxWidth: 210,
size: 42,
minSize: 30,
color: accent,
font: fonts.semibold,
align: 'right',
});
drawFittedText(canvas, paint, fonts, {
text: model.personalityText,
x: CENTER_X,
y: 1044,
maxWidth: 630,
size: 37,
minSize: 24,
color: TEXT,
font: fonts.regular,
});
const summaryCenters = [240, 737, 1234];
model.summaryStats.forEach((stat, index) => {
drawFittedText(canvas, paint, fonts, {
text: stat.value,
x: summaryCenters[index],
y: 1182,
maxWidth: 330,
size: 49,
minSize: 34,
color: TEXT,
font: fonts.semibold,
align: 'center',
});
drawFittedText(canvas, paint, fonts, {
text: stat.label,
x: summaryCenters[index],
y: 1234,
maxWidth: 340,
size: 26,
minSize: 21,
color: TEXT_SECONDARY,
font: fonts.mono,
align: 'center',
});
});
const items =
model.lens === 'overview' ? model.overviewItems.slice(0, 3) : model.secondaryItems.slice(0, 3);
drawFittedText(canvas, paint, fonts, {
text: model.lens === 'overview' ? 'YOUR TOP PICKS' : `NEXT ${model.lens === 'track' ? 'TRACKS' : 'ALBUMS'}`,
x: CONTENT_LEFT,
y: 1396,
maxWidth: 520,
size: 27,
minSize: 22,
color: accent,
font: fonts.mono,
});
drawFittedText(canvas, paint, fonts, {
text: model.rankingLabel,
x: CONTENT_RIGHT,
y: 1396,
maxWidth: 570,
size: 27,
minSize: 20,
color: TEXT_SECONDARY,
font: fonts.mono,
align: 'right',
});
items.forEach((item, index) => {
const y = 1448 + index * 115;
drawFittedText(canvas, paint, fonts, {
text: model.lens === 'overview' ? item.kind.toUpperCase() : String(item.rank).padStart(2, '0'),
x: 134,
y: y + 57,
maxWidth: 145,
size: model.lens === 'overview' ? 18 : 27,
minSize: 15,
color: TEXT_SECONDARY,
font: fonts.mono,
align: 'center',
});
drawCover(canvas, paint, images.get(item.key), 226, y, 92, 92, accent);
drawFittedText(canvas, paint, fonts, {
text: item.title,
x: 356,
y: y + 43,
maxWidth: 735,
size: 42,
minSize: 28,
color: TEXT,
font: fonts.semibold,
});
drawFittedText(canvas, paint, fonts, {
text: item.available ? item.subtitle : `${item.subtitle} • UNAVAILABLE`,
x: 356,
y: y + 81,
maxWidth: 735,
size: 27,
minSize: 20,
color: TEXT_SECONDARY,
font: fonts.regular,
});
drawFittedText(canvas, paint, fonts, {
text: itemMetric(item, model),
x: CONTENT_RIGHT,
y: y + 57,
maxWidth: 250,
size: 28,
minSize: 20,
color: TEXT_SECONDARY,
font: fonts.mono,
align: 'right',
});
});
drawFittedText(canvas, paint, fonts, {
text: model.rangeLabel,
x: 64,
y: 1882,
maxWidth: 730,
size: 24,
minSize: 18,
color: TEXT_SECONDARY,
font: fonts.mono,
});
drawFittedText(canvas, paint, fonts, {
text: 'LISTENED LOCALLY WITH',
x: 1190,
y: 1882,
maxWidth: 420,
size: 22,
minSize: 17,
color: TEXT_SECONDARY,
font: fonts.mono,
align: 'right',
});
setColor(paint, accent);
canvas.drawCircle(1224, 1872, 18, paint);
drawFittedText(canvas, paint, fonts, {
text: 'ASTRA',
x: 1256,
y: 1882,
maxWidth: 170,
size: 28,
minSize: 22,
color: TEXT,
font: fonts.bold,
});
}
export async function renderListeningStatsSharePng(
model: ListeningStatsShareModel,
options: ListeningStatsShareRenderOptions,
): Promise<string> {
const [fonts, images] = await Promise.all([
loadFonts(),
loadArtwork(options.artworkUris),
]);
const surface = Skia.Surface.MakeOffscreen(
LISTENING_STATS_SHARE_WIDTH,
LISTENING_STATS_SHARE_HEIGHT,
);
if (!surface) throw new Error('Share-card rendering is unavailable on this device.');
const paint = Skia.Paint();
let snapshot: SkImage | null = null;
try {
drawCard(
surface.getCanvas(),
paint,
fonts,
model,
options.accentColor,
images,
);
surface.flush();
snapshot = surface.makeImageSnapshot();
return snapshot.encodeToBase64(ImageFormat.PNG, 100);
} finally {
snapshot?.dispose();
paint.dispose();
images.forEach((image) => image.dispose());
fonts.regular.dispose();
fonts.semibold.dispose();
fonts.bold.dispose();
fonts.mono.dispose();
fonts.typefaces.forEach((typeface) => typeface.dispose());
fonts.fontData.forEach((data) => data.dispose());
surface.dispose();
}
}
+15 -5
View File
@@ -20,7 +20,13 @@ interface ListeningStatsStore {
setRange: (range: ListeningStatsRange) => void; setRange: (range: ListeningStatsRange) => void;
setRankingMetric: (metric: ListeningStatsRankingMetric) => void; setRankingMetric: (metric: ListeningStatsRankingMetric) => void;
setCategory: (category: ListeningStatsCategory) => void; setCategory: (category: ListeningStatsCategory) => void;
loadDashboard: () => Promise<void>; /**
* `silent` refreshes leave `refreshing` alone. Background triggers must use it:
* the screen reloads on a timer and on every listening checkpoint (~10s apart
* during playback), and `refreshing` drives the pull-to-refresh spinner, so
* non-silent background loads made the spinner flash on its own.
*/
loadDashboard: (options?: { silent?: boolean }) => Promise<void>;
loadHomePreview: () => Promise<void>; loadHomePreview: () => Promise<void>;
} }
@@ -53,25 +59,29 @@ export const useListeningStatsStore = create<ListeningStatsStore>((set, get) =>
refreshing: false, refreshing: false,
error: null, error: null,
// Switching range or metric reloads silently: the segmented control and the
// chart's own reveal already show that something changed, and driving
// `refreshing` here would flash the pull-to-refresh spinner on every tap.
setRange: (range) => { setRange: (range) => {
if (get().range === range) return; if (get().range === range) return;
set({ range }); set({ range });
void get().loadDashboard(); void get().loadDashboard({ silent: true });
}, },
setRankingMetric: (rankingMetric) => { setRankingMetric: (rankingMetric) => {
if (get().rankingMetric === rankingMetric) return; if (get().rankingMetric === rankingMetric) return;
set({ rankingMetric }); set({ rankingMetric });
void get().loadDashboard(); void get().loadDashboard({ silent: true });
}, },
setCategory: (category) => set({ category }), setCategory: (category) => set({ category }),
loadDashboard: async () => { loadDashboard: async (options) => {
const request = ++dashboardRequest; const request = ++dashboardRequest;
const silent = options?.silent === true;
set((state) => ({ set((state) => ({
loading: state.dashboard == null, loading: state.dashboard == null,
refreshing: state.dashboard != null, refreshing: silent ? state.refreshing : state.dashboard != null,
error: null, error: null,
})); }));
try { try {