From fd5239daeca5198e3c2107d380cb837c5ebad4d1 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:21:12 -0400 Subject: [PATCH] updated stats page --- .../data/ListeningStatsEngine.kt | 20 +- package.json | 2 +- src/app/(tabs)/stats.tsx | 316 ++++------ src/components/listening/ActivityChart.tsx | 314 ++++++++++ .../listening/ListeningStatsShareSheet.tsx | 268 -------- .../listening/activityChartMath.test.mts | 129 ++++ src/components/listening/activityChartMath.ts | 115 ++++ src/listeningStats/shareDimensions.ts | 2 - src/listeningStats/shareModel.test.mts | 90 --- src/listeningStats/shareModel.ts | 233 ------- src/listeningStats/shareRenderer.ts | 573 ------------------ src/stores/listeningStatsStore.ts | 20 +- 12 files changed, 716 insertions(+), 1366 deletions(-) create mode 100644 src/components/listening/ActivityChart.tsx delete mode 100644 src/components/listening/ListeningStatsShareSheet.tsx create mode 100644 src/components/listening/activityChartMath.test.mts create mode 100644 src/components/listening/activityChartMath.ts delete mode 100644 src/listeningStats/shareDimensions.ts delete mode 100644 src/listeningStats/shareModel.test.mts delete mode 100644 src/listeningStats/shareModel.ts delete mode 100644 src/listeningStats/shareRenderer.ts diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/ListeningStatsEngine.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/ListeningStatsEngine.kt index 0217a8b..94796d5 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/ListeningStatsEngine.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/ListeningStatsEngine.kt @@ -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 TOP_LIMIT = 10 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( val accepted: Boolean, @@ -379,9 +383,12 @@ internal object ListeningStatsEngine { tracksPlayed += identity.trackKey listenedSeconds += overlap 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( segment, - bucket.startAt, + max(bucket.startAt, rangeStartAt), min(bucket.endAt, now + 1), ) } @@ -693,9 +700,14 @@ private fun buildBuckets( rangeStartAt: Long?, now: Long, ): Pair> { - val granularity = when (range) { - "7d", "30d" -> "day" - "1y" -> "week" + // "all" spans however much history exists, so its granularity follows the span: + // a fixed month bucket gave a user with two weeks of history a single lonely bar. + 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" } if (rangeStartAt == null) return granularity to emptyList() diff --git a/package.json b/package.json index 0958548..e8682a5 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "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: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: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", diff --git a/src/app/(tabs)/stats.tsx b/src/app/(tabs)/stats.tsx index d23585e..50dcd0a 100644 --- a/src/app/(tabs)/stats.tsx +++ b/src/app/(tabs)/stats.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useMemo } from 'react'; import { AppState, Pressable, @@ -6,21 +6,17 @@ import { ScrollView, StyleSheet, View, - useWindowDimensions, } from 'react-native'; +import Animated, { FadeInDown } from 'react-native-reanimated'; 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 { ActivityChart } from '@/components/listening/ActivityChart'; import { listeningArtworkSource } from '@/library/artwork'; -import { - formatBucketDate, - formatListeningTime, - formatRecordedSince, -} from '@/listeningStats/format'; +import { formatListeningTime, formatRecordedSince } from '@/listeningStats/format'; import { useHomeLibraryNavigation } from '@/navigation/useHomeLibraryNavigation'; import { useListeningStatsStore } from '@/stores/listeningStatsStore'; import { playLibraryQuery } from '@/audio/playbackController'; @@ -30,6 +26,7 @@ import { useRipple } from '@/theme/ripple'; import type { ListeningStatsCategory, ListeningStatsDashboard, + ListeningStatsRankingMetric, RankedListeningAlbum, RankedListeningArtist, RankedListeningTrack, @@ -52,110 +49,48 @@ const CATEGORY_SEGMENTS = [ { key: 'albums', label: 'Albums' }, ]; -function SummaryGrid({ - dashboard, - wide, -}: { - dashboard: ListeningStatsDashboard; - wide: boolean; -}) { +/** Checkpoints land every ~10s of playback; collapse a burst into one query. */ +const HISTORY_REFRESH_DEBOUNCE_MS = 2_000; +const BACKGROUND_REFRESH_MS = 15_000; +/** Sections rise in sequence so the page assembles instead of snapping in. */ +const SECTION_STEP_MS = 60; + +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 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)], - ]; + const plays = dashboard.summary.qualifiedPlays; return ( - - {tiles.map(([label, value]) => ( - - {value} - {label} - - ))} + + + {formatListeningTime(dashboard.summary.listenedSeconds, true)} + + + listening time · {plays} qualified {plays === 1 ? 'play' : 'plays'} + ); } -function ActivityChart({ dashboard }: { dashboard: ListeningStatsDashboard }) { +function SummaryPair({ dashboard }: { dashboard: ListeningStatsDashboard }) { const styles = useStyles(); const colors = useColors(); - const scrollRef = useRef(null); - const [selectedStartAt, setSelectedStartAt] = useState(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 ( - setSelectedStartAt(bucket.startAt)} - accessibilityRole="button" - accessibilityState={{ selected: focused }} - accessibilityLabel={`${formatBucketDate(bucket.startAt, bucket.endAt)}, ${formatListeningTime(bucket.listenedSeconds)}, ${bucket.qualifiedPlays} qualified plays`} - > - - {(fillsCard || index % Math.max(1, Math.ceil(dashboard.activity.length / 7)) === 0) ? ( - - {bucket.label} - - ) : ( - - )} - - ); - }); - + const tiles = [ + ['Tracks Played', String(dashboard.summary.tracksPlayed)], + ['Active Days', String(dashboard.summary.activeDays)], + ]; return ( - - - Activity - - {dashboard.granularity === 'day' - ? 'Daily' - : dashboard.granularity === 'week' - ? 'Weekly' - : 'Monthly'} - - - {selected ? ( - - {formatBucketDate(selected.startAt, selected.endAt)} - - {formatListeningTime(selected.listenedSeconds)} · {selected.qualifiedPlays}{' '} - {selected.qualifiedPlays === 1 ? 'play' : 'plays'} - + + {tiles.map(([label, value]) => ( + + {value} + {label} - ) : null} - { - if (!fillsCard) scrollRef.current?.scrollToEnd({ animated: false }); - }} - contentContainerStyle={[styles.chart, fillsCard && styles.chartFill]} - > - {bars} - + ))} ); } @@ -178,17 +113,24 @@ function rankingCopy(item: RankedItem, category: ListeningStatsCategory) { 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({ item, index, category, selectedMetric, + share, onPress, }: { item: RankedItem; index: number; category: ListeningStatsCategory; - selectedMetric: 'plays' | 'time'; + selectedMetric: ListeningStatsRankingMetric; + /** 0–1 against the top-ranked entry, for the inline proportion bar. */ + share: number; onPress: () => void; }) { const styles = useStyles(); @@ -219,6 +161,16 @@ function RankingRow({ {item.available ? copy.subtitle : `${copy.subtitle} · Unavailable`} + {/* Turns the list into a visible ranking rather than a column of numbers. + Safe as a percentage: the row has a definite width. */} + + + s.range); const metric = useListeningStatsStore((s) => s.rankingMetric); const category = useListeningStatsStore((s) => s.category); @@ -286,18 +237,25 @@ export default function ListeningStatsScreen() { const setMetric = useListeningStatsStore((s) => s.setRankingMetric); const setCategory = useListeningStatsStore((s) => s.setCategory); const load = useListeningStatsStore((s) => s.loadDashboard); - const [shareSnapshot, setShareSnapshot] = useState(null); useFocusEffect( useCallback(() => { - void load(); - const interval = setInterval(() => void load(), 15_000); - const unsubscribe = subscribeToListeningHistory(() => void load()); + // Background reloads stay silent so the pull-to-refresh spinner only ever + // appears for an actual pull. + const refresh = () => void load({ silent: true }); + refresh(); + const interval = setInterval(refresh, BACKGROUND_REFRESH_MS); + let debounce: ReturnType | null = null; + const unsubscribe = subscribeToListeningHistory(() => { + if (debounce) clearTimeout(debounce); + debounce = setTimeout(refresh, HISTORY_REFRESH_DEBOUNCE_MS); + }); const subscription = AppState.addEventListener('change', (state) => { - if (state === 'active') void load(); + if (state === 'active') refresh(); }); return () => { clearInterval(interval); + if (debounce) clearTimeout(debounce); unsubscribe(); subscription.remove(); }; @@ -311,6 +269,9 @@ export default function ListeningStatsScreen() { return dashboard.topTracks; }, [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) => { if (!dashboard || !item.available) return; if (category === 'tracks') { @@ -352,21 +313,11 @@ export default function ListeningStatsScreen() { - Listening Stats + Listening Stats {formatRecordedSince(dashboard?.status.startedAt ?? null)} - setShareSnapshot(dashboard)} - accessibilityRole="button" - accessibilityLabel="Share Listening Stats" - > - - ) : null} - = 720} /> + + + + + {/* Page-level: drives the chart's bars as well as the rankings. */} + setMetric(value as typeof metric)} + /> {noActivity ? ( ) : ( <> - + {/* 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. */} + + + - + + + + + Rankings {error ? ( @@ -448,11 +422,6 @@ export default function ListeningStatsScreen() { ) : null} - setMetric(value as typeof metric)} - /> 0 ? metricValue(item, metric) / rankingPeak : 0} onPress={() => openRanking(item)} /> ))} - + )} )} - - {shareSnapshot ? ( - setShareSnapshot(null)} - /> - ) : null} ); } @@ -500,6 +463,10 @@ const useStyles = createThemedStyles((colors) => ({ minWidth: 0, gap: 2, }, + headerTitle: { + fontSize: 22, + lineHeight: 27, + }, iconButton: { width: 42, height: 42, @@ -511,7 +478,7 @@ const useStyles = createThemedStyles((colors) => ({ content: { paddingTop: spacing.md, paddingBottom: spacing.xxl, - gap: spacing.xl, + gap: spacing.lg, }, pausedBanner: { flexDirection: 'row', @@ -526,17 +493,23 @@ const useStyles = createThemedStyles((colors) => ({ flex: 1, gap: 2, }, + hero: { + gap: spacing.xs, + paddingTop: spacing.xs, + }, + heroValue: { + fontSize: 44, + lineHeight: 50, + color: colors.textPrimary, + fontFamily: fonts.sans.bold, + }, summaryGrid: { flexDirection: 'row', - flexWrap: 'wrap', gap: spacing.md, }, - summaryGridWide: { - flexWrap: 'nowrap', - }, summaryTile: { - width: '47%', - flexGrow: 1, + flex: 1, + minWidth: 0, padding: spacing.lg, gap: spacing.xs, borderRadius: radius.md, @@ -544,58 +517,10 @@ const useStyles = createThemedStyles((colors) => ({ 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, }, @@ -645,6 +570,17 @@ const useStyles = createThemedStyles((colors) => ({ minWidth: 0, gap: 2, }, + shareTrack: { + marginTop: 3, + height: 3, + borderRadius: 2, + overflow: 'hidden', + backgroundColor: colors.bgTertiary, + }, + shareFill: { + height: '100%', + borderRadius: 2, + }, rankingMetrics: { alignItems: 'flex-end', gap: 2, diff --git a/src/components/listening/ActivityChart.tsx b/src/components/listening/ActivityChart.tsx new file mode 100644 index 0000000..2d04fdc --- /dev/null +++ b/src/components/listening/ActivityChart.tsx @@ -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 = { + 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(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([]); + const toHeights = useSharedValue([]); + + 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(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 ( + + + Activity + + {GRANULARITY_LABEL[granularity]} + + + + {/* Reserved so selecting a bucket can't change the card's height. */} + + + {readout ?? 'Touch the chart for a breakdown'} + + + + 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 ? ( + + {/* 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. */} + + + + + + + + + + + ) : null} + + + {/* 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 ? ( + + + {buckets[0].label} + + + {buckets[count - 1].label} + + + ) : null} + + ); +} + +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, + }, +})); diff --git a/src/components/listening/ListeningStatsShareSheet.tsx b/src/components/listening/ListeningStatsShareSheet.tsx deleted file mode 100644 index e94d6a0..0000000 --- a/src/components/listening/ListeningStatsShareSheet.tsx +++ /dev/null @@ -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('overview'); - const [sharing, setSharing] = useState(false); - const [shareError, setShareError] = useState(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(); - 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 ( - - - - - {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 ( - setLens(option.key)} - accessibilityRole="radio" - accessibilityState={{ selected, disabled }} - > - - {option.label} - - - ); - })} - - - - {base64 ? ( - - ) : ( - - - - {error ?? (rendering ? 'Rendering 1474 × 1920 PNG…' : 'Preparing preview…')} - - - )} - - - {error && base64 ? ( - - {error} - - ) : null} - - void share()} - accessibilityRole="button" - > - - - {sharing ? 'Opening share sheet…' : 'Share PNG'} - - - - ); -} - -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, - }, -})); diff --git a/src/components/listening/activityChartMath.test.mts b/src/components/listening/activityChartMath.test.mts new file mode 100644 index 0000000..746f912 --- /dev/null +++ b/src/components/listening/activityChartMath.test.mts @@ -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); +}); diff --git a/src/components/listening/activityChartMath.ts b/src/components/listening/activityChartMath.ts new file mode 100644 index 0000000..070f27e --- /dev/null +++ b/src/components/listening/activityChartMath.ts @@ -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` (0–1) glides from the previously drawn heights to the current ones + * when a refresh lands, and `reveal` (0–1) 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); +} diff --git a/src/listeningStats/shareDimensions.ts b/src/listeningStats/shareDimensions.ts deleted file mode 100644 index c5c7160..0000000 --- a/src/listeningStats/shareDimensions.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const LISTENING_STATS_SHARE_WIDTH = 1474; -export const LISTENING_STATS_SHARE_HEIGHT = 1920; diff --git a/src/listeningStats/shareModel.test.mts b/src/listeningStats/shareModel.test.mts deleted file mode 100644 index 2d5172c..0000000 --- a/src/listeningStats/shareModel.test.mts +++ /dev/null @@ -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); -}); diff --git a/src/listeningStats/shareModel.ts b/src/listeningStats/shareModel.ts deleted file mode 100644 index 4074793..0000000 --- a/src/listeningStats/shareModel.ts +++ /dev/null @@ -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), - }; -} diff --git a/src/listeningStats/shareRenderer.ts b/src/listeningStats/shareRenderer.ts deleted file mode 100644 index 30e70ae..0000000 --- a/src/listeningStats/shareRenderer.ts +++ /dev/null @@ -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; -} - -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 { - 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, -): Promise> { - const images = new Map(); - 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['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, -): 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 { - 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(); - } -} diff --git a/src/stores/listeningStatsStore.ts b/src/stores/listeningStatsStore.ts index 59a9c85..362eb2d 100644 --- a/src/stores/listeningStatsStore.ts +++ b/src/stores/listeningStatsStore.ts @@ -20,7 +20,13 @@ interface ListeningStatsStore { setRange: (range: ListeningStatsRange) => void; setRankingMetric: (metric: ListeningStatsRankingMetric) => void; setCategory: (category: ListeningStatsCategory) => void; - loadDashboard: () => Promise; + /** + * `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; loadHomePreview: () => Promise; } @@ -53,25 +59,29 @@ export const useListeningStatsStore = create((set, get) => refreshing: false, 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) => { if (get().range === range) return; set({ range }); - void get().loadDashboard(); + void get().loadDashboard({ silent: true }); }, setRankingMetric: (rankingMetric) => { if (get().rankingMetric === rankingMetric) return; set({ rankingMetric }); - void get().loadDashboard(); + void get().loadDashboard({ silent: true }); }, setCategory: (category) => set({ category }), - loadDashboard: async () => { + loadDashboard: async (options) => { const request = ++dashboardRequest; + const silent = options?.silent === true; set((state) => ({ loading: state.dashboard == null, - refreshing: state.dashboard != null, + refreshing: silent ? state.refreshing : state.dashboard != null, error: null, })); try {