diff --git a/.github/workflows/android-apk-build.yml b/.github/workflows/android-apk-build.yml index dd75634..3270304 100644 --- a/.github/workflows/android-apk-build.yml +++ b/.github/workflows/android-apk-build.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: pull_request: branches: - - main + - master permissions: contents: read diff --git a/.gitignore b/.gitignore index 6230edc..9d55d38 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,11 @@ .externalNativeBuild .cxx local.properties -music_test \ No newline at end of file +music_test +.slim/deepwork/ +.codegraph/ +.ignore +temp/ +app/src/main/cpp/loopfinder/.vs/ +app/src/main/cpp/loopfinder/build/ +run - 副本.bat diff --git a/AGENTS.md b/AGENTS.md index 49afa21..09d5252 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,15 +4,15 @@ Android 无缝循环音频播放器,Kotlin + C++ (Oboe) 混合架构。 单模块项目 (`:app`),Min SDK 26,Compile/Target SDK 35。 -Kotlin 2.1.0 + AGP 9.0.1 + Gradle 9.1.0,Compose compiler 由 Kotlin 2.1.0 内置。 +Kotlin 2.1.0 + AGP 9.0.1 + Gradle 9.1.0;使用 `org.jetbrains.kotlin.plugin.compose` 插件,`composeCompiler.includeSourceInformation=true` 便于 Layout Inspector 溯源。 ## 构建与测试 ```bash -gradlew.bat assembleDebug -gradlew.bat testDebugUnitTest -gradlew.bat testDebugUnitTest --tests "com.cpu.seamlessloopmobile.viewmodel.PlayModeTest" -gradlew.bat connectedAndroidTest # 需要设备 +gradlew.bat -q assembleDebug +gradlew.bat -q testDebugUnitTest +gradlew.bat -q testDebugUnitTest --tests "com.cpu.seamlessloopmobile.viewmodel.PlayModeTest" +gradlew.bat -q connectedAndroidTest # 需要设备 ``` **测试前提**:`PcDatabaseImporterTest` 需要 `app/src/test/resources/pc_db_samples/pc_3nf_sample.db` 存在才能运行(仅 `pc_3nf_sample.db` 存在且有内容);若文件缺失则测试静默通过而不报错! @@ -21,12 +21,20 @@ gradlew.bat connectedAndroidTest # 需要设备 ## 架构要点 -- **导航**:自定义 `MusicUiState` 密封类 + `AnimatedContent`,未使用 Navigation 组件(虽依赖 navigation-compose) +- **导航/UI**:自定义 `MusicUiState` 密封类 + `AnimatedContent(targetState = uiState)` 统一承载 `Home`、`SongList`、`Search`、`Settings`、`PlaybackStats`,未使用 Navigation 组件(虽依赖 navigation-compose)。底部 `MiniPlayer` 与 `MainBottomNavigation` 是页面动画层的 sibling,不参与页面缩放。 + +- **底部毛玻璃约束**:`MainScreen` 页面内容层是唯一 `.haze(...)` source;`MiniPlayer` 在上层使用 `hazeChild(...)`。不要把 `MiniPlayer` 自身放进 Haze source,否则容易出现毛玻璃存在但控件内容不可见的问题。 - **服务**:`PlaybackService` (MediaBrowserService) 后台播放,`MediaControlManager` 管理媒体会话;`SystemMediaProgressSyncController` 在服务端定时同步播放进度到系统媒体状态/通知栏 - **无缝循环次数限制**:`SettingsManager.seamlessLoopCountLimit` 控制当前歌曲完成多少次无缝回绕后触发调度,默认 0 表示无限循环,上限 `SettingsManager.MAX_SEAMLESS_LOOP_COUNT_LIMIT`(当前 9999)。`PlaybackManager` 通过 native `EVENT_LOOP_JUMP` + 实际进度回绕确认来计数;达到上限后 LIST_LOOP/SHUFFLE 切下一首,SINGLE_LOOP 重新播放当前歌曲并重置计数。 +- **播放统计**:`PlaybackStatsTracker` 只统计处于 `AudioPlayState.PLAYING` 的真实墙钟收听时长,数据由 `ListenStatsRepository` 写入 `filesDir/listen_stats.json` 的本地 ListenStatsStore schema 3。日期桶使用来源设备本地日期,并保留无日期时长;不统计播放次数或循环次数。GitHub 云端只接受 schema 2,按 exact wire identity 和 per-device/per-generation 累计 contribution、永久 generation tombstone 同步;可解析但未匹配的歌曲保留为 `boundSongId = 0` 的 typed node,无法安全解析的 payload 才进入 `unresolvedNodes`。本地 fuzzy relink 是非破坏性绑定,多个 wire identity 只在 presentation 层聚合。删除/丢失文件时保留历史并在统计页显示缺失状态,来源设备管理与清理入口位于 `GitHub 同步 → 数据管理`。完整规则见 [播放统计与 GitHub 同步 Schema V2](docs/2026-07-12_播放统计与GitHub同步SchemaV2.md)。 + +- **GitHub 同步**:设置页含 `GitHub 同步` 页面,用 GitHub Contents API 在单个 JSON 快照文件中同步歌单、循环点、评分和播放统计。`GitHubSyncCoordinator` 负责导出本地 → 下载远端 → 合并 → 应用 → 带 SHA 乐观锁上传;`RoomSyncSnapshotStore` 负责 Room 快照转换;`SharedPreferencesPlaylistIdMapper` 维护歌单本地 ID 与同步 ID 映射。同步不包含音频文件、播放队列、封面/格式展示字段或 App 设置。 + +- **自动同步**:`GitHubAutoSyncScheduler` + `GitHubAutoSyncWorker` 使用 WorkManager 周期任务实现,默认关闭;开启后在网络可用时约每小时同步一次。配置/token 不完整时 UI 不允许开启;清除 GitHub 配置会关闭并取消 WorkManager 任务。当前不做 mutation-triggered 同步,因为评分/歌单/循环点的本地修改入口尚未全部统一接入 mutation hook。 + - **ViewModel/子管家**:`MainViewModel` 作为协调者,由 `MainViewModelFactory` 创建并通过属性赋值持有四个子管家(`LibraryViewModel`、`SelectionViewModel`、`PlaylistViewModel`、`LoopDetectionViewModel`)。注意:`LibraryViewModel` 与 `PlaylistViewModel` 是普通 class,共享 `MainViewModel.viewModelScope`;`SelectionViewModel` 与 `LoopDetectionViewModel` 继承 `ViewModel`,但也是由工厂直接创建后挂到 `MainViewModel` 上。自动循环点探测与试听调度由 `LoopDetectionViewModel` 管理,耗时调用需剥离出主线程以防止音频锁与 UI 锁争用。 - **Native 层**:`app/src/main/cpp/` — 包含两个核心引擎: @@ -35,19 +43,19 @@ gradlew.bat connectedAndroidTest # 需要设备 - 统一通过 `NativeAudio.kt` 进行 JNI 桥接 - 注意:`NativeAudio` 的 `init` 块中需同时加载 `seamlessloopmobile` 和 `loopfinder` 两个原生库 -- **UI**:Jetpack Compose + Material3,状态通过 `MainViewModel` 的 LiveData、子管家的 StateFlow/LiveData、`MediaControlManager` 的 StateFlow/SharedFlow 驱动 +- **UI**:Jetpack Compose + Material3,状态通过 `MainViewModel` 的 LiveData、子管家的 StateFlow/LiveData、`MediaControlManager` 的 StateFlow/SharedFlow 驱动;引入 Haze 0.7.0 与 Coil 2.7.0,封面统一通过 `SongArtwork` 展示。 - **对话框**:统一 `MusicDialog` 密封类 + `CentralizedDialogHost` 集中管理 ## 数据层(重要 — 近期大规模重构) -**数据库**:Room 2.7.0-alpha11,version 12,`fallbackToDestructiveMigration()`,DB 存在 `getExternalFilesDir(null)/databases/seamless_loop_db` +**数据库**:Room 2.7.0-alpha11,version 13,`fallbackToDestructiveMigration()`,DB 存在 `getExternalFilesDir(null)/databases/seamless_loop_db` **3NF 表结构**(9 张表): | 表 | 实体 | 说明 | |----|------|------| -| `Songs` | `SongEntity` | 主表,FK → Artists.Id, Albums.Id;索引:FilePath, FileName+duration, ArtistId, AlbumId, IsAbPartB | +| `Songs` | `SongEntity` | 主表,FK → Artists.Id, Albums.Id;索引:FilePath, FileName+duration, ArtistId, AlbumId, IsAbPartB;含封面 URI、MIME、采样率、码率展示字段 | | `Artists` | `Artist` | — | | `Albums` | `Album` | — | | `LoopPoints` | `LoopPoint` | 1:1 与 Songs,FK CASCADE | @@ -59,11 +67,11 @@ gradlew.bat connectedAndroidTest # 需要设备 **DAO 层**(3 个,都在 `model/`): -- `SongDao` — 最复杂的 DAO。含 `insertOrUpdateSong()`(双指纹匹配:优先 fileName+duration,回退 filePath)、`updateSongsMetadataBatch()`(批量同步)、`getOrCreateArtist/Album()`、`Song` POJO(`@Relation` 聚合 SongEntity+Artist+Album+LoopPoint+UserRating) +- `SongDao` — 最复杂的 DAO。含 `insertOrUpdateSong()`(双指纹匹配:优先 fileName+duration,回退 filePath)、`updateSongsMetadataBatch()`(批量同步,包含封面和音频格式展示字段)、`getOrCreateArtist/Album()`、`Song` POJO(`@Relation` 聚合 SongEntity+Artist+Album+LoopPoint+UserRating) - `PlaylistDao` — 含 `clearAndSyncPlaylist()`、`addSongsToPlaylist()`(去重) - `PlayQueueDao` — `replacePlayQueue()` 事务方法 -**Repository 层**(`data/`,6 文件): +**Repository 层**(`data/` + 子目录): - `MusicRepository` — Facade,聚合 3 个子 Repository + PlayQueueDao - `SongRepository` — 歌曲 CRUD @@ -71,7 +79,19 @@ gradlew.bat connectedAndroidTest # 需要设备 - `LoopDetectionRepository` — **新版逻辑核心**!负责音频临时文件安全拷贝、跨线程 JNI 调用与 JSON 缓存管理。 - `MusicScannerRepository` — 扫描逻辑,含 `getInitialScannedSongs()`(全量扫描+批量更新+Artist/Album 预创建+A/B 标记+多级匹配)、`findAbPair()` / `findAbPairRobust()`(DB + MediaStore) - `SettingsManager` — 单例 `getInstance(context)`,Gson 序列化,持久化 lastSongPath/lastPosition/playMode/isAbMode 等 -- `SettingsManager` 同时持久化 `isSeamlessLoopEnabled` 与 `seamlessLoopCountLimit`;循环次数上限 0 表示无限循环,设置 UI 需校验为 `0..MAX_SEAMLESS_LOOP_COUNT_LIMIT` 的整数。 +- `SettingsManager` 同时持久化 `isSeamlessLoopEnabled`、`seamlessLoopCountLimit`、`themePreference`、`buttonHapticFeedbackEnabled`;循环次数上限 0 表示无限循环,设置 UI 需校验为 `0..MAX_SEAMLESS_LOOP_COUNT_LIMIT` 的整数。 +- `data/stats/` — `TrackStat` + `ListenStatsRepository` + `ListenStatsStore`,使用本地 schema 3 JSON 保存真实收听时长、source-local 日期桶、无日期时长、设备/代际贡献、永久 tombstone 和 unresolved 节点。App 私有存储重置会创建新的 `deviceId`;应用内清除当前统计会 tombstone 当前 generation 并旋转到下一代。 +- `data/sync/` — GitHub/云同步模型、portable identity、合并策略、同步协调器、数据管理仓库、WorkManager 自动同步 Worker;`github/` 是 GitHub Contents API 后端,`room/` 是 Room 快照转换与歌单 ID 映射。 + +**GitHub 同步注意事项**: +- Token 当前由 `SharedPreferencesGitHubSyncStore` 以 `MODE_PRIVATE` 明文保存,只是 MVP;后续应迁移到 EncryptedSharedPreferences/Android KeyStore。 +- `GitHubSyncConfig.DEFAULT_BRANCH = "main"`,`DEFAULT_PATH = "seamless-loop/sync.json"`。 +- 需要 `INTERNET` 权限;依赖 OkHttp 与 WorkManager (`work-runtime-ktx`)。 +- 云端只接受 canonical schema 2:`playbackStatistics` 必须存在,`dateBucketBasis` 必须是 `sourceLocal`,其他 schema 直接拒绝。`prepareV2Egress()` 在上传前统一规范化快照。播放 wire identity 严格是 NFC + `Locale.ROOT` normalized basename 与 exact `durationMs`;`totalSamples` 仅为辅助字段。`RoomSyncSnapshotStore` 本地重绑定顺序为 exact duration、exact/tolerant samples ±10000、duration ±200ms、唯一同名,歧义即停止;绑定不改 wire identity 或 contributions,多个 wire identity 只在 presentation 聚合。非 key ACI metadata 使用确定性 reducer:有效原始 `fileName` 取 ordinal 最小值,`totalSamples` 与 `contentHash` 各取非空最大值。歌单/循环点/评分的通用 identity 可在 Gson 前 backfill `normalizedFileName`,播放 identity 不得 backfill。`seedCloudFromLocal()` 仅在云端文件不存在时创建初始快照,云端已有文件必须使用普通同步或先删除云端文件。 +- 播放统计合并按 `(deviceId, generation)` 做累计最大值;永久 tombstone 抑制对应代际。来源设备完全删除的分组只存在于数据管理 UI,不创建额外 wire identity。应用 stale payload 时必须保留当前本地节点并合并同步期间产生的 current deltas。 +- 循环点 `0/0` 与评分 `0` 视为未设置,不能覆盖远端/本地已有实质数据。 +- 自动同步唯一任务名为 `com.cpu.seamlessloopmobile.GITHUB_AUTO_SYNC_PERIODIC`,周期 1 小时,网络可用约束,`ExistingPeriodicWorkPolicy.KEEP`。 +- Worker 和手动同步当前没有共享同一个 coordinator mutex;依赖 GitHub SHA 乐观锁、Room 事务与下次周期同步收敛。若要更强一致性,需要新增跨入口同步锁。 **扫描流程**: `AudioScanner.scan()` (MediaStore) → `MusicScannerRepository.getInitialScannedSongs()` → 多级匹配(优先 fileName+duration,兼顾 mediaId/filePath/容差匹配)→ Artist/Album 预创建 → 批量写入 → A/B 标记(文件后缀 _B/_b/_loop/_Loop 设 `isAbPartB=true`,被大多数查询过滤) @@ -84,6 +104,7 @@ gradlew.bat connectedAndroidTest # 需要设备 - **Kotlin 源码目录**:所有 Kotlin 文件实际放在 `app/src/main/java/` 下(非 `kotlin/`),这是历史遗留的目录结构。搜索源码时需注意。 - **Kotlin Android 插件被注释**:`app/build.gradle.kts` 中 `alias(libs.plugins.kotlin.android)` 被注释掉了(AGP 9.x 内置 Kotlin 支持 + compose-compiler 插件足以编译)。 +- **Compose compiler 插件**:根 `build.gradle.kts` 与 `app/build.gradle.kts` 使用 `alias(libs.plugins.compose.compiler)`,不要删除;Layout Inspector 依赖 `includeSourceInformation=true`。 - **Room KSP**:`ksp { arg("room.generateKotlin", "true") }`,构建前需生成代码 - **Robolectric 测试**:`@Config(sdk = [34])`,JVM 模拟 Android 环境 - **CMake**:3.22.1,C++17,prefab 启用 @@ -100,23 +121,24 @@ gradlew.bat connectedAndroidTest # 需要设备 - **docs/ 目录**:主要是阶段记录/研究日志,可能过时;改架构说明时优先更新本文件,除非用户明确要求,不要批量改写 `docs/`。 - **快速部署**:项目根目录下的 `run.bat` 提供 root 设备的 adb push + pm install + am start 一键部署流程。 - **播放页进度条约束**:全屏播放页 `PlaybackProgressBar` 需要直接轮询 `NativeAudio` 以保证拖动低延迟;不要将其完全改成依赖 `MediaControlManager`/MediaSession 状态流。清除暂停通知导致 native engine 销毁时,应保留 UI 当前进度或使用 MediaSession 进度兜底,避免用 native 返回的 0 覆盖进度。 +- **页面动画约束**:主页面、歌曲列表、搜索、设置、统计页都通过 `MainScreen` 顶层 `AnimatedContent(targetState = uiState)` 做统一 scale/fade 切换。不要再额外叠一层二级页 overlay 动画,否则回媒体库时会退化成“底层露出”而不是媒体库入场。 - **⚠️ 物理路径与 JNI 避坑硬约束**:探测引擎的 C++ `fopen` 无法直接读取 `content://` 或 MediaStore URI!自动探测循环点时,必须先使用 `LoopDetectionRepository` 将音频拷贝至私有 cache 目录,再将物理路径传入 `NativeAudio.analyzeLoopPoints`;分析完毕后必须在 `finally` 块中立即彻底删除临时文件,防止磁盘残留。 ## 包结构速查 | 路径 | 职责 | |------|------| -| `audio/` | PlaybackService, PlaybackManager (IMultiPlayer), MediaControlManager, QueueManager, AudioFocusManager, Notify, HeadsetPlugReceiver, SystemMediaProgressSyncController 等 | -| `data/` | MusicRepository + SongRepository + PlaylistRepository + MusicScannerRepository + SettingsManager + LoopDetectionRepository | +| `audio/` | PlaybackService, PlaybackManager (IMultiPlayer), MediaControlManager, QueueManager, AudioFocusManager, Notify, HeadsetPlugReceiver, SystemMediaProgressSyncController, MediaSessionPlaybackStateThrottler;`timer/` 睡眠定时器契约;`effects/` 音效控制契约;`stats/` 播放统计追踪 | +| `data/` | MusicRepository + SongRepository + PlaylistRepository + MusicScannerRepository + SettingsManager + LoopDetectionRepository;`stats/` 收听时长 JSON 仓库;`sync/` GitHub 同步契约、合并、后端、Room 快照与自动同步 | | `db/` | AppDatabase + DateConverter + PcDatabaseImporter + PcDatabaseExporter(实体/DAO 已移到 model/) | | `model/` | 9 个 Room 实体 + 3 个 DAO + Song(Lookup POJO) + `SongMetadataUpdate`(定义在 Song.kt)+ LibraryItem + Folder 等 15 个 `.kt` 文件 | -| `ui/screen/` | MainScreen + MainAppBar + MainTabsPager + PlaylistTabScreen + category/search/settings/songlist 子目录 | -| `ui/components/` | app/common/dialogs 三类组件目录:CentralizedDialogHost, MiniPlayer, PlayingPanel, MultiSelectBar, PlayingComponents, FineTuneComponents, ListItems, LoopCandidatesDialog 等 | +| `ui/screen/` | MainScreen + MainAppBar + MainTabsPager + PlaylistTabScreen + search/settings/stats/songlist 子目录 | +| `ui/components/` | app/common/dialogs 三类组件目录:CentralizedDialogHost, MiniPlayer, MainBottomNavigation, PlayingPanel, MultiSelectBar, PlayingComponents, SongArtwork, FineTuneComponents, ListItems, LoopCandidatesDialog 等 | | `ui/state/` | DataUiState 等 UI 数据状态封装 | | `viewmodel/` | MainViewModel + LibraryViewModel + SelectionViewModel + PlaylistViewModel + LoopDetectionViewModel + MainViewModelFactory + MusicDialog | | `scanner/` | AudioScanner(Object,MediaStore 扫描;采样点在后续原生层处理) | | `jni/` | NativeAudio(Kotlin object,JNI 入口)、LoopPoint(原生层返回的数据类) | -| `utils/` | TimeUtils | +| `utils/` | TimeUtils, HapticFeedback | ## 注意 diff --git a/README.md b/README.md index 7f5ed64..ff5d7b0 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ Android 端高性能**无缝循环音频播放器**,采用 Kotlin + C++ (Oboe) 现在支持 PC 端数据库导入与手机端数据导出为 PC 端可识别数据库;手机上的循环点、评分、歌单等修改可以通过“导出 PC 端数据库”传回电脑端使用。 +当前界面参考了 NeriPlayer 的本地播放器体验,并结合本项目的无缝循环定位重新实现:底部主导航、毛玻璃迷你播放器、封面展示、独立搜索/设置/播放统计页面,以及统一的弹出式页面切换动画。 + +同时新增 GitHub 同步:可通过 GitHub Contents API 在单个 JSON 快照中同步歌单、循环点、评分和播放统计;自动同步默认关闭,用户开启后会在网络可用时由后台周期任务同步。 + ![a0e7472702aab23e61a4fb1f948aa075](./image/README/a0e7472702aab23e61a4fb1f948aa075.jpg) --- @@ -14,14 +18,26 @@ Android 端高性能**无缝循环音频播放器**,采用 Kotlin + C++ (Oboe) - **自动循环点探测引擎 (`loopfinder`)**:集成基于 FFT & Chroma 提取的高级音频算法,支持一键分析音频的最佳循环衔接点(提供 Top 5 候选推荐)。 - **A/B 式音乐支持**:独创支持由 Intro 段(A轨)和 Loop 段(B轨)组成的双轨歌曲无缝拼接播放,自动标记 B 轨并于 UI 中优雅过滤。 - **Room 3NF 数据持久化**:精心设计的 Room 2.7.0-alpha11 规范 3NF 数据库,支持双指纹去重(优先匹配 `FileName+Duration`,兜底 `FilePath` 匹配)。 +- **现代播放器界面**:Compose + Material3 构建媒体库、搜索、设置、播放统计和全屏播放页;底部 `MiniPlayer` 支持 Haze 毛玻璃、封面、进度与常用播放控制。 +- **真实收听时长统计**:只统计处于播放状态的墙钟收听时间,按累计时长排行;不统计播放次数和循环次数,文件缺失时仍保留历史记录。 +- **GitHub 同步**:将歌单、循环点、评分和播放统计同步到 GitHub 仓库中的单个 JSON 文件;支持手动同步、数据摘要、来源设备管理、删除云端快照,以及默认关闭的 WorkManager 自动同步。 +- **封面与音频格式展示**:扫描时写入封面 URI、MIME、采样率和码率,在列表、迷你播放器和播放页中统一展示。 +- **主题与触感反馈**:支持跟随系统/浅色/深色主题偏好,并可在设置中开关按钮触感反馈。 --- ## 🚀 用户使用指南 +### 🎧 媒体库、搜索、设置与统计 + +- 底部导航提供 **媒体库 / 搜索 / 设置** 三个主入口。 +- 媒体库右上角的统计入口可打开播放统计页,查看累计收听时长、Top 5 条形图和歌曲排行。 +- 播放统计可在“设置 → GitHub 同步 → 数据管理 → 清理本机数据”中与歌单、循环点和评分一起选择清理。 +- 搜索页不会自动弹出键盘,进入后可以直接浏览或手动输入关键词。 + ### 💻 导入 / 导出 PC 数据库 > [!TIP] -> **点击主界面右上角的设置入口,进入“数据同步与管理”。** +> **点击底部“设置”,进入“数据同步与管理”。** > - **导入 PC 端数据库**:选择电脑端 `.db` 文件,APP 会自动进行容差匹配和增量批量写入,补充手机端没有的循环点、候选循环点、评分、歌单等数据。 > - **导出 PC 端数据库**:将手机端当前的歌曲元数据、循环点、候选循环点、评分、歌单与队列转换为 PC 端 3NF schema,可传回电脑端继续使用。 @@ -45,6 +61,22 @@ Android 端高性能**无缝循环音频播放器**,采用 Kotlin + C++ (Oboe) seamless_loop_pc_export_yyyyMMdd_HHmmss.db ``` +### ☁️ GitHub 同步 + +> [!TIP] +> **点击底部“设置”,进入“GitHub 同步”。** +1. 在 GitHub 创建一个 token,并只授予目标仓库的 Contents 读写权限。 +2. 在 App 中填写并保存 Token、Owner、Repository、Branch 和同步文件 Path。默认 path 为 `seamless-loop/sync.json`。 +3. 第一次使用时,若云端文件不存在,可点击 **立即同步**;也可在数据管理中执行“初始化云端”。云端文件已存在时,使用普通 **立即同步** 做双向合并,不会无条件用本机覆盖云端。 +4. 可选开启 **自动同步**。任务默认关闭,网络可用时由 WorkManager 约每小时执行一次。 +5. 在 **数据管理** 中查看本机/云端摘要,清理歌单、循环点、评分或播放统计,也可以按来源设备删除播放统计历史;删除云端快照前请确认仓库内容。 + +同步内容包括歌单、循环点、评分和播放统计。不会上传音频文件、播放队列/位置、封面与音频格式展示字段或 App 设置。循环点 `0/0` 与评分 `0` 表示未设置,不会清空已有实质值。 + +播放统计严格使用“规范化 basename + 精确时长”的线上身份,本地重新扫描时才按容差规则重绑定;绑定不会改写云端身份。完整规则见 [播放统计与 GitHub 同步 Schema V2](./docs/2026-07-12_播放统计与GitHub同步SchemaV2.md)。 + +如果旧文件或手工 JSON 导致 `schemaVersion`、`playbackStatistics`、日期或规范化文件名校验失败,请先备份并检查 JSON。schema 2 以外的快照不能直接同步;云端已存在时不要使用 seed,改用普通同步或先明确删除云端文件。 + @@ -55,20 +87,21 @@ seamless_loop_pc_export_yyyyMMdd_HHmmss.db ### 💻 运行环境 - **操作系统**:原生 Windows 11 (命令行推荐使用 PowerShell)。 -- **编译依赖**:Min SDK 26, Target SDK 35, Gradle 9.1.0, Kotlin 2.1.0, Room KSP 启用。 +- **编译依赖**:Min SDK 26, Target SDK 35, Gradle 9.1.0, Kotlin 2.1.0, Room KSP 启用,Compose compiler 插件启用。 +- **主要 UI 依赖**:Jetpack Compose Material3、Haze 0.7.0、Coil 2.7.0。 ### ⌨️ 常用开发命令 - **编译 Debug 包**: ```powershell - .\gradlew.bat assembleDebug + .\gradlew.bat -q assembleDebug ``` - **运行单元测试**: ```powershell - .\gradlew.bat testDebugUnitTest + .\gradlew.bat -q testDebugUnitTest ``` - **运行特定测试(例如播放模式测试)**: ```powershell - .\gradlew.bat testDebugUnitTest --tests "com.cpu.seamlessloopmobile.viewmodel.PlayModeTest" + .\gradlew.bat -q testDebugUnitTest --tests "com.cpu.seamlessloopmobile.viewmodel.PlayModeTest" ``` - **一键调试部署 (需 Root 设备)**: 运行根目录下的部署脚本: @@ -79,8 +112,10 @@ seamless_loop_pc_export_yyyyMMdd_HHmmss.db --- ## 📖 深入架构与避坑说明 -如果您是参与本项目的**智能体(AI)** 或 **人类开发者**,在进行任何实质性代码修改前,请**务必先阅读 [AGENTS.md](file:///D:/seamless%20loop%20music/SeamlessLoopMobile/AGENTS.md) 了解以下关键细节**: -1. **构建天坑**:`app/build.gradle.kts` 中 `kotlin-android` 插件必须保持注释! +如果您是参与本项目的**智能体(AI)** 或 **人类开发者**,在进行任何实质性代码修改前,请**务必先阅读 [AGENTS.md](./AGENTS.md) 了解以下关键细节**: +1. **构建天坑**:`app/build.gradle.kts` 中 `kotlin-android` 插件保持注释;Compose compiler 插件不要删除。 2. **JNI / fopen 路径限制**:Native 音频分析无法直接读取 `content://`,必须先拷贝到私有 cache 目录。 -3. **Room Schema**:Room 9张实体表和3个DAO的详细映射图。 -4. **包结构速查**:子模块(audio, data, db, viewmodel, model, scanner, jni 等)职责定义。 +3. **UI / Haze 层级**:页面内容是唯一 Haze source;底部 `MiniPlayer` 是上层 `hazeChild` sibling,不能放进 source 内。 +4. **播放统计与 GitHub schema-v2**:统计按真实收听时长累计;线上身份、代际贡献和本地重绑定规则见 [权威文档](./docs/2026-07-12_播放统计与GitHub同步SchemaV2.md)。 +5. **Room Schema**:Room 9 张实体表和 3 个 DAO 的详细映射图。 +6. **包结构速查**:子模块(audio, data, db, viewmodel, model, scanner, jni 等)职责定义。 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2a333b6..23a40b4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,5 @@ plugins { alias(libs.plugins.android.application) - // alias(libs.plugins.kotlin.android) alias(libs.plugins.google.devtools.ksp) alias(libs.plugins.compose.compiler) } @@ -30,6 +29,10 @@ android { } buildTypes { + getByName("debug") { + isDebuggable = true + isMinifyEnabled = false + } release { isMinifyEnabled = false proguardFiles( @@ -55,6 +58,10 @@ android { } } +composeCompiler { + includeSourceInformation = true +} + ksp { arg("room.generateKotlin", "true") } @@ -67,7 +74,9 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.robolectric) testImplementation("org.mockito:mockito-core:5.11.0") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1") testImplementation(libs.androidx.core) + testImplementation(libs.androidx.work.testing) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) @@ -97,5 +106,10 @@ dependencies { implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.navigation.compose) implementation(libs.gson) + implementation(libs.haze.jetpack.compose) + implementation(libs.coil.compose) + implementation(libs.okhttp) + implementation(libs.androidx.work.runtime.ktx) + debugImplementation(libs.androidx.compose.ui.tooling) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3daddaa..b5e1d62 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,10 +1,11 @@ - + diff --git a/app/src/main/cpp/loopfinder/CMakeLists.txt b/app/src/main/cpp/loopfinder/CMakeLists.txt index c34e364..60dc248 100644 --- a/app/src/main/cpp/loopfinder/CMakeLists.txt +++ b/app/src/main/cpp/loopfinder/CMakeLists.txt @@ -93,7 +93,12 @@ target_link_libraries(loopfinder PRIVATE find_package(OpenMP) if(OpenMP_CXX_FOUND) - target_link_libraries(loopfinder PRIVATE OpenMP::OpenMP_CXX) + if(BUILD_FOR_ANDROID) + target_compile_options(loopfinder PRIVATE -fopenmp=libomp) + target_link_options(loopfinder PRIVATE -fopenmp=libomp -static-openmp) + else() + target_link_libraries(loopfinder PRIVATE OpenMP::OpenMP_CXX) + endif() endif() if(BUILD_FOR_ANDROID) diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/MainActivity.kt b/app/src/main/java/com/cpu/seamlessloopmobile/MainActivity.kt index 39ae531..b4a6b37 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/MainActivity.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/MainActivity.kt @@ -15,6 +15,8 @@ import com.cpu.seamlessloopmobile.viewmodel.MainViewModel import com.cpu.seamlessloopmobile.viewmodel.MainViewModelFactory import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.* +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier import androidx.compose.foundation.layout.fillMaxSize import androidx.lifecycle.lifecycleScope @@ -22,6 +24,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import com.cpu.seamlessloopmobile.ui.screen.MainScreen import com.cpu.seamlessloopmobile.data.SettingsManager +import com.cpu.seamlessloopmobile.data.ThemePreference import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -59,7 +62,15 @@ class MainActivity : ComponentActivity() { viewModel.initSettings(settingsManager) setContent { - com.cpu.seamlessloopmobile.ui.theme.SeamlessLoopTheme { + val systemDarkTheme = isSystemInDarkTheme() + val themePreference by viewModel.themePreference.observeAsState(settingsManager.themePreference) + val darkTheme = when (themePreference) { + ThemePreference.SYSTEM -> systemDarkTheme + ThemePreference.LIGHT -> false + ThemePreference.DARK -> true + } + + com.cpu.seamlessloopmobile.ui.theme.SeamlessLoopTheme(darkTheme = darkTheme) { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background @@ -68,7 +79,10 @@ class MainActivity : ComponentActivity() { viewModel = viewModel, playSong = { song -> viewModel.playSong(song) }, onSyncPc = { dbPickerLauncher.launch("application/octet-stream") }, - onExportDatabase = { dbExportLauncher.launch(createDatabaseExportFileName()) } + onExportDatabase = { dbExportLauncher.launch(createDatabaseExportFileName()) }, + isDarkTheme = darkTheme, + themePreference = themePreference, + onThemePreferenceChange = viewModel::setThemePreference ) } } diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/audio/MediaSessionPlaybackStateThrottler.kt b/app/src/main/java/com/cpu/seamlessloopmobile/audio/MediaSessionPlaybackStateThrottler.kt new file mode 100644 index 0000000..48d85b1 --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/audio/MediaSessionPlaybackStateThrottler.kt @@ -0,0 +1,102 @@ +package com.cpu.seamlessloopmobile.audio + +import android.support.v4.media.session.PlaybackStateCompat + +/** + * Tracks the last-dispatched playback state and decides whether a new dispatch + * should be sent to [android.support.v4.media.session.MediaSessionCompat]. + * + * This prevents flooding system UI (notification / lock screen) with redundant + * or near-identical progress updates. + * + * @param minUpdateIntervalMs Minimum elapsed wall-clock time between dispatches. + * @param positionDriftThresholdMs Allowed drift before a forced dispatch, even if + * the minimum interval has not elapsed. + */ +internal class MediaSessionPlaybackStateThrottler( + private val minUpdateIntervalMs: Long = 1000L, + private val positionDriftThresholdMs: Long = 1500L +) { + private var hasLastDispatch = false + private var lastDispatchElapsedMs: Long = 0L + private var lastState: Int = PlaybackStateCompat.STATE_NONE + private var lastPositionMs: Long = 0L + private var lastSpeed: Float = 0f + private var lastControlFingerprint: Int = 0 + + /** + * Returns `true` if the proposed playback-state update should be dispatched + * to the media session. + * + * Dispatch triggers: + * 1. No prior dispatch has been recorded. + * 2. The core playback state ([PlaybackStateCompat] state) has changed. + * 3. Playback speed has changed. + * 4. The control fingerprint has changed (seeks, mode toggles, etc.). + * 5. Enough wall-clock time has passed since the last dispatch. + * 6. The expected position has drifted beyond [positionDriftThresholdMs]. + * + * @param state The proposed [PlaybackStateCompat] state value. + * @param positionMs The proposed playback position in milliseconds. + * @param speed The proposed playback speed. + * @param controlFingerprint Opaque token that changes when user-control + * parameters (e.g. play-mode, AB-mode, loop-limit) + * are altered. + * @param nowElapsedMs Monotonic elapsed time (e.g. [System.nanoTime] + * converted to ms, or [SystemClock.elapsedRealtime]). + */ + fun shouldDispatch( + state: Int, + positionMs: Long, + speed: Float, + controlFingerprint: Int, + nowElapsedMs: Long + ): Boolean { + // 1. First dispatch + if (!hasLastDispatch) return true + + // 2. Core state change + if (state != lastState) return true + + // 3. Speed change + if (speed != lastSpeed) return true + + // 4. Control fingerprint change (seeks, mode toggles) + if (controlFingerprint != lastControlFingerprint) return true + + // 5. Minimum interval has passed + val elapsedSinceLastDispatch = (nowElapsedMs - lastDispatchElapsedMs).coerceAtLeast(0L) + if (elapsedSinceLastDispatch >= minUpdateIntervalMs) return true + + // 6. Position drift exceeds threshold + val expectedPositionMs = if (lastState == PlaybackStateCompat.STATE_PLAYING && lastSpeed > 0f) { + lastPositionMs + (elapsedSinceLastDispatch * lastSpeed).toLong() + } else { + lastPositionMs + } + val drift = kotlin.math.abs(positionMs - expectedPositionMs) + if (drift >= positionDriftThresholdMs) return true + + return false + } + + /** + * Records that a dispatch was just performed. Must be called immediately + * after dispatching so that subsequent [shouldDispatch] calls have accurate + * reference values. + */ + fun recordDispatch( + state: Int, + positionMs: Long, + speed: Float, + controlFingerprint: Int, + nowElapsedMs: Long + ) { + hasLastDispatch = true + lastDispatchElapsedMs = nowElapsedMs + lastState = state + lastPositionMs = positionMs + lastSpeed = speed + lastControlFingerprint = controlFingerprint + } +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/audio/PlaybackService.kt b/app/src/main/java/com/cpu/seamlessloopmobile/audio/PlaybackService.kt index 816cff9..4846d04 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/audio/PlaybackService.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/audio/PlaybackService.kt @@ -7,10 +7,15 @@ import androidx.media.session.MediaButtonReceiver import android.support.v4.media.session.MediaSessionCompat import com.cpu.seamlessloopmobile.model.Song import com.cpu.seamlessloopmobile.data.MusicRepository +import com.cpu.seamlessloopmobile.data.stats.ListenStatsRepository +import com.cpu.seamlessloopmobile.audio.stats.PlaybackStatsTracker import com.cpu.seamlessloopmobile.db.AppDatabase import kotlinx.coroutines.MainScope +import kotlinx.coroutines.Job import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import android.content.Intent @@ -29,6 +34,9 @@ class PlaybackService : MediaBrowserServiceCompat() { private var notify: Notify? = null private val queueManager = QueueManager() private lateinit var systemProgressSyncController: SystemMediaProgressSyncController + private lateinit var listenStatsRepository: ListenStatsRepository + private lateinit var playbackStatsTracker: PlaybackStatsTracker + private var statsFlushJob: Job? = null inner class PlaybackBinder : android.os.Binder() { fun getService(): PlaybackService = this@PlaybackService @@ -119,6 +127,14 @@ class PlaybackService : MediaBrowserServiceCompat() { playbackManager?.updatePosition() } + listenStatsRepository = ListenStatsRepository.getInstance(this) + playbackStatsTracker = PlaybackStatsTracker(listenStatsRepository) + serviceScope.launch { + listenStatsRepository.clearEvents.collect { + playbackStatsTracker.onStatsCleared() + } + } + playbackManager = PlaybackManager( context = this, coroutineScope = serviceScope, @@ -129,6 +145,11 @@ class PlaybackService : MediaBrowserServiceCompat() { if (song != null) { handlePlaybackStateChange(isPlaying, song) } + // Track real listened time via wall-clock while PLAYING. + if (song != null) { + playbackStatsTracker.onSongChanged(song) + playbackStatsTracker.onPlayingChanged(isPlaying) + } } onSongCompleted = { val settingsManager = com.cpu.seamlessloopmobile.data.SettingsManager.getInstance(this@PlaybackService) @@ -169,6 +190,21 @@ class PlaybackService : MediaBrowserServiceCompat() { // updating even when no Activity/MediaControlManager is connected. manager.state.collect { state -> systemProgressSyncController.onPlaybackStateChanged(state) + + // Manage periodic stats flush (~15s while playing). + if (state == AudioPlayState.PLAYING) { + if (statsFlushJob?.isActive != true && playbackStatsTracker.shouldFlushPeriodically()) { + statsFlushJob = serviceScope.launch { + while (isActive) { + delay(15_000L) + playbackStatsTracker.flushPeriodic() + } + } + } + } else { + statsFlushJob?.cancel() + statsFlushJob = null + } } } @@ -374,6 +410,9 @@ class PlaybackService : MediaBrowserServiceCompat() { } fun stopForegroundCompletely() { + statsFlushJob?.cancel() + statsFlushJob = null + playbackStatsTracker.flushFinal() systemProgressSyncController.onPlaybackStateChanged(AudioPlayState.IDLE) if (wakeLock?.isHeld == true) wakeLock?.release() audioFocusManager.abandonFocus() @@ -451,6 +490,8 @@ class PlaybackService : MediaBrowserServiceCompat() { } override fun onDestroy() { + statsFlushJob?.cancel() + playbackStatsTracker.flushFinal() systemProgressSyncController.dispose() if (wakeLock?.isHeld == true) wakeLock?.release() headsetPlugReceiver.unregister(this) diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/audio/SystemMediaProgressSyncController.kt b/app/src/main/java/com/cpu/seamlessloopmobile/audio/SystemMediaProgressSyncController.kt index 1f219e1..69d0721 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/audio/SystemMediaProgressSyncController.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/audio/SystemMediaProgressSyncController.kt @@ -27,8 +27,18 @@ internal class SystemMediaProgressSyncController( private val intervalMs: Long = 250L, private val onSyncTick: () -> Unit ) { + private var shouldDispatch: () -> Boolean = { true } private var syncJob: Job? = null + constructor( + scope: CoroutineScope, + intervalMs: Long = 250L, + shouldDispatch: () -> Boolean, + onSyncTick: () -> Unit + ) : this(scope, intervalMs, onSyncTick) { + this.shouldDispatch = shouldDispatch + } + val isRunning: Boolean get() = syncJob?.isActive == true @@ -48,11 +58,13 @@ internal class SystemMediaProgressSyncController( if (syncJob?.isActive == true) return syncJob = scope.launch { - // Sync once immediately after resume, then stay within the chosen 250ms budget. + // Sync once immediately after resume, then gate periodic ticks through shouldDispatch. onSyncTick() while (isActive) { delay(intervalMs) - onSyncTick() + if (shouldDispatch()) { + onSyncTick() + } } } } diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/audio/effects/AudioEffectsConfig.kt b/app/src/main/java/com/cpu/seamlessloopmobile/audio/effects/AudioEffectsConfig.kt new file mode 100644 index 0000000..ce17f3d --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/audio/effects/AudioEffectsConfig.kt @@ -0,0 +1,32 @@ +package com.cpu.seamlessloopmobile.audio.effects + +/** + * Desired audio-effects configuration. + * + * @param speed Playback speed multiplier (1.0 = normal). + * @param pitch Playback pitch multiplier (1.0 = normal). + * @param equalizerEnabled Whether the built-in equalizer is active. + * @param equalizerPresetId Stable identifier for a preset curve. + * @param customBandLevelsMb Per-band gain levels in millibels, ordered by band index. + * @param loudnessGainMb Loudness-enhancer gain in millibels. + */ +data class AudioEffectsConfig( + val speed: Float = 1.0f, + val pitch: Float = 1.0f, + val equalizerEnabled: Boolean = false, + val equalizerPresetId: String = PRESET_FLAT, + val customBandLevelsMb: List = emptyList(), + val loudnessGainMb: Int = 0 +) { + companion object { + const val PRESET_FLAT = "flat" + const val PRESET_CUSTOM = "custom" + const val PRESET_BASS_BOOST = "bass_boost" + const val PRESET_TREBLE_BOOST = "treble_boost" + const val PRESET_ROCK = "rock" + const val PRESET_POP = "pop" + const val PRESET_CLASSICAL = "classical" + const val PRESET_JAZZ = "jazz" + const val PRESET_VOCAL_BOOST = "vocal_boost" + } +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/audio/effects/AudioEffectsState.kt b/app/src/main/java/com/cpu/seamlessloopmobile/audio/effects/AudioEffectsState.kt new file mode 100644 index 0000000..9491821 --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/audio/effects/AudioEffectsState.kt @@ -0,0 +1,18 @@ +package com.cpu.seamlessloopmobile.audio.effects + +/** + * Observable snapshot of the audio-effects subsystem. + * + * @param appliedConfig The config currently applied to hardware or stored in the controller. + * @param equalizerAvailable Whether a hardware equalizer is present on this device. + * @param loudnessEnhancerAvailable Whether a hardware loudness enhancer is present. + * @param availableBands List of equalizer bands the hardware supports. + * @param attachedAudioSessionId The audio session ID the controller is bound to, or null. + */ +data class AudioEffectsState( + val appliedConfig: AudioEffectsConfig = AudioEffectsConfig(), + val equalizerAvailable: Boolean = false, + val loudnessEnhancerAvailable: Boolean = false, + val availableBands: List = emptyList(), + val attachedAudioSessionId: Int? = null +) diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/audio/effects/EqualizerBand.kt b/app/src/main/java/com/cpu/seamlessloopmobile/audio/effects/EqualizerBand.kt new file mode 100644 index 0000000..91d471e --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/audio/effects/EqualizerBand.kt @@ -0,0 +1,14 @@ +package com.cpu.seamlessloopmobile.audio.effects + +/** + * Represents a single equalizer band with a center frequency and gain level. + * + * @param index Zero-based Android Equalizer band index. + * @param frequencyHz Center frequency of the band in Hertz. + * @param gainMb Gain in millibels (mB). 0 mB means no adjustment. + */ +data class EqualizerBand( + val index: Int, + val frequencyHz: Int, + val gainMb: Int +) diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/audio/effects/IAudioEffectsController.kt b/app/src/main/java/com/cpu/seamlessloopmobile/audio/effects/IAudioEffectsController.kt new file mode 100644 index 0000000..375cc8d --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/audio/effects/IAudioEffectsController.kt @@ -0,0 +1,27 @@ +package com.cpu.seamlessloopmobile.audio.effects + +import kotlinx.coroutines.flow.StateFlow + +/** + * Interface for controlling audio effects (speed, pitch, equalizer, loudness). + * Implementations may be real (backed by Android audio effects) or no-op. + */ +interface IAudioEffectsController { + /** Observable state of effects subsystem. */ + val state: StateFlow + + /** + * Updates the desired configuration and returns the resulting state. + * The implementation may clamp or ignore unsupported values. + */ + fun updateConfig(config: AudioEffectsConfig): AudioEffectsState + + /** + * Attaches or detaches the controller to/from a given audio session. + * Returns the resulting state after attachment. + */ + fun attachAudioSessionId(sessionId: Int?): AudioEffectsState + + /** Releases all held resources (audio effect sessions, etc.). After this the controller is unusable. */ + fun release() +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/audio/effects/NoOpAudioEffectsController.kt b/app/src/main/java/com/cpu/seamlessloopmobile/audio/effects/NoOpAudioEffectsController.kt new file mode 100644 index 0000000..0968fd0 --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/audio/effects/NoOpAudioEffectsController.kt @@ -0,0 +1,32 @@ +package com.cpu.seamlessloopmobile.audio.effects + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * No-op implementation of [IAudioEffectsController] that never claims hardware + * capabilities. It stores the last config and session ID in state but does not + * interact with Android audio effects. + */ +class NoOpAudioEffectsController : IAudioEffectsController { + + private val _state = MutableStateFlow(AudioEffectsState()) + override val state: StateFlow = _state.asStateFlow() + + override fun updateConfig(config: AudioEffectsConfig): AudioEffectsState { + val updated = _state.value.copy(appliedConfig = config) + _state.value = updated + return updated + } + + override fun attachAudioSessionId(sessionId: Int?): AudioEffectsState { + val updated = _state.value.copy(attachedAudioSessionId = sessionId) + _state.value = updated + return updated + } + + override fun release() { + _state.value = AudioEffectsState() + } +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/audio/stats/PlaybackStatsTracker.kt b/app/src/main/java/com/cpu/seamlessloopmobile/audio/stats/PlaybackStatsTracker.kt new file mode 100644 index 0000000..3d81c4c --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/audio/stats/PlaybackStatsTracker.kt @@ -0,0 +1,170 @@ +package com.cpu.seamlessloopmobile.audio.stats + +import android.os.SystemClock +import com.cpu.seamlessloopmobile.data.stats.ListenStatsRepository +import com.cpu.seamlessloopmobile.data.stats.ListenStatsWriteFence +import com.cpu.seamlessloopmobile.data.stats.TrackStat +import com.cpu.seamlessloopmobile.model.Song + +/** + * Tracks real wall-clock listened time while [AudioPlayState.PLAYING]. + * + * Pure enough for unit testing — inject a custom [timeSource] (e.g. + * a fake that returns controlled values) and a [repository] backed by + * a temporary file or in-memory store. + * + * Lifecycle contract: + * 1. Call [onSongChanged] when the current song changes (even if paused). + * 2. Call [onPlayingChanged(true)] when playback starts. + * 3. Call [onPlayingChanged(false)] when playback pauses/stops/errors. + * 4. Call [flushPeriodic] from a ~15s timer while playing to persist + * accumulated time. + * 5. Call [flushFinal] when the service is destroyed or the session ends. + * + * @param repository The persistence layer. + * @param timeSource A millisecond monotonic clock; default [SystemClock.elapsedRealtime]. + */ +class PlaybackStatsTracker( + private val repository: ListenStatsRepository, + private val timeSource: () -> Long = { SystemClock.elapsedRealtime() } +) { + /** True when the tracker is actively accumulating wall-clock time. */ + var isTracking: Boolean = false + private set + + private var currentStat: TrackStat? = null + // This is always the exact recording identity derived from the current Song; + // it must not use a bound presentation identity from allStats. + private var currentIdentityKey: String? = null + private var sessionStartElapsed: Long = 0L + private var accumulatedThisSession: Long = 0L + private var writeFence: ListenStatsWriteFence? = null + + // --- Public API ---------------------------------------------------------- + + /** + * Notifies the tracker that the current song has changed. + * Any in-flight accumulation for the previous song is flushed first. + */ + fun onSongChanged(song: Song) { + val nextStat = buildTrackStat(song) + if (nextStat.identityKey == currentIdentityKey) { + currentStat = nextStat + return + } + + val wasTracking = isTracking + collectActiveSegment() + persistAccumulatedDelta() + currentStat = nextStat + currentIdentityKey = nextStat.identityKey + + if (wasTracking) { + isTracking = true + sessionStartElapsed = timeSource() + writeFence = captureWriteFence() + } + } + + /** + * Notifies the tracker that playback has started or stopped. + * + * @param playing `true` when transitioning to PLAYING, `false` otherwise. + */ + fun onPlayingChanged(playing: Boolean) { + if (playing) { + if (currentStat != null && !isTracking) { + isTracking = true + sessionStartElapsed = timeSource() + writeFence = captureWriteFence() + } + } else { + if (isTracking) { + collectActiveSegment() + isTracking = false + persistAccumulatedDelta() + } + } + } + + /** + * Persists the current session's accumulated time to the repository. + * Safe to call while tracking is active; does not reset the session clock. + */ + fun flushPeriodic() { + collectActiveSegment() + persistAccumulatedDelta() + } + + /** + * Flushes the final accumulated delta and resets the session. + * Call this when the song ends, the service stops, or the app is destroyed. + */ + fun flushFinal() { + collectActiveSegment() + persistAccumulatedDelta() + isTracking = false + currentStat = null + currentIdentityKey = null + writeFence = null + } + + /** + * Discards unpersisted time after statistics are cleared externally. + * Active tracking continues from the clear point instead of restoring pre-clear time. + */ + fun onStatsCleared() { + accumulatedThisSession = 0L + writeFence = captureWriteFence() + if (isTracking) { + sessionStartElapsed = timeSource() + } + } + + /** Returns `true` if periodic flushes should be scheduled (tracking is active). */ + fun shouldFlushPeriodically(): Boolean = isTracking + + // --- Internal helpers ---------------------------------------------------- + + private fun collectActiveSegment() { + if (!isTracking || currentStat == null) return + val now = timeSource() + val elapsed = now - sessionStartElapsed + accumulatedThisSession += elapsed.coerceAtLeast(0L) + sessionStartElapsed = now + } + + private fun persistAccumulatedDelta() { + val delta = accumulatedThisSession + val stat = currentStat ?: return + accumulatedThisSession = 0L + + if (delta > 0L) { + kotlinx.coroutines.runBlocking { + repository.recordListenDeltaNow(stat, delta, writeFence) + } + } + } + + private fun captureWriteFence(): ListenStatsWriteFence = kotlinx.coroutines.runBlocking { + repository.captureWriteFence() + } + + private fun buildTrackStat(song: Song): TrackStat { + val fileName = song.fileName + val filePath = song.filePath + val durationMs = song.duration.coerceAtLeast(0L) + return TrackStat( + songId = song.id, + displayName = song.displayName, + fileName = fileName, + artist = song.artist, + album = song.album, + coverPath = song.coverPath, + durationMs = durationMs, + filePath = filePath, + // Playback recording remains keyed by the exact wire identity. + identityKey = TrackStat.identityKey(fileName, durationMs, filePath) + ) + } +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/audio/timer/SleepTimerManager.kt b/app/src/main/java/com/cpu/seamlessloopmobile/audio/timer/SleepTimerManager.kt new file mode 100644 index 0000000..27fddc6 --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/audio/timer/SleepTimerManager.kt @@ -0,0 +1,151 @@ +package com.cpu.seamlessloopmobile.audio.timer + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * Manages sleep-timer countdown logic independently of [android.content.Context] + * or [com.cpu.seamlessloopmobile.data.SettingsManager]. + * + * @param scope CoroutineScope used for the internal countdown job. + * @param onTimerExpired Callback invoked when the timer naturally expires + * (COUNTDOWN reaches zero, or FINISH_CURRENT/FINISH_PLAYLIST + * triggers via [shouldStopOnTrackEnd]). + */ +class SleepTimerManager( + private val scope: CoroutineScope, + private val onTimerExpired: () -> Unit, + private val millisPerMinute: Long = 60_000L, + private val tickIntervalMs: Long = 250L +) { + private val _timerState = MutableStateFlow(SleepTimerState()) + val timerState: StateFlow = _timerState.asStateFlow() + + private var countdownJob: Job? = null + + /** + * Starts a countdown-based sleep timer. + * Clamps [minutes] to be positive (values <= 0 are treated as no-op). + */ + fun startCountdown(minutes: Int) { + if (minutes <= 0) return + cancel() + + val totalMs = minutes.toLong() * millisPerMinute.coerceAtLeast(1L) + _timerState.value = SleepTimerState( + isActive = true, + mode = SleepTimerMode.COUNTDOWN, + remainingMillis = totalMs, + totalMillis = totalMs + ) + + countdownJob = scope.launch { + var remaining = totalMs + while (isActive && remaining > 0L) { + val delayMillis = minOf(tickIntervalMs.coerceAtLeast(1L), remaining) + delay(delayMillis) + remaining -= delayMillis + _timerState.value = _timerState.value.copy(remainingMillis = remaining) + } + if (isActive && remaining <= 0L) { + _timerState.value = SleepTimerState() + onTimerExpired() + } + } + } + + /** + * Starts a sleep timer that expires after the current track finishes. + * Remaining time is set to a sentinel value of -1 to indicate indefinite + * track-bound mode; [shouldStopOnTrackEnd] handles the actual trigger. + */ + fun startFinishCurrent() { + cancel() + _timerState.value = SleepTimerState( + isActive = true, + mode = SleepTimerMode.FINISH_CURRENT, + remainingMillis = -1L, + totalMillis = -1L + ) + } + + /** + * Starts a sleep timer that expires after the playlist finishes. + * Remaining time is set to a sentinel value of -1. + */ + fun startFinishPlaylist() { + cancel() + _timerState.value = SleepTimerState( + isActive = true, + mode = SleepTimerMode.FINISH_PLAYLIST, + remainingMillis = -1L, + totalMillis = -1L + ) + } + + /** Cancels any active sleep timer and resets state. */ + fun cancel() { + countdownJob?.cancel() + countdownJob = null + _timerState.value = SleepTimerState() + } + + /** + * Called when a track ends or the playlist advances. + * Returns `true` if the sleep timer should stop playback. + * + * @param isLastInPlaylist Whether the current track is the last one + * in the active playlist/queue. + */ + fun shouldStopOnTrackEnd(isLastInPlaylist: Boolean): Boolean { + val state = _timerState.value + if (!state.isActive) return false + return when (state.mode) { + SleepTimerMode.COUNTDOWN -> { + // COUNTDOWN mode stopped playback when the countdown reached zero; + // this guard is a safety net in case the timer expired mid-track. + if (state.remainingMillis <= 0L) { + cancel() + onTimerExpired() + true + } else false + } + SleepTimerMode.FINISH_CURRENT -> { + cancel() + onTimerExpired() + true + } + SleepTimerMode.FINISH_PLAYLIST -> { + if (isLastInPlaylist) { + cancel() + onTimerExpired() + true + } else false + } + } + } + + /** + * Formats the remaining time as "MM:SS" or "--:--" for track/playlist-bound modes. + */ + fun formatRemainingTime(): String { + val state = _timerState.value + if (!state.isActive) return "" + return when (state.mode) { + SleepTimerMode.COUNTDOWN -> { + val totalSec = state.remainingMillis / 1000L + val min = totalSec / 60L + val sec = totalSec % 60L + "%02d:%02d".format(min, sec) + } + SleepTimerMode.FINISH_CURRENT, + SleepTimerMode.FINISH_PLAYLIST -> "--:--" + } + } +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/audio/timer/SleepTimerMode.kt b/app/src/main/java/com/cpu/seamlessloopmobile/audio/timer/SleepTimerMode.kt new file mode 100644 index 0000000..261846e --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/audio/timer/SleepTimerMode.kt @@ -0,0 +1,15 @@ +package com.cpu.seamlessloopmobile.audio.timer + +/** + * Defines how the sleep timer behaves once the trigger condition is met. + */ +enum class SleepTimerMode { + /** Countdown expires and playback stops immediately. */ + COUNTDOWN, + + /** Stops after the currently playing track finishes. */ + FINISH_CURRENT, + + /** Stops after the entire playlist reaches its end. */ + FINISH_PLAYLIST +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/audio/timer/SleepTimerState.kt b/app/src/main/java/com/cpu/seamlessloopmobile/audio/timer/SleepTimerState.kt new file mode 100644 index 0000000..b3efbe5 --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/audio/timer/SleepTimerState.kt @@ -0,0 +1,11 @@ +package com.cpu.seamlessloopmobile.audio.timer + +/** + * Immutable snapshot of the sleep timer at a point in time. + */ +data class SleepTimerState( + val isActive: Boolean = false, + val mode: SleepTimerMode = SleepTimerMode.COUNTDOWN, + val remainingMillis: Long = 0L, + val totalMillis: Long = 0L +) diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/data/MusicScannerRepository.kt b/app/src/main/java/com/cpu/seamlessloopmobile/data/MusicScannerRepository.kt index c325c00..18a0d00 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/data/MusicScannerRepository.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/data/MusicScannerRepository.kt @@ -254,6 +254,10 @@ class MusicScannerRepository(private val songDao: SongDao) { ?: song.albumEntity?.name?.lowercase()?.let { albumMap[it] } val resolvedMediaId = if (song.mediaId != 0L) song.mediaId else dbSong.mediaId val resolvedTotalSamples = song.totalSamples.takeIf { it > 0L } ?: dbSong.totalSamples + val resolvedCoverPath = song.coverPath ?: dbSong.coverPath + val resolvedMimeType = song.mimeType ?: dbSong.mimeType + val resolvedSampleRateHz = song.sampleRateHz ?: dbSong.sampleRateHz + val resolvedBitrateKbps = song.bitrateKbps ?: dbSong.bitrateKbps matchedDbIds.add(dbSong.id) @@ -261,7 +265,11 @@ class MusicScannerRepository(private val songDao: SongDao) { dbSong.filePath != song.filePath || dbSong.mediaId != resolvedMediaId || dbSong.duration != song.duration || - dbSong.totalSamples != resolvedTotalSamples + dbSong.totalSamples != resolvedTotalSamples || + dbSong.coverPath != resolvedCoverPath || + dbSong.mimeType != resolvedMimeType || + dbSong.sampleRateHz != resolvedSampleRateHz || + dbSong.bitrateKbps != resolvedBitrateKbps val pathOrNameChanged = dbSong.fileName != song.fileName || dbSong.filePath != song.filePath if (needLocationUpdate) { @@ -272,6 +280,10 @@ class MusicScannerRepository(private val songDao: SongDao) { mediaId = resolvedMediaId, duration = song.duration, totalSamples = resolvedTotalSamples, + coverPath = resolvedCoverPath, + mimeType = resolvedMimeType, + sampleRateHz = resolvedSampleRateHz, + bitrateKbps = resolvedBitrateKbps, lastModified = System.currentTimeMillis() ) if (pathOrNameChanged) pathUpdatedCount++ @@ -285,6 +297,10 @@ class MusicScannerRepository(private val songDao: SongDao) { // 性能极致优化看门狗:只有当歌手关联、专辑关联或 AB 段状态发生实际改变时,才刷写写盘更新喵! val needUpdate = dbSong.song.artistId != resolvedArtistId || dbSong.song.albumId != resolvedAlbumId || + dbSong.coverPath != resolvedCoverPath || + dbSong.mimeType != resolvedMimeType || + dbSong.sampleRateHz != resolvedSampleRateHz || + dbSong.bitrateKbps != resolvedBitrateKbps || dbSong.song.isAbPartB != song.isAbPartB if (needUpdate) { @@ -297,7 +313,10 @@ class MusicScannerRepository(private val songDao: SongDao) { artistId = resolvedArtistId, albumId = resolvedAlbumId, displayName = dbSong.displayName, - coverPath = dbSong.coverPath, + coverPath = resolvedCoverPath, + mimeType = resolvedMimeType, + sampleRateHz = resolvedSampleRateHz, + bitrateKbps = resolvedBitrateKbps, isAbPartB = song.isAbPartB )) } @@ -305,6 +324,10 @@ class MusicScannerRepository(private val songDao: SongDao) { id = dbSong.id, mediaId = resolvedMediaId, totalSamples = resolvedTotalSamples, + coverPath = resolvedCoverPath, + mimeType = resolvedMimeType, + sampleRateHz = resolvedSampleRateHz, + bitrateKbps = resolvedBitrateKbps, artistId = resolvedArtistId, albumId = resolvedAlbumId ))) diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/data/SettingsManager.kt b/app/src/main/java/com/cpu/seamlessloopmobile/data/SettingsManager.kt index 7d651c5..744c1f0 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/data/SettingsManager.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/data/SettingsManager.kt @@ -6,6 +6,12 @@ import com.cpu.seamlessloopmobile.viewmodel.PlayMode import com.google.gson.Gson import com.google.gson.reflect.TypeToken +enum class ThemePreference { + SYSTEM, + LIGHT, + DARK +} + /** * 莱芙的私人小本本 📒 * 负责记录 cpu 大人的所有偏好和上次的播放状态,哪怕 app 睡着了莱芙也能记得喵! @@ -24,6 +30,9 @@ class SettingsManager(context: Context) { private const val KEY_LIBRARY_STATS = "library_stats" private const val KEY_IS_SEAMLESS_LOOP_ENABLED = "is_seamless_loop_enabled" private const val KEY_SEAMLESS_LOOP_COUNT_LIMIT = "seamless_loop_count_limit" + private const val KEY_THEME_PREFERENCE = "theme_preference" + private const val KEY_DARK_THEME_OVERRIDE = "dark_theme_override" + private const val KEY_BUTTON_HAPTIC_FEEDBACK_ENABLED = "button_haptic_feedback_enabled" const val MAX_SEAMLESS_LOOP_COUNT_LIMIT = 9999 @Volatile @@ -46,6 +55,37 @@ class SettingsManager(context: Context) { get() = prefs.getInt(KEY_SEAMLESS_LOOP_COUNT_LIMIT, 0).coerceIn(0, MAX_SEAMLESS_LOOP_COUNT_LIMIT) set(value) = prefs.edit().putInt(KEY_SEAMLESS_LOOP_COUNT_LIMIT, value.coerceIn(0, MAX_SEAMLESS_LOOP_COUNT_LIMIT)).apply() + var themePreference: ThemePreference + get() { + val saved = prefs.getString(KEY_THEME_PREFERENCE, null) + if (saved != null) { + return runCatching { ThemePreference.valueOf(saved) }.getOrDefault(ThemePreference.SYSTEM) + } + return when (darkThemeOverride) { + true -> ThemePreference.DARK + false -> ThemePreference.LIGHT + null -> ThemePreference.SYSTEM + } + } + set(value) = prefs.edit().putString(KEY_THEME_PREFERENCE, value.name).apply() + + var buttonHapticFeedbackEnabled: Boolean + get() = prefs.getBoolean(KEY_BUTTON_HAPTIC_FEEDBACK_ENABLED, true) + set(value) = prefs.edit().putBoolean(KEY_BUTTON_HAPTIC_FEEDBACK_ENABLED, value).apply() + + var darkThemeOverride: Boolean? + get() = if (prefs.contains(KEY_DARK_THEME_OVERRIDE)) { + prefs.getBoolean(KEY_DARK_THEME_OVERRIDE, false) + } else { + null + } + set(value) { + prefs.edit().apply { + if (value == null) remove(KEY_DARK_THEME_OVERRIDE) else putBoolean(KEY_DARK_THEME_OVERRIDE, value) + apply() + } + } + var lastSongPath: String? get() = prefs.getString(KEY_LAST_SONG_PATH, null) set(value) = prefs.edit().putString(KEY_LAST_SONG_PATH, value).apply() diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/data/stats/ListenStatsPeriod.kt b/app/src/main/java/com/cpu/seamlessloopmobile/data/stats/ListenStatsPeriod.kt new file mode 100644 index 0000000..e4c5e1c --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/data/stats/ListenStatsPeriod.kt @@ -0,0 +1,10 @@ +package com.cpu.seamlessloopmobile.data.stats + +/** Calendar period used to query listening statistics. */ +enum class ListenStatsPeriod { + DAY, + WEEK, + MONTH, + YEAR, + ALL +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/data/stats/ListenStatsRepository.kt b/app/src/main/java/com/cpu/seamlessloopmobile/data/stats/ListenStatsRepository.kt new file mode 100644 index 0000000..29d221d --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/data/stats/ListenStatsRepository.kt @@ -0,0 +1,778 @@ +package com.cpu.seamlessloopmobile.data.stats + +import android.content.Context +import android.os.Build +import com.cpu.seamlessloopmobile.data.sync.SharedPreferencesGitHubSyncStore +import com.cpu.seamlessloopmobile.data.sync.SyncSongIdentity +import com.cpu.seamlessloopmobile.data.sync.reducePlaybackSongIdentity +import com.google.gson.Gson +import com.google.gson.JsonParser +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.time.Instant +import java.time.ZoneId + +/** + * Persists a versioned local contribution store while exposing aggregated [TrackStat]s. + * + * All mutations are serialised through a [Mutex] and run on [Dispatchers.IO]. + * + * @param jsonFile The file that holds the current object schema. + * @param gson Gson instance for serialisation. + * @param wallClockMillis Current wall-clock epoch milliseconds. + * @param zoneId Optional fixed zone used to assign deltas to local calendar days. + * @param zoneIdProvider Supplies the current system zone when [zoneId] is not fixed. + */ +class ListenStatsRepository( + private val jsonFile: File, + private val currentDeviceIdProvider: (() -> String)? = null, + private val currentDeviceDisplayNameProvider: (() -> String)? = null, + private val onMaterialMutation: (suspend () -> Unit)? = null, + private val gson: Gson = Gson(), + private val wallClockMillis: () -> Long = { System.currentTimeMillis() }, + private val zoneId: ZoneId? = null, + private val zoneIdProvider: () -> ZoneId = { ZoneId.systemDefault() } +) { + companion object { + @Volatile + private var instance: ListenStatsRepository? = null + private const val DEFAULT_LOCAL_DEVICE_ID = "local-device" + + fun getInstance(context: Context): ListenStatsRepository { + return instance ?: synchronized(this) { + instance ?: ListenStatsRepository( + jsonFile = File(context.applicationContext.filesDir, "listen_stats.json"), + currentDeviceIdProvider = { + SharedPreferencesGitHubSyncStore.getOrCreateDeviceId(context.applicationContext) + }, + currentDeviceDisplayNameProvider = { + listOf(Build.MANUFACTURER, Build.MODEL) + .map { it.orEmpty().trim() } + .filter { it.isNotBlank() } + .joinToString(" ") + .ifBlank { "Android device" } + }, + onMaterialMutation = { + SharedPreferencesGitHubSyncStore(context.applicationContext).markMutation() + } + ).also { instance = it } + } + } + } + + private val mutex = Mutex() + private var store = loadStoreFromDisk() + private var writeEpoch = 0L + private val _allStats = MutableStateFlow(store.toTrackStats()) + private val _clearEvents = MutableSharedFlow(extraBufferCapacity = 1) + + /** Observable presentation snapshot of all tracked songs. UI sorts and filters externally. */ + val allStats: StateFlow> = _allStats.asStateFlow() + + /** Emitted after statistics have been durably cleared from disk. */ + val clearEvents: SharedFlow = _clearEvents + + // --- Public API ---------------------------------------------------------- + + /** + * Records (adds or merges) a listened-time delta for the song identified + * by the exact normalized filename and raw duration from [stat]. + * the delta is accumulated into [TrackStat.totalListenMs] and the played-at + * timestamps are updated. Otherwise a new entry is added. + * + * @param stat A [TrackStat] built from the current [Song] + delta. + * @param listenedMs The wall-clock milliseconds to add (>= 0). + */ + suspend fun recordListenDeltaNow( + stat: TrackStat, + listenedMs: Long, + writeFence: ListenStatsWriteFence? = null + ): Boolean { + if (listenedMs <= 0L) return false + return withContext(Dispatchers.IO) { + val now = wallClockMillis() + val dailyDeltas = splitDeltaAcrossLocalDates( + end = Instant.ofEpochMilli(now), + listenedMs = listenedMs, + zone = zoneId ?: zoneIdProvider() + ) + mutex.withLock { + if (writeFence != null && !writeFence.matches(store, writeEpoch)) return@withLock false + val current = store.songs.toMutableList() + val wireIdentity = SyncSongIdentity( + fileName = stat.fileName, + durationMs = stat.durationMs, + normalizedFileName = normalizedStatsFileName(stat.fileName) + ) + val wireKey = wireIdentity.stableKeyForLocalNode() + val idx = current.indexOfFirst { it.wireKey() == wireKey } + if (idx >= 0) { + val existing = current[idx] + val contributionIndex = existing.contributions.indexOfFirst { + it.deviceId == store.currentDeviceId && it.generation == store.currentGeneration + } + val contributions = existing.contributions.toMutableList() + val existingContribution = contributions.getOrNull(contributionIndex) + ?: ListenStatsContribution(store.currentDeviceId, store.currentGeneration) + val updatedContribution = existingContribution.copy( + dailyListenMs = existingContribution.dailyListenMs.withAddedDeltas(dailyDeltas), + firstPlayedAtUtcMs = existingContribution.firstPlayedAtUtcMs.takeIf { it > 0L } ?: now, + lastPlayedAtUtcMs = now, + updatedAtUtcMs = now + ) + if (contributionIndex >= 0) contributions[contributionIndex] = updatedContribution + else contributions.add(updatedContribution) + val reducedIdentity = reducePlaybackSongIdentity(listOf(existing.toSyncIdentity(), wireIdentity)) + current[idx] = existing.copy( + identityKey = reducedIdentity.localIdentityKey(), + normalizedFileName = reducedIdentity.normalizedFileName, + fileName = reducedIdentity.fileName, + durationMs = reducedIdentity.durationMs, + boundSongId = if (stat.songId > 0L) stat.songId else existing.boundSongId, + displayName = stat.displayName.ifEmpty { existing.displayName }, + artist = stat.artist.ifEmpty { existing.artist }, + album = stat.album.ifEmpty { existing.album }, + coverPath = stat.coverPath ?: existing.coverPath, + lastPlayedAt = now, + firstPlayedAt = if (existing.firstPlayedAt == 0L) now else existing.firstPlayedAt, + filePath = stat.filePath.ifEmpty { existing.filePath }, + totalSamples = reducedIdentity.totalSamples, + contentHash = reducedIdentity.contentHash, + contributions = contributions + ) + } else { + current.add( + ListenStatsSongNode( + identityKey = wireIdentity.localIdentityKey(), + normalizedFileName = wireIdentity.normalizedFileName, + boundSongId = stat.songId, + displayName = stat.displayName, + artist = stat.artist, + album = stat.album, + coverPath = stat.coverPath, + durationMs = stat.durationMs, + lastPlayedAt = now, + firstPlayedAt = now, + filePath = stat.filePath, + fileName = stat.fileName, + totalSamples = wireIdentity.totalSamples, + contentHash = wireIdentity.contentHash, + contributions = listOf( + ListenStatsContribution( + deviceId = store.currentDeviceId, + generation = store.currentGeneration, + dailyListenMs = dailyDeltas, + firstPlayedAtUtcMs = now, + lastPlayedAtUtcMs = now, + updatedAtUtcMs = now + ) + ) + ) + ) + } + store = store.copy(songs = current).normalized() + saveToDisk(store) + _allStats.value = store.toTrackStats() + onMaterialMutation?.invoke() + true + } + } + } + + /** Clears the current device by tombstoning its generation and rotating to a new one. */ + suspend fun clearAll() { + clearCurrentDeviceStats() + } + + /** Emits [clearEvents] only after tombstone and generation rotation are durable. */ + suspend fun clearCurrentDeviceStats(reason: String = "local_clear") { + withContext(Dispatchers.IO) { + mutex.withLock { + val now = wallClockMillis() + val tombstone = ListenStatsTombstone( + deviceId = store.currentDeviceId, + generation = store.currentGeneration, + tombstonedAtUtcMs = now, + operatorDeviceId = store.currentDeviceId, + reason = reason + ) + val nextGeneration = store.allKnownGenerations().let { + if (it == Long.MAX_VALUE) Long.MAX_VALUE else it + 1L + } + store = store.copy( + currentGeneration = nextGeneration, + tombstones = (store.tombstones + tombstone).distinctBy { it.deviceId to it.generation } + ) + writeEpoch = writeEpoch.saturatingAdd(1L) + saveToDiskOrThrow(store) + _allStats.value = store.toTrackStats() + onMaterialMutation?.invoke() + _clearEvents.tryEmit(Unit) + } + } + } + + /** Captures a fence which a later playback flush must still match to be accepted. */ + suspend fun captureWriteFence(): ListenStatsWriteFence = withContext(Dispatchers.IO) { + mutex.withLock { ListenStatsWriteFence(store.currentDeviceId, store.currentGeneration, writeEpoch) } + } + + suspend fun currentSource(): ListenStatsSource = withContext(Dispatchers.IO) { + mutex.withLock { + val device = store.devices.first { it.deviceId == store.currentDeviceId } + ListenStatsSource(device, store.currentGeneration, true) + } + } + + suspend fun knownSources(): List = withContext(Dispatchers.IO) { + mutex.withLock { + store.devices.map { device -> + ListenStatsSource(device, if (device.deviceId == store.currentDeviceId) store.currentGeneration else -1L, + device.deviceId == store.currentDeviceId) + } + } + } + + suspend fun exportLocalPayload(): ListenStatsLocalPayload = withContext(Dispatchers.IO) { + mutex.withLock { + ListenStatsLocalPayload( + currentDeviceId = store.currentDeviceId, + currentGeneration = store.currentGeneration, + devices = store.devices, + songs = store.songs, + tombstones = store.tombstones, + unresolvedNodes = store.unresolvedNodes + ) + } + } + + /** Applies a previously merged local payload; snapshot serialization remains outside this repository. */ + suspend fun applyLocalPayload( + payload: ListenStatsLocalPayload, + trackMutation: Boolean = true + ) { + withContext(Dispatchers.IO) { + mutex.withLock { + val merged = store.copy( + currentDeviceId = payload.currentDeviceId.ifBlank { store.currentDeviceId }, + currentGeneration = payload.currentGeneration.coerceAtLeast(store.currentGeneration), + devices = mergeDevices(store.devices, payload.devices), + // The payload can have been exported before a playback flush. Keep the + // current nodes too; normalized() collapses exact wire identities and + // takes the per-(device, generation) cumulative maximum. + songs = mergePayloadSongs(store.songs, payload.songs), + tombstones = mergeTombstones(store.tombstones, payload.tombstones), + unresolvedNodes = mergeUnresolvedNodes( + store.unresolvedNodes, + payload.unresolvedNodes, + payload.songs + ) + ).normalized() + val activeGenerationWasTombstoned = merged.tombstones.any { + it.deviceId == merged.currentDeviceId && it.generation == merged.currentGeneration + } + val nextStore = if (activeGenerationWasTombstoned) { + val nextGeneration = merged.allKnownGenerations().let { + if (it == Long.MAX_VALUE) Long.MAX_VALUE else it + 1L + } + writeEpoch = writeEpoch.saturatingAdd(1L) + merged.copy(currentGeneration = nextGeneration) + } else merged + if (nextStore != store) { + store = nextStore + saveToDiskOrThrow(store) + _allStats.value = store.toTrackStats() + if (trackMutation) onMaterialMutation?.invoke() + } + if (activeGenerationWasTombstoned) _clearEvents.tryEmit(Unit) + } + } + } + + /** + * Convenience accessor for querying a single song's stats by presentation + * or exact recording identity. Exact wire lookups for a bound node return + * that node's aggregated presentation row. + */ + suspend fun getByIdentityKey(identityKey: String): TrackStat? { + return withContext(Dispatchers.IO) { + mutex.withLock { + _allStats.value.find { it.identityKey == identityKey } + ?: store.songs.firstOrNull { it.identityKey == identityKey } + ?.let { node -> + if (node.boundSongId > 0L) { + _allStats.value.find { + it.identityKey == TrackStat.boundPresentationIdentityKey(node.boundSongId) + } + } else { + null + } + } + } + } + } + + // --- Internal IO ---------------------------------------------------------- + + private fun loadStoreFromDisk(): ListenStatsStore { + if (!jsonFile.exists() || jsonFile.length() == 0L) { + return newStore() + } + return try { + val text = jsonFile.readText() + val root = JsonParser.parseString(text) + if (!root.isJsonObject) return newStore() + val schema = root.asJsonObject.get("schemaVersion") + if (schema == null || schema.isJsonNull || !schema.isJsonPrimitive || + !schema.asJsonPrimitive.isNumber || + schema.asString.toIntOrNull() != ListenStatsStore.SCHEMA_VERSION + ) return newStore() + + val parsed = gson.fromJson(root, ListenStatsStore::class.java) ?: return newStore() + val normalized = parsed.normalized() + if (parsed.requiresDeviceDisplayNameBackfill()) saveToDiskOrThrow(normalized) + normalized + } catch (e: Exception) { + newStore() + } + } + + private fun newStore(): ListenStatsStore { + val deviceId = resolveCurrentDeviceId() + val now = wallClockMillis() + return ListenStatsStore( + devices = listOf( + ListenStatsDevice( + deviceId = deviceId, + displayName = resolveCurrentDeviceDisplayName(), + displayNameUpdatedAtUtcMs = now, + platform = "android", + currentGeneration = 0L, + createdAt = now, + lastSeenAt = now, + updatedAtUtcMs = now + ) + ), + currentDeviceId = deviceId, + currentGeneration = 0L + ) + } + + private fun ListenStatsStore.normalized(): ListenStatsStore { + val preferredDeviceId = resolveCurrentDeviceId() + val deviceId = preferredDeviceId.ifBlank { currentDeviceId }.ifBlank { resolveCurrentDeviceId() } + val existingDevice = devices.find { it.deviceId == deviceId } + val normalizedCurrentGeneration = currentGeneration.coerceAtLeast(0L) + val deviceList = if (existingDevice != null) { + devices.map { + if (it.deviceId == deviceId) { + it.copy( + displayName = it.displayName.ifBlank { resolveCurrentDeviceDisplayName() }, + displayNameUpdatedAtUtcMs = if (it.displayName.isBlank()) wallClockMillis() else it.displayNameUpdatedAtUtcMs, + platform = if (it.platform.isBlank()) "android" else it.platform, + currentGeneration = normalizedCurrentGeneration, + lastSeenAt = wallClockMillis(), + updatedAtUtcMs = wallClockMillis() + ) + } else { + it.copy( + displayName = it.displayName.ifBlank { fallbackDisplayName(it.deviceId) }, + displayNameUpdatedAtUtcMs = if (it.displayName.isBlank()) wallClockMillis() else it.displayNameUpdatedAtUtcMs + ) + } + } + } else { + devices + ListenStatsDevice( + deviceId = deviceId, + displayName = resolveCurrentDeviceDisplayName(), + displayNameUpdatedAtUtcMs = wallClockMillis(), + platform = "android", + currentGeneration = normalizedCurrentGeneration, + createdAt = wallClockMillis(), + lastSeenAt = wallClockMillis(), + updatedAtUtcMs = wallClockMillis() + ) + } + return copy( + schemaVersion = ListenStatsStore.SCHEMA_VERSION, + currentDeviceId = deviceId, + currentGeneration = normalizedCurrentGeneration, + devices = deviceList.sortedBy { it.deviceId }, + songs = collapseDuplicateSongNodes(songs), + tombstones = tombstones.sortedWith(compareBy { it.deviceId }.thenBy { it.generation }), + unresolvedNodes = unresolvedNodes.sortedWith(compareBy { it.normalizedFileName }.thenBy { it.durationMs }) + ) + } + + private fun mergeDevices( + current: List, + incoming: List + ): List = (current + incoming) + .groupBy { it.deviceId } + .values + .map { devices -> + devices.maxWithOrNull( + compareBy { it.updatedAtUtcMs } + .thenBy { it.lastSeenAt } + .thenBy { it.currentGeneration } + )!! + } + + private fun mergeTombstones( + current: List, + incoming: List + ): List = (current + incoming) + .groupBy { it.deviceId to it.generation } + .values + .map { tombstones -> tombstones.maxBy { it.tombstonedAtUtcMs } } + + /** + * The incoming payload owns relinking and presentation metadata. Current nodes only + * supply deltas recorded after export, merged under the existing cumulative-max rule. + */ + private fun mergePayloadSongs( + current: List, + incoming: List + ): List { + val currentByWire = collapseDuplicateSongNodes(current).associateBy { it.wireKey() } + val incomingByWire = collapseDuplicateSongNodes(incoming).associateBy { it.wireKey() } + return (currentByWire.keys + incomingByWire.keys).sortedWith( + compareBy> { it.first }.thenBy { it.second } + ).mapNotNull { wireKey -> + val currentNode = currentByWire[wireKey] + val incomingNode = incomingByWire[wireKey] + when { + incomingNode == null -> currentNode + currentNode == null -> incomingNode + else -> { + val identity = reducePlaybackSongIdentity( + listOf(currentNode.toSyncIdentity(), incomingNode.toSyncIdentity()) + ) + val contributions = mergeLocalContributions( + currentNode.contributions + incomingNode.contributions + ) + incomingNode.copy( + identityKey = identity.localIdentityKey(), + normalizedFileName = identity.normalizedFileName, + fileName = identity.fileName, + durationMs = identity.durationMs, + totalSamples = identity.totalSamples, + contentHash = identity.contentHash, + contributions = contributions, + firstPlayedAt = contributions.map { it.firstPlayedAtUtcMs } + .filter { it > 0L }.minOrNull() ?: 0L, + lastPlayedAt = contributions.maxOfOrNull { it.lastPlayedAtUtcMs } ?: 0L + ) + } + } + } + } + + private fun mergeUnresolvedNodes( + current: List, + incoming: List, + incomingSongs: List + ): List { + val resolvedWireKeys = incomingSongs.map { + normalizedStatsFileName(it.fileName) to it.durationMs + }.toSet() + return (current.filterNot { node -> + (normalizedStatsFileName(node.normalizedFileName) to node.durationMs) in resolvedWireKeys + } + incoming).distinct() + } + + private fun collapseDuplicateSongNodes( + nodes: List + ): List { + val prepared = nodes.map { node -> + val normalizedFileName = normalizedStatsFileName(node.fileName) + node.copy( + identityKey = localIdentityKey(normalizedFileName, node.durationMs), + normalizedFileName = normalizedFileName, + contributions = node.contributions.sortedWith( + compareBy { it.deviceId }.thenBy { it.generation } + ) + ) + } + return prepared.groupBy { it.wireKey() } + .toSortedMap(compareBy> { it.first }.thenBy { it.second }) + .values + .map { duplicates -> + val identity = reducePlaybackSongIdentity(duplicates.map { it.toSyncIdentity() }) + val binding = duplicates.minWithOrNull( + compareBy { if (it.boundSongId > 0L) 0 else 1 } + .thenBy { it.boundSongId } + .thenBy { it.filePath } + .thenBy { it.displayName } + )!! + val contributions = mergeLocalContributions( + duplicates.flatMap { it.contributions } + ) + binding.copy( + identityKey = identity.localIdentityKey(), + normalizedFileName = identity.normalizedFileName, + fileName = identity.fileName, + durationMs = identity.durationMs, + totalSamples = identity.totalSamples, + contentHash = identity.contentHash, + contributions = contributions, + firstPlayedAt = contributions.map { it.firstPlayedAtUtcMs } + .filter { it > 0L }.minOrNull() ?: 0L, + lastPlayedAt = contributions.maxOfOrNull { it.lastPlayedAtUtcMs } ?: 0L + ) + } + } + + private fun mergeLocalContributions( + contributions: List + ): List = contributions + .groupBy { it.deviceId to it.generation } + .toSortedMap(compareBy> { it.first }.thenBy { it.second }) + .values + .map { duplicates -> + duplicates.drop(1).fold(duplicates.first()) { first, second -> + ListenStatsContribution( + deviceId = first.deviceId, + generation = first.generation, + dailyListenMs = (first.dailyListenMs.keys + second.dailyListenMs.keys) + .associateWith { date -> + maxOf(first.dailyListenMs[date] ?: 0L, second.dailyListenMs[date] ?: 0L) + }, + undatedListenMs = maxOf(first.undatedListenMs, second.undatedListenMs), + firstPlayedAtUtcMs = earliestMeaningful( + first.firstPlayedAtUtcMs, + second.firstPlayedAtUtcMs + ), + lastPlayedAtUtcMs = maxOf(first.lastPlayedAtUtcMs, second.lastPlayedAtUtcMs), + updatedAtUtcMs = maxOf(first.updatedAtUtcMs, second.updatedAtUtcMs) + ) + } + } + + private fun ListenStatsSongNode.toSyncIdentity() = SyncSongIdentity( + fileName = fileName, + durationMs = durationMs, + totalSamples = totalSamples, + normalizedFileName = normalizedFileName, + contentHash = contentHash + ) + + private fun ListenStatsSongNode.wireKey(): Pair = + normalizedFileName to durationMs + + private fun SyncSongIdentity.stableKeyForLocalNode(): Pair = + normalizedFileName to durationMs + + private fun SyncSongIdentity.localIdentityKey(): String = + localIdentityKey(normalizedFileName, durationMs) + + private fun localIdentityKey(normalizedFileName: String, durationMs: Long): String = + "$normalizedFileName|$durationMs" + + private data class EffectiveSongStats( + val node: ListenStatsSongNode, + val dailyListenMs: Map, + val undatedListenMs: Long, + val firstPlayedAt: Long, + val lastPlayedAt: Long + ) + + private fun ListenStatsStore.toTrackStats(): List { + // Tombstones must be applied before bound nodes are combined. The + // backing store remains untouched so sync/export stays lossless. + val effectiveNodes = songs.mapNotNull { node -> + val contributions = node.contributions.filterNot { contribution -> + tombstones.any { it.deviceId == contribution.deviceId && it.generation == contribution.generation } + } + if (contributions.isEmpty()) return@mapNotNull null + + val daily = contributions.fold(emptyMap()) { total, contribution -> + total.withAddedDeltas(contribution.dailyListenMs) + } + val hasContributionFirstPlayedAt = node.contributions.any { + it.firstPlayedAtUtcMs > 0L + } + val hasContributionLastPlayedAt = node.contributions.any { + it.lastPlayedAtUtcMs > 0L + } + EffectiveSongStats( + node = node, + dailyListenMs = daily, + undatedListenMs = contributions.fold(0L) { total, contribution -> + total.saturatingAdd(contribution.undatedListenMs.coerceAtLeast(0L)) + }, + firstPlayedAt = contributions.map { it.firstPlayedAtUtcMs } + .filter { it > 0L } + .minOrNull() + ?: if (!hasContributionFirstPlayedAt) node.firstPlayedAt.takeIf { it > 0L } ?: 0L else 0L, + lastPlayedAt = contributions.map { it.lastPlayedAtUtcMs } + .filter { it > 0L } + .maxOrNull() + ?: if (!hasContributionLastPlayedAt) node.lastPlayedAt.takeIf { it > 0L } ?: 0L else 0L + ) + } + + val boundRows = effectiveNodes + .filter { it.node.boundSongId > 0L } + .groupBy { it.node.boundSongId } + .toSortedMap() + .values + .map { it.toBoundTrackStat() } + val unboundRows = effectiveNodes + .filter { it.node.boundSongId <= 0L } + .map { it.toTrackStat(it.node.identityKey) } + + return (boundRows + unboundRows).sortedBy { it.identityKey } + } + + private fun List.toBoundTrackStat(): TrackStat { + val representative = minWithOrNull( + compareBy { -bindingMetadataScore(it.node) } + .thenBy { it.node.identityKey } + .thenBy { it.node.filePath } + .thenBy { it.node.fileName } + )!! + val daily = fold(emptyMap()) { total, stats -> + total.withAddedDeltas(stats.dailyListenMs) + } + val undated = fold(0L) { total, stats -> total.saturatingAdd(stats.undatedListenMs) } + val firstPlayedAt = map { it.firstPlayedAt }.filter { it > 0L }.minOrNull() ?: 0L + val lastPlayedAt = maxOfOrNull { it.lastPlayedAt } ?: 0L + return representative.toTrackStat( + identityKey = TrackStat.boundPresentationIdentityKey(representative.node.boundSongId), + dailyListenMs = daily, + totalListenMs = daily.values.fold(0L) { total, value -> + total.saturatingAdd(value.coerceAtLeast(0L)) + }.saturatingAdd(undated) + .coerceAtLeast(0L), + firstPlayedAt = firstPlayedAt, + lastPlayedAt = lastPlayedAt + ) + } + + private fun EffectiveSongStats.toTrackStat( + identityKey: String, + dailyListenMs: Map = this.dailyListenMs, + totalListenMs: Long = this.effectiveTotalListenMs(), + firstPlayedAt: Long = this.firstPlayedAt, + lastPlayedAt: Long = this.lastPlayedAt + ): TrackStat = TrackStat( + songId = node.boundSongId, + displayName = node.displayName, + fileName = node.fileName, + artist = node.artist, + album = node.album, + coverPath = node.coverPath, + durationMs = node.durationMs, + totalListenMs = totalListenMs, + lastPlayedAt = lastPlayedAt, + firstPlayedAt = firstPlayedAt, + filePath = node.filePath, + identityKey = identityKey, + dailyListenMs = dailyListenMs + ) + + private fun EffectiveSongStats.effectiveTotalListenMs(): Long = + dailyListenMs.values.fold(0L) { total, value -> + total.saturatingAdd(value.coerceAtLeast(0L)) + }.saturatingAdd(undatedListenMs) + + private fun bindingMetadataScore(node: ListenStatsSongNode): Int = listOf( + node.displayName, + node.artist, + node.album, + node.coverPath.orEmpty(), + node.filePath, + node.fileName + ).count { it.isNotBlank() } + if (node.durationMs > 0L) 1 else 0 + + private fun saveToDisk(store: ListenStatsStore) { + try { + saveToDiskOrThrow(store) + } catch (e: Exception) { + // Swallow IO errors — stats are best-effort + } + } + + private fun saveToDiskOrThrow(store: ListenStatsStore) { + jsonFile.parentFile?.mkdirs() + val tempFile = File(jsonFile.parentFile, "${jsonFile.name}.tmp") + tempFile.writeText(gson.toJson(store.normalized())) + try { + Files.move(tempFile.toPath(), jsonFile.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + } catch (_: Exception) { + Files.move(tempFile.toPath(), jsonFile.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + } + + private fun splitDeltaAcrossLocalDates( + end: Instant, + listenedMs: Long, + zone: ZoneId + ): Map { + val start = end.minusMillis(listenedMs) + val deltas = mutableMapOf() + var cursor = start + while (cursor < end) { + val date = cursor.atZone(zone).toLocalDate() + val nextMidnight = date.plusDays(1).atStartOfDay(zone).toInstant() + val sliceEnd = if (nextMidnight < end) nextMidnight else end + val sliceMs = java.time.Duration.between(cursor, sliceEnd).toMillis() + deltas[date.toString()] = (deltas[date.toString()] ?: 0L).saturatingAdd(sliceMs) + cursor = sliceEnd + } + return deltas + } + + private fun Map.withAddedDeltas(deltas: Map): Map { + return toMutableMap().apply { + deltas.forEach { (dateKey, delta) -> + this[dateKey] = (this[dateKey] ?: 0L).coerceAtLeast(0L).saturatingAdd(delta) + } + } + } + + private fun Long.saturatingAdd(other: Long): Long { + return if (this > Long.MAX_VALUE - other) Long.MAX_VALUE else this + other + } + + private fun earliestMeaningful(first: Long, second: Long): Long = when { + first == 0L -> second + second == 0L -> first + else -> minOf(first, second) + } + + private fun ListenStatsWriteFence.matches(store: ListenStatsStore, currentEpoch: Long): Boolean = + deviceId == store.currentDeviceId && generation == store.currentGeneration && epoch == currentEpoch + + private fun ListenStatsStore.allKnownGenerations(): Long = buildList { + add(currentGeneration) + devices.forEach { add(it.currentGeneration) } + songs.flatMapTo(this) { node -> node.contributions.map { it.generation } } + tombstones.forEach { add(it.generation) } + }.maxOrNull() ?: 0L + + private fun resolveCurrentDeviceId(): String = + currentDeviceIdProvider?.invoke()?.takeIf { it.isNotBlank() } + ?: DEFAULT_LOCAL_DEVICE_ID + + private fun resolveCurrentDeviceDisplayName(): String = + currentDeviceDisplayNameProvider?.invoke()?.trim()?.takeIf { it.isNotBlank() } + ?: "Android device" + + private fun fallbackDisplayName(deviceId: String): String = + "Device ${deviceId.ifBlank { "unknown" }}" + + private fun ListenStatsStore.requiresDeviceDisplayNameBackfill(): Boolean = + devices.any { it.displayName.isBlank() } +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/data/stats/ListenStatsStore.kt b/app/src/main/java/com/cpu/seamlessloopmobile/data/stats/ListenStatsStore.kt new file mode 100644 index 0000000..1a3f42e --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/data/stats/ListenStatsStore.kt @@ -0,0 +1,111 @@ +package com.cpu.seamlessloopmobile.data.stats + +import java.text.Normalizer +import java.util.Locale + +/** Local-only persistence schema for playback statistics. */ +data class ListenStatsStore( + val schemaVersion: Int = SCHEMA_VERSION, + val devices: List = emptyList(), + val currentDeviceId: String = "", + val currentGeneration: Long = 0L, + val songs: List = emptyList(), + val tombstones: List = emptyList(), + val unresolvedNodes: List = emptyList() +) { + companion object { + const val SCHEMA_VERSION = 3 + } +} + +data class ListenStatsDevice( + val deviceId: String, + val displayName: String = "", + val displayNameUpdatedAtUtcMs: Long = 0L, + val platform: String = "android", + val appVersion: String = "", + val currentGeneration: Long = 0L, + val createdAt: Long = 0L, + val lastSeenAt: Long = 0L, + val updatedAtUtcMs: Long = 0L +) + +data class ListenStatsSongNode( + /** Canonical local key for immutable wire (normalized filename, duration) identity. */ + val identityKey: String, + /** Immutable wire identity fields. */ + val normalizedFileName: String, + val fileName: String = "", + /** Local Room binding; never part of the immutable wire identity. */ + val boundSongId: Long = 0L, + /** Optional immutable wire auxiliaries. */ + val totalSamples: Long? = null, + val contentHash: String? = null, + /** Local binding/presentation metadata, refreshed from the bound Room song. */ + val displayName: String = "", + val artist: String = "", + val album: String = "", + val coverPath: String? = null, + val durationMs: Long = 0L, + val lastPlayedAt: Long = 0L, + val firstPlayedAt: Long = 0L, + val filePath: String = "", + val contributions: List = emptyList() +) + +data class ListenStatsContribution( + val deviceId: String, + val generation: Long, + val dailyListenMs: Map = emptyMap(), + val undatedListenMs: Long = 0L, + val firstPlayedAtUtcMs: Long = 0L, + val lastPlayedAtUtcMs: Long = 0L, + val updatedAtUtcMs: Long = 0L +) + +data class ListenStatsTombstone( + val deviceId: String, + val generation: Long, + val tombstonedAtUtcMs: Long = 0L, + val operatorDeviceId: String = "", + val reason: String = "", + val scope: String = SCOPE_DEVICE_GENERATION +) { + companion object { + const val SCOPE_DEVICE_GENERATION = "device_generation" + } +} + +/** A lossless placeholder for a synced node that cannot yet be matched locally. */ +data class ListenStatsUnresolvedNode( + val normalizedFileName: String, + val durationMs: Long, + val payloadJson: String, + val receivedAt: Long = 0L +) + +fun normalizedStatsFileName(fileName: String): String = + Normalizer.normalize(fileName.trim(), Normalizer.Form.NFC).lowercase(Locale.ROOT) + +/** Local intermediate shape for later snapshot-store integration. */ +data class ListenStatsLocalPayload( + val currentDeviceId: String, + val currentGeneration: Long, + val devices: List, + val songs: List, + val tombstones: List, + val unresolvedNodes: List +) + +data class ListenStatsSource( + val device: ListenStatsDevice, + val currentGeneration: Long, + val isCurrentDevice: Boolean +) + +/** Captures the current local generation for rejecting stale playback flushes. */ +data class ListenStatsWriteFence internal constructor( + internal val deviceId: String, + internal val generation: Long, + internal val epoch: Long +) diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/data/stats/TrackStat.kt b/app/src/main/java/com/cpu/seamlessloopmobile/data/stats/TrackStat.kt new file mode 100644 index 0000000..e7496cc --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/data/stats/TrackStat.kt @@ -0,0 +1,99 @@ +package com.cpu.seamlessloopmobile.data.stats + +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.temporal.TemporalAdjusters + +/** + * Immutable snapshot of a single song's listening statistics. + * + * This is a presentation snapshot rebuilt from the persisted contribution + * store. Playback recording uses the exact wire identity derived from the + * current song; bound presentation rows may instead use a `bound-song:` + * identity after aggregation. + * + * @property songId Room SongEntity id (0 if unknown). + * @property displayName Human-readable song title. + * @property fileName Raw file name. + * @property artist Artist display name. + * @property album Album display name. + * @property coverPath Optional cover art path. + * @property durationMs Song duration in milliseconds. + * @property totalListenMs Cumulative wall-clock milliseconds spent listening. + * @property dailyListenMs Listened milliseconds keyed by ISO local date. This + * only includes deltas recorded after this field was added. + * @property lastPlayedAt Epoch millis of the most recent listen session end. + * @property firstPlayedAt Epoch millis of the first-ever listen session end. + * @property filePath Absolute file path on disk. + * @property identityKey Presentation identity. Exact recording identities + * use `"$fileName|$durationMs"` when [durationMs] > 0, + * otherwise [filePath]. Aggregated bound rows use + * `"bound-song:"`. + */ +data class TrackStat( + val songId: Long = 0L, + val displayName: String = "", + val fileName: String = "", + val artist: String = "", + val album: String = "", + val coverPath: String? = null, + val durationMs: Long = 0L, + val totalListenMs: Long = 0L, + val lastPlayedAt: Long = 0L, + val firstPlayedAt: Long = 0L, + val filePath: String = "", + val identityKey: String = "", + val dailyListenMs: Map = emptyMap() +) { + /** Returns listened milliseconds for [period] relative to [today]. */ + fun listenMsFor(period: ListenStatsPeriod, today: LocalDate): Long { + if (period == ListenStatsPeriod.ALL) return totalListenMs + + val startDate = when (period) { + ListenStatsPeriod.DAY -> today + ListenStatsPeriod.WEEK -> today.with( + TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY) + ) + ListenStatsPeriod.MONTH -> today.withDayOfMonth(1) + ListenStatsPeriod.YEAR -> today.withDayOfYear(1) + ListenStatsPeriod.ALL -> error("Handled above") + } + val endDate = when (period) { + ListenStatsPeriod.DAY -> today + ListenStatsPeriod.WEEK -> startDate.plusDays(6) + ListenStatsPeriod.MONTH -> today.withDayOfMonth(today.lengthOfMonth()) + ListenStatsPeriod.YEAR -> today.withDayOfYear(today.lengthOfYear()) + ListenStatsPeriod.ALL -> error("Handled above") + } + + return dailyListenMs.entries.fold(0L) { total, (dateKey, listenedMs) -> + val date = runCatching { LocalDate.parse(dateKey) }.getOrNull() + if (date != null && date in startDate..endDate && listenedMs > 0L) { + if (total > Long.MAX_VALUE - listenedMs) Long.MAX_VALUE else total + listenedMs + } else { + total + } + } + } + + companion object { + private const val BOUND_PRESENTATION_ID_PREFIX = "bound-song:" + + /** + * Builds the exact recording identity for [fileName] and [durationMs]. + * The recording identity is the normalized filename and duration when + * the duration is positive. Falls back to [filePath] otherwise. + */ + fun identityKey(fileName: String, durationMs: Long, filePath: String): String { + return if (durationMs > 0L) { + "${normalizedStatsFileName(fileName)}|$durationMs" + } else { + filePath + } + } + + /** Returns the explicit identity used for an aggregated bound row. */ + fun boundPresentationIdentityKey(songId: Long): String = + "$BOUND_PRESENTATION_ID_PREFIX$songId" + } +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/GitHubAutoSyncScheduler.kt b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/GitHubAutoSyncScheduler.kt new file mode 100644 index 0000000..b153581 --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/GitHubAutoSyncScheduler.kt @@ -0,0 +1,75 @@ +package com.cpu.seamlessloopmobile.data.sync + +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import java.util.concurrent.TimeUnit + +/** + * WorkManager 驱动的自动同步调度器。 + * + * 负责注册/取消后台定时同步任务,本身不持有业务逻辑。 + * 通过 [reconcile] 方法统一根据开关状态决定调度或取消。 + * + * @param workManager WorkManager 实例,通常来自 [android.content.Context] + */ +class GitHubAutoSyncScheduler(private val workManager: WorkManager) { + + companion object { + /** + * 完全限定的唯一 WorkManager 工作名称,避免命名冲突。 + */ + const val UNIQUE_WORK_NAME = "com.cpu.seamlessloopmobile.GITHUB_AUTO_SYNC_PERIODIC" + } + + /** + * 注册周期同步任务。 + * + * 使用 [ExistingPeriodicWorkPolicy.KEEP] 保留已有任务, + * 避免反复调用时意外重置调度周期。 + */ + fun schedule() { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val request = PeriodicWorkRequestBuilder( + 1, TimeUnit.HOURS + ) + .setConstraints(constraints) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + 30, TimeUnit.SECONDS + ) + .build() + + workManager.enqueueUniquePeriodicWork( + UNIQUE_WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + request + ) + } + + /** + * 取消周期同步任务。 + */ + fun cancel() { + workManager.cancelUniqueWork(UNIQUE_WORK_NAME) + } + + /** + * 根据开关状态统一调度或取消。 + * + * @param enabled true 时调度,false 时取消 + */ + fun reconcile(enabled: Boolean) { + if (enabled) { + schedule() + } else { + cancel() + } + } +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/GitHubAutoSyncWorker.kt b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/GitHubAutoSyncWorker.kt new file mode 100644 index 0000000..3f4eb6d --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/GitHubAutoSyncWorker.kt @@ -0,0 +1,110 @@ +package com.cpu.seamlessloopmobile.data.sync + +import android.content.Context +import android.util.Log +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.cpu.seamlessloopmobile.data.sync.github.GitHubContentsSyncBackend +import com.cpu.seamlessloopmobile.data.sync.room.RoomSyncSnapshotStore +import com.cpu.seamlessloopmobile.data.sync.room.SharedPreferencesPlaylistIdMapper +import com.cpu.seamlessloopmobile.db.AppDatabase +import com.cpu.seamlessloopmobile.data.stats.ListenStatsRepository +import kotlinx.coroutines.CancellationException + +/** + * WorkManager 驱动的后台自动同步 Worker。 + * + * 在 [doWork] 中自行构建完整同步链,不依赖 ViewModel 或外部注入, + * 通过 [applicationContext] 获取全部依赖。 + */ +class GitHubAutoSyncWorker( + appContext: Context, + workerParams: WorkerParameters +) : CoroutineWorker(appContext, workerParams) { + + companion object { + private const val TAG = "GitHubAutoSyncWorker" + } + + override suspend fun doWork(): Result { + val outcome = try { + // 1. 检查自动同步开关 —— 关闭则静默退出 + val syncStore = SharedPreferencesGitHubSyncStore(applicationContext) + if (!syncStore.isAutoSyncEnabled()) { + Log.d(TAG, "Auto sync disabled, skipping work") + return Result.success() + } + + // 2. 检查配置和 token + val config = syncStore.getConfig() + val token = syncStore.getToken() + if (config == null || token.isNullOrBlank()) { + Log.d(TAG, "Config or token missing, skipping work") + return Result.success() + } + + // 3. 构建同步链 + val database = AppDatabase.getDatabase(applicationContext) + val playlistIdMapper = SharedPreferencesPlaylistIdMapper(applicationContext) + val songDao = database.songDao() + val playlistDao = database.playlistDao() + val roomSyncSnapshotStore = RoomSyncSnapshotStore( + database = database, + songDao = songDao, + playlistDao = playlistDao, + playlistIdMapper = playlistIdMapper, + listenStatsRepository = ListenStatsRepository.getInstance(applicationContext) + ) + val backend = GitHubContentsSyncBackend( + config = config, + tokenProvider = syncStore, + serializer = SyncSnapshotSerializer() + ) + // Worker 与手动同步各自构建 coordinator;跨入口并发依赖远端修订检查与下次周期重试收敛。 + val coordinator = GitHubSyncCoordinator( + backend = backend, + snapshotStore = roomSyncSnapshotStore, + metadataStore = syncStore + ) + + // 4. 执行同步 + coordinator.syncNow() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Sync failed with unexpected exception", e) + return Result.retry() + } + + // 5. 处理结果 + return when (outcome) { + is SyncOutcome.Success -> { + Log.i(TAG, "Auto sync succeeded, revision: ${outcome.remoteRevision}") + Result.success() + } + is SyncOutcome.Cancelled -> { + Log.d(TAG, "Auto sync cancelled") + Result.success() + } + is SyncOutcome.LocalMutationDuringSync -> { + Log.w(TAG, "Auto sync: local mutation during sync, will retry on next cycle") + Result.success() + } + is SyncOutcome.Failure -> { + Log.w(TAG, "Auto sync failed: ${outcome.code} - ${outcome.message}") + when (outcome.code) { + SyncErrorCode.NETWORK, + SyncErrorCode.CONFLICT, + SyncErrorCode.UNKNOWN -> Result.retry() + SyncErrorCode.NOT_CONFIGURED, + SyncErrorCode.UNAUTHORIZED, + SyncErrorCode.INVALID_REMOTE, + SyncErrorCode.NOT_FOUND -> { + Log.e(TAG, "Non-retryable sync failure: ${outcome.code}") + Result.success() + } + } + } + } + } +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/GitHubSyncConfig.kt b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/GitHubSyncConfig.kt new file mode 100644 index 0000000..237848f --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/GitHubSyncConfig.kt @@ -0,0 +1,18 @@ +package com.cpu.seamlessloopmobile.data.sync + +/** + * GitHub 同步配置。 + * + * Token 不放在这里,由 [GitHubTokenProvider] 单独提供,避免配置对象在 UI 层传递时泄露凭据。 + */ +data class GitHubSyncConfig( + val owner: String, + val repo: String, + val branch: String = "main", + val path: String = "seamless-loop/sync.json" +) { + companion object { + const val DEFAULT_BRANCH = "main" + const val DEFAULT_PATH = "seamless-loop/sync.json" + } +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/GitHubSyncConfigStore.kt b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/GitHubSyncConfigStore.kt new file mode 100644 index 0000000..925c0a6 --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/GitHubSyncConfigStore.kt @@ -0,0 +1,17 @@ +package com.cpu.seamlessloopmobile.data.sync + +/** + * GitHub 同步配置存储接口。 + * + * UI 配置页后续会通过该接口保存 owner/repo/branch/path,后端实例化时读取它。 + */ +interface GitHubSyncConfigStore { + /** 获取当前 GitHub 同步配置;未配置时返回 null。 */ + suspend fun getConfig(): GitHubSyncConfig? + + /** 保存 GitHub 同步配置。 */ + suspend fun saveConfig(config: GitHubSyncConfig) + + /** 清除 GitHub 同步配置。 */ + suspend fun clearConfig() +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/GitHubSyncCoordinator.kt b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/GitHubSyncCoordinator.kt new file mode 100644 index 0000000..e05ad2f --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/GitHubSyncCoordinator.kt @@ -0,0 +1,274 @@ +package com.cpu.seamlessloopmobile.data.sync + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * 同步操作的最终结果密封类。 + */ +sealed class SyncOutcome { + /** + * 同步成功。 + * @param report 合并与应用报告 + * @param remoteRevision 同步完成后远程端的最新修订版本 + */ + data class Success( + val report: SyncReport, + val remoteRevision: String + ) : SyncOutcome() + + /** + * 同步失败。 + * @param code 错误码 + * @param message 人类可读的错误描述 + * @param throwable 原始异常(可选) + */ + data class Failure( + val code: SyncErrorCode, + val message: String, + val throwable: Throwable? = null + ) : SyncOutcome() + + /** + * 同步期间本地数据发生变更,中止应用/上传。 + * 调用方可选择重新开始同步。 + */ + data object LocalMutationDuringSync : SyncOutcome() + + /** 同步被取消。 */ + data object Cancelled : SyncOutcome() +} + +/** + * GitHub 同步协调器。 + * + * 编排同步全流程:导出本地 → 下载远端 → 合并 → 应用 → 上传, + * 利用 [SyncBackend] 的 expectedRevision 机制和 [SyncMetadataStore] + * 的 mutationVersion 实现安全同步。 + * + * 使用 [Mutex] 保证同一时刻只有一个同步操作在执行。 + * + * @param backend 后端通信接口 + * @param snapshotStore 本地快照导出/应用接口 + * @param metadataStore 同步元数据持久化接口 + */ +class GitHubSyncCoordinator( + private val backend: SyncBackend, + private val snapshotStore: SyncSnapshotStore, + private val metadataStore: SyncMetadataStore +) { + private val mutex = Mutex() + private val mergeEngine = SyncMergeEngine() + + /** + * 执行一次完整的同步流程。 + * + * 流程: + * 1. 记录当前 mutationVersion + * 2. 导出本地快照 + * 3. 下载远端快照 + * 4. 若 NOT_FOUND → 直接上传本地快照(初次同步) + * 5. 若非 NOT_FOUND 的下载失败 → 返回 Failure + * 6. 合并远端与本地 + * 7. 检查 mutationVersion 是否改变 → 若变则返回 LocalMutationDuringSync + * 8. 应用合并后快照到本地 + * 9. 上传合并后快照,携带远端修订版本用于乐观锁 + * 10. 若 CONFLICT → 重试(最多 maxConflictRetries 次) + * 11. 成功 → 记录同步元数据,返回 Success + * + * @param maxConflictRetries 遇到 CONFLICT 时的最大重试次数 + * @return 同步结果 + */ + suspend fun syncNow(maxConflictRetries: Int = 3): SyncOutcome { + return mutex.withLock { + syncInternal(maxConflictRetries) + } + } + + private suspend fun syncInternal(maxConflictRetries: Int): SyncOutcome { + // 1. 记录初始 mutationVersion + val initialMutationVersion = metadataStore.getMutationVersion() + val deviceId = metadataStore.getDeviceId() + + // 2. 导出本地快照 + val localSnapshot = snapshotStore.exportSnapshot(deviceId) + + // 3. 下载远端快照 + val downloadResult = backend.downloadSnapshot() + + if (downloadResult is SyncResult.Failure) { + // 4. NOT_FOUND → 初次上传 + if (downloadResult.code == SyncErrorCode.NOT_FOUND) { + return initialUpload(localSnapshot, initialMutationVersion) + } + // 5. 其他错误 + return SyncOutcome.Failure( + code = downloadResult.code, + message = "Download failed: ${downloadResult.message}", + throwable = downloadResult.throwable + ) + } + + if (downloadResult is SyncResult.Cancelled) { + return SyncOutcome.Cancelled + } + + val (remoteSnapshot, remoteRevision) = when (downloadResult) { + is SyncResult.Success -> downloadResult.snapshot to downloadResult.remoteRevision + else -> return SyncOutcome.Failure( + SyncErrorCode.UNKNOWN, "Unexpected download result: $downloadResult" + ) + } + if (remoteSnapshot != null && remoteSnapshot.schemaVersion != SYNC_SCHEMA_VERSION_V2) { + return SyncOutcome.Failure( + SyncErrorCode.INVALID_REMOTE, + "Unsupported remote schema version ${remoteSnapshot.schemaVersion}" + ) + } + + // 6. 合并 + var retriesRemaining = maxConflictRetries + var currentRemoteRevision = remoteRevision + var currentRemoteSnapshot = remoteSnapshot + + while (true) { + val mergeResult = mergeEngine.merge(currentRemoteSnapshot, localSnapshot) + var report = mergeResult.report + + // 7. 检查并发修改 + if (metadataStore.getMutationVersion() != initialMutationVersion) { + return SyncOutcome.LocalMutationDuringSync + } + + // 8. 应用合并后快照到本地 + val applyReport = snapshotStore.applySnapshot( + mergeResult.snapshot, + trackLocalMutation = false + ) + // 合并 applyReport 中的冲突信息 + report = report.copy( + conflicts = report.conflicts + applyReport.conflicts + ) + + if (metadataStore.getMutationVersion() != initialMutationVersion) { + return SyncOutcome.LocalMutationDuringSync + } + + // 9. 上传合并后快照 + val preparedSnapshot = mergeResult.snapshot.prepareV2Egress() + if (metadataStore.getMutationVersion() != initialMutationVersion) { + return SyncOutcome.LocalMutationDuringSync + } + val uploadResult = backend.uploadSnapshot( + snapshot = preparedSnapshot, + expectedRevision = currentRemoteRevision + ) + + when (uploadResult) { + is SyncResult.Success -> { + val newRevision = uploadResult.remoteRevision + ?: return SyncOutcome.Failure( + SyncErrorCode.INVALID_REMOTE, + "Upload succeeded but no remote revision returned" + ) + + // 11. 记录成功同步 + metadataStore.saveSuccessfulSync( + remoteRevision = newRevision, + syncTime = System.currentTimeMillis() + ) + + return SyncOutcome.Success( + report = report, + remoteRevision = newRevision + ) + } + + is SyncResult.Failure -> { + if (uploadResult.code == SyncErrorCode.CONFLICT && retriesRemaining > 0) { + retriesRemaining-- + // 10. CONFLICT → 重新下载远端快照后重试 + val reDownload = backend.downloadSnapshot() + when (reDownload) { + is SyncResult.Success -> { + if (reDownload.snapshot != null && + reDownload.snapshot.schemaVersion != SYNC_SCHEMA_VERSION_V2 + ) { + return SyncOutcome.Failure( + SyncErrorCode.INVALID_REMOTE, + "Unsupported remote schema version ${reDownload.snapshot.schemaVersion}" + ) + } + currentRemoteSnapshot = reDownload.snapshot + currentRemoteRevision = reDownload.remoteRevision + // 继续循环 + } + is SyncResult.Cancelled -> return SyncOutcome.Cancelled + is SyncResult.Failure -> return SyncOutcome.Failure( + code = reDownload.code, + message = "Re-download after conflict failed: ${reDownload.message}", + throwable = reDownload.throwable + ) + } + } else { + return SyncOutcome.Failure( + code = uploadResult.code, + message = "Upload failed: ${uploadResult.message}", + throwable = uploadResult.throwable + ) + } + } + + is SyncResult.Cancelled -> return SyncOutcome.Cancelled + } + } + } + + /** + * 初次同步:远端不存在文件,直接上传本地快照。 + */ + private suspend fun initialUpload( + localSnapshot: SyncSnapshot, + initialMutationVersion: Int + ): SyncOutcome { + if (metadataStore.getMutationVersion() != initialMutationVersion) { + return SyncOutcome.LocalMutationDuringSync + } + + val preparedSnapshot = localSnapshot.prepareV2Egress() + val uploadResult = backend.uploadSnapshot( + snapshot = preparedSnapshot, + expectedRevision = null + ) + + return when (uploadResult) { + is SyncResult.Success -> { + val newRevision = uploadResult.remoteRevision + ?: return SyncOutcome.Failure( + SyncErrorCode.INVALID_REMOTE, + "Initial upload succeeded but no remote revision returned" + ) + + metadataStore.saveSuccessfulSync( + remoteRevision = newRevision, + syncTime = System.currentTimeMillis() + ) + + SyncOutcome.Success( + report = SyncReport( + playlistsUploaded = localSnapshot.playlists.size, + loopPointsUploaded = localSnapshot.loopPoints.size, + ratingsUploaded = localSnapshot.ratings.size + ), + remoteRevision = newRevision + ) + } + is SyncResult.Failure -> SyncOutcome.Failure( + code = uploadResult.code, + message = "Initial upload failed: ${uploadResult.message}", + throwable = uploadResult.throwable + ) + is SyncResult.Cancelled -> SyncOutcome.Cancelled + } + } +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/GitHubTokenProvider.kt b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/GitHubTokenProvider.kt new file mode 100644 index 0000000..b807ad6 --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/GitHubTokenProvider.kt @@ -0,0 +1,21 @@ +package com.cpu.seamlessloopmobile.data.sync + +/** + * GitHub Personal Access Token 提供者接口。 + * 实现类从安全存储(当前 MVP 使用 SharedPreferences)获取 token。 + */ +interface GitHubTokenProvider { + /** 获取当前 GitHub PAT,未配置时返回 null。 */ + suspend fun getToken(): String? +} + +/** + * GitHub Token 存储接口,扩展 [GitHubTokenProvider] 以支持写入/清除。 + */ +interface GitHubTokenStore : GitHubTokenProvider { + /** 持久化 token。 */ + suspend fun saveToken(token: String) + + /** 清除已存储的 token。 */ + suspend fun clearToken() +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SharedPreferencesGitHubSyncStore.kt b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SharedPreferencesGitHubSyncStore.kt new file mode 100644 index 0000000..ffcfb4a --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SharedPreferencesGitHubSyncStore.kt @@ -0,0 +1,161 @@ +package com.cpu.seamlessloopmobile.data.sync + +import android.content.Context +import java.util.UUID + +/** + * 自动同步开关存储接口。 + */ +interface AutoSyncEnabledStore { + /** 自动同步当前是否开启,默认 false。 */ + suspend fun isAutoSyncEnabled(): Boolean + + /** 设置自动同步开关状态。 */ + suspend fun setAutoSyncEnabled(enabled: Boolean) +} + +/** + * SharedPreferences 驱动的 GitHub token、配置及同步元数据存储。 + * + * ⚠️ 当前使用明文存储 token(MODE_PRIVATE),仅作为 MVP/开发实现。 + * TODO: 替换为 EncryptedSharedPreferences 或 Android KeyStore。 + * 参见 https://developer.android.com/privacy-and-security/security-crypto + */ +class SharedPreferencesGitHubSyncStore(context: Context) : + GitHubTokenStore, + GitHubSyncConfigStore, + SyncMetadataStore, + AutoSyncEnabledStore { + + private val prefs = context.applicationContext + .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + // ----------------------------------------------------------------------- + // GitHubTokenStore + // ----------------------------------------------------------------------- + + override suspend fun getToken(): String? = + prefs.getString(KEY_TOKEN, null) + + override suspend fun saveToken(token: String) { + prefs.edit().putString(KEY_TOKEN, token).apply() + } + + override suspend fun clearToken() { + prefs.edit().remove(KEY_TOKEN).apply() + } + + // ----------------------------------------------------------------------- + // GitHubSyncConfigStore + // ----------------------------------------------------------------------- + + override suspend fun getConfig(): GitHubSyncConfig? { + val owner = prefs.getString(KEY_REPO_OWNER, null)?.takeIf { it.isNotBlank() } + ?: return null + val repo = prefs.getString(KEY_REPO_NAME, null)?.takeIf { it.isNotBlank() } + ?: return null + val branch = prefs.getString(KEY_BRANCH, null)?.takeIf { it.isNotBlank() } + ?: GitHubSyncConfig.DEFAULT_BRANCH + val path = prefs.getString(KEY_PATH, null)?.takeIf { it.isNotBlank() } + ?: GitHubSyncConfig.DEFAULT_PATH + return GitHubSyncConfig( + owner = owner, + repo = repo, + branch = branch, + path = path + ) + } + + override suspend fun saveConfig(config: GitHubSyncConfig) { + prefs.edit() + .putString(KEY_REPO_OWNER, config.owner) + .putString(KEY_REPO_NAME, config.repo) + .putString(KEY_BRANCH, config.branch) + .putString(KEY_PATH, config.path) + .apply() + } + + override suspend fun clearConfig() { + prefs.edit() + .remove(KEY_REPO_OWNER) + .remove(KEY_REPO_NAME) + .remove(KEY_BRANCH) + .remove(KEY_PATH) + .apply() + } + + // ----------------------------------------------------------------------- + // 自动同步开关 + // ----------------------------------------------------------------------- + + override suspend fun isAutoSyncEnabled(): Boolean = + prefs.getBoolean(KEY_AUTO_SYNC_ENABLED, false) + + override suspend fun setAutoSyncEnabled(enabled: Boolean) { + prefs.edit().putBoolean(KEY_AUTO_SYNC_ENABLED, enabled).apply() + } + + // ----------------------------------------------------------------------- + // SyncMetadataStore + // ----------------------------------------------------------------------- + + override suspend fun getDeviceId(): String { + val existing = prefs.getString(KEY_DEVICE_ID, null) + if (existing != null) return existing + val newId = UUID.randomUUID().toString() + prefs.edit().putString(KEY_DEVICE_ID, newId).apply() + return newId + } + + override suspend fun getLastSyncTime(): Long = + prefs.getLong(KEY_LAST_SYNC_TIME, 0L) + + override suspend fun getLastRemoteRevision(): String? = + prefs.getString(KEY_LAST_REMOTE_REVISION, null) + + override suspend fun getMutationVersion(): Int = + prefs.getInt(KEY_MUTATION_VERSION, 0) + + override suspend fun markMutation() { + val next = getMutationVersion() + 1 + prefs.edit().putInt(KEY_MUTATION_VERSION, next).apply() + } + + override suspend fun saveSuccessfulSync(remoteRevision: String, syncTime: Long) { + prefs.edit() + .putString(KEY_LAST_REMOTE_REVISION, remoteRevision) + .putLong(KEY_LAST_SYNC_TIME, syncTime) + .apply() + } + + override suspend fun clearSyncMetadata() { + prefs.edit() + .remove(KEY_LAST_REMOTE_REVISION) + .remove(KEY_LAST_SYNC_TIME) + .apply() + } + + companion object { + private const val PREFS_NAME = "github_sync_prefs" + private const val KEY_TOKEN = "github_token" + private const val KEY_REPO_OWNER = "repo_owner" + private const val KEY_REPO_NAME = "repo_name" + private const val KEY_BRANCH = "branch" + private const val KEY_PATH = "path" + private const val KEY_AUTO_SYNC_ENABLED = "auto_sync_enabled" + private const val KEY_DEVICE_ID = "device_id" + private const val KEY_LAST_SYNC_TIME = "last_sync_time" + private const val KEY_LAST_REMOTE_REVISION = "last_remote_revision" + private const val KEY_MUTATION_VERSION = "mutation_version" + + fun getOrCreateDeviceId(context: Context): String { + val prefs = context.applicationContext + .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val existing = prefs.getString(KEY_DEVICE_ID, null) + if (existing != null) return existing + val newId = UUID.randomUUID().toString() + prefs.edit().putString(KEY_DEVICE_ID, newId).apply() + return newId + } + } +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncBackend.kt b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncBackend.kt new file mode 100644 index 0000000..ec63668 --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncBackend.kt @@ -0,0 +1,34 @@ +package com.cpu.seamlessloopmobile.data.sync + +/** + * 通用同步后端接口。 + * 实现类应负责与远程存储(GitHub、WebDAV 等)的通信。 + * 方法命名保持通用,不绑定任何特定后端。 + */ +interface SyncBackend { + + /** + * 将本地快照上传到远程后端。 + * @param snapshot 要上传的完整快照 + * @param expectedRevision 可选乐观锁参数(如 GitHub 文件 SHA), + * 为 null 时由后端按自身语义创建新文件 + * @return 操作结果,成功时关联 SyncReport 及新 remoteRevision + */ + suspend fun uploadSnapshot( + snapshot: SyncSnapshot, + expectedRevision: String? = null + ): SyncResult + + /** + * 从远程后端下载指定快照,未指定时下载最新快照。 + * @param snapshotId 可选的远端快照 ID + * @return 操作结果,成功时携带 SyncSnapshot 及 remoteRevision + */ + suspend fun downloadSnapshot(snapshotId: String? = null): SyncResult + + /** + * 列出远程后端可用的快照摘要。 + * @return 操作结果,成功时携带快照列表 + */ + suspend fun listSnapshots(): SyncResult +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncDataManagementModels.kt b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncDataManagementModels.kt new file mode 100644 index 0000000..7e03dd3 --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncDataManagementModels.kt @@ -0,0 +1,80 @@ +package com.cpu.seamlessloopmobile.data.sync + +/** + * 远程快照操作接口(用于 Repository 可测性)。 + * 由 [github.GitHubContentsSyncBackend] 实现。 + */ +interface GitHubSnapshotRemote { + suspend fun downloadSnapshot(snapshotId: String?): SyncResult + suspend fun uploadSnapshot(snapshot: SyncSnapshot, expectedRevision: String?): SyncResult + suspend fun deleteSnapshot(): SyncResult +} + +// =================================================================== +// 数据摘要 +// =================================================================== + +/** 本地同步数据摘要。 */ +data class LocalSyncDataSummary( + val songCount: Int, + val playlistCount: Int, + val playlistItemCount: Int, + val loopPointCount: Int, + val ratingCount: Int +) + +/** 云端同步数据预览。 */ +data class CloudSyncDataPreview( + val exists: Boolean, + val deviceId: String = "", + val exportedAt: Long = 0L, + val playlists: Int = 0, + val playlistItems: Int = 0, + val loopPointCount: Int = 0, + val ratingCount: Int = 0, + val matchedSongReferenceCount: Int = 0, + val missingSongReferenceCount: Int = 0, + val missingSongReferences: List = emptyList() +) + +/** 同步数据管理预览(本地 + 云端)。 */ +data class SyncDataManagementPreview( + val local: LocalSyncDataSummary, + val cloud: CloudSyncDataPreview? +) + +/** Local playback-stat source device information for data-management screens. */ +data class PlaybackStatsSourceDeviceSummary( + val deviceId: String, + val displayName: String, + val fallbackLabel: String, + val platform: String, + val currentGeneration: Long?, + val isCurrentDevice: Boolean, + val contributedListenMs: Long, + val hasEffectiveContributions: Boolean, + val allKnownGenerationsRemoved: Boolean +) + +/** 清除本地同步数据的选择。 */ +data class ClearLocalSyncDataSelection( + val clearPlaylists: Boolean = false, + val clearLoopPoints: Boolean = false, + val clearRatings: Boolean = false, + val clearListenStats: Boolean = false +) { + val hasSelection: Boolean + get() = clearPlaylists || clearLoopPoints || clearRatings || clearListenStats +} + +// =================================================================== +// 通用结果封装 +// =================================================================== + +sealed class SyncDataManagementResult { + data class Success(val data: T) : SyncDataManagementResult() + data class Failure( + val message: String, + val code: SyncErrorCode = SyncErrorCode.UNKNOWN + ) : SyncDataManagementResult() +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncDataManagementRepository.kt b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncDataManagementRepository.kt new file mode 100644 index 0000000..c357498 --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncDataManagementRepository.kt @@ -0,0 +1,517 @@ +package com.cpu.seamlessloopmobile.data.sync + +import androidx.room.withTransaction +import com.cpu.seamlessloopmobile.data.stats.ListenStatsContribution +import com.cpu.seamlessloopmobile.data.stats.ListenStatsLocalPayload +import com.cpu.seamlessloopmobile.data.stats.ListenStatsTombstone +import com.cpu.seamlessloopmobile.data.stats.ListenStatsRepository +import com.cpu.seamlessloopmobile.db.AppDatabase +import com.cpu.seamlessloopmobile.model.PlaylistDao +import com.cpu.seamlessloopmobile.model.Song +import com.cpu.seamlessloopmobile.model.SongDao +import com.cpu.seamlessloopmobile.data.sync.room.PlaylistIdMapper +import com.cpu.seamlessloopmobile.data.sync.room.RoomSyncSnapshotStore +import com.google.gson.Gson +import kotlinx.coroutines.CancellationException +import kotlin.math.abs + +/** + * 同步数据管理仓库。 + * + * 提供本地/云端数据预览、初始云端写入、远程删除、本地数据清除等管理操作, + * 不涉及自动同步或合并流程。 + */ +class SyncDataManagementRepository( + private val database: AppDatabase, + private val songDao: SongDao, + private val playlistDao: PlaylistDao, + private val snapshotStore: RoomSyncSnapshotStore, + private val backend: GitHubSnapshotRemote? = null, + private val metadataStore: SyncMetadataStore, + private val playlistIdMapper: PlaylistIdMapper, + private val listenStatsRepository: ListenStatsRepository +) { + private val gson = Gson() + + // =================================================================== + // 本地摘要 + // =================================================================== + + /** + * 获取本地同步数据摘要。 + */ + suspend fun getLocalSummary(): LocalSyncDataSummary { + val allSongs = songDao.getAllSongs() + val playlists = playlistDao.getPlaylistsWithCounts() + val totalPlaylistItems = playlists.sumOf { it.songCount } + val loopPointCount = allSongs.count { it.loopStart != 0L || it.loopEnd != 0L } + val ratingCount = allSongs.count { it.rating != 0 } + + return LocalSyncDataSummary( + songCount = allSongs.size, + playlistCount = playlists.size, + playlistItemCount = totalPlaylistItems, + loopPointCount = loopPointCount, + ratingCount = ratingCount + ) + } + + // =================================================================== + // 预览 + // =================================================================== + + /** + * 预览本地与云端数据摘要。 + * 不修改任何数据。 + */ + suspend fun preview(): SyncDataManagementResult { + val local = getLocalSummary() + val backend = backend ?: return remoteBackendUnavailable() + + val cloudResult = when (val result = backend.downloadSnapshot(null)) { + is SyncResult.Success -> { + val snapshot = result.snapshot + if (snapshot == null) { + SyncDataManagementPreview(local, CloudSyncDataPreview(exists = false)) + } else { + if (snapshot.schemaVersion != SYNC_SCHEMA_VERSION_V2) { + return SyncDataManagementResult.Failure( + "Unsupported remote schema version ${snapshot.schemaVersion}", + SyncErrorCode.INVALID_REMOTE + ) + } + val cloud = buildCloudPreview(snapshot) + SyncDataManagementPreview(local, cloud) + } + } + is SyncResult.Failure -> { + if (result.code == SyncErrorCode.NOT_FOUND) { + SyncDataManagementPreview(local, CloudSyncDataPreview(exists = false)) + } else { + return SyncDataManagementResult.Failure(result.message, result.code) + } + } + is SyncResult.Cancelled -> { + return SyncDataManagementResult.Failure("Operation cancelled", SyncErrorCode.UNKNOWN) + } + } + + return SyncDataManagementResult.Success(cloudResult) + } + + /** + * 根据云端快照构建预览数据。 + */ + private suspend fun buildCloudPreview(snapshot: SyncSnapshot): CloudSyncDataPreview { + val allLocalSongs = songDao.getAllSongs() + val matcher = SongIdentityMatcher(allLocalSongs) + + // 收集所有唯一的歌曲身份引用 + val allSongRefs = linkedMapOf() + snapshot.playlists.forEach { pl -> + pl.items.forEach { allSongRefs.putIfAbsent(it.song.stableKey(), it.song) } + } + snapshot.loopPoints.forEach { allSongRefs.putIfAbsent(it.song.stableKey(), it.song) } + snapshot.ratings.forEach { allSongRefs.putIfAbsent(it.song.stableKey(), it.song) } + + val matched = mutableSetOf() + val missing = mutableListOf() + + for (ref in allSongRefs.values) { + if (matcher.match(ref) != null) { + matched.add(ref) + } else { + missing.add(ref) + } + } + + val totalPlaylistItems = snapshot.playlists.sumOf { it.items.size } + + return CloudSyncDataPreview( + exists = true, + deviceId = snapshot.deviceId, + exportedAt = snapshot.exportedAt, + playlists = snapshot.playlists.size, + playlistItems = totalPlaylistItems, + loopPointCount = snapshot.loopPoints.size, + ratingCount = snapshot.ratings.size, + matchedSongReferenceCount = matched.size, + missingSongReferenceCount = missing.size, + missingSongReferences = missing + ) + } + + // =================================================================== + // 初始云端写入 + // =================================================================== + + /** + * 仅当云端同步文件不存在时,用当前本地数据创建初始快照。 + */ + suspend fun seedCloudFromLocal(): SyncDataManagementResult { + val backend = backend ?: return remoteBackendUnavailable() + when (val result = backend.downloadSnapshot(null)) { + is SyncResult.Success -> return SyncDataManagementResult.Failure( + "Cloud snapshot already exists; use normal sync or delete the cloud snapshot first", + SyncErrorCode.INVALID_REMOTE + ) + is SyncResult.Failure -> if (result.code != SyncErrorCode.NOT_FOUND) { + return SyncDataManagementResult.Failure(result.message, result.code) + } + is SyncResult.Cancelled -> return SyncDataManagementResult.Failure( + "Operation cancelled", SyncErrorCode.UNKNOWN + ) + } + + val deviceId = metadataStore.getDeviceId() + val localSnapshot = snapshotStore.exportSnapshot(deviceId) + + val preparedSnapshot = localSnapshot.prepareV2Egress() + + val uploadResult = backend.uploadSnapshot(preparedSnapshot, expectedRevision = null) + + return when (uploadResult) { + is SyncResult.Success -> { + val newRev = uploadResult.remoteRevision + if (newRev != null) { + metadataStore.saveSuccessfulSync(newRev, System.currentTimeMillis()) + } + SyncDataManagementResult.Success( + uploadResult.report.copy( + playlistsUploaded = localSnapshot.playlists.size, + loopPointsUploaded = localSnapshot.loopPoints.size, + ratingsUploaded = localSnapshot.ratings.size + ) + ) + } + is SyncResult.Failure -> { + SyncDataManagementResult.Failure(uploadResult.message, uploadResult.code) + } + is SyncResult.Cancelled -> SyncDataManagementResult.Failure( + "Operation cancelled", SyncErrorCode.UNKNOWN + ) + } + } + + // =================================================================== + // 删除远程快照 + // =================================================================== + + /** + * 删除 GitHub 上的远程快照文件。 + */ + suspend fun deleteCloudSnapshot(): SyncDataManagementResult { + val backend = backend ?: return remoteBackendUnavailable() + val result = backend.deleteSnapshot() + + return when (result) { + is SyncResult.Success -> { + metadataStore.clearSyncMetadata() + SyncDataManagementResult.Success(Unit) + } + is SyncResult.Failure -> { + SyncDataManagementResult.Failure(result.message, result.code) + } + is SyncResult.Cancelled -> SyncDataManagementResult.Failure( + "Operation cancelled", SyncErrorCode.UNKNOWN + ) + } + } + + // =================================================================== + // 清除本地同步数据 + // =================================================================== + + /** + * 按选择清除本机数据,不会删除歌曲本身。 + * Room 数据在同一事务内清除;播放统计 JSON 在事务外单独清除。 + */ + suspend fun clearLocalSyncData( + selection: ClearLocalSyncDataSelection + ): SyncDataManagementResult { + if (!selection.hasSelection) { + return SyncDataManagementResult.Failure( + "No data type selected for clearing", + SyncErrorCode.UNKNOWN + ) + } + + val clearsSyncedData = selection.clearPlaylists || + selection.clearLoopPoints || selection.clearRatings + + if (clearsSyncedData) { + database.withTransaction { + if (selection.clearLoopPoints) { + songDao.deleteAllLoopPoints() + } + if (selection.clearRatings) { + songDao.deleteAllUserRatings() + } + if (selection.clearPlaylists) { + playlistDao.deleteAllPlaylists() + playlistIdMapper.clearAllMappings() + } + } + metadataStore.clearSyncMetadata() + metadataStore.markMutation() + } + + if (selection.clearListenStats) { + try { + listenStatsRepository.clearAll() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + val message = if (clearsSyncedData) { + "Synced data was cleared, but playback statistics could not be cleared: ${e.message ?: "unknown error"}" + } else { + "Playback statistics could not be cleared: ${e.message ?: "unknown error"}" + } + return SyncDataManagementResult.Failure(message, SyncErrorCode.UNKNOWN) + } + } + + val updated = getLocalSummary() + return SyncDataManagementResult.Success(updated) + } + + /** Lists local playback-stat sources and their non-tombstoned listen-time contributions. */ + suspend fun getLocalPlaybackStatsSourceDevices(): + SyncDataManagementResult> = try { + SyncDataManagementResult.Success(buildPlaybackStatsSourceDevices()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + SyncDataManagementResult.Failure( + "Unable to load playback-stat source devices: ${e.message ?: "unknown error"}" + ) + } + + /** + * Tombstones every locally known generation for the selected source devices. + * Tombstoning the current source delegates generation rotation to [ListenStatsRepository]. + */ + suspend fun deleteLocalPlaybackStatsSourceDeviceHistories( + deviceIds: Set + ): SyncDataManagementResult> { + val selectedIds = deviceIds.filter { it.isNotBlank() }.toSet() + if (selectedIds.isEmpty()) { + return SyncDataManagementResult.Failure( + "No playback-stat source device selected", + SyncErrorCode.UNKNOWN + ) + } + + return try { + val payload = listenStatsRepository.exportLocalPayload() + val knownDeviceIds = payload.knownSourceDeviceIds() + val existingSelectedIds = selectedIds.intersect(knownDeviceIds) + if (existingSelectedIds.isEmpty()) { + return SyncDataManagementResult.Failure( + "Selected playback-stat source devices no longer exist", + SyncErrorCode.UNKNOWN + ) + } + + val now = System.currentTimeMillis() + val tombstones = payload.tombstones.toMutableList() + existingSelectedIds.forEach { deviceId -> + payload.knownGenerations(deviceId).forEach { generation -> + tombstones += ListenStatsTombstone( + deviceId = deviceId, + generation = generation, + tombstonedAtUtcMs = now, + operatorDeviceId = payload.currentDeviceId, + reason = "source_history_deleted" + ) + } + } + listenStatsRepository.applyLocalPayload( + payload.copy(tombstones = tombstones.distinctBy { it.deviceId to it.generation }) + ) + SyncDataManagementResult.Success(buildPlaybackStatsSourceDevices()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + SyncDataManagementResult.Failure( + "Unable to delete playback-stat source histories: ${e.message ?: "unknown error"}" + ) + } + } + + private suspend fun buildPlaybackStatsSourceDevices(): List { + val payload = listenStatsRepository.exportLocalPayload() + val tombstonedGenerations = payload.tombstones + .map { it.deviceId to it.generation } + .toSet() + val devices = payload.devices.associateBy { it.deviceId } + return payload.knownSourceDeviceIds().map { deviceId -> + val effectiveContributions = payload.mergedSourceContributions(deviceId).filter { + (it.deviceId to it.generation) !in tombstonedGenerations + } + val knownGenerations = payload.knownGenerations(deviceId) + val device = devices[deviceId] + PlaybackStatsSourceDeviceSummary( + deviceId = deviceId, + displayName = device?.displayName.orEmpty(), + fallbackLabel = device?.displayName?.takeIf { it.isNotBlank() } ?: deviceId, + platform = device?.platform?.ifBlank { "unknown" } ?: "unknown", + currentGeneration = when { + deviceId == payload.currentDeviceId -> payload.currentGeneration + device != null -> device.currentGeneration + else -> null + }, + isCurrentDevice = deviceId == payload.currentDeviceId, + contributedListenMs = effectiveContributions.sumOf { it.totalListenMs() }, + hasEffectiveContributions = effectiveContributions.isNotEmpty(), + allKnownGenerationsRemoved = knownGenerations.isNotEmpty() && + knownGenerations.all { (deviceId to it) in tombstonedGenerations } + ) + }.sortedWith(compareByDescending { it.isCurrentDevice } + .thenBy { it.fallbackLabel }) + } + + private fun com.cpu.seamlessloopmobile.data.stats.ListenStatsLocalPayload.knownSourceDeviceIds(): Set = + buildSet { + addAll(devices.map { it.deviceId }) + addAll(songs.flatMap { song -> song.contributions.map { it.deviceId } }) + addAll(tombstones.map { it.deviceId }) + addAll(unresolvedNodes.flatMap { node -> + unresolvedSyncSong(node)?.contributions.orEmpty().map { it.deviceId } + }) + }.filter { it.isNotBlank() }.toSet() + + private fun com.cpu.seamlessloopmobile.data.stats.ListenStatsLocalPayload.knownGenerations( + deviceId: String + ): Set = buildSet { + if (deviceId == currentDeviceId) add(currentGeneration) + devices.find { it.deviceId == deviceId }?.let { add(it.currentGeneration) } + songs.forEach { song -> song.contributions.filter { it.deviceId == deviceId }.forEach { add(it.generation) } } + tombstones.filter { it.deviceId == deviceId }.forEach { add(it.generation) } + unresolvedNodes.forEach { node -> + unresolvedSyncSong(node)?.contributions.orEmpty() + .filter { it.deviceId == deviceId } + .forEach { add(it.generation) } + } + } + + private fun ListenStatsLocalPayload.mergedSourceContributions(deviceId: String): List = + (songs.map { song -> + SyncPlaybackStatsSong( + SyncSongIdentity(song.fileName, song.durationMs, normalizedFileName = song.normalizedFileName), + song.contributions.map { contribution -> + SyncPlaybackStatsContribution( + contribution.deviceId, contribution.generation, contribution.dailyListenMs, + contribution.undatedListenMs, contribution.firstPlayedAtUtcMs, + contribution.lastPlayedAtUtcMs, contribution.updatedAtUtcMs + ) + } + ) + } + unresolvedNodes.mapNotNull(::unresolvedSyncSong)) + .groupBy { it.song.stableKey() } + .values + .flatMap { revisions -> + revisions.flatMap { it.contributions } + .filter { it.deviceId == deviceId } + .groupBy { it.deviceId to it.generation } + .values + .map(::mergeSourceContributions) + } + .map { contribution -> + ListenStatsContribution( + deviceId = contribution.deviceId, + generation = contribution.generation, + dailyListenMs = contribution.datedListenMs, + undatedListenMs = contribution.undatedListenMs, + firstPlayedAtUtcMs = contribution.firstPlayedAtUtcMs, + lastPlayedAtUtcMs = contribution.lastPlayedAtUtcMs, + updatedAtUtcMs = contribution.updatedAtUtcMs + ) + } + + private fun mergeSourceContributions( + revisions: List + ): SyncPlaybackStatsContribution = revisions.reduce { first, second -> + first.copy( + datedListenMs = (first.datedListenMs.keys + second.datedListenMs.keys).associateWith { date -> + maxOf(first.datedListenMs[date] ?: 0L, second.datedListenMs[date] ?: 0L) + }, + undatedListenMs = maxOf(first.undatedListenMs, second.undatedListenMs), + firstPlayedAtUtcMs = earliestMeaningful(first.firstPlayedAtUtcMs, second.firstPlayedAtUtcMs), + lastPlayedAtUtcMs = maxOf(first.lastPlayedAtUtcMs, second.lastPlayedAtUtcMs), + updatedAtUtcMs = maxOf(first.updatedAtUtcMs, second.updatedAtUtcMs) + ) + } + + private fun earliestMeaningful(first: Long, second: Long): Long = when { + first == 0L -> second + second == 0L -> first + else -> minOf(first, second) + } + + /** Gson permits missing Kotlin non-null fields, so validate before using unresolved contributions. */ + private fun unresolvedSyncSong( + node: com.cpu.seamlessloopmobile.data.stats.ListenStatsUnresolvedNode + ): SyncPlaybackStatsSong? = runCatching { + gson.fromJson(node.payloadJson, SyncPlaybackStatsSong::class.java) + }.getOrNull()?.takeIf { it.isSemanticallyValid() } + + private fun ListenStatsContribution.totalListenMs(): Long = + (dailyListenMs.values + undatedListenMs).fold(0L) { total, value -> + if (value <= 0L) total else if (Long.MAX_VALUE - total < value) Long.MAX_VALUE else total + value + } + + private fun remoteBackendUnavailable(): SyncDataManagementResult { + return SyncDataManagementResult.Failure( + "GitHub remote backend is unavailable", + SyncErrorCode.UNKNOWN + ) + } + + // =================================================================== + // 歌曲身份匹配器(独立副本,与 RoomSyncSnapshotStore 中逻辑一致) + // 顺序:精确 fileName+duration → 精确 fileName+totalSamples → + // fileName+totalSamples ±10000 → fileName+duration ±200ms → 唯一同名。 + // =================================================================== + + private class SongIdentityMatcher(allLocalSongs: List) { + private val byName: Map> = + allLocalSongs.groupBy { it.fileName.lowercase() } + private val byFingerprint: Map, List> = + allLocalSongs.groupBy { it.fileName.lowercase() to it.duration } + private val bySamples: Map, List> = + allLocalSongs.filter { it.totalSamples != 0L } + .groupBy { it.fileName.lowercase() to it.totalSamples } + + fun match(identity: SyncSongIdentity): Song? { + val key = identity.fileName.lowercase() + byFingerprint[key to identity.durationMs] + ?.takeIf { it.size == 1 } + ?.first() + ?.let { return it } + identity.totalSamples?.let { samples -> + bySamples[key to samples] + ?.takeIf { it.size == 1 } + ?.first() + ?.let { return it } + } + val candidates = byName[key].orEmpty() + identity.totalSamples?.let { samples -> + val sampleToleranceMatches = candidates.filter { + it.totalSamples > 0L && abs(it.totalSamples - samples) <= TOTAL_SAMPLES_TOLERANCE + } + if (sampleToleranceMatches.size == 1) return sampleToleranceMatches.first() + } + val toleranceMatches = candidates.filter { + abs(it.duration - identity.durationMs) <= 200L + } + if (toleranceMatches.size == 1) return toleranceMatches.first() + if (candidates.size == 1) return candidates.first() + return null + } + + companion object { + private const val TOTAL_SAMPLES_TOLERANCE = 10_000L + } + } +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncMergeEngine.kt b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncMergeEngine.kt new file mode 100644 index 0000000..20ccc33 --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncMergeEngine.kt @@ -0,0 +1,244 @@ +package com.cpu.seamlessloopmobile.data.sync + +/** + * 合并操作的结果。 + * @property snapshot 合并后的快照(保留本地 deviceId 和 exportedAt) + * @property report 合并操作报告 + */ +data class MergeResult( + val snapshot: SyncSnapshot, + val report: SyncReport +) + +/** + * 双向同步合并引擎。 + * + * 接收远端(可能为 null)和本地快照,按稳定歌曲身份标识和歌单 ID 进行合并, + * 使用可注入的策略对象处理各数据类型的冲突。 + */ +class SyncMergeEngine( + private val playlistPolicy: PlaylistMergePolicy = DefaultPlaylistMergePolicy, + private val loopPointPolicy: LoopPointMergePolicy = DefaultLoopPointMergePolicy, + private val ratingPolicy: RatingMergePolicy = DefaultRatingMergePolicy +) { + + /** + * 合并远端和本地快照。 + * - 如果远端为 null,直接返回本地快照(首次同步) + * - 按歌单 ID 合并播放列表 + * - 按 fileName(忽略大小写)+durationMs 合并循环点和评分;totalSamples 仅作辅助匹配字段 + * - 合并结果使用 schema 2,并保留本地 deviceId、exportedAt + * + * @param remote 来自远端的快照(可能为 null) + * @param local 本地导出的快照 + * @return 合并后的快照及报告 + */ + suspend fun merge(remote: SyncSnapshot?, local: SyncSnapshot): MergeResult { + if (remote == null) { + return MergeResult( + snapshot = local.prepareV2Egress(), + report = SyncReport( + playlistsUploaded = local.playlists.size, + loopPointsUploaded = local.loopPoints.size, + ratingsUploaded = local.ratings.size + ) + ) + } + + val conflicts = mutableListOf() + + val mergedPlaylists = mergePlaylists(remote.playlists, local.playlists, conflicts) + val mergedLoopPoints = mergeEntries( + remote.loopPoints, + local.loopPoints, + { it.song.stableKey() }, + { r, l -> + val merged = loopPointPolicy.resolve(r?.loopPoint, l?.loopPoint) + val song = preferStableSongIdentity(remote = r?.song, local = l?.song) + if (song != null && merged != null) SyncLoopPointEntry(song, merged) else null + }, + conflicts + ) + val mergedRatings = mergeEntries( + remote.ratings, + local.ratings, + { it.song.stableKey() }, + { r, l -> + val merged = ratingPolicy.resolve(r?.rating, l?.rating) + val song = preferStableSongIdentity(remote = r?.song, local = l?.song) + if (song != null && merged != null) SyncRatingEntry(song, merged) else null + }, + conflicts + ) + + val mergedSnapshot = SyncSnapshot( + schemaVersion = SYNC_SCHEMA_VERSION_V2, + deviceId = local.deviceId, + exportedAt = local.exportedAt, + playlists = mergedPlaylists, + loopPoints = mergedLoopPoints, + ratings = mergedRatings, + playbackStats = mergePlaybackStatistics(remote.playbackStats, local.playbackStats) + ).canonicalized() + + val report = SyncReport( + playlistsUploaded = local.playlists.size, + playlistsDownloaded = remote.playlists.size, + loopPointsUploaded = local.loopPoints.size, + loopPointsDownloaded = remote.loopPoints.size, + ratingsUploaded = local.ratings.size, + ratingsDownloaded = remote.ratings.size, + conflicts = conflicts + ) + + return MergeResult(mergedSnapshot, report) + } + + // ------------------------------------------------------------------- + // 内部辅助方法 + // ------------------------------------------------------------------- + + /** + * 按歌单 ID 合并播放列表。 + */ + private fun mergePlaylists( + remote: List, + local: List, + conflicts: MutableList + ): List { + val localById = local.associateBy { it.id } + val remoteById = remote.associateBy { it.id } + val allIds = (localById.keys + remoteById.keys).toSet() + + return allIds.mapNotNull { id -> + val localPlaylist = localById[id] + val remotePlaylist = remoteById[id] + + if (localPlaylist == null) return@mapNotNull remotePlaylist?.withoutDuplicateStableItems() + if (remotePlaylist == null) return@mapNotNull localPlaylist.withoutDuplicateStableItems() + + // Record conflict if names differ + if (localPlaylist.name != remotePlaylist.name) { + conflicts.add( + SyncConflict( + playlistName = localPlaylist.name, + remoteValue = remotePlaylist.name, + localValue = localPlaylist.name, + resolution = if (remotePlaylist.modifiedAt >= localPlaylist.modifiedAt) + "remote" else "local" + ) + ) + } + playlistPolicy.resolve(remotePlaylist, localPlaylist).withoutDuplicateStableItems() + }.sortedBy { it.name } + } + + private fun SyncPlaylist.withoutDuplicateStableItems(): SyncPlaylist = + copy(items = items.distinctBy { it.song.stableKey() }) + + private fun mergePlaybackStatistics( + remote: SyncPlaybackStats, + local: SyncPlaybackStats + ): SyncPlaybackStats { + val tombstones = (remote.tombstones + local.tombstones) + .groupBy { it.deviceId to it.generation } + .map { (_, entries) -> entries.maxWith(compareBy { it.tombstonedAtUtcMs } + .thenBy { it.tombstonedByDeviceId }.thenBy { it.reason }) } + val suppressed = tombstones.map { it.deviceId to it.generation }.toSet() + val devices = (remote.devices + local.devices) + .groupBy { it.deviceId } + .map { (_, entries) -> entries.reduce(::mergeDevice) } + val songs = (remote.songs + local.songs) + .groupBy { it.song.stableKey() } + .map { (_, entries) -> + val song = reducePlaybackSongIdentity(entries.map { it.song }) + val contributions = entries.flatMap { it.contributions } + .groupBy { it.deviceId to it.generation } + .filterKeys { it !in suppressed } + .map { (_, values) -> values.reduce(::mergeContribution) } + SyncPlaybackStatsSong(song, contributions) + } + return SyncPlaybackStats( + dateBucketBasis = SyncDateBucketBasis.SOURCE_LOCAL, + devices = devices, + songs = songs, + tombstones = tombstones + ).sorted() + } + + private fun mergeDevice( + first: SyncPlaybackStatsDevice, + second: SyncPlaybackStatsDevice + ): SyncPlaybackStatsDevice { + val display = listOf(first, second).maxWith( + compareBy { it.displayNameUpdatedAtUtcMs } + .thenBy { it.displayName } + ) + val platform = listOf(first, second).maxWith( + compareBy { it.lastSeenAtUtcMs } + .thenBy { it.platform } + ) + return SyncPlaybackStatsDevice( + deviceId = first.deviceId, + displayName = display.displayName, + firstSeenAtUtcMs = minOf(first.firstSeenAtUtcMs, second.firstSeenAtUtcMs), + lastSeenAtUtcMs = maxOf(first.lastSeenAtUtcMs, second.lastSeenAtUtcMs), + currentGeneration = maxOf(first.currentGeneration, second.currentGeneration), + platform = platform.platform, + displayNameUpdatedAtUtcMs = maxOf( + first.displayNameUpdatedAtUtcMs, + second.displayNameUpdatedAtUtcMs + ) + ) + } + + private fun mergeContribution( + first: SyncPlaybackStatsContribution, + second: SyncPlaybackStatsContribution + ): SyncPlaybackStatsContribution = SyncPlaybackStatsContribution( + deviceId = first.deviceId, + generation = first.generation, + datedListenMs = (first.datedListenMs.keys + second.datedListenMs.keys).associateWith { date -> + maxOf(first.datedListenMs[date] ?: 0L, second.datedListenMs[date] ?: 0L) + }, + undatedListenMs = maxOf(first.undatedListenMs, second.undatedListenMs), + firstPlayedAtUtcMs = earliestMeaningful( + first.firstPlayedAtUtcMs, + second.firstPlayedAtUtcMs + ), + lastPlayedAtUtcMs = maxOf(first.lastPlayedAtUtcMs, second.lastPlayedAtUtcMs), + updatedAtUtcMs = maxOf(first.updatedAtUtcMs, second.updatedAtUtcMs) + ) + + private fun earliestMeaningful(first: Long, second: Long): Long = when { + first == 0L -> second + second == 0L -> first + else -> minOf(first, second) + } + + /** + * 泛型条目合并辅助方法。 + * @param remote 远端条目列表 + * @param local 本地条目列表 + * @param keySelector 从条目中提取匹配键的函数 + * @param resolver 合并两个可选条目为一个可选结果 + * @param conflicts 冲突列表(用于记录) + */ + private fun mergeEntries( + remote: List, + local: List, + keySelector: (T) -> K, + resolver: (T?, T?) -> T?, + conflicts: MutableList + ): List { + val localByKey = local.associateBy(keySelector) + val remoteByKey = remote.associateBy(keySelector) + val allKeys = (localByKey.keys + remoteByKey.keys).toSet() + + return allKeys.mapNotNull { key -> + val localEntry = localByKey[key] + val remoteEntry = remoteByKey[key] + resolver(remoteEntry, localEntry) + } + } +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncMergePolicy.kt b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncMergePolicy.kt new file mode 100644 index 0000000..7967fe2 --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncMergePolicy.kt @@ -0,0 +1,136 @@ +package com.cpu.seamlessloopmobile.data.sync + +/** + * 播放列表合并策略。 + * resolve 方法根据具体规则合并远程与本地歌单,返回合并结果。 + */ +interface PlaylistMergePolicy { + /** + * 合并远程和本地歌单。 + * @param remote 从后端下载的歌单快照 + * @param local 本地的歌单数据 + * @return 合并后的歌单 + */ + fun resolve(remote: SyncPlaylist, local: SyncPlaylist): SyncPlaylist +} + +/** + * 循环点合并策略。 + */ +interface LoopPointMergePolicy { + /** + * 合并远程和本地的循环点数据。 + * @param remote 后端的循环点(可能为 null) + * @param local 本地的循环点(可能为 null) + * @return 合并后的循环点,若双方均为 null 则返回 null + */ + fun resolve(remote: SyncLoopPoint?, local: SyncLoopPoint?): SyncLoopPoint? +} + +/** + * 评分合并策略。 + */ +interface RatingMergePolicy { + /** + * 合并远程和本地的评分数据。 + * @param remote 后端的评分(可能为 null) + * @param local 本地的评分(可能为 null) + * @return 合并后的评分,若双方均为 null 则返回 null + */ + fun resolve(remote: SyncRating?, local: SyncRating?): SyncRating? +} + +// --------------------------------------------------------------------------- +// 默认实现 +// --------------------------------------------------------------------------- + +/** + * 默认播放列表合并策略: + * - 歌单元数据采用 Last-Writer-Wins(根据 modifiedAt) + * - 歌单内的歌曲条目按 fileName(忽略大小写)+durationMs 去重合并;totalSamples 不参与去重 + * - 同一歌曲两端 totalSamples 不同时,尽量保留远端 SyncSongIdentity,避免跨端反复改 JSON + */ +object DefaultPlaylistMergePolicy : PlaylistMergePolicy { + + override fun resolve(remote: SyncPlaylist, local: SyncPlaylist): SyncPlaylist { + val (winner, loser) = if (remote.modifiedAt >= local.modifiedAt) remote to local else local to remote + val remoteItemsByKey = remote.items.associateBy { it.song.stableKey() } + val mergedItems = mergeItems(winner.items, loser.items, remoteItemsByKey) + return winner.copy(items = mergedItems) + } + + private fun mergeItems( + base: List, + other: List, + remoteItemsByKey: Map + ): List { + val result = base.map { item -> + val remoteItem = remoteItemsByKey[item.song.stableKey()] + if (remoteItem != null) item.copy(song = remoteItem.song) else item + }.toMutableList() + val seenIdentities = base.map { it.song.stableKey() }.toMutableSet() + for (item in other) { + val key = item.song.stableKey() + if (seenIdentities.add(key)) { + result.add(remoteItemsByKey[key] ?: item) + } + } + return result.sortedBy { it.sortOrder } + } +} + +/** + * 默认循环点合并策略:Last-Writer-Wins(根据 lastModified), + * 附加保护规则:零值/未设置循环点不能覆盖有实质内容的循环点。 + * + * - 如果只有一方有数据则采用该方数据 + * - 如果双方都有数据且都有效则采用 lastModified 更新的一方 + * - 保护规则:若 LWW 胜方为未设置(loopStart==0 && loopEnd==0), + * 而负方有实质内容,则保留负方数据,防止意外清空 + * - 如果双方都为 null 则返回 null + */ +object DefaultLoopPointMergePolicy : LoopPointMergePolicy { + + override fun resolve(remote: SyncLoopPoint?, local: SyncLoopPoint?): SyncLoopPoint? { + if (remote == null) return local + if (local == null) return remote + + val remoteIsUnset = remote.loopStart == 0L && remote.loopEnd == 0L + val localIsUnset = local.loopStart == 0L && local.loopEnd == 0L + + return when { + // 保护规则:胜方未设置,负方有实质 → 保留负方 + remoteIsUnset && !localIsUnset -> local + !remoteIsUnset && localIsUnset -> remote + // 双方都未设置或都有实质 → LWW + else -> if (remote.lastModified >= local.lastModified) remote else local + } + } +} + +/** + * 默认评分合并策略:Last-Writer-Wins(根据 lastModified), + * 附加保护规则:评分 0 视为未设置,不能覆盖非零评分。 + * + * - 如果只有一方有数据则采用该方数据 + * - 如果双方都有数据则采用 lastModified 更新的一方 + * - 保护规则:若 LWW 胜方评分为 0(未设置),而负方有非零评分, + * 则保留负方数据 + * - 如果双方都为 null 则返回 null + */ +object DefaultRatingMergePolicy : RatingMergePolicy { + + override fun resolve(remote: SyncRating?, local: SyncRating?): SyncRating? { + if (remote == null) return local + if (local == null) return remote + + return when { + // 保护规则:远程 0 未设置,本地有值 → 保留本地 + remote.rating == 0 && local.rating != 0 -> local + // 保护规则:本地 0 未设置,远程有值 → 保留远程 + local.rating == 0 && remote.rating != 0 -> remote + // 双方都未设置(0)或都有值 → LWW + else -> if (remote.lastModified >= local.lastModified) remote else local + } + } +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncMetadataStore.kt b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncMetadataStore.kt new file mode 100644 index 0000000..59bddb6 --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncMetadataStore.kt @@ -0,0 +1,40 @@ +package com.cpu.seamlessloopmobile.data.sync + +/** + * 同步元数据持久化接口。 + * 追踪本地设备标识、上次同步时间、远程修订版本及本地变更版本号。 + */ +interface SyncMetadataStore { + + /** 获取本地设备标识符(首次自动生成并持久化)。 */ + suspend fun getDeviceId(): String + + /** 上次成功同步的墙钟时间戳(ms)。 */ + suspend fun getLastSyncTime(): Long + + /** 上次成功同步后记录的远程资源修订版本(如 GitHub 文件的 SHA)。 */ + suspend fun getLastRemoteRevision(): String? + + /** + * 当前本地数据的变更版本号。 + * 每次本地数据发生用户可见的修改(导入、评分、歌单变更等)时递增。 + * 用于在同步期间检测本地并发修改。 + */ + suspend fun getMutationVersion(): Int + + /** 标记一次本地数据变更(递增 mutationVersion)。 */ + suspend fun markMutation() + + /** + * 记录一次成功的同步结果。 + * @param remoteRevision 同步成功后远程端的最新修订版本 + * @param syncTime 同步完成时的墙钟时间戳(ms) + */ + suspend fun saveSuccessfulSync(remoteRevision: String, syncTime: Long) + + /** + * 清除同步元数据(lastRemoteRevision 和 lastSyncTime)。 + * 不修改 deviceId、token、config、mutationVersion。 + */ + suspend fun clearSyncMetadata() +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncModels.kt b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncModels.kt new file mode 100644 index 0000000..86980f7 --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncModels.kt @@ -0,0 +1,229 @@ +package com.cpu.seamlessloopmobile.data.sync + +import java.text.Normalizer +import java.time.LocalDate +import java.util.Locale + +/** + * 同步用的歌曲身份标识。 + * 不包含 filePath,使用 fileName + durationMs 作为跨设备匹配主键。 + * totalSamples 作为辅助匹配字段(可选)。 + */ +data class SyncSongIdentity( + val fileName: String, + val durationMs: Long, + val totalSamples: Long? = null, + val normalizedFileName: String = normalizeSyncFileName(fileName), + val contentHash: String? = null +) + + +fun normalizeSyncFileName(fileName: String): String = + Normalizer.normalize(fileName.trim(), Normalizer.Form.NFC).lowercase(Locale.ROOT) + +/** + * 循环点快照数据(已与 songId 解耦)。 + */ +data class SyncLoopPoint( + val loopStart: Long, + val loopEnd: Long, + val lastModified: Long +) { + /** True when this entry carries a real loop range rather than an unset sentinel. */ + val isSubstantive: Boolean get() = !isUnset(loopStart, loopEnd) + + companion object { + /** Sentinel value indicating an unset/zero loop point. */ + fun isUnset(loopStart: Long, loopEnd: Long): Boolean = + loopStart == 0L && loopEnd == 0L + } +} + +/** + * 用户评分快照数据(已与 songId 解耦)。 + */ +data class SyncRating( + val rating: Int, + val lastModified: Long +) { + companion object { + /** Sentinel value: rating 0 means unset. */ + const val UNSET_RATING: Int = 0 + } +} + +/** + * 歌单中的单曲条目,用 SyncSongIdentity 标识歌曲。 + */ +data class SyncPlaylistItem( + val song: SyncSongIdentity, + val sortOrder: Int +) + +/** + * 歌单快照。 + * id 使用字符串以保证跨设备可移植性(如 UUID 或 name+createdAt 哈希)。 + */ +data class SyncPlaylist( + val id: String, + val name: String, + val createdAt: Long, + val modifiedAt: Long, + val items: List = emptyList() +) + +/** + * SyncSnapshot 中使用的(歌曲身份 -> 循环点)条目对。 + * 使用列表而非 Map 以便 Gson 序列化。 + */ +data class SyncLoopPointEntry( + val song: SyncSongIdentity, + val loopPoint: SyncLoopPoint +) + +/** + * SyncSnapshot 中使用的(歌曲身份 -> 评分)条目对。 + * 使用列表而非 Map 以便 Gson 序列化。 + */ +data class SyncRatingEntry( + val song: SyncSongIdentity, + val rating: SyncRating +) + +/** + * 当前支持的快照 schema 版本。 + */ +const val SYNC_SCHEMA_VERSION_V2 = 2 +const val CURRENT_SYNC_SCHEMA_VERSION = SYNC_SCHEMA_VERSION_V2 + +enum class SyncDateBucketBasis { + @com.google.gson.annotations.SerializedName("sourceLocal") + SOURCE_LOCAL +} + +/** A playback-stat source device registered by a v2 snapshot. */ +data class SyncPlaybackStatsDevice( + val deviceId: String, + val displayName: String, + val firstSeenAtUtcMs: Long, + val lastSeenAtUtcMs: Long, + val currentGeneration: Long = 0L, + val platform: String = "unknown", + val displayNameUpdatedAtUtcMs: Long = firstSeenAtUtcMs +) + +/** A monotonic contribution for one source device and generation. */ +data class SyncPlaybackStatsContribution( + val deviceId: String, + val generation: Long, + val datedListenMs: Map = emptyMap(), + val undatedListenMs: Long = 0L, + val firstPlayedAtUtcMs: Long = 0L, + val lastPlayedAtUtcMs: Long = 0L, + val updatedAtUtcMs: Long = 0L +) + +data class SyncPlaybackStatsSong( + val song: SyncSongIdentity, + val contributions: List = emptyList() +) + +/** Safely validates a playback-stat song parsed from untrusted JSON. */ +fun SyncPlaybackStatsSong.isSemanticallyValid(): Boolean = runCatching { + require(song.fileName.isNotBlank()) + require(song.normalizedFileName.isNotBlank()) + require(song.normalizedFileName == normalizeSyncFileName(song.fileName)) + require(song.durationMs >= 0L) + require(song.totalSamples == null || song.totalSamples >= 0L) + + val contributionKeys = mutableSetOf>() + contributions.forEach { contribution -> + require(contribution.deviceId.isNotBlank()) + require(contribution.generation >= 0L) + require(contribution.undatedListenMs >= 0L) + require(contribution.firstPlayedAtUtcMs >= 0L) + require(contribution.lastPlayedAtUtcMs >= 0L) + require(contribution.updatedAtUtcMs >= 0L) + require(contributionKeys.add(contribution.deviceId to contribution.generation)) + contribution.datedListenMs.forEach { (date, listenMs) -> + LocalDate.parse(date) + require(listenMs >= 0L) + } + } +}.isSuccess + +/** Permanently suppresses contributions for a device generation. */ +enum class SyncPlaybackStatsTombstoneScope { + @com.google.gson.annotations.SerializedName("deviceGeneration") + DEVICE_GENERATION +} + +data class SyncPlaybackStatsTombstone( + val deviceId: String, + val generation: Long, + val tombstonedAtUtcMs: Long, + val scope: SyncPlaybackStatsTombstoneScope = SyncPlaybackStatsTombstoneScope.DEVICE_GENERATION, + val tombstonedByDeviceId: String = deviceId, + val reason: String = "deleted" +) + +data class SyncPlaybackStats( + val dateBucketBasis: SyncDateBucketBasis = SyncDateBucketBasis.SOURCE_LOCAL, + val devices: List = emptyList(), + val songs: List = emptyList(), + val tombstones: List = emptyList() +) { + fun sorted(): SyncPlaybackStats = copy( + devices = devices.sortedBy { it.deviceId }, + songs = songs.sortedWith( + compareBy { it.song.normalizedFileName } + .thenBy { it.song.durationMs } + ).map { song -> + song.copy(contributions = song.contributions.sortedWith( + compareBy { it.deviceId }.thenBy { it.generation } + ).map { it.copy(datedListenMs = it.datedListenMs.toSortedMap()) }) + }, + tombstones = tombstones.sortedWith( + compareBy { it.deviceId }.thenBy { it.generation } + ) + ) +} + +fun SyncSnapshot.canonicalized(): SyncSnapshot = copy( + playlists = playlists.map { playlist -> + playlist.copy(items = playlist.items.map { item -> item.copy(song = item.song.normalized()) }) + }, + loopPoints = loopPoints.map { it.copy(song = it.song.normalized()) }, + ratings = ratings.map { it.copy(song = it.song.normalized()) }, + playbackStats = playbackStats.sorted() +) + +fun SyncSongIdentity.normalized(): SyncSongIdentity = copy( + normalizedFileName = normalizedFileName.takeUnless { it.isBlank() } + ?: normalizeSyncFileName(fileName) +) + +/** + * 完整的同步快照,包含播放列表、循环点和评分数据。 + * 不包含设备特定设置或原始 Room 数据库路径。 + */ +data class SyncSnapshot( + val schemaVersion: Int = CURRENT_SYNC_SCHEMA_VERSION, + val deviceId: String, + val exportedAt: Long, + val playlists: List = emptyList(), + val loopPoints: List = emptyList(), + val ratings: List = emptyList(), + @com.google.gson.annotations.SerializedName("playbackStatistics") + val playbackStats: SyncPlaybackStats = SyncPlaybackStats() +) + +/** + * Lightweight remote snapshot listing entry for sync backends. + */ +data class SyncSnapshotSummary( + val id: String, + val schemaVersion: Int, + val deviceId: String, + val exportedAt: Long +) diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncReport.kt b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncReport.kt new file mode 100644 index 0000000..429d3e8 --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncReport.kt @@ -0,0 +1,83 @@ +package com.cpu.seamlessloopmobile.data.sync + +/** + * 结构化错误码,对应不同同步场景。 + */ +enum class SyncErrorCode { + /** GitHub token 未配置。 */ + NOT_CONFIGURED, + /** 认证/授权失败(401/403)。 */ + UNAUTHORIZED, + /** 远程资源不存在(404)。 */ + NOT_FOUND, + /** 远程已有更新,需要重试(409/422)。 */ + CONFLICT, + /** 远程数据格式异常,解析失败。 */ + INVALID_REMOTE, + /** 网络 IO 错误。 */ + NETWORK, + /** 未知错误。 */ + UNKNOWN +} + +/** + * 表示同步过程中检测到的一个具体冲突。 + * 可用于 UI 展示或日志记录。 + */ +data class SyncConflict( + val playlistName: String? = null, + val songIdentity: SyncSongIdentity? = null, + val field: String? = null, + val remoteValue: String? = null, + val localValue: String? = null, + val resolution: String? = null +) + +/** + * 同步操作的详细报告。 + * 包含上传/下载数量和冲突详情。 + */ +data class SyncReport( + val playlistsUploaded: Int = 0, + val playlistsDownloaded: Int = 0, + val loopPointsUploaded: Int = 0, + val loopPointsDownloaded: Int = 0, + val ratingsUploaded: Int = 0, + val ratingsDownloaded: Int = 0, + val conflicts: List = emptyList() +) + +/** + * 同步操作的结果密封类。 + * - Success:操作成功,附带 SyncReport 及可能的 SyncSnapshot + * - Failure:操作失败,附错误信息及结构化错误码 + * - Cancelled:操作被用户取消 + */ +sealed class SyncResult { + + /** + * @param report 同步报告 + * @param snapshot 下载操作成功时携带的快照数据 + * @param snapshotSummaries 列表操作成功时携带的远端快照摘要 + * @param remoteRevision 远程资源版本标识(GitHub SHA 等),用于后续乐观锁 + */ + data class Success( + val report: SyncReport, + val snapshot: SyncSnapshot? = null, + val snapshotSummaries: List = emptyList(), + val remoteRevision: String? = null + ) : SyncResult() + + /** + * @param message 人类可读的错误信息 + * @param throwable 原始异常(可选) + * @param code 结构化错误码 + */ + data class Failure( + val message: String, + val throwable: Throwable? = null, + val code: SyncErrorCode = SyncErrorCode.UNKNOWN + ) : SyncResult() + + data object Cancelled : SyncResult() +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncSnapshotSerializer.kt b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncSnapshotSerializer.kt new file mode 100644 index 0000000..7302a49 --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncSnapshotSerializer.kt @@ -0,0 +1,300 @@ +package com.cpu.seamlessloopmobile.data.sync + +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.JsonElement +import com.google.gson.JsonParseException +import com.google.gson.JsonParser +import java.time.LocalDate + +/** + * Gson 驱动的 SyncSnapshot 序列化/反序列化工具。 + * + * - 忽略未知字段(Gson 默认行为) + * - 校验 schemaVersion + * - 拒绝空/格式错误的 JSON + */ +class SyncSnapshotSerializer( + private val gson: Gson = GsonBuilder().create() +) { + + /** + * 序列化快照为 JSON 字符串。 + */ + fun serialize(snapshot: SyncSnapshot): String { + if (snapshot.schemaVersion != SYNC_SCHEMA_VERSION_V2) { + throw SyncSerializationException("Unsupported schema version ${snapshot.schemaVersion}") + } + val canonical = try { + snapshot.canonicalized() + } catch (e: NullPointerException) { + throw SyncSerializationException("Snapshot playbackStatistics is null", e) + } + checkNotNull(canonical.playbackStats) { "Snapshot playbackStatistics is null" } + val json = gson.toJson(canonical) + deserialize(json) + return json + } + + /** + * 反序列化 JSON 字符串为 SyncSnapshot。 + * + * @param json 待解析字符串 + * @return 校验通过后的 SyncSnapshot + * @throws SyncSerializationException 当 JSON 为空、格式错误或 schemaVersion 不匹配时 + */ + fun deserialize(json: String): SyncSnapshot { + if (json.isBlank()) { + throw SyncSerializationException( + "Snapshot JSON is blank or empty" + ) + } + + val root: JsonElement = try { + JsonParser.parseString(json) + } catch (e: JsonParseException) { + throw SyncSerializationException("Malformed snapshot JSON: ${e.message}", e) + } + if (!root.isJsonObject) throw SyncSerializationException("Snapshot JSON must be an object") + + val schemaVersion = root.asJsonObject.get("schemaVersion")?.asInt + ?: throw SyncSerializationException("Snapshot schemaVersion is missing") + if (schemaVersion != SYNC_SCHEMA_VERSION_V2) { + throw SyncSerializationException("Unsupported schema version $schemaVersion") + } + normalizeGenericSongIdentities(root.asJsonObject) + validateV2Payload(root.asJsonObject) + + val snapshot: SyncSnapshot = try { + gson.fromJson(root, SyncSnapshot::class.java) + ?: throw SyncSerializationException( + "JSON parsed to null (expected a valid snapshot object)" + ) + } catch (e: SyncSerializationException) { + throw e + } catch (e: Exception) { + throw SyncSerializationException( + "Malformed snapshot JSON: ${e.message}", + e + ) + } + + if (snapshot.deviceId.isNullOrBlank()) { + throw SyncSerializationException("Snapshot deviceId is blank") + } + + if (snapshot.exportedAt < 0L) { + throw SyncSerializationException("Snapshot exportedAt is negative") + } + if (snapshot.playbackStats == null) { + throw SyncSerializationException("Snapshot playbackStatistics is null") + } + + @Suppress("USELESS_CAST") + return snapshot.copy( + playlists = ((snapshot.playlists as List?) ?: emptyList()), + loopPoints = ((snapshot.loopPoints as List?) ?: emptyList()), + ratings = ((snapshot.ratings as List?) ?: emptyList()) + ).canonicalized() + } + + private fun validateV2Payload(root: com.google.gson.JsonObject) { + val canonicalStats = root.get("playbackStatistics") + if (root.has("playbackStats")) fail("Snapshot playbackStats alias is not supported") + if (canonicalStats == null || canonicalStats.isJsonNull) { + fail("Snapshot playbackStatistics is required") + } + if (!canonicalStats.isJsonObject) fail("playbackStatistics must be an object") + val payload = canonicalStats.asJsonObject + val dateBucketBasis = payload.get("dateBucketBasis") + ?.takeUnless { it.isJsonNull } + ?: fail("playbackStatistics dateBucketBasis is required") + if (!dateBucketBasis.isJsonPrimitive || !dateBucketBasis.asJsonPrimitive.isString) { + fail("playbackStatistics dateBucketBasis must be a string") + } + if (dateBucketBasis.asString != "sourceLocal") fail("Invalid dateBucketBasis") + + val devices = payload.array("devices") + unique(devices, "device") { it.objectValue("deviceId") } + devices.forEach { device -> + device.objectValue("deviceId").requireNotBlank("deviceId") + device.objectValue("displayName").requireNotBlank("displayName") + device.objectValue("platform").requireNotBlank("platform") + nonNegative(device.objectLong("firstSeenAtUtcMs"), "firstSeenAtUtcMs") + nonNegative(device.objectLong("lastSeenAtUtcMs"), "lastSeenAtUtcMs") + nonNegative(device.objectLong("currentGeneration"), "currentGeneration") + nonNegative(device.objectLong("displayNameUpdatedAtUtcMs"), "displayNameUpdatedAtUtcMs") + } + val songs = payload.array("songs") + unique(songs, "song") { song -> + val identity = song.asJsonObject.getAsJsonObject("song") ?: fail("song identity is missing") + "${identity.objectValue("normalizedFileName")}:${identity.objectLong("durationMs")}" + } + songs.forEach { song -> + val identity = song.asJsonObject.getAsJsonObject("song") ?: fail("song identity is missing") + identity.objectValue("fileName").requireNotBlank("song fileName") + val fileName = identity.objectValue("fileName") + val normalizedFileName = identity.objectValue("normalizedFileName") + normalizedFileName.requireNotBlank("song normalizedFileName") + if (normalizedFileName != normalizeSyncFileName(fileName)) { + fail("song normalizedFileName does not match fileName") + } + nonNegative(identity.objectLong("durationMs"), "song durationMs") + identity.get("totalSamples")?.takeUnless { it.isJsonNull }?.asLong?.let { nonNegative(it, "song totalSamples") } + val contributions = song.asJsonObject.array("contributions") + unique(contributions, "contribution") { "${it.objectValue("deviceId")}:${it.objectLong("generation")}" } + contributions.forEach { contribution -> + contribution.objectValue("deviceId").requireNotBlank("contribution deviceId") + nonNegative(contribution.objectLong("generation"), "generation") + nonNegative(contribution.objectLong("undatedListenMs", default = 0), "undatedListenMs") + nonNegative(contribution.objectLong("firstPlayedAtUtcMs"), "firstPlayedAtUtcMs") + nonNegative(contribution.objectLong("lastPlayedAtUtcMs"), "lastPlayedAtUtcMs") + nonNegative(contribution.objectLong("updatedAtUtcMs"), "updatedAtUtcMs") + val dates = contribution.asJsonObject.arrayOrObject("datedListenMs") + dates.entrySet().forEach { (date, value) -> + try { LocalDate.parse(date) } catch (_: Exception) { fail("Invalid date bucket $date") } + nonNegative(value.asLong, "datedListenMs") + } + } + } + val tombstones = payload.array("tombstones") + unique(tombstones, "tombstone") { "${it.objectValue("deviceId")}:${it.objectLong("generation")}" } + tombstones.forEach { + it.objectValue("deviceId").requireNotBlank("tombstone deviceId") + nonNegative(it.objectLong("generation"), "tombstone generation") + if (it.objectValue("scope") != "deviceGeneration") fail("Invalid tombstone scope") + it.objectValue("tombstonedByDeviceId").requireNotBlank("tombstonedByDeviceId") + it.objectValue("reason").requireNotBlank("tombstone reason") + nonNegative(it.objectLong("tombstonedAtUtcMs"), "tombstonedAtUtcMs") + } + } + + /** + * WPF's generic song identity payloads predate normalizedFileName. Normalize them + * before Gson sees the JSON so Kotlin non-null fields are never populated with null. + * Playback-statistics identities are intentionally handled only by the strict validator + * above and are not backfilled here. + */ + private fun normalizeGenericSongIdentities(root: com.google.gson.JsonObject) { + root.genericArray("playlists")?.forEachIndexed { playlistIndex, playlistElement -> + val playlistPath = "playlists[$playlistIndex]" + val playlist = playlistElement.requireObject(playlistPath) + playlist.get("items")?.let { itemsElement -> + if (itemsElement.isJsonNull) fail("$playlistPath.items is null") + if (!itemsElement.isJsonArray) fail("$playlistPath.items must be an array") + itemsElement.asJsonArray.forEachIndexed { itemIndex, itemElement -> + val itemPath = "$playlistPath.items[$itemIndex]" + val item = itemElement.requireObject(itemPath) + normalizeGenericSongIdentity(item.get("song"), "$itemPath.song") + } + } + } + + root.genericArray("loopPoints")?.forEachIndexed { index, entryElement -> + val entryPath = "loopPoints[$index]" + val entry = entryElement.requireObject(entryPath) + normalizeGenericSongIdentity(entry.get("song"), "$entryPath.song") + entry.get("loopPoint").requireObject("$entryPath.loopPoint") + } + + root.genericArray("ratings")?.forEachIndexed { index, entryElement -> + val entryPath = "ratings[$index]" + val entry = entryElement.requireObject(entryPath) + normalizeGenericSongIdentity(entry.get("song"), "$entryPath.song") + entry.get("rating").requireObject("$entryPath.rating") + } + } + + private fun normalizeGenericSongIdentity( + songElement: com.google.gson.JsonElement?, + path: String + ) { + val song = songElement.requireObject(path) + val fileName = song.stringValue("fileName", path).also { + if (it.isBlank()) fail("$path.fileName is blank") + } + val canonicalNormalizedFileName = normalizeSyncFileName(fileName) + song.nonNegativeLong("durationMs", path) + song.optionalNonNegativeLong("totalSamples", path) + + val normalizedElement = song.get("normalizedFileName") + if (normalizedElement == null || normalizedElement.isJsonNull) { + song.addProperty("normalizedFileName", canonicalNormalizedFileName) + } else { + val normalizedFileName = normalizedElement.stringValue("$path.normalizedFileName") + if (normalizedFileName.isBlank()) { + song.addProperty("normalizedFileName", canonicalNormalizedFileName) + } else if (normalizedFileName != canonicalNormalizedFileName) { + fail("$path.normalizedFileName does not match fileName") + } + } + } + + private fun com.google.gson.JsonObject.genericArray(name: String): com.google.gson.JsonArray? = + get(name)?.let { + if (it.isJsonNull) return null + if (!it.isJsonArray) fail("$name must be an array") + it.asJsonArray + } + + private fun com.google.gson.JsonElement?.requireObject(path: String): com.google.gson.JsonObject { + if (this == null || isJsonNull || !isJsonObject) fail("$path must be an object") + return asJsonObject + } + + private fun com.google.gson.JsonObject.stringValue(name: String, path: String): String { + val element = get(name) + if (element == null || element.isJsonNull) fail("$path.$name is missing") + return element.stringValue("$path.$name") + } + + private fun com.google.gson.JsonElement.stringValue(path: String): String { + if (!isJsonPrimitive || !asJsonPrimitive.isString) fail("$path must be a string") + return asString + } + + private fun com.google.gson.JsonObject.nonNegativeLong(name: String, path: String): Long { + val element = get(name) + if (element == null || element.isJsonNull) fail("$path.$name is missing") + if (!element.isJsonPrimitive || !element.asJsonPrimitive.isNumber) { + fail("$path.$name must be a number") + } + return element.asLong.also { nonNegative(it, "$path.$name") } + } + + private fun com.google.gson.JsonObject.optionalNonNegativeLong(name: String, path: String) { + val element = get(name) ?: return + if (element.isJsonNull) return + if (!element.isJsonPrimitive || !element.asJsonPrimitive.isNumber) { + fail("$path.$name must be a number") + } + nonNegative(element.asLong, "$path.$name") + } + + private fun com.google.gson.JsonObject.array(name: String) = get(name)?.let { + if (!it.isJsonArray) fail("$name must be an array") + it.asJsonArray + } ?: com.google.gson.JsonArray() + private fun com.google.gson.JsonObject.arrayOrObject(name: String) = get(name)?.let { + if (!it.isJsonObject) fail("$name must be an object") + it.asJsonObject + } ?: com.google.gson.JsonObject() + private fun com.google.gson.JsonElement.objectValue(name: String): String = + asJsonObject.get(name)?.takeUnless { it.isJsonNull }?.asString ?: fail("$name is missing") + private fun com.google.gson.JsonElement.objectLong(name: String, default: Long? = null): Long = + asJsonObject.get(name)?.takeUnless { it.isJsonNull }?.asLong ?: default ?: fail("$name is missing") + private fun unique(values: Iterable, label: String, key: (com.google.gson.JsonElement) -> String) { + if (values.map(key).toSet().size != values.count()) fail("Duplicate $label key") + } + private fun String.requireNotBlank(label: String) { if (isBlank()) fail("$label is blank") } + private fun nonNegative(value: Long, label: String) { if (value < 0L) fail("$label is negative") } + private fun fail(message: String): Nothing = throw SyncSerializationException(message) +} + +/** + * 快照序列化/反序列化过程中的异常。 + */ +class SyncSerializationException( + message: String, + cause: Throwable? = null +) : Exception(message, cause) diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncSnapshotStore.kt b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncSnapshotStore.kt new file mode 100644 index 0000000..92515ee --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncSnapshotStore.kt @@ -0,0 +1,31 @@ +package com.cpu.seamlessloopmobile.data.sync + +/** + * 本地快照导出/导入抽象。 + * + * 实现类负责从本地数据仓库构建 [SyncSnapshot], + * 以及将合并后的快照写回本地存储。 + */ +interface SyncSnapshotStore { + + /** + * 导出当前本地数据为同步快照。 + * @param deviceId 设备标识符 + * @param now 导出时间戳(ms),便于测试固定时间 + * @return 包含播放列表、循环点、评分的完整快照 + */ + suspend fun exportSnapshot( + deviceId: String, + now: Long = System.currentTimeMillis() + ): SyncSnapshot + + /** + * 将合并后的快照写回本地存储。 + * @param snapshot 待应用的合并后快照 + * @param trackLocalMutation Whether this externally initiated apply should advance the + * local mutation fence. Coordinators disable it for their own merged apply so concurrent + * local writes remain distinguishable. + * @return 应用操作的详细报告 + */ + suspend fun applySnapshot(snapshot: SyncSnapshot, trackLocalMutation: Boolean = true): SyncReport +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncSongIdentityKey.kt b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncSongIdentityKey.kt new file mode 100644 index 0000000..981bfd3 --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncSongIdentityKey.kt @@ -0,0 +1,65 @@ +package com.cpu.seamlessloopmobile.data.sync + +/** + * Stable sync identity for cross-device merge de-duplication. + * + * `totalSamples` can differ slightly between Android/native decoders and the + * desktop importer for the same file, so it must remain an auxiliary matching + * field rather than part of the primary sync key. + */ +internal data class SyncSongStableKey( + val fileNameKey: String, + val durationMs: Long +) + +internal fun SyncSongIdentity.stableKey(): SyncSongStableKey = + SyncSongStableKey( + fileNameKey = normalizedFileName.ifBlank { normalizeSyncFileName(fileName) }, + durationMs = durationMs + ) + +internal fun areSameSongIdentity( + first: SyncSongIdentity, + second: SyncSongIdentity +): Boolean = first.stableKey() == second.stableKey() + +/** + * Reduces metadata for playback entries with the same exact stable key. + * + * The raw filename winner is the lexicographically smallest UTF-16 code-unit + * string (`String.compareTo`, an ordinal comparison). This is deterministic + * across Android and desktop implementations after valid normalization. The + * largest non-null sample count and largest nonblank content hash are kept. + */ +internal fun reducePlaybackSongIdentity( + identities: Iterable +): SyncSongIdentity { + val entries = identities.toList() + require(entries.isNotEmpty()) { "At least one playback identity is required" } + val key = entries.first().stableKey() + require(entries.all { it.stableKey() == key }) { + "Playback identity reducer requires one exact stable key" + } + + val validRawNames = entries.filter { + it.fileName.isNotBlank() && normalizeSyncFileName(it.fileName) == key.fileNameKey + } + val fileName = (validRawNames.ifEmpty { entries }) + .minWithOrNull(compareBy { it.fileName })!! + .fileName + val contentHash = entries.mapNotNull { it.contentHash?.takeIf(String::isNotBlank) } + .maxOrNull() + + return SyncSongIdentity( + fileName = fileName, + durationMs = key.durationMs, + totalSamples = entries.mapNotNull { it.totalSamples }.maxOrNull(), + normalizedFileName = key.fileNameKey, + contentHash = contentHash + ) +} + +internal fun preferStableSongIdentity( + remote: SyncSongIdentity?, + local: SyncSongIdentity? +): SyncSongIdentity? = remote ?: local diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncV2Egress.kt b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncV2Egress.kt new file mode 100644 index 0000000..70e674f --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncV2Egress.kt @@ -0,0 +1,5 @@ +package com.cpu.seamlessloopmobile.data.sync + +/** Canonicalizes a snapshot immediately before writing the official cloud protocol. */ +fun SyncSnapshot.prepareV2Egress(): SyncSnapshot = + canonicalized().copy(schemaVersion = SYNC_SCHEMA_VERSION_V2) diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/github/GitHubContentsSyncBackend.kt b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/github/GitHubContentsSyncBackend.kt new file mode 100644 index 0000000..858dc43 --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/github/GitHubContentsSyncBackend.kt @@ -0,0 +1,388 @@ +package com.cpu.seamlessloopmobile.data.sync.github + +import com.cpu.seamlessloopmobile.data.sync.GitHubSyncConfig +import com.cpu.seamlessloopmobile.data.sync.GitHubSnapshotRemote +import com.cpu.seamlessloopmobile.data.sync.GitHubTokenProvider +import com.cpu.seamlessloopmobile.data.sync.SyncBackend +import com.cpu.seamlessloopmobile.data.sync.SyncErrorCode +import com.cpu.seamlessloopmobile.data.sync.SyncReport +import com.cpu.seamlessloopmobile.data.sync.SyncResult +import com.cpu.seamlessloopmobile.data.sync.SyncSnapshot +import com.cpu.seamlessloopmobile.data.sync.SyncSnapshotSerializer +import com.cpu.seamlessloopmobile.data.sync.SyncSnapshotSummary +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException +import java.util.Base64 + +/** + * 基于 GitHub Contents API 的同步后端实现。 + * + * 使用单文件策略:将序列化后的 SyncSnapshot 以 base64 编码存储到 + * 仓库指定路径([GitHubSyncConfig.path]),利用 GitHub 文件 SHA + * 实现乐观锁冲突检测。 + */ +class GitHubContentsSyncBackend( + private val config: GitHubSyncConfig, + private val tokenProvider: GitHubTokenProvider, + private val serializer: SyncSnapshotSerializer = SyncSnapshotSerializer(), + private val client: OkHttpClient = OkHttpClient() +) : SyncBackend, GitHubSnapshotRemote { + + companion object { + private const val GITHUB_API_BASE = "https://api.github.com" + private const val ERROR_MESSAGE_MAX_LENGTH = 200 + private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType() + } + + private val contentUrl: String + get() = "$GITHUB_API_BASE/repos/${config.owner}/${config.repo}/contents/${config.path}" + + // ------------------------------------------------------------------- + // SyncBackend 接口 + // ------------------------------------------------------------------- + + override suspend fun uploadSnapshot( + snapshot: SyncSnapshot, + expectedRevision: String? + ): SyncResult = withContext(Dispatchers.IO) { + val token = tokenProvider.getToken() + ?: return@withContext SyncResult.Failure( + "GitHub token not configured", + code = SyncErrorCode.NOT_CONFIGURED + ) + + try { + val json = serializer.serialize(snapshot) + val base64Content = Base64.getEncoder().encodeToString(json.toByteArray(Charsets.UTF_8)) + + val bodyJson = buildPutBody(base64Content, expectedRevision) + val requestBody = bodyJson.toString().toRequestBody(JSON_MEDIA_TYPE) + + val request = Request.Builder() + .url(contentUrl) + .put(requestBody) + .header("Accept", "application/vnd.github+json") + .header("Authorization", "Bearer $token") + .build() + + client.newCall(request).execute().use { response -> + val responseBody = response.body?.string() ?: "" + + when (response.code) { + in 200..299 -> parsePutResponse(responseBody) + 401, 403 -> SyncResult.Failure( + "GitHub authentication failed (${response.code})", + code = SyncErrorCode.UNAUTHORIZED + ) + 404 -> SyncResult.Failure( + "Repository or path not found: ${config.owner}/${config.repo}/${config.path}", + code = SyncErrorCode.NOT_FOUND + ) + 409 -> SyncResult.Failure( + "Conflict: remote has changed since last sync", + code = SyncErrorCode.CONFLICT + ) + 422 -> SyncResult.Failure( + "Unprocessable entity: ${extractMessage(responseBody)}", + code = SyncErrorCode.CONFLICT + ) + else -> SyncResult.Failure( + "GitHub API returned ${response.code}: ${extractMessage(responseBody)}", + code = SyncErrorCode.NETWORK + ) + } + } + } catch (e: IOException) { + SyncResult.Failure("Network error during upload: ${e.message}", e, SyncErrorCode.NETWORK) + } + } + + override suspend fun downloadSnapshot(snapshotId: String?): SyncResult = withContext(Dispatchers.IO) { + val token = tokenProvider.getToken() + ?: return@withContext SyncResult.Failure( + "GitHub token not configured", + code = SyncErrorCode.NOT_CONFIGURED + ) + + try { + val url = buildString { + append(contentUrl) + append("?ref=${config.branch}") + } + val request = Request.Builder() + .url(url) + .get() + .header("Accept", "application/vnd.github+json") + .header("Authorization", "Bearer $token") + .build() + + client.newCall(request).execute().use { response -> + val responseBody = response.body?.string() ?: "" + + when (response.code) { + in 200..299 -> parseGetResponse(responseBody) + 401, 403 -> SyncResult.Failure( + "GitHub authentication failed (${response.code})", + code = SyncErrorCode.UNAUTHORIZED + ) + 404 -> SyncResult.Failure( + "Snapshot file not found at ${config.path}", + code = SyncErrorCode.NOT_FOUND + ) + else -> SyncResult.Failure( + "GitHub API returned ${response.code}: ${extractMessage(responseBody)}", + code = SyncErrorCode.NETWORK + ) + } + } + } catch (e: IOException) { + SyncResult.Failure("Network error during download: ${e.message}", e, SyncErrorCode.NETWORK) + } + } + + override suspend fun listSnapshots(): SyncResult { + val downloadResult = downloadSnapshot() + return when (downloadResult) { + is SyncResult.Success -> { + val snapshot = downloadResult.snapshot + ?: return SyncResult.Success(SyncReport()) + val summary = SyncSnapshotSummary( + id = downloadResult.remoteRevision ?: "", + schemaVersion = snapshot.schemaVersion, + deviceId = snapshot.deviceId, + exportedAt = snapshot.exportedAt + ) + SyncResult.Success( + report = SyncReport(), + snapshotSummaries = listOf(summary) + ) + } + is SyncResult.Failure -> { + // 404 means no file yet → empty list + if (downloadResult.code == SyncErrorCode.NOT_FOUND) { + SyncResult.Success(SyncReport()) + } else { + downloadResult + } + } + is SyncResult.Cancelled -> downloadResult + } + } + + // ------------------------------------------------------------------- + // GitHub 专用操作 + // ------------------------------------------------------------------- + + /** + * 删除远程快照文件。 + * 先 GET 获取当前文件 SHA,再发送 DELETE 请求。 + * - 文件不存在 (404) → 视为幂等成功 + * - 成功 → 返回 Success,可能携带 commit SHA + */ + override suspend fun deleteSnapshot(): SyncResult = withContext(Dispatchers.IO) { + val token = tokenProvider.getToken() + ?: return@withContext SyncResult.Failure( + "GitHub token not configured", + code = SyncErrorCode.NOT_CONFIGURED + ) + try { + // 1. GET current file to obtain SHA + val getUrl = "$contentUrl?ref=${config.branch}" + val getRequest = Request.Builder() + .url(getUrl) + .get() + .header("Accept", "application/vnd.github+json") + .header("Authorization", "Bearer $token") + .build() + + client.newCall(getRequest).execute().use { getResponse -> + val getBody = getResponse.body?.string() ?: "" + when (getResponse.code) { + in 200..299 -> { + val sha = try { + JsonParser.parseString(getBody).asJsonObject.get("sha")?.asString + } catch (e: Exception) { + null + } + if (sha == null) { + return@withContext SyncResult.Failure( + "Missing SHA in GET response for delete", + code = SyncErrorCode.INVALID_REMOTE + ) + } + // 2. DELETE with SHA + val deleteBody = JsonObject().apply { + addProperty("message", "Delete sync snapshot") + addProperty("sha", sha) + addProperty("branch", config.branch) + } + val requestBody = deleteBody.toString().toRequestBody(JSON_MEDIA_TYPE) + val deleteRequest = Request.Builder() + .url(contentUrl) + .delete(requestBody) + .header("Accept", "application/vnd.github+json") + .header("Authorization", "Bearer $token") + .build() + + client.newCall(deleteRequest).execute().use { response -> + val respBody = response.body?.string() + when (response.code) { + in 200..299 -> { + val revSha = respBody?.let { parseDeleteCommitSha(it) } + SyncResult.Success(SyncReport(), remoteRevision = revSha) + } + 401, 403 -> SyncResult.Failure( + "GitHub auth failed (${response.code})", + code = SyncErrorCode.UNAUTHORIZED + ) + 404 -> SyncResult.Success(SyncReport()) + 409, 422 -> SyncResult.Failure( + "Conflict during delete: ${extractMessage(respBody ?: "")}", + code = SyncErrorCode.CONFLICT + ) + else -> SyncResult.Failure( + "GitHub DELETE returned ${response.code}", + code = SyncErrorCode.NETWORK + ) + } + } + } + 401, 403 -> SyncResult.Failure( + "GitHub auth failed (${getResponse.code})", + code = SyncErrorCode.UNAUTHORIZED + ) + 404 -> SyncResult.Success(SyncReport()) // 幂等删除 + else -> SyncResult.Failure( + "GitHub GET returned ${getResponse.code}: ${extractMessage(getBody)}", + code = SyncErrorCode.NETWORK + ) + } + } + } catch (e: IOException) { + SyncResult.Failure("Network error during delete: ${e.message}", e, SyncErrorCode.NETWORK) + } + } + + // ------------------------------------------------------------------- + // 内部辅助方法 + // ------------------------------------------------------------------- + + /** + * 构建 PUT 请求的 JSON body。 + */ + private fun buildPutBody( + base64Content: String, + expectedRevision: String? + ): com.google.gson.JsonObject { + val body = com.google.gson.JsonObject() + body.addProperty("message", "Sync snapshot update") + body.addProperty("content", base64Content) + body.addProperty("branch", config.branch) + expectedRevision?.let { body.addProperty("sha", it) } + return body + } + + /** + * 解析 GET 成功响应,提取 base64 content、sha、decoded snapshot。 + */ + private fun parseGetResponse(responseBody: String): SyncResult { + return try { + val root = JsonParser.parseString(responseBody).asJsonObject + + val sha = root.get("sha")?.asString + ?: return SyncResult.Failure( + "Missing SHA in GitHub response", + code = SyncErrorCode.INVALID_REMOTE + ) + + val contentBase64 = root.get("content")?.asString + ?: return SyncResult.Failure( + "Missing content in GitHub response", + code = SyncErrorCode.INVALID_REMOTE + ) + + val normalizedBase64 = contentBase64.replace("\n", "").replace("\r", "") + val decodedBytes = Base64.getDecoder().decode(normalizedBase64) + val decodedJson = decodedBytes.toString(Charsets.UTF_8) + + val snapshot = try { + serializer.deserialize(decodedJson) + } catch (e: Exception) { + return SyncResult.Failure( + "Failed to parse snapshot from GitHub: ${e.message}", + e, + SyncErrorCode.INVALID_REMOTE + ) + } + + SyncResult.Success( + report = SyncReport(), + snapshot = snapshot, + remoteRevision = sha + ) + } catch (e: Exception) { + SyncResult.Failure( + "Invalid JSON in GitHub response: ${e.message}", + e, + SyncErrorCode.INVALID_REMOTE + ) + } + } + + /** + * 解析 PUT 成功响应,提取新 SHA。 + */ + private fun parsePutResponse(responseBody: String): SyncResult { + return try { + val root = JsonParser.parseString(responseBody).asJsonObject + + // 优先从 content.sha 获取文件 SHA,回退到 commit.sha + val sha = root.getAsJsonObject("content")?.get("sha")?.asString + ?: root.getAsJsonObject("commit")?.get("sha")?.asString + ?: return SyncResult.Failure( + "Missing content.sha and commit.sha in PUT response", + code = SyncErrorCode.INVALID_REMOTE + ) + + SyncResult.Success( + report = SyncReport(), + remoteRevision = sha + ) + } catch (e: Exception) { + SyncResult.Failure( + "Invalid JSON in PUT response: ${e.message}", + e, + SyncErrorCode.INVALID_REMOTE + ) + } + } + + /** + * 从 DELETE 成功响应中提取 commit SHA(可选 fallback)。 + */ + private fun parseDeleteCommitSha(responseBody: String): String? { + return try { + val root = JsonParser.parseString(responseBody).asJsonObject + root.getAsJsonObject("commit")?.get("sha")?.asString + } catch (e: Exception) { null } + } + + /** + * 从 GitHub API 错误响应体中提取 message 字段。 + */ + private fun extractMessage(responseBody: String): String { + return try { + val root = JsonParser.parseString(responseBody).asJsonObject + root.get("message")?.asString ?: responseBody.take(ERROR_MESSAGE_MAX_LENGTH) + } catch (e: Exception) { + responseBody.take(ERROR_MESSAGE_MAX_LENGTH) + } + } +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/room/PlaylistIdMapper.kt b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/room/PlaylistIdMapper.kt new file mode 100644 index 0000000..f9981f5 --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/room/PlaylistIdMapper.kt @@ -0,0 +1,59 @@ +package com.cpu.seamlessloopmobile.data.sync.room + +/** + * 歌单同步 ID 映射记录。 + * @property syncId 跨设备一致的 UUID 字符串 + * @property localId 本地 Room Playlist 表主键 + * @property modifiedAt 上次导出时的修改时间戳(ms) + * @property fingerprint 歌单内容的哈希指纹 + */ +data class PlaylistSyncRecord( + val syncId: String, + val localId: Int, + val modifiedAt: Long, + val fingerprint: String +) + +/** + * 歌单同步 ID 映射器接口。 + * + * 负责在本地 Room 主键 (Int) 和跨设备可移植的 syncId (UUID string) + * 之间建立双向映射,同时追踪歌单内容和修改时间。 + */ +interface PlaylistIdMapper { + + /** + * 获取或创建 syncId 用于导出。 + * - 如果 localId 已有映射且 fingerprint 一致 → 返回现有记录,modifiedAt 不变。 + * - 如果 fingerprint 变脏 → 更新 modifiedAt 为 now。 + * - 如果 localId 尚无映射 → 生成新的 UUID syncId,modifiedAt = now。 + */ + suspend fun getOrCreateSyncIdForExport( + localId: Int, + fingerprint: String, + now: Long + ): PlaylistSyncRecord + + /** 通过 syncId 查找本地主键;未找到时返回 null。 */ + suspend fun findLocalId(syncId: String): Int? + + /** 通过本地主键查找 syncId;未找到时返回 null。 */ + suspend fun findSyncId(localId: Int): String? + + /** 保存或更新一条映射记录。 */ + suspend fun saveMapping( + syncId: String, + localId: Int, + modifiedAt: Long, + fingerprint: String + ) + + /** + * 清理已不存在的本地歌单对应映射记录。 + * @param validLocalIds 当前数据库中仍存在的所有本地歌单 ID 集合 + */ + suspend fun removeStaleMappings(validLocalIds: Set) + + /** 清除所有映射记录。 */ + suspend fun clearAllMappings() +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/room/RoomSyncSnapshotStore.kt b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/room/RoomSyncSnapshotStore.kt new file mode 100644 index 0000000..0618503 --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/room/RoomSyncSnapshotStore.kt @@ -0,0 +1,747 @@ +package com.cpu.seamlessloopmobile.data.sync.room + +import androidx.room.withTransaction +import com.cpu.seamlessloopmobile.db.AppDatabase +import com.cpu.seamlessloopmobile.data.stats.ListenStatsContribution +import com.cpu.seamlessloopmobile.data.stats.ListenStatsDevice +import com.cpu.seamlessloopmobile.data.stats.ListenStatsLocalPayload +import com.cpu.seamlessloopmobile.data.stats.ListenStatsRepository +import com.cpu.seamlessloopmobile.data.stats.ListenStatsSongNode +import com.cpu.seamlessloopmobile.data.stats.ListenStatsSource +import com.cpu.seamlessloopmobile.data.stats.ListenStatsTombstone +import com.cpu.seamlessloopmobile.data.stats.ListenStatsUnresolvedNode +import com.cpu.seamlessloopmobile.data.stats.normalizedStatsFileName +import com.cpu.seamlessloopmobile.model.Playlist +import com.cpu.seamlessloopmobile.model.PlaylistDao +import com.cpu.seamlessloopmobile.model.Song +import com.cpu.seamlessloopmobile.model.SongDao +import com.cpu.seamlessloopmobile.model.LoopPoint +import com.cpu.seamlessloopmobile.model.UserRating +import com.cpu.seamlessloopmobile.data.sync.SyncConflict +import com.cpu.seamlessloopmobile.data.sync.SyncLoopPoint +import com.cpu.seamlessloopmobile.data.sync.SyncLoopPointEntry +import com.cpu.seamlessloopmobile.data.sync.SyncPlaylist +import com.cpu.seamlessloopmobile.data.sync.SyncPlaylistItem +import com.cpu.seamlessloopmobile.data.sync.SyncRating +import com.cpu.seamlessloopmobile.data.sync.SyncRatingEntry +import com.cpu.seamlessloopmobile.data.sync.SyncReport +import com.cpu.seamlessloopmobile.data.sync.SyncSnapshot +import com.cpu.seamlessloopmobile.data.sync.SyncSongIdentity +import com.cpu.seamlessloopmobile.data.sync.SyncSongStableKey +import com.cpu.seamlessloopmobile.data.sync.SyncSnapshotStore +import com.cpu.seamlessloopmobile.data.sync.stableKey +import com.cpu.seamlessloopmobile.data.sync.SyncPlaybackStats +import com.cpu.seamlessloopmobile.data.sync.SyncPlaybackStatsContribution +import com.cpu.seamlessloopmobile.data.sync.SyncPlaybackStatsDevice +import com.cpu.seamlessloopmobile.data.sync.SyncPlaybackStatsSong +import com.cpu.seamlessloopmobile.data.sync.SyncPlaybackStatsTombstone +import com.cpu.seamlessloopmobile.data.sync.isSemanticallyValid +import com.cpu.seamlessloopmobile.data.sync.reducePlaybackSongIdentity +import com.google.gson.Gson +import kotlin.math.abs + +/** + * Room 数据库驱动的 [SyncSnapshotStore] 实现。 + * + * 导出时从 Room 读取播放列表、循环点、评分数据; + * 应用时将合并后的快照写回 Room。 + */ +class RoomSyncSnapshotStore( + private val database: AppDatabase, + private val songDao: SongDao, + private val playlistDao: PlaylistDao, + private val playlistIdMapper: PlaylistIdMapper, + private val listenStatsRepository: ListenStatsRepository +) : SyncSnapshotStore { + private val gson = Gson() + + // =================================================================== + // exportSnapshot + // =================================================================== + + override suspend fun exportSnapshot( + deviceId: String, + now: Long + ): SyncSnapshot { + val allSongs = songDao.getAllSongs() // IsAbPartB = 0 + + // 循环点:仅导出有实质内容的(非 0,0) + val loopPoints = allSongs + .filter { it.loopStart != 0L || it.loopEnd != 0L } + .map { song -> + SyncLoopPointEntry( + song = identityFromSong(song), + loopPoint = SyncLoopPoint( + loopStart = song.loopStart, + loopEnd = song.loopEnd, + lastModified = now // 替代值:目前 LoopPoint 表无 lastModified + ) + ) + } + + // 评分:仅导出非零评分 + val ratings = allSongs + .filter { it.rating != 0 } + .map { song -> + SyncRatingEntry( + song = identityFromSong(song), + rating = SyncRating( + rating = song.rating, + lastModified = song.userRating?.lastModified ?: now + ) + ) + } + + // 播放列表 + val playlistWithCounts = playlistDao.getPlaylistsWithCounts() + val playlists = playlistWithCounts.map { pc -> + val playlist = pc.playlist + val songs = playlistDao.getSongsInPlaylist(playlist.id) + val items = songs.mapIndexed { index, song -> + SyncPlaylistItem( + song = identityFromSong(song), + sortOrder = index + ) + } + val fingerprint = computePlaylistFingerprint(playlist.name, items) + val record = playlistIdMapper.getOrCreateSyncIdForExport( + localId = playlist.id, + fingerprint = fingerprint, + now = now + ) + SyncPlaylist( + id = record.syncId, + name = playlist.name, + createdAt = playlist.createdAt, + modifiedAt = record.modifiedAt, + items = items + ) + } + val playbackStats = listenStatsRepository.exportLocalPayload().toSyncPlaybackStats( + listenStatsRepository.currentSource() + ) + + return SyncSnapshot( + deviceId = deviceId, + exportedAt = now, + playlists = playlists, + loopPoints = loopPoints, + ratings = ratings, + playbackStats = playbackStats + ) + } + + // =================================================================== + // applySnapshot + // =================================================================== + + override suspend fun applySnapshot( + snapshot: SyncSnapshot, + trackLocalMutation: Boolean + ): SyncReport { + validatePlaybackStats(snapshot.playbackStats) + val report = database.withTransaction { + applySnapshotInternal(snapshot) + } + applyPlaybackStats(snapshot.playbackStats, trackLocalMutation) + // A scan may have completed while the merged payload was being persisted. + reAssociatePlaybackStats(songDao.getAllSongs()) + return report + } + + /** Rebinds persisted playback statistics to the current Room song metadata. */ + suspend fun rebindPlaybackStats() { + reAssociatePlaybackStats(songDao.getAllSongs()) + } + + private fun validatePlaybackStats(stats: SyncPlaybackStats) { + stats.songs.forEachIndexed { index, song -> + require(song.isSemanticallyValid()) { + "Invalid playback statistics song at index $index" + } + } + val stableKeys = stats.songs.map { it.song.stableKey() } + require(stableKeys.toSet().size == stableKeys.size) { + "Duplicate playback statistics song stable key" + } + } + + private suspend fun applySnapshotInternal(snapshot: SyncSnapshot): SyncReport { + val conflicts = mutableListOf() + val allLocalSongs = songDao.getAllSongs() // IsAbPartB = 0 + val matcher = SongMatcher(allLocalSongs) + + // ---- 循环点 ---- + var loopDownloaded = 0 + for (entry in snapshot.loopPoints) { + val localSong = matcher.match(entry.song) + if (localSong == null) { + conflicts.add(SyncConflict( + songIdentity = entry.song, + field = "loopPoint", + remoteValue = "(${entry.loopPoint.loopStart}, ${entry.loopPoint.loopEnd})", + localValue = null, + resolution = "skipped-unmatched" + )) + continue + } + + // 仅当远程有实质内容时才应用 + if (!entry.loopPoint.isSubstantive) continue + + val existingLoop = songDao.getLoopPointBySongId(localSong.id) + if (existingLoop?.loopStart == entry.loopPoint.loopStart && + existingLoop.loopEnd == entry.loopPoint.loopEnd + ) { + continue + } + + // 远程是实质内容时才覆盖本地;未设置值在上方直接跳过。 + songDao.insertLoopPoint(LoopPoint( + songId = localSong.id, + loopStart = entry.loopPoint.loopStart, + loopEnd = entry.loopPoint.loopEnd + )) + loopDownloaded++ + } + + // ---- 评分 ---- + var ratingDownloaded = 0 + for (entry in snapshot.ratings) { + val localSong = matcher.match(entry.song) + if (localSong == null) { + conflicts.add(SyncConflict( + songIdentity = entry.song, + field = "rating", + remoteValue = "${entry.rating.rating}", + localValue = null, + resolution = "skipped-unmatched" + )) + continue + } + + // 评分 0 是未设置 → 跳过 + if (entry.rating.rating == 0) continue + + val existingRating = songDao.getUserRatingBySongId(localSong.id) + + // 仅当远程评分更新或等新时才覆盖 + if (existingRating != null && + existingRating.rating != 0 && + existingRating.lastModified > entry.rating.lastModified + ) { + // 本地评分更新且非零 → 保留本地 + continue + } + + songDao.insertUserRating(UserRating( + songId = localSong.id, + rating = entry.rating.rating, + lastModified = entry.rating.lastModified + )) + ratingDownloaded++ + } + + // ---- 播放列表 ---- + val allLocalPlaylists = playlistDao.getPlaylistsWithCounts() + val validLocalIds = allLocalPlaylists.map { it.playlist.id }.toSet() + playlistIdMapper.removeStaleMappings(validLocalIds) + + var playlistDownloaded = 0 + + for (playlist in snapshot.playlists) { + // 解析本地歌单 ID + val resolvedId = resolvePlaylistId(playlist) + + if (resolvedId == null) { + conflicts.add(SyncConflict( + playlistName = playlist.name, + resolution = "skipped-unmatched-local-playlist" + )) + continue + } + + // 更新歌单名称 + val existingPlaylist = playlistDao.getPlaylistById(resolvedId) + if (existingPlaylist != null && existingPlaylist.name != playlist.name) { + playlistDao.updatePlaylist(existingPlaylist.copy(name = playlist.name)) + } + + // 匹配歌曲 + val orderedItems = playlist.items.sortedWith( + compareBy { it.sortOrder } + .thenBy { it.song.fileName.lowercase() } + .thenBy { it.song.durationMs } + ) + val matchedSongIds = orderedItems.distinctBy { it.song.stableKey() }.mapNotNull { item -> + val song = matcher.match(item.song) + if (song == null) { + conflicts.add(SyncConflict( + songIdentity = item.song, + field = "playlistItem", + playlistName = playlist.name, + resolution = "skipped-unmatched" + )) + null + } else { + song.id + } + } + + // 清空并同步 + playlistDao.clearAndSyncPlaylist(resolvedId, matchedSongIds) + playlistDownloaded++ + + // 保存或更新映射 + val fingerprint = computePlaylistFingerprint(playlist.name, playlist.items) + playlistIdMapper.saveMapping( + syncId = playlist.id, + localId = resolvedId, + modifiedAt = playlist.modifiedAt, + fingerprint = fingerprint + ) + } + return SyncReport( + playlistsDownloaded = playlistDownloaded, + loopPointsDownloaded = loopDownloaded, + ratingsDownloaded = ratingDownloaded, + conflicts = conflicts + ) + } + + // =================================================================== + // 解析播放列表 + // =================================================================== + + /** + * 解析远程歌单对应的本地歌单 ID。 + * 1. 通过映射查找 + * 2. 如果映射失效(已删除),按名称匹配 + * 3. 如果仍找不到,创建新歌单 + */ + private suspend fun resolvePlaylistId(playlist: SyncPlaylist): Int? { + // 步骤 1:通过 syncId 查找映射 + val mappedLocalId = playlistIdMapper.findLocalId(playlist.id) + if (mappedLocalId != null) { + val existing = playlistDao.getPlaylistById(mappedLocalId) + if (existing != null) return existing.id + } + + // 步骤 2:按名称匹配 + val byName = playlistDao.getPlaylistByName(playlist.name) + if (byName != null) return byName.id + + // 步骤 3:创建新歌单 + val newId = playlistDao.insertPlaylist(Playlist( + name = playlist.name, + createdAt = playlist.createdAt + )) + return newId.toInt() + } + + // =================================================================== + // 歌曲身份匹配 + // =================================================================== + + /** + * 本地歌曲匹配器。 + * 对传入的 [SyncSongIdentity] 执行多级匹配,返回匹配的本地 [Song] 或 null。 + */ + private class SongMatcher(allLocalSongs: List) { + /** fileName (小写) → 候选列表 */ + private val byName: Map> = + allLocalSongs.groupBy { it.fileName.lowercase() } + + /** fileName+duration → candidates. Only unique matches are accepted. */ + private val byFingerprint: Map, List> = + allLocalSongs.groupBy { it.fileName.lowercase() to it.duration } + + /** fileName+totalSamples → candidates (仅 non-zero samples). Only unique matches are accepted. */ + private val bySamples: Map, List> = + allLocalSongs + .filter { it.totalSamples != 0L } + .groupBy { it.fileName.lowercase() to it.totalSamples } + + /** + * 执行多级匹配。 + * 顺序:精确 fileName+duration → 精确 fileName+totalSamples → + * fileName+totalSamples ±10000 → fileName+duration ±200ms → 唯一同名。 + */ + fun match(identity: SyncSongIdentity): Song? { + val key = identity.fileName.lowercase() + + // 1. 精确 fileName + duration + byFingerprint[key to identity.durationMs] + ?.takeIf { it.size == 1 } + ?.first() + ?.let { return it } + + // 2. 精确 fileName + totalSamples + identity.totalSamples?.let { samples -> + bySamples[key to samples] + ?.takeIf { it.size == 1 } + ?.first() + ?.let { return it } + } + + // 3. fileName + totalSamples ±10000 + val candidates = byName[key].orEmpty() + identity.totalSamples?.let { samples -> + val sampleToleranceMatches = candidates.filter { + it.totalSamples > 0L && abs(it.totalSamples - samples) <= TOTAL_SAMPLES_TOLERANCE + } + if (sampleToleranceMatches.size == 1) return sampleToleranceMatches.first() + } + + // 4. fileName + duration ±200ms + if (candidates.isNotEmpty()) { + val toleranceMatches = candidates.filter { + abs(it.duration - identity.durationMs) <= 200L + } + if (toleranceMatches.size == 1) return toleranceMatches.first() + } + + // 5. 唯一同名(只有一个候选) + if (candidates.size == 1) return candidates.first() + + return null + } + + companion object { + private const val TOTAL_SAMPLES_TOLERANCE = 10_000L + } + } + + private suspend fun applyPlaybackStats( + stats: SyncPlaybackStats, + trackLocalMutation: Boolean + ) { + val localSongs = songDao.getAllSongs() + val matcher = PlaybackStatsSongMatcher(localSongs) + val current = listenStatsRepository.exportLocalPayload() + val resolved = current.songs.toMutableList() + val unresolved = current.unresolvedNodes.toMutableList() + + stats.songs.forEach { remote -> + resolved.add(remote.toLocalNode(matcher.match(remote.song))) + } + listenStatsRepository.applyLocalPayload( + ListenStatsLocalPayload( + currentDeviceId = current.currentDeviceId, + currentGeneration = current.currentGeneration, + devices = stats.devices.map { it.toLocalDevice() }, + songs = resolved, + tombstones = stats.tombstones.map { it.toLocalTombstone() }, + unresolvedNodes = mergeUnresolvedNodes(unresolved) + ), + trackMutation = trackLocalMutation + ) + } + + private suspend fun reAssociatePlaybackStats(localSongs: List) { + val current = listenStatsRepository.exportLocalPayload() + val matcher = PlaybackStatsSongMatcher(localSongs) + val parseable = current.unresolvedNodes.mapNotNull { unresolved -> + parseUnresolvedSyncSong(unresolved) + } + val malformed = current.unresolvedNodes.filter { unresolved -> + parseUnresolvedSyncSong(unresolved) == null + } + val candidates = current.songs + parseable.map { it.toLocalNode(null) } + val rebound = candidates.map { node -> + val local = matcher.match(node.toSyncIdentity()) + if (local == null) { + // The wire identity and last-known presentation metadata survive a stale binding. + node.copy(boundSongId = 0L) + } else { + node.copy( + boundSongId = local.id, + displayName = local.displayName, + artist = local.artist, + album = local.album, + coverPath = local.coverPath, + filePath = local.filePath + ) + } + } + listenStatsRepository.applyLocalPayload(current.copy( + songs = mergeLocalStatsNodes(rebound), + unresolvedNodes = malformed + ), trackMutation = false) + } + + private fun ListenStatsLocalPayload.toSyncPlaybackStats(currentSource: ListenStatsSource): SyncPlaybackStats = SyncPlaybackStats( + devices = devices.map { device -> + val generation = if (device.deviceId == currentSource.device.deviceId) currentSource.currentGeneration + else device.currentGeneration + SyncPlaybackStatsDevice(device.deviceId, device.displayName, device.createdAt, + device.lastSeenAt, generation, device.platform, device.displayNameUpdatedAtUtcMs) + }, + songs = mergeSyncPlaybackStatsSongs( + songs.map { it.toSyncSong(tombstones) } + unresolvedNodes.mapNotNull(::parseUnresolvedSyncSong), + tombstones + ), + tombstones = tombstones.map { tombstone -> SyncPlaybackStatsTombstone(tombstone.deviceId, tombstone.generation, + tombstone.tombstonedAtUtcMs, tombstonedByDeviceId = tombstone.operatorDeviceId, reason = tombstone.reason) } + ).sorted() + + private fun mergeLocalStatsNodes(nodes: List): List = + nodes.groupBy { it.wireKey() } + .toSortedMap(compareBy { it.fileNameKey }.thenBy { it.durationMs }) + .values.map { duplicates -> + val identity = reducePlaybackSongIdentity(duplicates.map { it.toSyncIdentity() }) + val binding = duplicates.minWithOrNull( + compareBy { if (it.boundSongId > 0L) 0 else 1 } + .thenBy { it.boundSongId } + .thenBy { it.filePath } + .thenBy { it.displayName } + )!! + binding.copy( + identityKey = identity.localIdentityKey(), + normalizedFileName = identity.normalizedFileName, + fileName = identity.fileName, + durationMs = identity.durationMs, + totalSamples = identity.totalSamples, + contentHash = identity.contentHash + ).withMergedContributions(duplicates.flatMap { it.contributions }) + } + + /** Collapses parseable revisions while retaining malformed payloads verbatim for later recovery. */ + private fun mergeUnresolvedNodes(nodes: List): List { + val parsed = mutableMapOf() + val malformed = mutableListOf() + nodes.forEach { node -> + val song = parseUnresolvedSyncSong(node) + if (song == null) { + malformed += node + } else { + val key = song.song.stableKey() + parsed[key] = parsed[key]?.let { existing -> + existing.copy( + song = reducePlaybackSongIdentity(listOf(existing.song, song.song)), + contributions = mergeSyncContributions(existing.contributions + song.contributions) + ) + } ?: song.copy(contributions = mergeSyncContributions(song.contributions)) + } + } + return parsed.values.map { it.toUnresolvedNode() } + malformed + } + + /** Gson permits missing Kotlin non-null fields, so validate before using an unresolved node. */ + private fun parseUnresolvedSyncSong(node: ListenStatsUnresolvedNode): SyncPlaybackStatsSong? = + runCatching { + gson.fromJson(node.payloadJson, SyncPlaybackStatsSong::class.java) + }.getOrNull()?.takeIf { it.isSemanticallyValid() } + + private fun mergeSyncPlaybackStatsSongs( + songs: List, + tombstones: List + ): List = songs.groupBy { it.song.stableKey() } + .toSortedMap(compareBy { it.fileNameKey }.thenBy { it.durationMs }).values.map { duplicates -> + val identity = reducePlaybackSongIdentity(duplicates.map { it.song }) + duplicates.first().copy( + song = identity, + contributions = mergeSyncContributions(duplicates.flatMap { it.contributions }) + ).let { song -> + song.copy(contributions = song.contributions.filterNot { contribution -> tombstones.any { + it.deviceId == contribution.deviceId && it.generation == contribution.generation + } }) + } + } + + private fun mergeLocalContributions(contributions: List): List = + contributions.groupBy { it.deviceId to it.generation }.toSortedMap(compareBy> { it.first }.thenBy { it.second }).values.map { duplicates -> + duplicates.drop(1).fold(duplicates.first()) { first, second -> + ListenStatsContribution( + deviceId = first.deviceId, + generation = first.generation, + dailyListenMs = (first.dailyListenMs.keys + second.dailyListenMs.keys).associateWith { date -> + maxOf(first.dailyListenMs[date] ?: 0L, second.dailyListenMs[date] ?: 0L) + }, + undatedListenMs = maxOf(first.undatedListenMs, second.undatedListenMs), + firstPlayedAtUtcMs = earliestMeaningful(first.firstPlayedAtUtcMs, second.firstPlayedAtUtcMs), + lastPlayedAtUtcMs = maxOf(first.lastPlayedAtUtcMs, second.lastPlayedAtUtcMs), + updatedAtUtcMs = maxOf(first.updatedAtUtcMs, second.updatedAtUtcMs) + ) + } + } + + private fun mergeSyncContributions(contributions: List): List = + contributions.groupBy { it.deviceId to it.generation }.toSortedMap(compareBy> { it.first }.thenBy { it.second }).values.map { duplicates -> + duplicates.drop(1).fold(duplicates.first()) { first, second -> + SyncPlaybackStatsContribution( + deviceId = first.deviceId, + generation = first.generation, + datedListenMs = (first.datedListenMs.keys + second.datedListenMs.keys).associateWith { date -> + maxOf(first.datedListenMs[date] ?: 0L, second.datedListenMs[date] ?: 0L) + }, + undatedListenMs = maxOf(first.undatedListenMs, second.undatedListenMs), + firstPlayedAtUtcMs = earliestMeaningful(first.firstPlayedAtUtcMs, second.firstPlayedAtUtcMs), + lastPlayedAtUtcMs = maxOf(first.lastPlayedAtUtcMs, second.lastPlayedAtUtcMs), + updatedAtUtcMs = maxOf(first.updatedAtUtcMs, second.updatedAtUtcMs) + ) + } + } + + private fun earliestMeaningful(first: Long, second: Long): Long = when { + first == 0L -> second + second == 0L -> first + else -> minOf(first, second) + } + + private fun ListenStatsSongNode.wireKey(): SyncSongStableKey = + SyncSongStableKey(normalizedFileName, durationMs) + + private fun ListenStatsSongNode.toSyncIdentity() = SyncSongIdentity( + fileName = fileName, + durationMs = durationMs, + totalSamples = totalSamples, + normalizedFileName = normalizedFileName, + contentHash = contentHash + ) + + private fun SyncSongIdentity.localIdentityKey(): String = + "${normalizedFileName}|${durationMs}" + + private fun ListenStatsSongNode.toSyncSong(tombstones: List) = SyncPlaybackStatsSong( + song = SyncSongIdentity( + fileName = fileName, + durationMs = durationMs, + totalSamples = totalSamples, + normalizedFileName = normalizedFileName, + contentHash = contentHash + ), + contributions = contributions.filterNot { contribution -> tombstones.any { + it.deviceId == contribution.deviceId && it.generation == contribution.generation + } }.map { contribution -> SyncPlaybackStatsContribution(contribution.deviceId, + contribution.generation, contribution.dailyListenMs, contribution.undatedListenMs, + contribution.firstPlayedAtUtcMs, contribution.lastPlayedAtUtcMs, contribution.updatedAtUtcMs) } + ) + + private fun SyncPlaybackStatsSong.toLocalNode(local: Song?) = ListenStatsSongNode( + identityKey = song.localIdentityKey(), + normalizedFileName = song.normalizedFileName, + fileName = song.fileName, + boundSongId = local?.id ?: 0L, + displayName = local?.displayName ?: song.fileName, + artist = local?.artist ?: "", + album = local?.album ?: "", + coverPath = local?.coverPath, + durationMs = song.durationMs, + totalSamples = song.totalSamples, + contentHash = song.contentHash, + filePath = local?.filePath ?: "", + contributions = contributions.map { it.toLocalContribution() } + ).withMergedContributions(contributions.map { it.toLocalContribution() }) + + private fun SyncPlaybackStatsSong.toUnresolvedNode() = ListenStatsUnresolvedNode( + normalizedFileName = song.normalizedFileName, durationMs = song.durationMs, payloadJson = gson.toJson(this) + ) + + private fun SyncPlaybackStatsContribution.toLocalContribution() = ListenStatsContribution(deviceId, generation, + datedListenMs, undatedListenMs, firstPlayedAtUtcMs, lastPlayedAtUtcMs, updatedAtUtcMs) + + private fun ListenStatsSongNode.withMergedContributions( + contributions: List + ): ListenStatsSongNode { + val merged = mergeLocalContributions(contributions) + return copy( + contributions = merged, + firstPlayedAt = merged.map { it.firstPlayedAtUtcMs }.filter { it > 0L }.minOrNull() ?: 0L, + lastPlayedAt = merged.maxOfOrNull { it.lastPlayedAtUtcMs } ?: 0L + ) + } + + private fun SyncPlaybackStatsDevice.toLocalDevice() = ListenStatsDevice(deviceId, displayName, + displayNameUpdatedAtUtcMs, platform, currentGeneration = currentGeneration, createdAt = firstSeenAtUtcMs, + lastSeenAt = lastSeenAtUtcMs, updatedAtUtcMs = displayNameUpdatedAtUtcMs) + + private fun SyncPlaybackStatsTombstone.toLocalTombstone() = ListenStatsTombstone(deviceId, generation, + tombstonedAtUtcMs, tombstonedByDeviceId, reason) + + private class PlaybackStatsSongMatcher(songs: List) { + private val byName: Map> = songs.groupBy { + normalizedStatsFileName(it.fileName) + } + private val byDuration: Map, List> = songs.groupBy { + normalizedStatsFileName(it.fileName) to it.duration + } + private val bySamples: Map, List> = songs + .filter { it.totalSamples != 0L } + .groupBy { normalizedStatsFileName(it.fileName) to it.totalSamples } + + fun match(identity: SyncSongIdentity): Song? { + val name = normalizedStatsFileName(identity.fileName) + val candidates = byName[name].orEmpty() + + byDuration[name to identity.durationMs] + ?.singleOrNull() + ?.let { return it } + + identity.totalSamples?.let { samples -> + bySamples[name to samples] + ?.singleOrNull() + ?.let { return it } + + candidates.filter { song -> + song.totalSamples > 0L && withinDistance(song.totalSamples, samples, SAMPLE_TOLERANCE) + }.singleOrNull()?.let { return it } + } + + candidates.filter { song -> + withinDistance(song.duration, identity.durationMs, DURATION_TOLERANCE) + }.singleOrNull()?.let { return it } + + return candidates.singleOrNull() + } + + private companion object { + const val SAMPLE_TOLERANCE = 10_000L + const val DURATION_TOLERANCE = 200L + + /** Returns false when the mathematical difference cannot fit in Long. */ + fun withinDistance(first: Long, second: Long, tolerance: Long): Boolean { + val lower = minOf(first, second) + val upper = maxOf(first, second) + val difference = try { + Math.subtractExact(upper, lower) + } catch (_: ArithmeticException) { + return false + } + return difference <= tolerance + } + } + } + + // =================================================================== + // 工具方法 + // =================================================================== + + private fun identityFromSong(song: Song): SyncSongIdentity = + SyncSongIdentity( + fileName = song.fileName, + durationMs = song.duration, + totalSamples = song.totalSamples.takeIf { it != 0L } + ) + + /** + * 计算歌单内容指纹。 + * 格式:name|file1,dur1;file2,dur2;... + */ + private fun computePlaylistFingerprint( + name: String, + items: List + ): String { + val itemsPart = items + .distinctBy { it.song.stableKey() } + .sortedWith( + compareBy { it.sortOrder } + .thenBy { it.song.fileName.lowercase() } + .thenBy { it.song.durationMs } + ) + .joinToString(";") { item -> + "${item.sortOrder},${item.song.fileName},${item.song.durationMs}" + } + return "$name|$itemsPart" + } +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/room/SharedPreferencesPlaylistIdMapper.kt b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/room/SharedPreferencesPlaylistIdMapper.kt new file mode 100644 index 0000000..b4ecef7 --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/data/sync/room/SharedPreferencesPlaylistIdMapper.kt @@ -0,0 +1,138 @@ +package com.cpu.seamlessloopmobile.data.sync.room + +import android.content.Context +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import java.util.UUID + +/** + * SharedPreferences 驱动的 PlaylistIdMapper 实现。 + * + * 将 [PlaylistMappingRecord] 列表以 JSON 格式存储在 SharedPreferences 中。 + */ +class SharedPreferencesPlaylistIdMapper( + context: Context, + private val gson: Gson = Gson() +) : PlaylistIdMapper { + + private val prefs = context.applicationContext + .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + // ------------------------------------------------------------------- + // PlaylistIdMapper + // ------------------------------------------------------------------- + + override suspend fun getOrCreateSyncIdForExport( + localId: Int, + fingerprint: String, + now: Long + ): PlaylistSyncRecord { + val records = loadRecords().toMutableList() + val existing = records.find { it.localId == localId } + + return if (existing != null) { + if (existing.fingerprint != fingerprint) { + // 内容变化 → 更新 modifiedAt + val updated = existing.copy(modifiedAt = now, fingerprint = fingerprint) + records.removeAll { it.localId == localId } + records.add(updated) + saveRecords(records) + PlaylistSyncRecord( + syncId = existing.syncId, + localId = localId, + modifiedAt = now, + fingerprint = fingerprint + ) + } else { + // 指纹一致 → 返回现有记录,modifiedAt 不变 + PlaylistSyncRecord( + syncId = existing.syncId, + localId = localId, + modifiedAt = existing.modifiedAt, + fingerprint = existing.fingerprint + ) + } + } else { + // 新建映射 + val syncId = UUID.randomUUID().toString() + val record = PlaylistMappingRecord( + syncId = syncId, + localId = localId, + modifiedAt = now, + fingerprint = fingerprint + ) + records.add(record) + saveRecords(records) + PlaylistSyncRecord( + syncId = syncId, + localId = localId, + modifiedAt = now, + fingerprint = fingerprint + ) + } + } + + override suspend fun findLocalId(syncId: String): Int? { + return loadRecords().find { it.syncId == syncId }?.localId + } + + override suspend fun findSyncId(localId: Int): String? { + return loadRecords().find { it.localId == localId }?.syncId + } + + override suspend fun saveMapping( + syncId: String, + localId: Int, + modifiedAt: Long, + fingerprint: String + ) { + val records = loadRecords().toMutableList() + val existing = records.find { it.syncId == syncId } + if (existing != null) { + records.remove(existing) + } + records.removeAll { it.localId == localId || it.syncId == syncId } + records.add(PlaylistMappingRecord(syncId, localId, modifiedAt, fingerprint)) + saveRecords(records) + } + + override suspend fun removeStaleMappings(validLocalIds: Set) { + val records = loadRecords().filter { it.localId in validLocalIds } + saveRecords(records) + } + + override suspend fun clearAllMappings() { + prefs.edit().remove(KEY_MAPPINGS).apply() + } + + // ------------------------------------------------------------------- + // 内部存储 + // ------------------------------------------------------------------- + + /** 内部 JSON 持久化记录。 */ + private data class PlaylistMappingRecord( + val syncId: String, + val localId: Int, + val modifiedAt: Long, + val fingerprint: String + ) + + private fun loadRecords(): List { + val json = prefs.getString(KEY_MAPPINGS, null) ?: return emptyList() + return try { + val type = object : TypeToken>() {}.type + gson.fromJson(json, type) ?: emptyList() + } catch (e: Exception) { + emptyList() + } + } + + private fun saveRecords(records: List) { + prefs.edit().putString(KEY_MAPPINGS, gson.toJson(records)).apply() + } + + companion object { + private const val PREFS_NAME = "playlist_sync_mappings" + private const val KEY_MAPPINGS = "mappings" + } +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/db/AppDatabase.kt b/app/src/main/java/com/cpu/seamlessloopmobile/db/AppDatabase.kt index 9271ae9..72d1031 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/db/AppDatabase.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/db/AppDatabase.kt @@ -19,7 +19,7 @@ import androidx.room.TypeConverters PlaylistFolder::class, PlayQueueItem::class ], - version = 12, + version = 13, exportSchema = false ) @TypeConverters(DateConverter::class) diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/model/PlaylistDao.kt b/app/src/main/java/com/cpu/seamlessloopmobile/model/PlaylistDao.kt index df0bbf3..3cf0cb8 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/model/PlaylistDao.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/model/PlaylistDao.kt @@ -25,6 +25,9 @@ interface PlaylistDao { val songCount: Int ) + @Query("SELECT * FROM Playlists WHERE Id = :id LIMIT 1") + suspend fun getPlaylistById(id: Int): Playlist? + @Query("SELECT * FROM Playlists WHERE Name = :name LIMIT 1") suspend fun getPlaylistByName(name: String): Playlist? @@ -111,4 +114,7 @@ interface PlaylistDao { @Query("SELECT COUNT(*) FROM PlaylistItems JOIN Songs ON PlaylistItems.SongId = Songs.Id WHERE PlaylistItems.PlaylistId = :playlistId AND Songs.IsAbPartB = 0") suspend fun getSongCountInPlaylist(playlistId: Int): Int + + @Query("DELETE FROM Playlists") + suspend fun deleteAllPlaylists(): Int } diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/model/Song.kt b/app/src/main/java/com/cpu/seamlessloopmobile/model/Song.kt index 3443f37..1e1a95e 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/model/Song.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/model/Song.kt @@ -48,6 +48,9 @@ data class Song( isLoopEnabled: Boolean = true, rating: Int = 0, coverPath: String? = null, + mimeType: String? = null, + sampleRateHz: Int? = null, + bitrateKbps: Int? = null, isAbPartB: Boolean = false, loopCandidatesJson: String? = null, artist: String? = null, @@ -65,6 +68,9 @@ data class Song( displayName = displayName, lastModified = lastModified, coverPath = coverPath, + mimeType = mimeType, + sampleRateHz = sampleRateHz, + bitrateKbps = bitrateKbps, artistId = artistId, albumId = albumId, duration = duration, @@ -89,6 +95,9 @@ data class Song( val displayName get() = song.displayName ?: song.fileName val lastModified get() = song.lastModified val coverPath get() = song.coverPath ?: albumEntity?.coverPath ?: artistEntity?.coverPath + val mimeType get() = song.mimeType + val sampleRateHz get() = song.sampleRateHz + val bitrateKbps get() = song.bitrateKbps val duration get() = song.duration val isLoopEnabled get() = song.isLoopEnabled val isAbPartB get() = song.isAbPartB @@ -119,6 +128,9 @@ data class Song( isLoopEnabled: Boolean = this.isLoopEnabled, rating: Int = this.rating, coverPath: String? = this.coverPath, + mimeType: String? = this.mimeType, + sampleRateHz: Int? = this.sampleRateHz, + bitrateKbps: Int? = this.bitrateKbps, isAbPartB: Boolean = this.isAbPartB, loopCandidatesJson: String? = this.loopCandidatesJson, artist: String? = this.artist, @@ -139,6 +151,9 @@ data class Song( isLoopEnabled = isLoopEnabled, rating = rating, coverPath = coverPath, + mimeType = mimeType, + sampleRateHz = sampleRateHz, + bitrateKbps = bitrateKbps, isAbPartB = isAbPartB, loopCandidatesJson = loopCandidatesJson, artist = artist, @@ -162,6 +177,9 @@ data class SongMetadataUpdate( val albumId: Long?, val displayName: String?, val coverPath: String?, + val mimeType: String? = null, + val sampleRateHz: Int? = null, + val bitrateKbps: Int? = null, val isAbPartB: Boolean = false, // PC 端把候选循环点存在 LoopPoints.LoopCandidatesJson;手机端存在 Songs.LoopCandidatesJson。 // 默认为 null 表示“不更新缓存字段”,避免普通扫描批量更新时清空已有探测缓存喵。 diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/model/SongDao.kt b/app/src/main/java/com/cpu/seamlessloopmobile/model/SongDao.kt index f40faea..972788b 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/model/SongDao.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/model/SongDao.kt @@ -48,6 +48,27 @@ interface SongDao { @Query("SELECT * FROM Songs WHERE FilePath LIKE :pathPrefix || '%'") suspend fun getSongsByPathPrefix(pathPrefix: String): List + // --- Sync 助手方法 --- + + @Query("SELECT * FROM LoopPoints WHERE SongId = :songId LIMIT 1") + suspend fun getLoopPointBySongId(songId: Long): LoopPoint? + + @Query("SELECT * FROM UserRatings WHERE SongId = :songId LIMIT 1") + suspend fun getUserRatingBySongId(songId: Long): UserRating? + + @Transaction + @Query(""" + SELECT * FROM Songs + WHERE FileName = :name + AND duration BETWEEN :minDuration AND :maxDuration + AND IsAbPartB = 0 + """) + suspend fun getSongsByNameWithDurationRange( + name: String, + minDuration: Long, + maxDuration: Long + ): List + // --- 关联表专用操作 --- @Insert(onConflict = OnConflictStrategy.REPLACE) @@ -86,6 +107,12 @@ interface SongDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertUserRatingsBatch(userRatings: List) + @Query("DELETE FROM LoopPoints") + suspend fun deleteAllLoopPoints(): Int + + @Query("DELETE FROM UserRatings") + suspend fun deleteAllUserRatings(): Int + // --- 基础增删改 (针对 Entity) --- @Insert(onConflict = OnConflictStrategy.REPLACE) @@ -113,6 +140,10 @@ interface SongDao { mediaId = :mediaId, duration = :duration, TotalSamples = :totalSamples, + CoverPath = :coverPath, + MimeType = :mimeType, + SampleRateHz = :sampleRateHz, + BitrateKbps = :bitrateKbps, LastModified = :lastModified WHERE Id = :id """) @@ -123,6 +154,10 @@ interface SongDao { mediaId: Long, duration: Long, totalSamples: Long, + coverPath: String?, + mimeType: String?, + sampleRateHz: Int?, + bitrateKbps: Int?, lastModified: Long ): Int @@ -138,12 +173,26 @@ interface SongDao { TotalSamples = :total, DisplayName = :displayName, CoverPath = :coverPath, + MimeType = :mimeType, + SampleRateHz = :sampleRateHz, + BitrateKbps = :bitrateKbps, ArtistId = :artistId, AlbumId = :albumId, IsAbPartB = :isAbPartB WHERE Id = :id """) - suspend fun updateSongSyncFields(id: Long, total: Long, displayName: String?, coverPath: String?, artistId: Long?, albumId: Long?, isAbPartB: Boolean) + suspend fun updateSongSyncFields( + id: Long, + total: Long, + displayName: String?, + coverPath: String?, + mimeType: String?, + sampleRateHz: Int?, + bitrateKbps: Int?, + artistId: Long?, + albumId: Long?, + isAbPartB: Boolean + ) @Transaction suspend fun updateSongsMetadataBatch(updates: List) { @@ -156,6 +205,9 @@ interface SongDao { total = update.total, displayName = update.displayName, coverPath = update.coverPath, + mimeType = update.mimeType, + sampleRateHz = update.sampleRateHz, + bitrateKbps = update.bitrateKbps, artistId = update.artistId, albumId = update.albumId, isAbPartB = update.isAbPartB diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/model/SongEntity.kt b/app/src/main/java/com/cpu/seamlessloopmobile/model/SongEntity.kt index d1d1454..1f9cdd9 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/model/SongEntity.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/model/SongEntity.kt @@ -57,6 +57,15 @@ data class SongEntity( @ColumnInfo(name = "CoverPath") val coverPath: String? = null, + @ColumnInfo(name = "MimeType") + val mimeType: String? = null, + + @ColumnInfo(name = "SampleRateHz") + val sampleRateHz: Int? = null, + + @ColumnInfo(name = "BitrateKbps") + val bitrateKbps: Int? = null, + @ColumnInfo(name = "ArtistId") val artistId: Long? = null, diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/scanner/AudioScanner.kt b/app/src/main/java/com/cpu/seamlessloopmobile/scanner/AudioScanner.kt index 1ddab63..7fafff8 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/scanner/AudioScanner.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/scanner/AudioScanner.kt @@ -3,10 +3,12 @@ package com.cpu.seamlessloopmobile.scanner import android.content.ContentResolver import android.content.ContentUris import android.content.Context +import android.media.MediaExtractor +import android.media.MediaFormat +import android.net.Uri import android.provider.MediaStore import com.cpu.seamlessloopmobile.model.Song import java.io.File -import java.io.RandomAccessFile /** * 极简音频扫描器 @@ -14,6 +16,12 @@ import java.io.RandomAccessFile */ object AudioScanner { + private data class AudioFileDetails( + val mimeType: String?, + val sampleRateHz: Int?, + val bitrateKbps: Int? + ) + fun scan(context: Context): List { val songs = mutableListOf() val contentResolver: ContentResolver = context.contentResolver @@ -26,6 +34,7 @@ object AudioScanner { MediaStore.Audio.Media.TITLE, MediaStore.Audio.Media.ARTIST, MediaStore.Audio.Media.ALBUM, + MediaStore.Audio.Media.ALBUM_ID, "album_artist", // ALBUM_ARTIST in newer Android versions MediaStore.Audio.Media.DATA, MediaStore.Audio.Media.DURATION @@ -43,6 +52,7 @@ object AudioScanner { val titleColumn = it.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE) val artistColumn = it.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST) val albumColumn = it.getColumnIndex(MediaStore.Audio.Media.ALBUM) + val albumIdColumn = it.getColumnIndex(MediaStore.Audio.Media.ALBUM_ID) val albumArtistColumn = it.getColumnIndex("album_artist") val pathColumn = it.getColumnIndexOrThrow(MediaStore.Audio.Media.DATA) val durationColumn = it.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) @@ -53,6 +63,7 @@ object AudioScanner { val title = it.getString(titleColumn) ?: "Unknown" val artist = it.getString(artistColumn) ?: "Unknown Artist" val album = if (albumColumn != -1) it.getString(albumColumn) ?: "Unknown Album" else "Unknown Album" + val albumId = if (albumIdColumn != -1 && !it.isNull(albumIdColumn)) it.getLong(albumIdColumn) else 0L val albumArtist = if (albumArtistColumn != -1) it.getString(albumArtistColumn) ?: artist else artist val filePath = it.getString(pathColumn) val duration = it.getLong(durationColumn) @@ -60,6 +71,7 @@ object AudioScanner { // 物理文件校验 val file = File(filePath) if (file.exists()) { + val fileDetails = readAudioFileDetails(filePath) // 先给个 0,让列表秒开喵! songs.add( Song( @@ -71,6 +83,10 @@ object AudioScanner { artist = artist, album = album, albumArtist = albumArtist, + coverPath = albumArtUri(albumId), + mimeType = fileDetails.mimeType, + sampleRateHz = fileDetails.sampleRateHz, + bitrateKbps = fileDetails.bitrateKbps, duration = duration, totalSamples = 0, isLoopEnabled = false @@ -81,5 +97,49 @@ object AudioScanner { } return songs } + + private fun albumArtUri(albumId: Long): String? { + if (albumId <= 0L) return null + return ContentUris.withAppendedId( + Uri.parse("content://media/external/audio/albumart"), + albumId + ).toString() + } + + private fun readAudioFileDetails(filePath: String): AudioFileDetails { + val extractor = MediaExtractor() + return try { + extractor.setDataSource(filePath) + for (index in 0 until extractor.trackCount) { + val format = extractor.getTrackFormat(index) + val mimeType = format.getString(MediaFormat.KEY_MIME) + if (mimeType?.startsWith("audio/") != true) continue + + val sampleRateHz = if (format.containsKey(MediaFormat.KEY_SAMPLE_RATE)) { + format.getInteger(MediaFormat.KEY_SAMPLE_RATE).takeIf { it > 0 } + } else { + null + } + val bitrateKbps = if (format.containsKey(MediaFormat.KEY_BIT_RATE)) { + format.getInteger(MediaFormat.KEY_BIT_RATE) + .takeIf { it > 0 } + ?.let { (it + 500) / 1000 } + } else { + null + } + + return AudioFileDetails( + mimeType = mimeType, + sampleRateHz = sampleRateHz, + bitrateKbps = bitrateKbps + ) + } + AudioFileDetails(null, null, null) + } catch (_: Throwable) { + AudioFileDetails(null, null, null) + } finally { + extractor.release() + } + } } diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/app/MainBottomNavigation.kt b/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/app/MainBottomNavigation.kt new file mode 100644 index 0000000..2fc22f9 --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/app/MainBottomNavigation.kt @@ -0,0 +1,165 @@ +package com.cpu.seamlessloopmobile.ui.components.app + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LibraryMusic +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.cpu.seamlessloopmobile.utils.rememberHapticClick + +enum class MainDestination { + Library, + Search, + Settings +} + +@Composable +fun MainBottomNavigation( + selectedDestination: MainDestination?, + onLibraryClick: () -> Unit, + onSearchClick: () -> Unit, + onSettingsClick: () -> Unit, + buttonHapticFeedbackEnabled: Boolean = true, + modifier: Modifier = Modifier +) { + val items = listOf( + BottomNavItem(MainDestination.Library, "媒体库", Icons.Default.LibraryMusic, onLibraryClick), + BottomNavItem(MainDestination.Search, "搜索", Icons.Default.Search, onSearchClick), + BottomNavItem(MainDestination.Settings, "设置", Icons.Default.Settings, onSettingsClick) + ) + + NavigationBar( + modifier = modifier + .fillMaxWidth() + .height(76.dp), + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + tonalElevation = 0.dp, + ) { + items.forEach { item -> + BottomNavButton( + item = item, + selected = item.destination == selectedDestination, + buttonHapticFeedbackEnabled = buttonHapticFeedbackEnabled, + modifier = Modifier.weight(1f) + ) + } + } +} + +@Composable +private fun BottomNavButton( + item: BottomNavItem, + selected: Boolean, + buttonHapticFeedbackEnabled: Boolean, + modifier: Modifier = Modifier +) { + val interactionSource = remember { MutableInteractionSource() } + val hovered by interactionSource.collectIsHoveredAsState() + val iconColor by animateColorAsState( + targetValue = if (selected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.72f) + }, + animationSpec = tween(durationMillis = 140), + label = "bottom_nav_color" + ) + val indicatorColor by animateColorAsState( + targetValue = when { + selected -> MaterialTheme.colorScheme.onPrimaryContainer + hovered -> MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.10f) + else -> Color.Transparent + }, + animationSpec = tween(durationMillis = 140), + label = "bottom_nav_indicator_color" + ) + val rippleColor = if (selected) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.12f) + } else { + MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.10f) + } + + Column( + modifier = modifier + .height(64.dp) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = rememberHapticClick(buttonHapticFeedbackEnabled, onClick = item.onClick) + ) + .padding(horizontal = 8.dp, vertical = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .width(58.dp) + .height(32.dp) + .clip(RoundedCornerShape(16.dp)) + .background(indicatorColor) + .indication( + interactionSource = interactionSource, + indication = ripple( + bounded = true, + radius = 29.dp, + color = rippleColor + ) + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = item.icon, + contentDescription = item.label, + tint = iconColor, + modifier = Modifier.size(22.dp) + ) + } + + Text( + text = item.label, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.72f), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Medium, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis + ) + } +} + +private data class BottomNavItem( + val destination: MainDestination, + val label: String, + val icon: ImageVector, + val onClick: () -> Unit +) diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/app/MiniPlayer.kt b/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/app/MiniPlayer.kt index b22a761..f3368fd 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/app/MiniPlayer.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/app/MiniPlayer.kt @@ -1,24 +1,46 @@ package com.cpu.seamlessloopmobile.ui.components.app import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* -import androidx.compose.material3.* +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import android.support.v4.media.session.PlaybackStateCompat +import com.cpu.seamlessloopmobile.ui.components.common.SongArtwork import com.cpu.seamlessloopmobile.viewmodel.MainViewModel +import com.cpu.seamlessloopmobile.utils.rememberHapticClick import com.cpu.seamlessloopmobile.utils.TimeUtils +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeChild import kotlinx.coroutines.delay /** @@ -27,18 +49,24 @@ import kotlinx.coroutines.delay @Composable fun MiniPlayer( viewModel: MainViewModel, + hazeState: HazeState? = null, + buttonHapticFeedbackEnabled: Boolean = true, onClick: () -> Unit ) { - val playbackState by viewModel.playbackState.collectAsState() val metadata by viewModel.metadata.collectAsState() val currentPosition by viewModel.currentPosition.collectAsState() val totalDuration by viewModel.totalDuration.collectAsState() val audioPlayState by viewModel.audioPlayState.collectAsState() + val currentSongIndex by viewModel.currentSongIndex.observeAsState(-1) + val playlist by viewModel.currentPlaylist.observeAsState(emptyList()) val isPlaying = audioPlayState == com.cpu.seamlessloopmobile.audio.AudioPlayState.PLAYING val isPreparing = audioPlayState == com.cpu.seamlessloopmobile.audio.AudioPlayState.PREPARING val isError = audioPlayState == com.cpu.seamlessloopmobile.audio.AudioPlayState.ERROR - val title = metadata?.description?.title ?: "未在播放" + val playingSong = playlist.getOrNull(currentSongIndex) + val title = playingSong?.displayName ?: metadata?.description?.title ?: "未在播放" + val progress = (if (totalDuration > 0L) currentPosition.toFloat() / totalDuration.toFloat() else 0f) + .coerceIn(0f, 1f) var showLoading by remember { mutableStateOf(false) } @@ -51,78 +79,180 @@ fun MiniPlayer( } } - val sampleRate = 44100L + val sampleRate = 44100L + val shape = RoundedCornerShape(24.dp) + val enableHaze = hazeState != null + val glassFillAlpha = if (enableHaze) 0.22f else 0.34f + val surfaceAlpha = if (enableHaze) 0.46f else 0.9f - Column( + Surface( modifier = Modifier .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceVariant) - .clickable { onClick() } - .padding(bottom = 8.dp) + .padding(start = 12.dp, end = 12.dp, top = 6.dp, bottom = 0.dp) + .clip(shape) + .then( + if (hazeState != null) Modifier.hazeChild(state = hazeState, shape = shape) + else Modifier + ), + shape = shape, + color = MaterialTheme.colorScheme.surface.copy(alpha = surfaceAlpha), + tonalElevation = 0.dp, + shadowElevation = 0.dp ) { - LinearProgressIndicator( - progress = { if (totalDuration > 0) currentPosition.toFloat() / totalDuration else 0f }, - modifier = Modifier.fillMaxWidth().height(2.dp), - color = MaterialTheme.colorScheme.primary, - trackColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f) - ) - - Row( + Box( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically + .height(68.dp) + .border(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.18f), shape) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = glassFillAlpha), shape) ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = title.toString(), - style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = "${TimeUtils.formatTime(currentPosition, sampleRate)} / ${TimeUtils.formatTime(totalDuration, sampleRate)}", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + GlassProgressOverlay(progress = progress, shape = shape) + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)) + .graphicsLayer { translationY = 0.5f } + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + SongArtwork( + coverPath = playingSong?.coverPath, + contentDescription = title.toString(), + modifier = Modifier + .size(48.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = rememberHapticClick(buttonHapticFeedbackEnabled, onClick = onClick) + ), + shape = RoundedCornerShape(14.dp), + iconSize = 24.dp, + backgroundColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), + iconTint = MaterialTheme.colorScheme.primary ) - } - IconButton(onClick = { viewModel.skipToPrevious() }) { - Icon(Icons.Default.SkipPrevious, contentDescription = "上一首") - } + androidx.compose.foundation.layout.Spacer(modifier = Modifier.size(12.dp)) - IconButton( - onClick = { - if (isError) { } - else if (isPlaying) viewModel.pause() - else viewModel.play() - }, - modifier = Modifier.size(48.dp), - enabled = !isPreparing - ) { - if (showLoading) { - CircularProgressIndicator( - modifier = Modifier.size(32.dp), - strokeWidth = 3.dp, - color = MaterialTheme.colorScheme.primary + Column( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(14.dp)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = rememberHapticClick(buttonHapticFeedbackEnabled, onClick = onClick) + ) + .padding(horizontal = 4.dp, vertical = 2.dp) + ) { + Text( + text = title.toString(), + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), + maxLines = 1, + overflow = TextOverflow.Ellipsis ) - } else { + Text( + text = "${TimeUtils.formatTime(currentPosition, sampleRate)} / ${TimeUtils.formatTime(totalDuration, sampleRate)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 + ) + } + + IconButton( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled) { viewModel.skipToPrevious() }, + modifier = Modifier.size(40.dp) + ) { Icon( - imageVector = if (isPlaying) Icons.Default.PauseCircle else Icons.Default.PlayCircle, - contentDescription = "播放/暂停", - modifier = Modifier.size(40.dp), - tint = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary + Icons.Default.SkipPrevious, + contentDescription = "上一首", + tint = MaterialTheme.colorScheme.onSurface ) } - } - IconButton(onClick = { viewModel.skipToNext() }) { - Icon(Icons.Default.SkipNext, contentDescription = "下一首") + Surface( + modifier = Modifier.size(44.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.16f), + tonalElevation = 0.dp, + shadowElevation = 0.dp + ) { + IconButton( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled) { + if (isError) { + Unit + } else if (isPlaying) viewModel.pause() + else viewModel.play() + }, + enabled = !isPreparing, + modifier = Modifier.size(44.dp) + ) { + if (showLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.5.dp, + color = MaterialTheme.colorScheme.primary + ) + } else { + Icon( + imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = "播放/暂停", + modifier = Modifier.size(22.dp), + tint = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary + ) + } + } + } + + IconButton( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled) { viewModel.skipToNext() }, + modifier = Modifier.size(40.dp) + ) { + Icon( + Icons.Default.SkipNext, + contentDescription = "下一首", + tint = MaterialTheme.colorScheme.onSurface + ) + } } } } } +@Composable +private fun BoxScope.GlassProgressOverlay( + progress: Float, + shape: Shape +) { + if (progress <= 0f) return + + Box( + modifier = Modifier + .matchParentSize() + .clip(shape) + ) { + Box( + modifier = Modifier + .fillMaxWidth(progress) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)) + ) + Box( + modifier = Modifier + .fillMaxWidth(progress) + .fillMaxHeight() + .background( + Color.White.copy(alpha = 0.04f) + ) + ) + } +} + @Preview(showBackground = true) @Composable fun MiniPlayerPreview() { diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/app/MultiSelectBar.kt b/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/app/MultiSelectBar.kt index 81e5db5..177e1ae 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/app/MultiSelectBar.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/app/MultiSelectBar.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.unit.dp import com.cpu.seamlessloopmobile.model.Folder import com.cpu.seamlessloopmobile.model.Playlist import com.cpu.seamlessloopmobile.model.Song +import com.cpu.seamlessloopmobile.utils.rememberHapticClick import com.cpu.seamlessloopmobile.viewmodel.MusicDialog /** @@ -61,7 +62,7 @@ fun MultiSelectBar( color = MaterialTheme.colorScheme.onPrimaryContainer ) Spacer(modifier = Modifier.weight(1f)) - IconButton(onClick = { + IconButton(onClick = rememberHapticClick { onShowDialog( MusicDialog.ConfirmDeletePlaylist( playlist = Playlist(name = "选中的 ${selectedPlaylists.size} 个歌单"), @@ -71,7 +72,7 @@ fun MultiSelectBar( }) { Icon(Icons.Default.Delete, contentDescription = "删除歌单") } - IconButton(onClick = onClearSelection) { + IconButton(onClick = rememberHapticClick(onClick = onClearSelection)) { Icon(Icons.Default.Close, contentDescription = "取消选择") } } else if (selectedFolders.isNotEmpty()) { @@ -82,7 +83,7 @@ fun MultiSelectBar( color = MaterialTheme.colorScheme.onPrimaryContainer ) Spacer(modifier = Modifier.weight(1f)) - IconButton(onClick = { + IconButton(onClick = rememberHapticClick { onShowDialog( MusicDialog.ImportFoldersOptions( count = selectedFolders.size, @@ -99,7 +100,7 @@ fun MultiSelectBar( }) { Icon(Icons.Default.PlaylistAdd, contentDescription = "导入文件夹") } - IconButton(onClick = onClearSelection) { + IconButton(onClick = rememberHapticClick(onClick = onClearSelection)) { Icon(Icons.Default.Close, contentDescription = "取消选择") } } else { @@ -110,7 +111,7 @@ fun MultiSelectBar( color = MaterialTheme.colorScheme.onPrimaryContainer ) Spacer(modifier = Modifier.weight(1f)) - IconButton(onClick = { + IconButton(onClick = rememberHapticClick { val dialog = if (playlists.isNotEmpty()) { MusicDialog.AddToPlaylist( playlists = playlists, @@ -134,7 +135,7 @@ fun MultiSelectBar( } val isAllSelected = songsInCurrentPage.isNotEmpty() && selectedItems.size >= songsInCurrentPage.size - IconButton(onClick = { + IconButton(onClick = rememberHapticClick { if (isAllSelected) { onSelectAll(emptyList()) } else { @@ -147,11 +148,11 @@ fun MultiSelectBar( ) } - IconButton(onClick = onShowMoreBulkOptions) { + IconButton(onClick = rememberHapticClick(onClick = onShowMoreBulkOptions)) { Icon(Icons.Default.MoreVert, contentDescription = "更多操作") } - IconButton(onClick = onClearSelection) { + IconButton(onClick = rememberHapticClick(onClick = onClearSelection)) { Icon(Icons.Default.Close, contentDescription = "取消选择") } } diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/app/PlayingPanel.kt b/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/app/PlayingPanel.kt index 973b534..cca1373 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/app/PlayingPanel.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/app/PlayingPanel.kt @@ -1,10 +1,12 @@ package com.cpu.seamlessloopmobile.ui.components.app import androidx.compose.animation.* +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* @@ -24,8 +26,9 @@ import com.cpu.seamlessloopmobile.ui.components.common.MainInfoPage import com.cpu.seamlessloopmobile.ui.components.common.PlaybackProgressBar import com.cpu.seamlessloopmobile.ui.components.common.PlaybackControls import com.cpu.seamlessloopmobile.jni.NativeAudio -import com.cpu.seamlessloopmobile.ui.theme.SeamlessLoopColors +import com.cpu.seamlessloopmobile.ui.theme.SeamlessLoopPlayerColors import androidx.compose.ui.platform.LocalContext +import com.cpu.seamlessloopmobile.utils.rememberHapticClick /** * 全屏音频播放核心面板,已移动至 ui/components/app/ 目录并融入 SeamlessLoopTheme 配色喵!(๑•̀ㅂ•́)و✧ @@ -38,6 +41,7 @@ fun PlayingPanel( onPlayPause: () -> Unit, onNext: () -> Unit, onPrev: () -> Unit, + buttonHapticFeedbackEnabled: Boolean = true, onMoreClick: (com.cpu.seamlessloopmobile.model.Song) -> Unit ) { val context = LocalContext.current @@ -81,8 +85,14 @@ fun PlayingPanel( AnimatedVisibility( visible = isVisible && playingSong != null, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() + enter = slideInVertically( + animationSpec = tween(280), + initialOffsetY = { it / 8 } + ) + fadeIn(animationSpec = tween(220)), + exit = slideOutVertically( + animationSpec = tween(220), + targetOffsetY = { it / 10 } + ) + fadeOut(animationSpec = tween(180)) ) { val songItem = playingSong ?: return@AnimatedVisibility @@ -92,8 +102,8 @@ fun PlayingPanel( .background( brush = Brush.verticalGradient( colors = listOf( - SeamlessLoopColors.DarkBgGradientStart, - SeamlessLoopColors.DarkBgGradientEnd + SeamlessLoopPlayerColors.GradientStart, + SeamlessLoopPlayerColors.GradientEnd ) ) ) @@ -104,17 +114,26 @@ fun PlayingPanel( ) { Column(modifier = Modifier.fillMaxSize()) { // --- 顶部控制 --- + val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() Box( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), + .height(topPadding + 64.dp) + .padding(top = topPadding, start = 4.dp, end = 4.dp), contentAlignment = Alignment.Center ) { IconButton( - onClick = onClose, - modifier = Modifier.align(Alignment.CenterStart) + onClick = rememberHapticClick(buttonHapticFeedbackEnabled, onClick = onClose), + modifier = Modifier + .align(Alignment.CenterStart) + .size(56.dp) ) { - Icon(Icons.Default.KeyboardArrowDown, contentDescription = "收起", tint = SeamlessLoopColors.White) + Icon( + Icons.Default.KeyboardArrowDown, + contentDescription = "收起", + tint = SeamlessLoopPlayerColors.PrimaryText, + modifier = Modifier.size(28.dp) + ) } Row( @@ -122,14 +141,41 @@ fun PlayingPanel( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { repeat(2) { index -> + val selected = pagerState.currentPage == index + val indicatorWidth by animateDpAsState( + targetValue = if (selected) 18.dp else 6.dp, + animationSpec = tween(160), + label = "player_page_indicator" + ) Box( modifier = Modifier - .size(if (pagerState.currentPage == index) 8.dp else 4.dp) - .clip(CircleShape) - .background(if (pagerState.currentPage == index) SeamlessLoopColors.PurpleAccent else SeamlessLoopColors.Gray) + .width(indicatorWidth) + .height(6.dp) + .clip(RoundedCornerShape(3.dp)) + .background( + if (selected) { + SeamlessLoopPlayerColors.Primary + } else { + SeamlessLoopPlayerColors.Inactive + } + ) ) } } + + IconButton( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled) { onMoreClick(songItem) }, + modifier = Modifier + .align(Alignment.CenterEnd) + .size(56.dp) + ) { + Icon( + Icons.Default.MoreVert, + contentDescription = "更多", + tint = SeamlessLoopPlayerColors.PrimaryText, + modifier = Modifier.size(26.dp) + ) + } } // --- 分页内容 --- @@ -139,7 +185,12 @@ fun PlayingPanel( beyondViewportPageCount = 1 ) { page -> when (page) { - 0 -> MainInfoPage(songItem, isPlaying, onRatingClick = { viewModel.cycleSongRating(songItem) }) + 0 -> MainInfoPage( + songItem = songItem, + isPlaying = isPlaying, + buttonHapticFeedbackEnabled = buttonHapticFeedbackEnabled, + onRatingClick = { viewModel.cycleSongRating(songItem) } + ) 1 -> { val isDetecting by viewModel.isDetectingLoop.collectAsState() @@ -211,12 +262,12 @@ fun PlayingPanel( isPreparing = isPreparing, isError = isError, showLoading = showLoading, + buttonHapticFeedbackEnabled = buttonHapticFeedbackEnabled, onTogglePlayMode = { viewModel.togglePlayMode() }, onToggleSeamlessLoop = { viewModel.setSeamlessLoopEnabled(!isSeamlessLoopEnabled) }, onPrev = onPrev, onPlayPause = onPlayPause, - onNext = onNext, - onMoreClick = { onMoreClick(songItem) } + onNext = onNext ) } } diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/common/FineTuneComponents.kt b/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/common/FineTuneComponents.kt index 63984d0..8052c83 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/common/FineTuneComponents.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/common/FineTuneComponents.kt @@ -21,7 +21,7 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.platform.LocalInspectionMode import com.cpu.seamlessloopmobile.jni.NativeAudio import com.cpu.seamlessloopmobile.model.Song -import com.cpu.seamlessloopmobile.ui.theme.SeamlessLoopColors +import com.cpu.seamlessloopmobile.ui.theme.SeamlessLoopPlayerColors /** * 循环微调面板,已完美融入 CPU 大人的 SeamlessLoopTheme 主题系统喵!(๑•̀ㅂ•́)و✧ @@ -48,64 +48,82 @@ fun FineTunePage( .padding(horizontal = 16.dp, vertical = 8.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(2.dp)) TuneSectionBox( label = "循环起点 (A)", samples = tempLoopStart, - accentColor = SeamlessLoopColors.PointAccentA, + accentColor = SeamlessLoopPlayerColors.PointAccentA, onValueChange = onStartValueChange, onAdjustMs = onStartAdjustMs, onEditClick = { onEditClick(true) } ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(6.dp)) TuneSectionBox( label = "循环终点 (B)", samples = tempLoopEnd, - accentColor = SeamlessLoopColors.PointAccentB, + accentColor = SeamlessLoopPlayerColors.PointAccentB, onValueChange = onEndValueChange, onAdjustMs = onEndAdjustMs, onEditClick = { onEditClick(false) } ) - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(8.dp)) // --- 自动检测区域喵 --- if (isDetecting) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = SeamlessLoopColors.PurpleAccent, - strokeWidth = 2.dp - ) - Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.height(42.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = SeamlessLoopPlayerColors.Primary, + strokeWidth = 2.dp + ) + Text( + text = "正在探测循环点", + style = MaterialTheme.typography.labelLarge, + color = SeamlessLoopPlayerColors.SecondaryText + ) + } } else { OutlinedButton( onClick = onDetectClick, - modifier = Modifier.fillMaxWidth().height(36.dp), - border = androidx.compose.foundation.BorderStroke(1.dp, SeamlessLoopColors.PurpleAccent.copy(alpha = 0.5f)), - shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth().height(42.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, SeamlessLoopPlayerColors.Primary.copy(alpha = 0.5f)), + shape = RoundedCornerShape(10.dp), contentPadding = PaddingValues(0.dp) ) { - Icon(Icons.Default.AutoAwesome, contentDescription = null, tint = SeamlessLoopColors.PurpleAccent, modifier = Modifier.size(16.dp)) - Spacer(modifier = Modifier.size(4.dp)) - Text("自动探测循环点", color = SeamlessLoopColors.PurpleAccent, fontSize = 12.sp) + Icon(Icons.Default.AutoAwesome, contentDescription = null, tint = SeamlessLoopPlayerColors.Primary, modifier = Modifier.size(17.dp)) + Spacer(modifier = Modifier.size(6.dp)) + Text( + text = "自动探测循环点", + style = MaterialTheme.typography.labelLarge, + color = SeamlessLoopPlayerColors.Primary + ) } } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(8.dp)) Button( onClick = onApplyAndListen, - modifier = Modifier.fillMaxWidth().height(40.dp), - colors = ButtonDefaults.buttonColors(containerColor = SeamlessLoopColors.ButtonDarkBg), - shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth().height(42.dp), + colors = ButtonDefaults.buttonColors(containerColor = SeamlessLoopPlayerColors.Control), + shape = RoundedCornerShape(10.dp), contentPadding = PaddingValues(0.dp) ) { - Icon(Icons.Default.Hearing, contentDescription = null, tint = SeamlessLoopColors.White, modifier = Modifier.size(18.dp)) - Spacer(modifier = Modifier.size(4.dp)) - Text("应用并试听", color = SeamlessLoopColors.White, fontSize = 13.sp) + Icon(Icons.Default.Hearing, contentDescription = null, tint = SeamlessLoopPlayerColors.PrimaryText, modifier = Modifier.size(17.dp)) + Spacer(modifier = Modifier.size(6.dp)) + Text( + text = "应用并试听", + style = MaterialTheme.typography.labelLarge, + color = SeamlessLoopPlayerColors.PrimaryText + ) } } } @@ -125,41 +143,41 @@ fun TuneSectionBox( Surface( modifier = Modifier.fillMaxWidth(), - color = SeamlessLoopColors.DarkBgGradientStart.copy(alpha = 0.5f), + color = SeamlessLoopPlayerColors.Panel.copy(alpha = 0.5f), shape = RoundedCornerShape(10.dp), - border = androidx.compose.foundation.BorderStroke(1.dp, SeamlessLoopColors.White.copy(alpha = 0.1f)) + border = androidx.compose.foundation.BorderStroke(1.dp, SeamlessLoopPlayerColors.PrimaryText.copy(alpha = 0.1f)) ) { Column( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - Box(modifier = Modifier.fillMaxWidth()) { + Text( + text = label, + color = SeamlessLoopPlayerColors.SecondaryText, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(2.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 64.dp) + .clickable { onEditClick() }, + horizontalAlignment = Alignment.CenterHorizontally + ) { Text( - label, - color = SeamlessLoopColors.LightGray, - fontSize = 11.sp, - modifier = Modifier.align(Alignment.CenterStart) + text = samples.toString(), + color = accentColor, + fontSize = 24.sp, + fontWeight = FontWeight.Bold + ) + Text( + text = String.format(java.util.Locale.US, "%.3fs", seconds), + color = SeamlessLoopPlayerColors.TertiaryText, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium ) - - Column( - modifier = Modifier - .align(Alignment.Center) - .clickable { onEditClick() }, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - samples.toString(), - color = accentColor, - fontSize = 24.sp, - fontWeight = FontWeight.Bold - ) - Text( - String.format(java.util.Locale.US, "%.3fs", seconds), - color = SeamlessLoopColors.Gray, - fontSize = 15.sp, - fontWeight = FontWeight.Medium - ) - } } HorizontalDivider( @@ -202,15 +220,27 @@ fun TuneSectionBox( @Composable fun TuneGridButton(text: String, modifier: Modifier = Modifier, onClick: () -> Unit) { - Surface( + Box( modifier = modifier - .height(30.dp) + .height(44.dp) .clickable { onClick() }, - color = SeamlessLoopColors.ComponentDarkBg, - shape = RoundedCornerShape(4.dp) + contentAlignment = Alignment.Center ) { - Box(contentAlignment = Alignment.Center) { - Text(text, color = SeamlessLoopColors.LightGray, fontSize = 10.sp, fontWeight = FontWeight.Normal) + Surface( + modifier = Modifier + .fillMaxWidth() + .height(38.dp), + color = SeamlessLoopPlayerColors.Control, + shape = RoundedCornerShape(7.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + color = SeamlessLoopPlayerColors.SecondaryText, + fontWeight = FontWeight.Medium + ) + } } } } @@ -219,7 +249,7 @@ fun TuneGridButton(text: String, modifier: Modifier = Modifier, onClick: () -> U @Composable @Suppress("SdCardPath") fun FineTunePagePreview() { - Box(modifier = Modifier.background(Color(0xFF1E1E2E))) { + Box(modifier = Modifier.background(SeamlessLoopPlayerColors.GradientStart)) { FineTunePage( song = Song( id = 1L, diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/common/ListItems.kt b/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/common/ListItems.kt index 2629d9c..a575595 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/common/ListItems.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/common/ListItems.kt @@ -5,11 +5,14 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.automirrored.filled.QueueMusic import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -22,6 +25,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.cpu.seamlessloopmobile.model.Playlist import com.cpu.seamlessloopmobile.model.Song +import com.cpu.seamlessloopmobile.utils.rememberHapticClick // --- 通用标题 --- @@ -159,9 +163,23 @@ fun SongListItem( onLongClick: () -> Unit, onMoreClick: () -> Unit ) { + val rowColor by animateColorAsState( + targetValue = when { + isSelected -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.72f) + isPlaying -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.24f) + else -> Color.Transparent + }, + animationSpec = tween(140), + label = "song_row_color" + ) + val titleColor by animateColorAsState( + targetValue = if (isPlaying) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + animationSpec = tween(140), + label = "song_title_color" + ) + Surface( - color = if (isSelected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) - else Color.Transparent, + color = rowColor, modifier = Modifier.fillMaxWidth() ) { Row( @@ -172,7 +190,8 @@ fun SongListItem( onClick = onClick, onLongClick = onLongClick ) - .padding(horizontal = 16.dp, vertical = 8.dp) + .heightIn(min = 68.dp) + .padding(horizontal = 16.dp, vertical = 10.dp) ) { if (isSelectionMode) { Checkbox( @@ -181,31 +200,48 @@ fun SongListItem( modifier = Modifier.padding(end = 8.dp) ) } - Icon( - imageVector = Icons.Default.MusicNote, - contentDescription = null, - tint = if (isPlaying) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(40.dp) + SongArtwork( + coverPath = song.coverPath, + contentDescription = song.displayName, + modifier = Modifier.size(48.dp), + shape = RoundedCornerShape(10.dp), + iconSize = 24.dp, + backgroundColor = if (isPlaying) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + iconTint = if (isPlaying) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(modifier = Modifier.width(16.dp)) + Spacer(modifier = Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { Text( text = song.displayName, - style = MaterialTheme.typography.bodyLarge, - color = if (isPlaying) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleSmall, + color = titleColor, + fontWeight = if (isPlaying) FontWeight.SemiBold else FontWeight.Normal, maxLines = 1, overflow = TextOverflow.Ellipsis ) Text( text = song.artist, - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis ) } - - IconButton(onClick = onMoreClick) { + Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center) { + if (isPlaying) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = "正在播放", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + } + } + IconButton(onClick = rememberHapticClick(onClick = onMoreClick)) { Icon( imageVector = Icons.Default.MoreVert, contentDescription = "更多选项", @@ -230,10 +266,27 @@ fun CategoryListItem( onClick: () -> Unit, onLongClick: () -> Unit = {} ) { + val rowColor by animateColorAsState( + targetValue = when { + isSelected -> MaterialTheme.colorScheme.primaryContainer + isPlaying -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.28f) + else -> Color.Transparent + }, + animationSpec = tween(140), + label = "category_row_color" + ) + val iconTint by animateColorAsState( + targetValue = if (isPlaying || isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + animationSpec = tween(140), + label = "category_icon_color" + ) + Surface( - color = if (isSelected) MaterialTheme.colorScheme.primaryContainer - else if (isPlaying) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.1f) - else Color.Transparent, + color = rowColor, modifier = Modifier.fillMaxWidth() ) { Row( @@ -244,6 +297,7 @@ fun CategoryListItem( onClick = onClick, onLongClick = onLongClick ) + .heightIn(min = 72.dp) .padding(horizontal = 16.dp, vertical = 12.dp) ) { if (isSelectionMode) { @@ -253,17 +307,29 @@ fun CategoryListItem( modifier = Modifier.padding(end = 8.dp) ) } - Icon( - imageVector = icon, - contentDescription = null, - tint = if (isPlaying) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.primary.copy(alpha = 0.6f), - modifier = Modifier.size(24.dp) - ) - Spacer(modifier = Modifier.width(16.dp)) + Surface( + modifier = Modifier.size(40.dp), + shape = RoundedCornerShape(10.dp), + color = if (isPlaying || isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.72f) + } + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconTint, + modifier = Modifier.size(22.dp) + ) + } + } + Spacer(modifier = Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { Text( text = title, - style = MaterialTheme.typography.bodyLarge, + style = MaterialTheme.typography.titleSmall, color = if (isPlaying) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, fontWeight = if (isPlaying) FontWeight.Bold else FontWeight.Normal, maxLines = 1, @@ -271,14 +337,21 @@ fun CategoryListItem( ) Text( text = subtitle, - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.labelMedium, color = if (isSelected || isPlaying) MaterialTheme.colorScheme.primary.copy(alpha=0.8f) else MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis ) } - if (isSelected && !isSelectionMode) { + if (isPlaying) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = "正在播放", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + } else if (isSelected && !isSelectionMode) { Icon( imageVector = Icons.Default.CheckCircle, contentDescription = null, diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/common/LoopCandidatesDialog.kt b/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/common/LoopCandidatesDialog.kt index 65348cf..4b91c6c 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/common/LoopCandidatesDialog.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/common/LoopCandidatesDialog.kt @@ -22,7 +22,8 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.cpu.seamlessloopmobile.jni.LoopPoint import com.cpu.seamlessloopmobile.jni.NativeAudio -import com.cpu.seamlessloopmobile.ui.theme.SeamlessLoopColors +import com.cpu.seamlessloopmobile.ui.theme.SeamlessLoopPlayerColors +import com.cpu.seamlessloopmobile.utils.rememberHapticClick import java.util.Locale /** @@ -47,7 +48,7 @@ fun LoopCandidatesDialog( .fillMaxWidth(0.92f) .fillMaxHeight(0.7f), shape = RoundedCornerShape(20.dp), - color = SeamlessLoopColors.DarkBgGradientStart, + color = SeamlessLoopPlayerColors.Panel, tonalElevation = 8.dp, shadowElevation = 16.dp ) { @@ -61,23 +62,23 @@ fun LoopCandidatesDialog( ) { Text( text = "循环点候选列表", - color = SeamlessLoopColors.White, + color = SeamlessLoopPlayerColors.PrimaryText, fontSize = 18.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f) ) - IconButton(onClick = onDismiss) { + IconButton(onClick = rememberHapticClick(onClick = onDismiss)) { Icon( Icons.Default.Close, contentDescription = "关闭", - tint = SeamlessLoopColors.Gray + tint = SeamlessLoopPlayerColors.Inactive ) } } HorizontalDivider( thickness = 1.dp, - color = SeamlessLoopColors.White.copy(alpha = 0.08f), + color = SeamlessLoopPlayerColors.PrimaryText.copy(alpha = 0.08f), modifier = Modifier.padding(horizontal = 16.dp) ) @@ -101,7 +102,7 @@ fun LoopCandidatesDialog( HorizontalDivider( thickness = 1.dp, - color = SeamlessLoopColors.White.copy(alpha = 0.08f), + color = SeamlessLoopPlayerColors.PrimaryText.copy(alpha = 0.08f), modifier = Modifier.padding(horizontal = 16.dp) ) @@ -116,7 +117,7 @@ fun LoopCandidatesDialog( onClick = onReanalyze, border = androidx.compose.foundation.BorderStroke( 1.dp, - SeamlessLoopColors.PurpleAccent.copy(alpha = 0.6f) + SeamlessLoopPlayerColors.Primary.copy(alpha = 0.6f) ), shape = RoundedCornerShape(10.dp), contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp) @@ -124,13 +125,13 @@ fun LoopCandidatesDialog( Icon( Icons.Default.Refresh, contentDescription = null, - tint = SeamlessLoopColors.PurpleAccent, + tint = SeamlessLoopPlayerColors.Primary, modifier = Modifier.size(16.dp) ) Spacer(modifier = Modifier.width(6.dp)) Text( "重新探测", - color = SeamlessLoopColors.PurpleAccent, + color = SeamlessLoopPlayerColors.Primary, fontSize = 13.sp ) } @@ -157,7 +158,7 @@ private fun LoopCandidateRow( modifier = Modifier .fillMaxWidth() .clickable { onClick() }, - color = SeamlessLoopColors.ComponentDarkBg, + color = SeamlessLoopPlayerColors.Control, shape = RoundedCornerShape(10.dp) ) { Row( @@ -170,7 +171,7 @@ private fun LoopCandidateRow( Icon( Icons.Default.PlayArrow, contentDescription = "试听", - tint = SeamlessLoopColors.PurpleAccent, + tint = SeamlessLoopPlayerColors.Primary, modifier = Modifier.size(20.dp) ) Spacer(modifier = Modifier.width(8.dp)) @@ -180,18 +181,18 @@ private fun LoopCandidateRow( Row(verticalAlignment = Alignment.CenterVertically) { Text( text = "候选 ${index + 1}", - color = SeamlessLoopColors.White, + color = SeamlessLoopPlayerColors.PrimaryText, fontSize = 14.sp, fontWeight = FontWeight.SemiBold ) Spacer(modifier = Modifier.width(8.dp)) Surface( shape = RoundedCornerShape(4.dp), - color = SeamlessLoopColors.PurpleAccent.copy(alpha = 0.2f) + color = SeamlessLoopPlayerColors.Primary.copy(alpha = 0.2f) ) { Text( text = String.format(Locale.US, "%.1f%%", scorePercent), - color = SeamlessLoopColors.PurpleAccent, + color = SeamlessLoopPlayerColors.Primary, fontSize = 11.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) @@ -205,7 +206,7 @@ private fun LoopCandidateRow( "区间: %.3fs 至 %.3fs (循环 %.3fs)", startSeconds, endSeconds, durationSeconds ), - color = SeamlessLoopColors.Gray, + color = SeamlessLoopPlayerColors.TertiaryText, fontSize = 12.sp ) Spacer(modifier = Modifier.height(2.dp)) @@ -215,7 +216,7 @@ private fun LoopCandidateRow( "采样: %d 至 %d (共 %d 帧)", point.loopStart, point.loopEnd, framesCount ), - color = SeamlessLoopColors.Gray, + color = SeamlessLoopPlayerColors.TertiaryText, fontSize = 12.sp ) } diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/common/LoopEditDialog.kt b/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/common/LoopEditDialog.kt index 594b731..14e3f0b 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/common/LoopEditDialog.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/common/LoopEditDialog.kt @@ -5,10 +5,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.cpu.seamlessloopmobile.ui.theme.SeamlessLoopColors +import com.cpu.seamlessloopmobile.ui.theme.SeamlessLoopPlayerColors /** * 循环点手动数值编辑弹窗喵!(๑•̀ㅂ•́)و✧ @@ -26,12 +27,32 @@ fun LoopEditDialog( onConfirm: () -> Unit ) { if (visible) { + val textFieldColors = OutlinedTextFieldDefaults.colors( + focusedTextColor = SeamlessLoopPlayerColors.PrimaryText, + unfocusedTextColor = SeamlessLoopPlayerColors.PrimaryText, + disabledTextColor = SeamlessLoopPlayerColors.TertiaryText, + errorTextColor = SeamlessLoopPlayerColors.OnErrorContainer, + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + errorContainerColor = Color.Transparent, + cursorColor = SeamlessLoopPlayerColors.Primary, + errorCursorColor = SeamlessLoopPlayerColors.OnErrorContainer, + focusedBorderColor = SeamlessLoopPlayerColors.Primary, + unfocusedBorderColor = SeamlessLoopPlayerColors.Track, + disabledBorderColor = SeamlessLoopPlayerColors.Inactive.copy(alpha = 0.5f), + errorBorderColor = SeamlessLoopPlayerColors.OnErrorContainer, + focusedLabelColor = SeamlessLoopPlayerColors.Primary, + unfocusedLabelColor = SeamlessLoopPlayerColors.SecondaryText, + disabledLabelColor = SeamlessLoopPlayerColors.TertiaryText, + errorLabelColor = SeamlessLoopPlayerColors.OnErrorContainer + ) AlertDialog( onDismissRequest = onDismiss, title = { Text( text = if (isStart) "修改循环起点 (A)" else "修改循环终点 (B)", - color = SeamlessLoopColors.White, + color = SeamlessLoopPlayerColors.PrimaryText, fontSize = 18.sp, fontWeight = FontWeight.Bold ) @@ -44,12 +65,7 @@ fun LoopEditDialog( label = { Text("采样数 (Samples)") }, modifier = Modifier.fillMaxWidth(), singleLine = true, - colors = OutlinedTextFieldDefaults.colors( - focusedTextColor = SeamlessLoopColors.White, - unfocusedTextColor = SeamlessLoopColors.White, - focusedBorderColor = SeamlessLoopColors.PurpleAccent, - unfocusedBorderColor = SeamlessLoopColors.Gray.copy(alpha = 0.5f) - ) + colors = textFieldColors ) Spacer(modifier = Modifier.height(12.dp)) OutlinedTextField( @@ -58,26 +74,21 @@ fun LoopEditDialog( label = { Text("时间 (Seconds)") }, modifier = Modifier.fillMaxWidth(), singleLine = true, - colors = OutlinedTextFieldDefaults.colors( - focusedTextColor = SeamlessLoopColors.White, - unfocusedTextColor = SeamlessLoopColors.White, - focusedBorderColor = SeamlessLoopColors.PurpleAccent, - unfocusedBorderColor = SeamlessLoopColors.Gray.copy(alpha = 0.5f) - ) + colors = textFieldColors ) } }, confirmButton = { TextButton(onClick = onConfirm) { - Text("确定", color = SeamlessLoopColors.PurpleAccent, fontWeight = FontWeight.Bold) + Text("确定", color = SeamlessLoopPlayerColors.Primary, fontWeight = FontWeight.Bold) } }, dismissButton = { TextButton(onClick = onDismiss) { - Text("取消", color = SeamlessLoopColors.Gray) + Text("取消", color = SeamlessLoopPlayerColors.TertiaryText) } }, - containerColor = SeamlessLoopColors.DarkBgGradientStart, + containerColor = SeamlessLoopPlayerColors.Panel, shape = RoundedCornerShape(16.dp) ) } diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/common/PlayingComponents.kt b/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/common/PlayingComponents.kt index 51fc2da..01dc4d9 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/common/PlayingComponents.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/common/PlayingComponents.kt @@ -1,10 +1,14 @@ package com.cpu.seamlessloopmobile.ui.components.common +import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.* +import androidx.compose.foundation.border import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* @@ -24,9 +28,11 @@ import androidx.compose.ui.platform.LocalInspectionMode import com.cpu.seamlessloopmobile.jni.NativeAudio import com.cpu.seamlessloopmobile.model.Song import com.cpu.seamlessloopmobile.utils.TimeUtils -import com.cpu.seamlessloopmobile.ui.theme.SeamlessLoopColors +import com.cpu.seamlessloopmobile.ui.theme.SeamlessLoopPlayerColors +import com.cpu.seamlessloopmobile.utils.rememberHapticClick import kotlinx.coroutines.launch import kotlinx.coroutines.delay +import java.util.Locale /** * 播放控制台的核心页面与子组件,已完美融入 CPU 大人的 SeamlessLoopTheme 主题系统喵!(๑•̀ㅂ•́)و✧ @@ -35,53 +41,44 @@ import kotlinx.coroutines.delay fun MainInfoPage( songItem: Song, isPlaying: Boolean, + buttonHapticFeedbackEnabled: Boolean = true, onRatingClick: () -> Unit = {} ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(48.dp)) - - // --- 封面图旋转动效喵 --- - val infiniteTransition = rememberInfiniteTransition(label = "rotate") - val rotation by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = 360f, - animationSpec = infiniteRepeatable( - animation = tween(20000, easing = LinearEasing), - repeatMode = RepeatMode.Restart - ), - label = "rotation" + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val artworkSize = minOf( + 312.dp, + (maxWidth - 48.dp).coerceAtLeast(0.dp), + (maxHeight * 0.42f).coerceAtLeast(0.dp) ) + val artworkIconSize = minOf(88.dp, artworkSize * 0.34f) - Box( + Column( modifier = Modifier - .size(280.dp) - .clip(CircleShape) - .background(SeamlessLoopColors.DarkBgGradientEnd.copy(alpha = 0.8f)) - .padding(2.dp) - .graphicsLayer { rotationZ = if (isPlaying) rotation else 0f } + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - Icon( - Icons.Default.MusicNote, - contentDescription = null, - modifier = Modifier.fillMaxSize().padding(64.dp), - tint = SeamlessLoopColors.PurpleAccent - ) - } + Spacer(modifier = Modifier.height(4.dp)) - Spacer(modifier = Modifier.height(32.dp)) + SongArtwork( + coverPath = songItem.coverPath, + contentDescription = songItem.displayName, + modifier = Modifier.size(artworkSize), + shape = RoundedCornerShape(24.dp), + iconSize = artworkIconSize, + backgroundColor = SeamlessLoopPlayerColors.Panel.copy(alpha = 0.82f), + iconTint = SeamlessLoopPlayerColors.Primary + ) + + Spacer(modifier = Modifier.height(20.dp)) // --- 歌曲信息 --- Text( text = songItem.displayName ?: songItem.fileName, style = MaterialTheme.typography.headlineMedium.copy( fontWeight = FontWeight.Bold, - color = SeamlessLoopColors.White, - fontSize = 24.sp + color = SeamlessLoopPlayerColors.PrimaryText ), maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -89,32 +86,38 @@ fun MainInfoPage( ) Text( - text = songItem.artist ?: "Unknown Artist", + text = songItem.artist?.takeIf { it.isNotBlank() } ?: "未知艺人", style = MaterialTheme.typography.bodyLarge.copy( - color = SeamlessLoopColors.Gray, - fontSize = 16.sp + color = SeamlessLoopPlayerColors.SecondaryText ), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center ) - Spacer(modifier = Modifier.height(16.dp)) + AudioFileInfoRow(songItem) + + Spacer(modifier = Modifier.height(14.dp)) // --- 评分控制 (0-5 循环) --- Button( - onClick = onRatingClick, + onClick = rememberHapticClick(buttonHapticFeedbackEnabled, onClick = onRatingClick), colors = ButtonDefaults.buttonColors( - containerColor = SeamlessLoopColors.White.copy(alpha = 0.05f), - contentColor = if (songItem.rating > 0) Color(0xFFFFD700) else SeamlessLoopColors.Gray + containerColor = SeamlessLoopPlayerColors.PrimaryText.copy(alpha = 0.05f), + contentColor = if (songItem.rating > 0) { + SeamlessLoopPlayerColors.LoopMarker + } else { + SeamlessLoopPlayerColors.TertiaryText + } ), shape = RoundedCornerShape(16.dp), + modifier = Modifier.heightIn(min = 48.dp), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) ) { Row(verticalAlignment = Alignment.CenterVertically) { Icon( if (songItem.rating > 0) Icons.Default.Star else Icons.Default.StarBorder, - contentDescription = "Rating", + contentDescription = "评分", modifier = Modifier.size(20.dp) ) Spacer(modifier = Modifier.width(8.dp)) @@ -125,10 +128,69 @@ fun MainInfoPage( } } - Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.height(12.dp)) + } + } +} + +@Composable +private fun AudioFileInfoRow(song: Song) { + val mime = formatMimeType(song.mimeType) + ?: formatMimeType(mimeFromFileName(song.fileName)) + ?: "未知格式" + val sampleRate = song.sampleRateHz?.takeIf { it > 0 }?.let { formatSampleRate(it) } + ?: "-- kHz" + val bitrate = song.bitrateKbps?.takeIf { it > 0 }?.let { "$it kbps" } + ?: "-- kbps" + + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "$mime · $sampleRate | $bitrate", + style = MaterialTheme.typography.labelMedium.copy( + color = SeamlessLoopPlayerColors.SecondaryText.copy(alpha = 0.82f), + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) +} + +private fun formatSampleRate(sampleRateHz: Int): String { + val khz = sampleRateHz / 1000.0 + return if (sampleRateHz % 1000 == 0) { + "${sampleRateHz / 1000} kHz" + } else { + String.format(Locale.US, "%.1f kHz", khz) + } +} + +private fun mimeFromFileName(fileName: String): String? { + return when (fileName.substringAfterLast('.', "").lowercase()) { + "mp3" -> "audio/mpeg" + "flac" -> "audio/flac" + "wav" -> "audio/wav" + "m4a" -> "audio/mp4" + "aac" -> "audio/aac" + "ogg", "oga" -> "audio/ogg" + else -> null } } +private fun formatMimeType(mimeType: String?): String? { + val trimmed = mimeType + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?: return null + val type = if (trimmed.startsWith("audio/", ignoreCase = true)) { + trimmed.substringAfter('/') + } else { + trimmed + } + return type.uppercase(Locale.US) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun PlaybackProgressBar( @@ -202,17 +264,34 @@ fun PlaybackProgressBar( val sliderMax = totalFrames.toFloat().coerceAtLeast(1f) val displayedValue = (sliderPosition ?: currentFrame.toFloat()).coerceIn(0f, sliderMax) - Column(modifier = Modifier.padding(horizontal = 24.dp)) { + val displayFrame = sliderPosition?.toLong() ?: currentFrame + val startTime = TimeUtils.formatTime(displayFrame, sampleRate) + val totalTime = TimeUtils.formatTime(totalFrames, sampleRate) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = startTime, + color = SeamlessLoopPlayerColors.SecondaryText, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.width(48.dp), + textAlign = TextAlign.Start + ) Slider( value = displayedValue, onValueChange = { sliderPosition = it }, - onValueChangeFinished = { + onValueChangeFinished = { sliderPosition?.let { finalPos -> if (!isPreview) { hasSeenNativeProgress = true currentFrame = finalPos.toLong() NativeAudio.seekTo(finalPos.toLong()) - + coroutineScope.launch { delay(300) sliderPosition = null @@ -224,46 +303,84 @@ fun PlaybackProgressBar( } }, valueRange = 0f..sliderMax, - modifier = Modifier.height(12.dp), - thumb = {}, + modifier = Modifier + .weight(1f) + .height(32.dp), + thumb = { + Box( + modifier = Modifier + .size(14.dp) + .clip(CircleShape) + .background(SeamlessLoopPlayerColors.Primary) + .border(2.dp, SeamlessLoopPlayerColors.PrimaryText.copy(alpha = 0.82f), CircleShape) + ) + }, track = { sliderState -> val fraction = if (sliderState.valueRange.endInclusive > sliderState.valueRange.start) { (sliderState.value - sliderState.valueRange.start) / (sliderState.valueRange.endInclusive - sliderState.valueRange.start) } else 0f - + val markerColor = SeamlessLoopPlayerColors.LoopMarker + val loopStartFraction = if (totalFrames > 0L && song.loopStart > 0L) { + (song.loopStart.toFloat() / totalFrames.toFloat()).coerceIn(0f, 1f) + } else null + val loopEndFraction = if (totalFrames > 0L && song.loopEnd > 0L && song.loopEnd < totalFrames) { + (song.loopEnd.toFloat() / totalFrames.toFloat()).coerceIn(0f, 1f) + } else null + Box( modifier = Modifier .fillMaxWidth() - .height(2.dp), + .height(20.dp), contentAlignment = Alignment.CenterStart ) { - // 背景轨道喵 Box( modifier = Modifier .fillMaxWidth() - .height(2.dp) - .background(SeamlessLoopColors.Gray.copy(alpha = 0.3f)) + .height(4.dp) + .clip(RoundedCornerShape(2.dp)) + .background(SeamlessLoopPlayerColors.Track.copy(alpha = 0.64f)) ) - // 进度轨道喵 Box( modifier = Modifier - .fillMaxWidth(fraction) - .height(2.dp) - .background(SeamlessLoopColors.PurpleAccent) + .fillMaxWidth(fraction.coerceIn(0f, 1f)) + .height(4.dp) + .clip(RoundedCornerShape(2.dp)) + .background(SeamlessLoopPlayerColors.Primary) ) + loopStartFraction?.let { LoopMarker(it, markerColor) } + loopEndFraction?.let { LoopMarker(it, markerColor) } } } ) - Row( - modifier = Modifier.fillMaxWidth().padding(top = 2.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - val displayFrame = sliderPosition?.toLong() ?: currentFrame - val startTime = TimeUtils.formatTime(displayFrame, sampleRate) - val totalTime = TimeUtils.formatTime(totalFrames, sampleRate) - Text(startTime, color = SeamlessLoopColors.Gray, fontSize = 9.sp, fontWeight = FontWeight.Light) - Text(totalTime, color = SeamlessLoopColors.Gray, fontSize = 9.sp, fontWeight = FontWeight.Light) - } + Text( + text = totalTime, + color = SeamlessLoopPlayerColors.SecondaryText, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.width(48.dp), + textAlign = TextAlign.End + ) + } +} + +@Composable +private fun BoxScope.LoopMarker( + fraction: Float, + color: Color +) { + Box( + modifier = Modifier + .fillMaxWidth(fraction.coerceIn(0.002f, 0.998f)) + .height(18.dp), + contentAlignment = Alignment.CenterEnd + ) { + Box( + modifier = Modifier + .width(2.dp) + .height(18.dp) + .clip(RoundedCornerShape(1.dp)) + .background(color) + ) } } @@ -275,72 +392,125 @@ fun PlaybackControls( isPreparing: Boolean, isError: Boolean, showLoading: Boolean, + buttonHapticFeedbackEnabled: Boolean = true, onTogglePlayMode: () -> Unit, onToggleSeamlessLoop: () -> Unit, onPrev: () -> Unit, onPlayPause: () -> Unit, - onNext: () -> Unit, - onMoreClick: () -> Unit + onNext: () -> Unit ) { + val playButtonContainerColor = if (isError) { + SeamlessLoopPlayerColors.ErrorContainer + } else { + SeamlessLoopPlayerColors.Primary + } + val playButtonContentColor = if (isError) { + SeamlessLoopPlayerColors.OnErrorContainer + } else { + SeamlessLoopPlayerColors.GradientStart + } + val loopControlTint by animateColorAsState( + targetValue = if (isSeamlessLoopEnabled) { + SeamlessLoopPlayerColors.Primary + } else { + SeamlessLoopPlayerColors.Inactive + }, + animationSpec = tween(140), + label = "seamless_loop_control_color" + ) + Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically ) { - IconButton(onClick = onTogglePlayMode) { - val modeIcon = when(playMode) { - com.cpu.seamlessloopmobile.viewmodel.PlayMode.LIST_LOOP -> Icons.Default.Repeat - com.cpu.seamlessloopmobile.viewmodel.PlayMode.SINGLE_LOOP -> Icons.Default.RepeatOne - com.cpu.seamlessloopmobile.viewmodel.PlayMode.SHUFFLE -> Icons.Default.Shuffle + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.Center + ) { + IconButton( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled, onClick = onTogglePlayMode), + modifier = Modifier.size(48.dp) + ) { + val modeIcon = when(playMode) { + com.cpu.seamlessloopmobile.viewmodel.PlayMode.LIST_LOOP -> Icons.Default.Repeat + com.cpu.seamlessloopmobile.viewmodel.PlayMode.SINGLE_LOOP -> Icons.Default.RepeatOne + com.cpu.seamlessloopmobile.viewmodel.PlayMode.SHUFFLE -> Icons.Default.Shuffle + } + Icon(modeIcon, contentDescription = "播放模式", tint = SeamlessLoopPlayerColors.Primary, modifier = Modifier.size(24.dp)) } - Icon(modeIcon, contentDescription = "播放模式", tint = SeamlessLoopColors.PurpleAccent, modifier = Modifier.size(24.dp)) } - - // 🆕 在旁边肩并肩并排放置无缝循环按钮!使用代表无限符号的平放8字(AllInclusive)表示喵!(๑•̀ㅂ•稳t)و✧ - IconButton(onClick = onToggleSeamlessLoop) { - Icon( - imageVector = Icons.Default.AllInclusive, - contentDescription = "单曲无缝循环", - tint = if (isSeamlessLoopEnabled) SeamlessLoopColors.PurpleAccent else SeamlessLoopColors.Gray, - modifier = Modifier.size(24.dp) - ) - } - - IconButton(onClick = onPrev) { - Icon(Icons.Default.SkipPrevious, contentDescription = "上一首", tint = SeamlessLoopColors.White, modifier = Modifier.size(32.dp)) + + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.Center + ) { + IconButton( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled, onClick = onPrev), + modifier = Modifier.size(48.dp) + ) { + Icon(Icons.Default.SkipPrevious, contentDescription = "上一首", tint = SeamlessLoopPlayerColors.PrimaryText, modifier = Modifier.size(32.dp)) + } } - FilledIconButton( - onClick = { if (!isPreparing) onPlayPause() }, - modifier = Modifier.size(64.dp), - colors = IconButtonDefaults.filledIconButtonColors( - containerColor = if (isError) MaterialTheme.colorScheme.error else SeamlessLoopColors.PurpleAccent, - disabledContainerColor = SeamlessLoopColors.PurpleAccent.copy(alpha = 0.5f) - ), - enabled = !isPreparing + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.Center ) { - if (showLoading) { - CircularProgressIndicator( - modifier = Modifier.size(28.dp), - strokeWidth = 3.dp, - color = SeamlessLoopColors.DarkBgGradientStart - ) - } else { - Icon( - if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, - contentDescription = "播放/暂停", - modifier = Modifier.size(36.dp), - tint = SeamlessLoopColors.DarkBgGradientStart - ) + FilledIconButton( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled) { if (!isPreparing) onPlayPause() }, + modifier = Modifier.size(64.dp), + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = playButtonContainerColor, + contentColor = playButtonContentColor, + disabledContainerColor = playButtonContainerColor.copy(alpha = 0.5f), + disabledContentColor = playButtonContentColor.copy(alpha = 0.5f) + ), + enabled = !isPreparing + ) { + if (showLoading) { + CircularProgressIndicator( + modifier = Modifier.size(28.dp), + strokeWidth = 3.dp, + color = playButtonContentColor + ) + } else { + Icon( + if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = "播放/暂停", + modifier = Modifier.size(36.dp), + tint = playButtonContentColor + ) + } } } - IconButton(onClick = onNext) { - Icon(Icons.Default.SkipNext, contentDescription = "下一首", tint = SeamlessLoopColors.White, modifier = Modifier.size(32.dp)) + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.Center + ) { + IconButton( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled, onClick = onNext), + modifier = Modifier.size(48.dp) + ) { + Icon(Icons.Default.SkipNext, contentDescription = "下一首", tint = SeamlessLoopPlayerColors.PrimaryText, modifier = Modifier.size(32.dp)) + } } - - IconButton(onClick = onMoreClick) { - Icon(Icons.Default.MoreVert, contentDescription = "更多", tint = SeamlessLoopColors.White, modifier = Modifier.size(24.dp)) + + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.Center + ) { + IconButton( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled, onClick = onToggleSeamlessLoop), + modifier = Modifier.size(48.dp) + ) { + Icon( + imageVector = Icons.Default.AllInclusive, + contentDescription = "单曲无缝循环", + tint = loopControlTint, + modifier = Modifier.size(24.dp) + ) + } } } } @@ -367,7 +537,7 @@ fun MainInfoPagePreview() { @Composable fun PlaybackControlsPreview() { MaterialTheme { - Box(modifier = Modifier.background(SeamlessLoopColors.DarkBgGradientStart).padding(16.dp)) { + Box(modifier = Modifier.background(SeamlessLoopPlayerColors.GradientStart).padding(16.dp)) { PlaybackControls( playMode = com.cpu.seamlessloopmobile.viewmodel.PlayMode.LIST_LOOP, isSeamlessLoopEnabled = true, @@ -379,8 +549,7 @@ fun PlaybackControlsPreview() { onToggleSeamlessLoop = {}, onPrev = {}, onPlayPause = {}, - onNext = {}, - onMoreClick = {} + onNext = {} ) } } diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/common/SongArtwork.kt b/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/common/SongArtwork.kt new file mode 100644 index 0000000..9ddff77 --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/common/SongArtwork.kt @@ -0,0 +1,88 @@ +package com.cpu.seamlessloopmobile.ui.components.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil.compose.SubcomposeAsyncImage +import coil.compose.SubcomposeAsyncImageContent +import coil.request.ImageRequest + +@Composable +fun SongArtwork( + coverPath: String?, + contentDescription: String?, + modifier: Modifier = Modifier, + shape: Shape = RoundedCornerShape(12.dp), + iconSize: Dp = 24.dp, + backgroundColor: Color = MaterialTheme.colorScheme.surfaceVariant, + iconTint: Color = MaterialTheme.colorScheme.primary +) { + Box( + modifier = modifier + .clip(shape) + .background(backgroundColor), + contentAlignment = Alignment.Center + ) { + if (coverPath.isNullOrBlank()) { + ArtworkPlaceholder(iconSize = iconSize, iconTint = iconTint) + } else { + val request = ImageRequest.Builder(LocalContext.current) + .data(coverPath) + .crossfade(true) + .build() + SubcomposeAsyncImage( + model = request, + contentDescription = contentDescription, + contentScale = ContentScale.Crop, + modifier = Modifier.matchParentSize(), + loading = { + ArtworkPlaceholder( + iconSize = iconSize, + iconTint = iconTint, + modifier = Modifier.fillMaxSize() + ) + }, + error = { + ArtworkPlaceholder( + iconSize = iconSize, + iconTint = iconTint, + modifier = Modifier.fillMaxSize() + ) + }, + success = { SubcomposeAsyncImageContent() } + ) + } + } +} + +@Composable +private fun ArtworkPlaceholder( + iconSize: Dp, + iconTint: Color, + modifier: Modifier = Modifier +) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.MusicNote, + contentDescription = null, + tint = iconTint, + modifier = Modifier.size(iconSize) + ) + } +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/common/TopAppBarSearchBar.kt b/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/common/TopAppBarSearchBar.kt index bf32eba..766234c 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/common/TopAppBarSearchBar.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/ui/components/common/TopAppBarSearchBar.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -14,11 +15,12 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.cpu.seamlessloopmobile.utils.rememberHapticClick import kotlinx.coroutines.delay /** * 精致通用的 TopAppBar 内嵌搜索栏组件喵!🔍 - * 完美封装了 150ms 延迟自动聚焦逻辑,并支持自定义 placeholder 与样式重用喵。 + * 支持自定义 placeholder,并可按需开启延迟自动聚焦。 */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -26,12 +28,15 @@ fun TopAppBarSearchBar( value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier, - placeholderText: String = "搜索歌曲、艺人、专辑..." + placeholderText: String = "搜索歌曲、艺人、专辑...", + autoFocus: Boolean = false ) { val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - delay(150) // 腾出少量时间给转场过渡动画喵 - focusRequester.requestFocus() + LaunchedEffect(autoFocus) { + if (autoFocus) { + delay(150) + focusRequester.requestFocus() + } } TextField( @@ -48,9 +53,16 @@ fun TopAppBarSearchBar( color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f) ) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + ) + }, trailingIcon = { if (value.isNotEmpty()) { - IconButton(onClick = { onValueChange("") }) { + IconButton(onClick = rememberHapticClick { onValueChange("") }) { Icon( imageVector = Icons.Default.Clear, contentDescription = "清除", diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/MainAppBar.kt b/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/MainAppBar.kt index 4b9286e..5e5984b 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/MainAppBar.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/MainAppBar.kt @@ -1,31 +1,56 @@ package com.cpu.seamlessloopmobile.ui.screen import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.BarChart import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import com.cpu.seamlessloopmobile.ui.state.DataUiState +import com.cpu.seamlessloopmobile.utils.rememberHapticClick @Composable private fun SyncStatusSmallText(syncStatus: DataUiState) { when (syncStatus) { is DataUiState.Loading -> { - Text("🔍 寻找新曲子中...", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary) + Text( + "🔍 寻找新曲子中...", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) } is DataUiState.Error -> { - Text("❌ 扫描失败: ${syncStatus.message}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.error) + Text( + "❌ 扫描失败: ${syncStatus.message}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) } is DataUiState.Success -> { if (syncStatus.data.isNotEmpty()) { - Text(syncStatus.data, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary) + Text( + syncStatus.data, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) } } } @@ -35,22 +60,21 @@ private fun SyncStatusSmallText(syncStatus: DataUiState) { @Composable fun HomeAppBar( libraryVM: com.cpu.seamlessloopmobile.viewmodel.LibraryViewModel, - onSettingsClick: () -> Unit, - onSearchClick: () -> Unit + buttonHapticFeedbackEnabled: Boolean = true, + onStatsClick: () -> Unit ) { val syncStatus by libraryVM.syncStatus.collectAsState() TopAppBar( title = { - SyncStatusSmallText(syncStatus = syncStatus) - }, - navigationIcon = { - IconButton(onClick = onSettingsClick) { - Icon(Icons.Default.Settings, contentDescription = "设置") + Row(verticalAlignment = Alignment.CenterVertically) { + Text("媒体库", fontWeight = FontWeight.Bold) + Spacer(modifier = Modifier.width(8.dp)) + SyncStatusSmallText(syncStatus = syncStatus) } }, actions = { - IconButton(onClick = onSearchClick) { - Icon(Icons.Default.Search, contentDescription = "搜索") + IconButton(onClick = rememberHapticClick(buttonHapticFeedbackEnabled, onClick = onStatsClick)) { + Icon(Icons.Default.BarChart, contentDescription = "播放统计") } }, colors = TopAppBarDefaults.topAppBarColors( @@ -65,6 +89,7 @@ fun HomeAppBar( fun SongListAppBar( title: String, libraryVM: com.cpu.seamlessloopmobile.viewmodel.LibraryViewModel, + buttonHapticFeedbackEnabled: Boolean = true, onBackClick: () -> Unit, onSearchClick: () -> Unit ) { @@ -81,12 +106,12 @@ fun SongListAppBar( } }, navigationIcon = { - IconButton(onClick = onBackClick) { + IconButton(onClick = rememberHapticClick(buttonHapticFeedbackEnabled, onClick = onBackClick)) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") } }, actions = { - IconButton(onClick = onSearchClick) { + IconButton(onClick = rememberHapticClick(buttonHapticFeedbackEnabled, onClick = onSearchClick)) { Icon(Icons.Default.Search, contentDescription = "搜索") } }, @@ -103,6 +128,7 @@ fun SelectionAppBar( selectedItemsCount: Int, selectedFoldersCount: Int, selectedPlaylistsCount: Int, + buttonHapticFeedbackEnabled: Boolean = true, onCloseSelectionClick: () -> Unit ) { val selectionTitle = when { @@ -117,7 +143,7 @@ fun SelectionAppBar( Text(selectionTitle, fontWeight = FontWeight.Bold) }, navigationIcon = { - IconButton(onClick = onCloseSelectionClick) { + IconButton(onClick = rememberHapticClick(buttonHapticFeedbackEnabled, onClick = onCloseSelectionClick)) { Icon(Icons.Default.Close, contentDescription = "取消选择") } }, diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/MainScreen.kt b/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/MainScreen.kt index 5f65f89..a07f488 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/MainScreen.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/MainScreen.kt @@ -3,21 +3,35 @@ package com.cpu.seamlessloopmobile.ui.screen import androidx.compose.animation.AnimatedContent import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn import androidx.compose.animation.togetherWith +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.cpu.seamlessloopmobile.model.Song import com.cpu.seamlessloopmobile.ui.screen.songlist.MainSongListScreen import com.cpu.seamlessloopmobile.viewmodel.MainViewModel import com.cpu.seamlessloopmobile.viewmodel.MusicUiState import com.cpu.seamlessloopmobile.viewmodel.MusicDialog +import com.cpu.seamlessloopmobile.viewmodel.GitHubSyncUiState +import com.cpu.seamlessloopmobile.data.ThemePreference import androidx.activity.compose.BackHandler import com.cpu.seamlessloopmobile.ui.screen.search.SearchScreen -import com.cpu.seamlessloopmobile.ui.screen.settings.SettingsDrawer +import com.cpu.seamlessloopmobile.ui.screen.stats.PlaybackStatsScreen +import com.cpu.seamlessloopmobile.ui.screen.settings.SettingsScreen +import com.cpu.seamlessloopmobile.data.stats.ListenStatsRepository +import com.cpu.seamlessloopmobile.data.stats.TrackStat +import com.cpu.seamlessloopmobile.ui.components.app.MainBottomNavigation +import com.cpu.seamlessloopmobile.ui.components.app.MainDestination import com.cpu.seamlessloopmobile.ui.components.app.MiniPlayer import com.cpu.seamlessloopmobile.ui.components.app.CentralizedDialogHost import com.cpu.seamlessloopmobile.ui.components.app.PlayingPanel @@ -26,14 +40,19 @@ import com.cpu.seamlessloopmobile.ui.components.app.MultiSelectBar import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember import androidx.compose.material3.* +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.padding import androidx.compose.ui.Alignment -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.runtime.derivedStateOf +import dev.chrisbanes.haze.HazeDefaults +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.HazeStyle +import dev.chrisbanes.haze.haze +import com.cpu.seamlessloopmobile.utils.LocalButtonHapticFeedbackEnabled /** * 主界面总装配站喵!(๑•̀ㅂ•́)و✧ @@ -46,7 +65,10 @@ fun MainScreen( viewModel: MainViewModel, playSong: (com.cpu.seamlessloopmobile.model.Song) -> Unit, onSyncPc: () -> Unit, - onExportDatabase: () -> Unit + onExportDatabase: () -> Unit, + isDarkTheme: Boolean, + themePreference: ThemePreference, + onThemePreferenceChange: (ThemePreference) -> Unit ) { val uiState by viewModel.uiState.observeAsState(MusicUiState.Home) @@ -57,10 +79,21 @@ fun MainScreen( val selectedFolders by viewModel.selection.selectedFolders.observeAsState(emptySet()) val isPlayingPanelVisible by viewModel.isPlayingPanelVisible.observeAsState(false) - val isSettingsPanelVisible by viewModel.isSettingsPanelVisible.observeAsState(false) val seamlessLoopCountLimit by viewModel.seamlessLoopCountLimit.observeAsState(0) + val buttonHapticFeedbackEnabled by viewModel.buttonHapticFeedbackEnabled.observeAsState(true) + val githubSyncState by viewModel.githubSyncState.observeAsState(GitHubSyncUiState()) + val bottomDestination = when (uiState) { + is MusicUiState.Settings -> MainDestination.Settings + is MusicUiState.Search -> MainDestination.Search + is MusicUiState.PlaybackStats -> null + else -> MainDestination.Library + } + val context = LocalContext.current + val miniPlayerHazeState = remember { HazeState() } + val statsRepository = remember(context) { ListenStatsRepository.getInstance(context) } val playlists by viewModel.playlist.playlists.collectAsState() + val allSongs by viewModel.library.allSongs.collectAsState() // --- 导航滚动位置记忆中心喵 --- val categoryScrollStates = remember { mutableMapOf() } @@ -78,156 +111,239 @@ fun MainScreen( } // --- 接管返回键喵 --- - BackHandler(enabled = isSettingsPanelVisible || isPlayingPanelVisible || isSelectionMode || uiState !is MusicUiState.Home) { + BackHandler(enabled = isPlayingPanelVisible || isSelectionMode || uiState !is MusicUiState.Home) { when { - isSettingsPanelVisible -> viewModel.setSettingsPanelVisible(false) isPlayingPanelVisible -> viewModel.setPlayingPanelVisible(false) isSelectionMode -> viewModel.clearSelection() else -> viewModel.goBack() } } + CompositionLocalProvider(LocalButtonHapticFeedbackEnabled provides buttonHapticFeedbackEnabled) { // --- 界面架构拼装喵 --- Box(modifier = Modifier.fillMaxSize()) { - Box(modifier = Modifier.fillMaxSize()) { - Scaffold( - topBar = { - val currentUiState = uiState - when { - isSelectionMode -> SelectionAppBar( - selectedItemsCount = selectedItems.size, - selectedFoldersCount = selectedFolders.size, - selectedPlaylistsCount = selectedPlaylists.size, - onCloseSelectionClick = remember(viewModel) { viewModel::clearSelection } - ) - currentUiState is MusicUiState.Search -> {} // 搜索页自己掌控顶栏喵! - currentUiState is MusicUiState.Home -> HomeAppBar( - libraryVM = viewModel.library, - onSettingsClick = remember(viewModel) { { viewModel.setSettingsPanelVisible(true) } }, - onSearchClick = remember(viewModel) { { viewModel.navigateTo(MusicUiState.Search) } } + Box( + modifier = Modifier + .fillMaxSize() + .haze( + miniPlayerHazeState, + HazeStyle( + tint = MaterialTheme.colorScheme.surface.copy(alpha = 0.08f), + blurRadius = 28.dp, + noiseFactor = HazeDefaults.noiseFactor + ) + ) + ) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) {} + + AnimatedContent( + targetState = uiState, + transitionSpec = { + (scaleIn( + animationSpec = tween(200, easing = FastOutSlowInEasing), + initialScale = 0.98f + ) + fadeIn(animationSpec = tween(180))) togetherWith + fadeOut(animationSpec = tween(160)) + }, + label = "MainPageTransition", + modifier = Modifier.fillMaxSize() + ) { state -> + when (state) { + is MusicUiState.Search -> { + SearchScreen( + viewModel = viewModel, + playSong = playSong, + onBack = remember(viewModel) { { viewModel.goBack() } } ) - currentUiState is MusicUiState.SongList -> SongListAppBar( - title = currentUiState.title, - libraryVM = viewModel.library, - onBackClick = remember(viewModel) { { viewModel.goBack() } }, - onSearchClick = remember(viewModel) { { viewModel.navigateTo(MusicUiState.Search) } } + } + + is MusicUiState.Settings -> { + SettingsScreen( + onBack = remember(viewModel) { { viewModel.goBack() } }, + onRescan = remember(viewModel) { { context -> viewModel.scanLibrary(context) } }, + onSyncPc = onSyncPc, + onExportDatabase = onExportDatabase, + seamlessLoopCountLimit = seamlessLoopCountLimit, + onSeamlessLoopCountLimitChange = remember(viewModel) { viewModel::setSeamlessLoopCountLimit }, + buttonHapticFeedbackEnabled = buttonHapticFeedbackEnabled, + onButtonHapticFeedbackEnabledChange = remember(viewModel) { viewModel::setButtonHapticFeedbackEnabled }, + isDarkTheme = isDarkTheme, + themePreference = themePreference, + onThemePreferenceChange = onThemePreferenceChange, + githubSyncState = githubSyncState, + onGitHubAutoSyncEnabledChange = remember(viewModel) { viewModel::setGitHubAutoSyncEnabled }, + onSaveGitHubSyncConfig = remember(viewModel) { viewModel::saveGitHubSyncConfig }, + onClearGitHubSyncConfig = remember(viewModel) { viewModel::clearGitHubSyncConfig }, + onRunGitHubSync = remember(viewModel) { viewModel::runGitHubSync }, + onRefreshSyncDataManagementPreview = remember(viewModel) { viewModel::refreshSyncDataManagementPreview }, + onLoadPlaybackStatsSourceDevices = remember(viewModel) { viewModel::loadPlaybackStatsSourceDevices }, + onDeletePlaybackStatsSourceDeviceHistories = remember(viewModel) { + viewModel::deletePlaybackStatsSourceDeviceHistories + }, + onSeedCloudFromLocal = remember(viewModel) { viewModel::seedCloudFromLocal }, + onDeleteCloudSnapshot = remember(viewModel) { viewModel::deleteCloudSnapshot }, + onClearLocalSyncData = remember(viewModel) { viewModel::clearLocalSyncData } ) - else -> {} } - }, - bottomBar = { - if (!isSelectionMode) { - MiniPlayer( - viewModel = viewModel, - onClick = remember(viewModel) { { viewModel.setPlayingPanelVisible(true) } } + + is MusicUiState.PlaybackStats -> { + PlaybackStatsScreen( + repository = statsRepository, + buttonHapticFeedbackEnabled = buttonHapticFeedbackEnabled, + onTrackClick = remember(allSongs, viewModel, playSong) { + { stat -> + val song = findSongForTrackStat(allSongs, stat) + if (song != null) { + val index = allSongs.indexOfFirst { it.filePath == song.filePath } + viewModel.updateCurrentPlaylist(allSongs, index) + playSong(song) + } + } + }, + onBack = remember(viewModel) { { viewModel.goBack() } } ) } - } - ) { paddingValues -> - Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { - // 1. 扁平 Tab 翻页展示主底层(已完美解耦抽离!) - MainTabsPager( - viewModel = viewModel, - isSelectionMode = isSelectionMode, - selectedFolders = selectedFolders, - categoryScrollStates = categoryScrollStates - ) - - // 2. 二级 SongList 页面 Overlay (使用 AnimatedContent 控制淡入淡出,盖在 Pager 上方) - AnimatedContent( - targetState = uiState, - transitionSpec = { - fadeIn() togetherWith fadeOut() - }, - label = "MainScreenNavigation", - modifier = Modifier.fillMaxSize() - ) { state -> - when (state) { - is MusicUiState.Search -> { - SearchScreen( - viewModel = viewModel, - playSong = playSong - ) - } - is MusicUiState.SongList -> { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - MainSongListScreen( - state = state, + + else -> { + Scaffold( + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + when { + isSelectionMode -> SelectionAppBar( + selectedItemsCount = selectedItems.size, + selectedFoldersCount = selectedFolders.size, + selectedPlaylistsCount = selectedPlaylists.size, + buttonHapticFeedbackEnabled = buttonHapticFeedbackEnabled, + onCloseSelectionClick = remember(viewModel) { viewModel::clearSelection } + ) + state is MusicUiState.Home -> HomeAppBar( libraryVM = viewModel.library, - selectionVM = viewModel.selection, - mainVM = viewModel, - onPlaySong = remember(viewModel) { - { song, songsToShow -> - val index = songsToShow.indexOf(song) - viewModel.updateCurrentPlaylist(songsToShow, index) - playSong(song) - } - }, - onShowMoreOptions = remember(viewModel) { - { song -> - val currentPlaylistId = if (state.type == MusicUiState.ListType.PLAYLIST) { - playlists.find { it.name == state.title }?.id - } else null - - viewModel.showDialog(MusicDialog.SongMoreOptions(song, currentPlaylistId)) - } - }, - listState = songListScrollStates.getOrPut("${state.type}_${state.title}") { - androidx.compose.foundation.lazy.LazyListState() - } + buttonHapticFeedbackEnabled = buttonHapticFeedbackEnabled, + onStatsClick = remember(viewModel) { { viewModel.navigateTo(MusicUiState.PlaybackStats) } } + ) + state is MusicUiState.SongList -> SongListAppBar( + title = state.title, + libraryVM = viewModel.library, + buttonHapticFeedbackEnabled = buttonHapticFeedbackEnabled, + onBackClick = remember(viewModel) { { viewModel.goBack() } }, + onSearchClick = remember(viewModel) { { viewModel.navigateTo(MusicUiState.Search) } } ) + else -> {} } } - else -> { - Spacer(modifier = Modifier.fillMaxSize()) + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + MainTabsPager( + viewModel = viewModel, + isSelectionMode = isSelectionMode, + selectedFolders = selectedFolders, + categoryScrollStates = categoryScrollStates + ) + + if (state is MusicUiState.SongList) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + MainSongListScreen( + state = state, + libraryVM = viewModel.library, + selectionVM = viewModel.selection, + mainVM = viewModel, + onPlaySong = remember(viewModel) { + { song, songsToShow -> + val index = songsToShow.indexOf(song) + viewModel.updateCurrentPlaylist(songsToShow, index) + playSong(song) + } + }, + onShowMoreOptions = remember(viewModel) { + { song -> + val currentPlaylistId = if (state.type == MusicUiState.ListType.PLAYLIST) { + playlists.find { it.name == state.title }?.id + } else null + + viewModel.showDialog(MusicDialog.SongMoreOptions(song, currentPlaylistId)) + } + }, + listState = songListScrollStates.getOrPut("${state.type}_${state.title}") { + androidx.compose.foundation.lazy.LazyListState() + } + ) + } + } + + MultiSelectBar( + isSelectionMode = isSelectionMode, + selectedItems = selectedItems, + selectedPlaylists = selectedPlaylists, + selectedFolders = selectedFolders, + playlists = playlists, + songsInCurrentPage = songsInCurrentPage, + onClearSelection = remember(viewModel) { viewModel::clearSelection }, + onSelectAll = remember(viewModel) { { songs -> viewModel.selectAll(songs) } }, + onDeleteSelectedPlaylists = remember(viewModel) { viewModel::deleteSelectedPlaylists }, + onImportFoldersIndividually = remember(viewModel) { viewModel::importSelectedFoldersIndividually }, + onImportFoldersAsSinglePlaylist = remember(viewModel) { { name -> viewModel.importSelectedFoldersAsSinglePlaylist(name) } }, + onAddSelectedToPlaylist = remember(viewModel) { { playlistId -> viewModel.addSelectedToPlaylist(playlistId) } }, + onCreatePlaylistWithSelected = remember(viewModel) { { name -> viewModel.createPlaylistWithSelected(name) } }, + onShowDialog = remember(viewModel) { { dialog -> viewModel.showDialog(dialog) } }, + onShowMoreBulkOptions = remember(viewModel, state) { + { + val currentPlaylistId = if (state is MusicUiState.SongList && state.type == MusicUiState.ListType.PLAYLIST) { + playlists.find { it.name == state.title }?.id + } else null + + viewModel.showDialog(MusicDialog.BulkMoreOptions(selectedItems.size, currentPlaylistId)) + } + }, + modifier = Modifier.align(Alignment.BottomCenter) + ) } } } - - // --- 多选操作悬浮页喵 --- - MultiSelectBar( - isSelectionMode = isSelectionMode, - selectedItems = selectedItems, - selectedPlaylists = selectedPlaylists, - selectedFolders = selectedFolders, - playlists = playlists, - songsInCurrentPage = songsInCurrentPage, - onClearSelection = remember(viewModel) { viewModel::clearSelection }, - onSelectAll = remember(viewModel) { { songs -> viewModel.selectAll(songs) } }, - onDeleteSelectedPlaylists = remember(viewModel) { viewModel::deleteSelectedPlaylists }, - onImportFoldersIndividually = remember(viewModel) { viewModel::importSelectedFoldersIndividually }, - onImportFoldersAsSinglePlaylist = remember(viewModel) { { name -> viewModel.importSelectedFoldersAsSinglePlaylist(name) } }, - onAddSelectedToPlaylist = remember(viewModel) { { playlistId -> viewModel.addSelectedToPlaylist(playlistId) } }, - onCreatePlaylistWithSelected = remember(viewModel) { { name -> viewModel.createPlaylistWithSelected(name) } }, - onShowDialog = remember(viewModel) { { dialog -> viewModel.showDialog(dialog) } }, - onShowMoreBulkOptions = remember(viewModel, uiState) { - { - val currentPlaylistId = if (uiState is MusicUiState.SongList && (uiState as MusicUiState.SongList).type == MusicUiState.ListType.PLAYLIST) { - playlists.find { it.name == (uiState as MusicUiState.SongList).title }?.id - } else null - - viewModel.showDialog(MusicDialog.BulkMoreOptions(selectedItems.size, currentPlaylistId)) - } - }, - modifier = Modifier.align(Alignment.BottomCenter) - ) } } } - - // --- Layer 2: 侧边设置面板遮罩层与滑入面板喵!⚙️ --- - SettingsDrawer( - isVisible = isSettingsPanelVisible, - onClose = remember(viewModel) { { viewModel.setSettingsPanelVisible(false) } }, - onRescan = remember(viewModel) { { context -> viewModel.scanLibrary(context) } }, - onSyncPc = onSyncPc, - onExportDatabase = onExportDatabase, - seamlessLoopCountLimit = seamlessLoopCountLimit, - onSeamlessLoopCountLimitChange = remember(viewModel) { viewModel::setSeamlessLoopCountLimit } - ) + + if (!isSelectionMode) { + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + ) { + MiniPlayer( + viewModel = viewModel, + hazeState = miniPlayerHazeState, + buttonHapticFeedbackEnabled = buttonHapticFeedbackEnabled, + onClick = remember(viewModel) { { viewModel.setPlayingPanelVisible(true) } } + ) + MainBottomNavigation( + selectedDestination = bottomDestination, + onLibraryClick = remember(viewModel) { + { + viewModel.resetToHome() + } + }, + onSearchClick = remember(viewModel) { + { + viewModel.navigateTo(MusicUiState.Search) + } + }, + onSettingsClick = remember(viewModel) { + { viewModel.navigateTo(MusicUiState.Settings) } + }, + buttonHapticFeedbackEnabled = buttonHapticFeedbackEnabled + ) + } + } } // --- 全屏播放面板 Overlay --- @@ -238,6 +354,7 @@ fun MainScreen( onPlayPause = remember(viewModel) { viewModel::togglePlayPause }, onNext = remember(viewModel) { { viewModel.skipToNext() } }, onPrev = remember(viewModel) { { viewModel.skipToPrevious() } }, + buttonHapticFeedbackEnabled = buttonHapticFeedbackEnabled, onMoreClick = remember(viewModel) { { song -> val currentPlaylistId = viewModel.currentOpenPlaylist.value?.id @@ -248,6 +365,15 @@ fun MainScreen( // --- 全局对话框托管 --- CentralizedDialogHost(viewModel) + } +} + +internal fun findSongForTrackStat( + songs: List, + stat: TrackStat +): Song? { + if (stat.songId <= 0L) return null + return songs.firstOrNull { song -> song.id == stat.songId } } @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/MainTabsPager.kt b/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/MainTabsPager.kt index f0fff98..86dd7d4 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/MainTabsPager.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/MainTabsPager.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.MaterialTheme @@ -47,19 +48,34 @@ fun MainTabsPager( ScrollableTabRow( selectedTabIndex = pagerState.currentPage, edgePadding = 16.dp, - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.primary, + divider = { + androidx.compose.material3.HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.58f) + ) + }, modifier = Modifier.fillMaxWidth() ) { tabs.forEachIndexed { index, title -> + val selected = pagerState.currentPage == index Tab( - selected = pagerState.currentPage == index, + selected = selected, onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } }, - text = { Text(title, fontWeight = FontWeight.Bold) } + modifier = Modifier.height(48.dp), + selectedContentColor = MaterialTheme.colorScheme.primary, + unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + text = { + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium + ) + } ) } } diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/PlaylistTabScreen.kt b/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/PlaylistTabScreen.kt index d4c6784..a67260f 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/PlaylistTabScreen.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/PlaylistTabScreen.kt @@ -2,6 +2,7 @@ package com.cpu.seamlessloopmobile.ui.screen import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons @@ -46,7 +47,8 @@ fun PlaylistTabScreen( LazyColumn( modifier = modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = 80.dp) + contentPadding = PaddingValues(top = 4.dp, bottom = 176.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) ) { // 1. 全部歌曲 item { diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/category/CategoryScreen.kt b/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/category/CategoryScreen.kt index 61ff7a1..9d41d9b 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/category/CategoryScreen.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/category/CategoryScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.material.icons.filled.Album import androidx.compose.material.icons.filled.Person import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import com.cpu.seamlessloopmobile.model.Folder import com.cpu.seamlessloopmobile.ui.components.common.CategoryListItem @@ -27,7 +28,9 @@ fun CategoryScreen( ) { LazyColumn( modifier = Modifier.fillMaxSize(), - state = listState + state = listState, + contentPadding = PaddingValues(top = 4.dp, bottom = 176.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) ) { items(items) { folder -> val isSelected = selectedFolders.any { it.path == folder.path } diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/search/SearchScreen.kt b/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/search/SearchScreen.kt index 58a59b0..6b60081 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/search/SearchScreen.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/search/SearchScreen.kt @@ -2,14 +2,17 @@ package com.cpu.seamlessloopmobile.ui.screen.search import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import com.cpu.seamlessloopmobile.model.Song import com.cpu.seamlessloopmobile.ui.components.common.TopAppBarSearchBar import com.cpu.seamlessloopmobile.ui.screen.songlist.SongListScreen @@ -21,9 +24,9 @@ import kotlinx.coroutines.delay @Composable fun SearchScreen( viewModel: MainViewModel, - playSong: (Song) -> Unit + playSong: (Song) -> Unit, + onBack: () -> Unit ) { - val context = androidx.compose.ui.platform.LocalContext.current val allSongs by viewModel.library.allSongs.collectAsState() val isSelectionMode by viewModel.selection.isSelectionMode.observeAsState(false) val selectedItems by viewModel.selection.selectedItems.observeAsState(emptySet()) @@ -32,11 +35,12 @@ fun SearchScreen( var searchQuery by remember { mutableStateOf("") } var filteredSongs by remember { mutableStateOf>(emptyList()) } + val visibleSongs = if (searchQuery.isBlank()) allSongs else filteredSongs // --- 300ms 响应式搜索防抖过滤机制喵!🔍 --- LaunchedEffect(searchQuery, allSongs) { if (searchQuery.isBlank()) { - filteredSongs = emptyList() + filteredSongs = allSongs } else { delay(300) filteredSongs = allSongs.filter { song -> @@ -48,35 +52,38 @@ fun SearchScreen( } } - // 接管物理返回键,点击时返回上一级(Home 页面) - BackHandler { - if (isSelectionMode) { - viewModel.clearSelection() - } else { - viewModel.goBack() + val handleBack = remember(isSelectionMode, onBack) { + { + if (isSelectionMode) { + viewModel.clearSelection() + } else { + onBack() + } } } + BackHandler(onBack = handleBack) Scaffold( + contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = { TopAppBar( + navigationIcon = { + IconButton( + onClick = handleBack, + modifier = Modifier.size(48.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "返回" + ) + } + }, title = { TopAppBarSearchBar( value = searchQuery, onValueChange = { searchQuery = it } ) }, - navigationIcon = { - IconButton(onClick = { - if (isSelectionMode) { - viewModel.clearSelection() - } else { - viewModel.goBack() - } - }) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") - } - }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.primaryContainer, titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer @@ -92,13 +99,13 @@ fun SearchScreen( val currentPlayingPath = currentPlaylist.getOrNull(currentSongIndex)?.filePath SongListScreen( - songs = filteredSongs, + songs = visibleSongs, currentPlayingSongPath = currentPlayingPath, isSelectionMode = isSelectionMode, selectedItems = selectedItems, onPlaySong = { song -> - val index = filteredSongs.indexOf(song) - viewModel.updateCurrentPlaylist(filteredSongs, index) + val index = visibleSongs.indexOf(song) + viewModel.updateCurrentPlaylist(visibleSongs, index) playSong(song) }, onToggleSelection = { song -> diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/settings/SettingsDrawer.kt b/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/settings/SettingsDrawer.kt deleted file mode 100644 index 48dbc2c..0000000 --- a/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/settings/SettingsDrawer.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.cpu.seamlessloopmobile.ui.screen.settings - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ModalDrawerSheet -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color - -/** - * 从 MainScreen 中完美抽取出来的侧边设置抽屉组件喵!(๑•̀ㅂ•́)و✧ - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun SettingsDrawer( - isVisible: Boolean, - onClose: () -> Unit, - onRescan: (android.content.Context) -> Unit, - onSyncPc: () -> Unit, - onExportDatabase: () -> Unit, - seamlessLoopCountLimit: Int, - onSeamlessLoopCountLimitChange: (Int) -> Unit, - modifier: Modifier = Modifier -) { - AnimatedVisibility( - visible = isVisible, - enter = fadeIn(animationSpec = tween(300)), - exit = fadeOut(animationSpec = tween(300)), - modifier = modifier - ) { - Box(modifier = Modifier.fillMaxSize()) { - // 可点击遮罩背景 - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.32f)) - .clickable( - interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }, - indication = null, - onClick = onClose - ) - ) - - // 侧边设置抽屉(滑入滑出) - AnimatedVisibility( - visible = isVisible, - enter = slideInHorizontally( - animationSpec = tween(300), - initialOffsetX = { -it } - ), - exit = slideOutHorizontally( - animationSpec = tween(300), - targetOffsetX = { -it } - ), - modifier = Modifier - .fillMaxHeight() - .fillMaxWidth(0.85f) - .align(Alignment.CenterStart) - ) { - ModalDrawerSheet(modifier = Modifier.fillMaxSize()) { - val context = androidx.compose.ui.platform.LocalContext.current - SettingsScreen( - onClose = onClose, - onRescan = { onRescan(context) }, - onSyncPc = onSyncPc, - onExportDatabase = onExportDatabase, - seamlessLoopCountLimit = seamlessLoopCountLimit, - onSeamlessLoopCountLimitChange = onSeamlessLoopCountLimitChange - ) - } - } - } - } -} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/settings/SettingsScreen.kt b/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/settings/SettingsScreen.kt index be75af9..397f7dc 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/settings/SettingsScreen.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/settings/SettingsScreen.kt @@ -1,48 +1,156 @@ package com.cpu.seamlessloopmobile.ui.screen.settings +import android.content.Context +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +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.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material.icons.filled.CloudUpload +import androidx.compose.material.icons.filled.DarkMode +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Smartphone +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.LightMode +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.RepeatOne +import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material.icons.filled.Vibration +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Divider +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.cpu.seamlessloopmobile.data.SettingsManager +import com.cpu.seamlessloopmobile.data.ThemePreference +import com.cpu.seamlessloopmobile.data.sync.LocalSyncDataSummary +import com.cpu.seamlessloopmobile.data.sync.PlaybackStatsSourceDeviceSummary +import com.cpu.seamlessloopmobile.utils.rememberHapticClick +import com.cpu.seamlessloopmobile.viewmodel.GitHubSyncUiState +import java.text.DateFormat +import java.util.Date + +private enum class SettingsPage { + Appearance, + Playback, + Data, + Sync +} + +internal data class PlaybackStatsSourceDevicePresentation( + val activeSources: List, + val historicalSourceCount: Int +) + +internal fun playbackStatsSourceDevicePresentation( + sources: List +): PlaybackStatsSourceDevicePresentation { + val (historicalSources, activeSources) = sources.partition { + it.allKnownGenerationsRemoved && !it.isCurrentDevice + } + return PlaybackStatsSourceDevicePresentation( + activeSources = activeSources, + historicalSourceCount = historicalSources.size + ) +} -/** - * 设置侧边栏面板喵!⚙️ - * 优雅美观,包含语言选择(壳子)、库重新扫描、PC 数据库导入与 PC 兼容数据库导出喵!(๑•̀ㅂ•́)و✧ - */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( - onClose: () -> Unit, - onRescan: () -> Unit, + onBack: () -> Unit, + onRescan: (Context) -> Unit, onSyncPc: () -> Unit, onExportDatabase: () -> Unit, seamlessLoopCountLimit: Int, onSeamlessLoopCountLimitChange: (Int) -> Unit, + buttonHapticFeedbackEnabled: Boolean, + onButtonHapticFeedbackEnabledChange: (Boolean) -> Unit, + isDarkTheme: Boolean, + themePreference: ThemePreference, + onThemePreferenceChange: (ThemePreference) -> Unit, + githubSyncState: GitHubSyncUiState, + onGitHubAutoSyncEnabledChange: (Boolean) -> Unit, + onSaveGitHubSyncConfig: (token: String, owner: String, repo: String, branch: String, path: String) -> Unit, + onClearGitHubSyncConfig: () -> Unit, + onRunGitHubSync: () -> Unit, + onRefreshSyncDataManagementPreview: () -> Unit, + onLoadPlaybackStatsSourceDevices: () -> Unit, + onDeletePlaybackStatsSourceDeviceHistories: (Set) -> Unit, + onSeedCloudFromLocal: () -> Unit, + onDeleteCloudSnapshot: () -> Unit, + onClearLocalSyncData: ( + clearPlaylists: Boolean, + clearLoopPoints: Boolean, + clearRatings: Boolean, + clearListenStats: Boolean + ) -> Unit, modifier: Modifier = Modifier ) { + var activePage by rememberSaveable { mutableStateOf(null) } var dropdownExpanded by remember { mutableStateOf(false) } - val languages = listOf("简体中文") + val languages = remember { listOf("简体中文") } var selectedLanguage by remember { mutableStateOf(languages[0]) } var loopLimitText by remember(seamlessLoopCountLimit) { mutableStateOf(seamlessLoopCountLimit.toString()) } var loopLimitSavedMessage by remember { mutableStateOf(null) } val maxLoopLimit = SettingsManager.MAX_SEAMLESS_LOOP_COUNT_LIMIT - val parsedLoopLimit = remember(loopLimitText) { - loopLimitText.toIntOrNull() - } + val parsedLoopLimit = remember(loopLimitText) { loopLimitText.toIntOrNull() } val loopLimitError = remember(loopLimitText, parsedLoopLimit, maxLoopLimit) { when { loopLimitText.isBlank() -> "请输入 0 到 $maxLoopLimit 之间的整数" @@ -51,351 +159,1675 @@ fun SettingsScreen( parsedLoopLimit > maxLoopLimit -> "循环次数不能超过 $maxLoopLimit" else -> null } - } + } + + BackHandler(enabled = activePage != null) { + activePage = null + } + + val currentPage = activePage + Box(modifier = modifier.fillMaxSize()) { + if (currentPage == null) { + SettingsHomePage( + buttonHapticFeedbackEnabled = buttonHapticFeedbackEnabled, + onPageClick = { activePage = it } + ) + } else { + SettingsDetailPage( + page = currentPage, + onBack = { activePage = null }, + content = { + when (currentPage) { + SettingsPage.Appearance -> AppearanceSettings( + isDarkTheme = isDarkTheme, + themePreference = themePreference, + onThemePreferenceChange = onThemePreferenceChange, + buttonHapticFeedbackEnabled = buttonHapticFeedbackEnabled, + onButtonHapticFeedbackEnabledChange = onButtonHapticFeedbackEnabledChange, + dropdownExpanded = dropdownExpanded, + onDropdownExpandedChange = { dropdownExpanded = it }, + selectedLanguage = selectedLanguage, + languages = languages, + onLanguageSelected = { selectedLanguage = it } + ) + SettingsPage.Playback -> PlaybackSettings( + loopLimitText = loopLimitText, + onLoopLimitTextChange = { + loopLimitText = it + loopLimitSavedMessage = null + }, + loopLimitError = loopLimitError, + loopLimitSavedMessage = loopLimitSavedMessage, + maxLoopLimit = maxLoopLimit, + parsedLoopLimit = parsedLoopLimit, + buttonHapticFeedbackEnabled = buttonHapticFeedbackEnabled, + onSaveLoopLimit = { value -> + onSeamlessLoopCountLimitChange(value) + loopLimitSavedMessage = if (value == 0) { + "已保存:无限循环" + } else { + "已保存:循环 $value 次后切换下一首" + } + } + ) + SettingsPage.Data -> DataSettings( + onClosePage = onBack, + onRescan = onRescan, + onSyncPc = onSyncPc, + onExportDatabase = onExportDatabase, + buttonHapticFeedbackEnabled = buttonHapticFeedbackEnabled + ) + SettingsPage.Sync -> GitHubSyncSettings( + githubSyncState = githubSyncState, + buttonHapticFeedbackEnabled = buttonHapticFeedbackEnabled, + onGitHubAutoSyncEnabledChange = onGitHubAutoSyncEnabledChange, + onSaveGitHubSyncConfig = onSaveGitHubSyncConfig, + onClearGitHubSyncConfig = onClearGitHubSyncConfig, + onRunGitHubSync = onRunGitHubSync, + onRefreshSyncDataManagementPreview = onRefreshSyncDataManagementPreview, + onLoadPlaybackStatsSourceDevices = onLoadPlaybackStatsSourceDevices, + onDeletePlaybackStatsSourceDeviceHistories = onDeletePlaybackStatsSourceDeviceHistories, + onSeedCloudFromLocal = onSeedCloudFromLocal, + onDeleteCloudSnapshot = onDeleteCloudSnapshot, + onClearLocalSyncData = onClearLocalSyncData + ) + } + } + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SettingsHomePage( + buttonHapticFeedbackEnabled: Boolean, + onPageClick: (SettingsPage) -> Unit +) { + Scaffold( + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + TopAppBar( + title = { Text("设置", fontWeight = FontWeight.Bold) }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(paddingValues), + contentPadding = PaddingValues(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 176.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + SettingsGroupCard( + title = "界面", + pages = listOf(SettingsPage.Appearance), + buttonHapticFeedbackEnabled = buttonHapticFeedbackEnabled, + onPageClick = onPageClick + ) + } + item { + SettingsGroupCard( + title = "播放与数据", + pages = listOf(SettingsPage.Playback, SettingsPage.Data, SettingsPage.Sync), + buttonHapticFeedbackEnabled = buttonHapticFeedbackEnabled, + onPageClick = onPageClick + ) + } + item { + FooterInfo() + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SettingsDetailPage( + page: SettingsPage, + onBack: () -> Unit, + content: @Composable () -> Unit +) { + Scaffold( + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + TopAppBar( + title = { Text(page.title, fontWeight = FontWeight.Bold) }, + navigationIcon = { + IconButton( + onClick = onBack, + modifier = Modifier.size(48.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "返回" + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(paddingValues), + contentPadding = PaddingValues(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 176.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { content() } + } + } +} + +@Composable +private fun SettingsGroupCard( + title: String, + pages: List, + buttonHapticFeedbackEnabled: Boolean, + onPageClick: (SettingsPage) -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.72f), + modifier = Modifier.padding(horizontal = 4.dp) + ) + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.34f) + ), + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 0.dp) + ) { + pages.forEachIndexed { index, page -> + SettingsNavigationRow( + page = page, + buttonHapticFeedbackEnabled = buttonHapticFeedbackEnabled, + onClick = { onPageClick(page) } + ) + if (index < pages.lastIndex) { + Divider( + modifier = Modifier.padding(start = 72.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f) + ) + } + } + } + } +} + +@Composable +private fun SettingsNavigationRow( + page: SettingsPage, + buttonHapticFeedbackEnabled: Boolean, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = rememberHapticClick(buttonHapticFeedbackEnabled, onClick = onClick)) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + SettingsIconBox(icon = page.icon) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = page.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = page.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AppearanceSettings( + isDarkTheme: Boolean, + themePreference: ThemePreference, + onThemePreferenceChange: (ThemePreference) -> Unit, + buttonHapticFeedbackEnabled: Boolean, + onButtonHapticFeedbackEnabledChange: (Boolean) -> Unit, + dropdownExpanded: Boolean, + onDropdownExpandedChange: (Boolean) -> Unit, + selectedLanguage: String, + languages: List, + onLanguageSelected: (String) -> Unit +) { + SettingsSectionCard { + ThemePreferenceSelector( + themePreference = themePreference, + isDarkTheme = isDarkTheme, + buttonHapticFeedbackEnabled = buttonHapticFeedbackEnabled, + onThemePreferenceChange = onThemePreferenceChange + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + HapticFeedbackSettings( + enabled = buttonHapticFeedbackEnabled, + onEnabledChange = onButtonHapticFeedbackEnabledChange + ) + + Spacer(modifier = Modifier.height(16.dp)) + + SettingsSectionCard { + Row(verticalAlignment = Alignment.CenterVertically) { + SettingsIconBox(icon = Icons.Default.Language) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = "语言", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + } + Spacer(modifier = Modifier.height(16.dp)) + ExposedDropdownMenuBox( + expanded = dropdownExpanded, + onExpandedChange = { onDropdownExpandedChange(!dropdownExpanded) } + ) { + OutlinedTextField( + value = selectedLanguage, + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropdownExpanded) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline + ) + ) + ExposedDropdownMenu( + expanded = dropdownExpanded, + onDismissRequest = { onDropdownExpandedChange(false) } + ) { + languages.forEach { language -> + DropdownMenuItem( + text = { Text(language) }, + onClick = { + onLanguageSelected(language) + onDropdownExpandedChange(false) + } + ) + } + } + } + } +} + +@Composable +private fun HapticFeedbackSettings( + enabled: Boolean, + onEnabledChange: (Boolean) -> Unit +) { + SettingsSectionCard { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable( + onClick = rememberHapticClick(enabled) { + onEnabledChange(!enabled) + } + ) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + SettingsIconBox(icon = Icons.Default.Vibration) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "点击触感反馈", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "播放器和设置页按钮点击时提供轻微反馈", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = enabled, + onCheckedChange = null + ) + } + } +} + +@Composable +private fun ThemePreferenceSelector( + themePreference: ThemePreference, + isDarkTheme: Boolean, + buttonHapticFeedbackEnabled: Boolean, + onThemePreferenceChange: (ThemePreference) -> Unit +) { + Row(verticalAlignment = Alignment.CenterVertically) { + SettingsIconBox(icon = if (isDarkTheme) Icons.Default.DarkMode else Icons.Default.LightMode) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "显示模式", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = when (themePreference) { + ThemePreference.SYSTEM -> if (isDarkTheme) "跟随系统,当前为深色" else "跟随系统,当前为浅色" + ThemePreference.LIGHT -> "固定使用浅色" + ThemePreference.DARK -> "固定使用深色" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + ThemePreferenceButton( + text = "跟随系统", + selected = themePreference == ThemePreference.SYSTEM, + buttonHapticFeedbackEnabled = buttonHapticFeedbackEnabled, + onClick = { onThemePreferenceChange(ThemePreference.SYSTEM) }, + modifier = Modifier.weight(1f) + ) + ThemePreferenceButton( + text = "浅色", + selected = themePreference == ThemePreference.LIGHT, + buttonHapticFeedbackEnabled = buttonHapticFeedbackEnabled, + onClick = { onThemePreferenceChange(ThemePreference.LIGHT) }, + modifier = Modifier.weight(1f) + ) + ThemePreferenceButton( + text = "深色", + selected = themePreference == ThemePreference.DARK, + buttonHapticFeedbackEnabled = buttonHapticFeedbackEnabled, + onClick = { onThemePreferenceChange(ThemePreference.DARK) }, + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +private fun ThemePreferenceButton( + text: String, + selected: Boolean, + buttonHapticFeedbackEnabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + if (selected) { + Button( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled, onClick = onClick), + modifier = modifier, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) { + Text(text = text, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } else { + OutlinedButton( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled, onClick = onClick), + modifier = modifier, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 10.dp), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) { + Text(text = text, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} + +@Composable +private fun PlaybackSettings( + loopLimitText: String, + onLoopLimitTextChange: (String) -> Unit, + loopLimitError: String?, + loopLimitSavedMessage: String?, + maxLoopLimit: Int, + parsedLoopLimit: Int?, + buttonHapticFeedbackEnabled: Boolean, + onSaveLoopLimit: (Int) -> Unit +) { + SettingsSectionCard { + Row(verticalAlignment = Alignment.CenterVertically) { + SettingsIconBox(icon = Icons.Default.RepeatOne) + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text( + text = "无缝循环行为", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "控制单曲循环达到指定次数后的调度方式", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(18.dp)) + + OutlinedTextField( + value = loopLimitText, + onValueChange = onLoopLimitTextChange, + modifier = Modifier.fillMaxWidth(), + label = { Text("单曲无缝循环次数上限") }, + singleLine = true, + isError = loopLimitError != null, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + supportingText = { + Text( + text = loopLimitError + ?: loopLimitSavedMessage + ?: "0 表示无限循环;最大值:$maxLoopLimit", + color = when { + loopLimitError != null -> MaterialTheme.colorScheme.error + loopLimitSavedMessage != null -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + ) + }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline, + errorBorderColor = MaterialTheme.colorScheme.error + ) + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Button( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled) { parsedLoopLimit?.let(onSaveLoopLimit) }, + enabled = loopLimitError == null && parsedLoopLimit != null, + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(vertical = 12.dp) + ) { + Icon( + imageVector = Icons.Default.Save, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("保存循环次数设置", fontWeight = FontWeight.Bold) + } + } +} + +@Composable +private fun DataSettings( + onClosePage: () -> Unit, + onRescan: (Context) -> Unit, + onSyncPc: () -> Unit, + onExportDatabase: () -> Unit, + buttonHapticFeedbackEnabled: Boolean +) { + val context = LocalContext.current + + SettingsSectionCard { + Row(verticalAlignment = Alignment.CenterVertically) { + SettingsIconBox(icon = Icons.Default.Sync) + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text( + text = "数据同步与管理", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "扫描媒体库,导入或导出 PC 端数据库", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + Button( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled) { + onClosePage() + onRescan(context) + }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary), + contentPadding = PaddingValues(vertical = 12.dp) + ) { + Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("重新扫描库音乐", fontWeight = FontWeight.Bold) + } + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedButton( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled) { + onClosePage() + onSyncPc() + }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.primary), + contentPadding = PaddingValues(vertical = 12.dp) + ) { + Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("导入 PC 端数据库", fontWeight = FontWeight.Bold) + } + + Spacer(modifier = Modifier.height(12.dp)) + OutlinedButton( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled) { + onClosePage() + onExportDatabase() + }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.primary), + contentPadding = PaddingValues(vertical = 12.dp) + ) { + Icon(Icons.Default.CloudUpload, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("导出 PC 端数据库", fontWeight = FontWeight.Bold) + } + } +} + +@Composable +private fun GitHubSyncSettings( + githubSyncState: GitHubSyncUiState, + buttonHapticFeedbackEnabled: Boolean, + onGitHubAutoSyncEnabledChange: (Boolean) -> Unit, + onSaveGitHubSyncConfig: (token: String, owner: String, repo: String, branch: String, path: String) -> Unit, + onClearGitHubSyncConfig: () -> Unit, + onRunGitHubSync: () -> Unit, + onRefreshSyncDataManagementPreview: () -> Unit, + onLoadPlaybackStatsSourceDevices: () -> Unit, + onDeletePlaybackStatsSourceDeviceHistories: (Set) -> Unit, + onSeedCloudFromLocal: () -> Unit, + onDeleteCloudSnapshot: () -> Unit, + onClearLocalSyncData: ( + clearPlaylists: Boolean, + clearLoopPoints: Boolean, + clearRatings: Boolean, + clearListenStats: Boolean + ) -> Unit +) { + var token by rememberSaveable(githubSyncState.isConfigured, githubSyncState.hasToken) { mutableStateOf("") } + var owner by rememberSaveable(githubSyncState.owner) { mutableStateOf(githubSyncState.owner) } + var repo by rememberSaveable(githubSyncState.repo) { mutableStateOf(githubSyncState.repo) } + var branch by rememberSaveable(githubSyncState.branch) { mutableStateOf(githubSyncState.branch) } + var path by rememberSaveable(githubSyncState.path) { mutableStateOf(githubSyncState.path) } + var showClearDialog by remember { mutableStateOf(false) } + var showSeedCloudDialog by remember { mutableStateOf(false) } + var showDeleteCloudDialog by remember { mutableStateOf(false) } + var showClearLocalDialog by remember { mutableStateOf(false) } + var showDeleteSourceDevicesDialog by remember { mutableStateOf(false) } + var clearLocalPlaylists by remember { mutableStateOf(false) } + var clearLocalLoopPoints by remember { mutableStateOf(false) } + var clearLocalRatings by remember { mutableStateOf(false) } + var clearLocalListenStats by remember { mutableStateOf(false) } + var selectedSourceDeviceIds by remember { mutableStateOf(setOf()) } + + val missingConfigFields = owner.isBlank() || repo.isBlank() || branch.isBlank() || path.isBlank() + val saveEnabled = !missingConfigFields && (githubSyncState.hasToken || token.isNotBlank()) + val managementActionEnabled = githubSyncState.isConfigured && + githubSyncState.hasToken && + !githubSyncState.isManagementLoading && + !githubSyncState.isManagementOperationRunning + val clearLocalActionEnabled = !githubSyncState.isManagementLoading && + !githubSyncState.isManagementOperationRunning + val clearLocalSelectionValid = + clearLocalPlaylists || clearLocalLoopPoints || clearLocalRatings || clearLocalListenStats + val autoSyncToggleEnabled = githubSyncState.canEnableAutoSync || githubSyncState.isAutoSyncEnabled + val lastSyncText = remember(githubSyncState.lastSyncTime) { + if (githubSyncState.lastSyncTime > 0L) { + DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT) + .format(Date(githubSyncState.lastSyncTime)) + } else { + null + } + } + val report = githubSyncState.lastReport + val managementPreview = githubSyncState.managementPreview + val cloudPreview = managementPreview?.cloud + val sourceDevicePresentation = remember(githubSyncState.playbackStatsSourceDevices) { + playbackStatsSourceDevicePresentation(githubSyncState.playbackStatsSourceDevices) + } + val activePlaybackStatsSourceDevices = sourceDevicePresentation.activeSources + fun resetClearLocalSelections() { + clearLocalPlaylists = false + clearLocalLoopPoints = false + clearLocalRatings = false + clearLocalListenStats = false + } + fun resetSourceDeviceSelections() { + selectedSourceDeviceIds = emptySet() + } + val cloudExportedAtText = remember(cloudPreview?.exportedAt) { + val exportedAt = cloudPreview?.exportedAt ?: 0L + if (exportedAt > 0L) { + DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT) + .format(Date(exportedAt)) + } else { + null + } + } + + LaunchedEffect(Unit) { + onLoadPlaybackStatsSourceDevices() + } + + if (showClearDialog) { + AlertDialog( + onDismissRequest = { showClearDialog = false }, + title = { Text("清除 GitHub 配置") }, + text = { Text("这会移除当前仓库配置和已保存的 Token。") }, + confirmButton = { + TextButton( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled) { + onClearGitHubSyncConfig() + token = "" + showClearDialog = false + } + ) { + Text("清除", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled) { + showClearDialog = false + } + ) { + Text("取消") + } + } + ) + } + + if (showSeedCloudDialog) { + AlertDialog( + onDismissRequest = { showSeedCloudDialog = false }, + title = { Text("初始化云端同步") }, + text = { Text("仅当云端同步文件不存在时,才会用当前本机数据创建它。云端已有文件时请使用普通同步,或先删除云端文件。") }, + confirmButton = { + TextButton( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled) { + onSeedCloudFromLocal() + showSeedCloudDialog = false + } + ) { + Text("创建") + } + }, + dismissButton = { + TextButton( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled) { + showSeedCloudDialog = false + } + ) { + Text("取消") + } + } + ) + } + + if (showDeleteCloudDialog) { + AlertDialog( + onDismissRequest = { showDeleteCloudDialog = false }, + title = { Text("删除云端同步文件") }, + text = { Text("这会删除 GitHub 上的同步文件,本机数据会保留。") }, + confirmButton = { + TextButton( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled) { + onDeleteCloudSnapshot() + showDeleteCloudDialog = false + } + ) { + Text("删除", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled) { + showDeleteCloudDialog = false + } + ) { + Text("取消") + } + } + ) + } + + if (showClearLocalDialog) { + AlertDialog( + onDismissRequest = { + showClearLocalDialog = false + resetClearLocalSelections() + }, + title = { Text("清理本机数据") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("所选内容将从本机清除。歌单、循环点和评分可能在后续同步中恢复;播放统计会同步永久删除标记,不会恢复已清理的代际。") + LocalDataOptionRow( + checked = clearLocalPlaylists, + label = "歌单", + buttonHapticFeedbackEnabled = buttonHapticFeedbackEnabled, + onCheckedChange = { clearLocalPlaylists = it } + ) + LocalDataOptionRow( + checked = clearLocalLoopPoints, + label = "循环点", + buttonHapticFeedbackEnabled = buttonHapticFeedbackEnabled, + onCheckedChange = { clearLocalLoopPoints = it } + ) + LocalDataOptionRow( + checked = clearLocalRatings, + label = "评分", + buttonHapticFeedbackEnabled = buttonHapticFeedbackEnabled, + onCheckedChange = { clearLocalRatings = it } + ) + LocalDataOptionRow( + checked = clearLocalListenStats, + label = "播放统计", + buttonHapticFeedbackEnabled = buttonHapticFeedbackEnabled, + onCheckedChange = { clearLocalListenStats = it } + ) + } + }, + confirmButton = { + TextButton( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled) { + onClearLocalSyncData( + clearLocalPlaylists, + clearLocalLoopPoints, + clearLocalRatings, + clearLocalListenStats + ) + showClearLocalDialog = false + resetClearLocalSelections() + }, + enabled = clearLocalSelectionValid + ) { + Text("清理", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled) { + showClearLocalDialog = false + resetClearLocalSelections() + } + ) { + Text("取消") + } + } + ) + } + + if (showDeleteSourceDevicesDialog) { + AlertDialog( + onDismissRequest = { + showDeleteSourceDevicesDialog = false + resetSourceDeviceSelections() + }, + title = { Text("删除来源设备历史") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("所选设备的播放统计历史将通过 tombstone 标记失效;当前设备会切换到新的空 generation。") + activePlaybackStatsSourceDevices.forEach { source -> + SourceDeviceOptionRow( + checked = source.deviceId in selectedSourceDeviceIds, + source = source, + buttonHapticFeedbackEnabled = buttonHapticFeedbackEnabled, + onCheckedChange = { checked -> + selectedSourceDeviceIds = if (checked) { + selectedSourceDeviceIds + source.deviceId + } else { + selectedSourceDeviceIds - source.deviceId + } + } + ) + } + } + }, + confirmButton = { + TextButton( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled) { + onDeletePlaybackStatsSourceDeviceHistories(selectedSourceDeviceIds) + showDeleteSourceDevicesDialog = false + resetSourceDeviceSelections() + }, + enabled = selectedSourceDeviceIds.isNotEmpty() + ) { + Text("删除", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled) { + showDeleteSourceDevicesDialog = false + resetSourceDeviceSelections() + } + ) { + Text("取消") + } + } + ) + } + + SettingsSectionCard { + Row(verticalAlignment = Alignment.CenterVertically) { + SettingsIconBox(icon = Icons.Default.CloudUpload) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = if (githubSyncState.isConfigured) "已连接 GitHub 仓库" else "尚未配置 GitHub 同步", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = when { + githubSyncState.isSyncing -> "正在同步歌单、循环点与评分" + githubSyncState.isConfigured -> "${githubSyncState.owner}/${githubSyncState.repo}" + else -> "保存仓库信息后可手动同步" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + + if (lastSyncText != null) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "上次同步:$lastSyncText", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + + if (githubSyncState.statusMessage.isNotBlank()) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = githubSyncState.statusMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + } + + if (githubSyncState.errorMessage.isNotBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = githubSyncState.errorMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + SettingsSectionCard { + Row(verticalAlignment = Alignment.CenterVertically) { + SettingsIconBox(icon = Icons.Default.Sync) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "仓库配置", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = if (githubSyncState.hasToken) "Token 留空时保留当前已保存值" else "首次保存需要填写 Token", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(18.dp)) + + OutlinedTextField( + value = token, + onValueChange = { token = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("GitHub Token") }, + visualTransformation = PasswordVisualTransformation(), + singleLine = true, + supportingText = { + Text(if (githubSyncState.hasToken) "留空则保持已保存 Token" else "未检测到已保存 Token") + }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline + ) + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = owner, + onValueChange = { owner = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Owner") }, + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline + ) + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = repo, + onValueChange = { repo = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Repository") }, + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline + ) + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = branch, + onValueChange = { branch = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Branch") }, + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline + ) + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = path, + onValueChange = { path = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Path") }, + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline + ) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled) { + onSaveGitHubSyncConfig( + token.trim(), + owner.trim(), + repo.trim(), + branch.trim(), + path.trim() + ) + token = "" + }, + enabled = saveEnabled, + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(vertical = 12.dp) + ) { + Icon(Icons.Default.Save, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("保存同步配置", fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + + Spacer(modifier = Modifier.height(12.dp)) - Column( - modifier = modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surface) - .statusBarsPadding() - .verticalScroll(rememberScrollState()) - ) { - // --- 顶部标题与返回控制 --- Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), + .clip(RoundedCornerShape(12.dp)) + .clickable( + enabled = autoSyncToggleEnabled, + onClick = rememberHapticClick(buttonHapticFeedbackEnabled) { + onGitHubAutoSyncEnabledChange(!githubSyncState.isAutoSyncEnabled) + } + ) + .padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically ) { - IconButton(onClick = onClose) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "关闭", - tint = MaterialTheme.colorScheme.onSurfaceVariant + Column(modifier = Modifier.weight(1f)) { + Text( + text = "自动同步", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = when { + githubSyncState.isAutoSyncEnabled && githubSyncState.canEnableAutoSync -> "网络可用时在后台约每小时同步一次" + githubSyncState.isAutoSyncEnabled -> "配置不完整,自动同步已暂停" + !githubSyncState.canEnableAutoSync -> "配置 Token 和仓库后可开启" + else -> "关闭时仅手动同步" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } - - Spacer(modifier = Modifier.width(12.dp)) - - Text( - text = "设置", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface + Switch( + checked = githubSyncState.isAutoSyncEnabled, + onCheckedChange = if (autoSyncToggleEnabled) onGitHubAutoSyncEnabledChange else null, + enabled = autoSyncToggleEnabled ) } - Divider( - thickness = 1.dp, - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - modifier = Modifier.padding(horizontal = 16.dp) - ) + Spacer(modifier = Modifier.height(12.dp)) - Spacer(modifier = Modifier.height(16.dp)) + OutlinedButton( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled, onClick = onRunGitHubSync), + enabled = githubSyncState.isConfigured && !githubSyncState.isSyncing, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.primary), + contentPadding = PaddingValues(vertical = 12.dp) + ) { + Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("立即同步", fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + } - // --- 1. 语言设置(壳子) --- - Card( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - ) + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedButton( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled) { + showClearDialog = true + }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + contentPadding = PaddingValues(vertical = 12.dp) ) { - Column( - modifier = Modifier.padding(16.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Language, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp) - ) - Spacer(modifier = Modifier.width(12.dp)) - Text( - text = "语言与显示", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - } + Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("清除配置", fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(16.dp)) - ExposedDropdownMenuBox( - expanded = dropdownExpanded, - onExpandedChange = { dropdownExpanded = !dropdownExpanded } - ) { - OutlinedTextField( - value = selectedLanguage, - onValueChange = {}, - readOnly = true, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropdownExpanded) }, - modifier = Modifier - .fillMaxWidth() - .menuAnchor(), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.outline - ) - ) - ExposedDropdownMenu( - expanded = dropdownExpanded, - onDismissRequest = { dropdownExpanded = false } - ) { - languages.forEach { selectionOption -> - DropdownMenuItem( - text = { Text(text = selectionOption) }, - onClick = { - selectedLanguage = selectionOption - dropdownExpanded = false - } - ) - } - } - } - - Spacer(modifier = Modifier.height(8.dp)) - + SettingsSectionCard { + Row(verticalAlignment = Alignment.CenterVertically) { + SettingsIconBox(icon = Icons.Default.CloudDownload) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "数据管理", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) Text( - text = "* 目前仅支持简体中文喵,更多语言敬请期待 (´w`)", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + text = when { + githubSyncState.isManagementOperationRunning -> "正在处理同步数据" + githubSyncState.isManagementLoading -> "正在刷新数据预览" + else -> "查看本机与云端同步数据摘要" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis ) } } Spacer(modifier = Modifier.height(16.dp)) - // --- 2. 播放行为设置 --- - Card( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - ) + OutlinedButton( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled, onClick = onRefreshSyncDataManagementPreview), + enabled = managementActionEnabled, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.primary), + contentPadding = PaddingValues(vertical = 12.dp) ) { - Column( - modifier = Modifier.padding(16.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.RepeatOne, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp) + Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("刷新数据预览", fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + + managementPreview?.let { preview -> + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "本机", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(8.dp)) + SyncDataSummaryText(summary = preview.local) + + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "云端", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(8.dp)) + + when { + preview.cloud == null -> { + Text( + text = "尚未获取云端摘要", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(modifier = Modifier.width(12.dp)) + } + !preview.cloud.exists -> { Text( - text = "无缝循环行为", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, + text = "云端同步文件不存在", + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface ) } - - Spacer(modifier = Modifier.height(16.dp)) - - OutlinedTextField( - value = loopLimitText, - onValueChange = { newValue -> - loopLimitText = newValue - loopLimitSavedMessage = null - }, - modifier = Modifier.fillMaxWidth(), - label = { Text("单曲无缝循环次数上限") }, - singleLine = true, - isError = loopLimitError != null, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - supportingText = { + else -> { + cloudExportedAtText?.let { Text( - text = loopLimitError - ?: loopLimitSavedMessage - ?: "0 表示无限循环;达到上限后自动切换下一首。最大值:$maxLoopLimit", - color = when { - loopLimitError != null -> MaterialTheme.colorScheme.error - loopLimitSavedMessage != null -> MaterialTheme.colorScheme.primary - else -> MaterialTheme.colorScheme.onSurfaceVariant - } + text = "导出时间:$it", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface ) - }, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.outline, - errorBorderColor = MaterialTheme.colorScheme.error + Spacer(modifier = Modifier.height(8.dp)) + } + Text( + text = "歌单 ${preview.cloud.playlists},项目 ${preview.cloud.playlistItems}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface ) - ) - - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "循环点 ${preview.cloud.loopPointCount},评分 ${preview.cloud.ratingCount}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "本机已匹配 ${preview.cloud.matchedSongReferenceCount} 首云端引用歌曲", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "本机未找到 ${preview.cloud.missingSongReferenceCount} 首云端引用歌曲", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } - Button( - onClick = { - parsedLoopLimit?.let { value -> - onSeamlessLoopCountLimitChange(value) - loopLimitSavedMessage = if (value == 0) { - "已保存:无限循环" - } else { - "已保存:循环 $value 次后切换下一首" - } - } + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "播放统计来源", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(8.dp)) + if (activePlaybackStatsSourceDevices.isEmpty() && sourceDevicePresentation.historicalSourceCount == 0) { + Text( + text = "暂无来源设备记录", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + activePlaybackStatsSourceDevices.forEach { source -> + PlaybackStatsSourceDeviceRow(source = source) + Spacer(modifier = Modifier.height(8.dp)) + } + if (sourceDevicePresentation.historicalSourceCount > 0) { + if (activePlaybackStatsSourceDevices.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + } + DeletedPlaybackStatsSourcesRow( + count = sourceDevicePresentation.historicalSourceCount + ) + } + if (activePlaybackStatsSourceDevices.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled) { + resetSourceDeviceSelections() + showDeleteSourceDevicesDialog = true }, - enabled = loopLimitError == null && parsedLoopLimit != null, + enabled = clearLocalActionEnabled, modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), contentPadding = PaddingValues(vertical = 12.dp) ) { - Icon( - imageVector = Icons.Default.Save, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) + Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(modifier = Modifier.width(8.dp)) - Text("保存循环次数设置", fontWeight = FontWeight.Bold) + Text("删除来源设备历史", fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) } } } - Spacer(modifier = Modifier.height(16.dp)) + if (githubSyncState.managementStatusMessage.isNotBlank()) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = githubSyncState.managementStatusMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + } - // --- 3. 数据与同步中心 --- - Card( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + if (githubSyncState.managementErrorMessage.isNotBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = githubSyncState.managementErrorMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedButton( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled) { + showSeedCloudDialog = true + }, + enabled = managementActionEnabled, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.primary), + contentPadding = PaddingValues(vertical = 12.dp) ) { - Column( - modifier = Modifier.padding(16.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Sync, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp) - ) - Spacer(modifier = Modifier.width(12.dp)) + Icon(Icons.Default.CloudUpload, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("用本机数据初始化云端", fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedButton( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled) { + showDeleteCloudDialog = true + }, + enabled = managementActionEnabled, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + contentPadding = PaddingValues(vertical = 12.dp) + ) { + Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("删除云端同步文件", fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedButton( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled) { + resetClearLocalSelections() + showClearLocalDialog = true + }, + enabled = clearLocalActionEnabled, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + contentPadding = PaddingValues(vertical = 12.dp) + ) { + Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("清理本机数据", fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + + if (report != null) { + Spacer(modifier = Modifier.height(16.dp)) + + SettingsSectionCard { + Row(verticalAlignment = Alignment.CenterVertically) { + SettingsIconBox(icon = Icons.Default.CloudDownload) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { Text( - text = "数据同步与管理", + text = "最近一次同步结果", style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface ) - } - - Spacer(modifier = Modifier.height(20.dp)) - - // 重新扫描按钮 - Button( - onClick = { - onClose() - onRescan() - }, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ), - contentPadding = PaddingValues(vertical = 12.dp) - ) { - Icon( - imageVector = Icons.Default.Refresh, - contentDescription = null, - modifier = Modifier.size(18.dp) + Text( + text = "冲突 ${report.conflicts.size} 项", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(modifier = Modifier.width(8.dp)) - Text("重新扫描库音乐", fontWeight = FontWeight.Bold) } + } - Spacer(modifier = Modifier.height(12.dp)) - - // PC 导入按钮 - OutlinedButton( - onClick = { - onClose() - onSyncPc() - }, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.primary - ), - contentPadding = PaddingValues(vertical = 12.dp) - ) { - Icon( - imageVector = Icons.Default.CloudDownload, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("导入 PC 端数据库", fontWeight = FontWeight.Bold) - } + Spacer(modifier = Modifier.height(16.dp)) - Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "歌单 ${report.playlistsUploaded} 上传 / ${report.playlistsDownloaded} 下载", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "循环点 ${report.loopPointsUploaded} 上传 / ${report.loopPointsDownloaded} 下载", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "评分 ${report.ratingsUploaded} 上传 / ${report.ratingsDownloaded} 下载", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + } +} - // PC 兼容数据库导出按钮 - // 注意:这里导出的不是手机 Room 原始库,而是转换后的 PC 端 3NF 数据库喵。 - // 用系统文件保存器选择导出位置,避免硬编码下载目录,也不用额外申请存储写权限。 - OutlinedButton( - onClick = { - onClose() - onExportDatabase() - }, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.primary - ), - contentPadding = PaddingValues(vertical = 12.dp) - ) { - Icon( - imageVector = Icons.Default.CloudUpload, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("导出 PC 端数据库", fontWeight = FontWeight.Bold) - } - - Spacer(modifier = Modifier.height(12.dp)) - +@Composable +private fun PlaybackStatsSourceDeviceRow(source: PlaybackStatsSourceDeviceSummary) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.24f), + shape = RoundedCornerShape(14.dp), + tonalElevation = 0.dp, + shadowElevation = 0.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + SettingsIconBox(icon = Icons.Default.Smartphone) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = source.fallbackLabel, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) Text( - text = "提示:重新扫描会自动发现设备上的所有音频文件喵!若有在电脑端编辑好的无缝循环数据,可以直接选择 PC 数据库文件进行同步;手机端修改过的循环点、评分和歌单也可以导出为 PC 端可识别的数据库喵。(๑•̀ㅂ•́)و✧", - style = MaterialTheme.typography.labelSmall, + text = buildString { + append(source.platform) + source.currentGeneration?.let { append(" Generation $it") } + if (source.isCurrentDevice) append(" 当前设备") + }, + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - lineHeight = 16.sp + maxLines = 2, + overflow = TextOverflow.Ellipsis ) } + Text( + text = formatPlaybackStatsSourceDuration(source.contributedListenMs), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) } + } +} - Spacer(modifier = Modifier.height(32.dp)) - - // --- 底部版权信息喵 --- - Column( +@Composable +private fun DeletedPlaybackStatsSourcesRow(count: Int) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.24f), + shape = RoundedCornerShape(14.dp), + tonalElevation = 0.dp, + shadowElevation = 0.dp + ) { + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically ) { + SettingsIconBox(icon = Icons.Default.Delete) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = "已删除的来源(${count} 台)", + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + } + } +} + +@Composable +private fun SourceDeviceOptionRow( + checked: Boolean, + source: PlaybackStatsSourceDeviceSummary, + buttonHapticFeedbackEnabled: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled) { + onCheckedChange(!checked) + } + ) + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox(checked = checked, onCheckedChange = null) + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( - text = "Seamless Loop Mobile v1.0", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + text = source.fallbackLabel, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface ) - Spacer(modifier = Modifier.height(4.dp)) Text( - text = "莱芙・泽诺为您倾情服务喵 (´w`)", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + text = buildString { + append(source.platform) + source.currentGeneration?.let { append(" Generation $it") } + if (source.isCurrentDevice) append(" 当前设备") + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } - - Spacer(modifier = Modifier.height(24.dp)) + Text( + text = formatPlaybackStatsSourceDuration(source.contributedListenMs), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +private fun formatPlaybackStatsSourceDuration(listenMs: Long): String { + val totalMinutes = (listenMs.coerceAtLeast(0L) / 60_000L) + val hours = totalMinutes / 60 + val minutes = totalMinutes % 60 + return if (hours > 0) { + "${hours}h ${minutes}m" + } else { + "${minutes}m" + } +} + +@Composable +private fun SyncDataSummaryText(summary: LocalSyncDataSummary) { + Text( + text = "歌曲 ${summary.songCount},歌单 ${summary.playlistCount},项目 ${summary.playlistItemCount}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "循环点 ${summary.loopPointCount},评分 ${summary.ratingCount}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) +} + +@Composable +private fun LocalDataOptionRow( + checked: Boolean, + label: String, + buttonHapticFeedbackEnabled: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable( + onClick = rememberHapticClick(buttonHapticFeedbackEnabled) { + onCheckedChange(!checked) + } + ) + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = checked, + onCheckedChange = null + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + } +} + +@Composable +private fun SettingsSectionCard(content: @Composable ColumnScope.() -> Unit) { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.34f) + ), + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 0.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + content = content + ) + } +} + +@Composable +private fun SettingsIconBox(icon: ImageVector) { + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(14.dp)) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + } +} + +@Composable +private fun FooterInfo() { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Seamless Loop Mobile v0.2.0", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "莱芙・泽诺为您倾情服务喵 (´w`)", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.4f), + lineHeight = 16.sp + ) } } + +private val SettingsPage.title: String + get() = when (this) { + SettingsPage.Appearance -> "外观" + SettingsPage.Playback -> "播放" + SettingsPage.Data -> "数据" + SettingsPage.Sync -> "GitHub 同步" + } + +private val SettingsPage.description: String + get() = when (this) { + SettingsPage.Appearance -> "语言、昼夜模式与触感反馈" + SettingsPage.Playback -> "无缝循环次数与播放行为" + SettingsPage.Data -> "扫描、导入和导出音乐数据" + SettingsPage.Sync -> "同步歌单、循环点与评分" + } + +private val SettingsPage.icon: ImageVector + get() = when (this) { + SettingsPage.Appearance -> Icons.Default.DarkMode + SettingsPage.Playback -> Icons.Default.RepeatOne + SettingsPage.Data -> Icons.Default.Sync + SettingsPage.Sync -> Icons.Default.CloudUpload + } diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/songlist/SongListScreen.kt b/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/songlist/SongListScreen.kt index 1edba3d..f8049cc 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/songlist/SongListScreen.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/songlist/SongListScreen.kt @@ -67,14 +67,14 @@ fun SongListScreen( ) Spacer(modifier = Modifier.height(16.dp)) Text( - text = "今天想在乐库里寻找什么曲子呢?", + text = "搜索歌曲、艺人或专辑", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.Medium ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = "在这里打字,莱芙会自动为您防抖寻找喵 (´w`)", + text = "输入关键词即可开始搜索", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) ) @@ -88,22 +88,22 @@ fun SongListScreen( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { - Text( - text = "ㅠㅠ", - fontSize = 32.sp, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) + Icon( + imageVector = Icons.Default.SearchOff, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.4f), + modifier = Modifier.size(48.dp) ) Spacer(modifier = Modifier.height(16.dp)) Text( - text = "没有找到相符的歌曲喵", + text = "没有找到匹配的内容", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = "要不要换个关键词再试试看呢 (´w`)...", + text = "请尝试其他关键词", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -113,7 +113,8 @@ fun SongListScreen( LazyColumn( modifier = Modifier.fillMaxSize(), state = listState, - contentPadding = PaddingValues(bottom = 80.dp) // 预留底部 MiniPlayer 避让距离喵 + contentPadding = PaddingValues(top = 4.dp, bottom = 176.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) ) { items(songs, key = { it.id }) { song -> val isPlaying = song.filePath == currentPlayingSongPath diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/stats/PlaybackStatsScreen.kt b/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/stats/PlaybackStatsScreen.kt new file mode 100644 index 0000000..dd482e6 --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/stats/PlaybackStatsScreen.kt @@ -0,0 +1,513 @@ +package com.cpu.seamlessloopmobile.ui.screen.stats + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.defaultMinSize +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.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.WarningAmber +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.cpu.seamlessloopmobile.data.stats.ListenStatsRepository +import com.cpu.seamlessloopmobile.data.stats.ListenStatsPeriod +import com.cpu.seamlessloopmobile.data.stats.TrackStat +import com.cpu.seamlessloopmobile.ui.components.common.SongArtwork +import com.cpu.seamlessloopmobile.utils.rememberHapticClick +import java.io.File +import java.time.Duration +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import kotlinx.coroutines.delay + +private data class PeriodTrackStat( + val stat: TrackStat, + val listenMs: Long +) + +internal fun canNavigateToPlaybackStat(stat: TrackStat): Boolean = stat.songId > 0L + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PlaybackStatsScreen( + repository: ListenStatsRepository, + buttonHapticFeedbackEnabled: Boolean = true, + onTrackClick: (TrackStat) -> Unit = {}, + onBack: () -> Unit +) { + val allStats by repository.allStats.collectAsState() + var selectedPeriod by remember { mutableStateOf(ListenStatsPeriod.ALL) } + var today by remember { mutableStateOf(LocalDate.now(ZoneId.systemDefault())) } + LaunchedEffect(Unit) { + while (true) { + val zone = ZoneId.systemDefault() + val now = Instant.now() + val nextMidnight = LocalDate.now(zone).plusDays(1).atStartOfDay(zone).toInstant() + val delayMillis = Duration.between(now, nextMidnight) + .toMillis() + .coerceIn(1L, 15 * 60 * 1000L) + delay(delayMillis) + today = LocalDate.now(ZoneId.systemDefault()) + } + } + val sortedStats = remember(allStats, selectedPeriod, today) { + allStats + .mapNotNull { stat -> + stat.listenMsFor(selectedPeriod, today) + .takeIf { it > 0L } + ?.let { listenMs -> PeriodTrackStat(stat, listenMs) } + } + .sortedByDescending { it.listenMs } + } + val totalListenMs = remember(sortedStats) { + sortedStats.fold(0L) { total, periodStat -> + val listenMs = periodStat.listenMs.coerceAtLeast(0L) + if (total > Long.MAX_VALUE - listenMs) Long.MAX_VALUE else total + listenMs + } + } + Scaffold( + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + TopAppBar( + title = { Text("播放统计") }, + navigationIcon = { + IconButton(onClick = rememberHapticClick(buttonHapticFeedbackEnabled, onClick = onBack)) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(paddingValues), + contentPadding = PaddingValues(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 176.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { + PeriodSelector( + selectedPeriod = selectedPeriod, + onPeriodSelected = { selectedPeriod = it } + ) + } + + item { + OverviewSection(totalListenMs = totalListenMs, trackedSongsCount = sortedStats.size) + } + + if (sortedStats.isEmpty()) { + item { + EmptyStatsState(selectedPeriod) + } + } else { + item { + TopTracksBarChart( + stats = sortedStats.take(5) + ) + } + + item { + Text( + text = "最常听", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + } + + itemsIndexed( + sortedStats, + key = { _, it -> + val stat = it.stat + "${stat.identityKey}|${stat.songId}|${stat.filePath}|${stat.fileName}" + } + ) { index, periodStat -> + PlaybackStatRow( + rank = index + 1, + periodStat = periodStat, + buttonHapticFeedbackEnabled = buttonHapticFeedbackEnabled, + onClick = { onTrackClick(periodStat.stat) } + ) + } + } + } + } +} + +@Composable +private fun PeriodSelector( + selectedPeriod: ListenStatsPeriod, + onPeriodSelected: (ListenStatsPeriod) -> Unit +) { + val periods = remember { + listOf( + ListenStatsPeriod.DAY to "日", + ListenStatsPeriod.WEEK to "周", + ListenStatsPeriod.MONTH to "月", + ListenStatsPeriod.YEAR to "年", + ListenStatsPeriod.ALL to "总" + ) + } + + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + periods.forEachIndexed { index, (period, label) -> + SegmentedButton( + modifier = Modifier.weight(1f), + selected = period == selectedPeriod, + onClick = { onPeriodSelected(period) }, + shape = SegmentedButtonDefaults.itemShape(index = index, count = periods.size), + label = { Text(label, maxLines = 1) } + ) + } + } +} + +@Composable +private fun OverviewSection( + totalListenMs: Long, + trackedSongsCount: Int +) { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = RoundedCornerShape(20.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + OverviewMetric(label = "收听时长", value = formatListenDuration(totalListenMs)) + Box( + modifier = Modifier + .height(20.dp) + .width(1.dp) + .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + ) + OverviewMetric(label = "已追踪", value = "$trackedSongsCount 首") + } + } +} + +@Composable +private fun PlaybackStatRow( + rank: Int, + periodStat: PeriodTrackStat, + buttonHapticFeedbackEnabled: Boolean, + onClick: () -> Unit +) { + val stat = periodStat.stat + val canNavigate = canNavigateToPlaybackStat(stat) + val isStale = remember(stat.songId, stat.filePath) { + stat.songId <= 0L || stat.filePath.isBlank() || !File(stat.filePath).exists() + } + + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.24f), + shape = RoundedCornerShape(16.dp), + tonalElevation = 0.dp, + shadowElevation = 0.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .then( + if (canNavigate) { + Modifier.clickable(onClick = rememberHapticClick(buttonHapticFeedbackEnabled, onClick = onClick)) + } else { + Modifier + } + ) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = rank.toString(), + modifier = Modifier.width(24.dp), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + SongArtwork( + coverPath = stat.coverPath, + contentDescription = stat.displayName, + modifier = Modifier.size(40.dp), + shape = RoundedCornerShape(12.dp), + iconSize = 18.dp, + backgroundColor = MaterialTheme.colorScheme.surfaceVariant, + iconTint = MaterialTheme.colorScheme.primary + ) + Column( + modifier = Modifier + .weight(1f) + .padding(start = 10.dp, end = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = stat.displayName.ifBlank { stat.fileName.ifBlank { "未知歌曲" } }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = stat.artist.ifBlank { "Unknown Artist" }, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = formatListenDuration(periodStat.listenMs), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + if (isStale) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + Icon( + imageVector = Icons.Default.WarningAmber, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.error + ) + Text( + text = if (stat.songId <= 0L) "未绑定" else "文件缺失", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error + ) + } + } + } + } + } +} + +@Composable +private fun TopTracksBarChart(stats: List) { + if (stats.isEmpty()) return + + val maxListenMs = stats.maxOf { it.listenMs }.coerceAtLeast(1L) + + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.36f), + shape = RoundedCornerShape(20.dp) + ) + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + stats.forEachIndexed { index, periodStat -> + val stat = periodStat.stat + val fraction = (periodStat.listenMs.toFloat() / maxListenMs.toFloat()).coerceIn(0f, 1f) + val animatedFraction = animateFloatAsState( + targetValue = fraction, + animationSpec = tween(durationMillis = 500 + index * 90), + label = "top_track_bar_$index" + ) + + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "${index + 1}", + modifier = Modifier.width(18.dp), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.SemiBold + ) + SongArtwork( + coverPath = stat.coverPath, + contentDescription = stat.displayName, + modifier = Modifier.size(30.dp), + shape = RoundedCornerShape(10.dp), + iconSize = 14.dp, + backgroundColor = MaterialTheme.colorScheme.surface, + iconTint = MaterialTheme.colorScheme.primary + ) + Column( + modifier = Modifier + .weight(1f) + .padding(start = 8.dp, end = 8.dp) + ) { + Text( + text = stat.displayName.ifBlank { stat.fileName.ifBlank { "未知歌曲" } }, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Text( + text = formatListenDuration(periodStat.listenMs), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 + ) + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(7.dp) + .clip(RoundedCornerShape(999.dp)) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.7f)) + ) { + Box( + modifier = Modifier + .fillMaxWidth(animatedFraction.value) + .defaultMinSize(minWidth = 10.dp) + .height(7.dp) + .clip(RoundedCornerShape(999.dp)) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.75f)) + ) + } + } + } + } +} + +@Composable +private fun RowScope.OverviewMetric( + label: String, + value: String +) { + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = value, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1 + ) + } +} + +@Composable +private fun EmptyStatsState(selectedPeriod: ListenStatsPeriod) { + val title = when (selectedPeriod) { + ListenStatsPeriod.DAY -> "本日暂无收听记录" + ListenStatsPeriod.WEEK -> "本周暂无收听记录" + ListenStatsPeriod.MONTH -> "本月暂无收听记录" + ListenStatsPeriod.YEAR -> "今年暂无收听记录" + ListenStatsPeriod.ALL -> "还没有可显示的收听记录" + } + val description = if (selectedPeriod == ListenStatsPeriod.ALL) { + "开始播放后会在这里累计收听时长。" + } else { + "选择其他时间范围查看收听时长。" + } + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 48.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +private fun formatListenDuration(totalListenMs: Long): String { + if (totalListenMs <= 0L) return "0 秒" + + if (totalListenMs < 60_000L) { + val seconds = (totalListenMs / 1000L).coerceAtLeast(1L) + return "${seconds} 秒" + } + + val totalMinutes = totalListenMs / 60_000L + val days = totalMinutes / (24 * 60) + val hours = (totalMinutes % (24 * 60)) / 60 + val minutes = totalMinutes % 60 + + return buildList { + if (days > 0) add("${days} 天") + if (hours > 0) add("${hours} 小时") + if (minutes > 0 || isEmpty()) add("${minutes} 分钟") + }.joinToString(" ") +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/ui/theme/Color.kt b/app/src/main/java/com/cpu/seamlessloopmobile/ui/theme/Color.kt index c88b34a..986ee8f 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/ui/theme/Color.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/ui/theme/Color.kt @@ -2,28 +2,62 @@ package com.cpu.seamlessloopmobile.ui.theme import androidx.compose.ui.graphics.Color -// CPU 大人的专属精美配色板喵!(๑•̀ㅂ•́)و✧ -// 在这里统一管理所有的颜色,彻底告别硬编码! -object SeamlessLoopColors { - // 基础主题核心色喵 - val PurpleAccent = Color(0xFFBB86FC) - val PurpleDark = Color(0xFF3700B3) - val TealAccent = Color(0xFF03DAC6) - - // 播放面板专属酷炫深渐变色喵 - val DarkBgGradientStart = Color(0xFF1E1E2E) - val DarkBgGradientEnd = Color(0xFF2E2E3E) - - // 精细调节面板的卡片与按键背景色喵 - val ComponentDarkBg = Color(0xFF2D2D3D) - val ButtonDarkBg = Color(0xFF353545) - - // 循环微调起点 (A) 和终点 (B) 的独特高亮色喵 +object SeamlessLoopMaterialColors { + object Light { + val Primary = Color(0xFF6246A8) + val OnPrimary = Color(0xFFFFFFFF) + val PrimaryContainer = Color(0xFFEAE1FF) + val OnPrimaryContainer = Color(0xFF241A35) + val Secondary = Color(0xFF007F73) + val OnSecondary = Color(0xFFFFFFFF) + val SecondaryContainer = Color(0xFFB8F1E8) + val OnSecondaryContainer = Color(0xFF003731) + val Background = Color(0xFFFCF9FF) + val OnBackground = Color(0xFF1C1921) + val Surface = Color(0xFFFFFBFF) + val OnSurface = Color(0xFF1C1921) + val SurfaceVariant = Color(0xFFEAE7F2) + val OnSurfaceVariant = Color(0xFF635C6B) + val OutlineVariant = Color(0xFFCBC3D2) + val Error = Color(0xFFBA1A1A) + val OnError = Color(0xFFFFFFFF) + } + + object Dark { + val Primary = Color(0xFFD1BCFF) + val OnPrimary = Color(0xFF38206D) + val PrimaryContainer = Color(0xFF4C3584) + val OnPrimaryContainer = Color(0xFFEAE1FF) + val Secondary = Color(0xFF68D8C8) + val OnSecondary = Color(0xFF003731) + val SecondaryContainer = Color(0xFF005146) + val OnSecondaryContainer = Color(0xFFB8F1E8) + val Background = Color(0xFF14121A) + val OnBackground = Color(0xFFE8E1EA) + val Surface = Color(0xFF1C1922) + val OnSurface = Color(0xFFE8E1EA) + val SurfaceVariant = Color(0xFF36313E) + val OnSurfaceVariant = Color(0xFFCBC3D2) + val OutlineVariant = Color(0xFF494451) + val Error = Color(0xFFFFB4AB) + val OnError = Color(0xFF690005) + } +} + +object SeamlessLoopPlayerColors { + val GradientStart = Color(0xFF17131F) + val GradientEnd = Color(0xFF211A2C) + val Panel = Color(0xFF282132) + val Control = Color(0xFF342A43) + val Primary = Color(0xFFD1BCFF) + val PrimaryText = Color(0xFFF6F0FA) + val SecondaryText = Color(0xFFC9C0D0) + val TertiaryText = Color(0xFF9B92A3) + val Inactive = Color(0xFF847B8D) + val Track = Color(0xFF5A5163) + val ErrorContainer = Color(0xFF8C2D39) + val OnErrorContainer = Color(0xFFFFEDEA) + val LoopMarker = Color(0xFFFFD166) val PointAccentA = Color(0xFF8FBBD9) val PointAccentB = Color(0xFFF398AF) - - // 通用黑白灰辅助色喵 - val White = Color(0xFFFFFFFF) - val LightGray = Color(0xFFCCCCCC) - val Gray = Color(0xFF888888) } diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/ui/theme/Theme.kt b/app/src/main/java/com/cpu/seamlessloopmobile/ui/theme/Theme.kt index da8d003..c7fe8c1 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/ui/theme/Theme.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/ui/theme/Theme.kt @@ -12,28 +12,45 @@ import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat private val DarkColorScheme = darkColorScheme( - primary = SeamlessLoopColors.PurpleAccent, - secondary = SeamlessLoopColors.TealAccent, - background = SeamlessLoopColors.DarkBgGradientStart, - surface = SeamlessLoopColors.ComponentDarkBg, - onPrimary = SeamlessLoopColors.White, - onBackground = SeamlessLoopColors.White, - onSurface = SeamlessLoopColors.White + primary = SeamlessLoopMaterialColors.Dark.Primary, + onPrimary = SeamlessLoopMaterialColors.Dark.OnPrimary, + primaryContainer = SeamlessLoopMaterialColors.Dark.PrimaryContainer, + onPrimaryContainer = SeamlessLoopMaterialColors.Dark.OnPrimaryContainer, + secondary = SeamlessLoopMaterialColors.Dark.Secondary, + onSecondary = SeamlessLoopMaterialColors.Dark.OnSecondary, + secondaryContainer = SeamlessLoopMaterialColors.Dark.SecondaryContainer, + onSecondaryContainer = SeamlessLoopMaterialColors.Dark.OnSecondaryContainer, + background = SeamlessLoopMaterialColors.Dark.Background, + onBackground = SeamlessLoopMaterialColors.Dark.OnBackground, + surface = SeamlessLoopMaterialColors.Dark.Surface, + onSurface = SeamlessLoopMaterialColors.Dark.OnSurface, + surfaceVariant = SeamlessLoopMaterialColors.Dark.SurfaceVariant, + onSurfaceVariant = SeamlessLoopMaterialColors.Dark.OnSurfaceVariant, + outlineVariant = SeamlessLoopMaterialColors.Dark.OutlineVariant, + error = SeamlessLoopMaterialColors.Dark.Error, + onError = SeamlessLoopMaterialColors.Dark.OnError ) private val LightColorScheme = lightColorScheme( - primary = SeamlessLoopColors.PurpleAccent, - secondary = SeamlessLoopColors.TealAccent, - background = SeamlessLoopColors.White, - surface = SeamlessLoopColors.White, - onPrimary = SeamlessLoopColors.White, - onBackground = SeamlessLoopColors.DarkBgGradientEnd, - onSurface = SeamlessLoopColors.DarkBgGradientEnd + primary = SeamlessLoopMaterialColors.Light.Primary, + onPrimary = SeamlessLoopMaterialColors.Light.OnPrimary, + primaryContainer = SeamlessLoopMaterialColors.Light.PrimaryContainer, + onPrimaryContainer = SeamlessLoopMaterialColors.Light.OnPrimaryContainer, + secondary = SeamlessLoopMaterialColors.Light.Secondary, + onSecondary = SeamlessLoopMaterialColors.Light.OnSecondary, + secondaryContainer = SeamlessLoopMaterialColors.Light.SecondaryContainer, + onSecondaryContainer = SeamlessLoopMaterialColors.Light.OnSecondaryContainer, + background = SeamlessLoopMaterialColors.Light.Background, + onBackground = SeamlessLoopMaterialColors.Light.OnBackground, + surface = SeamlessLoopMaterialColors.Light.Surface, + onSurface = SeamlessLoopMaterialColors.Light.OnSurface, + surfaceVariant = SeamlessLoopMaterialColors.Light.SurfaceVariant, + onSurfaceVariant = SeamlessLoopMaterialColors.Light.OnSurfaceVariant, + outlineVariant = SeamlessLoopMaterialColors.Light.OutlineVariant, + error = SeamlessLoopMaterialColors.Light.Error, + onError = SeamlessLoopMaterialColors.Light.OnError ) -/** - * 莱芙为 CPU 大人量身定制的主题系统包装喵!(๑•̀ㅂ•́)و✧ - */ @Composable fun SeamlessLoopTheme( darkTheme: Boolean = isSystemInDarkTheme(), @@ -41,16 +58,14 @@ fun SeamlessLoopTheme( ) { val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme val view = LocalView.current - + if (!view.isInEditMode) { SideEffect { val window = (view.context as Activity).window - // 设置状态栏与导航栏颜色喵,保持整体视觉高度统一! window.statusBarColor = colorScheme.background.toArgb() window.navigationBarColor = colorScheme.background.toArgb() - + val windowInsetsController = WindowCompat.getInsetsController(window, view) - // 依据深浅色主题,灵动调整系统状态栏和导航栏文字的高亮模式喵 ^ㅅ^ windowInsetsController.isAppearanceLightStatusBars = !darkTheme windowInsetsController.isAppearanceLightNavigationBars = !darkTheme } @@ -58,6 +73,7 @@ fun SeamlessLoopTheme( MaterialTheme( colorScheme = colorScheme, + typography = SeamlessLoopTypography, content = content ) } diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/ui/theme/Type.kt b/app/src/main/java/com/cpu/seamlessloopmobile/ui/theme/Type.kt new file mode 100644 index 0000000..a36db0f --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/ui/theme/Type.kt @@ -0,0 +1,30 @@ +package com.cpu.seamlessloopmobile.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +private val DefaultTextStyle = TextStyle( + fontFamily = FontFamily.Default, + letterSpacing = 0.sp +) + +val SeamlessLoopTypography = Typography( + displayLarge = DefaultTextStyle.copy(fontSize = 44.sp, lineHeight = 52.sp, fontWeight = FontWeight.Bold), + displayMedium = DefaultTextStyle.copy(fontSize = 36.sp, lineHeight = 44.sp, fontWeight = FontWeight.Bold), + displaySmall = DefaultTextStyle.copy(fontSize = 30.sp, lineHeight = 38.sp, fontWeight = FontWeight.SemiBold), + headlineLarge = DefaultTextStyle.copy(fontSize = 26.sp, lineHeight = 34.sp, fontWeight = FontWeight.Bold), + headlineMedium = DefaultTextStyle.copy(fontSize = 23.sp, lineHeight = 30.sp, fontWeight = FontWeight.SemiBold), + headlineSmall = DefaultTextStyle.copy(fontSize = 20.sp, lineHeight = 28.sp, fontWeight = FontWeight.SemiBold), + titleLarge = DefaultTextStyle.copy(fontSize = 19.sp, lineHeight = 26.sp, fontWeight = FontWeight.SemiBold), + titleMedium = DefaultTextStyle.copy(fontSize = 16.sp, lineHeight = 23.sp, fontWeight = FontWeight.SemiBold), + titleSmall = DefaultTextStyle.copy(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.SemiBold), + bodyLarge = DefaultTextStyle.copy(fontSize = 16.sp, lineHeight = 24.sp, fontWeight = FontWeight.Normal), + bodyMedium = DefaultTextStyle.copy(fontSize = 14.sp, lineHeight = 21.sp, fontWeight = FontWeight.Normal), + bodySmall = DefaultTextStyle.copy(fontSize = 12.sp, lineHeight = 18.sp, fontWeight = FontWeight.Normal), + labelLarge = DefaultTextStyle.copy(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Medium), + labelMedium = DefaultTextStyle.copy(fontSize = 12.sp, lineHeight = 17.sp, fontWeight = FontWeight.Medium), + labelSmall = DefaultTextStyle.copy(fontSize = 11.sp, lineHeight = 16.sp, fontWeight = FontWeight.Medium) +) diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/utils/HapticFeedback.kt b/app/src/main/java/com/cpu/seamlessloopmobile/utils/HapticFeedback.kt new file mode 100644 index 0000000..fb35894 --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/utils/HapticFeedback.kt @@ -0,0 +1,30 @@ +package com.cpu.seamlessloopmobile.utils + +import android.view.HapticFeedbackConstants +import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalView + +val LocalButtonHapticFeedbackEnabled = staticCompositionLocalOf { true } + +@Composable +fun rememberHapticClick( + enabled: Boolean = LocalButtonHapticFeedbackEnabled.current, + hapticFeedbackType: Int = HapticFeedbackConstants.VIRTUAL_KEY, + onClick: () -> Unit +): () -> Unit { + val view = LocalView.current + val latestOnClick by rememberUpdatedState(onClick) + + return remember(view, enabled, hapticFeedbackType) { + { + if (enabled) { + view.performHapticFeedback(hapticFeedbackType) + } + latestOnClick() + } + } +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/viewmodel/GitHubSyncUiState.kt b/app/src/main/java/com/cpu/seamlessloopmobile/viewmodel/GitHubSyncUiState.kt new file mode 100644 index 0000000..fa27d8d --- /dev/null +++ b/app/src/main/java/com/cpu/seamlessloopmobile/viewmodel/GitHubSyncUiState.kt @@ -0,0 +1,35 @@ +package com.cpu.seamlessloopmobile.viewmodel + +import com.cpu.seamlessloopmobile.data.sync.SyncDataManagementPreview +import com.cpu.seamlessloopmobile.data.sync.SyncReport +import com.cpu.seamlessloopmobile.data.sync.PlaybackStatsSourceDeviceSummary + +/** + * GitHub 同步 UI 状态。 + * 不由 Sealed Class 封装,方便设置页各字段独立双向绑定。 + */ +data class GitHubSyncUiState( + val isConfigured: Boolean = false, + val hasToken: Boolean = false, + val isSyncing: Boolean = false, + val owner: String = "", + val repo: String = "", + val branch: String = "main", + val path: String = "seamless-loop/sync.json", + val lastSyncTime: Long = 0L, + val statusMessage: String = "", + val errorMessage: String = "", + val lastReport: SyncReport? = null, + // --- 自动同步 --- + val isAutoSyncEnabled: Boolean = false, + // --- 数据管理状态 --- + val isManagementLoading: Boolean = false, + val isManagementOperationRunning: Boolean = false, + val managementPreview: SyncDataManagementPreview? = null, + val playbackStatsSourceDevices: List = emptyList(), + val managementStatusMessage: String = "", + val managementErrorMessage: String = "" +) { + val canEnableAutoSync: Boolean + get() = isConfigured && hasToken +} diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/viewmodel/LibraryViewModel.kt b/app/src/main/java/com/cpu/seamlessloopmobile/viewmodel/LibraryViewModel.kt index 10bcc30..fc06864 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/viewmodel/LibraryViewModel.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/viewmodel/LibraryViewModel.kt @@ -21,7 +21,8 @@ import com.cpu.seamlessloopmobile.data.SettingsManager class LibraryViewModel( private val repository: com.cpu.seamlessloopmobile.data.MusicRepository, private val coroutineScope: kotlinx.coroutines.CoroutineScope, - private val settingsManager: SettingsManager? = null + private val settingsManager: SettingsManager? = null, + private val onScanCompleted: suspend () -> Unit = {} ) { private val _syncStatus = MutableStateFlow>(com.cpu.seamlessloopmobile.ui.state.DataUiState.Success("")) @@ -108,6 +109,7 @@ class LibraryViewModel( val scanResult = withContext(Dispatchers.IO) { repository.getInitialScannedSongs(context) } + onScanCompleted() val completionState = com.cpu.seamlessloopmobile.ui.state.DataUiState.Success(scanResult.statusMessage()) _syncStatus.value = completionState delay(2000L) diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/viewmodel/MainViewModel.kt b/app/src/main/java/com/cpu/seamlessloopmobile/viewmodel/MainViewModel.kt index 3545706..e995ed6 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/viewmodel/MainViewModel.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/viewmodel/MainViewModel.kt @@ -10,11 +10,25 @@ import com.cpu.seamlessloopmobile.model.Song import com.cpu.seamlessloopmobile.model.PlaylistDao import com.cpu.seamlessloopmobile.data.MusicRepository import com.cpu.seamlessloopmobile.data.SettingsManager +import com.cpu.seamlessloopmobile.data.ThemePreference +import com.cpu.seamlessloopmobile.data.sync.ClearLocalSyncDataSelection +import com.cpu.seamlessloopmobile.data.sync.GitHubAutoSyncScheduler +import com.cpu.seamlessloopmobile.data.sync.GitHubSyncConfig +import com.cpu.seamlessloopmobile.data.sync.GitHubSyncCoordinator +import com.cpu.seamlessloopmobile.data.sync.SharedPreferencesGitHubSyncStore +import com.cpu.seamlessloopmobile.data.sync.SyncDataManagementRepository +import com.cpu.seamlessloopmobile.data.sync.SyncDataManagementResult +import com.cpu.seamlessloopmobile.data.sync.SyncOutcome +import com.cpu.seamlessloopmobile.data.sync.SyncSnapshotSerializer +import com.cpu.seamlessloopmobile.data.sync.github.GitHubContentsSyncBackend +import com.cpu.seamlessloopmobile.data.sync.room.RoomSyncSnapshotStore +import com.cpu.seamlessloopmobile.data.sync.room.SharedPreferencesPlaylistIdMapper import com.cpu.seamlessloopmobile.jni.LoopPoint import com.cpu.seamlessloopmobile.jni.NativeAudio import com.google.gson.Gson import com.google.gson.reflect.TypeToken import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File @@ -38,6 +52,8 @@ enum class PlayMode { sealed class MusicUiState { object Home : MusicUiState() object Search : MusicUiState() + object Settings : MusicUiState() + object PlaybackStats : MusicUiState() // 一级分类展开 @Deprecated("Use Tab navigation instead") @@ -68,12 +84,593 @@ class MainViewModel( lateinit var selection: SelectionViewModel lateinit var playlist: PlaylistViewModel lateinit var loopDetection: LoopDetectionViewModel + + // GitHub 同步基础设施(由工厂注入) + lateinit var githubSyncStore: SharedPreferencesGitHubSyncStore + lateinit var playlistIdMapper: SharedPreferencesPlaylistIdMapper + lateinit var roomSyncSnapshotStore: RoomSyncSnapshotStore + lateinit var localSyncDataManagementRepository: SyncDataManagementRepository + + /** 数据管理仓库工厂 —— 由 [MainViewModelFactory] 注入,每次调用传入最新 [GitHubSyncConfig]。 */ + lateinit var syncDataManagementRepositoryFactory: (GitHubSyncConfig) -> SyncDataManagementRepository + + /** 自动同步调度器(由工厂注入)。 */ + lateinit var githubAutoSyncScheduler: GitHubAutoSyncScheduler + private var settingsManager: SettingsManager? = null init { // 莱芙现在变聪明了,不在构造函数里乱连天线了喵! } + // =================================================================== + // GitHub 同步 UI 状态 & 操作 + // =================================================================== + + private val _githubSyncState = MutableLiveData(GitHubSyncUiState()) + val githubSyncState: LiveData = _githubSyncState + + private fun updateGitHubSyncState( + transform: (GitHubSyncUiState) -> GitHubSyncUiState + ) { + val current = _githubSyncState.value ?: GitHubSyncUiState() + _githubSyncState.value = transform(current) + } + + /** + * 从持久化存储加载 GitHub 同步配置状态。 + * 被 [MainViewModelFactory] 在初始化后立即调用。 + */ + fun loadGitHubSyncState() { + viewModelScope.launch { + val config = githubSyncStore.getConfig() + val token = githubSyncStore.getToken() + val hasUsableToken = !token.isNullOrBlank() + val lastSyncTime = githubSyncStore.getLastSyncTime() + val isAutoSyncEnabled = githubSyncStore.isAutoSyncEnabled() + val current = _githubSyncState.value ?: GitHubSyncUiState() + _githubSyncState.postValue(current.copy( + isConfigured = config != null, + hasToken = hasUsableToken, + owner = config?.owner ?: "", + repo = config?.repo ?: "", + branch = config?.branch ?: "main", + path = config?.path ?: "seamless-loop/sync.json", + lastSyncTime = lastSyncTime, + isAutoSyncEnabled = isAutoSyncEnabled + )) + // 根据已持久化的状态同步 WorkManager 调度 + githubAutoSyncScheduler.reconcile(isAutoSyncEnabled && config != null && hasUsableToken) + } + } + + /** + * 保存 GitHub 同步配置及 token。 + * 允许 token 留空,此时保留已存储的 token(如有)。 + * @param token 新 token;空字符串表示不更改 + * @param owner 仓库所有者 + * @param repo 仓库名 + * @param branch 分支名 + * @param path 文件路径 + */ + fun saveGitHubSyncConfig( + token: String, + owner: String, + repo: String, + branch: String, + path: String + ) { + viewModelScope.launch { + val normalizedOwner = owner.trim() + val normalizedRepo = repo.trim() + val normalizedBranch = branch.trim() + val normalizedPath = path.trim() + + if (normalizedOwner.isBlank() || + normalizedRepo.isBlank() || + normalizedBranch.isBlank() || + normalizedPath.isBlank() + ) { + _githubSyncState.postValue( + _githubSyncState.value?.copy(errorMessage = "请填写完整的仓库信息") + ) + return@launch + } + + // token 为空时尝试保留现有 token + val effectiveToken = if (token.isBlank()) { + githubSyncStore.getToken() + } else { + token.trim() + } + + if (effectiveToken == null) { + _githubSyncState.postValue( + _githubSyncState.value?.copy(errorMessage = "请先填写 GitHub Token") + ) + return@launch + } + + githubSyncStore.saveToken(effectiveToken) + githubSyncStore.saveConfig( + GitHubSyncConfig( + owner = normalizedOwner, + repo = normalizedRepo, + branch = normalizedBranch, + path = normalizedPath + ) + ) + _githubSyncState.postValue( + _githubSyncState.value?.copy( + statusMessage = "GitHub 同步配置已保存", + errorMessage = "" + ) + ) + loadGitHubSyncState() + // 如果自动同步之前已开启,确保调度器在新配置下继续运行 + if (githubSyncStore.isAutoSyncEnabled()) { + githubAutoSyncScheduler.reconcile(true) + } + } + } + + /** 清除 GitHub 同步配置和 token,同时关闭自动同步并取消调度。 */ + fun clearGitHubSyncConfig() { + viewModelScope.launch { + githubSyncStore.clearToken() + githubSyncStore.clearConfig() + githubSyncStore.setAutoSyncEnabled(false) + githubAutoSyncScheduler.cancel() + _githubSyncState.postValue( + GitHubSyncUiState(statusMessage = "GitHub 同步配置已清除") + ) + } + } + + /** + * 设置自动同步开关。 + * + * 开启时会检查配置和 token 是否就绪,不满足条件则拒绝开启并设置错误信息。 + * 关闭时取消 WorkManager 调度。 + */ + fun setGitHubAutoSyncEnabled(enabled: Boolean) { + viewModelScope.launch { + if (enabled) { + // 开启前校验配置和 token 是否就绪 + val config = githubSyncStore.getConfig() + val token = githubSyncStore.getToken() + if (config == null || token.isNullOrBlank()) { + _githubSyncState.postValue( + _githubSyncState.value?.copy( + isAutoSyncEnabled = false, + errorMessage = "请先配置 GitHub Token 和仓库" + ) + ) + return@launch + } + } + + githubSyncStore.setAutoSyncEnabled(enabled) + githubAutoSyncScheduler.reconcile(enabled) + _githubSyncState.postValue( + _githubSyncState.value?.copy(isAutoSyncEnabled = enabled, errorMessage = "") + ) + } + } + + /** 执行一次 GitHub 同步。 */ + fun runGitHubSync() { + viewModelScope.launch { + _githubSyncState.postValue( + _githubSyncState.value?.copy( + isSyncing = true, + statusMessage = "正在同步 GitHub 数据...", + errorMessage = "" + ) + ) + + val config = githubSyncStore.getConfig() + val token = githubSyncStore.getToken() + + if (config == null || token == null) { + _githubSyncState.postValue( + _githubSyncState.value?.copy( + isSyncing = false, + errorMessage = "请先配置 GitHub Token 和仓库" + ) + ) + return@launch + } + + val backend = GitHubContentsSyncBackend( + config = config, + tokenProvider = githubSyncStore, + serializer = SyncSnapshotSerializer() + ) + + val coordinator = GitHubSyncCoordinator( + backend = backend, + snapshotStore = roomSyncSnapshotStore, + metadataStore = githubSyncStore + ) + + val outcome = try { + coordinator.syncNow() + } catch (e: Exception) { + _githubSyncState.postValue( + _githubSyncState.value?.copy( + isSyncing = false, + errorMessage = "GitHub 同步失败:${e.message ?: "未知错误"}" + ) + ) + return@launch + } + + when (outcome) { + is SyncOutcome.Success -> { + val lastSyncTime = githubSyncStore.getLastSyncTime() + _githubSyncState.postValue( + _githubSyncState.value?.copy( + isSyncing = false, + statusMessage = "GitHub 同步完成", + errorMessage = "", + lastSyncTime = lastSyncTime, + lastReport = outcome.report + ) + ) + } + is SyncOutcome.Failure -> { + _githubSyncState.postValue( + _githubSyncState.value?.copy( + isSyncing = false, + errorMessage = outcome.message + ) + ) + } + is SyncOutcome.LocalMutationDuringSync -> { + _githubSyncState.postValue( + _githubSyncState.value?.copy( + isSyncing = false, + errorMessage = "同步期间本地数据发生变化,请重试" + ) + ) + } + is SyncOutcome.Cancelled -> { + _githubSyncState.postValue( + _githubSyncState.value?.copy( + isSyncing = false, + statusMessage = "GitHub 同步已取消" + ) + ) + } + } + } + } + + // =================================================================== + // 同步数据管理操作 + // =================================================================== + + /** + * 构建 [SyncDataManagementRepository] 实例。 + * 如果 GitHub 未配置(无 config 或 token), + * 设置 managementErrorMessage 并返回 null。 + */ + private suspend fun buildManagementRepository(): SyncDataManagementRepository? { + val config = githubSyncStore.getConfig() + val token = githubSyncStore.getToken() + if (config == null || token == null) { + updateGitHubSyncState { + it.copy(managementErrorMessage = "请先配置 GitHub Token 和仓库") + } + return null + } + return try { + syncDataManagementRepositoryFactory(config) + } catch (e: Exception) { + updateGitHubSyncState { + it.copy(managementErrorMessage = "创建仓库实例失败:${e.message ?: "未知错误"}") + } + null + } + } + + /** 刷新数据管理预览。 */ + fun refreshSyncDataManagementPreview() { + viewModelScope.launch { + updateGitHubSyncState { + it.copy( + isManagementLoading = true, + isManagementOperationRunning = false, + managementErrorMessage = "" + ) + } + + val repo = buildManagementRepository() ?: run { + updateGitHubSyncState { it.copy(isManagementLoading = false) } + return@launch + } + + try { + when (val result = repo.preview()) { + is SyncDataManagementResult.Success -> { + updateGitHubSyncState { + it.copy( + isManagementLoading = false, + isManagementOperationRunning = false, + managementPreview = result.data, + managementStatusMessage = "数据预览已更新", + managementErrorMessage = "" + ) + } + } + is SyncDataManagementResult.Failure -> { + updateGitHubSyncState { + it.copy( + isManagementLoading = false, + isManagementOperationRunning = false, + managementErrorMessage = result.message + ) + } + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + updateGitHubSyncState { + it.copy( + isManagementLoading = false, + isManagementOperationRunning = false, + managementErrorMessage = "预览失败:${e.message ?: "未知错误"}" + ) + } + } + } + } + + /** 仅在云端同步文件不存在时,用本机数据创建初始云端快照。 */ + fun seedCloudFromLocal() { + viewModelScope.launch { + updateGitHubSyncState { + it.copy( + isManagementOperationRunning = true, + isManagementLoading = false, + managementErrorMessage = "" + ) + } + + val repo = buildManagementRepository() ?: run { + updateGitHubSyncState { it.copy(isManagementOperationRunning = false) } + return@launch + } + + try { + when (val result = repo.seedCloudFromLocal()) { + is SyncDataManagementResult.Success -> { + val lastSyncTime = githubSyncStore.getLastSyncTime() + updateGitHubSyncState { + it.copy( + isManagementOperationRunning = false, + isManagementLoading = false, + managementStatusMessage = "已用本机数据创建云端同步文件,可刷新数据预览", + statusMessage = "已创建初始云端同步文件", + lastSyncTime = lastSyncTime, + lastReport = result.data, + managementErrorMessage = "" + ) + } + } + is SyncDataManagementResult.Failure -> { + updateGitHubSyncState { + it.copy( + isManagementOperationRunning = false, + isManagementLoading = false, + managementErrorMessage = result.message + ) + } + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + updateGitHubSyncState { + it.copy( + isManagementOperationRunning = false, + isManagementLoading = false, + managementErrorMessage = "初始化云端失败:${e.message ?: "未知错误"}" + ) + } + } + } + } + + /** 删除云端同步快照文件。 */ + fun deleteCloudSnapshot() { + viewModelScope.launch { + updateGitHubSyncState { + it.copy( + isManagementOperationRunning = true, + isManagementLoading = false, + managementErrorMessage = "" + ) + } + + val repo = buildManagementRepository() ?: run { + updateGitHubSyncState { it.copy(isManagementOperationRunning = false) } + return@launch + } + + try { + when (val result = repo.deleteCloudSnapshot()) { + is SyncDataManagementResult.Success -> { + updateGitHubSyncState { + it.copy( + isManagementOperationRunning = false, + isManagementLoading = false, + managementStatusMessage = "云端同步文件已删除,可刷新数据预览", + statusMessage = "云端同步文件已删除", + lastReport = null, + lastSyncTime = 0L, + managementPreview = it.managementPreview?.copy( + cloud = com.cpu.seamlessloopmobile.data.sync.CloudSyncDataPreview(exists = false) + ), + managementErrorMessage = "" + ) + } + } + is SyncDataManagementResult.Failure -> { + updateGitHubSyncState { + it.copy( + isManagementOperationRunning = false, + isManagementLoading = false, + managementErrorMessage = result.message + ) + } + } + } + } catch (e: Exception) { + updateGitHubSyncState { + it.copy( + isManagementOperationRunning = false, + isManagementLoading = false, + managementErrorMessage = "删除云端快照失败:${e.message ?: "未知错误"}" + ) + } + } + } + } + + /** + * 清除本机同步相关数据。 + * @param clearPlaylists 是否清除歌单 + * @param clearLoopPoints 是否清除循环点 + * @param clearRatings 是否清除评分 + * @param clearListenStats 是否清除播放统计 + */ + fun clearLocalSyncData( + clearPlaylists: Boolean, + clearLoopPoints: Boolean, + clearRatings: Boolean, + clearListenStats: Boolean + ) { + viewModelScope.launch { + updateGitHubSyncState { + it.copy( + isManagementOperationRunning = true, + isManagementLoading = false, + managementErrorMessage = "" + ) + } + + val selection = ClearLocalSyncDataSelection( + clearPlaylists = clearPlaylists, + clearLoopPoints = clearLoopPoints, + clearRatings = clearRatings, + clearListenStats = clearListenStats + ) + + try { + when (val result = localSyncDataManagementRepository.clearLocalSyncData(selection)) { + is SyncDataManagementResult.Success -> { + val currentPreview = _githubSyncState.value?.managementPreview + val updatedPreview = currentPreview?.copy(local = result.data) + updateGitHubSyncState { + it.copy( + isManagementOperationRunning = false, + isManagementLoading = false, + managementStatusMessage = "所选本机数据已清除", + managementPreview = updatedPreview, + managementErrorMessage = "" + ) + } + } + is SyncDataManagementResult.Failure -> { + updateGitHubSyncState { + it.copy( + isManagementOperationRunning = false, + isManagementLoading = false, + managementErrorMessage = result.message + ) + } + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + updateGitHubSyncState { + it.copy( + isManagementOperationRunning = false, + isManagementLoading = false, + managementErrorMessage = "清除失败:${e.message ?: "未知错误"}" + ) + } + } + } + } + + /** Loads local playback-stat source device data for the data-management UI. */ + fun loadPlaybackStatsSourceDevices() { + viewModelScope.launch { + updateGitHubSyncState { + it.copy( + isManagementLoading = true, + isManagementOperationRunning = false, + managementStatusMessage = "", + managementErrorMessage = "" + ) + } + when (val result = localSyncDataManagementRepository.getLocalPlaybackStatsSourceDevices()) { + is SyncDataManagementResult.Success -> updateGitHubSyncState { + it.copy( + isManagementLoading = false, + playbackStatsSourceDevices = result.data, + managementStatusMessage = "播放统计来源已更新", + managementErrorMessage = "" + ) + } + is SyncDataManagementResult.Failure -> updateGitHubSyncState { + it.copy( + isManagementLoading = false, + managementStatusMessage = "", + managementErrorMessage = result.message + ) + } + } + } + } + + /** Deletes selected local playback-stat source histories using generation tombstones. */ + fun deletePlaybackStatsSourceDeviceHistories(deviceIds: Set) { + viewModelScope.launch { + updateGitHubSyncState { + it.copy( + isManagementOperationRunning = true, + isManagementLoading = false, + managementStatusMessage = "", + managementErrorMessage = "" + ) + } + when (val result = localSyncDataManagementRepository + .deleteLocalPlaybackStatsSourceDeviceHistories(deviceIds)) { + is SyncDataManagementResult.Success -> updateGitHubSyncState { + it.copy( + isManagementOperationRunning = false, + playbackStatsSourceDevices = result.data, + managementStatusMessage = "所选播放统计来源记录已删除", + managementErrorMessage = "" + ) + } + is SyncDataManagementResult.Failure -> updateGitHubSyncState { + it.copy( + isManagementOperationRunning = false, + managementStatusMessage = "", + managementErrorMessage = result.message + ) + } + } + } + } + fun startObservation() { viewModelScope.launch { mediaControlManager.metadata.collect { meta -> @@ -115,6 +712,8 @@ class MainViewModel( _playMode.value = savedMode isSeamlessLoopEnabled.value = manager.isSeamlessLoopEnabled _seamlessLoopCountLimit.value = manager.seamlessLoopCountLimit + _themePreference.value = manager.themePreference + _buttonHapticFeedbackEnabled.value = manager.buttonHapticFeedbackEnabled // 给底层发个信号,告诉它当前的初始模式,也把状态机的模式同步好喵! val bundle = android.os.Bundle().apply { @@ -138,12 +737,28 @@ class MainViewModel( private val _seamlessLoopCountLimit = MutableLiveData(0) val seamlessLoopCountLimit: LiveData = _seamlessLoopCountLimit + private val _themePreference = MutableLiveData(ThemePreference.SYSTEM) + val themePreference: LiveData = _themePreference + + private val _buttonHapticFeedbackEnabled = MutableLiveData(true) + val buttonHapticFeedbackEnabled: LiveData = _buttonHapticFeedbackEnabled + fun setSeamlessLoopCountLimit(limit: Int) { val safeLimit = limit.coerceIn(0, SettingsManager.MAX_SEAMLESS_LOOP_COUNT_LIMIT) _seamlessLoopCountLimit.value = safeLimit settingsManager?.seamlessLoopCountLimit = safeLimit } + fun setThemePreference(preference: ThemePreference) { + _themePreference.value = preference + settingsManager?.themePreference = preference + } + + fun setButtonHapticFeedbackEnabled(enabled: Boolean) { + _buttonHapticFeedbackEnabled.value = enabled + settingsManager?.buttonHapticFeedbackEnabled = enabled + } + private fun restorePlaybackSession() { val manager = settingsManager ?: return @@ -189,17 +804,10 @@ class MainViewModel( private val _isSearchPanelVisible = MutableLiveData(false) val isSearchPanelVisible: LiveData = _isSearchPanelVisible - private val _isSettingsPanelVisible = MutableLiveData(false) - val isSettingsPanelVisible: LiveData = _isSettingsPanelVisible - fun setSearchPanelVisible(visible: Boolean) { _isSearchPanelVisible.value = visible } - fun setSettingsPanelVisible(visible: Boolean) { - _isSettingsPanelVisible.value = visible - } - // --- 循环检测状态只读流代理喵 --- val detectedLoopPoints: StateFlow?> get() = loopDetection.detectedLoopPoints val isDetectingLoop: StateFlow get() = loopDetection.isDetectingLoop diff --git a/app/src/main/java/com/cpu/seamlessloopmobile/viewmodel/MainViewModelFactory.kt b/app/src/main/java/com/cpu/seamlessloopmobile/viewmodel/MainViewModelFactory.kt index 101b7be..cbe06a3 100644 --- a/app/src/main/java/com/cpu/seamlessloopmobile/viewmodel/MainViewModelFactory.kt +++ b/app/src/main/java/com/cpu/seamlessloopmobile/viewmodel/MainViewModelFactory.kt @@ -3,6 +3,7 @@ package com.cpu.seamlessloopmobile.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import androidx.work.WorkManager import com.cpu.seamlessloopmobile.model.PlaylistDao import com.cpu.seamlessloopmobile.model.SongDao import com.cpu.seamlessloopmobile.model.PlayQueueDao @@ -25,12 +26,71 @@ class MainViewModelFactory( val scope = (viewModel as ViewModel).viewModelScope val settingsManager = com.cpu.seamlessloopmobile.data.SettingsManager.getInstance(context) - val libraryVM = LibraryViewModel(repository, scope, settingsManager) val selectionVM = SelectionViewModel() val playlistVM = PlaylistViewModel(repository, scope, settingsManager) val loopDetectionRepo = com.cpu.seamlessloopmobile.data.LoopDetectionRepository(repository, context.applicationContext) val loopDetectionVM = LoopDetectionViewModel(loopDetectionRepo, mediaControl, scope) + // GitHub 同步基础设施 + val database = com.cpu.seamlessloopmobile.db.AppDatabase.getDatabase(context) + val listenStatsRepository = com.cpu.seamlessloopmobile.data.stats.ListenStatsRepository + .getInstance(context.applicationContext) + val githubSyncStore = com.cpu.seamlessloopmobile.data.sync.SharedPreferencesGitHubSyncStore(context.applicationContext) + val playlistIdMapper = com.cpu.seamlessloopmobile.data.sync.room.SharedPreferencesPlaylistIdMapper(context.applicationContext) + val roomSyncSnapshotStore = com.cpu.seamlessloopmobile.data.sync.room.RoomSyncSnapshotStore( + database = database, + songDao = songDao, + playlistDao = playlistDao, + playlistIdMapper = playlistIdMapper, + listenStatsRepository = listenStatsRepository + ) + + val libraryVM = LibraryViewModel( + repository = repository, + coroutineScope = scope, + settingsManager = settingsManager, + onScanCompleted = roomSyncSnapshotStore::rebindPlaybackStats + ) + + viewModel.githubSyncStore = githubSyncStore + viewModel.playlistIdMapper = playlistIdMapper + viewModel.roomSyncSnapshotStore = roomSyncSnapshotStore + viewModel.localSyncDataManagementRepository = com.cpu.seamlessloopmobile.data.sync.SyncDataManagementRepository( + database = database, + songDao = songDao, + playlistDao = playlistDao, + snapshotStore = roomSyncSnapshotStore, + metadataStore = githubSyncStore, + playlistIdMapper = playlistIdMapper, + listenStatsRepository = listenStatsRepository + ) + + // 自动同步调度器 + viewModel.githubAutoSyncScheduler = com.cpu.seamlessloopmobile.data.sync.GitHubAutoSyncScheduler( + workManager = WorkManager.getInstance(context.applicationContext) + ) + + // 数据管理仓库工厂 —— 每次调用时使用最新配置构建后端 + viewModel.syncDataManagementRepositoryFactory = { config -> + val mgmtBackend = com.cpu.seamlessloopmobile.data.sync.github.GitHubContentsSyncBackend( + config = config, + tokenProvider = githubSyncStore, + serializer = com.cpu.seamlessloopmobile.data.sync.SyncSnapshotSerializer() + ) + com.cpu.seamlessloopmobile.data.sync.SyncDataManagementRepository( + database = database, + songDao = songDao, + playlistDao = playlistDao, + snapshotStore = roomSyncSnapshotStore, + backend = mgmtBackend, + metadataStore = githubSyncStore, + playlistIdMapper = playlistIdMapper, + listenStatsRepository = listenStatsRepository + ) + } + + viewModel.loadGitHubSyncState() + // 设置子管家引用 viewModel.library = libraryVM viewModel.selection = selectionVM diff --git a/app/src/test/java/com/cpu/seamlessloopmobile/audio/MediaSessionPlaybackStateThrottlerTest.kt b/app/src/test/java/com/cpu/seamlessloopmobile/audio/MediaSessionPlaybackStateThrottlerTest.kt new file mode 100644 index 0000000..cdc7706 --- /dev/null +++ b/app/src/test/java/com/cpu/seamlessloopmobile/audio/MediaSessionPlaybackStateThrottlerTest.kt @@ -0,0 +1,203 @@ +package com.cpu.seamlessloopmobile.audio + +import android.support.v4.media.session.PlaybackStateCompat +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MediaSessionPlaybackStateThrottlerTest { + + private val throttler = MediaSessionPlaybackStateThrottler( + minUpdateIntervalMs = 1000L, + positionDriftThresholdMs = 1500L + ) + + @Test + fun firstDispatchAlwaysAllowed() { + assertTrue( + throttler.shouldDispatch( + state = PlaybackStateCompat.STATE_PLAYING, + positionMs = 0L, + speed = 1.0f, + controlFingerprint = 0, + nowElapsedMs = 100L + ) + ) + } + + @Test + fun stateChangeTriggersDispatch() { + throttler.recordDispatch( + state = PlaybackStateCompat.STATE_PLAYING, + positionMs = 0L, + speed = 1.0f, + controlFingerprint = 0, + nowElapsedMs = 100L + ) + + assertTrue( + throttler.shouldDispatch( + state = PlaybackStateCompat.STATE_PAUSED, + positionMs = 500L, + speed = 0f, + controlFingerprint = 0, + nowElapsedMs = 200L + ) + ) + } + + @Test + fun speedChangeTriggersDispatch() { + throttler.recordDispatch( + state = PlaybackStateCompat.STATE_PLAYING, + positionMs = 0L, + speed = 1.0f, + controlFingerprint = 0, + nowElapsedMs = 100L + ) + + assertTrue( + throttler.shouldDispatch( + state = PlaybackStateCompat.STATE_PLAYING, + positionMs = 10L, + speed = 2.0f, + controlFingerprint = 0, + nowElapsedMs = 200L + ) + ) + } + + @Test + fun fingerprintChangeTriggersDispatch() { + throttler.recordDispatch( + state = PlaybackStateCompat.STATE_PLAYING, + positionMs = 0L, + speed = 1.0f, + controlFingerprint = 0, + nowElapsedMs = 100L + ) + + assertTrue( + throttler.shouldDispatch( + state = PlaybackStateCompat.STATE_PLAYING, + positionMs = 10L, + speed = 1.0f, + controlFingerprint = 42, + nowElapsedMs = 200L + ) + ) + } + + @Test + fun minIntervalElapsedTriggersDispatch() { + throttler.recordDispatch( + state = PlaybackStateCompat.STATE_PLAYING, + positionMs = 0L, + speed = 1.0f, + controlFingerprint = 0, + nowElapsedMs = 1000L + ) + + assertTrue( + throttler.shouldDispatch( + state = PlaybackStateCompat.STATE_PLAYING, + positionMs = 10L, + speed = 1.0f, + controlFingerprint = 0, + nowElapsedMs = 3000L // > 1000ms elapsed + ) + ) + } + + @Test + fun withinMinIntervalAndNoChangeIsSuppressed() { + throttler.recordDispatch( + state = PlaybackStateCompat.STATE_PLAYING, + positionMs = 0L, + speed = 1.0f, + controlFingerprint = 0, + nowElapsedMs = 1000L + ) + + assertFalse( + throttler.shouldDispatch( + state = PlaybackStateCompat.STATE_PLAYING, + positionMs = 100L, + speed = 1.0f, + controlFingerprint = 0, + nowElapsedMs = 1100L // only 100ms elapsed, drift well below 1500ms + ) + ) + } + + @Test + fun positionDriftExceedsThresholdForcesDispatch() { + throttler.recordDispatch( + state = PlaybackStateCompat.STATE_PLAYING, + positionMs = 0L, + speed = 1.0f, + controlFingerprint = 0, + nowElapsedMs = 1000L + ) + + // Within min interval, but drift >= 1500ms threshold + assertTrue( + throttler.shouldDispatch( + state = PlaybackStateCompat.STATE_PLAYING, + positionMs = 2000L, + speed = 1.0f, + controlFingerprint = 0, + nowElapsedMs = 1100L + ) + ) + } + + @Test + fun recordDispatchUpdatesLastState() { + throttler.recordDispatch( + state = PlaybackStateCompat.STATE_PLAYING, + positionMs = 100L, + speed = 1.0f, + controlFingerprint = 1, + nowElapsedMs = 500L + ) + + // Same state, same fingerprint, same speed: should be suppressed + assertFalse( + throttler.shouldDispatch( + state = PlaybackStateCompat.STATE_PLAYING, + positionMs = 200L, + speed = 1.0f, + controlFingerprint = 1, + nowElapsedMs = 600L + ) + ) + } + + @Test + fun customIntervalAndThreshold() { + val fastThrottler = MediaSessionPlaybackStateThrottler( + minUpdateIntervalMs = 50L, + positionDriftThresholdMs = 200L + ) + + fastThrottler.recordDispatch( + state = PlaybackStateCompat.STATE_PLAYING, + positionMs = 0L, + speed = 1.0f, + controlFingerprint = 0, + nowElapsedMs = 1000L + ) + + // 60ms later: above min interval -> allowed + assertTrue( + fastThrottler.shouldDispatch( + state = PlaybackStateCompat.STATE_PLAYING, + positionMs = 10L, + speed = 1.0f, + controlFingerprint = 0, + nowElapsedMs = 1060L + ) + ) + } +} diff --git a/app/src/test/java/com/cpu/seamlessloopmobile/audio/SystemMediaProgressSyncControllerTest.kt b/app/src/test/java/com/cpu/seamlessloopmobile/audio/SystemMediaProgressSyncControllerTest.kt index c819812..1b0f4f2 100644 --- a/app/src/test/java/com/cpu/seamlessloopmobile/audio/SystemMediaProgressSyncControllerTest.kt +++ b/app/src/test/java/com/cpu/seamlessloopmobile/audio/SystemMediaProgressSyncControllerTest.kt @@ -86,6 +86,28 @@ class SystemMediaProgressSyncControllerTest { scope.cancel() } + @Test + fun dispatchPredicateCanSuppressPeriodicTicksWithoutBreakingImmediateSync() = runBlocking { + val scope = CoroutineScope(SupervisorJob() + testDispatcher) + val tickCount = AtomicInteger(0) + val controller = SystemMediaProgressSyncController( + scope = scope, + intervalMs = 20L, + shouldDispatch = { false } + ) { + tickCount.incrementAndGet() + } + + controller.onPlaybackStateChanged(AudioPlayState.PLAYING) + waitUntil { tickCount.get() == 1 } + assertEquals(1, tickCount.get()) + + kotlinx.coroutines.delay(60L) + assertEquals("Predicate should suppress periodic ticks after the immediate sync", 1, tickCount.get()) + controller.dispose() + scope.cancel() + } + @Test fun convertsFramesToPlaybackMillisecondsUsingSampleRateAtSampleTime() { assertEquals(1000L, framesToPlaybackPositionMs(positionFrames = 44100L, sampleRate = 44100)) diff --git a/app/src/test/java/com/cpu/seamlessloopmobile/audio/stats/PlaybackStatsTrackerTest.kt b/app/src/test/java/com/cpu/seamlessloopmobile/audio/stats/PlaybackStatsTrackerTest.kt new file mode 100644 index 0000000..38a1437 --- /dev/null +++ b/app/src/test/java/com/cpu/seamlessloopmobile/audio/stats/PlaybackStatsTrackerTest.kt @@ -0,0 +1,276 @@ +package com.cpu.seamlessloopmobile.audio.stats + +import com.cpu.seamlessloopmobile.data.stats.ListenStatsRepository +import com.cpu.seamlessloopmobile.data.stats.TrackStat +import com.cpu.seamlessloopmobile.model.Song +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File + +@OptIn(ExperimentalCoroutinesApi::class) +class PlaybackStatsTrackerTest { + + private lateinit var repo: ListenStatsRepository + private lateinit var tracker: PlaybackStatsTracker + private var fakeElapsed: Long = 0L + + @Before + fun setUp() { + // Use a temporary JSON file for each test + val tmpFile = File.createTempFile("listen_stats_test_", ".json") + tmpFile.deleteOnExit() + repo = ListenStatsRepository( + jsonFile = tmpFile + ) + fakeElapsed = 0L + tracker = PlaybackStatsTracker( + repository = repo, + timeSource = { fakeElapsed } + ) + } + + // --- Song helpers -------------------------------------------------------- + + private fun song( + id: Long = 1L, + fileName: String = "test.mp3", + filePath: String = "/music/test.mp3", + displayName: String = "Test Song", + artist: String = "Test Artist", + album: String = "Test Album", + duration: Long = 200_000L, + coverPath: String? = null + ): Song = Song( + id = id, + fileName = fileName, + filePath = filePath, + displayName = displayName, + duration = duration, + artist = artist, + album = album, + coverPath = coverPath, + mediaId = 100L + ) + + // --- Tests --------------------------------------------------------------- + + @Test + fun initialTrackingIsFalse() { + assertFalse(tracker.isTracking) + } + + @Test + fun onSongChangedSetsCurrentStatWithoutTracking() = runTest { + val s = song() + tracker.onSongChanged(s) + assertFalse(tracker.isTracking) + } + + @Test + fun onPlayingChangedTrueStartsTracking() { + tracker.onSongChanged(song()) + assertFalse(tracker.isTracking) + + tracker.onPlayingChanged(true) + assertTrue(tracker.isTracking) + } + + @Test + fun onPlayingChangedFalseStopsTracking() { + tracker.onSongChanged(song()) + tracker.onPlayingChanged(true) + assertTrue(tracker.isTracking) + + tracker.onPlayingChanged(false) + assertFalse(tracker.isTracking) + } + + @Test + fun accumulatesTimeAndFlushesOnSongChange() = runTest { + val s = song(id = 1, fileName = "song_a.mp3", duration = 200_000L) + tracker.onSongChanged(s) + tracker.onPlayingChanged(true) + + fakeElapsed = 5_000L // 5 seconds of listening + + val s2 = song(id = 2, fileName = "song_b.mp3", duration = 180_000L) + tracker.onSongChanged(s2) + tracker.onPlayingChanged(true) + + // song_a should have 5000ms tracked + val keyA = TrackStat.identityKey("song_a.mp3", 200_000L, "/music/song_a.mp3") + val statA = repo.getByIdentityKey(keyA) + assertEquals(5_000L, statA?.totalListenMs ?: 0L) + } + + @Test + fun flushPeriodicPersistsAccumulatedTimeWhileTracking() = runTest { + val s = song(id = 1, fileName = "track.mp3", duration = 300_000L) + tracker.onSongChanged(s) + + fakeElapsed = 2_000L + tracker.onPlayingChanged(true) + + fakeElapsed = 12_000L // 10s of accumulated time + + tracker.flushPeriodic() + + val key = TrackStat.identityKey("track.mp3", 300_000L, "/music/track.mp3") + val stat = repo.getByIdentityKey(key) + assertEquals(10_000L, stat?.totalListenMs ?: 0L) + + // Tracking should still be active + assertTrue(tracker.isTracking) + } + + @Test + fun flushFinalStopsTrackingAndPersists() = runTest { + val s = song(id = 1, fileName = "final.mp3", duration = 120_000L) + tracker.onSongChanged(s) + fakeElapsed = 100L + tracker.onPlayingChanged(true) + + fakeElapsed = 7_100L // 7s accumulated + + tracker.flushFinal() + + assertFalse(tracker.isTracking) + + val key = TrackStat.identityKey("final.mp3", 120_000L, "/music/final.mp3") + val stat = repo.getByIdentityKey(key) + assertEquals(7_000L, stat?.totalListenMs ?: 0L) + } + + @Test + fun repeatedPlaySongsAccumulatesTotalListenTime() = runTest { + val s = song(id = 1, fileName = "replay.mp3", duration = 100_000L) + + // Session 1 + tracker.onSongChanged(s) + fakeElapsed = 10_000L + tracker.onPlayingChanged(true) + fakeElapsed = 25_000L + tracker.onPlayingChanged(false) // 15s listened + + // Session 2 + fakeElapsed = 30_000L + tracker.onSongChanged(s) // same song + tracker.onPlayingChanged(true) + fakeElapsed = 50_000L + tracker.onPlayingChanged(false) // 20s listened + + val key = TrackStat.identityKey("replay.mp3", 100_000L, "/music/replay.mp3") + val stat = repo.getByIdentityKey(key) + assertEquals(35_000L, stat?.totalListenMs ?: 0L) + } + + @Test + fun shouldFlushPeriodicallyReturnsTrackingState() { + assertFalse(tracker.shouldFlushPeriodically()) + + tracker.onSongChanged(song()) + tracker.onPlayingChanged(true) + assertTrue(tracker.shouldFlushPeriodically()) + + tracker.onPlayingChanged(false) + assertFalse(tracker.shouldFlushPeriodically()) + } + + @Test + fun playPausePlaySameSongPreservesAccumulatedTime() = runTest { + val s = song(id = 1, fileName = "same.mp3", duration = 60_000L) + + tracker.onSongChanged(s) + tracker.onPlayingChanged(true) + fakeElapsed = 3_000L + tracker.onPlayingChanged(false) // pause after 3s + + fakeElapsed = 10_000L + tracker.onPlayingChanged(true) // resume + fakeElapsed = 18_000L + tracker.onPlayingChanged(false) // pause after 8s more + + val key = TrackStat.identityKey("same.mp3", 60_000L, "/music/same.mp3") + val stat = repo.getByIdentityKey(key) + assertEquals(11_000L, stat?.totalListenMs ?: 0L) + } + + @Test + fun repeatedSongChangedForSameSongDoesNotResetActiveSegment() = runTest { + val s = song(id = 1, fileName = "stable.mp3", duration = 90_000L) + + tracker.onSongChanged(s) + tracker.onPlayingChanged(true) + fakeElapsed = 4_000L + + tracker.onSongChanged(s.copy(displayName = "Stable Song Updated")) + fakeElapsed = 9_000L + tracker.flushPeriodic() + + val key = TrackStat.identityKey("stable.mp3", 90_000L, "/music/stable.mp3") + val stat = repo.getByIdentityKey(key) + assertEquals(9_000L, stat?.totalListenMs ?: 0L) + } + + @Test + fun statsClearDiscardsPreClearPendingTimeAndPersistsOnlyPostClearTime() = runTest { + val s = song(id = 1, fileName = "clear.mp3", duration = 90_000L) + tracker.onSongChanged(s) + tracker.onPlayingChanged(true) + + fakeElapsed = 5_000L + repo.clearAll() + tracker.onStatsCleared() + + fakeElapsed = 8_000L + tracker.flushPeriodic() + + val key = TrackStat.identityKey("clear.mp3", 90_000L, "/music/clear.mp3") + assertEquals(3_000L, repo.getByIdentityKey(key)?.totalListenMs ?: 0L) + assertTrue(tracker.isTracking) + } + + @Test + fun staleFlushBeforeClearEventCannotRepopulateRotatedGeneration() = runTest { + val s = song(id = 1, fileName = "rotated.mp3", duration = 90_000L) + tracker.onSongChanged(s) + tracker.onPlayingChanged(true) + + fakeElapsed = 5_000L + repo.clearAll() + + // The service collector may not have delivered clearEvents yet. The active fence must + // still reject this delayed periodic flush. + tracker.flushPeriodic() + val key = TrackStat.identityKey("rotated.mp3", 90_000L, "/music/rotated.mp3") + assertEquals(0L, repo.getByIdentityKey(key)?.totalListenMs ?: 0L) + + tracker.onStatsCleared() + fakeElapsed = 8_000L + tracker.flushFinal() + assertEquals(3_000L, repo.getByIdentityKey(key)?.totalListenMs ?: 0L) + } + + @Test + fun identityKeyUsesFileNameAndDurationWhenDurationPositive() { + val key = TrackStat.identityKey("song.mp3", 200_000L, "/ignored/path.mp3") + assertEquals("song.mp3|200000", key) + } + + @Test + fun identityKeyFallsBackToFilePathWhenDurationZero() { + val key = TrackStat.identityKey("song.mp3", 0L, "/fallback/path.mp3") + assertEquals("/fallback/path.mp3", key) + } + + @Test + fun identityKeyFallsBackToFilePathWhenDurationNegative() { + val key = TrackStat.identityKey("song.mp3", -1L, "/fallback/neg.mp3") + assertEquals("/fallback/neg.mp3", key) + } +} diff --git a/app/src/test/java/com/cpu/seamlessloopmobile/data/stats/ListenStatsRepositoryTest.kt b/app/src/test/java/com/cpu/seamlessloopmobile/data/stats/ListenStatsRepositoryTest.kt new file mode 100644 index 0000000..03cdbf1 --- /dev/null +++ b/app/src/test/java/com/cpu/seamlessloopmobile/data/stats/ListenStatsRepositoryTest.kt @@ -0,0 +1,575 @@ +package com.cpu.seamlessloopmobile.data.stats + +import com.google.gson.Gson +import com.google.gson.JsonParser +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runCurrent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import java.time.LocalDate +import java.time.ZoneId +import java.time.ZonedDateTime + +@OptIn(ExperimentalCoroutinesApi::class) +class ListenStatsRepositoryTest { + + @Test + fun recordListenDeltaNowAccumulatesTotalAndTodaysBucket() = runTest { + val today = LocalDate.of(2026, 7, 10) + val repository = repositoryAt(today) + val stat = stat() + + repository.recordListenDeltaNow(stat, 1_000L) + repository.recordListenDeltaNow(stat, 2_000L) + + val saved = repository.getByIdentityKey(stat.identityKey)!! + assertEquals(3_000L, saved.totalListenMs) + assertEquals(3_000L, saved.dailyListenMs[today.toString()]) + } + + @Test + fun listenMsForUsesMondayBasedWeekAndCalendarBoundaries() { + val stat = stat( + totalListenMs = 99_000L, + dailyListenMs = mapOf( + "2026-07-05" to 100L, + "2026-07-06" to 200L, + "2026-07-12" to 300L, + "2026-07-13" to 400L, + "2026-06-30" to 500L, + "not-a-date" to 600L + ) + ) + val today = LocalDate.of(2026, 7, 10) + + assertEquals(0L, stat.listenMsFor(ListenStatsPeriod.DAY, today)) + assertEquals(500L, stat.listenMsFor(ListenStatsPeriod.WEEK, today)) + assertEquals(1_000L, stat.listenMsFor(ListenStatsPeriod.MONTH, today)) + assertEquals(1_500L, stat.listenMsFor(ListenStatsPeriod.YEAR, today)) + } + + @Test + fun listenMsForAllRetainsUndatedTotalWithoutDailyHistory() { + val stat = stat(totalListenMs = 12_345L) + + assertEquals(12_345L, stat.listenMsFor(ListenStatsPeriod.ALL, LocalDate.of(2026, 7, 10))) + assertEquals(0L, stat.listenMsFor(ListenStatsPeriod.MONTH, LocalDate.of(2026, 7, 10))) + } + + @Test + fun loadsCurrentStorePreservingDatedUndatedPeriodsGenerationsTombstonesAndUnresolvedNodes() = runTest { + val file = File.createTempFile("current_listen_stats_", ".json").apply { deleteOnExit() } + val persisted = ListenStatsStore( + schemaVersion = ListenStatsStore.SCHEMA_VERSION, + devices = listOf( + ListenStatsDevice( + deviceId = "device-a", + displayName = "Pixel Test", + platform = "android", + currentGeneration = 3L, + createdAt = 100L, + lastSeenAt = 200L, + updatedAtUtcMs = 200L + ) + ), + currentDeviceId = "device-a", + currentGeneration = 3L, + songs = listOf( + ListenStatsSongNode( + identityKey = "test.mp3|1000", + normalizedFileName = "test.mp3", + fileName = "test.mp3", + durationMs = 1_000L, + contributions = listOf( + ListenStatsContribution( + deviceId = "device-a", + generation = 3L, + dailyListenMs = mapOf("2026-07-10" to 2_000L), + undatedListenMs = 5_000L + ) + ) + ) + ), + tombstones = listOf( + ListenStatsTombstone("device-a", 2L, 300L, "device-a", "local_clear") + ), + unresolvedNodes = listOf( + ListenStatsUnresolvedNode("missing.mp3", 2_000L, "{\"unresolved\":true}") + ) + ) + file.writeText(Gson().toJson(persisted)) + + val repository = ListenStatsRepository( + jsonFile = file, + currentDeviceIdProvider = { "device-a" }, + wallClockMillis = { 400L }, + zoneId = ZoneId.of("UTC") + ) + + val loaded = repository.getByIdentityKey("test.mp3|1000")!! + val payload = repository.exportLocalPayload() + assertEquals(7_000L, loaded.totalListenMs) + assertEquals(7_000L, loaded.listenMsFor(ListenStatsPeriod.ALL, LocalDate.of(2026, 7, 10))) + assertEquals(2_000L, loaded.listenMsFor(ListenStatsPeriod.DAY, LocalDate.of(2026, 7, 10))) + assertEquals(2_000L, loaded.listenMsFor(ListenStatsPeriod.MONTH, LocalDate.of(2026, 7, 10))) + assertEquals(3L, payload.currentGeneration) + assertEquals(1, payload.tombstones.size) + assertEquals(1, payload.unresolvedNodes.size) + assertTrue(payload.devices.all { it.displayName.isNotBlank() }) + assertEquals("Pixel Test", payload.devices.single().displayName) + } + + @Test + fun rootArrayResetsToFreshStoreWithoutRewritingFile() = assertInvalidStoreStartsFresh("[]") + + @Test + fun missingSchemaResetsToFreshStoreWithoutRewritingFile() = + assertInvalidStoreStartsFresh("{\"devices\":[]}") + + @Test + fun nullSchemaResetsToFreshStoreWithoutRewritingFile() = + assertInvalidStoreStartsFresh("{\"schemaVersion\":null}") + + @Test + fun mismatchedSchemaResetsToFreshStoreWithoutRewritingFile() = + assertInvalidStoreStartsFresh("{\"schemaVersion\":2}") + + @Test + fun malformedJsonResetsToFreshStoreWithoutRewritingFile() = + assertInvalidStoreStartsFresh("{broken") + + @Test + fun structurallyInvalidObjectResetsToFreshStoreWithoutRewritingFile() = + assertInvalidStoreStartsFresh("{\"schemaVersion\":2,\"devices\":null}") + + @Test + fun successfulWriteReplacesInvalidFileWithCurrentObjectSchema() = runTest { + val file = File.createTempFile("invalid_listen_stats_", ".json").apply { + deleteOnExit() + writeText("[]") + } + val repository = ListenStatsRepository(file) + + repository.recordListenDeltaNow(stat(), 100L) + + val root = JsonParser.parseString(file.readText()).asJsonObject + assertEquals(ListenStatsStore.SCHEMA_VERSION, root.get("schemaVersion").asInt) + assertTrue(root.has("devices")) + assertTrue(root.has("songs")) + } + + @Test + fun schemaThreeStorePersistsAcrossRepositoryReload() = runTest { + val file = File.createTempFile("schema_three_listen_stats_", ".json").apply { + deleteOnExit() + } + val first = ListenStatsRepository(file, zoneId = ZoneId.of("UTC")) + first.recordListenDeltaNow(stat(), 123L) + + assertEquals(3, JsonParser.parseString(file.readText()).asJsonObject + .get("schemaVersion").asInt) + assertEquals(123L, ListenStatsRepository(file, zoneId = ZoneId.of("UTC")) + .getByIdentityKey("test.mp3|1000")!!.totalListenMs) + } + + @Test + fun recordListenDeltaNowSplitsAcrossLocalMidnight() = runTest { + val zone = ZoneId.of("America/New_York") + val end = ZonedDateTime.of(2026, 7, 11, 0, 0, 30, 0, zone) + val file = File.createTempFile("listen_stats_", ".json").apply { deleteOnExit() } + val repository = ListenStatsRepository( + jsonFile = file, + wallClockMillis = { end.toInstant().toEpochMilli() }, + zoneId = zone + ) + + repository.recordListenDeltaNow(stat(), 60_000L) + + val saved = repository.getByIdentityKey("test.mp3|1000")!! + assertEquals(60_000L, saved.totalListenMs) + assertEquals(30_000L, saved.dailyListenMs["2026-07-10"]) + assertEquals(30_000L, saved.dailyListenMs["2026-07-11"]) + } + + @Test + fun recordListenDeltaNowSplitsAcrossSpringForwardDay() = runTest { + val zone = ZoneId.of("America/New_York") + val end = ZonedDateTime.of(2026, 3, 9, 0, 0, 0, 0, zone) + val file = File.createTempFile("listen_stats_", ".json").apply { deleteOnExit() } + val repository = ListenStatsRepository( + jsonFile = file, + wallClockMillis = { end.toInstant().toEpochMilli() }, + zoneId = zone + ) + + repository.recordListenDeltaNow(stat(), 24 * 60 * 60 * 1_000L) + + val saved = repository.getByIdentityKey("test.mp3|1000")!! + assertEquals(24 * 60 * 60 * 1_000L, saved.totalListenMs) + assertEquals(60 * 60 * 1_000L, saved.dailyListenMs["2026-03-07"]) + assertEquals(23 * 60 * 60 * 1_000L, saved.dailyListenMs["2026-03-08"]) + } + + @Test + fun recordListenDeltaNowUsesZoneProviderForEachRecord() = runTest { + val instant = ZonedDateTime.of(2026, 7, 10, 2, 0, 0, 0, ZoneId.of("UTC")).toInstant() + var currentZone = ZoneId.of("UTC") + val file = File.createTempFile("listen_stats_", ".json").apply { deleteOnExit() } + val repository = ListenStatsRepository( + jsonFile = file, + wallClockMillis = { instant.toEpochMilli() }, + zoneIdProvider = { currentZone } + ) + + repository.recordListenDeltaNow(stat(), 1L) + currentZone = ZoneId.of("America/Los_Angeles") + repository.recordListenDeltaNow(stat(), 1L) + + val saved = repository.getByIdentityKey("test.mp3|1000")!! + assertEquals(1L, saved.dailyListenMs["2026-07-10"]) + assertEquals(1L, saved.dailyListenMs["2026-07-09"]) + } + + @Test + fun clearAllPersistsEmptyStateBeforeEmittingClearEvent() = runTest { + val file = File.createTempFile("listen_stats_", ".json").apply { deleteOnExit() } + val repository = ListenStatsRepository(file) + repository.recordListenDeltaNow(stat(), 1_000L) + val clearEvent = async { repository.clearEvents.first() } + runCurrent() + + repository.clearAll() + + clearEvent.await() + assertEquals(emptyList(), repository.allStats.value) + val store = Gson().fromJson(file.readText(), ListenStatsStore::class.java) + assertEquals(1, store.tombstones.size) + assertEquals(1L, store.currentGeneration) + } + + @Test + fun clearCurrentDeviceStatsTombstonesGenerationAndRotatesSource() = runTest { + val repository = repositoryWithDevice("device-a", now = 1_000L) + repository.recordListenDeltaNow(stat(), 100L) + + repository.clearCurrentDeviceStats("user_requested") + + val source = repository.currentSource() + val payload = repository.exportLocalPayload() + assertEquals("device-a", source.device.deviceId) + assertEquals(1L, source.currentGeneration) + assertEquals(1, payload.tombstones.size) + assertEquals(0L, payload.tombstones.single().generation) + assertEquals("device-a", payload.tombstones.single().operatorDeviceId) + assertEquals("user_requested", payload.tombstones.single().reason) + assertTrue(repository.allStats.value.isEmpty()) + } + + @Test + fun staleFenceAfterRotationCannotRepopulateTombstonedGeneration() = runTest { + val repository = repositoryWithDevice("device-a", now = 1_000L) + val fence = repository.captureWriteFence() + + repository.clearCurrentDeviceStats() + val accepted = repository.recordListenDeltaNow(stat(), 100L, fence) + + assertFalse(accepted) + assertTrue(repository.allStats.value.isEmpty()) + assertEquals(1L, repository.currentSource().currentGeneration) + } + + @Test + fun remoteTombstoneOfCurrentGenerationRotatesSourceAndInvalidatesStaleFence() = runTest { + val repository = repositoryWithDevice("device-a", now = 1_000L) + repository.recordListenDeltaNow(stat(), 100L) + val staleFence = repository.captureWriteFence() + val payload = repository.exportLocalPayload() + + repository.applyLocalPayload(payload.copy( + tombstones = payload.tombstones + ListenStatsTombstone( + deviceId = "device-a", + generation = 0L, + tombstonedAtUtcMs = 2_000L, + operatorDeviceId = "remote-device", + reason = "remote_clear" + ) + )) + + assertEquals(1L, repository.currentSource().currentGeneration) + assertTrue(repository.allStats.value.isEmpty()) + assertFalse(repository.recordListenDeltaNow(stat(), 100L, staleFence)) + } + + @Test + fun materialStatsMutationsNotifySyncFence() = runTest { + var mutations = 0 + val file = File.createTempFile("listen_stats_", ".json").apply { deleteOnExit() } + val repository = ListenStatsRepository( + jsonFile = file, + onMaterialMutation = { mutations++ }, + zoneId = ZoneId.of("UTC") + ) + + repository.recordListenDeltaNow(stat(), 100L) + repository.clearAll() + + assertEquals(2, mutations) + } + + @Test + fun exportedPayloadIncludesContributionTimestampsAndSourceMetadata() = runTest { + val repository = repositoryWithDevice("device-a", now = 12_345L) + repository.recordListenDeltaNow(stat(), 100L) + + val payload = repository.exportLocalPayload() + val device = payload.devices.single() + val contribution = payload.songs.single().contributions.single() + assertEquals("device-a", device.deviceId) + assertEquals("android", device.platform) + assertEquals(12_345L, device.updatedAtUtcMs) + assertEquals(12_345L, contribution.firstPlayedAtUtcMs) + assertEquals(12_345L, contribution.lastPlayedAtUtcMs) + assertEquals(12_345L, contribution.updatedAtUtcMs) + } + + @Test + fun applyStalePayloadPreservesLaterLocalDeltaAndMergesRemoteContribution() = runTest { + val repository = repositoryWithDevice("phone", now = 1_000L) + repository.recordListenDeltaNow(stat(), 1_000L) + val stalePayload = repository.exportLocalPayload() + + repository.recordListenDeltaNow(stat(), 500L) + val remoteNode = songNode( + fileName = "test.mp3", + durationMs = 1_000L, + deviceId = "desktop", + daily = mapOf("2026-07-10" to 2_000L) + ) + + repository.applyLocalPayload(stalePayload.copy( + songs = stalePayload.songs + remoteNode, + tombstones = stalePayload.tombstones + ListenStatsTombstone("retired", 0L), + unresolvedNodes = stalePayload.unresolvedNodes + ListenStatsUnresolvedNode( + "missing.mp3", 2_000L, "{\"unresolved\":true}" + ) + )) + + val node = repository.exportLocalPayload().songs.single { it.identityKey == "test.mp3|1000" } + val contributions = node.contributions.associateBy { it.deviceId to it.generation } + assertEquals(1_500L, contributions["phone" to 0L]!!.dailyListenMs["1970-01-01"]) + assertEquals(2_000L, contributions["desktop" to 0L]!!.dailyListenMs["2026-07-10"]) + assertEquals(3_500L, repository.getByIdentityKey("test.mp3|1000")!!.totalListenMs) + assertTrue(repository.exportLocalPayload().tombstones.any { it.deviceId == "retired" }) + assertTrue(repository.exportLocalPayload().unresolvedNodes.any { it.normalizedFileName == "missing.mp3" }) + } + + @Test + fun boundNodesForSameSongAggregateDatedAndUndatedTotals() = runTest { + val repository = repositoryWithStore( + songs = listOf( + songNode("exact.mp3", 239_986L, boundSongId = 42L, + daily = mapOf("2026-07-10" to 100L), undated = 200L), + songNode("exact.mp3", 239_987L, boundSongId = 42L, + daily = mapOf("2026-07-10" to 300L), undated = 400L) + ) + ) + + val stats = repository.allStats.value + assertEquals(1, stats.size) + assertEquals(42L, stats.single().songId) + assertEquals(1_000L, stats.single().totalListenMs) + assertEquals(400L, stats.single().dailyListenMs["2026-07-10"]) + assertEquals(TrackStat.boundPresentationIdentityKey(42L), stats.single().identityKey) + assertEquals(stats.single(), repository.getByIdentityKey("exact.mp3|239986")) + } + + @Test + fun distinctWireIdentitiesSumSameDeviceAndGenerationInsteadOfTakingMax() = runTest { + val repository = repositoryWithStore( + songs = listOf( + songNode("first.mp3", 1_000L, boundSongId = 7L, daily = mapOf("2026-07-10" to 10L)), + songNode("second.mp3", 1_000L, boundSongId = 7L, daily = mapOf("2026-07-10" to 20L)) + ) + ) + + assertEquals(30L, repository.allStats.value.single().dailyListenMs["2026-07-10"]) + } + + @Test + fun tombstonesAreAppliedBeforeBoundAggregation() = runTest { + val repository = repositoryWithStore( + songs = listOf( + songNode("active.mp3", 1_000L, boundSongId = 7L, daily = mapOf("2026-07-10" to 30L)), + songNode("cleared.mp3", 1_001L, boundSongId = 7L, deviceId = "cleared", daily = mapOf("2026-07-10" to 90L)) + ), + tombstones = listOf(ListenStatsTombstone("cleared", 0L)) + ) + + assertEquals(30L, repository.allStats.value.single().totalListenMs) + } + + @Test + fun boundAggregationSaturatesDailyAndUndatedTotals() = runTest { + val repository = repositoryWithStore( + songs = listOf( + songNode("first.mp3", 1_000L, boundSongId = 7L, + daily = mapOf("2026-07-10" to Long.MAX_VALUE), undated = Long.MAX_VALUE), + songNode("second.mp3", 1_001L, boundSongId = 7L, + daily = mapOf("2026-07-10" to 1L), undated = 1L) + ) + ) + + val stat = repository.allStats.value.single() + assertEquals(Long.MAX_VALUE, stat.dailyListenMs["2026-07-10"]) + assertEquals(Long.MAX_VALUE, stat.totalListenMs) + } + + @Test + fun unboundRowsRemainSeparateByExactWireIdentityAndExportUnchanged() = runTest { + val songs = listOf( + songNode("exact.mp3", 239_986L, daily = mapOf("2026-07-10" to 3_000L)), + songNode("exact.mp3", 239_987L, daily = mapOf("2026-07-10" to 5_000L)) + ) + val repository = repositoryWithStore(songs = songs) + + assertEquals( + listOf("exact.mp3|239986", "exact.mp3|239987"), + repository.allStats.value.map { it.identityKey } + ) + assertEquals(songs, repository.exportLocalPayload().songs) + } + + @Test + fun newStoreUsesProvidedCurrentDeviceDisplayName() = runTest { + val file = File.createTempFile("listen_stats_", ".json").apply { deleteOnExit() } + val repository = ListenStatsRepository( + jsonFile = file, + currentDeviceIdProvider = { "android-1" }, + currentDeviceDisplayNameProvider = { "Pixel Test" }, + wallClockMillis = { 100L } + ) + + assertEquals("Pixel Test", repository.currentSource().device.displayName) + } + + @Test + fun normalizedStoreBackfillsBlankDeviceNamesWithoutOverwritingExistingNames() = runTest { + val file = File.createTempFile("listen_stats_", ".json").apply { + deleteOnExit() + writeText(Gson().toJson(ListenStatsStore( + devices = listOf( + ListenStatsDevice("current", displayName = "", platform = "android"), + ListenStatsDevice("remote", displayName = "", platform = "windows"), + ListenStatsDevice("named", displayName = "Desktop", platform = "windows") + ), + currentDeviceId = "current" + ))) + } + + val repository = ListenStatsRepository( + jsonFile = file, + currentDeviceIdProvider = { "current" }, + currentDeviceDisplayNameProvider = { "Pixel Test" }, + wallClockMillis = { 100L } + ) + + val devices = repository.exportLocalPayload().devices.associateBy { it.deviceId } + assertEquals("Pixel Test", devices.getValue("current").displayName) + assertEquals("Device remote", devices.getValue("remote").displayName) + assertEquals("Desktop", devices.getValue("named").displayName) + val persisted = Gson().fromJson(file.readText(), ListenStatsStore::class.java) + assertEquals("Pixel Test", persisted.devices.first { it.deviceId == "current" }.displayName) + assertEquals("Device remote", persisted.devices.first { it.deviceId == "remote" }.displayName) + } + + private fun assertInvalidStoreStartsFresh(raw: String) { + val file = File.createTempFile("invalid_listen_stats_", ".json").apply { + deleteOnExit() + writeText(raw) + } + val repository = ListenStatsRepository(file) + + assertTrue(repository.allStats.value.isEmpty()) + assertEquals(raw, file.readText()) + } + + private fun repositoryAt(date: LocalDate): ListenStatsRepository { + val file = File.createTempFile("listen_stats_", ".json").apply { deleteOnExit() } + val zone = ZoneId.of("UTC") + return ListenStatsRepository( + jsonFile = file, + wallClockMillis = { date.atTime(12, 0).atZone(zone).toInstant().toEpochMilli() }, + zoneId = zone + ) + } + + private fun repositoryWithDevice(deviceId: String, now: Long): ListenStatsRepository { + val file = File.createTempFile("listen_stats_", ".json").apply { deleteOnExit() } + return ListenStatsRepository( + jsonFile = file, + currentDeviceIdProvider = { deviceId }, + wallClockMillis = { now }, + zoneId = ZoneId.of("UTC") + ) + } + + private fun repositoryWithStore( + songs: List, + tombstones: List = emptyList() + ): ListenStatsRepository { + val file = File.createTempFile("listen_stats_aggregation_", ".json").apply { deleteOnExit() } + val store = ListenStatsStore( + devices = listOf(ListenStatsDevice(deviceId = "device-a")), + currentDeviceId = "device-a", + songs = songs, + tombstones = tombstones + ) + file.writeText(Gson().toJson(store)) + return ListenStatsRepository( + jsonFile = file, + currentDeviceIdProvider = { "device-a" }, + zoneId = ZoneId.of("UTC") + ) + } + + private fun songNode( + fileName: String, + durationMs: Long, + boundSongId: Long = 0L, + deviceId: String = "device-a", + daily: Map = emptyMap(), + undated: Long = 0L + ) = ListenStatsSongNode( + identityKey = "$fileName|$durationMs", + normalizedFileName = fileName, + fileName = fileName, + boundSongId = boundSongId, + durationMs = durationMs, + contributions = listOf( + ListenStatsContribution( + deviceId = deviceId, + generation = 0L, + dailyListenMs = daily, + undatedListenMs = undated + ) + ) + ) + + private fun stat( + totalListenMs: Long = 0L, + dailyListenMs: Map = emptyMap() + ) = TrackStat( + displayName = "Test song", + fileName = "test.mp3", + durationMs = 1_000L, + totalListenMs = totalListenMs, + dailyListenMs = dailyListenMs, + identityKey = "test.mp3|1000" + ) +} diff --git a/app/src/test/java/com/cpu/seamlessloopmobile/data/sync/GitHubSyncCoordinatorTest.kt b/app/src/test/java/com/cpu/seamlessloopmobile/data/sync/GitHubSyncCoordinatorTest.kt new file mode 100644 index 0000000..139699b --- /dev/null +++ b/app/src/test/java/com/cpu/seamlessloopmobile/data/sync/GitHubSyncCoordinatorTest.kt @@ -0,0 +1,521 @@ +package com.cpu.seamlessloopmobile.data.sync + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * GitHubSyncCoordinator 单元测试。 + * + * 验证协调器的同步流程:初始上传、冲突重试、本地并发修改中止。 + * 使用纯 Kotlin fake 实现,不依赖 Android 框架。 + */ +class GitHubSyncCoordinatorTest { + + private lateinit var backend: FakeSyncBackend + private lateinit var snapshotStore: FakeSyncSnapshotStore + private lateinit var metadataStore: FakeSyncMetadataStore + private lateinit var coordinator: GitHubSyncCoordinator + + @Before + fun setUp() { + backend = FakeSyncBackend() + snapshotStore = FakeSyncSnapshotStore() + metadataStore = FakeSyncMetadataStore() + coordinator = GitHubSyncCoordinator( + backend = backend, + snapshotStore = snapshotStore, + metadataStore = metadataStore + ) + } + + // ------------------------------------------------------------------- + // Initial upload on NOT_FOUND + // ------------------------------------------------------------------- + + @Test + fun `initial sync uploads local when remote not found`() = kotlinx.coroutines.runBlocking { + backend.downloadResult = SyncResult.Failure("Not found", code = SyncErrorCode.NOT_FOUND) + snapshotStore.exportResult = SyncSnapshot( + deviceId = "local", exportedAt = 1L, playbackStats = SyncPlaybackStats() + ) + backend.uploadResult = SyncResult.Success( + report = SyncReport(), + remoteRevision = "sha-initial" + ) + + val outcome = coordinator.syncNow() + + assertTrue("Expected SyncOutcome.Success", outcome is SyncOutcome.Success) + val success = outcome as SyncOutcome.Success + assertEquals("sha-initial", success.remoteRevision) + + // Verify upload was called with local snapshot + assertEquals(1, backend.uploadCallCount) + assertEquals(1, backend.downloadCallCount) + assertEquals(SYNC_SCHEMA_VERSION_V2, backend.lastUploadedSnapshot?.schemaVersion) + assertNotNull(backend.lastUploadedSnapshot?.playbackStats) + + // Verify metadata was saved + assertEquals("sha-initial", metadataStore.lastRemoteRevision) + assertTrue(metadataStore.lastSyncTime > 0) + } + + @Test + fun `initial sync uploads schema 2 snapshot with playback statistics`() = kotlinx.coroutines.runBlocking { + backend.downloadResult = SyncResult.Failure("Not found", code = SyncErrorCode.NOT_FOUND) + + val outcome = coordinator.syncNow() + + assertTrue(outcome is SyncOutcome.Success) + assertEquals(SYNC_SCHEMA_VERSION_V2, backend.lastUploadedSnapshot?.schemaVersion) + assertTrue(backend.lastUploadedSnapshot?.playbackStats != null) + } + + @Test + fun `initial upload failure returns failure outcome`() = kotlinx.coroutines.runBlocking { + backend.downloadResult = SyncResult.Failure("Not found", code = SyncErrorCode.NOT_FOUND) + backend.uploadResult = SyncResult.Failure( + "Token expired", code = SyncErrorCode.UNAUTHORIZED + ) + + val outcome = coordinator.syncNow() + + assertTrue("Expected SyncOutcome.Failure", outcome is SyncOutcome.Failure) + val failure = outcome as SyncOutcome.Failure + assertEquals(SyncErrorCode.UNAUTHORIZED, failure.code) + } + + // ------------------------------------------------------------------- + // Download failure (non-NOT_FOUND) + // ------------------------------------------------------------------- + + @Test + fun `non not-found download failure returns failure outcome`() = kotlinx.coroutines.runBlocking { + backend.downloadResult = SyncResult.Failure("Network error", code = SyncErrorCode.NETWORK) + + val outcome = coordinator.syncNow() + + assertTrue("Expected SyncOutcome.Failure", outcome is SyncOutcome.Failure) + assertEquals(SyncErrorCode.NETWORK, (outcome as SyncOutcome.Failure).code) + assertEquals(0, backend.uploadCallCount) + } + + @Test + fun `unsupported remote schema is rejected before merge or upload`() = kotlinx.coroutines.runBlocking { + backend.downloadResult = SyncResult.Success( + SyncReport(), + SyncSnapshot(schemaVersion = 999, deviceId = "remote", exportedAt = 1L), + remoteRevision = "remote-sha" + ) + + val outcome = coordinator.syncNow() + + assertTrue(outcome is SyncOutcome.Failure) + assertEquals(SyncErrorCode.INVALID_REMOTE, (outcome as SyncOutcome.Failure).code) + assertEquals(0, backend.uploadCallCount) + } + + // ------------------------------------------------------------------- + // Normal merge upload + // ------------------------------------------------------------------- + + @Test + fun `normal merge upload cycle succeeds`() = kotlinx.coroutines.runBlocking { + // Remote exists + backend.downloadResult = SyncResult.Success( + report = SyncReport(), + snapshot = SyncSnapshot( + deviceId = "remote", + exportedAt = 200L, + playlists = listOf( + SyncPlaylist("p1", "Remote Playlist", 100L, 200L) + ) + ), + remoteRevision = "remote-sha" + ) + backend.uploadResult = SyncResult.Success( + report = SyncReport(), + remoteRevision = "new-sha-after-merge" + ) + + // Local has data + snapshotStore.exportResult = SyncSnapshot( + deviceId = "local-device", + exportedAt = 100L, + playlists = listOf( + SyncPlaylist("p2", "Local Playlist", 300L, 400L) + ) + ) + + val outcome = coordinator.syncNow() + + assertTrue("Expected SyncOutcome.Success", outcome is SyncOutcome.Success) + val success = outcome as SyncOutcome.Success + assertEquals("new-sha-after-merge", success.remoteRevision) + + // Verify merge: merged snapshot should have both playlists + assertEquals(1, backend.uploadCallCount) + val uploadedSnapshot = backend.lastUploadedSnapshot + assertNotNull(uploadedSnapshot) + assertEquals(2, uploadedSnapshot?.playlists?.size) + assertEquals(SYNC_SCHEMA_VERSION_V2, uploadedSnapshot?.schemaVersion) + assertNotNull(uploadedSnapshot?.playbackStats) + + // Verify metadata saved + assertEquals("new-sha-after-merge", metadataStore.lastRemoteRevision) + } + + @Test + fun `normal merge upload emits schema 2 snapshot with playback statistics`() = kotlinx.coroutines.runBlocking { + backend.downloadResult = SyncResult.Success( + SyncReport(), SyncSnapshot(deviceId = "remote", exportedAt = 200L), remoteRevision = "remote-sha" + ) + + val outcome = coordinator.syncNow() + + assertTrue(outcome is SyncOutcome.Success) + assertEquals(SYNC_SCHEMA_VERSION_V2, backend.lastUploadedSnapshot?.schemaVersion) + assertTrue(backend.lastUploadedSnapshot?.playbackStats != null) + } + + // ------------------------------------------------------------------- + // Conflict retry + // ------------------------------------------------------------------- + + @Test + fun `conflict triggers re-download and retry`() = kotlinx.coroutines.runBlocking { + // Remote exists initially + backend.downloadResult = SyncResult.Success( + report = SyncReport(), + snapshot = SyncSnapshot( + deviceId = "remote", + exportedAt = 200L, + playlists = listOf(SyncPlaylist("p1", "Remote", 100L, 200L)) + ), + remoteRevision = "remote-sha" + ) + + // First upload fails with CONFLICT + backend.uploadResults = mutableListOf( + SyncResult.Failure("Conflict", code = SyncErrorCode.CONFLICT), + SyncResult.Success(report = SyncReport(), remoteRevision = "final-sha") + ) + + // Re-download returns updated remote + backend.downloadResults = mutableListOf( + // first call from syncNow + SyncResult.Success( + report = SyncReport(), + snapshot = SyncSnapshot( + deviceId = "remote", + exportedAt = 200L, + playlists = listOf(SyncPlaylist("p1", "Remote", 100L, 200L)) + ), + remoteRevision = "remote-sha" + ), + // second call from re-download after conflict + SyncResult.Success( + report = SyncReport(), + snapshot = SyncSnapshot( + deviceId = "remote", + exportedAt = 300L, + playlists = listOf( + SyncPlaylist("p1", "Remote Updated", 100L, 500L) + ) + ), + remoteRevision = "updated-remote-sha" + ) + ) + + snapshotStore.exportResult = SyncSnapshot( + deviceId = "local-device", + exportedAt = 100L, + playlists = listOf(SyncPlaylist("p1", "Local", 100L, 300L)) + ) + + val outcome = coordinator.syncNow() + + assertTrue("Expected SyncOutcome.Success", outcome is SyncOutcome.Success) + val success = outcome as SyncOutcome.Success + assertEquals("final-sha", success.remoteRevision) + + // Should have attempted upload twice (conflict + retry) + assertEquals(2, backend.uploadCallCount) + // Should have downloaded twice (initial + re-download) + assertEquals(2, backend.downloadCallCount) + assertTrue(backend.uploadedSnapshots.all { + it.schemaVersion == SYNC_SCHEMA_VERSION_V2 && it.playbackStats != null + }) + + // Verify metadata saved + assertEquals("final-sha", metadataStore.lastRemoteRevision) + } + + @Test + fun `conflict retry emits schema 2 for every upload`() = kotlinx.coroutines.runBlocking { + backend.downloadResults = mutableListOf( + SyncResult.Success( + SyncReport(), SyncSnapshot(deviceId = "remote", exportedAt = 1L), remoteRevision = "sha-1" + ), + SyncResult.Success( + SyncReport(), SyncSnapshot(deviceId = "remote", exportedAt = 2L), remoteRevision = "sha-2" + ) + ) + backend.uploadResults = mutableListOf( + SyncResult.Failure("Conflict", code = SyncErrorCode.CONFLICT), + SyncResult.Success(SyncReport(), remoteRevision = "sha-3") + ) + + val outcome = coordinator.syncNow() + + assertTrue(outcome is SyncOutcome.Success) + assertEquals(2, backend.uploadedSnapshots.size) + assertTrue(backend.uploadedSnapshots.all { + it.schemaVersion == SYNC_SCHEMA_VERSION_V2 && it.playbackStats != null + }) + } + + @Test + fun `conflict retries exhausted returns failure`() = kotlinx.coroutines.runBlocking { + backend.downloadResult = SyncResult.Success( + report = SyncReport(), + snapshot = SyncSnapshot( + deviceId = "remote", exportedAt = 200L, + playlists = listOf(SyncPlaylist("p1", "Remote", 100L, 200L)) + ), + remoteRevision = "remote-sha" + ) + + // All uploads fail with CONFLICT + backend.uploadResult = SyncResult.Failure("Conflict", code = SyncErrorCode.CONFLICT) + + // Re-download always returns same data + backend.downloadResults = mutableListOf( + SyncResult.Success( + SyncReport(), + SyncSnapshot(deviceId = "remote", exportedAt = 200L), + remoteRevision = "remote-sha" + ), + SyncResult.Success( + SyncReport(), + SyncSnapshot(deviceId = "remote", exportedAt = 200L), + remoteRevision = "remote-sha" + ), + SyncResult.Success( + SyncReport(), + SyncSnapshot(deviceId = "remote", exportedAt = 200L), + remoteRevision = "remote-sha" + ), + SyncResult.Success( + SyncReport(), + SyncSnapshot(deviceId = "remote", exportedAt = 200L), + remoteRevision = "remote-sha" + ) + ) + + snapshotStore.exportResult = SyncSnapshot(deviceId = "local-device", exportedAt = 100L) + + // maxConflictRetries = 2 → 3 total attempts (1 initial + 2 retries) + val outcome = coordinator.syncNow(maxConflictRetries = 2) + + assertTrue("Expected SyncOutcome.Failure", outcome is SyncOutcome.Failure) + assertEquals(SyncErrorCode.CONFLICT, (outcome as SyncOutcome.Failure).code) + assertEquals(3, backend.uploadCallCount) // 1 initial + 2 retries + assertTrue(backend.uploadedSnapshots.all { + it.schemaVersion == SYNC_SCHEMA_VERSION_V2 && it.playbackStats != null + }) + } + + // ------------------------------------------------------------------- + // Local mutation during sync + // ------------------------------------------------------------------- + + @Test + fun `local mutation during sync aborts with LocalMutationDuringSync`() = kotlinx.coroutines.runBlocking { + backend.downloadResult = SyncResult.Success( + report = SyncReport(), + snapshot = SyncSnapshot( + deviceId = "remote", exportedAt = 200L, + playlists = listOf(SyncPlaylist("p1", "Remote", 100L, 200L)) + ), + remoteRevision = "remote-sha" + ) + + snapshotStore.exportResult = SyncSnapshot( + deviceId = "local-device", exportedAt = 100L, + playlists = listOf(SyncPlaylist("p1", "Local", 100L, 300L)) + ) + + // Simulate mutation occurring during merge + metadataStore.mutationVersionAfterCheck = metadataStore.mutationVersion + 1 + + val outcome = coordinator.syncNow() + + assertTrue("Expected SyncOutcome.LocalMutationDuringSync", + outcome is SyncOutcome.LocalMutationDuringSync) + + // Verify no upload occurred + assertEquals(0, backend.uploadCallCount) + // Verify no snapshot was applied + assertEquals(null, snapshotStore.applyCalledCount) + } + + @Test + fun `playback stats delta during sync aborts before apply and upload`() = kotlinx.coroutines.runBlocking { + backend.downloadResult = SyncResult.Success( + report = SyncReport(), + snapshot = SyncSnapshot(deviceId = "remote", exportedAt = 200L), + remoteRevision = "remote-sha" + ) + snapshotStore.exportResult = SyncSnapshot( + deviceId = "local-device", + exportedAt = 100L, + playbackStats = SyncPlaybackStats() + ) + snapshotStore.onExport = { metadataStore.markMutation() } + + val outcome = coordinator.syncNow() + + assertTrue(outcome is SyncOutcome.LocalMutationDuringSync) + assertEquals(0, backend.uploadCallCount) + assertEquals(null, snapshotStore.applyCalledCount) + } + + // ------------------------------------------------------------------- + // Cancelled + // ------------------------------------------------------------------- + + @Test + fun `cancelled download returns cancelled outcome`() = kotlinx.coroutines.runBlocking { + backend.downloadResult = SyncResult.Cancelled + + val outcome = coordinator.syncNow() + + assertTrue("Expected SyncOutcome.Cancelled", outcome is SyncOutcome.Cancelled) + } + + // ------------------------------------------------------------------- + // Fake implementations + // ------------------------------------------------------------------- + + /** + * Fake [SyncBackend] with controllable results per call. + */ + class FakeSyncBackend : SyncBackend { + /** Result returned for all download calls (unless [downloadResults] is set). */ + var downloadResult: SyncResult = SyncResult.Failure("Not found", code = SyncErrorCode.NOT_FOUND) + + /** Ordered queue of download results (takes precedence over [downloadResult]). */ + var downloadResults: MutableList = mutableListOf() + + /** Result returned for all upload calls (unless [uploadResults] is set). */ + var uploadResult: SyncResult = SyncResult.Success(SyncReport(), remoteRevision = "fake-sha") + + /** Ordered queue of upload results (takes precedence over [uploadResult]). */ + var uploadResults: MutableList = mutableListOf() + + var downloadCallCount = 0 + var uploadCallCount = 0 + var lastUploadedSnapshot: SyncSnapshot? = null + val uploadedSnapshots = mutableListOf() + var lastExpectedRevision: String? = null + + override suspend fun downloadSnapshot(snapshotId: String?): SyncResult { + downloadCallCount++ + return when { + downloadResults.isNotEmpty() -> downloadResults.removeAt(0) + else -> downloadResult + } + } + + override suspend fun uploadSnapshot( + snapshot: SyncSnapshot, + expectedRevision: String? + ): SyncResult { + uploadCallCount++ + lastUploadedSnapshot = snapshot + uploadedSnapshots += snapshot + lastExpectedRevision = expectedRevision + return when { + uploadResults.isNotEmpty() -> uploadResults.removeAt(0) + else -> uploadResult + } + } + + override suspend fun listSnapshots(): SyncResult { + return SyncResult.Success(SyncReport()) + } + } + + /** + * Fake [SyncSnapshotStore] with controllable export result. + */ + class FakeSyncSnapshotStore : SyncSnapshotStore { + var exportResult: SyncSnapshot = SyncSnapshot( + deviceId = "fake-device", + exportedAt = 1000L + ) + var applyCalledCount: Int? = null + var lastAppliedSnapshot: SyncSnapshot? = null + var applyReport: SyncReport = SyncReport() + var onExport: (suspend () -> Unit)? = null + + override suspend fun exportSnapshot(deviceId: String, now: Long): SyncSnapshot { + onExport?.invoke() + return exportResult.copy(deviceId = deviceId, exportedAt = now) + } + + override suspend fun applySnapshot( + snapshot: SyncSnapshot, + trackLocalMutation: Boolean + ): SyncReport { + applyCalledCount = (applyCalledCount ?: 0) + 1 + lastAppliedSnapshot = snapshot + return applyReport + } + } + + /** + * Fake [SyncMetadataStore] with controllable mutation version. + */ + class FakeSyncMetadataStore : SyncMetadataStore { + var deviceId: String = "test-device-fake" + var lastSyncTime: Long = 0L + var lastRemoteRevision: String? = null + var mutationVersion: Int = 0 + + /** If non-null, this value is returned during the mutation check after merge. */ + var mutationVersionAfterCheck: Int? = null + private var checkCount = 0 + + override suspend fun getDeviceId(): String = deviceId + override suspend fun getLastSyncTime(): Long = lastSyncTime + override suspend fun getLastRemoteRevision(): String? = lastRemoteRevision + + override suspend fun getMutationVersion(): Int { + checkCount++ + // Return the "after check" value on the second getMutationVersion call + // (which happens after merge in the coordinator) + val afterCheck = mutationVersionAfterCheck + return if (checkCount >= 2 && afterCheck != null) { + afterCheck + } else { + mutationVersion + } + } + + override suspend fun markMutation() { mutationVersion++ } + override suspend fun saveSuccessfulSync(remoteRevision: String, syncTime: Long) { + lastRemoteRevision = remoteRevision + lastSyncTime = syncTime + } + + override suspend fun clearSyncMetadata() { + lastRemoteRevision = null + lastSyncTime = 0L + } + } +} diff --git a/app/src/test/java/com/cpu/seamlessloopmobile/data/sync/SyncDataManagementRepositoryTest.kt b/app/src/test/java/com/cpu/seamlessloopmobile/data/sync/SyncDataManagementRepositoryTest.kt new file mode 100644 index 0000000..f17400f --- /dev/null +++ b/app/src/test/java/com/cpu/seamlessloopmobile/data/sync/SyncDataManagementRepositoryTest.kt @@ -0,0 +1,1002 @@ +package com.cpu.seamlessloopmobile.data.sync + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.cpu.seamlessloopmobile.data.stats.ListenStatsRepository +import com.cpu.seamlessloopmobile.data.stats.ListenStatsContribution +import com.cpu.seamlessloopmobile.data.stats.ListenStatsDevice +import com.cpu.seamlessloopmobile.data.stats.ListenStatsUnresolvedNode +import com.cpu.seamlessloopmobile.data.stats.ListenStatsSongNode +import com.cpu.seamlessloopmobile.data.stats.TrackStat +import com.cpu.seamlessloopmobile.data.sync.room.PlaylistIdMapper +import com.cpu.seamlessloopmobile.data.sync.room.PlaylistSyncRecord +import com.cpu.seamlessloopmobile.data.sync.room.RoomSyncSnapshotStore +import com.cpu.seamlessloopmobile.db.AppDatabase +import com.cpu.seamlessloopmobile.model.LoopPoint +import com.cpu.seamlessloopmobile.model.Playlist +import com.cpu.seamlessloopmobile.model.PlaylistDao +import com.cpu.seamlessloopmobile.model.Song +import com.cpu.seamlessloopmobile.model.SongDao +import com.cpu.seamlessloopmobile.model.SongEntity +import com.cpu.seamlessloopmobile.model.UserRating +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File + +/** + * SyncDataManagementRepository 单元测试。 + * + * 验证预览、初始云端写入、云端删除、本地清除等管理操作。 + * 使用 Robolectric 内存数据库和 fake 依赖。 + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class SyncDataManagementRepositoryTest { + + private lateinit var db: AppDatabase + private lateinit var songDao: SongDao + private lateinit var playlistDao: PlaylistDao + private lateinit var mapper: FakePlaylistIdMapper + private lateinit var snapshotStore: RoomSyncSnapshotStore + private lateinit var fakeBackend: FakeGitHubSnapshotRemote + private lateinit var fakeMetadata: FakeSyncMetadataStore + private lateinit var listenStatsRepository: ListenStatsRepository + private lateinit var listenStatsFile: File + private lateinit var repo: SyncDataManagementRepository + private lateinit var localRepo: SyncDataManagementRepository + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) + .allowMainThreadQueries() + .build() + songDao = db.songDao() + playlistDao = db.playlistDao() + mapper = FakePlaylistIdMapper() + fakeBackend = FakeGitHubSnapshotRemote() + fakeMetadata = FakeSyncMetadataStore() + listenStatsFile = File.createTempFile("listen_stats", ".json") + listenStatsRepository = ListenStatsRepository(listenStatsFile) + snapshotStore = RoomSyncSnapshotStore(db, songDao, playlistDao, mapper, listenStatsRepository) + repo = SyncDataManagementRepository( + database = db, + songDao = songDao, + playlistDao = playlistDao, + snapshotStore = snapshotStore, + backend = fakeBackend, + metadataStore = fakeMetadata, + playlistIdMapper = mapper, + listenStatsRepository = listenStatsRepository + ) + localRepo = SyncDataManagementRepository( + database = db, + songDao = songDao, + playlistDao = playlistDao, + snapshotStore = snapshotStore, + metadataStore = fakeMetadata, + playlistIdMapper = mapper, + listenStatsRepository = listenStatsRepository + ) + } + + @After + fun tearDown() { + db.close() + listenStatsFile.delete() + } + + // =================================================================== + // Helper + // =================================================================== + + /** Insert a test song and optionally loop point + rating. Returns song id. */ + private suspend fun insertSong( + fileName: String, + filePath: String = "/storage/$fileName", + duration: Long = 100_000L, + totalSamples: Long = 0L, + loopStart: Long = 0L, + loopEnd: Long = 0L, + rating: Int = 0, + ratingLastModified: Long = 0L + ): Long { + val id = songDao.insertSongEntity(SongEntity( + fileName = fileName, + filePath = filePath, + duration = duration, + totalSamples = totalSamples + )) + if (loopStart != 0L || loopEnd != 0L) { + songDao.insertLoopPoint(LoopPoint(id, loopStart, loopEnd)) + } + if (rating != 0) { + songDao.insertUserRating(UserRating(id, rating, ratingLastModified)) + } + return id + } + + // =================================================================== + // 1. preview matched / missing cloud song references + // =================================================================== + + @Test + fun `preview counts matched and missing cloud song references`() = runBlocking { + // Insert a local song that will be matched + insertSong(fileName = "matched.mp3", filePath = "/a/matched.mp3", duration = 100_000L) + + // Remote snapshot referencing matched + missing songs + val matchedRef = SyncSongIdentity("matched.mp3", 100_000L) + val missingRef = SyncSongIdentity("missing.mp3", 200_000L) + + val cloudSnapshot = SyncSnapshot( + deviceId = "cloud-device", + exportedAt = 5000L, + playlists = listOf( + SyncPlaylist( + id = "sync-pl-1", + name = "Cloud Playlist", + createdAt = 100L, + modifiedAt = 200L, + items = listOf( + SyncPlaylistItem(matchedRef, 0), + SyncPlaylistItem(missingRef, 1) + ) + ) + ), + loopPoints = listOf( + SyncLoopPointEntry(matchedRef, SyncLoopPoint(1000L, 5000L, 100L)) + ), + ratings = listOf( + SyncRatingEntry(missingRef, SyncRating(5, 200L)) + ) + ) + + fakeBackend.downloadResult = SyncResult.Success( + report = SyncReport(), + snapshot = cloudSnapshot, + remoteRevision = "sha-1" + ) + + val result = repo.preview() + + assertTrue("Expected Success", result is SyncDataManagementResult.Success) + val preview = (result as SyncDataManagementResult.Success).data + + // Local: 1 song, 0 playlists + assertEquals(1, preview.local.songCount) + assertEquals(0, preview.local.playlistCount) + + // Cloud: exists, 1 playlist, 1 playlistItem, 1 loop, 1 rating + val cloud = preview.cloud + assertNotNull(cloud) + assertEquals(true, cloud!!.exists) + assertEquals("cloud-device", cloud.deviceId) + assertEquals(1, cloud.playlists) + assertEquals(2, cloud.playlistItems) // 2 items across all playlists + assertEquals(1, cloud.loopPointCount) + assertEquals(1, cloud.ratingCount) + + // 2 unique song refs: matched.mp3 + missing.mp3 + // matched.mp3 matches local → matched=1 + // missing.mp3 has no local → missing=1 + assertEquals(1, cloud.matchedSongReferenceCount) + assertEquals(1, cloud.missingSongReferenceCount) + assertEquals(1, cloud.missingSongReferences.size) + assertEquals(missingRef, cloud.missingSongReferences[0]) + } + + @Test + fun `preview de-duplicates identical song refs across playlists loop and rating`() = runBlocking { + insertSong(fileName = "common.mp3", filePath = "/a/common.mp3", duration = 90_000L) + + val commonRef = SyncSongIdentity("common.mp3", 90_000L) + val missingRef = SyncSongIdentity("unique.mp3", 80_000L) + + val cloudSnapshot = SyncSnapshot( + deviceId = "d", exportedAt = 100L, + playlists = listOf( + SyncPlaylist("p1", "P1", 0L, 0L, items = listOf( + SyncPlaylistItem(commonRef, 0), + SyncPlaylistItem(commonRef, 1) // same ref twice + )), + SyncPlaylist("p2", "P2", 0L, 0L, items = listOf( + SyncPlaylistItem(missingRef, 0) + )) + ), + loopPoints = listOf( + SyncLoopPointEntry(commonRef, SyncLoopPoint(100L, 200L, 10L)) + ), + ratings = listOf( + SyncRatingEntry(commonRef, SyncRating(3, 20L)) + ) + ) + + fakeBackend.downloadResult = SyncResult.Success( + report = SyncReport(), snapshot = cloudSnapshot, remoteRevision = "s" + ) + + val result = repo.preview() + assertTrue(result is SyncDataManagementResult.Success) + val cloud = (result as SyncDataManagementResult.Success).data.cloud!! + assertTrue(cloud.exists) + + // 2 unique refs total (common.mp3 + unique.mp3) + // common.mp3 matched, missing.mp3 unmatched + assertEquals(1, cloud.matchedSongReferenceCount) + assertEquals(1, cloud.missingSongReferenceCount) + // Total items = 3 (2 items from p1 + 1 item from p2) + assertEquals(3, cloud.playlistItems) + } + + @Test + fun `preview treats same file and duration with different totalSamples as matched same reference`() = runBlocking { + insertSong( + fileName = "1-02. Summer Pockets.flac", + filePath = "/a/1-02. Summer Pockets.flac", + duration = 239_987L, + totalSamples = 10_583_412L + ) + + val phoneRef = SyncSongIdentity("1-02. Summer Pockets.flac", 239_987L, 10_583_412L) + val desktopRef = SyncSongIdentity("1-02. Summer Pockets.flac", 239_987L, 10_583_426L) + val cloudSnapshot = SyncSnapshot( + deviceId = "desktop", + exportedAt = 100L, + playlists = listOf( + SyncPlaylist( + id = "p1", + name = "P1", + createdAt = 0L, + modifiedAt = 0L, + items = listOf( + SyncPlaylistItem(desktopRef, 0), + SyncPlaylistItem(phoneRef, 1) + ) + ) + ), + ratings = listOf( + SyncRatingEntry(desktopRef, SyncRating(4, 20L)) + ) + ) + + fakeBackend.downloadResult = SyncResult.Success( + report = SyncReport(), snapshot = cloudSnapshot, remoteRevision = "s" + ) + + val result = repo.preview() + assertTrue(result is SyncDataManagementResult.Success) + val cloud = (result as SyncDataManagementResult.Success).data.cloud!! + + assertEquals(1, cloud.matchedSongReferenceCount) + assertEquals(0, cloud.missingSongReferenceCount) + assertTrue(cloud.missingSongReferences.isEmpty()) + assertEquals(2, cloud.playlistItems) + } + + // =================================================================== + // 2. preview returns non-existing cloud on NOT_FOUND + // =================================================================== + + @Test + fun `preview returns non-existing cloud on not found`() = runBlocking { + // Insert a local song to verify local summary still populated + insertSong(fileName = "local.mp3", filePath = "/a/local.mp3", duration = 50_000L) + + fakeBackend.downloadResult = SyncResult.Failure( + "Not found", code = SyncErrorCode.NOT_FOUND + ) + + val result = repo.preview() + + assertTrue("Expected Success", result is SyncDataManagementResult.Success) + val preview = (result as SyncDataManagementResult.Success).data + + assertEquals(1, preview.local.songCount) + + val cloud = preview.cloud + assertNotNull(cloud) + assertEquals(false, cloud!!.exists) + } + + @Test + fun `preview treats successful null snapshot as non-existing cloud`() = runBlocking { + fakeBackend.downloadResult = SyncResult.Success( + report = SyncReport(), + snapshot = null, + remoteRevision = "sha-empty" + ) + + val result = repo.preview() + + assertTrue("Expected Success", result is SyncDataManagementResult.Success) + val cloud = (result as SyncDataManagementResult.Success).data.cloud + assertNotNull(cloud) + assertEquals(false, cloud!!.exists) + } + + @Test + fun `preview propagates download failure other than not found`() = runBlocking { + fakeBackend.downloadResult = SyncResult.Failure( + "Network error", code = SyncErrorCode.NETWORK + ) + + val result = repo.preview() + + assertTrue("Expected Failure", result is SyncDataManagementResult.Failure) + assertEquals(SyncErrorCode.NETWORK, (result as SyncDataManagementResult.Failure).code) + } + + // =================================================================== + // 3. seed cloud only when the remote snapshot does not exist + // =================================================================== + + @Test + fun `seed cloud creates v2 snapshot when remote is not found`() = runBlocking { + // Insert local data + val songId = insertSong( + fileName = "track.mp3", filePath = "/a/track.mp3", + duration = 120_000L, loopStart = 1000L, loopEnd = 60_000L, rating = 4 + ) + val playlistId = playlistDao.insertPlaylist( + Playlist(name = "My Playlist", createdAt = 100L) + ).toInt() + playlistDao.clearAndSyncPlaylist(playlistId, listOf(songId)) + + fakeBackend.downloadResult = SyncResult.Failure("Not found", code = SyncErrorCode.NOT_FOUND) + fakeBackend.uploadResult = SyncResult.Success( + report = SyncReport(playlistsUploaded = 1, loopPointsUploaded = 1, ratingsUploaded = 1), + remoteRevision = "sha-new" + ) + + val result = repo.seedCloudFromLocal() + + assertTrue("Expected Success", result is SyncDataManagementResult.Success) + val report = (result as SyncDataManagementResult.Success).data + + // Seed upload must use no revision because the file is absent. + assertEquals(1, fakeBackend.uploadCallCount) + assertNull(fakeBackend.lastExpectedRevision) + + // Uploaded snapshot has the right content + assertNotNull(fakeBackend.lastUploadedSnapshot) + assertEquals(1, fakeBackend.lastUploadedSnapshot!!.playlists.size) + assertEquals(1, fakeBackend.lastUploadedSnapshot!!.loopPoints.size) + assertEquals(1, fakeBackend.lastUploadedSnapshot!!.ratings.size) + assertEquals("test-device", fakeBackend.lastUploadedSnapshot!!.deviceId) + assertEquals(SYNC_SCHEMA_VERSION_V2, fakeBackend.lastUploadedSnapshot!!.schemaVersion) + assertNotNull(fakeBackend.lastUploadedSnapshot!!.playbackStats) + + // Report reflects uploaded counts + assertEquals(1, report.playlistsUploaded) + assertEquals(1, report.loopPointsUploaded) + assertEquals(1, report.ratingsUploaded) + + // Metadata was saved + assertEquals("sha-new", fakeMetadata.lastRemoteRevision) + assertTrue(fakeMetadata.lastSyncTime > 0) + } + + @Test + fun `seed cloud rejects any existing remote snapshot without uploading`() = runBlocking { + insertSong(fileName = "a.mp3", filePath = "/a/a.mp3", duration = 50_000L) + + fakeBackend.downloadResult = SyncResult.Success( + SyncReport(), + SyncSnapshot(deviceId = "remote", exportedAt = 1L), + remoteRevision = "sha-existing" + ) + + val result = repo.seedCloudFromLocal() + + assertTrue(result is SyncDataManagementResult.Failure) + assertEquals(SyncErrorCode.INVALID_REMOTE, (result as SyncDataManagementResult.Failure).code) + assertTrue(result.message.contains("normal sync")) + assertTrue(result.message.contains("delete")) + assertEquals(0, fakeBackend.uploadCallCount) + } + + @Test + fun `seed cloud propagates upload conflict without retry`() = runBlocking { + insertSong(fileName = "a.mp3", filePath = "/a/a.mp3", duration = 50_000L) + fakeBackend.downloadResult = SyncResult.Failure("Not found", code = SyncErrorCode.NOT_FOUND) + fakeBackend.uploadResult = SyncResult.Failure( + "Conflict", + code = SyncErrorCode.CONFLICT + ) + + val result = repo.seedCloudFromLocal() + + assertTrue("Expected Failure", result is SyncDataManagementResult.Failure) + assertEquals(SyncErrorCode.CONFLICT, (result as SyncDataManagementResult.Failure).code) + assertEquals(1, fakeBackend.uploadCallCount) + assertNull(fakeBackend.lastExpectedRevision) + assertEquals("sha-old", fakeMetadata.lastRemoteRevision) + } + + // =================================================================== + // 4. delete cloud clears metadata + // =================================================================== + + @Test + fun `delete cloud clears metadata on success`() = runBlocking { + fakeMetadata.lastRemoteRevision = "sha-existing" + fakeMetadata.lastSyncTime = 5000L + + fakeBackend.deleteResult = SyncResult.Success(SyncReport()) + + val result = repo.deleteCloudSnapshot() + + assertTrue("Expected Success", result is SyncDataManagementResult.Success) + + // Metadata cleared + assertNull(fakeMetadata.lastRemoteRevision) + assertEquals(0L, fakeMetadata.lastSyncTime) + } + + @Test + fun `delete cloud propagates failure`() = runBlocking { + fakeBackend.deleteResult = SyncResult.Failure( + "Unauthorized", code = SyncErrorCode.UNAUTHORIZED + ) + + val result = repo.deleteCloudSnapshot() + + assertTrue("Expected Failure", result is SyncDataManagementResult.Failure) + assertEquals(SyncErrorCode.UNAUTHORIZED, (result as SyncDataManagementResult.Failure).code) + + // Metadata should NOT be cleared on failure + assertEquals("sha-old", fakeMetadata.lastRemoteRevision) + } + + // =================================================================== + // 5. clear local selected data + // =================================================================== + + @Test + fun `clear local loop and rating preserves song and playlist`() = runBlocking { + // Insert data + val songId = insertSong( + fileName = "s.mp3", filePath = "/a/s.mp3", duration = 100_000L, + loopStart = 1000L, loopEnd = 5000L, rating = 4, ratingLastModified = 100L + ) + // Verify initial state + var allSongs = songDao.getAllSongs() + assertEquals(1, allSongs.size) + assertEquals(1000L, allSongs[0].loopStart) + assertEquals(4, allSongs[0].rating) + + // Clear loop points + ratings only + val result = repo.clearLocalSyncData( + ClearLocalSyncDataSelection(clearLoopPoints = true, clearRatings = true) + ) + + assertTrue("Expected Success", result is SyncDataManagementResult.Success) + val summary = (result as SyncDataManagementResult.Success).data + + // Song count unchanged + assertEquals(1, summary.songCount) + // Loop and rating should be gone + assertEquals(0, summary.loopPointCount) + assertEquals(0, summary.ratingCount) + + // Verify actual DB state + allSongs = songDao.getAllSongs() + assertEquals(1, allSongs.size) + assertEquals(0L, allSongs[0].loopStart) + assertEquals(0, allSongs[0].rating) + + // Metadata should be cleared + assertNull(fakeMetadata.lastRemoteRevision) + assertEquals(0L, fakeMetadata.lastSyncTime) + } + + @Test + fun `clear local playlists also clears mapper mappings and leaves songs`() = runBlocking { + // Insert data + val songId = insertSong( + fileName = "s.mp3", filePath = "/a/s.mp3", duration = 100_000L, + loopStart = 1000L, loopEnd = 5000L, rating = 3 + ) + val playlistId = playlistDao.insertPlaylist( + Playlist(name = "Test", createdAt = 100L) + ).toInt() + playlistDao.clearAndSyncPlaylist(playlistId, listOf(songId)) + + // Save a mapping to verify it's cleared + mapper.saveMapping("sync-1", playlistId, 200L, "fp") + + // Verify initial state + assertEquals(1, songDao.getAllSongs().size) + assertEquals(1, playlistDao.getPlaylistsWithCounts().size) + assertNotNull(mapper.findLocalId("sync-1")) + + // Clear only playlists + val result = repo.clearLocalSyncData( + ClearLocalSyncDataSelection(clearPlaylists = true) + ) + + assertTrue("Expected Success", result is SyncDataManagementResult.Success) + val summary = (result as SyncDataManagementResult.Success).data + + // Playlist should be gone + assertEquals(0, summary.playlistCount) + assertEquals(0, summary.playlistItemCount) + // Song count unchanged + assertEquals(1, summary.songCount) + // Loop and rating still present + assertEquals(1, summary.loopPointCount) + assertEquals(1, summary.ratingCount) + + // Mapper mappings cleared + assertNull(mapper.findLocalId("sync-1")) + + // Metadata cleared + assertNull(fakeMetadata.lastRemoteRevision) + assertEquals(0L, fakeMetadata.lastSyncTime) + // Mutation version was incremented + assertEquals(6, fakeMetadata.mutationVersion) + } + + @Test + fun `clear local with empty selection returns failure`() = runBlocking { + val result = repo.clearLocalSyncData( + ClearLocalSyncDataSelection() + ) + + assertTrue("Expected Failure", result is SyncDataManagementResult.Failure) + } + + @Test + fun `clear local stats only clears stats without changing synced data or metadata`() = runBlocking { + val songId = insertSong( + fileName = "s.mp3", + loopStart = 1000L, + loopEnd = 5000L, + rating = 4 + ) + val playlistId = playlistDao.insertPlaylist(Playlist(name = "Test", createdAt = 100L)).toInt() + playlistDao.clearAndSyncPlaylist(playlistId, listOf(songId)) + listenStatsRepository.recordListenDeltaNow( + TrackStat(fileName = "s.mp3", durationMs = 100_000L, identityKey = "s.mp3|100000"), + 1_000L + ) + + val result = localRepo.clearLocalSyncData( + ClearLocalSyncDataSelection(clearListenStats = true) + ) + + assertTrue("Expected Success", result is SyncDataManagementResult.Success) + assertTrue(listenStatsRepository.allStats.value.isEmpty()) + assertEquals(1, songDao.getAllSongs().size) + assertEquals(1, playlistDao.getPlaylistsWithCounts().size) + assertEquals(1000L, songDao.getAllSongs().single().loopStart) + assertEquals(4, songDao.getAllSongs().single().rating) + assertEquals("sha-old", fakeMetadata.lastRemoteRevision) + assertEquals(1000L, fakeMetadata.lastSyncTime) + assertEquals(5, fakeMetadata.mutationVersion) + } + + @Test + fun `clear local combined selection clears stats and synced data`() = runBlocking { + insertSong(fileName = "s.mp3", loopStart = 1000L, loopEnd = 5000L, rating = 4) + listenStatsRepository.recordListenDeltaNow( + TrackStat(fileName = "s.mp3", durationMs = 100_000L, identityKey = "s.mp3|100000"), + 1_000L + ) + + val result = repo.clearLocalSyncData( + ClearLocalSyncDataSelection(clearLoopPoints = true, clearListenStats = true) + ) + + assertTrue("Expected Success", result is SyncDataManagementResult.Success) + assertTrue(listenStatsRepository.allStats.value.isEmpty()) + assertEquals(0L, songDao.getAllSongs().single().loopStart) + assertNull(fakeMetadata.lastRemoteRevision) + assertEquals(6, fakeMetadata.mutationVersion) + } + + @Test + fun `local repository clears all selected local data without remote backend`() = runBlocking { + val songId = insertSong(fileName = "s.mp3", loopStart = 1000L, loopEnd = 5000L, rating = 4) + val playlistId = playlistDao.insertPlaylist(Playlist(name = "Test", createdAt = 100L)).toInt() + playlistDao.clearAndSyncPlaylist(playlistId, listOf(songId)) + listenStatsRepository.recordListenDeltaNow( + TrackStat(fileName = "s.mp3", durationMs = 100_000L, identityKey = "s.mp3|100000"), + 1_000L + ) + + val result = localRepo.clearLocalSyncData( + ClearLocalSyncDataSelection( + clearPlaylists = true, + clearLoopPoints = true, + clearRatings = true, + clearListenStats = true + ) + ) + + assertTrue(result is SyncDataManagementResult.Success) + assertTrue(listenStatsRepository.allStats.value.isEmpty()) + assertEquals(1, songDao.getAllSongs().size) + assertEquals(0, playlistDao.getPlaylistsWithCounts().size) + assertEquals(0L, songDao.getAllSongs().single().loopStart) + assertEquals(0, songDao.getAllSongs().single().rating) + assertNull(fakeMetadata.lastRemoteRevision) + assertEquals(6, fakeMetadata.mutationVersion) + } + + @Test + fun `local repository rejects remote operations without backend`() = runBlocking { + assertTrue(localRepo.preview() is SyncDataManagementResult.Failure) + assertTrue(localRepo.seedCloudFromLocal() is SyncDataManagementResult.Failure) + assertTrue(localRepo.deleteCloudSnapshot() is SyncDataManagementResult.Failure) + } + + // =================================================================== + // 6. playback-stat source device management + // =================================================================== + + @Test + fun `lists source devices with aggregated effective contributions`() = runBlocking { + seedStatsSources() + + val result = localRepo.getLocalPlaybackStatsSourceDevices() + + assertTrue(result is SyncDataManagementResult.Success) + val sources = (result as SyncDataManagementResult.Success).data + assertEquals(2, sources.size) + val current = sources.first { it.isCurrentDevice } + val other = sources.first { it.deviceId == "desktop" } + assertEquals(1_500L, current.contributedListenMs) + assertTrue(current.hasEffectiveContributions) + assertEquals("android", current.platform) + assertEquals("Desktop", other.displayName) + assertEquals(2_500L, other.contributedListenMs) + assertTrue(other.hasEffectiveContributions) + } + + @Test + fun `deleting current source rotates generation and removes effective contribution`() = runBlocking { + seedStatsSources() + val before = listenStatsRepository.exportLocalPayload() + + val result = localRepo.deleteLocalPlaybackStatsSourceDeviceHistories( + setOf(before.currentDeviceId) + ) + + assertTrue(result is SyncDataManagementResult.Success) + val current = (result as SyncDataManagementResult.Success).data.first { it.isCurrentDevice } + val after = listenStatsRepository.exportLocalPayload() + assertTrue(after.currentGeneration > before.currentGeneration) + assertEquals(0L, current.contributedListenMs) + assertTrue(!current.hasEffectiveContributions) + } + + @Test + fun `deleting another source suppresses its contribution without rotating current generation`() = runBlocking { + seedStatsSources() + val before = listenStatsRepository.exportLocalPayload() + + val result = localRepo.deleteLocalPlaybackStatsSourceDeviceHistories(setOf("desktop")) + + assertTrue(result is SyncDataManagementResult.Success) + val sources = (result as SyncDataManagementResult.Success).data + val desktop = sources.first { it.deviceId == "desktop" } + val current = sources.first { it.isCurrentDevice } + assertEquals(0L, desktop.contributedListenMs) + assertTrue(!desktop.hasEffectiveContributions) + assertTrue(desktop.allKnownGenerationsRemoved) + assertEquals(before.currentGeneration, current.currentGeneration) + assertEquals(1_500L, current.contributedListenMs) + } + + @Test + fun `source device summary includes unresolved contributions`() = runBlocking { + seedStatsSources(includeUnresolvedDesktopGeneration = true) + val payload = listenStatsRepository.exportLocalPayload() + listenStatsRepository.applyLocalPayload(payload.copy(unresolvedNodes = payload.unresolvedNodes + + ListenStatsUnresolvedNode("structurally-broken", 0L, "{}")), trackMutation = false) + + val result = localRepo.getLocalPlaybackStatsSourceDevices() + + assertTrue(result is SyncDataManagementResult.Success) + val desktop = (result as SyncDataManagementResult.Success).data.first { it.deviceId == "desktop" } + assertEquals(5_500L, desktop.contributedListenMs) + assertTrue(desktop.hasEffectiveContributions) + } + + @Test + fun `source device summary collapses duplicate unresolved cumulative revisions`() = runBlocking { + val payload = listenStatsRepository.exportLocalPayload() + val older = SyncPlaybackStatsSong( + SyncSongIdentity("missing.mp3", 60_000L), + listOf(SyncPlaybackStatsContribution("desktop", 3L, mapOf("2026-01-01" to 2_000L), 100L)) + ) + val newer = older.copy(contributions = listOf( + SyncPlaybackStatsContribution("desktop", 3L, mapOf("2026-01-01" to 5_000L), 400L) + )) + listenStatsRepository.applyLocalPayload(payload.copy(unresolvedNodes = listOf( + ListenStatsUnresolvedNode("missing.mp3", 60_000L, com.google.gson.Gson().toJson(older)), + ListenStatsUnresolvedNode("missing.mp3", 60_000L, com.google.gson.Gson().toJson(newer)) + )), trackMutation = false) + + val result = localRepo.getLocalPlaybackStatsSourceDevices() + + assertTrue(result is SyncDataManagementResult.Success) + val desktop = (result as SyncDataManagementResult.Success).data.single { it.deviceId == "desktop" } + assertEquals(5_400L, desktop.contributedListenMs) + } + + @Test + fun `source device summary ignores semantically invalid unresolved contributions`() = runBlocking { + val invalidDate = """{"song":{"fileName":"bad-date.mp3","durationMs":1,"normalizedFileName":"bad-date.mp3"},"contributions":[{"deviceId":"desktop","generation":0,"datedListenMs":{"not-a-date":1}}]}""" + val negativeCumulative = """{"song":{"fileName":"negative-total.mp3","durationMs":1,"normalizedFileName":"negative-total.mp3"},"contributions":[{"deviceId":"desktop","generation":0,"undatedListenMs":-1}]}""" + val negativeGeneration = """{"song":{"fileName":"negative-generation.mp3","durationMs":1,"normalizedFileName":"negative-generation.mp3"},"contributions":[{"deviceId":"desktop","generation":-1,"datedListenMs":{}}]}""" + val payload = listenStatsRepository.exportLocalPayload() + listenStatsRepository.applyLocalPayload(payload.copy(unresolvedNodes = listOf( + ListenStatsUnresolvedNode("bad-date.mp3", 1L, invalidDate), + ListenStatsUnresolvedNode("negative-total.mp3", 1L, negativeCumulative), + ListenStatsUnresolvedNode("negative-generation.mp3", 1L, negativeGeneration) + )), trackMutation = false) + + val result = localRepo.getLocalPlaybackStatsSourceDevices() + + assertTrue(result is SyncDataManagementResult.Success) + val sources = (result as SyncDataManagementResult.Success).data + assertTrue(sources.none { it.deviceId == "desktop" }) + } + + @Test + fun `deleting another source tombstones unresolved generations too`() = runBlocking { + seedStatsSources(includeUnresolvedDesktopGeneration = true) + + val result = localRepo.deleteLocalPlaybackStatsSourceDeviceHistories(setOf("desktop")) + + assertTrue(result is SyncDataManagementResult.Success) + val desktop = (result as SyncDataManagementResult.Success).data.first { it.deviceId == "desktop" } + val payload = listenStatsRepository.exportLocalPayload() + val desktopTombstones = payload.tombstones.filter { it.deviceId == "desktop" }.map { it.generation }.toSet() + assertEquals(setOf(3L, 4L), desktopTombstones) + assertEquals(0L, desktop.contributedListenMs) + assertTrue(desktop.allKnownGenerationsRemoved) + } + + @Test + fun `clear local stats failure returns failure without claiming success`() = runBlocking { + val failingRepo = repositoryWithUnwritableStatsFile() + + val result = failingRepo.clearLocalSyncData( + ClearLocalSyncDataSelection(clearListenStats = true) + ) + + assertTrue(result is SyncDataManagementResult.Failure) + assertEquals("sha-old", fakeMetadata.lastRemoteRevision) + assertEquals(5, fakeMetadata.mutationVersion) + } + + @Test + fun `combined clear preserves sync metadata mutation when stats clear fails`() = runBlocking { + insertSong(fileName = "s.mp3", loopStart = 1_000L, loopEnd = 5_000L) + val failingRepo = repositoryWithUnwritableStatsFile() + + val result = failingRepo.clearLocalSyncData( + ClearLocalSyncDataSelection(clearLoopPoints = true, clearListenStats = true) + ) + + assertTrue(result is SyncDataManagementResult.Failure) + assertEquals(0L, songDao.getAllSongs().single().loopStart) + assertNull(fakeMetadata.lastRemoteRevision) + assertEquals(6, fakeMetadata.mutationVersion) + } + + // =================================================================== + // 7. getLocalSummary returns correct counts + // =================================================================== + + @Test + fun `getLocalSummary returns accurate counts`() = runBlocking { + // No data → all zeros + var summary = repo.getLocalSummary() + assertEquals(0, summary.songCount) + assertEquals(0, summary.playlistCount) + assertEquals(0, summary.loopPointCount) + assertEquals(0, summary.ratingCount) + + // Insert data + val sA = insertSong(fileName = "a.mp3", filePath = "/a/a.mp3", duration = 100_000L) + val sB = insertSong(fileName = "b.mp3", filePath = "/a/b.mp3", duration = 200_000L, + loopStart = 500L, loopEnd = 1000L, rating = 4) + val sC = insertSong(fileName = "c.mp3", filePath = "/a/c.mp3", duration = 300_000L, + loopStart = 100L, loopEnd = 200L) // no rating + val plId = playlistDao.insertPlaylist(Playlist(name = "P1", createdAt = 100L)).toInt() + playlistDao.clearAndSyncPlaylist(plId, listOf(sA, sB)) + + summary = repo.getLocalSummary() + assertEquals(3, summary.songCount) + assertEquals(1, summary.playlistCount) + assertEquals(2, summary.playlistItemCount) + assertEquals(2, summary.loopPointCount) + assertEquals(1, summary.ratingCount) + } + + private suspend fun seedStatsSources(includeUnresolvedDesktopGeneration: Boolean = false) { + val initial = listenStatsRepository.exportLocalPayload() + val currentId = initial.currentDeviceId + listenStatsRepository.applyLocalPayload( + initial.copy( + devices = initial.devices + ListenStatsDevice( + deviceId = "desktop", + displayName = "Desktop", + platform = "windows", + currentGeneration = 3L + ), + songs = listOf( + ListenStatsSongNode( + identityKey = "one|1", + normalizedFileName = "one.mp3", + contributions = listOf( + ListenStatsContribution( + deviceId = currentId, + generation = initial.currentGeneration, + dailyListenMs = mapOf("2026-01-01" to 1_000L), + undatedListenMs = 500L + ), + ListenStatsContribution( + deviceId = "desktop", + generation = 3L, + dailyListenMs = mapOf("2026-01-01" to 2_000L), + undatedListenMs = 500L + ) + ) + ) + ), + unresolvedNodes = if (includeUnresolvedDesktopGeneration) { + listOf( + ListenStatsUnresolvedNode( + normalizedFileName = "ghost.mp3", + durationMs = 10L, + payloadJson = com.google.gson.Gson().toJson( + SyncPlaybackStatsSong( + SyncSongIdentity("ghost.mp3", 10L), + listOf( + SyncPlaybackStatsContribution( + deviceId = "desktop", + generation = 4L, + datedListenMs = mapOf("2026-01-02" to 3_000L) + ) + ) + ) + ) + ) + ) + } else { + initial.unresolvedNodes + } + ) + ) + } + + private fun repositoryWithUnwritableStatsFile(): SyncDataManagementRepository { + val statsParentAsFile = File.createTempFile("listen_stats_parent", ".tmp").apply { + deleteOnExit() + } + return SyncDataManagementRepository( + database = db, + songDao = songDao, + playlistDao = playlistDao, + snapshotStore = snapshotStore, + backend = fakeBackend, + metadataStore = fakeMetadata, + playlistIdMapper = mapper, + listenStatsRepository = ListenStatsRepository(File(statsParentAsFile, "listen_stats.json")) + ) + } + + // =================================================================== + // Fake implementations + // =================================================================== + + class FakeGitHubSnapshotRemote : GitHubSnapshotRemote { + var downloadResult: SyncResult = SyncResult.Failure( + "Not found", code = SyncErrorCode.NOT_FOUND + ) + var uploadResult: SyncResult = SyncResult.Success( + SyncReport(), remoteRevision = "fake-sha" + ) + var deleteResult: SyncResult = SyncResult.Success(SyncReport()) + + var downloadCallCount = 0 + var uploadCallCount = 0 + var lastUploadedSnapshot: SyncSnapshot? = null + var lastExpectedRevision: String? = null + + override suspend fun downloadSnapshot(snapshotId: String?): SyncResult { + downloadCallCount++ + return downloadResult + } + + override suspend fun uploadSnapshot( + snapshot: SyncSnapshot, + expectedRevision: String? + ): SyncResult { + uploadCallCount++ + lastUploadedSnapshot = snapshot + lastExpectedRevision = expectedRevision + return uploadResult + } + + override suspend fun deleteSnapshot(): SyncResult { + return deleteResult + } + } + + class FakeSyncMetadataStore : SyncMetadataStore { + var deviceId: String = "test-device" + var lastSyncTime: Long = 1000L + var lastRemoteRevision: String? = "sha-old" + var mutationVersion: Int = 5 + + override suspend fun getDeviceId(): String = deviceId + override suspend fun getLastSyncTime(): Long = lastSyncTime + override suspend fun getLastRemoteRevision(): String? = lastRemoteRevision + override suspend fun getMutationVersion(): Int = mutationVersion + override suspend fun markMutation() { mutationVersion++ } + override suspend fun saveSuccessfulSync(remoteRevision: String, syncTime: Long) { + lastRemoteRevision = remoteRevision + lastSyncTime = syncTime + } + override suspend fun clearSyncMetadata() { + lastRemoteRevision = null + lastSyncTime = 0L + } + } + + class FakePlaylistIdMapper : PlaylistIdMapper { + private val records = mutableMapOf() + + override suspend fun getOrCreateSyncIdForExport( + localId: Int, fingerprint: String, now: Long + ): PlaylistSyncRecord { + val existing = records.values.firstOrNull { it.localId == localId } + if (existing != null) { + val updated = if (existing.fingerprint == fingerprint) existing + else existing.copy(modifiedAt = now, fingerprint = fingerprint) + records[updated.syncId] = updated + return updated + } + val syncId = "sync-${localId}-$now" + val rec = PlaylistSyncRecord(syncId, localId, now, fingerprint) + records[syncId] = rec + return rec + } + + override suspend fun findLocalId(syncId: String): Int? = records[syncId]?.localId + override suspend fun findSyncId(localId: Int): String? = + records.values.firstOrNull { it.localId == localId }?.syncId + + override suspend fun saveMapping( + syncId: String, localId: Int, modifiedAt: Long, fingerprint: String + ) { + records.entries.removeAll { it.key == syncId || it.value.localId == localId } + records[syncId] = PlaylistSyncRecord(syncId, localId, modifiedAt, fingerprint) + } + + override suspend fun removeStaleMappings(validLocalIds: Set) { + records.entries.removeAll { it.value.localId !in validLocalIds } + } + + override suspend fun clearAllMappings() { + records.clear() + } + } +} diff --git a/app/src/test/java/com/cpu/seamlessloopmobile/data/sync/SyncMergeEngineTest.kt b/app/src/test/java/com/cpu/seamlessloopmobile/data/sync/SyncMergeEngineTest.kt new file mode 100644 index 0000000..08d3669 --- /dev/null +++ b/app/src/test/java/com/cpu/seamlessloopmobile/data/sync/SyncMergeEngineTest.kt @@ -0,0 +1,643 @@ +package com.cpu.seamlessloopmobile.data.sync + +import com.google.gson.JsonParser +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * SyncMergeEngine 单元测试。 + * 验证双向合并的正确性:按歌单 ID 和歌曲身份标识合并, + * 保护本地元数据,以及报告冲突。 + */ +class SyncMergeEngineTest { + + private val engine = SyncMergeEngine() + + @Test + fun `merge playback statistics uses cumulative maxima and deterministic ordering`() { + val songA = SyncSongIdentity("a.mp3", 1L) + val songB = SyncSongIdentity("B.mp3", 2L) + val remote = statsSnapshot( + songs = listOf( + SyncPlaybackStatsSong(songB, listOf(contribution("device-b", 1L, 4L))), + SyncPlaybackStatsSong(songA, listOf(contribution("device-a", 0L, 2L))) + ), + devices = listOf(device("device-b", "Old", 1L, 4L), device("device-a", "A", 2L, 2L)) + ) + val local = statsSnapshot( + songs = listOf(SyncPlaybackStatsSong(songA, listOf(contribution("device-a", 0L, 5L)))), + devices = listOf(device("device-b", "New", 2L, 5L)) + ) + + val stats = runBlockingTest { engine.merge(remote, local).snapshot }.playbackStats!! + + assertEquals(listOf("a.mp3", "b.mp3"), stats.songs.map { it.song.normalizedFileName }) + assertEquals(5L, stats.songs.first().contributions.single().datedListenMs["2026-01-01"]) + assertEquals(listOf("device-a", "device-b"), stats.devices.map { it.deviceId }) + assertEquals("New", stats.devices.last().displayName) + } + + @Test + fun `merge playback tombstone permanently suppresses contribution regardless of side`() { + val song = SyncSongIdentity("a.mp3", 1L) + val remote = statsSnapshot( + songs = listOf(SyncPlaybackStatsSong(song, listOf(contribution("device", 3L, 10L)))), + tombstones = listOf(SyncPlaybackStatsTombstone("device", 3L, 10L)) + ) + val local = statsSnapshot( + songs = listOf(SyncPlaybackStatsSong(song, listOf(contribution("device", 3L, 20L)))) + ) + + val stats = runBlockingTest { engine.merge(remote, local).snapshot }.playbackStats!! + + assertTrue(stats.songs.single().contributions.isEmpty()) + assertEquals(listOf("device"), stats.tombstones.map { it.deviceId }) + } + + @Test + fun `fixture tombstone suppresses stale contribution in both merge directions`() { + val serializer = SyncSnapshotSerializer() + val androidGolden = deserializeFixture(serializer, "sync/playback_stats_v2_android_golden.json") + val staleCollision = deserializeFixture(serializer, "sync/playback_stats_v2_tombstone_collision.json") + + listOf( + runBlockingTest { engine.merge(androidGolden, staleCollision).snapshot }, + runBlockingTest { engine.merge(staleCollision, androidGolden).snapshot } + ).forEach { merged -> + val stats = merged.playbackStats!! + val cafe = stats.songs.single { it.song.normalizedFileName == "café.mp3" } + + assertTrue(cafe.contributions.none { + it.deviceId == "android-pixel-8" && it.generation == 1L + }) + assertTrue(stats.tombstones.any { + it.deviceId == "android-pixel-8" && it.generation == 1L + }) + assertTrue(stats.songs.any { it.song.normalizedFileName == "unresolved mix.flac" }) + + val serialized = serializer.serialize(merged) + val root = JsonParser.parseString(serialized).asJsonObject + assertEquals(SYNC_SCHEMA_VERSION_V2, root.get("schemaVersion").asInt) + assertTrue(root.has("playbackStatistics")) + assertTrue(!root.has("playbackStats")) + } + } + + @Test + fun `wpf and android canonical playback statistics merge both directions`() { + val serializer = SyncSnapshotSerializer() + val wpf = deserializeFixture(serializer, "sync/playback_stats_v2_wpf_canonical.json") + val android = deserializeFixture(serializer, "sync/playback_stats_v2_android_golden.json") + + listOf( + runBlockingTest { engine.merge(wpf, android).snapshot }, + runBlockingTest { engine.merge(android, wpf).snapshot } + ).forEach { merged -> + assertEquals(wpf.playbackStats, merged.playbackStats) + assertTrue(merged.playbackStats!!.songs.any { + it.song.normalizedFileName == "unresolved mix.flac" + }) + assertTrue(merged.playbackStats.tombstones.any { + it.deviceId == "android-pixel-8" && it.generation == 1L + }) + assertTrue(merged.playbackStats.songs.single { + it.song.normalizedFileName == "café.mp3" + }.contributions.none { + it.deviceId == "android-pixel-8" && it.generation == 1L + }) + } + } + + @Test + fun `merge playback contribution first played uses earliest non-zero timestamp`() { + val song = SyncSongIdentity("first-played.mp3", 1L) + fun mergedFirstPlayedAt(remoteFirstPlayedAt: Long, localFirstPlayedAt: Long): Long { + val remote = statsSnapshot( + songs = listOf( + SyncPlaybackStatsSong( + song, + listOf(contribution("device", 1L, 1L).copy(firstPlayedAtUtcMs = remoteFirstPlayedAt)) + ) + ) + ) + val local = statsSnapshot( + songs = listOf( + SyncPlaybackStatsSong( + song, + listOf(contribution("device", 1L, 2L).copy(firstPlayedAtUtcMs = localFirstPlayedAt)) + ) + ) + ) + return runBlockingTest { engine.merge(remote, local).snapshot } + .playbackStats!!.songs.single().contributions.single().firstPlayedAtUtcMs + } + + assertEquals(500L, mergedFirstPlayedAt(0L, 500L)) + assertEquals(300L, mergedFirstPlayedAt(700L, 300L)) + } + + @Test + fun `playback identity metadata reducer is commutative and contributions use maxima`() { + val firstIdentity = SyncSongIdentity( + fileName = " Track.MP3", + durationMs = 239_987L, + totalSamples = 10_583_412L, + contentHash = "hash-a" + ) + val secondIdentity = SyncSongIdentity( + fileName = "track.mp3", + durationMs = 239_987L, + totalSamples = 10_583_426L, + contentHash = "hash-z" + ) + val first = statsSnapshot( + songs = listOf( + SyncPlaybackStatsSong(firstIdentity, listOf(contribution("device", 1L, 4L))) + ) + ) + val second = statsSnapshot( + songs = listOf( + SyncPlaybackStatsSong(secondIdentity, listOf(contribution("device", 1L, 7L))) + ) + ) + + val left = runBlockingTest { engine.merge(first, second).snapshot }.playbackStats!! + val right = runBlockingTest { engine.merge(second, first).snapshot }.playbackStats!! + + assertEquals(left, right) + assertEquals(" Track.MP3", left.songs.single().song.fileName) + assertEquals(10_583_426L, left.songs.single().song.totalSamples) + assertEquals("hash-z", left.songs.single().song.contentHash) + assertEquals(7L, left.songs.single().contributions.single().datedListenMs["2026-01-01"]) + } + + private fun statsSnapshot( + songs: List = emptyList(), + devices: List = emptyList(), + tombstones: List = emptyList() + ) = SyncSnapshot( + schemaVersion = SYNC_SCHEMA_VERSION_V2, + deviceId = "device", + exportedAt = 1L, + playbackStats = SyncPlaybackStats(devices = devices, songs = songs, tombstones = tombstones) + ) + + private fun device(id: String, name: String, displayUpdatedAt: Long, lastSeenAt: Long) = + SyncPlaybackStatsDevice(id, name, 1L, lastSeenAt, 0L, "android", displayUpdatedAt) + + private fun contribution(deviceId: String, generation: Long, value: Long) = + SyncPlaybackStatsContribution( + deviceId = deviceId, + generation = generation, + datedListenMs = mapOf("2026-01-01" to value), + firstPlayedAtUtcMs = 1L, + lastPlayedAtUtcMs = value, + updatedAtUtcMs = value + ) + + private fun deserializeFixture(serializer: SyncSnapshotSerializer, path: String): SyncSnapshot = + requireNotNull(javaClass.classLoader?.getResourceAsStream(path)) + .bufferedReader().use { serializer.deserialize(it.readText()) } + + // ------------------------------------------------------------------- + // Null remote (initial sync) + // ------------------------------------------------------------------- + + @Test + fun `merge with null remote returns local snapshot`() { + val local = SyncSnapshot( + deviceId = "local-device", + exportedAt = 1000L, + playlists = listOf( + SyncPlaylist("p1", "Local P1", 100L, 200L) + ) + ) + + val result = runBlockingTest { engine.merge(null, local) } + + assertEquals(local, result.snapshot) + assertEquals(1, result.report.playlistsUploaded) + assertEquals(0, result.report.playlistsDownloaded) + } + + @Test + fun `merge with null remote and empty local returns empty`() { + val local = SyncSnapshot(deviceId = "d", exportedAt = 100L) + + val result = runBlockingTest { engine.merge(null, local) } + + assertEquals(local, result.snapshot) + assertEquals(0, result.report.playlistsUploaded) + assertTrue(result.report.conflicts.isEmpty()) + } + + // ------------------------------------------------------------------- + // Playlist merge by id + // ------------------------------------------------------------------- + + @Test + fun `merge combines playlists from both sides`() { + val local = SyncSnapshot( + deviceId = "local", + exportedAt = 100L, + playlists = listOf( + SyncPlaylist("p1", "Local Only", 100L, 200L) + ) + ) + val remote = SyncSnapshot( + deviceId = "remote", + exportedAt = 200L, + playlists = listOf( + SyncPlaylist("p2", "Remote Only", 300L, 400L) + ) + ) + + val result = runBlockingTest { engine.merge(remote, local) } + + assertEquals(2, result.snapshot.playlists.size) + val names = result.snapshot.playlists.map { it.name }.sorted() + assertEquals(listOf("Local Only", "Remote Only"), names) + } + + @Test + fun `merge same playlist id picks newer`() { + val local = SyncSnapshot( + deviceId = "local", + exportedAt = 100L, + playlists = listOf( + SyncPlaylist("p1", "Old Name", 100L, 200L) + ) + ) + val remote = SyncSnapshot( + deviceId = "remote", + exportedAt = 200L, + playlists = listOf( + SyncPlaylist("p1", "New Name", 100L, 400L) + ) + ) + + val result = runBlockingTest { engine.merge(remote, local) } + + assertEquals(1, result.snapshot.playlists.size) + assertEquals("New Name", result.snapshot.playlists[0].name) + } + + @Test + fun `merge playlist with same id but newer local keeps local name`() { + val local = SyncSnapshot( + deviceId = "local", + exportedAt = 100L, + playlists = listOf( + SyncPlaylist("p1", "Local Name", 100L, 500L) + ) + ) + val remote = SyncSnapshot( + deviceId = "remote", + exportedAt = 200L, + playlists = listOf( + SyncPlaylist("p1", "Remote Name", 100L, 300L) + ) + ) + + val result = runBlockingTest { engine.merge(remote, local) } + + assertEquals("Local Name", result.snapshot.playlists[0].name) + } + + // ------------------------------------------------------------------- + // Merge by song identity + // ------------------------------------------------------------------- + + @Test + fun `merge loop points by song identity`() { + val song = SyncSongIdentity("track.mp3", 30000L) + val local = SyncSnapshot( + deviceId = "local", + exportedAt = 100L, + loopPoints = listOf( + SyncLoopPointEntry(song, SyncLoopPoint(100L, 200L, 1000L)) + ) + ) + val remote = SyncSnapshot( + deviceId = "remote", + exportedAt = 200L, + loopPoints = listOf( + SyncLoopPointEntry(song, SyncLoopPoint(150L, 250L, 2000L)) + ) + ) + + // remote loop point is newer → merged should use remote + val result = runBlockingTest { engine.merge(remote, local) } + + assertEquals(1, result.snapshot.loopPoints.size) + val mergedEntry = result.snapshot.loopPoints[0] + assertEquals(song, mergedEntry.song) + assertEquals(150L, mergedEntry.loopPoint.loopStart) + assertEquals(250L, mergedEntry.loopPoint.loopEnd) + } + + @Test + fun `merge ratings by song identity`() { + val song = SyncSongIdentity("track.mp3", 30000L) + val local = SyncSnapshot( + deviceId = "local", + exportedAt = 100L, + ratings = listOf( + SyncRatingEntry(song, SyncRating(5, 1000L)) + ) + ) + val remote = SyncSnapshot( + deviceId = "remote", + exportedAt = 200L, + ratings = listOf( + SyncRatingEntry(song, SyncRating(3, 2000L)) + ) + ) + + // remote rating is newer → merged should use remote + val result = runBlockingTest { engine.merge(remote, local) } + + assertEquals(1, result.snapshot.ratings.size) + assertEquals(3, result.snapshot.ratings[0].rating.rating) + } + + @Test + fun `merge ratings with same file and duration ignores totalSamples mismatch and keeps remote identity`() { + val remoteSong = SyncSongIdentity("1-02. Summer Pockets.flac", 239_987L, 10_583_412L) + val localSong = SyncSongIdentity("1-02. Summer Pockets.flac", 239_987L, 10_583_426L) + val local = SyncSnapshot( + deviceId = "local", + exportedAt = 100L, + ratings = listOf( + SyncRatingEntry(localSong, SyncRating(4, 1_000L)) + ) + ) + val remote = SyncSnapshot( + deviceId = "remote", + exportedAt = 200L, + ratings = listOf( + SyncRatingEntry(remoteSong, SyncRating(3, 2_000L)) + ) + ) + + val result = runBlockingTest { engine.merge(remote, local) } + + assertEquals(1, result.snapshot.ratings.size) + assertEquals(remoteSong, result.snapshot.ratings.single().song) + assertEquals(3, result.snapshot.ratings.single().rating.rating) + } + + @Test + fun `merge loop points with same file and duration ignores totalSamples mismatch and keeps remote identity`() { + val remoteSong = SyncSongIdentity("loop.flac", 120_000L, 5_292_000L) + val localSong = SyncSongIdentity("loop.flac", 120_000L, 5_292_014L) + val local = SyncSnapshot( + deviceId = "local", + exportedAt = 100L, + loopPoints = listOf( + SyncLoopPointEntry(localSong, SyncLoopPoint(100L, 1_000L, 1_000L)) + ) + ) + val remote = SyncSnapshot( + deviceId = "remote", + exportedAt = 200L, + loopPoints = listOf( + SyncLoopPointEntry(remoteSong, SyncLoopPoint(200L, 2_000L, 2_000L)) + ) + ) + + val result = runBlockingTest { engine.merge(remote, local) } + + assertEquals(1, result.snapshot.loopPoints.size) + assertEquals(remoteSong, result.snapshot.loopPoints.single().song) + assertEquals(200L, result.snapshot.loopPoints.single().loopPoint.loopStart) + } + + @Test + fun `merge playlist items with same file and duration ignores totalSamples mismatch and keeps remote identity`() { + val remoteSong = SyncSongIdentity("track.flac", 180_000L, 7_938_000L) + val localSong = SyncSongIdentity("track.flac", 180_000L, 7_938_014L) + val local = SyncSnapshot( + deviceId = "local", + exportedAt = 100L, + playlists = listOf( + SyncPlaylist( + id = "p1", + name = "Playlist", + createdAt = 100L, + modifiedAt = 3_000L, + items = listOf(SyncPlaylistItem(localSong, sortOrder = 0)) + ) + ) + ) + val remote = SyncSnapshot( + deviceId = "remote", + exportedAt = 200L, + playlists = listOf( + SyncPlaylist( + id = "p1", + name = "Playlist", + createdAt = 100L, + modifiedAt = 2_000L, + items = listOf(SyncPlaylistItem(remoteSong, sortOrder = 0)) + ) + ) + ) + + val result = runBlockingTest { engine.merge(remote, local) } + + assertEquals(1, result.snapshot.playlists.single().items.size) + assertEquals(remoteSong, result.snapshot.playlists.single().items.single().song) + } + + @Test + fun `merge loop points from different songs`() { + val songA = SyncSongIdentity("a.mp3", 10000L) + val songB = SyncSongIdentity("b.mp3", 20000L) + val local = SyncSnapshot( + deviceId = "local", + exportedAt = 100L, + loopPoints = listOf( + SyncLoopPointEntry(songA, SyncLoopPoint(100L, 200L, 1000L)) + ) + ) + val remote = SyncSnapshot( + deviceId = "remote", + exportedAt = 200L, + loopPoints = listOf( + SyncLoopPointEntry(songB, SyncLoopPoint(300L, 400L, 2000L)) + ) + ) + + val result = runBlockingTest { engine.merge(remote, local) } + + assertEquals(2, result.snapshot.loopPoints.size) + } + + // ------------------------------------------------------------------- + // Preserve local metadata + // ------------------------------------------------------------------- + + @Test + fun `merge preserves local deviceId and exportedAt`() { + val local = SyncSnapshot( + deviceId = "my-device", + exportedAt = 5000L, + playlists = listOf( + SyncPlaylist("p1", "My List", 100L, 200L) + ) + ) + val remote = SyncSnapshot( + deviceId = "other-device", + exportedAt = 3000L, + playlists = listOf( + SyncPlaylist("p1", "Remote List", 100L, 400L) + ) + ) + + val result = runBlockingTest { engine.merge(remote, local) } + + // Local metadata should be preserved + assertEquals("my-device", result.snapshot.deviceId) + assertEquals(5000L, result.snapshot.exportedAt) + } + + @Test + fun `merge always emits schema two`() { + val local = SyncSnapshot( + deviceId = "local", + exportedAt = 100L, + playlists = listOf(SyncPlaylist("p1", "Test", 100L, 200L)) + ) + val remote = SyncSnapshot( + deviceId = "remote", + exportedAt = 200L, + playlists = listOf(SyncPlaylist("p1", "Updated", 100L, 400L)) + ) + + val result = runBlockingTest { engine.merge(remote, local) } + + assertEquals(SYNC_SCHEMA_VERSION_V2, result.snapshot.schemaVersion) + assertEquals(SyncPlaybackStats(), result.snapshot.playbackStats) + } + + // ------------------------------------------------------------------- + // Report counts + // ------------------------------------------------------------------- + + @Test + fun `merge report has correct counts`() { + val local = SyncSnapshot( + deviceId = "local", + exportedAt = 100L, + playlists = listOf( + SyncPlaylist("p1", "Local", 100L, 200L) + ), + loopPoints = listOf( + SyncLoopPointEntry( + SyncSongIdentity("a.mp3", 10000L), + SyncLoopPoint(100L, 200L, 100L) + ) + ), + ratings = listOf( + SyncRatingEntry( + SyncSongIdentity("b.mp3", 20000L), + SyncRating(4, 200L) + ) + ) + ) + val remote = SyncSnapshot( + deviceId = "remote", + exportedAt = 200L, + playlists = listOf( + SyncPlaylist("p2", "Remote", 300L, 400L) + ), + loopPoints = listOf( + SyncLoopPointEntry( + SyncSongIdentity("c.mp3", 30000L), + SyncLoopPoint(300L, 400L, 300L) + ) + ), + ratings = listOf( + SyncRatingEntry( + SyncSongIdentity("d.mp3", 40000L), + SyncRating(3, 400L) + ) + ) + ) + + val result = runBlockingTest { engine.merge(remote, local) } + + assertEquals(1, result.report.playlistsUploaded) // local has 1 + assertEquals(1, result.report.playlistsDownloaded) // remote has 1 + assertEquals(1, result.report.loopPointsUploaded) + assertEquals(1, result.report.loopPointsDownloaded) + assertEquals(1, result.report.ratingsUploaded) + assertEquals(1, result.report.ratingsDownloaded) + } + + // ------------------------------------------------------------------- + // Conflict detection + // ------------------------------------------------------------------- + + @Test + fun `merge detects playlist name conflict`() { + val local = SyncSnapshot( + deviceId = "local", + exportedAt = 100L, + playlists = listOf( + SyncPlaylist("p1", "Local Name", 100L, 200L) + ) + ) + val remote = SyncSnapshot( + deviceId = "remote", + exportedAt = 200L, + playlists = listOf( + SyncPlaylist("p1", "Remote Name", 100L, 300L) + ) + ) + + val result = runBlockingTest { engine.merge(remote, local) } + + assertTrue(result.report.conflicts.isNotEmpty()) + assertEquals("Local Name", result.report.conflicts[0].playlistName) + assertEquals("Remote Name", result.report.conflicts[0].remoteValue) + assertEquals("Local Name", result.report.conflicts[0].localValue) + } + + @Test + fun `merge no conflict when playlist names match`() { + val local = SyncSnapshot( + deviceId = "local", + exportedAt = 100L, + playlists = listOf( + SyncPlaylist("p1", "Same Name", 100L, 200L) + ) + ) + val remote = SyncSnapshot( + deviceId = "remote", + exportedAt = 200L, + playlists = listOf( + SyncPlaylist("p1", "Same Name", 100L, 300L) + ) + ) + + val result = runBlockingTest { engine.merge(remote, local) } + + assertTrue(result.report.conflicts.isEmpty()) + } + + // ------------------------------------------------------------------- + // Helper + // ------------------------------------------------------------------- + + /** + * Small inline helper to run suspend code in tests without + * requiring kotlinx-coroutines-test runTest integration. + */ + private fun runBlockingTest(block: suspend () -> T): T { + return kotlinx.coroutines.runBlocking { block() } + } +} diff --git a/app/src/test/java/com/cpu/seamlessloopmobile/data/sync/SyncMergePolicyTest.kt b/app/src/test/java/com/cpu/seamlessloopmobile/data/sync/SyncMergePolicyTest.kt new file mode 100644 index 0000000..de9b00f --- /dev/null +++ b/app/src/test/java/com/cpu/seamlessloopmobile/data/sync/SyncMergePolicyTest.kt @@ -0,0 +1,292 @@ +package com.cpu.seamlessloopmobile.data.sync + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Phase 1 合并策略单元测试。 + * 验证默认合并策略的正确性。 + */ +class SyncMergePolicyTest { + + // ----------------------------------------------------------------------- + // DefaultPlaylistMergePolicy + // ----------------------------------------------------------------------- + + @Test + fun `playlist merge uses remote when remote is newer`() { + val local = SyncPlaylist( + id = "p1", + name = "Local Playlist", + createdAt = 1000L, + modifiedAt = 2000L, + items = listOf( + SyncPlaylistItem(SyncSongIdentity("a.mp3", 10000L), 0) + ) + ) + val remote = SyncPlaylist( + id = "p1", + name = "Remote Playlist", + createdAt = 1000L, + modifiedAt = 3000L, + items = listOf( + SyncPlaylistItem(SyncSongIdentity("a.mp3", 10000L), 0), + SyncPlaylistItem(SyncSongIdentity("b.mp3", 20000L), 1) + ) + ) + val result = DefaultPlaylistMergePolicy.resolve(remote, local) + assertEquals("Remote Playlist", result.name) + assertEquals(2, result.items.size) + } + + @Test + fun `playlist merge uses local when local is newer`() { + val local = SyncPlaylist( + id = "p2", + name = "Local Playlist", + createdAt = 1000L, + modifiedAt = 3000L, + items = listOf( + SyncPlaylistItem(SyncSongIdentity("a.mp3", 10000L), 0) + ) + ) + val remote = SyncPlaylist( + id = "p2", + name = "Remote Playlist", + createdAt = 1000L, + modifiedAt = 2000L, + items = listOf( + SyncPlaylistItem(SyncSongIdentity("b.mp3", 20000L), 1) + ) + ) + val result = DefaultPlaylistMergePolicy.resolve(remote, local) + assertEquals("Local Playlist", result.name) + // local has a.mp3, remote has b.mp3: both should be present after merge + val identities = result.items.map { it.song } + assertEquals(2, identities.size) + assertEquals(SyncSongIdentity("a.mp3", 10000L), identities[0]) + assertEquals(SyncSongIdentity("b.mp3", 20000L), identities[1]) + } + + @Test + fun `playlist merge deduplicates items by song identity`() { + val local = SyncPlaylist( + id = "p3", + name = "Playlist", + createdAt = 1000L, + modifiedAt = 2000L, + items = listOf( + SyncPlaylistItem(SyncSongIdentity("a.mp3", 10000L), 0), + SyncPlaylistItem(SyncSongIdentity("b.mp3", 20000L), 1) + ) + ) + val remote = SyncPlaylist( + id = "p3", + name = "Playlist", + createdAt = 1000L, + modifiedAt = 3000L, + items = listOf( + SyncPlaylistItem(SyncSongIdentity("a.mp3", 10000L), 5), + SyncPlaylistItem(SyncSongIdentity("c.mp3", 30000L), 2) + ) + ) + val result = DefaultPlaylistMergePolicy.resolve(remote, local) + // remote wins (newer), but we keep remote's sortOrder for a.mp3 (5) + // and add b.mp3 from local since it's missing in remote + assertEquals(3, result.items.size) + assertEquals(5, result.items.find { it.song == SyncSongIdentity("a.mp3", 10000L) }?.sortOrder) + assertEquals(1, result.items.find { it.song == SyncSongIdentity("b.mp3", 20000L) }?.sortOrder) + assertEquals(2, result.items.find { it.song == SyncSongIdentity("c.mp3", 30000L) }?.sortOrder) + } + + @Test + fun `playlist merge keeps items sorted by sortOrder`() { + val remote = SyncPlaylist( + id = "p4", + name = "Sorted", + createdAt = 1000L, + modifiedAt = 2000L, + items = listOf( + SyncPlaylistItem(SyncSongIdentity("z.mp3", 5000L), 10), + SyncPlaylistItem(SyncSongIdentity("a.mp3", 5000L), 1) + ) + ) + val local = SyncPlaylist( + id = "p4", + name = "Sorted", + createdAt = 1000L, + modifiedAt = 1000L, + items = emptyList() + ) + val result = DefaultPlaylistMergePolicy.resolve(remote, local) + assertEquals(2, result.items.size) + assertEquals("a.mp3", result.items[0].song.fileName) + assertEquals("z.mp3", result.items[1].song.fileName) + } + + @Test + fun `playlist merge same timestamp prefers remote`() { + val now = 1000L + val local = SyncPlaylist("p5", "Local", now, now, emptyList()) + val remote = SyncPlaylist("p5", "Remote", now, now, emptyList()) + val result = DefaultPlaylistMergePolicy.resolve(remote, local) + assertEquals("Remote", result.name) // >= picks remote + } + + // ----------------------------------------------------------------------- + // DefaultLoopPointMergePolicy + // ----------------------------------------------------------------------- + + @Test + fun `loop point both null returns null`() { + assertNull(DefaultLoopPointMergePolicy.resolve(null, null)) + } + + @Test + fun `loop point remote null returns local`() { + val local = SyncLoopPoint(100L, 200L, 1000L) + assertEquals(local, DefaultLoopPointMergePolicy.resolve(null, local)) + } + + @Test + fun `loop point local null returns remote`() { + val remote = SyncLoopPoint(300L, 400L, 2000L) + assertEquals(remote, DefaultLoopPointMergePolicy.resolve(remote, null)) + } + + @Test + fun `loop point remote newer returns remote`() { + val local = SyncLoopPoint(100L, 200L, 1000L) + val remote = SyncLoopPoint(150L, 250L, 2000L) + assertEquals(remote, DefaultLoopPointMergePolicy.resolve(remote, local)) + } + + @Test + fun `loop point local newer returns local`() { + val local = SyncLoopPoint(100L, 200L, 2000L) + val remote = SyncLoopPoint(150L, 250L, 1000L) + assertEquals(local, DefaultLoopPointMergePolicy.resolve(remote, local)) + } + + @Test + fun `loop point same timestamp prefers remote`() { + val now = 5000L + val local = SyncLoopPoint(100L, 200L, now) + val remote = SyncLoopPoint(150L, 250L, now) + assertEquals(remote, DefaultLoopPointMergePolicy.resolve(remote, local)) + } + + // ----------------------------------------------------------------------- + // DefaultRatingMergePolicy + // ----------------------------------------------------------------------- + + @Test + fun `rating both null returns null`() { + assertNull(DefaultRatingMergePolicy.resolve(null, null)) + } + + @Test + fun `rating remote null returns local`() { + val local = SyncRating(5, 1000L) + assertEquals(local, DefaultRatingMergePolicy.resolve(null, local)) + } + + @Test + fun `rating local null returns remote`() { + val remote = SyncRating(3, 2000L) + assertEquals(remote, DefaultRatingMergePolicy.resolve(remote, null)) + } + + @Test + fun `rating remote newer returns remote`() { + val local = SyncRating(5, 1000L) + val remote = SyncRating(3, 2000L) + assertEquals(remote, DefaultRatingMergePolicy.resolve(remote, local)) + } + + @Test + fun `rating local newer returns local`() { + val local = SyncRating(5, 2000L) + val remote = SyncRating(3, 1000L) + assertEquals(local, DefaultRatingMergePolicy.resolve(remote, local)) + } + + @Test + fun `rating same timestamp prefers remote`() { + val now = 5000L + val local = SyncRating(5, now) + val remote = SyncRating(3, now) + assertEquals(remote, DefaultRatingMergePolicy.resolve(remote, local)) + } + + // ----------------------------------------------------------------------- + // 保护规则:循环点零值保护 + // ----------------------------------------------------------------------- + + @Test + fun `loop point zero values protected - remote unset keeps local substantive`() { + val local = SyncLoopPoint(100L, 200L, 3000L) // substantive + val remote = SyncLoopPoint(0L, 0L, 4000L) // unset, newer + // remote is newer but unset → keep local + assertEquals(local, DefaultLoopPointMergePolicy.resolve(remote, local)) + } + + @Test + fun `loop point zero values protected - local unset keeps remote substantive`() { + val local = SyncLoopPoint(0L, 0L, 4000L) // unset, newer + val remote = SyncLoopPoint(100L, 200L, 3000L) // substantive + // local is newer but unset → keep remote + assertEquals(remote, DefaultLoopPointMergePolicy.resolve(remote, local)) + } + + @Test + fun `loop point both unset picks newer`() { + val local = SyncLoopPoint(0L, 0L, 1000L) + val remote = SyncLoopPoint(0L, 0L, 2000L) + // both unset, remote newer → remote + assertEquals(remote, DefaultLoopPointMergePolicy.resolve(remote, local)) + } + + @Test + fun `loop point both substantive normal LWW`() { + val local = SyncLoopPoint(100L, 200L, 2000L) + val remote = SyncLoopPoint(300L, 400L, 1000L) + // both substantive, local newer → local + assertEquals(local, DefaultLoopPointMergePolicy.resolve(remote, local)) + } + + // ----------------------------------------------------------------------- + // 保护规则:评分零值保护 + // ----------------------------------------------------------------------- + + @Test + fun `rating zero protected - remote unset keeps local substantive`() { + val local = SyncRating(4, 2000L) // substantive + val remote = SyncRating(0, 3000L) // unset, newer + // remote is newer but 0 → keep local + assertEquals(local, DefaultRatingMergePolicy.resolve(remote, local)) + } + + @Test + fun `rating zero protected - local unset keeps remote substantive`() { + val local = SyncRating(0, 3000L) // unset, newer + val remote = SyncRating(4, 2000L) // substantive + // local is newer but 0 → keep remote + assertEquals(remote, DefaultRatingMergePolicy.resolve(remote, local)) + } + + @Test + fun `rating both zero picks newer`() { + val local = SyncRating(0, 1000L) + val remote = SyncRating(0, 2000L) + assertEquals(remote, DefaultRatingMergePolicy.resolve(remote, local)) + } + + @Test + fun `rating both nonzero normal LWW`() { + val local = SyncRating(3, 2000L) + val remote = SyncRating(5, 1000L) + assertEquals(local, DefaultRatingMergePolicy.resolve(remote, local)) + } +} diff --git a/app/src/test/java/com/cpu/seamlessloopmobile/data/sync/SyncSnapshotSerializerTest.kt b/app/src/test/java/com/cpu/seamlessloopmobile/data/sync/SyncSnapshotSerializerTest.kt new file mode 100644 index 0000000..545b660 --- /dev/null +++ b/app/src/test/java/com/cpu/seamlessloopmobile/data/sync/SyncSnapshotSerializerTest.kt @@ -0,0 +1,640 @@ +package com.cpu.seamlessloopmobile.data.sync + +import com.google.gson.GsonBuilder +import com.google.gson.JsonParser +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +/** + * SyncSnapshotSerializer 单元测试。 + * 验证 Gson 驱动的序列化/反序列化正确性,包括 schema 校验和异常处理。 + */ +class SyncSnapshotSerializerTest { + + private val serializer = SyncSnapshotSerializer() + private val gson = GsonBuilder().create() + + // ------------------------------------------------------------------- + // Round-trip + // ------------------------------------------------------------------- + + @Test + fun `round trip produces identical snapshot`() { + val original = SyncSnapshot( + deviceId = "device-123", + exportedAt = 1000000L, + playlists = listOf( + SyncPlaylist( + id = "pl-1", + name = "Favorites", + createdAt = 100L, + modifiedAt = 200L, + items = listOf( + SyncPlaylistItem( + song = SyncSongIdentity("song1.mp3", 30000L), + sortOrder = 0 + ) + ) + ) + ), + loopPoints = listOf( + SyncLoopPointEntry( + song = SyncSongIdentity("song1.mp3", 30000L), + loopPoint = SyncLoopPoint(1000L, 5000L, 300L) + ) + ), + ratings = listOf( + SyncRatingEntry( + song = SyncSongIdentity("song1.mp3", 30000L), + rating = SyncRating(5, 400L) + ) + ) + ) + + val json = serializer.serialize(original) + val restored = serializer.deserialize(json) + + assertEquals(original, restored) + } + + @Test + fun `round trip with minimal snapshot`() { + val original = SyncSnapshot( + deviceId = "device-456", + exportedAt = 2000000L + ) + + val json = serializer.serialize(original) + val restored = serializer.deserialize(json) + + assertEquals(original, restored) + } + + @Test(expected = SyncSerializationException::class) + fun `rejects schema one input`() { + serializer.deserialize("""{ + "schemaVersion": 1, "deviceId": "legacy", "exportedAt": 10, + "playbackStatistics": {} + }""") + } + + @Test(expected = SyncSerializationException::class) + fun `rejects missing canonical playback statistics`() { + serializer.deserialize("""{ + "schemaVersion": 2, "deviceId": "v2", "exportedAt": 10 + }""") + } + + @Test(expected = SyncSerializationException::class) + fun `rejects null canonical playback statistics`() { + serializer.deserialize("""{ + "schemaVersion": 2, "deviceId": "v2", "exportedAt": 10, + "playbackStatistics": null + }""") + } + + @Test(expected = SyncSerializationException::class) + fun `rejects playback stats alias`() { + serializer.deserialize("""{ + "schemaVersion": 2, "deviceId": "v2", "exportedAt": 10, + "playbackStats": {} + }""") + } + + @Test(expected = SyncSerializationException::class) + fun `rejects both playback statistics fields`() { + serializer.deserialize("""{ + "schemaVersion": 2, "deviceId": "v2", "exportedAt": 10, + "playbackStatistics": {}, "playbackStats": {} + }""") + } + + @Test + fun `v2 round trip sorts playback stats deterministically`() { + val original = SyncSnapshot( + schemaVersion = SYNC_SCHEMA_VERSION_V2, + deviceId = "v2", + exportedAt = 10L, + playbackStats = SyncPlaybackStats( + devices = listOf( + SyncPlaybackStatsDevice("b", "B", 2L, 3L, 1L, "android", 2L), + SyncPlaybackStatsDevice("a", "A", 1L, 4L, 0L, "android", 1L) + ), + songs = listOf( + SyncPlaybackStatsSong( + SyncSongIdentity("Z.mp3", 20L), + listOf(SyncPlaybackStatsContribution("b", 1L, mapOf("2026-01-02" to 5L))) + ), + SyncPlaybackStatsSong( + SyncSongIdentity("a.mp3", 10L), + listOf(SyncPlaybackStatsContribution("a", 0L, mapOf("2026-01-01" to 1L))) + ) + ), + tombstones = listOf(SyncPlaybackStatsTombstone("b", 1L, 5L)) + ) + ) + + val restored = serializer.deserialize(serializer.serialize(original)) + + assertEquals(listOf("a", "b"), restored.playbackStats!!.devices.map { it.deviceId }) + assertEquals(listOf("a.mp3", "z.mp3"), restored.playbackStats.songs.map { it.song.normalizedFileName }) + } + + @Test + fun `v2 serialization emits an empty playback statistics object and round trips`() { + val original = SyncSnapshot( + schemaVersion = SYNC_SCHEMA_VERSION_V2, + deviceId = "v2", + exportedAt = 1L, + playbackStats = SyncPlaybackStats() + ) + + val json = serializer.serialize(original) + + assertTrue(JsonParser.parseString(json).asJsonObject.get("playbackStatistics").isJsonObject) + assertEquals(SyncPlaybackStats(), serializer.deserialize(json).playbackStats) + } + + @Test(expected = SyncSerializationException::class) + fun `v2 serialization rejects invalid outbound device metadata`() { + serializer.serialize( + SyncSnapshot( + schemaVersion = SYNC_SCHEMA_VERSION_V2, + deviceId = "v2", + exportedAt = 1L, + playbackStats = SyncPlaybackStats( + devices = listOf(SyncPlaybackStatsDevice("device", "", 1L, 1L)) + ) + ) + ) + } + + @Test + fun `v2 serialization is canonical and fixture round trips deterministically`() { + val fixture = requireNotNull(javaClass.classLoader?.getResourceAsStream( + "sync/playback_stats_v2_android_golden.json" + )).bufferedReader().use { it.readText() } + val snapshot = serializer.deserialize(fixture) + + val serialized = serializer.serialize(snapshot) + + assertTrue(serialized.contains("\"playbackStatistics\"")) + assertTrue(!serialized.contains("\"playbackStats\"")) + assertEquals(snapshot, serializer.deserialize(serialized)) + assertEquals(serialized, serializer.serialize(serializer.deserialize(serialized))) + } + + @Test + fun `v2 playback statistics fixtures strictly deserialize`() { + val fixtures = listOf( + "sync/playback_stats_v2_android_golden.json", + "sync/playback_stats_v2_tombstone_collision.json", + "sync/playback_stats_v2_wpf_canonical.json" + ) + + fixtures.forEach { fixturePath -> + val fixture = requireNotNull(javaClass.classLoader?.getResourceAsStream(fixturePath)) + .bufferedReader().use { it.readText() } + + val snapshot = serializer.deserialize(fixture) + + assertEquals(SYNC_SCHEMA_VERSION_V2, snapshot.schemaVersion) + assertNotNull(snapshot.playbackStats) + } + } + + @Test + fun `wpf canonical playback statistics match android golden apart from provenance device id`() { + val wpf = deserializeFixture("sync/playback_stats_v2_wpf_canonical.json") + val android = deserializeFixture("sync/playback_stats_v2_android_golden.json") + + assertEquals(android.playbackStats, wpf.playbackStats) + assertTrue(wpf.deviceId != android.deviceId) + } + + @Test + fun `v2 canonical playback statistics round trip uses canonical field`() { + val snapshot = SyncSnapshot( + schemaVersion = SYNC_SCHEMA_VERSION_V2, + deviceId = "v2", + exportedAt = 1L, + playbackStats = SyncPlaybackStats( + devices = listOf(SyncPlaybackStatsDevice("d", "Phone", 1L, 2L, 0L, "android", 1L)) + ) + ) + + val json = serializer.serialize(snapshot) + val restored = serializer.deserialize(json) + + assertTrue(json.contains("\"playbackStatistics\"")) + assertTrue(!json.contains("\"playbackStats\"")) + assertEquals(snapshot.playbackStats, restored.playbackStats) + } + + @Test + fun `playback stats song semantic validator rejects invalid dates and negative values`() { + val invalidDate = SyncPlaybackStatsSong( + SyncSongIdentity("a.mp3", 1L), + listOf(SyncPlaybackStatsContribution("d", 0L, mapOf("not-a-date" to 0L))) + ) + val negativeValues = SyncPlaybackStatsSong( + SyncSongIdentity("a.mp3", 1L), + listOf(SyncPlaybackStatsContribution("d", -1L, undatedListenMs = -1L)) + ) + + assertTrue(!invalidDate.isSemanticallyValid()) + assertTrue(!negativeValues.isSemanticallyValid()) + } + + @Test + fun `playback stats song semantic validator rejects mismatched normalized file name`() { + val song = SyncPlaybackStatsSong( + SyncSongIdentity( + fileName = "first-song.mp3", + durationMs = 1L, + normalizedFileName = "other-song.mp3" + ) + ) + + assertTrue(!song.isSemanticallyValid()) + } + + @Test(expected = SyncSerializationException::class) + fun `rejects v2 playback statistics song with mismatched normalized file name`() { + serializer.deserialize("""{ + "schemaVersion": 2, "deviceId": "v2", "exportedAt": 1, + "playbackStatistics": { "dateBucketBasis": "sourceLocal", "songs": [{ + "song": { "fileName": "first-song.mp3", "normalizedFileName": "other-song.mp3", "durationMs": 1 }, + "contributions": [] + }] } + }""") + } + + @Test + fun `accepts v2 playback statistics song with canonical Unicode normalized file name`() { + val snapshot = serializer.deserialize("""{ + "schemaVersion": 2, "deviceId": "v2", "exportedAt": 1, + "playbackStatistics": { "dateBucketBasis": "sourceLocal", "songs": [{ + "song": { "fileName": " CAFÉ.MP3 ", "normalizedFileName": "café.mp3", "durationMs": 1 }, + "contributions": [] + }] } + }""") + + assertEquals("café.mp3", snapshot.playbackStats!!.songs.single().song.normalizedFileName) + } + + @Test + fun `backfills missing generic normalized file names and reserializes canonically`() { + val json = """{ + "schemaVersion": 2, "deviceId": "wpf", "exportedAt": 1, + "playlists": [{ + "id": "p1", "name": "Favorites", "createdAt": 1, "modifiedAt": 2, + "items": [{ + "song": { "fileName": " Track.MP3 ", "durationMs": 10, "totalSamples": 20 }, + "sortOrder": 0 + }] + }], + "loopPoints": [{ + "song": { "fileName": "Loop.FLAC", "durationMs": 11, "totalSamples": 21 }, + "loopPoint": { "loopStart": 1, "loopEnd": 2, "lastModified": 3 } + }], + "playbackStatistics": { "dateBucketBasis": "sourceLocal" } + }""" + + val snapshot = serializer.deserialize(json) + val playlistSong = snapshot.playlists.single().items.single().song + val loopSong = snapshot.loopPoints.single().song + + assertEquals("track.mp3", playlistSong.normalizedFileName) + assertEquals("loop.flac", loopSong.normalizedFileName) + val serialized = serializer.serialize(snapshot) + val root = JsonParser.parseString(serialized).asJsonObject + assertEquals( + "track.mp3", + root.getAsJsonArray("playlists").single() + .asJsonObject.getAsJsonArray("items").single().asJsonObject + .getAsJsonObject("song").get("normalizedFileName").asString + ) + assertEquals( + "loop.flac", + root.getAsJsonArray("loopPoints").single().asJsonObject + .getAsJsonObject("song").get("normalizedFileName").asString + ) + } + + @Test + fun `backfills null or blank generic normalized file names`() { + val snapshot = serializer.deserialize("""{ + "schemaVersion": 2, "deviceId": "wpf", "exportedAt": 1, + "ratings": [ + { "song": { "fileName": "Rating.MP3", "durationMs": 12, "normalizedFileName": null }, + "rating": { "rating": 4, "lastModified": 3 } }, + { "song": { "fileName": "Blank.MP3", "durationMs": 13, "normalizedFileName": " " }, + "rating": { "rating": 3, "lastModified": 4 } } + ], + "playbackStatistics": { "dateBucketBasis": "sourceLocal" } + }""") + + assertEquals( + listOf("rating.mp3", "blank.mp3"), + snapshot.ratings.map { it.song.normalizedFileName } + ) + } + + @Test + fun `rejects inconsistent generic normalized file name`() { + val exception = try { + serializer.deserialize("""{ + "schemaVersion": 2, "deviceId": "wpf", "exportedAt": 1, + "loopPoints": [{ + "song": { "fileName": "Loop.MP3", "durationMs": 1, "normalizedFileName": "other.mp3" }, + "loopPoint": { "loopStart": 1, "loopEnd": 2, "lastModified": 3 } + }], + "playbackStatistics": { "dateBucketBasis": "sourceLocal" } + }""") + throw AssertionError("Expected SyncSerializationException") + } catch (e: SyncSerializationException) { + e + } + + assertTrue(exception.message!!.contains("loopPoints[0].song.normalizedFileName")) + } + + @Test + fun `rejects missing or null generic file name with a field-specific exception`() { + listOf( + "{ \"durationMs\": 1 }", + "{ \"fileName\": null, \"durationMs\": 1 }" + ).forEach { songJson -> + val exception = try { + serializer.deserialize("""{ + "schemaVersion": 2, "deviceId": "wpf", "exportedAt": 1, + "ratings": [{ + "song": $songJson, + "rating": { "rating": 4, "lastModified": 3 } + }], + "playbackStatistics": { "dateBucketBasis": "sourceLocal" } + }""") + throw AssertionError("Expected SyncSerializationException") + } catch (e: SyncSerializationException) { + e + } + + assertTrue(exception.message!!.contains("ratings[0].song.fileName")) + } + } + + @Test + fun `playback statistics song still requires normalized file name`() { + val exception = try { + serializer.deserialize("""{ + "schemaVersion": 2, "deviceId": "v2", "exportedAt": 1, + "playbackStatistics": { "dateBucketBasis": "sourceLocal", "songs": [{ + "song": { "fileName": "track.mp3", "durationMs": 1 }, + "contributions": [] + }] } + }""") + throw AssertionError("Expected SyncSerializationException") + } catch (e: SyncSerializationException) { + e + } + + assertTrue(exception.message!!.contains("normalizedFileName")) + } + + @Test + fun `playback statistics requires explicit date bucket basis`() { + val exception = try { + serializer.deserialize("""{ + "schemaVersion": 2, "deviceId": "v2", "exportedAt": 1, + "playbackStatistics": { "devices": [], "songs": [], "tombstones": [] } + }""") + throw AssertionError("Expected SyncSerializationException") + } catch (e: SyncSerializationException) { + e + } + + assertTrue(exception.message!!.contains("dateBucketBasis")) + } + + @Test(expected = SyncSerializationException::class) + fun `rejects duplicate v2 contribution and invalid date`() { + serializer.deserialize("""{ + "schemaVersion": 2, "deviceId": "v2", "exportedAt": 1, + "playbackStatistics": { "dateBucketBasis": "sourceLocal", "songs": [{ + "song": { "fileName": "a.mp3", "normalizedFileName": "a.mp3", "durationMs": 1 }, + "contributions": [ + { "deviceId": "d", "generation": 0, "datedListenMs": { "not-a-date": 1 } }, + { "deviceId": "d", "generation": 0, "datedListenMs": {} } + ] + }] } + }""") + } + + @Test(expected = SyncSerializationException::class) + fun `rejects invalid canonical playback statistics audit fields`() { + serializer.deserialize("""{ + "schemaVersion": 2, "deviceId": "v2", "exportedAt": 1, + "playbackStatistics": { "dateBucketBasis": "sourceLocal", "devices": [{ + "deviceId": "d", "displayName": "Phone", "platform": "android", + "firstSeenAtUtcMs": 1, "lastSeenAtUtcMs": 2, + "currentGeneration": -1, "displayNameUpdatedAtUtcMs": 1 + }] } + }""") + } + + @Test(expected = SyncSerializationException::class) + fun `rejects duplicate canonical playback statistics contribution`() { + serializer.deserialize("""{ + "schemaVersion": 2, "deviceId": "v2", "exportedAt": 1, + "playbackStatistics": { "dateBucketBasis": "sourceLocal", "songs": [{ + "song": { "fileName": "a.mp3", "normalizedFileName": "a.mp3", "durationMs": 1 }, + "contributions": [ + { "deviceId": "d", "generation": 0, "datedListenMs": {}, "firstPlayedAtUtcMs": 1, "lastPlayedAtUtcMs": 1, "updatedAtUtcMs": 1 }, + { "deviceId": "d", "generation": 0, "datedListenMs": {}, "firstPlayedAtUtcMs": 1, "lastPlayedAtUtcMs": 1, "updatedAtUtcMs": 1 } + ] + }] } + }""") + } + + @Test + fun `round trip preserves all fields`() { + val original = SyncSnapshot( + schemaVersion = SYNC_SCHEMA_VERSION_V2, + deviceId = "test-device", + exportedAt = 5000L, + playlists = listOf( + SyncPlaylist("p1", "Test", 100L, 200L) + ), + loopPoints = listOf( + SyncLoopPointEntry( + SyncSongIdentity("a.mp3", 10000L), + SyncLoopPoint(0L, 0L, 300L) + ) + ), + ratings = emptyList() + ) + + val json = serializer.serialize(original) + val restored = serializer.deserialize(json) + + assertEquals(original.schemaVersion, restored.schemaVersion) + assertEquals(original.deviceId, restored.deviceId) + assertEquals(original.exportedAt, restored.exportedAt) + assertEquals(original.playlists.size, restored.playlists.size) + assertEquals(original.loopPoints.size, restored.loopPoints.size) + assertEquals(original.ratings.size, restored.ratings.size) + assertEquals(original, restored) + } + + // ------------------------------------------------------------------- + // Schema version validation + // ------------------------------------------------------------------- + + @Test(expected = SyncSerializationException::class) + fun `rejects unsupported schema version - too high`() { + val json = gson.toJson( + mapOf( + "schemaVersion" to 999, + "deviceId" to "test", + "exportedAt" to 1000L + ) + ) + serializer.deserialize(json) + } + + @Test(expected = SyncSerializationException::class) + fun `rejects unsupported schema version - too low`() { + val json = gson.toJson( + mapOf( + "schemaVersion" to 0, + "deviceId" to "test", + "exportedAt" to 1000L + ) + ) + serializer.deserialize(json) + } + + // ------------------------------------------------------------------- + // Malformed / empty JSON + // ------------------------------------------------------------------- + + @Test(expected = SyncSerializationException::class) + fun `rejects empty string`() { + serializer.deserialize("") + } + + @Test(expected = SyncSerializationException::class) + fun `rejects blank string`() { + serializer.deserialize(" ") + } + + @Test(expected = SyncSerializationException::class) + fun `rejects malformed JSON`() { + serializer.deserialize("{{{{broken json}///") + } + + @Test(expected = SyncSerializationException::class) + fun `rejects null literal`() { + serializer.deserialize("null") + } + + @Test(expected = SyncSerializationException::class) + fun `rejects missing device id`() { + val json = """{ + "schemaVersion": 2, + "exportedAt": 1000, + "playbackStatistics": {} + }""" + serializer.deserialize(json) + } + + @Test(expected = SyncSerializationException::class) + fun `rejects negative exported at`() { + val json = """{ + "schemaVersion": 2, + "deviceId": "device", + "exportedAt": -1, + "playbackStatistics": {} + }""" + serializer.deserialize(json) + } + + @Test + fun `normalizes null lists to empty lists`() { + val json = """{ + "schemaVersion": 2, + "deviceId": "device", + "exportedAt": 1000, + "playlists": null, + "loopPoints": null, + "ratings": null, + "playbackStatistics": { "dateBucketBasis": "sourceLocal" } + }""" + + val snapshot = serializer.deserialize(json) + + assertEquals(emptyList(), snapshot.playlists) + assertEquals(emptyList(), snapshot.loopPoints) + assertEquals(emptyList(), snapshot.ratings) + } + + // ------------------------------------------------------------------- + // Unknown fields ignored + // ------------------------------------------------------------------- + + @Test + fun `ignores unknown fields during deserialization`() { + val json = """{ + "schemaVersion": 2, + "deviceId": "test-device", + "exportedAt": 1000, + "playbackStatistics": { "dateBucketBasis": "sourceLocal" }, + "unknownField": "should be ignored", + "anotherUnknown": 42 + }""" + val snapshot = serializer.deserialize(json) + assertNotNull(snapshot) + assertEquals("test-device", snapshot.deviceId) + assertEquals(1000L, snapshot.exportedAt) + assertEquals(2, snapshot.schemaVersion) + } + + // ------------------------------------------------------------------- + // Serialize + // ------------------------------------------------------------------- + + @Test + fun `serialize produces valid JSON`() { + val snapshot = SyncSnapshot(deviceId = "d1", exportedAt = 100L) + val json = serializer.serialize(snapshot) + // Should be parseable back via Gson + val parsed = gson.fromJson(json, Map::class.java) + assertNotNull(parsed) + assertEquals("d1", (parsed as Map<*, *>)["deviceId"]) + } + + // ------------------------------------------------------------------- + // Custom Gson instance + // ------------------------------------------------------------------- + + @Test + fun `uses custom gson instance`() { + val customGson = GsonBuilder().setPrettyPrinting().create() + val customSerializer = SyncSnapshotSerializer(customGson) + val snapshot = SyncSnapshot(deviceId = "custom", exportedAt = 200L) + val json = customSerializer.serialize(snapshot) + // Pretty-printed JSON should still be valid + val restored = customSerializer.deserialize(json) + assertEquals(snapshot, restored) + } + + private fun deserializeFixture(path: String): SyncSnapshot { + val fixture = requireNotNull(javaClass.classLoader?.getResourceAsStream(path)) + .bufferedReader().use { it.readText() } + return serializer.deserialize(fixture) + } +} diff --git a/app/src/test/java/com/cpu/seamlessloopmobile/data/sync/room/RoomSyncSnapshotStoreTest.kt b/app/src/test/java/com/cpu/seamlessloopmobile/data/sync/room/RoomSyncSnapshotStoreTest.kt new file mode 100644 index 0000000..377ba70 --- /dev/null +++ b/app/src/test/java/com/cpu/seamlessloopmobile/data/sync/room/RoomSyncSnapshotStoreTest.kt @@ -0,0 +1,943 @@ +package com.cpu.seamlessloopmobile.data.sync.room + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.cpu.seamlessloopmobile.data.sync.SyncLoopPoint +import com.cpu.seamlessloopmobile.data.sync.SyncLoopPointEntry +import com.cpu.seamlessloopmobile.data.sync.SyncPlaylist +import com.cpu.seamlessloopmobile.data.sync.SyncPlaylistItem +import com.cpu.seamlessloopmobile.data.sync.SyncRating +import com.cpu.seamlessloopmobile.data.sync.SyncRatingEntry +import com.cpu.seamlessloopmobile.data.sync.SyncSnapshot +import com.cpu.seamlessloopmobile.data.sync.SyncSnapshotSerializer +import com.cpu.seamlessloopmobile.data.sync.SyncSongIdentity +import com.cpu.seamlessloopmobile.data.sync.prepareV2Egress +import com.cpu.seamlessloopmobile.data.sync.SyncPlaybackStats +import com.cpu.seamlessloopmobile.data.sync.SyncPlaybackStatsContribution +import com.cpu.seamlessloopmobile.data.sync.SyncPlaybackStatsDevice +import com.cpu.seamlessloopmobile.data.sync.SyncPlaybackStatsSong +import com.cpu.seamlessloopmobile.data.sync.SyncPlaybackStatsTombstone +import com.cpu.seamlessloopmobile.data.stats.ListenStatsRepository +import com.cpu.seamlessloopmobile.data.stats.ListenStatsContribution +import com.cpu.seamlessloopmobile.data.stats.ListenStatsSongNode +import com.cpu.seamlessloopmobile.data.stats.ListenStatsTombstone +import com.cpu.seamlessloopmobile.data.stats.ListenStatsUnresolvedNode +import com.cpu.seamlessloopmobile.data.stats.TrackStat +import com.cpu.seamlessloopmobile.db.AppDatabase +import com.cpu.seamlessloopmobile.model.Playlist +import com.cpu.seamlessloopmobile.model.PlaylistDao +import com.cpu.seamlessloopmobile.model.Song +import com.cpu.seamlessloopmobile.model.SongDao +import com.cpu.seamlessloopmobile.model.UserRating +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class RoomSyncSnapshotStoreTest { + + private lateinit var db: AppDatabase + private lateinit var songDao: SongDao + private lateinit var playlistDao: PlaylistDao + private lateinit var mapper: FakePlaylistIdMapper + private lateinit var listenStatsRepository: ListenStatsRepository + private lateinit var listenStatsFile: File + private lateinit var store: RoomSyncSnapshotStore + + @Before + fun createDb() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) + .allowMainThreadQueries() + .build() + songDao = db.songDao() + playlistDao = db.playlistDao() + mapper = FakePlaylistIdMapper() + listenStatsFile = File.createTempFile("room_sync_stats", ".json") + listenStatsRepository = ListenStatsRepository(listenStatsFile) + store = RoomSyncSnapshotStore(db, songDao, playlistDao, mapper, listenStatsRepository) + } + + @After + fun closeDb() { + db.close() + } + + @Test + fun exportSnapshotUsesPortableSongIdentityAndPlaylistSyncId() = runBlocking { + val loopSongId = insertSong( + fileName = "loop.mp3", + filePath = "/storage/device-a/loop.mp3", + duration = 123_000L, + totalSamples = 5_424_300L, + loopStart = 1_000L, + loopEnd = 120_000L, + rating = 4 + ) + val plainSongId = insertSong( + fileName = "plain.mp3", + filePath = "/storage/device-a/plain.mp3", + duration = 90_000L, + totalSamples = 3_969_000L + ) + val playlistId = playlistDao.insertPlaylist( + Playlist(name = "Favorites", createdAt = 1_000L) + ).toInt() + playlistDao.clearAndSyncPlaylist(playlistId, listOf(loopSongId, plainSongId)) + + val snapshot = store.exportSnapshot(deviceId = "device-a", now = 10_000L) + + assertEquals("device-a", snapshot.deviceId) + assertEquals(10_000L, snapshot.exportedAt) + assertEquals(1, snapshot.loopPoints.size) + assertEquals( + SyncSongIdentity("loop.mp3", 123_000L, 5_424_300L), + snapshot.loopPoints.single().song + ) + assertEquals(10_000L, snapshot.loopPoints.single().loopPoint.lastModified) + assertEquals(1, snapshot.ratings.size) + assertEquals(SyncSongIdentity("loop.mp3", 123_000L, 5_424_300L), snapshot.ratings.single().song) + assertEquals(1, snapshot.playlists.size) + assertEquals("playlist-sync-1", snapshot.playlists.single().id) + assertEquals("Favorites", snapshot.playlists.single().name) + assertEquals( + listOf( + SyncSongIdentity("loop.mp3", 123_000L, 5_424_300L), + SyncSongIdentity("plain.mp3", 90_000L, 3_969_000L) + ), + snapshot.playlists.single().items.map { it.song } + ) + } + + @Test + fun applySnapshotWritesMatchedDataAndSkipsUnmatchedSongs() = runBlocking { + val localSongId = insertSong( + fileName = "remote.mp3", + filePath = "/local/remote.mp3", + duration = 100_000L, + totalSamples = 4_410_000L + ) + val matched = SyncSongIdentity("remote.mp3", 100_000L, 4_410_000L) + val missing = SyncSongIdentity("missing.mp3", 80_000L, 3_528_000L) + val snapshot = SyncSnapshot( + deviceId = "device-b", + exportedAt = 20_000L, + loopPoints = listOf( + SyncLoopPointEntry(matched, SyncLoopPoint(2_000L, 98_000L, 20_000L)), + SyncLoopPointEntry(missing, SyncLoopPoint(1_000L, 70_000L, 20_000L)) + ), + ratings = listOf( + SyncRatingEntry(matched, SyncRating(5, 20_000L)) + ), + playlists = listOf( + SyncPlaylist( + id = "remote-playlist-1", + name = "Remote List", + createdAt = 12_000L, + modifiedAt = 20_000L, + items = listOf( + SyncPlaylistItem(matched, sortOrder = 0), + SyncPlaylistItem(missing, sortOrder = 1) + ) + ) + ) + ) + + val report = store.applySnapshot(snapshot) + + assertEquals(1, report.loopPointsDownloaded) + assertEquals(1, report.ratingsDownloaded) + assertEquals(1, report.playlistsDownloaded) + assertEquals(2, report.conflicts.size) + assertEquals(1, songDao.getAllSongs().size) + + val updatedSong = songDao.getSongById(localSongId) + assertNotNull(updatedSong) + assertEquals(2_000L, updatedSong?.loopStart) + assertEquals(98_000L, updatedSong?.loopEnd) + assertEquals(5, updatedSong?.rating) + + val playlists = playlistDao.getPlaylistsWithCounts() + assertEquals(1, playlists.size) + assertEquals("Remote List", playlists.single().playlist.name) + val playlistSongs = playlistDao.getSongsInPlaylist(playlists.single().playlist.id) + assertEquals(listOf(localSongId), playlistSongs.map { it.id }) + } + + @Test + fun applySnapshotRejectsInvalidPlaybackStatsBeforeApplyingRoomData() = runBlocking { + val identity = SyncSongIdentity("invalid.mp3", 60_000L) + val snapshot = SyncSnapshot( + deviceId = "device-b", + exportedAt = 20_000L, + loopPoints = listOf( + SyncLoopPointEntry(identity, SyncLoopPoint(2_000L, 58_000L, 20_000L)) + ), + ratings = listOf(SyncRatingEntry(identity, SyncRating(4, 20_000L))), + playbackStats = SyncPlaybackStats( + songs = listOf( + SyncPlaybackStatsSong( + song = identity.copy(normalizedFileName = "wrong.mp3"), + contributions = listOf( + SyncPlaybackStatsContribution( + deviceId = "device-b", + generation = 0L, + datedListenMs = mapOf("not-a-date" to 1L) + ) + ) + ) + ) + ) + ) + + assertThrows(IllegalArgumentException::class.java) { + runBlocking { store.applySnapshot(snapshot) } + } + assertTrue(songDao.getAllSongs().isEmpty()) + assertTrue(playlistDao.getPlaylistsWithCounts().isEmpty()) + assertTrue(listenStatsRepository.exportLocalPayload().songs.isEmpty()) + } + + @Test + fun applySnapshotRejectsDuplicatePlaybackStatsStableKeys() = runBlocking { + val identity = SyncSongIdentity("duplicate.mp3", 60_000L) + val snapshot = statsSnapshot( + SyncPlaybackStatsSong(identity), + SyncPlaybackStatsSong(identity.copy(fileName = "DUPLICATE.mp3")) + ) + + assertThrows(IllegalArgumentException::class.java) { + runBlocking { store.applySnapshot(snapshot) } + } + assertTrue(listenStatsRepository.exportLocalPayload().songs.isEmpty()) + } + + @Test + fun applySnapshotMatchesByExactDurationBeforeTotalSamplesAndToleratesSampleDifferences() = runBlocking { + val localSongId = insertSong( + fileName = "1-02. Summer Pockets.flac", + filePath = "/local/1-02. Summer Pockets.flac", + duration = 239_987L, + totalSamples = 10_583_412L + ) + val remoteIdentity = SyncSongIdentity( + fileName = "1-02. Summer Pockets.flac", + durationMs = 239_987L, + totalSamples = 10_583_426L + ) + + val report = store.applySnapshot( + SyncSnapshot( + deviceId = "desktop", + exportedAt = 20_000L, + ratings = listOf( + SyncRatingEntry(remoteIdentity, SyncRating(4, 20_000L)) + ), + loopPoints = listOf( + SyncLoopPointEntry(remoteIdentity, SyncLoopPoint(1_000L, 10_000L, 20_000L)) + ), + playlists = listOf( + SyncPlaylist( + id = "desktop-playlist", + name = "Desktop Playlist", + createdAt = 10_000L, + modifiedAt = 20_000L, + items = listOf(SyncPlaylistItem(remoteIdentity, sortOrder = 0)) + ) + ) + ) + ) + + assertEquals(1, report.ratingsDownloaded) + assertEquals(1, report.loopPointsDownloaded) + assertEquals(1, report.playlistsDownloaded) + assertTrue(report.conflicts.isEmpty()) + val updatedSong = songDao.getSongById(localSongId) + assertNotNull(updatedSong) + assertEquals(4, updatedSong?.rating) + assertEquals(1_000L, updatedSong?.loopStart) + assertEquals(10_000L, updatedSong?.loopEnd) + val playlistSongs = playlistDao.getSongsInPlaylist(playlistDao.getPlaylistsWithCounts().single().playlist.id) + assertEquals(listOf(localSongId), playlistSongs.map { it.id }) + } + + @Test + fun applySnapshotKeepsSubstantiveLocalDataWhenRemoteIsUnsetOrOlder() = runBlocking { + val localSongId = insertSong( + fileName = "protected.mp3", + filePath = "/local/protected.mp3", + duration = 60_000L, + totalSamples = 2_646_000L, + loopStart = 500L, + loopEnd = 58_000L, + rating = 5 + ) + songDao.insertUserRating(UserRating(localSongId, rating = 5, lastModified = 30_000L)) + + val identity = SyncSongIdentity("protected.mp3", 60_000L, 2_646_000L) + val snapshot = SyncSnapshot( + deviceId = "device-b", + exportedAt = 20_000L, + loopPoints = listOf( + SyncLoopPointEntry(identity, SyncLoopPoint(0L, 0L, 20_000L)) + ), + ratings = listOf( + SyncRatingEntry(identity, SyncRating(1, 20_000L)) + ) + ) + + val report = store.applySnapshot(snapshot) + + assertEquals(0, report.loopPointsDownloaded) + assertEquals(0, report.ratingsDownloaded) + val updatedSong = songDao.getSongById(localSongId) + assertNotNull(updatedSong) + assertEquals(500L, updatedSong?.loopStart) + assertEquals(58_000L, updatedSong?.loopEnd) + assertEquals(5, updatedSong?.rating) + } + + @Test + fun applySnapshotSkipsAmbiguousSongMatches() = runBlocking { + val firstId = insertSong( + fileName = "duplicate.mp3", + filePath = "/local/a/duplicate.mp3", + duration = 120_000L, + totalSamples = 5_292_000L + ) + val secondId = songDao.insertSongEntity( + com.cpu.seamlessloopmobile.model.SongEntity( + fileName = "duplicate.mp3", + filePath = "/local/b/duplicate.mp3", + duration = 120_000L, + totalSamples = 5_292_000L + ) + ) + val identity = SyncSongIdentity("duplicate.mp3", 120_000L, 5_292_000L) + + val report = store.applySnapshot( + SyncSnapshot( + deviceId = "device-b", + exportedAt = 20_000L, + loopPoints = listOf( + SyncLoopPointEntry(identity, SyncLoopPoint(1_000L, 119_000L, 20_000L)) + ) + ) + ) + + assertEquals(0, report.loopPointsDownloaded) + assertEquals(1, report.conflicts.size) + assertEquals(0L, songDao.getSongById(firstId)?.loopStart) + assertEquals(0L, songDao.getSongById(secondId)?.loopStart) + } + + @Test + fun exportSnapshotIncludesPlaybackStatisticsDeviceContributionAndTombstone() = runBlocking { + val songId = insertSong("stats.mp3", "/local/stats.mp3", 60_000L, 2_646_000L) + listenStatsRepository.recordListenDeltaNow( + TrackStat(songId, "Stats", "stats.mp3", durationMs = 60_000L, filePath = "/local/stats.mp3", + identityKey = "stats.mp3|60000"), 1_000L + ) + val snapshot = store.exportSnapshot("device-a", 10_000L) + val payload = snapshot.playbackStats!! + assertEquals(1, payload.devices.size) + assertEquals(1, payload.songs.size) + assertEquals("stats.mp3", payload.songs.single().song.normalizedFileName) + assertEquals(1, payload.songs.single().contributions.size) + + listenStatsRepository.clearCurrentDeviceStats() + val clearedPayload = store.exportSnapshot("device-a", 20_000L).playbackStats!! + assertEquals(1, clearedPayload.tombstones.size) + assertTrue(clearedPayload.songs.single().contributions.isEmpty()) + } + + @Test + fun applySnapshotImportsPlaybackStatisticsAndPreservesThenReassociatesUnmatchedNodes() = runBlocking { + val matchedId = insertSong("matched.mp3", "/local/matched.mp3", 90_000L, 3_969_000L) + val matched = SyncPlaybackStatsSong( + SyncSongIdentity("matched.mp3", 90_000L), + listOf(SyncPlaybackStatsContribution("desktop", 2L, mapOf("2026-07-10" to 7_000L), firstPlayedAtUtcMs = 100L, lastPlayedAtUtcMs = 700L)) + ) + val missing = SyncPlaybackStatsSong( + SyncSongIdentity("later.mp3", 80_000L), + listOf(SyncPlaybackStatsContribution("desktop", 2L, mapOf("2026-07-10" to 3_000L))) + ) + store.applySnapshot(statsSnapshot(matched, missing)) + + assertEquals(7_000L, listenStatsRepository.getByIdentityKey("matched.mp3|90000")!!.totalListenMs) + assertEquals(100L, listenStatsRepository.getByIdentityKey("matched.mp3|90000")!!.firstPlayedAt) + assertEquals(700L, listenStatsRepository.getByIdentityKey("matched.mp3|90000")!!.lastPlayedAt) + assertTrue(listenStatsRepository.exportLocalPayload().unresolvedNodes.isEmpty()) + assertEquals(0L, listenStatsRepository.exportLocalPayload().songs.single { + it.identityKey == "later.mp3|80000" + }.boundSongId) + + insertSong("later.mp3", "/local/later.mp3", 80_000L, 3_528_000L) + store.applySnapshot(statsSnapshot(matched, missing)) + + assertEquals(3_000L, listenStatsRepository.getByIdentityKey("later.mp3|80000")!!.totalListenMs) + assertTrue(listenStatsRepository.exportLocalPayload().unresolvedNodes.isEmpty()) + assertTrue(matchedId > 0L) + } + + @Test + fun applyPlaybackStatsBindsExactDurationAndReexportsRemoteWireMetadata() = runBlocking { + val nearbyId = insertSong( + "exact.mp3", "/local/nearby.mp3", 239_986L, 10_583_382L + ) + val exactId = insertSong( + "exact.mp3", "/local/exact.mp3", 239_987L, 10_583_412L + ) + val identity = SyncSongIdentity( + fileName = "exact.mp3", + durationMs = 239_987L, + totalSamples = 10_583_426L, + contentHash = "remote-hash" + ) + val remote = SyncPlaybackStatsSong( + identity, + listOf(SyncPlaybackStatsContribution( + deviceId = "desktop", + generation = 2L, + datedListenMs = mapOf("2026-07-10" to 7_000L), + firstPlayedAtUtcMs = 100L, + lastPlayedAtUtcMs = 700L, + updatedAtUtcMs = 700L + )) + ) + + store.applySnapshot(statsSnapshot(remote)) + + val localNode = listenStatsRepository.exportLocalPayload().songs.single() + assertEquals(exactId, localNode.boundSongId) + assertEquals(239_987L, localNode.durationMs) + assertEquals(10_583_426L, localNode.totalSamples) + assertEquals("remote-hash", localNode.contentHash) + assertEquals(7_000L, localNode.contributions.single().dailyListenMs["2026-07-10"]) + assertTrue(nearbyId != localNode.boundSongId) + + val exported = store.exportSnapshot("phone", 2L).playbackStats!! + assertEquals(1, exported.songs.size) + assertEquals(identity, exported.songs.single().song) + assertEquals(1, exported.songs.single().contributions.size) + assertEquals(7_000L, exported.songs.single().contributions.single() + .datedListenMs["2026-07-10"]) + } + + @Test + fun applyPlaybackStatsBindsUniqueNearbySongAndReexportsExactWireIdentity() = runBlocking { + val localId = insertSong( + "observed.mp3", "/local/observed.mp3", 239_986L, 10_583_412L + ) + val identity = SyncSongIdentity( + "observed.mp3", 239_987L, 10_583_426L, contentHash = "wire-hash" + ) + + store.applySnapshot(statsSnapshot(SyncPlaybackStatsSong( + identity, + listOf(SyncPlaybackStatsContribution("desktop", 2L, mapOf("2026-07-10" to 1_000L))) + ))) + + val node = listenStatsRepository.exportLocalPayload().songs.single() + assertEquals(localId, node.boundSongId) + assertEquals(identity.fileName, node.fileName) + assertEquals(identity.durationMs, node.durationMs) + assertEquals(identity.totalSamples, node.totalSamples) + assertEquals(identity.contentHash, node.contentHash) + assertEquals(identity, store.exportSnapshot("phone", 2L).playbackStats!!.songs.single().song) + } + + @Test + fun playbackMatcherUsesSampleExactThenSampleTolerance() = runBlocking { + val sampleExactId = insertSong("sample.mp3", "/local/exact.mp3", 100_001L, 1_000_000L) + insertSong("sample.mp3", "/local/tolerance.mp3", 100_002L, 2_000_000L) + + store.applySnapshot(statsSnapshot(SyncPlaybackStatsSong( + SyncSongIdentity("sample.mp3", 100_999L, 1_000_000L), emptyList() + ))) + assertEquals(sampleExactId, listenStatsRepository.exportLocalPayload().songs.single().boundSongId) + + listenStatsRepository.clearCurrentDeviceStats() + val toleranceId = songDao.getSongsByName("sample.mp3").single { it.totalSamples == 2_000_000L }.id + store.applySnapshot(statsSnapshot(SyncPlaybackStatsSong( + SyncSongIdentity("sample.mp3", 100_999L, 2_009_999L), emptyList() + ))) + assertEquals(toleranceId, listenStatsRepository.exportLocalPayload().songs.single { + it.totalSamples == 2_009_999L + }.boundSongId) + } + + @Test + fun playbackMatcherUsesInclusiveDurationToleranceAndUniqueNameFallback() = runBlocking { + val boundaryId = insertSong("boundary.mp3", "/local/boundary.mp3", 100_200L, 1_000_000L) + store.applySnapshot(statsSnapshot(SyncPlaybackStatsSong( + SyncSongIdentity("boundary.mp3", 100_000L), emptyList() + ))) + assertEquals(boundaryId, listenStatsRepository.exportLocalPayload().songs.single().boundSongId) + + val fallbackId = insertSong("fallback.mp3", "/local/fallback.mp3", 900_000L, 2_000_000L) + store.applySnapshot(statsSnapshot(SyncPlaybackStatsSong( + SyncSongIdentity("fallback.mp3", 100_000L), emptyList() + ))) + assertEquals(fallbackId, listenStatsRepository.exportLocalPayload().songs.first { + it.normalizedFileName == "fallback.mp3" + }.boundSongId) + } + + @Test + fun playbackMatcherLeavesAmbiguousNameUnbound() = runBlocking { + insertSong("ambiguous.mp3", "/local/a/ambiguous.mp3", 100_000L, 1_000_000L) + songDao.insertSongEntity(com.cpu.seamlessloopmobile.model.SongEntity( + fileName = "ambiguous.mp3", + filePath = "/local/b/ambiguous.mp3", + duration = 100_000L, + totalSamples = 1_000_000L + )) + + store.applySnapshot(statsSnapshot(SyncPlaybackStatsSong( + SyncSongIdentity("ambiguous.mp3", 200_000L), emptyList() + ))) + + assertEquals(0L, listenStatsRepository.exportLocalPayload().songs.single().boundSongId) + } + + @Test + fun reassociationClearsInvalidBindingAndRebindsAfterScan() = runBlocking { + val firstId = insertSong("rescan.mp3", "/local/first.mp3", 60_000L, 2_000_000L) + val identity = SyncSongIdentity("rescan.mp3", 60_000L, 2_000_000L) + store.applySnapshot(statsSnapshot(SyncPlaybackStatsSong(identity, emptyList()))) + assertEquals(firstId, listenStatsRepository.exportLocalPayload().songs.single().boundSongId) + + songDao.deleteSongsByIds(listOf(firstId)) + store.applySnapshot(statsSnapshot()) + val stale = listenStatsRepository.exportLocalPayload().songs.single() + assertEquals(0L, stale.boundSongId) + assertEquals("rescan.mp3", stale.displayName) + + val replacementId = insertSong("rescan.mp3", "/local/replacement.mp3", 60_000L, 2_000_000L) + store.applySnapshot(statsSnapshot()) + assertEquals(replacementId, listenStatsRepository.exportLocalPayload().songs.single().boundSongId) + } + + @Test + fun reassociationClearsBindingWhenCandidateBecomesAmbiguous() = runBlocking { + val firstId = insertSong("ambiguous-rescan.mp3", "/local/first.mp3", 60_000L, 2_000_000L) + store.applySnapshot(statsSnapshot(SyncPlaybackStatsSong( + SyncSongIdentity("ambiguous-rescan.mp3", 60_000L, 2_000_000L), emptyList() + ))) + assertEquals(firstId, listenStatsRepository.exportLocalPayload().songs.single().boundSongId) + + songDao.insertSongEntity(com.cpu.seamlessloopmobile.model.SongEntity( + fileName = "ambiguous-rescan.mp3", + filePath = "/local/second.mp3", + duration = 60_000L, + totalSamples = 2_000_000L + )) + store.applySnapshot(statsSnapshot()) + assertEquals(0L, listenStatsRepository.exportLocalPayload().songs.single().boundSongId) + } + + @Test + fun playbackNodesWithNearbyDurationsRemainSeparateOnLocalCollapse() = runBlocking { + val payload = listenStatsRepository.exportLocalPayload() + listenStatsRepository.applyLocalPayload(payload.copy(songs = payload.songs + listOf( + ListenStatsSongNode( + identityKey = "exact.mp3|239986", + normalizedFileName = "exact.mp3", + fileName = "exact.mp3", + durationMs = 239_986L, + contributions = listOf( + ListenStatsContribution("desktop", 1L, mapOf("2026-07-10" to 3_000L)) + ) + ), + ListenStatsSongNode( + identityKey = "exact.mp3|239987", + normalizedFileName = "exact.mp3", + fileName = "exact.mp3", + durationMs = 239_987L, + contributions = listOf( + ListenStatsContribution("desktop", 1L, mapOf("2026-07-10" to 5_000L)) + ) + ) + )), trackMutation = false) + + val exported = store.exportSnapshot("phone", 2L).playbackStats!! + assertEquals(listOf(239_986L, 239_987L), exported.songs.map { it.song.durationMs }) + assertEquals(listOf(3_000L, 5_000L), exported.songs.map { + it.contributions.single().datedListenMs["2026-07-10"] + }) + } + + @Test + fun applyWpfFixtureKeepsUnresolvedStatsGenerationsDatesTombstonesAndV2Egress() = runBlocking { + insertSong("CAFÉ.MP3", "/local/CAFÉ.MP3", 123_456L, 5_444_400L) + val stale = ListenStatsSongNode( + identityKey = "café.mp3|123456", + normalizedFileName = "café.mp3", + fileName = "CAFÉ.MP3", + durationMs = 123_456L, + contributions = listOf( + ListenStatsContribution( + deviceId = "android-pixel-8", + generation = 1L, + dailyListenMs = mapOf("2026-07-09" to 99_000L) + ) + ) + ) + val localPayload = listenStatsRepository.exportLocalPayload() + listenStatsRepository.applyLocalPayload(localPayload.copy(songs = listOf(stale)), trackMutation = false) + + val fixture = requireNotNull(javaClass.classLoader?.getResourceAsStream( + "sync/playback_stats_v2_wpf_canonical.json" + )).bufferedReader().use { SyncSnapshotSerializer().deserialize(it.readText()) } + + store.applySnapshot(fixture) + + val afterApply = listenStatsRepository.exportLocalPayload() + assertTrue(afterApply.songs.any { + it.normalizedFileName == "unresolved mix.flac" && it.boundSongId == 0L + }) + assertTrue(afterApply.tombstones.any { + it.deviceId == "android-pixel-8" && it.generation == 1L + }) + + val exported = store.exportSnapshot("android-pixel-8", fixture.exportedAt) + val exportedStats = exported.playbackStats!! + val cafe = exportedStats.songs.single { it.song.normalizedFileName == "café.mp3" } + assertTrue(cafe.contributions.none { + it.deviceId == "android-pixel-8" && it.generation == 1L + }) + val androidGeneration = cafe.contributions.single { + it.deviceId == "android-pixel-8" && it.generation == 2L + } + assertEquals(60_000L, androidGeneration.datedListenMs["2026-07-10"]) + assertEquals(30_000L, androidGeneration.datedListenMs["2026-07-11"]) + assertEquals(12_000L, androidGeneration.undatedListenMs) + assertTrue(exportedStats.tombstones.any { + it.deviceId == "android-pixel-8" && it.generation == 1L + }) + assertTrue(exportedStats.songs.any { + it.song.normalizedFileName == "unresolved mix.flac" + }) + + val prepared = exported.prepareV2Egress() + assertEquals(com.cpu.seamlessloopmobile.data.sync.SYNC_SCHEMA_VERSION_V2, prepared.schemaVersion) + val restored = SyncSnapshotSerializer().deserialize( + SyncSnapshotSerializer().serialize(prepared) + ) + val restoredStats = restored.playbackStats!! + assertEquals(androidGeneration, restoredStats.songs.single { + it.song.normalizedFileName == "café.mp3" + }.contributions.single { + it.deviceId == "android-pixel-8" && it.generation == 2L + }) + assertTrue(restoredStats.tombstones.any { + it.deviceId == "android-pixel-8" && it.generation == 1L + }) + assertTrue(restoredStats.songs.any { + it.song.normalizedFileName == "unresolved mix.flac" + }) + } + + @Test + fun exportPlaybackStatsMergesResolvedAndUnresolvedNodesWithSameIdentity() = runBlocking { + val remote = SyncPlaybackStatsSong( + SyncSongIdentity("duplicate-stats.mp3", 60_000L), + listOf( + SyncPlaybackStatsContribution("desktop", 2L, mapOf("2026-07-10" to 7_000L), 500L, 100L, 600L, 600L), + SyncPlaybackStatsContribution("tablet", 1L, mapOf("2026-07-10" to 3_000L)) + ) + ) + store.applySnapshot(statsSnapshot(remote)) + val songId = insertSong("duplicate-stats.mp3", "/local/duplicate-stats.mp3", 60_000L, 2_646_000L) + val payload = listenStatsRepository.exportLocalPayload() + listenStatsRepository.applyLocalPayload(payload.copy(songs = payload.songs + listOf( + ListenStatsSongNode( + identityKey = "duplicate-stats.mp3|60000", normalizedFileName = "duplicate-stats.mp3", + fileName = "duplicate-stats.mp3", boundSongId = songId, displayName = "Local metadata", + durationMs = 60_000L, filePath = "/local/duplicate-stats.mp3", + contributions = listOf( + ListenStatsContribution("desktop", 2L, mapOf("2026-07-10" to 4_000L, "2026-07-11" to 9_000L), + 800L, 0L, 700L, 800L), + ListenStatsContribution("phone", 1L, mapOf("2026-07-10" to 2_000L)) + ) + ) + )), trackMutation = false) + + val contributions = store.exportSnapshot("phone", 1L).playbackStats!!.songs.single().contributions.associateBy { + it.deviceId to it.generation + } + assertEquals(3, contributions.size) + assertEquals(7_000L, contributions["desktop" to 2L]!!.datedListenMs["2026-07-10"]) + assertEquals(9_000L, contributions["desktop" to 2L]!!.datedListenMs["2026-07-11"]) + assertEquals(800L, contributions["desktop" to 2L]!!.undatedListenMs) + assertEquals(100L, contributions["desktop" to 2L]!!.firstPlayedAtUtcMs) + assertEquals(700L, contributions["desktop" to 2L]!!.lastPlayedAtUtcMs) + } + + @Test + fun exportPlaybackStatsExcludesTombstonedDuplicateContributionAcrossResolvedAndUnresolvedNodes() = runBlocking { + val song = SyncPlaybackStatsSong( + SyncSongIdentity("tombstoned-duplicate.mp3", 60_000L), + listOf(SyncPlaybackStatsContribution("desktop", 2L, mapOf("2026-07-10" to 7_000L))) + ) + val songId = insertSong("tombstoned-duplicate.mp3", "/local/tombstoned-duplicate.mp3", 60_000L, 2_646_000L) + val payload = listenStatsRepository.exportLocalPayload() + listenStatsRepository.applyLocalPayload( + payload.copy( + songs = listOf( + ListenStatsSongNode( + identityKey = "tombstoned-duplicate.mp3|60000", + normalizedFileName = "tombstoned-duplicate.mp3", + fileName = "tombstoned-duplicate.mp3", + boundSongId = songId, + durationMs = 60_000L, + filePath = "/local/tombstoned-duplicate.mp3", + contributions = listOf(ListenStatsContribution("desktop", 2L, mapOf("2026-07-10" to 4_000L))) + ) + ), + unresolvedNodes = listOf( + ListenStatsUnresolvedNode("tombstoned-duplicate.mp3", 60_000L, com.google.gson.Gson().toJson(song)) + ), + tombstones = listOf(ListenStatsTombstone("desktop", 2L, 1L)) + ), + trackMutation = false + ) + + val exported = store.exportSnapshot("phone", 1L).playbackStats!! + + assertEquals(1, exported.songs.size) + assertTrue(exported.songs.single().contributions.isEmpty()) + } + + @Test + fun reassociatePlaybackStatsMergesOntoExistingLocalNodeAndPreservesMetadata() = runBlocking { + val remote = SyncPlaybackStatsSong( + SyncSongIdentity( + fileName = "Reassociate.MP3", + durationMs = 70_000L, + totalSamples = 3_100_000L, + contentHash = "remote-hash" + ), + listOf(SyncPlaybackStatsContribution("desktop", 2L, mapOf("2026-07-10" to 7_000L), + firstPlayedAtUtcMs = 100L, lastPlayedAtUtcMs = 700L)) + ) + store.applySnapshot(statsSnapshot(remote)) + val songId = insertSong( + "reassociate.mp3", "/local/reassociate.mp3", 70_000L, 3_087_000L, + displayName = "Existing local metadata" + ) + val payload = listenStatsRepository.exportLocalPayload() + listenStatsRepository.applyLocalPayload(payload.copy(songs = payload.songs + listOf( + ListenStatsSongNode( + identityKey = "reassociate.mp3|70000", normalizedFileName = "reassociate.mp3", + fileName = "reassociate.mp3", boundSongId = songId, displayName = "Existing local metadata", + durationMs = 70_000L, totalSamples = 3_087_000L, contentHash = "local-hash", + filePath = "/local/reassociate.mp3", + contributions = listOf( + ListenStatsContribution("desktop", 2L, mapOf("2026-07-10" to 4_000L, "2026-07-11" to 9_000L), + firstPlayedAtUtcMs = 50L, lastPlayedAtUtcMs = 500L) + ) + ) + )), trackMutation = false) + + val localMetadata = listenStatsRepository.exportLocalPayload().songs.single { it.boundSongId == songId } + store.applySnapshot(statsSnapshot()) + + val reassociated = listenStatsRepository.exportLocalPayload() + assertTrue(reassociated.unresolvedNodes.isEmpty()) + val node = reassociated.songs.single() + assertEquals(songId, node.boundSongId) + assertEquals(localMetadata.displayName, node.displayName) + assertEquals(localMetadata.filePath, node.filePath) + assertEquals("Reassociate.MP3", node.fileName) + assertEquals(3_100_000L, node.totalSamples) + assertEquals("remote-hash", node.contentHash) + val contributions = node.contributions.associateBy { it.deviceId to it.generation } + assertEquals(1, contributions.size) + assertEquals(7_000L, contributions["desktop" to 2L]!!.dailyListenMs["2026-07-10"]) + assertEquals(9_000L, contributions["desktop" to 2L]!!.dailyListenMs["2026-07-11"]) + assertEquals(50L, node.firstPlayedAt) + assertEquals(700L, node.lastPlayedAt) + + val exported = store.exportSnapshot("phone", 2L).playbackStats!! + assertEquals( + SyncSongIdentity( + fileName = "Reassociate.MP3", + durationMs = 70_000L, + totalSamples = 3_100_000L, + contentHash = "remote-hash" + ), + exported.songs.single().song + ) + } + + @Test + fun rebindPlaybackStatsBindsAnUnboundNodeAfterLocalSongAppears() = runBlocking { + val identity = SyncSongIdentity("rebind-later.mp3", 70_000L) + store.applySnapshot(statsSnapshot( + SyncPlaybackStatsSong( + identity, + listOf(SyncPlaybackStatsContribution("desktop", 1L, undatedListenMs = 5_000L)) + ) + )) + assertEquals(0L, listenStatsRepository.exportLocalPayload().songs.single().boundSongId) + + val songId = insertSong("rebind-later.mp3", "/local/rebind-later.mp3", 70_000L, 3_087_000L) + store.rebindPlaybackStats() + + assertEquals(songId, listenStatsRepository.exportLocalPayload().songs.single().boundSongId) + assertEquals(5_000L, listenStatsRepository.exportLocalPayload().songs.single() + .contributions.single().undatedListenMs) + } + + @Test + fun applySnapshotCollapsesParseableUnresolvedRevisionsButKeepsMalformedPayloads() = runBlocking { + val older = SyncPlaybackStatsSong( + SyncSongIdentity("missing.mp3", 60_000L), + listOf(SyncPlaybackStatsContribution("desktop", 1L, mapOf("2026-07-10" to 2_000L), 100L, 200L, 200L)) + ) + val newer = older.copy(contributions = listOf( + SyncPlaybackStatsContribution("desktop", 1L, mapOf("2026-07-10" to 5_000L), 300L, 100L, 400L, 400L) + )) + val payload = listenStatsRepository.exportLocalPayload() + listenStatsRepository.applyLocalPayload(payload.copy(unresolvedNodes = listOf( + ListenStatsUnresolvedNode("broken", 1L, "{not json}"), + ListenStatsUnresolvedNode("structurally-broken", 2L, "{}"), + ListenStatsUnresolvedNode("missing.mp3", 60_000L, com.google.gson.Gson().toJson(older)) + )), trackMutation = false) + + store.applySnapshot(statsSnapshot(newer)) + + val afterApply = listenStatsRepository.exportLocalPayload() + val unresolved = afterApply.unresolvedNodes + assertEquals(2, unresolved.size) + assertTrue(unresolved.any { it.payloadJson == "{not json}" }) + assertTrue(unresolved.any { it.payloadJson == "{}" }) + val contribution = afterApply.songs.single { it.normalizedFileName == "missing.mp3" } + .contributions.single() + assertEquals(5_000L, contribution.dailyListenMs["2026-07-10"]) + assertEquals(300L, contribution.undatedListenMs) + assertEquals(100L, contribution.firstPlayedAtUtcMs) + assertEquals(400L, contribution.lastPlayedAtUtcMs) + val exported = store.exportSnapshot("phone", 2L).playbackStats!! + assertEquals(1, exported.songs.size) + assertEquals("missing.mp3", exported.songs.single().song.fileName) + } + + @Test + fun invalidSemanticUnresolvedPayloadsRemainVerbatimAndAreNotExported() = runBlocking { + val invalidDate = """{"song":{"fileName":"bad-date.mp3","durationMs":1,"normalizedFileName":"bad-date.mp3"},"contributions":[{"deviceId":"desktop","generation":0,"datedListenMs":{"not-a-date":1}}]}""" + val negativeValues = """{"song":{"fileName":"negative.mp3","durationMs":1,"normalizedFileName":"negative.mp3"},"contributions":[{"deviceId":"desktop","generation":-1,"datedListenMs":{"2026-07-10":-1}}]}""" + val payload = listenStatsRepository.exportLocalPayload() + listenStatsRepository.applyLocalPayload(payload.copy(unresolvedNodes = listOf( + ListenStatsUnresolvedNode("bad-date.mp3", 1L, invalidDate), + ListenStatsUnresolvedNode("negative.mp3", 1L, negativeValues) + )), trackMutation = false) + + store.applySnapshot(statsSnapshot()) + + val unresolved = listenStatsRepository.exportLocalPayload().unresolvedNodes + assertEquals(listOf(invalidDate, negativeValues), unresolved.map { it.payloadJson }) + assertTrue(store.exportSnapshot("phone", 2L).playbackStats!!.songs.isEmpty()) + } + + private fun statsSnapshot(vararg songs: SyncPlaybackStatsSong) = SyncSnapshot( + deviceId = "desktop", + exportedAt = 1L, + playbackStats = SyncPlaybackStats( + devices = listOf(SyncPlaybackStatsDevice("desktop", "Desktop", 1L, 1L, 2L, "desktop")), + songs = songs.toList(), + tombstones = listOf(SyncPlaybackStatsTombstone("old", 1L, 1L)) + ) + ) + + private suspend fun insertSong( + fileName: String, + filePath: String, + duration: Long, + totalSamples: Long, + loopStart: Long = 0L, + loopEnd: Long = 0L, + rating: Int = 0, + displayName: String? = null + ): Long { + return songDao.insertOrUpdateSong( + Song( + fileName = fileName, + filePath = filePath, + duration = duration, + totalSamples = totalSamples, + displayName = displayName, + loopStart = loopStart, + loopEnd = loopEnd, + rating = rating + ) + ) + } + + private class FakePlaylistIdMapper : PlaylistIdMapper { + private val records = mutableMapOf() + private var nextId = 1 + + override suspend fun getOrCreateSyncIdForExport( + localId: Int, + fingerprint: String, + now: Long + ): PlaylistSyncRecord { + val existing = records.values.firstOrNull { it.localId == localId } + if (existing != null) { + val updated = if (existing.fingerprint == fingerprint) { + existing + } else { + existing.copy(modifiedAt = now, fingerprint = fingerprint) + } + records[updated.syncId] = updated + return updated + } + + val record = PlaylistSyncRecord( + syncId = "playlist-sync-${nextId++}", + localId = localId, + modifiedAt = now, + fingerprint = fingerprint + ) + records[record.syncId] = record + return record + } + + override suspend fun findLocalId(syncId: String): Int? = records[syncId]?.localId + + override suspend fun findSyncId(localId: Int): String? = + records.values.firstOrNull { it.localId == localId }?.syncId + + override suspend fun saveMapping( + syncId: String, + localId: Int, + modifiedAt: Long, + fingerprint: String + ) { + records.entries.removeAll { it.key == syncId || it.value.localId == localId } + records[syncId] = PlaylistSyncRecord(syncId, localId, modifiedAt, fingerprint) + } + + override suspend fun removeStaleMappings(validLocalIds: Set) { + records.entries.removeAll { it.value.localId !in validLocalIds } + } + + override suspend fun clearAllMappings() { + records.clear() + } + } +} diff --git a/app/src/test/java/com/cpu/seamlessloopmobile/ui/screen/MainScreenTest.kt b/app/src/test/java/com/cpu/seamlessloopmobile/ui/screen/MainScreenTest.kt new file mode 100644 index 0000000..0279371 --- /dev/null +++ b/app/src/test/java/com/cpu/seamlessloopmobile/ui/screen/MainScreenTest.kt @@ -0,0 +1,39 @@ +package com.cpu.seamlessloopmobile.ui.screen + +import com.cpu.seamlessloopmobile.data.stats.TrackStat +import com.cpu.seamlessloopmobile.model.Song +import com.cpu.seamlessloopmobile.ui.screen.stats.canNavigateToPlaybackStat +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class MainScreenTest { + + @Test + fun boundStatsSelectTheExactLocalSongId() { + val target = Song(id = 42L, fileName = "target.mp3", filePath = "/music/target.mp3") + val samePathDifferentId = Song(id = 7L, fileName = "other.mp3", filePath = target.filePath) + val stat = TrackStat(songId = target.id, filePath = target.filePath) + + assertEquals(target, findSongForTrackStat(listOf(samePathDifferentId, target), stat)) + } + + @Test + fun unboundStatsDoNotUseWireOrPathFallback() { + val song = Song(id = 42L, fileName = "target.mp3", filePath = "/music/target.mp3") + val stat = TrackStat( + songId = 0L, + fileName = song.fileName, + filePath = song.filePath, + identityKey = "target.mp3|0" + ) + + assertNull(findSongForTrackStat(listOf(song), stat)) + } + + @Test + fun boundStatsRemainNavigableWhenTheirFileIsMissing() { + assertEquals(true, canNavigateToPlaybackStat(TrackStat(songId = 42L, filePath = "/missing.mp3"))) + assertEquals(false, canNavigateToPlaybackStat(TrackStat(songId = 0L, filePath = "/present.mp3"))) + } +} diff --git a/app/src/test/java/com/cpu/seamlessloopmobile/ui/screen/settings/PlaybackStatsSourceDevicePresentationTest.kt b/app/src/test/java/com/cpu/seamlessloopmobile/ui/screen/settings/PlaybackStatsSourceDevicePresentationTest.kt new file mode 100644 index 0000000..c2f1f1b --- /dev/null +++ b/app/src/test/java/com/cpu/seamlessloopmobile/ui/screen/settings/PlaybackStatsSourceDevicePresentationTest.kt @@ -0,0 +1,40 @@ +package com.cpu.seamlessloopmobile.ui.screen.settings + +import com.cpu.seamlessloopmobile.data.sync.PlaybackStatsSourceDeviceSummary +import org.junit.Assert.assertEquals +import org.junit.Test + +class PlaybackStatsSourceDevicePresentationTest { + + @Test + fun groupsOnlyNonCurrentFullyRemovedSourcesAndKeepsActiveSources() { + val currentRemoved = source(deviceId = "current", isCurrentDevice = true, removed = true, listenMs = 60_000L) + val active = source(deviceId = "active", removed = false, listenMs = 120_000L) + val deletedOne = source(deviceId = "deleted-1", removed = true, listenMs = 180_000L) + val deletedTwo = source(deviceId = "deleted-2", removed = true, listenMs = 240_000L) + + val presentation = playbackStatsSourceDevicePresentation( + listOf(currentRemoved, active, deletedOne, deletedTwo) + ) + + assertEquals(listOf("current", "active"), presentation.activeSources.map { it.deviceId }) + assertEquals(2, presentation.historicalSourceCount) + } + + private fun source( + deviceId: String, + isCurrentDevice: Boolean = false, + removed: Boolean, + listenMs: Long + ) = PlaybackStatsSourceDeviceSummary( + deviceId = deviceId, + displayName = deviceId, + fallbackLabel = deviceId, + platform = "Android", + currentGeneration = 1L, + isCurrentDevice = isCurrentDevice, + contributedListenMs = listenMs, + hasEffectiveContributions = !removed, + allKnownGenerationsRemoved = removed + ) +} diff --git a/app/src/test/resources/sync/playback_stats_v2_android_golden.json b/app/src/test/resources/sync/playback_stats_v2_android_golden.json new file mode 100644 index 0000000..eeaed17 --- /dev/null +++ b/app/src/test/resources/sync/playback_stats_v2_android_golden.json @@ -0,0 +1,89 @@ +{ + "schemaVersion": 2, + "deviceId": "android-pixel-8", + "exportedAt": 1783779600000, + "playlists": [], + "loopPoints": [], + "ratings": [], + "playbackStatistics": { + "dateBucketBasis": "sourceLocal", + "devices": [ + { + "deviceId": "android-pixel-8", + "displayName": "Google Pixel 8", + "firstSeenAtUtcMs": 1783588500000, + "lastSeenAtUtcMs": 1783779600000, + "currentGeneration": 2, + "platform": "android", + "displayNameUpdatedAtUtcMs": 1783588500000 + }, + { + "deviceId": "desktop-wpf-1", + "displayName": "Windows desktop", + "firstSeenAtUtcMs": 1783588500000, + "lastSeenAtUtcMs": 1783779600000, + "currentGeneration": 1, + "platform": "windows", + "displayNameUpdatedAtUtcMs": 1783588500000 + } + ], + "songs": [ + { + "song": { + "fileName": " CAFÉ.MP3 ", + "durationMs": 123456, + "totalSamples": 5444400, + "normalizedFileName": "café.mp3" + }, + "contributions": [ + { + "deviceId": "android-pixel-8", + "generation": 2, + "datedListenMs": { "2026-07-10": 60000, "2026-07-11": 30000 }, + "undatedListenMs": 12000, + "firstPlayedAtUtcMs": 1783717800000, + "lastPlayedAtUtcMs": 1783779600000, + "updatedAtUtcMs": 1783779600000 + }, + { + "deviceId": "desktop-wpf-1", + "generation": 1, + "datedListenMs": { "2026-07-09": 45000 }, + "undatedListenMs": 0, + "firstPlayedAtUtcMs": 1783622400000, + "lastPlayedAtUtcMs": 1783622400000, + "updatedAtUtcMs": 1783622400000 + } + ] + }, + { + "song": { + "fileName": "Unresolved Mix.FLAC", + "durationMs": 654321, + "normalizedFileName": "unresolved mix.flac" + }, + "contributions": [ + { + "deviceId": "desktop-wpf-1", + "generation": 0, + "datedListenMs": {}, + "undatedListenMs": 9876, + "firstPlayedAtUtcMs": 1783622400000, + "lastPlayedAtUtcMs": 1783622400000, + "updatedAtUtcMs": 1783622400000 + } + ] + } + ], + "tombstones": [ + { + "deviceId": "android-pixel-8", + "generation": 1, + "tombstonedAtUtcMs": 1783672200000, + "scope": "deviceGeneration", + "tombstonedByDeviceId": "android-pixel-8", + "reason": "local_clear" + } + ] + } +} diff --git a/app/src/test/resources/sync/playback_stats_v2_tombstone_collision.json b/app/src/test/resources/sync/playback_stats_v2_tombstone_collision.json new file mode 100644 index 0000000..99d24a3 --- /dev/null +++ b/app/src/test/resources/sync/playback_stats_v2_tombstone_collision.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 2, + "deviceId": "desktop-wpf-1", + "exportedAt": 1783779600000, + "playlists": [], + "loopPoints": [], + "ratings": [], + "playbackStatistics": { + "dateBucketBasis": "sourceLocal", + "devices": [ + { + "deviceId": "android-pixel-8", + "displayName": "Google Pixel 8", + "firstSeenAtUtcMs": 1783588500000, + "lastSeenAtUtcMs": 1783672200000, + "currentGeneration": 2, + "platform": "android", + "displayNameUpdatedAtUtcMs": 1783588500000 + }, + { + "deviceId": "desktop-wpf-1", + "displayName": "Windows desktop", + "firstSeenAtUtcMs": 1783588500000, + "lastSeenAtUtcMs": 1783779600000, + "currentGeneration": 1, + "platform": "windows", + "displayNameUpdatedAtUtcMs": 1783588500000 + } + ], + "songs": [ + { + "song": { + "fileName": " CAFÉ.MP3 ", + "durationMs": 123456, + "totalSamples": 5444400, + "normalizedFileName": "café.mp3" + }, + "contributions": [ + { + "deviceId": "android-pixel-8", + "generation": 1, + "datedListenMs": { "2026-07-09": 15000 }, + "undatedListenMs": 0, + "firstPlayedAtUtcMs": 1783622400000, + "lastPlayedAtUtcMs": 1783622400000, + "updatedAtUtcMs": 1783622400000 + } + ] + } + ], + "tombstones": [] + } +} diff --git a/app/src/test/resources/sync/playback_stats_v2_wpf_canonical.json b/app/src/test/resources/sync/playback_stats_v2_wpf_canonical.json new file mode 100644 index 0000000..f84e897 --- /dev/null +++ b/app/src/test/resources/sync/playback_stats_v2_wpf_canonical.json @@ -0,0 +1,94 @@ +{ + "schemaVersion": 2, + "deviceId": "desktop-wpf-1", + "exportedAt": 1783779600000, + "playlists": [], + "loopPoints": [], + "ratings": [], + "playbackStatistics": { + "dateBucketBasis": "sourceLocal", + "devices": [ + { + "deviceId": "android-pixel-8", + "currentGeneration": 2, + "displayName": "Google Pixel 8", + "displayNameUpdatedAtUtcMs": 1783588500000, + "platform": "android", + "firstSeenAtUtcMs": 1783588500000, + "lastSeenAtUtcMs": 1783779600000 + }, + { + "deviceId": "desktop-wpf-1", + "currentGeneration": 1, + "displayName": "Windows desktop", + "displayNameUpdatedAtUtcMs": 1783588500000, + "platform": "windows", + "firstSeenAtUtcMs": 1783588500000, + "lastSeenAtUtcMs": 1783779600000 + } + ], + "songs": [ + { + "song": { + "fileName": " CAFÉ.MP3 ", + "normalizedFileName": "café.mp3", + "durationMs": 123456, + "totalSamples": 5444400 + }, + "contributions": [ + { + "deviceId": "android-pixel-8", + "generation": 2, + "datedListenMs": { + "2026-07-10": 60000, + "2026-07-11": 30000 + }, + "undatedListenMs": 12000, + "firstPlayedAtUtcMs": 1783717800000, + "lastPlayedAtUtcMs": 1783779600000, + "updatedAtUtcMs": 1783779600000 + }, + { + "deviceId": "desktop-wpf-1", + "generation": 1, + "datedListenMs": { + "2026-07-09": 45000 + }, + "undatedListenMs": 0, + "firstPlayedAtUtcMs": 1783622400000, + "lastPlayedAtUtcMs": 1783622400000, + "updatedAtUtcMs": 1783622400000 + } + ] + }, + { + "song": { + "fileName": "Unresolved Mix.FLAC", + "normalizedFileName": "unresolved mix.flac", + "durationMs": 654321 + }, + "contributions": [ + { + "deviceId": "desktop-wpf-1", + "generation": 0, + "datedListenMs": {}, + "undatedListenMs": 9876, + "firstPlayedAtUtcMs": 1783622400000, + "lastPlayedAtUtcMs": 1783622400000, + "updatedAtUtcMs": 1783622400000 + } + ] + } + ], + "tombstones": [ + { + "deviceId": "android-pixel-8", + "generation": 1, + "scope": "deviceGeneration", + "tombstonedAtUtcMs": 1783672200000, + "tombstonedByDeviceId": "android-pixel-8", + "reason": "local_clear" + } + ] + } +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index fee978b..529a386 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,5 @@ plugins { alias(libs.plugins.android.application) apply false - alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.google.devtools.ksp) apply false + alias(libs.plugins.compose.compiler) apply false } diff --git "a/docs/2026-07-06_NeriPlayer\351\243\216\346\240\274UI\344\270\216\346\222\255\346\224\276\347\273\237\350\256\241.md" "b/docs/2026-07-06_NeriPlayer\351\243\216\346\240\274UI\344\270\216\346\222\255\346\224\276\347\273\237\350\256\241.md" new file mode 100644 index 0000000..4ed7bef --- /dev/null +++ "b/docs/2026-07-06_NeriPlayer\351\243\216\346\240\274UI\344\270\216\346\222\255\346\224\276\347\273\237\350\256\241.md" @@ -0,0 +1,67 @@ +# 2026-07-06 NeriPlayer 风格 UI 与播放统计改造 + +## 背景 + +本轮改造以 `temp/NeriPlayer-master` 为视觉与交互参考,但保留本项目自定义 `MusicUiState` 导航、Native Oboe 播放核心和现有主色系。目标是把主界面从单一媒体库页扩展为更完整的本地播放器体验:底部主导航、独立搜索/设置/统计页面、毛玻璃迷你播放器、封面图、音频文件信息、触感反馈和真实播放时长统计。 + +## 主要变更 + +### 主界面与导航 + +- `MainScreen` 改为用单个 `AnimatedContent(targetState = uiState)` 承载 `Home`、`SongList`、`Search`、`Settings`、`PlaybackStats` 等页面。 +- 页面切换统一为向外弹出的 `scaleIn + fadeIn` / `scaleOut + fadeOut`,因此从二级页回媒体库时媒体库本身也会作为 incoming page 入场,而不是只被露出来。 +- 底部 `MiniPlayer` 和 `MainBottomNavigation` 是页面动画层的 sibling,不参与页面缩放,避免切页时底部控件闪烁或被 Haze 采样层吞掉。 +- 新增 `MainBottomNavigation`,底部主入口为 `媒体库 / 搜索 / 设置`;统计页不高亮任何底部主入口。 + +### Haze 与 MiniPlayer + +- 引入 Haze `0.7.0`,页面内容层作为唯一 `.haze(...)` source。 +- `MiniPlayer` 在上层使用 `hazeChild(...)`,保持真实背景采样;不要把 `MiniPlayer` 自身放进 `.haze(...)` source 内。 +- `MiniPlayer` 保留上一首/播放暂停/下一首,叠加播放进度、封面图、标题和时间信息。 + +### 搜索、设置与主题 + +- `SearchScreen` 成为独立页面,不自动聚焦输入框,避免进入搜索页时自动弹出键盘。 +- 删除旧 `SettingsDrawer`,设置改为二级页面结构,覆盖主题、数据、播放、界面等分组入口。 +- 新增 `ThemePreference`:`跟随系统 / 浅色 / 深色`,由 `SettingsManager.themePreference` 持久化。 +- 新增按钮触感反馈开关,设置项为 `点击触感反馈`,通过 `LocalButtonHapticFeedbackEnabled` 和 `rememberHapticClick` 下发到常用按钮。 + +### 播放页、封面与音频元数据 + +- Room 数据库版本升至 `13`,`SongEntity` / `Song` / `SongMetadataUpdate` 增加 `coverPath`、`mimeType`、`sampleRateHz`、`bitrateKbps` 等展示字段。 +- `AudioScanner` 通过 MediaStore 与 `MediaExtractor` 写入封面 URI、MIME、采样率和码率;`MusicScannerRepository` 在扫描合并时保留并刷新这些字段。 +- 新增 `SongArtwork`,供 `MiniPlayer`、歌曲列表、全屏播放页复用。 +- `PlayingPanel` 增加封面、文件信息行、改进后的进度条、循环点标记和更大的顶部按钮点击区域。 + +### 播放统计 + +- 新增 `TrackStat`、`ListenStatsRepository`、`PlaybackStatsTracker`。 +- 统计数据存储在 `filesDir/listen_stats.json`,不进入 Room。 +- 只统计真实处于 `PLAYING` 状态的墙钟播放时长;不统计播放次数、不统计循环次数。 +- `PlaybackService` 在播放、暂停、停止、切歌和销毁时刷新统计。 +- 新增 `PlaybackStatsScreen`:总览、Top 5 横向条形图、按累计收听时长排序的排行列表;歌曲文件缺失时保留历史并显示缺失状态。 +- 设置页新增清除播放统计入口,清除前有确认。 + +### 预留架构契约 + +- 新增睡眠定时器模型与管理器:`audio/timer/*`。 +- 新增音效控制契约:`audio/effects/*`,当前为 No-Op 实现,保留均衡器/预设/配置模型。 +- 新增同步契约与合并策略:`data/sync/*`,为后续 GitHub/云同步预留 portable identity 和 merge policy。 +- 新增 `MediaSessionPlaybackStateThrottler`,约束系统媒体状态刷新频率。 + +## 测试与验证 + +- 新增或更新单元测试: + - `SleepTimerManagerTest` + - `MediaSessionPlaybackStateThrottlerTest` + - `SystemMediaProgressSyncControllerTest` + - `SyncMergePolicyTest` + - `PlaybackStatsTrackerTest` +- 本轮由用户侧编译与真机视觉验证;本地只执行 `git diff --check`,未本地运行 Gradle 构建。 + +## 后续维护注意 + +- 页面切换动画现在由 `MainScreen` 顶层 `AnimatedContent(targetState = uiState)` 统一管理,避免再额外叠加二级页 overlay 动画。 +- Haze 层级必须保持“页面内容是 source,MiniPlayer 是上层 hazeChild sibling”。如果把 MiniPlayer 放进 source,容易出现毛玻璃存在但控件内容不可见的问题。 +- 播放统计的排序语义是累计真实收听时长,不要引入播放次数或循环次数作为排行指标。 +- 音频探测仍必须遵守 Native `fopen` 限制:传给 `NativeAudio.analyzeLoopPoints` 的必须是私有 cache 临时物理路径,并在 `finally` 中清理。 diff --git "a/docs/2026-07-07_GitHub\345\220\214\346\255\245\344\270\216\350\207\252\345\212\250\345\220\214\346\255\245.md" "b/docs/2026-07-07_GitHub\345\220\214\346\255\245\344\270\216\350\207\252\345\212\250\345\220\214\346\255\245.md" new file mode 100644 index 0000000..37c6422 --- /dev/null +++ "b/docs/2026-07-07_GitHub\345\220\214\346\255\245\344\270\216\350\207\252\345\212\250\345\220\214\346\255\245.md" @@ -0,0 +1,142 @@ +# 2026-07-07 GitHub 同步与自动同步 + +> [!IMPORTANT] +> 本文仅保留为早期 schema 1 实现记录,已不代表当前行为。当前只支持包含播放统计的严格 schema 2;云端初始化不能覆盖已存在的文件。请以 [播放统计与 GitHub 同步 Schema V2](./2026-07-12_播放统计与GitHub同步SchemaV2.md) 为准。 + +## 背景 + +本轮将此前预留的 `data/sync/` 同步契约落地为可用的 GitHub 单文件同步能力,用于在多台 Android 设备之间同步歌单、循环点和评分。同步文件不是手机 Room 数据库的复制品,而是一个便携 JSON 快照,使用歌曲的 portable identity 进行跨设备匹配。 + +同步入口放在设置页的 **GitHub 同步** 页面;自动同步为 MVP 版本,默认关闭,用户显式开启后才会注册后台任务。 + +## 用户入口 + +设置 → **GitHub 同步**: + +- `GitHub Token`:保存到 `SharedPreferencesGitHubSyncStore`;当前仍是 MVP 明文 `MODE_PRIVATE` 存储,后续应迁移到 EncryptedSharedPreferences 或 Android KeyStore。 +- `Owner / Repository / Branch / Path`:组成 `GitHubSyncConfig`,默认路径为 `seamless-loop/sync.json`。 +- `保存同步配置`:保存仓库配置和 token。 +- `自动同步`:默认关闭;配置和 token 完整后可开启。开启后由 WorkManager 在网络可用时约每小时同步一次。 +- `立即同步`:手动执行一次双向同步。 +- `数据管理`:刷新本机/云端摘要、用本机数据覆盖云端、删除云端同步文件、清除本机同步数据。 + +## 同步数据范围 + +当前 GitHub 同步范围只包含: + +- 歌单与歌单歌曲条目 +- 实质循环点(`loopStart/loopEnd` 非 `0/0`) +- 非零评分 + +不同步: + +- 音频文件本体 +- 封面、MIME、采样率、码率等扫描展示字段 +- 播放统计 JSON +- 播放队列和播放位置 +- App 设置项 + +## 快照与匹配策略 + +`SyncSnapshot` schema 当前版本为 `1`,核心内容包括: + +- `deviceId` +- `exportedAt` +- `playlists` +- `loopPoints` +- `ratings` + +歌曲引用使用 `SyncSongIdentity`: + +- 合并主键:`fileName.lowercase() + durationMs` +- 辅助匹配:`totalSamples` +- 兜底:`totalSamples ±10000`、`durationMs ±200ms`、唯一同名 + +`totalSamples` 不能参与普通合并去重,因为 Android/native 解码器与桌面端导入器可能对同一 FLAC 得到轻微不同的 sample 数。合并同一 identity 时,手机端优先保留远端原始 `SyncSongIdentity`,避免手机/桌面端在 `sync.json` 中反复把 `totalSamples` 改来改去。 + +本地导出与应用由 `RoomSyncSnapshotStore` 完成;歌单跨设备 ID 由 `SharedPreferencesPlaylistIdMapper` 维护。 + +## 合并与保护策略 + +合并由 `GitHubSyncCoordinator` 编排: + +1. 读取当前 mutationVersion 与 deviceId +2. 导出本地快照 +3. 下载远端快照 +4. 远端不存在时执行初次上传 +5. 远端存在时合并远端与本地 +6. 应用合并快照到 Room +7. 使用 GitHub 文件 SHA 作为 expectedRevision 上传 +8. 成功后保存 lastRemoteRevision 与 lastSyncTime + +保护策略: + +- 循环点 `0/0` 视为未设置,不能覆盖已有实质循环点。 +- 评分 `0` 视为未评分,不能覆盖已有非零评分。 +- 同步过程中若 `mutationVersion` 变化,返回 `LocalMutationDuringSync`,避免把同步期间的新本地修改覆盖掉。 +- GitHub 上传使用文件 SHA 做乐观锁;冲突时重新下载远端后重试。 + +注意:当前 mutationVersion 只在同步数据管理的清理操作中显式更新;评分、歌单、循环点的所有本地修改入口尚未完全统一接入 mutation hook。因此自动同步先实现为周期同步,暂不做“每次修改后立刻同步”。 + +## GitHub 后端 + +`GitHubContentsSyncBackend` 使用 GitHub Contents API: + +- `GET /repos/{owner}/{repo}/contents/{path}?ref={branch}` 下载快照并读取文件 SHA。 +- `PUT /repos/{owner}/{repo}/contents/{path}` 上传快照;携带 SHA 时执行乐观锁更新。 +- `DELETE /repos/{owner}/{repo}/contents/{path}` 删除远端同步文件。 + +需要 `INTERNET` 权限。Token 不进入 `GitHubSyncConfig`,而是由 `GitHubTokenProvider` 单独提供,减少配置对象在 UI 层传递时泄露凭据的风险。 + +## 自动同步 + +自动同步由 WorkManager 实现: + +- 调度器:`GitHubAutoSyncScheduler` +- Worker:`GitHubAutoSyncWorker` +- 唯一任务名:`com.cpu.seamlessloopmobile.GITHUB_AUTO_SYNC_PERIODIC` +- 周期:1 小时 +- 约束:`NetworkType.CONNECTED` +- 策略:`ExistingPeriodicWorkPolicy.KEEP` +- 重试:网络、冲突、未知错误使用 WorkManager exponential backoff;配置缺失、未授权、远端格式异常等永久错误不重试,避免后台耗电。 + +默认行为: + +- 自动同步开关默认 `false`。 +- 未配置仓库或 token 时不能开启 UI 开关。 +- 清除 GitHub 配置会同时关闭自动同步并取消 WorkManager 任务。 +- App 启动加载同步状态时会根据持久化开关、仓库配置和 token 可用性重新 reconcile WorkManager 队列。 + +并发注意:Worker 和手动同步各自构建 `GitHubSyncCoordinator`,因此没有共享同一个进程内 mutex。当前依赖 GitHub SHA 乐观锁、Room 事务和下一轮周期同步收敛;若后续要做更强一致性,可引入跨入口/跨进程同步锁。 + +## 涉及文件 + +- `app/src/main/java/com/cpu/seamlessloopmobile/data/sync/*` +- `app/src/main/java/com/cpu/seamlessloopmobile/data/sync/github/GitHubContentsSyncBackend.kt` +- `app/src/main/java/com/cpu/seamlessloopmobile/data/sync/room/RoomSyncSnapshotStore.kt` +- `app/src/main/java/com/cpu/seamlessloopmobile/data/sync/room/SharedPreferencesPlaylistIdMapper.kt` +- `app/src/main/java/com/cpu/seamlessloopmobile/viewmodel/GitHubSyncUiState.kt` +- `app/src/main/java/com/cpu/seamlessloopmobile/viewmodel/MainViewModel.kt` +- `app/src/main/java/com/cpu/seamlessloopmobile/viewmodel/MainViewModelFactory.kt` +- `app/src/main/java/com/cpu/seamlessloopmobile/ui/screen/settings/SettingsScreen.kt` +- `gradle/libs.versions.toml` +- `app/build.gradle.kts` +- `app/src/main/AndroidManifest.xml` + +## 验证 + +- 本轮执行了 `git diff --check`,仅出现 Windows 下 LF/CRLF 提示,没有空白错误。 +- 自动同步实现经过 oracle 复审,无阻塞问题。 +- 本地未运行 Gradle 构建;后续如要完整验证,优先运行: + +```bash +gradlew.bat testDebugUnitTest +gradlew.bat assembleDebug +``` + +## 后续事项 + +- 将 token 存储迁移到 EncryptedSharedPreferences 或 Android KeyStore。 +- 为评分、歌单、循环点修改入口补齐统一 mutation hook,再考虑修改触发的一次性延迟同步。 +- 增加 WorkManager `work-testing` 覆盖:调度器唯一任务、Worker 结果映射、配置缺失跳过、网络错误重试、未授权不重试。 +- 若需要更强一致性,引入手动同步与 Worker 共享的同步锁。 diff --git "a/docs/2026-07-12_\346\222\255\346\224\276\347\273\237\350\256\241\344\270\216GitHub\345\220\214\346\255\245SchemaV2.md" "b/docs/2026-07-12_\346\222\255\346\224\276\347\273\237\350\256\241\344\270\216GitHub\345\220\214\346\255\245SchemaV2.md" new file mode 100644 index 0000000..215c20c --- /dev/null +++ "b/docs/2026-07-12_\346\222\255\346\224\276\347\273\237\350\256\241\344\270\216GitHub\345\220\214\346\255\245SchemaV2.md" @@ -0,0 +1,234 @@ +# 播放统计与 GitHub 同步 Schema V2 + +## 1. 范围与当前状态 + +本文是当前 Android 实现的权威说明和用户参考,适用于 2026-07-12 的代码状态。 + +- 本地播放统计持久化格式是 `ListenStatsStore.SCHEMA_VERSION = 3`。 +- GitHub 单文件快照格式严格是 schema 2;线上字段名为 `playbackStatistics`。 +- 当前正式同步内容包括歌单、循环点、评分和播放统计。 +- 同步不上传音频文件,也不复制 Room 数据库或设备设置。 +- 当前只接受本地 schema 3 与云端 schema 2,不声明旧格式兼容或迁移能力。 + +相关实现集中在 `app/src/main/java/com/cpu/seamlessloopmobile/data/stats/` 和 +`app/src/main/java/com/cpu/seamlessloopmobile/data/sync/`。 + +## 2. 用户可见的同步内容 + +### 会同步 + +- 歌单、歌单名称及歌曲顺序。 +- 有实质值的循环点;`loopStart = 0` 且 `loopEnd = 0` 表示未设置。 +- 非零评分;评分 `0` 表示未设置。 +- 播放统计:歌曲身份、设备目录、按设备和代际累计的收听贡献、日期桶、无日期时长和永久代际 tombstone。 + +### 不会同步 + +- 音频文件本体、文件 URI/path 和播放队列。 +- 播放位置、播放模式、无缝循环开关及其他 App 设置。 +- 封面 URI、MIME、采样率、码率等扫描展示字段。 +- Room 数据库文件、设备本地缓存和 GitHub token。 + +云端快照是可移植的 JSON,不是任意一台手机 Room 文件的备份。 + +## 3. 本地 ListenStatsStore schema 3 + +本地文件位于 `filesDir/listen_stats.json`,由 `ListenStatsRepository` 和 +`ListenStatsStore` 管理。统计只累计 `AudioPlayState.PLAYING` 状态下的真实墙钟收听时长,不统计播放次数或循环次数。 + +schema 3 的主要结构如下: + +| 字段/节点 | 作用 | +| --- | --- | +| `schemaVersion` | 固定为 `3`。 | +| `devices` | 已知来源设备的 `deviceId`、展示名、平台、首次/最近出现时间和当前代际。 | +| `currentDeviceId` / `currentGeneration` | 当前安装实例使用的设备和代际。 | +| `songs` | 本地歌曲统计节点,保存线上身份、可选的 Room `boundSongId` 和展示元数据;未匹配节点使用 `boundSongId = 0`。 | +| `contributions` | `(deviceId, generation)` 唯一的累计贡献。 | +| `tombstones` | 永久屏蔽某个设备代际的删除记录。 | +| `unresolvedNodes` | 无法安全转换为 typed song node 的原始 payload,供后续恢复或诊断。 | + +每个 contribution 包含: + +- `dailyListenMs`:按来源设备本地日期保存的 `YYYY-MM-DD -> milliseconds` 桶。 +- `undatedListenMs`:无法归入日期桶的历史时长,不能因为日期缺失而丢弃。 +- `firstPlayedAtUtcMs`、`lastPlayedAtUtcMs`、`updatedAtUtcMs`:UTC 时间戳。 + +日期桶的含义是 `sourceLocal`:日期使用产生该收听记录的来源设备本地时区,而不是合并设备的时区。日、周、月、年和总排行在本地展示层计算。 + +## 4. 云端 schema 2 与 canonical `playbackStatistics` + +`SyncSnapshot.schemaVersion` 必须为 `2`,且必须存在对象字段 `playbackStatistics`。上传前由 `prepareV2Egress()` 和序列化器生成 canonical 形式。 + +`playbackStatistics` 的主要结构为: + +```json +{ + "dateBucketBasis": "sourceLocal", + "devices": [], + "songs": [ + { + "song": { + "fileName": "Example.ogg", + "normalizedFileName": "example.ogg", + "durationMs": 123456, + "totalSamples": 12345678 + }, + "contributions": [] + } + ], + "tombstones": [] +} +``` + +`devices`、歌曲和 contribution 会按稳定规则排序;日期桶按日期排序。每个歌曲身份和每个 `(deviceId, generation)` contribution 在同一个 `playbackStatistics` 中只能出现一次。 + +### 累计贡献与代际 + +一个设备的一次安装实例使用一个 `deviceId`,并在同一设备上用 `generation` 区分清除前后的历史。贡献是单调累计值,合并时对每个日期桶和 `undatedListenMs` 取最大值,而不是把两个快照简单相加。这样同一批数据被重复上传不会重复计时。 + +清理当前设备统计时,应用会: + +1. 为当前 `(deviceId, generation)` 写入 tombstone。 +2. 持久化 tombstone。 +3. 将当前 generation 旋转到下一代。 +4. 之后的新收听写入新代际。 + +tombstone 是永久的。它会阻止该代际的 contribution 重新进入合并结果,也会使使用旧 write fence 的延迟播放写入失效。远端删除来源历史同样按代际 tombstone 表示,不删除线上其他设备的贡献。 + +### 严格线上歌曲身份 + +播放统计的 wire identity 精确为: + +```text +(NFC + Locale.ROOT normalized basename, exact durationMs) +``` + +也就是文件 basename 经 trim、Unicode NFC 规范化、`Locale.ROOT` 小写化,再与精确的 `durationMs` 组成主键。`totalSamples` 是辅助信息,绝不能成为 wire identity 的组成部分;同一首歌在 Android/native 与桌面端可能产生略有差异的 sample 数。 + +线上 identity 和 contribution 一旦写入,不能因为本地重新扫描、绑定或展示元数据刷新而被改写或拆分。 + +## 5. 本地重绑定与展示聚合 + +云端歌曲应用到本地时,`RoomSyncSnapshotStore` 只做本地匹配,不改变 wire identity 或 contribution。匹配顺序如下,每一级只有唯一候选才绑定: + +1. 精确 basename + 精确 `durationMs`。 +2. 精确 basename + 精确 `totalSamples`。 +3. 同名且 `totalSamples` 差值不超过 `10000`。 +4. 同名且 `durationMs` 差值不超过 `200ms`。 +5. 唯一同名候选。 + +任一级出现多个候选都会停止该级并继续后续规则;最终同名仍有歧义时保持未绑定。重新扫描或歌曲出现后会再次尝试绑定。绑定只更新本地 `boundSongId`、路径、标题、艺术家、专辑和封面等展示关系。 + +多个 wire identity 可以绑定到同一个本地 Room song。它们只在播放统计页等 presentation 层按本地歌曲聚合,云端仍分别保留各自 identity 和 contribution;presentation 聚合不能反向合并或改写 wire 数据。 + +可解析但无法匹配的歌曲仍保留为 typed song node,并设置 `boundSongId = 0`;重新扫描后会再次尝试绑定。文件暂时缺失、被删除或名称发生变化时,历史仍保留并在统计页显示缺失状态。无法安全解析的原始 payload 才保留在 `unresolvedNodes`,且不会作为有效统计再次导出。 + +## 6. 确定性 ACI 元数据归约 + +多个来源为同一个 exact wire key 提供了不同的非 key metadata 时,`reducePlaybackSongIdentity()` 使用确定性规则: + +- `fileName` 选择规范化结果仍匹配 key 的有效原始名称中,按 Kotlin `String.compareTo` 的 UTF-16 字典序最小者。 +- `normalizedFileName` 和 `durationMs` 始终来自 stable key。 +- `totalSamples` 取最大的非空值。 +- `contentHash` 取最大的非空字符串。 + +这是非 key metadata 的确定性 reduction,不是“远端优先”;远端原始 sample 数不会因为来源位置而优先覆盖本地值。 + +## 7. 设备身份、展示名与来源删除 + +`SharedPreferencesGitHubSyncStore` 在 App 私有存储中保存随机 UUID 形式的 `deviceId`。设备展示名、平台及时间戳用于来源管理和统计展示,不能用展示名替代 `deviceId`。清空 App 存储会一并清除该 ID,因此下一次启动会创建新的 `deviceId`;这属于新来源设备,不会自动继承旧来源的代际。 + +“GitHub 同步 → 数据管理”会按来源设备汇总当前仍有效的 contribution。用户删除来源设备历史时,底层仍按该设备已知的每个 generation 写入 tombstone。完全删除的来源只在 UI 中作为来源汇总/历史状态分组,不会在 wire schema 中创建一个额外的“已删除来源”身份。 + +应用内清除当前播放统计会旋转 generation;清除歌单、循环点或评分则清除对应 Room 数据并清理同步元数据。清除本地数据不会删除音频文件。 + +## 8. 手动同步、自动同步与首次初始化 + +### 普通手动同步 + +`GitHubSyncCoordinator` 的普通流程是:导出本地 → 下载云端 → 校验 schema 2 → 合并歌单/循环点/评分/播放统计 → 应用合并快照 → 带 SHA 上传 → 保存最新 revision 和同步时间。 + +云端文件不存在时,普通同步会把本地 schema 2 快照作为初次内容上传。云端已存在时,始终使用双向合并,不会用本机数据无条件覆盖云端。 + +### 自动同步 + +自动同步默认关闭。配置完整且用户开启后,`GitHubAutoSyncScheduler` 使用 WorkManager 注册唯一任务 `com.cpu.seamlessloopmobile.GITHUB_AUTO_SYNC_PERIODIC`:约每小时一次,要求网络可用,使用 `ExistingPeriodicWorkPolicy.KEEP`。清除 GitHub 配置会关闭并取消该任务。当前没有对每一次歌单/循环点/评分修改立即触发同步的统一入口,因此自动同步仍是周期任务。 + +### Seed-cloud 安全规则 + +“数据管理 → 用本机数据初始化云端”只允许在 GitHub 文件确实 `NOT_FOUND` 时执行。若云端文件已经存在,即使用户希望覆盖,也必须使用普通同步,或先明确删除云端文件再重新 seed;seed 不携带 expected SHA,也不会绕过这个存在性检查。 + +## 9. 并发、冲突与增量保护 + +- 同一 `GitHubSyncCoordinator` 内用 `Mutex` 串行化同步。 +- GitHub Contents API 上传携带下载到的文件 SHA 作为 expected revision。SHA 冲突时重新下载远端,重新合并并重试,避免覆盖其他设备的更新。 +- 同步开始时记录 `mutationVersion`。导出、合并、应用或上传前发现本地 mutationVersion 已变化,会返回 `LocalMutationDuringSync`,不继续发布过时结果。 +- 手动同步和 WorkManager Worker 当前各自创建 coordinator,没有共享跨入口 mutex;依靠 SHA 乐观锁、Room 事务和下一轮同步收敛。 +- 应用 stale payload 时,`ListenStatsRepository.applyLocalPayload()` 会保留当前本地节点,并按 `(deviceId, generation)` 的累计最大值合并同步期间产生的新 contribution;不会让旧导出覆盖较新的本地收听记录。 + +## 10. 严格校验与 identity backfill + +`SyncSnapshotSerializer` 在 Gson 反序列化前校验: + +- JSON 必须是对象,`schemaVersion` 必须为 `2`。 +- 必须使用 `playbackStatistics`;旧别名 `playbackStats`、其他 schema 和缺失字段直接拒绝。 +- `dateBucketBasis` 必须是 `sourceLocal`。 +- 身份、设备、代际、时间、时长、sample 数和收听时长不能为负;日期桶必须是合法 ISO 日期。 +- playback songs、contributions 和 tombstones 不能有重复 stable key。 +- playback 的 `normalizedFileName` 必须严格等于 `fileName` 的 NFC + `Locale.ROOT` 规范化值。 + +只有通用的歌单/循环点/评分歌曲 identity 允许在 Gson 之前补写缺失的 `normalizedFileName`,再进行规范化校验。播放统计 identity 不做 backfill,缺失或不一致直接失败,以保证线上播放主键严格不变。 + +## 11. 故障排查 + +| 现象 | 处理 | +| --- | --- | +| 无法开启自动同步 | 检查 Owner、Repository、Branch、Path 和 token 是否都已保存;自动同步默认关闭。 | +| `NOT_FOUND` | 确认仓库、分支和 path;若确实是首次使用,可执行普通同步或 seed。 | +| 云端已存在但 seed 失败 | 这是保护行为。使用普通“立即同步”,或确认后删除云端文件再 seed。 | +| `Unsupported remote schema version` / `INVALID_REMOTE` | 云端不是严格 schema 2,先备份并检查 JSON;不要把旧格式强行改名后上传。 | +| `playbackStatistics` 校验失败 | 检查字段名、`sourceLocal`、规范化文件名、负数、重复 identity/contribution 和非法日期。 | +| 统计显示未绑定或缺失 | 重新扫描本地音乐;匹配只按本文第 5 节规则进行,歧义不会强行绑定。 | +| 同步提示本地变更 | 同步期间发生了新的本地 mutation,重新执行“立即同步”。 | +| 上传冲突 | SHA 已变化,协调器会自动重试;连续失败时检查网络和是否有其他客户端持续写入同一文件。 | +| 清空 App 后来源变多 | App 私有存储重置会创建新的 `deviceId`,旧来源历史仍由 wire tombstone/贡献保留。 | + +## 12. 相关文件、测试与命令 + +主要实现文件: + +- `app/src/main/java/com/cpu/seamlessloopmobile/data/stats/ListenStatsStore.kt` +- `app/src/main/java/com/cpu/seamlessloopmobile/data/stats/ListenStatsRepository.kt` +- `app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncModels.kt` +- `app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncSongIdentityKey.kt` +- `app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncMergeEngine.kt` +- `app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncSnapshotSerializer.kt` +- `app/src/main/java/com/cpu/seamlessloopmobile/data/sync/GitHubSyncCoordinator.kt` +- `app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SyncDataManagementRepository.kt` +- `app/src/main/java/com/cpu/seamlessloopmobile/data/sync/room/RoomSyncSnapshotStore.kt` +- `app/src/main/java/com/cpu/seamlessloopmobile/data/sync/SharedPreferencesGitHubSyncStore.kt` + +重点测试: + +- `ListenStatsRepositoryTest` +- `SyncMergeEngineTest` +- `SyncSnapshotSerializerTest` +- `GitHubSyncCoordinatorTest` +- `SyncDataManagementRepositoryTest` +- `RoomSyncSnapshotStoreTest` + +常用验证命令: + +```powershell +.\gradlew.bat -q testDebugUnitTest --tests "com.cpu.seamlessloopmobile.data.stats.ListenStatsRepositoryTest" +.\gradlew.bat -q testDebugUnitTest --tests "com.cpu.seamlessloopmobile.data.sync.SyncMergeEngineTest" +.\gradlew.bat -q testDebugUnitTest --tests "com.cpu.seamlessloopmobile.data.sync.SyncSnapshotSerializerTest" +.\gradlew.bat -q testDebugUnitTest --tests "com.cpu.seamlessloopmobile.data.sync.GitHubSyncCoordinatorTest" +.\gradlew.bat -q testDebugUnitTest --tests "com.cpu.seamlessloopmobile.data.sync.SyncDataManagementRepositoryTest" +.\gradlew.bat -q testDebugUnitTest --tests "com.cpu.seamlessloopmobile.data.sync.room.RoomSyncSnapshotStoreTest" +.\gradlew.bat -q testDebugUnitTest +.\gradlew.bat -q assembleDebug +``` + +本文与工程约束见 [AGENTS.md](../AGENTS.md);用户入口和配置步骤见 [README.md](../README.md)。 diff --git a/gradle.properties b/gradle.properties index 079a4a7..754aa91 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,8 +22,6 @@ kotlin.code.style=official android.injected.testOnly=false # 莱芙给大人加一点编译体力喵! -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -XX:MaxMetaspaceSize=512m -Xss4m - +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -XX:MaxMetaspaceSize=512m -Xss4m -Djava.security.manager=allow # 解决 KSP 与 AGP 9.0+ 内置 Kotlin 的 sourceSets 兼容冲突喵 ㅠㅠ android.disallowKotlinSourceSets=false - diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e01d951..c9fa736 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,10 @@ robolectric = "4.11.1" core = "1.7.0" composeBom = "2024.12.01" gson = "2.10.1" +hazeJetpackCompose = "0.7.0" +coil = "2.7.0" +okhttp = "4.12.0" +work = "2.9.0" [libraries] @@ -44,6 +48,11 @@ androidx-compose-runtime-livedata = { group = "androidx.compose.runtime", name = androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version = "2.8.5" } gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } +haze-jetpack-compose = { module = "dev.chrisbanes.haze:haze-jetpack-compose", version.ref = "hazeJetpackCompose" } +coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } +androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "work" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }