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 | ✅ | ✅ |
|
||||
| ABS notification (modern style) | ❌ | ✅ |
|
||||
| Android Auto | — | ✅ |
|
||||
| Offline download (albums/playlists) | ❌ | ✅ |
|
||||
| Offline playback | ❌ | ✅ |
|
||||
| Offline detail / manage downloads | ❌ | ✅ |
|
||||
| Romaji / Japanese sort | ✅ | ❌ |
|
||||
| Settings (Navidrome, ABS, etc.) | ✅ | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## [0.10] — 2026-06-25
|
||||
|
||||
### Added
|
||||
- **Android** — Offline downloads: download albums and playlists to device storage with a single tap; album download button is in the album hero row (▶ / shuffle / ↓), playlist button next to the edit toggle. Tapping again resumes / re-downloads any missing songs (already-downloaded tracks are skipped)
|
||||
- **Android** — Offline tab in Library (position 2, next to Playlists): lists downloaded groups with cover art, track count, and an in-progress bar; shows total MB used
|
||||
- **Android** — Offline detail screen: tap a group to view its songs (with cover art, like the online playlist), play / shuffle, and remove individual songs from the device via the edit toggle. Works fully offline (reads from Room, no network)
|
||||
- **Android** — Offline playback: tap ▶ on a fully-downloaded group to play locally; downloaded songs are also served from disk automatically when opened from any album or playlist view
|
||||
- **Android** — Downloaded badge: albums and playlists in the Library tabs show a ↓-in-circle badge on the cover when fully downloaded
|
||||
- **Android** — Duplicate-safe storage: a song shared by an album and a playlist is stored once (keyed by song ID) and reference-counted on delete — removing one group keeps the file if another still needs it
|
||||
|
||||
### Fixed
|
||||
- **Android** — Download crash on Android 15 (targetSdk 35): `FOREGROUND_SERVICE_TYPE_DATA_SYNC` is banned for apps targeting API 35; WorkManager's expedited path used it and the resulting exception killed the app ~1.5 s after tapping download. Fixed by dropping `setExpedited()` and all foreground-service use from `DownloadWorker` — downloads run as silent background work with progress shown in the Offline tab
|
||||
- **Android** — Offline detail screen flashed open then immediately returned: its initial empty state (before Room emitted) was mistaken for "deleted". Now shows a brief spinner until loaded, and only leaves when the last song is actually removed
|
||||
|
||||
### Internal
|
||||
- **Android** — Offline system: `Room` DB (`novasonic_offline.db`) with `downloaded_tracks`, `download_groups`, `group_track_cross_refs`; `OfflineRepository` holds hot `StateFlow` maps for O(1) local-path lookups; `DownloadWorker` streams audio + cover art via `HttpURLConnection` to `context.filesDir/offline/{audio|covers}/`. `MusicRepository.streamUrl()` checks local files first, so playback uses the `file://` URI on every entry point
|
||||
|
||||
---
|
||||
|
||||
## [0.9] — 2026-06-22
|
||||
|
||||
### Added
|
||||
|
||||
@@ -70,6 +70,15 @@ Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives a
|
||||
share the same `mainArtist` value (= iTunes `collectionArtistName` when available, else
|
||||
`albumArtist` param, else track artist). Never use `rawArtist` for `album_artist`.
|
||||
|
||||
## Android offline system
|
||||
|
||||
- **Storage:** `context.filesDir/offline/audio/{songId}.{ext}` and `.../covers/{coverArtId}.jpg` — private internal storage, not accessible via file manager.
|
||||
- **Database:** Room DB `novasonic_offline.db` (version 1) with three tables: `downloaded_tracks` (track metadata + local paths + state), `download_groups` (album/playlist/song group + progress), `group_track_cross_refs` (many-to-many). See [OfflineEntities.kt](android/app/src/main/kotlin/com/novasonic/app/offline/OfflineEntities.kt) and [OfflineDao.kt](android/app/src/main/kotlin/com/novasonic/app/offline/OfflineDao.kt).
|
||||
- **OfflineRepository** ([OfflineRepository.kt](android/app/src/main/kotlin/com/novasonic/app/offline/OfflineRepository.kt)): holds hot `StateFlow` maps (`_audioMap`, `_coverMap`) keyed by songId/coverArtId for O(1) lookups in `MusicRepository.streamUrl()`. Call `enqueueGroup()` to schedule a download.
|
||||
- **DownloadWorker** ([DownloadWorker.kt](android/app/src/main/kotlin/com/novasonic/app/offline/DownloadWorker.kt)): `CoroutineWorker` using plain `HttpURLConnection` streaming to `.tmp` → rename. **No `setExpedited()` and no `setForeground()`** — `FOREGROUND_SERVICE_TYPE_DATA_SYNC` is banned on Android 15 (targetSdk=35) and crashes the app; downloads are silent background work.
|
||||
- **Playback:** `MusicRepository.streamUrl()` checks `offlineRepo.localAudioPath(id)` first; if the file exists it returns a `file://` URI. This applies to every play path — album, playlist, Offline tab — no special offline mode needed.
|
||||
- **Offline tab:** Library → tab 2 (OFFLINE). Driven by `OfflineViewModel`. Play button on a completed group calls `offlineVm.songsForGroup()` (JOIN query → `DownloadedTrackEntity.toSong()`) then `player.playSongs()`.
|
||||
|
||||
## UI / references
|
||||
|
||||
When referencing files in chat, use clickable markdown links, not backticks:
|
||||
|
||||
@@ -7,70 +7,17 @@ Built with Vite + vanilla ES modules (web) and Jetpack Compose + Media3/ExoPlaye
|
||||
|
||||
## Features
|
||||
|
||||
### Music Library
|
||||
- Stream music from your Navidrome / Subsonic library
|
||||
- Browse artists, albums, playlists, starred tracks, genres, and random picks
|
||||
- Search across songs, albums, and artists
|
||||
- Star and context menu on every song row
|
||||
- Play count display (optional)
|
||||
- **Library** — Stream from Navidrome / Subsonic; browse artists, albums, playlists, genres, starred, and random; search; star + context menu on every row.
|
||||
- **Playback** — Queue with shuffle, repeat, and radio mode (endless similar songs); gapless-style preload; lock-screen / notification controls; Navidrome scrobbling.
|
||||
- **Lyrics** — Synced and plain lyrics via lrclib.net, auto-fetched and cached per session.
|
||||
- **Artist pages** — Hero card, Last.fm bio / similar / top songs, Ticketmaster events, and iTunes cross-reference showing owned vs. missing tracks.
|
||||
- **Playlists** — Create, rename, delete, add/remove tracks, and upload a cover (Web + Android).
|
||||
- **Importer & downloads** — Paste a Spotify / YouTube / YouTube Music / SoundCloud link to download via yt-dlp, with iTunes metadata enrichment and duplicate detection.
|
||||
- **Metadata editor** (web) — Edit tags + cover art; files are renamed/moved and Navidrome is rescanned on save.
|
||||
- **Offline** (Android) — Download albums/playlists to private storage; an Offline tab to play and manage them; downloaded tracks play from disk on every screen.
|
||||
- **Audiobookshelf** — Browse and stream audiobooks with chapter navigation, speed control, sleep timer, and progress sync.
|
||||
|
||||
### Lyrics
|
||||
- Synced and plain lyrics via lrclib.net, auto-fetched per track
|
||||
- Fallback chain: exact match → no-duration match → fuzzy artist+title search → title-only search
|
||||
- In-memory cache per session
|
||||
|
||||
### Artist Pages
|
||||
- Hero card with artist photo and blurred background
|
||||
- Bio, similar artists, top songs from Last.fm
|
||||
- Concert events via Ticketmaster
|
||||
- Albums cross-referenced against iTunes catalog — shows downloaded vs. missing tracks
|
||||
- Artist cards with images in search results
|
||||
|
||||
### Playback
|
||||
- Queue with shuffle, repeat (all / one), and radio mode (endless similar songs)
|
||||
- Now Playing overlay with synced lyrics, queue view, star, and multi-artist links
|
||||
- Gapless-style playback — next track pre-buffered before current track ends
|
||||
- Lock screen / notification media controls
|
||||
- Scrobbling to Navidrome (configurable delay)
|
||||
|
||||
### Discovery
|
||||
- iTunes / Last.fm — charts, global search, 30-second previews, similar artists
|
||||
- Song Importer — paste a Spotify, YouTube, YouTube Music, or SoundCloud link to download
|
||||
|
||||
### Downloads
|
||||
- Download tracks, albums, or entire artists from YouTube Music / SoundCloud / Spotify via yt-dlp
|
||||
- iTunes metadata enrichment — album art, year, track number, genre
|
||||
- Duplicate detection, standard folder/filename structure
|
||||
|
||||
### Metadata Editor
|
||||
- Edit tags (title, artist, album, genre, year, track, disc, cover art)
|
||||
- File renamed and moved automatically; Navidrome rescan triggered on save
|
||||
|
||||
### Playlists
|
||||
- Create, rename, and delete playlists
|
||||
- Add tracks via "Add to Playlist" context menu
|
||||
- Remove tracks from a playlist (edit mode on Android; inline on Web)
|
||||
- Cover image upload (Web + Android)
|
||||
|
||||
### Audiobookshelf Integration
|
||||
- Browse libraries and audiobooks on both Web and Android
|
||||
- Stream audiobooks with chapter-by-chapter navigation
|
||||
- Playback speed control (0.5× – 2.0×)
|
||||
- Sleep timer
|
||||
- Progress sync — current position saved back to ABS server every 30 s
|
||||
- Chapter track mode — progress bar shows current chapter length instead of full book (toggle in Settings)
|
||||
- Tab/section hidden until ABS is configured in Settings
|
||||
|
||||
### App — Web PWA
|
||||
- Installable on Android / iOS / desktop
|
||||
- Multiple color themes, German UI
|
||||
|
||||
### App — Android (native Jetpack Compose)
|
||||
- Media3 / ExoPlayer backend
|
||||
- OS notification with playback controls
|
||||
- ABS audiobook notification: chapter name shown as title, book title as artist; extra buttons — Kapitel zurück | 30s zurück | Play/Pause | 30s vor | Kapitel vor (compact + expanded)
|
||||
- Last played song shown in mini player bar on restart; tap to resume
|
||||
- Full playlist management: create, rename (with description), delete, remove tracks, cover upload
|
||||
Web is an installable PWA (themes, German UI); Android is native Jetpack Compose + Media3/ExoPlayer with a mini player, media/ABS notifications, and Android Auto. See [CHANGELOG.md](CHANGELOG.md) for the full web/Android parity matrix.
|
||||
|
||||
---
|
||||
|
||||
@@ -119,6 +66,7 @@ NovaSonic/
|
||||
│ └── app/src/main/kotlin/com/novasonic/app/
|
||||
│ ├── api/ # SubsonicClient, AbsClient, LrcLibClient, ItunesClient
|
||||
│ ├── data/ # PrefsStore (DataStore), MusicRepository
|
||||
│ ├── offline/ # Room DB, DAO, OfflineRepository, DownloadWorker (offline downloads)
|
||||
│ ├── player/ # PlayerController, MusicService (Media3)
|
||||
│ ├── ui/
|
||||
│ │ ├── screen/ # HomeScreen, NowPlayingScreen, AbsScreen, SettingsScreen, …
|
||||
|
||||
@@ -87,6 +87,9 @@ dependencies {
|
||||
implementation(libs.room.ktx)
|
||||
ksp(libs.room.compiler)
|
||||
|
||||
// WorkManager
|
||||
implementation(libs.work.runtime.ktx)
|
||||
|
||||
// DataStore
|
||||
implementation(libs.datastore.preferences)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<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_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.RECEIVE_BOOT_COMPLETED"/>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.novasonic.app
|
||||
|
||||
import android.app.Application
|
||||
import com.novasonic.app.data.MusicRepository
|
||||
import com.novasonic.app.offline.OfflineRepository
|
||||
import com.novasonic.app.player.AbsPlayerController
|
||||
import com.novasonic.app.player.PlayerController
|
||||
|
||||
@@ -13,9 +14,12 @@ class NovaSonicApp : Application() {
|
||||
private set
|
||||
lateinit var absPlayerController: AbsPlayerController
|
||||
private set
|
||||
lateinit var offlineRepository: OfflineRepository
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
offlineRepository = OfflineRepository(applicationContext) // must be first
|
||||
repository = MusicRepository(applicationContext)
|
||||
playerController = PlayerController(applicationContext, repository)
|
||||
playerController.connect()
|
||||
|
||||
@@ -126,7 +126,8 @@ object LrcLibClient {
|
||||
val synced = candidates.filter { !it.syncedLyrics.isNullOrBlank() }
|
||||
val pool = synced.ifEmpty { candidates }
|
||||
return if (durationSecs != null && durationSecs > 0) {
|
||||
val best = pool.minByOrNull { kotlin.math.abs(it.duration - durationSecs) }!!
|
||||
val best = pool.minByOrNull { kotlin.math.abs(it.duration - durationSecs) }
|
||||
?: return synced.firstOrNull() ?: candidates.firstOrNull()
|
||||
if (kotlin.math.abs(best.duration - durationSecs) <= 5.0) best
|
||||
else synced.firstOrNull() ?: candidates.firstOrNull()
|
||||
} else {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package com.novasonic.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.novasonic.app.NovaSonicApp
|
||||
import com.novasonic.app.api.SubsonicClient
|
||||
import com.novasonic.app.api.model.*
|
||||
import com.novasonic.app.offline.OfflineRepository
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.io.File
|
||||
|
||||
object ClientHolder {
|
||||
@Volatile private var client: SubsonicClient? = null
|
||||
@@ -65,16 +69,30 @@ class MusicRepository(private val context: Context) {
|
||||
suspend fun removePlaylistCover(playlistId: String) = api().removePlaylistCover(playlistId)
|
||||
|
||||
fun coverArtUrl(id: String?, size: Int = 300): String? {
|
||||
if (id != null) {
|
||||
offlineRepo()?.localCoverPath(id)?.let { path ->
|
||||
val f = File(path)
|
||||
if (f.exists()) return Uri.fromFile(f).toString()
|
||||
}
|
||||
}
|
||||
val creds = lastCredentials ?: return null
|
||||
return SubsonicClient(creds.serverUrl, creds.username, creds.password)
|
||||
.coverArtUrl(id ?: return null, size)
|
||||
}
|
||||
|
||||
fun streamUrl(id: String): String? {
|
||||
offlineRepo()?.localAudioPath(id)?.let { path ->
|
||||
val f = File(path)
|
||||
if (f.exists()) return Uri.fromFile(f).toString()
|
||||
}
|
||||
val creds = lastCredentials ?: return null
|
||||
return SubsonicClient(creds.serverUrl, creds.username, creds.password).streamUrl(id)
|
||||
}
|
||||
|
||||
private fun offlineRepo(): OfflineRepository? = runCatching {
|
||||
(context.applicationContext as NovaSonicApp).offlineRepository
|
||||
}.getOrNull()
|
||||
|
||||
var lastCredentials: PrefsStore.Credentials? = null
|
||||
|
||||
fun credentialsFlow() = prefs.credentials
|
||||
|
||||
@@ -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) {
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
var scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
var scope = CoroutineScope(
|
||||
Dispatchers.Main + SupervisorJob() +
|
||||
CoroutineExceptionHandler { _, t -> android.util.Log.e("AbsPlayerController", "Uncaught coroutine exception", t) }
|
||||
)
|
||||
private set
|
||||
|
||||
private val _state = MutableStateFlow(AbsPlayState())
|
||||
|
||||
@@ -19,6 +19,7 @@ import com.novasonic.app.NovaSonicApp
|
||||
import com.novasonic.app.R
|
||||
import com.novasonic.app.data.MusicRepository
|
||||
import com.novasonic.app.ui.MainActivity
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@@ -46,7 +47,10 @@ class MusicService : MediaLibraryService() {
|
||||
private lateinit var player: ExoPlayer
|
||||
private lateinit var session: MediaLibrarySession
|
||||
private lateinit var repo: MusicRepository
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
private val scope = CoroutineScope(
|
||||
Dispatchers.Main + SupervisorJob() +
|
||||
CoroutineExceptionHandler { _, t -> Log.e("MusicService", "Uncaught coroutine exception", t) }
|
||||
)
|
||||
private var lastAbsChapterIdx = -1
|
||||
|
||||
override fun onCreate() {
|
||||
|
||||
@@ -36,7 +36,10 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
private val _state = MutableStateFlow(PlayerState())
|
||||
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 queue: 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.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.widget.Toast
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
@@ -263,6 +260,8 @@ fun AppNavigation() {
|
||||
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
|
||||
onArtistClick = { artist -> navController.navigate("artist/${artist.id}") },
|
||||
onPlaylistClick = { pl -> navController.navigate("playlist/${pl.id}") },
|
||||
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||
onOpenOffline = { group -> navController.navigate("offline/${encodeRoute(group.groupId)}") },
|
||||
)
|
||||
}
|
||||
composable(TopLevel.ERKUNDEN.route) {
|
||||
@@ -351,6 +350,17 @@ fun AppNavigation() {
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
"offline/{groupId}",
|
||||
arguments = listOf(navArgument("groupId") { type = NavType.StringType }),
|
||||
) {
|
||||
OfflineDetailScreen(
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
playerState = playerState,
|
||||
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
"genre/{genreName}",
|
||||
arguments = listOf(navArgument("genreName") { type = NavType.StringType }),
|
||||
@@ -431,7 +441,21 @@ fun AppNavigation() {
|
||||
onAddToPlaylist = { playlistPickerSong = song; contextMenuSong = null },
|
||||
onGoToAlbum = song.albumId?.let { albumId -> { navController.navigate("album/$albumId") } },
|
||||
onShowInfo = { infoSheetSong = song; contextMenuSong = null },
|
||||
onDownload = { downloadSong(context, song, repo.streamUrl(song.id) ?: ""); contextMenuSong = null },
|
||||
onDownload = {
|
||||
scope.launch {
|
||||
runCatching {
|
||||
app.offlineRepository.enqueueGroup(
|
||||
type = com.novasonic.app.offline.GroupType.SONG,
|
||||
sourceId = song.id,
|
||||
name = song.title,
|
||||
coverArt = song.coverArt,
|
||||
songs = listOf(song),
|
||||
)
|
||||
}.onFailure { android.util.Log.e("AppNavigation", "enqueueGroup failed", it) }
|
||||
}
|
||||
Toast.makeText(context, "Download gestartet: ${song.title}", android.widget.Toast.LENGTH_SHORT).show()
|
||||
contextMenuSong = null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -496,21 +520,5 @@ fun AppNavigation() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadSong(context: Context, song: Song, streamUrl: String) {
|
||||
if (streamUrl.isBlank()) {
|
||||
Toast.makeText(context, "Stream-URL nicht verfügbar", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val safe = (song.title + "." + (song.suffix ?: "mp3")).replace(Regex("[/\\\\:*?\"<>|]"), "_")
|
||||
val request = DownloadManager.Request(Uri.parse(streamUrl))
|
||||
.setTitle(song.title)
|
||||
.setDescription(song.artist ?: "")
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
.setDestinationInExternalPublicDir(Environment.DIRECTORY_MUSIC, safe)
|
||||
.setMimeType(song.contentType ?: "audio/mpeg")
|
||||
val dm = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
dm.enqueue(request)
|
||||
Toast.makeText(context, "Download gestartet: ${song.title}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun encodeRoute(s: String) = java.net.URLEncoder.encode(s, "UTF-8")
|
||||
@@ -4,10 +4,15 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DownloadForOffline
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -28,6 +33,7 @@ fun AlbumCard(
|
||||
album: Album,
|
||||
coverUrl: String?,
|
||||
width: Dp = 145.dp,
|
||||
downloaded: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
@@ -40,15 +46,35 @@ fun AlbumCard(
|
||||
.border(1.dp, cardBorder, shape)
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = coverUrl,
|
||||
contentDescription = album.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Box {
|
||||
AsyncImage(
|
||||
model = coverUrl,
|
||||
contentDescription = album.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.background(NsSurface2),
|
||||
)
|
||||
if (downloaded) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(6.dp)
|
||||
.size(22.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.55f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.DownloadForOffline,
|
||||
contentDescription = "Heruntergeladen",
|
||||
tint = NsAccent,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
Text(
|
||||
text = album.name,
|
||||
|
||||
@@ -9,6 +9,8 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Shuffle
|
||||
import androidx.compose.material3.*
|
||||
@@ -45,6 +47,8 @@ fun AlbumScreen(
|
||||
) {
|
||||
val vm: AlbumViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val downloadGroup by vm.downloadGroup.collectAsStateWithLifecycle()
|
||||
val isDownloading by vm.isDownloading.collectAsStateWithLifecycle()
|
||||
|
||||
if (state.isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
@@ -173,6 +177,30 @@ fun AlbumScreen(
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent)
|
||||
}
|
||||
val isFullyDownloaded = downloadGroup != null &&
|
||||
downloadGroup!!.downloadedTracks >= downloadGroup!!.totalTracks &&
|
||||
downloadGroup!!.totalTracks > 0
|
||||
OutlinedButton(
|
||||
// Tappable unless fully done — resumes / re-downloads any missing songs.
|
||||
onClick = { if (!isFullyDownloaded) vm.downloadAlbum() },
|
||||
modifier = Modifier.height(46.dp).width(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
|
||||
brush = SolidColor(
|
||||
when {
|
||||
isFullyDownloaded -> MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
|
||||
else -> NsAccent.copy(alpha = 0.55f)
|
||||
}
|
||||
)
|
||||
),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
when {
|
||||
isFullyDownloaded -> Icon(Icons.Default.CheckCircle, "Downloaded", Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
isDownloading -> CircularProgressIndicator(Modifier.size(18.dp), color = NsAccent, strokeWidth = 2.dp)
|
||||
else -> Icon(Icons.Default.Download, "Herunterladen", Modifier.size(20.dp), tint = NsAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = NsBorder)
|
||||
|
||||
@@ -21,6 +21,7 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
@@ -36,12 +37,20 @@ import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Album
|
||||
import com.novasonic.app.api.model.Artist
|
||||
import com.novasonic.app.api.model.Playlist
|
||||
import com.novasonic.app.offline.DownloadGroupEntity
|
||||
import com.novasonic.app.offline.GroupType
|
||||
import com.novasonic.app.ui.component.AlbumCard
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.AlbumSort
|
||||
import com.novasonic.app.viewmodel.LibraryTab
|
||||
import com.novasonic.app.viewmodel.LibraryViewModel
|
||||
import com.novasonic.app.viewmodel.OfflineViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.DownloadDone
|
||||
import androidx.compose.material.icons.filled.DownloadForOffline
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import com.novasonic.app.api.model.Song
|
||||
|
||||
@Composable
|
||||
fun LibraryScreen(
|
||||
@@ -49,11 +58,26 @@ fun LibraryScreen(
|
||||
onAlbumClick: (Album) -> Unit,
|
||||
onArtistClick: (Artist) -> Unit,
|
||||
onPlaylistClick: (Playlist) -> Unit,
|
||||
onPlay: (List<Song>, Int) -> Unit,
|
||||
onOpenOffline: (DownloadGroupEntity) -> Unit,
|
||||
) {
|
||||
val vm: LibraryViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val offlineVm: OfflineViewModel = viewModel()
|
||||
val offlineGroups by offlineVm.groups.collectAsStateWithLifecycle()
|
||||
|
||||
// Fully-downloaded source IDs per type — drives the "downloaded" badge on cards.
|
||||
val downloadedAlbumIds = remember(offlineGroups) {
|
||||
offlineGroups.filter { it.type == GroupType.ALBUM && it.totalTracks > 0 && it.downloadedTracks >= it.totalTracks }
|
||||
.map { it.sourceId }.toSet()
|
||||
}
|
||||
val downloadedPlaylistIds = remember(offlineGroups) {
|
||||
offlineGroups.filter { it.type == GroupType.PLAYLIST && it.totalTracks > 0 && it.downloadedTracks >= it.totalTracks }
|
||||
.map { it.sourceId }.toSet()
|
||||
}
|
||||
|
||||
var showCreateDialog by remember { mutableStateOf(false) }
|
||||
var groupToDelete by remember { mutableStateOf<DownloadGroupEntity?>(null) }
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
@@ -72,6 +96,7 @@ fun LibraryScreen(
|
||||
LibraryTab.PLAYLISTS -> "Playlists"
|
||||
LibraryTab.ALBUMS -> "Alben"
|
||||
LibraryTab.ARTISTS -> "Künstler"
|
||||
LibraryTab.OFFLINE -> "Offline"
|
||||
},
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
@@ -138,10 +163,11 @@ fun LibraryScreen(
|
||||
) {
|
||||
items(sortedAlbums, key = { it.id }) { album ->
|
||||
AlbumCard(
|
||||
album = album,
|
||||
coverUrl = coverUrl(album.coverArt),
|
||||
width = 150.dp,
|
||||
onClick = { onAlbumClick(album) },
|
||||
album = album,
|
||||
coverUrl = coverUrl(album.coverArt),
|
||||
width = 150.dp,
|
||||
downloaded = album.id in downloadedAlbumIds,
|
||||
onClick = { onAlbumClick(album) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -287,13 +313,78 @@ fun LibraryScreen(
|
||||
) {
|
||||
items(state.playlists, key = { it.id }) { pl ->
|
||||
PlaylistCard(
|
||||
name = pl.name,
|
||||
subtitle = "${pl.songCount} Titel",
|
||||
coverUrl = coverUrl(pl.coverArt),
|
||||
onClick = { onPlaylistClick(pl) },
|
||||
name = pl.name,
|
||||
subtitle = "${pl.songCount} Titel",
|
||||
coverUrl = coverUrl(pl.coverArt),
|
||||
downloaded = pl.id in downloadedPlaylistIds,
|
||||
onClick = { onPlaylistClick(pl) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LibraryTab.OFFLINE -> {
|
||||
val groups = offlineGroups
|
||||
val storage by offlineVm.storageBytes.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if (groups.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
Icons.Default.DownloadDone,
|
||||
contentDescription = null,
|
||||
tint = NsOnSurface3,
|
||||
modifier = Modifier.size(56.dp),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
"Noch nichts heruntergeladen",
|
||||
fontFamily = Outfit,
|
||||
fontSize = 15.sp,
|
||||
color = NsOnSurface2,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"Öffne ein Album und tippe auf ↓",
|
||||
fontFamily = Outfit,
|
||||
fontSize = 12.sp,
|
||||
color = NsOnSurface3,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = "%.1f MB gespeichert".format(storage / 1_000_000.0),
|
||||
fontFamily = Outfit,
|
||||
fontSize = 12.sp,
|
||||
color = NsOnSurface3,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
items(groups, key = { it.groupId }) { group ->
|
||||
OfflineGroupRow(
|
||||
group = group,
|
||||
coverUrl = coverUrl(group.coverArt),
|
||||
onOpen = { onOpenOffline(group) },
|
||||
onPlay = {
|
||||
scope.launch {
|
||||
val songs = offlineVm.songsForGroup(group.groupId)
|
||||
if (songs.isNotEmpty()) onPlay(songs, 0)
|
||||
}
|
||||
},
|
||||
onDelete = { groupToDelete = group },
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(80.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} // end Column
|
||||
|
||||
@@ -310,6 +401,29 @@ fun LibraryScreen(
|
||||
}
|
||||
} // end Box
|
||||
|
||||
// ── Delete offline group dialog ───────────────────────────────────────────
|
||||
groupToDelete?.let { group ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { groupToDelete = null },
|
||||
title = { Text("Löschen?", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
|
||||
text = { Text("\"${group.name}\" aus dem Offline-Speicher entfernen?", fontFamily = Outfit) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
offlineVm.deleteGroup(group.groupId)
|
||||
groupToDelete = null
|
||||
}) {
|
||||
Text("Löschen", color = MaterialTheme.colorScheme.error, fontFamily = Outfit, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { groupToDelete = null }) {
|
||||
Text("Abbrechen", color = NsOnSurface2)
|
||||
}
|
||||
},
|
||||
containerColor = NsSurface,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Create playlist dialog ────────────────────────────────────────────────
|
||||
if (showCreateDialog) {
|
||||
var nameInput by remember { mutableStateOf("") }
|
||||
@@ -354,7 +468,88 @@ fun LibraryScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaylistCard(name: String, subtitle: String, coverUrl: String?, onClick: () -> Unit) {
|
||||
private fun OfflineGroupRow(
|
||||
group: DownloadGroupEntity,
|
||||
coverUrl: String?,
|
||||
onOpen: () -> Unit,
|
||||
onPlay: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val isFullyDone = group.totalTracks > 0 && group.downloadedTracks >= group.totalTracks
|
||||
val subtitle = when {
|
||||
group.totalTracks == 0 -> "Ausstehend…"
|
||||
isFullyDone -> "${group.totalTracks} Titel · Vollständig"
|
||||
else -> "${group.downloadedTracks}/${group.totalTracks} Titel"
|
||||
}
|
||||
val typeLabel = when (group.type) {
|
||||
GroupType.ALBUM -> "Album"
|
||||
GroupType.PLAYLIST -> "Playlist"
|
||||
GroupType.SONG -> "Song"
|
||||
GroupType.FAVORITES -> "Favoriten"
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(NsSurface)
|
||||
.border(1.dp, NsBorder, RoundedCornerShape(12.dp))
|
||||
.clickable(onClick = onOpen)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
AsyncImage(
|
||||
model = coverUrl,
|
||||
contentDescription = group.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(52.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = group.name,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "$typeLabel · $subtitle",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onPlay, enabled = isFullyDone) {
|
||||
Icon(Icons.Default.PlayArrow, "Abspielen",
|
||||
tint = if (isFullyDone) NsAccent else NsOnSurface3,
|
||||
modifier = Modifier.size(20.dp))
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(Icons.Default.Delete, "Löschen", tint = NsOnSurface3, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
if (!isFullyDone && group.totalTracks > 0) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { group.downloadedTracks.toFloat() / group.totalTracks },
|
||||
modifier = Modifier.fillMaxWidth().height(3.dp).clip(RoundedCornerShape(2.dp)),
|
||||
color = NsAccent,
|
||||
trackColor = NsSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaylistCard(
|
||||
name: String,
|
||||
subtitle: String,
|
||||
coverUrl: String?,
|
||||
downloaded: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -363,15 +558,18 @@ private fun PlaylistCard(name: String, subtitle: String, coverUrl: String?, onCl
|
||||
.border(1.dp, NsBorder, shape)
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = coverUrl,
|
||||
contentDescription = name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Box {
|
||||
AsyncImage(
|
||||
model = coverUrl,
|
||||
contentDescription = name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.background(NsSurface2),
|
||||
)
|
||||
if (downloaded) DownloadedBadge(Modifier.align(Alignment.BottomEnd).padding(6.dp))
|
||||
}
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
Text(
|
||||
text = name,
|
||||
@@ -387,3 +585,22 @@ private fun PlaylistCard(name: String, subtitle: String, coverUrl: String?, onCl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Small "downloaded" pill overlaid on a cover — the down-arrow-in-circle, Spotify-style. */
|
||||
@Composable
|
||||
fun DownloadedBadge(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(22.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.55f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.DownloadForOffline,
|
||||
contentDescription = "Heruntergeladen",
|
||||
tint = NsAccent,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -33,6 +34,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.offline.DownloadGroupEntity
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
@@ -55,6 +57,8 @@ fun PlaylistDetailScreen(
|
||||
var editMode by remember { mutableStateOf(false) }
|
||||
var showRenameDialog by remember { mutableStateOf(false) }
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
val downloadGroup by vm.downloadGroup.collectAsStateWithLifecycle()
|
||||
val isDownloading by vm.isDownloading.collectAsStateWithLifecycle()
|
||||
|
||||
val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
|
||||
uri ?: return@rememberLauncherForActivityResult
|
||||
@@ -234,6 +238,23 @@ fun PlaylistDetailScreen(
|
||||
tint = if (editMode) NsAccent else NsOnSurface2,
|
||||
)
|
||||
}
|
||||
// Download button — tappable unless fully done; resumes / re-downloads missing songs
|
||||
val dlDone = downloadGroup?.let { it.downloadedTracks >= it.totalTracks && it.totalTracks > 0 } ?: false
|
||||
OutlinedButton(
|
||||
onClick = { if (!dlDone) vm.downloadPlaylist() },
|
||||
modifier = Modifier.height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
|
||||
brush = SolidColor(if (dlDone) NsAccent else NsOnSurface3)
|
||||
),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(horizontal = 14.dp),
|
||||
) {
|
||||
when {
|
||||
dlDone -> Icon(Icons.Default.CheckCircle, null, modifier = Modifier.size(18.dp), tint = NsAccent)
|
||||
isDownloading -> CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp, color = NsAccent)
|
||||
else -> Icon(Icons.Default.Download, null, modifier = Modifier.size(18.dp), tint = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = NsBorder)
|
||||
|
||||
@@ -6,8 +6,10 @@ import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.model.Album
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import com.novasonic.app.offline.DownloadGroupEntity
|
||||
import com.novasonic.app.offline.DownloadState
|
||||
import com.novasonic.app.offline.GroupType
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class AlbumUiState(
|
||||
@@ -18,12 +20,22 @@ data class AlbumUiState(
|
||||
|
||||
class AlbumViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
private val repo = app.app.repository
|
||||
private val offlineRepo = app.app.offlineRepository
|
||||
private val albumId: String? = savedState["albumId"]
|
||||
|
||||
private val _state = MutableStateFlow(AlbumUiState())
|
||||
val state: StateFlow<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 {
|
||||
val id = albumId
|
||||
if (id.isNullOrBlank()) {
|
||||
@@ -39,4 +51,19 @@ class AlbumViewModel(app: Application, savedState: SavedStateHandle) : AndroidVi
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadAlbum() {
|
||||
val album = _state.value.album ?: return
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
offlineRepo.enqueueGroup(
|
||||
type = GroupType.ALBUM,
|
||||
sourceId = album.id,
|
||||
name = album.name,
|
||||
coverArt = album.coverArt,
|
||||
songs = album.song,
|
||||
)
|
||||
}.onFailure { android.util.Log.e("AlbumViewModel", "enqueueGroup failed", it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,17 +44,28 @@ class HomeViewModel(app: Application) : AndroidViewModel(app) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val recentDef = async { repo.getRecentAlbums(20) }
|
||||
val recentlyPlayedDef = async { runCatching { repo.getRecentlyPlayedAlbums(5) }.getOrDefault(emptyList()) }
|
||||
val frequentDef = async { repo.getFrequentAlbums(20) }
|
||||
val randomDef = async { repo.getRandomAlbums(20) }
|
||||
val genresDef = async { runCatching { repo.getGenres() }.getOrDefault(emptyList()) }
|
||||
val recentDef = async { runCatching { repo.getRecentAlbums(20) } }
|
||||
val recentlyPlayedDef = async { runCatching { repo.getRecentlyPlayedAlbums(5) } }
|
||||
val frequentDef = async { runCatching { repo.getFrequentAlbums(20) } }
|
||||
val randomDef = async { runCatching { repo.getRandomAlbums(20) } }
|
||||
val genresDef = async { runCatching { repo.getGenres() } }
|
||||
|
||||
val recent = recentDef.await()
|
||||
val recentlyPlayed = recentlyPlayedDef.await()
|
||||
val frequent = frequentDef.await()
|
||||
val random = randomDef.await()
|
||||
val genres = genresDef.await()
|
||||
val recentResult = recentDef.await()
|
||||
val recentlyPlayedResult = recentlyPlayedDef.await()
|
||||
val frequentResult = frequentDef.await()
|
||||
val randomResult = randomDef.await()
|
||||
val genresResult = genresDef.await()
|
||||
|
||||
if (recentResult.isFailure) {
|
||||
_state.value = HomeUiState(error = recentResult.exceptionOrNull()?.message)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val recent = recentResult.getOrDefault(emptyList())
|
||||
val recentlyPlayed = recentlyPlayedResult.getOrDefault(emptyList())
|
||||
val frequent = frequentResult.getOrDefault(emptyList())
|
||||
val random = randomResult.getOrDefault(emptyList())
|
||||
val genres = genresResult.getOrDefault(emptyList())
|
||||
|
||||
// Hero pool = frequent.take(5) ∪ recentlyPlayed.take(5), deduped — matches web
|
||||
val frequentIds = frequent.take(5).map { it.id }.toSet()
|
||||
|
||||
@@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class LibraryTab { PLAYLISTS, ALBUMS, ARTISTS }
|
||||
enum class LibraryTab { PLAYLISTS, OFFLINE, ALBUMS, ARTISTS }
|
||||
enum class AlbumSort { NEWEST, OLDEST, AZ }
|
||||
|
||||
data class LibraryUiState(
|
||||
@@ -46,17 +46,24 @@ class LibraryViewModel(app: Application) : AndroidViewModel(app) {
|
||||
LibraryTab.ALBUMS -> if (_state.value.albumsByNewest.isEmpty() && _state.value.albumsByName.isEmpty()) loadAlbums()
|
||||
LibraryTab.ARTISTS -> if (_state.value.artistIndexes.isEmpty()) loadArtists()
|
||||
LibraryTab.PLAYLISTS -> if (_state.value.playlists.isEmpty()) loadPlaylists()
|
||||
LibraryTab.OFFLINE -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadAlbums() = viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val newestDef = async { repo.getRecentAlbums(500) } // type=newest → add-date order
|
||||
val azDef = async { repo.getAlphaAlbums(500) } // type=alphabeticalByName
|
||||
val newestDef = async { runCatching { repo.getRecentAlbums(500) } }
|
||||
val azDef = async { runCatching { repo.getAlphaAlbums(500) } }
|
||||
val newestResult = newestDef.await()
|
||||
val azResult = azDef.await()
|
||||
if (newestResult.isFailure) {
|
||||
_state.value = _state.value.copy(error = newestResult.exceptionOrNull()?.message, isLoading = false)
|
||||
return@launch
|
||||
}
|
||||
_state.value = _state.value.copy(
|
||||
albumsByNewest = newestDef.await(),
|
||||
albumsByName = azDef.await(),
|
||||
albumsByNewest = newestResult.getOrDefault(emptyList()),
|
||||
albumsByName = azResult.getOrDefault(emptyList()),
|
||||
isLoading = false,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -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 com.novasonic.app.api.model.PlaylistWithSongs
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.offline.DownloadGroupEntity
|
||||
import com.novasonic.app.offline.DownloadState
|
||||
import com.novasonic.app.offline.GroupType
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class PlaylistUiState(
|
||||
@@ -20,12 +26,22 @@ data class PlaylistUiState(
|
||||
|
||||
class PlaylistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
private val repo = app.app.repository
|
||||
private val offlineRepo = app.app.offlineRepository
|
||||
private val playlistId: String? = savedState["playlistId"]
|
||||
|
||||
private val _state = MutableStateFlow(PlaylistUiState())
|
||||
val state: StateFlow<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() }
|
||||
|
||||
fun load() {
|
||||
@@ -110,6 +126,21 @@ class PlaylistViewModel(app: Application, savedState: SavedStateHandle) : Androi
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadPlaylist() {
|
||||
val pl = _state.value.playlist ?: return
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
offlineRepo.enqueueGroup(
|
||||
type = GroupType.PLAYLIST,
|
||||
sourceId = pl.id,
|
||||
name = pl.name,
|
||||
coverArt = pl.coverArt,
|
||||
songs = pl.entry,
|
||||
)
|
||||
}.onFailure { android.util.Log.e("PlaylistViewModel", "downloadPlaylist failed", it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun removeSong(index: Int) {
|
||||
val id = playlistId ?: return
|
||||
val pl = _state.value.playlist ?: return
|
||||
|
||||
@@ -12,6 +12,7 @@ ktor = "2.3.12"
|
||||
kotlinxSerialization = "1.7.3"
|
||||
kotlinxCoroutines = "1.9.0"
|
||||
room = "2.6.1"
|
||||
workmanager = "2.9.1"
|
||||
datastore = "1.1.1"
|
||||
coil = "2.7.0"
|
||||
splashscreen = "1.0.1"
|
||||
@@ -63,6 +64,9 @@ room-runtime = { group = "androidx.room", name = "room-runtime", version.ref =
|
||||
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||
|
||||
# WorkManager
|
||||
work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workmanager" }
|
||||
|
||||
# DataStore (settings / credentials)
|
||||
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||
|
||||
|
||||
Reference in new issue
Block a user