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

This commit is contained in:
Boof2015
2026-07-28 19:25:30 -04:00
parent b28a018ca0
commit 00d0fc4b2f
40 changed files with 5280 additions and 126 deletions
@@ -565,6 +565,182 @@ class RoomLibraryRepositoryTest {
assertEquals(CURRENT_ARTIST_CREDIT_VERSION, dao.getSource("local:1")?.artistCreditVersion)
}
@Test
fun listeningStatsCheckpointIsIdempotentAndClearPreservesProtectedHistory() = runBlocking {
val path = "content://track/listening.flac"
publish("listening", listOf(track("listening", 1, "Headphones On", path)))
val status = ListeningStatsEngine.status(user)
val generation = status.getValue("generation") as String
val startedAt = System.currentTimeMillis() - 20_000
val common = mapOf<String, Any?>(
"generation" to generation,
"sessionKey" to "session-1",
"segmentKey" to "segment-1",
"trackPath" to path,
"sessionStartedAt" to startedAt,
"segmentStartedAt" to startedAt,
"trackDurationSeconds" to 181.0,
"qualificationEligible" to true,
"completedNaturally" to false,
"finalizeSegment" to false,
"finalizeSession" to false,
)
ListeningStatsEngine.checkpoint(
user,
catalog.catalogDao(),
common + mapOf(
"observedAt" to startedAt + 10_000,
"sessionListenedSeconds" to 10.0,
"segmentListenedSeconds" to 10.0,
),
)
val qualified = ListeningStatsEngine.checkpoint(
user,
catalog.catalogDao(),
common + mapOf(
"observedAt" to startedAt + 15_000,
"sessionListenedSeconds" to 15.0,
"segmentListenedSeconds" to 15.0,
),
)
val duplicate = ListeningStatsEngine.checkpoint(
user,
catalog.catalogDao(),
common + mapOf(
"observedAt" to startedAt + 15_000,
"sessionListenedSeconds" to 15.0,
"segmentListenedSeconds" to 15.0,
),
)
assertEquals(true, qualified["qualifiedNow"])
assertEquals(false, duplicate["qualifiedNow"])
assertEquals(1L, user.userDao().getPlaybackHistory(path)?.playCount)
val dashboard = ListeningStatsEngine.dashboard(
user,
catalog.catalogDao(),
mapOf(
"range" to "all",
"rankingMetric" to "plays",
"artistGroupingMode" to "astra",
"now" to startedAt + 20_000,
),
)
val summary = dashboard.getValue("summary") as Map<*, *>
assertEquals(15.0, (summary["listenedSeconds"] as Number).toDouble(), 0.001)
assertEquals(1.0, (summary["qualifiedPlays"] as Number).toDouble(), 0.001)
assertEquals(1.0, (summary["tracksPlayed"] as Number).toDouble(), 0.001)
val cleared = ListeningStatsEngine.clear(user)
assertNull(cleared["startedAt"])
assertEquals(1L, user.userDao().getPlaybackHistory(path)?.playCount)
assertEquals(0, user.userDao().getListeningSessionsInRange(generation, 0, Long.MAX_VALUE).size)
}
@Test
fun listeningStatsExcludePausedGapAndRetainRemovedTrackMetadata() = runBlocking {
val path = "content://track/removed.flac"
publish("stats-old", listOf(track("stats-old", 2, "Song That Left", path)))
val generation = ListeningStatsEngine.status(user).getValue("generation") as String
val startedAt = System.currentTimeMillis() - 60_000
val base = mapOf<String, Any?>(
"generation" to generation,
"sessionKey" to "session-split",
"trackPath" to path,
"sessionStartedAt" to startedAt,
"trackDurationSeconds" to 182.0,
"qualificationEligible" to true,
"completedNaturally" to false,
)
ListeningStatsEngine.checkpoint(
user,
catalog.catalogDao(),
base + mapOf(
"segmentKey" to "segment-a",
"segmentStartedAt" to startedAt,
"observedAt" to startedAt + 5_000,
"sessionListenedSeconds" to 5.0,
"segmentListenedSeconds" to 5.0,
"finalizeSegment" to true,
"finalizeSession" to false,
),
)
ListeningStatsEngine.checkpoint(
user,
catalog.catalogDao(),
base + mapOf(
"segmentKey" to "segment-b",
"segmentStartedAt" to startedAt + 35_000,
"observedAt" to startedAt + 45_000,
"sessionListenedSeconds" to 15.0,
"segmentListenedSeconds" to 10.0,
"finalizeSegment" to true,
"finalizeSession" to true,
),
)
publish(
"stats-new",
listOf(track("stats-new", 3, "A Different Song")),
previous = "stats-old",
)
val dashboard = ListeningStatsEngine.dashboard(
user,
catalog.catalogDao(),
mapOf(
"range" to "all",
"rankingMetric" to "time",
"artistGroupingMode" to "fileTags",
"now" to startedAt + 55_000,
),
)
val summary = dashboard.getValue("summary") as Map<*, *>
assertEquals(15.0, (summary["listenedSeconds"] as Number).toDouble(), 0.001)
val topTrack = (dashboard.getValue("topTracks") as List<*>).single() as Map<*, *>
assertEquals("Song That Left", topTrack["title"])
assertEquals(false, topTrack["available"])
assertNull(topTrack["trackPath"])
}
@Test
fun listeningStatsShortTracksQualifyOnlyOnNaturalCompletion() = runBlocking {
val path = "content://track/short.flac"
publish(
"stats-short",
listOf(track("stats-short", 4, "Short Song", path).copy(duration = 5.0)),
)
val generation = ListeningStatsEngine.status(user).getValue("generation") as String
val startedAt = System.currentTimeMillis() - 10_000
suspend fun checkpoint(sessionKey: String, completedNaturally: Boolean): Map<String, Any?> =
ListeningStatsEngine.checkpoint(
user,
catalog.catalogDao(),
mapOf(
"generation" to generation,
"sessionKey" to sessionKey,
"segmentKey" to "segment-$sessionKey",
"trackPath" to path,
"sessionStartedAt" to startedAt,
"segmentStartedAt" to startedAt,
"observedAt" to startedAt + 4_500,
"sessionListenedSeconds" to 4.5,
"segmentListenedSeconds" to 4.5,
"trackDurationSeconds" to 5.0,
"qualificationEligible" to true,
"completedNaturally" to completedNaturally,
"finalizeSegment" to true,
"finalizeSession" to true,
),
)
assertEquals(false, checkpoint("manual", false)["qualifiedNow"])
assertNull(user.userDao().getPlaybackHistory(path))
assertEquals(true, checkpoint("natural", true)["qualifiedNow"])
assertEquals(1L, user.userDao().getPlaybackHistory(path)?.playCount)
}
/** 10 tracks under each of A-Z, so every section has rows above and below it. */
private fun seedAlphabet(): List<TrackEntity> =
(0 until ALPHABET_SEED_SIZE).map { index ->
@@ -0,0 +1,122 @@
package expo.modules.astralibraryscanner.data
import androidx.room.testing.MigrationTestHelper
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class UserMigrationTest {
@get:Rule
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AstraUserDatabase::class.java,
)
@After
fun cleanUp() {
InstrumentationRegistry.getInstrumentation().targetContext.deleteDatabase(TEST_DATABASE)
}
@Test
fun migrationAddsDetailedHistoryWithoutChangingProtectedUserData() {
helper.createDatabase(TEST_DATABASE, 1).apply {
execSQL("INSERT INTO settings (`key`, value) VALUES ('theme_base', 'amoled')")
execSQL(
"""
INSERT INTO folders
(id, tree_uri, display_name, added_at, last_scanned_at, last_scan_status, last_scan_error)
VALUES (1, 'content://music', 'Music', 10, 20, 'ready', NULL)
""".trimIndent(),
)
execSQL(
"""
INSERT INTO playlists
(id, name, created_at, updated_at, last_played_at, kind, dynamic_rules_json,
remote_source_id, remote_playlist_id, sync_uid)
VALUES (1, 'Keep Me', 10, 20, 30, 'static', NULL, NULL, NULL, 'playlist-1')
""".trimIndent(),
)
execSQL(
"""
INSERT INTO playlist_tracks
(id, playlist_id, track_path, position, added_at, fallback_title,
fallback_artist, fallback_album)
VALUES (1, 1, '/music/track.flac', 0, 10, 'Track', 'Artist', 'Album')
""".trimIndent(),
)
execSQL("INSERT INTO favorites (track_path, added_at) VALUES ('/music/track.flac', 10)")
execSQL(
"""
INSERT INTO playback_history (track_path, last_played_at, play_count)
VALUES ('/music/track.flac', 30, 9)
""".trimIndent(),
)
execSQL(
"""
INSERT INTO playback_sessions
(id, context_json, anchor_path, shuffle_seed, active_position, created_at, updated_at)
VALUES ('session', '{}', '/music/track.flac', NULL, 0, 10, 20)
""".trimIndent(),
)
execSQL(
"""
INSERT INTO playback_queue_entries (session_id, position, track_path)
VALUES ('session', 0, '/music/track.flac')
""".trimIndent(),
)
close()
}
val database = helper.runMigrationsAndValidate(
TEST_DATABASE,
2,
true,
USER_MIGRATION_1_2,
)
assertEquals("amoled", database.singleString("SELECT value FROM settings WHERE `key` = 'theme_base'"))
assertEquals("Music", database.singleString("SELECT display_name FROM folders WHERE id = 1"))
assertEquals("Keep Me", database.singleString("SELECT name FROM playlists WHERE id = 1"))
assertEquals(1, database.singleInt("SELECT COUNT(*) FROM playlist_tracks"))
assertEquals(1, database.singleInt("SELECT COUNT(*) FROM favorites"))
assertEquals(9, database.singleInt("SELECT play_count FROM playback_history"))
assertEquals(1, database.singleInt("SELECT COUNT(*) FROM playback_sessions"))
assertEquals(1, database.singleInt("SELECT COUNT(*) FROM playback_queue_entries"))
val tables = buildSet {
database.query(
"""
SELECT name FROM sqlite_master
WHERE type = 'table' AND name LIKE 'listening_%'
""".trimIndent(),
).use { cursor ->
while (cursor.moveToNext()) add(cursor.getString(0))
}
}
assertTrue("listening_history_meta" in tables)
assertTrue("listening_sessions" in tables)
assertTrue("listening_segments" in tables)
}
private fun androidx.sqlite.db.SupportSQLiteDatabase.singleString(query: String): String =
this.query(query).use { cursor ->
assertTrue(cursor.moveToFirst())
cursor.getString(0)
}
private fun androidx.sqlite.db.SupportSQLiteDatabase.singleInt(query: String): Int =
this.query(query).use { cursor ->
assertTrue(cursor.moveToFirst())
cursor.getInt(0)
}
private companion object {
const val TEST_DATABASE = "listening-history-user-migration-test"
}
}
@@ -204,6 +204,22 @@ class AstraLibraryDataModule : Module() {
repositoryCall { recordTrackPlayed(path) }
}
AsyncFunction("getListeningHistoryStatus").Coroutine<Map<String, Any?>> {
repositoryCall { getListeningHistoryStatus() }
}
AsyncFunction("checkpointListeningSession") Coroutine { payload: Map<String, Any?> ->
repositoryCall { checkpointListeningSession(payload) }
}
AsyncFunction("getListeningStatsDashboard") Coroutine { query: Map<String, Any?> ->
repositoryCall { getListeningStatsDashboard(query) }
}
AsyncFunction("clearDetailedListeningHistory").Coroutine<Map<String, Any?>> {
repositoryCall { clearDetailedListeningHistory() }
}
AsyncFunction("getRecentlyPlayed") Coroutine { limit: Int ->
repositoryCall { getRecentlyPlayed(limit) }
}
@@ -1194,6 +1194,36 @@ class AstraLibraryRepository private constructor(
return true
}
suspend fun getListeningHistoryStatus(): Map<String, Any?> {
initialize()
return ListeningStatsEngine.status(requireUser())
}
suspend fun checkpointListeningSession(payload: Map<String, Any?>): Map<String, Any?> {
initialize()
val result = ListeningStatsEngine.checkpoint(
requireUser(),
requireCatalog().catalogDao(),
payload,
)
if (result["qualifiedNow"] == true) scheduleSnapshot()
return result
}
suspend fun getListeningStatsDashboard(query: Map<String, Any?>): Map<String, Any?> {
initialize()
return ListeningStatsEngine.dashboard(
requireUser(),
requireCatalog().catalogDao(),
query,
)
}
suspend fun clearDetailedListeningHistory(): Map<String, Any?> {
initialize()
return ListeningStatsEngine.clear(requireUser())
}
suspend fun listRemoteSources(): List<Map<String, Any?>> {
initialize()
return requireUser().userDao().getRemoteSources().map(RemoteSourceEntity::toBridgeMap)
@@ -2762,6 +2792,7 @@ class AstraLibraryRepository private constructor(
private fun buildUserDatabase(): AstraUserDatabase =
Room.databaseBuilder(applicationContext, AstraUserDatabase::class.java, USER_DB_NAME)
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
.addMigrations(USER_MIGRATION_1_2)
.build()
private fun buildCatalogDatabase(): AstraCatalogDatabase =
@@ -0,0 +1,785 @@
package expo.modules.astralibraryscanner.data
import androidx.room.withTransaction
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import java.util.UUID
import kotlin.math.max
import kotlin.math.min
internal const val LISTENING_HISTORY_ENABLED_KEY = "listening_history_enabled"
private const val LISTENING_QUALIFICATION_SECONDS = 15.0
private const val SHORT_TRACK_COMPLETION_TOLERANCE_SECONDS = 0.5
private const val SHORT_TRACK_COMPLETION_TOLERANCE_RATIO = 0.1
private const val TOP_LIMIT = 10
private const val CATALOG_BATCH_SIZE = 400
private data class ListeningCheckpointResult(
val accepted: Boolean,
val qualifiedNow: Boolean,
val history: PlaybackHistoryEntity? = null,
)
private data class ActivityBucket(
val startAt: Long,
val endAt: Long,
val label: String,
var listenedSeconds: Double = 0.0,
var qualifiedPlays: Long = 0,
)
private data class ListeningIdentity(
val trackKey: String,
val trackPath: String?,
val title: String,
val artist: String,
val artistNamesJson: String?,
val album: String,
val albumArtist: String?,
val albumArtistNamesJson: String?,
val albumKey: String,
val artworkHash: String?,
val sourceType: String,
val sourceId: Long?,
val artworkSourceId: String?,
val available: Boolean,
)
private data class TrackAggregate(
val key: String,
var trackPath: String?,
var title: String,
var artist: String,
var album: String,
var artworkHash: String?,
var sourceType: String,
var sourceId: Long?,
var artworkSourceId: String?,
var available: Boolean,
var listenedSeconds: Double = 0.0,
var qualifiedPlays: Long = 0,
)
private data class ArtistAggregate(
val key: String,
var artist: String,
var artworkHash: String?,
var sourceType: String,
var sourceId: Long?,
var artworkSourceId: String?,
var available: Boolean,
var listenedSeconds: Double = 0.0,
var qualifiedPlays: Long = 0,
)
private data class AlbumAggregate(
val key: String,
var album: String,
var artist: String,
var artworkHash: String?,
var sourceType: String,
var sourceId: Long?,
var artworkSourceId: String?,
var available: Boolean,
var listenedSeconds: Double = 0.0,
var qualifiedPlays: Long = 0,
)
internal object ListeningStatsEngine {
suspend fun status(database: AstraUserDatabase): Map<String, Any?> {
val dao = database.userDao()
val meta = ensureMeta(dao)
return statusMap(meta, listeningEnabled(dao))
}
suspend fun checkpoint(
database: AstraUserDatabase,
catalogDao: CatalogDao,
payload: Map<String, Any?>,
): Map<String, Any?> {
val dao = database.userDao()
val meta = ensureMeta(dao)
if (!listeningEnabled(dao)) {
return checkpointMap(false, false, meta, false)
}
val generation = payload.string("generation")
val sessionKey = payload.string("sessionKey")
val segmentKey = payload.string("segmentKey")
val trackPath = payload.string("trackPath")
if (
generation.isEmpty() ||
generation != meta.generation ||
sessionKey.isEmpty() ||
(segmentKey.isEmpty() && !payload.boolean("finalizeSession")) ||
trackPath.isEmpty()
) {
return checkpointMap(false, false, meta, true)
}
val track = catalogDao.getActiveTrack(trackPath)
?: return checkpointMap(false, false, meta, true)
val observedAt = payload.long("observedAt", System.currentTimeMillis()).coerceAtLeast(0)
val sessionStartedAt = payload.long("sessionStartedAt", observedAt).coerceIn(0, observedAt)
val segmentStartedAt = payload.long("segmentStartedAt", observedAt).coerceIn(0, observedAt)
val sessionListenedSeconds = payload.double("sessionListenedSeconds").finiteNonNegative()
val segmentListenedSeconds = min(
sessionListenedSeconds,
payload.double("segmentListenedSeconds").finiteNonNegative(),
)
val durationSeconds = max(
track.duration.finiteNonNegative(),
payload.double("trackDurationSeconds").finiteNonNegative(),
)
val finalizeSegment = payload.boolean("finalizeSegment")
val finalizeSession = payload.boolean("finalizeSession")
val completedNaturally = payload.boolean("completedNaturally")
val qualificationEligible = payload["qualificationEligible"] != false
val result = database.withTransaction {
val currentMeta = ensureMeta(dao)
if (currentMeta.generation != generation || !listeningEnabled(dao)) {
return@withTransaction ListeningCheckpointResult(false, false)
}
if (sessionListenedSeconds > 0 && currentMeta.startedAt == null) {
dao.putListeningHistoryMeta(currentMeta.copy(startedAt = segmentStartedAt))
}
val existingSession = dao.getListeningSession(sessionKey)
val session = if (existingSession == null) {
ListeningSessionEntity(
sessionKey = sessionKey,
generation = generation,
trackPath = track.path,
title = track.title,
artist = track.artist,
artistNamesJson = track.artistNamesJson,
album = track.album,
albumArtist = track.albumArtist,
albumArtistNamesJson = track.albumArtistNamesJson,
albumIdentityKey = track.albumIdentityKey,
artworkHash = track.artworkHash,
sourceType = track.sourceType,
sourceId = track.sourceId,
artworkSourceId = track.artworkSourceId,
durationSeconds = durationSeconds,
startedAt = sessionStartedAt,
endedAt = observedAt.takeIf { finalizeSession },
listenedSeconds = sessionListenedSeconds,
)
} else {
existingSession.copy(
durationSeconds = max(existingSession.durationSeconds, durationSeconds),
endedAt = maxNullable(existingSession.endedAt, observedAt.takeIf { finalizeSession }),
listenedSeconds = max(existingSession.listenedSeconds, sessionListenedSeconds),
)
}
dao.putListeningSession(session)
if (segmentKey.isNotEmpty()) {
val existingSegment = dao.getListeningSegment(sessionKey, segmentKey)
val segment = if (existingSegment == null) {
ListeningSegmentEntity(
sessionKey = sessionKey,
segmentKey = segmentKey,
generation = generation,
startedAt = segmentStartedAt,
lastObservedAt = observedAt,
endedAt = observedAt.takeIf { finalizeSegment || finalizeSession },
listenedSeconds = segmentListenedSeconds,
)
} else {
existingSegment.copy(
lastObservedAt = max(existingSegment.lastObservedAt, observedAt),
endedAt = maxNullable(
existingSegment.endedAt,
observedAt.takeIf { finalizeSegment || finalizeSession },
),
listenedSeconds = max(existingSegment.listenedSeconds, segmentListenedSeconds),
)
}
dao.putListeningSegment(segment)
}
val persisted = dao.getListeningSession(sessionKey) ?: session
val qualifies = qualificationEligible &&
persisted.qualifiedAt == null &&
sessionQualifies(
listenedSeconds = persisted.listenedSeconds,
durationSeconds = persisted.durationSeconds,
finalizeSession = finalizeSession,
completedNaturally = completedNaturally,
)
if (!qualifies || dao.qualifyListeningSession(sessionKey, generation, observedAt) == 0) {
return@withTransaction ListeningCheckpointResult(true, false)
}
val previousHistory = dao.getPlaybackHistory(track.path)
val history = PlaybackHistoryEntity(
trackPath = track.path,
lastPlayedAt = observedAt,
playCount = (previousHistory?.playCount ?: 0) + 1,
)
dao.putPlaybackHistory(history)
ListeningCheckpointResult(true, true, history)
}
result.history?.let { history ->
runCatching {
catalogDao.putTrackUserFacts(
listOf(
TrackUserFactEntity(
path = track.path,
isFavorite = dao.isFavorite(track.path),
playCount = history.playCount,
lastPlayedAt = history.lastPlayedAt,
),
),
)
}
}
return checkpointMap(
result.accepted,
result.qualifiedNow,
ensureMeta(dao),
listeningEnabled(dao),
)
}
suspend fun clear(database: AstraUserDatabase): Map<String, Any?> {
val dao = database.userDao()
val meta = database.withTransaction {
dao.clearListeningSegments()
dao.clearListeningSessions()
dao.clearListeningHistoryMeta()
ListeningHistoryMetaEntity(generation = UUID.randomUUID().toString()).also {
dao.putListeningHistoryMeta(it)
}
}
return statusMap(meta, listeningEnabled(dao))
}
suspend fun dashboard(
database: AstraUserDatabase,
catalogDao: CatalogDao,
query: Map<String, Any?>,
): Map<String, Any?> {
val dao = database.userDao()
val meta = ensureMeta(dao)
val enabled = listeningEnabled(dao)
val range = when (query.string("range")) {
"7d", "1y", "all" -> query.string("range")
else -> "30d"
}
val rankingMetric = if (query.string("rankingMetric") == "time") "time" else "plays"
val groupingMode = if (query.string("artistGroupingMode") == "fileTags") "fileTags" else "astra"
val now = query.long("now", System.currentTimeMillis()).coerceAtLeast(0)
val rangeStartAt = rangeStart(range, now, meta.startedAt)
val (granularity, buckets) = buildBuckets(range, rangeStartAt, now)
if (rangeStartAt == null) {
return emptyDashboard(meta, enabled, range, rankingMetric, now, granularity, buckets)
}
val sessions = dao.getListeningSessionsInRange(meta.generation, rangeStartAt, now)
if (sessions.isEmpty()) {
return emptyDashboard(meta, enabled, range, rankingMetric, now, granularity, buckets)
}
val segments = dao.getListeningSegmentsInRange(meta.generation, rangeStartAt, now)
val activeTracks = sessions
.map(ListeningSessionEntity::trackPath)
.distinct()
.chunked(CATALOG_BATCH_SIZE)
.flatMap { paths -> catalogDao.getActiveTracks(paths) }
.associateBy(ActiveTrackView::path)
val sessionsByKey = sessions.associateBy(ListeningSessionEntity::sessionKey)
val trackAggregates = linkedMapOf<String, TrackAggregate>()
val artistAggregates = linkedMapOf<String, ArtistAggregate>()
val albumAggregates = linkedMapOf<String, AlbumAggregate>()
val tracksPlayed = linkedSetOf<String>()
val activeDays = linkedSetOf<Long>()
var listenedSeconds = 0.0
var qualifiedPlays = 0L
fun ensureAggregates(identity: ListeningIdentity): Triple<TrackAggregate, List<ArtistAggregate>, AlbumAggregate> {
val track = trackAggregates.getOrPut(identity.trackKey) {
TrackAggregate(
key = identity.trackKey,
trackPath = identity.trackPath,
title = identity.title,
artist = identity.artist,
album = identity.album,
artworkHash = identity.artworkHash,
sourceType = identity.sourceType,
sourceId = identity.sourceId,
artworkSourceId = identity.artworkSourceId,
available = identity.available,
)
}.also {
it.available = it.available || identity.available
if (identity.available) {
it.trackPath = identity.trackPath
it.artworkHash = identity.artworkHash ?: it.artworkHash
it.sourceType = identity.sourceType
it.sourceId = identity.sourceId
it.artworkSourceId = identity.artworkSourceId
}
}
val artists = browseArtists(identity, groupingMode).map { display ->
val key = normalizeKey(display).ifEmpty { display }
artistAggregates.getOrPut(key) {
ArtistAggregate(
key = key,
artist = display,
artworkHash = identity.artworkHash,
sourceType = identity.sourceType,
sourceId = identity.sourceId,
artworkSourceId = identity.artworkSourceId,
available = identity.available,
)
}.also {
it.available = it.available || identity.available
it.artworkHash = it.artworkHash ?: identity.artworkHash
it.sourceId = it.sourceId ?: identity.sourceId
it.artworkSourceId = it.artworkSourceId ?: identity.artworkSourceId
}
}
val album = albumAggregates.getOrPut(identity.albumKey) {
AlbumAggregate(
key = identity.albumKey,
album = identity.album,
artist = identity.albumArtist?.takeIf(String::isNotBlank) ?: identity.artist,
artworkHash = identity.artworkHash,
sourceType = identity.sourceType,
sourceId = identity.sourceId,
artworkSourceId = identity.artworkSourceId,
available = identity.available,
)
}.also {
it.available = it.available || identity.available
it.artworkHash = it.artworkHash ?: identity.artworkHash
it.sourceId = it.sourceId ?: identity.sourceId
it.artworkSourceId = it.artworkSourceId ?: identity.artworkSourceId
}
return Triple(track, artists, album)
}
for (segment in segments) {
val session = sessionsByKey[segment.sessionKey] ?: continue
val overlap = overlapSeconds(segment, rangeStartAt, now + 1)
if (overlap <= 0) continue
val identity = identity(session, activeTracks[session.trackPath])
val (track, artists, album) = ensureAggregates(identity)
track.listenedSeconds += overlap
artists.forEach { it.listenedSeconds += overlap }
album.listenedSeconds += overlap
tracksPlayed += identity.trackKey
listenedSeconds += overlap
buckets.forEach { bucket ->
bucket.listenedSeconds += overlapSeconds(
segment,
bucket.startAt,
min(bucket.endAt, now + 1),
)
}
var day = startOfDay(max(segment.startedAt, rangeStartAt))
val dayEnd = min(segment.lastObservedAt, now)
while (day <= dayEnd) {
val nextDay = addDays(day, 1)
if (overlapSeconds(segment, day, nextDay) > 0) activeDays += day
day = nextDay
}
}
for (session in sessions) {
val qualifiedAt = session.qualifiedAt ?: continue
if (qualifiedAt < rangeStartAt || qualifiedAt > now) continue
val identity = identity(session, activeTracks[session.trackPath])
val (track, artists, album) = ensureAggregates(identity)
track.qualifiedPlays += 1
artists.forEach { it.qualifiedPlays += 1 }
album.qualifiedPlays += 1
qualifiedPlays += 1
buckets.firstOrNull { qualifiedAt >= it.startAt && qualifiedAt < it.endAt }
?.let { it.qualifiedPlays += 1 }
}
val trackComparator = aggregateComparator<TrackAggregate>(
rankingMetric,
{ it.qualifiedPlays },
{ it.listenedSeconds },
{ "${it.title}\u0000${it.artist}" },
)
val artistComparator = aggregateComparator<ArtistAggregate>(
rankingMetric,
{ it.qualifiedPlays },
{ it.listenedSeconds },
{ it.artist },
)
val albumComparator = aggregateComparator<AlbumAggregate>(
rankingMetric,
{ it.qualifiedPlays },
{ it.listenedSeconds },
{ "${it.album}\u0000${it.artist}" },
)
return dashboardMap(
meta = meta,
enabled = enabled,
range = range,
rankingMetric = rankingMetric,
rangeStartAt = rangeStartAt,
rangeEndAt = now,
granularity = granularity,
summary = mapOf(
"listenedSeconds" to listenedSeconds,
"qualifiedPlays" to qualifiedPlays.toDouble(),
"tracksPlayed" to tracksPlayed.size.toDouble(),
"activeDays" to activeDays.size.toDouble(),
),
buckets = buckets,
tracks = trackAggregates.values.sortedWith(trackComparator).take(TOP_LIMIT),
artists = artistAggregates.values.sortedWith(artistComparator).take(TOP_LIMIT),
albums = albumAggregates.values.sortedWith(albumComparator).take(TOP_LIMIT),
)
}
private suspend fun ensureMeta(dao: UserDao): ListeningHistoryMetaEntity {
val existing = dao.getListeningHistoryMeta()
if (existing != null) return existing
val created = ListeningHistoryMetaEntity(generation = UUID.randomUUID().toString())
dao.putListeningHistoryMeta(created)
return dao.getListeningHistoryMeta() ?: created
}
private suspend fun listeningEnabled(dao: UserDao): Boolean =
dao.getSetting(LISTENING_HISTORY_ENABLED_KEY) != "0"
}
private fun sessionQualifies(
listenedSeconds: Double,
durationSeconds: Double,
finalizeSession: Boolean,
completedNaturally: Boolean,
): Boolean {
if (durationSeconds > 0 && durationSeconds < LISTENING_QUALIFICATION_SECONDS) {
if (!finalizeSession || !completedNaturally) return false
val tolerance = min(
SHORT_TRACK_COMPLETION_TOLERANCE_SECONDS,
durationSeconds * SHORT_TRACK_COMPLETION_TOLERANCE_RATIO,
)
return listenedSeconds >= durationSeconds - tolerance
}
return listenedSeconds >= LISTENING_QUALIFICATION_SECONDS
}
private fun statusMap(meta: ListeningHistoryMetaEntity, enabled: Boolean): Map<String, Any?> =
mapOf(
"generation" to meta.generation,
"startedAt" to meta.startedAt?.toDouble(),
"enabled" to enabled,
)
private fun checkpointMap(
accepted: Boolean,
qualifiedNow: Boolean,
meta: ListeningHistoryMetaEntity,
enabled: Boolean,
): Map<String, Any?> =
mapOf(
"accepted" to accepted,
"qualifiedNow" to qualifiedNow,
"status" to statusMap(meta, enabled),
)
private fun emptyDashboard(
meta: ListeningHistoryMetaEntity,
enabled: Boolean,
range: String,
rankingMetric: String,
rangeEndAt: Long,
granularity: String,
buckets: List<ActivityBucket>,
): Map<String, Any?> =
dashboardMap(
meta = meta,
enabled = enabled,
range = range,
rankingMetric = rankingMetric,
rangeStartAt = rangeStart(range, rangeEndAt, meta.startedAt),
rangeEndAt = rangeEndAt,
granularity = granularity,
summary = mapOf(
"listenedSeconds" to 0.0,
"qualifiedPlays" to 0.0,
"tracksPlayed" to 0.0,
"activeDays" to 0.0,
),
buckets = buckets,
tracks = emptyList(),
artists = emptyList(),
albums = emptyList(),
)
private fun dashboardMap(
meta: ListeningHistoryMetaEntity,
enabled: Boolean,
range: String,
rankingMetric: String,
rangeStartAt: Long?,
rangeEndAt: Long,
granularity: String,
summary: Map<String, Any?>,
buckets: List<ActivityBucket>,
tracks: List<TrackAggregate>,
artists: List<ArtistAggregate>,
albums: List<AlbumAggregate>,
): Map<String, Any?> =
mapOf(
"status" to statusMap(meta, enabled),
"range" to range,
"rankingMetric" to rankingMetric,
"rangeStartAt" to rangeStartAt?.toDouble(),
"rangeEndAt" to rangeEndAt.toDouble(),
"granularity" to granularity,
"summary" to summary,
"activity" to buckets.map { bucket ->
mapOf(
"startAt" to bucket.startAt.toDouble(),
"endAt" to bucket.endAt.toDouble(),
"label" to bucket.label,
"listenedSeconds" to bucket.listenedSeconds,
"qualifiedPlays" to bucket.qualifiedPlays.toDouble(),
)
},
"topTracks" to tracks.map { aggregate ->
mapOf(
"key" to aggregate.key,
"trackPath" to aggregate.trackPath,
"title" to aggregate.title,
"artist" to aggregate.artist,
"album" to aggregate.album,
"artworkHash" to aggregate.artworkHash,
"sourceType" to aggregate.sourceType,
"sourceId" to aggregate.sourceId?.toDouble(),
"artworkSourceId" to aggregate.artworkSourceId,
"listenedSeconds" to aggregate.listenedSeconds,
"qualifiedPlays" to aggregate.qualifiedPlays.toDouble(),
"available" to aggregate.available,
)
},
"topArtists" to artists.map { aggregate ->
mapOf(
"key" to aggregate.key,
"artist" to aggregate.artist,
"artworkHash" to aggregate.artworkHash,
"sourceType" to aggregate.sourceType,
"sourceId" to aggregate.sourceId?.toDouble(),
"artworkSourceId" to aggregate.artworkSourceId,
"listenedSeconds" to aggregate.listenedSeconds,
"qualifiedPlays" to aggregate.qualifiedPlays.toDouble(),
"available" to aggregate.available,
)
},
"topAlbums" to albums.map { aggregate ->
mapOf(
"key" to aggregate.key,
"album" to aggregate.album,
"artist" to aggregate.artist,
"artworkHash" to aggregate.artworkHash,
"sourceType" to aggregate.sourceType,
"sourceId" to aggregate.sourceId?.toDouble(),
"artworkSourceId" to aggregate.artworkSourceId,
"listenedSeconds" to aggregate.listenedSeconds,
"qualifiedPlays" to aggregate.qualifiedPlays.toDouble(),
"available" to aggregate.available,
)
},
)
private fun identity(
session: ListeningSessionEntity,
current: ActiveTrackView?,
): ListeningIdentity {
val available = current != null
return ListeningIdentity(
trackKey = "track:${session.trackPath}",
trackPath = current?.path,
title = current?.title?.trim()?.takeIf { it.isNotEmpty() } ?: session.title,
artist = current?.artist?.trim()?.takeIf { it.isNotEmpty() } ?: session.artist,
artistNamesJson = current?.artistNamesJson ?: session.artistNamesJson,
album = current?.album?.trim()?.takeIf { it.isNotEmpty() } ?: session.album,
albumArtist = current?.albumArtist?.trim()?.takeIf { it.isNotEmpty() } ?: session.albumArtist,
albumArtistNamesJson = current?.albumArtistNamesJson ?: session.albumArtistNamesJson,
albumKey = current?.albumIdentityKey ?: session.albumIdentityKey,
artworkHash = current?.artworkHash ?: session.artworkHash,
sourceType = current?.sourceType ?: session.sourceType,
sourceId = current?.sourceId ?: session.sourceId,
artworkSourceId = current?.artworkSourceId ?: session.artworkSourceId,
available = available,
)
}
private fun browseArtists(identity: ListeningIdentity, groupingMode: String): List<String> {
val strict = identity.albumArtist?.trim()?.takeIf { it.isNotEmpty() } ?: identity.artist
if (groupingMode == "fileTags") return listOf(strict.ifBlank { "Unknown Artist" })
val result = LinkedHashMap<String, String>()
fun add(value: String) {
val display = normalizeDisplay(value)
val key = normalizeKey(display)
if (key.isNotEmpty()) result.putIfAbsent(key, display)
}
val albumNames = deserializeArtistNames(identity.albumArtistNamesJson)
val trackNames = deserializeArtistNames(identity.artistNamesJson)
val primary = albumNames.firstOrNull()
?: splitArtists(identity.albumArtist.orEmpty(), splitAmpersand = false).firstOrNull()
?: trackNames.firstOrNull()
?: splitArtists(identity.artist, splitAmpersand = true).firstOrNull()
?: "Unknown Artist"
add(primary)
val artists = trackNames.ifEmpty { splitArtists(identity.artist, splitAmpersand = true) }
artists.forEach(::add)
if (artists.isEmpty()) {
albumNames.ifEmpty { splitArtists(identity.albumArtist.orEmpty(), splitAmpersand = false) }
.forEach(::add)
}
return result.values.toList().ifEmpty { listOf("Unknown Artist") }
}
private fun splitArtists(raw: String, splitAmpersand: Boolean): List<String> {
var unified = normalizeDisplay(raw)
.replace(Regex("\\s*;\\s*"), ",")
.replace(Regex("\\s+[x×]\\s+", RegexOption.IGNORE_CASE), ",")
.replace(Regex("\\s+(?:feat\\.?|ft\\.?|featuring|with)\\s+", RegexOption.IGNORE_CASE), ",")
if (splitAmpersand) unified = unified.replace(Regex("\\s+&\\s+"), ",")
val result = LinkedHashMap<String, String>()
unified.split(',').forEach { part ->
val display = normalizeDisplay(part)
val key = normalizeKey(display)
if (key.isNotEmpty()) result.putIfAbsent(key, display)
}
return result.values.toList()
}
private fun normalizeDisplay(value: String): String = value.replace(Regex("\\s+"), " ").trim()
private fun normalizeKey(value: String): String = normalizeDisplay(value).lowercase(Locale.ROOT)
private fun overlapSeconds(segment: ListeningSegmentEntity, startAt: Long, endAt: Long): Double {
val segmentStart = segment.startedAt
val segmentEnd = max(segmentStart, segment.lastObservedAt)
val listened = segment.listenedSeconds.finiteNonNegative()
if (listened <= 0 || segmentEnd <= startAt || segmentStart >= endAt) return 0.0
val wallDuration = segmentEnd - segmentStart
if (wallDuration <= 0) return if (segmentStart in startAt until endAt) listened else 0.0
val overlap = max(0L, min(segmentEnd, endAt) - max(segmentStart, startAt))
return listened * min(1.0, overlap.toDouble() / wallDuration.toDouble())
}
private fun rangeStart(range: String, now: Long, baseline: Long?): Long? {
if (range == "all") return baseline
val today = startOfDay(now)
return when (range) {
"7d" -> addDays(today, -6)
"1y" -> addDays(today, -364)
else -> addDays(today, -29)
}
}
private fun buildBuckets(
range: String,
rangeStartAt: Long?,
now: Long,
): Pair<String, List<ActivityBucket>> {
val granularity = when (range) {
"7d", "30d" -> "day"
"1y" -> "week"
else -> "month"
}
if (rangeStartAt == null) return granularity to emptyList()
val labelFormat = SimpleDateFormat(
if (granularity == "month") "MMM yyyy" else "MMM d",
Locale.getDefault(),
)
val buckets = mutableListOf<ActivityBucket>()
var cursor = if (granularity == "month") startOfMonth(rangeStartAt) else startOfDay(rangeStartAt)
while (cursor <= now) {
val end = when (granularity) {
"week" -> addDays(cursor, 7)
"month" -> addMonths(cursor, 1)
else -> addDays(cursor, 1)
}
buckets += ActivityBucket(cursor, end, labelFormat.format(Date(cursor)))
cursor = end
}
return granularity to buckets
}
private fun startOfDay(timestamp: Long): Long =
Calendar.getInstance().apply {
timeInMillis = timestamp
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
private fun startOfMonth(timestamp: Long): Long =
Calendar.getInstance().apply {
timeInMillis = timestamp
set(Calendar.DAY_OF_MONTH, 1)
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
private fun addDays(timestamp: Long, days: Int): Long =
Calendar.getInstance().apply {
timeInMillis = timestamp
add(Calendar.DAY_OF_MONTH, days)
}.timeInMillis
private fun addMonths(timestamp: Long, months: Int): Long =
Calendar.getInstance().apply {
timeInMillis = timestamp
add(Calendar.MONTH, months)
}.timeInMillis
private fun <T> aggregateComparator(
metric: String,
plays: (T) -> Long,
seconds: (T) -> Double,
label: (T) -> String,
): Comparator<T> = Comparator { left, right ->
val primary = if (metric == "time") {
seconds(right).compareTo(seconds(left))
} else {
plays(right).compareTo(plays(left))
}
if (primary != 0) return@Comparator primary
val secondary = if (metric == "time") {
plays(right).compareTo(plays(left))
} else {
seconds(right).compareTo(seconds(left))
}
if (secondary != 0) return@Comparator secondary
label(left).compareTo(label(right), ignoreCase = true)
}
private fun maxNullable(left: Long?, right: Long?): Long? = when {
left == null -> right
right == null -> left
else -> max(left, right)
}
private fun Double.finiteNonNegative(): Double =
if (isFinite()) coerceAtLeast(0.0) else 0.0
private fun Map<String, Any?>.string(key: String): String = (this[key] as? String)?.trim().orEmpty()
private fun Map<String, Any?>.boolean(key: String): Boolean = this[key] == true
private fun Map<String, Any?>.double(key: String): Double = (this[key] as? Number)?.toDouble() ?: 0.0
private fun Map<String, Any?>.long(key: String, fallback: Long): Long =
(this[key] as? Number)?.toDouble()?.takeIf(Double::isFinite)?.toLong() ?: fallback
@@ -9,6 +9,8 @@ import androidx.room.Query
import androidx.room.RoomDatabase
import androidx.room.Transaction
import androidx.room.Upsert
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
data class RemotePlaylistSyncPlan(
val playlist: PlaylistEntity,
@@ -177,6 +179,93 @@ interface UserDao {
@Upsert
suspend fun putPlaybackHistories(history: List<PlaybackHistoryEntity>)
@Query("SELECT * FROM listening_history_meta WHERE id = 1")
suspend fun getListeningHistoryMeta(): ListeningHistoryMetaEntity?
@Upsert
suspend fun putListeningHistoryMeta(meta: ListeningHistoryMetaEntity)
@Query("SELECT * FROM listening_sessions WHERE session_key = :sessionKey")
suspend fun getListeningSession(sessionKey: String): ListeningSessionEntity?
@Upsert
suspend fun putListeningSession(session: ListeningSessionEntity)
@Query(
"""
SELECT * FROM listening_sessions
WHERE generation = :generation
AND (
session_key IN (
SELECT session_key FROM listening_segments
WHERE generation = :generation
AND last_observed_at >= :startAt
AND started_at <= :endAt
)
OR (qualified_at >= :startAt AND qualified_at <= :endAt)
)
ORDER BY started_at
""",
)
suspend fun getListeningSessionsInRange(
generation: String,
startAt: Long,
endAt: Long,
): List<ListeningSessionEntity>
@Query(
"""
UPDATE listening_sessions
SET qualified_at = :qualifiedAt
WHERE session_key = :sessionKey
AND generation = :generation
AND qualified_at IS NULL
""",
)
suspend fun qualifyListeningSession(
sessionKey: String,
generation: String,
qualifiedAt: Long,
): Int
@Query(
"""
SELECT * FROM listening_segments
WHERE generation = :generation
AND last_observed_at >= :startAt
AND started_at <= :endAt
ORDER BY started_at
""",
)
suspend fun getListeningSegmentsInRange(
generation: String,
startAt: Long,
endAt: Long,
): List<ListeningSegmentEntity>
@Query(
"""
SELECT * FROM listening_segments
WHERE session_key = :sessionKey AND segment_key = :segmentKey
""",
)
suspend fun getListeningSegment(
sessionKey: String,
segmentKey: String,
): ListeningSegmentEntity?
@Upsert
suspend fun putListeningSegment(segment: ListeningSegmentEntity)
@Query("DELETE FROM listening_segments")
suspend fun clearListeningSegments()
@Query("DELETE FROM listening_sessions")
suspend fun clearListeningSessions()
@Query("DELETE FROM listening_history_meta")
suspend fun clearListeningHistoryMeta()
@Query("SELECT * FROM remote_sources ORDER BY created_at, id")
suspend fun getRemoteSources(): List<RemoteSourceEntity>
@@ -423,6 +512,9 @@ interface UserDao {
PlaylistTrackEntity::class,
FavoriteEntity::class,
PlaybackHistoryEntity::class,
ListeningHistoryMetaEntity::class,
ListeningSessionEntity::class,
ListeningSegmentEntity::class,
RemoteSourceEntity::class,
FavoriteTombstoneEntity::class,
PendingFavoriteEntity::class,
@@ -433,9 +525,86 @@ interface UserDao {
PlaybackOriginalQueueEntryEntity::class,
SnapshotMetadataEntity::class,
],
version = 1,
version = 2,
exportSchema = true,
)
abstract class AstraUserDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}
internal val USER_MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `listening_history_meta` (
`id` INTEGER NOT NULL,
`generation` TEXT NOT NULL,
`started_at` INTEGER,
PRIMARY KEY(`id`)
)
""".trimIndent(),
)
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `listening_sessions` (
`session_key` TEXT NOT NULL,
`generation` TEXT NOT NULL,
`track_path` TEXT NOT NULL,
`title` TEXT NOT NULL,
`artist` TEXT NOT NULL,
`artist_names_json` TEXT,
`album` TEXT NOT NULL,
`album_artist` TEXT,
`album_artist_names_json` TEXT,
`album_identity_key` TEXT NOT NULL,
`artwork_hash` TEXT,
`source_type` TEXT NOT NULL,
`source_id` INTEGER,
`artwork_source_id` TEXT,
`duration_seconds` REAL NOT NULL,
`started_at` INTEGER NOT NULL,
`ended_at` INTEGER,
`listened_seconds` REAL NOT NULL,
`qualified_at` INTEGER,
PRIMARY KEY(`session_key`)
)
""".trimIndent(),
)
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `listening_segments` (
`session_key` TEXT NOT NULL,
`segment_key` TEXT NOT NULL,
`generation` TEXT NOT NULL,
`started_at` INTEGER NOT NULL,
`last_observed_at` INTEGER NOT NULL,
`ended_at` INTEGER,
`listened_seconds` REAL NOT NULL,
PRIMARY KEY(`session_key`, `segment_key`),
FOREIGN KEY(`session_key`) REFERENCES `listening_sessions`(`session_key`)
ON UPDATE NO ACTION ON DELETE CASCADE
)
""".trimIndent(),
)
database.execSQL(
"CREATE INDEX IF NOT EXISTS `index_listening_sessions_generation_started_at` " +
"ON `listening_sessions` (`generation`, `started_at`)",
)
database.execSQL(
"CREATE INDEX IF NOT EXISTS `index_listening_sessions_generation_qualified_at` " +
"ON `listening_sessions` (`generation`, `qualified_at`)",
)
database.execSQL(
"CREATE INDEX IF NOT EXISTS `index_listening_sessions_track_path` " +
"ON `listening_sessions` (`track_path`)",
)
database.execSQL(
"CREATE INDEX IF NOT EXISTS `index_listening_segments_generation_started_at_last_observed_at` " +
"ON `listening_segments` (`generation`, `started_at`, `last_observed_at`)",
)
database.execSQL(
"CREATE INDEX IF NOT EXISTS `index_listening_segments_session_key` " +
"ON `listening_segments` (`session_key`)",
)
}
}
@@ -93,6 +93,71 @@ data class PlaybackHistoryEntity(
@ColumnInfo(name = "play_count") val playCount: Long = 1,
)
@Entity(tableName = "listening_history_meta")
data class ListeningHistoryMetaEntity(
@PrimaryKey val id: Int = 1,
val generation: String,
@ColumnInfo(name = "started_at") val startedAt: Long? = null,
)
@Entity(
tableName = "listening_sessions",
indices = [
Index(value = ["generation", "started_at"]),
Index(value = ["generation", "qualified_at"]),
Index(value = ["track_path"]),
],
)
data class ListeningSessionEntity(
@PrimaryKey
@ColumnInfo(name = "session_key")
val sessionKey: String,
val generation: String,
@ColumnInfo(name = "track_path") val trackPath: String,
val title: String,
val artist: String,
@ColumnInfo(name = "artist_names_json") val artistNamesJson: String? = null,
val album: String,
@ColumnInfo(name = "album_artist") val albumArtist: String? = null,
@ColumnInfo(name = "album_artist_names_json") val albumArtistNamesJson: String? = null,
@ColumnInfo(name = "album_identity_key") val albumIdentityKey: String,
@ColumnInfo(name = "artwork_hash") val artworkHash: String? = null,
@ColumnInfo(name = "source_type") val sourceType: String,
@ColumnInfo(name = "source_id") val sourceId: Long? = null,
@ColumnInfo(name = "artwork_source_id") val artworkSourceId: String? = null,
@ColumnInfo(name = "duration_seconds") val durationSeconds: Double,
@ColumnInfo(name = "started_at") val startedAt: Long,
@ColumnInfo(name = "ended_at") val endedAt: Long? = null,
@ColumnInfo(name = "listened_seconds") val listenedSeconds: Double = 0.0,
@ColumnInfo(name = "qualified_at") val qualifiedAt: Long? = null,
)
@Entity(
tableName = "listening_segments",
primaryKeys = ["session_key", "segment_key"],
foreignKeys = [
ForeignKey(
entity = ListeningSessionEntity::class,
parentColumns = ["session_key"],
childColumns = ["session_key"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [
Index(value = ["generation", "started_at", "last_observed_at"]),
Index(value = ["session_key"]),
],
)
data class ListeningSegmentEntity(
@ColumnInfo(name = "session_key") val sessionKey: String,
@ColumnInfo(name = "segment_key") val segmentKey: String,
val generation: String,
@ColumnInfo(name = "started_at") val startedAt: Long,
@ColumnInfo(name = "last_observed_at") val lastObservedAt: Long,
@ColumnInfo(name = "ended_at") val endedAt: Long? = null,
@ColumnInfo(name = "listened_seconds") val listenedSeconds: Double = 0.0,
)
@Entity(
tableName = "remote_sources",
indices = [Index(value = ["type", "name"])],
+4
View File
@@ -389,6 +389,10 @@ declare class AstraLibraryDataModuleType extends NativeModule<AstraLibraryDataEv
values: Record<string, unknown>
): Promise<NativePlaybackWindow<T> | null>;
recordTrackPlayed(path: string): Promise<boolean>;
getListeningHistoryStatus<T>(): Promise<T>;
checkpointListeningSession<T>(payload: Record<string, unknown>): Promise<T>;
getListeningStatsDashboard<T>(query: Record<string, unknown>): Promise<T>;
clearDetailedListeningHistory<T>(): Promise<T>;
getRecentlyPlayed<T>(limit: number): Promise<T[]>;
listRemoteSources<T>(): Promise<T[]>;
getRemoteSource<T>(sourceId: number): Promise<T | null>;
+2 -1
View File
@@ -77,13 +77,14 @@
"test:audio-startup": "node --experimental-strip-types --test src/audio/dspStartupCoordinator.test.mts src/audio/dspStartupGain.test.mts",
"test:seek-bar": "node --experimental-strip-types --test src/audio/playbackClock.test.mts src/audio/playbackNavigation.test.mts src/audio/playbackProgressProjection.test.mts src/components/waveformScrubDetents.test.mts",
"test:recent-play": "node --experimental-strip-types --test src/audio/recentPlayTracking.test.mts",
"test:listening-stats": "node --experimental-strip-types --test src/audio/listeningHistoryState.test.mts src/listeningStats/shareModel.test.mts",
"test:lyrics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lyrics/parsing.test.mts src/lyrics/presentation.test.mts src/lyrics/displaySettings.test.mts src/lyrics/embedded.test.mts src/lyrics/resolver.test.mts",
"test:sleep": "node --experimental-strip-types --test src/audio/sleepTimerState.test.mts",
"test:troubleshooting": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/cacheInvalidation.test.mts",
"test:settings-search": "node --experimental-strip-types --test src/components/search/settingsSearchRoutes.test.mts",
"test:now-playing-layout": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/player/nowPlayingLayout.test.mts src/components/player/nowPlayingPreferences.test.mts src/components/player/nowPlayingDismiss.test.mts src/playback/playbackTargetPresentation.test.mts",
"test:memory-lifecycle": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/delayedPresence.test.mts scripts/android-memory-profile.test.mjs",
"test:ui-navigation": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/stores/playerPresence.test.mts src/navigation/tabsAnchor.test.mts src/navigation/libraryDetailBack.test.mts src/navigation/homeLibraryNavigation.test.mts src/navigation/tabTransition.test.mts",
"test:ui-navigation": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/stores/playerPresence.test.mts src/navigation/tabsAnchor.test.mts src/navigation/libraryDetailBack.test.mts src/navigation/homeLibraryNavigation.test.mts src/navigation/tabTransition.test.mts src/navigation/statsTabState.test.mts",
"test:library-layout": "node --experimental-strip-types --test src/library/libraryLayout.test.mts",
"test:haptics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/haptics.test.mts",
"test:app-dialog": "node --experimental-strip-types --test src/components/dialogs/dialogQueue.test.mts",
+11 -2
View File
@@ -8,6 +8,7 @@ import {
} from '@/navigation/tabTransition';
import { popToTop } from '@/navigation/stackActions';
import { useColors } from '@/theme/themed';
import { isDisplayedTabFocused } from '@/navigation/statsTabState';
export default function TabsLayout() {
const colors = useColors();
@@ -31,10 +32,16 @@ export default function TabsLayout() {
detachInactiveScreens={false}
screenOptions={screenOptions}
tabBar={({ state, navigation }) => {
const activeRouteName = state.routes[state.index]?.name;
const items: TabItem[] = state.routes.map((route, index) => ({
key: route.key,
name: route.name,
focused: state.index === index,
focused: isDisplayedTabFocused(
route.name,
index,
state.index,
activeRouteName,
),
}));
const handlePress = (item: TabItem) => {
@@ -50,7 +57,8 @@ export default function TabsLayout() {
});
if (event.defaultPrevented) return;
if (item.focused) {
const actuallyFocused = state.routes[state.index]?.key === item.key;
if (actuallyFocused) {
// Re-tapping the active tab resets its nested stack. This is the
// one-tap escape from a deep library chain (artist → album →
// another artist), which is why back itself only pops one level.
@@ -72,6 +80,7 @@ export default function TabsLayout() {
<Tabs.Screen name="library" />
<Tabs.Screen name="eq" />
<Tabs.Screen name="settings" />
<Tabs.Screen name="stats" options={{ href: null }} />
</Tabs>
);
}
+39 -9
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
AppState,
Pressable,
@@ -9,7 +9,7 @@ import {
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useFocusEffect, useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo';
@@ -44,6 +44,9 @@ import {
} from '@/home/homeGreeting';
import { useHomeLibraryNavigation } from '@/navigation/useHomeLibraryNavigation';
import type { Album, Artist, DbTrack } from '@/types/library';
import { ListeningPreviewCard } from '@/components/listening/ListeningPreviewCard';
import { useListeningStatsStore } from '@/stores/listeningStatsStore';
import { subscribeToListeningHistory } from '@/listeningStats/events';
const RECENT_ALBUM_LIMIT = 8;
const RECENT_TRACK_LIMIT = 3;
@@ -529,6 +532,8 @@ export default function HomeScreen() {
const openQuickSearch = useSearchStore((s) => s.openQuickSearch);
const homeGreetingTextMode = useSettingsStore((s) => s.homeGreetingTextMode);
const artistGroupingMode = useSettingsStore((s) => s.artistGroupingMode);
const listeningPreview = useListeningStatsStore((s) => s.homePreview);
const loadListeningPreview = useListeningStatsStore((s) => s.loadHomePreview);
const [spotlightOverride, setSpotlightOverride] = useState<RandomSpotlight | null>(null);
const [randomSeeds] = useState(() => [Math.random(), Math.random()] as const);
@@ -629,6 +634,20 @@ export default function HomeScreen() {
const openSearch = () => openQuickSearch();
const openSignalScanner = () => router.push('/signal/scan' as never);
useFocusEffect(
useCallback(() => {
void loadListeningPreview();
const unsubscribe = subscribeToListeningHistory(() => void loadListeningPreview());
const subscription = AppState.addEventListener('change', (state) => {
if (state === 'active') void loadListeningPreview();
});
return () => {
unsubscribe();
subscription.remove();
};
}, [loadListeningPreview]),
);
return (
<Screen>
<PullSearchGesture atTop={scrollTop.atTop} onOpen={openSearch}>
@@ -648,13 +667,19 @@ export default function HomeScreen() {
<ScanProgress />
{!hasLibrary ? (
<EmptyHomeCard
scanError={scanError}
status={libraryStatus}
onManageFolders={() => router.push(
libraryStatus === 'fatalUserData' ? '/settings/troubleshooting' : '/settings'
)}
/>
<>
<EmptyHomeCard
scanError={scanError}
status={libraryStatus}
onManageFolders={() => router.push(
libraryStatus === 'fatalUserData' ? '/settings/troubleshooting' : '/settings'
)}
/>
<ListeningPreviewCard
dashboard={listeningPreview}
onPress={() => router.push('/stats' as never)}
/>
</>
) : (
<>
{spotlightContent ? (
@@ -676,6 +701,11 @@ export default function HomeScreen() {
</View>
) : null}
<ListeningPreviewCard
dashboard={listeningPreview}
onPress={() => router.push('/stats' as never)}
/>
{recentTracks.length > 0 ? (
<View style={styles.section}>
<SectionHeader
@@ -537,7 +537,7 @@ function ConditionEditorSheet({
const error = validateCondition(draft);
return (
<AppSheet onClose={onCancel}>
<AppSheet onClose={onCancel} scrollable>
<AppSheetTitle title={target.mode === 'new' ? 'Add filter' : 'Edit filter'} />
<Pressable android_ripple={ripple.bounded} style={styles.sheetSelectRow} onPress={onChangeField} accessibilityRole="button">
<View style={styles.sheetSelectText}>
@@ -610,7 +610,7 @@ function SortLimitSheet({
const applyDisabled = !limitValid;
return (
<AppSheet onClose={onCancel}>
<AppSheet onClose={onCancel} scrollable>
<AppSheetTitle title="Result order" />
<AppSheetSection label="SORT BY" />
{SORT_FIELD_OPTIONS.map(([field, label]) => (
@@ -983,7 +983,7 @@ export default function DynamicPlaylistEditorScreen() {
if (sheet.kind === 'field-picker') {
return (
<AppSheet onClose={() => setSheet(null)}>
<AppSheet onClose={() => setSheet(null)} scrollable>
<AppSheetTitle title="Choose filter" />
{(['text', 'activity', 'library', 'audio'] as FieldGroup[]).map((group) => (
<View key={group}>
+7 -1
View File
@@ -29,6 +29,7 @@ import { createBuildInfo } from '@/release/buildInfo';
import { useThemeStore } from '@/stores/themeStore';
import { useSleepTimerStore } from '@/stores/sleepTimerStore';
import { formatSleepTimerStatus } from '@/audio/sleepTimerState';
import { useSettingsStore } from '@/stores/settingsStore';
function formatEnabled(value: boolean): string {
return value ? 'On' : 'Off';
@@ -53,6 +54,7 @@ export default function SettingsScreen() {
const desktopSyncConflictCount = useDesktopSyncStore((s) => s.conflicts.length);
const sleepTimer = useSleepTimerStore((s) => s.timer);
const sleepRemainingMs = useSleepTimerStore((s) => s.remainingMs);
const listeningHistoryEnabled = useSettingsStore((s) => s.listeningHistoryEnabled);
void sleepRemainingMs;
useEffect(() => {
@@ -112,7 +114,11 @@ export default function SettingsScreen() {
<SettingsNavRow
icon="play-circle-outline"
title="Playback"
subtitle={sleepTimer ? `Sleep timer: ${formatSleepTimerStatus(sleepTimer)}.` : 'Sleep timer and playback behavior.'}
subtitle={
sleepTimer
? `Sleep timer: ${formatSleepTimerStatus(sleepTimer)}. Listening history ${formatEnabled(listeningHistoryEnabled)}.`
: `Listening history ${formatEnabled(listeningHistoryEnabled)}. Sleep timer and playback behavior.`
}
onPress={() => router.push('/settings/playback' as never)}
/>
<SettingsNavRow
+684
View File
@@ -0,0 +1,684 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import {
AppState,
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
View,
useWindowDimensions,
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect, useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { SegmentedControl } from '@/components/SegmentedControl';
import { ListeningStatsShareSheet } from '@/components/listening/ListeningStatsShareSheet';
import { listeningArtworkSource } from '@/library/artwork';
import {
formatBucketDate,
formatListeningTime,
formatRecordedSince,
} from '@/listeningStats/format';
import { useHomeLibraryNavigation } from '@/navigation/useHomeLibraryNavigation';
import { useListeningStatsStore } from '@/stores/listeningStatsStore';
import { playLibraryQuery } from '@/audio/playbackController';
import { fonts, radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import type {
ListeningStatsCategory,
ListeningStatsDashboard,
RankedListeningAlbum,
RankedListeningArtist,
RankedListeningTrack,
} from '@/types/listeningStats';
import { subscribeToListeningHistory } from '@/listeningStats/events';
const RANGE_SEGMENTS = [
{ key: '7d', label: '7D' },
{ key: '30d', label: '30D' },
{ key: '1y', label: '1Y' },
{ key: 'all', label: 'All' },
];
const METRIC_SEGMENTS = [
{ key: 'plays', label: 'Plays' },
{ key: 'time', label: 'Time' },
];
const CATEGORY_SEGMENTS = [
{ key: 'tracks', label: 'Tracks' },
{ key: 'artists', label: 'Artists' },
{ key: 'albums', label: 'Albums' },
];
function SummaryGrid({
dashboard,
wide,
}: {
dashboard: ListeningStatsDashboard;
wide: boolean;
}) {
const styles = useStyles();
const colors = useColors();
const tiles = [
['Listening Time', formatListeningTime(dashboard.summary.listenedSeconds, true)],
['Qualified Plays', String(dashboard.summary.qualifiedPlays)],
['Tracks Played', String(dashboard.summary.tracksPlayed)],
['Active Days', String(dashboard.summary.activeDays)],
];
return (
<View style={[styles.summaryGrid, wide && styles.summaryGridWide]}>
{tiles.map(([label, value]) => (
<View key={label} style={[styles.summaryTile, wide && styles.summaryTileWide]}>
<Text variant="title" style={styles.summaryValue} numberOfLines={1}>{value}</Text>
<Text variant="caption" color={colors.textSecondary}>{label}</Text>
</View>
))}
</View>
);
}
function ActivityChart({ dashboard }: { dashboard: ListeningStatsDashboard }) {
const styles = useStyles();
const colors = useColors();
const scrollRef = useRef<ScrollView>(null);
const [selectedStartAt, setSelectedStartAt] = useState<number | null>(null);
const selectedIndex = dashboard.activity.findIndex(
(bucket) => bucket.startAt === selectedStartAt,
);
const resolvedSelectedIndex = selectedIndex >= 0
? selectedIndex
: Math.max(0, dashboard.activity.length - 1);
const selected = dashboard.activity[resolvedSelectedIndex] ?? dashboard.activity.at(-1) ?? null;
const maxSeconds = Math.max(1, ...dashboard.activity.map((bucket) => bucket.listenedSeconds));
const fillsCard = dashboard.range === '7d';
const bars = dashboard.activity.map((bucket, index) => {
const height = Math.max(3, Math.round((bucket.listenedSeconds / maxSeconds) * 118));
const focused = index === resolvedSelectedIndex;
return (
<Pressable
key={`${bucket.startAt}-${index}`}
style={[styles.barSlot, fillsCard && styles.barSlotFill]}
onPress={() => setSelectedStartAt(bucket.startAt)}
accessibilityRole="button"
accessibilityState={{ selected: focused }}
accessibilityLabel={`${formatBucketDate(bucket.startAt, bucket.endAt)}, ${formatListeningTime(bucket.listenedSeconds)}, ${bucket.qualifiedPlays} qualified plays`}
>
<View
style={[
styles.bar,
{ height, backgroundColor: focused ? colors.accent : colors.accentHover },
]}
/>
{(fillsCard || index % Math.max(1, Math.ceil(dashboard.activity.length / 7)) === 0) ? (
<Text variant="caption" color={focused ? colors.textPrimary : colors.textTertiary} numberOfLines={1}>
{bucket.label}
</Text>
) : (
<View style={styles.barLabelSpacer} />
)}
</Pressable>
);
});
return (
<View style={styles.card}>
<View style={styles.cardHeader}>
<Text variant="heading">Activity</Text>
<Text variant="caption" color={colors.textSecondary}>
{dashboard.granularity === 'day'
? 'Daily'
: dashboard.granularity === 'week'
? 'Weekly'
: 'Monthly'}
</Text>
</View>
{selected ? (
<View style={styles.chartDetail}>
<Text variant="label">{formatBucketDate(selected.startAt, selected.endAt)}</Text>
<Text variant="caption" color={colors.textSecondary}>
{formatListeningTime(selected.listenedSeconds)} · {selected.qualifiedPlays}{' '}
{selected.qualifiedPlays === 1 ? 'play' : 'plays'}
</Text>
</View>
) : null}
<ScrollView
ref={scrollRef}
horizontal
scrollEnabled={!fillsCard}
showsHorizontalScrollIndicator={false}
onContentSizeChange={() => {
if (!fillsCard) scrollRef.current?.scrollToEnd({ animated: false });
}}
contentContainerStyle={[styles.chart, fillsCard && styles.chartFill]}
>
{bars}
</ScrollView>
</View>
);
}
type RankedItem = RankedListeningTrack | RankedListeningArtist | RankedListeningAlbum;
function rankingCopy(item: RankedItem, category: ListeningStatsCategory) {
if (category === 'tracks') {
const track = item as RankedListeningTrack;
return { title: track.title, subtitle: track.artist, icon: 'musical-note' as const };
}
if (category === 'artists') {
return {
title: (item as RankedListeningArtist).artist,
subtitle: 'Artist',
icon: 'person' as const,
};
}
const album = item as RankedListeningAlbum;
return { title: album.album, subtitle: album.artist, icon: 'disc' as const };
}
function RankingRow({
item,
index,
category,
selectedMetric,
onPress,
}: {
item: RankedItem;
index: number;
category: ListeningStatsCategory;
selectedMetric: 'plays' | 'time';
onPress: () => void;
}) {
const styles = useStyles();
const colors = useColors();
const ripple = useRipple();
const copy = rankingCopy(item, category);
const art = listeningArtworkSource(item, true);
return (
<Pressable
style={[styles.rankingRow, !item.available && styles.unavailable]}
android_ripple={item.available ? ripple.bounded : undefined}
disabled={!item.available}
onPress={onPress}
accessibilityRole="button"
accessibilityState={{ disabled: !item.available }}
accessibilityLabel={`${index + 1}. ${copy.title}`}
>
<Text variant="label" style={styles.rankNumber}>{index + 1}</Text>
<View style={styles.rankingArt}>
{art ? (
<Image source={{ uri: art }} style={styles.artImage} contentFit="cover" />
) : (
<Ionicons name={copy.icon} size={22} color={colors.textTertiary} />
)}
</View>
<View style={styles.rankingMeta}>
<Text variant="body" numberOfLines={1}>{copy.title}</Text>
<Text variant="caption" color={colors.textSecondary} numberOfLines={1}>
{item.available ? copy.subtitle : `${copy.subtitle} · Unavailable`}
</Text>
</View>
<View style={styles.rankingMetrics}>
<Text
variant="label"
color={selectedMetric === 'plays' ? colors.accentText : colors.textSecondary}
>
{item.qualifiedPlays} {item.qualifiedPlays === 1 ? 'play' : 'plays'}
</Text>
<Text
variant="caption"
color={selectedMetric === 'time' ? colors.accentText : colors.textTertiary}
>
{formatListeningTime(item.listenedSeconds, true)}
</Text>
</View>
</Pressable>
);
}
function EmptyState({
icon,
title,
body,
action,
onAction,
}: {
icon: keyof typeof Ionicons.glyphMap;
title: string;
body: string;
action?: string;
onAction?: () => void;
}) {
const styles = useStyles();
const colors = useColors();
const ripple = useRipple();
return (
<View style={styles.empty}>
<Ionicons name={icon} size={34} color={colors.textTertiary} />
<Text variant="heading">{title}</Text>
<Text variant="body" color={colors.textSecondary} style={styles.emptyBody}>{body}</Text>
{action && onAction ? (
<Pressable style={styles.primaryButton} android_ripple={ripple.onAccent()} onPress={onAction}>
<Text variant="body" style={styles.primaryButtonText}>{action}</Text>
</Pressable>
) : null}
</View>
);
}
export default function ListeningStatsScreen() {
const styles = useStyles();
const colors = useColors();
const ripple = useRipple();
const router = useRouter();
const openLibrary = useHomeLibraryNavigation();
const { width } = useWindowDimensions();
const range = useListeningStatsStore((s) => s.range);
const metric = useListeningStatsStore((s) => s.rankingMetric);
const category = useListeningStatsStore((s) => s.category);
const dashboard = useListeningStatsStore((s) => s.dashboard);
const loading = useListeningStatsStore((s) => s.loading);
const refreshing = useListeningStatsStore((s) => s.refreshing);
const error = useListeningStatsStore((s) => s.error);
const setRange = useListeningStatsStore((s) => s.setRange);
const setMetric = useListeningStatsStore((s) => s.setRankingMetric);
const setCategory = useListeningStatsStore((s) => s.setCategory);
const load = useListeningStatsStore((s) => s.loadDashboard);
const [shareSnapshot, setShareSnapshot] = useState<ListeningStatsDashboard | null>(null);
useFocusEffect(
useCallback(() => {
void load();
const interval = setInterval(() => void load(), 15_000);
const unsubscribe = subscribeToListeningHistory(() => void load());
const subscription = AppState.addEventListener('change', (state) => {
if (state === 'active') void load();
});
return () => {
clearInterval(interval);
unsubscribe();
subscription.remove();
};
}, [load]),
);
const rankings = useMemo<RankedItem[]>(() => {
if (!dashboard) return [];
if (category === 'artists') return dashboard.topArtists;
if (category === 'albums') return dashboard.topAlbums;
return dashboard.topTracks;
}, [category, dashboard]);
const openRanking = (item: RankedItem) => {
if (!dashboard || !item.available) return;
if (category === 'tracks') {
const paths = dashboard.topTracks.flatMap((track) =>
track.available && track.trackPath ? [track.trackPath] : []
);
const track = item as RankedListeningTrack;
if (!track.trackPath || paths.length === 0) return;
void playLibraryQuery(
{ kind: 'manual', paths },
{
anchorPath: track.trackPath,
source: { kind: 'listening-stats', label: 'Listening Stats' },
},
);
return;
}
if (category === 'artists') {
openLibrary({ kind: 'artist', name: (item as RankedListeningArtist).artist });
} else {
openLibrary({ kind: 'album', key: item.key });
}
};
const noActivity = dashboard
? dashboard.summary.listenedSeconds <= 0 && dashboard.summary.qualifiedPlays <= 0
: false;
return (
<Screen>
<View style={styles.header}>
<Pressable
style={styles.iconButton}
android_ripple={ripple.icon(22)}
onPress={() => router.back()}
accessibilityRole="button"
accessibilityLabel="Back to Home"
>
<Ionicons name="chevron-back" size={23} color={colors.textPrimary} />
</Pressable>
<View style={styles.headerCopy}>
<Text variant="title">Listening Stats</Text>
<Text variant="caption" color={colors.textSecondary} numberOfLines={1}>
{formatRecordedSince(dashboard?.status.startedAt ?? null)}
</Text>
</View>
<Pressable
style={[styles.iconButton, (!dashboard || noActivity) && styles.unavailable]}
android_ripple={dashboard && !noActivity ? ripple.icon(22) : undefined}
disabled={!dashboard || noActivity}
onPress={() => setShareSnapshot(dashboard)}
accessibilityRole="button"
accessibilityLabel="Share Listening Stats"
>
<Ionicons name="share-outline" size={21} color={colors.textPrimary} />
</Pressable>
</View>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.content}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => void load()}
tintColor={colors.accent}
colors={[colors.accent]}
/>
}
>
<SegmentedControl
segments={RANGE_SEGMENTS}
value={range}
onChange={(value) => setRange(value as typeof range)}
/>
{loading && !dashboard ? (
<EmptyState
icon="stats-chart"
title="Loading Listening Stats"
body="Reading the detailed history recorded on this phone…"
/>
) : error && !dashboard ? (
<EmptyState
icon="cloud-offline-outline"
title="Stats could not load"
body={error}
action="Try again"
onAction={() => void load()}
/>
) : !dashboard?.status.startedAt ? (
<EmptyState
icon={dashboard?.status.enabled === false ? 'pause-circle-outline' : 'headset-outline'}
title={dashboard?.status.enabled === false ? 'Listening History is paused' : 'Your stats start here'}
body={
dashboard?.status.enabled === false
? 'Resume Listening History in Playback settings. Play counts and other library data are unchanged.'
: 'Play music on this phone to begin detailed listening time, activity, and rankings. Existing play counts are not backfilled.'
}
action={dashboard?.status.enabled === false ? 'Playback settings' : undefined}
onAction={() => router.push('/settings/playback' as never)}
/>
) : (
<>
{!dashboard.status.enabled ? (
<View style={styles.pausedBanner}>
<Ionicons name="pause-circle-outline" size={20} color={colors.warning} />
<View style={styles.bannerCopy}>
<Text variant="label" color={colors.warning}>History paused</Text>
<Text variant="caption" color={colors.textSecondary}>
Existing history is shown; future listening is not being recorded.
</Text>
</View>
</View>
) : null}
<SummaryGrid dashboard={dashboard} wide={width >= 720} />
{noActivity ? (
<EmptyState
icon="calendar-outline"
title="No activity in this range"
body="Choose another range or keep listening to fill this view."
/>
) : (
<>
<ActivityChart dashboard={dashboard} />
<View style={styles.rankingsSection}>
<View style={styles.rankingsHeader}>
<Text variant="heading">Rankings</Text>
{error ? (
<Pressable onPress={() => void load()} accessibilityRole="button">
<Text variant="caption" color={colors.warning}>Refresh failed · Retry</Text>
</Pressable>
) : null}
</View>
<SegmentedControl
segments={METRIC_SEGMENTS}
value={metric}
onChange={(value) => setMetric(value as typeof metric)}
/>
<SegmentedControl
segments={CATEGORY_SEGMENTS}
value={category}
onChange={(value) => setCategory(value as ListeningStatsCategory)}
/>
<View style={styles.rankingList}>
{rankings.map((item, index) => (
<RankingRow
key={item.key}
item={item}
index={index}
category={category}
selectedMetric={metric}
onPress={() => openRanking(item)}
/>
))}
</View>
</View>
</>
)}
</>
)}
</ScrollView>
{shareSnapshot ? (
<ListeningStatsShareSheet
snapshot={shareSnapshot}
onClose={() => setShareSnapshot(null)}
/>
) : null}
</Screen>
);
}
const useStyles = createThemedStyles((colors) => ({
header: {
minHeight: 72,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingTop: spacing.sm,
},
headerCopy: {
flex: 1,
minWidth: 0,
gap: 2,
},
iconButton: {
width: 42,
height: 42,
borderRadius: 21,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
content: {
paddingTop: spacing.md,
paddingBottom: spacing.xxl,
gap: spacing.xl,
},
pausedBanner: {
flexDirection: 'row',
gap: spacing.md,
padding: spacing.md,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.warning,
backgroundColor: colors.glassBg,
},
bannerCopy: {
flex: 1,
gap: 2,
},
summaryGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.md,
},
summaryGridWide: {
flexWrap: 'nowrap',
},
summaryTile: {
width: '47%',
flexGrow: 1,
padding: spacing.lg,
gap: spacing.xs,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
summaryTileWide: {
width: undefined,
flex: 1,
},
summaryValue: {
fontSize: 24,
lineHeight: 29,
},
card: {
padding: spacing.lg,
gap: spacing.md,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
chartDetail: {
gap: 2,
},
chart: {
minHeight: 156,
alignItems: 'flex-end',
gap: spacing.xs,
paddingTop: spacing.sm,
},
chartFill: {
width: '100%',
},
barSlot: {
width: 36,
height: 150,
alignItems: 'center',
justifyContent: 'flex-end',
gap: spacing.xs,
},
barSlotFill: {
width: undefined,
flex: 1,
},
bar: {
width: 18,
minHeight: 3,
borderRadius: 4,
},
barLabelSpacer: {
height: 14,
},
rankingsSection: {
gap: spacing.md,
},
rankingsHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
gap: spacing.sm,
},
rankingList: {
borderRadius: radius.md,
overflow: 'hidden',
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
rankingRow: {
minHeight: 68,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.glassBorder,
},
rankNumber: {
width: 22,
textAlign: 'center',
fontFamily: fonts.mono.medium,
},
rankingArt: {
width: 46,
height: 46,
borderRadius: radius.sm,
overflow: 'hidden',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgTertiary,
},
artImage: {
width: '100%',
height: '100%',
},
rankingMeta: {
flex: 1,
minWidth: 0,
gap: 2,
},
rankingMetrics: {
alignItems: 'flex-end',
gap: 2,
},
empty: {
minHeight: 220,
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
padding: spacing.xl,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
emptyBody: {
maxWidth: 440,
textAlign: 'center',
lineHeight: 21,
},
primaryButton: {
marginTop: spacing.sm,
minHeight: 40,
justifyContent: 'center',
paddingHorizontal: spacing.lg,
borderRadius: radius.pill,
backgroundColor: colors.accent,
overflow: 'hidden',
},
primaryButtonText: {
color: colors.bgPrimary,
fontFamily: fonts.sans.semibold,
},
unavailable: {
opacity: 0.48,
},
}));
+108
View File
@@ -1,17 +1,125 @@
import { SleepTimerControls } from '@/components/player/SleepTimerControls';
import { Pressable, StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { AstraLibraryData } from '../../../modules/astra-library-scanner';
import {
SettingsCard,
SettingsSectionLabel,
SettingsSectionScreen,
SettingsToggleRow,
} from '@/components/settings/SettingsSectionScaffold';
import { Text } from '@/components/Text';
import { showAppDialog } from '@/components/dialogs/AppDialog';
import {
pauseListeningHistoryTracking,
resumeListeningHistoryTracking,
} from '@/audio/listeningHistoryTracker';
import { useSettingsStore } from '@/stores/settingsStore';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { notifyListeningHistoryChanged } from '@/listeningStats/events';
export default function PlaybackSettingsScreen() {
const styles = useStyles();
const colors = useColors();
const ripple = useRipple();
const historyEnabled = useSettingsStore((s) => s.listeningHistoryEnabled);
const setHistoryEnabled = useSettingsStore((s) => s.setListeningHistoryEnabled);
const confirmClear = () => {
showAppDialog({
title: 'Clear detailed listening history?',
message:
'Listening time, activity, and rankings recorded on this phone will be removed. Play counts, recents, favorites, playlists, and last-played dates are preserved.',
actions: [
{ label: 'Cancel', role: 'cancel' },
{
label: 'Clear history',
role: 'destructive',
onPress: () => {
void (async () => {
await pauseListeningHistoryTracking();
try {
await AstraLibraryData.clearDetailedListeningHistory();
notifyListeningHistoryChanged();
} finally {
if (useSettingsStore.getState().listeningHistoryEnabled) {
resumeListeningHistoryTracking();
}
}
})().catch((error) => {
showAppDialog({
title: 'Could not clear history',
message: error instanceof Error ? error.message : 'Please try again.',
});
});
},
},
],
});
};
return (
<SettingsSectionScreen title="Playback">
<SettingsSectionLabel>SLEEP TIMER</SettingsSectionLabel>
<SettingsCard>
<SleepTimerControls />
</SettingsCard>
<SettingsSectionLabel spaced>LISTENING HISTORY</SettingsSectionLabel>
<SettingsCard>
<SettingsToggleRow
title="Listening History"
description="Record detailed listening time and qualified plays on this phone."
value={historyEnabled}
onValueChange={(enabled) => {
void setHistoryEnabled(enabled).catch((error) => {
showAppDialog({
title: 'Could not update Listening History',
message: error instanceof Error ? error.message : 'Please try again.',
});
});
}}
/>
<View style={styles.divider} />
<Pressable
android_ripple={ripple.bounded}
style={styles.clearRow}
onPress={confirmClear}
accessibilityRole="button"
>
<Ionicons name="trash-outline" size={20} color={colors.warning} />
<View style={styles.clearMeta}>
<Text variant="body" color={colors.warning}>
Clear Detailed Listening History
</Text>
<Text variant="caption" color={colors.textSecondary}>
Keeps play counts, recents, favorites, and playlists.
</Text>
</View>
</Pressable>
</SettingsCard>
</SettingsSectionScreen>
);
}
const useStyles = createThemedStyles((colors) => ({
divider: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.glassBorder,
marginVertical: spacing.lg,
},
clearRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.sm,
overflow: 'hidden',
},
clearMeta: {
flex: 1,
gap: 2,
},
}));
+52
View File
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
listenedTickDeltaMs,
listeningCheckpointDue,
playbackAppearsNaturallyCompleted,
} from './listeningHistoryState.ts';
test('counts wall-clock time only while actively playing', () => {
assert.equal(listenedTickDeltaMs(1_000, 2_000, true), 1_000);
assert.equal(listenedTickDeltaMs(1_000, 2_000, false), 0);
assert.equal(listenedTickDeltaMs(null, 2_000, true), 0);
});
test('counts background event stalls and ignores backwards clocks', () => {
assert.equal(listenedTickDeltaMs(1_000, 30_000, true), 29_000);
assert.equal(listenedTickDeltaMs(2_000, 1_000, true), 0);
});
test('checkpoints every ten seconds and at the qualification boundary', () => {
assert.equal(listeningCheckpointDue({
listenedSeconds: 9.99,
lastCheckpointSeconds: 0,
qualificationCheckpointSent: false,
durationSeconds: 180,
}), false);
assert.equal(listeningCheckpointDue({
listenedSeconds: 10,
lastCheckpointSeconds: 0,
qualificationCheckpointSent: false,
durationSeconds: 180,
}), true);
assert.equal(listeningCheckpointDue({
listenedSeconds: 15,
lastCheckpointSeconds: 10,
qualificationCheckpointSent: false,
durationSeconds: 180,
}), true);
assert.equal(listeningCheckpointDue({
listenedSeconds: 15,
lastCheckpointSeconds: 15,
qualificationCheckpointSent: true,
durationSeconds: 180,
}), false);
});
test('natural completion requires the final native position', () => {
assert.equal(playbackAppearsNaturallyCompleted(5, 4), true);
assert.equal(playbackAppearsNaturallyCompleted(5, 3.99), false);
assert.equal(playbackAppearsNaturallyCompleted(180, 15), false);
assert.equal(playbackAppearsNaturallyCompleted(0, 0), false);
});
+46
View File
@@ -0,0 +1,46 @@
export const LISTENING_CHECKPOINT_SECONDS = 10;
export const LISTENING_QUALIFICATION_SECONDS = 15;
export const LISTENING_NATURAL_END_TOLERANCE_SECONDS = 1;
/** Wall-clock delta that is safe to count for one actively-playing progress tick. */
export function listenedTickDeltaMs(
lastTickAt: number | null,
now: number,
activelyPlaying: boolean,
): number {
if (!activelyPlaying || lastTickAt == null || !Number.isFinite(now)) return 0;
return Math.max(0, now - lastTickAt);
}
export function listeningCheckpointDue(options: {
listenedSeconds: number;
lastCheckpointSeconds: number;
qualificationCheckpointSent: boolean;
durationSeconds: number;
}): boolean {
const sinceCheckpoint =
options.listenedSeconds - options.lastCheckpointSeconds >= LISTENING_CHECKPOINT_SECONDS;
const reachedQualification =
!options.qualificationCheckpointSent &&
options.durationSeconds >= LISTENING_QUALIFICATION_SECONDS &&
options.listenedSeconds >= LISTENING_QUALIFICATION_SECONDS;
return sinceCheckpoint || reachedQualification;
}
export function playbackAppearsNaturallyCompleted(
durationSeconds: number,
positionSeconds: number,
): boolean {
if (
!Number.isFinite(durationSeconds) ||
!Number.isFinite(positionSeconds) ||
durationSeconds <= 0 ||
positionSeconds < 0
) {
return false;
}
return positionSeconds >= Math.max(
0,
durationSeconds - LISTENING_NATURAL_END_TOLERANCE_SECONDS,
);
}
+266
View File
@@ -0,0 +1,266 @@
import TrackPlayer, {
State,
type PlaybackActiveTrackChangedEvent,
type Track as RntpTrack,
} from 'react-native-track-player';
import { AstraLibraryData } from '../../modules/astra-library-scanner';
import type {
ListeningCheckpointResult,
ListeningHistoryStatus,
ListeningSessionCheckpoint,
} from '@/types/listeningStats';
import { consumeManualRecentPlayTransition } from './recentPlayTracking';
import { rntpToTrack } from './sampleTracks';
import { notifyListeningHistoryChanged } from '@/listeningStats/events';
import {
LISTENING_QUALIFICATION_SECONDS,
listenedTickDeltaMs,
listeningCheckpointDue,
playbackAppearsNaturallyCompleted,
} from './listeningHistoryState';
interface ActiveListeningSession {
generation: string;
sessionKey: string;
segmentKey: string | null;
trackPath: string;
durationSeconds: number;
sessionStartedAt: number;
segmentStartedAt: number | null;
sessionListenedSeconds: number;
segmentListenedSeconds: number;
lastTickAt: number | null;
lastCheckpointSeconds: number;
qualifiedCheckpointSent: boolean;
}
let status: ListeningHistoryStatus | null = null;
let active: ActiveListeningSession | null = null;
let isPlaying = false;
let operation = Promise.resolve();
function key(prefix: string, now = Date.now()): string {
return `${prefix}:${now.toString(36)}:${Math.random().toString(36).slice(2, 10)}`;
}
function enqueue(task: () => Promise<void>): void {
operation = operation.then(task, task).catch((error) => {
console.warn('[listening-history] tracker operation failed', error);
});
}
function finiteDuration(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0;
}
function beginSession(track: RntpTrack | undefined, now = Date.now()): void {
if (!track || !status?.enabled) {
active = null;
return;
}
const astraTrack = rntpToTrack(track);
if (!astraTrack.path) {
active = null;
return;
}
active = {
generation: status.generation,
sessionKey: key('session', now),
segmentKey: null,
trackPath: astraTrack.path,
durationSeconds: finiteDuration(astraTrack.duration),
sessionStartedAt: now,
segmentStartedAt: null,
sessionListenedSeconds: 0,
segmentListenedSeconds: 0,
lastTickAt: null,
lastCheckpointSeconds: 0,
qualifiedCheckpointSent: false,
};
if (isPlaying) beginSegment(now);
}
function beginSegment(now: number): void {
if (!active || active.segmentKey) return;
active.segmentKey = key('segment', now);
active.segmentStartedAt = now;
active.segmentListenedSeconds = 0;
active.lastTickAt = now;
}
function advance(now: number): void {
if (!active || !isPlaying || !active.segmentKey || active.lastTickAt == null) return;
const elapsedMs = listenedTickDeltaMs(active.lastTickAt, now, true);
const elapsedSeconds = elapsedMs / 1_000;
active.sessionListenedSeconds += elapsedSeconds;
active.segmentListenedSeconds += elapsedSeconds;
active.lastTickAt = now;
}
function shouldCheckpoint(session: ActiveListeningSession): boolean {
return listeningCheckpointDue({
listenedSeconds: session.sessionListenedSeconds,
lastCheckpointSeconds: session.lastCheckpointSeconds,
qualificationCheckpointSent: session.qualifiedCheckpointSent,
durationSeconds: session.durationSeconds,
});
}
async function persist(
now: number,
finalizeSegment: boolean,
finalizeSession: boolean,
completedNaturally: boolean,
): Promise<void> {
const session = active;
if (!session || (!session.segmentKey && !finalizeSession)) return;
const payload: ListeningSessionCheckpoint = {
generation: session.generation,
sessionKey: session.sessionKey,
segmentKey: session.segmentKey ?? '',
trackPath: session.trackPath,
sessionStartedAt: session.sessionStartedAt,
segmentStartedAt: session.segmentStartedAt ?? now,
observedAt: now,
sessionListenedSeconds: session.sessionListenedSeconds,
segmentListenedSeconds: session.segmentListenedSeconds,
trackDurationSeconds: session.durationSeconds,
finalizeSegment,
finalizeSession,
completedNaturally,
qualificationEligible: true,
};
const result = await AstraLibraryData.checkpointListeningSession<ListeningCheckpointResult>(
payload as unknown as Record<string, unknown>,
);
status = result.status;
if (!result.accepted) {
active = null;
return;
}
notifyListeningHistoryChanged(result.qualifiedNow);
session.lastCheckpointSeconds = session.sessionListenedSeconds;
if (session.sessionListenedSeconds >= LISTENING_QUALIFICATION_SECONDS) {
session.qualifiedCheckpointSent = true;
}
}
async function closeSegment(now: number): Promise<void> {
if (!active?.segmentKey) return;
await persist(now, true, false, false);
if (!active) return;
active.segmentKey = null;
active.segmentStartedAt = null;
active.segmentListenedSeconds = 0;
active.lastTickAt = null;
}
async function closeSession(now: number, completedNaturally: boolean): Promise<void> {
if (!active) return;
await persist(now, Boolean(active.segmentKey), true, completedNaturally);
active = null;
}
function appearsNaturallyCompleted(
track: RntpTrack | undefined,
position: number | undefined,
): boolean {
return playbackAppearsNaturallyCompleted(
finiteDuration(track?.duration),
typeof position === 'number' ? position : -1,
);
}
export function initializeListeningHistoryTracking(): void {
enqueue(async () => {
status = await AstraLibraryData.getListeningHistoryStatus<ListeningHistoryStatus>();
const [track, playbackState] = await Promise.all([
TrackPlayer.getActiveTrack(),
TrackPlayer.getPlaybackState(),
]);
isPlaying = playbackState.state === State.Playing;
beginSession(track);
});
}
export function handleListeningTrackChanged(event: PlaybackActiveTrackChangedEvent): void {
enqueue(async () => {
const now = Date.now();
advance(now);
const lastTrack = event.lastTrack;
const wasManual = consumeManualRecentPlayTransition(
lastTrack ? rntpToTrack(lastTrack).path : null,
now,
);
const completedNaturally =
!wasManual && appearsNaturallyCompleted(lastTrack, event.lastPosition);
await closeSession(now, completedNaturally);
if (!status?.enabled) {
status = await AstraLibraryData.getListeningHistoryStatus<ListeningHistoryStatus>();
}
beginSession(event.track, now);
});
}
export function handleListeningPlaybackState(nextState: State): void {
enqueue(async () => {
const now = Date.now();
advance(now);
const wasPlaying = isPlaying;
isPlaying = nextState === State.Playing;
if (nextState === State.Stopped || nextState === State.Ended || nextState === State.Error) {
await closeSession(now, nextState === State.Ended);
return;
}
if (wasPlaying && !isPlaying) await closeSegment(now);
if (isPlaying) {
if (!active) beginSession(await TrackPlayer.getActiveTrack(), now);
beginSegment(now);
}
});
}
export function handleListeningProgress(position: number, duration: number): void {
enqueue(async () => {
const now = Date.now();
if (!active) {
beginSession(await TrackPlayer.getActiveTrack(), now);
}
if (!active) return;
const nextDuration = finiteDuration(duration);
if (nextDuration > 0) active.durationSeconds = nextDuration;
advance(now);
if (shouldCheckpoint(active)) await persist(now, false, false, false);
});
}
export function handleListeningQueueEnded(position: number): void {
enqueue(async () => {
const now = Date.now();
advance(now);
const track = await TrackPlayer.getActiveTrack();
await closeSession(now, appearsNaturallyCompleted(track, position));
});
}
export async function pauseListeningHistoryTracking(): Promise<void> {
enqueue(async () => {
const now = Date.now();
advance(now);
await closeSession(now, false);
});
await operation;
}
export function resumeListeningHistoryTracking(): void {
enqueue(async () => {
status = await AstraLibraryData.getListeningHistoryStatus<ListeningHistoryStatus>();
const [track, playbackState] = await Promise.all([
TrackPlayer.getActiveTrack(),
TrackPlayer.getPlaybackState(),
]);
isPlaying = playbackState.state === State.Playing;
beginSession(track);
});
}
+14 -2
View File
@@ -12,6 +12,13 @@ import {
import { nativeIndexToAbsolute } from './queueLoader';
import { useQueueStore } from '@/stores/queueStore';
import { useSleepTimerStore } from '@/stores/sleepTimerStore';
import {
handleListeningPlaybackState,
handleListeningProgress,
handleListeningQueueEnded,
handleListeningTrackChanged,
initializeListeningHistoryTracking,
} from './listeningHistoryTracker';
/**
* RNTP playback service registered in `index.js`. Runs in a headless context
@@ -19,6 +26,7 @@ import { useSleepTimerStore } from '@/stores/sleepTimerStore';
* controls to the player. Must not depend on React or the JS UI tree.
*/
export async function PlaybackService(): Promise<void> {
initializeListeningHistoryTracking();
void useSleepTimerStore.getState().hydrate().catch(() => {});
// Begin the small fail-closed warm-up before a car/Bluetooth play command can
// arrive. Full-queue registration and analysis start only after it is safe.
@@ -55,6 +63,7 @@ export async function PlaybackService(): Promise<void> {
// zero; this only late-corrects unanalyzed tracks. Rapid skips coalesce.
let normalizeTimer: ReturnType<typeof setTimeout> | null = null;
TrackPlayer.addEventListener(Event.PlaybackActiveTrackChanged, (event) => {
handleListeningTrackChanged(event);
// Keep the queue mirror's active index fresh while the tray is unmounted.
// Natural track advances otherwise leave it stale, and the tray's
// synchronous first paint would show the old head for a frame before its
@@ -80,11 +89,13 @@ export async function PlaybackService(): Promise<void> {
void applyNormalizationForActiveTrack();
}, 300);
});
TrackPlayer.addEventListener(Event.PlaybackState, () => {
TrackPlayer.addEventListener(Event.PlaybackState, ({ state }) => {
handleListeningPlaybackState(state);
scheduleSync();
void useSleepTimerStore.getState().reconcile();
});
TrackPlayer.addEventListener(Event.PlaybackProgressUpdated, ({ position, duration }) => {
handleListeningProgress(position, duration);
const timer = useSleepTimerStore.getState();
void timer.reconcile();
if (timer.timer?.mode === 'end-of-track') {
@@ -99,7 +110,8 @@ export async function PlaybackService(): Promise<void> {
.then(({ position, duration }) => useSleepTimerStore.getState().reconcileEndOfTrack(position, duration, playWhenReady))
.catch(() => {});
});
TrackPlayer.addEventListener(Event.PlaybackQueueEnded, () => {
TrackPlayer.addEventListener(Event.PlaybackQueueEnded, ({ position }) => {
handleListeningQueueEnded(position);
if (useSleepTimerStore.getState().timer?.mode !== 'end-of-track') return;
void TrackPlayer.getProgress()
.then(({ position, duration }) => useSleepTimerStore.getState().reconcileEndOfTrack(position, duration, false))
+1
View File
@@ -12,6 +12,7 @@ const PLAYBACK_SOURCE_KINDS = new Set<PlaybackSourceKind>([
'search',
'signal',
'android-auto',
'listening-stats',
'sample',
]);
+8 -107
View File
@@ -1,11 +1,9 @@
import { useCallback, useEffect, useRef } from 'react';
import { useEffect, useRef } from 'react';
import {
Event,
State,
useActiveTrack,
usePlaybackState,
useProgress,
useTrackPlayerEvents,
} from 'react-native-track-player';
import { usePlayerStore } from '@/stores/playerStore';
import { useLibraryStore } from '@/stores/libraryStore';
@@ -13,16 +11,7 @@ import { useQueueStore } from '@/stores/queueStore';
import type { PlaybackState, Track } from '@/types/audio';
import { rntpToTrack } from './sampleTracks';
import { buildWidgetRecentItems, setWidgetNowPlaying } from './widgetSync';
import {
advanceRecentPlayCandidate,
consumeManualRecentPlayTransition,
createRecentPlayCandidate,
emptyRecentPlayCandidate,
evaluateRecentPlayCandidate,
finalizeRecentPlayCandidate,
type RecentPlayCandidate,
withRecentPlayDuration,
} from './recentPlayTracking';
import { subscribeToListeningHistory } from '@/listeningStats/events';
const SEEK_ACK_EPS = 0.75;
const SEEK_ACK_TIMEOUT_MS = 3000;
@@ -98,7 +87,6 @@ export function usePlaybackSync(): void {
const activeTrack = useActiveTrack();
const progress = useProgress(500);
const playbackState = usePlaybackState();
const recentPlayCandidate = useRef<RecentPlayCandidate>(emptyRecentPlayCandidate());
const stablePlayback = useRef<{ path: string | null; state: PlaybackState }>({
path: null,
state: 'stopped',
@@ -111,50 +99,15 @@ export function usePlaybackSync(): void {
const setCurrentTrack = usePlayerStore((s) => s.setCurrentTrack);
const setProgress = usePlayerStore((s) => s.setProgress);
const setPlaybackState = usePlayerStore((s) => s.setPlaybackState);
const recordTrackPlayed = useLibraryStore((s) => s.recordTrackPlayed);
const recentlyPlayedTracks = useLibraryStore((s) => s.recentlyPlayedTracks);
const recordRecentPlay = useCallback((path: string | null) => {
if (!path) return;
void recordTrackPlayed(path).catch((err) => {
console.warn('[library] playback history update failed', err);
});
}, [recordTrackPlayed]);
useTrackPlayerEvents(
[Event.PlaybackActiveTrackChanged, Event.PlaybackQueueEnded, Event.PlaybackState],
(event) => {
if (restoredSessionPending) return;
const now = Date.now();
if (event.type === Event.PlaybackActiveTrackChanged) {
const lastTrack = event.lastTrack ? rntpToTrack(event.lastTrack) : null;
const wasManual = consumeManualRecentPlayTransition(lastTrack?.path, now);
const candidate = recentPlayCandidate.current;
if (!lastTrack || candidate.path !== lastTrack.path) {
recentPlayCandidate.current = emptyRecentPlayCandidate();
return;
}
const finalized = finalizeRecentPlayCandidate(
candidate,
!wasManual,
now,
lastTrack.duration,
);
recentPlayCandidate.current = finalized.candidate;
recordRecentPlay(finalized.recordPath);
return;
useEffect(
() => subscribeToListeningHistory((change) => {
if (change.qualifiedNow) {
void useLibraryStore.getState().refreshRecentlyPlayed().catch(() => {});
}
if (event.type === Event.PlaybackState && event.state !== State.Ended) return;
const finalized = finalizeRecentPlayCandidate(
recentPlayCandidate.current,
true,
now,
);
recentPlayCandidate.current = finalized.candidate;
recordRecentPlay(finalized.recordPath);
},
}),
[],
);
useEffect(() => {
@@ -257,56 +210,4 @@ export function usePlaybackSync(): void {
);
}, [activeTrack, rawPlaybackState, recentlyPlayedTracks, restoredSessionPending, restoredTrack]);
useEffect(() => {
if (restoredSessionPending) return;
// Use the identity path (subsonic://|jellyfin:// for remote; the file URI for
// local) so history matches `tracks.path` — activeTrack.url is the stream URL.
const track = activeTrack ? rntpToTrack(activeTrack) : null;
const path = track?.path ?? null;
const duration = Number.isFinite(progress.duration) && progress.duration > 0
? progress.duration
: track?.duration;
const mappedPlaybackState = resolveTransientLoading(
rawPlaybackState,
path,
stablePlayback.current
);
const now = Date.now();
if (!path) {
recentPlayCandidate.current = emptyRecentPlayCandidate();
return;
}
let candidate = recentPlayCandidate.current;
candidate = candidate.path === path
? withRecentPlayDuration(candidate, duration)
: createRecentPlayCandidate(
path,
duration,
mappedPlaybackState === 'playing',
now,
);
if (mappedPlaybackState === 'stopped') {
recentPlayCandidate.current = emptyRecentPlayCandidate();
return;
}
candidate = advanceRecentPlayCandidate(
candidate,
mappedPlaybackState === 'playing',
now,
);
const evaluated = evaluateRecentPlayCandidate(candidate, false);
recentPlayCandidate.current = evaluated.candidate;
recordRecentPlay(evaluated.recordPath);
}, [
activeTrack,
rawPlaybackState,
progress.duration,
progress.position,
recordRecentPlay,
restoredSessionPending,
]);
}
@@ -0,0 +1,158 @@
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { Pressable, StyleSheet, View } from 'react-native';
import { AstraLogo } from '@/components/AstraLogo';
import { Text } from '@/components/Text';
import { listeningArtworkSource } from '@/library/artwork';
import { formatListeningTime } from '@/listeningStats/format';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import type { ListeningStatsDashboard } from '@/types/listeningStats';
export function ListeningPreviewCard({
dashboard,
onPress,
}: {
dashboard: ListeningStatsDashboard | null;
onPress: () => void;
}) {
const styles = useStyles();
const colors = useColors();
const ripple = useRipple();
if (!dashboard?.status.startedAt) return null;
const topTrack = dashboard.topTracks[0] ?? null;
const artwork = topTrack ? listeningArtworkSource(topTrack, true) : null;
const paused = !dashboard.status.enabled;
return (
<Pressable
style={styles.card}
android_ripple={ripple.tile}
onPress={onPress}
accessibilityRole="button"
accessibilityLabel="Open Listening Stats"
>
<View style={styles.header}>
<View style={styles.titleRow}>
<Ionicons
name={paused ? 'pause-circle-outline' : 'stats-chart'}
size={18}
color={paused ? colors.warning : colors.accent}
/>
<Text variant="heading">Your Listening</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</View>
{paused ? (
<View style={styles.paused}>
<Text variant="label" color={colors.warning}>History paused</Text>
<Text variant="caption" color={colors.textSecondary}>
Existing stats are still available. Resume recording in Playback settings.
</Text>
</View>
) : null}
<View style={styles.metrics}>
<View style={styles.metric}>
<Text variant="title" style={styles.metricValue} numberOfLines={1}>
{formatListeningTime(dashboard.summary.listenedSeconds, true)}
</Text>
<Text variant="caption" color={colors.textSecondary}>Last 7 days</Text>
</View>
<View style={styles.metric}>
<Text variant="title" style={styles.metricValue} numberOfLines={1}>
{dashboard.summary.qualifiedPlays}
</Text>
<Text variant="caption" color={colors.textSecondary}>Qualified plays</Text>
</View>
</View>
{topTrack ? (
<View style={styles.topTrack}>
<View style={styles.art}>
{artwork ? (
<Image source={{ uri: artwork }} style={styles.image} contentFit="cover" />
) : (
<AstraLogo size={24} />
)}
</View>
<View style={styles.trackMeta}>
<Text variant="label" color={colors.textTertiary}>TOP TRACK</Text>
<Text variant="body" numberOfLines={1}>{topTrack.title}</Text>
<Text variant="caption" color={colors.textSecondary} numberOfLines={1}>
{topTrack.artist} · {topTrack.qualifiedPlays} {topTrack.qualifiedPlays === 1 ? 'play' : 'plays'}
</Text>
</View>
</View>
) : (
<Text variant="caption" color={colors.textSecondary}>
No qualified plays in the last 7 days.
</Text>
)}
</Pressable>
);
}
const useStyles = createThemedStyles((colors) => ({
card: {
marginTop: spacing.xl,
padding: spacing.lg,
gap: spacing.md,
borderRadius: radius.md,
backgroundColor: colors.glassBg,
borderColor: colors.glassBorder,
borderWidth: StyleSheet.hairlineWidth,
overflow: 'hidden',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
paused: {
gap: 2,
},
metrics: {
flexDirection: 'row',
gap: spacing.md,
},
metric: {
flex: 1,
minWidth: 0,
},
metricValue: {
fontSize: 22,
lineHeight: 27,
},
topTrack: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
},
art: {
width: 52,
height: 52,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
borderRadius: radius.sm,
backgroundColor: colors.bgTertiary,
},
image: {
width: '100%',
height: '100%',
},
trackMeta: {
flex: 1,
minWidth: 0,
gap: 1,
},
}));
@@ -0,0 +1,268 @@
import { useEffect, useMemo, useState } from 'react';
import { Image, Pressable, StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { cacheDirectory, EncodingType, writeAsStringAsync } from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing';
import { AppSheet, AppSheetTitle } from '@/components/sheets/AppSheet';
import { Text } from '@/components/Text';
import { listeningArtworkSource } from '@/library/artwork';
import {
buildListeningStatsShareModel,
type ListeningStatsShareLens,
} from '@/listeningStats/shareModel';
import { renderListeningStatsSharePng } from '@/listeningStats/shareRenderer';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import type { ListeningStatsDashboard } from '@/types/listeningStats';
const LENSES: { key: ListeningStatsShareLens; label: string }[] = [
{ key: 'overview', label: 'Overview' },
{ key: 'track', label: 'Top Track' },
{ key: 'album', label: 'Top Album' },
];
export function ListeningStatsShareSheet({
snapshot,
onClose,
}: {
snapshot: ListeningStatsDashboard;
onClose: () => void;
}) {
const styles = useStyles();
const colors = useColors();
const ripple = useRipple();
const [lens, setLens] = useState<ListeningStatsShareLens>('overview');
const [sharing, setSharing] = useState(false);
const [shareError, setShareError] = useState<string | null>(null);
const [rendered, setRendered] = useState<{
key: string;
base64: string | null;
error: string | null;
} | null>(null);
const model = useMemo(() => buildListeningStatsShareModel(snapshot, lens), [lens, snapshot]);
const renderKey = `${model.suggestedFileName}:${lens}:${colors.accent}`;
const currentRender = rendered?.key === renderKey ? rendered : null;
const base64 = currentRender?.base64 ?? null;
const rendering = currentRender == null;
const error = shareError ?? currentRender?.error ?? null;
const artworkUris = useMemo(() => {
const map = new Map<string, string>();
snapshot.topTracks.forEach((item, index) => {
const uri = listeningArtworkSource(item);
if (uri) map.set(`track:${index + 1}`, uri);
});
snapshot.topArtists.forEach((item, index) => {
const uri = listeningArtworkSource(item);
if (uri) map.set(`artist:${index + 1}`, uri);
});
snapshot.topAlbums.forEach((item, index) => {
const uri = listeningArtworkSource(item);
if (uri) map.set(`album:${index + 1}`, uri);
});
return map;
}, [snapshot]);
useEffect(() => {
let cancelled = false;
void renderListeningStatsSharePng(model, {
accentColor: colors.accent,
artworkUris,
}).then(
(result) => {
if (!cancelled) setRendered({ key: renderKey, base64: result, error: null });
},
(renderError) => {
if (!cancelled) {
setRendered({
key: renderKey,
base64: null,
error: renderError instanceof Error
? renderError.message
: 'The share card could not be rendered.',
});
}
},
);
return () => {
cancelled = true;
};
}, [artworkUris, colors.accent, model, renderKey]);
const share = async () => {
if (!base64 || !cacheDirectory) {
setShareError('The temporary share image could not be created.');
return;
}
setSharing(true);
setShareError(null);
try {
if (!(await Sharing.isAvailableAsync())) {
throw new Error('No compatible sharing service is available on this device.');
}
const fileUri = `${cacheDirectory}${model.suggestedFileName}`;
await writeAsStringAsync(fileUri, base64, { encoding: EncodingType.Base64 });
await Sharing.shareAsync(fileUri, {
mimeType: 'image/png',
dialogTitle: 'Share Listening Stats',
UTI: 'public.png',
});
} catch (shareError) {
setShareError(
shareError instanceof Error ? shareError.message : 'The share sheet could not be opened.',
);
} finally {
setSharing(false);
}
};
return (
<AppSheet onClose={onClose} scrollable>
<AppSheetTitle
title="Share Listening Stats"
subtitle="This snapshot stays frozen while you choose a card."
/>
<View style={styles.lenses}>
{LENSES.map((option) => {
const disabled =
(option.key === 'track' && snapshot.topTracks.length === 0) ||
(option.key === 'album' && snapshot.topAlbums.length === 0);
const selected = option.key === lens;
return (
<Pressable
key={option.key}
style={[
styles.lens,
selected && styles.lensSelected,
disabled && styles.disabled,
]}
android_ripple={!disabled ? ripple.bounded : undefined}
disabled={disabled}
onPress={() => setLens(option.key)}
accessibilityRole="radio"
accessibilityState={{ selected, disabled }}
>
<Text
variant="label"
color={selected ? colors.accentTextStrong : colors.textSecondary}
numberOfLines={1}
>
{option.label}
</Text>
</Pressable>
);
})}
</View>
<View style={styles.preview}>
{base64 ? (
<Image
source={{ uri: `data:image/png;base64,${base64}` }}
style={styles.previewImage}
resizeMode="contain"
/>
) : (
<View style={styles.previewLoading}>
<Ionicons
name={error ? 'warning-outline' : 'image-outline'}
size={34}
color={error ? colors.warning : colors.textTertiary}
/>
<Text variant="label" color={error ? colors.warning : colors.textSecondary}>
{error ?? (rendering ? 'Rendering 1474 × 1920 PNG…' : 'Preparing preview…')}
</Text>
</View>
)}
</View>
{error && base64 ? (
<Text variant="caption" color={colors.warning} style={styles.error}>
{error}
</Text>
) : null}
<Pressable
style={[styles.shareButton, (!base64 || sharing) && styles.disabled]}
android_ripple={base64 && !sharing ? ripple.onAccent() : undefined}
disabled={!base64 || sharing}
onPress={() => void share()}
accessibilityRole="button"
>
<Ionicons name="share-outline" size={19} color={colors.bgPrimary} />
<Text variant="body" style={styles.shareLabel}>
{sharing ? 'Opening share sheet…' : 'Share PNG'}
</Text>
</Pressable>
</AppSheet>
);
}
const useStyles = createThemedStyles((colors) => ({
lenses: {
flexDirection: 'row',
gap: spacing.xs,
marginVertical: spacing.md,
padding: 3,
borderRadius: radius.pill,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
lens: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.sm,
borderRadius: radius.pill,
overflow: 'hidden',
},
lensSelected: {
backgroundColor: colors.glassHighlight,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.accent,
},
preview: {
width: 246,
height: 320,
alignSelf: 'center',
marginVertical: spacing.sm,
overflow: 'hidden',
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.bgTertiary,
},
previewImage: {
width: '100%',
height: '100%',
},
previewLoading: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
padding: spacing.lg,
},
error: {
textAlign: 'center',
marginBottom: spacing.sm,
},
shareButton: {
minHeight: 46,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
marginTop: spacing.md,
borderRadius: radius.pill,
backgroundColor: colors.accent,
overflow: 'hidden',
},
shareLabel: {
color: colors.bgPrimary,
},
disabled: {
opacity: 0.45,
},
}));
+26
View File
@@ -5,6 +5,11 @@ import { AstraLibraryScanner } from '../../modules/astra-library-scanner';
import { artworkUrlForTrack } from '@/services/remoteUrls';
import type { Track } from '@/types/audio';
import type { Album, DbTrack } from '@/types/library';
import type {
RankedListeningAlbum,
RankedListeningArtist,
RankedListeningTrack,
} from '@/types/listeningStats';
let artworkDir: string | null = null;
let artworkThumbDir: string | null = null;
@@ -125,6 +130,27 @@ export function albumArtworkSource(album: AlbumArtworkFields): string | null {
return album.artwork_hash ? artworkUri(album.artwork_hash) : null;
}
type ListeningArtworkFields = Pick<
RankedListeningTrack | RankedListeningArtist | RankedListeningAlbum,
'sourceType' | 'sourceId' | 'artworkSourceId' | 'artworkHash'
>;
/** Artwork retained with a stats ranking, including remote-library covers. */
export function listeningArtworkSource(
item: ListeningArtworkFields,
thumbnail = false,
): string | null {
if (item.sourceType !== 'local') {
return artworkUrlForTrack({
sourceType: item.sourceType,
sourceId: item.sourceId ?? undefined,
artworkSourceId: item.artworkSourceId ?? undefined,
}, thumbnail ? { size: 256 } : undefined);
}
if (!item.artworkHash) return null;
return thumbnail ? artworkThumbUri(item.artworkHash) : artworkUri(item.artworkHash);
}
export async function ensureArtworkThumbnails(
hashes: readonly (string | null | undefined)[]
): Promise<number> {
+17
View File
@@ -0,0 +1,17 @@
export interface ListeningHistoryChange {
qualifiedNow: boolean;
}
const listeners = new Set<(change: ListeningHistoryChange) => void>();
export function subscribeToListeningHistory(
listener: (change: ListeningHistoryChange) => void,
): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function notifyListeningHistoryChanged(qualifiedNow = false): void {
const change = { qualifiedNow };
listeners.forEach((listener) => listener(change));
}
+29
View File
@@ -0,0 +1,29 @@
export function formatListeningTime(totalSeconds: number, compact = false): string {
const seconds = Math.max(0, Math.round(Number.isFinite(totalSeconds) ? totalSeconds : 0));
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) return compact ? `${hours}h ${minutes}m` : `${hours} hr ${minutes} min`;
if (minutes > 0) return compact ? `${minutes}m` : `${minutes} min`;
return compact ? `${seconds}s` : `${seconds} sec`;
}
export function formatRecordedSince(timestamp: number | null): string {
if (timestamp == null) return 'No detailed history recorded yet';
return `Recorded on this phone since ${new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
}).format(new Date(timestamp))}`;
}
export function formatBucketDate(startAt: number, endAt: number): string {
const start = new Date(startAt);
const end = new Date(Math.max(startAt, endAt - 1));
const formatter = new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
if (start.toDateString() === end.toDateString()) return formatter.format(start);
return `${formatter.format(start)} ${formatter.format(end)}`;
}
+2
View File
@@ -0,0 +1,2 @@
export const LISTENING_STATS_SHARE_WIDTH = 1474;
export const LISTENING_STATS_SHARE_HEIGHT = 1920;
+90
View File
@@ -0,0 +1,90 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
LISTENING_STATS_SHARE_HEIGHT,
LISTENING_STATS_SHARE_WIDTH,
} from './shareDimensions.ts';
import {
buildListeningStatsShareModel,
formatCompactListeningDuration,
formatListeningShare,
} from './shareModel.ts';
import type { ListeningStatsDashboard } from '../types/listeningStats.ts';
function dashboard(): ListeningStatsDashboard {
return {
status: { generation: 'generation', startedAt: 1_700_000_000_000, enabled: true },
range: '30d',
rankingMetric: 'plays',
rangeStartAt: 1_700_000_000_000,
rangeEndAt: 1_702_000_000_000,
granularity: 'day',
summary: {
listenedSeconds: 7_200,
qualifiedPlays: 12,
tracksPlayed: 4,
activeDays: 3,
},
activity: [],
topTracks: [{
key: 'track:/private/path.flac',
trackPath: '/private/path.flac',
title: 'Top Track',
artist: 'Artist',
album: 'Album',
artworkHash: null,
sourceType: 'local',
sourceId: null,
artworkSourceId: null,
listenedSeconds: 3_600,
qualifiedPlays: 7,
available: true,
}],
topArtists: [{
key: 'artist',
artist: 'Artist',
artworkHash: null,
sourceType: 'local',
sourceId: null,
artworkSourceId: null,
listenedSeconds: 4_000,
qualifiedPlays: 8,
available: true,
}],
topAlbums: [{
key: 'album-key',
album: 'Album',
artist: 'Artist',
artworkHash: null,
sourceType: 'local',
sourceId: null,
artworkSourceId: null,
listenedSeconds: 3_900,
qualifiedPlays: 8,
available: true,
}],
};
}
test('share model carries range and ranking context without private paths', () => {
const model = buildListeningStatsShareModel(dashboard(), 'track');
assert.equal(model.title, 'YOUR TOP TRACK');
assert.equal(model.rankingLabel, 'RANKED BY PLAYS');
assert.match(model.suggestedFileName, /^astra-listening-30d-plays-\d{4}-\d{2}-\d{2}\.png$/);
assert.equal(JSON.stringify(model).includes('/private/path.flac'), false);
});
test('overview and album lenses use matching ranked data', () => {
assert.deepEqual(
buildListeningStatsShareModel(dashboard(), 'overview').overviewItems.map((item) => item.kind),
['track', 'album', 'artist'],
);
assert.equal(buildListeningStatsShareModel(dashboard(), 'album').hero?.title, 'Album');
});
test('duration, percentages, and canonical PNG dimensions are stable', () => {
assert.equal(formatCompactListeningDuration(7_200), '2h');
assert.equal(formatListeningShare(3_600, 7_200), '50%');
assert.equal(LISTENING_STATS_SHARE_WIDTH, 1474);
assert.equal(LISTENING_STATS_SHARE_HEIGHT, 1920);
});
+233
View File
@@ -0,0 +1,233 @@
import type {
ListeningStatsDashboard,
ListeningStatsRange,
ListeningStatsRankingMetric,
} from '@/types/listeningStats';
export type ListeningStatsShareLens = 'overview' | 'track' | 'album';
export type ListeningStatsShareItemKind = 'track' | 'album' | 'artist';
export interface ListeningStatsShareItem {
kind: ListeningStatsShareItemKind;
rank: number;
available: boolean;
key: string;
title: string;
subtitle: string;
listenedSeconds: number;
qualifiedPlays: number;
}
export interface ListeningStatsShareModel {
lens: ListeningStatsShareLens;
range: ListeningStatsRange;
rankingMetric: ListeningStatsRankingMetric;
rankingLabel: string;
rangeLabel: string;
title: string;
hero: ListeningStatsShareItem | null;
overviewItems: ListeningStatsShareItem[];
secondaryItems: ListeningStatsShareItem[];
summaryStats: { label: string; value: string }[];
personalityValue: string;
personalityText: string;
artworkKeys: string[];
suggestedFileName: string;
}
const COUNT_FORMATTER = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 });
const SHORT_DATE_FORMATTER = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
});
const FULL_DATE_FORMATTER = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
function safeNumber(value: number): number {
return Number.isFinite(value) ? Math.max(0, value) : 0;
}
export function formatCompactListeningDuration(seconds: number): string {
const totalMinutes = Math.floor(safeNumber(seconds) / 60);
if (totalMinutes < 1) return '<1m';
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours < 1) return `${minutes}m`;
if (minutes === 0) return `${hours}h`;
return `${hours}h ${minutes}m`;
}
export function formatListeningShare(partSeconds: number, totalSeconds: number): string {
const total = safeNumber(totalSeconds);
const part = Math.min(safeNumber(partSeconds), total);
if (total <= 0 || part <= 0) return '0%';
const percentage = (part / total) * 100;
if (percentage < 1) return '<1%';
return `${Math.min(100, Math.round(percentage))}%`;
}
function formatRangeLabel(dashboard: ListeningStatsDashboard): string {
if (dashboard.range === 'all') {
const start = dashboard.status.startedAt ?? dashboard.rangeStartAt;
return start == null
? 'ALL RECORDED LISTENING'
: `SINCE ${FULL_DATE_FORMATTER.format(start).toUpperCase()}`;
}
const start = dashboard.rangeStartAt;
if (start == null) return dashboard.range.toUpperCase();
const end = dashboard.rangeEndAt;
if (new Date(start).getFullYear() !== new Date(end).getFullYear()) {
return `${FULL_DATE_FORMATTER.format(start)} ${FULL_DATE_FORMATTER.format(end)}`.toUpperCase();
}
return `${SHORT_DATE_FORMATTER.format(start)} ${FULL_DATE_FORMATTER.format(end)}`.toUpperCase();
}
function createSuggestedFileName(dashboard: ListeningStatsDashboard): string {
const date = new Date(dashboard.rangeEndAt).toISOString().slice(0, 10);
return `astra-listening-${dashboard.range}-${dashboard.rankingMetric}-${date}.png`;
}
function trackItem(
track: ListeningStatsDashboard['topTracks'][number],
rank = 1,
): ListeningStatsShareItem {
return {
kind: 'track',
rank,
available: track.available,
key: `track:${rank}`,
title: track.title,
subtitle: `${track.artist}${track.album}`,
listenedSeconds: track.listenedSeconds,
qualifiedPlays: track.qualifiedPlays,
};
}
function albumItem(
album: ListeningStatsDashboard['topAlbums'][number],
rank = 1,
): ListeningStatsShareItem {
return {
kind: 'album',
rank,
available: album.available,
key: `album:${rank}`,
title: album.album,
subtitle: album.artist,
listenedSeconds: album.listenedSeconds,
qualifiedPlays: album.qualifiedPlays,
};
}
function artistItem(
artist: ListeningStatsDashboard['topArtists'][number],
rank = 1,
): ListeningStatsShareItem {
return {
kind: 'artist',
rank,
available: artist.available,
key: `artist:${rank}`,
title: artist.artist,
subtitle: 'Artist',
listenedSeconds: artist.listenedSeconds,
qualifiedPlays: artist.qualifiedPlays,
};
}
export function buildListeningStatsShareModel(
dashboard: ListeningStatsDashboard,
lens: ListeningStatsShareLens,
): ListeningStatsShareModel {
const rankingLabel =
dashboard.rankingMetric === 'plays' ? 'RANKED BY PLAYS' : 'RANKED BY LISTENING TIME';
const common = {
lens,
range: dashboard.range,
rankingMetric: dashboard.rankingMetric,
rankingLabel,
rangeLabel: formatRangeLabel(dashboard),
summaryStats: [
{
label: 'LISTENED',
value: formatCompactListeningDuration(dashboard.summary.listenedSeconds),
},
{
label: 'PLAYS',
value: COUNT_FORMATTER.format(safeNumber(dashboard.summary.qualifiedPlays)),
},
{
label: 'ACTIVE DAYS',
value: COUNT_FORMATTER.format(safeNumber(dashboard.summary.activeDays)),
},
],
suggestedFileName: createSuggestedFileName(dashboard),
};
if (lens === 'track') {
const items = dashboard.topTracks.map((track, index) => trackItem(track, index + 1));
const hero = items[0] ?? null;
const secondaryItems = items.slice(1, 4);
return {
...common,
title: 'YOUR TOP TRACK',
hero,
overviewItems: [],
secondaryItems,
personalityValue: formatListeningShare(
hero?.listenedSeconds ?? 0,
dashboard.summary.listenedSeconds,
),
personalityText: 'of your listening time went to this track.',
artworkKeys: [hero, ...secondaryItems]
.filter((item): item is ListeningStatsShareItem => item != null)
.map((item) => item.key),
};
}
if (lens === 'album') {
const items = dashboard.topAlbums.map((album, index) => albumItem(album, index + 1));
const hero = items[0] ?? null;
const secondaryItems = items.slice(1, 4);
return {
...common,
title: 'YOUR TOP ALBUM',
hero,
overviewItems: [],
secondaryItems,
personalityValue: formatListeningShare(
hero?.listenedSeconds ?? 0,
dashboard.summary.listenedSeconds,
),
personalityText: 'of your listening time was spent inside this album.',
artworkKeys: [hero, ...secondaryItems]
.filter((item): item is ListeningStatsShareItem => item != null)
.map((item) => item.key),
};
}
const overviewItems = [
dashboard.topTracks[0] ? trackItem(dashboard.topTracks[0]) : null,
dashboard.topAlbums[0] ? albumItem(dashboard.topAlbums[0]) : null,
dashboard.topArtists[0] ? artistItem(dashboard.topArtists[0]) : null,
].filter((item): item is ListeningStatsShareItem => item != null);
const topArtist = overviewItems.find((item) => item.kind === 'artist') ?? null;
return {
...common,
title: 'YOUR LISTENING',
hero: null,
overviewItems,
secondaryItems: [],
personalityValue: formatListeningShare(
topArtist?.listenedSeconds ?? 0,
dashboard.summary.listenedSeconds,
),
personalityText: topArtist
? `of your listening time went to ${topArtist.title}.`
: 'of your listening time is still waiting to be discovered.',
artworkKeys: overviewItems.map((item) => item.key),
};
}
+573
View File
@@ -0,0 +1,573 @@
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();
}
}
+14
View File
@@ -0,0 +1,14 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { isDisplayedTabFocused } from './statsTabState.ts';
test('Stats keeps Home visibly selected without selecting another visible tab', () => {
assert.equal(isDisplayedTabFocused('index', 0, 4, 'stats'), true);
assert.equal(isDisplayedTabFocused('library', 1, 4, 'stats'), false);
assert.equal(isDisplayedTabFocused('stats', 4, 4, 'stats'), true);
});
test('ordinary tabs keep their normal selected state', () => {
assert.equal(isDisplayedTabFocused('index', 0, 1, 'library'), false);
assert.equal(isDisplayedTabFocused('library', 1, 1, 'library'), true);
});
+9
View File
@@ -0,0 +1,9 @@
/** Stats is Home-owned: it is a hidden route while Home remains visibly selected. */
export function isDisplayedTabFocused(
routeName: string,
routeIndex: number,
activeIndex: number,
activeRouteName: string | undefined,
): boolean {
return routeIndex === activeIndex || (routeName === 'index' && activeRouteName === 'stats');
}
+1
View File
@@ -26,6 +26,7 @@ test('normalizes stable routes and rejects transient or unsafe routes', () => {
assert.equal(normalizeStableHref('/library/artist/Artist?credit=1&ignored=yes'), '/library/artist/Artist?credit=1');
assert.equal(normalizeStableHref('/settings/audio?ignored=yes'), '/settings/audio');
assert.equal(normalizeStableHref('/settings/playback'), '/settings/playback');
assert.equal(normalizeStableHref('/stats'), '/stats');
assert.equal(normalizeStableHref('/settings/lyrics'), '/settings/lyrics');
assert.equal(normalizeStableHref('/settings/troubleshooting'), '/settings/troubleshooting');
assert.equal(normalizeStableHref('/library/playlist/edit-dynamic?id=4'), null);
+1
View File
@@ -57,6 +57,7 @@ const STATIC_STABLE_PATHS = new Set([
'/eq',
'/settings',
'/recently-played',
'/stats',
'/settings/appearance',
'/settings/library',
'/settings/audio',
+5
View File
@@ -123,6 +123,7 @@ interface LibraryStore {
loadPreviousArtists: () => Promise<void>;
jumpToSection: (cursor: string) => Promise<boolean>;
recordTrackPlayed: (path: string) => Promise<void>;
refreshRecentlyPlayed: () => Promise<void>;
recomputeArtists: () => void;
recomputeAlbums: () => void;
setViewMode: (mode: ViewMode) => void;
@@ -907,6 +908,10 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
set({ recentlyPlayedTracks: await AstraLibraryData.getRecentlyPlayed<DbTrack>(20) });
},
refreshRecentlyPlayed: async () => {
set({ recentlyPlayedTracks: await AstraLibraryData.getRecentlyPlayed<DbTrack>(20) });
},
recomputeArtists: () => {
void resetArtists();
},
+97
View File
@@ -0,0 +1,97 @@
import { create } from 'zustand';
import { AstraLibraryData } from '../../modules/astra-library-scanner';
import { useSettingsStore } from './settingsStore';
import type {
ListeningStatsCategory,
ListeningStatsDashboard,
ListeningStatsRange,
ListeningStatsRankingMetric,
} from '@/types/listeningStats';
interface ListeningStatsStore {
range: ListeningStatsRange;
rankingMetric: ListeningStatsRankingMetric;
category: ListeningStatsCategory;
dashboard: ListeningStatsDashboard | null;
homePreview: ListeningStatsDashboard | null;
loading: boolean;
refreshing: boolean;
error: string | null;
setRange: (range: ListeningStatsRange) => void;
setRankingMetric: (metric: ListeningStatsRankingMetric) => void;
setCategory: (category: ListeningStatsCategory) => void;
loadDashboard: () => Promise<void>;
loadHomePreview: () => Promise<void>;
}
let dashboardRequest = 0;
let homeRequest = 0;
function errorMessage(error: unknown): string {
return error instanceof Error && error.message ? error.message : 'Listening Stats could not load.';
}
async function queryDashboard(
range: ListeningStatsRange,
rankingMetric: ListeningStatsRankingMetric,
): Promise<ListeningStatsDashboard> {
await AstraLibraryData.initialize();
return AstraLibraryData.getListeningStatsDashboard<ListeningStatsDashboard>({
range,
rankingMetric,
artistGroupingMode: useSettingsStore.getState().artistGroupingMode,
});
}
export const useListeningStatsStore = create<ListeningStatsStore>((set, get) => ({
range: '30d',
rankingMetric: 'plays',
category: 'tracks',
dashboard: null,
homePreview: null,
loading: false,
refreshing: false,
error: null,
setRange: (range) => {
if (get().range === range) return;
set({ range });
void get().loadDashboard();
},
setRankingMetric: (rankingMetric) => {
if (get().rankingMetric === rankingMetric) return;
set({ rankingMetric });
void get().loadDashboard();
},
setCategory: (category) => set({ category }),
loadDashboard: async () => {
const request = ++dashboardRequest;
set((state) => ({
loading: state.dashboard == null,
refreshing: state.dashboard != null,
error: null,
}));
try {
const { range, rankingMetric } = get();
const dashboard = await queryDashboard(range, rankingMetric);
if (request !== dashboardRequest) return;
set({ dashboard, loading: false, refreshing: false, error: null });
} catch (error) {
if (request !== dashboardRequest) return;
set({ loading: false, refreshing: false, error: errorMessage(error) });
}
},
loadHomePreview: async () => {
const request = ++homeRequest;
try {
const homePreview = await queryDashboard('7d', 'plays');
if (request === homeRequest) set({ homePreview });
} catch {
// Home remains quiet on transient stats failures; the full screen has retry UI.
}
},
}));
+28
View File
@@ -9,6 +9,11 @@ import {
parseHomeGreetingTextMode,
type HomeGreetingTextMode,
} from '@/home/homeGreeting';
import {
pauseListeningHistoryTracking,
resumeListeningHistoryTracking,
} from '@/audio/listeningHistoryTracker';
import { notifyListeningHistoryChanged } from '@/listeningStats/events';
/**
* Persisted app preferences. SQLite (settings table) is the source of truth this
@@ -23,6 +28,7 @@ const SCOPE_STYLE_KEY = 'now_playing_scope_style';
const LYRICS_VISIBLE_KEY = 'lyrics_visible';
const NOW_PLAYING_COMPANION_KEY = 'now_playing_companion';
const HOME_GREETING_TEXT_MODE_KEY = 'home_greeting_text_mode';
const LISTENING_HISTORY_ENABLED_KEY = 'listening_history_enabled';
/** Which visualizer the now-playing scope stage shows. */
export type ScopeMode = 'spectrum' | 'scope';
@@ -61,6 +67,7 @@ interface SettingsStore {
lyricsVisible: boolean;
nowPlayingCompanion: NowPlayingCompanion;
homeGreetingTextMode: HomeGreetingTextMode;
listeningHistoryEnabled: boolean;
loaded: boolean;
load: () => Promise<void>;
setArtistGroupingMode: (mode: ArtistGroupingMode) => Promise<void>;
@@ -71,6 +78,7 @@ interface SettingsStore {
setLyricsVisible: (visible: boolean) => Promise<void>;
setNowPlayingCompanion: (companion: NowPlayingCompanion) => Promise<void>;
setHomeGreetingTextMode: (mode: HomeGreetingTextMode) => Promise<void>;
setListeningHistoryEnabled: (enabled: boolean) => Promise<void>;
}
export const useSettingsStore = create<SettingsStore>((set, get) => ({
@@ -82,6 +90,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
lyricsVisible: false,
nowPlayingCompanion: 'queue',
homeGreetingTextMode: 'messages',
listeningHistoryEnabled: true,
loaded: false,
load: async () => {
@@ -96,6 +105,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
LYRICS_VISIBLE_KEY,
NOW_PLAYING_COMPANION_KEY,
HOME_GREETING_TEXT_MODE_KEY,
LISTENING_HISTORY_ENABLED_KEY,
]);
const grouping = values[ARTIST_GROUPING_KEY] ?? null;
const includeSingles = values[INCLUDE_SINGLES_KEY] ?? null;
@@ -105,6 +115,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
const lyricsVisible = values[LYRICS_VISIBLE_KEY] ?? null;
const nowPlayingCompanion = values[NOW_PLAYING_COMPANION_KEY] ?? null;
const homeGreetingTextMode = values[HOME_GREETING_TEXT_MODE_KEY] ?? null;
const listeningHistoryEnabled = values[LISTENING_HISTORY_ENABLED_KEY] !== '0';
set({
artistGroupingMode: parseGroupingMode(grouping),
includeSingles: parseBoolean(includeSingles),
@@ -114,6 +125,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
lyricsVisible: parseBoolean(lyricsVisible),
nowPlayingCompanion: parseNowPlayingCompanion(nowPlayingCompanion),
homeGreetingTextMode: parseHomeGreetingTextMode(homeGreetingTextMode),
listeningHistoryEnabled,
loaded: true,
});
},
@@ -166,4 +178,20 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
set({ homeGreetingTextMode: nextMode });
await AstraLibraryData.setSettings({ [HOME_GREETING_TEXT_MODE_KEY]: nextMode });
},
setListeningHistoryEnabled: async (enabled) => {
if (get().listeningHistoryEnabled === enabled) return;
if (!enabled) await pauseListeningHistoryTracking();
try {
await AstraLibraryData.setSettings({
[LISTENING_HISTORY_ENABLED_KEY]: enabled ? '1' : '0',
});
set({ listeningHistoryEnabled: enabled });
notifyListeningHistoryChanged();
if (enabled) resumeListeningHistoryTracking();
} catch (error) {
if (!enabled) resumeListeningHistoryTracking();
throw error;
}
},
}));
+1
View File
@@ -51,6 +51,7 @@ export type PlaybackSourceKind =
| 'search'
| 'signal'
| 'android-auto'
| 'listening-stats'
| 'sample';
/** The collection or surface that created the current playback queue. */
+96
View File
@@ -0,0 +1,96 @@
export type ListeningStatsRange = '7d' | '30d' | '1y' | 'all';
export type ListeningStatsRankingMetric = 'plays' | 'time';
export type ListeningStatsGranularity = 'day' | 'week' | 'month';
export type ListeningStatsCategory = 'tracks' | 'artists' | 'albums';
export interface ListeningHistoryStatus {
generation: string;
startedAt: number | null;
enabled: boolean;
}
export interface ListeningSessionCheckpoint {
generation: string;
sessionKey: string;
segmentKey: string;
trackPath: string;
sessionStartedAt: number;
segmentStartedAt: number;
observedAt: number;
sessionListenedSeconds: number;
segmentListenedSeconds: number;
trackDurationSeconds: number;
finalizeSegment: boolean;
finalizeSession: boolean;
completedNaturally: boolean;
qualificationEligible: boolean;
}
export interface ListeningCheckpointResult {
accepted: boolean;
qualifiedNow: boolean;
status: ListeningHistoryStatus;
}
export interface ListeningStatsSummary {
listenedSeconds: number;
qualifiedPlays: number;
tracksPlayed: number;
activeDays: number;
}
export interface ListeningStatsActivityBucket {
startAt: number;
endAt: number;
label: string;
listenedSeconds: number;
qualifiedPlays: number;
}
interface RankedListeningRecord {
key: string;
artworkHash: string | null;
sourceType: 'local' | 'subsonic' | 'jellyfin';
sourceId: number | null;
artworkSourceId: string | null;
listenedSeconds: number;
qualifiedPlays: number;
available: boolean;
}
export interface RankedListeningTrack extends RankedListeningRecord {
trackPath: string | null;
title: string;
artist: string;
album: string;
}
export interface RankedListeningArtist extends RankedListeningRecord {
artist: string;
}
export interface RankedListeningAlbum extends RankedListeningRecord {
album: string;
artist: string;
}
export interface ListeningStatsDashboard {
status: ListeningHistoryStatus;
range: ListeningStatsRange;
rankingMetric: ListeningStatsRankingMetric;
rangeStartAt: number | null;
rangeEndAt: number;
granularity: ListeningStatsGranularity;
summary: ListeningStatsSummary;
activity: ListeningStatsActivityBucket[];
topTracks: RankedListeningTrack[];
topArtists: RankedListeningArtist[];
topAlbums: RankedListeningAlbum[];
}
export interface ListeningStatsDashboardQuery {
range: ListeningStatsRange;
rankingMetric: ListeningStatsRankingMetric;
artistGroupingMode: 'astra' | 'fileTags';
now?: number;
}