Compare commits
10
Commits
afae5d94df
...
7e20062995
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e20062995 | ||
|
|
8c8f03503b | ||
|
|
55eef2f831 | ||
|
|
f15c005a3c | ||
|
|
0e651e8be3 | ||
|
|
3855207530 | ||
|
|
13e9ec9d9a | ||
|
|
b5aa484c20 | ||
|
|
42c12cbd3a | ||
|
|
f01fc71f54 |
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 */
|
||||
suspend fun lookupArtistTopTracks(artistId: Long, limit: Int = 10): List<ItunesItem> {
|
||||
val resp: ItunesSearchResponse = http.get("https://itunes.apple.com/lookup") {
|
||||
parameter("id", artistId)
|
||||
parameter("entity", "musicTrack")
|
||||
parameter("limit", limit + 1) // first result is the artist itself
|
||||
parameter("id", artistId)
|
||||
parameter("entity", "song") // "musicTrack" only works on /search — lookup needs "song"
|
||||
parameter("limit", limit + 1)
|
||||
parameter("explicit", "Yes")
|
||||
}.body()
|
||||
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> =
|
||||
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 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 starred2: Starred2? = null,
|
||||
val randomSongs: SongListResult? = null,
|
||||
val similarSongs2: SongListResult? = null,
|
||||
val scanStatus: ScanStatus? = null,
|
||||
val artistInfo2: ArtistInfo? = null,
|
||||
val topSongs: SongListResult? = null,
|
||||
@@ -40,6 +41,12 @@ data class SubsonicError(val code: Int, val message: String)
|
||||
|
||||
// ── Domain models ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
data class SongArtist(
|
||||
val id: String = "",
|
||||
val name: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Song(
|
||||
val id: String,
|
||||
@@ -48,6 +55,7 @@ data class Song(
|
||||
val albumId: String? = null,
|
||||
val artist: String? = null,
|
||||
val artistId: String? = null,
|
||||
val artists: List<SongArtist> = emptyList(),
|
||||
val track: Int? = null,
|
||||
val year: Int? = 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 getStarred() = api().getStarred()
|
||||
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 unstar(id: String) = api().unstar(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
|
||||
const val ROOT_ID = "ROOT"
|
||||
const val RECENT_ID = "RECENT"
|
||||
const val ALBUMS_ID = "ALBUMS"
|
||||
const val ARTISTS_ID = "ARTISTS"
|
||||
const val PLAYLISTS_ID = "PLAYLISTS"
|
||||
const val FAVORITES_ID = "FAVORITES"
|
||||
const val ITEM_PREFIX_ALBUM = "album:"
|
||||
const val ITEM_PREFIX_ARTIST = "artist:"
|
||||
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
|
||||
@@ -80,6 +81,12 @@ class MusicService : MediaLibraryService() {
|
||||
.setBitmapLoader(bitmapLoader)
|
||||
.setSessionActivity(activityIntent)
|
||||
.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
|
||||
@@ -102,7 +109,6 @@ class MusicService : MediaLibraryService() {
|
||||
browser: MediaSession.ControllerInfo,
|
||||
params: LibraryParams?,
|
||||
): ListenableFuture<LibraryResult<MediaItem>> {
|
||||
val isAuto = params?.isRecent == true || isAndroidAuto(browser)
|
||||
val root = MediaItem.Builder()
|
||||
.setMediaId(ROOT_ID)
|
||||
.setMediaMetadata(
|
||||
@@ -125,7 +131,6 @@ class MusicService : MediaLibraryService() {
|
||||
params: LibraryParams?,
|
||||
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||
val future = scope.async { buildChildren(parentId) }
|
||||
val executor = MoreExecutors.directExecutor()
|
||||
val settable = SettableFuture.create<LibraryResult<ImmutableList<MediaItem>>>()
|
||||
scope.launch {
|
||||
try {
|
||||
@@ -172,6 +177,12 @@ class MusicService : MediaLibraryService() {
|
||||
try {
|
||||
val resolved = mediaItems.flatMap { item ->
|
||||
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) -> {
|
||||
val album = repo.getAlbum(item.mediaId.removePrefix(ITEM_PREFIX_ALBUM))
|
||||
album.song.map { song -> song.toMediaItem(repo) }
|
||||
@@ -190,6 +201,25 @@ class MusicService : MediaLibraryService() {
|
||||
}
|
||||
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 ─────────────────────────────────────
|
||||
@@ -197,27 +227,27 @@ class MusicService : MediaLibraryService() {
|
||||
private suspend fun buildChildren(parentId: String): ImmutableList<MediaItem> {
|
||||
val items = when (parentId) {
|
||||
ROOT_ID -> listOf(
|
||||
browseFolder(RECENT_ID, "Recently Played", MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS),
|
||||
browseFolder(ALBUMS_ID, "Albums", MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS),
|
||||
browseFolder(ARTISTS_ID, "Artists", MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS),
|
||||
browseFolder(PLAYLISTS_ID, "Playlists", MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS),
|
||||
browseFolder(RECENT_ID, "Zuletzt gespielt", MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS),
|
||||
browseFolder(FAVORITES_ID, "Favoriten", MediaMetadata.MEDIA_TYPE_FOLDER_MIXED),
|
||||
browseFolder(PLAYLISTS_ID, "Playlists", MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS),
|
||||
)
|
||||
RECENT_ID -> repo.getRecentlyPlayedAlbums(20).map { it.toMediaItem(repo) }
|
||||
ALBUMS_ID -> repo.getAlphaAlbums(40).map { it.toMediaItem(repo) }
|
||||
PLAYLISTS_ID -> repo.getPlaylists().map { it.toMediaItem() }
|
||||
ARTISTS_ID -> repo.getArtists().flatMap { idx -> idx.artist.map { it.toMediaItem() } }
|
||||
RECENT_ID -> {
|
||||
val albums = repo.getRecentlyPlayedAlbums(20)
|
||||
listOf(playAllItem(RECENT_ID), shuffleItem(RECENT_ID)) + albums.map { it.toMediaItem(repo) }
|
||||
}
|
||||
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 {
|
||||
parentId.startsWith(ITEM_PREFIX_ALBUM) -> {
|
||||
val album = repo.getAlbum(parentId.removePrefix(ITEM_PREFIX_ALBUM))
|
||||
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) }
|
||||
listOf(playAllItem(parentId), shuffleItem(parentId)) + album.song.map { it.toMediaItem(repo) }
|
||||
}
|
||||
parentId.startsWith(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()
|
||||
}
|
||||
@@ -237,6 +267,28 @@ class MusicService : MediaLibraryService() {
|
||||
.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 =
|
||||
info.packageName.contains("android.auto", 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()
|
||||
}
|
||||
|
||||
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 =
|
||||
MediaItem.Builder()
|
||||
fun com.novasonic.app.api.model.Playlist.toMediaItem(repo: MusicRepository): MediaItem {
|
||||
val artUri = coverArt?.let { repo.coverArtUrl(it, 300)?.let { url -> android.net.Uri.parse(url) } }
|
||||
return MediaItem.Builder()
|
||||
.setMediaId("${MusicService.ITEM_PREFIX_PL}$id")
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(name)
|
||||
.setArtworkUri(artUri)
|
||||
.setIsPlayable(true)
|
||||
.setIsBrowsable(true)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
|
||||
.build()
|
||||
).build()
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.novasonic.app.api.model.Song
|
||||
@@ -25,6 +26,8 @@ data class PlayerState(
|
||||
val repeatMode: Int = Player.REPEAT_MODE_OFF,
|
||||
val shuffled: Boolean = false,
|
||||
val volume: Float = 1f,
|
||||
val artworkUri: String? = null,
|
||||
val radioMode: Boolean = false,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
// ── 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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private fun startTicker() {
|
||||
@@ -163,6 +210,17 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
|
||||
// ── 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() {
|
||||
val ctrl = controller ?: return
|
||||
val idx = ctrl.currentMediaItemIndex
|
||||
@@ -177,6 +235,8 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
repeatMode = ctrl.repeatMode,
|
||||
shuffled = isManuallyShuffled,
|
||||
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) {
|
||||
syncState()
|
||||
checkRadioRefill()
|
||||
val ctrl = controller ?: return
|
||||
val song = queue.getOrNull(ctrl.currentMediaItemIndex) ?: return
|
||||
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 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
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
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.app
|
||||
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.SongContextSheet
|
||||
import com.novasonic.app.ui.component.SongInfoSheet
|
||||
@@ -68,9 +70,24 @@ fun AppNavigation() {
|
||||
val playerState by player.state.collectAsStateWithLifecycle()
|
||||
val navController = rememberNavController()
|
||||
var showNowPlaying by remember { mutableStateOf(false) }
|
||||
var contextMenuSong by remember { mutableStateOf<Song?>(null) }
|
||||
var playlistPickerSong by remember { mutableStateOf<Song?>(null) }
|
||||
var infoSheetSong by remember { mutableStateOf<Song?>(null) }
|
||||
|
||||
// Re-register a fresh callback each time NowPlaying opens so it is always
|
||||
// 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 heroCoverUrl(coverId: String?) = repo.coverArtUrl(coverId, 1000)
|
||||
@@ -81,6 +98,8 @@ fun AppNavigation() {
|
||||
|
||||
fun showContextMenu(song: Song) { contextMenuSong = song }
|
||||
|
||||
var homeRefreshKey by remember { mutableIntStateOf(0) }
|
||||
|
||||
val currentBackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = currentBackEntry?.destination?.route
|
||||
val isTopLevel = TopLevel.entries.any { it.route == currentRoute }
|
||||
@@ -104,7 +123,7 @@ fun AppNavigation() {
|
||||
) {
|
||||
MiniPlayer(
|
||||
state = playerState,
|
||||
coverUrl = coverUrl(playerState.currentSong?.coverArt),
|
||||
coverUrl = coverUrl(playerState.currentSong?.coverArt) ?: playerState.artworkUri,
|
||||
onTap = { showNowPlaying = true },
|
||||
onTogglePlay = { player.togglePlay() },
|
||||
onSkipPrev = { player.skipPrev() },
|
||||
@@ -116,9 +135,13 @@ fun AppNavigation() {
|
||||
NavigationBarItem(
|
||||
selected = currentRoute == dest.route,
|
||||
onClick = {
|
||||
navController.navigate(dest.route) {
|
||||
popUpTo(navController.graph.startDestinationId) { saveState = true }
|
||||
launchSingleTop = true; restoreState = true
|
||||
if (dest == TopLevel.HOME && currentRoute == TopLevel.HOME.route) {
|
||||
homeRefreshKey++
|
||||
} else {
|
||||
navController.navigate(dest.route) {
|
||||
popUpTo(navController.graph.startDestinationId) { saveState = true }
|
||||
launchSingleTop = true; restoreState = true
|
||||
}
|
||||
}
|
||||
},
|
||||
icon = { Icon(dest.icon, contentDescription = dest.label) },
|
||||
@@ -163,21 +186,24 @@ fun AppNavigation() {
|
||||
) {
|
||||
composable(TopLevel.HOME.route) {
|
||||
HomeScreen(
|
||||
coverUrl = { album -> coverUrl(album.coverArt) },
|
||||
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
|
||||
onAlbumPlay = { album ->
|
||||
coverUrl = { album -> coverUrl(album.coverArt) },
|
||||
onBellClick = { showNotifications = true },
|
||||
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
|
||||
onAlbumPlay = { album ->
|
||||
scope.launch { runCatching {
|
||||
val full = repo.getAlbum(album.id)
|
||||
if (full.song.isNotEmpty()) player.playSongs(full.song, 0)
|
||||
}}
|
||||
},
|
||||
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)
|
||||
}}
|
||||
},
|
||||
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)
|
||||
}}
|
||||
},
|
||||
onShowAllGenres = { navController.navigate("genres") },
|
||||
refreshKey = homeRefreshKey,
|
||||
)
|
||||
}
|
||||
composable(TopLevel.LIBRARY.route) {
|
||||
@@ -237,6 +263,7 @@ fun AppNavigation() {
|
||||
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
|
||||
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
|
||||
onShowContextMenu = ::showContextMenu,
|
||||
onArtistClick = { artistId -> navController.navigate("artist/$artistId") },
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
@@ -268,12 +295,21 @@ fun AppNavigation() {
|
||||
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
|
||||
AnimatedVisibility(
|
||||
visible = showNowPlaying,
|
||||
@@ -282,7 +318,7 @@ fun AppNavigation() {
|
||||
) {
|
||||
NowPlayingScreen(
|
||||
state = playerState,
|
||||
coverUrl = heroCoverUrl(playerState.currentSong?.coverArt),
|
||||
coverUrl = heroCoverUrl(playerState.currentSong?.coverArt) ?: playerState.artworkUri,
|
||||
queueCoverUrl = { id -> coverUrl(id) },
|
||||
controller = player,
|
||||
onDismiss = { showNowPlaying = false },
|
||||
@@ -292,12 +328,11 @@ fun AppNavigation() {
|
||||
onShowOptions = {
|
||||
playerState.currentSong?.let { s -> contextMenuSong = s }
|
||||
},
|
||||
onArtistClick = playerState.currentSong?.artistId?.let { artistId ->
|
||||
{
|
||||
showNowPlaying = false
|
||||
navController.navigate("artist/$artistId")
|
||||
}
|
||||
onArtistClick = { artistId ->
|
||||
showNowPlaying = false
|
||||
if (artistId.isNotBlank()) navController.navigate("artist/$artistId")
|
||||
},
|
||||
onToggleRadio = { player.toggleRadio() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -358,6 +393,11 @@ fun AppNavigation() {
|
||||
infoSheetSong?.let { song ->
|
||||
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.material.icons.Icons
|
||||
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.Shuffle
|
||||
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.player.PlayerState
|
||||
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.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
@@ -49,6 +51,7 @@ fun ArtistDetailScreen(
|
||||
onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit,
|
||||
onStarToggle: (Song) -> Unit,
|
||||
onShowContextMenu: ((Song) -> Unit)? = null,
|
||||
onArtistClick: ((String) -> Unit)? = null,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val vm: ArtistViewModel = viewModel()
|
||||
@@ -58,6 +61,25 @@ fun ArtistDetailScreen(
|
||||
BackHandler(enabled = state.partialAlbum != null) { vm.dismissPartialAlbum() }
|
||||
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
|
||||
state.downloadSheetItem?.let { item ->
|
||||
ItunesDownloadSheet(
|
||||
@@ -183,17 +205,29 @@ fun ArtistDetailScreen(
|
||||
|
||||
// Action bar
|
||||
item {
|
||||
Row(
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 8.dp),
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
) {
|
||||
ArtistActionPill(Icons.Default.PlayArrow, "Top Songs") {
|
||||
if (state.topSongs.isNotEmpty()) onPlay(state.topSongs, 0)
|
||||
item {
|
||||
ArtistActionPill(Icons.Default.PlayArrow, "Top Songs") {
|
||||
if (state.topSongs.isNotEmpty()) onPlay(state.topSongs, 0)
|
||||
}
|
||||
}
|
||||
ArtistActionPill(Icons.Default.Shuffle, "Shuffle") {
|
||||
if (state.topSongs.isNotEmpty()) onPlay(state.topSongs.shuffled(), 0)
|
||||
item {
|
||||
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),
|
||||
) {
|
||||
items(similarArtists) { sim ->
|
||||
val navigable = onArtistClick != null && sim.id.isNotBlank()
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.width(70.dp),
|
||||
modifier = Modifier
|
||||
.width(70.dp)
|
||||
.then(
|
||||
if (navigable) Modifier.clickable { onArtistClick!!(sim.id) }
|
||||
else Modifier
|
||||
),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = coverUrl(sim.coverArt),
|
||||
contentDescription = sim.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(60.dp).clip(CircleShape)
|
||||
modifier = Modifier
|
||||
.size(60.dp)
|
||||
.clip(CircleShape)
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
sim.name, fontFamily = Outfit, fontSize = 11.sp,
|
||||
color = NsOnSurface2, maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = if (navigable) NsOnSurface else NsOnSurface2,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -55,6 +56,11 @@ fun ExploreScreen(
|
||||
val focusManager = LocalFocusManager.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
|
||||
state.downloadSheetItem?.let { item ->
|
||||
ItunesDownloadSheet(
|
||||
@@ -274,12 +280,13 @@ private fun LibrarySearchContent(
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
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()) {
|
||||
@@ -402,12 +409,16 @@ private fun GlobalExploreContent(
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
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()) {
|
||||
@@ -579,17 +590,28 @@ private fun ItunesTrackRow(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SmallArtistChip(name: String, onClick: () -> Unit) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
private fun ArtistCard(name: String, imageUrl: String?, onClick: () -> Unit) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(NsSurface2)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
.width(80.dp)
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
Text(name, fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
AsyncImage(
|
||||
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.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
@@ -27,12 +28,40 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Album
|
||||
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.theme.*
|
||||
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(0xFF1a2a2a), Color(0xFF2a1e1a), Color(0xFF1e1a2a),
|
||||
Color(0xFF2a2a1a), Color(0xFF1a2a1e), Color(0xFF201520),
|
||||
@@ -46,18 +75,19 @@ fun HomeScreen(
|
||||
onAlbumPlay: (Album) -> Unit,
|
||||
onGenreClick: (Genre) -> Unit,
|
||||
onGenrePlay: ((Genre) -> Unit)? = null,
|
||||
onShowAllGenres: (() -> Unit)? = null,
|
||||
onBellClick: () -> Unit = {},
|
||||
refreshKey: Int = 0,
|
||||
) {
|
||||
val vm: HomeViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val notifs by NotificationStore.items.collectAsStateWithLifecycle()
|
||||
val unreadCount = notifs.count { !it.read }
|
||||
|
||||
val greeting = remember {
|
||||
val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY)
|
||||
when {
|
||||
hour < 12 -> "Guten Morgen"
|
||||
hour < 18 -> "Guten Tag"
|
||||
else -> "Guten Abend"
|
||||
}
|
||||
}
|
||||
LaunchedEffect(refreshKey) { if (refreshKey > 0) vm.load() }
|
||||
|
||||
val greeting = remember { greetingsNoName.random() }
|
||||
val greetingWithName = remember { greetingsWithName.random() }
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
@@ -65,14 +95,34 @@ fun HomeScreen(
|
||||
) {
|
||||
item {
|
||||
val name = state.username.replaceFirstChar { it.uppercaseChar() }
|
||||
Text(
|
||||
text = if (name.isBlank()) greeting else "$greeting, $name 👋",
|
||||
fontFamily = Outfit,
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = NsOnSurface,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 24.dp, bottom = 20.dp),
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 24.dp, bottom = 20.dp),
|
||||
) {
|
||||
Text(
|
||||
text = if (name.isBlank()) greeting else "$greetingWithName $name",
|
||||
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) {
|
||||
@@ -118,8 +168,28 @@ fun HomeScreen(
|
||||
if (state.genres.isNotEmpty()) {
|
||||
item { SectionHeader("Genres") }
|
||||
item {
|
||||
GenreGrid(genres = state.genres, onGenreClick = onGenreClick, onGenrePlay = onGenrePlay)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
GenreGrid(genres = state.genres.take(6), onGenreClick = onGenreClick, onGenrePlay = onGenrePlay)
|
||||
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
|
||||
private fun GenreGrid(
|
||||
internal fun GenreGrid(
|
||||
genres: List<Genre>,
|
||||
onGenreClick: (Genre) -> Unit,
|
||||
onGenrePlay: ((Genre) -> Unit)?,
|
||||
@@ -267,7 +337,7 @@ private fun GenreGrid(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GenreChip(
|
||||
internal fun GenreChip(
|
||||
genre: Genre,
|
||||
bgColor: Color,
|
||||
onClick: () -> Unit,
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.LocalDensity
|
||||
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.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -59,7 +63,8 @@ fun NowPlayingScreen(
|
||||
onDismiss: () -> Unit,
|
||||
onStarToggle: (() -> Unit)? = null,
|
||||
onShowOptions: (() -> Unit)? = null,
|
||||
onArtistClick: (() -> Unit)? = null,
|
||||
onArtistClick: ((artistId: String) -> Unit)? = null,
|
||||
onToggleRadio: (() -> Unit)? = null,
|
||||
) {
|
||||
val song = state.currentSong
|
||||
var tab by remember { mutableStateOf(NpTab.PLAYER) }
|
||||
@@ -135,7 +140,7 @@ fun NowPlayingScreen(
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(if (sel) Color.White.copy(0.18f) else Color.Transparent)
|
||||
.clickable { tab = t }
|
||||
.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||
.padding(horizontal = 18.dp, vertical = 9.dp),
|
||||
) {
|
||||
Text(
|
||||
when (t) {
|
||||
@@ -143,7 +148,7 @@ fun NowPlayingScreen(
|
||||
NpTab.LYRICS -> "Lyrics"
|
||||
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),
|
||||
)
|
||||
}
|
||||
@@ -164,6 +169,8 @@ fun NowPlayingScreen(
|
||||
if (tab == NpTab.PLAYER) {
|
||||
var dragX by remember { mutableStateOf(0f) }
|
||||
|
||||
Spacer(Modifier.height(28.dp))
|
||||
|
||||
// Cover art — full content width, scale-animates like Apple Music
|
||||
AsyncImage(
|
||||
model = coverUrl,
|
||||
@@ -206,16 +213,57 @@ fun NowPlayingScreen(
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(3.dp))
|
||||
if (!song?.artist.isNullOrBlank()) {
|
||||
Text(
|
||||
song!!.artist!!,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 15.sp, color = NsAccent,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = if (onArtistClick != null)
|
||||
Modifier.clickable(onClick = onArtistClick)
|
||||
else Modifier,
|
||||
)
|
||||
if (song != null && !song.artist.isNullOrBlank()) {
|
||||
// Prefer OpenSubsonic artists array (has IDs per contributor).
|
||||
// Fall back to splitting artist string by ';' (same as web app).
|
||||
val raw: List<com.novasonic.app.api.model.SongArtist> =
|
||||
if (song.artists.size > 1) song.artists
|
||||
else song.artist!!.split(";").map { part ->
|
||||
val n = part.trim()
|
||||
com.novasonic.app.api.model.SongArtist(
|
||||
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()) {
|
||||
Text(
|
||||
@@ -315,25 +363,30 @@ fun NowPlayingScreen(
|
||||
if (tab == NpTab.LYRICS && song != null) {
|
||||
val lyricsVm: LyricsViewModel = viewModel()
|
||||
val lyricsState by lyricsVm.state.collectAsStateWithLifecycle()
|
||||
val listState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val lyricsScrollState = rememberScrollState()
|
||||
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) }
|
||||
|
||||
// 200ms lookahead — same as web app for better sync feel
|
||||
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
|
||||
|
||||
// Smooth scroll to active line
|
||||
// Absolute scroll: always lands on the exact pixel, no accumulation lag
|
||||
LaunchedEffect(activeIdx) {
|
||||
if (activeIdx >= 0 && lyricsState.lines.isNotEmpty()) {
|
||||
scope.launch {
|
||||
listState.animateScrollToItem(
|
||||
(activeIdx - 2).coerceAtLeast(0),
|
||||
scrollOffset = 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (activeIdx < 0 || lyricsState.lines.isEmpty() || lyricsViewH == 0) return@LaunchedEffect
|
||||
// Center of active line sits at 30% from top (clearly upper area)
|
||||
val targetY = (lyricsPaddingPx + activeIdx * lineHeightPx + lineHeightPx / 2f
|
||||
- lyricsViewH * 0.30f).coerceAtLeast(0f)
|
||||
lyricsScrollState.animateScrollTo(targetY.toInt(), tween(280, easing = FastOutSlowInEasing))
|
||||
}
|
||||
|
||||
when {
|
||||
@@ -356,45 +409,26 @@ fun NowPlayingScreen(
|
||||
color = Color.White.copy(0.4f), textAlign = TextAlign.Center)
|
||||
}
|
||||
|
||||
lyricsState.lines.isNotEmpty() -> LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
contentPadding = PaddingValues(vertical = 40.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
lyricsState.lines.isNotEmpty() -> Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.onSizeChanged { lyricsViewH = it.height }
|
||||
.verticalScroll(lyricsScrollState),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
itemsIndexed(lyricsState.lines) { idx, line ->
|
||||
val isActive = idx == activeIdx
|
||||
val isPast = idx < activeIdx
|
||||
|
||||
val targetColor = when {
|
||||
isActive -> Color.White
|
||||
isPast -> Color.White.copy(alpha = 0.32f)
|
||||
else -> Color.White.copy(alpha = 0.55f)
|
||||
Spacer(Modifier.height(200.dp))
|
||||
lyricsState.lines.forEachIndexed { idx, line ->
|
||||
key(idx) {
|
||||
LyricsLineItem(
|
||||
text = line.text,
|
||||
isActive = idx == activeIdx,
|
||||
isPast = idx < activeIdx,
|
||||
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(
|
||||
@@ -416,6 +450,53 @@ fun NowPlayingScreen(
|
||||
|
||||
// ── QUEUE TAB ──────────────────────────────────────────────────
|
||||
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()) {
|
||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Text("Warteschlange leer", fontFamily = Outfit, fontSize = 14.sp,
|
||||
@@ -553,3 +634,40 @@ private fun formatMs(ms: Long): String {
|
||||
val s = (ms / 1000).coerceAtLeast(0)
|
||||
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.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.EventsClient
|
||||
import com.novasonic.app.api.ImporterClient
|
||||
import com.novasonic.app.api.ItunesClient
|
||||
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.DownloadRequest
|
||||
import com.novasonic.app.api.model.ItunesItem
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.data.NotificationStore
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -27,12 +30,18 @@ data class ArtistUiState(
|
||||
val iAlbums: List<ItunesItem> = emptyList(),
|
||||
val iTracks: List<ItunesItem> = emptyList(),
|
||||
val itunesLoading: Boolean = false,
|
||||
// Shuffle all
|
||||
val isShuffling: Boolean = false,
|
||||
val pendingShufflePlay: List<Song>? = null,
|
||||
// Sub-album detail (tapped not-downloaded album)
|
||||
val subAlbumItem: ItunesItem? = null,
|
||||
val subAlbumTracks: List<ItunesItem>? = null,
|
||||
val subAlbumTitle: String = "",
|
||||
// Partial album detail (tapped incomplete album — shows downloaded + missing tracks)
|
||||
val partialAlbum: PartialAlbumState? = null,
|
||||
// Concerts
|
||||
val events: List<ConcertEvent> = emptyList(),
|
||||
val showEventsSheet: Boolean = false,
|
||||
// Preview + download
|
||||
val previewingId: Long? = null,
|
||||
val downloadSheetItem: ItunesItem? = null,
|
||||
@@ -41,6 +50,7 @@ data class ArtistUiState(
|
||||
class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
private val player = app.app.playerController
|
||||
private val artistId: String? = savedState["artistId"]
|
||||
|
||||
private val _state = MutableStateFlow(ArtistUiState())
|
||||
@@ -54,6 +64,11 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
||||
} else {
|
||||
load()
|
||||
}
|
||||
viewModelScope.launch {
|
||||
player.state.collect { ps ->
|
||||
if (ps.isPlaying && _state.value.previewingId != null) stopPreview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun load() {
|
||||
@@ -75,8 +90,13 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
||||
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 {
|
||||
val backendUrl = repo.getBackendUrl()
|
||||
val ev = runCatching { EventsClient.fetchEvents(backendUrl, artist.name) }.getOrDefault(emptyList())
|
||||
_state.value = _state.value.copy(events = ev)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_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)
|
||||
}.getOrNull()
|
||||
|
||||
val iArtistId = searchResp?.results
|
||||
?.filter { it.wrapperType == "artist" }
|
||||
?.find {
|
||||
it.artistName?.equals(artistName, ignoreCase = true) == true ||
|
||||
it.artistName?.contains(artistName, ignoreCase = true) == true
|
||||
}
|
||||
?.artistId
|
||||
val artists = searchResp?.results?.filter { it.wrapperType == "artist" } ?: emptyList()
|
||||
val iArtistId = (
|
||||
artists.find { it.artistName?.equals(artistName, ignoreCase = true) == true }
|
||||
?: artists.find {
|
||||
val n = it.artistName ?: return@find false
|
||||
n.contains(artistName, ignoreCase = true) || artistName.contains(n, ignoreCase = true)
|
||||
}
|
||||
?: artists.firstOrNull() // iTunes sorts by relevance — first hit is almost always right
|
||||
)?.artistId
|
||||
|
||||
if (iArtistId != null) {
|
||||
coroutineScope {
|
||||
@@ -163,6 +185,28 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
||||
|
||||
// ── 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) {
|
||||
_state.value = _state.value.copy(downloadSheetItem = item)
|
||||
}
|
||||
@@ -201,7 +245,10 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
||||
}
|
||||
val client = ImporterClient(backendUrl)
|
||||
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}") }
|
||||
client.close()
|
||||
}
|
||||
@@ -217,6 +264,7 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
||||
|
||||
private fun startPreview(url: String, id: Long) {
|
||||
stopPreview()
|
||||
player.pause()
|
||||
_state.value = _state.value.copy(previewingId = id)
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
|
||||
@@ -66,7 +66,8 @@ data class ExploreUiState(
|
||||
|
||||
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))
|
||||
val state: StateFlow<ExploreUiState> = _state
|
||||
@@ -74,7 +75,14 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
|
||||
private var searchJob: Job? = 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) {
|
||||
_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) {
|
||||
stopPreview()
|
||||
player.pause()
|
||||
_state.value = _state.value.copy(previewingId = id)
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
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,
|
||||
frequentAlbums = frequent,
|
||||
randomAlbums = random,
|
||||
genres = genres.filter { it.songCount > 0 }.take(12),
|
||||
genres = genres.filter { it.songCount > 0 }.sortedByDescending { it.songCount },
|
||||
isLoading = false,
|
||||
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.ImportResolveResponse
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.data.AppNotification
|
||||
import com.novasonic.app.data.NotificationStore
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -120,6 +122,9 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
|
||||
private suspend fun pollJob(client: ImporterClient, jobId: String) {
|
||||
val s = _state.value
|
||||
val title = s.resolved?.name ?: "Import"
|
||||
val artist = s.resolved?.artist ?: ""
|
||||
try {
|
||||
repeat(600) { // up to ~20 min at 2s interval
|
||||
delay(2000)
|
||||
@@ -132,6 +137,13 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
|
||||
)
|
||||
if (st.status == "done" || st.status == "error") {
|
||||
_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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.LrcLibClient
|
||||
import com.novasonic.app.api.LrcLine
|
||||
import com.novasonic.app.api.parseLrc
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.data.MusicRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -22,6 +21,7 @@ data class LyricsUiState(
|
||||
|
||||
class LyricsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = MusicRepository(app.applicationContext)
|
||||
private val _state = MutableStateFlow(LyricsUiState())
|
||||
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
|
||||
_state.value = LyricsUiState(isLoading = true, songId = song.id)
|
||||
viewModelScope.launch {
|
||||
val artist = song.artist ?: ""
|
||||
val title = song.title
|
||||
val album = song.album
|
||||
val durSecs = song.duration
|
||||
|
||||
val result = LrcLibClient.getLyrics(artist, title, album, durSecs)
|
||||
// Navidrome plugin (getLyricsBySongId) — plugin handles source lookup + caching
|
||||
val ndLyrics = runCatching { repo.getLyrics(song.id) }.getOrNull()
|
||||
val synced = ndLyrics?.firstOrNull { it.synced && it.line.isNotEmpty() }
|
||||
val plain = ndLyrics?.firstOrNull { it.line.isNotEmpty() }
|
||||
|
||||
when {
|
||||
result == null -> _state.value = LyricsUiState(
|
||||
notFound = true, songId = song.id)
|
||||
result.instrumental -> _state.value = LyricsUiState(
|
||||
plainText = "🎵 Instrumentalstück", songId = song.id)
|
||||
!result.syncedLyrics.isNullOrBlank() -> _state.value = LyricsUiState(
|
||||
lines = parseLrc(result.syncedLyrics), songId = song.id)
|
||||
!result.plainLyrics.isNullOrBlank() -> _state.value = LyricsUiState(
|
||||
plainText = result.plainLyrics, songId = song.id)
|
||||
else -> _state.value = LyricsUiState(
|
||||
notFound = true, songId = song.id)
|
||||
synced != null -> {
|
||||
val lines = synced.line
|
||||
.mapNotNull { l -> l.start?.let { ms -> LrcLine(ms, l.value) } }
|
||||
.filter { it.text.isNotBlank() }
|
||||
.sortedBy { it.timeMs }
|
||||
_state.value = if (lines.isNotEmpty())
|
||||
LyricsUiState(lines = lines, songId = song.id)
|
||||
else
|
||||
LyricsUiState(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
@@ -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>';
|
||||
|
||||
const token = ++_lyricsLoadToken;
|
||||
const s = S.queue[S.qIdx];
|
||||
const { getLyricsBySongId } = await import('../api.js');
|
||||
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);
|
||||
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 = '';
|
||||
if(lrcText) {
|
||||
const parsed = _parseLrc(lrcText);
|
||||
if(parsed.length) { _renderSyncedLines(content, parsed); return; }
|
||||
}
|
||||
const synced = lyrics.find(t => t.synced && t.line?.length);
|
||||
const plain = lyrics.find(t => t.line?.length);
|
||||
|
||||
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) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'lyrics-plain';
|
||||
el.textContent = plain;
|
||||
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';
|
||||
el.textContent = plain.line.map(l => l.value).join('\n');
|
||||
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) {
|
||||
|
||||
+17
-4
@@ -20,12 +20,25 @@ export async function getArtistImage(artistId) {
|
||||
}
|
||||
|
||||
// ─── 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() {
|
||||
const hour = new Date().getHours();
|
||||
const greeting = hour < 12 ? 'Guten Morgen' : hour < 18 ? 'Guten Tag' : 'Guten Abend';
|
||||
const name = S.cfg.user ? `, ${S.cfg.user}` : '';
|
||||
const name = S.cfg.user || '';
|
||||
const g = name
|
||||
? `${_greetW[Math.floor(Math.random() * _greetW.length)]} ${name}`
|
||||
: _greetN[Math.floor(Math.random() * _greetN.length)];
|
||||
const el = document.getElementById('home-greeting');
|
||||
if (el) el.textContent = `${greeting}${name} 👋`;
|
||||
if (el) el.textContent = g;
|
||||
|
||||
loadHeroCard();
|
||||
loadGenreSection();
|
||||
|
||||
Reference in new issue
Block a user