mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-20 04:30:54 +02:00
updated stats page
This commit is contained in:
+16
-4
@@ -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
@@ -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
@@ -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;
|
||||||
|
/** 0–1 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,
|
||||||
|
|||||||
@@ -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` (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);
|
||||||
|
}
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export const LISTENING_STATS_SHARE_WIDTH = 1474;
|
|
||||||
export const LISTENING_STATS_SHARE_HEIGHT = 1920;
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user