feat(android): offline downloads — albums, playlists, detail screen & playback
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
5a2ab54bbb
commit
73fcfae30f
29 files changed
+1326
-134
No files matched your search
@@ -45,11 +45,33 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
|
|||||||
| Media notification | ✅ | ✅ |
|
| Media notification | ✅ | ✅ |
|
||||||
| ABS notification (modern style) | ❌ | ✅ |
|
| ABS notification (modern style) | ❌ | ✅ |
|
||||||
| Android Auto | — | ✅ |
|
| Android Auto | — | ✅ |
|
||||||
|
| Offline download (albums/playlists) | ❌ | ✅ |
|
||||||
|
| Offline playback | ❌ | ✅ |
|
||||||
|
| Offline detail / manage downloads | ❌ | ✅ |
|
||||||
| Romaji / Japanese sort | ✅ | ❌ |
|
| Romaji / Japanese sort | ✅ | ❌ |
|
||||||
| Settings (Navidrome, ABS, etc.) | ✅ | ✅ |
|
| 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
|
## [0.9] — 2026-06-22
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -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
|
share the same `mainArtist` value (= iTunes `collectionArtistName` when available, else
|
||||||
`albumArtist` param, else track artist). Never use `rawArtist` for `album_artist`.
|
`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
|
## UI / references
|
||||||
|
|
||||||
When referencing files in chat, use clickable markdown links, not backticks:
|
When referencing files in chat, use clickable markdown links, not backticks:
|
||||||
|
|||||||
@@ -7,70 +7,17 @@ Built with Vite + vanilla ES modules (web) and Jetpack Compose + Media3/ExoPlaye
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Music Library
|
- **Library** — Stream from Navidrome / Subsonic; browse artists, albums, playlists, genres, starred, and random; search; star + context menu on every row.
|
||||||
- Stream music from your Navidrome / Subsonic library
|
- **Playback** — Queue with shuffle, repeat, and radio mode (endless similar songs); gapless-style preload; lock-screen / notification controls; Navidrome scrobbling.
|
||||||
- Browse artists, albums, playlists, starred tracks, genres, and random picks
|
- **Lyrics** — Synced and plain lyrics via lrclib.net, auto-fetched and cached per session.
|
||||||
- Search across songs, albums, and artists
|
- **Artist pages** — Hero card, Last.fm bio / similar / top songs, Ticketmaster events, and iTunes cross-reference showing owned vs. missing tracks.
|
||||||
- Star and context menu on every song row
|
- **Playlists** — Create, rename, delete, add/remove tracks, and upload a cover (Web + Android).
|
||||||
- Play count display (optional)
|
- **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
|
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.
|
||||||
- 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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -119,6 +66,7 @@ NovaSonic/
|
|||||||
│ └── app/src/main/kotlin/com/novasonic/app/
|
│ └── app/src/main/kotlin/com/novasonic/app/
|
||||||
│ ├── api/ # SubsonicClient, AbsClient, LrcLibClient, ItunesClient
|
│ ├── api/ # SubsonicClient, AbsClient, LrcLibClient, ItunesClient
|
||||||
│ ├── data/ # PrefsStore (DataStore), MusicRepository
|
│ ├── data/ # PrefsStore (DataStore), MusicRepository
|
||||||
|
│ ├── offline/ # Room DB, DAO, OfflineRepository, DownloadWorker (offline downloads)
|
||||||
│ ├── player/ # PlayerController, MusicService (Media3)
|
│ ├── player/ # PlayerController, MusicService (Media3)
|
||||||
│ ├── ui/
|
│ ├── ui/
|
||||||
│ │ ├── screen/ # HomeScreen, NowPlayingScreen, AbsScreen, SettingsScreen, …
|
│ │ ├── screen/ # HomeScreen, NowPlayingScreen, AbsScreen, SettingsScreen, …
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ dependencies {
|
|||||||
implementation(libs.room.ktx)
|
implementation(libs.room.ktx)
|
||||||
ksp(libs.room.compiler)
|
ksp(libs.room.compiler)
|
||||||
|
|
||||||
|
// WorkManager
|
||||||
|
implementation(libs.work.runtime.ktx)
|
||||||
|
|
||||||
// DataStore
|
// DataStore
|
||||||
implementation(libs.datastore.preferences)
|
implementation(libs.datastore.preferences)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.novasonic.app
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import com.novasonic.app.data.MusicRepository
|
import com.novasonic.app.data.MusicRepository
|
||||||
|
import com.novasonic.app.offline.OfflineRepository
|
||||||
import com.novasonic.app.player.AbsPlayerController
|
import com.novasonic.app.player.AbsPlayerController
|
||||||
import com.novasonic.app.player.PlayerController
|
import com.novasonic.app.player.PlayerController
|
||||||
|
|
||||||
@@ -13,9 +14,12 @@ class NovaSonicApp : Application() {
|
|||||||
private set
|
private set
|
||||||
lateinit var absPlayerController: AbsPlayerController
|
lateinit var absPlayerController: AbsPlayerController
|
||||||
private set
|
private set
|
||||||
|
lateinit var offlineRepository: OfflineRepository
|
||||||
|
private set
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
offlineRepository = OfflineRepository(applicationContext) // must be first
|
||||||
repository = MusicRepository(applicationContext)
|
repository = MusicRepository(applicationContext)
|
||||||
playerController = PlayerController(applicationContext, repository)
|
playerController = PlayerController(applicationContext, repository)
|
||||||
playerController.connect()
|
playerController.connect()
|
||||||
|
|||||||
@@ -126,7 +126,8 @@ object LrcLibClient {
|
|||||||
val synced = candidates.filter { !it.syncedLyrics.isNullOrBlank() }
|
val synced = candidates.filter { !it.syncedLyrics.isNullOrBlank() }
|
||||||
val pool = synced.ifEmpty { candidates }
|
val pool = synced.ifEmpty { candidates }
|
||||||
return if (durationSecs != null && durationSecs > 0) {
|
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
|
if (kotlin.math.abs(best.duration - durationSecs) <= 5.0) best
|
||||||
else synced.firstOrNull() ?: candidates.firstOrNull()
|
else synced.firstOrNull() ?: candidates.firstOrNull()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
package com.novasonic.app.data
|
package com.novasonic.app.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import com.novasonic.app.NovaSonicApp
|
||||||
import com.novasonic.app.api.SubsonicClient
|
import com.novasonic.app.api.SubsonicClient
|
||||||
import com.novasonic.app.api.model.*
|
import com.novasonic.app.api.model.*
|
||||||
|
import com.novasonic.app.offline.OfflineRepository
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
object ClientHolder {
|
object ClientHolder {
|
||||||
@Volatile private var client: SubsonicClient? = null
|
@Volatile private var client: SubsonicClient? = null
|
||||||
@@ -65,16 +69,30 @@ class MusicRepository(private val context: Context) {
|
|||||||
suspend fun removePlaylistCover(playlistId: String) = api().removePlaylistCover(playlistId)
|
suspend fun removePlaylistCover(playlistId: String) = api().removePlaylistCover(playlistId)
|
||||||
|
|
||||||
fun coverArtUrl(id: String?, size: Int = 300): String? {
|
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
|
val creds = lastCredentials ?: return null
|
||||||
return SubsonicClient(creds.serverUrl, creds.username, creds.password)
|
return SubsonicClient(creds.serverUrl, creds.username, creds.password)
|
||||||
.coverArtUrl(id ?: return null, size)
|
.coverArtUrl(id ?: return null, size)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun streamUrl(id: String): String? {
|
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
|
val creds = lastCredentials ?: return null
|
||||||
return SubsonicClient(creds.serverUrl, creds.username, creds.password).streamUrl(id)
|
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
|
var lastCredentials: PrefsStore.Credentials? = null
|
||||||
|
|
||||||
fun credentialsFlow() = prefs.credentials
|
fun credentialsFlow() = prefs.credentials
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<List<DoneTrackInfo>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM downloaded_tracks WHERE songId = :songId")
|
||||||
|
fun trackFlow(songId: String): Flow<DownloadedTrackEntity?>
|
||||||
|
|
||||||
|
@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<List<DownloadedTrackEntity>>
|
||||||
|
|
||||||
|
@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<List<DownloadGroupEntity>>
|
||||||
|
|
||||||
|
@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<String>
|
||||||
|
|
||||||
|
@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<DownloadedTrackEntity>
|
||||||
|
|
||||||
|
@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<List<DownloadedTrackEntity>>
|
||||||
|
|
||||||
|
@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?
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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<Map<String, String>>(emptyMap())
|
||||||
|
// coverArtId (Navidrome) → absolute local cover path
|
||||||
|
private val _coverMap = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||||
|
|
||||||
|
/** Reactive set of all downloaded song IDs — collect in UI for per-row indicators. */
|
||||||
|
val downloadedIds: StateFlow<Set<String>> = _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<List<DownloadGroupEntity>> = dao.allGroupsFlow()
|
||||||
|
val allTracksFlow: Flow<List<DownloadedTrackEntity>> = dao.allTracksFlow()
|
||||||
|
|
||||||
|
fun trackFlow(songId: String): Flow<DownloadedTrackEntity?> = 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<String> = dao.songIdsForGroup(groupId)
|
||||||
|
|
||||||
|
suspend fun getTrack(songId: String): DownloadedTrackEntity? = dao.getTrackById(songId)
|
||||||
|
|
||||||
|
suspend fun tracksForGroup(groupId: String): List<DownloadedTrackEntity> = dao.tracksForGroup(groupId)
|
||||||
|
|
||||||
|
fun tracksForGroupFlow(groupId: String): Flow<List<DownloadedTrackEntity>> = 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<Song>,
|
||||||
|
) {
|
||||||
|
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<DownloadWorker>()
|
||||||
|
.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() }
|
||||||
|
}
|
||||||
@@ -32,7 +32,10 @@ data class AbsPlayState(
|
|||||||
class AbsPlayerController(context: Context) {
|
class AbsPlayerController(context: Context) {
|
||||||
|
|
||||||
private val appContext = context.applicationContext
|
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 set
|
||||||
|
|
||||||
private val _state = MutableStateFlow(AbsPlayState())
|
private val _state = MutableStateFlow(AbsPlayState())
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import com.novasonic.app.NovaSonicApp
|
|||||||
import com.novasonic.app.R
|
import com.novasonic.app.R
|
||||||
import com.novasonic.app.data.MusicRepository
|
import com.novasonic.app.data.MusicRepository
|
||||||
import com.novasonic.app.ui.MainActivity
|
import com.novasonic.app.ui.MainActivity
|
||||||
|
import android.util.Log
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
@@ -46,7 +47,10 @@ class MusicService : MediaLibraryService() {
|
|||||||
private lateinit var player: ExoPlayer
|
private lateinit var player: ExoPlayer
|
||||||
private lateinit var session: MediaLibrarySession
|
private lateinit var session: MediaLibrarySession
|
||||||
private lateinit var repo: MusicRepository
|
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
|
private var lastAbsChapterIdx = -1
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
|
|||||||
@@ -36,7 +36,10 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
|||||||
private val _state = MutableStateFlow(PlayerState())
|
private val _state = MutableStateFlow(PlayerState())
|
||||||
val state: StateFlow<PlayerState> = _state.asStateFlow()
|
val state: StateFlow<PlayerState> = _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 controller: MediaController? = null
|
||||||
private var queue: List<Song> = emptyList()
|
private var queue: List<Song> = emptyList()
|
||||||
private var originalQueue: List<Song> = emptyList()
|
private var originalQueue: List<Song> = emptyList()
|
||||||
|
|||||||
@@ -16,10 +16,7 @@ import androidx.compose.ui.geometry.Offset
|
|||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import android.app.DownloadManager
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Environment
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
@@ -263,6 +260,8 @@ fun AppNavigation() {
|
|||||||
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
|
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
|
||||||
onArtistClick = { artist -> navController.navigate("artist/${artist.id}") },
|
onArtistClick = { artist -> navController.navigate("artist/${artist.id}") },
|
||||||
onPlaylistClick = { pl -> navController.navigate("playlist/${pl.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) {
|
composable(TopLevel.ERKUNDEN.route) {
|
||||||
@@ -351,6 +350,17 @@ fun AppNavigation() {
|
|||||||
onBack = { navController.popBackStack() },
|
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(
|
composable(
|
||||||
"genre/{genreName}",
|
"genre/{genreName}",
|
||||||
arguments = listOf(navArgument("genreName") { type = NavType.StringType }),
|
arguments = listOf(navArgument("genreName") { type = NavType.StringType }),
|
||||||
@@ -431,7 +441,21 @@ fun AppNavigation() {
|
|||||||
onAddToPlaylist = { playlistPickerSong = song; contextMenuSong = null },
|
onAddToPlaylist = { playlistPickerSong = song; contextMenuSong = null },
|
||||||
onGoToAlbum = song.albumId?.let { albumId -> { navController.navigate("album/$albumId") } },
|
onGoToAlbum = song.albumId?.let { albumId -> { navController.navigate("album/$albumId") } },
|
||||||
onShowInfo = { infoSheetSong = song; contextMenuSong = null },
|
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")
|
private fun encodeRoute(s: String) = java.net.URLEncoder.encode(s, "UTF-8")
|
||||||
@@ -4,10 +4,15 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -28,6 +33,7 @@ fun AlbumCard(
|
|||||||
album: Album,
|
album: Album,
|
||||||
coverUrl: String?,
|
coverUrl: String?,
|
||||||
width: Dp = 145.dp,
|
width: Dp = 145.dp,
|
||||||
|
downloaded: Boolean = false,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val shape = RoundedCornerShape(12.dp)
|
val shape = RoundedCornerShape(12.dp)
|
||||||
@@ -40,15 +46,35 @@ fun AlbumCard(
|
|||||||
.border(1.dp, cardBorder, shape)
|
.border(1.dp, cardBorder, shape)
|
||||||
.clickable(onClick = onClick),
|
.clickable(onClick = onClick),
|
||||||
) {
|
) {
|
||||||
AsyncImage(
|
Box {
|
||||||
model = coverUrl,
|
AsyncImage(
|
||||||
contentDescription = album.name,
|
model = coverUrl,
|
||||||
contentScale = ContentScale.Crop,
|
contentDescription = album.name,
|
||||||
modifier = Modifier
|
contentScale = ContentScale.Crop,
|
||||||
.fillMaxWidth()
|
modifier = Modifier
|
||||||
.aspectRatio(1f)
|
.fillMaxWidth()
|
||||||
.background(NsSurface2),
|
.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)) {
|
Column(Modifier.padding(10.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = album.name,
|
text = album.name,
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import androidx.compose.foundation.shape.CircleShape
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
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.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Shuffle
|
import androidx.compose.material.icons.filled.Shuffle
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
@@ -45,6 +47,8 @@ fun AlbumScreen(
|
|||||||
) {
|
) {
|
||||||
val vm: AlbumViewModel = viewModel()
|
val vm: AlbumViewModel = viewModel()
|
||||||
val state by vm.state.collectAsStateWithLifecycle()
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
|
val downloadGroup by vm.downloadGroup.collectAsStateWithLifecycle()
|
||||||
|
val isDownloading by vm.isDownloading.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
if (state.isLoading) {
|
if (state.isLoading) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
@@ -173,6 +177,30 @@ fun AlbumScreen(
|
|||||||
Spacer(Modifier.width(6.dp))
|
Spacer(Modifier.width(6.dp))
|
||||||
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent)
|
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)
|
HorizontalDivider(color = NsBorder)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.PointerEventType
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.ContentScale
|
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.Album
|
||||||
import com.novasonic.app.api.model.Artist
|
import com.novasonic.app.api.model.Artist
|
||||||
import com.novasonic.app.api.model.Playlist
|
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.component.AlbumCard
|
||||||
import com.novasonic.app.ui.theme.*
|
import com.novasonic.app.ui.theme.*
|
||||||
import com.novasonic.app.viewmodel.AlbumSort
|
import com.novasonic.app.viewmodel.AlbumSort
|
||||||
import com.novasonic.app.viewmodel.LibraryTab
|
import com.novasonic.app.viewmodel.LibraryTab
|
||||||
import com.novasonic.app.viewmodel.LibraryViewModel
|
import com.novasonic.app.viewmodel.LibraryViewModel
|
||||||
|
import com.novasonic.app.viewmodel.OfflineViewModel
|
||||||
import kotlinx.coroutines.launch
|
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
|
@Composable
|
||||||
fun LibraryScreen(
|
fun LibraryScreen(
|
||||||
@@ -49,11 +58,26 @@ fun LibraryScreen(
|
|||||||
onAlbumClick: (Album) -> Unit,
|
onAlbumClick: (Album) -> Unit,
|
||||||
onArtistClick: (Artist) -> Unit,
|
onArtistClick: (Artist) -> Unit,
|
||||||
onPlaylistClick: (Playlist) -> Unit,
|
onPlaylistClick: (Playlist) -> Unit,
|
||||||
|
onPlay: (List<Song>, Int) -> Unit,
|
||||||
|
onOpenOffline: (DownloadGroupEntity) -> Unit,
|
||||||
) {
|
) {
|
||||||
val vm: LibraryViewModel = viewModel()
|
val vm: LibraryViewModel = viewModel()
|
||||||
val state by vm.state.collectAsStateWithLifecycle()
|
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 showCreateDialog by remember { mutableStateOf(false) }
|
||||||
|
var groupToDelete by remember { mutableStateOf<DownloadGroupEntity?>(null) }
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
@@ -72,6 +96,7 @@ fun LibraryScreen(
|
|||||||
LibraryTab.PLAYLISTS -> "Playlists"
|
LibraryTab.PLAYLISTS -> "Playlists"
|
||||||
LibraryTab.ALBUMS -> "Alben"
|
LibraryTab.ALBUMS -> "Alben"
|
||||||
LibraryTab.ARTISTS -> "Künstler"
|
LibraryTab.ARTISTS -> "Künstler"
|
||||||
|
LibraryTab.OFFLINE -> "Offline"
|
||||||
},
|
},
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
@@ -138,10 +163,11 @@ fun LibraryScreen(
|
|||||||
) {
|
) {
|
||||||
items(sortedAlbums, key = { it.id }) { album ->
|
items(sortedAlbums, key = { it.id }) { album ->
|
||||||
AlbumCard(
|
AlbumCard(
|
||||||
album = album,
|
album = album,
|
||||||
coverUrl = coverUrl(album.coverArt),
|
coverUrl = coverUrl(album.coverArt),
|
||||||
width = 150.dp,
|
width = 150.dp,
|
||||||
onClick = { onAlbumClick(album) },
|
downloaded = album.id in downloadedAlbumIds,
|
||||||
|
onClick = { onAlbumClick(album) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -287,13 +313,78 @@ fun LibraryScreen(
|
|||||||
) {
|
) {
|
||||||
items(state.playlists, key = { it.id }) { pl ->
|
items(state.playlists, key = { it.id }) { pl ->
|
||||||
PlaylistCard(
|
PlaylistCard(
|
||||||
name = pl.name,
|
name = pl.name,
|
||||||
subtitle = "${pl.songCount} Titel",
|
subtitle = "${pl.songCount} Titel",
|
||||||
coverUrl = coverUrl(pl.coverArt),
|
coverUrl = coverUrl(pl.coverArt),
|
||||||
onClick = { onPlaylistClick(pl) },
|
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
|
} // end Column
|
||||||
|
|
||||||
@@ -310,6 +401,29 @@ fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
} // end Box
|
} // 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 ────────────────────────────────────────────────
|
// ── Create playlist dialog ────────────────────────────────────────────────
|
||||||
if (showCreateDialog) {
|
if (showCreateDialog) {
|
||||||
var nameInput by remember { mutableStateOf("") }
|
var nameInput by remember { mutableStateOf("") }
|
||||||
@@ -354,7 +468,88 @@ fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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)
|
val shape = RoundedCornerShape(12.dp)
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -363,15 +558,18 @@ private fun PlaylistCard(name: String, subtitle: String, coverUrl: String?, onCl
|
|||||||
.border(1.dp, NsBorder, shape)
|
.border(1.dp, NsBorder, shape)
|
||||||
.clickable(onClick = onClick),
|
.clickable(onClick = onClick),
|
||||||
) {
|
) {
|
||||||
AsyncImage(
|
Box {
|
||||||
model = coverUrl,
|
AsyncImage(
|
||||||
contentDescription = name,
|
model = coverUrl,
|
||||||
contentScale = ContentScale.Crop,
|
contentDescription = name,
|
||||||
modifier = Modifier
|
contentScale = ContentScale.Crop,
|
||||||
.fillMaxWidth()
|
modifier = Modifier
|
||||||
.aspectRatio(1f)
|
.fillMaxWidth()
|
||||||
.background(NsSurface2),
|
.aspectRatio(1f)
|
||||||
)
|
.background(NsSurface2),
|
||||||
|
)
|
||||||
|
if (downloaded) DownloadedBadge(Modifier.align(Alignment.BottomEnd).padding(6.dp))
|
||||||
|
}
|
||||||
Column(Modifier.padding(10.dp)) {
|
Column(Modifier.padding(10.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = name,
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Song>, 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)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import androidx.compose.foundation.text.KeyboardOptions
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -33,6 +34,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import com.novasonic.app.api.model.Song
|
import com.novasonic.app.api.model.Song
|
||||||
|
import com.novasonic.app.offline.DownloadGroupEntity
|
||||||
import com.novasonic.app.player.PlayerState
|
import com.novasonic.app.player.PlayerState
|
||||||
import com.novasonic.app.ui.component.SongRow
|
import com.novasonic.app.ui.component.SongRow
|
||||||
import com.novasonic.app.ui.theme.*
|
import com.novasonic.app.ui.theme.*
|
||||||
@@ -55,6 +57,8 @@ fun PlaylistDetailScreen(
|
|||||||
var editMode by remember { mutableStateOf(false) }
|
var editMode by remember { mutableStateOf(false) }
|
||||||
var showRenameDialog by remember { mutableStateOf(false) }
|
var showRenameDialog by remember { mutableStateOf(false) }
|
||||||
var showDeleteDialog 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? ->
|
val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
|
||||||
uri ?: return@rememberLauncherForActivityResult
|
uri ?: return@rememberLauncherForActivityResult
|
||||||
@@ -234,6 +238,23 @@ fun PlaylistDetailScreen(
|
|||||||
tint = if (editMode) NsAccent else NsOnSurface2,
|
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)
|
HorizontalDivider(color = NsBorder)
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import androidx.lifecycle.SavedStateHandle
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.novasonic.app.api.model.Album
|
import com.novasonic.app.api.model.Album
|
||||||
import com.novasonic.app.app
|
import com.novasonic.app.app
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import com.novasonic.app.offline.DownloadGroupEntity
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import com.novasonic.app.offline.DownloadState
|
||||||
|
import com.novasonic.app.offline.GroupType
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
data class AlbumUiState(
|
data class AlbumUiState(
|
||||||
@@ -18,12 +20,22 @@ data class AlbumUiState(
|
|||||||
|
|
||||||
class AlbumViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
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 albumId: String? = savedState["albumId"]
|
||||||
|
|
||||||
private val _state = MutableStateFlow(AlbumUiState())
|
private val _state = MutableStateFlow(AlbumUiState())
|
||||||
val state: StateFlow<AlbumUiState> = _state
|
val state: StateFlow<AlbumUiState> = _state
|
||||||
|
|
||||||
|
val downloadGroup: StateFlow<DownloadGroupEntity?> = 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<Boolean> = offlineRepo.tracksForGroupFlow("album:$albumId")
|
||||||
|
.map { tracks -> tracks.any { it.state == DownloadState.QUEUED || it.state == DownloadState.DOWNLOADING } }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
val id = albumId
|
val id = albumId
|
||||||
if (id.isNullOrBlank()) {
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -44,17 +44,28 @@ class HomeViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||||
try {
|
try {
|
||||||
val recentDef = async { repo.getRecentAlbums(20) }
|
val recentDef = async { runCatching { repo.getRecentAlbums(20) } }
|
||||||
val recentlyPlayedDef = async { runCatching { repo.getRecentlyPlayedAlbums(5) }.getOrDefault(emptyList()) }
|
val recentlyPlayedDef = async { runCatching { repo.getRecentlyPlayedAlbums(5) } }
|
||||||
val frequentDef = async { repo.getFrequentAlbums(20) }
|
val frequentDef = async { runCatching { repo.getFrequentAlbums(20) } }
|
||||||
val randomDef = async { repo.getRandomAlbums(20) }
|
val randomDef = async { runCatching { repo.getRandomAlbums(20) } }
|
||||||
val genresDef = async { runCatching { repo.getGenres() }.getOrDefault(emptyList()) }
|
val genresDef = async { runCatching { repo.getGenres() } }
|
||||||
|
|
||||||
val recent = recentDef.await()
|
val recentResult = recentDef.await()
|
||||||
val recentlyPlayed = recentlyPlayedDef.await()
|
val recentlyPlayedResult = recentlyPlayedDef.await()
|
||||||
val frequent = frequentDef.await()
|
val frequentResult = frequentDef.await()
|
||||||
val random = randomDef.await()
|
val randomResult = randomDef.await()
|
||||||
val genres = genresDef.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
|
// Hero pool = frequent.take(5) ∪ recentlyPlayed.take(5), deduped — matches web
|
||||||
val frequentIds = frequent.take(5).map { it.id }.toSet()
|
val frequentIds = frequent.take(5).map { it.id }.toSet()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
enum class LibraryTab { PLAYLISTS, ALBUMS, ARTISTS }
|
enum class LibraryTab { PLAYLISTS, OFFLINE, ALBUMS, ARTISTS }
|
||||||
enum class AlbumSort { NEWEST, OLDEST, AZ }
|
enum class AlbumSort { NEWEST, OLDEST, AZ }
|
||||||
|
|
||||||
data class LibraryUiState(
|
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.ALBUMS -> if (_state.value.albumsByNewest.isEmpty() && _state.value.albumsByName.isEmpty()) loadAlbums()
|
||||||
LibraryTab.ARTISTS -> if (_state.value.artistIndexes.isEmpty()) loadArtists()
|
LibraryTab.ARTISTS -> if (_state.value.artistIndexes.isEmpty()) loadArtists()
|
||||||
LibraryTab.PLAYLISTS -> if (_state.value.playlists.isEmpty()) loadPlaylists()
|
LibraryTab.PLAYLISTS -> if (_state.value.playlists.isEmpty()) loadPlaylists()
|
||||||
|
LibraryTab.OFFLINE -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadAlbums() = viewModelScope.launch {
|
private fun loadAlbums() = viewModelScope.launch {
|
||||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||||
try {
|
try {
|
||||||
val newestDef = async { repo.getRecentAlbums(500) } // type=newest → add-date order
|
val newestDef = async { runCatching { repo.getRecentAlbums(500) } }
|
||||||
val azDef = async { repo.getAlphaAlbums(500) } // type=alphabeticalByName
|
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(
|
_state.value = _state.value.copy(
|
||||||
albumsByNewest = newestDef.await(),
|
albumsByNewest = newestResult.getOrDefault(emptyList()),
|
||||||
albumsByName = azDef.await(),
|
albumsByName = azResult.getOrDefault(emptyList()),
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -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<DownloadGroupEntity?> = offlineRepo.allGroupsFlow
|
||||||
|
.map { groups -> groups.find { it.groupId == groupId } }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
|
val tracks: StateFlow<List<DownloadedTrackEntity>> = offlineRepo.tracksForGroupFlow(groupId)
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||||
|
|
||||||
|
fun songs(): List<Song> = tracks.value.map { it.toSong() }
|
||||||
|
|
||||||
|
fun deleteSong(songId: String) {
|
||||||
|
viewModelScope.launch { offlineRepo.removeSongFromGroup(groupId, songId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<List<DownloadGroupEntity>> = offlineRepo.allGroupsFlow
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||||
|
|
||||||
|
val storageBytes: StateFlow<Long> = 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<Song> =
|
||||||
|
offlineRepo.tracksForGroup(groupId).map { it.toSong() }
|
||||||
|
}
|
||||||
@@ -6,8 +6,14 @@ import androidx.lifecycle.SavedStateHandle
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.novasonic.app.api.model.PlaylistWithSongs
|
import com.novasonic.app.api.model.PlaylistWithSongs
|
||||||
import com.novasonic.app.app
|
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.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
data class PlaylistUiState(
|
data class PlaylistUiState(
|
||||||
@@ -20,12 +26,22 @@ data class PlaylistUiState(
|
|||||||
|
|
||||||
class PlaylistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
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 playlistId: String? = savedState["playlistId"]
|
||||||
|
|
||||||
private val _state = MutableStateFlow(PlaylistUiState())
|
private val _state = MutableStateFlow(PlaylistUiState())
|
||||||
val state: StateFlow<PlaylistUiState> = _state
|
val state: StateFlow<PlaylistUiState> = _state
|
||||||
|
|
||||||
|
val downloadGroup: StateFlow<DownloadGroupEntity?> = 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<Boolean> = 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() }
|
init { load() }
|
||||||
|
|
||||||
fun 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) {
|
fun removeSong(index: Int) {
|
||||||
val id = playlistId ?: return
|
val id = playlistId ?: return
|
||||||
val pl = _state.value.playlist ?: return
|
val pl = _state.value.playlist ?: return
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ ktor = "2.3.12"
|
|||||||
kotlinxSerialization = "1.7.3"
|
kotlinxSerialization = "1.7.3"
|
||||||
kotlinxCoroutines = "1.9.0"
|
kotlinxCoroutines = "1.9.0"
|
||||||
room = "2.6.1"
|
room = "2.6.1"
|
||||||
|
workmanager = "2.9.1"
|
||||||
datastore = "1.1.1"
|
datastore = "1.1.1"
|
||||||
coil = "2.7.0"
|
coil = "2.7.0"
|
||||||
splashscreen = "1.0.1"
|
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-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||||
room-compiler = { group = "androidx.room", name = "room-compiler", 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 (settings / credentials)
|
||||||
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user