diff --git a/modules/astra-library-scanner/android/schemas/expo.modules.astralibraryscanner.data.AstraUserDatabase/2.json b/modules/astra-library-scanner/android/schemas/expo.modules.astralibraryscanner.data.AstraUserDatabase/2.json new file mode 100644 index 0000000..a95c58e --- /dev/null +++ b/modules/astra-library-scanner/android/schemas/expo.modules.astralibraryscanner.data.AstraUserDatabase/2.json @@ -0,0 +1,1022 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "df7c3e29b08958c78d78c7341a3f9a77", + "entities": [ + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tree_uri` TEXT NOT NULL, `display_name` TEXT NOT NULL, `added_at` INTEGER NOT NULL, `last_scanned_at` INTEGER, `last_scan_status` TEXT NOT NULL, `last_scan_error` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "treeUri", + "columnName": "tree_uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "added_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastScannedAt", + "columnName": "last_scanned_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastScanStatus", + "columnName": "last_scan_status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastScanError", + "columnName": "last_scan_error", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_folders_tree_uri", + "unique": true, + "columnNames": [ + "tree_uri" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_folders_tree_uri` ON `${TABLE_NAME}` (`tree_uri`)" + } + ] + }, + { + "tableName": "playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `last_played_at` INTEGER, `kind` TEXT NOT NULL, `dynamic_rules_json` TEXT, `remote_source_id` INTEGER, `remote_playlist_id` TEXT, `sync_uid` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastPlayedAt", + "columnName": "last_played_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dynamicRulesJson", + "columnName": "dynamic_rules_json", + "affinity": "TEXT" + }, + { + "fieldPath": "remoteSourceId", + "columnName": "remote_source_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "remotePlaylistId", + "columnName": "remote_playlist_id", + "affinity": "TEXT" + }, + { + "fieldPath": "syncUid", + "columnName": "sync_uid", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_playlists_sync_uid", + "unique": true, + "columnNames": [ + "sync_uid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlists_sync_uid` ON `${TABLE_NAME}` (`sync_uid`)" + }, + { + "name": "index_playlists_remote_source_id_remote_playlist_id", + "unique": true, + "columnNames": [ + "remote_source_id", + "remote_playlist_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlists_remote_source_id_remote_playlist_id` ON `${TABLE_NAME}` (`remote_source_id`, `remote_playlist_id`)" + }, + { + "name": "index_playlists_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_kind` ON `${TABLE_NAME}` (`kind`)" + } + ] + }, + { + "tableName": "playlist_tracks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlist_id` INTEGER NOT NULL, `track_path` TEXT NOT NULL, `position` INTEGER NOT NULL, `added_at` INTEGER NOT NULL, `fallback_title` TEXT, `fallback_artist` TEXT, `fallback_album` TEXT, FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackPath", + "columnName": "track_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "added_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fallbackTitle", + "columnName": "fallback_title", + "affinity": "TEXT" + }, + { + "fieldPath": "fallbackArtist", + "columnName": "fallback_artist", + "affinity": "TEXT" + }, + { + "fieldPath": "fallbackAlbum", + "columnName": "fallback_album", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_playlist_tracks_playlist_id_position", + "unique": false, + "columnNames": [ + "playlist_id", + "position" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_tracks_playlist_id_position` ON `${TABLE_NAME}` (`playlist_id`, `position`)" + }, + { + "name": "index_playlist_tracks_playlist_id_track_path", + "unique": true, + "columnNames": [ + "playlist_id", + "track_path" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlist_tracks_playlist_id_track_path` ON `${TABLE_NAME}` (`playlist_id`, `track_path`)" + } + ], + "foreignKeys": [ + { + "table": "playlists", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlist_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "favorites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`track_path` TEXT NOT NULL, `added_at` INTEGER NOT NULL, PRIMARY KEY(`track_path`))", + "fields": [ + { + "fieldPath": "trackPath", + "columnName": "track_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "added_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "track_path" + ] + } + }, + { + "tableName": "playback_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`track_path` TEXT NOT NULL, `last_played_at` INTEGER NOT NULL, `play_count` INTEGER NOT NULL, PRIMARY KEY(`track_path`))", + "fields": [ + { + "fieldPath": "trackPath", + "columnName": "track_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastPlayedAt", + "columnName": "last_played_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "track_path" + ] + }, + "indices": [ + { + "name": "index_playback_history_last_played_at", + "unique": false, + "columnNames": [ + "last_played_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_history_last_played_at` ON `${TABLE_NAME}` (`last_played_at`)" + } + ] + }, + { + "tableName": "listening_history_meta", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `generation` TEXT NOT NULL, `started_at` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "generation", + "columnName": "generation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startedAt", + "columnName": "started_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "listening_sessions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`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`))", + "fields": [ + { + "fieldPath": "sessionKey", + "columnName": "session_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "generation", + "columnName": "generation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackPath", + "columnName": "track_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistNamesJson", + "columnName": "artist_names_json", + "affinity": "TEXT" + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumArtist", + "columnName": "album_artist", + "affinity": "TEXT" + }, + { + "fieldPath": "albumArtistNamesJson", + "columnName": "album_artist_names_json", + "affinity": "TEXT" + }, + { + "fieldPath": "albumIdentityKey", + "columnName": "album_identity_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artworkHash", + "columnName": "artwork_hash", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceType", + "columnName": "source_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "source_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "artworkSourceId", + "columnName": "artwork_source_id", + "affinity": "TEXT" + }, + { + "fieldPath": "durationSeconds", + "columnName": "duration_seconds", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "startedAt", + "columnName": "started_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endedAt", + "columnName": "ended_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "listenedSeconds", + "columnName": "listened_seconds", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "qualifiedAt", + "columnName": "qualified_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "session_key" + ] + }, + "indices": [ + { + "name": "index_listening_sessions_generation_started_at", + "unique": false, + "columnNames": [ + "generation", + "started_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_sessions_generation_started_at` ON `${TABLE_NAME}` (`generation`, `started_at`)" + }, + { + "name": "index_listening_sessions_generation_qualified_at", + "unique": false, + "columnNames": [ + "generation", + "qualified_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_sessions_generation_qualified_at` ON `${TABLE_NAME}` (`generation`, `qualified_at`)" + }, + { + "name": "index_listening_sessions_track_path", + "unique": false, + "columnNames": [ + "track_path" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_sessions_track_path` ON `${TABLE_NAME}` (`track_path`)" + } + ] + }, + { + "tableName": "listening_segments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`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 )", + "fields": [ + { + "fieldPath": "sessionKey", + "columnName": "session_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "segmentKey", + "columnName": "segment_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "generation", + "columnName": "generation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startedAt", + "columnName": "started_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastObservedAt", + "columnName": "last_observed_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endedAt", + "columnName": "ended_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "listenedSeconds", + "columnName": "listened_seconds", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "session_key", + "segment_key" + ] + }, + "indices": [ + { + "name": "index_listening_segments_generation_started_at_last_observed_at", + "unique": false, + "columnNames": [ + "generation", + "started_at", + "last_observed_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_segments_generation_started_at_last_observed_at` ON `${TABLE_NAME}` (`generation`, `started_at`, `last_observed_at`)" + }, + { + "name": "index_listening_segments_session_key", + "unique": false, + "columnNames": [ + "session_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_listening_segments_session_key` ON `${TABLE_NAME}` (`session_key`)" + } + ], + "foreignKeys": [ + { + "table": "listening_sessions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "session_key" + ], + "referencedColumns": [ + "session_key" + ] + } + ] + }, + { + "tableName": "remote_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` TEXT NOT NULL, `name` TEXT NOT NULL, `base_url` TEXT NOT NULL, `username` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `last_status` TEXT NOT NULL, `last_error` TEXT, `last_sync_at` INTEGER, `last_checked_at` INTEGER, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "base_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus", + "columnName": "last_status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastError", + "columnName": "last_error", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSyncAt", + "columnName": "last_sync_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastCheckedAt", + "columnName": "last_checked_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_remote_sources_type_name", + "unique": false, + "columnNames": [ + "type", + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_remote_sources_type_name` ON `${TABLE_NAME}` (`type`, `name`)" + } + ] + }, + { + "tableName": "favorite_tombstones", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sync_key` TEXT NOT NULL, `deleted_at` INTEGER NOT NULL, PRIMARY KEY(`sync_key`))", + "fields": [ + { + "fieldPath": "syncKey", + "columnName": "sync_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deleted_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sync_key" + ] + } + }, + { + "tableName": "favorite_sync_pending", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sync_key` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT NOT NULL, `added_at` INTEGER NOT NULL, PRIMARY KEY(`sync_key`))", + "fields": [ + { + "fieldPath": "syncKey", + "columnName": "sync_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "added_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sync_key" + ] + } + }, + { + "tableName": "playlist_tombstones", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sync_uid` TEXT NOT NULL, `deleted_at` INTEGER NOT NULL, PRIMARY KEY(`sync_uid`))", + "fields": [ + { + "fieldPath": "syncUid", + "columnName": "sync_uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deleted_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sync_uid" + ] + } + }, + { + "tableName": "playlist_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sync_uid` TEXT NOT NULL, `local_updated_at` INTEGER NOT NULL, `remote_updated_at` INTEGER NOT NULL, PRIMARY KEY(`sync_uid`))", + "fields": [ + { + "fieldPath": "syncUid", + "columnName": "sync_uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localUpdatedAt", + "columnName": "local_updated_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteUpdatedAt", + "columnName": "remote_updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sync_uid" + ] + } + }, + { + "tableName": "playback_sessions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `context_json` TEXT NOT NULL, `anchor_path` TEXT, `shuffle_seed` INTEGER, `active_position` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextJson", + "columnName": "context_json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "anchorPath", + "columnName": "anchor_path", + "affinity": "TEXT" + }, + { + "fieldPath": "shuffleSeed", + "columnName": "shuffle_seed", + "affinity": "INTEGER" + }, + { + "fieldPath": "activePosition", + "columnName": "active_position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "playback_queue_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`session_id` TEXT NOT NULL, `position` INTEGER NOT NULL, `track_path` TEXT NOT NULL, PRIMARY KEY(`session_id`, `position`), FOREIGN KEY(`session_id`) REFERENCES `playback_sessions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackPath", + "columnName": "track_path", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "session_id", + "position" + ] + }, + "indices": [ + { + "name": "index_playback_queue_entries_session_id_track_path", + "unique": false, + "columnNames": [ + "session_id", + "track_path" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_queue_entries_session_id_track_path` ON `${TABLE_NAME}` (`session_id`, `track_path`)" + } + ], + "foreignKeys": [ + { + "table": "playback_sessions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "session_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playback_original_queue_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`session_id` TEXT NOT NULL, `position` INTEGER NOT NULL, `track_path` TEXT NOT NULL, PRIMARY KEY(`session_id`, `position`), FOREIGN KEY(`session_id`) REFERENCES `playback_sessions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackPath", + "columnName": "track_path", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "session_id", + "position" + ] + }, + "indices": [ + { + "name": "index_playback_original_queue_entries_session_id_track_path", + "unique": false, + "columnNames": [ + "session_id", + "track_path" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_original_queue_entries_session_id_track_path` ON `${TABLE_NAME}` (`session_id`, `track_path`)" + } + ], + "foreignKeys": [ + { + "table": "playback_sessions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "session_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "snapshot_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `last_snapshot_at` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSnapshotAt", + "columnName": "last_snapshot_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'df7c3e29b08958c78d78c7341a3f9a77')" + ] + } +} \ No newline at end of file diff --git a/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/RoomLibraryRepositoryTest.kt b/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/RoomLibraryRepositoryTest.kt index f510d1c..a9b41c9 100644 --- a/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/RoomLibraryRepositoryTest.kt +++ b/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/RoomLibraryRepositoryTest.kt @@ -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( + "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( + "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 = + 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 = (0 until ALPHABET_SEED_SIZE).map { index -> diff --git a/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/UserMigrationTest.kt b/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/UserMigrationTest.kt new file mode 100644 index 0000000..2d6b625 --- /dev/null +++ b/modules/astra-library-scanner/android/src/androidTest/java/expo/modules/astralibraryscanner/data/UserMigrationTest.kt @@ -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" + } +} diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryDataModule.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryDataModule.kt index 8280476..4a9a784 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryDataModule.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryDataModule.kt @@ -204,6 +204,22 @@ class AstraLibraryDataModule : Module() { repositoryCall { recordTrackPlayed(path) } } + AsyncFunction("getListeningHistoryStatus").Coroutine> { + repositoryCall { getListeningHistoryStatus() } + } + + AsyncFunction("checkpointListeningSession") Coroutine { payload: Map -> + repositoryCall { checkpointListeningSession(payload) } + } + + AsyncFunction("getListeningStatsDashboard") Coroutine { query: Map -> + repositoryCall { getListeningStatsDashboard(query) } + } + + AsyncFunction("clearDetailedListeningHistory").Coroutine> { + repositoryCall { clearDetailedListeningHistory() } + } + AsyncFunction("getRecentlyPlayed") Coroutine { limit: Int -> repositoryCall { getRecentlyPlayed(limit) } } diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/AstraLibraryRepository.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/AstraLibraryRepository.kt index f364cf0..5b035af 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/AstraLibraryRepository.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/AstraLibraryRepository.kt @@ -1194,6 +1194,36 @@ class AstraLibraryRepository private constructor( return true } + suspend fun getListeningHistoryStatus(): Map { + initialize() + return ListeningStatsEngine.status(requireUser()) + } + + suspend fun checkpointListeningSession(payload: Map): Map { + initialize() + val result = ListeningStatsEngine.checkpoint( + requireUser(), + requireCatalog().catalogDao(), + payload, + ) + if (result["qualifiedNow"] == true) scheduleSnapshot() + return result + } + + suspend fun getListeningStatsDashboard(query: Map): Map { + initialize() + return ListeningStatsEngine.dashboard( + requireUser(), + requireCatalog().catalogDao(), + query, + ) + } + + suspend fun clearDetailedListeningHistory(): Map { + initialize() + return ListeningStatsEngine.clear(requireUser()) + } + suspend fun listRemoteSources(): List> { 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 = diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/ListeningStatsEngine.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/ListeningStatsEngine.kt new file mode 100644 index 0000000..0217a8b --- /dev/null +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/ListeningStatsEngine.kt @@ -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 { + val dao = database.userDao() + val meta = ensureMeta(dao) + return statusMap(meta, listeningEnabled(dao)) + } + + suspend fun checkpoint( + database: AstraUserDatabase, + catalogDao: CatalogDao, + payload: Map, + ): Map { + 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 { + 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, + ): Map { + 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() + val artistAggregates = linkedMapOf() + val albumAggregates = linkedMapOf() + val tracksPlayed = linkedSetOf() + val activeDays = linkedSetOf() + var listenedSeconds = 0.0 + var qualifiedPlays = 0L + + fun ensureAggregates(identity: ListeningIdentity): Triple, 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( + rankingMetric, + { it.qualifiedPlays }, + { it.listenedSeconds }, + { "${it.title}\u0000${it.artist}" }, + ) + val artistComparator = aggregateComparator( + rankingMetric, + { it.qualifiedPlays }, + { it.listenedSeconds }, + { it.artist }, + ) + val albumComparator = aggregateComparator( + 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 = + 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 = + 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, +): Map = + 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, + buckets: List, + tracks: List, + artists: List, + albums: List, +): Map = + 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 { + val strict = identity.albumArtist?.trim()?.takeIf { it.isNotEmpty() } ?: identity.artist + if (groupingMode == "fileTags") return listOf(strict.ifBlank { "Unknown Artist" }) + val result = LinkedHashMap() + 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 { + 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() + 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> { + 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() + 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 aggregateComparator( + metric: String, + plays: (T) -> Long, + seconds: (T) -> Double, + label: (T) -> String, +): Comparator = 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(key: String): String = (this[key] as? String)?.trim().orEmpty() +private fun Map.boolean(key: String): Boolean = this[key] == true +private fun Map.double(key: String): Double = (this[key] as? Number)?.toDouble() ?: 0.0 +private fun Map.long(key: String, fallback: Long): Long = + (this[key] as? Number)?.toDouble()?.takeIf(Double::isFinite)?.toLong() ?: fallback diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserDatabase.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserDatabase.kt index 122bc05..fab0f1a 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserDatabase.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserDatabase.kt @@ -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) + @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 + + @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 + + @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 @@ -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`)", + ) + } +} diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserEntities.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserEntities.kt index 30b853a..1889a35 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserEntities.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/data/UserEntities.kt @@ -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"])], diff --git a/modules/astra-library-scanner/index.ts b/modules/astra-library-scanner/index.ts index 70e68f1..6ccc2c2 100644 --- a/modules/astra-library-scanner/index.ts +++ b/modules/astra-library-scanner/index.ts @@ -389,6 +389,10 @@ declare class AstraLibraryDataModuleType extends NativeModule ): Promise | null>; recordTrackPlayed(path: string): Promise; + getListeningHistoryStatus(): Promise; + checkpointListeningSession(payload: Record): Promise; + getListeningStatsDashboard(query: Record): Promise; + clearDetailedListeningHistory(): Promise; getRecentlyPlayed(limit: number): Promise; listRemoteSources(): Promise; getRemoteSource(sourceId: number): Promise; diff --git a/package.json b/package.json index 8f30eab..8b49571 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/(tabs)/_layout.tsx b/src/app/(tabs)/_layout.tsx index ea9ebb6..6d9bbb2 100644 --- a/src/app/(tabs)/_layout.tsx +++ b/src/app/(tabs)/_layout.tsx @@ -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() { + ); } diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx index 03d4286..5fe1332 100644 --- a/src/app/(tabs)/index.tsx +++ b/src/app/(tabs)/index.tsx @@ -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(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 ( @@ -648,13 +667,19 @@ export default function HomeScreen() { {!hasLibrary ? ( - router.push( - libraryStatus === 'fatalUserData' ? '/settings/troubleshooting' : '/settings' - )} - /> + <> + router.push( + libraryStatus === 'fatalUserData' ? '/settings/troubleshooting' : '/settings' + )} + /> + router.push('/stats' as never)} + /> + ) : ( <> {spotlightContent ? ( @@ -676,6 +701,11 @@ export default function HomeScreen() { ) : null} + router.push('/stats' as never)} + /> + {recentTracks.length > 0 ? ( + @@ -610,7 +610,7 @@ function SortLimitSheet({ const applyDisabled = !limitValid; return ( - + {SORT_FIELD_OPTIONS.map(([field, label]) => ( @@ -983,7 +983,7 @@ export default function DynamicPlaylistEditorScreen() { if (sheet.kind === 'field-picker') { return ( - setSheet(null)}> + setSheet(null)} scrollable> {(['text', 'activity', 'library', 'audio'] as FieldGroup[]).map((group) => ( diff --git a/src/app/(tabs)/settings.tsx b/src/app/(tabs)/settings.tsx index ed2c53f..c88a267 100644 --- a/src/app/(tabs)/settings.tsx +++ b/src/app/(tabs)/settings.tsx @@ -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() { router.push('/settings/playback' as never)} /> + {tiles.map(([label, value]) => ( + + {value} + {label} + + ))} + + ); +} + +function ActivityChart({ dashboard }: { dashboard: ListeningStatsDashboard }) { + const styles = useStyles(); + const colors = useColors(); + const scrollRef = useRef(null); + const [selectedStartAt, setSelectedStartAt] = useState(null); + const selectedIndex = dashboard.activity.findIndex( + (bucket) => bucket.startAt === selectedStartAt, + ); + const resolvedSelectedIndex = selectedIndex >= 0 + ? selectedIndex + : Math.max(0, dashboard.activity.length - 1); + const selected = dashboard.activity[resolvedSelectedIndex] ?? dashboard.activity.at(-1) ?? null; + const maxSeconds = Math.max(1, ...dashboard.activity.map((bucket) => bucket.listenedSeconds)); + const fillsCard = dashboard.range === '7d'; + + const bars = dashboard.activity.map((bucket, index) => { + const height = Math.max(3, Math.round((bucket.listenedSeconds / maxSeconds) * 118)); + const focused = index === resolvedSelectedIndex; + return ( + setSelectedStartAt(bucket.startAt)} + accessibilityRole="button" + accessibilityState={{ selected: focused }} + accessibilityLabel={`${formatBucketDate(bucket.startAt, bucket.endAt)}, ${formatListeningTime(bucket.listenedSeconds)}, ${bucket.qualifiedPlays} qualified plays`} + > + + {(fillsCard || index % Math.max(1, Math.ceil(dashboard.activity.length / 7)) === 0) ? ( + + {bucket.label} + + ) : ( + + )} + + ); + }); + + return ( + + + Activity + + {dashboard.granularity === 'day' + ? 'Daily' + : dashboard.granularity === 'week' + ? 'Weekly' + : 'Monthly'} + + + {selected ? ( + + {formatBucketDate(selected.startAt, selected.endAt)} + + {formatListeningTime(selected.listenedSeconds)} · {selected.qualifiedPlays}{' '} + {selected.qualifiedPlays === 1 ? 'play' : 'plays'} + + + ) : null} + { + if (!fillsCard) scrollRef.current?.scrollToEnd({ animated: false }); + }} + contentContainerStyle={[styles.chart, fillsCard && styles.chartFill]} + > + {bars} + + + ); +} + +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 ( + + {index + 1} + + {art ? ( + + ) : ( + + )} + + + {copy.title} + + {item.available ? copy.subtitle : `${copy.subtitle} · Unavailable`} + + + + + {item.qualifiedPlays} {item.qualifiedPlays === 1 ? 'play' : 'plays'} + + + {formatListeningTime(item.listenedSeconds, true)} + + + + ); +} + +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 ( + + + {title} + {body} + {action && onAction ? ( + + {action} + + ) : null} + + ); +} + +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(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(() => { + 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 ( + + + router.back()} + accessibilityRole="button" + accessibilityLabel="Back to Home" + > + + + + Listening Stats + + {formatRecordedSince(dashboard?.status.startedAt ?? null)} + + + setShareSnapshot(dashboard)} + accessibilityRole="button" + accessibilityLabel="Share Listening Stats" + > + + + + + void load()} + tintColor={colors.accent} + colors={[colors.accent]} + /> + } + > + setRange(value as typeof range)} + /> + + {loading && !dashboard ? ( + + ) : error && !dashboard ? ( + void load()} + /> + ) : !dashboard?.status.startedAt ? ( + router.push('/settings/playback' as never)} + /> + ) : ( + <> + {!dashboard.status.enabled ? ( + + + + History paused + + Existing history is shown; future listening is not being recorded. + + + + ) : null} + + = 720} /> + + {noActivity ? ( + + ) : ( + <> + + + + + Rankings + {error ? ( + void load()} accessibilityRole="button"> + Refresh failed · Retry + + ) : null} + + setMetric(value as typeof metric)} + /> + setCategory(value as ListeningStatsCategory)} + /> + + {rankings.map((item, index) => ( + openRanking(item)} + /> + ))} + + + + )} + + )} + + + {shareSnapshot ? ( + setShareSnapshot(null)} + /> + ) : null} + + ); +} + +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, + }, +})); diff --git a/src/app/settings/playback.tsx b/src/app/settings/playback.tsx index 6395fc4..aa6aa7a 100644 --- a/src/app/settings/playback.tsx +++ b/src/app/settings/playback.tsx @@ -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 ( SLEEP TIMER + + LISTENING HISTORY + + { + void setHistoryEnabled(enabled).catch((error) => { + showAppDialog({ + title: 'Could not update Listening History', + message: error instanceof Error ? error.message : 'Please try again.', + }); + }); + }} + /> + + + + + + Clear Detailed Listening History + + + Keeps play counts, recents, favorites, and playlists. + + + + ); } + +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, + }, +})); diff --git a/src/audio/listeningHistoryState.test.mts b/src/audio/listeningHistoryState.test.mts new file mode 100644 index 0000000..7f0a188 --- /dev/null +++ b/src/audio/listeningHistoryState.test.mts @@ -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); +}); diff --git a/src/audio/listeningHistoryState.ts b/src/audio/listeningHistoryState.ts new file mode 100644 index 0000000..31776d8 --- /dev/null +++ b/src/audio/listeningHistoryState.ts @@ -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, + ); +} diff --git a/src/audio/listeningHistoryTracker.ts b/src/audio/listeningHistoryTracker.ts new file mode 100644 index 0000000..f9b030a --- /dev/null +++ b/src/audio/listeningHistoryTracker.ts @@ -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 { + 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 { + 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( + payload as unknown as Record, + ); + 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 { + 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 { + 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(); + 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(); + } + 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 { + enqueue(async () => { + const now = Date.now(); + advance(now); + await closeSession(now, false); + }); + await operation; +} + +export function resumeListeningHistoryTracking(): void { + enqueue(async () => { + status = await AstraLibraryData.getListeningHistoryStatus(); + const [track, playbackState] = await Promise.all([ + TrackPlayer.getActiveTrack(), + TrackPlayer.getPlaybackState(), + ]); + isPlaying = playbackState.state === State.Playing; + beginSession(track); + }); +} diff --git a/src/audio/playbackService.ts b/src/audio/playbackService.ts index 366eeda..037c0c6 100644 --- a/src/audio/playbackService.ts +++ b/src/audio/playbackService.ts @@ -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 { + 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 { // zero; this only late-corrects unanalyzed tracks. Rapid skips coalesce. let normalizeTimer: ReturnType | 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 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 { .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)) diff --git a/src/audio/playbackSource.ts b/src/audio/playbackSource.ts index 54c52c9..24e354f 100644 --- a/src/audio/playbackSource.ts +++ b/src/audio/playbackSource.ts @@ -12,6 +12,7 @@ const PLAYBACK_SOURCE_KINDS = new Set([ 'search', 'signal', 'android-auto', + 'listening-stats', 'sample', ]); diff --git a/src/audio/usePlaybackSync.ts b/src/audio/usePlaybackSync.ts index 1bff80e..529cbc2 100644 --- a/src/audio/usePlaybackSync.ts +++ b/src/audio/usePlaybackSync.ts @@ -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(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, - ]); } diff --git a/src/components/listening/ListeningPreviewCard.tsx b/src/components/listening/ListeningPreviewCard.tsx new file mode 100644 index 0000000..facdac1 --- /dev/null +++ b/src/components/listening/ListeningPreviewCard.tsx @@ -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 ( + + + + + Your Listening + + + + + {paused ? ( + + History paused + + Existing stats are still available. Resume recording in Playback settings. + + + ) : null} + + + + + {formatListeningTime(dashboard.summary.listenedSeconds, true)} + + Last 7 days + + + + {dashboard.summary.qualifiedPlays} + + Qualified plays + + + + {topTrack ? ( + + + {artwork ? ( + + ) : ( + + )} + + + TOP TRACK + {topTrack.title} + + {topTrack.artist} · {topTrack.qualifiedPlays} {topTrack.qualifiedPlays === 1 ? 'play' : 'plays'} + + + + ) : ( + + No qualified plays in the last 7 days. + + )} + + ); +} + +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, + }, +})); diff --git a/src/components/listening/ListeningStatsShareSheet.tsx b/src/components/listening/ListeningStatsShareSheet.tsx new file mode 100644 index 0000000..e94d6a0 --- /dev/null +++ b/src/components/listening/ListeningStatsShareSheet.tsx @@ -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('overview'); + const [sharing, setSharing] = useState(false); + const [shareError, setShareError] = useState(null); + const [rendered, setRendered] = useState<{ + key: string; + base64: string | null; + error: string | null; + } | null>(null); + const model = useMemo(() => buildListeningStatsShareModel(snapshot, lens), [lens, snapshot]); + const renderKey = `${model.suggestedFileName}:${lens}:${colors.accent}`; + const currentRender = rendered?.key === renderKey ? rendered : null; + const base64 = currentRender?.base64 ?? null; + const rendering = currentRender == null; + const error = shareError ?? currentRender?.error ?? null; + const artworkUris = useMemo(() => { + const map = new Map(); + snapshot.topTracks.forEach((item, index) => { + const uri = listeningArtworkSource(item); + if (uri) map.set(`track:${index + 1}`, uri); + }); + snapshot.topArtists.forEach((item, index) => { + const uri = listeningArtworkSource(item); + if (uri) map.set(`artist:${index + 1}`, uri); + }); + snapshot.topAlbums.forEach((item, index) => { + const uri = listeningArtworkSource(item); + if (uri) map.set(`album:${index + 1}`, uri); + }); + return map; + }, [snapshot]); + + useEffect(() => { + let cancelled = false; + void renderListeningStatsSharePng(model, { + accentColor: colors.accent, + artworkUris, + }).then( + (result) => { + if (!cancelled) setRendered({ key: renderKey, base64: result, error: null }); + }, + (renderError) => { + if (!cancelled) { + setRendered({ + key: renderKey, + base64: null, + error: renderError instanceof Error + ? renderError.message + : 'The share card could not be rendered.', + }); + } + }, + ); + return () => { + cancelled = true; + }; + }, [artworkUris, colors.accent, model, renderKey]); + + const share = async () => { + if (!base64 || !cacheDirectory) { + setShareError('The temporary share image could not be created.'); + return; + } + setSharing(true); + setShareError(null); + try { + if (!(await Sharing.isAvailableAsync())) { + throw new Error('No compatible sharing service is available on this device.'); + } + const fileUri = `${cacheDirectory}${model.suggestedFileName}`; + await writeAsStringAsync(fileUri, base64, { encoding: EncodingType.Base64 }); + await Sharing.shareAsync(fileUri, { + mimeType: 'image/png', + dialogTitle: 'Share Listening Stats', + UTI: 'public.png', + }); + } catch (shareError) { + setShareError( + shareError instanceof Error ? shareError.message : 'The share sheet could not be opened.', + ); + } finally { + setSharing(false); + } + }; + + return ( + + + + + {LENSES.map((option) => { + const disabled = + (option.key === 'track' && snapshot.topTracks.length === 0) || + (option.key === 'album' && snapshot.topAlbums.length === 0); + const selected = option.key === lens; + return ( + setLens(option.key)} + accessibilityRole="radio" + accessibilityState={{ selected, disabled }} + > + + {option.label} + + + ); + })} + + + + {base64 ? ( + + ) : ( + + + + {error ?? (rendering ? 'Rendering 1474 × 1920 PNG…' : 'Preparing preview…')} + + + )} + + + {error && base64 ? ( + + {error} + + ) : null} + + void share()} + accessibilityRole="button" + > + + + {sharing ? 'Opening share sheet…' : 'Share PNG'} + + + + ); +} + +const useStyles = createThemedStyles((colors) => ({ + lenses: { + flexDirection: 'row', + gap: spacing.xs, + marginVertical: spacing.md, + padding: 3, + borderRadius: radius.pill, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.glassBg, + }, + lens: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: spacing.sm, + borderRadius: radius.pill, + overflow: 'hidden', + }, + lensSelected: { + backgroundColor: colors.glassHighlight, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.accent, + }, + preview: { + width: 246, + height: 320, + alignSelf: 'center', + marginVertical: spacing.sm, + overflow: 'hidden', + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.bgTertiary, + }, + previewImage: { + width: '100%', + height: '100%', + }, + previewLoading: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + padding: spacing.lg, + }, + error: { + textAlign: 'center', + marginBottom: spacing.sm, + }, + shareButton: { + minHeight: 46, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + marginTop: spacing.md, + borderRadius: radius.pill, + backgroundColor: colors.accent, + overflow: 'hidden', + }, + shareLabel: { + color: colors.bgPrimary, + }, + disabled: { + opacity: 0.45, + }, +})); diff --git a/src/library/artwork.ts b/src/library/artwork.ts index 34b5d98..f4da371 100644 --- a/src/library/artwork.ts +++ b/src/library/artwork.ts @@ -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 { diff --git a/src/listeningStats/events.ts b/src/listeningStats/events.ts new file mode 100644 index 0000000..71353fc --- /dev/null +++ b/src/listeningStats/events.ts @@ -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)); +} diff --git a/src/listeningStats/format.ts b/src/listeningStats/format.ts new file mode 100644 index 0000000..3701f78 --- /dev/null +++ b/src/listeningStats/format.ts @@ -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)}`; +} diff --git a/src/listeningStats/shareDimensions.ts b/src/listeningStats/shareDimensions.ts new file mode 100644 index 0000000..c5c7160 --- /dev/null +++ b/src/listeningStats/shareDimensions.ts @@ -0,0 +1,2 @@ +export const LISTENING_STATS_SHARE_WIDTH = 1474; +export const LISTENING_STATS_SHARE_HEIGHT = 1920; diff --git a/src/listeningStats/shareModel.test.mts b/src/listeningStats/shareModel.test.mts new file mode 100644 index 0000000..2d5172c --- /dev/null +++ b/src/listeningStats/shareModel.test.mts @@ -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); +}); diff --git a/src/listeningStats/shareModel.ts b/src/listeningStats/shareModel.ts new file mode 100644 index 0000000..4074793 --- /dev/null +++ b/src/listeningStats/shareModel.ts @@ -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), + }; +} diff --git a/src/listeningStats/shareRenderer.ts b/src/listeningStats/shareRenderer.ts new file mode 100644 index 0000000..30e70ae --- /dev/null +++ b/src/listeningStats/shareRenderer.ts @@ -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; +} + +async function loadTypeface(moduleId: number): Promise<{ typeface: SkTypeface; data: SkData }> { + const asset = Asset.fromModule(moduleId); + if (!asset.localUri) await asset.downloadAsync(); + const data = await Skia.Data.fromURI(asset.localUri ?? asset.uri); + const typeface = Skia.Typeface.MakeFreeTypeFaceFromData(data); + if (!typeface) { + data.dispose(); + throw new Error('A bundled share-card font could not be loaded.'); + } + return { typeface, data }; +} + +async function loadFonts(): Promise { + const loaded = await Promise.all([ + loadTypeface(Inter_400Regular), + loadTypeface(Inter_600SemiBold), + loadTypeface(Inter_700Bold), + loadTypeface(JetBrainsMono_500Medium), + ]); + const typefaces = loaded.map((entry) => entry.typeface); + return { + regular: Skia.Font(typefaces[0], 32), + semibold: Skia.Font(typefaces[1], 32), + bold: Skia.Font(typefaces[2], 32), + mono: Skia.Font(typefaces[3], 28), + typefaces, + fontData: loaded.map((entry) => entry.data), + }; +} + +async function loadArtwork( + artworkUris: ReadonlyMap, +): Promise> { + const images = new Map(); + await Promise.all( + [...artworkUris].map(async ([key, uri]) => { + if (!uri) return; + try { + const data = await Skia.Data.fromURI(uri); + try { + const image = Skia.Image.MakeImageFromEncoded(data); + if (image) images.set(key, image); + } finally { + data.dispose(); + } + } catch { + // A missing local file or unreachable remote cover gets the branded placeholder. + } + }), + ); + return images; +} + +function setColor(paint: SkPaint, color: string): void { + paint.setColor(Skia.Color(color)); +} + +function fittedText( + value: string, + maxWidth: number, + font: SkFont, + paint: SkPaint, +): string { + const clean = value.trim() || 'Unknown'; + if (font.measureText(clean, paint).width <= maxWidth) return clean; + const characters = Array.from(clean); + let low = 0; + let high = characters.length; + while (low < high) { + const middle = Math.ceil((low + high) / 2); + const candidate = `${characters.slice(0, middle).join('').trimEnd()}…`; + if (font.measureText(candidate, paint).width <= maxWidth) low = middle; + else high = middle - 1; + } + return `${characters.slice(0, low).join('').trimEnd()}…`; +} + +function drawSystemParagraph( + canvas: SkCanvas, + fonts: RendererFonts, + options: { + text: string; + x: number; + y: number; + maxWidth: number; + size: number; + minSize: number; + color: string; + font: SkFont; + align?: 'left' | 'center' | 'right'; + }, +): void { + const weight = options.font === fonts.bold + ? FontWeight.Bold + : options.font === fonts.semibold + ? FontWeight.SemiBold + : options.font === fonts.mono + ? FontWeight.Medium + : FontWeight.Normal; + const align = options.align === 'center' + ? TextAlign.Center + : options.align === 'right' + ? TextAlign.Right + : TextAlign.Left; + const direction = /[\u0590-\u08FF]/.test(options.text) + ? TextDirection.RTL + : TextDirection.LTR; + let size = options.size; + let paragraph: ReturnType['build']> | null = null; + while (size >= options.minSize) { + const builder = Skia.ParagraphBuilder.Make({ + maxLines: 1, + ellipsis: '…', + textAlign: align, + textDirection: direction, + textStyle: { + color: Skia.Color(options.color), + fontFamilies: ['sans-serif'], + fontSize: size, + fontStyle: { weight }, + }, + }); + builder.addText(options.text.trim() || 'Unknown'); + const candidate = builder.build(); + builder.dispose(); + candidate.layout(options.maxWidth); + paragraph?.dispose(); + paragraph = candidate; + if (candidate.getMaxIntrinsicWidth() <= options.maxWidth || size === options.minSize) break; + size -= 1; + } + if (!paragraph) return; + const baseline = paragraph.getLineMetrics()[0]?.baseline ?? size; + const x = options.align === 'center' + ? options.x - options.maxWidth / 2 + : options.align === 'right' + ? options.x - options.maxWidth + : options.x; + paragraph.paint(canvas, x, options.y - baseline); + paragraph.dispose(); +} + +function drawFittedText( + canvas: SkCanvas, + paint: SkPaint, + fonts: RendererFonts, + options: { + text: string; + x: number; + y: number; + maxWidth: number; + size: number; + minSize: number; + color: string; + font: SkFont; + align?: 'left' | 'center' | 'right'; + }, +): void { + if (NON_LATIN.test(options.text)) { + drawSystemParagraph(canvas, fonts, options); + return; + } + const font = options.font; + let size = options.size; + font.setSize(size); + while (size > options.minSize && font.measureText(options.text, paint).width > options.maxWidth) { + size -= 1; + font.setSize(size); + } + const text = fittedText(options.text, options.maxWidth, font, paint); + const width = font.measureText(text, paint).width; + const x = options.align === 'center' + ? options.x - width / 2 + : options.align === 'right' + ? options.x - width + : options.x; + setColor(paint, options.color); + canvas.drawText(text, x, options.y, paint, font); +} + +function drawCover( + canvas: SkCanvas, + paint: SkPaint, + image: SkImage | undefined, + x: number, + y: number, + width: number, + height: number, + accent: string, +): void { + const rounded = { rect: rect(x, y, width, height), rx: 20, ry: 20 }; + const save = canvas.save(); + canvas.clipRRect(rounded, ClipOp.Intersect, true); + if (!image) { + setColor(paint, '#222329'); + canvas.drawRect(rect(x, y, width, height), paint); + setColor(paint, `${accent}55`); + canvas.drawCircle(x + width * 0.32, y + height * 0.35, width * 0.25, paint); + setColor(paint, `${accent}33`); + canvas.drawCircle(x + width * 0.72, y + height * 0.68, width * 0.33, paint); + } else { + const scale = Math.max(width / image.width(), height / image.height()); + const sourceWidth = width / scale; + const sourceHeight = height / scale; + canvas.drawImageRect( + image, + rect( + (image.width() - sourceWidth) / 2, + (image.height() - sourceHeight) / 2, + sourceWidth, + sourceHeight, + ), + rect(x, y, width, height), + paint, + ); + } + canvas.restoreToCount(save); +} + +function itemMetric(item: ListeningStatsShareItem, model: ListeningStatsShareModel): string { + if (model.rankingMetric === 'plays') { + const plays = Math.max(0, Math.round(item.qualifiedPlays)); + return `${plays.toLocaleString('en-US')} ${plays === 1 ? 'PLAY' : 'PLAYS'}`; + } + const minutes = Math.floor(Math.max(0, item.listenedSeconds) / 60); + if (minutes < 1) return '<1 MIN'; + const hours = Math.floor(minutes / 60); + const remainder = minutes % 60; + if (hours === 0) return `${minutes} MIN`; + return remainder === 0 ? `${hours} HR` : `${hours} HR ${remainder} MIN`; +} + +function drawCard( + canvas: SkCanvas, + paint: SkPaint, + fonts: RendererFonts, + model: ListeningStatsShareModel, + accent: string, + images: ReadonlyMap, +): void { + canvas.clear(Skia.Color(BACKGROUND)); + + setColor(paint, `${accent}22`); + canvas.drawCircle(CENTER_X, 360, 530, paint); + setColor(paint, `${accent}12`); + canvas.drawCircle(160, 720, 420, paint); + + drawFittedText(canvas, paint, fonts, { + text: 'LISTENING STATS', + x: 64, + y: 82, + maxWidth: 520, + size: 31, + minSize: 24, + color: TEXT_SECONDARY, + font: fonts.mono, + }); + drawFittedText(canvas, paint, fonts, { + text: model.rankingLabel.replace('RANKED ', ''), + x: 1410, + y: 82, + maxWidth: 600, + size: 31, + minSize: 22, + color: TEXT_SECONDARY, + font: fonts.mono, + align: 'right', + }); + drawFittedText(canvas, paint, fonts, { + text: model.title, + x: CENTER_X, + y: 148, + maxWidth: 1100, + size: 38, + minSize: 28, + color: accent, + font: fonts.bold, + align: 'center', + }); + + if (model.lens === 'overview') { + const items = model.overviewItems.slice(0, 3); + if (items.length <= 1) { + drawCover(canvas, paint, images.get(items[0]?.key ?? ''), HERO_X, HERO_Y, HERO_SIZE, HERO_SIZE, accent); + } else { + const half = (HERO_SIZE - 6) / 2; + drawCover(canvas, paint, images.get(items[0]?.key ?? ''), HERO_X, HERO_Y, half, HERO_SIZE, accent); + drawCover(canvas, paint, images.get(items[1]?.key ?? ''), HERO_X + half + 6, HERO_Y, half, half, accent); + drawCover(canvas, paint, images.get(items[2]?.key ?? ''), HERO_X + half + 6, HERO_Y + half + 6, half, half, accent); + } + } else { + drawCover(canvas, paint, images.get(model.hero?.key ?? ''), HERO_X, HERO_Y, HERO_SIZE, HERO_SIZE, accent); + } + + drawFittedText(canvas, paint, fonts, { + text: model.hero?.title ?? 'YOUR TOP PICKS', + x: CENTER_X, + y: 880, + maxWidth: 1180, + size: 57, + minSize: 34, + color: TEXT, + font: fonts.bold, + align: 'center', + }); + drawFittedText(canvas, paint, fonts, { + text: model.hero?.subtitle ?? 'TRACK • ALBUM • ARTIST', + x: CENTER_X, + y: 940, + maxWidth: 1120, + size: 34, + minSize: 24, + color: TEXT_SECONDARY, + font: fonts.regular, + align: 'center', + }); + + drawFittedText(canvas, paint, fonts, { + text: model.personalityValue, + x: CENTER_X - 16, + y: 1044, + maxWidth: 210, + size: 42, + minSize: 30, + color: accent, + font: fonts.semibold, + align: 'right', + }); + drawFittedText(canvas, paint, fonts, { + text: model.personalityText, + x: CENTER_X, + y: 1044, + maxWidth: 630, + size: 37, + minSize: 24, + color: TEXT, + font: fonts.regular, + }); + + const summaryCenters = [240, 737, 1234]; + model.summaryStats.forEach((stat, index) => { + drawFittedText(canvas, paint, fonts, { + text: stat.value, + x: summaryCenters[index], + y: 1182, + maxWidth: 330, + size: 49, + minSize: 34, + color: TEXT, + font: fonts.semibold, + align: 'center', + }); + drawFittedText(canvas, paint, fonts, { + text: stat.label, + x: summaryCenters[index], + y: 1234, + maxWidth: 340, + size: 26, + minSize: 21, + color: TEXT_SECONDARY, + font: fonts.mono, + align: 'center', + }); + }); + + const items = + model.lens === 'overview' ? model.overviewItems.slice(0, 3) : model.secondaryItems.slice(0, 3); + drawFittedText(canvas, paint, fonts, { + text: model.lens === 'overview' ? 'YOUR TOP PICKS' : `NEXT ${model.lens === 'track' ? 'TRACKS' : 'ALBUMS'}`, + x: CONTENT_LEFT, + y: 1396, + maxWidth: 520, + size: 27, + minSize: 22, + color: accent, + font: fonts.mono, + }); + drawFittedText(canvas, paint, fonts, { + text: model.rankingLabel, + x: CONTENT_RIGHT, + y: 1396, + maxWidth: 570, + size: 27, + minSize: 20, + color: TEXT_SECONDARY, + font: fonts.mono, + align: 'right', + }); + + items.forEach((item, index) => { + const y = 1448 + index * 115; + drawFittedText(canvas, paint, fonts, { + text: model.lens === 'overview' ? item.kind.toUpperCase() : String(item.rank).padStart(2, '0'), + x: 134, + y: y + 57, + maxWidth: 145, + size: model.lens === 'overview' ? 18 : 27, + minSize: 15, + color: TEXT_SECONDARY, + font: fonts.mono, + align: 'center', + }); + drawCover(canvas, paint, images.get(item.key), 226, y, 92, 92, accent); + drawFittedText(canvas, paint, fonts, { + text: item.title, + x: 356, + y: y + 43, + maxWidth: 735, + size: 42, + minSize: 28, + color: TEXT, + font: fonts.semibold, + }); + drawFittedText(canvas, paint, fonts, { + text: item.available ? item.subtitle : `${item.subtitle} • UNAVAILABLE`, + x: 356, + y: y + 81, + maxWidth: 735, + size: 27, + minSize: 20, + color: TEXT_SECONDARY, + font: fonts.regular, + }); + drawFittedText(canvas, paint, fonts, { + text: itemMetric(item, model), + x: CONTENT_RIGHT, + y: y + 57, + maxWidth: 250, + size: 28, + minSize: 20, + color: TEXT_SECONDARY, + font: fonts.mono, + align: 'right', + }); + }); + + drawFittedText(canvas, paint, fonts, { + text: model.rangeLabel, + x: 64, + y: 1882, + maxWidth: 730, + size: 24, + minSize: 18, + color: TEXT_SECONDARY, + font: fonts.mono, + }); + drawFittedText(canvas, paint, fonts, { + text: 'LISTENED LOCALLY WITH', + x: 1190, + y: 1882, + maxWidth: 420, + size: 22, + minSize: 17, + color: TEXT_SECONDARY, + font: fonts.mono, + align: 'right', + }); + setColor(paint, accent); + canvas.drawCircle(1224, 1872, 18, paint); + drawFittedText(canvas, paint, fonts, { + text: 'ASTRA', + x: 1256, + y: 1882, + maxWidth: 170, + size: 28, + minSize: 22, + color: TEXT, + font: fonts.bold, + }); +} + +export async function renderListeningStatsSharePng( + model: ListeningStatsShareModel, + options: ListeningStatsShareRenderOptions, +): Promise { + const [fonts, images] = await Promise.all([ + loadFonts(), + loadArtwork(options.artworkUris), + ]); + const surface = Skia.Surface.MakeOffscreen( + LISTENING_STATS_SHARE_WIDTH, + LISTENING_STATS_SHARE_HEIGHT, + ); + if (!surface) throw new Error('Share-card rendering is unavailable on this device.'); + const paint = Skia.Paint(); + let snapshot: SkImage | null = null; + try { + drawCard( + surface.getCanvas(), + paint, + fonts, + model, + options.accentColor, + images, + ); + surface.flush(); + snapshot = surface.makeImageSnapshot(); + return snapshot.encodeToBase64(ImageFormat.PNG, 100); + } finally { + snapshot?.dispose(); + paint.dispose(); + images.forEach((image) => image.dispose()); + fonts.regular.dispose(); + fonts.semibold.dispose(); + fonts.bold.dispose(); + fonts.mono.dispose(); + fonts.typefaces.forEach((typeface) => typeface.dispose()); + fonts.fontData.forEach((data) => data.dispose()); + surface.dispose(); + } +} diff --git a/src/navigation/statsTabState.test.mts b/src/navigation/statsTabState.test.mts new file mode 100644 index 0000000..6d96fe7 --- /dev/null +++ b/src/navigation/statsTabState.test.mts @@ -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); +}); diff --git a/src/navigation/statsTabState.ts b/src/navigation/statsTabState.ts new file mode 100644 index 0000000..d872fe2 --- /dev/null +++ b/src/navigation/statsTabState.ts @@ -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'); +} diff --git a/src/session/sessionState.test.mts b/src/session/sessionState.test.mts index 73801b2..c40ed50 100644 --- a/src/session/sessionState.test.mts +++ b/src/session/sessionState.test.mts @@ -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); diff --git a/src/session/sessionState.ts b/src/session/sessionState.ts index 165c269..56c9d71 100644 --- a/src/session/sessionState.ts +++ b/src/session/sessionState.ts @@ -57,6 +57,7 @@ const STATIC_STABLE_PATHS = new Set([ '/eq', '/settings', '/recently-played', + '/stats', '/settings/appearance', '/settings/library', '/settings/audio', diff --git a/src/stores/libraryStore.ts b/src/stores/libraryStore.ts index 153ee2a..5216a5d 100644 --- a/src/stores/libraryStore.ts +++ b/src/stores/libraryStore.ts @@ -123,6 +123,7 @@ interface LibraryStore { loadPreviousArtists: () => Promise; jumpToSection: (cursor: string) => Promise; recordTrackPlayed: (path: string) => Promise; + refreshRecentlyPlayed: () => Promise; recomputeArtists: () => void; recomputeAlbums: () => void; setViewMode: (mode: ViewMode) => void; @@ -907,6 +908,10 @@ export const useLibraryStore = create((set, get) => { set({ recentlyPlayedTracks: await AstraLibraryData.getRecentlyPlayed(20) }); }, + refreshRecentlyPlayed: async () => { + set({ recentlyPlayedTracks: await AstraLibraryData.getRecentlyPlayed(20) }); + }, + recomputeArtists: () => { void resetArtists(); }, diff --git a/src/stores/listeningStatsStore.ts b/src/stores/listeningStatsStore.ts new file mode 100644 index 0000000..59a9c85 --- /dev/null +++ b/src/stores/listeningStatsStore.ts @@ -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; + loadHomePreview: () => Promise; +} + +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 { + await AstraLibraryData.initialize(); + return AstraLibraryData.getListeningStatsDashboard({ + range, + rankingMetric, + artistGroupingMode: useSettingsStore.getState().artistGroupingMode, + }); +} + +export const useListeningStatsStore = create((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. + } + }, +})); diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index 9fdaad6..5b24542 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -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; setArtistGroupingMode: (mode: ArtistGroupingMode) => Promise; @@ -71,6 +78,7 @@ interface SettingsStore { setLyricsVisible: (visible: boolean) => Promise; setNowPlayingCompanion: (companion: NowPlayingCompanion) => Promise; setHomeGreetingTextMode: (mode: HomeGreetingTextMode) => Promise; + setListeningHistoryEnabled: (enabled: boolean) => Promise; } export const useSettingsStore = create((set, get) => ({ @@ -82,6 +90,7 @@ export const useSettingsStore = create((set, get) => ({ lyricsVisible: false, nowPlayingCompanion: 'queue', homeGreetingTextMode: 'messages', + listeningHistoryEnabled: true, loaded: false, load: async () => { @@ -96,6 +105,7 @@ export const useSettingsStore = create((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((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((set, get) => ({ lyricsVisible: parseBoolean(lyricsVisible), nowPlayingCompanion: parseNowPlayingCompanion(nowPlayingCompanion), homeGreetingTextMode: parseHomeGreetingTextMode(homeGreetingTextMode), + listeningHistoryEnabled, loaded: true, }); }, @@ -166,4 +178,20 @@ export const useSettingsStore = create((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; + } + }, })); diff --git a/src/types/audio.ts b/src/types/audio.ts index c6d4faf..7399935 100644 --- a/src/types/audio.ts +++ b/src/types/audio.ts @@ -51,6 +51,7 @@ export type PlaybackSourceKind = | 'search' | 'signal' | 'android-auto' + | 'listening-stats' | 'sample'; /** The collection or surface that created the current playback queue. */ diff --git a/src/types/listeningStats.ts b/src/types/listeningStats.ts new file mode 100644 index 0000000..765492c --- /dev/null +++ b/src/types/listeningStats.ts @@ -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; +}