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:
HoneyandClaude Opus 4.8 committed 2026-06-25 10:06:43 +02:00
1 parent 5a2ab54bbb
commit 73fcfae30f
29 files changed
+1326 -134

No files matched your search

+22
View File
@@ -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
+9
View File
@@ -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:
+11 -63
View File
@@ -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, …
+3
View File
@@ -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)
+1 -1
View File
@@ -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
+4
View File
@@ -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" }