Compare commits

..
10 Commits
Author SHA1 Message Date
HoneyandClaude Sonnet 4.6 7e20062995 feat: Android Auto, radio mode, lyrics overhaul + web lyrics cleanup
Android:
- fix: Android Auto credentials never set on MusicService → artwork/stream null
- feat: Android Auto tabs reduced to Zuletzt gespielt, Favoriten, Playlists
- feat: radio mode (getSimilarSongs2 → getRandomSongs fallback, <5 songs refill)
- fix: lyrics now use Navidrome getLyricsBySongId plugin exclusively
- fix: lyrics scroll rewritten with Column+ScrollState absolute animateScrollTo
- fix: lyrics recomposition lag — extracted LyricsLineItem + key(idx) per line
- chore: remove dead LrcLibClient (direct lrclib bypassed Navidrome plugin)

Web:
- fix: lyrics now use Navidrome getLyricsBySongId only, removed direct lrclib fallback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 20:30:20 +02:00
HoneyandClaude Sonnet 4.6 8c8f03503b fix: multi-artist click in NowPlaying — individual composables per artist
Replace AnnotatedString/ClickableText (offset-based click was unreliable)
with a horizontally-scrollable Row of individual Text composables. Each
artist has its own explicit clickable modifier bound directly to its ID.

ID de-duplication: if Navidrome copies the primary artistId to all entries
in the artists array, only the first keeps the ID; others render as
non-navigable text (dimmed NsAccent) instead of silently going to wrong page.

Fallback: split song.artist by ';' when artists array is absent/single,
matching the web app's parseArtists() logic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 00:44:59 +02:00
HoneyandClaude Sonnet 4.6 55eef2f831 feat: randomized home greetings (web PWA)
Same pool of music-themed phrases as Android — picks randomly each load.
With username: "Ready for some beats, Marc" — without: "Pick something great"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 00:38:21 +02:00
HoneyandClaude Sonnet 4.6 f15c005a3c feat: multi-artist support in NowPlaying (OpenSubsonic artists array)
- Song model: add SongArtist{id,name} + artists: List<SongArtist>
  Navidrome sends this via OpenSubsonic extension — previously ignored
- NowPlaying: if song.artists has >1 entry, render each name as an
  inline AnnotatedString with ClickableText, separated by ' · ';
  every artist navigates to their own page with their real ID
  Falls back to single artist/artistId entry when array is absent
- AppNavigation: onArtistClick now receives artistId: String instead
  of a fixed closure, enabling per-artist navigation from the sheet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 00:37:08 +02:00
HoneyandClaude Sonnet 4.6 0e651e8be3 feat: randomized home greetings (music-themed, EN)
Replace static time-of-day greetings (Guten Morgen/Tag/Abend) with a random
pick from a pool of 10-12 music-themed phrases each time HomeScreen is composed.
With username: "Ready for some beats, Marc" — without: "What's on the playlist today?"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 00:28:44 +02:00
HoneyandClaude Sonnet 4.6 3855207530 feat: artist cards with image in search + fix action bar overflow
Search: replace SmallArtistChip (text pill) with ArtistCard showing
circular profile image (72dp) + name below; works for both Navidrome
artists (uses coverArt) and iTunes artists (uses artworkUrl100 if present)

ArtistDetailScreen: convert action bar Row → LazyRow so Play / Shuffle /
Konzerte pills sit comfortably with horizontal scroll instead of cramming

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 00:24:18 +02:00
HoneyandClaude Sonnet 4.6 13e9ec9d9a feat: Konzerte button on artist page (Android)
- EventModels: ConcertEvent / ConcertVenue / ConcertOffer (Kotlinx Serializable)
- EventsClient: Ktor client fetching backendUrl/api/events/{artist}
- ArtistViewModel: fetches events in parallel with iTunes data on load;
  openEventsSheet / dismissEventsSheet
- ConcertsSheet: ModalBottomSheet with event rows (date, venue, city,
  Tickets → button that opens browser, Ausverkauft state)
- ArtistDetailScreen: Konzerte pill in action bar showing event count,
  tapping opens ConcertsSheet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 00:17:55 +02:00
HoneyandClaude Sonnet 4.6 b5aa484c20 fix: push NowPlaying cover and controls down by 28dp
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 23:35:59 +02:00
HoneyandClaude Sonnet 4.6 42c12cbd3a fix: genre loading spinner + NowPlaying back button priority
GenreViewModel: GenreUiState(genres = it) left isLoading=true (default),
so genres loaded but spinner never cleared. Added isLoading=false explicitly.

AppNavigation: replace BackHandler + navController.enableOnBackPressed with a
DisposableEffect that re-adds an OnBackPressedCallback each time NowPlaying
opens. Since callbacks are LIFO, a freshly-added callback always beats the
BackHandler NavHost registers internally, regardless of composition order.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 22:49:40 +02:00
HoneyandClaude Sonnet 4.6 f01fc71f54 feat: back navigation, NowPlaying fix, home refresh, genre top-6
- ExploreScreen: add BackHandlers for state-based overlays (partialAlbum →
  albumTracks → artistDetail hierarchy) so system back navigates correctly
- AppNavigation: disable NavController's OnBackPressedCallback while NowPlaying
  is open so our BackHandler always intercepts first (fixes tab-switch-then-back bug)
- Home tab: tapping Home while already on Home triggers vm.load() refresh via refreshKey
- HomeViewModel: load all genres sorted by songCount desc (removed take(12) cap)
- HomeScreen: show only top-6 genres + 'Alle anzeigen' button with total count
- New GenreListScreen + GenreViewModel: full scrollable genre list at 'genres' route

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 22:40:48 +02:00
26 changed files with 1310 additions and 310 deletions

No files matched your search

@@ -0,0 +1,26 @@
package com.novasonic.app.api
import com.novasonic.app.api.model.ConcertEvent
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.android.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
object EventsClient {
private val http = HttpClient(Android) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true; coerceInputValues = true })
}
}
suspend fun fetchEvents(backendUrl: String, artistName: String): List<ConcertEvent> {
if (backendUrl.isBlank()) return emptyList()
val base = backendUrl.trimEnd('/')
val encoded = java.net.URLEncoder.encode(artistName, "UTF-8")
return http.get("$base/api/events/$encoded").body()
}
}
@@ -54,9 +54,10 @@ object ItunesClient {
/** Worldwide top tracks for an iTunes artist */ /** Worldwide top tracks for an iTunes artist */
suspend fun lookupArtistTopTracks(artistId: Long, limit: Int = 10): List<ItunesItem> { suspend fun lookupArtistTopTracks(artistId: Long, limit: Int = 10): List<ItunesItem> {
val resp: ItunesSearchResponse = http.get("https://itunes.apple.com/lookup") { val resp: ItunesSearchResponse = http.get("https://itunes.apple.com/lookup") {
parameter("id", artistId) parameter("id", artistId)
parameter("entity", "musicTrack") parameter("entity", "song") // "musicTrack" only works on /search — lookup needs "song"
parameter("limit", limit + 1) // first result is the artist itself parameter("limit", limit + 1)
parameter("explicit", "Yes")
}.body() }.body()
return resp.results.filter { it.wrapperType == "track" && it.kind == "song" }.take(limit) return resp.results.filter { it.wrapperType == "track" && it.kind == "song" }.take(limit)
} }
@@ -1,64 +0,0 @@
package com.novasonic.app.api
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.android.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class LrcLibResponse(
val id: Long = 0,
val trackName: String = "",
val artistName: String = "",
val albumName: String = "",
val duration: Double = 0.0,
val instrumental: Boolean = false,
val plainLyrics: String? = null,
val syncedLyrics: String? = null,
)
data class LrcLine(val timeMs: Long, val text: String)
fun parseLrc(lrc: String): List<LrcLine> {
val regex = Regex("""\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)""")
return lrc.lines()
.mapNotNull { line ->
regex.find(line.trim())?.let { m ->
val mm = m.groupValues[1].toLong()
val ss = m.groupValues[2].toLong()
val frac = m.groupValues[3]
val ms = mm * 60_000 + ss * 1_000 +
if (frac.length == 2) frac.toLong() * 10 else frac.toLong()
val text = m.groupValues[4].trim()
if (text.isNotEmpty()) LrcLine(ms, text) else null
}
}
.sortedBy { it.timeMs }
}
object LrcLibClient {
private val client = HttpClient(Android) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true; isLenient = true })
}
}
suspend fun getLyrics(
artist: String,
title: String,
album: String?,
durationSecs: Int?,
): LrcLibResponse? = runCatching {
client.get("https://lrclib.net/api/get") {
parameter("artist_name", artist)
parameter("track_name", title)
if (!album.isNullOrBlank()) parameter("album_name", album)
if (durationSecs != null && durationSecs > 0) parameter("duration", durationSecs)
}.body<LrcLibResponse>()
}.getOrNull()
}
@@ -133,6 +133,9 @@ class SubsonicClient(
suspend fun getRandomSongs(size: Int = 50): List<Song> = suspend fun getRandomSongs(size: Int = 50): List<Song> =
get("getRandomSongs.view", mapOf("size" to size)).randomSongs?.song ?: emptyList() get("getRandomSongs.view", mapOf("size" to size)).randomSongs?.song ?: emptyList()
suspend fun getSimilarSongs(songId: String, count: Int = 20): List<Song> =
get("getSimilarSongs2.view", mapOf("id" to songId, "count" to count)).similarSongs2?.song ?: emptyList()
suspend fun star(id: String) = get("star.view", mapOf("id" to id)) suspend fun star(id: String) = get("star.view", mapOf("id" to id))
suspend fun unstar(id: String) = get("unstar.view", mapOf("id" to id)) suspend fun unstar(id: String) = get("unstar.view", mapOf("id" to id))
@@ -0,0 +1,23 @@
package com.novasonic.app.api.model
import kotlinx.serialization.Serializable
@Serializable
data class ConcertEvent(
val datetime: String = "",
val venue: ConcertVenue? = null,
val offers: List<ConcertOffer> = emptyList(),
)
@Serializable
data class ConcertVenue(
val name: String? = null,
val city: String? = null,
val country: String? = null,
)
@Serializable
data class ConcertOffer(
val url: String? = null,
val status: String? = null,
)
@@ -27,6 +27,7 @@ data class SubsonicResponse(
val song: Song? = null, val song: Song? = null,
val starred2: Starred2? = null, val starred2: Starred2? = null,
val randomSongs: SongListResult? = null, val randomSongs: SongListResult? = null,
val similarSongs2: SongListResult? = null,
val scanStatus: ScanStatus? = null, val scanStatus: ScanStatus? = null,
val artistInfo2: ArtistInfo? = null, val artistInfo2: ArtistInfo? = null,
val topSongs: SongListResult? = null, val topSongs: SongListResult? = null,
@@ -40,6 +41,12 @@ data class SubsonicError(val code: Int, val message: String)
// ── Domain models ───────────────────────────────────────────────────────────── // ── Domain models ─────────────────────────────────────────────────────────────
@Serializable
data class SongArtist(
val id: String = "",
val name: String = "",
)
@Serializable @Serializable
data class Song( data class Song(
val id: String, val id: String,
@@ -48,6 +55,7 @@ data class Song(
val albumId: String? = null, val albumId: String? = null,
val artist: String? = null, val artist: String? = null,
val artistId: String? = null, val artistId: String? = null,
val artists: List<SongArtist> = emptyList(),
val track: Int? = null, val track: Int? = null,
val year: Int? = null, val year: Int? = null,
val genre: String? = null, val genre: String? = null,
@@ -48,6 +48,7 @@ class MusicRepository(private val context: Context) {
suspend fun getPlaylist(id: String) = api().getPlaylist(id) suspend fun getPlaylist(id: String) = api().getPlaylist(id)
suspend fun getStarred() = api().getStarred() suspend fun getStarred() = api().getStarred()
suspend fun getRandomSongs() = api().getRandomSongs() suspend fun getRandomSongs() = api().getRandomSongs()
suspend fun getSimilarSongs(songId: String, count: Int = 20) = api().getSimilarSongs(songId, count)
suspend fun star(id: String) = api().star(id) suspend fun star(id: String) = api().star(id)
suspend fun unstar(id: String) = api().unstar(id) suspend fun unstar(id: String) = api().unstar(id)
suspend fun scrobble(id: String) = api().scrobble(id) suspend fun scrobble(id: String) = api().scrobble(id)
@@ -0,0 +1,81 @@
package com.novasonic.app.data
import com.novasonic.app.api.ImporterClient
import com.novasonic.app.api.model.DownloadStatus
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
data class AppNotification(
val id: String,
val title: String,
val artist: String = "",
val status: String, // "done" | "error" | "pending"
val message: String = "",
val timestamp: Long = System.currentTimeMillis(),
val read: Boolean = false,
)
object NotificationStore {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _items = MutableStateFlow<List<AppNotification>>(emptyList())
val items: StateFlow<List<AppNotification>> = _items.asStateFlow()
fun add(n: AppNotification) = _items.update { listOf(n) + it }
fun markAllRead() = _items.update { list -> list.map { it.copy(read = true) } }
fun remove(id: String) = _items.update { it.filter { n -> n.id != id } }
fun clear() { _items.value = emptyList() }
/**
* Submit a download job to the backend and poll until done/error,
* then add a notification. Safe to call from any ViewModel — uses
* an app-lifetime coroutine scope so the ViewModel can be cleared
* while polling continues.
*/
fun startPoll(
backendUrl: String,
jobId: String,
title: String,
artist: String,
) {
add(AppNotification(id = jobId, title = title, artist = artist, status = "pending"))
scope.launch {
val client = ImporterClient(backendUrl)
try {
repeat(240) { // up to ~6 minutes at 1.5 s interval
delay(1500)
val st: DownloadStatus = runCatching { client.status(jobId) }.getOrNull()
?: return@repeat
when (st.status) {
"done" -> {
replace(AppNotification(
id = jobId, title = title, artist = artist,
status = "done", message = st.message ?: "Fertig!",
))
return@launch
}
"error" -> {
replace(AppNotification(
id = jobId, title = title, artist = artist,
status = "error", message = st.message ?: "Fehler",
))
return@launch
}
}
}
} finally {
client.close()
}
}
}
private fun replace(n: AppNotification) =
_items.update { list -> listOf(n) + list.filter { it.id != n.id } }
}
@@ -26,12 +26,13 @@ class MusicService : MediaLibraryService() {
// Android Auto root IDs // Android Auto root IDs
const val ROOT_ID = "ROOT" const val ROOT_ID = "ROOT"
const val RECENT_ID = "RECENT" const val RECENT_ID = "RECENT"
const val ALBUMS_ID = "ALBUMS"
const val ARTISTS_ID = "ARTISTS"
const val PLAYLISTS_ID = "PLAYLISTS" const val PLAYLISTS_ID = "PLAYLISTS"
const val FAVORITES_ID = "FAVORITES"
const val ITEM_PREFIX_ALBUM = "album:" const val ITEM_PREFIX_ALBUM = "album:"
const val ITEM_PREFIX_ARTIST = "artist:"
const val ITEM_PREFIX_PL = "playlist:" const val ITEM_PREFIX_PL = "playlist:"
// Pseudo-items for play-all / shuffle at top of each list
const val PLAY_ALL_PREFIX = "playall:"
const val SHUFFLE_PREFIX = "shuffle:"
} }
private lateinit var player: ExoPlayer private lateinit var player: ExoPlayer
@@ -80,6 +81,12 @@ class MusicService : MediaLibraryService() {
.setBitmapLoader(bitmapLoader) .setBitmapLoader(bitmapLoader)
.setSessionActivity(activityIntent) .setSessionActivity(activityIntent)
.build() .build()
// Keep credentials cached so coverArtUrl/streamUrl work when Auto calls us
// without the UI running (AppNavigation never subscribes on this repo instance).
scope.launch {
repo.credentialsFlow().collect { creds -> repo.lastCredentials = creds }
}
} }
override fun onGetSession(info: MediaSession.ControllerInfo) = session override fun onGetSession(info: MediaSession.ControllerInfo) = session
@@ -102,7 +109,6 @@ class MusicService : MediaLibraryService() {
browser: MediaSession.ControllerInfo, browser: MediaSession.ControllerInfo,
params: LibraryParams?, params: LibraryParams?,
): ListenableFuture<LibraryResult<MediaItem>> { ): ListenableFuture<LibraryResult<MediaItem>> {
val isAuto = params?.isRecent == true || isAndroidAuto(browser)
val root = MediaItem.Builder() val root = MediaItem.Builder()
.setMediaId(ROOT_ID) .setMediaId(ROOT_ID)
.setMediaMetadata( .setMediaMetadata(
@@ -125,7 +131,6 @@ class MusicService : MediaLibraryService() {
params: LibraryParams?, params: LibraryParams?,
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> { ): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
val future = scope.async { buildChildren(parentId) } val future = scope.async { buildChildren(parentId) }
val executor = MoreExecutors.directExecutor()
val settable = SettableFuture.create<LibraryResult<ImmutableList<MediaItem>>>() val settable = SettableFuture.create<LibraryResult<ImmutableList<MediaItem>>>()
scope.launch { scope.launch {
try { try {
@@ -172,6 +177,12 @@ class MusicService : MediaLibraryService() {
try { try {
val resolved = mediaItems.flatMap { item -> val resolved = mediaItems.flatMap { item ->
when { when {
item.mediaId.startsWith(PLAY_ALL_PREFIX) -> {
resolveSongs(item.mediaId.removePrefix(PLAY_ALL_PREFIX), shuffle = false)
}
item.mediaId.startsWith(SHUFFLE_PREFIX) -> {
resolveSongs(item.mediaId.removePrefix(SHUFFLE_PREFIX), shuffle = true)
}
item.mediaId.startsWith(ITEM_PREFIX_ALBUM) -> { item.mediaId.startsWith(ITEM_PREFIX_ALBUM) -> {
val album = repo.getAlbum(item.mediaId.removePrefix(ITEM_PREFIX_ALBUM)) val album = repo.getAlbum(item.mediaId.removePrefix(ITEM_PREFIX_ALBUM))
album.song.map { song -> song.toMediaItem(repo) } album.song.map { song -> song.toMediaItem(repo) }
@@ -190,6 +201,25 @@ class MusicService : MediaLibraryService() {
} }
return settable return settable
} }
private suspend fun resolveSongs(contextId: String, shuffle: Boolean): List<MediaItem> {
val songs: List<com.novasonic.app.api.model.Song> = when {
contextId.startsWith(ITEM_PREFIX_ALBUM) ->
repo.getAlbum(contextId.removePrefix(ITEM_PREFIX_ALBUM)).song
contextId.startsWith(ITEM_PREFIX_PL) ->
repo.getPlaylist(contextId.removePrefix(ITEM_PREFIX_PL)).entry
contextId == FAVORITES_ID ->
repo.getStarred().song
contextId == RECENT_ID -> {
repo.getRecentlyPlayedAlbums(20).flatMap { album ->
runCatching { repo.getAlbum(album.id).song }.getOrDefault(emptyList())
}
}
else -> emptyList()
}
val ordered = if (shuffle) songs.shuffled() else songs
return ordered.map { it.toMediaItem(repo) }
}
} }
// ── Build Android Auto browsable tree ───────────────────────────────────── // ── Build Android Auto browsable tree ─────────────────────────────────────
@@ -197,27 +227,27 @@ class MusicService : MediaLibraryService() {
private suspend fun buildChildren(parentId: String): ImmutableList<MediaItem> { private suspend fun buildChildren(parentId: String): ImmutableList<MediaItem> {
val items = when (parentId) { val items = when (parentId) {
ROOT_ID -> listOf( ROOT_ID -> listOf(
browseFolder(RECENT_ID, "Recently Played", MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS), browseFolder(RECENT_ID, "Zuletzt gespielt", MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS),
browseFolder(ALBUMS_ID, "Albums", MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS), browseFolder(FAVORITES_ID, "Favoriten", MediaMetadata.MEDIA_TYPE_FOLDER_MIXED),
browseFolder(ARTISTS_ID, "Artists", MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS), browseFolder(PLAYLISTS_ID, "Playlists", MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS),
browseFolder(PLAYLISTS_ID, "Playlists", MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS),
) )
RECENT_ID -> repo.getRecentlyPlayedAlbums(20).map { it.toMediaItem(repo) } RECENT_ID -> {
ALBUMS_ID -> repo.getAlphaAlbums(40).map { it.toMediaItem(repo) } val albums = repo.getRecentlyPlayedAlbums(20)
PLAYLISTS_ID -> repo.getPlaylists().map { it.toMediaItem() } listOf(playAllItem(RECENT_ID), shuffleItem(RECENT_ID)) + albums.map { it.toMediaItem(repo) }
ARTISTS_ID -> repo.getArtists().flatMap { idx -> idx.artist.map { it.toMediaItem() } } }
PLAYLISTS_ID -> repo.getPlaylists().map { it.toMediaItem(repo) }
FAVORITES_ID -> {
val songs = repo.getStarred().song
listOf(playAllItem(FAVORITES_ID), shuffleItem(FAVORITES_ID)) + songs.map { it.toMediaItem(repo) }
}
else -> when { else -> when {
parentId.startsWith(ITEM_PREFIX_ALBUM) -> { parentId.startsWith(ITEM_PREFIX_ALBUM) -> {
val album = repo.getAlbum(parentId.removePrefix(ITEM_PREFIX_ALBUM)) val album = repo.getAlbum(parentId.removePrefix(ITEM_PREFIX_ALBUM))
album.song.map { it.toMediaItem(repo) } listOf(playAllItem(parentId), shuffleItem(parentId)) + album.song.map { it.toMediaItem(repo) }
}
parentId.startsWith(ITEM_PREFIX_ARTIST) -> {
val artist = repo.getArtist(parentId.removePrefix(ITEM_PREFIX_ARTIST))
artist.album.map { it.toMediaItem(repo) }
} }
parentId.startsWith(ITEM_PREFIX_PL) -> { parentId.startsWith(ITEM_PREFIX_PL) -> {
val pl = repo.getPlaylist(parentId.removePrefix(ITEM_PREFIX_PL)) val pl = repo.getPlaylist(parentId.removePrefix(ITEM_PREFIX_PL))
pl.entry.map { it.toMediaItem(repo) } listOf(playAllItem(parentId), shuffleItem(parentId)) + pl.entry.map { it.toMediaItem(repo) }
} }
else -> emptyList() else -> emptyList()
} }
@@ -237,6 +267,28 @@ class MusicService : MediaLibraryService() {
.build() .build()
).build() ).build()
private fun playAllItem(contextId: String) = MediaItem.Builder()
.setMediaId("$PLAY_ALL_PREFIX$contextId")
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle("▶ Alle abspielen")
.setIsPlayable(true)
.setIsBrowsable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
.build()
).build()
private fun shuffleItem(contextId: String) = MediaItem.Builder()
.setMediaId("$SHUFFLE_PREFIX$contextId")
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle("⇀ Zufällig abspielen")
.setIsPlayable(true)
.setIsBrowsable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
.build()
).build()
private fun isAndroidAuto(info: MediaSession.ControllerInfo): Boolean = private fun isAndroidAuto(info: MediaSession.ControllerInfo): Boolean =
info.packageName.contains("android.auto", ignoreCase = true) || info.packageName.contains("android.auto", ignoreCase = true) ||
info.packageName.contains("goog.android.projected", ignoreCase = true) info.packageName.contains("goog.android.projected", ignoreCase = true)
@@ -282,26 +334,18 @@ fun com.novasonic.app.api.model.Album.toMediaItem(repo: MusicRepository): MediaI
).build() ).build()
} }
fun com.novasonic.app.api.model.Artist.toMediaItem(): MediaItem =
MediaItem.Builder()
.setMediaId("${MusicService.ITEM_PREFIX_ARTIST}$id")
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(name)
.setIsPlayable(false)
.setIsBrowsable(true)
.setMediaType(MediaMetadata.MEDIA_TYPE_ARTIST)
.build()
).build()
fun com.novasonic.app.api.model.Playlist.toMediaItem(): MediaItem = fun com.novasonic.app.api.model.Playlist.toMediaItem(repo: MusicRepository): MediaItem {
MediaItem.Builder() val artUri = coverArt?.let { repo.coverArtUrl(it, 300)?.let { url -> android.net.Uri.parse(url) } }
return MediaItem.Builder()
.setMediaId("${MusicService.ITEM_PREFIX_PL}$id") .setMediaId("${MusicService.ITEM_PREFIX_PL}$id")
.setMediaMetadata( .setMediaMetadata(
MediaMetadata.Builder() MediaMetadata.Builder()
.setTitle(name) .setTitle(name)
.setArtworkUri(artUri)
.setIsPlayable(true) .setIsPlayable(true)
.setIsBrowsable(true) .setIsBrowsable(true)
.setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST) .setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
.build() .build()
).build() ).build()
}
@@ -4,6 +4,7 @@ import android.content.ComponentName
import android.content.Context import android.content.Context
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.media3.session.MediaController import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken import androidx.media3.session.SessionToken
import com.novasonic.app.api.model.Song import com.novasonic.app.api.model.Song
@@ -25,6 +26,8 @@ data class PlayerState(
val repeatMode: Int = Player.REPEAT_MODE_OFF, val repeatMode: Int = Player.REPEAT_MODE_OFF,
val shuffled: Boolean = false, val shuffled: Boolean = false,
val volume: Float = 1f, val volume: Float = 1f,
val artworkUri: String? = null,
val radioMode: Boolean = false,
) )
class PlayerController(private val context: Context, private val repo: MusicRepository) { class PlayerController(private val context: Context, private val repo: MusicRepository) {
@@ -132,6 +135,50 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
fun addToQueue(song: Song) = addToQueueEnd(song) fun addToQueue(song: Song) = addToQueueEnd(song)
// ── Radio mode ────────────────────────────────────────────────────────────
private var _radioMode = false
fun toggleRadio() {
_radioMode = !_radioMode
_state.update { it.copy(radioMode = _radioMode) }
if (_radioMode) scope.launch { fillRadioQueue() }
}
private fun checkRadioRefill() {
if (!_radioMode) return
val remaining = queue.size - (_state.value.currentIndex + 1)
if (remaining < 5) scope.launch { fillRadioQueue() }
}
private suspend fun fillRadioQueue() {
val currentSong = _state.value.currentSong ?: return
val recentIds = queue.takeLast(50).map { it.id }.toHashSet()
var candidates = runCatching {
repo.getSimilarSongs(currentSong.id, 20)
}.getOrDefault(emptyList()).filter { it.id !in recentIds }
if (candidates.isEmpty()) {
candidates = runCatching {
repo.getRandomSongs()
}.getOrDefault(emptyList()).filter { it.id !in recentIds }
}
// Small library fallback: allow repeats rather than stopping
if (candidates.isEmpty()) {
candidates = runCatching { repo.getRandomSongs() }.getOrDefault(emptyList())
}
val ctrl = controller ?: return
candidates.forEach { song ->
queue = queue + song
originalQueue = originalQueue + song
ctrl.addMediaItem(song.toMediaItem(repo))
}
syncState()
}
// ── Ticker ──────────────────────────────────────────────────────────────── // ── Ticker ────────────────────────────────────────────────────────────────
private fun startTicker() { private fun startTicker() {
@@ -163,6 +210,17 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
// ── Internal ────────────────────────────────────────────────────────────── // ── Internal ──────────────────────────────────────────────────────────────
private fun songFromMediaItem(item: MediaItem): Song {
val meta = item.mediaMetadata
return Song(
id = item.mediaId,
title = meta.title?.toString() ?: "",
artist = meta.artist?.toString(),
album = meta.albumTitle?.toString(),
track = meta.trackNumber,
)
}
private fun syncState() { private fun syncState() {
val ctrl = controller ?: return val ctrl = controller ?: return
val idx = ctrl.currentMediaItemIndex val idx = ctrl.currentMediaItemIndex
@@ -177,6 +235,8 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
repeatMode = ctrl.repeatMode, repeatMode = ctrl.repeatMode,
shuffled = isManuallyShuffled, shuffled = isManuallyShuffled,
volume = ctrl.volume, volume = ctrl.volume,
artworkUri = ctrl.currentMediaItem?.mediaMetadata?.artworkUri?.toString(),
radioMode = _radioMode,
) )
} }
@@ -190,6 +250,7 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
override fun onMediaItemTransition(item: MediaItem?, reason: Int) { override fun onMediaItemTransition(item: MediaItem?, reason: Int) {
syncState() syncState()
checkRadioRefill()
val ctrl = controller ?: return val ctrl = controller ?: return
val song = queue.getOrNull(ctrl.currentMediaItemIndex) ?: return val song = queue.getOrNull(ctrl.currentMediaItemIndex) ?: return
val dur = ctrl.duration.coerceAtLeast(0) val dur = ctrl.duration.coerceAtLeast(0)
@@ -198,5 +259,17 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
override fun onShuffleModeEnabledChanged(enabled: Boolean) = syncState() override fun onShuffleModeEnabledChanged(enabled: Boolean) = syncState()
override fun onRepeatModeChanged(mode: Int) = syncState() override fun onRepeatModeChanged(mode: Int) = syncState()
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
val ctrl = controller ?: return
val ctrlCount = ctrl.mediaItemCount
// Rebuild queue from controller when items were set externally (e.g. Android Auto)
if (reason == Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED && ctrlCount != queue.size) {
queue = (0 until ctrlCount).map { i -> songFromMediaItem(ctrl.getMediaItemAt(i)) }
originalQueue = queue
isManuallyShuffled = false
}
syncState()
}
} }
} }
@@ -1,6 +1,7 @@
package com.novasonic.app.ui package com.novasonic.app.ui
import androidx.activity.compose.BackHandler import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
@@ -31,6 +32,7 @@ import androidx.navigation.compose.*
import com.novasonic.app.api.model.Song import com.novasonic.app.api.model.Song
import com.novasonic.app.app import com.novasonic.app.app
import com.novasonic.app.ui.component.MiniPlayer import com.novasonic.app.ui.component.MiniPlayer
import com.novasonic.app.ui.component.NotificationSheet
import com.novasonic.app.ui.component.PlaylistPickerSheet import com.novasonic.app.ui.component.PlaylistPickerSheet
import com.novasonic.app.ui.component.SongContextSheet import com.novasonic.app.ui.component.SongContextSheet
import com.novasonic.app.ui.component.SongInfoSheet import com.novasonic.app.ui.component.SongInfoSheet
@@ -68,9 +70,24 @@ fun AppNavigation() {
val playerState by player.state.collectAsStateWithLifecycle() val playerState by player.state.collectAsStateWithLifecycle()
val navController = rememberNavController() val navController = rememberNavController()
var showNowPlaying by remember { mutableStateOf(false) } var showNowPlaying by remember { mutableStateOf(false) }
var contextMenuSong by remember { mutableStateOf<Song?>(null) }
var playlistPickerSong by remember { mutableStateOf<Song?>(null) } // Re-register a fresh callback each time NowPlaying opens so it is always
var infoSheetSong by remember { mutableStateOf<Song?>(null) } // the LAST entry in the dispatcher queue (LIFO = highest priority), beating
// the BackHandler that NavHost registers internally for its own back stack.
val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
DisposableEffect(showNowPlaying, backDispatcher) {
if (!showNowPlaying || backDispatcher == null) return@DisposableEffect onDispose {}
val cb = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() { showNowPlaying = false }
}
backDispatcher.addCallback(cb)
onDispose { cb.remove() }
}
var contextMenuSong by remember { mutableStateOf<Song?>(null) }
var playlistPickerSong by remember { mutableStateOf<Song?>(null) }
var infoSheetSong by remember { mutableStateOf<Song?>(null) }
var showNotifications by remember { mutableStateOf(false) }
fun coverUrl(coverId: String?) = repo.coverArtUrl(coverId, 600) fun coverUrl(coverId: String?) = repo.coverArtUrl(coverId, 600)
fun heroCoverUrl(coverId: String?) = repo.coverArtUrl(coverId, 1000) fun heroCoverUrl(coverId: String?) = repo.coverArtUrl(coverId, 1000)
@@ -81,6 +98,8 @@ fun AppNavigation() {
fun showContextMenu(song: Song) { contextMenuSong = song } fun showContextMenu(song: Song) { contextMenuSong = song }
var homeRefreshKey by remember { mutableIntStateOf(0) }
val currentBackEntry by navController.currentBackStackEntryAsState() val currentBackEntry by navController.currentBackStackEntryAsState()
val currentRoute = currentBackEntry?.destination?.route val currentRoute = currentBackEntry?.destination?.route
val isTopLevel = TopLevel.entries.any { it.route == currentRoute } val isTopLevel = TopLevel.entries.any { it.route == currentRoute }
@@ -104,7 +123,7 @@ fun AppNavigation() {
) { ) {
MiniPlayer( MiniPlayer(
state = playerState, state = playerState,
coverUrl = coverUrl(playerState.currentSong?.coverArt), coverUrl = coverUrl(playerState.currentSong?.coverArt) ?: playerState.artworkUri,
onTap = { showNowPlaying = true }, onTap = { showNowPlaying = true },
onTogglePlay = { player.togglePlay() }, onTogglePlay = { player.togglePlay() },
onSkipPrev = { player.skipPrev() }, onSkipPrev = { player.skipPrev() },
@@ -116,9 +135,13 @@ fun AppNavigation() {
NavigationBarItem( NavigationBarItem(
selected = currentRoute == dest.route, selected = currentRoute == dest.route,
onClick = { onClick = {
navController.navigate(dest.route) { if (dest == TopLevel.HOME && currentRoute == TopLevel.HOME.route) {
popUpTo(navController.graph.startDestinationId) { saveState = true } homeRefreshKey++
launchSingleTop = true; restoreState = true } else {
navController.navigate(dest.route) {
popUpTo(navController.graph.startDestinationId) { saveState = true }
launchSingleTop = true; restoreState = true
}
} }
}, },
icon = { Icon(dest.icon, contentDescription = dest.label) }, icon = { Icon(dest.icon, contentDescription = dest.label) },
@@ -163,21 +186,24 @@ fun AppNavigation() {
) { ) {
composable(TopLevel.HOME.route) { composable(TopLevel.HOME.route) {
HomeScreen( HomeScreen(
coverUrl = { album -> coverUrl(album.coverArt) }, coverUrl = { album -> coverUrl(album.coverArt) },
onAlbumClick = { album -> navController.navigate("album/${album.id}") }, onBellClick = { showNotifications = true },
onAlbumPlay = { album -> onAlbumClick = { album -> navController.navigate("album/${album.id}") },
onAlbumPlay = { album ->
scope.launch { runCatching { scope.launch { runCatching {
val full = repo.getAlbum(album.id) val full = repo.getAlbum(album.id)
if (full.song.isNotEmpty()) player.playSongs(full.song, 0) if (full.song.isNotEmpty()) player.playSongs(full.song, 0)
}} }}
}, },
onGenreClick = { genre -> navController.navigate("genre/${encodeRoute(genre.value)}") }, onGenreClick = { genre -> navController.navigate("genre/${encodeRoute(genre.value)}") },
onGenrePlay = { genre -> onGenrePlay = { genre ->
scope.launch { runCatching { scope.launch { runCatching {
val songs = repo.getSongsByGenre(genre.value) val songs = repo.getSongsByGenre(genre.value)
if (songs.isNotEmpty()) player.playSongs(songs.shuffled(), 0) if (songs.isNotEmpty()) player.playSongs(songs.shuffled(), 0)
}} }}
}, },
onShowAllGenres = { navController.navigate("genres") },
refreshKey = homeRefreshKey,
) )
} }
composable(TopLevel.LIBRARY.route) { composable(TopLevel.LIBRARY.route) {
@@ -237,6 +263,7 @@ fun AppNavigation() {
onAlbumClick = { album -> navController.navigate("album/${album.id}") }, onAlbumClick = { album -> navController.navigate("album/${album.id}") },
onStarToggle = { song -> starToggle(song.id, song.starred != null) }, onStarToggle = { song -> starToggle(song.id, song.starred != null) },
onShowContextMenu = ::showContextMenu, onShowContextMenu = ::showContextMenu,
onArtistClick = { artistId -> navController.navigate("artist/$artistId") },
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
) )
} }
@@ -268,12 +295,21 @@ fun AppNavigation() {
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
) )
} }
composable("genres") {
GenreListScreen(
onGenreClick = { genre -> navController.navigate("genre/${encodeRoute(genre.value)}") },
onGenrePlay = { genre ->
scope.launch { runCatching {
val songs = repo.getSongsByGenre(genre.value)
if (songs.isNotEmpty()) player.playSongs(songs.shuffled(), 0)
}}
},
onBack = { navController.popBackStack() },
)
}
} }
} }
// Dismiss NowPlaying with hardware back button
BackHandler(enabled = showNowPlaying) { showNowPlaying = false }
// NowPlaying overlay — outside Scaffold so WindowInsets are not double-consumed // NowPlaying overlay — outside Scaffold so WindowInsets are not double-consumed
AnimatedVisibility( AnimatedVisibility(
visible = showNowPlaying, visible = showNowPlaying,
@@ -282,7 +318,7 @@ fun AppNavigation() {
) { ) {
NowPlayingScreen( NowPlayingScreen(
state = playerState, state = playerState,
coverUrl = heroCoverUrl(playerState.currentSong?.coverArt), coverUrl = heroCoverUrl(playerState.currentSong?.coverArt) ?: playerState.artworkUri,
queueCoverUrl = { id -> coverUrl(id) }, queueCoverUrl = { id -> coverUrl(id) },
controller = player, controller = player,
onDismiss = { showNowPlaying = false }, onDismiss = { showNowPlaying = false },
@@ -292,12 +328,11 @@ fun AppNavigation() {
onShowOptions = { onShowOptions = {
playerState.currentSong?.let { s -> contextMenuSong = s } playerState.currentSong?.let { s -> contextMenuSong = s }
}, },
onArtistClick = playerState.currentSong?.artistId?.let { artistId -> onArtistClick = { artistId ->
{ showNowPlaying = false
showNowPlaying = false if (artistId.isNotBlank()) navController.navigate("artist/$artistId")
navController.navigate("artist/$artistId")
}
}, },
onToggleRadio = { player.toggleRadio() },
) )
} }
@@ -358,6 +393,11 @@ fun AppNavigation() {
infoSheetSong?.let { song -> infoSheetSong?.let { song ->
SongInfoSheet(song = song, onDismiss = { infoSheetSong = null }) SongInfoSheet(song = song, onDismiss = { infoSheetSong = null })
} }
// Notification sheet
if (showNotifications) {
NotificationSheet(onDismiss = { showNotifications = false })
}
} }
} }
@@ -0,0 +1,169 @@
package com.novasonic.app.ui.component
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.novasonic.app.api.model.ConcertEvent
import com.novasonic.app.ui.theme.*
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConcertsSheet(
artistName: String,
events: List<ConcertEvent>,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
ModalBottomSheet(
onDismissRequest = onDismiss,
containerColor = NsSurface,
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
) {
Text(
"Konzerte · $artistName",
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 16.sp,
color = NsOnSurface,
modifier = Modifier.padding(start = 20.dp, end = 20.dp, bottom = 16.dp),
)
if (events.isEmpty()) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 40.dp),
) {
Text(
"Keine Konzerte angekündigt",
fontFamily = Outfit, fontSize = 14.sp,
color = NsOnSurface2,
)
}
} else {
LazyColumn(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(events) { event ->
EventRow(event = event, onTicketClick = { url ->
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
}
})
}
}
}
}
}
@Composable
private fun EventRow(event: ConcertEvent, onTicketClick: (String) -> Unit) {
val (dateStr, timeStr) = formatEventDateTime(event.datetime)
val venue = event.venue?.name.orEmpty()
val city = listOfNotNull(event.venue?.city, event.venue?.country).joinToString(", ")
val offer = event.offers.firstOrNull { !it.url.isNullOrBlank() }
val soldOut = offer?.status == "unavailable"
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(NsSurface2)
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
// Date block
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.width(52.dp),
) {
Text(
dateStr,
fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 13.sp, color = NsAccent,
)
if (timeStr.isNotBlank()) {
Text(
timeStr,
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2,
)
}
}
Spacer(Modifier.width(14.dp))
Box(Modifier.width(1.dp).height(36.dp).background(NsBorder))
Spacer(Modifier.width(14.dp))
// Venue
Column(Modifier.weight(1f)) {
if (venue.isNotBlank()) {
Text(
venue, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 13.sp, color = NsOnSurface,
maxLines = 1,
)
}
if (city.isNotBlank()) {
Text(
city, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
maxLines = 1,
)
}
}
// Ticket button
if (offer?.url != null) {
Spacer(Modifier.width(10.dp))
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.then(
if (soldOut) Modifier.border(1.dp, NsOnSurface2.copy(alpha = 0.3f), RoundedCornerShape(8.dp))
else Modifier.background(NsAccent)
)
.clickable(enabled = !soldOut) { onTicketClick(offer.url) }
.padding(horizontal = 10.dp, vertical = 6.dp),
) {
Text(
if (soldOut) "Ausverkauft" else "Tickets →",
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 11.sp,
color = if (soldOut) NsOnSurface2 else Color.White,
)
}
}
}
}
private fun formatEventDateTime(datetime: String): Pair<String, String> {
return try {
val zdt = ZonedDateTime.parse(datetime)
val date = zdt.format(DateTimeFormatter.ofPattern("dd. MMM yyyy", Locale.GERMAN))
val time = if (zdt.hour == 0 && zdt.minute == 0) ""
else zdt.format(DateTimeFormatter.ofPattern("HH:mm"))
date to time
} catch (_: Exception) {
datetime.take(10) to ""
}
}
@@ -0,0 +1,154 @@
package com.novasonic.app.ui.component
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.novasonic.app.data.AppNotification
import com.novasonic.app.data.NotificationStore
import com.novasonic.app.ui.theme.*
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NotificationSheet(onDismiss: () -> Unit) {
val items by NotificationStore.items.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { NotificationStore.markAllRead() }
ModalBottomSheet(
onDismissRequest = onDismiss,
containerColor = NsSurface,
dragHandle = null,
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 8.dp, top = 18.dp, bottom = 12.dp),
) {
Icon(Icons.Default.Notifications, null,
tint = NsAccent, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(
"Benachrichtigungen",
fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 16.sp, color = NsOnSurface,
modifier = Modifier.weight(1f),
)
if (items.isNotEmpty()) {
TextButton(onClick = { NotificationStore.clear() }) {
Text("Alle löschen", color = NsOnSurface2, fontFamily = Outfit, fontSize = 12.sp)
}
}
}
HorizontalDivider(color = NsBorder)
if (items.isEmpty()) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth().height(140.dp),
) {
Text("Keine Benachrichtigungen", color = NsOnSurface2, fontFamily = Outfit)
}
} else {
LazyColumn(
contentPadding = PaddingValues(top = 4.dp, bottom = 24.dp),
modifier = Modifier.navigationBarsPadding(),
) {
items(items, key = { it.id }) { n ->
NotifCard(n, onDelete = { NotificationStore.remove(n.id) })
}
}
}
}
}
@Composable
private fun NotifCard(n: AppNotification, onDelete: () -> Unit) {
Row(
verticalAlignment = Alignment.Top,
modifier = Modifier
.fillMaxWidth()
.background(if (!n.read) NsAccent.copy(alpha = 0.05f) else Color.Transparent)
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(36.dp)
.background(
color = if (n.status == "done") Color(0xFF22C55E).copy(0.15f)
else if (n.status == "error") Color(0xFFEF4444).copy(0.15f)
else NsAccent.copy(0.15f),
shape = RoundedCornerShape(8.dp),
),
) {
Text(
text = when (n.status) {
"done" -> "✓"
"error" -> "✕"
else -> "…"
},
fontSize = 14.sp,
color = when (n.status) {
"done" -> Color(0xFF22C55E)
"error" -> Color(0xFFEF4444)
else -> NsAccent
},
fontWeight = FontWeight.Bold,
)
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
n.title,
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp, color = NsOnSurface,
)
if (n.artist.isNotBlank()) {
Text(n.artist, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
}
if (n.message.isNotBlank() && n.status != "pending") {
Text(
n.message,
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
modifier = Modifier.padding(top = 2.dp),
)
}
Text(
formatTs(n.timestamp),
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2.copy(alpha = 0.55f),
modifier = Modifier.padding(top = 4.dp),
)
}
if (n.status != "pending") {
IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.Close, null, tint = NsOnSurface2, modifier = Modifier.size(16.dp))
}
}
}
HorizontalDivider(
color = NsBorder.copy(alpha = 0.4f),
modifier = Modifier.padding(horizontal = 16.dp),
)
}
private val _fmt = SimpleDateFormat("dd.MM. HH:mm", Locale.GERMAN)
private fun formatTs(ts: Long): String = _fmt.format(Date(ts))
@@ -14,6 +14,7 @@ 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.Event
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.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
@@ -36,6 +37,7 @@ import coil.compose.AsyncImage
import com.novasonic.app.api.model.Song import com.novasonic.app.api.model.Song
import com.novasonic.app.player.PlayerState import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.component.AlbumCard import com.novasonic.app.ui.component.AlbumCard
import com.novasonic.app.ui.component.ConcertsSheet
import com.novasonic.app.ui.component.ItunesDownloadSheet import com.novasonic.app.ui.component.ItunesDownloadSheet
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.*
@@ -49,6 +51,7 @@ fun ArtistDetailScreen(
onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit, onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit,
onStarToggle: (Song) -> Unit, onStarToggle: (Song) -> Unit,
onShowContextMenu: ((Song) -> Unit)? = null, onShowContextMenu: ((Song) -> Unit)? = null,
onArtistClick: ((String) -> Unit)? = null,
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
val vm: ArtistViewModel = viewModel() val vm: ArtistViewModel = viewModel()
@@ -58,6 +61,25 @@ fun ArtistDetailScreen(
BackHandler(enabled = state.partialAlbum != null) { vm.dismissPartialAlbum() } BackHandler(enabled = state.partialAlbum != null) { vm.dismissPartialAlbum() }
BackHandler(enabled = state.subAlbumItem != null && state.partialAlbum == null) { vm.dismissSubAlbum() } BackHandler(enabled = state.subAlbumItem != null && state.partialAlbum == null) { vm.dismissSubAlbum() }
LaunchedEffect(state.pendingShufflePlay) {
state.pendingShufflePlay?.let { songs ->
onPlay(songs, 0)
vm.consumeShufflePlay()
}
}
// Concerts sheet
if (state.showEventsSheet) {
val artist = state.artist
if (artist != null) {
ConcertsSheet(
artistName = artist.name,
events = state.events,
onDismiss = vm::dismissEventsSheet,
)
}
}
// Download sheet // Download sheet
state.downloadSheetItem?.let { item -> state.downloadSheetItem?.let { item ->
ItunesDownloadSheet( ItunesDownloadSheet(
@@ -183,17 +205,29 @@ fun ArtistDetailScreen(
// Action bar // Action bar
item { item {
Row( LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier modifier = Modifier.padding(bottom = 8.dp),
.padding(horizontal = 16.dp)
.padding(bottom = 8.dp),
) { ) {
ArtistActionPill(Icons.Default.PlayArrow, "Top Songs") { item {
if (state.topSongs.isNotEmpty()) onPlay(state.topSongs, 0) ArtistActionPill(Icons.Default.PlayArrow, "Top Songs") {
if (state.topSongs.isNotEmpty()) onPlay(state.topSongs, 0)
}
} }
ArtistActionPill(Icons.Default.Shuffle, "Shuffle") { item {
if (state.topSongs.isNotEmpty()) onPlay(state.topSongs.shuffled(), 0) ArtistActionPill(
icon = Icons.Default.Shuffle,
label = if (state.isShuffling) "Laden…" else "Shuffle",
onClick = { vm.shuffleAll() },
)
}
item {
val eventCount = state.events.size
val konzertLabel = if (eventCount > 0) "Konzerte ($eventCount)" else "Konzerte"
ArtistActionPill(Icons.Default.Event, konzertLabel) {
vm.openEventsSheet()
}
} }
} }
} }
@@ -211,22 +245,30 @@ fun ArtistDetailScreen(
modifier = Modifier.padding(bottom = 20.dp), modifier = Modifier.padding(bottom = 20.dp),
) { ) {
items(similarArtists) { sim -> items(similarArtists) { sim ->
val navigable = onArtistClick != null && sim.id.isNotBlank()
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.width(70.dp), modifier = Modifier
.width(70.dp)
.then(
if (navigable) Modifier.clickable { onArtistClick!!(sim.id) }
else Modifier
),
) { ) {
AsyncImage( AsyncImage(
model = coverUrl(sim.coverArt), model = coverUrl(sim.coverArt),
contentDescription = sim.name, contentDescription = sim.name,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier.size(60.dp).clip(CircleShape) modifier = Modifier
.size(60.dp)
.clip(CircleShape)
.background(NsSurface2), .background(NsSurface2),
) )
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
Text( Text(
sim.name, fontFamily = Outfit, fontSize = 11.sp, sim.name, fontFamily = Outfit, fontSize = 11.sp,
color = NsOnSurface2, maxLines = 2, color = if (navigable) NsOnSurface else NsOnSurface2,
overflow = TextOverflow.Ellipsis, maxLines = 2, overflow = TextOverflow.Ellipsis,
textAlign = androidx.compose.ui.text.style.TextAlign.Center, textAlign = androidx.compose.ui.text.style.TextAlign.Center,
) )
} }
@@ -1,5 +1,6 @@
package com.novasonic.app.ui.screen package com.novasonic.app.ui.screen
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@@ -55,6 +56,11 @@ fun ExploreScreen(
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val context = LocalContext.current val context = LocalContext.current
// BackHandlers for state-based navigation (must be unconditional)
BackHandler(enabled = state.artistDetail != null && state.partialAlbum != null) { vm.dismissPartialAlbum() }
BackHandler(enabled = state.artistDetail != null && state.albumTracks != null && state.partialAlbum == null) { vm.dismissAlbum() }
BackHandler(enabled = state.artistDetail != null && state.albumTracks == null && state.partialAlbum == null) { vm.closeArtistDetail() }
// Download sheet // Download sheet
state.downloadSheetItem?.let { item -> state.downloadSheetItem?.let { item ->
ItunesDownloadSheet( ItunesDownloadSheet(
@@ -274,12 +280,13 @@ private fun LibrarySearchContent(
item { item {
LazyRow( LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp), contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
items(artists, key = { it.id }) { a -> items(artists, key = { it.id }) { a ->
SmallArtistChip(a.name) { onArtistClick(a) } ArtistCard(name = a.name, imageUrl = coverUrl(a.coverArt)) { onArtistClick(a) }
} }
} }
Spacer(Modifier.height(8.dp))
} }
} }
if (albums.isNotEmpty()) { if (albums.isNotEmpty()) {
@@ -402,12 +409,16 @@ private fun GlobalExploreContent(
item { item {
LazyRow( LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp), contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
items(state.artists) { a -> items(state.artists) { a ->
SmallArtistChip(a.artistName ?: "") { vm.openArtistDetail(a) } ArtistCard(
name = a.artistName ?: "",
imageUrl = a.artworkUrl100?.replace("100x100bb", "300x300bb"),
) { vm.openArtistDetail(a) }
} }
} }
Spacer(Modifier.height(8.dp))
} }
} }
if (state.albums.isNotEmpty()) { if (state.albums.isNotEmpty()) {
@@ -579,17 +590,28 @@ private fun ItunesTrackRow(
} }
@Composable @Composable
private fun SmallArtistChip(name: String, onClick: () -> Unit) { private fun ArtistCard(name: String, imageUrl: String?, onClick: () -> Unit) {
Box( Column(
contentAlignment = Alignment.Center, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(20.dp)) .width(80.dp)
.background(NsSurface2) .clickable(onClick = onClick),
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 8.dp),
) { ) {
Text(name, fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface, AsyncImage(
maxLines = 1, overflow = TextOverflow.Ellipsis) model = imageUrl,
contentDescription = name,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(72.dp)
.clip(androidx.compose.foundation.shape.CircleShape)
.background(NsSurface2),
)
Spacer(Modifier.height(6.dp))
Text(
name, fontFamily = Outfit, fontSize = 12.sp, fontWeight = FontWeight.Medium,
color = NsOnSurface, maxLines = 2, overflow = TextOverflow.Ellipsis,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
} }
} }
@@ -0,0 +1,96 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.novasonic.app.api.model.Genre
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.GenreViewModel
@Composable
fun GenreListScreen(
onGenreClick: (Genre) -> Unit,
onGenrePlay: (Genre) -> Unit,
onBack: () -> Unit,
) {
val vm: GenreViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
Column(Modifier.fillMaxSize()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 4.dp, end = 16.dp, top = 8.dp, bottom = 4.dp),
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück", tint = NsOnSurface2)
}
Text(
"Alle Genres",
fontFamily = Outfit,
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
color = NsOnSurface,
)
}
when {
state.isLoading -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
}
state.error != null -> {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxSize().padding(32.dp),
) {
Text(state.error!!, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(12.dp))
Button(onClick = { vm.load() },
colors = ButtonDefaults.buttonColors(containerColor = NsAccent)) {
Text("Retry")
}
}
}
else -> {
LazyColumn(
contentPadding = PaddingValues(bottom = 24.dp),
modifier = Modifier.fillMaxSize(),
) {
val chunked = state.genres.chunked(2)
chunked.forEachIndexed { rowIdx, rowGenres ->
item(key = "row_$rowIdx") {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
) {
rowGenres.forEachIndexed { colIdx, genre ->
val colorIdx = (rowIdx * 2 + colIdx) % genreColors.size
GenreChip(
genre = genre,
bgColor = genreColors[colorIdx],
onClick = { onGenreClick(genre) },
onPlay = { onGenrePlay(genre) },
modifier = Modifier.weight(1f),
)
}
if (rowGenres.size == 1) Spacer(Modifier.weight(1f))
}
}
}
}
}
}
}
}
@@ -8,6 +8,7 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
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.filled.Notifications
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
@@ -27,12 +28,40 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.novasonic.app.api.model.Album import com.novasonic.app.api.model.Album
import com.novasonic.app.api.model.Genre import com.novasonic.app.api.model.Genre
import com.novasonic.app.data.NotificationStore
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.HomeViewModel import com.novasonic.app.viewmodel.HomeViewModel
import java.util.Calendar
private val genreColors = listOf( private val greetingsWithName = listOf(
"Ready for some beats,",
"What's on the playlist,",
"Good vibes only,",
"Let the music play,",
"Your soundtrack awaits,",
"Turn it up,",
"Time to tune in,",
"Hit play and chill,",
"Music is the answer,",
"Welcome back,",
)
private val greetingsNoName = listOf(
"Ready for some beats?",
"What's on the playlist today?",
"Good vibes only",
"Let the music play",
"Your soundtrack awaits",
"Turn it up",
"Time to tune in",
"Hit play and chill",
"Music is the answer",
"What are we listening to?",
"Time to escape into music",
"Pick something great",
)
internal val genreColors = listOf(
Color(0xFF1a1a3e), Color(0xFF1e2a1a), Color(0xFF2a1a1a), Color(0xFF1a1a3e), Color(0xFF1e2a1a), Color(0xFF2a1a1a),
Color(0xFF1a2a2a), Color(0xFF2a1e1a), Color(0xFF1e1a2a), Color(0xFF1a2a2a), Color(0xFF2a1e1a), Color(0xFF1e1a2a),
Color(0xFF2a2a1a), Color(0xFF1a2a1e), Color(0xFF201520), Color(0xFF2a2a1a), Color(0xFF1a2a1e), Color(0xFF201520),
@@ -46,18 +75,19 @@ fun HomeScreen(
onAlbumPlay: (Album) -> Unit, onAlbumPlay: (Album) -> Unit,
onGenreClick: (Genre) -> Unit, onGenreClick: (Genre) -> Unit,
onGenrePlay: ((Genre) -> Unit)? = null, onGenrePlay: ((Genre) -> Unit)? = null,
onShowAllGenres: (() -> Unit)? = null,
onBellClick: () -> Unit = {},
refreshKey: Int = 0,
) { ) {
val vm: HomeViewModel = viewModel() val vm: HomeViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle() val state by vm.state.collectAsStateWithLifecycle()
val notifs by NotificationStore.items.collectAsStateWithLifecycle()
val unreadCount = notifs.count { !it.read }
val greeting = remember { LaunchedEffect(refreshKey) { if (refreshKey > 0) vm.load() }
val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY)
when { val greeting = remember { greetingsNoName.random() }
hour < 12 -> "Guten Morgen" val greetingWithName = remember { greetingsWithName.random() }
hour < 18 -> "Guten Tag"
else -> "Guten Abend"
}
}
LazyColumn( LazyColumn(
contentPadding = PaddingValues(bottom = 24.dp), contentPadding = PaddingValues(bottom = 24.dp),
@@ -65,14 +95,34 @@ fun HomeScreen(
) { ) {
item { item {
val name = state.username.replaceFirstChar { it.uppercaseChar() } val name = state.username.replaceFirstChar { it.uppercaseChar() }
Text( Row(
text = if (name.isBlank()) greeting else "$greeting, $name 👋", verticalAlignment = Alignment.CenterVertically,
fontFamily = Outfit, modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 24.dp, bottom = 20.dp),
fontSize = 24.sp, ) {
fontWeight = FontWeight.Bold, Text(
color = NsOnSurface, text = if (name.isBlank()) greeting else "$greetingWithName $name",
modifier = Modifier.padding(start = 16.dp, top = 24.dp, bottom = 20.dp), fontFamily = Outfit,
) fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = NsOnSurface,
modifier = Modifier.weight(1f),
)
BadgedBox(
badge = {
if (unreadCount > 0) Badge(containerColor = NsAccent) {
Text(
if (unreadCount > 9) "9+" else "$unreadCount",
fontSize = 9.sp, color = NsBackground,
)
}
}
) {
IconButton(onClick = onBellClick) {
Icon(Icons.Default.Notifications, "Benachrichtigungen",
tint = if (unreadCount > 0) NsAccent else NsOnSurface2)
}
}
}
} }
if (state.isLoading) { if (state.isLoading) {
@@ -118,8 +168,28 @@ fun HomeScreen(
if (state.genres.isNotEmpty()) { if (state.genres.isNotEmpty()) {
item { SectionHeader("Genres") } item { SectionHeader("Genres") }
item { item {
GenreGrid(genres = state.genres, onGenreClick = onGenreClick, onGenrePlay = onGenrePlay) GenreGrid(genres = state.genres.take(6), onGenreClick = onGenreClick, onGenrePlay = onGenrePlay)
Spacer(Modifier.height(28.dp)) if (onShowAllGenres != null && state.genres.size > 6) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp)
.clip(RoundedCornerShape(10.dp))
.background(NsSurface)
.clickable(onClick = onShowAllGenres)
.padding(vertical = 12.dp),
) {
Text(
"Alle anzeigen · ${state.genres.size}",
fontFamily = Outfit,
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp,
color = NsAccent,
)
}
}
Spacer(Modifier.height(20.dp))
} }
} }
@@ -238,7 +308,7 @@ private fun HeroCard(album: Album, coverUrl: String?, onPlay: () -> Unit, onClic
} }
@Composable @Composable
private fun GenreGrid( internal fun GenreGrid(
genres: List<Genre>, genres: List<Genre>,
onGenreClick: (Genre) -> Unit, onGenreClick: (Genre) -> Unit,
onGenrePlay: ((Genre) -> Unit)?, onGenrePlay: ((Genre) -> Unit)?,
@@ -267,7 +337,7 @@ private fun GenreGrid(
} }
@Composable @Composable
private fun GenreChip( internal fun GenreChip(
genre: Genre, genre: Genre,
bgColor: Color, bgColor: Color,
onClick: () -> Unit, onClick: () -> Unit,
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.CircleShape 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
@@ -33,6 +34,9 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import coil.request.ImageRequest import coil.request.ImageRequest
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -59,7 +63,8 @@ fun NowPlayingScreen(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onStarToggle: (() -> Unit)? = null, onStarToggle: (() -> Unit)? = null,
onShowOptions: (() -> Unit)? = null, onShowOptions: (() -> Unit)? = null,
onArtistClick: (() -> Unit)? = null, onArtistClick: ((artistId: String) -> Unit)? = null,
onToggleRadio: (() -> Unit)? = null,
) { ) {
val song = state.currentSong val song = state.currentSong
var tab by remember { mutableStateOf(NpTab.PLAYER) } var tab by remember { mutableStateOf(NpTab.PLAYER) }
@@ -135,7 +140,7 @@ fun NowPlayingScreen(
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.background(if (sel) Color.White.copy(0.18f) else Color.Transparent) .background(if (sel) Color.White.copy(0.18f) else Color.Transparent)
.clickable { tab = t } .clickable { tab = t }
.padding(horizontal = 14.dp, vertical = 6.dp), .padding(horizontal = 18.dp, vertical = 9.dp),
) { ) {
Text( Text(
when (t) { when (t) {
@@ -143,7 +148,7 @@ fun NowPlayingScreen(
NpTab.LYRICS -> "Lyrics" NpTab.LYRICS -> "Lyrics"
NpTab.QUEUE -> "Queue" NpTab.QUEUE -> "Queue"
}, },
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 11.sp, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp,
color = if (sel) Color.White else Color.White.copy(alpha = 0.4f), color = if (sel) Color.White else Color.White.copy(alpha = 0.4f),
) )
} }
@@ -164,6 +169,8 @@ fun NowPlayingScreen(
if (tab == NpTab.PLAYER) { if (tab == NpTab.PLAYER) {
var dragX by remember { mutableStateOf(0f) } var dragX by remember { mutableStateOf(0f) }
Spacer(Modifier.height(28.dp))
// Cover art — full content width, scale-animates like Apple Music // Cover art — full content width, scale-animates like Apple Music
AsyncImage( AsyncImage(
model = coverUrl, model = coverUrl,
@@ -206,16 +213,57 @@ fun NowPlayingScreen(
maxLines = 1, overflow = TextOverflow.Ellipsis, maxLines = 1, overflow = TextOverflow.Ellipsis,
) )
Spacer(Modifier.height(3.dp)) Spacer(Modifier.height(3.dp))
if (!song?.artist.isNullOrBlank()) { if (song != null && !song.artist.isNullOrBlank()) {
Text( // Prefer OpenSubsonic artists array (has IDs per contributor).
song!!.artist!!, // Fall back to splitting artist string by ';' (same as web app).
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, val raw: List<com.novasonic.app.api.model.SongArtist> =
fontSize = 15.sp, color = NsAccent, if (song.artists.size > 1) song.artists
maxLines = 1, overflow = TextOverflow.Ellipsis, else song.artist!!.split(";").map { part ->
modifier = if (onArtistClick != null) val n = part.trim()
Modifier.clickable(onClick = onArtistClick) com.novasonic.app.api.model.SongArtist(
else Modifier, id = if (n.equals(song.artist?.trim(), ignoreCase = true)) song.artistId ?: "" else "",
) name = n,
)
}.filter { it.name.isNotBlank() }
// De-duplicate: if multiple entries share the same ID (Navidrome
// sometimes copies the primary artistId to all entries), only the
// first occurrence keeps it — others become non-navigable.
val seenIds = mutableSetOf<String>()
val artistList = raw.map { a ->
if (a.id.isNotBlank() && seenIds.add(a.id)) a
else a.copy(id = "")
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.horizontalScroll(rememberScrollState()),
) {
artistList.forEachIndexed { idx, a ->
if (idx > 0) {
Text(
" · ",
fontFamily = Outfit,
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.35f),
)
}
val navigable = onArtistClick != null && a.id.isNotBlank()
Text(
text = a.name,
fontFamily = Outfit,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
maxLines = 1,
color = if (navigable) NsAccent else NsAccent.copy(alpha = 0.55f),
modifier = if (navigable)
Modifier.pointerInput(a.id) {
detectTapGestures { onArtistClick!!(a.id) }
}
else Modifier,
)
}
}
} }
if (!song?.album.isNullOrBlank()) { if (!song?.album.isNullOrBlank()) {
Text( Text(
@@ -315,25 +363,30 @@ fun NowPlayingScreen(
if (tab == NpTab.LYRICS && song != null) { if (tab == NpTab.LYRICS && song != null) {
val lyricsVm: LyricsViewModel = viewModel() val lyricsVm: LyricsViewModel = viewModel()
val lyricsState by lyricsVm.state.collectAsStateWithLifecycle() val lyricsState by lyricsVm.state.collectAsStateWithLifecycle()
val listState = rememberLazyListState() val lyricsScrollState = rememberScrollState()
val scope = rememberCoroutineScope() val density = LocalDensity.current
// Pre-compute pixel constants for absolute scroll target calculation
val lineHeightPx = remember(density) {
with(density) { 28.sp.toPx() + 12.dp.toPx() } // lineHeight + 2×vertical padding
}
val lyricsPaddingPx = remember(density) { with(density) { 200.dp.toPx() } }
var lyricsViewH by remember { mutableIntStateOf(0) }
LaunchedEffect(song.id) { lyricsVm.loadForSong(song) } LaunchedEffect(song.id) { lyricsVm.loadForSong(song) }
// 200ms lookahead — same as web app for better sync feel
val activeIdx = if (lyricsState.lines.isNotEmpty()) val activeIdx = if (lyricsState.lines.isNotEmpty())
lyricsState.lines.indexOfLast { it.timeMs <= state.positionMs }.coerceAtLeast(0) lyricsState.lines.indexOfLast { it.timeMs <= state.positionMs + 200L }.coerceAtLeast(0)
else -1 else -1
// Smooth scroll to active line // Absolute scroll: always lands on the exact pixel, no accumulation lag
LaunchedEffect(activeIdx) { LaunchedEffect(activeIdx) {
if (activeIdx >= 0 && lyricsState.lines.isNotEmpty()) { if (activeIdx < 0 || lyricsState.lines.isEmpty() || lyricsViewH == 0) return@LaunchedEffect
scope.launch { // Center of active line sits at 30% from top (clearly upper area)
listState.animateScrollToItem( val targetY = (lyricsPaddingPx + activeIdx * lineHeightPx + lineHeightPx / 2f
(activeIdx - 2).coerceAtLeast(0), - lyricsViewH * 0.30f).coerceAtLeast(0f)
scrollOffset = 0, lyricsScrollState.animateScrollTo(targetY.toInt(), tween(280, easing = FastOutSlowInEasing))
)
}
}
} }
when { when {
@@ -356,45 +409,26 @@ fun NowPlayingScreen(
color = Color.White.copy(0.4f), textAlign = TextAlign.Center) color = Color.White.copy(0.4f), textAlign = TextAlign.Center)
} }
lyricsState.lines.isNotEmpty() -> LazyColumn( lyricsState.lines.isNotEmpty() -> Column(
state = listState, modifier = Modifier
modifier = Modifier.weight(1f).fillMaxWidth(), .weight(1f)
contentPadding = PaddingValues(vertical = 40.dp), .fillMaxWidth()
verticalArrangement = Arrangement.spacedBy(6.dp), .onSizeChanged { lyricsViewH = it.height }
.verticalScroll(lyricsScrollState),
horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
itemsIndexed(lyricsState.lines) { idx, line -> Spacer(Modifier.height(200.dp))
val isActive = idx == activeIdx lyricsState.lines.forEachIndexed { idx, line ->
val isPast = idx < activeIdx key(idx) {
LyricsLineItem(
val targetColor = when { text = line.text,
isActive -> Color.White isActive = idx == activeIdx,
isPast -> Color.White.copy(alpha = 0.32f) isPast = idx < activeIdx,
else -> Color.White.copy(alpha = 0.55f) onSeek = { controller.seekTo(line.timeMs) },
)
} }
val animColor by animateColorAsState(
targetValue = targetColor,
animationSpec = tween(350),
label = "lyricColor$idx",
)
val animSize by animateFloatAsState(
targetValue = if (isActive) 20f else 16f,
animationSpec = tween(300),
label = "lyricSize$idx",
)
Text(
text = line.text,
fontFamily = Outfit,
fontWeight = if (isActive) FontWeight.ExtraBold else FontWeight.Normal,
fontSize = animSize.sp,
color = animColor,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.clickable { controller.seekTo(line.timeMs) }
.padding(horizontal = 12.dp, vertical = 5.dp),
)
} }
Spacer(Modifier.height(200.dp))
} }
lyricsState.plainText.isNotBlank() -> LazyColumn( lyricsState.plainText.isNotBlank() -> LazyColumn(
@@ -416,6 +450,53 @@ fun NowPlayingScreen(
// ── QUEUE TAB ────────────────────────────────────────────────── // ── QUEUE TAB ──────────────────────────────────────────────────
if (tab == NpTab.QUEUE) { if (tab == NpTab.QUEUE) {
// Radio mode toggle
if (onToggleRadio != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp)
.clip(RoundedCornerShape(12.dp))
.background(
if (state.radioMode) NsAccent.copy(alpha = 0.18f)
else Color.White.copy(alpha = 0.07f)
)
.clickable(onClick = onToggleRadio)
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
Icon(
Icons.Default.Radio, null,
tint = if (state.radioMode) NsAccent else Color.White.copy(0.55f),
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
"Radio-Modus",
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
color = if (state.radioMode) NsAccent else Color.White.copy(0.85f),
)
Text(
"Lädt ähnliche Songs automatisch nach",
fontFamily = Outfit, fontSize = 11.sp,
color = Color.White.copy(0.4f),
)
}
Switch(
checked = state.radioMode,
onCheckedChange = { onToggleRadio() },
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = NsAccent,
uncheckedThumbColor = Color.White.copy(0.6f),
uncheckedTrackColor = Color.White.copy(0.15f),
),
)
}
}
if (state.queue.isEmpty()) { if (state.queue.isEmpty()) {
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Text("Warteschlange leer", fontFamily = Outfit, fontSize = 14.sp, Text("Warteschlange leer", fontFamily = Outfit, fontSize = 14.sp,
@@ -553,3 +634,40 @@ private fun formatMs(ms: Long): String {
val s = (ms / 1000).coerceAtLeast(0) val s = (ms / 1000).coerceAtLeast(0)
return "%d:%02d".format(s / 60, s % 60) return "%d:%02d".format(s / 60, s % 60)
} }
@Composable
private fun LyricsLineItem(
text: String,
isActive: Boolean,
isPast: Boolean,
onSeek: () -> Unit,
) {
val animColor by animateColorAsState(
targetValue = when {
isActive -> Color.White
isPast -> Color.White.copy(alpha = 0.45f)
else -> Color.White.copy(alpha = 0.22f)
},
animationSpec = tween(300),
label = "lyricColor",
)
val animScale by animateFloatAsState(
targetValue = if (isActive) 1.04f else 1f,
animationSpec = tween(300),
label = "lyricScale",
)
Text(
text = text,
fontFamily = Outfit,
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp,
lineHeight = 28.sp,
color = animColor,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.graphicsLayer { scaleX = animScale; scaleY = animScale }
.clickable { onSeek() }
.padding(horizontal = 20.dp, vertical = 6.dp),
)
}
@@ -5,14 +5,17 @@ import android.media.MediaPlayer
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.EventsClient
import com.novasonic.app.api.ImporterClient import com.novasonic.app.api.ImporterClient
import com.novasonic.app.api.ItunesClient import com.novasonic.app.api.ItunesClient
import com.novasonic.app.api.model.Artist import com.novasonic.app.api.model.Artist
import com.novasonic.app.api.model.ConcertEvent
import com.novasonic.app.api.model.ArtistInfo import com.novasonic.app.api.model.ArtistInfo
import com.novasonic.app.api.model.DownloadRequest import com.novasonic.app.api.model.DownloadRequest
import com.novasonic.app.api.model.ItunesItem import com.novasonic.app.api.model.ItunesItem
import com.novasonic.app.api.model.Song import com.novasonic.app.api.model.Song
import com.novasonic.app.app import com.novasonic.app.app
import com.novasonic.app.data.NotificationStore
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -27,12 +30,18 @@ data class ArtistUiState(
val iAlbums: List<ItunesItem> = emptyList(), val iAlbums: List<ItunesItem> = emptyList(),
val iTracks: List<ItunesItem> = emptyList(), val iTracks: List<ItunesItem> = emptyList(),
val itunesLoading: Boolean = false, val itunesLoading: Boolean = false,
// Shuffle all
val isShuffling: Boolean = false,
val pendingShufflePlay: List<Song>? = null,
// Sub-album detail (tapped not-downloaded album) // Sub-album detail (tapped not-downloaded album)
val subAlbumItem: ItunesItem? = null, val subAlbumItem: ItunesItem? = null,
val subAlbumTracks: List<ItunesItem>? = null, val subAlbumTracks: List<ItunesItem>? = null,
val subAlbumTitle: String = "", val subAlbumTitle: String = "",
// Partial album detail (tapped incomplete album — shows downloaded + missing tracks) // Partial album detail (tapped incomplete album — shows downloaded + missing tracks)
val partialAlbum: PartialAlbumState? = null, val partialAlbum: PartialAlbumState? = null,
// Concerts
val events: List<ConcertEvent> = emptyList(),
val showEventsSheet: Boolean = false,
// Preview + download // Preview + download
val previewingId: Long? = null, val previewingId: Long? = null,
val downloadSheetItem: ItunesItem? = null, val downloadSheetItem: ItunesItem? = null,
@@ -41,6 +50,7 @@ data class ArtistUiState(
class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) { class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
private val repo = app.app.repository private val repo = app.app.repository
private val player = app.app.playerController
private val artistId: String? = savedState["artistId"] private val artistId: String? = savedState["artistId"]
private val _state = MutableStateFlow(ArtistUiState()) private val _state = MutableStateFlow(ArtistUiState())
@@ -54,6 +64,11 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
} else { } else {
load() load()
} }
viewModelScope.launch {
player.state.collect { ps ->
if (ps.isPlaying && _state.value.previewingId != null) stopPreview()
}
}
} }
fun load() { fun load() {
@@ -75,8 +90,13 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
isLoading = false, itunesLoading = true, isLoading = false, itunesLoading = true,
) )
// Fetch iTunes data without blocking the main screen // Fetch iTunes + events in parallel without blocking the main screen
launch { loadItunesData(artist.name) } launch { loadItunesData(artist.name) }
launch {
val backendUrl = repo.getBackendUrl()
val ev = runCatching { EventsClient.fetchEvents(backendUrl, artist.name) }.getOrDefault(emptyList())
_state.value = _state.value.copy(events = ev)
}
} catch (e: Exception) { } catch (e: Exception) {
_state.value = ArtistUiState(isLoading = false, itunesLoading = false, error = e.message ?: e.toString()) _state.value = ArtistUiState(isLoading = false, itunesLoading = false, error = e.message ?: e.toString())
} }
@@ -88,13 +108,15 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
ItunesClient.search(artistName, entity = "musicArtist", limit = 5) ItunesClient.search(artistName, entity = "musicArtist", limit = 5)
}.getOrNull() }.getOrNull()
val iArtistId = searchResp?.results val artists = searchResp?.results?.filter { it.wrapperType == "artist" } ?: emptyList()
?.filter { it.wrapperType == "artist" } val iArtistId = (
?.find { artists.find { it.artistName?.equals(artistName, ignoreCase = true) == true }
it.artistName?.equals(artistName, ignoreCase = true) == true || ?: artists.find {
it.artistName?.contains(artistName, ignoreCase = true) == true val n = it.artistName ?: return@find false
} n.contains(artistName, ignoreCase = true) || artistName.contains(n, ignoreCase = true)
?.artistId }
?: artists.firstOrNull() // iTunes sorts by relevance — first hit is almost always right
)?.artistId
if (iArtistId != null) { if (iArtistId != null) {
coroutineScope { coroutineScope {
@@ -163,6 +185,28 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
// ── Download sheet ─────────────────────────────────────────────────────── // ── Download sheet ───────────────────────────────────────────────────────
fun shuffleAll() {
val artist = _state.value.artist ?: return
if (_state.value.isShuffling) return
viewModelScope.launch {
_state.value = _state.value.copy(isShuffling = true)
val allSongs = artist.album.flatMap { album ->
runCatching { repo.getAlbum(album.id).song }.getOrDefault(emptyList())
}
_state.value = _state.value.copy(
isShuffling = false,
pendingShufflePlay = allSongs.shuffled().ifEmpty { null },
)
}
}
fun consumeShufflePlay() {
_state.value = _state.value.copy(pendingShufflePlay = null)
}
fun openEventsSheet() { _state.value = _state.value.copy(showEventsSheet = true) }
fun dismissEventsSheet() { _state.value = _state.value.copy(showEventsSheet = false) }
fun showDownloadSheet(item: ItunesItem) { fun showDownloadSheet(item: ItunesItem) {
_state.value = _state.value.copy(downloadSheetItem = item) _state.value = _state.value.copy(downloadSheetItem = item)
} }
@@ -201,7 +245,10 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
} }
val client = ImporterClient(backendUrl) val client = ImporterClient(backendUrl)
runCatching { client.download(request) } runCatching { client.download(request) }
.onSuccess { onDone(true, "✓ Download gestartet") } .onSuccess {
NotificationStore.startPoll(backendUrl, request.jobId, request.title, request.artist ?: "")
onDone(true, "✓ Download gestartet")
}
.onFailure { onDone(false, "Backend nicht erreichbar: ${it.message}") } .onFailure { onDone(false, "Backend nicht erreichbar: ${it.message}") }
client.close() client.close()
} }
@@ -217,6 +264,7 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
private fun startPreview(url: String, id: Long) { private fun startPreview(url: String, id: Long) {
stopPreview() stopPreview()
player.pause()
_state.value = _state.value.copy(previewingId = id) _state.value = _state.value.copy(previewingId = id)
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
@@ -66,7 +66,8 @@ data class ExploreUiState(
class ExploreViewModel(app: Application) : AndroidViewModel(app) { class ExploreViewModel(app: Application) : AndroidViewModel(app) {
private val repo = app.app.repository private val repo = app.app.repository
private val player = app.app.playerController
private val _state = MutableStateFlow(ExploreUiState(chartsLoading = true)) private val _state = MutableStateFlow(ExploreUiState(chartsLoading = true))
val state: StateFlow<ExploreUiState> = _state val state: StateFlow<ExploreUiState> = _state
@@ -74,7 +75,14 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
private var searchJob: Job? = null private var searchJob: Job? = null
private var mediaPlayer: MediaPlayer? = null private var mediaPlayer: MediaPlayer? = null
init { loadCharts() } init {
loadCharts()
viewModelScope.launch {
player.state.collect { ps ->
if (ps.isPlaying && _state.value.previewingId != null) stopPreview()
}
}
}
fun selectTab(tab: ExploreTab) { fun selectTab(tab: ExploreTab) {
_state.value = _state.value.copy(tab = tab, albumTracks = null) _state.value = _state.value.copy(tab = tab, albumTracks = null)
@@ -264,6 +272,7 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
private fun startPreview(url: String, id: Long) { private fun startPreview(url: String, id: Long) {
stopPreview() stopPreview()
player.pause()
_state.value = _state.value.copy(previewingId = id) _state.value = _state.value.copy(previewingId = id)
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
@@ -0,0 +1,33 @@
package com.novasonic.app.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.model.Genre
import com.novasonic.app.app
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
data class GenreUiState(
val genres: List<Genre> = emptyList(),
val isLoading: Boolean = true,
val error: String? = null,
)
class GenreViewModel(app: Application) : AndroidViewModel(app) {
private val repo = app.app.repository
private val _state = MutableStateFlow(GenreUiState())
val state: StateFlow<GenreUiState> = _state
init { load() }
fun load() {
viewModelScope.launch {
_state.value = GenreUiState(isLoading = true)
runCatching { repo.getGenres().filter { it.songCount > 0 }.sortedByDescending { it.songCount } }
.onSuccess { _state.value = GenreUiState(genres = it, isLoading = false) }
.onFailure { _state.value = GenreUiState(error = it.message, isLoading = false) }
}
}
}
@@ -61,7 +61,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app) {
recentAlbums = recent, recentAlbums = recent,
frequentAlbums = frequent, frequentAlbums = frequent,
randomAlbums = random, randomAlbums = random,
genres = genres.filter { it.songCount > 0 }.take(12), genres = genres.filter { it.songCount > 0 }.sortedByDescending { it.songCount },
isLoading = false, isLoading = false,
error = null, error = null,
) )
@@ -7,6 +7,8 @@ import com.novasonic.app.api.ImporterClient
import com.novasonic.app.api.model.DownloadRequest import com.novasonic.app.api.model.DownloadRequest
import com.novasonic.app.api.model.ImportResolveResponse import com.novasonic.app.api.model.ImportResolveResponse
import com.novasonic.app.app import com.novasonic.app.app
import com.novasonic.app.data.AppNotification
import com.novasonic.app.data.NotificationStore
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -120,6 +122,9 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
} }
private suspend fun pollJob(client: ImporterClient, jobId: String) { private suspend fun pollJob(client: ImporterClient, jobId: String) {
val s = _state.value
val title = s.resolved?.name ?: "Import"
val artist = s.resolved?.artist ?: ""
try { try {
repeat(600) { // up to ~20 min at 2s interval repeat(600) { // up to ~20 min at 2s interval
delay(2000) delay(2000)
@@ -132,6 +137,13 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
) )
if (st.status == "done" || st.status == "error") { if (st.status == "done" || st.status == "error") {
_state.value = _state.value.copy(isDownloading = false) _state.value = _state.value.copy(isDownloading = false)
NotificationStore.add(AppNotification(
id = jobId,
title = title,
artist = artist,
status = st.status ?: "error",
message = st.message ?: if (st.status == "done") "Fertig!" else "Fehler",
))
return return
} }
} }
@@ -3,11 +3,10 @@
import android.app.Application import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.LrcLibClient
import com.novasonic.app.api.LrcLine import com.novasonic.app.api.LrcLine
import com.novasonic.app.api.parseLrc
import com.novasonic.app.api.model.Song import com.novasonic.app.api.model.Song
import com.novasonic.app.app import com.novasonic.app.app
import com.novasonic.app.data.MusicRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -22,6 +21,7 @@ data class LyricsUiState(
class LyricsViewModel(app: Application) : AndroidViewModel(app) { class LyricsViewModel(app: Application) : AndroidViewModel(app) {
private val repo = MusicRepository(app.applicationContext)
private val _state = MutableStateFlow(LyricsUiState()) private val _state = MutableStateFlow(LyricsUiState())
val state: StateFlow<LyricsUiState> = _state val state: StateFlow<LyricsUiState> = _state
@@ -29,24 +29,30 @@ class LyricsViewModel(app: Application) : AndroidViewModel(app) {
if (_state.value.songId == song.id && !_state.value.isLoading) return if (_state.value.songId == song.id && !_state.value.isLoading) return
_state.value = LyricsUiState(isLoading = true, songId = song.id) _state.value = LyricsUiState(isLoading = true, songId = song.id)
viewModelScope.launch { viewModelScope.launch {
val artist = song.artist ?: "" // Navidrome plugin (getLyricsBySongId) — plugin handles source lookup + caching
val title = song.title val ndLyrics = runCatching { repo.getLyrics(song.id) }.getOrNull()
val album = song.album val synced = ndLyrics?.firstOrNull { it.synced && it.line.isNotEmpty() }
val durSecs = song.duration val plain = ndLyrics?.firstOrNull { it.line.isNotEmpty() }
val result = LrcLibClient.getLyrics(artist, title, album, durSecs)
when { when {
result == null -> _state.value = LyricsUiState( synced != null -> {
notFound = true, songId = song.id) val lines = synced.line
result.instrumental -> _state.value = LyricsUiState( .mapNotNull { l -> l.start?.let { ms -> LrcLine(ms, l.value) } }
plainText = "🎵 Instrumentalstück", songId = song.id) .filter { it.text.isNotBlank() }
!result.syncedLyrics.isNullOrBlank() -> _state.value = LyricsUiState( .sortedBy { it.timeMs }
lines = parseLrc(result.syncedLyrics), songId = song.id) _state.value = if (lines.isNotEmpty())
!result.plainLyrics.isNullOrBlank() -> _state.value = LyricsUiState( LyricsUiState(lines = lines, songId = song.id)
plainText = result.plainLyrics, songId = song.id) else
else -> _state.value = LyricsUiState( LyricsUiState(notFound = true, songId = song.id)
notFound = true, songId = song.id) }
plain != null -> {
val text = plain.line.joinToString("\n") { it.value }.trim()
_state.value = if (text.isNotBlank())
LyricsUiState(plainText = text, songId = song.id)
else
LyricsUiState(notFound = true, songId = song.id)
}
else -> _state.value = LyricsUiState(notFound = true, songId = song.id)
} }
} }
} }
+14 -42
View File
@@ -147,61 +147,33 @@ async function _loadNpLyrics(songId) {
content.innerHTML = '<div style="text-align:center;padding:60px 0;color:rgba(255,255,255,.3);font-size:14px">Lade Lyrics…</div>'; content.innerHTML = '<div style="text-align:center;padding:60px 0;color:rgba(255,255,255,.3);font-size:14px">Lade Lyrics…</div>';
const token = ++_lyricsLoadToken; const token = ++_lyricsLoadToken;
const s = S.queue[S.qIdx];
const { getLyricsBySongId } = await import('../api.js'); const { getLyricsBySongId } = await import('../api.js');
if(token !== _lyricsLoadToken) return; if(token !== _lyricsLoadToken) return;
// 1. Navidrome (plugin handles fetching + caching server-side) // Navidrome plugin (getLyricsBySongId) — plugin handles source lookup + caching
const lyrics = await getLyricsBySongId(songId); const lyrics = await getLyricsBySongId(songId);
if(token !== _lyricsLoadToken) return; if(token !== _lyricsLoadToken) return;
const track = lyrics[0];
if(track?.synced && track.line?.length) {
content.innerHTML = '';
_renderSyncedLines(content, track.line.map(l => ({ start: l.start, value: l.value || '' })).filter(l => l.value));
return;
}
// 2. Fallback: LRCLIB directly (synced LRC format)
const firstArtist = (s?.artist||'').split(/[/,]/)[0].trim();
let lrcText = null;
let lrclibPlain = null;
try {
const attempts = [
new URLSearchParams({ artist_name: firstArtist, track_name: s?.title||'', ...(s?.album ? { album_name: s.album } : {}) }),
...(s?.album ? [new URLSearchParams({ artist_name: firstArtist, track_name: s?.title||'' })] : []),
];
for(const p of attempts) {
if(token !== _lyricsLoadToken) return;
const r = await fetch(`https://lrclib.net/api/get?${p}`, { signal: AbortSignal.timeout(6000) });
if(r.ok) {
const d = await r.json();
lrcText = d.syncedLyrics || null;
lrclibPlain = d.plainLyrics || null;
if(lrcText || lrclibPlain) break;
}
}
} catch {}
if(token !== _lyricsLoadToken) return;
content.innerHTML = ''; content.innerHTML = '';
if(lrcText) { const synced = lyrics.find(t => t.synced && t.line?.length);
const parsed = _parseLrc(lrcText); const plain = lyrics.find(t => t.line?.length);
if(parsed.length) { _renderSyncedLines(content, parsed); return; }
}
const plain = lrclibPlain || (track?.line?.length ? track.line.map(l => l.value).join('\n') : null); if(synced) {
_renderSyncedLines(content, synced.line.map(l => ({ start: l.start, value: l.value || '' })).filter(l => l.value));
return;
}
if(plain) { if(plain) {
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'lyrics-plain'; el.className = 'lyrics-plain';
el.textContent = plain; el.textContent = plain.line.map(l => l.value).join('\n');
content.appendChild(el);
} else {
const el = document.createElement('div');
el.className = 'lyrics-none';
el.style.cssText = 'text-align:center;padding:60px 0;color:rgba(255,255,255,.3);font-size:14px';
el.textContent = 'Keine Lyrics verfügbar';
content.appendChild(el); content.appendChild(el);
return;
} }
const el = document.createElement('div');
el.className = 'lyrics-none';
el.style.cssText = 'text-align:center;padding:60px 0;color:rgba(255,255,255,.3);font-size:14px';
el.textContent = 'Keine Lyrics verfügbar';
content.appendChild(el);
} }
export function updateLyricsHighlight(currentMs) { export function updateLyricsHighlight(currentMs) {
+17 -4
View File
@@ -20,12 +20,25 @@ export async function getArtistImage(artistId) {
} }
// ─── HOME ─── // ─── HOME ───
const _greetW = [
'Ready for some beats,', "What's on the playlist,", 'Good vibes only,',
'Let the music play,', 'Your soundtrack awaits,', 'Turn it up,',
'Time to tune in,', 'Hit play and chill,', 'Music is the answer,', 'Welcome back,',
];
const _greetN = [
'Ready for some beats?', "What's on the playlist today?", 'Good vibes only',
'Let the music play', 'Your soundtrack awaits', 'Turn it up', 'Time to tune in',
'Hit play and chill', 'Music is the answer', 'What are we listening to?',
'Time to escape into music', 'Pick something great',
];
export async function loadHomeData() { export async function loadHomeData() {
const hour = new Date().getHours(); const name = S.cfg.user || '';
const greeting = hour < 12 ? 'Guten Morgen' : hour < 18 ? 'Guten Tag' : 'Guten Abend'; const g = name
const name = S.cfg.user ? `, ${S.cfg.user}` : ''; ? `${_greetW[Math.floor(Math.random() * _greetW.length)]} ${name}`
: _greetN[Math.floor(Math.random() * _greetN.length)];
const el = document.getElementById('home-greeting'); const el = document.getElementById('home-greeting');
if (el) el.textContent = `${greeting}${name} 👋`; if (el) el.textContent = g;
loadHeroCard(); loadHeroCard();
loadGenreSection(); loadGenreSection();