mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
add stats tracking + stats page + fix scrolling bug in dynamic playlists tray
This commit is contained in:
+1022
File diff suppressed because it is too large
Load Diff
+176
@@ -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 ->
|
||||
|
||||
+122
@@ -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"
|
||||
}
|
||||
}
|
||||
+16
@@ -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) }
|
||||
}
|
||||
|
||||
+31
@@ -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 =
|
||||
|
||||
+785
@@ -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
|
||||
+170
-1
@@ -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`)",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+65
@@ -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"])],
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -12,6 +12,7 @@ const PLAYBACK_SOURCE_KINDS = new Set<PlaybackSourceKind>([
|
||||
'search',
|
||||
'signal',
|
||||
'android-auto',
|
||||
'listening-stats',
|
||||
'sample',
|
||||
]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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> {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const LISTENING_STATS_SHARE_WIDTH = 1474;
|
||||
export const LISTENING_STATS_SHARE_HEIGHT = 1920;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -57,6 +57,7 @@ const STATIC_STABLE_PATHS = new Set([
|
||||
'/eq',
|
||||
'/settings',
|
||||
'/recently-played',
|
||||
'/stats',
|
||||
'/settings/appearance',
|
||||
'/settings/library',
|
||||
'/settings/audio',
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -51,6 +51,7 @@ export type PlaybackSourceKind =
|
||||
| 'search'
|
||||
| 'signal'
|
||||
| 'android-auto'
|
||||
| 'listening-stats'
|
||||
| 'sample';
|
||||
|
||||
/** The collection or surface that created the current playback queue. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user