From 73fcfae30f4a85fdc8ec1f928044085334aeedef Mon Sep 17 00:00:00 2001 From: Honey Date: Thu, 25 Jun 2026 10:06:43 +0200 Subject: [PATCH] =?UTF-8?q?feat(android):=20offline=20downloads=20?= =?UTF-8?q?=E2=80=94=20albums,=20playlists,=20detail=20screen=20&=20playba?= =?UTF-8?q?ck?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Download albums/playlists to private device storage (Room + WorkManager, HttpURLConnection streaming). New Offline tab lists groups with progress; tapping a group opens a detail screen (cover-art rows, play/shuffle, per-song delete from device). Downloaded tracks play from disk on every screen. Album/playlist cards show a downloaded badge; download button resumes missing songs. Song storage is dedup'd by ID and reference-counted on delete. Fixes: Android 15 download crash (dropped banned DATA_SYNC foreground service); offline detail flash-back (initial empty state no longer mistaken for deleted). Docs: CHANGELOG 0.10 + parity matrix; condensed README Features. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 22 ++ CLAUDE.md | 9 + README.md | 74 +---- android/app/build.gradle.kts | 3 + android/app/src/main/AndroidManifest.xml | 2 +- .../kotlin/com/novasonic/app/NovaSonicApp.kt | 4 + .../com/novasonic/app/api/LrcLibClient.kt | 3 +- .../com/novasonic/app/data/MusicRepository.kt | 18 ++ .../novasonic/app/offline/DownloadWorker.kt | 114 ++++++++ .../com/novasonic/app/offline/OfflineDao.kt | 93 +++++++ .../novasonic/app/offline/OfflineDatabase.kt | 38 +++ .../novasonic/app/offline/OfflineEntities.kt | 91 +++++++ .../app/offline/OfflineRepository.kt | 211 +++++++++++++++ .../app/player/AbsPlayerController.kt | 5 +- .../com/novasonic/app/player/MusicService.kt | 6 +- .../novasonic/app/player/PlayerController.kt | 5 +- .../com/novasonic/app/ui/AppNavigation.kt | 48 ++-- .../novasonic/app/ui/component/AlbumCard.kt | 44 ++- .../novasonic/app/ui/screen/AlbumScreen.kt | 28 ++ .../novasonic/app/ui/screen/LibraryScreen.kt | 253 ++++++++++++++++-- .../app/ui/screen/OfflineDetailScreen.kt | 185 +++++++++++++ .../app/ui/screen/PlaylistDetailScreen.kt | 21 ++ .../novasonic/app/viewmodel/AlbumViewModel.kt | 33 ++- .../novasonic/app/viewmodel/HomeViewModel.kt | 31 ++- .../app/viewmodel/LibraryViewModel.kt | 17 +- .../app/viewmodel/OfflineDetailViewModel.kt | 33 +++ .../app/viewmodel/OfflineViewModel.kt | 32 +++ .../app/viewmodel/PlaylistViewModel.kt | 33 ++- android/gradle/libs.versions.toml | 4 + 29 files changed, 1326 insertions(+), 134 deletions(-) create mode 100644 android/app/src/main/kotlin/com/novasonic/app/offline/DownloadWorker.kt create mode 100644 android/app/src/main/kotlin/com/novasonic/app/offline/OfflineDao.kt create mode 100644 android/app/src/main/kotlin/com/novasonic/app/offline/OfflineDatabase.kt create mode 100644 android/app/src/main/kotlin/com/novasonic/app/offline/OfflineEntities.kt create mode 100644 android/app/src/main/kotlin/com/novasonic/app/offline/OfflineRepository.kt create mode 100644 android/app/src/main/kotlin/com/novasonic/app/ui/screen/OfflineDetailScreen.kt create mode 100644 android/app/src/main/kotlin/com/novasonic/app/viewmodel/OfflineDetailViewModel.kt create mode 100644 android/app/src/main/kotlin/com/novasonic/app/viewmodel/OfflineViewModel.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index ae4ab85..0fe5a44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,11 +45,33 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an | Media notification | ✅ | ✅ | | ABS notification (modern style) | ❌ | ✅ | | Android Auto | — | ✅ | +| Offline download (albums/playlists) | ❌ | ✅ | +| Offline playback | ❌ | ✅ | +| Offline detail / manage downloads | ❌ | ✅ | | Romaji / Japanese sort | ✅ | ❌ | | Settings (Navidrome, ABS, etc.) | ✅ | ✅ | --- +## [0.10] — 2026-06-25 + +### Added +- **Android** — Offline downloads: download albums and playlists to device storage with a single tap; album download button is in the album hero row (▶ / shuffle / ↓), playlist button next to the edit toggle. Tapping again resumes / re-downloads any missing songs (already-downloaded tracks are skipped) +- **Android** — Offline tab in Library (position 2, next to Playlists): lists downloaded groups with cover art, track count, and an in-progress bar; shows total MB used +- **Android** — Offline detail screen: tap a group to view its songs (with cover art, like the online playlist), play / shuffle, and remove individual songs from the device via the edit toggle. Works fully offline (reads from Room, no network) +- **Android** — Offline playback: tap ▶ on a fully-downloaded group to play locally; downloaded songs are also served from disk automatically when opened from any album or playlist view +- **Android** — Downloaded badge: albums and playlists in the Library tabs show a ↓-in-circle badge on the cover when fully downloaded +- **Android** — Duplicate-safe storage: a song shared by an album and a playlist is stored once (keyed by song ID) and reference-counted on delete — removing one group keeps the file if another still needs it + +### Fixed +- **Android** — Download crash on Android 15 (targetSdk 35): `FOREGROUND_SERVICE_TYPE_DATA_SYNC` is banned for apps targeting API 35; WorkManager's expedited path used it and the resulting exception killed the app ~1.5 s after tapping download. Fixed by dropping `setExpedited()` and all foreground-service use from `DownloadWorker` — downloads run as silent background work with progress shown in the Offline tab +- **Android** — Offline detail screen flashed open then immediately returned: its initial empty state (before Room emitted) was mistaken for "deleted". Now shows a brief spinner until loaded, and only leaves when the last song is actually removed + +### Internal +- **Android** — Offline system: `Room` DB (`novasonic_offline.db`) with `downloaded_tracks`, `download_groups`, `group_track_cross_refs`; `OfflineRepository` holds hot `StateFlow` maps for O(1) local-path lookups; `DownloadWorker` streams audio + cover art via `HttpURLConnection` to `context.filesDir/offline/{audio|covers}/`. `MusicRepository.streamUrl()` checks local files first, so playback uses the `file://` URI on every entry point + +--- + ## [0.9] — 2026-06-22 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 978d911..19f09ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,6 +70,15 @@ Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives a share the same `mainArtist` value (= iTunes `collectionArtistName` when available, else `albumArtist` param, else track artist). Never use `rawArtist` for `album_artist`. +## Android offline system + +- **Storage:** `context.filesDir/offline/audio/{songId}.{ext}` and `.../covers/{coverArtId}.jpg` — private internal storage, not accessible via file manager. +- **Database:** Room DB `novasonic_offline.db` (version 1) with three tables: `downloaded_tracks` (track metadata + local paths + state), `download_groups` (album/playlist/song group + progress), `group_track_cross_refs` (many-to-many). See [OfflineEntities.kt](android/app/src/main/kotlin/com/novasonic/app/offline/OfflineEntities.kt) and [OfflineDao.kt](android/app/src/main/kotlin/com/novasonic/app/offline/OfflineDao.kt). +- **OfflineRepository** ([OfflineRepository.kt](android/app/src/main/kotlin/com/novasonic/app/offline/OfflineRepository.kt)): holds hot `StateFlow` maps (`_audioMap`, `_coverMap`) keyed by songId/coverArtId for O(1) lookups in `MusicRepository.streamUrl()`. Call `enqueueGroup()` to schedule a download. +- **DownloadWorker** ([DownloadWorker.kt](android/app/src/main/kotlin/com/novasonic/app/offline/DownloadWorker.kt)): `CoroutineWorker` using plain `HttpURLConnection` streaming to `.tmp` → rename. **No `setExpedited()` and no `setForeground()`** — `FOREGROUND_SERVICE_TYPE_DATA_SYNC` is banned on Android 15 (targetSdk=35) and crashes the app; downloads are silent background work. +- **Playback:** `MusicRepository.streamUrl()` checks `offlineRepo.localAudioPath(id)` first; if the file exists it returns a `file://` URI. This applies to every play path — album, playlist, Offline tab — no special offline mode needed. +- **Offline tab:** Library → tab 2 (OFFLINE). Driven by `OfflineViewModel`. Play button on a completed group calls `offlineVm.songsForGroup()` (JOIN query → `DownloadedTrackEntity.toSong()`) then `player.playSongs()`. + ## UI / references When referencing files in chat, use clickable markdown links, not backticks: diff --git a/README.md b/README.md index 9d35372..3e003a4 100644 --- a/README.md +++ b/README.md @@ -7,70 +7,17 @@ Built with Vite + vanilla ES modules (web) and Jetpack Compose + Media3/ExoPlaye ## Features -### Music Library -- Stream music from your Navidrome / Subsonic library -- Browse artists, albums, playlists, starred tracks, genres, and random picks -- Search across songs, albums, and artists -- Star and context menu on every song row -- Play count display (optional) +- **Library** — Stream from Navidrome / Subsonic; browse artists, albums, playlists, genres, starred, and random; search; star + context menu on every row. +- **Playback** — Queue with shuffle, repeat, and radio mode (endless similar songs); gapless-style preload; lock-screen / notification controls; Navidrome scrobbling. +- **Lyrics** — Synced and plain lyrics via lrclib.net, auto-fetched and cached per session. +- **Artist pages** — Hero card, Last.fm bio / similar / top songs, Ticketmaster events, and iTunes cross-reference showing owned vs. missing tracks. +- **Playlists** — Create, rename, delete, add/remove tracks, and upload a cover (Web + Android). +- **Importer & downloads** — Paste a Spotify / YouTube / YouTube Music / SoundCloud link to download via yt-dlp, with iTunes metadata enrichment and duplicate detection. +- **Metadata editor** (web) — Edit tags + cover art; files are renamed/moved and Navidrome is rescanned on save. +- **Offline** (Android) — Download albums/playlists to private storage; an Offline tab to play and manage them; downloaded tracks play from disk on every screen. +- **Audiobookshelf** — Browse and stream audiobooks with chapter navigation, speed control, sleep timer, and progress sync. -### Lyrics -- Synced and plain lyrics via lrclib.net, auto-fetched per track -- Fallback chain: exact match → no-duration match → fuzzy artist+title search → title-only search -- In-memory cache per session - -### Artist Pages -- Hero card with artist photo and blurred background -- Bio, similar artists, top songs from Last.fm -- Concert events via Ticketmaster -- Albums cross-referenced against iTunes catalog — shows downloaded vs. missing tracks -- Artist cards with images in search results - -### Playback -- Queue with shuffle, repeat (all / one), and radio mode (endless similar songs) -- Now Playing overlay with synced lyrics, queue view, star, and multi-artist links -- Gapless-style playback — next track pre-buffered before current track ends -- Lock screen / notification media controls -- Scrobbling to Navidrome (configurable delay) - -### Discovery -- iTunes / Last.fm — charts, global search, 30-second previews, similar artists -- Song Importer — paste a Spotify, YouTube, YouTube Music, or SoundCloud link to download - -### Downloads -- Download tracks, albums, or entire artists from YouTube Music / SoundCloud / Spotify via yt-dlp -- iTunes metadata enrichment — album art, year, track number, genre -- Duplicate detection, standard folder/filename structure - -### Metadata Editor -- Edit tags (title, artist, album, genre, year, track, disc, cover art) -- File renamed and moved automatically; Navidrome rescan triggered on save - -### Playlists -- Create, rename, and delete playlists -- Add tracks via "Add to Playlist" context menu -- Remove tracks from a playlist (edit mode on Android; inline on Web) -- Cover image upload (Web + Android) - -### Audiobookshelf Integration -- Browse libraries and audiobooks on both Web and Android -- Stream audiobooks with chapter-by-chapter navigation -- Playback speed control (0.5× – 2.0×) -- Sleep timer -- Progress sync — current position saved back to ABS server every 30 s -- Chapter track mode — progress bar shows current chapter length instead of full book (toggle in Settings) -- Tab/section hidden until ABS is configured in Settings - -### App — Web PWA -- Installable on Android / iOS / desktop -- Multiple color themes, German UI - -### App — Android (native Jetpack Compose) -- Media3 / ExoPlayer backend -- OS notification with playback controls -- ABS audiobook notification: chapter name shown as title, book title as artist; extra buttons — Kapitel zurück | 30s zurück | Play/Pause | 30s vor | Kapitel vor (compact + expanded) -- Last played song shown in mini player bar on restart; tap to resume -- Full playlist management: create, rename (with description), delete, remove tracks, cover upload +Web is an installable PWA (themes, German UI); Android is native Jetpack Compose + Media3/ExoPlayer with a mini player, media/ABS notifications, and Android Auto. See [CHANGELOG.md](CHANGELOG.md) for the full web/Android parity matrix. --- @@ -119,6 +66,7 @@ NovaSonic/ │ └── app/src/main/kotlin/com/novasonic/app/ │ ├── api/ # SubsonicClient, AbsClient, LrcLibClient, ItunesClient │ ├── data/ # PrefsStore (DataStore), MusicRepository +│ ├── offline/ # Room DB, DAO, OfflineRepository, DownloadWorker (offline downloads) │ ├── player/ # PlayerController, MusicService (Media3) │ ├── ui/ │ │ ├── screen/ # HomeScreen, NowPlayingScreen, AbsScreen, SettingsScreen, … diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9f3c9ad..f6c9dac 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -87,6 +87,9 @@ dependencies { implementation(libs.room.ktx) ksp(libs.room.compiler) + // WorkManager + implementation(libs.work.runtime.ktx) + // DataStore implementation(libs.datastore.preferences) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fddbd2f..6ec18a9 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,7 +5,7 @@ - + diff --git a/android/app/src/main/kotlin/com/novasonic/app/NovaSonicApp.kt b/android/app/src/main/kotlin/com/novasonic/app/NovaSonicApp.kt index 8ffbd0a..53c3ca3 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/NovaSonicApp.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/NovaSonicApp.kt @@ -2,6 +2,7 @@ package com.novasonic.app import android.app.Application import com.novasonic.app.data.MusicRepository +import com.novasonic.app.offline.OfflineRepository import com.novasonic.app.player.AbsPlayerController import com.novasonic.app.player.PlayerController @@ -13,9 +14,12 @@ class NovaSonicApp : Application() { private set lateinit var absPlayerController: AbsPlayerController private set + lateinit var offlineRepository: OfflineRepository + private set override fun onCreate() { super.onCreate() + offlineRepository = OfflineRepository(applicationContext) // must be first repository = MusicRepository(applicationContext) playerController = PlayerController(applicationContext, repository) playerController.connect() diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/LrcLibClient.kt b/android/app/src/main/kotlin/com/novasonic/app/api/LrcLibClient.kt index 71c315a..c0c6424 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/api/LrcLibClient.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/api/LrcLibClient.kt @@ -126,7 +126,8 @@ object LrcLibClient { val synced = candidates.filter { !it.syncedLyrics.isNullOrBlank() } val pool = synced.ifEmpty { candidates } return if (durationSecs != null && durationSecs > 0) { - val best = pool.minByOrNull { kotlin.math.abs(it.duration - durationSecs) }!! + val best = pool.minByOrNull { kotlin.math.abs(it.duration - durationSecs) } + ?: return synced.firstOrNull() ?: candidates.firstOrNull() if (kotlin.math.abs(best.duration - durationSecs) <= 5.0) best else synced.firstOrNull() ?: candidates.firstOrNull() } else { diff --git a/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt b/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt index 2f98d8b..0ef7932 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt @@ -1,9 +1,13 @@ package com.novasonic.app.data import android.content.Context +import android.net.Uri +import com.novasonic.app.NovaSonicApp import com.novasonic.app.api.SubsonicClient import com.novasonic.app.api.model.* +import com.novasonic.app.offline.OfflineRepository import kotlinx.coroutines.flow.first +import java.io.File object ClientHolder { @Volatile private var client: SubsonicClient? = null @@ -65,16 +69,30 @@ class MusicRepository(private val context: Context) { suspend fun removePlaylistCover(playlistId: String) = api().removePlaylistCover(playlistId) fun coverArtUrl(id: String?, size: Int = 300): String? { + if (id != null) { + offlineRepo()?.localCoverPath(id)?.let { path -> + val f = File(path) + if (f.exists()) return Uri.fromFile(f).toString() + } + } val creds = lastCredentials ?: return null return SubsonicClient(creds.serverUrl, creds.username, creds.password) .coverArtUrl(id ?: return null, size) } fun streamUrl(id: String): String? { + offlineRepo()?.localAudioPath(id)?.let { path -> + val f = File(path) + if (f.exists()) return Uri.fromFile(f).toString() + } val creds = lastCredentials ?: return null return SubsonicClient(creds.serverUrl, creds.username, creds.password).streamUrl(id) } + private fun offlineRepo(): OfflineRepository? = runCatching { + (context.applicationContext as NovaSonicApp).offlineRepository + }.getOrNull() + var lastCredentials: PrefsStore.Credentials? = null fun credentialsFlow() = prefs.credentials diff --git a/android/app/src/main/kotlin/com/novasonic/app/offline/DownloadWorker.kt b/android/app/src/main/kotlin/com/novasonic/app/offline/DownloadWorker.kt new file mode 100644 index 0000000..8539870 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/offline/DownloadWorker.kt @@ -0,0 +1,114 @@ +package com.novasonic.app.offline + +import android.content.Context +import android.util.Log +import androidx.work.* +import com.novasonic.app.app +import com.novasonic.app.data.PrefsStore +import com.novasonic.app.api.SubsonicClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import java.net.HttpURLConnection +import java.net.URL + +class DownloadWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + + companion object { + const val EXTRA_GROUP_ID = "group_id" + } + + private val offlineRepo = applicationContext.app.offlineRepository + + override suspend fun doWork(): Result { + val groupId = inputData.getString(EXTRA_GROUP_ID) ?: return Result.failure() + val group = offlineRepo.getGroup(groupId) ?: return Result.failure() + val songIds = offlineRepo.songIdsForGroup(groupId) + if (songIds.isEmpty()) return Result.success() + + val creds = PrefsStore(applicationContext).credentials.first() + if (!creds.isValid) return Result.failure() + + val subsonicClient = SubsonicClient(creds.serverUrl, creds.username, creds.password) + var done = group.downloadedTracks + val dao = OfflineDatabase.get(applicationContext).offlineDao() + + return try { + for (songId in songIds) { + if (offlineRepo.getTrackState(songId) == DownloadState.DONE) { + done++ + dao.updateGroupProgress(groupId, done) + continue + } + + val track = offlineRepo.getTrack(songId) ?: continue + dao.updateState(songId, DownloadState.DOWNLOADING, 0f) + + val suffix = track.suffix?.takeIf { it.isNotBlank() } ?: "mp3" + val audioFile = offlineRepo.audioFile(songId, suffix) + + try { + streamToFile(subsonicClient.streamUrl(songId), audioFile) + + val coverPath = track.coverArt?.let { coverArtId -> + val existing = offlineRepo.localCoverPath(coverArtId) + if (existing != null && File(existing).exists()) { + existing + } else { + val coverFile = offlineRepo.coverFile(coverArtId) + runCatching { streamToFile(subsonicClient.coverArtUrl(coverArtId, 512), coverFile) } + if (coverFile.exists()) coverFile.absolutePath else null + } + } + + offlineRepo.markDone(songId, audioFile.absolutePath, coverPath) + done++ + } catch (e: Exception) { + Log.e("DownloadWorker", "Failed to download song $songId", e) + audioFile.delete() + offlineRepo.markFailed(songId) + } + + dao.updateGroupProgress(groupId, done) + } + Result.success() + } catch (e: Exception) { + Log.e("DownloadWorker", "Worker failed for group $groupId", e) + Result.failure() + } + } + + private suspend fun streamToFile(url: String, dest: File) = withContext(Dispatchers.IO) { + val tmp = File("${dest.absolutePath}.tmp") + try { + val conn = URL(url).openConnection() as HttpURLConnection + conn.connectTimeout = 30_000 + conn.readTimeout = 120_000 + conn.connect() + if (conn.responseCode !in 200..299) { + throw Exception("HTTP ${conn.responseCode} for ${dest.name}") + } + conn.inputStream.use { input -> + FileOutputStream(tmp).use { out -> + val buf = ByteArray(32_768) + var n: Int + while (input.read(buf).also { n = it } >= 0) { + out.write(buf, 0, n) + } + } + } + if (!tmp.renameTo(dest)) { + tmp.copyTo(dest, overwrite = true) + tmp.delete() + } + } catch (e: Exception) { + tmp.delete() + throw e + } + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineDao.kt b/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineDao.kt new file mode 100644 index 0000000..bdaa0cb --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineDao.kt @@ -0,0 +1,93 @@ +package com.novasonic.app.offline + +import androidx.room.* +import kotlinx.coroutines.flow.Flow + +@Dao +interface OfflineDao { + + // ── Tracks ──────────────────────────────────────────────────────────────── + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertTrack(track: DownloadedTrackEntity) + + @Query("UPDATE downloaded_tracks SET state = :state, progress = :progress WHERE songId = :songId") + suspend fun updateState(songId: String, state: DownloadState, progress: Float) + + @Query("UPDATE downloaded_tracks SET state = 'DONE', progress = 1, localPath = :localPath, coverPath = :coverPath, downloadedAt = :at WHERE songId = :songId") + suspend fun markDone(songId: String, localPath: String, coverPath: String?, at: Long) + + /** Emits whenever any downloaded track changes — drives the hot in-memory maps. */ + @Query("SELECT songId, localPath, coverArt, coverPath FROM downloaded_tracks WHERE state = 'DONE'") + fun doneTracksFlow(): Flow> + + @Query("SELECT * FROM downloaded_tracks WHERE songId = :songId") + fun trackFlow(songId: String): Flow + + @Query("SELECT * FROM downloaded_tracks WHERE songId = :songId") + suspend fun getTrackById(songId: String): DownloadedTrackEntity? + + @Query("SELECT state FROM downloaded_tracks WHERE songId = :songId") + suspend fun getTrackStateRaw(songId: String): String? + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertTrackIfAbsent(track: DownloadedTrackEntity) + + @Query("SELECT * FROM downloaded_tracks ORDER BY downloadedAt DESC") + fun allTracksFlow(): Flow> + + @Query("SELECT COUNT(*) FROM group_track_cross_refs WHERE songId = :songId") + suspend fun groupCountForSong(songId: String): Int + + @Query("DELETE FROM downloaded_tracks WHERE songId = :songId") + suspend fun deleteTrackById(songId: String) + + // ── Groups ──────────────────────────────────────────────────────────────── + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertGroup(group: DownloadGroupEntity) + + @Query("SELECT * FROM download_groups ORDER BY createdAt DESC") + fun allGroupsFlow(): Flow> + + @Query("SELECT * FROM download_groups WHERE groupId = :groupId") + suspend fun getGroup(groupId: String): DownloadGroupEntity? + + @Query("DELETE FROM download_groups WHERE groupId = :groupId") + suspend fun deleteGroup(groupId: String) + + @Query("UPDATE download_groups SET downloadedTracks = :count WHERE groupId = :groupId") + suspend fun updateGroupProgress(groupId: String, count: Int) + + @Query("UPDATE download_groups SET totalTracks = :total WHERE groupId = :groupId") + suspend fun updateGroupTotal(groupId: String, total: Int) + + // ── Cross-refs ──────────────────────────────────────────────────────────── + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertCrossRef(ref: GroupTrackCrossRef) + + @Query("SELECT songId FROM group_track_cross_refs WHERE groupId = :groupId") + suspend fun songIdsForGroup(groupId: String): List + + @Query(""" + SELECT t.* FROM downloaded_tracks t + INNER JOIN group_track_cross_refs r ON t.songId = r.songId + WHERE r.groupId = :groupId + """) + suspend fun tracksForGroup(groupId: String): List + + @Query(""" + SELECT t.* FROM downloaded_tracks t + INNER JOIN group_track_cross_refs r ON t.songId = r.songId + WHERE r.groupId = :groupId + """) + fun tracksForGroupFlow(groupId: String): Flow> + + @Query("DELETE FROM group_track_cross_refs WHERE groupId = :groupId AND songId = :songId") + suspend fun removeCrossRef(groupId: String, songId: String) + + /** Find a locally-stored cover path by Navidrome coverArt ID (deduped storage). */ + @Query("SELECT coverPath FROM downloaded_tracks WHERE coverArt = :coverArtId AND state = 'DONE' AND coverPath IS NOT NULL LIMIT 1") + suspend fun localCoverPathByCoverArt(coverArtId: String): String? +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineDatabase.kt b/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineDatabase.kt new file mode 100644 index 0000000..babc45d --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineDatabase.kt @@ -0,0 +1,38 @@ +package com.novasonic.app.offline + +import android.content.Context +import androidx.room.* + +@TypeConverters(OfflineConverters::class) +@Database( + entities = [ + DownloadedTrackEntity::class, + DownloadGroupEntity::class, + GroupTrackCrossRef::class, + ], + version = 1, + exportSchema = false, +) +abstract class OfflineDatabase : RoomDatabase() { + + abstract fun offlineDao(): OfflineDao + + companion object { + @Volatile private var INSTANCE: OfflineDatabase? = null + + fun get(context: Context): OfflineDatabase = INSTANCE ?: synchronized(this) { + INSTANCE ?: Room.databaseBuilder( + context.applicationContext, + OfflineDatabase::class.java, + "novasonic_offline.db", + ).build().also { INSTANCE = it } + } + } +} + +class OfflineConverters { + @TypeConverter fun fromState(v: DownloadState): String = v.name + @TypeConverter fun toState(v: String): DownloadState = DownloadState.valueOf(v) + @TypeConverter fun fromType(v: GroupType): String = v.name + @TypeConverter fun toType(v: String): GroupType = GroupType.valueOf(v) +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineEntities.kt b/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineEntities.kt new file mode 100644 index 0000000..56aff56 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineEntities.kt @@ -0,0 +1,91 @@ +package com.novasonic.app.offline + +import androidx.room.* +import com.novasonic.app.api.model.Song + +enum class DownloadState { QUEUED, DOWNLOADING, DONE, FAILED } +enum class GroupType { SONG, ALBUM, PLAYLIST, FAVORITES } + +/** Maps a stored offline track back to a domain [Song] for playback / list display. */ +fun DownloadedTrackEntity.toSong() = Song( + id = songId, + title = title, + album = album, + albumId = albumId, + artist = artist, + artistId = artistId, + track = track, + year = year, + genre = genre, + coverArt = coverArt, + duration = duration, + suffix = suffix, + contentType = contentType, + bitRate = bitRate, + starred = starred, +) + +/** Lightweight projection used to keep the hot in-memory maps up to date. */ +data class DoneTrackInfo( + val songId: String, + val localPath: String, + val coverArt: String?, + val coverPath: String?, +) + +@Entity(tableName = "downloaded_tracks") +data class DownloadedTrackEntity( + @PrimaryKey val songId: String, + val title: String, + val artist: String?, + val artistId: String?, + val album: String?, + val albumId: String?, + val coverArt: String?, + val track: Int?, + val year: Int?, + val genre: String?, + val duration: Int?, + val suffix: String?, + val contentType: String?, + val bitRate: Int?, + val starred: String?, + val localPath: String, + val coverPath: String?, + val state: DownloadState, + val progress: Float = 0f, + val downloadedAt: Long = 0L, +) + +@Entity(tableName = "download_groups") +data class DownloadGroupEntity( + @PrimaryKey val groupId: String, // e.g. "album:abc123", "playlist:xyz", "favorites" + val type: GroupType, + val sourceId: String, + val name: String, + val coverArt: String?, + val totalTracks: Int = 0, + val downloadedTracks: Int = 0, + val createdAt: Long = System.currentTimeMillis(), +) + +@Entity( + tableName = "group_track_cross_refs", + primaryKeys = ["groupId", "songId"], + foreignKeys = [ + ForeignKey( + entity = DownloadGroupEntity::class, + parentColumns = ["groupId"], + childColumns = ["groupId"], + onDelete = ForeignKey.CASCADE, + ), + ForeignKey( + entity = DownloadedTrackEntity::class, + parentColumns = ["songId"], + childColumns = ["songId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("songId")], +) +data class GroupTrackCrossRef(val groupId: String, val songId: String) diff --git a/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineRepository.kt b/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineRepository.kt new file mode 100644 index 0000000..6d2fb22 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineRepository.kt @@ -0,0 +1,211 @@ +package com.novasonic.app.offline + +import android.content.Context +import android.util.Log +import androidx.work.* +import com.novasonic.app.api.model.Song +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import java.io.File + +class OfflineRepository(context: Context) { + + private val appContext = context.applicationContext + private val dao = OfflineDatabase.get(appContext).offlineDao() + private val scope = CoroutineScope( + SupervisorJob() + Dispatchers.IO + + CoroutineExceptionHandler { _, t -> Log.e("OfflineRepository", "Unhandled exception", t) } + ) + + // ── Hot in-memory maps (no suspend needed for lookups) ──────────────────── + // songId → absolute local audio path + private val _audioMap = MutableStateFlow>(emptyMap()) + // coverArtId (Navidrome) → absolute local cover path + private val _coverMap = MutableStateFlow>(emptyMap()) + + /** Reactive set of all downloaded song IDs — collect in UI for per-row indicators. */ + val downloadedIds: StateFlow> = _audioMap + .map { it.keys.toSet() } + .stateIn(scope, SharingStarted.Eagerly, emptySet()) + + init { + scope.launch { + dao.doneTracksFlow().collect { tracks -> + _audioMap.value = tracks.associate { it.songId to it.localPath } + _coverMap.value = tracks + .filter { it.coverArt != null && it.coverPath != null } + .associate { it.coverArt!! to it.coverPath!! } + } + } + } + + // ── Synchronous lookups (called from MusicRepository — no coroutine) ────── + + fun isDownloaded(songId: String): Boolean = songId in _audioMap.value + fun localAudioPath(songId: String): String? = _audioMap.value[songId] + fun localCoverPath(coverArtId: String): String? = _coverMap.value[coverArtId] + + // ── Storage layout ──────────────────────────────────────────────────────── + + fun audioDir(): File = File(appContext.filesDir, "offline/audio").also { it.mkdirs() } + fun coversDir(): File = File(appContext.filesDir, "offline/covers").also { it.mkdirs() } + + fun audioFile(songId: String, suffix: String): File = File(audioDir(), "$songId.$suffix") + fun coverFile(coverArtId: String): File = File(coversDir(), "$coverArtId.jpg") + + // ── Reactive flows for UI ───────────────────────────────────────────────── + + val allGroupsFlow: Flow> = dao.allGroupsFlow() + val allTracksFlow: Flow> = dao.allTracksFlow() + + fun trackFlow(songId: String): Flow = dao.trackFlow(songId) + + // ── Write operations (used by DownloadWorker — Phase 2) ────────────────── + + suspend fun insertTrack(entity: DownloadedTrackEntity) = dao.insertTrack(entity) + + suspend fun updateState(songId: String, state: DownloadState, progress: Float) = + dao.updateState(songId, state, progress) + + suspend fun markDone(songId: String, localPath: String, coverPath: String?) = + dao.markDone(songId, localPath, coverPath, System.currentTimeMillis()) + + suspend fun markFailed(songId: String) = + dao.updateState(songId, DownloadState.FAILED, 0f) + + suspend fun insertGroup(group: DownloadGroupEntity) = dao.insertGroup(group) + suspend fun getGroup(groupId: String) = dao.getGroup(groupId) + suspend fun updateGroupProgress(groupId: String, count: Int) = + dao.updateGroupProgress(groupId, count) + suspend fun updateGroupTotal(groupId: String, total: Int) = + dao.updateGroupTotal(groupId, total) + + suspend fun insertCrossRef(groupId: String, songId: String) = + dao.insertCrossRef(GroupTrackCrossRef(groupId, songId)) + + suspend fun songIdsForGroup(groupId: String): List = dao.songIdsForGroup(groupId) + + suspend fun getTrack(songId: String): DownloadedTrackEntity? = dao.getTrackById(songId) + + suspend fun tracksForGroup(groupId: String): List = dao.tracksForGroup(groupId) + + fun tracksForGroupFlow(groupId: String): Flow> = dao.tracksForGroupFlow(groupId) + + suspend fun getTrackState(songId: String): DownloadState? = + dao.getTrackStateRaw(songId)?.let { DownloadState.valueOf(it) } + + /** + * Inserts all songs as QUEUED into Room, creates a download group, and enqueues a WorkManager + * job that downloads the files in the background. Already-DONE tracks are counted but not + * re-downloaded. Safe to call for an existing groupId — WorkManager KEEP policy is used. + */ + suspend fun enqueueGroup( + type: GroupType, + sourceId: String, + name: String, + coverArt: String?, + songs: List, + ) { + val groupId = "${type.name.lowercase()}:$sourceId" + // Preserve creation time + progress when re-enqueuing (resume / re-download missing songs). + val existing = dao.getGroup(groupId) + dao.insertGroup( + DownloadGroupEntity( + groupId = groupId, + type = type, + sourceId = sourceId, + name = name, + coverArt = coverArt, + totalTracks = songs.size, + downloadedTracks = existing?.downloadedTracks ?: 0, + createdAt = existing?.createdAt ?: System.currentTimeMillis(), + ) + ) + var alreadyDone = 0 + for (song in songs) { + if (isDownloaded(song.id)) { + dao.insertCrossRef(GroupTrackCrossRef(groupId, song.id)) + alreadyDone++ + } else { + dao.insertTrackIfAbsent( + DownloadedTrackEntity( + songId = song.id, + title = song.title, + artist = song.artist, + artistId = song.artistId, + album = song.album, + albumId = song.albumId, + coverArt = song.coverArt, + track = song.track, + year = song.year, + genre = song.genre, + duration = song.duration, + suffix = song.suffix, + contentType = song.contentType, + bitRate = song.bitRate, + starred = song.starred, + localPath = "", + coverPath = null, + state = DownloadState.QUEUED, + ) + ) + dao.insertCrossRef(GroupTrackCrossRef(groupId, song.id)) + } + } + dao.updateGroupProgress(groupId, alreadyDone) + + val request = OneTimeWorkRequestBuilder() + .setInputData(workDataOf(DownloadWorker.EXTRA_GROUP_ID to groupId)) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .setRequiresStorageNotLow(true) + .build() + ) + .build() + + WorkManager.getInstance(appContext) + .enqueueUniqueWork(groupId, ExistingWorkPolicy.KEEP, request) + } + + // ── Delete with reference-count file cleanup ────────────────────────────── + + /** Removes a group and any tracks that are no longer referenced by another group. */ + suspend fun deleteGroup(groupId: String) { + val songIds = dao.songIdsForGroup(groupId) + dao.deleteGroup(groupId) // cascades GroupTrackCrossRef rows + for (songId in songIds) { + if (dao.groupCountForSong(songId) == 0) { + _audioMap.value[songId]?.let { File(it).delete() } + dao.deleteTrackById(songId) + } + } + } + + /** Removes a single track from a group, deleting the file if no other group still needs it. */ + suspend fun removeSongFromGroup(groupId: String, songId: String) { + dao.removeCrossRef(groupId, songId) + if (dao.groupCountForSong(songId) == 0) { + _audioMap.value[songId]?.let { File(it).delete() } + dao.deleteTrackById(songId) + } + recountGroup(groupId) + } + + /** Recomputes a group's total + downloaded counts from its cross-refs; deletes it if empty. */ + private suspend fun recountGroup(groupId: String) { + val tracks = dao.tracksForGroup(groupId) + if (tracks.isEmpty()) { + dao.deleteGroup(groupId) + return + } + dao.updateGroupTotal(groupId, tracks.size) + dao.updateGroupProgress(groupId, tracks.count { it.state == DownloadState.DONE }) + } + + // ── Storage accounting ──────────────────────────────────────────────────── + + fun storageUsedBytes(): Long = + audioDir().walkTopDown().filter { it.isFile }.sumOf { it.length() } + + coversDir().walkTopDown().filter { it.isFile }.sumOf { it.length() } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/player/AbsPlayerController.kt b/android/app/src/main/kotlin/com/novasonic/app/player/AbsPlayerController.kt index 999b22d..81c8b85 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/player/AbsPlayerController.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/player/AbsPlayerController.kt @@ -32,7 +32,10 @@ data class AbsPlayState( class AbsPlayerController(context: Context) { private val appContext = context.applicationContext - var scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + var scope = CoroutineScope( + Dispatchers.Main + SupervisorJob() + + CoroutineExceptionHandler { _, t -> android.util.Log.e("AbsPlayerController", "Uncaught coroutine exception", t) } + ) private set private val _state = MutableStateFlow(AbsPlayState()) diff --git a/android/app/src/main/kotlin/com/novasonic/app/player/MusicService.kt b/android/app/src/main/kotlin/com/novasonic/app/player/MusicService.kt index f3f8489..64fc3ae 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/player/MusicService.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/player/MusicService.kt @@ -19,6 +19,7 @@ import com.novasonic.app.NovaSonicApp import com.novasonic.app.R import com.novasonic.app.data.MusicRepository import com.novasonic.app.ui.MainActivity +import android.util.Log import kotlinx.coroutines.* import java.util.concurrent.Executors @@ -46,7 +47,10 @@ class MusicService : MediaLibraryService() { private lateinit var player: ExoPlayer private lateinit var session: MediaLibrarySession private lateinit var repo: MusicRepository - private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private val scope = CoroutineScope( + Dispatchers.Main + SupervisorJob() + + CoroutineExceptionHandler { _, t -> Log.e("MusicService", "Uncaught coroutine exception", t) } + ) private var lastAbsChapterIdx = -1 override fun onCreate() { diff --git a/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt b/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt index 4b902b6..57f19f4 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt @@ -36,7 +36,10 @@ class PlayerController(private val context: Context, private val repo: MusicRepo private val _state = MutableStateFlow(PlayerState()) val state: StateFlow = _state.asStateFlow() - private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private val scope = CoroutineScope( + Dispatchers.Main + SupervisorJob() + + CoroutineExceptionHandler { _, t -> android.util.Log.e("PlayerController", "Uncaught coroutine exception", t) } + ) private var controller: MediaController? = null private var queue: List = emptyList() private var originalQueue: List = emptyList() diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt index 147a33a..b5b07eb 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt @@ -16,10 +16,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import android.app.DownloadManager import android.content.Context -import android.net.Uri -import android.os.Environment import android.widget.Toast import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextStyle @@ -263,6 +260,8 @@ fun AppNavigation() { onAlbumClick = { album -> navController.navigate("album/${album.id}") }, onArtistClick = { artist -> navController.navigate("artist/${artist.id}") }, onPlaylistClick = { pl -> navController.navigate("playlist/${pl.id}") }, + onPlay = { songs, idx -> player.playSongs(songs, idx) }, + onOpenOffline = { group -> navController.navigate("offline/${encodeRoute(group.groupId)}") }, ) } composable(TopLevel.ERKUNDEN.route) { @@ -351,6 +350,17 @@ fun AppNavigation() { onBack = { navController.popBackStack() }, ) } + composable( + "offline/{groupId}", + arguments = listOf(navArgument("groupId") { type = NavType.StringType }), + ) { + OfflineDetailScreen( + coverUrl = { id -> coverUrl(id) }, + playerState = playerState, + onPlay = { songs, idx -> player.playSongs(songs, idx) }, + onBack = { navController.popBackStack() }, + ) + } composable( "genre/{genreName}", arguments = listOf(navArgument("genreName") { type = NavType.StringType }), @@ -431,7 +441,21 @@ fun AppNavigation() { onAddToPlaylist = { playlistPickerSong = song; contextMenuSong = null }, onGoToAlbum = song.albumId?.let { albumId -> { navController.navigate("album/$albumId") } }, onShowInfo = { infoSheetSong = song; contextMenuSong = null }, - onDownload = { downloadSong(context, song, repo.streamUrl(song.id) ?: ""); contextMenuSong = null }, + onDownload = { + scope.launch { + runCatching { + app.offlineRepository.enqueueGroup( + type = com.novasonic.app.offline.GroupType.SONG, + sourceId = song.id, + name = song.title, + coverArt = song.coverArt, + songs = listOf(song), + ) + }.onFailure { android.util.Log.e("AppNavigation", "enqueueGroup failed", it) } + } + Toast.makeText(context, "Download gestartet: ${song.title}", android.widget.Toast.LENGTH_SHORT).show() + contextMenuSong = null + }, ) } @@ -496,21 +520,5 @@ fun AppNavigation() { } } -private fun downloadSong(context: Context, song: Song, streamUrl: String) { - if (streamUrl.isBlank()) { - Toast.makeText(context, "Stream-URL nicht verfügbar", Toast.LENGTH_SHORT).show() - return - } - val safe = (song.title + "." + (song.suffix ?: "mp3")).replace(Regex("[/\\\\:*?\"<>|]"), "_") - val request = DownloadManager.Request(Uri.parse(streamUrl)) - .setTitle(song.title) - .setDescription(song.artist ?: "") - .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) - .setDestinationInExternalPublicDir(Environment.DIRECTORY_MUSIC, safe) - .setMimeType(song.contentType ?: "audio/mpeg") - val dm = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager - dm.enqueue(request) - Toast.makeText(context, "Download gestartet: ${song.title}", Toast.LENGTH_SHORT).show() -} private fun encodeRoute(s: String) = java.net.URLEncoder.encode(s, "UTF-8") diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/component/AlbumCard.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/component/AlbumCard.kt index 8c1b9d0..8d04118 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/component/AlbumCard.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/component/AlbumCard.kt @@ -4,10 +4,15 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border 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.DownloadForOffline +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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 @@ -28,6 +33,7 @@ fun AlbumCard( album: Album, coverUrl: String?, width: Dp = 145.dp, + downloaded: Boolean = false, onClick: () -> Unit, ) { val shape = RoundedCornerShape(12.dp) @@ -40,15 +46,35 @@ fun AlbumCard( .border(1.dp, cardBorder, shape) .clickable(onClick = onClick), ) { - AsyncImage( - model = coverUrl, - contentDescription = album.name, - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f) - .background(NsSurface2), - ) + Box { + AsyncImage( + model = coverUrl, + contentDescription = album.name, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .background(NsSurface2), + ) + if (downloaded) { + Box( + Modifier + .align(Alignment.BottomEnd) + .padding(6.dp) + .size(22.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.55f)), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Default.DownloadForOffline, + contentDescription = "Heruntergeladen", + tint = NsAccent, + modifier = Modifier.size(16.dp), + ) + } + } + } Column(Modifier.padding(10.dp)) { Text( text = album.name, diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/AlbumScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/AlbumScreen.kt index 7b679d5..bdd0989 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/AlbumScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/AlbumScreen.kt @@ -9,6 +9,8 @@ import androidx.compose.foundation.shape.CircleShape 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.CheckCircle +import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Shuffle import androidx.compose.material3.* @@ -45,6 +47,8 @@ fun AlbumScreen( ) { val vm: AlbumViewModel = viewModel() val state by vm.state.collectAsStateWithLifecycle() + val downloadGroup by vm.downloadGroup.collectAsStateWithLifecycle() + val isDownloading by vm.isDownloading.collectAsStateWithLifecycle() if (state.isLoading) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -173,6 +177,30 @@ fun AlbumScreen( Spacer(Modifier.width(6.dp)) Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent) } + val isFullyDownloaded = downloadGroup != null && + downloadGroup!!.downloadedTracks >= downloadGroup!!.totalTracks && + downloadGroup!!.totalTracks > 0 + OutlinedButton( + // Tappable unless fully done — resumes / re-downloads any missing songs. + onClick = { if (!isFullyDownloaded) vm.downloadAlbum() }, + modifier = Modifier.height(46.dp).width(46.dp), + border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy( + brush = SolidColor( + when { + isFullyDownloaded -> MaterialTheme.colorScheme.primary.copy(alpha = 0.6f) + else -> NsAccent.copy(alpha = 0.55f) + } + ) + ), + shape = RoundedCornerShape(14.dp), + contentPadding = PaddingValues(0.dp), + ) { + when { + isFullyDownloaded -> Icon(Icons.Default.CheckCircle, "Downloaded", Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary) + isDownloading -> CircularProgressIndicator(Modifier.size(18.dp), color = NsAccent, strokeWidth = 2.dp) + else -> Icon(Icons.Default.Download, "Herunterladen", Modifier.size(20.dp), tint = NsAccent) + } + } } HorizontalDivider(color = NsBorder) diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LibraryScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LibraryScreen.kt index 1b3660f..da084e8 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LibraryScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LibraryScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.* 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.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale @@ -36,12 +37,20 @@ import coil.compose.AsyncImage import com.novasonic.app.api.model.Album import com.novasonic.app.api.model.Artist import com.novasonic.app.api.model.Playlist +import com.novasonic.app.offline.DownloadGroupEntity +import com.novasonic.app.offline.GroupType import com.novasonic.app.ui.component.AlbumCard import com.novasonic.app.ui.theme.* import com.novasonic.app.viewmodel.AlbumSort import com.novasonic.app.viewmodel.LibraryTab import com.novasonic.app.viewmodel.LibraryViewModel +import com.novasonic.app.viewmodel.OfflineViewModel import kotlinx.coroutines.launch +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.DownloadDone +import androidx.compose.material.icons.filled.DownloadForOffline +import androidx.compose.material.icons.filled.PlayArrow +import com.novasonic.app.api.model.Song @Composable fun LibraryScreen( @@ -49,11 +58,26 @@ fun LibraryScreen( onAlbumClick: (Album) -> Unit, onArtistClick: (Artist) -> Unit, onPlaylistClick: (Playlist) -> Unit, + onPlay: (List, Int) -> Unit, + onOpenOffline: (DownloadGroupEntity) -> Unit, ) { val vm: LibraryViewModel = viewModel() val state by vm.state.collectAsStateWithLifecycle() + val offlineVm: OfflineViewModel = viewModel() + val offlineGroups by offlineVm.groups.collectAsStateWithLifecycle() + + // Fully-downloaded source IDs per type — drives the "downloaded" badge on cards. + val downloadedAlbumIds = remember(offlineGroups) { + offlineGroups.filter { it.type == GroupType.ALBUM && it.totalTracks > 0 && it.downloadedTracks >= it.totalTracks } + .map { it.sourceId }.toSet() + } + val downloadedPlaylistIds = remember(offlineGroups) { + offlineGroups.filter { it.type == GroupType.PLAYLIST && it.totalTracks > 0 && it.downloadedTracks >= it.totalTracks } + .map { it.sourceId }.toSet() + } var showCreateDialog by remember { mutableStateOf(false) } + var groupToDelete by remember { mutableStateOf(null) } Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) { @@ -72,6 +96,7 @@ fun LibraryScreen( LibraryTab.PLAYLISTS -> "Playlists" LibraryTab.ALBUMS -> "Alben" LibraryTab.ARTISTS -> "Künstler" + LibraryTab.OFFLINE -> "Offline" }, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -138,10 +163,11 @@ fun LibraryScreen( ) { items(sortedAlbums, key = { it.id }) { album -> AlbumCard( - album = album, - coverUrl = coverUrl(album.coverArt), - width = 150.dp, - onClick = { onAlbumClick(album) }, + album = album, + coverUrl = coverUrl(album.coverArt), + width = 150.dp, + downloaded = album.id in downloadedAlbumIds, + onClick = { onAlbumClick(album) }, ) } } @@ -287,13 +313,78 @@ fun LibraryScreen( ) { items(state.playlists, key = { it.id }) { pl -> PlaylistCard( - name = pl.name, - subtitle = "${pl.songCount} Titel", - coverUrl = coverUrl(pl.coverArt), - onClick = { onPlaylistClick(pl) }, + name = pl.name, + subtitle = "${pl.songCount} Titel", + coverUrl = coverUrl(pl.coverArt), + downloaded = pl.id in downloadedPlaylistIds, + onClick = { onPlaylistClick(pl) }, ) } } + + LibraryTab.OFFLINE -> { + val groups = offlineGroups + val storage by offlineVm.storageBytes.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + + if (groups.isEmpty()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + Icons.Default.DownloadDone, + contentDescription = null, + tint = NsOnSurface3, + modifier = Modifier.size(56.dp), + ) + Spacer(Modifier.height(12.dp)) + Text( + "Noch nichts heruntergeladen", + fontFamily = Outfit, + fontSize = 15.sp, + color = NsOnSurface2, + ) + Spacer(Modifier.height(4.dp)) + Text( + "Öffne ein Album und tippe auf ↓", + fontFamily = Outfit, + fontSize = 12.sp, + color = NsOnSurface3, + ) + } + } + } else { + LazyColumn( + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(1.dp), + modifier = Modifier.fillMaxSize(), + ) { + item { + Text( + text = "%.1f MB gespeichert".format(storage / 1_000_000.0), + fontFamily = Outfit, + fontSize = 12.sp, + color = NsOnSurface3, + modifier = Modifier.padding(bottom = 12.dp), + ) + } + items(groups, key = { it.groupId }) { group -> + OfflineGroupRow( + group = group, + coverUrl = coverUrl(group.coverArt), + onOpen = { onOpenOffline(group) }, + onPlay = { + scope.launch { + val songs = offlineVm.songsForGroup(group.groupId) + if (songs.isNotEmpty()) onPlay(songs, 0) + } + }, + onDelete = { groupToDelete = group }, + ) + } + item { Spacer(Modifier.height(80.dp)) } + } + } + } } } // end Column @@ -310,6 +401,29 @@ fun LibraryScreen( } } // end Box + // ── Delete offline group dialog ─────────────────────────────────────────── + groupToDelete?.let { group -> + AlertDialog( + onDismissRequest = { groupToDelete = null }, + title = { Text("Löschen?", fontFamily = Outfit, fontWeight = FontWeight.Bold) }, + text = { Text("\"${group.name}\" aus dem Offline-Speicher entfernen?", fontFamily = Outfit) }, + confirmButton = { + TextButton(onClick = { + offlineVm.deleteGroup(group.groupId) + groupToDelete = null + }) { + Text("Löschen", color = MaterialTheme.colorScheme.error, fontFamily = Outfit, fontWeight = FontWeight.Bold) + } + }, + dismissButton = { + TextButton(onClick = { groupToDelete = null }) { + Text("Abbrechen", color = NsOnSurface2) + } + }, + containerColor = NsSurface, + ) + } + // ── Create playlist dialog ──────────────────────────────────────────────── if (showCreateDialog) { var nameInput by remember { mutableStateOf("") } @@ -354,7 +468,88 @@ fun LibraryScreen( } @Composable -private fun PlaylistCard(name: String, subtitle: String, coverUrl: String?, onClick: () -> Unit) { +private fun OfflineGroupRow( + group: DownloadGroupEntity, + coverUrl: String?, + onOpen: () -> Unit, + onPlay: () -> Unit, + onDelete: () -> Unit, +) { + val isFullyDone = group.totalTracks > 0 && group.downloadedTracks >= group.totalTracks + val subtitle = when { + group.totalTracks == 0 -> "Ausstehend…" + isFullyDone -> "${group.totalTracks} Titel · Vollständig" + else -> "${group.downloadedTracks}/${group.totalTracks} Titel" + } + val typeLabel = when (group.type) { + GroupType.ALBUM -> "Album" + GroupType.PLAYLIST -> "Playlist" + GroupType.SONG -> "Song" + GroupType.FAVORITES -> "Favoriten" + } + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(NsSurface) + .border(1.dp, NsBorder, RoundedCornerShape(12.dp)) + .clickable(onClick = onOpen) + .padding(12.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + AsyncImage( + model = coverUrl, + contentDescription = group.name, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(52.dp) + .clip(RoundedCornerShape(8.dp)) + .background(NsSurface2), + ) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text( + text = group.name, + fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp, + color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(2.dp)) + Text( + text = "$typeLabel · $subtitle", + fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2, + ) + } + IconButton(onClick = onPlay, enabled = isFullyDone) { + Icon(Icons.Default.PlayArrow, "Abspielen", + tint = if (isFullyDone) NsAccent else NsOnSurface3, + modifier = Modifier.size(20.dp)) + } + IconButton(onClick = onDelete) { + Icon(Icons.Default.Delete, "Löschen", tint = NsOnSurface3, modifier = Modifier.size(20.dp)) + } + } + if (!isFullyDone && group.totalTracks > 0) { + Spacer(Modifier.height(8.dp)) + LinearProgressIndicator( + progress = { group.downloadedTracks.toFloat() / group.totalTracks }, + modifier = Modifier.fillMaxWidth().height(3.dp).clip(RoundedCornerShape(2.dp)), + color = NsAccent, + trackColor = NsSurface2, + ) + } + } + Spacer(Modifier.height(8.dp)) +} + +@Composable +private fun PlaylistCard( + name: String, + subtitle: String, + coverUrl: String?, + downloaded: Boolean = false, + onClick: () -> Unit, +) { val shape = RoundedCornerShape(12.dp) Column( modifier = Modifier @@ -363,15 +558,18 @@ private fun PlaylistCard(name: String, subtitle: String, coverUrl: String?, onCl .border(1.dp, NsBorder, shape) .clickable(onClick = onClick), ) { - AsyncImage( - model = coverUrl, - contentDescription = name, - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f) - .background(NsSurface2), - ) + Box { + AsyncImage( + model = coverUrl, + contentDescription = name, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .background(NsSurface2), + ) + if (downloaded) DownloadedBadge(Modifier.align(Alignment.BottomEnd).padding(6.dp)) + } Column(Modifier.padding(10.dp)) { Text( text = name, @@ -387,3 +585,22 @@ private fun PlaylistCard(name: String, subtitle: String, coverUrl: String?, onCl } } } + +/** Small "downloaded" pill overlaid on a cover — the down-arrow-in-circle, Spotify-style. */ +@Composable +fun DownloadedBadge(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(22.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.55f)), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Default.DownloadForOffline, + contentDescription = "Heruntergeladen", + tint = NsAccent, + modifier = Modifier.size(16.dp), + ) + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/OfflineDetailScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/OfflineDetailScreen.kt new file mode 100644 index 0000000..dbce016 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/OfflineDetailScreen.kt @@ -0,0 +1,185 @@ +package com.novasonic.app.ui.screen + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +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.Done +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.RemoveCircle +import androidx.compose.material.icons.filled.Shuffle +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage +import com.novasonic.app.api.model.Song +import com.novasonic.app.offline.toSong +import com.novasonic.app.player.PlayerState +import com.novasonic.app.ui.component.SongRow +import com.novasonic.app.ui.theme.* +import com.novasonic.app.viewmodel.OfflineDetailViewModel + +@Composable +fun OfflineDetailScreen( + coverUrl: (String?) -> String?, + playerState: PlayerState, + onPlay: (List, Int) -> Unit, + onBack: () -> Unit, +) { + val vm: OfflineDetailViewModel = viewModel() + val group by vm.group.collectAsStateWithLifecycle() + val tracks by vm.tracks.collectAsStateWithLifecycle() + + var editMode by remember { mutableStateOf(false) } + // The group flow starts as null before Room emits — don't treat that initial null as "deleted". + var everLoaded by remember { mutableStateOf(false) } + LaunchedEffect(group) { if (group != null) everLoaded = true } + // Only leave once the group was loaded and has since disappeared (last song removed). + LaunchedEffect(everLoaded, group) { if (everLoaded && group == null) onBack() } + + val g = group ?: run { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = NsAccent) + } + return + } + val songs = remember(tracks) { tracks.map { it.toSong() } } + + LazyColumn(Modifier.fillMaxSize()) { + item { + // ── Hero ───────────────────────────────────────────────────────── + Box(Modifier.fillMaxWidth()) { + AsyncImage( + model = coverUrl(g.coverArt), + contentDescription = g.name, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxWidth().aspectRatio(1f).background(NsSurface2), + ) + Box( + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .background( + Brush.verticalGradient( + 0.0f to Color.Transparent, + 0.40f to NsBackground.copy(alpha = 0.35f), + 1.0f to NsBackground, + ) + ) + ) + Box( + Modifier + .statusBarsPadding() + .padding(start = 14.dp, top = 14.dp) + .size(36.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.45f)) + .clickable(onClick = onBack) + .align(Alignment.TopStart), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück", + tint = Color.White, modifier = Modifier.size(18.dp)) + } + Column( + Modifier.align(Alignment.BottomStart).padding(horizontal = 20.dp, vertical = 20.dp), + ) { + Text( + g.name, + fontFamily = Outfit, fontWeight = FontWeight.ExtraBold, + fontSize = 26.sp, color = Color.White, + maxLines = 2, overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(4.dp)) + Text( + "Offline · ${tracks.size} Titel", + fontFamily = Outfit, fontSize = 13.sp, + color = Color.White.copy(alpha = 0.65f), + ) + } + } + + // ── Play / Shuffle / Edit row ───────────────────────────────────── + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Button( + onClick = { if (songs.isNotEmpty()) onPlay(songs, 0) }, + modifier = Modifier.weight(1f).height(46.dp), + colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White), + shape = RoundedCornerShape(14.dp), + ) { + Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(6.dp)) + Text("Play", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp) + } + OutlinedButton( + onClick = { if (songs.isNotEmpty()) onPlay(songs.shuffled(), 0) }, + modifier = Modifier.weight(1f).height(46.dp), + border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))), + shape = RoundedCornerShape(14.dp), + ) { + Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(20.dp), tint = NsAccent) + Spacer(Modifier.width(6.dp)) + Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent) + } + OutlinedButton( + onClick = { editMode = !editMode }, + modifier = Modifier.height(46.dp), + border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy( + brush = SolidColor(if (editMode) NsAccent else NsOnSurface3) + ), + shape = RoundedCornerShape(14.dp), + contentPadding = PaddingValues(horizontal = 14.dp), + ) { + Icon( + if (editMode) Icons.Default.Done else Icons.Default.Edit, null, + modifier = Modifier.size(18.dp), + tint = if (editMode) NsAccent else NsOnSurface2, + ) + } + } + + HorizontalDivider(color = NsBorder) + } + + itemsIndexed(songs, key = { _, s -> s.id }) { index, song -> + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.weight(1f)) { + SongRow( + song = song, + coverUrl = coverUrl(song.coverArt), + isPlaying = playerState.currentSong?.id == song.id, + onPlay = { onPlay(songs, index) }, + ) + } + if (editMode) { + IconButton(onClick = { vm.deleteSong(song.id) }, modifier = Modifier.padding(end = 8.dp)) { + Icon(Icons.Default.RemoveCircle, "Vom Gerät löschen", + tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(22.dp)) + } + } + } + } + item { Spacer(Modifier.height(16.dp)) } + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/PlaylistDetailScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/PlaylistDetailScreen.kt index ca32b90..b778731 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/PlaylistDetailScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/PlaylistDetailScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.* +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -33,6 +34,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import com.novasonic.app.api.model.Song +import com.novasonic.app.offline.DownloadGroupEntity import com.novasonic.app.player.PlayerState import com.novasonic.app.ui.component.SongRow import com.novasonic.app.ui.theme.* @@ -55,6 +57,8 @@ fun PlaylistDetailScreen( var editMode by remember { mutableStateOf(false) } var showRenameDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) } + val downloadGroup by vm.downloadGroup.collectAsStateWithLifecycle() + val isDownloading by vm.isDownloading.collectAsStateWithLifecycle() val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> uri ?: return@rememberLauncherForActivityResult @@ -234,6 +238,23 @@ fun PlaylistDetailScreen( tint = if (editMode) NsAccent else NsOnSurface2, ) } + // Download button — tappable unless fully done; resumes / re-downloads missing songs + val dlDone = downloadGroup?.let { it.downloadedTracks >= it.totalTracks && it.totalTracks > 0 } ?: false + OutlinedButton( + onClick = { if (!dlDone) vm.downloadPlaylist() }, + modifier = Modifier.height(46.dp), + border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy( + brush = SolidColor(if (dlDone) NsAccent else NsOnSurface3) + ), + shape = RoundedCornerShape(14.dp), + contentPadding = PaddingValues(horizontal = 14.dp), + ) { + when { + dlDone -> Icon(Icons.Default.CheckCircle, null, modifier = Modifier.size(18.dp), tint = NsAccent) + isDownloading -> CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp, color = NsAccent) + else -> Icon(Icons.Default.Download, null, modifier = Modifier.size(18.dp), tint = NsOnSurface2) + } + } } HorizontalDivider(color = NsBorder) diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/AlbumViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/AlbumViewModel.kt index 3384bb7..84bfd2b 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/AlbumViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/AlbumViewModel.kt @@ -6,8 +6,10 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.novasonic.app.api.model.Album import com.novasonic.app.app -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow +import com.novasonic.app.offline.DownloadGroupEntity +import com.novasonic.app.offline.DownloadState +import com.novasonic.app.offline.GroupType +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch data class AlbumUiState( @@ -18,12 +20,22 @@ data class AlbumUiState( class AlbumViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) { - private val repo = app.app.repository + private val repo = app.app.repository + private val offlineRepo = app.app.offlineRepository private val albumId: String? = savedState["albumId"] private val _state = MutableStateFlow(AlbumUiState()) val state: StateFlow = _state + val downloadGroup: StateFlow = offlineRepo.allGroupsFlow + .map { groups -> groups.find { it.groupId == "album:$albumId" } } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + /** True while any track in this album's group is queued or actively downloading. */ + val isDownloading: StateFlow = offlineRepo.tracksForGroupFlow("album:$albumId") + .map { tracks -> tracks.any { it.state == DownloadState.QUEUED || it.state == DownloadState.DOWNLOADING } } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + init { val id = albumId if (id.isNullOrBlank()) { @@ -39,4 +51,19 @@ class AlbumViewModel(app: Application, savedState: SavedStateHandle) : AndroidVi } } } + + fun downloadAlbum() { + val album = _state.value.album ?: return + viewModelScope.launch { + runCatching { + offlineRepo.enqueueGroup( + type = GroupType.ALBUM, + sourceId = album.id, + name = album.name, + coverArt = album.coverArt, + songs = album.song, + ) + }.onFailure { android.util.Log.e("AlbumViewModel", "enqueueGroup failed", it) } + } + } } diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/HomeViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/HomeViewModel.kt index 8c52117..e43ef09 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/HomeViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/HomeViewModel.kt @@ -44,17 +44,28 @@ class HomeViewModel(app: Application) : AndroidViewModel(app) { viewModelScope.launch { _state.value = _state.value.copy(isLoading = true, error = null) try { - val recentDef = async { repo.getRecentAlbums(20) } - val recentlyPlayedDef = async { runCatching { repo.getRecentlyPlayedAlbums(5) }.getOrDefault(emptyList()) } - val frequentDef = async { repo.getFrequentAlbums(20) } - val randomDef = async { repo.getRandomAlbums(20) } - val genresDef = async { runCatching { repo.getGenres() }.getOrDefault(emptyList()) } + val recentDef = async { runCatching { repo.getRecentAlbums(20) } } + val recentlyPlayedDef = async { runCatching { repo.getRecentlyPlayedAlbums(5) } } + val frequentDef = async { runCatching { repo.getFrequentAlbums(20) } } + val randomDef = async { runCatching { repo.getRandomAlbums(20) } } + val genresDef = async { runCatching { repo.getGenres() } } - val recent = recentDef.await() - val recentlyPlayed = recentlyPlayedDef.await() - val frequent = frequentDef.await() - val random = randomDef.await() - val genres = genresDef.await() + val recentResult = recentDef.await() + val recentlyPlayedResult = recentlyPlayedDef.await() + val frequentResult = frequentDef.await() + val randomResult = randomDef.await() + val genresResult = genresDef.await() + + if (recentResult.isFailure) { + _state.value = HomeUiState(error = recentResult.exceptionOrNull()?.message) + return@launch + } + + val recent = recentResult.getOrDefault(emptyList()) + val recentlyPlayed = recentlyPlayedResult.getOrDefault(emptyList()) + val frequent = frequentResult.getOrDefault(emptyList()) + val random = randomResult.getOrDefault(emptyList()) + val genres = genresResult.getOrDefault(emptyList()) // Hero pool = frequent.take(5) ∪ recentlyPlayed.take(5), deduped — matches web val frequentIds = frequent.take(5).map { it.id }.toSet() diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/LibraryViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/LibraryViewModel.kt index fca9a91..39d7d73 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/LibraryViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/LibraryViewModel.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -enum class LibraryTab { PLAYLISTS, ALBUMS, ARTISTS } +enum class LibraryTab { PLAYLISTS, OFFLINE, ALBUMS, ARTISTS } enum class AlbumSort { NEWEST, OLDEST, AZ } data class LibraryUiState( @@ -46,17 +46,24 @@ class LibraryViewModel(app: Application) : AndroidViewModel(app) { LibraryTab.ALBUMS -> if (_state.value.albumsByNewest.isEmpty() && _state.value.albumsByName.isEmpty()) loadAlbums() LibraryTab.ARTISTS -> if (_state.value.artistIndexes.isEmpty()) loadArtists() LibraryTab.PLAYLISTS -> if (_state.value.playlists.isEmpty()) loadPlaylists() + LibraryTab.OFFLINE -> Unit } } private fun loadAlbums() = viewModelScope.launch { _state.value = _state.value.copy(isLoading = true, error = null) try { - val newestDef = async { repo.getRecentAlbums(500) } // type=newest → add-date order - val azDef = async { repo.getAlphaAlbums(500) } // type=alphabeticalByName + val newestDef = async { runCatching { repo.getRecentAlbums(500) } } + val azDef = async { runCatching { repo.getAlphaAlbums(500) } } + val newestResult = newestDef.await() + val azResult = azDef.await() + if (newestResult.isFailure) { + _state.value = _state.value.copy(error = newestResult.exceptionOrNull()?.message, isLoading = false) + return@launch + } _state.value = _state.value.copy( - albumsByNewest = newestDef.await(), - albumsByName = azDef.await(), + albumsByNewest = newestResult.getOrDefault(emptyList()), + albumsByName = azResult.getOrDefault(emptyList()), isLoading = false, ) } catch (e: Exception) { diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/OfflineDetailViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/OfflineDetailViewModel.kt new file mode 100644 index 0000000..c9e87af --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/OfflineDetailViewModel.kt @@ -0,0 +1,33 @@ +package com.novasonic.app.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.novasonic.app.api.model.Song +import com.novasonic.app.app +import com.novasonic.app.offline.DownloadGroupEntity +import com.novasonic.app.offline.DownloadedTrackEntity +import com.novasonic.app.offline.toSong +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +class OfflineDetailViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) { + + private val offlineRepo = app.app.offlineRepository + // groupId is passed URL-encoded in the route; Navigation decodes it back to "album:xyz". + private val groupId: String = savedState["groupId"] ?: "" + + val group: StateFlow = offlineRepo.allGroupsFlow + .map { groups -> groups.find { it.groupId == groupId } } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + val tracks: StateFlow> = offlineRepo.tracksForGroupFlow(groupId) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + fun songs(): List = tracks.value.map { it.toSong() } + + fun deleteSong(songId: String) { + viewModelScope.launch { offlineRepo.removeSongFromGroup(groupId, songId) } + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/OfflineViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/OfflineViewModel.kt new file mode 100644 index 0000000..b444d39 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/OfflineViewModel.kt @@ -0,0 +1,32 @@ +package com.novasonic.app.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.novasonic.app.api.model.Song +import com.novasonic.app.app +import com.novasonic.app.offline.DownloadGroupEntity +import com.novasonic.app.offline.toSong +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class OfflineViewModel(app: Application) : AndroidViewModel(app) { + + private val offlineRepo = app.app.offlineRepository + + val groups: StateFlow> = offlineRepo.allGroupsFlow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + val storageBytes: StateFlow = groups + .map { withContext(Dispatchers.IO) { offlineRepo.storageUsedBytes() } } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0L) + + fun deleteGroup(groupId: String) { + viewModelScope.launch { offlineRepo.deleteGroup(groupId) } + } + + suspend fun songsForGroup(groupId: String): List = + offlineRepo.tracksForGroup(groupId).map { it.toSong() } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/PlaylistViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/PlaylistViewModel.kt index c4781b8..8f15d1e 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/PlaylistViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/PlaylistViewModel.kt @@ -6,8 +6,14 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.novasonic.app.api.model.PlaylistWithSongs import com.novasonic.app.app +import com.novasonic.app.offline.DownloadGroupEntity +import com.novasonic.app.offline.DownloadState +import com.novasonic.app.offline.GroupType import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch data class PlaylistUiState( @@ -20,12 +26,22 @@ data class PlaylistUiState( class PlaylistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) { - private val repo = app.app.repository + private val repo = app.app.repository + private val offlineRepo = app.app.offlineRepository private val playlistId: String? = savedState["playlistId"] private val _state = MutableStateFlow(PlaylistUiState()) val state: StateFlow = _state + val downloadGroup: StateFlow = offlineRepo.allGroupsFlow + .map { groups -> groups.find { it.groupId == "playlist:$playlistId" } } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + /** True while any track in this playlist's group is queued or actively downloading. */ + val isDownloading: StateFlow = offlineRepo.tracksForGroupFlow("playlist:$playlistId") + .map { tracks -> tracks.any { it.state == DownloadState.QUEUED || it.state == DownloadState.DOWNLOADING } } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + init { load() } fun load() { @@ -110,6 +126,21 @@ class PlaylistViewModel(app: Application, savedState: SavedStateHandle) : Androi } } + fun downloadPlaylist() { + val pl = _state.value.playlist ?: return + viewModelScope.launch { + runCatching { + offlineRepo.enqueueGroup( + type = GroupType.PLAYLIST, + sourceId = pl.id, + name = pl.name, + coverArt = pl.coverArt, + songs = pl.entry, + ) + }.onFailure { android.util.Log.e("PlaylistViewModel", "downloadPlaylist failed", it) } + } + } + fun removeSong(index: Int) { val id = playlistId ?: return val pl = _state.value.playlist ?: return diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index d6517a0..99548ac 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -12,6 +12,7 @@ ktor = "2.3.12" kotlinxSerialization = "1.7.3" kotlinxCoroutines = "1.9.0" room = "2.6.1" +workmanager = "2.9.1" datastore = "1.1.1" coil = "2.7.0" splashscreen = "1.0.1" @@ -63,6 +64,9 @@ room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +# WorkManager +work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workmanager" } + # DataStore (settings / credentials) datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }