From b41753c25d3b56a582460c6dbd1a3e1ad5ceca2e Mon Sep 17 00:00:00 2001 From: GuiltySpark-343 <1875217517@qq.com> Date: Sat, 5 Sep 2026 23:20:43 +0800 Subject: [PATCH 1/4] Fix taglib submodule URL to upstream --- .gitmodules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitmodules b/.gitmodules index 952ed5db..66893e19 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "third-party/taglib"] path = third-party/taglib - url = ../taglib.git + url = https://github.com/rRemix/taglib.git branch = main From 054ad2b2566047da303163976d7e6adf649c83b7 Mon Sep 17 00:00:00 2001 From: GuiltySpark-343 <1875217517@qq.com> Date: Sat, 5 Sep 2026 23:20:43 +0800 Subject: [PATCH 2/4] Restore original song sorting with TinyPinyin --- app/build.gradle.kts | 1 + .../java/remix/myplayer/helper/ItemsSorter.kt | 133 ++++++------------ gradle/libs.versions.toml | 1 + 3 files changed, 43 insertions(+), 92 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1c3c446a..51285f2d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -254,6 +254,7 @@ dependencies { exclude(group = "xpp3", module = "xpp3") } implementation(libs.timber) + implementation(libs.tinypinyin) debugImplementation(libs.leakcanary) diff --git a/app/src/main/java/remix/myplayer/helper/ItemsSorter.kt b/app/src/main/java/remix/myplayer/helper/ItemsSorter.kt index ddeb160e..7705b365 100644 --- a/app/src/main/java/remix/myplayer/helper/ItemsSorter.kt +++ b/app/src/main/java/remix/myplayer/helper/ItemsSorter.kt @@ -1,99 +1,55 @@ package remix.myplayer.helper +import com.github.promeg.pinyinhelper.Pinyin import remix.myplayer.data.db.room.entity.PlayList import remix.myplayer.data.model.audio.Album import remix.myplayer.data.model.audio.Artist import remix.myplayer.data.model.audio.Folder import remix.myplayer.data.model.audio.Genre import remix.myplayer.data.model.audio.Song -import java.text.Collator +import kotlin.math.min object ItemsSorter { - // 自然排序:文本段用 Collator 数字段按数值语义比较 - private fun compare(o1: String, o2: String, collator: Collator): Int { - var i = 0 - var j = 0 - - while (i < o1.length && j < o2.length) { + // 逐字符比较(与原版 APlayer 一致): + // - 中文排在非中文前面;都是中文则按拼音比较; + // - 非中文先忽略大小写比较,再用区分大小写兜底; + // - 数字就是普通 ASCII 字符(code-point 顺序),因此 1/2/10 会按 1,10,2 排列。 + private fun compare(o1: String, o2: String): Int { + val minLength = min(o1.length, o2.length) + for (i in 0 until minLength) { val c1 = o1[i] - val c2 = o2[j] - - if (c1.isAsciiDigit() && c2.isAsciiDigit()) { - // 两侧都遇到数字:提取连续数字段并按数值比较 - val start1 = i - val start2 = j - while (i < o1.length && o1[i].isAsciiDigit()) { - i++ - } - while (j < o2.length && o2[j].isAsciiDigit()) { - j++ - } - - val numberResult = compareNumberSegment( - o1.substring(start1, i), - o2.substring(start2, j) - ) - if (numberResult != 0) { - return numberResult - } + val c2 = o2[i] + if (Pinyin.isChinese(c1) != Pinyin.isChinese(c2)) { + return if (Pinyin.isChinese(c1)) -1 else 1 + } + if (Pinyin.isChinese(c1) && Pinyin.isChinese(c2)) { + val pinyinResult = Pinyin.toPinyin(c1).compareTo(Pinyin.toPinyin(c2)) + if (pinyinResult != 0) return pinyinResult } else { - // 文本段比较使用 Collator - val start1 = i - val start2 = j - while (i < o1.length && !o1[i].isAsciiDigit()) { - i++ - } - while (j < o2.length && !o2[j].isAsciiDigit()) { - j++ - } - - val textResult = collator.compare( - o1.substring(start1, i), - o2.substring(start2, j) - ) - if (textResult != 0) { - return textResult - } + val lowerResult = c1.lowercaseChar().compareTo(c2.lowercaseChar()) + if (lowerResult != 0) return lowerResult } + val rawResult = c1.compareTo(c2) + if (rawResult != 0) return rawResult } - return o1.length.compareTo(o2.length) } - private fun compareNumberSegment(n1: String, n2: String): Int { - // 去前导零后先比有效数字长度,再按字典序比较,避免大数转整数溢出 - val v1 = n1.trimStart('0').ifEmpty { "0" } - val v2 = n2.trimStart('0').ifEmpty { "0" } - - if (v1.length != v2.length) { - return v1.length.compareTo(v2.length) - } - - val valueResult = v1.compareTo(v2) - if (valueResult != 0) { - return valueResult - } - - // 数值相同(如 1 和 01)时,短的在前,保证排序稳定且更符合直觉 - return n1.length.compareTo(n2.length) - } - fun sortedSongs(songs: List, sortOrder: String?): List { - val collator = Collator.getInstance() return when (sortOrder) { SortOrder.DATE, SortOrder.DATE_DESC, SortOrder.TRACK_NUMBER -> songs else -> { songs.sortedWith(Comparator { o1: Song, o2: Song -> when (sortOrder) { - SortOrder.SONG_A_Z -> compare(o1.title, o2.title, collator) - SortOrder.SONG_Z_A -> compare(o2.title, o1.title, collator) - SortOrder.ARTIST_A_Z -> compare(o1.artist, o2.artist, collator) - SortOrder.ARTIST_Z_A -> compare(o2.artist, o1.artist, collator) - SortOrder.ALBUM_A_Z -> compare(o1.album, o2.album, collator) - SortOrder.ALBUM_Z_A -> compare(o2.album, o1.album, collator) - SortOrder.DISPLAY_NAME_A_Z -> compare(o1.displayName, o2.displayName, collator) - SortOrder.DISPLAY_NAME_Z_A -> compare(o2.displayName, o1.displayName, collator) + SortOrder.SONG_A_Z -> compare(o1.title, o2.title) + SortOrder.SONG_Z_A -> compare(o2.title, o1.title) + SortOrder.ARTIST_A_Z -> compare(o1.artist, o2.artist) + SortOrder.ARTIST_Z_A -> compare(o2.artist, o1.artist) + SortOrder.ALBUM_A_Z -> compare(o1.album, o2.album) + SortOrder.ALBUM_Z_A -> compare(o2.album, o1.album) + SortOrder.DISPLAY_NAME_A_Z -> compare(o1.displayName, o2.displayName) + SortOrder.DISPLAY_NAME_Z_A -> compare(o2.displayName, o1.displayName) else -> 0 } }) @@ -102,38 +58,35 @@ object ItemsSorter { } fun sortedAlbums(albums: List, sortOrder: String?): List { - val collator = Collator.getInstance() return albums.sortedWith(Comparator { o1: Album, o2: Album -> when (sortOrder) { - SortOrder.ALBUM_A_Z -> compare(o1.album, o2.album, collator) - SortOrder.ALBUM_Z_A -> compare(o2.album, o1.album, collator) - SortOrder.ARTIST_A_Z -> compare(o1.artist, o2.artist, collator) - SortOrder.ARTIST_Z_A -> compare(o2.artist, o1.artist, collator) + SortOrder.ALBUM_A_Z -> compare(o1.album, o2.album) + SortOrder.ALBUM_Z_A -> compare(o2.album, o1.album) + SortOrder.ARTIST_A_Z -> compare(o1.artist, o2.artist) + SortOrder.ARTIST_Z_A -> compare(o2.artist, o1.artist) else -> 0 } }) } fun sortedArtists(artists: List, sortOrder: String?): List { - val collator = Collator.getInstance() return artists.sortedWith(Comparator { o1: Artist, o2: Artist -> when (sortOrder) { - SortOrder.ARTIST_A_Z -> compare(o1.artist, o2.artist, collator) - SortOrder.ARTIST_Z_A -> compare(o2.artist, o1.artist, collator) + SortOrder.ARTIST_A_Z -> compare(o1.artist, o2.artist) + SortOrder.ARTIST_Z_A -> compare(o2.artist, o1.artist) else -> 0 } }) } fun sortedPlayLists(playLists: List, sortOrder: String?): List { - val collator = Collator.getInstance() return when (sortOrder) { SortOrder.PLAYLIST_DATE -> playLists else -> { playLists.sortedWith(Comparator { o1: PlayList, o2: PlayList -> when (sortOrder) { - SortOrder.PLAYLIST_A_Z -> compare(o1.name, o2.name, collator) - SortOrder.PLAYLIST_Z_A -> compare(o2.name, o1.name, collator) + SortOrder.PLAYLIST_A_Z -> compare(o1.name, o2.name) + SortOrder.PLAYLIST_Z_A -> compare(o2.name, o1.name) else -> 0 } }) @@ -142,27 +95,23 @@ object ItemsSorter { } fun sortedGenres(genres: List, sortOrder: String?): List { - val collator = Collator.getInstance() return genres.sortedWith(Comparator { o1: Genre, o2: Genre -> when (sortOrder) { - SortOrder.GENRE_A_Z -> compare(o1.genre, o2.genre, collator) - SortOrder.GENRE_Z_A -> compare(o2.genre, o1.genre, collator) + SortOrder.GENRE_A_Z -> compare(o1.genre, o2.genre) + SortOrder.GENRE_Z_A -> compare(o2.genre, o1.genre) else -> 0 } }) } fun sortedFolders(folders: List, sortOrder: String?): List { - val collator = Collator.getInstance() return folders.sortedWith(Comparator { o1: Folder, o2: Folder -> val compareResult = when (sortOrder) { - SortOrder.FOLDER_A_Z -> compare(o1.name ?: o1.path, o2.name ?: o2.path, collator) - SortOrder.FOLDER_Z_A -> compare(o2.name ?: o2.path, o1.name ?: o1.path, collator) + SortOrder.FOLDER_A_Z -> compare(o1.name ?: o1.path, o2.name ?: o2.path) + SortOrder.FOLDER_Z_A -> compare(o2.name ?: o2.path, o1.name ?: o1.path) else -> 0 } - if (compareResult != 0) compareResult else compare(o1.path, o2.path, collator) + if (compareResult != 0) compareResult else compare(o1.path, o2.path) }) } } - -private fun Char.isAsciiDigit(): Boolean = this in '0'..'9' diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7c79cd41..8a0119d0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -103,6 +103,7 @@ xxpermissions = { module = "com.github.getActivity:XXPermissions", version.ref = sardine-android = { group = "com.github.thegrizzlylabs", name = "sardine-android", version.ref = "sardineAndroid" } smbj = { group = "com.hierynomus", name = "smbj", version.ref = "smbj" } timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } +tinypinyin = { group = "io.github.biezhi", name = "TinyPinyin", version.ref = "tinypinyin" } # compose androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } From 19538db946f6713b0653050ddb48a4155b68631a Mon Sep 17 00:00:00 2001 From: GuiltySpark-343 <1875217517@qq.com> Date: Sat, 5 Sep 2026 23:20:43 +0800 Subject: [PATCH 3/4] Add local play event recording for annual listening report --- .../remix/myplayer/data/db/DbMigrations.kt | 33 ++ .../myplayer/data/db/room/AppDatabase.kt | 13 +- .../myplayer/data/db/room/dao/PlayEventDao.kt | 202 ++++++++++++ .../myplayer/data/db/room/entity/PlayEvent.kt | 68 ++++ .../data/model/audio/PlayEventEndReason.kt | 17 + .../data/model/audio/PlayEventSource.kt | 19 ++ .../data/model/report/AnnualReport.kt | 40 +++ .../data/model/report/PlayEventExport.kt | 48 +++ .../remix/myplayer/data/prefs/PrefKeys.kt | 3 + .../remix/myplayer/data/prefs/SettingPrefs.kt | 2 + .../java/remix/myplayer/di/DatabaseModule.kt | 6 + .../remix/myplayer/di/RepositoryModule.kt | 6 + .../myplayer/repo/PlayEventRepository.kt | 76 +++++ .../repo/usecase/PlayFromUriUseCase.kt | 2 + .../remix/myplayer/service/MusicService.kt | 52 ++++ .../service/playback/PlayEventRecorder.kt | 279 +++++++++++++++++ .../main/java/remix/myplayer/ui/nav/AppNav.kt | 6 + .../ui/screen/report/AnnualReportScreen.kt | 290 ++++++++++++++++++ .../ui/screen/setting/SettingScreen.kt | 8 + .../logic/other/PlayEventSettingLogic.kt | 47 +++ .../ui/widget/popup/LibraryItemPopup.kt | 6 + .../myplayer/util/CanonicalIdProvider.kt | 35 +++ .../remix/myplayer/util/DeviceIdProvider.kt | 32 ++ .../viewmodel/AnnualReportViewModel.kt | 167 ++++++++++ .../myplayer/viewmodel/ViewModelProvider.kt | 11 +- .../viewmodel/settings/SettingViewModel.kt | 10 + app/src/main/res/values-zh-rCN/strings.xml | 22 ++ app/src/main/res/values/strings.xml | 22 ++ 28 files changed, 1519 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/remix/myplayer/data/db/room/dao/PlayEventDao.kt create mode 100644 app/src/main/java/remix/myplayer/data/db/room/entity/PlayEvent.kt create mode 100644 app/src/main/java/remix/myplayer/data/model/audio/PlayEventEndReason.kt create mode 100644 app/src/main/java/remix/myplayer/data/model/audio/PlayEventSource.kt create mode 100644 app/src/main/java/remix/myplayer/data/model/report/AnnualReport.kt create mode 100644 app/src/main/java/remix/myplayer/data/model/report/PlayEventExport.kt create mode 100644 app/src/main/java/remix/myplayer/repo/PlayEventRepository.kt create mode 100644 app/src/main/java/remix/myplayer/service/playback/PlayEventRecorder.kt create mode 100644 app/src/main/java/remix/myplayer/ui/screen/report/AnnualReportScreen.kt create mode 100644 app/src/main/java/remix/myplayer/ui/screen/setting/logic/other/PlayEventSettingLogic.kt create mode 100644 app/src/main/java/remix/myplayer/util/CanonicalIdProvider.kt create mode 100644 app/src/main/java/remix/myplayer/util/DeviceIdProvider.kt create mode 100644 app/src/main/java/remix/myplayer/viewmodel/AnnualReportViewModel.kt diff --git a/app/src/main/java/remix/myplayer/data/db/DbMigrations.kt b/app/src/main/java/remix/myplayer/data/db/DbMigrations.kt index 05386bd1..122656ec 100644 --- a/app/src/main/java/remix/myplayer/data/db/DbMigrations.kt +++ b/app/src/main/java/remix/myplayer/data/db/DbMigrations.kt @@ -66,4 +66,37 @@ internal object DbMigrations { db.execSQL("CREATE TABLE IF NOT EXISTS `Smb` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `alias` TEXT NOT NULL, `domain` TEXT, `account` TEXT NOT NULL, `pwd` TEXT NOT NULL, `server` TEXT NOT NULL, `share` TEXT NOT NULL, `lastUrl` TEXT NOT NULL, `createAt` INTEGER NOT NULL)") } } + + val migration7to8 = object : Migration(7, 8) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `play_events` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `schemaVersion` INTEGER NOT NULL, `eventId` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `eventType` TEXT NOT NULL, `canonicalId` TEXT NOT NULL, `audioId` INTEGER, `startedAt` INTEGER NOT NULL, `endedAt` INTEGER NOT NULL, `durationMs` INTEGER NOT NULL, `listenedMs` INTEGER NOT NULL, `listenRatio` REAL NOT NULL, `playScore` REAL NOT NULL, `completed` INTEGER NOT NULL, `source` TEXT NOT NULL, `endReason` TEXT NOT NULL, `titleSnapshot` TEXT NOT NULL, `artistSnapshot` TEXT NOT NULL, `albumSnapshot` TEXT NOT NULL, `genreSnapshot` TEXT, `sourceUri` TEXT, `contentHash` TEXT, `pathHint` TEXT, `year` INTEGER NOT NULL, `month` INTEGER NOT NULL, `day` INTEGER NOT NULL, `hour` INTEGER NOT NULL, `weekday` INTEGER NOT NULL)" + ) + db.execSQL("CREATE INDEX IF NOT EXISTS `index_play_events_year` ON `play_events` (`year`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_play_events_canonicalId` ON `play_events` (`canonicalId`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_play_events_audioId` ON `play_events` (`audioId`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_play_events_source` ON `play_events` (`source`)") + + db.execSQL("CREATE INDEX IF NOT EXISTS `index_play_events_startedAt` ON `play_events` (`startedAt`)") + } + } + + val migration8to9 = object : Migration(8, 9) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `play_events` ADD COLUMN `songId` TEXT") + db.execSQL("ALTER TABLE `play_events` ADD COLUMN `artistId` TEXT") + db.execSQL("ALTER TABLE `play_events` ADD COLUMN `albumId` TEXT") + db.execSQL("ALTER TABLE `play_events` ADD COLUMN `genreId` TEXT") + db.execSQL("ALTER TABLE `play_events` ADD COLUMN `playlistId` TEXT") + db.execSQL("ALTER TABLE `play_events` ADD COLUMN `mediaType` TEXT") + db.execSQL("ALTER TABLE `play_events` ADD COLUMN `sessionId` TEXT") + db.execSQL("ALTER TABLE `play_events` ADD COLUMN `gapBeforeMs` INTEGER") + db.execSQL("ALTER TABLE `play_events` ADD COLUMN `gapAfterMs` INTEGER") + db.execSQL("ALTER TABLE `play_events` ADD COLUMN `loopCount` INTEGER NOT NULL DEFAULT 0") + db.execSQL("ALTER TABLE `play_events` ADD COLUMN `outputDevice` TEXT") + db.execSQL("ALTER TABLE `play_events` ADD COLUMN `isForeground` INTEGER") + db.execSQL("ALTER TABLE `play_events` ADD COLUMN `decoder` TEXT") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_play_events_eventType` ON `play_events` (`eventType`)") + } + } } \ No newline at end of file diff --git a/app/src/main/java/remix/myplayer/data/db/room/AppDatabase.kt b/app/src/main/java/remix/myplayer/data/db/room/AppDatabase.kt index c3fab71b..1365304f 100644 --- a/app/src/main/java/remix/myplayer/data/db/room/AppDatabase.kt +++ b/app/src/main/java/remix/myplayer/data/db/room/AppDatabase.kt @@ -12,14 +12,18 @@ import remix.myplayer.data.db.DbMigrations.migration3to4 import remix.myplayer.data.db.DbMigrations.migration4to5 import remix.myplayer.data.db.DbMigrations.migration5to6 import remix.myplayer.data.db.DbMigrations.migration6to7 +import remix.myplayer.data.db.DbMigrations.migration7to8 +import remix.myplayer.data.db.DbMigrations.migration8to9 import remix.myplayer.data.db.room.dao.HistoryDao import remix.myplayer.data.db.room.dao.MetaDataCacheDao +import remix.myplayer.data.db.room.dao.PlayEventDao import remix.myplayer.data.db.room.dao.PlayListDao import remix.myplayer.data.db.room.dao.PlayQueueDao import remix.myplayer.data.db.room.dao.SmbDao import remix.myplayer.data.db.room.dao.WebDavDao import remix.myplayer.data.db.room.entity.History import remix.myplayer.data.db.room.entity.MetaDataCache +import remix.myplayer.data.db.room.entity.PlayEvent import remix.myplayer.data.db.room.entity.PlayList import remix.myplayer.data.db.room.entity.PlayQueue import remix.myplayer.data.db.room.entity.Smb @@ -39,7 +43,8 @@ import timber.log.Timber History::class, WebDav::class, Smb::class, - MetaDataCache::class + MetaDataCache::class, + PlayEvent::class ], version = AppDatabase.VERSION, exportSchema = true ) abstract class AppDatabase : RoomDatabase() { @@ -50,6 +55,8 @@ abstract class AppDatabase : RoomDatabase() { abstract fun historyDao(): HistoryDao + abstract fun playEventDao(): PlayEventDao + abstract fun webDavDao(): WebDavDao abstract fun smbDao(): SmbDao @@ -58,7 +65,7 @@ abstract class AppDatabase : RoomDatabase() { companion object { - const val VERSION = 7 + const val VERSION = 9 @Volatile private var INSTANCE: AppDatabase? = null @@ -83,6 +90,8 @@ abstract class AppDatabase : RoomDatabase() { .addMigrations(migration4to5) .addMigrations(migration5to6) .addMigrations(migration6to7) + .addMigrations(migration7to8) + .addMigrations(migration8to9) .build() database.invalidationTracker.addObserver(object : InvalidationTracker.Observer(PlayList.TABLE_NAME, PlayQueue.TABLE_NAME) { diff --git a/app/src/main/java/remix/myplayer/data/db/room/dao/PlayEventDao.kt b/app/src/main/java/remix/myplayer/data/db/room/dao/PlayEventDao.kt new file mode 100644 index 00000000..c22a734d --- /dev/null +++ b/app/src/main/java/remix/myplayer/data/db/room/dao/PlayEventDao.kt @@ -0,0 +1,202 @@ +package remix.myplayer.data.db.room.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.OnConflictStrategy.Companion.REPLACE +import remix.myplayer.data.db.room.entity.PlayEvent + +@Dao +interface PlayEventDao { + + @Insert(onConflict = REPLACE) + suspend fun insert(event: PlayEvent): Long + + // 导出用:包税全部事件类型(playback + song_added) + @Query("SELECT * FROM play_events WHERE year = :year ORDER BY startedAt DESC") + suspend fun selectByYear(year: Int): List + + // 报告年份选择:只统计播放事件 + @Query("SELECT DISTINCT year FROM play_events WHERE eventType = 'playback' ORDER BY year DESC") + suspend fun distinctYears(): List + + // ---- 播放类聚合(只统计 playback)---- + @Query("SELECT COUNT(*) FROM play_events WHERE eventType = 'playback' AND year = :year") + suspend fun countByYear(year: Int): Int + + @Query("SELECT COUNT(*) FROM play_events WHERE eventType = 'song_added' AND canonicalId = :canonicalId") + suspend fun countSongAdded(canonicalId: String): Int + + @Query("SELECT COUNT(*) FROM play_events WHERE eventType = 'playback' AND year = :year") + suspend fun countPlayable(year: Int): Int + + @Query("SELECT COALESCE(SUM(playScore), 0) FROM play_events WHERE eventType = 'playback' AND year = :year") + suspend fun sumPlayScore(year: Int): Double + + @Query("SELECT COALESCE(SUM(listenedMs), 0) FROM play_events WHERE eventType = 'playback' AND year = :year") + suspend fun sumListenMs(year: Int): Long + + @Query("SELECT COUNT(*) FROM play_events WHERE eventType = 'playback' AND year = :year AND completed = 1") + suspend fun countCompleted(year: Int): Int + + @Query("SELECT COUNT(DISTINCT day) FROM play_events WHERE eventType = 'playback' AND year = :year") + suspend fun countListenDays(year: Int): Int + + @Query("SELECT COUNT(DISTINCT canonicalId) FROM play_events WHERE eventType = 'playback' AND year = :year") + suspend fun countDistinctSongs(year: Int): Int + + @Query("SELECT COUNT(DISTINCT artistSnapshot) FROM play_events WHERE eventType = 'playback' AND year = :year") + suspend fun countDistinctArtists(year: Int): Int + + @Query("SELECT COUNT(DISTINCT albumSnapshot) FROM play_events WHERE eventType = 'playback' AND year = :year") + suspend fun countDistinctAlbums(year: Int): Int + + // 当年首次收听的歌曲数(该歌曲最早一次播放发生在该年) + @Query( + """ + SELECT COUNT(*) FROM ( + SELECT canonicalId, MIN(startedAt) AS firstAt + FROM play_events + WHERE eventType = 'playback' + GROUP BY canonicalId + HAVING CAST(strftime('%Y', firstAt / 1000, 'unixepoch') AS INTEGER) = :year + ) + """ + ) + suspend fun countFirstListenedSongs(year: Int): Int + + // 当年新增歌曲数(song_added 且 year = 该年) + @Query("SELECT COUNT(*) FROM play_events WHERE eventType = 'song_added' AND year = :year") + suspend fun countAddedSongs(year: Int): Int + + @Query( + """ + SELECT canonicalId AS canonicalId, + (SELECT titleSnapshot FROM play_events e2 WHERE e2.canonicalId = e1.canonicalId AND e2.eventType = 'playback' AND e2.year = :year ORDER BY e2.startedAt DESC LIMIT 1) AS title, + (SELECT artistSnapshot FROM play_events e2 WHERE e2.canonicalId = e1.canonicalId AND e2.eventType = 'playback' AND e2.year = :year ORDER BY e2.startedAt DESC LIMIT 1) AS artist, + (SELECT albumSnapshot FROM play_events e2 WHERE e2.canonicalId = e1.canonicalId AND e2.eventType = 'playback' AND e2.year = :year ORDER BY e2.startedAt DESC LIMIT 1) AS album, + SUM(listenedMs) AS listenedMs, + SUM(playScore) AS playScore, + COUNT(*) AS plays + FROM play_events e1 + WHERE e1.eventType = 'playback' AND e1.year = :year + GROUP BY canonicalId + ORDER BY listenedMs DESC + LIMIT :limit + """ + ) + suspend fun topSongs(year: Int, limit: Int): List + + @Query( + """ + SELECT artistSnapshot AS name, + SUM(listenedMs) AS listenedMs, + COUNT(*) AS plays, + COUNT(DISTINCT canonicalId) AS songs + FROM play_events + WHERE eventType = 'playback' AND year = :year + GROUP BY artistSnapshot + ORDER BY listenedMs DESC + LIMIT :limit + """ + ) + suspend fun topArtists(year: Int, limit: Int): List + + @Query( + """ + SELECT albumSnapshot AS name, + SUM(listenedMs) AS listenedMs, + COUNT(*) AS plays, + COUNT(DISTINCT canonicalId) AS songs + FROM play_events + WHERE eventType = 'playback' AND year = :year + GROUP BY albumSnapshot + ORDER BY listenedMs DESC + LIMIT :limit + """ + ) + suspend fun topAlbums(year: Int, limit: Int): List + + @Query( + """ + SELECT month AS month, COUNT(*) AS plays, COALESCE(SUM(listenedMs), 0) AS listenedMs + FROM play_events + WHERE eventType = 'playback' AND year = :year + GROUP BY month + ORDER BY month + """ + ) + suspend fun monthDistribution(year: Int): List + + @Query( + """ + SELECT hour AS hour, COUNT(*) AS plays + FROM play_events + WHERE eventType = 'playback' AND year = :year + GROUP BY hour + ORDER BY hour + """ + ) + suspend fun hourDistribution(year: Int): List + + @Query( + """ + SELECT source AS source, COUNT(*) AS plays, COALESCE(SUM(listenedMs), 0) AS listenedMs + FROM play_events + WHERE eventType = 'playback' AND year = :year + GROUP BY source + ORDER BY plays DESC + """ + ) + suspend fun sourceBreakdown(year: Int): List + + @Query("SELECT COUNT(*) FROM play_events") + suspend fun countAll(): Int + + @Query("DELETE FROM play_events") + suspend fun clear() + + @Query("DELETE FROM play_events WHERE year = :year") + suspend fun deleteByYear(year: Int) +} + +data class TopPlayItem( + val canonicalId: String, + val title: String, + val artist: String, + val album: String, + val listenedMs: Long, + val playScore: Double, + val plays: Int +) + +data class TopArtistItem( + val name: String, + val listenedMs: Long, + val plays: Int, + val songs: Int +) + +data class TopAlbumItem( + val name: String, + val listenedMs: Long, + val plays: Int, + val songs: Int +) + +data class MonthCount( + val month: Int, + val plays: Int, + val listenedMs: Long +) + +data class HourCount( + val hour: Int, + val plays: Int +) + +data class SourceCount( + val source: String, + val plays: Int, + val listenedMs: Long +) diff --git a/app/src/main/java/remix/myplayer/data/db/room/entity/PlayEvent.kt b/app/src/main/java/remix/myplayer/data/db/room/entity/PlayEvent.kt new file mode 100644 index 00000000..882d5dc5 --- /dev/null +++ b/app/src/main/java/remix/myplayer/data/db/room/entity/PlayEvent.kt @@ -0,0 +1,68 @@ +package remix.myplayer.data.db.room.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * 单次播放事件(v2)。 + * + * 字段遵循跨平台协议:eventId/deviceId/schemaVersion/canonicalId 与平台无关。 + * startedAt/endedAt 使用 UTC epoch 毫秒。listenRatio/playScore/completed 为派生值。 + * v2 新增的可选字段用于更丰富的年度统计(媒体类型/稳定ID/会话/循环/环境占位)。 + */ +@Entity( + tableName = "play_events", + indices = [ + Index(value = ["year"]), + Index(value = ["canonicalId"]), + Index(value = ["audioId"]), + Index(value = ["source"]), + Index(value = ["startedAt"]), + Index(value = ["eventType"]) + ] +) +data class PlayEvent( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val schemaVersion: Int = 2, + val eventId: String, + val deviceId: String, + val eventType: String = "playback", + val canonicalId: String, + val audioId: Long?, + val startedAt: Long, + val endedAt: Long, + val durationMs: Long, + val listenedMs: Long, + val listenRatio: Double, + val playScore: Double, + val completed: Boolean, + val source: String, + val endReason: String, + val titleSnapshot: String, + val artistSnapshot: String, + val albumSnapshot: String, + val genreSnapshot: String?, + val sourceUri: String?, + val contentHash: String?, + val pathHint: String?, + val year: Int, + val month: Int, + val day: Int, + val hour: Int, + val weekday: Int, + // ---- v2 扩展(全部可选,向前兼容) ---- + val songId: String? = null, + val artistId: String? = null, + val albumId: String? = null, + val genreId: String? = null, + val playlistId: String? = null, + val mediaType: String? = null, + val sessionId: String? = null, + val gapBeforeMs: Long? = null, + val gapAfterMs: Long? = null, + val loopCount: Int = 0, + val outputDevice: String? = null, + val isForeground: Boolean? = null, + val decoder: String? = null +) diff --git a/app/src/main/java/remix/myplayer/data/model/audio/PlayEventEndReason.kt b/app/src/main/java/remix/myplayer/data/model/audio/PlayEventEndReason.kt new file mode 100644 index 00000000..0722293d --- /dev/null +++ b/app/src/main/java/remix/myplayer/data/model/audio/PlayEventEndReason.kt @@ -0,0 +1,17 @@ +package remix.myplayer.data.model.audio + +/** + * 播放事件结束原因。 + */ +enum class PlayEventEndReason(val value: Int) { + NATURAL_END(0), + SKIP_TO_NEXT(1), + SKIP_TO_PREVIOUS(2), + PLAYLIST_CHANGED(3), + STOP(4), + ERROR(5); + + companion object { + fun fromValue(value: Int): PlayEventEndReason? = entries.firstOrNull { it.value == value } + } +} diff --git a/app/src/main/java/remix/myplayer/data/model/audio/PlayEventSource.kt b/app/src/main/java/remix/myplayer/data/model/audio/PlayEventSource.kt new file mode 100644 index 00000000..b3800b09 --- /dev/null +++ b/app/src/main/java/remix/myplayer/data/model/audio/PlayEventSource.kt @@ -0,0 +1,19 @@ +package remix.myplayer.data.model.audio + +/** + * 播放来源:描述一次播放是如何被触发的。 + */ +enum class PlayEventSource(val value: Int) { + SEARCH_CLICK(0), + LIBRARY_CLICK(1), + PLAYLIST_CLICK(2), + QUEUE_AUTO(3), + RESUME(4), + EXTERNAL_INTENT(5); + + companion object { + fun fromValue(value: Int): PlayEventSource? = entries.firstOrNull { it.value == value } + + fun fromName(name: String?): PlayEventSource? = entries.firstOrNull { it.name == name } + } +} diff --git a/app/src/main/java/remix/myplayer/data/model/report/AnnualReport.kt b/app/src/main/java/remix/myplayer/data/model/report/AnnualReport.kt new file mode 100644 index 00000000..a311169d --- /dev/null +++ b/app/src/main/java/remix/myplayer/data/model/report/AnnualReport.kt @@ -0,0 +1,40 @@ +package remix.myplayer.data.model.report + +import remix.myplayer.data.db.room.dao.HourCount +import remix.myplayer.data.db.room.dao.MonthCount +import remix.myplayer.data.db.room.dao.SourceCount +import remix.myplayer.data.db.room.dao.TopAlbumItem +import remix.myplayer.data.db.room.dao.TopArtistItem +import remix.myplayer.data.db.room.dao.TopPlayItem + +/** + * 某一年度的听歌报告聚合结果。 + */ +data class AnnualReport( + val year: Int, + val plays: Int, + val listenScore: Double, + val completedPlays: Int, + val listenMs: Long, + val listenedDays: Int, + val distinctSongs: Int, + val distinctArtists: Int, + val distinctAlbums: Int, + val firstListenedSongs: Int, + val addedSongs: Int, + val topSongs: List, + val topArtists: List, + val topAlbums: List, + val monthDistribution: List, + val hourDistribution: List, + val sourceBreakdown: List +) + +/** + * 排行榜单项。 + */ +data class TopItem( + val name: String, + val listenedMs: Long, + val plays: Int +) diff --git a/app/src/main/java/remix/myplayer/data/model/report/PlayEventExport.kt b/app/src/main/java/remix/myplayer/data/model/report/PlayEventExport.kt new file mode 100644 index 00000000..90f55cb2 --- /dev/null +++ b/app/src/main/java/remix/myplayer/data/model/report/PlayEventExport.kt @@ -0,0 +1,48 @@ +package remix.myplayer.data.model.report + +import kotlinx.serialization.Serializable + +/** + * JSONL 导出协议数据(schema v2)。时间统一使用 UTC ISO 8601。 + * v2 字段全部可选,缺省不出现(跨端读取需容忍缺省)。 + */ +@Serializable +data class PlayEventExport( + val schemaVersion: Int, + val eventId: String, + val deviceId: String, + val eventType: String, + val startedAt: String, + val endedAt: String, + val durationMs: Long, + val listenedMs: Long, + val listenRatio: Double, + val playScore: Double, + val completed: Boolean, + val source: String, + val endReason: String, + val track: TrackExport, + // ---- v2 可选 ---- + val songId: String? = null, + val artistId: String? = null, + val albumId: String? = null, + val genreId: String? = null, + val playlistId: String? = null, + val mediaType: String? = null, + val sessionId: String? = null, + val gapBeforeMs: Long? = null, + val gapAfterMs: Long? = null, + val loopCount: Int = 0, + val outputDevice: String? = null, + val isForeground: Boolean? = null, + val decoder: String? = null +) + +@Serializable +data class TrackExport( + val canonicalId: String, + val title: String, + val artist: String, + val album: String, + val durationMs: Long +) diff --git a/app/src/main/java/remix/myplayer/data/prefs/PrefKeys.kt b/app/src/main/java/remix/myplayer/data/prefs/PrefKeys.kt index 134c96b1..edb9a8dc 100644 --- a/app/src/main/java/remix/myplayer/data/prefs/PrefKeys.kt +++ b/app/src/main/java/remix/myplayer/data/prefs/PrefKeys.kt @@ -200,6 +200,9 @@ object PrefKeys { /** 播放页封面切换动画速率 */ const val PLAYING_COVER_ANIMATION_SPEED = "playing_cover_animation_speed" + /** 播放统计(年度报告)开关 */ + const val PLAY_EVENT_ENABLED = "play_event_enabled" + /** 版本号 */ const val VERSION = "version" diff --git a/app/src/main/java/remix/myplayer/data/prefs/SettingPrefs.kt b/app/src/main/java/remix/myplayer/data/prefs/SettingPrefs.kt index 7597b56f..aa743678 100644 --- a/app/src/main/java/remix/myplayer/data/prefs/SettingPrefs.kt +++ b/app/src/main/java/remix/myplayer/data/prefs/SettingPrefs.kt @@ -32,6 +32,8 @@ class SettingPrefs @Inject constructor( var firstLoad by PrefsDelegate(sp, PrefKeys.Setting.FIRST_LOAD, true) + var playEventEnabled by PrefsDelegate(sp, PrefKeys.Setting.PLAY_EVENT_ENABLED, true) + var libraryJson by PrefsDelegate(sp, PrefKeys.Setting.LIBRARY, "") var scanSize by PrefsDelegate(sp, PrefKeys.Setting.SCAN_SIZE, MB) diff --git a/app/src/main/java/remix/myplayer/di/DatabaseModule.kt b/app/src/main/java/remix/myplayer/di/DatabaseModule.kt index a1ada8b6..b9cc0fd1 100644 --- a/app/src/main/java/remix/myplayer/di/DatabaseModule.kt +++ b/app/src/main/java/remix/myplayer/di/DatabaseModule.kt @@ -9,6 +9,7 @@ import dagger.hilt.components.SingletonComponent import remix.myplayer.data.db.room.AppDatabase import remix.myplayer.data.db.room.dao.HistoryDao import remix.myplayer.data.db.room.dao.MetaDataCacheDao +import remix.myplayer.data.db.room.dao.PlayEventDao import remix.myplayer.data.db.room.dao.PlayListDao import remix.myplayer.data.db.room.dao.PlayQueueDao import remix.myplayer.data.db.room.dao.WebDavDao @@ -39,6 +40,11 @@ object DatabaseModule { return database.historyDao() } + @Provides + fun providePlayEventDao(database: AppDatabase): PlayEventDao { + return database.playEventDao() + } + @Provides fun provideWebdavDao(database: AppDatabase): WebDavDao { return database.webDavDao() diff --git a/app/src/main/java/remix/myplayer/di/RepositoryModule.kt b/app/src/main/java/remix/myplayer/di/RepositoryModule.kt index 800f4a78..32737253 100644 --- a/app/src/main/java/remix/myplayer/di/RepositoryModule.kt +++ b/app/src/main/java/remix/myplayer/di/RepositoryModule.kt @@ -14,6 +14,8 @@ import remix.myplayer.repo.GenreRepoImpl import remix.myplayer.repo.GenreRepository import remix.myplayer.repo.HistoryRepoImpl import remix.myplayer.repo.HistoryRepository +import remix.myplayer.repo.PlayEventRepoImpl +import remix.myplayer.repo.PlayEventRepository import remix.myplayer.repo.PlayListRepoImpl import remix.myplayer.repo.PlayListRepository import remix.myplayer.repo.PlayQueueRepoImpl @@ -60,6 +62,10 @@ abstract class RepositoryModule { @Binds abstract fun bindHistoryRepo(repo: HistoryRepoImpl): HistoryRepository + @Singleton + @Binds + abstract fun bindPlayEventRepo(repo: PlayEventRepoImpl): PlayEventRepository + @Singleton @Binds abstract fun bindWebDavRepo(repo: remix.myplayer.repo.WebDavRepoImpl): WebDavRepository diff --git a/app/src/main/java/remix/myplayer/repo/PlayEventRepository.kt b/app/src/main/java/remix/myplayer/repo/PlayEventRepository.kt new file mode 100644 index 00000000..5fc12286 --- /dev/null +++ b/app/src/main/java/remix/myplayer/repo/PlayEventRepository.kt @@ -0,0 +1,76 @@ +package remix.myplayer.repo + +import remix.myplayer.data.db.room.dao.PlayEventDao +import remix.myplayer.data.db.room.entity.PlayEvent +import remix.myplayer.data.model.report.AnnualReport +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 播放事件仓库:负责写入原始事件以及按年聚合。 + */ +interface PlayEventRepository { + + suspend fun record(event: PlayEvent) + + /** 若该 canonicalId 尚无 song_added 事件,则登记一条。 */ + suspend fun recordSongAddedIfAbsent(event: PlayEvent) + + suspend fun availableYears(): List + + suspend fun annualReport(year: Int): AnnualReport + + suspend fun eventsOf(year: Int): List + + suspend fun clear() +} + +@Singleton +class PlayEventRepoImpl @Inject constructor( + private val playEventDao: PlayEventDao, +) : PlayEventRepository { + + override suspend fun record(event: PlayEvent) { + playEventDao.insert(event) + } + + override suspend fun recordSongAddedIfAbsent(event: PlayEvent) { + if (playEventDao.countSongAdded(event.canonicalId) == 0) { + playEventDao.insert(event) + } + } + + override suspend fun availableYears(): List = playEventDao.distinctYears() + + override suspend fun eventsOf(year: Int): List = playEventDao.selectByYear(year) + + override suspend fun annualReport(year: Int): AnnualReport { + return AnnualReport( + year = year, + plays = playEventDao.countByYear(year), + listenScore = playEventDao.sumPlayScore(year), + completedPlays = playEventDao.countCompleted(year), + listenMs = playEventDao.sumListenMs(year), + listenedDays = playEventDao.countListenDays(year), + distinctSongs = playEventDao.countDistinctSongs(year), + distinctArtists = playEventDao.countDistinctArtists(year), + distinctAlbums = playEventDao.countDistinctAlbums(year), + firstListenedSongs = playEventDao.countFirstListenedSongs(year), + addedSongs = playEventDao.countAddedSongs(year), + topSongs = playEventDao.topSongs(year, TOP_LIMIT), + topArtists = playEventDao.topArtists(year, TOP_LIMIT), + topAlbums = playEventDao.topAlbums(year, TOP_LIMIT), + monthDistribution = playEventDao.monthDistribution(year), + hourDistribution = playEventDao.hourDistribution(year), + sourceBreakdown = playEventDao.sourceBreakdown(year) + ) + } + + override suspend fun clear() { + playEventDao.clear() + } + + companion object { + private const val TOP_LIMIT = 10 + } +} diff --git a/app/src/main/java/remix/myplayer/repo/usecase/PlayFromUriUseCase.kt b/app/src/main/java/remix/myplayer/repo/usecase/PlayFromUriUseCase.kt index 76f4133a..69fa598e 100644 --- a/app/src/main/java/remix/myplayer/repo/usecase/PlayFromUriUseCase.kt +++ b/app/src/main/java/remix/myplayer/repo/usecase/PlayFromUriUseCase.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import remix.myplayer.R +import remix.myplayer.data.model.audio.PlayEventSource import remix.myplayer.data.model.audio.Song import remix.myplayer.misc.MediaScanner import remix.myplayer.repo.SongRepository @@ -96,6 +97,7 @@ class PlayFromUriUseCase @Inject constructor( Intent(context, MusicService::class.java).run { action = MusicService.ACTION_CMD putExtra(MusicService.EXTRA_COMMAND, Command.PLAY_TEMP) + putExtra(MusicService.EXTRA_PLAY_EVENT_SOURCE, PlayEventSource.EXTERNAL_INTENT.value) putExtra( MusicService.EXTRA_SONG, songs.first() diff --git a/app/src/main/java/remix/myplayer/service/MusicService.kt b/app/src/main/java/remix/myplayer/service/MusicService.kt index 44b22869..bb5924be 100644 --- a/app/src/main/java/remix/myplayer/service/MusicService.kt +++ b/app/src/main/java/remix/myplayer/service/MusicService.kt @@ -29,6 +29,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import remix.myplayer.R +import remix.myplayer.data.model.audio.PlayEventSource import remix.myplayer.data.model.audio.Song import remix.myplayer.data.model.audio.Song.Companion.EMPTY_SONG import remix.myplayer.data.prefs.PrefKeys @@ -49,6 +50,7 @@ import remix.myplayer.misc.receiver.ExitReceiver import remix.myplayer.misc.receiver.HeadsetPlugReceiver import remix.myplayer.misc.receiver.MediaButtonReceiver import remix.myplayer.repo.HistoryRepository +import remix.myplayer.repo.PlayEventRepository import remix.myplayer.repo.PlayListRepository import remix.myplayer.repo.SongRepository import remix.myplayer.repo.usecase.FetchMetaDataUseCase @@ -58,6 +60,7 @@ import remix.myplayer.service.notification.NotifyImpl24 import remix.myplayer.service.playback.ExoPlayback import remix.myplayer.service.playback.MusicStateSource import remix.myplayer.service.playback.Playback +import remix.myplayer.service.playback.PlayEventRecorder import remix.myplayer.service.playback.ReplayGainController import remix.myplayer.service.playback.PlaybackFavoriteState import remix.myplayer.service.playback.PlaybackProgressSaver @@ -66,7 +69,9 @@ import remix.myplayer.ui.activity.base.BaseMusicActivity import remix.myplayer.ui.activity.base.BaseMusicActivity.Companion.EXTRA_PERMISSION import remix.myplayer.ui.activity.base.BaseMusicActivity.Companion.EXTRA_PLAYLIST import remix.myplayer.ui.nav.MessageNotifier +import remix.myplayer.util.CanonicalIdProvider import remix.myplayer.util.Constants.ACTION_EXIT +import remix.myplayer.util.DeviceIdProvider import remix.myplayer.util.PermissionUtil import remix.myplayer.util.Util import remix.myplayer.util.Util.registerLocalReceiver @@ -126,6 +131,15 @@ class MusicService : BaseService(), @Inject lateinit var historyRepository: HistoryRepository + @Inject + lateinit var playEventRepository: PlayEventRepository + + @Inject + lateinit var deviceIdProvider: DeviceIdProvider + + @Inject + lateinit var canonicalIdProvider: CanonicalIdProvider + @Inject lateinit var fetchMetaDataUseCase: FetchMetaDataUseCase @@ -172,6 +186,11 @@ class MusicService : BaseService(), lateinit var playback: ExoPlayback private set + /** + * 播放事件采集器 + */ + private lateinit var playEventRecorder: PlayEventRecorder + /** * 播放控制的Receiver */ @@ -544,6 +563,14 @@ class MusicService : BaseService(), * 初始化Mediaplayer */ private fun setUpPlayback() { + playEventRecorder = PlayEventRecorder( + repository = playEventRepository, + deviceIdProvider = deviceIdProvider, + canonicalIdProvider = canonicalIdProvider, + scope = this, + enabled = { settingPrefs.playEventEnabled } + ) + playback = ExoPlayback(this, settingPrefs.decoderMode, audioFocusManager, replayGainController) playback.attach(this) @@ -553,6 +580,7 @@ class MusicService : BaseService(), override fun onIsPlayingChanged(isPlaying: Boolean) { Timber.v("onIsPlayingChanged: $isPlaying") + playEventRecorder.onIsPlayingChanged(isPlaying) stateSource.updatePlaybackUiState(isPlaying = isPlaying) if (isPlaying) { updatePlayHistory() @@ -586,6 +614,7 @@ class MusicService : BaseService(), override fun onEnded() { Timber.v("onEnded") + playEventRecorder.onEnded() if (playback.itemCount == 0) { notify.stopForegroundAndNotification() } @@ -596,6 +625,7 @@ class MusicService : BaseService(), Timber.v("onItemTransition, playback.audioSessionId: ${playback.audioSessionId}") val song = mediaItem?.localConfiguration?.tag as? Song + playEventRecorder.onItemTransition(song, reason) if (song is Song.Remote) { launch { withContext(Dispatchers.IO) { @@ -631,6 +661,7 @@ class MusicService : BaseService(), override fun onError(error: PlaybackException) { Timber.e("onPlayerError, code: ${error.errorCode} name: ${error.errorCodeName} cause: ${error.cause}") + playEventRecorder.onError() MessageNotifier.show(R.string.play_failed, error.errorCodeName) when (error.errorCode) { // fatal error @@ -649,6 +680,7 @@ class MusicService : BaseService(), } override fun onPositionChange() { + playEventRecorder.onPositionChange(playback.position) pushProgressUiState() } @@ -681,6 +713,7 @@ class MusicService : BaseService(), if (isPlaying) { pause() } + playEventRecorder.stop() playback.release() load = 0 @@ -732,11 +765,27 @@ class MusicService : BaseService(), launch { playQueueStore.save(newQueue) } } + /** + * 根据 Intent 与命令设置下一次播放的来源。 + */ + private fun setPendingPlayEventSource(intent: Intent, command: Int) { + val explicit = PlayEventSource.fromValue(intent.getIntExtra(EXTRA_PLAY_EVENT_SOURCE, -1)) + val source = explicit ?: when (command) { + Command.PLAY_AT -> PlayEventSource.LIBRARY_CLICK + Command.PLAY_TEMP -> PlayEventSource.SEARCH_CLICK + else -> null + } + if (source != null) { + playEventRecorder.setPendingSource(source) + } + } + /** * 设置播放队列 */ fun setPlayQueue(newQueue: List?, intent: Intent) { Timber.v("setPlayQueue") + setPendingPlayEventSource(intent, Command.PLAY_AT) // 如果是随机播放,需要更新播放模式 val shuffle = intent.getBooleanExtra(EXTRA_SHUFFLE, false) if (newQueue.isNullOrEmpty()) { @@ -1101,6 +1150,7 @@ class MusicService : BaseService(), } val command = intent.getIntExtra(EXTRA_COMMAND, -1) Timber.v("handleCommand, command: $command") + setPendingPlayEventSource(intent, command) if (shouldThrottleCommand(command)) { val now = System.currentTimeMillis() @@ -1285,6 +1335,7 @@ class MusicService : BaseService(), } if (queue.isNotEmpty()) { + playEventRecorder.setPendingSource(PlayEventSource.RESUME) playback.setPlaylist( queue, pos, @@ -1385,6 +1436,7 @@ class MusicService : BaseService(), const val EXTRA_COMMAND = "command" const val EXTRA_SHUFFLE = "shuffle" const val EXTRA_PROGRESS = "progress" + const val EXTRA_PLAY_EVENT_SOURCE = "play_event_source" const val ACTION_APPWIDGET_OPERATE = "$APLAYER_PACKAGE_NAME.appwidget.operate" const val ACTION_SHORTCUT_SHUFFLE = "$APLAYER_PACKAGE_NAME.shortcut.shuffle" const val ACTION_SHORTCUT_MYLOVE = "$APLAYER_PACKAGE_NAME.shortcut.my_love" diff --git a/app/src/main/java/remix/myplayer/service/playback/PlayEventRecorder.kt b/app/src/main/java/remix/myplayer/service/playback/PlayEventRecorder.kt new file mode 100644 index 00000000..4b1ef8a6 --- /dev/null +++ b/app/src/main/java/remix/myplayer/service/playback/PlayEventRecorder.kt @@ -0,0 +1,279 @@ +package remix.myplayer.service.playback + +import androidx.media3.common.Player +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import remix.myplayer.data.db.room.entity.PlayEvent +import remix.myplayer.data.model.audio.PlayEventEndReason +import remix.myplayer.data.model.audio.PlayEventSource +import remix.myplayer.data.model.audio.Song +import remix.myplayer.repo.PlayEventRepository +import remix.myplayer.util.CanonicalIdProvider +import remix.myplayer.util.DeviceIdProvider +import java.util.Calendar +import java.util.TimeZone +import java.util.UUID + +/** + * 在播放会话生命周期内累计“真实收听时长”,并在会话结束时写成一条 PlayEvent(schema v2)。 + * + * 规则: + * - begin:歌曲开始播放(或切到新歌且正在播放)时建立会话,并补登记 song_added(首次出现才写)。 + * - accumulate:仅在播放状态下,按 position 增量累计,增量异常视为 seek 忽略。 + * - end:切歌/自然结束/错误/停止/单曲循环重置时最终化。 + * - discard:有效收听比例 < 0.1(或未知时长时收听 < 1s)的会话不落库(但 song_added 已登记)。 + */ +class PlayEventRecorder( + private val repository: PlayEventRepository, + private val deviceIdProvider: DeviceIdProvider, + private val canonicalIdProvider: CanonicalIdProvider, + private val scope: CoroutineScope, + private val enabled: () -> Boolean, +) { + + private data class Session( + val song: Song, + val source: PlayEventSource, + val startedAt: Long, + val sessionId: String, + val gapBeforeMs: Long?, + var loopCount: Int, + var lastPosition: Long = 0L, + var listenedMs: Long = 0L, + ) + + private var currentSong: Song? = null + private var session: Session? = null + private var pendingSource: PlayEventSource? = null + private var playing = false + private var currentSessionId: String? = null + private var lastEventEndedAt: Long = 0L + + /** 由播放入口(UI/Service)设置,本次“下一次开始播放”的来源。 */ + fun setPendingSource(source: PlayEventSource?) { + pendingSource = source + } + + fun onIsPlayingChanged(isPlaying: Boolean) { + this.playing = isPlaying + if (isPlaying) { + val song = currentSong + if (song != null && session?.song?.id != song.id) { + begin(song) + } + } + } + + fun onItemTransition(song: Song?, transitionReason: Int) { + val current = session + if (current != null && current.song.id != song?.id) { + val reason = when (transitionReason) { + Player.MEDIA_ITEM_TRANSITION_REASON_AUTO -> PlayEventEndReason.NATURAL_END + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED -> PlayEventEndReason.PLAYLIST_CHANGED + else -> PlayEventEndReason.SKIP_TO_NEXT + } + finishSession(reason) + } + currentSong = song + if (playing && song != null) { + begin(song) + } + } + + fun onPositionChange(position: Long) { + val s = session ?: return + if (!playing) return + + val delta = position - s.lastPosition + if (delta >= 0 && delta <= MAX_SEEK_GAP) { + s.listenedMs += delta + s.lastPosition = position + } else if (delta >= 0) { + // seek 前进:不计入收听,但更新基准 + s.lastPosition = position + } else { + // 位置回退:若回到开头,视为单曲循环重新开始,单独记录新一轮 + if (position <= LOOP_RESET_THRESHOLD) { + val prevLoop = s.loopCount + finishSession(PlayEventEndReason.NATURAL_END) + begin(song = currentSong ?: return, loopCount = prevLoop + 1) + } else { + s.lastPosition = position + } + } + } + + fun onEnded() { + finishSession(PlayEventEndReason.NATURAL_END) + } + + fun onError() { + finishSession(PlayEventEndReason.ERROR) + } + + fun stop() { + finishSession(PlayEventEndReason.STOP) + } + + private fun begin(song: Song, loopCount: Int = 1) { + if (!enabled()) return + val startedAt = System.currentTimeMillis() + val source = resolveSource() + val sessionId = resolveSessionId() + session = Session( + song = song, + source = source, + startedAt = startedAt, + sessionId = sessionId, + gapBeforeMs = if (lastEventEndedAt > 0) (startedAt - lastEventEndedAt).coerceAtLeast(0) else null, + loopCount = loopCount + ) + maybeRecordSongAdded(song, sessionId, startedAt) + } + + private fun resolveSource(): PlayEventSource { + val pending = pendingSource + return if (pending != null) { + pendingSource = null + pending + } else { + PlayEventSource.QUEUE_AUTO + } + } + + private fun resolveSessionId(): String { + val now = System.currentTimeMillis() + if (currentSessionId == null || (lastEventEndedAt > 0 && now - lastEventEndedAt > SESSION_GAP_MS)) { + currentSessionId = "sess-" + UUID.randomUUID().toString().replace("-", "") + } + return currentSessionId ?: "sess-" + UUID.randomUUID().toString().replace("-", "") + } + + private fun finishSession(endReason: PlayEventEndReason) { + val s = session ?: return + session = null + if (!enabled()) return + + val endedAt = System.currentTimeMillis() + val durationMs = s.song.duration.coerceAtLeast(0L) + val ratio = if (durationMs > 0) { + (s.listenedMs.toDouble() / durationMs).coerceIn(0.0, 1.0) + } else { + 0.0 + } + val completed = durationMs > 0 && + (ratio >= COMPLETE_THRESHOLD || endReason == PlayEventEndReason.NATURAL_END) + val isEffective = if (durationMs > 0) { + ratio >= EFFECTIVE_THRESHOLD + } else { + s.listenedMs >= MIN_LISTEN_MS_UNKNOWN_DURATION + } + + lastEventEndedAt = endedAt + if (!isEffective || !s.song.valid()) return + + val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply { + timeInMillis = s.startedAt + } + val event = PlayEvent( + schemaVersion = 2, + eventId = "evt-" + UUID.randomUUID().toString().replace("-", ""), + deviceId = deviceIdProvider.deviceId(), + canonicalId = canonicalIdProvider.canonicalId(s.song), + audioId = s.song.takeIf { it.isLocal() }?.id, + startedAt = s.startedAt, + endedAt = endedAt, + durationMs = durationMs, + listenedMs = s.listenedMs, + listenRatio = ratio, + playScore = ratio, + completed = completed, + source = s.source.name, + endReason = endReason.name, + titleSnapshot = s.song.title, + artistSnapshot = s.song.artist, + albumSnapshot = s.song.album, + genreSnapshot = s.song.genre.takeIf { it.isNotBlank() }, + sourceUri = s.song.contentUri.toString(), + contentHash = canonicalIdProvider.contentHash(s.song), + pathHint = canonicalIdProvider.pathHint(s.song), + year = cal.get(Calendar.YEAR), + month = cal.get(Calendar.MONTH) + 1, + day = cal.get(Calendar.DAY_OF_MONTH), + hour = cal.get(Calendar.HOUR_OF_DAY), + weekday = cal.get(Calendar.DAY_OF_WEEK), + // v2 + songId = s.song.id.toString(), + artistId = s.song.artistId.takeIf { it > 0 }?.toString(), + albumId = s.song.albumId.takeIf { it > 0 }?.toString(), + mediaType = songMediaType(s.song), + sessionId = s.sessionId, + gapBeforeMs = s.gapBeforeMs, + loopCount = s.loopCount + ) + + scope.launch { + repository.record(event) + } + } + + private fun maybeRecordSongAdded(song: Song, sessionId: String, addedAt: Long) { + if (!enabled()) return + val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply { + timeInMillis = addedAt + } + val event = PlayEvent( + schemaVersion = 2, + eventId = "evt-" + UUID.randomUUID().toString().replace("-", ""), + deviceId = deviceIdProvider.deviceId(), + eventType = "song_added", + canonicalId = canonicalIdProvider.canonicalId(song), + audioId = song.takeIf { it.isLocal() }?.id, + startedAt = addedAt, + endedAt = addedAt, + durationMs = song.duration.coerceAtLeast(0L), + listenedMs = 0L, + listenRatio = 0.0, + playScore = 0.0, + completed = false, + source = "", + endReason = "", + titleSnapshot = song.title, + artistSnapshot = song.artist, + albumSnapshot = song.album, + genreSnapshot = song.genre.takeIf { it.isNotBlank() }, + sourceUri = song.contentUri.toString(), + contentHash = canonicalIdProvider.contentHash(song), + pathHint = canonicalIdProvider.pathHint(song), + year = cal.get(Calendar.YEAR), + month = cal.get(Calendar.MONTH) + 1, + day = cal.get(Calendar.DAY_OF_MONTH), + hour = cal.get(Calendar.HOUR_OF_DAY), + weekday = cal.get(Calendar.DAY_OF_WEEK), + songId = song.id.toString(), + artistId = song.artistId.takeIf { it > 0 }?.toString(), + albumId = song.albumId.takeIf { it > 0 }?.toString(), + mediaType = songMediaType(song), + sessionId = sessionId, + loopCount = 0 + ) + scope.launch { + repository.recordSongAddedIfAbsent(event) + } + } + + private fun songMediaType(song: Song): String = when { + song.isLocal() -> "local" + song.contentUri.scheme == "smb" -> "smb" + else -> "webdav" + } + + companion object { + private const val MAX_SEEK_GAP = 2500L + private const val LOOP_RESET_THRESHOLD = 1000L + private const val EFFECTIVE_THRESHOLD = 0.1 + private const val COMPLETE_THRESHOLD = 0.9 + private const val MIN_LISTEN_MS_UNKNOWN_DURATION = 1000L + private const val SESSION_GAP_MS = 30L * 60L * 1000L + } +} diff --git a/app/src/main/java/remix/myplayer/ui/nav/AppNav.kt b/app/src/main/java/remix/myplayer/ui/nav/AppNav.kt index 02b55b01..db870a3f 100644 --- a/app/src/main/java/remix/myplayer/ui/nav/AppNav.kt +++ b/app/src/main/java/remix/myplayer/ui/nav/AppNav.kt @@ -52,6 +52,7 @@ import remix.myplayer.ui.screen.SearchScreen import remix.myplayer.ui.screen.SongChooserScreen import remix.myplayer.ui.screen.SupportScreen import remix.myplayer.ui.screen.TagEditScreen +import remix.myplayer.ui.screen.report.AnnualReportScreen import remix.myplayer.ui.screen.crop.CropScreen import remix.myplayer.ui.screen.detail.DetailScreen import remix.myplayer.ui.screen.history.HistoryScreen @@ -82,6 +83,7 @@ const val RouteCustomCoverCrop = "custom_cover_crop" const val RouteTagEditCrop = "tag_edit_crop" const val RouteEq = "eq" const val RouteSupport = "support" +const val RouteAnnualReport = "annual_report" // 存在目标页 NavBackStackEntry.savedStateHandle 里的标志, // 标记该页是从播放页浮窗跳转过来的,退出时需要恢复浮窗 @@ -256,6 +258,10 @@ fun AppNav() { normalAnimatedScreen(RouteSupport) { SupportScreen() } + + normalAnimatedScreen(RouteAnnualReport) { + AnnualReportScreen() + } } } } diff --git a/app/src/main/java/remix/myplayer/ui/screen/report/AnnualReportScreen.kt b/app/src/main/java/remix/myplayer/ui/screen/report/AnnualReportScreen.kt new file mode 100644 index 00000000..7c14eba3 --- /dev/null +++ b/app/src/main/java/remix/myplayer/ui/screen/report/AnnualReportScreen.kt @@ -0,0 +1,290 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package remix.myplayer.ui.screen.report + +import android.content.Intent +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import remix.myplayer.R +import remix.myplayer.ui.theme.LocalTheme +import remix.myplayer.ui.widget.common.CommonAppBar +import remix.myplayer.ui.widget.common.TextPrimary +import remix.myplayer.ui.widget.common.TextSecondary +import remix.myplayer.viewmodel.AnnualReportViewModel +import remix.myplayer.viewmodel.annualReportViewModel + +@Composable +fun AnnualReportScreen() { + val viewModel = annualReportViewModel + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + + LaunchedEffect(Unit) { + viewModel.load() + } + + LaunchedEffect(state.exportIntent) { + val intent = state.exportIntent + if (intent != null) { + context.startActivity(Intent.createChooser(intent, null)) + viewModel.consumeExportIntent() + } + } + + BackHandler { viewModel.consumeExportIntent() } + + Scaffold( + topBar = { CommonAppBar(title = stringResource(R.string.annual_report), actions = emptyList()) }, + containerColor = LocalTheme.current.mainBackground, + ) { contentPadding -> + val report = state.report + if (report == null) { + Column( + modifier = Modifier + .padding(contentPadding) + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + TextSecondary(text = stringResource(R.string.no_play_stat_data), fontSize = 16.sp) + } + return@Scaffold + } + + LazyColumn(modifier = Modifier.padding(contentPadding)) { + item { + if (state.years.size > 1) { + YearSelector( + years = state.years, + selected = state.year ?: report.year, + onSelect = viewModel::selectYear + ) + } + Spacer(Modifier.height(8.dp)) + MetricCard(report) + Spacer(Modifier.height(8.dp)) + TopSongsCard(report) + Spacer(Modifier.height(8.dp)) + TopArtistsCard(report) + Spacer(Modifier.height(8.dp)) + TopAlbumsCard(report) + Spacer(Modifier.height(8.dp)) + DistributionCard(report) + Spacer(Modifier.height(8.dp)) + SourceCard(report) + Spacer(Modifier.height(8.dp)) + ExportRow( + onExport = viewModel::exportJsonl, + onClear = viewModel::clear + ) + Spacer(Modifier.height(24.dp)) + } + } + } +} + +@Composable +private fun YearSelector(years: List, selected: Int, onSelect: (Int) -> Unit) { + LazyRow(contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 12.dp)) { + items(years) { year -> + val isSelected = year == selected + TextPrimary( + text = year.toString(), + fontSize = 16.sp, + color = if (isSelected) LocalTheme.current.primary else LocalTheme.current.textSecondary, + modifier = Modifier + .padding(8.dp) + .clickable { onSelect(year) } + ) + } + } +} + +@Composable +private fun MetricCard(report: remix.myplayer.data.model.report.AnnualReport) { + SectionCard { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + MetricItem(stringResource(R.string.stat_plays), report.plays.toString()) + MetricItem(stringResource(R.string.stat_listen_score), "%.1f".format(report.listenScore)) + MetricItem(stringResource(R.string.stat_completed), report.completedPlays.toString()) + } + Row( + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + MetricItem(stringResource(R.string.stat_listen_ms), formatTime(report.listenMs)) + MetricItem(stringResource(R.string.stat_days), report.listenedDays.toString()) + MetricItem(stringResource(R.string.stat_first_listened), report.firstListenedSongs.toString()) + } + Row( + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + MetricItem(stringResource(R.string.stat_songs), report.distinctSongs.toString()) + MetricItem(stringResource(R.string.stat_artists), report.distinctArtists.toString()) + MetricItem(stringResource(R.string.stat_albums), report.distinctAlbums.toString()) + } + } +} + +@Composable +private fun MetricItem(label: String, value: String) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + TextPrimary(text = value, fontSize = 20.sp) + TextSecondary(text = label, fontSize = 12.sp) + } +} + +@Composable +private fun TopSongsCard(report: remix.myplayer.data.model.report.AnnualReport) { + SectionCard(title = stringResource(R.string.stat_top_songs)) { + report.topSongs.forEachIndexed { index, item -> + RankRow(index + 1, item.title, item.artist, item.listenedMs, item.plays) + } + } +} + +@Composable +private fun TopArtistsCard(report: remix.myplayer.data.model.report.AnnualReport) { + SectionCard(title = stringResource(R.string.stat_top_artists)) { + report.topArtists.forEachIndexed { index, item -> + RankRow(index + 1, item.name, "", item.listenedMs, item.plays) + } + } +} + +@Composable +private fun TopAlbumsCard(report: remix.myplayer.data.model.report.AnnualReport) { + SectionCard(title = stringResource(R.string.stat_top_albums)) { + report.topAlbums.forEachIndexed { index, item -> + RankRow(index + 1, item.name, "", item.listenedMs, item.plays) + } + } +} + +@Composable +private fun RankRow(rank: Int, title: String, subtitle: String, listenMs: Long, plays: Int) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TextPrimary(text = "$rank", fontSize = 16.sp, color = LocalTheme.current.secondary) + Column(modifier = Modifier.padding(start = 12.dp).weight(1f)) { + TextPrimary(text = title, fontSize = 15.sp) + if (subtitle.isNotBlank()) { + TextSecondary(text = subtitle, fontSize = 12.sp) + } + } + Column(horizontalAlignment = Alignment.End) { + TextPrimary(text = formatTime(listenMs), fontSize = 13.sp) + TextSecondary(text = "$plays x", fontSize = 12.sp) + } + } +} + +@Composable +private fun DistributionCard(report: remix.myplayer.data.model.report.AnnualReport) { + SectionCard(title = stringResource(R.string.stat_months)) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + report.monthDistribution.forEach { m -> + Column(horizontalAlignment = Alignment.CenterHorizontally) { + TextPrimary(text = m.month.toString(), fontSize = 14.sp) + TextSecondary(text = m.plays.toString(), fontSize = 12.sp) + } + } + } + } +} + +@Composable +private fun SourceCard(report: remix.myplayer.data.model.report.AnnualReport) { + SectionCard(title = stringResource(R.string.stat_sources)) { + report.sourceBreakdown.forEach { s -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + TextPrimary(text = s.source, fontSize = 14.sp) + TextSecondary(text = s.plays.toString(), fontSize = 13.sp) + } + } + } +} + +@Composable +private fun ExportRow(onExport: () -> Unit, onClear: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + SectionButton(text = stringResource(R.string.export_jsonl), onClick = onExport) + SectionButton(text = stringResource(R.string.clear_play_stats), onClick = onClear) + } +} + +@Composable +private fun SectionButton(text: String, onClick: () -> Unit) { + androidx.compose.material3.Text( + text = text, + color = LocalTheme.current.primary, + fontSize = 14.sp, + modifier = Modifier + .padding(vertical = 12.dp) + .clickable { onClick() } + ) +} + +@Composable +private fun SectionCard(title: String? = null, content: @Composable () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp) + ) { + if (title != null) { + TextPrimary(text = title, fontSize = 16.sp) + Spacer(Modifier.height(6.dp)) + } + content() + } +} + +private fun formatTime(ms: Long): String { + val minutes = ms / 60000.0 + return if (minutes < 60) { + "%.1f min".format(minutes) + } else { + "%.1f h".format(minutes / 60.0) + } +} diff --git a/app/src/main/java/remix/myplayer/ui/screen/setting/SettingScreen.kt b/app/src/main/java/remix/myplayer/ui/screen/setting/SettingScreen.kt index 6e37ff8c..29c293c3 100644 --- a/app/src/main/java/remix/myplayer/ui/screen/setting/SettingScreen.kt +++ b/app/src/main/java/remix/myplayer/ui/screen/setting/SettingScreen.kt @@ -17,6 +17,7 @@ import remix.myplayer.R import remix.myplayer.helper.EQHelper import remix.myplayer.ui.nav.LocalNavController import remix.myplayer.ui.nav.RouteAbout +import remix.myplayer.ui.nav.RouteAnnualReport import remix.myplayer.ui.nav.RouteEq import remix.myplayer.ui.nav.RouteSettingDetail import remix.myplayer.ui.screen.setting.logic.color.BlackThemeLogic @@ -47,6 +48,7 @@ import remix.myplayer.ui.screen.setting.logic.lyric.TranslationLogic import remix.myplayer.ui.screen.setting.logic.notification.ClassicNotifyLogic import remix.myplayer.ui.screen.setting.logic.notification.NotifyBackgroundLogic import remix.myplayer.ui.screen.setting.logic.other.ClearCacheLogic +import remix.myplayer.ui.screen.setting.logic.other.PlayEventSettingLogic import remix.myplayer.ui.screen.setting.logic.play.AutoPlayLogic import remix.myplayer.ui.screen.setting.logic.play.DecoderModeLogic import remix.myplayer.ui.screen.setting.logic.play.IgnoreAudioFocusLogic @@ -303,5 +305,11 @@ private fun OtherPreferenceItems() { }, title = stringResource(R.string.check_update)) } + PlayEventSettingLogic() + + ArrowPreference(R.string.annual_report) { + nav.navigate(RouteAnnualReport) + } + ClearCacheLogic() } diff --git a/app/src/main/java/remix/myplayer/ui/screen/setting/logic/other/PlayEventSettingLogic.kt b/app/src/main/java/remix/myplayer/ui/screen/setting/logic/other/PlayEventSettingLogic.kt new file mode 100644 index 00000000..35693f92 --- /dev/null +++ b/app/src/main/java/remix/myplayer/ui/screen/setting/logic/other/PlayEventSettingLogic.kt @@ -0,0 +1,47 @@ +package remix.myplayer.ui.screen.setting.logic.other + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.res.stringResource +import kotlinx.coroutines.launch +import remix.myplayer.R +import remix.myplayer.ui.dialog.NormalDialog +import remix.myplayer.ui.dialog.rememberDialogState +import remix.myplayer.ui.screen.setting.Preference +import remix.myplayer.ui.screen.setting.SwitchPreference +import remix.myplayer.viewmodel.settingViewModel + +/** + * 播放统计设置:开关采集 + 清除统计数据。 + */ +@Composable +fun PlayEventSettingLogic() { + val settingVM = settingViewModel + val scope = rememberCoroutineScope() + val clearState = rememberDialogState() + + SwitchPreference( + title = stringResource(R.string.play_stat_enabled), + content = stringResource(R.string.play_stat_enabled_desc), + checked = settingVM.settingPrefs.playEventEnabled, + onCheckedChange = { + settingVM.settingPrefs.playEventEnabled = it + } + ) + + Preference( + onClick = { clearState.show() }, + title = stringResource(R.string.clear_play_stats) + ) + + NormalDialog( + dialogState = clearState, + titleRes = R.string.confirm_clear_play_stats, + onPositive = { + scope.launch { + settingVM.clearPlayEvents() + clearState.dismiss() + } + } + ) +} diff --git a/app/src/main/java/remix/myplayer/ui/widget/popup/LibraryItemPopup.kt b/app/src/main/java/remix/myplayer/ui/widget/popup/LibraryItemPopup.kt index 30adfe1e..001d4b66 100644 --- a/app/src/main/java/remix/myplayer/ui/widget/popup/LibraryItemPopup.kt +++ b/app/src/main/java/remix/myplayer/ui/widget/popup/LibraryItemPopup.kt @@ -26,11 +26,13 @@ import remix.myplayer.R import remix.myplayer.data.db.room.entity.PlayList import remix.myplayer.data.model.audio.APlayerModel import remix.myplayer.data.model.audio.Album +import remix.myplayer.data.model.audio.PlayEventSource import remix.myplayer.data.model.audio.Artist import remix.myplayer.data.model.audio.Folder import remix.myplayer.data.model.audio.Genre import remix.myplayer.data.model.audio.type import remix.myplayer.service.Command +import remix.myplayer.service.MusicService import remix.myplayer.service.MusicService.Companion.EXTRA_POSITION import remix.myplayer.service.MusicServiceRemote.setPlayQueue import remix.myplayer.ui.nav.LocalNavController @@ -117,6 +119,10 @@ fun LibraryItemDropdownMenu( setPlayQueue( songs, makeCmdIntent(Command.PLAY_AT) .putExtra(EXTRA_POSITION, 0) + .putExtra( + MusicService.EXTRA_PLAY_EVENT_SOURCE, + (if (model is PlayList) PlayEventSource.PLAYLIST_CLICK else PlayEventSource.LIBRARY_CLICK).value + ) ) } // 添加到播放队列 diff --git a/app/src/main/java/remix/myplayer/util/CanonicalIdProvider.kt b/app/src/main/java/remix/myplayer/util/CanonicalIdProvider.kt new file mode 100644 index 00000000..c545958c --- /dev/null +++ b/app/src/main/java/remix/myplayer/util/CanonicalIdProvider.kt @@ -0,0 +1,35 @@ +package remix.myplayer.util + +import remix.myplayer.data.model.audio.Song +import java.security.MessageDigest +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 生成跨平台稳定的歌曲 canonicalId、contentHash 与 pathHint。 + * + * 本地歌曲优先使用规范化元数据哈希;在线歌曲使用来源 URI 哈希。 + */ +@Singleton +class CanonicalIdProvider @Inject constructor() { + + fun canonicalId(song: Song): String { + val base = when (song) { + is Song.Local -> listOf(song.title, song.artist, song.album, song.duration.toString()) + .joinToString("|") { it.trim().lowercase() } + is Song.Remote -> song.data + } + return "sha256:" + sha256(base) + } + + fun contentHash(song: Song): String? = "sha256:" + sha256(song.data) + + fun pathHint(song: Song): String? = + song.data.substringAfterLast('/').takeIf { it.isNotEmpty() } + + private fun sha256(input: String): String { + val digest = MessageDigest.getInstance("SHA-256") + val bytes = digest.digest(input.toByteArray(Charsets.UTF_8)) + return bytes.joinToString("") { "%02x".format(it) } + } +} diff --git a/app/src/main/java/remix/myplayer/util/DeviceIdProvider.kt b/app/src/main/java/remix/myplayer/util/DeviceIdProvider.kt new file mode 100644 index 00000000..2db87db3 --- /dev/null +++ b/app/src/main/java/remix/myplayer/util/DeviceIdProvider.kt @@ -0,0 +1,32 @@ +package remix.myplayer.util + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 生成并持久化一个稳定的设备 ID,用于跨端事件标识。 + */ +@Singleton +class DeviceIdProvider @Inject constructor( + @ApplicationContext context: Context +) { + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + private val cached: String by lazy { + prefs.getString(KEY_DEVICE_ID, null) ?: generate().also { + prefs.edit().putString(KEY_DEVICE_ID, it).apply() + } + } + + fun deviceId(): String = cached + + private fun generate(): String = "android-" + UUID.randomUUID().toString().replace("-", "") + + companion object { + private const val PREFS_NAME = "play_event" + private const val KEY_DEVICE_ID = "device_id" + } +} diff --git a/app/src/main/java/remix/myplayer/viewmodel/AnnualReportViewModel.kt b/app/src/main/java/remix/myplayer/viewmodel/AnnualReportViewModel.kt new file mode 100644 index 00000000..dd264033 --- /dev/null +++ b/app/src/main/java/remix/myplayer/viewmodel/AnnualReportViewModel.kt @@ -0,0 +1,167 @@ +package remix.myplayer.viewmodel + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.core.content.FileProvider +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import remix.myplayer.data.db.room.entity.PlayEvent +import remix.myplayer.data.model.report.AnnualReport +import remix.myplayer.data.model.report.PlayEventExport +import remix.myplayer.data.model.report.TrackExport +import remix.myplayer.repo.PlayEventRepository +import java.io.File +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale +import java.util.TimeZone +import javax.inject.Inject + +@HiltViewModel +class AnnualReportViewModel @Inject constructor( + private val playEventRepository: PlayEventRepository, + @param:ApplicationContext private val context: Context, +) : ViewModel() { + + private val _state = MutableStateFlow(ReportUiState()) + val state = _state.asStateFlow() + + fun load() { + viewModelScope.launch { + val years = playEventRepository.availableYears() + val year = _state.value.year.takeIf { it in years } ?: (years.firstOrNull() ?: currentYear()) + val report = playEventRepository.annualReport(year) + _state.value = ReportUiState( + years = years, + year = year, + report = report, + loading = false + ) + } + } + + fun selectYear(year: Int) { + if (_state.value.year == year) return + _state.value = _state.value.copy(year = year, loading = true) + viewModelScope.launch { + _state.value = _state.value.copy( + report = playEventRepository.annualReport(year), + loading = false + ) + } + } + + fun clear() { + viewModelScope.launch { + playEventRepository.clear() + load() + } + } + + fun exportJsonl() { + viewModelScope.launch { + val year = _state.value.year ?: return@launch + val events = playEventRepository.eventsOf(year) + if (events.isEmpty()) return@launch + val intent = withContext(Dispatchers.IO) { + writeAndShare(events) + } + _state.value = _state.value.copy( + exportIntent = intent, + exportRequestId = _state.value.exportRequestId + 1 + ) + } + } + + fun consumeExportIntent() { + _state.value = _state.value.copy(exportIntent = null) + } + + private suspend fun writeAndShare(events: List): Intent? = withContext(Dispatchers.IO) { + val year = _state.value.year ?: return@withContext null + val dir = File(context.getExternalFilesDir(null) ?: context.filesDir, "play_event") + dir.mkdirs() + val file = File(dir, "play-events-%d.jsonl".format(year)) + val json = Json { encodeDefaults = true; ignoreUnknownKeys = true } + file.bufferedWriter().use { writer -> + events.forEach { event -> + writer.write(json.encodeToString(PlayEventExport.serializer(), event.toExport())) + writer.newLine() + } + } + try { + val uri = FileProvider.getUriForFile(context, context.packageName + ".fileprovider", file) + Intent(Intent.ACTION_SEND) + .setType("application/jsonl") + .putExtra(Intent.EXTRA_STREAM, uri) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (e: Exception) { + null + } + } + + private fun PlayEvent.toExport(): PlayEventExport { + return PlayEventExport( + schemaVersion = schemaVersion, + eventId = eventId, + deviceId = deviceId, + eventType = eventType, + startedAt = formatIso(startedAt), + endedAt = formatIso(endedAt), + durationMs = durationMs, + listenedMs = listenedMs, + listenRatio = listenRatio, + playScore = playScore, + completed = completed, + source = source, + endReason = endReason, + track = TrackExport( + canonicalId = canonicalId, + title = titleSnapshot, + artist = artistSnapshot, + album = albumSnapshot, + durationMs = durationMs + ), + songId = songId, + artistId = artistId, + albumId = albumId, + genreId = genreId, + playlistId = playlistId, + mediaType = mediaType, + sessionId = sessionId, + gapBeforeMs = gapBeforeMs, + gapAfterMs = gapAfterMs, + loopCount = loopCount, + outputDevice = outputDevice, + isForeground = isForeground, + decoder = decoder + ) + } + + private fun formatIso(epochMs: Long): String { + val fmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + return fmt.format(epochMs) + } + + private fun currentYear(): Int = Calendar.getInstance().get(Calendar.YEAR) +} + +data class ReportUiState( + val years: List = emptyList(), + val year: Int? = null, + val report: AnnualReport? = null, + val loading: Boolean = true, + val exportIntent: Intent? = null, + val exportRequestId: Int = 0 +) diff --git a/app/src/main/java/remix/myplayer/viewmodel/ViewModelProvider.kt b/app/src/main/java/remix/myplayer/viewmodel/ViewModelProvider.kt index a5461b74..fb548402 100644 --- a/app/src/main/java/remix/myplayer/viewmodel/ViewModelProvider.kt +++ b/app/src/main/java/remix/myplayer/viewmodel/ViewModelProvider.kt @@ -31,6 +31,9 @@ val LocalSmbViewModel = compositionLocalOf { val LocalPlaybackViewModel = compositionLocalOf { error("PlaybackViewModel not provided") } +val LocalAnnualReportViewModel = compositionLocalOf { + error("AnnualReportViewModel not provided") +} @Composable @@ -43,7 +46,8 @@ fun ProvideViewModels(content: @Composable () -> Unit) { LocalTimerViewModel provides activityViewModel(), LocalWebDavViewModel provides activityViewModel(), LocalSmbViewModel provides activityViewModel(), - LocalPlaybackViewModel provides activityViewModel() + LocalPlaybackViewModel provides activityViewModel(), + LocalAnnualReportViewModel provides activityViewModel() ) { content() } @@ -88,3 +92,8 @@ val smbViewModel: SmbViewModel @Composable @ReadOnlyComposable get() = LocalSmbViewModel.current + +val annualReportViewModel: AnnualReportViewModel + @Composable + @ReadOnlyComposable + get() = LocalAnnualReportViewModel.current diff --git a/app/src/main/java/remix/myplayer/viewmodel/settings/SettingViewModel.kt b/app/src/main/java/remix/myplayer/viewmodel/settings/SettingViewModel.kt index 4a6fb312..d88af8a2 100644 --- a/app/src/main/java/remix/myplayer/viewmodel/settings/SettingViewModel.kt +++ b/app/src/main/java/remix/myplayer/viewmodel/settings/SettingViewModel.kt @@ -30,6 +30,7 @@ import remix.myplayer.lyric.LyricManager import remix.myplayer.lyric.LyricManager.Companion.ACTION_LYRIC import remix.myplayer.lyric.LyricManager.Companion.CHANGE_LYRIC import remix.myplayer.lyric.LyricManager.Companion.EXTRA_LYRIC +import remix.myplayer.repo.PlayEventRepository import remix.myplayer.repo.SongRepository import remix.myplayer.repo.usecase.DeleteSongUseCase import remix.myplayer.ui.activity.base.BaseActivity @@ -59,6 +60,9 @@ class SettingViewModel @Inject constructor( @Inject lateinit var deleteSongUseCase: DeleteSongUseCase + @Inject + lateinit var playEventRepository: PlayEventRepository + private val _currentLibrary = MutableStateFlow(Library(TAG_SONG)) val currentLibrary = _currentLibrary.asStateFlow() @@ -542,6 +546,12 @@ class SettingViewModel @Inject constructor( ) } + fun clearPlayEvents() { + viewModelScope.launch { + playEventRepository.clear() + } + } + fun clearCache(context: Context, andThen: () -> Unit = {}) { viewModelScope.launch(Dispatchers.IO) { context.cacheDir.deleteRecursively() diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 80d39a6d..2cefe6da 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -565,4 +565,26 @@ SMB 客户端 选择图片 清除封面 + + 年度听歌报告 + 暂无收听数据 + 播放次数 + 收听积分 + 完整播放 + 收听时长 + 听歌天数 + 首次收听 + 歌曲数 + 歌手数 + 专辑数 + TOP 歌曲 + TOP 歌手 + TOP 专辑 + 月份 + 来源 + 导出 JSONL + 清除播放统计数据 + 播放统计 + 记录播放事件以生成年度报告 + 确定清除所有播放统计数据? diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fafbb72a..9617411e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -583,4 +583,26 @@ Play from this line SMB Client + + Annual Listening Report + No listening data yet + Plays + Listen Score + Completed + Listened + Days + First Listened + Songs + Artists + Albums + Top Songs + Top Artists + Top Albums + Months + Sources + Export JSONL + Clear listening stats + Listening stats + Record play events for the annual report + Clear all listening stats? From 00030bd31f46cf208520acb729d1af62303fcf94 Mon Sep 17 00:00:00 2001 From: GuiltySpark-343 <1875217517@qq.com> Date: Sat, 5 Sep 2026 23:20:44 +0800 Subject: [PATCH 4/4] Add play event design, protocol and sample docs --- docs/play-event-design.md | 229 +++++++++++++++++++++ docs/play-event-execution.md | 130 ++++++++++++ docs/play-event-protocol-spec.md | 60 ++++++ docs/samples/play-events-2026.sample.jsonl | 6 + 4 files changed, 425 insertions(+) create mode 100644 docs/play-event-design.md create mode 100644 docs/play-event-execution.md create mode 100644 docs/play-event-protocol-spec.md create mode 100644 docs/samples/play-events-2026.sample.jsonl diff --git a/docs/play-event-design.md b/docs/play-event-design.md new file mode 100644 index 00000000..b4221a32 --- /dev/null +++ b/docs/play-event-design.md @@ -0,0 +1,229 @@ +# 播放事件功能设计(统一文档 · 已合并 v2 升级) + +> 本文档是统一的“播放事件 + 年度报告”设计文档,已合并 v2。v1 为初版,v2 见第 10 章,跨端说明见第 11 章。跨平台协议以本文档为准。 + +## 1. 目标 + +新增本地播放事件记录能力,为年度听歌报告提供可靠的原始数据,同时保留现有 `History` 表和最近播放功能。Room 只是 Android 端的存储方式,事件从第一版开始遵循跨平台协议,可导出为 JSON/JSONL 并被电脑端直接导入。 + +## 2. 数据模型 + +新增 Room Entity:`PlayEvent`。 + +| 字段 | 说明 | +|---|---| +| `schemaVersion` | 事件协议版本 | +| `eventId` | 全局唯一事件 ID,用于同步去重 | +| `deviceId` | 产生事件的设备 ID | +| `eventType` | 第一版固定为 `playback` | +| `canonicalId` | 跨平台稳定歌曲 ID | +| `audioId` | Android 本地歌曲 ID,仅作本地映射 | +| `startedAt` | 开始播放时间,内部可用 Long | +| `endedAt` | 播放结束时间 | +| `durationMs` | 歌曲总时长 | +| `listenedMs` | 实际累计收听时长 | +| `listenRatio` | 收听比例 | +| `playScore` | 统计使用的收听积分 | +| `completed` | 是否完整播放 | +| `source` | 播放来源 | +| `endReason` | 结束原因 | +| `titleSnapshot` | 播放时的歌曲名 | +| `artistSnapshot` | 播放时的歌手 | +| `albumSnapshot` | 播放时的专辑 | +| `genreSnapshot` | 播放时的流派,可选 | +| `sourceUri` | 歌曲来源 URI,可选 | +| `contentHash` | 音频内容哈希,可选 | +| `pathHint` | 相对路径提示,可选 | + +歌曲实体或仓库还应维护 `addedAt`、`firstPlayedAt`、`lastPlayedAt`。远程歌曲可能没有稳定本地 ID,因此事件中必须保留 `canonicalId` 和歌曲信息快照。 + +## 3. 跨平台协议 + +```json +{ + "schemaVersion": 1, + "eventId": "01JABCD1234567890", + "deviceId": "android-xxxxxxxx", + "eventType": "playback", + "startedAt": "2026-08-28T08:30:00Z", + "endedAt": "2026-08-28T08:33:45Z", + "durationMs": 245000, + "listenedMs": 225000, + "listenRatio": 0.9184, + "playScore": 0.9184, + "completed": true, + "source": "SEARCH_CLICK", + "endReason": "NATURAL_END", + "track": { + "canonicalId": "sha256:...", + "title": "歌曲名", + "artist": "歌手", + "album": "专辑", + "durationMs": 245000 + } +} +``` + +规范要求: + +- `canonicalId` 是跨平台主标识,不能使用 Android `audioId`。 +- 本地文件优先使用内容哈希或规范化元数据哈希;在线来源优先使用来源系统稳定 ID。 +- `sourceUri`、`contentHash`、`pathHint` 是辅助匹配字段。 +- 导出协议统一使用 UTC ISO 8601,例如 `2026-08-28T08:30:00Z`。 +- `listenRatio`、`playScore`、`completed` 和日期分组字段属于派生值,原始依据是播放时间和收听时长。 +- `eventId`、`deviceId` 和 `schemaVersion` 必须存在。 + +优先支持 JSONL,每行一条完整事件,例如 `play-events-2026.jsonl`。同步时按 `eventId` 合并,重复事件直接忽略;事件不可变,修正应产生新事件或更高协议版本。 + +预留事件类型:`playback`、`song_added`、`song_removed`、`metadata_changed`。第一版只实现 `playback`。 + +## 4. 播放会话生命周期 + +1. 播放器进入 `STATE_READY` 且开始播放时创建内存中的播放会话。 +2. 播放、暂停、恢复时累计真实播放时间。 +3. 切歌、播放结束、播放错误、服务停止或应用退出时结束会话。 +4. 通过播放位置变化计算 `listenedMs`,不能直接使用最终播放位置,避免拖动进度条被误判为听完。 +5. `listenedMs / durationMs < 0.1` 时丢弃事件。 +6. 达到有效阈值后保存事件,并计算 `playScore`。 + +暂停后继续播放属于同一事件。循环播放时,每一轮单独记录。总时长未知时只保存实际收听时长,不计算比例。 + +## 5. 统计规则 + +```text +listenRatio < 0.1 -> 忽略 +0.1 <= ratio < 1 -> playScore = ratio +ratio >= 0.9 或自然结束 -> completed = true +``` + +“有效播放次数”是有效事件数量;“收听积分”是所有有效事件的 `playScore` 之和;“完整播放数”是 `completed = true` 的事件数量。 + +## 6. 播放来源 + +创建播放队列或调用播放入口时,将来源传递到 `MusicService` / `ExoPlayback`: + +- `SEARCH_CLICK` +- `LIBRARY_CLICK` +- `PLAYLIST_CLICK` +- `QUEUE_AUTO` +- `RESUME` +- `EXTERNAL_INTENT` + +## 7. 数据库与同步 + +- 提升 `AppDatabase` 版本并增加迁移。 +- 新增 `PlayEventDao`,支持按年份、歌曲、歌手、专辑、来源和日期范围查询。 +- 对 `startedAt`、`canonicalId`、`audioId`、`source` 建立索引。 +- 统计页面只查询聚合结果,避免一次加载全部事件。 +- 保留现有 `History`,避免影响历史页面和最近播放逻辑。 +- 增加 JSONL 导出;导入和跨设备同步可作为后续阶段。 + +## 8. 实现阶段 + +### 第一阶段:采集 + +- 新增 Entity、DAO、Repository 和数据库迁移。 +- 生成稳定的 `deviceId`,并为事件生成全局唯一 `eventId`。 +- 按协议保存 `canonicalId`、UTC 时间和歌曲快照。 +- 在 `MusicService` 接入播放会话计时和主动播放来源。 + +### 第二阶段:统计 + +- 增加年度聚合查询。 +- 计算播放次数、收听积分、完整播放数、时长、天数和时段分布。 +- 增加新增歌曲和首次收听统计。 + +### 第三阶段:界面与互操作 + +- 增加年度报告入口和年份选择。 +- 展示核心数字、排行和播放来源对比。 +- 增加关闭统计、清除统计和 JSONL 导出能力。 +- 电脑端支持按 `eventId` 幂等导入。 + +## 9. 测试重点 + +- 播放不足 10% 时不会产生事件。 +- 播放 50% 时产生 `playScore = 0.5`。 +- 播放完成时只产生一个完整事件。 +- 暂停恢复不会重复计算时间。 +- 拖动进度条不会虚增 `listenedMs`。 +- 随机播放和主动点击的 `source` 正确。 +- 数据库升级不会影响既有 `History` 数据。 +- 导出的 JSONL 能被电脑端逐行读取,并按 `eventId` 幂等合并。 + +--- + +## 10. 版本 2 数据层升级(已落地,含本次扩充) + +> 本节为 v2 升级。**只动数据层**:存储结构、采集字段、导出协议、可登记的事件类型;报告层可视化/分享等后续再做。 + +### 10.1 新增字段(全部可选,向前兼容) + +| 字段 | 类型 | 用途 | +|---|---|---| +| `songId` | String? | 播放器内部歌曲 ID | +| `artistId` | String? | 歌手稳定 ID | +| `albumId` | String? | 专辑稳定 ID | +| `genreId` | String? | 流派稳定 ID | +| `playlistId` | String? | 来源歌单 ID | +| `mediaType` | String? | `local`/`webdav`/`smb`/`online` | +| `sessionId` | String? | 连续听歌会话 ID(长时间间隔后重新生成) | +| `gapBeforeMs` | Long? | 距上一首结束的间隔 | +| `gapAfterMs` | Long? | 距下一首开始的间隔(可由相邻事件推导) | +| `loopCount` | Int | 单曲循环第几轮(默认 1) | +| `outputDevice` | String? | 输出设备(耳机/外放/蓝牙;可选) | +| `isForeground` | Boolean? | 是否前台播放(可选) | +| `decoder` | String? | 解码器/音质(可选) | + +### 10.2 新增事件类型 `song_added` + +- `eventType = "song_added"`:表示歌曲进入报告数据(用于“年度新增歌曲”)。 +- 字段:`canonicalId`、快照、`mediaType`、`artistId/albumId`、`startedAt=endedAt=addedAt`、`durationMs`;`listenedMs/ratio/playScore=0`、`completed=false`、`source`/`endReason` 空串。 +- 去重:同一 `canonicalId` 只落一条。 +- `addedAt` 优先取“入库时间”,取不到取“首次出现时间”。 + +### 10.3 采集规则(v2 补充) + +- `sessionId`:两次听歌间隔 >30 分钟重新生成。 +- 单曲循环:`loopCount` 自增,每轮单独记录。 +- 首次播放某歌时补登记 `song_added`(按 `canonicalId` 去重)。 +- `mediaType`:本地=local,smb 来源=smb,其余远程=webdav。 + +### 10.4 数据库与迁移 + +- Room 版本 **8 → 9**;迁移为逐列 `ALTER TABLE play_events ADD COLUMN`(新列可空;`loopCount` 为 `NOT NULL DEFAULT 0`),并新增 `eventType` 索引。 +- `schemaVersion` 升为 **2**;旧 v1 事件仍可读,缺失字段按空/默认处理。 +- **播放类聚合只统计 `eventType='playback'`**,避免 `song_added` 污染报告数字;新增 `countSongAdded`、`countAddedSongs`。 + +### 10.5 统计口径新增点(数据层已支持) + +- 年度新增歌曲数:`eventType='song_added'` 且 `year=该年` 去重 `canonicalId`。 +- 新增收听表现:新增歌曲在 `playback` 事件的次数/积分。 +- 单曲循环王:`loopCount>1` 聚合。 +- 连续听歌天数/最长连续:按 `day` 与 `sessionId` 推导。 +- 深夜听歌:`hour \in [0,6)`。 +- 跳过率/完整率:`endReason \in (SKIP_TO_NEXT, SKIP_TO_PREVIOUS)` / `completed=true`。 +- 重复 vs 探索:有效事件数 / 去重 `canonicalId`;首次收听用 `song_added` 或 `MIN(startedAt)`。 +- 年度第一首/最后一首:`MIN/MAX(startedAt)`。 + +### 10.6 导出(v2) + +- `play-events-.jsonl`;每行一个 JSON;时间 UTC ISO 8601;`schemaVersion=2`。 +- 顶层可选字段:`songId/artistId/albumId/genreId/playlistId/mediaType/sessionId/gapBeforeMs/gapAfterMs/loopCount/outputDevice/isForeground/decoder`。 +- 示例:`docs/samples/play-events-2026.sample.jsonl`(含一条 `song_added`)。 + +### 10.7 相关文档 + +- 跨平台协议规范:`docs/play-event-protocol-spec.md` +- 导出样例:`docs/samples/play-events-2026.sample.jsonl` + +--- + +## 11. 跨端与实现说明 + +- 本文档是**唯一的设计/规范依据**。 +- `deviceId` 前缀:Android=`android-`,PC=`pc-`;跨端可按 `deviceId` 归并。 +- 存储:PC 端可用 SQLite 或 PostgreSQL;`eventId` 作唯一键,导入/合并按 `eventId` `ON CONFLICT DO NOTHING`;建议对 `canonicalId`、`year`、`source`、`startedAt` 建索引。 +- `loopCount`、`gapAfterMs` 等可由相邻事件时间推导;`playlistId`/`outputDevice`/`isForeground`/`decoder` 为可选,PC 端可自行填充。 +- **PC 端具体实现步骤(接入点、状态机、存储脚本)由后续基于 PC 端代码单独生成的文档提供**,不在此设计文档内。 + diff --git a/docs/play-event-execution.md b/docs/play-event-execution.md new file mode 100644 index 00000000..1b9a0ff3 --- /dev/null +++ b/docs/play-event-execution.md @@ -0,0 +1,130 @@ +# 播放事件执行文档 + +> 本文档基于 `play-event-design.md`,把设计落地为 APlayer 具体代码改动,供实现与评审使用。 +> 目标:让播放器在运行中自动记录一次次的“播放事件”,为年度听歌报告提供跨平台可导出的原始数据,同时不破坏现有 `History`/最近播放。 + +## 0. 现状分析与实现策略 + +- 播放核心在 `service/MusicService.kt`,底层播放实现是 `service/playback/ExoPlayback.kt`。 +- ExoPlayback 已提供 `onIsPlayingChanged`、`onPositionChange`(每 100ms)、`onItemTransition`、`onEnded`、`onError` 等回调,足够支撑“会话计时”。 +- 播放器用 `Playback.PlayerCallback` 把事件回调给 `MusicService`。因此会话记录器放在 `MusicService` 的成员层最合适,既能拿到歌曲快照,也能用 `PlayEventRepository` 落库。 +- 播放来源(source)通过 `MusicService.EXTRA_PLAY_EVENT_SOURCE` 随 Intent 传递,在创建播放队列的入口写入;为减少改动面,为未显式标注的命令提供默认映射。 + +## 1. 数据模型(Phase 1 采集) + +### 1.1 新增文件 + +| 文件 | 内容 | +|---|---| +| `data/model/audio/PlayEventSource.kt` | 播放来源枚举,含 `value:Int` 与 `fromValue()` | +| `data/model/audio/PlayEventEndReason.kt` | 会话结束原因枚举 | +| `data/db/room/entity/PlayEvent.kt` | Room 实体 `play_events` | +| `data/db/room/dao/PlayEventDao.kt` | 插入/查询/聚合/清除 | +| `repo/PlayEventRepository.kt` | 接口 + `PlayEventRepoImpl` | +| `service/playback/PlayEventRecorder.kt` | 会话计时与落库逻辑 | +| `util/DeviceIdProvider.kt` | 稳定 deviceId | +| `util/CanonicalIdProvider.kt` | canonicalId 生成 | + +### 1.2 `PlayEvent` 字段 + +与设计一致,另加 `year` 便于按年索引: + +```kotlin +@Entity(tableName = "play_events") +data class PlayEvent( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val schemaVersion: Int = 1, + val eventId: String, // 全局唯一,用于跨端去重 + val deviceId: String, + val eventType: String = "playback", + val canonicalId: String, // 跨平台稳定主键 + val audioId: Long?, // 仅本地映射 + val startedAt: Long, // UTC epoch ms + val endedAt: Long, + val durationMs: Long, + val listenedMs: Long, + val listenRatio: Double, + val playScore: Double, // = min(listenedMs/durationMs, 1.0) + val completed: Boolean, + val source: String, // PlayEventSource.name + val endReason: String, // PlayEventEndReason.name + val titleSnapshot: String, + val artistSnapshot: String, + val albumSnapshot: String, + val genreSnapshot: String?, + val sourceUri: String?, + val contentHash: String?, + val pathHint: String?, + val year: Int, // 由 startedAt 派生,便于查询 + val month: Int, + val day: Int, + val hour: Int, + val weekday: Int +) +``` + +### 1.3 迁移 + +- `AppDatabase.VERSION` 从 `7` 升到 `8`。 +- `DbMigrations.migration7to8`:`CREATE TABLE IF NOT EXISTS `play_events`(...)`(字段与 Room 生成一致,保证 schema 校验通过)。 +- `buildDatabase` 添加 `addMigrations(migration7to8)`。 +- `@Database(entities=[...PlayEvent::class])`、`abstract fun playEventDao()`。 + +### 1.4 采集规则 + +- 会话开始:歌曲开始播放(`onIsPlayingChanged(true)`)时,若当前歌曲与上次不同则新建会话。 +- 会话累计:在 `onPositionChange`(每 100ms)且正在播放时累加 `position` 增量;增量异常(>2500ms 或 <0)视为 seek,不累计,避免拖动虚增。 +- 会话结束:切歌/自然结束/错误/暂停后切歌/服务停止时最终化。 +- 丢弃:`listenedMs / durationMs < 0.1` 不落库。 +- 完整:`ratio >= 0.9` 或自然结束 → `completed = true`。 +- `playScore = min(ratio, 1.0)`。 + +## 2. 来源传递(source) + +- `MusicService` 增加常量 `EXTRA_PLAY_EVENT_SOURCE`。 +- `MusicService` 内持有一个 `PlayEventRecorder`,其 `pendingSource` 由入口方法设置。 +- 映射(无显式 source 时的默认值): + - `PLAY_TEMP` → `SEARCH_CLICK`(显式标注例外:外部打开 → `EXTERNAL_INTENT`) + - `PLAY_AT` → `LIBRARY_CLICK` + - 歌单/我的收藏 → `PLAYLIST_CLICK`(在 `LibraryItemPopup` 显式标注) + - 恢复上次播放 → `RESUME` + - 队列自动切换 → `QUEUE_AUTO` +- 显式标注的入口: + - `SearchScreen`:`PLAY_TEMP` 加 `EXTRA_PLAY_EVENT_SOURCE=SEARCH_CLICK` + - `PlayFromUriUseCase`:`PLAY_TEMP` 加 `EXTRA_PLAY_EVENT_SOURCE=EXTERNAL_INTENT` + - `LibraryItemPopup`、各 Library 页 `setPlayQueue` → `LIBRARY_CLICK`/`PLAYLIST_CLICK` + - `MusicService.restorePlayList` → 设置 `RESUME` + +## 3. 统计聚合(Phase 2) + +`PlayEventRepository` 提供: + +- `suspend fun annualReport(year: Int): AnnualReport` +- `suspend fun clear()` + +`AnnualReport`: +- `plays`(有效事件数)、`listenScore`(∑playScore)、`completedPlays`、`listenMs`(∑listenedMs)、`listenedDays`、`distinctSongs/artists/albums`、`firstListenedSongs` +- `topSongs/topArtists/topAlbums`(List) +- `monthDistribution`、`hourDistribution` +- `sourceBreakdown`(source → 次数) +- 需 DAO 提供聚合 SQL(按 `year` 分组、按 `canonicalId`/snapshot 聚合、按 `listenedMs` 求和、按 `source` 计数)。 + +## 4. 界面与互操作(Phase 3) + +- 设置新增: + - `播放统计` 开关(`SettingPrefs.playEventEnabled`) + - `清除播放统计数据` + - `导出本年度 JSONL` +- 新增 `ui/screen/report/AnnualReportScreen.kt` 展示核心数字、Top 排行、来源对比。 +- `AppNav` 增 `RouteAnnualReport`,`SettingScreen` 增加入口。 +- JSONL 导出:把当年事件序列化为一行一条 JSON(含 `track` 对象),写入 `context.getExternalFilesDir` 下,并用 `FileProvider`/Intent 分享。字段按 `play-event-design.md` §3 协议。 + +## 5. 测试重点 + +对应设计 §9:<10% 不落库、50% score=0.5、自然结束只写一次、暂停恢复不重复计时、拖动不虚增、随机/主动 source 正确、库升级不影响 History、JSONL 可逐行读取。 + +## 6. 风险与后续 + +- canonicalId 第一版用“规范化元数据哈希 / URI”生成,稳定但抗重组;后续可换内容哈希。 +- `addedAt` 来自 MediaStore `DATE_ADDED`,本版用“当年首次收听”代替新歌统计,属可接受近似。 +- 统计页聚合查询只取结果,不加载全量事件。 diff --git a/docs/play-event-protocol-spec.md b/docs/play-event-protocol-spec.md new file mode 100644 index 00000000..30a8f105 --- /dev/null +++ b/docs/play-event-protocol-spec.md @@ -0,0 +1,60 @@ +# APlayer 播放事件跨平台协议规范 + +> 供桌面/电脑端应用做同样改动时参考。Android 端已按此实现(Room + JSONL 导出)。 +> +> **当前 schemaVersion 已升级到 2**(新增可选字段 + `song_added` 事件)。唯一设计文档见 `docs/play-event-design.md`(第 10/11 章);PC 端具体实现步骤由后续基于 PC 代码生成的文档提供。 +> +> **相关参考**: +> - 导出样例:`docs/samples/play-events-2026.sample.jsonl`(6 条合法 JSONL) + +## 0. 总览 + +播放器在运行中**逐次**记录“播放事件”(每次有效收听一条),用于年度听歌报告。 +- Android 端用 **Room** 表 `play_events` 本地存储,可导出 **JSONL**(一行一条事件)。 +- 跨平台统一关键字段:`eventId`(用于去重)、`deviceId`、`schemaVersion`、`canonicalId`、`UTC ISO 8601`。 +- 汇总统计从事件派生,事件本身是唯一事实来源。 + +## 1. 播放会话语义 + +- 会话开始:歌曲开始播放(或切到新歌且正在播放)。 +- 累计:仅播放状态下按位置 100ms 递增累计 `listenedMs`;相邻位置增量 >2500ms 视为 seek 不计入;位置回退到接近开头视为单曲循环新开一轮。 +- 结束:切歌/自然结束/错误/服务停止/单曲循环重置/暂停后切歌。 +- 阈值:有效播放 `listenedMs/durationMs >= 0.1`(未知时长需 `listenedMs >= 1000ms`);`listenRatio=min(ratio,1)`;`playScore=listenRatio`;`completed = duration>0 && (ratio>=0.9 || endReason==NATURAL_END)`。 +- 每会话一条;暂停→恢复同一事件;拖动不虚增。 + +## 2. `source` 枚举 + +`SEARCH_CLICK` / `LIBRARY_CLICK` / `PLAYLIST_CLICK` / `QUEUE_AUTO` / `RESUME` / `EXTERNAL_INTENT` + +队列首曲用入口来源,其后自动切换记 `QUEUE_AUTO`;单曲循环重开记 `QUEUE_AUTO`。 + +## 3. `endReason` 枚举 + +`NATURAL_END` / `SKIP_TO_NEXT` / `SKIP_TO_PREVIOUS` / `PLAYLIST_CHANGED` / `STOP` / `ERROR` + +## 4. 事件字段(`PlayEvent`,表 `play_events`,schema v2) + +`id`(本地主键)、`schemaVersion`(=2)、`eventId`、`deviceId`、`eventType`(`playback`/`song_added`)、`canonicalId`、`audioId?`、`startedAt/endedAt`(UTC ms)、`durationMs`、`listenedMs`、`listenRatio`、`playScore`、`completed`、`source`、`endReason`、`titleSnapshot`、`artistSnapshot`、`albumSnapshot`、`genreSnapshot?`、`sourceUri?`、`contentHash?`、`pathHint?`、`year/month/day/hour/weekday`, +以及 v2 可选:`songId?`、`artistId?`、`albumId?`、`genreId?`、`playlistId?`、`mediaType?`、`sessionId?`、`gapBeforeMs?`、`gapAfterMs?`、`loopCount`、`outputDevice?`、`isForeground?`、`decoder?`。 + +索引:`year`、`canonicalId`、`audioId`、`source`、`startedAt`、`eventType`。 + +## 5. 导出 JSONL + +文件 `play-events-.jsonl`;每行一个 JSON;UTF-8;`\n`;时间 UTC ISO 8601;`schemaVersion=2`。 +示例见 `docs/samples/play-events-2026.sample.jsonl`。 + +## 6. 标识生成 + +- `eventId`=`evt-`+UUID去连字符;`deviceId`:Android=`android-`、PC=`pc-`+UUID去连字符(持久化)。 +- `canonicalId`:本地=sha256(规范化元数据),在线=sha256(来源URI),前缀 `sha256:`。 + +## 7. 统计口径 + +见 `docs/play-event-design.md` 第 5 章、第 10.5 节。 + +## 8. 注意 + +- 统计本地化、不依赖账号;提供开关与清除入口。 +- `eventType` 预留 `playback`/`song_added`/`song_removed`/`metadata_changed`;本期实现 playback + song_added。 +- 同步按 `eventId` 幂等导入,事件不可变,修正产生新事件或更高 `schemaVersion`。 diff --git a/docs/samples/play-events-2026.sample.jsonl b/docs/samples/play-events-2026.sample.jsonl new file mode 100644 index 00000000..e997c309 --- /dev/null +++ b/docs/samples/play-events-2026.sample.jsonl @@ -0,0 +1,6 @@ +{"schemaVersion":2,"eventId":"evt-01JABCD1234567890","deviceId":"android-0123abcdef0123abcdef0123abcdef01","eventType":"playback","startedAt":"2026-08-28T08:30:00Z","endedAt":"2026-08-28T08:31:43Z","durationMs":230000,"listenedMs":103500,"listenRatio":0.45,"playScore":0.45,"completed":false,"source":"SEARCH_CLICK","endReason":"SKIP_TO_NEXT","track":{"canonicalId":"sha256:0c3e1a2b4d5f6a7b8c9d0e1f2a3b4c5d","title":"Color Your Night","artist":"Atlus Sound Team","album":"Persona 3 Reload","durationMs":230000},"songId":"1042","artistId":"22","albumId":"31","mediaType":"local","sessionId":"sess-0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d","loopCount":1} +{"schemaVersion":2,"eventId":"evt-01JABCD1234567891","deviceId":"android-0123abcdef0123abcdef0123abcdef01","eventType":"playback","startedAt":"2026-08-28T08:34:00Z","endedAt":"2026-08-28T08:38:05Z","durationMs":245000,"listenedMs":245000,"listenRatio":1.0,"playScore":1.0,"completed":true,"source":"LIBRARY_CLICK","endReason":"NATURAL_END","track":{"canonicalId":"sha256:1d4f2b3c5e6a7b8c9d0e1f2a3b4c5d6e","title":"Changing Seasons -Reload-","artist":"Atlus Sound Team","album":"Persona 3 Reload","durationMs":245000},"songId":"1043","artistId":"22","albumId":"31","mediaType":"local","sessionId":"sess-0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d","loopCount":1} +{"schemaVersion":2,"eventId":"evt-01JABCD1234567892","deviceId":"android-0123abcdef0123abcdef0123abcdef01","eventType":"playback","startedAt":"2026-08-28T08:41:00Z","endedAt":"2026-08-28T08:42:51Z","durationMs":180000,"listenedMs":111600,"listenRatio":0.62,"playScore":0.62,"completed":false,"source":"QUEUE_AUTO","endReason":"SKIP_TO_NEXT","track":{"canonicalId":"sha256:2e5f3c4d6f7a8b9c0d1e2f3a4b5c6d7e","title":"Want To Be Close","artist":"Atlus Sound Team","album":"Persona 3 Reload","durationMs":180000},"songId":"1044","artistId":"22","albumId":"31","mediaType":"local","sessionId":"sess-0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d","loopCount":1} +{"schemaVersion":2,"eventId":"evt-01JABCD1234567893","deviceId":"android-0123abcdef0123abcdef0123abcdef01","eventType":"playback","startedAt":"2026-08-28T09:00:00Z","endedAt":"2026-08-28T09:03:16Z","durationMs":200000,"listenedMs":196000,"listenRatio":0.98,"playScore":0.98,"completed":true,"source":"RESUME","endReason":"NATURAL_END","track":{"canonicalId":"sha256:3f6a4d5e6f7a8b9c0d1e2f3a4b5c6d7e","title":"Enduring Bonds","artist":"Atlus Sound Team","album":"Persona 3 Reload","durationMs":200000},"songId":"1045","artistId":"22","albumId":"31","mediaType":"local","sessionId":"sess-0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d","loopCount":2} +{"schemaVersion":2,"eventId":"evt-01JABCD1234567894","deviceId":"android-0123abcdef0123abcdef0123abcdef01","eventType":"playback","startedAt":"2026-08-28T12:20:00Z","endedAt":"2026-08-28T12:21:19Z","durationMs":240000,"listenedMs":79200,"listenRatio":0.33,"playScore":0.33,"completed":false,"source":"PLAYLIST_CLICK","endReason":"SKIP_TO_NEXT","track":{"canonicalId":"sha256:4a7b5e6f7a8b9c0d1e2f3a4b5c6d7e8","title":"03911 Color Your Night","artist":"Atlus Sound Team","album":"Persona 3 Reload","durationMs":240000},"songId":"1046","artistId":"22","albumId":"31","mediaType":"local","sessionId":"sess-0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d","loopCount":1} +{"schemaVersion":2,"eventId":"evt-01JABCD1234567895","deviceId":"android-0123abcdef0123abcdef0123abcdef01","eventType":"song_added","startedAt":"2026-08-28T09:11:00Z","endedAt":"2026-08-28T09:11:00Z","durationMs":240000,"listenedMs":0,"listenRatio":0.0,"playScore":0.0,"completed":false,"source":"","endReason":"","track":{"canonicalId":"sha256:5b8c6f7a8b9c0d1e2f3a4b5c6d7e8f9","title":"Theme Song","artist":"Atlus Sound Team","album":"Persona 3 Reload","durationMs":240000},"songId":"1047","artistId":"22","albumId":"31","mediaType":"local","sessionId":"","loopCount":0}