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>
This commit is contained in:
1 parent
8c8f03503b
commit
7e20062995
18 files changed
+700
-243
No files matched your search
@@ -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))
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,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
|
||||
@@ -83,9 +84,10 @@ fun AppNavigation() {
|
||||
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 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)
|
||||
@@ -121,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() },
|
||||
@@ -185,6 +187,7 @@ fun AppNavigation() {
|
||||
composable(TopLevel.HOME.route) {
|
||||
HomeScreen(
|
||||
coverUrl = { album -> coverUrl(album.coverArt) },
|
||||
onBellClick = { showNotifications = true },
|
||||
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
|
||||
onAlbumPlay = { album ->
|
||||
scope.launch { runCatching {
|
||||
@@ -260,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() },
|
||||
)
|
||||
}
|
||||
@@ -314,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 },
|
||||
@@ -328,6 +332,7 @@ fun AppNavigation() {
|
||||
showNowPlaying = false
|
||||
if (artistId.isNotBlank()) navController.navigate("artist/$artistId")
|
||||
},
|
||||
onToggleRadio = { player.toggleRadio() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -388,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,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))
|
||||
@@ -51,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()
|
||||
@@ -60,6 +61,13 @@ 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
|
||||
@@ -208,9 +216,11 @@ fun ArtistDetailScreen(
|
||||
}
|
||||
}
|
||||
item {
|
||||
ArtistActionPill(Icons.Default.Shuffle, "Shuffle") {
|
||||
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
|
||||
@@ -235,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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,6 +28,7 @@ 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
|
||||
@@ -74,10 +76,13 @@ fun HomeScreen(
|
||||
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 }
|
||||
|
||||
LaunchedEffect(refreshKey) { if (refreshKey > 0) vm.load() }
|
||||
|
||||
@@ -90,14 +95,34 @@ fun HomeScreen(
|
||||
) {
|
||||
item {
|
||||
val name = state.username.replaceFirstChar { it.uppercaseChar() }
|
||||
Text(
|
||||
text = if (name.isBlank()) greeting else "$greetingWithName $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) {
|
||||
|
||||
@@ -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
|
||||
@@ -35,6 +36,7 @@ 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
|
||||
@@ -62,6 +64,7 @@ fun NowPlayingScreen(
|
||||
onStarToggle: (() -> Unit)? = null,
|
||||
onShowOptions: (() -> Unit)? = null,
|
||||
onArtistClick: ((artistId: String) -> Unit)? = null,
|
||||
onToggleRadio: (() -> Unit)? = null,
|
||||
) {
|
||||
val song = state.currentSong
|
||||
var tab by remember { mutableStateOf(NpTab.PLAYER) }
|
||||
@@ -137,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) {
|
||||
@@ -145,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),
|
||||
)
|
||||
}
|
||||
@@ -239,8 +242,10 @@ fun NowPlayingScreen(
|
||||
artistList.forEachIndexed { idx, a ->
|
||||
if (idx > 0) {
|
||||
Text(
|
||||
" · ", fontFamily = Outfit,
|
||||
fontSize = 14.sp, color = Color.White.copy(0.35f),
|
||||
" · ",
|
||||
fontFamily = Outfit,
|
||||
fontSize = 14.sp,
|
||||
color = Color.White.copy(alpha = 0.35f),
|
||||
)
|
||||
}
|
||||
val navigable = onArtistClick != null && a.id.isNotBlank()
|
||||
@@ -250,10 +255,11 @@ fun NowPlayingScreen(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = if (navigable) NsAccent else NsAccent.copy(alpha = 0.55f),
|
||||
modifier = if (navigable)
|
||||
Modifier.clickable { onArtistClick!!(a.id) }
|
||||
Modifier.pointerInput(a.id) {
|
||||
detectTapGestures { onArtistClick!!(a.id) }
|
||||
}
|
||||
else Modifier,
|
||||
)
|
||||
}
|
||||
@@ -357,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 {
|
||||
@@ -398,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(
|
||||
@@ -458,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,
|
||||
@@ -595,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),
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ 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
|
||||
@@ -29,6 +30,9 @@ 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,
|
||||
@@ -46,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())
|
||||
@@ -59,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() {
|
||||
@@ -98,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 {
|
||||
@@ -173,6 +185,25 @@ 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) }
|
||||
|
||||
@@ -214,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()
|
||||
}
|
||||
@@ -230,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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user