feat: partial album split view + artist page full-screen + Bibliothek iTunes integration
- Partial album detail: tapping incomplete albums now shows downloaded songs (normal playback) above and missing iTunes tracks (30s preview + download) below — on both Android and web PWA - Android: ItunesArtistScreen now renders full-screen above the tab/search bar - Android: ArtistDetailScreen (Bibliothek) now loads iTunes data in parallel — adds Weltweit populär section and In Library/Unvollständig/Nicht heruntergeladen album sections identical to the Erkunden artist page - Fix: artist no longer appears in its own Similar Artists row - Fix: duplicate album entries deduplicated by collectionId in categorizeAlbums Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
b25e162806
commit
afae5d94df
104 files changed
+8839
-9420
No files matched your search
@@ -1,22 +1,25 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.novasonic.app"
|
||||
compileSdk = 34
|
||||
namespace = "com.novasonic.app"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.novasonic.app"
|
||||
minSdk = 24
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
buildConfigField("String", "NOVASONIC_URL", "\"https://music.asianxpolish.duckdns.org\"")
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
@@ -28,22 +31,68 @@ android {
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
jvmTarget = "11"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Core
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.material)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.splashscreen)
|
||||
|
||||
// Compose
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.ui.tooling.preview)
|
||||
implementation(libs.compose.foundation)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.icons)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
|
||||
// Navigation
|
||||
implementation(libs.navigation.compose)
|
||||
|
||||
// Lifecycle / ViewModel
|
||||
implementation(libs.lifecycle.viewmodel.compose)
|
||||
implementation(libs.lifecycle.runtime.compose)
|
||||
|
||||
// Media3
|
||||
implementation(libs.media3.exoplayer)
|
||||
implementation(libs.media3.session)
|
||||
implementation(libs.media3.common)
|
||||
implementation(libs.media3.datasource)
|
||||
implementation(libs.media3.ui)
|
||||
|
||||
// Ktor
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.android)
|
||||
implementation(libs.ktor.client.content.neg)
|
||||
implementation(libs.ktor.serialization.json)
|
||||
implementation(libs.ktor.client.logging)
|
||||
|
||||
// Kotlinx
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.kotlin.reflect)
|
||||
|
||||
// Room
|
||||
implementation(libs.room.runtime)
|
||||
implementation(libs.room.ktx)
|
||||
ksp(libs.room.compiler)
|
||||
|
||||
// DataStore
|
||||
implementation(libs.datastore.preferences)
|
||||
|
||||
// Coil
|
||||
implementation(libs.coil.compose)
|
||||
|
||||
// Google Fonts (Outfit)
|
||||
implementation(libs.compose.ui.google.fonts)
|
||||
}
|
||||
@@ -7,8 +7,10 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
|
||||
<application
|
||||
android:name=".NovaSonicApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
@@ -20,7 +22,7 @@
|
||||
android:largeHeap="true">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:name=".ui.MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden|smallestScreenSize|screenLayout"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
@@ -32,7 +34,7 @@
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".MusicService"
|
||||
android:name=".player.MusicService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
@@ -41,7 +43,7 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- Android Auto support -->
|
||||
<!-- Android Auto -->
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc"/>
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package com.novasonic.app
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
data class Track(
|
||||
val id: String,
|
||||
val url: String,
|
||||
val title: String,
|
||||
val artist: String,
|
||||
val album: String?,
|
||||
val artworkUrl: String?,
|
||||
val durationMs: Long
|
||||
) {
|
||||
fun toMediaItem(): MediaItem {
|
||||
val metadata = MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
.setArtist(artist)
|
||||
.setAlbumTitle(album)
|
||||
.apply {
|
||||
if (!artworkUrl.isNullOrEmpty()) setArtworkUri(Uri.parse(artworkUrl))
|
||||
if (durationMs > 0) setDurationMs(durationMs)
|
||||
}
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setMediaId(id)
|
||||
.setUri(url)
|
||||
.setMediaMetadata(metadata)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromJson(obj: JSONObject) = Track(
|
||||
id = obj.optString("id", ""),
|
||||
url = obj.optString("url", ""),
|
||||
title = obj.optString("title", ""),
|
||||
artist = obj.optString("artist", ""),
|
||||
album = obj.optString("album").takeIf { it.isNotEmpty() },
|
||||
artworkUrl = obj.optString("artworkUrl").takeIf { it.isNotEmpty() },
|
||||
durationMs = obj.optLong("durationMs", 0)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun parseTrackList(json: String): List<Track> = try {
|
||||
val arr = JSONArray(json)
|
||||
List(arr.length()) { Track.fromJson(arr.getJSONObject(it)) }
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
package com.novasonic.app
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import org.json.JSONObject
|
||||
|
||||
class MainActivity : AppCompatActivity(), MusicService.StateListener {
|
||||
|
||||
private lateinit var webView: WebView
|
||||
private lateinit var webBridge: WebBridge
|
||||
private var controllerFuture: ListenableFuture<MediaController>? = null
|
||||
private var mediaController: MediaController? = null
|
||||
private var pendingOpenNowPlaying = false
|
||||
// Debounce onReady: WebView can fire onPageFinished multiple times (hash changes,
|
||||
// certain JS navigations). Only deliver onReady once per 3-second window.
|
||||
private var lastOnReadyMs = 0L
|
||||
|
||||
// ─── Back-press handling ──────────────────────────────────────────────────
|
||||
|
||||
// Always enabled — the callback itself decides whether to handle the event.
|
||||
private val backCallback = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (webBridge.consumeBackPress()) {
|
||||
// An overlay is open in JS — ask it to close the topmost layer.
|
||||
webView.evaluateJavascript(
|
||||
"if(window.NSPlayer&&window.NSPlayer.handleBack)window.NSPlayer.handleBack()",
|
||||
null
|
||||
)
|
||||
return
|
||||
}
|
||||
if (webView.canGoBack()) {
|
||||
webView.goBack()
|
||||
return
|
||||
}
|
||||
// Nothing left to pop — send the task to background.
|
||||
// Using moveTaskToBack instead of finish() so the Activity stays alive
|
||||
// and the MusicService binding is preserved; resuming is instant.
|
||||
moveTaskToBack(true)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Lifecycle ────────────────────────────────────────────────────────────
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
webView = findViewById(R.id.web_view)
|
||||
|
||||
webBridge = WebBridge { mediaController }
|
||||
configureWebView()
|
||||
requestNotificationPermission()
|
||||
onBackPressedDispatcher.addCallback(this, backCallback)
|
||||
// Explicitly start the service before binding so Android 14 lets it
|
||||
// promote to foreground (bindService alone doesn't satisfy the
|
||||
// "started from foreground" requirement for foreground services).
|
||||
startService(Intent(this, MusicService::class.java))
|
||||
connectToService()
|
||||
|
||||
val url = BuildConfig.NOVASONIC_URL.ifEmpty { "https://demo.navidrome.org" }
|
||||
webView.loadUrl(url)
|
||||
|
||||
// Notification tap while app was not running — defer until page is loaded.
|
||||
if (intent?.getBooleanExtra("open_now_playing", false) == true) {
|
||||
pendingOpenNowPlaying = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
// Notification tap while app is already running — WebView is ready immediately.
|
||||
if (intent.getBooleanExtra("open_now_playing", false)) {
|
||||
// Refresh snapshot so getState() is never stale, then emit onTrackChanged
|
||||
// + openNowPlaying in a single JS tick to avoid the race where the bottom
|
||||
// bar and NP overlay show the previous song.
|
||||
updateStateSnapshot()
|
||||
val ctrl = mediaController
|
||||
if (ctrl != null) {
|
||||
val idx = ctrl.currentMediaItemIndex
|
||||
val trackId = ctrl.currentMediaItem?.mediaId ?: ""
|
||||
val json = JSONObject().apply {
|
||||
put("index", idx)
|
||||
put("trackId", trackId)
|
||||
put("reason", "notification")
|
||||
}.toString()
|
||||
val escaped = escapeJs(json)
|
||||
webView.post {
|
||||
webView.evaluateJavascript(
|
||||
"(function(){" +
|
||||
"if(window.NSPlayer&&window.NSPlayer.onTrackChanged)window.NSPlayer.onTrackChanged(\"$escaped\");" +
|
||||
"if(window.openNowPlaying)window.openNowPlaying();" +
|
||||
"})()",
|
||||
null
|
||||
)
|
||||
}
|
||||
} else {
|
||||
webView.post { webView.evaluateJavascript("if(window.openNowPlaying)window.openNowPlaying()", null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() { super.onResume(); webView.onResume() }
|
||||
override fun onPause() { webView.onPause(); super.onPause() }
|
||||
|
||||
override fun onDestroy() {
|
||||
MusicService.stateListener = null
|
||||
controllerFuture?.let { MediaController.releaseFuture(it) }
|
||||
webView.destroy()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
// ─── Service connection ───────────────────────────────────────────────────
|
||||
|
||||
private fun connectToService() {
|
||||
MusicService.stateListener = this
|
||||
val token = SessionToken(this, ComponentName(this, MusicService::class.java))
|
||||
val future = MediaController.Builder(this, token).buildAsync()
|
||||
controllerFuture = future
|
||||
future.addListener({
|
||||
mediaController = try { future.get() } catch (_: Exception) { null }
|
||||
if (mediaController != null) {
|
||||
lastOnReadyMs = System.currentTimeMillis()
|
||||
updateStateSnapshot() // ensure getState() is fresh when JS calls it in onReady
|
||||
callJs("onReady")
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(this))
|
||||
}
|
||||
|
||||
// ─── WebView configuration ────────────────────────────────────────────────
|
||||
|
||||
private fun configureWebView() {
|
||||
WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
|
||||
|
||||
webView.settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
cacheMode = WebSettings.LOAD_DEFAULT
|
||||
mediaPlaybackRequiresUserGesture = false
|
||||
allowFileAccess = true // needed for bridge-test.html asset
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||
useWideViewPort = true
|
||||
loadWithOverviewMode = true
|
||||
userAgentString = userAgentString.replace("wv", "") + " NovaSonicApp/1.0"
|
||||
}
|
||||
|
||||
// New full bridge — JS calls window.NSBridge.*
|
||||
webView.addJavascriptInterface(webBridge, "NSBridge")
|
||||
|
||||
webView.webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest) = false
|
||||
|
||||
// Re-announce ready + current state after a WebView reload so JS can re-sync.
|
||||
// Debounced: WebView can fire onPageFinished for hash/state changes mid-session.
|
||||
override fun onPageFinished(view: WebView, url: String) {
|
||||
if (mediaController != null) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastOnReadyMs > 3_000) {
|
||||
lastOnReadyMs = now
|
||||
updateStateSnapshot()
|
||||
callJs("onReady")
|
||||
}
|
||||
}
|
||||
if (pendingOpenNowPlaying) {
|
||||
pendingOpenNowPlaying = false
|
||||
webView.post { webView.evaluateJavascript("if(window.openNowPlaying)window.openNowPlaying()", null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
webView.webChromeClient = WebChromeClient()
|
||||
}
|
||||
|
||||
// ─── MusicService.StateListener → push state to JS ───────────────────────
|
||||
|
||||
override fun onReady() = callJs("onReady")
|
||||
|
||||
override fun onPlaybackStateChanged(isPlaying: Boolean, isBuffering: Boolean, isLoading: Boolean) {
|
||||
val json = """{"isPlaying":$isPlaying,"isBuffering":$isBuffering,"isLoading":$isLoading}"""
|
||||
updateStateSnapshot()
|
||||
callJs("onPlaybackStateChanged", json)
|
||||
}
|
||||
|
||||
override fun onTrackChanged(index: Int, trackId: String, reason: String) {
|
||||
val json = JSONObject().apply {
|
||||
put("index", index)
|
||||
put("trackId", trackId)
|
||||
put("reason", reason)
|
||||
}.toString()
|
||||
updateStateSnapshot()
|
||||
callJs("onTrackChanged", json)
|
||||
}
|
||||
|
||||
override fun onPositionUpdate(positionMs: Long, durationMs: Long) =
|
||||
callJs("onPositionUpdate", positionMs, durationMs)
|
||||
|
||||
override fun onQueueEnded() = callJs("onQueueEnded")
|
||||
|
||||
override fun onError(message: String) = callJs("onError", message)
|
||||
|
||||
override fun onSkipToNext() = callJs("onNotificationNext")
|
||||
override fun onSkipToPrevious() = callJs("onNotificationPrev")
|
||||
|
||||
// ─── JS bridge helpers ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Calls window.NSPlayer.<method>(<args>) safely on the UI thread.
|
||||
* Strings are JSON-escaped and quoted; numbers/booleans are passed as-is.
|
||||
*/
|
||||
fun callJs(method: String, vararg args: Any?) {
|
||||
val argsJs = args.joinToString(",") { arg ->
|
||||
when (arg) {
|
||||
null -> "null"
|
||||
is Boolean -> arg.toString()
|
||||
is Number -> arg.toString()
|
||||
is String -> "\"${escapeJs(arg)}\""
|
||||
else -> "\"${escapeJs(arg.toString())}\""
|
||||
}
|
||||
}
|
||||
val js = "if(window.NSPlayer&&window.NSPlayer.$method)window.NSPlayer.$method($argsJs)"
|
||||
webView.post { webView.evaluateJavascript(js, null) }
|
||||
}
|
||||
|
||||
private fun escapeJs(s: String) = s
|
||||
.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "")
|
||||
|
||||
private fun updateStateSnapshot() {
|
||||
val ctrl = mediaController ?: return
|
||||
val json = JSONObject().apply {
|
||||
put("isPlaying", ctrl.isPlaying)
|
||||
put("isBuffering", ctrl.playbackState == Player.STATE_BUFFERING)
|
||||
put("isEnded", ctrl.playbackState == Player.STATE_ENDED)
|
||||
put("currentIndex", ctrl.currentMediaItemIndex)
|
||||
put("currentId", ctrl.currentMediaItem?.mediaId ?: "")
|
||||
put("positionMs", ctrl.currentPosition)
|
||||
put("queueLength", ctrl.mediaItemCount)
|
||||
}.toString()
|
||||
webBridge.updateSnapshot(json)
|
||||
}
|
||||
|
||||
// ─── Permissions ──────────────────────────────────────────────────────────
|
||||
|
||||
private fun requestNotificationPermission() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
||||
when {
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
||||
== PackageManager.PERMISSION_GRANTED -> return
|
||||
|
||||
// System won't show the dialog again after one denial — send user to settings once.
|
||||
!ActivityCompat.shouldShowRequestPermissionRationale(
|
||||
this, Manifest.permission.POST_NOTIFICATIONS
|
||||
) && _notifPermissionAskedBefore() && !_notifSettingsShownBefore() -> {
|
||||
markNotifSettingsShown()
|
||||
openNotificationSettings()
|
||||
}
|
||||
|
||||
else -> {
|
||||
markNotifPermissionAsked()
|
||||
ActivityCompat.requestPermissions(
|
||||
this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 42
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int, permissions: Array<out String>, grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode == 42 &&
|
||||
grantResults.firstOrNull() == PackageManager.PERMISSION_DENIED &&
|
||||
!_notifSettingsShownBefore()
|
||||
) {
|
||||
markNotifSettingsShown()
|
||||
openNotificationSettings()
|
||||
}
|
||||
}
|
||||
|
||||
private fun openNotificationSettings() {
|
||||
startActivity(
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", packageName, null)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun _notifPermissionAskedBefore() =
|
||||
getSharedPreferences("ns_prefs", MODE_PRIVATE).getBoolean("notif_asked", false)
|
||||
|
||||
private fun markNotifPermissionAsked() =
|
||||
getSharedPreferences("ns_prefs", MODE_PRIVATE).edit()
|
||||
.putBoolean("notif_asked", true).apply()
|
||||
|
||||
private fun _notifSettingsShownBefore() =
|
||||
getSharedPreferences("ns_prefs", MODE_PRIVATE).getBoolean("notif_settings_shown", false)
|
||||
|
||||
private fun markNotifSettingsShown() =
|
||||
getSharedPreferences("ns_prefs", MODE_PRIVATE).edit()
|
||||
.putBoolean("notif_settings_shown", true).apply()
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
package com.novasonic.app
|
||||
|
||||
// Replaced by MusicService.kt (Media3 MediaSessionService + ExoPlayer).
|
||||
// Kept as an empty file so git history is preserved.
|
||||
@@ -1,280 +0,0 @@
|
||||
package com.novasonic.app
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.ForwardingPlayer
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.media3.datasource.DataSourceBitmapLoader
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.datasource.DefaultHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.session.CacheBitmapLoader
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class MusicService : MediaSessionService() {
|
||||
|
||||
// ─── State callback interface (consumed by MainActivity) ─────────────────
|
||||
|
||||
interface StateListener {
|
||||
fun onReady()
|
||||
fun onPlaybackStateChanged(isPlaying: Boolean, isBuffering: Boolean, isLoading: Boolean)
|
||||
fun onTrackChanged(index: Int, trackId: String, reason: String)
|
||||
fun onPositionUpdate(positionMs: Long, durationMs: Long)
|
||||
fun onQueueEnded()
|
||||
fun onError(message: String)
|
||||
fun onSkipToNext()
|
||||
fun onSkipToPrevious()
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Set by MainActivity before connecting; cleared on destroy.
|
||||
@Volatile var stateListener: StateListener? = null
|
||||
// Service instance — lets WebBridge trigger artwork pre-warming directly.
|
||||
@Volatile var instance: MusicService? = null
|
||||
}
|
||||
|
||||
// ─── Fields ───────────────────────────────────────────────────────────────
|
||||
|
||||
private lateinit var player: ExoPlayer
|
||||
private lateinit var mediaSession: MediaSession
|
||||
private lateinit var bitmapLoader: CacheBitmapLoader
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
// 250 ms position ticker — only runs while actually playing
|
||||
private val positionTicker = object : Runnable {
|
||||
override fun run() {
|
||||
if (player.isPlaying) {
|
||||
val dur = player.duration.let { if (it == C.TIME_UNSET) 0L else it }
|
||||
stateListener?.onPositionUpdate(player.currentPosition, dur)
|
||||
handler.postDelayed(this, 250)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Lifecycle ────────────────────────────────────────────────────────────
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
|
||||
val httpFactory = DefaultHttpDataSource.Factory()
|
||||
.setUserAgent("NovaSonic-Android/1.0")
|
||||
.setConnectTimeoutMs(15_000)
|
||||
.setReadTimeoutMs(15_000)
|
||||
.setAllowCrossProtocolRedirects(true)
|
||||
|
||||
val dataSourceFactory = DefaultDataSource.Factory(this, httpFactory)
|
||||
|
||||
// CacheBitmapLoader caches fetched bitmaps in-memory so that
|
||||
// the notification never has to re-fetch artwork on auto-advance.
|
||||
val executor = Executors.newSingleThreadExecutor()
|
||||
bitmapLoader = CacheBitmapLoader(
|
||||
DataSourceBitmapLoader(
|
||||
MoreExecutors.listeningDecorator(executor),
|
||||
dataSourceFactory
|
||||
)
|
||||
)
|
||||
|
||||
val audioAttributes = AudioAttributes.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.build()
|
||||
|
||||
player = ExoPlayer.Builder(this)
|
||||
.setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory))
|
||||
.setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true)
|
||||
.setHandleAudioBecomingNoisy(true) // pause on headphone unplug
|
||||
.setWakeMode(C.WAKE_MODE_NETWORK) // holds WakeLock + WifiLock through Doze
|
||||
.build()
|
||||
|
||||
// ForwardingPlayer that always reports next/prev as available commands.
|
||||
// ExoPlayer drops those commands when its queue has only one item (single-track model),
|
||||
// which makes Media3 hide the skip buttons in the notification. JS still owns queue
|
||||
// logic — the MediaSession callback intercepts the commands and delegates to JS.
|
||||
// seekToNext* are no-oped here so ExoPlayer never tries to advance its single-item
|
||||
// queue; JS handles all actual navigation via stateListener callbacks.
|
||||
val sessionPlayer = object : ForwardingPlayer(player) {
|
||||
override fun getAvailableCommands(): Player.Commands =
|
||||
super.getAvailableCommands().buildUpon()
|
||||
.add(Player.COMMAND_SEEK_TO_NEXT)
|
||||
.add(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
|
||||
.add(Player.COMMAND_SEEK_TO_PREVIOUS)
|
||||
.add(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
|
||||
.build()
|
||||
|
||||
override fun isCommandAvailable(command: Int): Boolean = when (command) {
|
||||
Player.COMMAND_SEEK_TO_NEXT,
|
||||
Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM,
|
||||
Player.COMMAND_SEEK_TO_PREVIOUS,
|
||||
Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM -> true
|
||||
else -> super.isCommandAvailable(command)
|
||||
}
|
||||
|
||||
override fun seekToNext() {}
|
||||
override fun seekToNextMediaItem() {}
|
||||
override fun seekToPrevious() {}
|
||||
override fun seekToPreviousMediaItem() {}
|
||||
}
|
||||
|
||||
// Tapping the notification opens the app directly to the Now Playing screen.
|
||||
val notifTapIntent = Intent(this, MainActivity::class.java).apply {
|
||||
putExtra("open_now_playing", true)
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
}
|
||||
val notifTapPending = PendingIntent.getActivity(
|
||||
this, 0, notifTapIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
mediaSession = MediaSession.Builder(this, sessionPlayer)
|
||||
.setBitmapLoader(bitmapLoader)
|
||||
.setSessionActivity(notifTapPending)
|
||||
.setCallback(object : MediaSession.Callback {
|
||||
// Intercept notification next/prev so JS (not ExoPlayer) handles queue logic.
|
||||
// With single-track model ExoPlayer has no queue to advance — block the command
|
||||
// and delegate to JS instead.
|
||||
override fun onPlayerCommandRequest(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
playerCommand: Int
|
||||
): Int = when (playerCommand) {
|
||||
Player.COMMAND_SEEK_TO_NEXT,
|
||||
Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM -> {
|
||||
stateListener?.onSkipToNext()
|
||||
// Return SUCCESS so Media3 keeps the button enabled in the notification.
|
||||
// The ForwardingPlayer no-ops seekToNext so ExoPlayer does nothing.
|
||||
androidx.media3.session.SessionResult.RESULT_SUCCESS
|
||||
}
|
||||
Player.COMMAND_SEEK_TO_PREVIOUS,
|
||||
Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM -> {
|
||||
stateListener?.onSkipToPrevious()
|
||||
androidx.media3.session.SessionResult.RESULT_SUCCESS
|
||||
}
|
||||
else -> androidx.media3.session.SessionResult.RESULT_SUCCESS
|
||||
}
|
||||
})
|
||||
.build()
|
||||
|
||||
player.addListener(PlayerListener())
|
||||
|
||||
// Pre-warm: load the last played track immediately so ExoPlayer starts
|
||||
// buffering while the WebView is still loading — reduces startup latency.
|
||||
try {
|
||||
val prefs = getSharedPreferences("ns_prefs", android.content.Context.MODE_PRIVATE)
|
||||
val trackJson = prefs.getString("last_played_track", null)
|
||||
if (!trackJson.isNullOrBlank()) {
|
||||
val track = Track.fromJson(org.json.JSONObject(trackJson))
|
||||
player.setMediaItem(track.toMediaItem())
|
||||
player.prepare()
|
||||
// No play() — wait for JS to decide whether to auto-play
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo) = mediaSession
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
player.stop()
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
instance = null
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
mediaSession.release()
|
||||
player.release()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
// ─── Artwork pre-warming ──────────────────────────────────────────────────
|
||||
|
||||
// Loads the next `count` tracks' artwork into the CacheBitmapLoader so that
|
||||
// onMediaItemTransition never stalls waiting for a fresh HTTP fetch.
|
||||
// fromIndex: explicit start index; -1 means use player.currentMediaItemIndex.
|
||||
private fun warmArtworkCache(count: Int = 3, fromIndex: Int = -1) {
|
||||
val startIdx = if (fromIndex >= 0) fromIndex else player.currentMediaItemIndex
|
||||
val total = player.mediaItemCount
|
||||
for (offset in 0 until count) {
|
||||
val idx = startIdx + offset
|
||||
if (idx >= total) break
|
||||
val item = player.getMediaItemAt(idx)
|
||||
if (item.mediaMetadata.artworkUri != null) {
|
||||
bitmapLoader.loadBitmapFromMetadata(item.mediaMetadata)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Called by WebBridge right after setMediaItems so artwork fetch starts
|
||||
// before prepare()/play() — avoids the race where the notification renders
|
||||
// before the bitmap is in the cache.
|
||||
fun publicWarmArtwork(startIndex: Int) = warmArtworkCache(count = 5, fromIndex = startIndex)
|
||||
|
||||
// ─── Player.Listener ─────────────────────────────────────────────────────
|
||||
|
||||
private inner class PlayerListener : Player.Listener {
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
val state = player.playbackState
|
||||
stateListener?.onPlaybackStateChanged(
|
||||
isPlaying,
|
||||
isBuffering = state == Player.STATE_BUFFERING,
|
||||
isLoading = state == Player.STATE_IDLE || state == Player.STATE_BUFFERING
|
||||
)
|
||||
if (isPlaying) handler.post(positionTicker)
|
||||
else handler.removeCallbacks(positionTicker)
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
val isPlaying = player.isPlaying
|
||||
stateListener?.onPlaybackStateChanged(
|
||||
isPlaying,
|
||||
isBuffering = playbackState == Player.STATE_BUFFERING,
|
||||
isLoading = playbackState == Player.STATE_IDLE
|
||||
)
|
||||
// STATE_ENDED fires when the last item finishes with no repeat
|
||||
if (playbackState == Player.STATE_ENDED) stateListener?.onQueueEnded()
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
val reasonStr = when (reason) {
|
||||
Player.MEDIA_ITEM_TRANSITION_REASON_AUTO -> "auto"
|
||||
Player.MEDIA_ITEM_TRANSITION_REASON_SEEK -> "seek"
|
||||
Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED -> "queue"
|
||||
else -> "seek"
|
||||
}
|
||||
// Fire immediately — snapshot must always be fresh so the JS-side
|
||||
// getState() reconciliation in onTrackChanged sees current native state.
|
||||
stateListener?.onTrackChanged(
|
||||
player.currentMediaItemIndex,
|
||||
mediaItem?.mediaId ?: "",
|
||||
reasonStr
|
||||
)
|
||||
// Pre-warm artwork for current + next tracks so notification
|
||||
// never shows a blank cover on auto-advance.
|
||||
warmArtworkCache()
|
||||
}
|
||||
|
||||
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
||||
// Queue was replaced (setQueue / addToQueue from JS) — warm cache from new start.
|
||||
if (reason == Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) {
|
||||
warmArtworkCache()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
stateListener?.onError(error.message ?: "Playback error")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
package com.novasonic.app
|
||||
|
||||
// Replaced by Media3-managed notifications (MusicService.kt).
|
||||
// Notification actions are now handled natively by Media3 MediaSession.
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.novasonic.app
|
||||
|
||||
import android.app.Application
|
||||
import com.novasonic.app.data.MusicRepository
|
||||
import com.novasonic.app.player.PlayerController
|
||||
|
||||
class NovaSonicApp : Application() {
|
||||
|
||||
lateinit var repository: MusicRepository
|
||||
private set
|
||||
lateinit var playerController: PlayerController
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
repository = MusicRepository(applicationContext)
|
||||
playerController = PlayerController(applicationContext, repository)
|
||||
playerController.connect()
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience extension to access app-level singletons from any Activity/Composable context.
|
||||
val android.content.Context.app: NovaSonicApp
|
||||
get() = applicationContext as NovaSonicApp
|
||||
@@ -1,9 +0,0 @@
|
||||
package com.novasonic.app
|
||||
|
||||
/**
|
||||
* Legacy singleton — no longer used. Kept to avoid breaking any stale git references.
|
||||
* State is now pushed directly from MusicService.StateListener via MainActivity.callJs().
|
||||
*/
|
||||
object NovaSonicBridge {
|
||||
@Volatile var jsRunner: ((String) -> Unit)? = null
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
package com.novasonic.app
|
||||
|
||||
// Replaced by WebBridge.kt (window.NSBridge).
|
||||
// Kept as an empty file so git history is preserved.
|
||||
@@ -1,177 +0,0 @@
|
||||
package com.novasonic.app
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.webkit.JavascriptInterface
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.session.MediaController
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Exposed to the WebView as `window.NSBridge`.
|
||||
* All @JavascriptInterface methods are called on a WebView background thread — every
|
||||
* MediaController call is posted to the main looper before execution.
|
||||
*/
|
||||
class WebBridge(private val controllerProvider: () -> MediaController?) {
|
||||
|
||||
// Snapshot of current playback state; read from getState() on any thread.
|
||||
@Volatile private var stateSnapshot = "{}"
|
||||
|
||||
fun updateSnapshot(snapshot: String) { stateSnapshot = snapshot }
|
||||
|
||||
// ─── Queue management ─────────────────────────────────────────────────────
|
||||
|
||||
@JavascriptInterface
|
||||
fun setQueue(queueJson: String, startIndex: Int, autoPlay: Boolean) {
|
||||
val items = parseTrackList(queueJson).map { it.toMediaItem() }
|
||||
if (items.isEmpty()) return
|
||||
runOnMain {
|
||||
val ctrl = controllerProvider() ?: return@runOnMain
|
||||
val idx = startIndex.coerceAtLeast(0)
|
||||
// If ExoPlayer already has this exact single track buffered from the cold-start
|
||||
// pre-warm, skip setMediaItems+prepare so we don't throw away the buffered data.
|
||||
// Just call play() — this makes the "resume last track" instant instead of ~10s.
|
||||
val alreadyReady = items.size == 1 &&
|
||||
ctrl.mediaItemCount == 1 &&
|
||||
ctrl.currentMediaItem?.mediaId == items[0].mediaId &&
|
||||
ctrl.playbackState != Player.STATE_IDLE
|
||||
if (!alreadyReady) {
|
||||
ctrl.setMediaItems(items, idx, 0L)
|
||||
// Pre-warm artwork before prepare()/play() so the notification bitmap
|
||||
// cache is populated before Media3 renders the first notification.
|
||||
MusicService.instance?.publicWarmArtwork(idx)
|
||||
ctrl.prepare()
|
||||
}
|
||||
if (autoPlay) ctrl.play()
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun addToQueue(trackJson: String, position: Int) {
|
||||
val track = try { Track.fromJson(JSONObject(trackJson)) } catch (_: Exception) { return }
|
||||
runOnMain {
|
||||
val ctrl = controllerProvider() ?: return@runOnMain
|
||||
val item = track.toMediaItem()
|
||||
if (position < 0 || position >= ctrl.mediaItemCount) ctrl.addMediaItem(item)
|
||||
else ctrl.addMediaItem(position, item)
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun removeFromQueue(index: Int) {
|
||||
runOnMain { controllerProvider()?.removeMediaItem(index) }
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun clearQueue() {
|
||||
runOnMain {
|
||||
val ctrl = controllerProvider() ?: return@runOnMain
|
||||
ctrl.stop()
|
||||
ctrl.clearMediaItems()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Transport ────────────────────────────────────────────────────────────
|
||||
|
||||
@JavascriptInterface
|
||||
fun play() { runOnMain { controllerProvider()?.play() } }
|
||||
|
||||
@JavascriptInterface
|
||||
fun pause() { runOnMain { controllerProvider()?.pause() } }
|
||||
|
||||
@JavascriptInterface
|
||||
fun togglePlay() {
|
||||
runOnMain {
|
||||
val ctrl = controllerProvider() ?: return@runOnMain
|
||||
if (ctrl.isPlaying) ctrl.pause() else ctrl.play()
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun seekTo(positionMs: Long) {
|
||||
runOnMain { controllerProvider()?.seekTo(positionMs) }
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun seekToIndex(index: Int, positionMs: Long) {
|
||||
runOnMain { controllerProvider()?.seekTo(index, positionMs) }
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun next() { runOnMain { controllerProvider()?.seekToNextMediaItem() } }
|
||||
|
||||
@JavascriptInterface
|
||||
fun prev() {
|
||||
runOnMain {
|
||||
val ctrl = controllerProvider() ?: return@runOnMain
|
||||
if (ctrl.currentPosition > 3000) ctrl.seekTo(0)
|
||||
else ctrl.seekToPreviousMediaItem()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Playback settings ────────────────────────────────────────────────────
|
||||
|
||||
@JavascriptInterface
|
||||
fun setRepeatMode(mode: String) {
|
||||
runOnMain {
|
||||
val ctrl = controllerProvider() ?: return@runOnMain
|
||||
ctrl.repeatMode = when (mode) {
|
||||
"one" -> Player.REPEAT_MODE_ONE
|
||||
"all" -> Player.REPEAT_MODE_ALL
|
||||
else -> Player.REPEAT_MODE_OFF
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun setShuffleMode(enabled: Boolean) {
|
||||
runOnMain { controllerProvider()?.shuffleModeEnabled = enabled }
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun setVolume(volume: Float) {
|
||||
runOnMain { controllerProvider()?.volume = volume }
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun setPlaybackSpeed(speed: Float) {
|
||||
runOnMain { controllerProvider()?.setPlaybackSpeed(speed) }
|
||||
}
|
||||
|
||||
// ─── State snapshot (synchronous read from JS) ────────────────────────────
|
||||
|
||||
@JavascriptInterface
|
||||
fun getState(): String = stateSnapshot
|
||||
|
||||
// ─── Last played (persisted for startup preload) ──────────────────────────
|
||||
|
||||
// Called by JS whenever the current song changes — persists to SharedPreferences
|
||||
// so MusicService can start buffering immediately on next launch before JS loads.
|
||||
@JavascriptInterface
|
||||
fun saveLastPlayed(trackJson: String) {
|
||||
runOnMain {
|
||||
try {
|
||||
val ctx = MusicService.instance ?: return@runOnMain
|
||||
ctx.getSharedPreferences("ns_prefs", android.content.Context.MODE_PRIVATE)
|
||||
.edit().putString("last_played_track", trackJson).apply()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Overlay state (for back-button routing) ──────────────────────────────
|
||||
|
||||
// Written by WebView thread; read by UI thread — must be @Volatile.
|
||||
@Volatile private var hasOpenOverlay = false
|
||||
|
||||
@JavascriptInterface
|
||||
fun setOverlayState(active: Boolean) { hasOpenOverlay = active }
|
||||
|
||||
// Called by MainActivity on back press; returns true if JS owns the event.
|
||||
fun consumeBackPress(): Boolean = hasOpenOverlay
|
||||
|
||||
// ─── Internal ─────────────────────────────────────────────────────────────
|
||||
|
||||
private fun runOnMain(block: () -> Unit) {
|
||||
Handler(Looper.getMainLooper()).post(block)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.novasonic.app.api
|
||||
|
||||
import com.novasonic.app.api.model.*
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.android.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/** Talks to the NovaSonic download/importer backend (Express server, default :3700). */
|
||||
class ImporterClient(private val backendUrl: String) {
|
||||
|
||||
private val http = HttpClient(Android) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json { ignoreUnknownKeys = true; coerceInputValues = true })
|
||||
}
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 40_000
|
||||
connectTimeoutMillis = 12_000
|
||||
socketTimeoutMillis = 40_000
|
||||
}
|
||||
}
|
||||
|
||||
private fun base() = backendUrl.trimEnd('/')
|
||||
|
||||
companion object {
|
||||
/** Detect which importer endpoint to hit from a pasted URL. */
|
||||
fun platformFor(url: String): String? = when {
|
||||
url.contains("spotify.com") -> "spotify"
|
||||
url.contains("soundcloud.com") -> "soundcloud"
|
||||
url.contains("youtube.com") ||
|
||||
url.contains("youtu.be") -> "youtube"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun resolve(platform: String, url: String): ImportResolveResponse =
|
||||
http.post("${base()}/api/$platform/resolve") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(ResolveRequest(url))
|
||||
}.body()
|
||||
|
||||
suspend fun download(request: DownloadRequest) {
|
||||
http.post("${base()}/api/download") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(request)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun status(jobId: String): DownloadStatus =
|
||||
http.get("${base()}/api/status/$jobId").body()
|
||||
|
||||
fun close() = http.close()
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.novasonic.app.api
|
||||
|
||||
import com.novasonic.app.api.model.*
|
||||
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.http.ContentType
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
object ItunesClient {
|
||||
|
||||
private val http = HttpClient(Android) {
|
||||
install(ContentNegotiation) {
|
||||
// iTunes returns text/javascript instead of application/json — accept any content type
|
||||
json(Json { ignoreUnknownKeys = true; coerceInputValues = true }, contentType = ContentType.Any)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun search(
|
||||
query: String,
|
||||
entity: String = "musicTrack,album,musicArtist",
|
||||
limit: Int = 30,
|
||||
country: String = "us",
|
||||
): ItunesSearchResponse = http.get("https://itunes.apple.com/search") {
|
||||
parameter("term", query)
|
||||
parameter("media", "music")
|
||||
parameter("entity", entity)
|
||||
parameter("limit", limit)
|
||||
parameter("country", country)
|
||||
}.body()
|
||||
|
||||
suspend fun lookupAlbum(collectionId: Long): List<ItunesItem> {
|
||||
val resp: ItunesSearchResponse = http.get("https://itunes.apple.com/lookup") {
|
||||
parameter("id", collectionId)
|
||||
parameter("entity", "song")
|
||||
}.body()
|
||||
// First result is the album itself; rest are tracks
|
||||
return resp.results.filter { it.kind == "song" }
|
||||
}
|
||||
|
||||
/** All albums for an iTunes artist */
|
||||
suspend fun lookupArtistAlbums(artistId: Long): List<ItunesItem> {
|
||||
val resp: ItunesSearchResponse = http.get("https://itunes.apple.com/lookup") {
|
||||
parameter("id", artistId)
|
||||
parameter("entity", "album")
|
||||
parameter("limit", 200)
|
||||
}.body()
|
||||
return resp.results.filter { it.wrapperType == "collection" }
|
||||
}
|
||||
|
||||
/** 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
|
||||
}.body()
|
||||
return resp.results.filter { it.wrapperType == "track" && it.kind == "song" }.take(limit)
|
||||
}
|
||||
|
||||
/** Top albums RSS feed for a given country */
|
||||
suspend fun topAlbums(country: String = "us", limit: Int = 25): List<ItunesRssEntry> {
|
||||
val resp: ItunesRssResponse =
|
||||
http.get("https://itunes.apple.com/$country/rss/topalbums/limit=$limit/json").body()
|
||||
return resp.feed?.entry ?: emptyList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package com.novasonic.app.api
|
||||
|
||||
import com.novasonic.app.api.model.*
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.android.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.plugins.logging.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.security.MessageDigest
|
||||
import kotlin.random.Random
|
||||
|
||||
class SubsonicClient(
|
||||
private val serverUrl: String,
|
||||
private val username: String,
|
||||
private val password: String,
|
||||
private val clientName: String = "NovaSonic",
|
||||
private val apiVersion: String = "1.16.1",
|
||||
) {
|
||||
private val http = HttpClient(Android) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
})
|
||||
}
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 20_000
|
||||
connectTimeoutMillis = 12_000
|
||||
socketTimeoutMillis = 20_000
|
||||
}
|
||||
install(Logging) {
|
||||
level = LogLevel.INFO
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private fun authParams(): Map<String, String> {
|
||||
val salt = Random.nextInt(100_000, 999_999).toString()
|
||||
val token = md5(password + salt)
|
||||
return mapOf("u" to username, "t" to token, "s" to salt,
|
||||
"v" to apiVersion, "c" to clientName, "f" to "json")
|
||||
}
|
||||
|
||||
private fun md5(input: String): String =
|
||||
MessageDigest.getInstance("MD5")
|
||||
.digest(input.toByteArray())
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
|
||||
private fun url(path: String) = "${serverUrl.trimEnd('/')}/rest/$path"
|
||||
|
||||
// ── Core request ──────────────────────────────────────────────────────────
|
||||
|
||||
private suspend fun get(path: String, params: Map<String, Any?> = emptyMap()): SubsonicResponse {
|
||||
val combined = authParams() + params.filterValues { it != null }
|
||||
.mapValues { it.value.toString() }
|
||||
val resp: SubsonicWrapper = http.get(url(path)) {
|
||||
combined.forEach { (k, v) -> parameter(k, v) }
|
||||
}.body()
|
||||
if (resp.response.status != "ok") {
|
||||
val err = resp.response.error
|
||||
throw SubsonicException(err?.code ?: -1, err?.message ?: "Unknown error")
|
||||
}
|
||||
return resp.response
|
||||
}
|
||||
|
||||
// ── Cover art / stream URLs (no auth roundtrip needed for URL building) ───
|
||||
|
||||
fun coverArtUrl(id: String, size: Int = 300): String {
|
||||
val salt = "novasonic"
|
||||
val token = md5(password + salt)
|
||||
val base = url("getCoverArt.view")
|
||||
return "$base?id=$id&size=$size&u=$username&t=$token&s=$salt&v=$apiVersion&c=$clientName&f=json"
|
||||
}
|
||||
|
||||
fun streamUrl(id: String, maxBitRate: Int? = null): String {
|
||||
val salt = "novasonic"
|
||||
val token = md5(password + salt)
|
||||
val base = url("stream.view")
|
||||
val br = if (maxBitRate != null) "&maxBitRate=$maxBitRate" else ""
|
||||
return "$base?id=$id$br&u=$username&t=$token&s=$salt&v=$apiVersion&c=$clientName&f=json"
|
||||
}
|
||||
|
||||
// ── API methods ───────────────────────────────────────────────────────────
|
||||
|
||||
suspend fun ping() = get("ping.view")
|
||||
|
||||
suspend fun getAlbumList(
|
||||
type: String = "newest",
|
||||
size: Int = 30,
|
||||
offset: Int = 0,
|
||||
): List<Album> = get("getAlbumList2.view",
|
||||
mapOf("type" to type, "size" to size, "offset" to offset))
|
||||
.albumList2?.album ?: emptyList()
|
||||
|
||||
suspend fun getAlbum(id: String): Album =
|
||||
get("getAlbum.view", mapOf("id" to id)).album
|
||||
?: throw SubsonicException(-1, "Album not found")
|
||||
|
||||
suspend fun getArtists(): List<ArtistIndex> =
|
||||
get("getArtists.view").artists?.index ?: emptyList()
|
||||
|
||||
suspend fun getArtist(id: String): Artist =
|
||||
get("getArtist.view", mapOf("id" to id)).artist
|
||||
?: throw SubsonicException(-1, "Artist not found")
|
||||
|
||||
suspend fun getSong(id: String): Song =
|
||||
get("getSong.view", mapOf("id" to id)).song
|
||||
?: throw SubsonicException(-1, "Song not found")
|
||||
|
||||
suspend fun search(query: String, albumCount: Int = 10, artistCount: Int = 5, songCount: Int = 20): SearchResult3 =
|
||||
get("search3.view", mapOf(
|
||||
"query" to query,
|
||||
"albumCount" to albumCount,
|
||||
"artistCount" to artistCount,
|
||||
"songCount" to songCount,
|
||||
)).searchResult3 ?: SearchResult3()
|
||||
|
||||
suspend fun getPlaylists(): List<Playlist> =
|
||||
get("getPlaylists.view").playlists?.playlist ?: emptyList()
|
||||
|
||||
suspend fun getPlaylist(id: String): PlaylistWithSongs =
|
||||
get("getPlaylist.view", mapOf("id" to id)).playlist
|
||||
?: throw SubsonicException(-1, "Playlist not found")
|
||||
|
||||
suspend fun getStarred(): Starred2 =
|
||||
get("getStarred2.view").starred2 ?: Starred2()
|
||||
|
||||
suspend fun getRandomSongs(size: Int = 50): List<Song> =
|
||||
get("getRandomSongs.view", mapOf("size" to size)).randomSongs?.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))
|
||||
|
||||
suspend fun scrobble(id: String, submission: Boolean = true) =
|
||||
get("scrobble.view", mapOf("id" to id, "submission" to submission))
|
||||
|
||||
suspend fun getArtistInfo(id: String, count: Int = 10): com.novasonic.app.api.model.ArtistInfo =
|
||||
get("getArtistInfo2.view", mapOf("id" to id, "count" to count)).artistInfo2
|
||||
?: com.novasonic.app.api.model.ArtistInfo()
|
||||
|
||||
suspend fun getTopSongs(artist: String, count: Int = 10): List<com.novasonic.app.api.model.Song> =
|
||||
get("getTopSongs.view", mapOf("artist" to artist, "count" to count)).topSongs?.song
|
||||
?: emptyList()
|
||||
|
||||
suspend fun getGenres(): List<com.novasonic.app.api.model.Genre> =
|
||||
get("getGenres.view").genres?.genre ?: emptyList()
|
||||
|
||||
suspend fun getSongsByGenre(genre: String, count: Int = 50): List<com.novasonic.app.api.model.Song> =
|
||||
get("getSongsByGenre.view", mapOf("genre" to genre, "count" to count)).songsByGenre?.song
|
||||
?: emptyList()
|
||||
|
||||
suspend fun getLyrics(songId: String): List<com.novasonic.app.api.model.StructuredLyrics> =
|
||||
get("getLyricsBySongId.view", mapOf("id" to songId)).lyricsList?.structuredLyrics
|
||||
?: emptyList()
|
||||
|
||||
suspend fun addSongToPlaylist(playlistId: String, songId: String) =
|
||||
getList("updatePlaylist.view", listOf("playlistId" to playlistId, "songIdToAdd" to songId))
|
||||
|
||||
suspend fun createPlaylist(name: String, songIds: List<String> = emptyList()): com.novasonic.app.api.model.PlaylistWithSongs {
|
||||
val params = mutableListOf("name" to name)
|
||||
songIds.forEach { params += "songId" to it }
|
||||
return getList("createPlaylist.view", params).playlist
|
||||
?: throw SubsonicException(-1, "Failed to create playlist")
|
||||
}
|
||||
|
||||
fun close() = http.close()
|
||||
|
||||
private suspend fun getList(path: String, params: List<Pair<String, String>>): SubsonicResponse {
|
||||
val allParams = authParams().entries.map { (k, v) -> k to v } + params
|
||||
val resp: SubsonicWrapper = http.get(url(path)) {
|
||||
allParams.forEach { (k, v) -> parameter(k, v) }
|
||||
}.body()
|
||||
if (resp.response.status != "ok") {
|
||||
val err = resp.response.error
|
||||
throw SubsonicException(err?.code ?: -1, err?.message ?: "Unknown error")
|
||||
}
|
||||
return resp.response
|
||||
}
|
||||
}
|
||||
|
||||
class SubsonicException(val code: Int, message: String) : Exception(message)
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.novasonic.app.api.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** Response from POST {backend}/api/{platform}/resolve */
|
||||
@Serializable
|
||||
data class ImportResolveResponse(
|
||||
val type: String? = null, // "track" | "album" | "playlist"
|
||||
val name: String? = null,
|
||||
val artist: String? = null,
|
||||
val artworkUrl: String? = null,
|
||||
val tracks: List<ImportTrack> = emptyList(),
|
||||
val skippedLocal: Int = 0,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ImportTrack(
|
||||
val trackName: String? = null,
|
||||
val artistName: String? = null,
|
||||
val collectionName: String? = null,
|
||||
val artworkUrl100: String? = null,
|
||||
val previewUrl: String? = null,
|
||||
val durationMs: Long? = null,
|
||||
val trackNumber: Int? = null,
|
||||
)
|
||||
|
||||
/** Body for POST {backend}/api/download */
|
||||
@Serializable
|
||||
data class DownloadRequest(
|
||||
val jobId: String,
|
||||
val type: String, // "track" | "album" | "artist"
|
||||
val title: String,
|
||||
val artist: String? = null,
|
||||
val album: String? = null,
|
||||
val artworkUrl: String? = null,
|
||||
val itunesId: Long? = null,
|
||||
val collectionId: Long? = null,
|
||||
val tracks: List<ImportTrack> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ResolveRequest(val url: String)
|
||||
|
||||
/** Response from GET {backend}/api/status/{jobId} */
|
||||
@Serializable
|
||||
data class DownloadStatus(
|
||||
val status: String? = null, // "pending" | "downloading" | "done" | "error"
|
||||
val progress: Int? = null,
|
||||
val message: String? = null,
|
||||
val completed: Int? = null,
|
||||
val total: Int? = null,
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.novasonic.app.api.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ItunesSearchResponse(
|
||||
val resultCount: Int = 0,
|
||||
val results: List<ItunesItem> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ItunesItem(
|
||||
val wrapperType: String? = null, // "track" | "collection" | "artist"
|
||||
val kind: String? = null, // "song" | "album" etc
|
||||
val artistId: Long? = null,
|
||||
val collectionId: Long? = null,
|
||||
val trackId: Long? = null,
|
||||
val artistName: String? = null,
|
||||
val collectionName: String? = null,
|
||||
val trackName: String? = null,
|
||||
val artworkUrl100: String? = null,
|
||||
val previewUrl: String? = null,
|
||||
val trackTimeMillis: Long? = null,
|
||||
val releaseDate: String? = null,
|
||||
val primaryGenreName: String? = null,
|
||||
val trackCount: Int? = null,
|
||||
val trackNumber: Int? = null,
|
||||
val collectionPrice: Double? = null,
|
||||
)
|
||||
|
||||
// iTunes RSS feed response
|
||||
@Serializable
|
||||
data class ItunesRssResponse(
|
||||
val feed: ItunesFeed? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ItunesFeed(
|
||||
val entry: List<ItunesRssEntry> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ItunesRssEntry(
|
||||
@SerialName("im:name") val name: ItunesLabel? = null,
|
||||
@SerialName("im:artist") val artist: ItunesLabel? = null,
|
||||
@SerialName("im:image") val image: List<ItunesLabel> = emptyList(),
|
||||
@SerialName("im:itemCount") val itemCount: ItunesLabel? = null,
|
||||
val id: ItunesId? = null,
|
||||
val category: ItunesCategory? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ItunesLabel(
|
||||
val label: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ItunesId(
|
||||
val label: String? = null,
|
||||
val attributes: ItunesIdAttributes? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ItunesIdAttributes(
|
||||
@SerialName("im:id") val imId: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ItunesCategory(
|
||||
val attributes: ItunesCategoryAttributes? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ItunesCategoryAttributes(
|
||||
val term: String? = null,
|
||||
)
|
||||
|
||||
// Helper to get high-res artwork (replace 100x100 with 600x600)
|
||||
fun ItunesItem.artworkHd(): String? =
|
||||
artworkUrl100?.replace("100x100bb", "600x600bb")?.replace("/100x100/", "/600x600/")
|
||||
|
||||
fun ItunesRssEntry.artworkHd(): String? =
|
||||
image.lastOrNull()?.label // last image is always the largest in the RSS
|
||||
@@ -0,0 +1,194 @@
|
||||
package com.novasonic.app.api.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
// ── Subsonic JSON envelope ────────────────────────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
data class SubsonicWrapper(
|
||||
@SerialName("subsonic-response") val response: SubsonicResponse,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SubsonicResponse(
|
||||
val status: String,
|
||||
val version: String = "1.16.1",
|
||||
val error: SubsonicError? = null,
|
||||
|
||||
val musicFolders: MusicFoldersResult? = null,
|
||||
val albumList2: AlbumListResult? = null,
|
||||
val album: Album? = null,
|
||||
val artists: ArtistsResult? = null,
|
||||
val artist: Artist? = null,
|
||||
val searchResult3: SearchResult3? = null,
|
||||
val playlists: PlaylistsResult? = null,
|
||||
val playlist: PlaylistWithSongs? = null,
|
||||
val song: Song? = null,
|
||||
val starred2: Starred2? = null,
|
||||
val randomSongs: SongListResult? = null,
|
||||
val scanStatus: ScanStatus? = null,
|
||||
val artistInfo2: ArtistInfo? = null,
|
||||
val topSongs: SongListResult? = null,
|
||||
val genres: GenresResult? = null,
|
||||
val songsByGenre: SongListResult? = null,
|
||||
val lyricsList: LyricsListResult? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SubsonicError(val code: Int, val message: String)
|
||||
|
||||
// ── Domain models ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
data class Song(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val album: String? = null,
|
||||
val albumId: String? = null,
|
||||
val artist: String? = null,
|
||||
val artistId: String? = null,
|
||||
val track: Int? = null,
|
||||
val year: Int? = null,
|
||||
val genre: String? = null,
|
||||
val coverArt: String? = null,
|
||||
val size: Long? = null,
|
||||
val contentType: String? = null,
|
||||
val suffix: String? = null,
|
||||
val duration: Int? = null,
|
||||
val bitRate: Int? = null,
|
||||
val path: String? = null,
|
||||
val playCount: Long? = null,
|
||||
val starred: String? = null, // ISO date string when starred
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Album(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val artist: String? = null,
|
||||
val artistId: String? = null,
|
||||
val coverArt: String? = null,
|
||||
val songCount: Int = 0,
|
||||
val duration: Int = 0,
|
||||
val year: Int? = null,
|
||||
val genre: String? = null,
|
||||
val starred: String? = null,
|
||||
val song: List<Song> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Artist(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val coverArt: String? = null,
|
||||
val albumCount: Int = 0,
|
||||
val starred: String? = null,
|
||||
val album: List<Album> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ArtistIndex(
|
||||
val name: String,
|
||||
val artist: List<Artist> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Playlist(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val comment: String? = null,
|
||||
val owner: String? = null,
|
||||
val songCount: Int = 0,
|
||||
val duration: Int = 0,
|
||||
val coverArt: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PlaylistWithSongs(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val comment: String? = null,
|
||||
val owner: String? = null,
|
||||
val songCount: Int = 0,
|
||||
val duration: Int = 0,
|
||||
val coverArt: String? = null,
|
||||
val entry: List<Song> = emptyList(),
|
||||
)
|
||||
|
||||
// ── Result wrappers ───────────────────────────────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
data class MusicFoldersResult(val musicFolder: List<MusicFolder> = emptyList())
|
||||
|
||||
@Serializable
|
||||
data class MusicFolder(val id: String, val name: String? = null)
|
||||
|
||||
@Serializable
|
||||
data class AlbumListResult(val album: List<Album> = emptyList())
|
||||
|
||||
@Serializable
|
||||
data class ArtistsResult(val index: List<ArtistIndex> = emptyList())
|
||||
|
||||
@Serializable
|
||||
data class PlaylistsResult(val playlist: List<Playlist> = emptyList())
|
||||
|
||||
@Serializable
|
||||
data class SongListResult(val song: List<Song> = emptyList())
|
||||
|
||||
@Serializable
|
||||
data class Starred2(
|
||||
val song: List<Song> = emptyList(),
|
||||
val album: List<Album> = emptyList(),
|
||||
val artist: List<Artist> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SearchResult3(
|
||||
val song: List<Song> = emptyList(),
|
||||
val album: List<Album> = emptyList(),
|
||||
val artist: List<Artist> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ScanStatus(val scanning: Boolean, val count: Long? = null)
|
||||
|
||||
// ── Artist info (Last.fm bio + similar artists) ───────────────────────────────
|
||||
@Serializable
|
||||
data class ArtistInfo(
|
||||
val biography: String? = null,
|
||||
val musicBrainzId: String? = null,
|
||||
val lastFmUrl: String? = null,
|
||||
val smallImageUrl: String? = null,
|
||||
val mediumImageUrl: String? = null,
|
||||
val largeImageUrl: String? = null,
|
||||
val similarArtist: List<Artist> = emptyList(),
|
||||
)
|
||||
|
||||
// ── Genres ────────────────────────────────────────────────────────────────────
|
||||
@Serializable
|
||||
data class Genre(
|
||||
val value: String, // genre name is the text value
|
||||
val songCount: Int = 0,
|
||||
val albumCount: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GenresResult(val genre: List<Genre> = emptyList())
|
||||
|
||||
// ── Lyrics ────────────────────────────────────────────────────────────────────
|
||||
@Serializable
|
||||
data class LyricsLine(val start: Long? = null, val value: String = "")
|
||||
|
||||
@Serializable
|
||||
data class StructuredLyrics(
|
||||
val lang: String = "",
|
||||
val synced: Boolean = false,
|
||||
val line: List<LyricsLine> = emptyList(),
|
||||
val displayArtist: String? = null,
|
||||
val displayTitle: String? = null,
|
||||
val offset: Long? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LyricsListResult(val structuredLyrics: List<StructuredLyrics> = emptyList())
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.novasonic.app.data
|
||||
|
||||
import android.content.Context
|
||||
import com.novasonic.app.api.SubsonicClient
|
||||
import com.novasonic.app.api.model.*
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
object ClientHolder {
|
||||
@Volatile private var client: SubsonicClient? = null
|
||||
@Volatile private var lastCreds: PrefsStore.Credentials? = null
|
||||
|
||||
fun get(creds: PrefsStore.Credentials): SubsonicClient {
|
||||
if (creds != lastCreds) {
|
||||
client?.close()
|
||||
client = SubsonicClient(creds.serverUrl, creds.username, creds.password)
|
||||
lastCreds = creds
|
||||
}
|
||||
return client!!
|
||||
}
|
||||
|
||||
fun invalidate() {
|
||||
client?.close()
|
||||
client = null
|
||||
lastCreds = null
|
||||
}
|
||||
}
|
||||
|
||||
class MusicRepository(private val context: Context) {
|
||||
|
||||
private val prefs = PrefsStore(context)
|
||||
|
||||
private suspend fun api(): SubsonicClient =
|
||||
ClientHolder.get(prefs.credentials.first())
|
||||
|
||||
suspend fun ping() = api().ping()
|
||||
suspend fun getRecentAlbums(size: Int = 20) = api().getAlbumList("newest", size)
|
||||
suspend fun getFrequentAlbums(size: Int = 20) = api().getAlbumList("frequent", size)
|
||||
suspend fun getRecentlyPlayedAlbums(size: Int = 20) = api().getAlbumList("recent", size)
|
||||
suspend fun getRandomAlbums(size: Int = 20) = api().getAlbumList("random", size)
|
||||
suspend fun getAlphaAlbums(size: Int = 40, offset: Int = 0) =
|
||||
api().getAlbumList("alphabeticalByName", size, offset)
|
||||
suspend fun getAlbum(id: String) = api().getAlbum(id)
|
||||
suspend fun getArtists() = api().getArtists()
|
||||
suspend fun getArtist(id: String) = api().getArtist(id)
|
||||
suspend fun getSong(id: String) = api().getSong(id)
|
||||
suspend fun search(query: String) = api().search(query)
|
||||
suspend fun getPlaylists() = api().getPlaylists()
|
||||
suspend fun getPlaylist(id: String) = api().getPlaylist(id)
|
||||
suspend fun getStarred() = api().getStarred()
|
||||
suspend fun getRandomSongs() = api().getRandomSongs()
|
||||
suspend fun star(id: String) = api().star(id)
|
||||
suspend fun unstar(id: String) = api().unstar(id)
|
||||
suspend fun scrobble(id: String) = api().scrobble(id)
|
||||
suspend fun getArtistInfo(id: String) = api().getArtistInfo(id)
|
||||
suspend fun getTopSongs(artistName: String) = api().getTopSongs(artistName)
|
||||
suspend fun getGenres() = api().getGenres()
|
||||
suspend fun getSongsByGenre(genre: String) = api().getSongsByGenre(genre)
|
||||
suspend fun getLyrics(songId: String) = api().getLyrics(songId)
|
||||
suspend fun addSongToPlaylist(playlistId: String, songId: String) = api().addSongToPlaylist(playlistId, songId)
|
||||
suspend fun createPlaylist(name: String, songIds: List<String>) = api().createPlaylist(name, songIds)
|
||||
|
||||
fun coverArtUrl(id: String?, size: Int = 300): String? {
|
||||
val creds = lastCredentials ?: return null
|
||||
return SubsonicClient(creds.serverUrl, creds.username, creds.password)
|
||||
.coverArtUrl(id ?: return null, size)
|
||||
}
|
||||
|
||||
fun streamUrl(id: String): String? {
|
||||
val creds = lastCredentials ?: return null
|
||||
return SubsonicClient(creds.serverUrl, creds.username, creds.password).streamUrl(id)
|
||||
}
|
||||
|
||||
var lastCredentials: PrefsStore.Credentials? = null
|
||||
|
||||
fun credentialsFlow() = prefs.credentials
|
||||
fun appSettingsFlow() = prefs.appSettings
|
||||
suspend fun getBackendUrl(): String = prefs.appSettings.first().backendUrl
|
||||
|
||||
/** Finds Navidrome albums for a given artist name (for iTunes cross-reference). */
|
||||
suspend fun getNdAlbumsForArtist(artistName: String): List<com.novasonic.app.api.model.Album> {
|
||||
// Try exact artist match first, then fall back to album search
|
||||
val artistResult = api().search(artistName, albumCount = 0, artistCount = 10, songCount = 0)
|
||||
val ndArtist = artistResult.artist.find { a ->
|
||||
a.name.equals(artistName, ignoreCase = true) ||
|
||||
a.name.contains(artistName, ignoreCase = true) ||
|
||||
artistName.contains(a.name, ignoreCase = true)
|
||||
}
|
||||
return if (ndArtist != null) {
|
||||
runCatching { api().getArtist(ndArtist.id).album }.getOrDefault(emptyList())
|
||||
} else {
|
||||
api().search(artistName, albumCount = 50, artistCount = 0, songCount = 0).album
|
||||
.filter { it.artist?.contains(artistName, ignoreCase = true) == true }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveCredentials(serverUrl: String, username: String, password: String) {
|
||||
ClientHolder.invalidate()
|
||||
prefs.save(serverUrl, username, password)
|
||||
}
|
||||
|
||||
suspend fun saveTheme(themeId: String) = prefs.saveTheme(themeId)
|
||||
|
||||
suspend fun saveAppSettings(settings: PrefsStore.AppSettings) {
|
||||
val urlChanged = settings.serverUrl != lastCredentials?.serverUrl ||
|
||||
settings.username != lastCredentials?.username ||
|
||||
settings.password != lastCredentials?.password
|
||||
if (urlChanged) ClientHolder.invalidate()
|
||||
prefs.saveAll(settings)
|
||||
}
|
||||
|
||||
suspend fun getScrobbleDelayMs(durationMs: Long): Long {
|
||||
val mode = prefs.appSettings.first().scrobbleMode
|
||||
return when (mode) {
|
||||
"15s" -> 15_000L
|
||||
"30s" -> 30_000L
|
||||
"third" -> (durationMs / 3).coerceAtLeast(15_000L)
|
||||
"two_thirds" -> (durationMs * 2 / 3).coerceAtLeast(15_000L)
|
||||
"end" -> (durationMs - 5_000L).coerceAtLeast(15_000L)
|
||||
else -> 30_000L
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.novasonic.app.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.dataStore by preferencesDataStore("novasonic_prefs")
|
||||
|
||||
class PrefsStore(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
val KEY_SERVER = stringPreferencesKey("server_url")
|
||||
val KEY_USERNAME = stringPreferencesKey("username")
|
||||
val KEY_PASSWORD = stringPreferencesKey("password")
|
||||
val KEY_LASTFM_KEY = stringPreferencesKey("lastfm_key")
|
||||
val KEY_BACKEND_URL = stringPreferencesKey("backend_url")
|
||||
val KEY_SCROBBLE_MODE = stringPreferencesKey("scrobble_mode") // 15s|30s|third|two_thirds|end
|
||||
val KEY_SHOW_COUNT = booleanPreferencesKey("show_play_count")
|
||||
val KEY_THEME = stringPreferencesKey("theme")
|
||||
}
|
||||
|
||||
data class Credentials(val serverUrl: String, val username: String, val password: String) {
|
||||
val isValid get() = serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank()
|
||||
}
|
||||
|
||||
data class AppSettings(
|
||||
val serverUrl: String = "",
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val lastFmKey: String = "",
|
||||
val backendUrl: String = "",
|
||||
val scrobbleMode: String = "30s",
|
||||
val showPlayCount: Boolean = false,
|
||||
val theme: String = "dark-ocean",
|
||||
)
|
||||
|
||||
val credentials: Flow<Credentials> = context.dataStore.data.map { prefs ->
|
||||
Credentials(
|
||||
serverUrl = prefs[KEY_SERVER] ?: "",
|
||||
username = prefs[KEY_USERNAME] ?: "",
|
||||
password = prefs[KEY_PASSWORD] ?: "",
|
||||
)
|
||||
}
|
||||
|
||||
val appSettings: Flow<AppSettings> = context.dataStore.data.map { prefs ->
|
||||
AppSettings(
|
||||
serverUrl = prefs[KEY_SERVER] ?: "",
|
||||
username = prefs[KEY_USERNAME] ?: "",
|
||||
password = prefs[KEY_PASSWORD] ?: "",
|
||||
lastFmKey = prefs[KEY_LASTFM_KEY] ?: "",
|
||||
backendUrl = prefs[KEY_BACKEND_URL] ?: "",
|
||||
scrobbleMode = prefs[KEY_SCROBBLE_MODE] ?: "30s",
|
||||
showPlayCount = prefs[KEY_SHOW_COUNT] ?: false,
|
||||
theme = prefs[KEY_THEME] ?: "dark-ocean",
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun save(serverUrl: String, username: String, password: String) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[KEY_SERVER] = serverUrl.trimEnd('/')
|
||||
prefs[KEY_USERNAME] = username
|
||||
prefs[KEY_PASSWORD] = password
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveAll(settings: AppSettings) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[KEY_SERVER] = settings.serverUrl.trimEnd('/')
|
||||
prefs[KEY_USERNAME] = settings.username
|
||||
prefs[KEY_PASSWORD] = settings.password
|
||||
prefs[KEY_LASTFM_KEY] = settings.lastFmKey
|
||||
prefs[KEY_BACKEND_URL] = settings.backendUrl
|
||||
prefs[KEY_SCROBBLE_MODE] = settings.scrobbleMode
|
||||
prefs[KEY_SHOW_COUNT] = settings.showPlayCount
|
||||
prefs[KEY_THEME] = settings.theme
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveTheme(themeId: String) {
|
||||
context.dataStore.edit { prefs -> prefs[KEY_THEME] = themeId }
|
||||
}
|
||||
|
||||
suspend fun clear() {
|
||||
context.dataStore.edit { it.clear() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package com.novasonic.app.player
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.media3.common.*
|
||||
import androidx.media3.datasource.DataSourceBitmapLoader
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.datasource.DefaultHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.session.*
|
||||
import com.google.common.collect.ImmutableList
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
import com.google.common.util.concurrent.SettableFuture
|
||||
import com.novasonic.app.data.MusicRepository
|
||||
import com.novasonic.app.ui.MainActivity
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class MusicService : MediaLibraryService() {
|
||||
|
||||
companion object {
|
||||
// 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 ITEM_PREFIX_ALBUM = "album:"
|
||||
const val ITEM_PREFIX_ARTIST = "artist:"
|
||||
const val ITEM_PREFIX_PL = "playlist:"
|
||||
}
|
||||
|
||||
private lateinit var player: ExoPlayer
|
||||
private lateinit var session: MediaLibrarySession
|
||||
private lateinit var repo: MusicRepository
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
repo = MusicRepository(applicationContext)
|
||||
|
||||
val httpFactory = DefaultHttpDataSource.Factory()
|
||||
.setUserAgent("NovaSonic-Android/1.0")
|
||||
.setConnectTimeoutMs(15_000)
|
||||
.setReadTimeoutMs(15_000)
|
||||
.setAllowCrossProtocolRedirects(true)
|
||||
|
||||
val dataFactory = DefaultDataSource.Factory(this, httpFactory)
|
||||
|
||||
val executor = Executors.newSingleThreadExecutor()
|
||||
val bitmapLoader = CacheBitmapLoader(
|
||||
DataSourceBitmapLoader(MoreExecutors.listeningDecorator(executor), dataFactory)
|
||||
)
|
||||
|
||||
val audioAttr = AudioAttributes.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.build()
|
||||
|
||||
player = ExoPlayer.Builder(this)
|
||||
.setMediaSourceFactory(DefaultMediaSourceFactory(dataFactory))
|
||||
.setAudioAttributes(audioAttr, true)
|
||||
.setHandleAudioBecomingNoisy(true)
|
||||
.setWakeMode(C.WAKE_MODE_NETWORK)
|
||||
.build()
|
||||
|
||||
val activityIntent = PendingIntent.getActivity(
|
||||
this, 0,
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
session = MediaLibrarySession.Builder(this, player, LibraryCallback())
|
||||
.setBitmapLoader(bitmapLoader)
|
||||
.setSessionActivity(activityIntent)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onGetSession(info: MediaSession.ControllerInfo) = session
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) { player.stop(); stopSelf() }
|
||||
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
session.release()
|
||||
player.release()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
// ── Android Auto / MediaBrowser library callbacks ─────────────────────────
|
||||
|
||||
private inner class LibraryCallback : MediaLibrarySession.Callback {
|
||||
|
||||
override fun onGetLibraryRoot(
|
||||
session: MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
params: LibraryParams?,
|
||||
): ListenableFuture<LibraryResult<MediaItem>> {
|
||||
val isAuto = params?.isRecent == true || isAndroidAuto(browser)
|
||||
val root = MediaItem.Builder()
|
||||
.setMediaId(ROOT_ID)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle("NovaSonic")
|
||||
.setIsPlayable(false)
|
||||
.setIsBrowsable(true)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED)
|
||||
.build()
|
||||
).build()
|
||||
return Futures.immediateFuture(LibraryResult.ofItem(root, null))
|
||||
}
|
||||
|
||||
override fun onGetChildren(
|
||||
session: MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
parentId: String,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
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 {
|
||||
settable.set(LibraryResult.ofItemList(future.await(), null))
|
||||
} catch (e: Exception) {
|
||||
settable.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_UNKNOWN))
|
||||
}
|
||||
}
|
||||
return settable
|
||||
}
|
||||
|
||||
override fun onGetItem(
|
||||
session: MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
mediaId: String,
|
||||
): ListenableFuture<LibraryResult<MediaItem>> {
|
||||
val settable = SettableFuture.create<LibraryResult<MediaItem>>()
|
||||
scope.launch {
|
||||
try {
|
||||
val item = when {
|
||||
mediaId.startsWith(ITEM_PREFIX_ALBUM) -> {
|
||||
val album = repo.getAlbum(mediaId.removePrefix(ITEM_PREFIX_ALBUM))
|
||||
album.toMediaItem(repo)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
if (item != null) settable.set(LibraryResult.ofItem(item, null))
|
||||
else settable.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE))
|
||||
} catch (e: Exception) {
|
||||
settable.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_UNKNOWN))
|
||||
}
|
||||
}
|
||||
return settable
|
||||
}
|
||||
|
||||
// When Android Auto selects a browsable item (album/playlist), load its songs.
|
||||
override fun onAddMediaItems(
|
||||
mediaSession: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
mediaItems: MutableList<MediaItem>,
|
||||
): ListenableFuture<MutableList<MediaItem>> {
|
||||
val settable = SettableFuture.create<MutableList<MediaItem>>()
|
||||
scope.launch {
|
||||
try {
|
||||
val resolved = mediaItems.flatMap { item ->
|
||||
when {
|
||||
item.mediaId.startsWith(ITEM_PREFIX_ALBUM) -> {
|
||||
val album = repo.getAlbum(item.mediaId.removePrefix(ITEM_PREFIX_ALBUM))
|
||||
album.song.map { song -> song.toMediaItem(repo) }
|
||||
}
|
||||
item.mediaId.startsWith(ITEM_PREFIX_PL) -> {
|
||||
val pl = repo.getPlaylist(item.mediaId.removePrefix(ITEM_PREFIX_PL))
|
||||
pl.entry.map { song -> song.toMediaItem(repo) }
|
||||
}
|
||||
else -> listOf(item)
|
||||
}
|
||||
}
|
||||
settable.set(resolved.toMutableList())
|
||||
} catch (e: Exception) {
|
||||
settable.set(mediaItems)
|
||||
}
|
||||
}
|
||||
return settable
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build Android Auto browsable tree ─────────────────────────────────────
|
||||
|
||||
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),
|
||||
)
|
||||
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() } }
|
||||
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) }
|
||||
}
|
||||
parentId.startsWith(ITEM_PREFIX_PL) -> {
|
||||
val pl = repo.getPlaylist(parentId.removePrefix(ITEM_PREFIX_PL))
|
||||
pl.entry.map { it.toMediaItem(repo) }
|
||||
}
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
return ImmutableList.copyOf(items)
|
||||
}
|
||||
|
||||
private fun browseFolder(id: String, title: String, mediaType: Int) =
|
||||
MediaItem.Builder()
|
||||
.setMediaId(id)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
.setIsPlayable(false)
|
||||
.setIsBrowsable(true)
|
||||
.setMediaType(mediaType)
|
||||
.build()
|
||||
).build()
|
||||
|
||||
private fun isAndroidAuto(info: MediaSession.ControllerInfo): Boolean =
|
||||
info.packageName.contains("android.auto", ignoreCase = true) ||
|
||||
info.packageName.contains("goog.android.projected", ignoreCase = true)
|
||||
}
|
||||
|
||||
// ── Extension: model → MediaItem ─────────────────────────────────────────────
|
||||
|
||||
fun com.novasonic.app.api.model.Song.toMediaItem(repo: MusicRepository): MediaItem {
|
||||
val artUri = coverArt?.let { repo.coverArtUrl(it, 512)?.let { url ->
|
||||
android.net.Uri.parse(url) } }
|
||||
val streamUri = repo.streamUrl(id)?.let { android.net.Uri.parse(it) }
|
||||
return MediaItem.Builder()
|
||||
.setMediaId(id)
|
||||
.setUri(streamUri)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
.setArtist(artist)
|
||||
.setAlbumTitle(album)
|
||||
.setArtworkUri(artUri)
|
||||
.setTrackNumber(track)
|
||||
.setIsPlayable(true)
|
||||
.setIsBrowsable(false)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
|
||||
.build()
|
||||
).build()
|
||||
}
|
||||
|
||||
fun com.novasonic.app.api.model.Album.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_ALBUM}$id")
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(name)
|
||||
.setArtist(artist)
|
||||
.setArtworkUri(artUri)
|
||||
.setIsPlayable(true)
|
||||
.setIsBrowsable(true)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM)
|
||||
.build()
|
||||
).build()
|
||||
}
|
||||
|
||||
fun com.novasonic.app.api.model.Artist.toMediaItem(): MediaItem =
|
||||
MediaItem.Builder()
|
||||
.setMediaId("${MusicService.ITEM_PREFIX_ARTIST}$id")
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(name)
|
||||
.setIsPlayable(false)
|
||||
.setIsBrowsable(true)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_ARTIST)
|
||||
.build()
|
||||
).build()
|
||||
|
||||
fun com.novasonic.app.api.model.Playlist.toMediaItem(): MediaItem =
|
||||
MediaItem.Builder()
|
||||
.setMediaId("${MusicService.ITEM_PREFIX_PL}$id")
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(name)
|
||||
.setIsPlayable(true)
|
||||
.setIsBrowsable(true)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
|
||||
.build()
|
||||
).build()
|
||||
@@ -0,0 +1,202 @@
|
||||
package com.novasonic.app.player
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.data.MusicRepository
|
||||
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 PlayerState(
|
||||
val isPlaying: Boolean = false,
|
||||
val isBuffering: Boolean = false,
|
||||
val currentSong: Song? = null,
|
||||
val currentIndex: Int = -1,
|
||||
val positionMs: Long = 0L,
|
||||
val durationMs: Long = 0L,
|
||||
val queue: List<Song> = emptyList(),
|
||||
val repeatMode: Int = Player.REPEAT_MODE_OFF,
|
||||
val shuffled: Boolean = false,
|
||||
val volume: Float = 1f,
|
||||
)
|
||||
|
||||
class PlayerController(private val context: Context, private val repo: MusicRepository) {
|
||||
|
||||
private val _state = MutableStateFlow(PlayerState())
|
||||
val state: StateFlow<PlayerState> = _state.asStateFlow()
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
private var controller: MediaController? = null
|
||||
private var queue: List<Song> = emptyList()
|
||||
private var originalQueue: List<Song> = emptyList()
|
||||
private var isManuallyShuffled = false
|
||||
private var tickerJob: Job? = null
|
||||
private var scrobbleJob: Job? = null
|
||||
|
||||
fun connect() {
|
||||
if (controller != null) return
|
||||
val token = SessionToken(context, ComponentName(context, MusicService::class.java))
|
||||
MediaController.Builder(context, token).buildAsync().also { future ->
|
||||
future.addListener({
|
||||
controller = runCatching { future.get() }.getOrNull()
|
||||
controller?.addListener(PlayerListener())
|
||||
syncState()
|
||||
}, context.mainExecutor)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Playback commands ─────────────────────────────────────────────────────
|
||||
|
||||
fun playSongs(songs: List<Song>, startIndex: Int = 0) {
|
||||
val ctrl = controller ?: return
|
||||
queue = songs
|
||||
originalQueue = songs
|
||||
isManuallyShuffled = false
|
||||
ctrl.shuffleModeEnabled = false
|
||||
val items = songs.map { it.toMediaItem(repo) }
|
||||
ctrl.setMediaItems(items, startIndex, 0L)
|
||||
ctrl.prepare()
|
||||
ctrl.play()
|
||||
syncState()
|
||||
}
|
||||
|
||||
fun play() = controller?.play()
|
||||
fun pause() = controller?.pause()
|
||||
fun togglePlay() = controller?.let { if (it.isPlaying) it.pause() else it.play() }
|
||||
fun seekTo(ms: Long) = controller?.seekTo(ms)
|
||||
fun skipNext() = controller?.seekToNextMediaItem()
|
||||
fun skipPrev() = controller?.seekToPreviousMediaItem()
|
||||
fun seekToItem(index: Int) = controller?.seekToDefaultPosition(index)
|
||||
|
||||
fun setVolume(v: Float) {
|
||||
val clamped = v.coerceIn(0f, 1f)
|
||||
controller?.volume = clamped
|
||||
_state.update { it.copy(volume = clamped) }
|
||||
}
|
||||
|
||||
fun insertNext(song: Song) {
|
||||
val ctrl = controller ?: return
|
||||
val idx = (ctrl.currentMediaItemIndex + 1).coerceAtMost(queue.size)
|
||||
queue = queue.toMutableList().apply { add(idx, song) }
|
||||
originalQueue = originalQueue + song
|
||||
ctrl.addMediaItem(idx, song.toMediaItem(repo))
|
||||
}
|
||||
|
||||
fun addToQueueEnd(song: Song) {
|
||||
val ctrl = controller ?: return
|
||||
queue = queue + song
|
||||
originalQueue = originalQueue + song
|
||||
ctrl.addMediaItem(song.toMediaItem(repo))
|
||||
}
|
||||
|
||||
fun toggleRepeat() {
|
||||
val ctrl = controller ?: return
|
||||
ctrl.repeatMode = when (ctrl.repeatMode) {
|
||||
Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ALL
|
||||
Player.REPEAT_MODE_ALL -> Player.REPEAT_MODE_ONE
|
||||
else -> Player.REPEAT_MODE_OFF
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleShuffle() {
|
||||
val ctrl = controller ?: return
|
||||
val currentIdx = ctrl.currentMediaItemIndex
|
||||
|
||||
if (!isManuallyShuffled) {
|
||||
// Turn ON: shuffle everything after the current song
|
||||
isManuallyShuffled = true
|
||||
val played = queue.take(currentIdx + 1)
|
||||
val remaining = queue.drop(currentIdx + 1).shuffled()
|
||||
queue = played + remaining
|
||||
for (i in ctrl.mediaItemCount - 1 downTo currentIdx + 1) ctrl.removeMediaItem(i)
|
||||
remaining.forEach { s -> ctrl.addMediaItem(s.toMediaItem(repo)) }
|
||||
} else {
|
||||
// Turn OFF: restore original order from the current song's position
|
||||
isManuallyShuffled = false
|
||||
val currentSong = queue.getOrNull(currentIdx)
|
||||
val origIdx = originalQueue.indexOfFirst { it.id == currentSong?.id }.coerceAtLeast(0)
|
||||
val restOfOriginal = originalQueue.drop(origIdx + 1)
|
||||
queue = originalQueue.take(origIdx + 1) + restOfOriginal
|
||||
for (i in ctrl.mediaItemCount - 1 downTo currentIdx + 1) ctrl.removeMediaItem(i)
|
||||
restOfOriginal.forEach { s -> ctrl.addMediaItem(s.toMediaItem(repo)) }
|
||||
}
|
||||
syncState()
|
||||
}
|
||||
|
||||
fun addToQueue(song: Song) = addToQueueEnd(song)
|
||||
|
||||
// ── Ticker ────────────────────────────────────────────────────────────────
|
||||
|
||||
private fun startTicker() {
|
||||
tickerJob?.cancel()
|
||||
tickerJob = scope.launch {
|
||||
while (isActive) {
|
||||
delay(500)
|
||||
val ctrl = controller ?: break
|
||||
_state.update { it.copy(positionMs = ctrl.currentPosition) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopTicker() {
|
||||
tickerJob?.cancel()
|
||||
tickerJob = null
|
||||
}
|
||||
|
||||
// ── Scrobbling ────────────────────────────────────────────────────────────
|
||||
|
||||
private fun scheduleScrobble(song: Song, durationMs: Long) {
|
||||
scrobbleJob?.cancel()
|
||||
scrobbleJob = scope.launch {
|
||||
val delayMs = runCatching { repo.getScrobbleDelayMs(durationMs) }.getOrDefault(30_000L)
|
||||
delay(delayMs)
|
||||
runCatching { repo.scrobble(song.id) }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal ──────────────────────────────────────────────────────────────
|
||||
|
||||
private fun syncState() {
|
||||
val ctrl = controller ?: return
|
||||
val idx = ctrl.currentMediaItemIndex
|
||||
_state.value = PlayerState(
|
||||
isPlaying = ctrl.isPlaying,
|
||||
isBuffering = ctrl.playbackState == Player.STATE_BUFFERING,
|
||||
currentSong = queue.getOrNull(idx),
|
||||
currentIndex = idx,
|
||||
positionMs = ctrl.currentPosition,
|
||||
durationMs = ctrl.duration.coerceAtLeast(0),
|
||||
queue = queue,
|
||||
repeatMode = ctrl.repeatMode,
|
||||
shuffled = isManuallyShuffled,
|
||||
volume = ctrl.volume,
|
||||
)
|
||||
}
|
||||
|
||||
private inner class PlayerListener : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
if (isPlaying) startTicker() else stopTicker()
|
||||
syncState()
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(state: Int) = syncState()
|
||||
|
||||
override fun onMediaItemTransition(item: MediaItem?, reason: Int) {
|
||||
syncState()
|
||||
val ctrl = controller ?: return
|
||||
val song = queue.getOrNull(ctrl.currentMediaItemIndex) ?: return
|
||||
val dur = ctrl.duration.coerceAtLeast(0)
|
||||
scheduleScrobble(song, dur)
|
||||
}
|
||||
|
||||
override fun onShuffleModeEnabledChanged(enabled: Boolean) = syncState()
|
||||
override fun onRepeatModeChanged(mode: Int) = syncState()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
package com.novasonic.app.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.widget.Toast
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
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.navigation.*
|
||||
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.PlaylistPickerSheet
|
||||
import com.novasonic.app.ui.component.SongContextSheet
|
||||
import com.novasonic.app.ui.component.SongInfoSheet
|
||||
import com.novasonic.app.ui.screen.*
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private enum class TopLevel(val route: String, val icon: ImageVector, val label: String) {
|
||||
HOME("home", Icons.Default.Home, "Home"),
|
||||
LIBRARY("library", Icons.Default.LibraryMusic, "Bibliothek"),
|
||||
ERKUNDEN("erkunden", Icons.Default.Explore, "Erkunden"),
|
||||
STARRED("starred", Icons.Default.Favorite, "Favoriten"),
|
||||
SETTINGS("settings", Icons.Default.Settings, "Einstellungen"),
|
||||
}
|
||||
|
||||
private val NavBg = Color(0xF7080814)
|
||||
|
||||
@Composable
|
||||
fun AppNavigation() {
|
||||
val context = LocalContext.current
|
||||
val app = context.app
|
||||
val repo = app.repository
|
||||
val player = app.playerController
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
repo.credentialsFlow().collect { creds -> repo.lastCredentials = creds }
|
||||
}
|
||||
|
||||
// Apply saved theme whenever settings change
|
||||
LaunchedEffect(Unit) {
|
||||
repo.appSettingsFlow().collect { settings -> applyNsTheme(settings.theme) }
|
||||
}
|
||||
|
||||
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) }
|
||||
|
||||
fun coverUrl(coverId: String?) = repo.coverArtUrl(coverId, 600)
|
||||
fun heroCoverUrl(coverId: String?) = repo.coverArtUrl(coverId, 1000)
|
||||
|
||||
fun starToggle(songId: String, currentlyStarred: Boolean) {
|
||||
scope.launch { runCatching { if (currentlyStarred) repo.unstar(songId) else repo.star(songId) } }
|
||||
}
|
||||
|
||||
fun showContextMenu(song: Song) { contextMenuSong = song }
|
||||
|
||||
val currentBackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = currentBackEntry?.destination?.route
|
||||
val isTopLevel = TopLevel.entries.any { it.route == currentRoute }
|
||||
|
||||
val bgGlow = Brush.radialGradient(
|
||||
colors = listOf(Color(0x0AE8A020), Color(0x00000000)),
|
||||
center = Offset.Unspecified, radius = 900f,
|
||||
)
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Scaffold(
|
||||
containerColor = NsBackground,
|
||||
modifier = Modifier.background(NsBackground).background(bgGlow),
|
||||
bottomBar = {
|
||||
if (isTopLevel) {
|
||||
Column {
|
||||
AnimatedVisibility(
|
||||
visible = playerState.currentSong != null,
|
||||
enter = slideInVertically { it } + fadeIn(tween(220)),
|
||||
exit = slideOutVertically { it } + fadeOut(tween(180)),
|
||||
) {
|
||||
MiniPlayer(
|
||||
state = playerState,
|
||||
coverUrl = coverUrl(playerState.currentSong?.coverArt),
|
||||
onTap = { showNowPlaying = true },
|
||||
onTogglePlay = { player.togglePlay() },
|
||||
onSkipPrev = { player.skipPrev() },
|
||||
onSkipNext = { player.skipNext() },
|
||||
)
|
||||
}
|
||||
NavigationBar(containerColor = NavBg, tonalElevation = 0.dp) {
|
||||
TopLevel.entries.forEach { dest ->
|
||||
NavigationBarItem(
|
||||
selected = currentRoute == dest.route,
|
||||
onClick = {
|
||||
navController.navigate(dest.route) {
|
||||
popUpTo(navController.graph.startDestinationId) { saveState = true }
|
||||
launchSingleTop = true; restoreState = true
|
||||
}
|
||||
},
|
||||
icon = { Icon(dest.icon, contentDescription = dest.label) },
|
||||
label = {
|
||||
Text(dest.label, style = TextStyle(
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 10.sp))
|
||||
},
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = NsAccent,
|
||||
selectedTextColor = NsAccent,
|
||||
indicatorColor = NsAccent.copy(alpha = 0.15f),
|
||||
unselectedIconColor = Color(0xFF8888AA),
|
||||
unselectedTextColor = Color(0xFF8888AA),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = TopLevel.HOME.route,
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
enterTransition = {
|
||||
slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(280, easing = FastOutSlowInEasing)) +
|
||||
fadeIn(tween(200))
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutHorizontally(targetOffsetX = { -it / 3 }, animationSpec = tween(280, easing = FastOutSlowInEasing)) +
|
||||
fadeOut(tween(180))
|
||||
},
|
||||
popEnterTransition = {
|
||||
slideInHorizontally(initialOffsetX = { -it / 3 }, animationSpec = tween(280, easing = FastOutSlowInEasing)) +
|
||||
fadeIn(tween(200))
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(280, easing = FastOutSlowInEasing)) +
|
||||
fadeOut(tween(180))
|
||||
},
|
||||
) {
|
||||
composable(TopLevel.HOME.route) {
|
||||
HomeScreen(
|
||||
coverUrl = { album -> coverUrl(album.coverArt) },
|
||||
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)
|
||||
}}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(TopLevel.LIBRARY.route) {
|
||||
LibraryScreen(
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
|
||||
onArtistClick = { artist -> navController.navigate("artist/${artist.id}") },
|
||||
onPlaylistClick = { pl -> navController.navigate("playlist/${pl.id}") },
|
||||
)
|
||||
}
|
||||
composable(TopLevel.ERKUNDEN.route) {
|
||||
ExploreScreen(
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
playerState = playerState,
|
||||
onSongPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
|
||||
onArtistClick = { artist -> navController.navigate("artist/${artist.id}") },
|
||||
onOpenImporter = { navController.navigate("importer") },
|
||||
)
|
||||
}
|
||||
composable("importer") {
|
||||
ImporterScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
composable(TopLevel.STARRED.route) {
|
||||
StarredScreen(
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
playerState = playerState,
|
||||
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
|
||||
onShowContextMenu = ::showContextMenu,
|
||||
)
|
||||
}
|
||||
composable(TopLevel.SETTINGS.route) { SettingsScreen() }
|
||||
|
||||
composable(
|
||||
"album/{albumId}",
|
||||
arguments = listOf(navArgument("albumId") { type = NavType.StringType }),
|
||||
) {
|
||||
AlbumScreen(
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
playerState = playerState,
|
||||
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
|
||||
onShowContextMenu = ::showContextMenu,
|
||||
onArtistClick = { artistId -> navController.navigate("artist/$artistId") },
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
"artist/{artistId}",
|
||||
arguments = listOf(navArgument("artistId") { type = NavType.StringType }),
|
||||
) {
|
||||
ArtistDetailScreen(
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
playerState = playerState,
|
||||
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
|
||||
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
|
||||
onShowContextMenu = ::showContextMenu,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
"playlist/{playlistId}",
|
||||
arguments = listOf(navArgument("playlistId") { type = NavType.StringType }),
|
||||
) {
|
||||
PlaylistDetailScreen(
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
playerState = playerState,
|
||||
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
|
||||
onShowContextMenu = ::showContextMenu,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
"genre/{genreName}",
|
||||
arguments = listOf(navArgument("genreName") { type = NavType.StringType }),
|
||||
) { backEntry ->
|
||||
val genreName = backEntry.arguments?.getString("genreName") ?: ""
|
||||
GenreSongsScreen(
|
||||
genreName = genreName,
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
playerState = playerState,
|
||||
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
|
||||
onShowContextMenu = ::showContextMenu,
|
||||
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,
|
||||
enter = slideInVertically { it } + fadeIn(tween(220)),
|
||||
exit = slideOutVertically { it } + fadeOut(tween(180)),
|
||||
) {
|
||||
NowPlayingScreen(
|
||||
state = playerState,
|
||||
coverUrl = heroCoverUrl(playerState.currentSong?.coverArt),
|
||||
queueCoverUrl = { id -> coverUrl(id) },
|
||||
controller = player,
|
||||
onDismiss = { showNowPlaying = false },
|
||||
onStarToggle = {
|
||||
playerState.currentSong?.let { s -> starToggle(s.id, s.starred != null) }
|
||||
},
|
||||
onShowOptions = {
|
||||
playerState.currentSong?.let { s -> contextMenuSong = s }
|
||||
},
|
||||
onArtistClick = playerState.currentSong?.artistId?.let { artistId ->
|
||||
{
|
||||
showNowPlaying = false
|
||||
navController.navigate("artist/$artistId")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Song context menu bottom sheet
|
||||
contextMenuSong?.let { song ->
|
||||
SongContextSheet(
|
||||
song = song,
|
||||
isStarred = song.starred != null,
|
||||
onDismiss = { contextMenuSong = null },
|
||||
onPlay = { player.playSongs(listOf(song), 0) },
|
||||
onInsertNext = { player.insertNext(song) },
|
||||
onAddToQueue = { player.addToQueueEnd(song) },
|
||||
onStarToggle = { starToggle(song.id, song.starred != null) },
|
||||
onAddToPlaylist = {
|
||||
playlistPickerSong = song
|
||||
contextMenuSong = null
|
||||
},
|
||||
onGoToAlbum = song.albumId?.let { albumId ->
|
||||
{ navController.navigate("album/$albumId") }
|
||||
},
|
||||
onShowInfo = {
|
||||
infoSheetSong = song
|
||||
contextMenuSong = null
|
||||
},
|
||||
onDownload = {
|
||||
downloadSong(context, song, repo.streamUrl(song.id) ?: "")
|
||||
contextMenuSong = null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Playlist picker sheet
|
||||
playlistPickerSong?.let { song ->
|
||||
PlaylistPickerSheet(
|
||||
song = song,
|
||||
loadPlaylists = { repo.getPlaylists() },
|
||||
onAdd = { pl ->
|
||||
playlistPickerSong = null
|
||||
scope.launch {
|
||||
runCatching { repo.addSongToPlaylist(pl.id, song.id) }
|
||||
.onSuccess { Toast.makeText(context, "✓ ${song.title} → ${pl.name}", Toast.LENGTH_SHORT).show() }
|
||||
.onFailure { Toast.makeText(context, "Fehler: ${it.message}", Toast.LENGTH_SHORT).show() }
|
||||
}
|
||||
},
|
||||
onCreate = { name ->
|
||||
playlistPickerSong = null
|
||||
scope.launch {
|
||||
runCatching { repo.createPlaylist(name, listOf(song.id)) }
|
||||
.onSuccess { Toast.makeText(context, "✓ Playlist \"$name\" erstellt", Toast.LENGTH_SHORT).show() }
|
||||
.onFailure { Toast.makeText(context, "Fehler: ${it.message}", Toast.LENGTH_SHORT).show() }
|
||||
}
|
||||
},
|
||||
onDismiss = { playlistPickerSong = null },
|
||||
)
|
||||
}
|
||||
|
||||
// Song info sheet
|
||||
infoSheetSong?.let { song ->
|
||||
SongInfoSheet(song = song, onDismiss = { infoSheetSong = null })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadSong(context: Context, song: Song, streamUrl: String) {
|
||||
if (streamUrl.isBlank()) {
|
||||
Toast.makeText(context, "Stream-URL nicht verfügbar", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val safe = (song.title + "." + (song.suffix ?: "mp3"))
|
||||
.replace(Regex("[/\\\\:*?\"<>|]"), "_")
|
||||
val request = DownloadManager.Request(Uri.parse(streamUrl))
|
||||
.setTitle(song.title)
|
||||
.setDescription(song.artist ?: "")
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
.setDestinationInExternalPublicDir(Environment.DIRECTORY_MUSIC, safe)
|
||||
.setMimeType(song.contentType ?: "audio/mpeg")
|
||||
val dm = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
dm.enqueue(request)
|
||||
Toast.makeText(context, "Download gestartet: ${song.title}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun encodeRoute(s: String) = java.net.URLEncoder.encode(s, "UTF-8")
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.novasonic.app.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.data.PrefsStore
|
||||
import com.novasonic.app.ui.screen.LoginScreen
|
||||
import com.novasonic.app.ui.theme.NovaSonicTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
NovaSonicTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
NovaSonicRoot()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NovaSonicRoot() {
|
||||
val context = LocalContext.current
|
||||
val prefs = remember { PrefsStore(context) }
|
||||
val creds by prefs.credentials.collectAsStateWithLifecycle(
|
||||
initialValue = PrefsStore.Credentials("", "", ""),
|
||||
)
|
||||
|
||||
var forceLogin by remember { mutableStateOf(false) }
|
||||
|
||||
if (!creds.isValid || forceLogin) {
|
||||
LoginScreen(onLoggedIn = { forceLogin = false })
|
||||
} else {
|
||||
AppNavigation()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.novasonic.app.ui.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Album
|
||||
import com.novasonic.app.ui.theme.*
|
||||
|
||||
private val cardBorder = Color(0x12FFFFFF) // rgba(255,255,255,0.07)
|
||||
|
||||
@Composable
|
||||
fun AlbumCard(
|
||||
album: Album,
|
||||
coverUrl: String?,
|
||||
width: Dp = 145.dp,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(width)
|
||||
.clip(shape)
|
||||
.background(NsSurface)
|
||||
.border(1.dp, cardBorder, shape)
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = coverUrl,
|
||||
contentDescription = album.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
Text(
|
||||
text = album.name,
|
||||
fontFamily = Outfit,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 12.sp,
|
||||
color = NsOnSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (!album.artist.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = album.artist,
|
||||
fontFamily = Outfit,
|
||||
fontSize = 11.sp,
|
||||
color = NsOnSurface2,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package com.novasonic.app.ui.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.ItunesItem
|
||||
import com.novasonic.app.ui.theme.*
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ItunesDownloadSheet(
|
||||
item: ItunesItem,
|
||||
onDownload: (type: String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val hasAlbum = !item.collectionName.isNullOrBlank()
|
||||
val hasArtist = !item.artistName.isNullOrBlank()
|
||||
|
||||
var selected by remember {
|
||||
mutableStateOf(if (item.trackName != null) "track" else if (hasAlbum) "album" else "artist")
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
containerColor = NsSurface,
|
||||
dragHandle = { BottomSheetDefaults.DragHandle(color = NsOnSurface2.copy(0.3f)) },
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp)
|
||||
.navigationBarsPadding()
|
||||
.padding(bottom = 24.dp),
|
||||
) {
|
||||
// ── Header ────────────────────────────────────────────────────────
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val art = item.artworkUrl100?.replace("100x100bb", "80x80bb")
|
||||
AsyncImage(
|
||||
model = art, contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
item.trackName ?: item.collectionName ?: item.artistName ?: "—",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 15.sp, color = NsOnSurface,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (hasArtist) {
|
||||
Text(
|
||||
item.artistName!!,
|
||||
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (hasAlbum && item.trackName != null) {
|
||||
Text(
|
||||
item.collectionName!!,
|
||||
fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = NsOnSurface2.copy(0.6f),
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(
|
||||
"Was herunterladen?",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 12.sp, color = NsOnSurface2,
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
// ── Options ───────────────────────────────────────────────────────
|
||||
if (item.trackName != null) {
|
||||
DownloadTypeRow(
|
||||
icon = Icons.Default.MusicNote,
|
||||
title = "Nur dieser Titel",
|
||||
subtitle = item.trackName!!,
|
||||
selected = selected == "track",
|
||||
onClick = { selected = "track" },
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
if (hasAlbum) {
|
||||
DownloadTypeRow(
|
||||
icon = Icons.Default.Album,
|
||||
title = "Ganzes Album",
|
||||
subtitle = "\"${item.collectionName}\" herunterladen",
|
||||
selected = selected == "album",
|
||||
onClick = { selected = "album" },
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
if (hasArtist) {
|
||||
DownloadTypeRow(
|
||||
icon = Icons.Default.Person,
|
||||
title = "Ganzer Künstler",
|
||||
subtitle = "Alle Songs von ${item.artistName}",
|
||||
selected = selected == "artist",
|
||||
onClick = { selected = "artist" },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
Button(
|
||||
onClick = { onDownload(selected) },
|
||||
modifier = Modifier.fillMaxWidth().height(50.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
|
||||
) {
|
||||
Icon(Icons.Default.Download, null, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Herunterladen", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DownloadTypeRow(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val borderColor = if (selected) NsAccent else NsBorder
|
||||
val bgColor = if (selected) NsAccent.copy(alpha = 0.08f) else NsSurface2
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(bgColor)
|
||||
.border(1.dp, borderColor, RoundedCornerShape(12.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (selected) NsAccent else NsOnSurface2.copy(0.12f)),
|
||||
) {
|
||||
Icon(icon, null, modifier = Modifier.size(18.dp),
|
||||
tint = if (selected) Color.White else NsOnSurface2)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp, color = if (selected) NsAccent else NsOnSurface)
|
||||
Text(subtitle, fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
if (selected) {
|
||||
Icon(Icons.Default.CheckCircle, null,
|
||||
tint = NsAccent, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.novasonic.app.ui.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.SkipNext
|
||||
import androidx.compose.material.icons.filled.SkipPrevious
|
||||
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.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun MiniPlayer(
|
||||
state: PlayerState,
|
||||
coverUrl: String?,
|
||||
onTap: () -> Unit,
|
||||
onTogglePlay: () -> Unit,
|
||||
onSkipPrev: () -> Unit,
|
||||
onSkipNext: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val song = state.currentSong ?: return
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp)
|
||||
.padding(top = 6.dp, bottom = 4.dp)
|
||||
.shadow(12.dp, RoundedCornerShape(18.dp))
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(NsSurface),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onTap)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = coverUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = song.title,
|
||||
fontFamily = Outfit,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp,
|
||||
color = NsOnSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (!song.artist.isNullOrBlank()) {
|
||||
Text(
|
||||
text = song.artist!!,
|
||||
fontFamily = Outfit,
|
||||
fontSize = 12.sp,
|
||||
color = NsOnSurface2,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onSkipPrev, modifier = Modifier.size(40.dp)) {
|
||||
Icon(
|
||||
Icons.Default.SkipPrevious,
|
||||
contentDescription = null,
|
||||
tint = NsOnSurface2,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
// Accent play/pause circle button
|
||||
Box(
|
||||
Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(NsAccent)
|
||||
.clickable(onClick = onTogglePlay),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
IconButton(onClick = onSkipNext, modifier = Modifier.size(40.dp)) {
|
||||
Icon(
|
||||
Icons.Default.SkipNext,
|
||||
contentDescription = null,
|
||||
tint = NsOnSurface2,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Progress bar anchored to the bottom of the card
|
||||
if (state.durationMs > 0) {
|
||||
LinearProgressIndicator(
|
||||
progress = { (state.positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f) },
|
||||
modifier = Modifier.fillMaxWidth().height(3.dp),
|
||||
color = NsAccent,
|
||||
trackColor = NsAccent.copy(alpha = 0.15f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.novasonic.app.ui.component
|
||||
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.QueueMusic
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
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 com.novasonic.app.api.model.Playlist
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.ui.theme.*
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PlaylistPickerSheet(
|
||||
song: Song,
|
||||
loadPlaylists: suspend () -> List<Playlist>,
|
||||
onAdd: (Playlist) -> Unit,
|
||||
onCreate: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var playlists by remember { mutableStateOf<List<Playlist>?>(null) }
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var loadError by remember { mutableStateOf<String?>(null) }
|
||||
var newName by remember { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
isLoading = true
|
||||
try { playlists = loadPlaylists() }
|
||||
catch (e: Exception) { loadError = e.message ?: e.toString() }
|
||||
finally { isLoading = false }
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
containerColor = NsSurface,
|
||||
dragHandle = { BottomSheetDefaults.DragHandle(color = NsOnSurface2) },
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = 20.dp).padding(bottom = 8.dp)) {
|
||||
Text(
|
||||
"\"${song.title}\" zu Playlist",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp, color = NsOnSurface, maxLines = 1,
|
||||
)
|
||||
if (!song.artist.isNullOrBlank()) {
|
||||
Text(song.artist!!, fontFamily = Outfit, fontSize = 13.sp, color = NsAccent)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = NsBorder)
|
||||
|
||||
// Create new playlist row
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = newName,
|
||||
onValueChange = { newName = it },
|
||||
placeholder = { Text("Neue Playlist…", fontSize = 14.sp) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NsAccent,
|
||||
unfocusedBorderColor = NsBorder,
|
||||
cursorColor = NsAccent,
|
||||
focusedTextColor = NsOnSurface,
|
||||
unfocusedTextColor = NsOnSurface,
|
||||
),
|
||||
textStyle = androidx.compose.ui.text.TextStyle(
|
||||
fontFamily = Outfit, fontSize = 14.sp,
|
||||
),
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
val name = newName.trim()
|
||||
if (name.isNotBlank()) { onCreate(name) }
|
||||
},
|
||||
enabled = newName.isNotBlank(),
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Erstellen", tint = NsAccent)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = NsBorder)
|
||||
|
||||
when {
|
||||
isLoading -> Box(
|
||||
Modifier.fillMaxWidth().padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { CircularProgressIndicator(color = NsAccent, modifier = Modifier.size(24.dp)) }
|
||||
|
||||
loadError != null -> Box(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Text(loadError!!, color = MaterialTheme.colorScheme.error, fontSize = 13.sp) }
|
||||
|
||||
playlists.isNullOrEmpty() -> Box(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Text("Noch keine Playlists vorhanden", color = NsOnSurface2, fontSize = 13.sp) }
|
||||
|
||||
else -> LazyColumn(Modifier.fillMaxWidth()) {
|
||||
items(playlists!!, key = { it.id }) { pl ->
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onAdd(pl) }
|
||||
.padding(horizontal = 20.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.QueueMusic, null,
|
||||
tint = NsOnSurface2, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Text(
|
||||
pl.name,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Medium,
|
||||
fontSize = 15.sp, color = NsOnSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
"${pl.songCount} Titel",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.novasonic.app.ui.component
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
|
||||
import androidx.compose.material.icons.automirrored.filled.QueueMusic
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
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.Song
|
||||
import com.novasonic.app.ui.theme.*
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SongContextSheet(
|
||||
song: Song,
|
||||
isStarred: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onPlay: () -> Unit,
|
||||
onInsertNext: () -> Unit,
|
||||
onAddToQueue: () -> Unit,
|
||||
onStarToggle: () -> Unit,
|
||||
onAddToPlaylist: (() -> Unit)? = null,
|
||||
onGoToAlbum: (() -> Unit)? = null,
|
||||
onShowInfo: (() -> Unit)? = null,
|
||||
onDownload: (() -> Unit)? = null,
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
containerColor = NsSurface,
|
||||
dragHandle = { BottomSheetDefaults.DragHandle(color = NsOnSurface2) },
|
||||
) {
|
||||
// Header: song title + artist
|
||||
Column(Modifier.padding(horizontal = 20.dp).padding(bottom = 8.dp)) {
|
||||
Text(
|
||||
song.title, fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp, color = NsOnSurface, maxLines = 1,
|
||||
)
|
||||
if (!song.artist.isNullOrBlank()) {
|
||||
Text(song.artist!!, fontFamily = Outfit, fontSize = 13.sp, color = NsAccent)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = NsBorder)
|
||||
|
||||
CtxItem(Icons.Default.PlayArrow, "Abspielen", onClick = { onDismiss(); onPlay() })
|
||||
CtxItem(Icons.AutoMirrored.Filled.QueueMusic, "Als Nächstes einfügen", onClick = { onDismiss(); onInsertNext() })
|
||||
CtxItem(Icons.Default.AddToQueue, "An Queue anfügen", onClick = { onDismiss(); onAddToQueue() })
|
||||
|
||||
HorizontalDivider(color = NsBorder, modifier = Modifier.padding(vertical = 4.dp))
|
||||
|
||||
if (onAddToPlaylist != null) {
|
||||
CtxItem(Icons.AutoMirrored.Filled.PlaylistAdd, "Zu Playlist hinzufügen",
|
||||
onClick = { onDismiss(); onAddToPlaylist() })
|
||||
}
|
||||
|
||||
CtxItem(
|
||||
icon = if (isStarred) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
|
||||
label = if (isStarred) "Aus Favoriten entfernen" else "Zu Favoriten",
|
||||
tint = if (isStarred) NsAccent else NsOnSurface,
|
||||
onClick = { onDismiss(); onStarToggle() },
|
||||
)
|
||||
|
||||
if (onGoToAlbum != null || onShowInfo != null || onDownload != null) {
|
||||
HorizontalDivider(color = NsBorder, modifier = Modifier.padding(vertical = 4.dp))
|
||||
onGoToAlbum?.let { CtxItem(Icons.Default.Album, "Zum Album", onClick = { onDismiss(); it() }) }
|
||||
onShowInfo?.let { CtxItem(Icons.Default.Info, "Song-Info", onClick = { onDismiss(); it() }) }
|
||||
onDownload?.let { CtxItem(Icons.Default.Download, "Herunterladen", onClick = { onDismiss(); it() }) }
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CtxItem(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
tint: androidx.compose.ui.graphics.Color = NsOnSurface,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 20.dp, vertical = 14.dp),
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(22.dp))
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Text(label, fontFamily = Outfit, fontWeight = FontWeight.Medium,
|
||||
fontSize = 15.sp, color = tint)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.novasonic.app.ui.component
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
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 com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.ui.theme.*
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SongInfoSheet(song: Song, onDismiss: () -> Unit) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
containerColor = NsSurface,
|
||||
dragHandle = { BottomSheetDefaults.DragHandle(color = NsOnSurface2) },
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = 20.dp).padding(bottom = 32.dp)) {
|
||||
Text(
|
||||
"Song-Info",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp, color = NsOnSurface,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
HorizontalDivider(color = NsBorder, modifier = Modifier.padding(bottom = 12.dp))
|
||||
|
||||
InfoRow("Titel", song.title)
|
||||
song.artist?.takeIf { it.isNotBlank() }?.let { InfoRow("Künstler", it) }
|
||||
song.album?.takeIf { it.isNotBlank() }?.let { InfoRow("Album", it) }
|
||||
song.year?.let { InfoRow("Jahr", it.toString()) }
|
||||
song.genre?.takeIf { it.isNotBlank() }?.let { InfoRow("Genre", it) }
|
||||
song.track?.let { InfoRow("Track", it.toString()) }
|
||||
song.duration?.let { InfoRow("Dauer", formatDur(it)) }
|
||||
song.bitRate?.let { InfoRow("Bitrate", "${it} kbps") }
|
||||
song.suffix?.let { InfoRow("Format", it.uppercase()) }
|
||||
song.size?.let { InfoRow("Größe", formatSize(it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoRow(label: String, value: String) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 5.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.width(70.dp),
|
||||
)
|
||||
Text(
|
||||
value,
|
||||
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDur(s: Int): String {
|
||||
val h = s / 3600; val m = (s % 3600) / 60; val sec = s % 60
|
||||
return if (h > 0) "%d:%02d:%02d".format(h, m, sec) else "%d:%02d".format(m, sec)
|
||||
}
|
||||
|
||||
private fun formatSize(bytes: Long): String = when {
|
||||
bytes < 1024L -> "$bytes B"
|
||||
bytes < 1024L * 1024 -> "${bytes / 1024} KB"
|
||||
else -> "%.1f MB".format(bytes / (1024.0 * 1024.0))
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.novasonic.app.ui.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
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.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun SongRow(
|
||||
song: Song,
|
||||
coverUrl: String?,
|
||||
isPlaying: Boolean = false,
|
||||
trackNumber: Int? = null,
|
||||
starred: Boolean = false,
|
||||
onPlay: () -> Unit,
|
||||
onStarToggle: (() -> Unit)? = null,
|
||||
onMore: (() -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onPlay)
|
||||
.background(if (isPlaying) NsAccent.copy(alpha = 0.08f) else Color.Transparent)
|
||||
.padding(start = if (isPlaying) 0.dp else 4.dp),
|
||||
) {
|
||||
// Accent left bar when playing
|
||||
if (isPlaying) {
|
||||
Box(
|
||||
Modifier
|
||||
.width(3.dp)
|
||||
.height(52.dp)
|
||||
.background(NsAccent, RoundedCornerShape(topEnd = 3.dp, bottomEnd = 3.dp))
|
||||
)
|
||||
Spacer(Modifier.width(9.dp))
|
||||
}
|
||||
|
||||
// Track number OR album art
|
||||
if (trackNumber != null) {
|
||||
Text(
|
||||
text = trackNumber.toString(),
|
||||
fontFamily = JetBrainsMono,
|
||||
fontSize = 11.sp,
|
||||
color = if (isPlaying) NsAccent else NsOnSurface2,
|
||||
modifier = Modifier.width(34.dp).padding(start = if (isPlaying) 0.dp else 12.dp),
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier.width(if (isPlaying) 0.dp else 12.dp))
|
||||
AsyncImage(
|
||||
model = coverUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(46.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 10.dp)
|
||||
.padding(end = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = song.title,
|
||||
fontFamily = Outfit,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
color = if (isPlaying) NsAccent else NsOnSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val sub = if (trackNumber != null) {
|
||||
// in album context: show artist only (album name redundant)
|
||||
song.artist?.takeIf { it.isNotBlank() } ?: ""
|
||||
} else {
|
||||
listOfNotNull(song.artist, song.album).joinToString(" · ")
|
||||
}
|
||||
if (sub.isNotBlank()) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = sub,
|
||||
fontFamily = Outfit,
|
||||
fontSize = 12.sp,
|
||||
color = NsOnSurface2,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (onStarToggle != null) {
|
||||
IconButton(onClick = onStarToggle, modifier = Modifier.size(38.dp)) {
|
||||
Icon(
|
||||
if (starred) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
|
||||
contentDescription = null,
|
||||
tint = if (starred) NsAccent else NsOnSurface2.copy(alpha = 0.6f),
|
||||
modifier = Modifier.size(17.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (onMore != null) {
|
||||
IconButton(onClick = onMore, modifier = Modifier.size(38.dp)) {
|
||||
Icon(
|
||||
Icons.Default.MoreVert, contentDescription = null,
|
||||
tint = NsOnSurface2.copy(alpha = 0.6f), modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Shuffle
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.AlbumViewModel
|
||||
|
||||
@Composable
|
||||
fun AlbumScreen(
|
||||
coverUrl: (String?) -> String?,
|
||||
playerState: PlayerState,
|
||||
onPlay: (List<Song>, Int) -> Unit,
|
||||
onStarToggle: ((Song) -> Unit)? = null,
|
||||
onShowContextMenu: ((Song) -> Unit)? = null,
|
||||
onArtistClick: ((String) -> Unit)? = null,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val vm: AlbumViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
|
||||
if (state.isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val album = state.album ?: run {
|
||||
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
state.error ?: "Album nicht gefunden",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedButton(onClick = onBack) { Text("Zurück") }
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
item {
|
||||
// ── Hero ─────────────────────────────────────────────────────────
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
AsyncImage(
|
||||
model = coverUrl(album.coverArt),
|
||||
contentDescription = album.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.background(NsSurface2),
|
||||
)
|
||||
// gradient overlay
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0.0f to Color.Transparent,
|
||||
0.40f to NsBackground.copy(alpha = 0.35f),
|
||||
1.0f to NsBackground,
|
||||
)
|
||||
)
|
||||
)
|
||||
// back button — frosted circle
|
||||
Box(
|
||||
Modifier
|
||||
.statusBarsPadding()
|
||||
.padding(start = 14.dp, top = 14.dp)
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.45f))
|
||||
.clickable(onClick = onBack)
|
||||
.align(Alignment.TopStart),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
||||
tint = Color.White, modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
// album info overlaid at bottom
|
||||
Column(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(horizontal = 20.dp, vertical = 20.dp),
|
||||
) {
|
||||
Text(
|
||||
album.name,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 26.sp, color = Color.White,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (!album.artist.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
album.artist!!, fontFamily = Outfit,
|
||||
fontWeight = FontWeight.Medium, fontSize = 15.sp, color = NsAccent,
|
||||
modifier = if (onArtistClick != null && !album.artistId.isNullOrBlank())
|
||||
Modifier.clickable { onArtistClick(album.artistId!!) }
|
||||
else Modifier,
|
||||
)
|
||||
}
|
||||
val meta = listOfNotNull(
|
||||
album.year?.toString(),
|
||||
album.genre?.takeIf { it.isNotBlank() },
|
||||
if (album.songCount > 0) "${album.songCount} Songs" else null,
|
||||
).joinToString(" · ")
|
||||
if (meta.isNotBlank()) {
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
meta, fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = Color.White.copy(alpha = 0.55f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Play / Shuffle row ────────────────────────────────────────────
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { onPlay(album.song, 0) },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Play", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { onPlay(album.song.shuffled(), 0) },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(20.dp), tint = NsAccent)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = NsBorder)
|
||||
}
|
||||
|
||||
itemsIndexed(album.song, key = { _, s -> s.id }) { index, song ->
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = null,
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
trackNumber = song.track ?: (index + 1),
|
||||
starred = song.starred != null,
|
||||
onPlay = { onPlay(album.song, index) },
|
||||
onStarToggle = onStarToggle?.let { cb -> { cb(song) } },
|
||||
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
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.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Shuffle
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.AlbumCard
|
||||
import com.novasonic.app.ui.component.ItunesDownloadSheet
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.ArtistViewModel
|
||||
|
||||
@Composable
|
||||
fun ArtistDetailScreen(
|
||||
coverUrl: (String?) -> String?,
|
||||
playerState: PlayerState,
|
||||
onPlay: (List<Song>, Int) -> Unit,
|
||||
onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit,
|
||||
onStarToggle: (Song) -> Unit,
|
||||
onShowContextMenu: ((Song) -> Unit)? = null,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val vm: ArtistViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
BackHandler(enabled = state.partialAlbum != null) { vm.dismissPartialAlbum() }
|
||||
BackHandler(enabled = state.subAlbumItem != null && state.partialAlbum == null) { vm.dismissSubAlbum() }
|
||||
|
||||
// Download sheet
|
||||
state.downloadSheetItem?.let { item ->
|
||||
ItunesDownloadSheet(
|
||||
item = item,
|
||||
onDownload = { type ->
|
||||
vm.dismissDownloadSheet()
|
||||
vm.downloadItem(item, type) { _, msg ->
|
||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
onDismiss = vm::dismissDownloadSheet,
|
||||
)
|
||||
}
|
||||
|
||||
if (state.isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val artist = state.artist ?: run {
|
||||
Box(Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center) {
|
||||
Text(state.error ?: "Artist not found", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Partial album detail (incomplete album — downloaded + missing tracks split view)
|
||||
val partialAlbum = state.partialAlbum
|
||||
if (partialAlbum != null) {
|
||||
PartialAlbumScreen(
|
||||
partialAlbum = partialAlbum,
|
||||
playerState = playerState,
|
||||
coverUrl = coverUrl,
|
||||
previewingId = state.previewingId,
|
||||
onPlay = onPlay,
|
||||
onPreview = vm::togglePreview,
|
||||
onDownload = vm::showDownloadSheet,
|
||||
onBack = vm::dismissPartialAlbum,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Sub-album detail (not-downloaded iTunes album)
|
||||
val subAlbumItem = state.subAlbumItem
|
||||
val subAlbumTracks = state.subAlbumTracks
|
||||
if (subAlbumItem != null) {
|
||||
if (subAlbumTracks == null) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
} else {
|
||||
ItunesAlbumDetail(
|
||||
title = state.subAlbumTitle,
|
||||
albumItem = subAlbumItem,
|
||||
tracks = subAlbumTracks,
|
||||
previewingId = state.previewingId,
|
||||
onPreview = vm::togglePreview,
|
||||
onDownload = vm::showDownloadSheet,
|
||||
onBack = vm::dismissSubAlbum,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Album categories (computed once iTunes data arrives)
|
||||
val cats = if (state.iAlbums.isNotEmpty()) categorizeAlbums(artist.album, state.iAlbums) else null
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
// Back link
|
||||
item {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onBack)
|
||||
.padding(start = 12.dp, end = 16.dp, top = 12.dp, bottom = 12.dp),
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
||||
tint = NsOnSurface2, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Alle Künstler", fontFamily = Outfit, fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
|
||||
// Hero card
|
||||
item {
|
||||
val imgUrl = state.info?.largeImageUrl?.takeIf { it.isNotBlank() }
|
||||
?: coverUrl(artist.coverArt)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.height(320.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(NsSurface2),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = imgUrl,
|
||||
contentDescription = artist.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(
|
||||
0.35f to Color(0x00000000),
|
||||
1f to Color(0xD107070F),
|
||||
)
|
||||
)
|
||||
)
|
||||
Text(
|
||||
artist.name,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold, fontSize = 30.sp,
|
||||
letterSpacing = (-0.5).sp, color = Color.White,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.align(Alignment.BottomStart).padding(20.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// Action bar
|
||||
item {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 8.dp),
|
||||
) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Similar artists (exclude the current artist)
|
||||
val similarArtists = state.info?.similarArtist
|
||||
?.filter { !it.name.equals(artist.name, ignoreCase = true) }
|
||||
?: emptyList()
|
||||
if (similarArtists.isNotEmpty()) {
|
||||
item {
|
||||
SectionLabel("Similar Artists")
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 20.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.padding(bottom = 20.dp),
|
||||
) {
|
||||
items(similarArtists) { sim ->
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.width(70.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = coverUrl(sim.coverArt),
|
||||
contentDescription = sim.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
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,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Navidrome top songs
|
||||
if (state.topSongs.isNotEmpty()) {
|
||||
item { SectionLabel("Top Songs") }
|
||||
itemsIndexed(state.topSongs, key = { _, s -> "top_${s.id}" }) { idx, song ->
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = coverUrl(song.coverArt),
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
starred = song.starred != null,
|
||||
onPlay = { onPlay(state.topSongs, idx) },
|
||||
onStarToggle = { onStarToggle(song) },
|
||||
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
|
||||
// ── iTunes: Weltweit populär ──────────────────────────────────────────
|
||||
if (state.iTracks.isNotEmpty()) {
|
||||
item { ArtistSectionHeader("Weltweit populär") }
|
||||
itemsIndexed(state.iTracks, key = { _, t -> t.trackId ?: 0L }) { _, track ->
|
||||
ItunesArtistTrackRow(
|
||||
track = track,
|
||||
isPreviewing = state.previewingId == track.trackId,
|
||||
onPreview = { vm.togglePreview(track) },
|
||||
onDownload = { vm.showDownloadSheet(track) },
|
||||
)
|
||||
}
|
||||
} else if (state.itunesLoading) {
|
||||
item {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = NsAccent, modifier = Modifier.size(16.dp), strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text("iTunes Daten werden geladen…", fontFamily = Outfit,
|
||||
fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Album sections (categorized once iTunes data is ready) ────────────
|
||||
if (cats != null) {
|
||||
if (cats.downloaded.isNotEmpty()) {
|
||||
item { ArtistSectionHeader("In Library", cats.downloaded.size) }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(cats.downloaded) { (nd, _) ->
|
||||
AlbumThumb(
|
||||
name = nd.name,
|
||||
year = nd.year?.toString(),
|
||||
artUrl = coverUrl(nd.coverArt),
|
||||
onClick = { onAlbumClick(nd) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (cats.partial.isNotEmpty()) {
|
||||
item { ArtistSectionHeader("Unvollständig", cats.partial.size) }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(cats.partial) { (nd, ia, counts) ->
|
||||
AlbumThumb(
|
||||
name = nd.name,
|
||||
year = nd.year?.toString(),
|
||||
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb")
|
||||
?: coverUrl(nd.coverArt),
|
||||
badge = "${counts.first}/${counts.second}",
|
||||
onClick = { vm.loadPartialAlbum(nd, ia) },
|
||||
onDownload = { vm.showDownloadSheet(ia) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (cats.notDownloaded.isNotEmpty()) {
|
||||
item { ArtistSectionHeader("Nicht heruntergeladen", cats.notDownloaded.size) }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(cats.notDownloaded, key = { it.collectionId ?: 0L }) { ia ->
|
||||
AlbumThumb(
|
||||
name = ia.collectionName ?: "—",
|
||||
year = ia.releaseDate?.take(4),
|
||||
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb"),
|
||||
dimmed = true,
|
||||
onClick = { vm.loadSubAlbum(ia) },
|
||||
onDownload = { vm.showDownloadSheet(ia) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
} else if (!state.itunesLoading && artist.album.isNotEmpty()) {
|
||||
// Fallback when no iTunes match found
|
||||
item { SectionLabel("Alben") }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 20.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.padding(bottom = 20.dp),
|
||||
) {
|
||||
items(artist.album, key = { it.id }) { album ->
|
||||
AlbumCard(
|
||||
album = album,
|
||||
coverUrl = coverUrl(album.coverArt),
|
||||
onClick = { onAlbumClick(album) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bio
|
||||
state.info?.biography?.takeIf { it.isNotBlank() }?.let { bio ->
|
||||
item {
|
||||
SectionLabel("Über")
|
||||
Box(
|
||||
Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 24.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(NsSurface)
|
||||
.border(1.dp, NsBorder, RoundedCornerShape(10.dp))
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = bio.replace(Regex("<[^>]+>"), "").trim(),
|
||||
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
|
||||
lineHeight = 21.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item { Spacer(Modifier.height(24.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtistActionPill(icon: ImageVector, label: String, onClick: () -> Unit) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(NsSurface)
|
||||
.border(1.dp, NsBorder, RoundedCornerShape(20.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 18.dp, vertical = 9.dp),
|
||||
) {
|
||||
Icon(icon, null, tint = NsOnSurface, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(label, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = NsOnSurface)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionLabel(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 4.dp, bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import android.widget.Toast
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.novasonic.app.api.model.*
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.ItunesDownloadSheet
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.ExploreTab
|
||||
import com.novasonic.app.viewmodel.ExploreViewModel
|
||||
|
||||
@Composable
|
||||
fun ExploreScreen(
|
||||
coverUrl: (String?) -> String?,
|
||||
playerState: PlayerState,
|
||||
onSongPlay: (List<com.novasonic.app.api.model.Song>, Int) -> Unit,
|
||||
onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit,
|
||||
onArtistClick: (com.novasonic.app.api.model.Artist) -> Unit,
|
||||
onOpenImporter: () -> Unit = {},
|
||||
) {
|
||||
val vm: ExploreViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val focusManager = LocalFocusManager.current
|
||||
val context = LocalContext.current
|
||||
|
||||
// Download sheet
|
||||
state.downloadSheetItem?.let { item ->
|
||||
ItunesDownloadSheet(
|
||||
item = item,
|
||||
onDownload = { type ->
|
||||
vm.dismissDownloadSheet()
|
||||
vm.downloadItem(item, type) { _, msg ->
|
||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
onDismiss = vm::dismissDownloadSheet,
|
||||
)
|
||||
}
|
||||
|
||||
// ── iTunes artist detail — full-screen overlay above tabs ────────────────
|
||||
state.artistDetail?.let { artistState ->
|
||||
val partialAlbum = state.partialAlbum
|
||||
val albumTracks = state.albumTracks
|
||||
val albumItem = state.albumItem
|
||||
when {
|
||||
partialAlbum != null -> {
|
||||
PartialAlbumScreen(
|
||||
partialAlbum = partialAlbum,
|
||||
playerState = playerState,
|
||||
coverUrl = coverUrl,
|
||||
previewingId = state.previewingId,
|
||||
onPlay = onSongPlay,
|
||||
onPreview = vm::togglePreview,
|
||||
onDownload = vm::showDownloadSheet,
|
||||
onBack = vm::dismissPartialAlbum,
|
||||
)
|
||||
}
|
||||
albumTracks != null && albumItem != null -> {
|
||||
ItunesAlbumDetail(
|
||||
title = state.albumTitle,
|
||||
albumItem = albumItem,
|
||||
tracks = albumTracks,
|
||||
previewingId = state.previewingId,
|
||||
onPreview = vm::togglePreview,
|
||||
onDownload = vm::showDownloadSheet,
|
||||
onBack = vm::dismissAlbum,
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
ItunesArtistScreen(
|
||||
artistState = artistState,
|
||||
playerState = playerState,
|
||||
coverUrl = coverUrl,
|
||||
onBack = vm::closeArtistDetail,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onPartialAlbumOpen = vm::loadPartialAlbum,
|
||||
onSongPlay = onSongPlay,
|
||||
onDownload = vm::showDownloadSheet,
|
||||
onItunesAlbumOpen = vm::loadAlbum,
|
||||
onItunesTrackPreview = vm::togglePreview,
|
||||
previewingId = state.previewingId,
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
// ── Tabs ─────────────────────────────────────────────────────────────
|
||||
TabRow(
|
||||
selectedTabIndex = state.tab.ordinal,
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = NsAccent,
|
||||
) {
|
||||
Tab(
|
||||
selected = state.tab == ExploreTab.LIBRARY,
|
||||
onClick = { vm.selectTab(ExploreTab.LIBRARY); vm.stopPreview() },
|
||||
text = { Text("Bibliothek", maxLines = 1) },
|
||||
selectedContentColor = NsAccent,
|
||||
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Tab(
|
||||
selected = state.tab == ExploreTab.GLOBAL,
|
||||
onClick = { vm.selectTab(ExploreTab.GLOBAL); vm.stopPreview() },
|
||||
text = { Text("Erkunden", maxLines = 1) },
|
||||
selectedContentColor = NsAccent,
|
||||
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Search bar ───────────────────────────────────────────────────────
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
OutlinedTextField(
|
||||
value = state.query,
|
||||
onValueChange = vm::onQueryChange,
|
||||
placeholder = {
|
||||
Text(
|
||||
if (state.tab == ExploreTab.LIBRARY) "Suche in Bibliothek…" else "Global suchen…",
|
||||
color = NsOnSurface2,
|
||||
)
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Default.Search, null, tint = NsOnSurface2) },
|
||||
trailingIcon = if (state.query.isNotEmpty()) { {
|
||||
IconButton(onClick = { vm.onQueryChange(""); focusManager.clearFocus() }) {
|
||||
Icon(Icons.Default.Close, "Löschen", tint = NsOnSurface2)
|
||||
}
|
||||
} } else null,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = { focusManager.clearFocus() }),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NsAccent,
|
||||
unfocusedBorderColor = NsOnSurface3,
|
||||
cursorColor = NsAccent,
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
.focusRequester(focusRequester),
|
||||
)
|
||||
|
||||
// ── Importer entry (matches PWA "Importer" under Entdecken) ───────────
|
||||
if (state.tab == ExploreTab.GLOBAL) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 6.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(NsSurface)
|
||||
.clickable(onClick = onOpenImporter)
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Download, null, tint = NsAccent, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Importer", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp, color = NsOnSurface)
|
||||
Text("Spotify · YouTube · SoundCloud Link laden",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
Icon(Icons.Default.ChevronRight, null, tint = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Content ──────────────────────────────────────────────────────────
|
||||
when (state.tab) {
|
||||
ExploreTab.LIBRARY -> LibrarySearchContent(
|
||||
query = state.query,
|
||||
coverUrl = coverUrl,
|
||||
playerState = playerState,
|
||||
onSongPlay = onSongPlay,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistClick = onArtistClick,
|
||||
)
|
||||
ExploreTab.GLOBAL -> GlobalExploreContent(
|
||||
state = state,
|
||||
vm = vm,
|
||||
coverUrl = coverUrl,
|
||||
playerState = playerState,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onSongPlay = onSongPlay,
|
||||
onDownload = vm::showDownloadSheet,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Library (local Navidrome) search ─────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun LibrarySearchContent(
|
||||
query: String,
|
||||
coverUrl: (String?) -> String?,
|
||||
playerState: PlayerState,
|
||||
onSongPlay: (List<com.novasonic.app.api.model.Song>, Int) -> Unit,
|
||||
onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit,
|
||||
onArtistClick: (com.novasonic.app.api.model.Artist) -> Unit,
|
||||
) {
|
||||
// Reuse SearchScreen's ViewModel
|
||||
val vm: com.novasonic.app.viewmodel.SearchViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(query) { vm.onSearch(query) }
|
||||
|
||||
if (query.isBlank()) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(Icons.Default.Search, null, tint = NsOnSurface3, modifier = Modifier.size(48.dp))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("Bibliothek durchsuchen", color = NsOnSurface2, fontFamily = Outfit)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (state.isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn {
|
||||
val artists = state.searchResult?.artist ?: emptyList()
|
||||
val albums = state.searchResult?.album ?: emptyList()
|
||||
val songs = state.searchResult?.song ?: emptyList()
|
||||
|
||||
if (artists.isEmpty() && albums.isEmpty() && songs.isEmpty()) {
|
||||
item {
|
||||
Box(Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Keine Ergebnisse", color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
return@LazyColumn
|
||||
}
|
||||
if (artists.isNotEmpty()) {
|
||||
item { SectionHeader("Künstler") }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(artists, key = { it.id }) { a ->
|
||||
SmallArtistChip(a.name) { onArtistClick(a) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (albums.isNotEmpty()) {
|
||||
item { SectionHeader("Alben") }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(albums, key = { it.id }) { album ->
|
||||
com.novasonic.app.ui.component.AlbumCard(
|
||||
album = album, coverUrl = coverUrl(album.coverArt),
|
||||
onClick = { onAlbumClick(album) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
if (songs.isNotEmpty()) {
|
||||
item { SectionHeader("Titel") }
|
||||
itemsIndexed(songs, key = { _, s -> s.id }) { idx, song ->
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = coverUrl(song.coverArt),
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
onPlay = { onSongPlay(songs, idx) },
|
||||
)
|
||||
}
|
||||
}
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Global (iTunes) explore ───────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun GlobalExploreContent(
|
||||
state: com.novasonic.app.viewmodel.ExploreUiState,
|
||||
vm: ExploreViewModel,
|
||||
coverUrl: (String?) -> String? = { null },
|
||||
playerState: PlayerState = com.novasonic.app.player.PlayerState(),
|
||||
onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit = {},
|
||||
onSongPlay: (List<com.novasonic.app.api.model.Song>, Int) -> Unit = { _, _ -> },
|
||||
onDownload: (ItunesItem) -> Unit = {},
|
||||
) {
|
||||
// Album drill-down view
|
||||
if (state.albumTracks != null) {
|
||||
ItunesAlbumDetail(
|
||||
title = state.albumTitle,
|
||||
albumItem = state.albumItem,
|
||||
tracks = state.albumTracks,
|
||||
previewingId = state.previewingId,
|
||||
onPreview = vm::togglePreview,
|
||||
onDownload = onDownload,
|
||||
onBack = vm::dismissAlbum,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (state.isSearching) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (state.query.isBlank()) {
|
||||
// Show iTunes charts
|
||||
if (state.chartsLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
return
|
||||
}
|
||||
LazyColumn {
|
||||
item { SectionHeader("Top-Alben") }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
items(state.charts) { entry ->
|
||||
ItunesChartCard(entry = entry, onClick = {
|
||||
val id = entry.id?.attributes?.imId?.toLongOrNull()
|
||||
if (id != null) vm.loadAlbum(
|
||||
ItunesItem(
|
||||
wrapperType = "collection",
|
||||
collectionId = id,
|
||||
collectionName = entry.name?.label,
|
||||
artistName = entry.artist?.label,
|
||||
artworkUrl100 = entry.artworkHd(),
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Search results
|
||||
val hasResults = state.artists.isNotEmpty() || state.albums.isNotEmpty() || state.tracks.isNotEmpty()
|
||||
if (!hasResults && state.error == null) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Keine Ergebnisse", color = NsOnSurface2, fontFamily = Outfit)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn {
|
||||
if (state.error != null) {
|
||||
item { Text(state.error, color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(16.dp)) }
|
||||
}
|
||||
if (state.artists.isNotEmpty()) {
|
||||
item { SectionHeader("Künstler") }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(state.artists) { a ->
|
||||
SmallArtistChip(a.artistName ?: "") { vm.openArtistDetail(a) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.albums.isNotEmpty()) {
|
||||
item { SectionHeader("Alben") }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
items(state.albums) { album ->
|
||||
ItunesAlbumCard(album = album, onClick = { vm.loadAlbum(album) })
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
if (state.tracks.isNotEmpty()) {
|
||||
item { SectionHeader("Titel") }
|
||||
itemsIndexed(state.tracks, key = { _, t -> t.trackId ?: 0 }) { _, track ->
|
||||
ItunesTrackRow(
|
||||
track = track,
|
||||
isPreviewing = state.previewingId == track.trackId,
|
||||
onPreview = { vm.togglePreview(track) },
|
||||
onDownload = { onDownload(track) },
|
||||
)
|
||||
}
|
||||
}
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ItunesAlbumDetail(
|
||||
title: String,
|
||||
albumItem: ItunesItem?,
|
||||
tracks: List<ItunesItem>,
|
||||
previewingId: Long?,
|
||||
onPreview: (ItunesItem) -> Unit,
|
||||
onDownload: (ItunesItem) -> Unit = {},
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 4.dp, top = 4.dp, bottom = 4.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
||||
tint = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
Text(title, fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f))
|
||||
if (albumItem != null) {
|
||||
IconButton(onClick = { onDownload(albumItem) }) {
|
||||
Icon(Icons.Default.Download, "Album herunterladen",
|
||||
tint = NsAccent, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
LazyColumn {
|
||||
itemsIndexed(tracks, key = { _, t -> t.trackId ?: 0 }) { _, track ->
|
||||
ItunesTrackRow(
|
||||
track = track,
|
||||
isPreviewing = previewingId == track.trackId,
|
||||
onPreview = { onPreview(track) },
|
||||
onDownload = { onDownload(track) },
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Small shared components ───────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun ItunesChartCard(entry: ItunesRssEntry, onClick: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.width(130.dp).clickable(onClick = onClick),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = entry.artworkHd(), contentDescription = entry.name?.label,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(130.dp).clip(RoundedCornerShape(10.dp))
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(entry.name?.label ?: "", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
Text(entry.artist?.label ?: "", fontFamily = Outfit, fontSize = 11.sp,
|
||||
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ItunesAlbumCard(album: ItunesItem, onClick: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.width(130.dp).clickable(onClick = onClick),
|
||||
) {
|
||||
val art = album.artworkUrl100?.replace("100x100bb", "300x300bb")
|
||||
AsyncImage(
|
||||
model = art, contentDescription = album.collectionName,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(130.dp).clip(RoundedCornerShape(10.dp))
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(album.collectionName ?: "", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
Text(album.artistName ?: "", fontFamily = Outfit, fontSize = 11.sp,
|
||||
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ItunesTrackRow(
|
||||
track: ItunesItem,
|
||||
isPreviewing: Boolean,
|
||||
onPreview: () -> Unit,
|
||||
onDownload: (() -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.clickable(onClick = onPreview)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
val art = track.artworkUrl100?.replace("100x100bb", "60x60bb")
|
||||
AsyncImage(
|
||||
model = art, contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(4.dp))
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(track.trackName ?: "", style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isPreviewing) NsAccent else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
listOfNotNull(track.artistName, track.collectionName).joinToString(" · "),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
// Preview button
|
||||
if (track.previewUrl != null) {
|
||||
IconButton(onClick = onPreview, modifier = Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
if (isPreviewing) Icons.Default.Stop else Icons.Default.PlayCircle,
|
||||
if (isPreviewing) "Stop" else "Preview",
|
||||
tint = if (isPreviewing) NsAccent else NsOnSurface2,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Download button
|
||||
if (onDownload != null) {
|
||||
IconButton(onClick = onDownload, modifier = Modifier.size(36.dp)) {
|
||||
Icon(Icons.Default.Download, "Herunterladen",
|
||||
tint = NsOnSurface2, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SmallArtistChip(name: String, onClick: () -> Unit) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(NsSurface2)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(name, fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(title: String) {
|
||||
Text(title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 12.dp, bottom = 10.dp))
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
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.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.Song
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun GenreSongsScreen(
|
||||
genreName: String,
|
||||
coverUrl: (String?) -> String?,
|
||||
playerState: PlayerState,
|
||||
onPlay: (List<Song>, Int) -> Unit,
|
||||
onStarToggle: (Song) -> Unit,
|
||||
onShowContextMenu: ((Song) -> Unit)? = null,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val repo = context.app.repository
|
||||
|
||||
val decodedName = remember(genreName) {
|
||||
java.net.URLDecoder.decode(genreName, "UTF-8")
|
||||
}
|
||||
|
||||
var songs by remember { mutableStateOf<List<Song>>(emptyList()) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(decodedName) {
|
||||
try {
|
||||
songs = repo.getSongsByGenre(decodedName)
|
||||
loading = false
|
||||
} catch (e: Exception) {
|
||||
error = e.message
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 4.dp, top = 8.dp, bottom = 8.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back",
|
||||
tint = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
Text(
|
||||
decodedName,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 20.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
|
||||
when {
|
||||
loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
error != null -> Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text(error!!, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
songs.isEmpty() -> Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text("Keine Songs gefunden", color = NsOnSurface2)
|
||||
}
|
||||
else -> LazyColumn {
|
||||
itemsIndexed(songs, key = { _, s -> s.id }) { idx, song ->
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = coverUrl(song.coverArt),
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
starred = song.starred != null,
|
||||
onPlay = { onPlay(songs, idx) },
|
||||
onStarToggle = { onStarToggle(song) },
|
||||
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.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.PlayArrow
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Album
|
||||
import com.novasonic.app.api.model.Genre
|
||||
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(
|
||||
Color(0xFF1a1a3e), Color(0xFF1e2a1a), Color(0xFF2a1a1a),
|
||||
Color(0xFF1a2a2a), Color(0xFF2a1e1a), Color(0xFF1e1a2a),
|
||||
Color(0xFF2a2a1a), Color(0xFF1a2a1e), Color(0xFF201520),
|
||||
Color(0xFF152020), Color(0xFF201515), Color(0xFF151520),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
coverUrl: (Album) -> String?,
|
||||
onAlbumClick: (Album) -> Unit,
|
||||
onAlbumPlay: (Album) -> Unit,
|
||||
onGenreClick: (Genre) -> Unit,
|
||||
onGenrePlay: ((Genre) -> Unit)? = null,
|
||||
) {
|
||||
val vm: HomeViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
|
||||
val greeting = remember {
|
||||
val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY)
|
||||
when {
|
||||
hour < 12 -> "Guten Morgen"
|
||||
hour < 18 -> "Guten Tag"
|
||||
else -> "Guten Abend"
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
if (state.isLoading) {
|
||||
item {
|
||||
Box(Modifier.fillMaxWidth().height(170.dp), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
}
|
||||
return@LazyColumn
|
||||
}
|
||||
|
||||
if (state.error != null) {
|
||||
item {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxWidth().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")
|
||||
}
|
||||
}
|
||||
}
|
||||
return@LazyColumn
|
||||
}
|
||||
|
||||
// Hero card — matches PWA .home-hero (170px height)
|
||||
state.heroAlbum?.let { hero ->
|
||||
item {
|
||||
HeroCard(
|
||||
album = hero,
|
||||
coverUrl = coverUrl(hero),
|
||||
onPlay = { onAlbumPlay(hero) },
|
||||
onClick = { onAlbumClick(hero) },
|
||||
)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (state.genres.isNotEmpty()) {
|
||||
item { SectionHeader("Genres") }
|
||||
item {
|
||||
GenreGrid(genres = state.genres, onGenreClick = onGenreClick, onGenrePlay = onGenrePlay)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (state.recentAlbums.isNotEmpty()) {
|
||||
item { SectionHeader("Zuletzt hinzugefügt") }
|
||||
item {
|
||||
AlbumRow(albums = state.recentAlbums, coverUrl = coverUrl, onAlbumClick = onAlbumClick)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
}
|
||||
}
|
||||
if (state.frequentAlbums.isNotEmpty()) {
|
||||
item { SectionHeader("Meistgespielt") }
|
||||
item {
|
||||
AlbumRow(albums = state.frequentAlbums, coverUrl = coverUrl, onAlbumClick = onAlbumClick)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
}
|
||||
}
|
||||
if (state.randomAlbums.isNotEmpty()) {
|
||||
item { SectionHeader("Entdecken") }
|
||||
item {
|
||||
AlbumRow(albums = state.randomAlbums, coverUrl = coverUrl, onAlbumClick = onAlbumClick)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PWA: .home-hero { height:170px; border-radius:14px }
|
||||
// background = blur(8px) brightness(.45) + horizontal gradient overlay
|
||||
// cover = 100x100, border-radius:9px
|
||||
@Composable
|
||||
private fun HeroCard(album: Album, coverUrl: String?, onPlay: () -> Unit, onClick: () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(170.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
// Blurred background image — matches .home-hero-bg
|
||||
AsyncImage(
|
||||
model = coverUrl, contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize().blur(16.dp),
|
||||
)
|
||||
// Gradient overlay — matches .home-hero-overlay
|
||||
// linear-gradient(to right, rgba(7,7,15,.88) 0%, rgba(7,7,15,.3) 100%)
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(
|
||||
Brush.horizontalGradient(listOf(Color(0xE107070F), Color(0x4D07070F)))
|
||||
)
|
||||
)
|
||||
// Content row
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
) {
|
||||
// Cover art — 100dp, matches .home-hero-cover (100px, border-radius:9px)
|
||||
AsyncImage(
|
||||
model = coverUrl, contentDescription = album.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(100.dp)
|
||||
.clip(RoundedCornerShape(9.dp))
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
// Label — matches .home-hero-label
|
||||
Text(
|
||||
text = "AKTUELL",
|
||||
fontFamily = Outfit,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 10.sp,
|
||||
letterSpacing = 1.sp,
|
||||
color = NsAccent,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = album.name,
|
||||
fontFamily = Outfit,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp,
|
||||
letterSpacing = (-0.3).sp,
|
||||
color = NsOnSurface,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (!album.artist.isNullOrBlank()) {
|
||||
Text(
|
||||
text = album.artist!!,
|
||||
fontFamily = Outfit,
|
||||
fontSize = 13.sp,
|
||||
color = NsOnSurface2,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
// Play button — matches .home-hero-play (42px circle, accent bg)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(42.dp)
|
||||
.clip(RoundedCornerShape(21.dp))
|
||||
.background(NsAccent)
|
||||
.clickable(onClick = onPlay),
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, "Play",
|
||||
tint = NsBackground, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GenreGrid(
|
||||
genres: List<Genre>,
|
||||
onGenreClick: (Genre) -> Unit,
|
||||
onGenrePlay: ((Genre) -> Unit)?,
|
||||
) {
|
||||
val rows = genres.chunked(2)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
) {
|
||||
rows.forEach { rowGenres ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
rowGenres.forEachIndexed { _, genre ->
|
||||
val colorIdx = genres.indexOf(genre) % genreColors.size
|
||||
GenreChip(
|
||||
genre = genre,
|
||||
bgColor = genreColors[colorIdx],
|
||||
onClick = { onGenreClick(genre) },
|
||||
onPlay = onGenrePlay?.let { cb -> { cb(genre) } },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
if (rowGenres.size == 1) Spacer(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GenreChip(
|
||||
genre: Genre,
|
||||
bgColor: Color,
|
||||
onClick: () -> Unit,
|
||||
onPlay: (() -> Unit)?,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.height(52.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(bgColor)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(start = 14.dp, end = if (onPlay != null) 8.dp else 14.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier.width(4.dp).height(24.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(NsAccent)
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
text = genre.value,
|
||||
fontFamily = Outfit,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp,
|
||||
color = NsOnSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (onPlay != null) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(NsAccent.copy(alpha = 0.18f))
|
||||
.clickable(onClick = onPlay),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.PlayArrow, "Play",
|
||||
tint = NsAccent,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AlbumRow(albums: List<Album>, coverUrl: (Album) -> String?, onAlbumClick: (Album) -> Unit) {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(albums, key = { it.id }) { album ->
|
||||
AlbumCard(album = album, coverUrl = coverUrl(album), onClick = { onAlbumClick(album) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PWA: .section-header { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:1.5px }
|
||||
@Composable
|
||||
private fun SectionHeader(title: String) {
|
||||
Text(
|
||||
text = title.uppercase(),
|
||||
fontFamily = Outfit,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 10.sp,
|
||||
letterSpacing = 1.5.sp,
|
||||
color = NsOnSurface2,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 4.dp, bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.ImportTrack
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.ImporterViewModel
|
||||
|
||||
// Brand colors for the platform badge
|
||||
private val Spotify = Color(0xFF1DB954)
|
||||
private val SoundCloud = Color(0xFFFF5500)
|
||||
private val YouTube = Color(0xFFFF0000)
|
||||
|
||||
@Composable
|
||||
fun ImporterScreen(onBack: () -> Unit) {
|
||||
val vm: ImporterViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val focus = LocalFocusManager.current
|
||||
|
||||
val brand = when (state.platform) {
|
||||
"spotify" -> Spotify
|
||||
"soundcloud" -> SoundCloud
|
||||
"youtube" -> YouTube
|
||||
else -> NsAccent
|
||||
}
|
||||
val brandLabel = when (state.platform) {
|
||||
"spotify" -> "Spotify"
|
||||
"soundcloud" -> "SoundCloud"
|
||||
"youtube" -> "YouTube"
|
||||
else -> "URL"
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
// Header
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 4.dp, top = 8.dp, bottom = 4.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück", tint = NsOnSurface)
|
||||
}
|
||||
Text("Importer", fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp, color = NsOnSurface)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
"Spotify-, YouTube-, YouTube-Music- oder SoundCloud-Link einfügen, " +
|
||||
"um Tracks zu laden.",
|
||||
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2, lineHeight = 19.sp,
|
||||
)
|
||||
}
|
||||
|
||||
// Link input + badge
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = state.link,
|
||||
onValueChange = vm::onLink,
|
||||
placeholder = { Text("https://open.spotify.com/…", color = NsOnSurface2) },
|
||||
singleLine = true,
|
||||
trailingIcon = {
|
||||
Box(
|
||||
Modifier.padding(end = 8.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(brand.copy(alpha = 0.15f))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(brandLabel, fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 10.sp, color = brand)
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go),
|
||||
keyboardActions = KeyboardActions(onGo = { focus.clearFocus(); vm.resolve() }),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = brand,
|
||||
unfocusedBorderColor = NsOnSurface3,
|
||||
cursorColor = brand,
|
||||
focusedContainerColor = NsSurface,
|
||||
unfocusedContainerColor = NsSurface,
|
||||
focusedTextColor = NsOnSurface,
|
||||
unfocusedTextColor = NsOnSurface,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Button(
|
||||
onClick = { focus.clearFocus(); vm.resolve() },
|
||||
enabled = !state.isResolving && state.link.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = brand, contentColor = Color.Black),
|
||||
) {
|
||||
if (state.isResolving) {
|
||||
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp, color = Color.Black)
|
||||
} else {
|
||||
Text("Auflösen", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 15.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.error?.let { err ->
|
||||
item {
|
||||
Box(
|
||||
Modifier.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(NsSurface)
|
||||
.border(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.5f), RoundedCornerShape(10.dp))
|
||||
.padding(14.dp),
|
||||
) {
|
||||
Text(err, fontFamily = Outfit, fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.error, lineHeight = 19.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolved preview header
|
||||
state.resolved?.let { r ->
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(NsSurface)
|
||||
.border(1.dp, NsBorder, RoundedCornerShape(14.dp))
|
||||
.padding(16.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = r.artworkUrl?.replace("100x100", "300x300"),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(72.dp).clip(RoundedCornerShape(8.dp))
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text((r.type ?: "").uppercase(), fontFamily = Outfit,
|
||||
fontWeight = FontWeight.Bold, fontSize = 10.sp,
|
||||
letterSpacing = 1.5.sp, color = brand)
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(r.name ?: "—", fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 17.sp, color = NsOnSurface,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
listOfNotNull(r.artist, "${r.tracks.size} Tracks").joinToString(" · "),
|
||||
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Download button / job progress
|
||||
item {
|
||||
if (state.isDownloading || state.jobStatus == "done" || state.jobStatus == "error") {
|
||||
JobProgress(
|
||||
status = state.jobStatus,
|
||||
message = state.jobMessage,
|
||||
completed = state.jobCompleted,
|
||||
total = state.jobTotal,
|
||||
brand = brand,
|
||||
)
|
||||
} else {
|
||||
Button(
|
||||
onClick = vm::downloadAll,
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = brand, contentColor = Color.Black),
|
||||
) {
|
||||
Icon(Icons.Default.Download, null, Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Alle ${r.tracks.size} Tracks laden",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 15.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
itemsIndexed(r.tracks) { idx, track ->
|
||||
ImportTrackRow(idx + 1, track)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun JobProgress(
|
||||
status: String?, message: String?, completed: Int, total: Int, brand: Color,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(NsSurface)
|
||||
.border(1.dp, NsBorder, RoundedCornerShape(12.dp))
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
when (status) {
|
||||
"done" -> Icon(Icons.Default.Check, null, tint = brand, modifier = Modifier.size(20.dp))
|
||||
"error" -> Icon(Icons.Default.Check, null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(20.dp))
|
||||
else -> CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp, color = brand)
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
when (status) {
|
||||
"done" -> "Fertig — in Navidrome scannen lassen"
|
||||
"error" -> "Fehler beim Download"
|
||||
"downloading" -> "Lädt… ${if (total > 0) "$completed/$total" else ""}"
|
||||
else -> "In Warteschlange…"
|
||||
},
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||
color = if (status == "error") MaterialTheme.colorScheme.error else NsOnSurface,
|
||||
)
|
||||
}
|
||||
if (total > 0 && status != "done") {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { if (total > 0) completed.toFloat() / total else 0f },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = brand,
|
||||
trackColor = NsSurface2,
|
||||
)
|
||||
}
|
||||
message?.takeIf { it.isNotBlank() }?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(it, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImportTrackRow(num: Int, track: ImportTrack) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = track.artworkUrl100,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(6.dp)).background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(track.trackName ?: "—", fontFamily = Outfit, fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (!track.artistName.isNullOrBlank()) {
|
||||
Text(track.artistName, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Album
|
||||
import com.novasonic.app.api.model.ItunesItem
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.ItunesArtistState
|
||||
|
||||
private fun normAlbum(s: String?): String =
|
||||
(s ?: "").lowercase().replace(Regex("[^a-z0-9\\s]"), " ").replace(Regex("\\s+"), " ").trim()
|
||||
|
||||
data class ArtistAlbumCategories(
|
||||
val downloaded: List<Pair<Album, ItunesItem?>>,
|
||||
val partial: List<Triple<Album, ItunesItem, Pair<Int, Int>>>,
|
||||
val notDownloaded: List<ItunesItem>,
|
||||
)
|
||||
|
||||
fun categorizeAlbums(ndAlbums: List<Album>, iAlbums: List<ItunesItem>): ArtistAlbumCategories {
|
||||
// Deduplicate iTunes albums by collectionId (iTunes can return the same album multiple times)
|
||||
val uniqueIAlbums = iAlbums.distinctBy { it.collectionId ?: it.collectionName }
|
||||
val downloaded = mutableListOf<Pair<Album, ItunesItem?>>()
|
||||
val partial = mutableListOf<Triple<Album, ItunesItem, Pair<Int, Int>>>()
|
||||
val notDl = mutableListOf<ItunesItem>()
|
||||
val usedNdIds = mutableSetOf<String>()
|
||||
|
||||
for (ia in uniqueIAlbums) {
|
||||
val iName = normAlbum(ia.collectionName)
|
||||
val iCount = ia.trackCount ?: 0
|
||||
val match = ndAlbums.find { a ->
|
||||
val n = normAlbum(a.name)
|
||||
n == iName || (n.length > 3 && iName.startsWith(n)) || (iName.length > 3 && n.startsWith(iName))
|
||||
}
|
||||
when {
|
||||
match == null -> notDl.add(ia)
|
||||
iCount > 0 && match.songCount >= iCount -> {
|
||||
downloaded.add(Pair(match, ia)); usedNdIds.add(match.id)
|
||||
}
|
||||
else -> {
|
||||
partial.add(Triple(match, ia, Pair(match.songCount, iCount))); usedNdIds.add(match.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (na in ndAlbums) {
|
||||
if (na.id !in usedNdIds) downloaded.add(Pair(na, null))
|
||||
}
|
||||
|
||||
return ArtistAlbumCategories(
|
||||
downloaded = downloaded.sortedByDescending { it.first.year ?: 0 },
|
||||
partial = partial.sortedByDescending { it.first.year ?: 0 },
|
||||
notDownloaded = notDl.sortedByDescending { it.releaseDate ?: "" },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ItunesArtistScreen(
|
||||
artistState: ItunesArtistState,
|
||||
playerState: PlayerState,
|
||||
coverUrl: (String?) -> String?,
|
||||
onBack: () -> Unit,
|
||||
onAlbumClick: (Album) -> Unit,
|
||||
onPartialAlbumOpen: (Album, ItunesItem) -> Unit,
|
||||
onSongPlay: (List<Song>, Int) -> Unit,
|
||||
onDownload: (ItunesItem) -> Unit,
|
||||
onItunesAlbumOpen: (ItunesItem) -> Unit,
|
||||
onItunesTrackPreview: (ItunesItem) -> Unit,
|
||||
previewingId: Long?,
|
||||
) {
|
||||
val artist = artistState.artist
|
||||
val cats = if (!artistState.isLoading)
|
||||
categorizeAlbums(artistState.ndAlbums, artistState.iAlbums)
|
||||
else null
|
||||
|
||||
// Pick backdrop: first Navidrome album cover, else first iTunes album art
|
||||
val backdropUrl = artistState.ndAlbums.firstOrNull()?.let { coverUrl(it.coverArt) }
|
||||
?: artistState.iAlbums.firstOrNull()?.artworkUrl100?.replace("100x100bb", "300x300bb")
|
||||
|
||||
Column(Modifier.fillMaxSize().background(NsBackground)) {
|
||||
// ── Hero ──────────────────────────────────────────────────────────────
|
||||
Box(
|
||||
Modifier.fillMaxWidth().height(200.dp)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = backdropUrl, contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize().blur(24.dp),
|
||||
)
|
||||
Box(Modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(0f to Color.Black.copy(0.5f), 1f to NsBackground)
|
||||
))
|
||||
// Back button
|
||||
Box(
|
||||
Modifier
|
||||
.statusBarsPadding()
|
||||
.padding(start = 14.dp, top = 14.dp)
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(0.45f))
|
||||
.clickable(onClick = onBack)
|
||||
.align(Alignment.TopStart),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
||||
tint = Color.White, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
// Artist name
|
||||
Column(
|
||||
Modifier.align(Alignment.BottomStart)
|
||||
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
) {
|
||||
Text(
|
||||
artist.artistName ?: "Unbekannter Künstler",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 28.sp, color = Color.White,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (!artist.primaryGenreName.isNullOrBlank()) {
|
||||
Text(artist.primaryGenreName!!, fontFamily = Outfit,
|
||||
fontSize = 13.sp, color = Color.White.copy(0.6f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Action row ────────────────────────────────────────────────────────
|
||||
if (artistState.topSongs.isNotEmpty()) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { onSongPlay(artistState.topSongs, 0) },
|
||||
modifier = Modifier.weight(1f).height(42.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent),
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Top Songs", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { onSongPlay(artistState.topSongs.shuffled(), 0) },
|
||||
modifier = Modifier.weight(1f).height(42.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true)
|
||||
.copy(brush = androidx.compose.ui.graphics.SolidColor(NsAccent.copy(0.5f))),
|
||||
) {
|
||||
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(18.dp), tint = NsAccent)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 13.sp, color = NsAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (artistState.isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
|
||||
// ── Scrollable content ────────────────────────────────────────────────
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
|
||||
// iTunes top tracks
|
||||
if (artistState.iTracks.isNotEmpty()) {
|
||||
item { ArtistSectionHeader("Weltweit populär (iTunes)") }
|
||||
itemsIndexed(artistState.iTracks, key = { _, t -> t.trackId ?: 0L }) { _, track ->
|
||||
ItunesArtistTrackRow(
|
||||
track = track,
|
||||
isPreviewing = previewingId == track.trackId,
|
||||
onPreview = { onItunesTrackPreview(track) },
|
||||
onDownload = { onDownload(track) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Downloaded albums ─────────────────────────────────────────────
|
||||
cats?.let { c ->
|
||||
if (c.downloaded.isNotEmpty()) {
|
||||
item { ArtistSectionHeader("In Library", c.downloaded.size) }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(c.downloaded) { (nd, _) ->
|
||||
AlbumThumb(
|
||||
name = nd.name,
|
||||
year = nd.year?.toString(),
|
||||
artUrl = coverUrl(nd.coverArt),
|
||||
onClick = { onAlbumClick(nd) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Partial albums ────────────────────────────────────────────
|
||||
if (c.partial.isNotEmpty()) {
|
||||
item { ArtistSectionHeader("Unvollständig", c.partial.size) }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(c.partial) { (nd, ia, counts) ->
|
||||
AlbumThumb(
|
||||
name = nd.name,
|
||||
year = nd.year?.toString(),
|
||||
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb") ?: coverUrl(nd.coverArt),
|
||||
badge = "${counts.first}/${counts.second}",
|
||||
onClick = { onPartialAlbumOpen(nd, ia) },
|
||||
onDownload = { onDownload(ia) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Not downloaded ────────────────────────────────────────────
|
||||
if (c.notDownloaded.isNotEmpty()) {
|
||||
item { ArtistSectionHeader("Nicht heruntergeladen", c.notDownloaded.size) }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(c.notDownloaded, key = { it.collectionId ?: 0L }) { ia ->
|
||||
AlbumThumb(
|
||||
name = ia.collectionName ?: "—",
|
||||
year = ia.releaseDate?.take(4),
|
||||
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb"),
|
||||
dimmed = true,
|
||||
onClick = { onItunesAlbumOpen(ia) },
|
||||
onDownload = { onDownload(ia) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item { Spacer(Modifier.height(24.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun AlbumThumb(
|
||||
name: String,
|
||||
year: String?,
|
||||
artUrl: String?,
|
||||
badge: String? = null,
|
||||
dimmed: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
onDownload: (() -> Unit)? = null,
|
||||
) {
|
||||
Column(modifier = Modifier.width(130.dp)) {
|
||||
Box {
|
||||
AsyncImage(
|
||||
model = artUrl, contentDescription = name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(130.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(NsSurface2)
|
||||
.clickable(onClick = onClick)
|
||||
.then(if (dimmed) Modifier.background(Color.Black.copy(0.35f)) else Modifier),
|
||||
)
|
||||
// Track count badge for partial
|
||||
if (badge != null) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(6.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(Color.Black.copy(0.7f))
|
||||
.padding(horizontal = 5.dp, vertical = 2.dp),
|
||||
) {
|
||||
Text(badge, fontFamily = JetBrainsMono, fontSize = 10.sp, color = Color.White)
|
||||
}
|
||||
}
|
||||
// Download button (for partial/not-downloaded)
|
||||
if (onDownload != null) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(6.dp)
|
||||
.size(28.dp)
|
||||
.clip(CircleShape)
|
||||
.background(NsAccent)
|
||||
.clickable(onClick = onDownload),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.Default.Download, null,
|
||||
tint = Color.White, modifier = Modifier.size(14.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(5.dp))
|
||||
Text(name, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 12.sp, color = if (dimmed) NsOnSurface2 else NsOnSurface,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (!year.isNullOrBlank()) {
|
||||
Text(year, fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ItunesArtistTrackRow(
|
||||
track: ItunesItem,
|
||||
isPreviewing: Boolean,
|
||||
onPreview: () -> Unit,
|
||||
onDownload: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onPreview)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
val art = track.artworkUrl100?.replace("100x100bb", "60x60bb")
|
||||
AsyncImage(
|
||||
model = art, contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(4.dp)).background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(track.trackName ?: "", style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isPreviewing) NsAccent else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
listOfNotNull(track.collectionName).joinToString(" · "),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (track.previewUrl != null) {
|
||||
IconButton(onClick = onPreview, modifier = Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
if (isPreviewing) Icons.Default.Stop else Icons.Default.PlayCircle,
|
||||
null, tint = if (isPreviewing) NsAccent else NsOnSurface2,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDownload, modifier = Modifier.size(36.dp)) {
|
||||
Icon(Icons.Default.Download, null, tint = NsOnSurface2, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ArtistSectionHeader(title: String, count: Int? = null) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 16.dp, bottom = 10.dp, end = 16.dp),
|
||||
) {
|
||||
Text(title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = NsOnSurface2, modifier = Modifier.weight(1f))
|
||||
if (count != null) {
|
||||
Text("$count", fontFamily = Outfit, fontSize = 11.sp,
|
||||
color = NsOnSurface2.copy(0.5f),
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(NsSurface2)
|
||||
.padding(horizontal = 7.dp, vertical = 2.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Album
|
||||
import com.novasonic.app.api.model.Artist
|
||||
import com.novasonic.app.api.model.Playlist
|
||||
import com.novasonic.app.ui.component.AlbumCard
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.LibraryTab
|
||||
import com.novasonic.app.viewmodel.LibraryViewModel
|
||||
|
||||
@Composable
|
||||
fun LibraryScreen(
|
||||
coverUrl: (String?) -> String?,
|
||||
onAlbumClick: (Album) -> Unit,
|
||||
onArtistClick: (Artist) -> Unit,
|
||||
onPlaylistClick: (Playlist) -> Unit,
|
||||
) {
|
||||
val vm: LibraryViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
TabRow(
|
||||
selectedTabIndex = state.tab.ordinal,
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = NsAccent,
|
||||
) {
|
||||
LibraryTab.entries.forEach { tab ->
|
||||
Tab(
|
||||
selected = state.tab == tab,
|
||||
onClick = { vm.selectTab(tab) },
|
||||
text = {
|
||||
Text(
|
||||
when (tab) {
|
||||
LibraryTab.PLAYLISTS -> "Playlists"
|
||||
LibraryTab.ALBUMS -> "Alben"
|
||||
LibraryTab.ARTISTS -> "Künstler"
|
||||
},
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
selectedContentColor = NsAccent,
|
||||
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
|
||||
when (state.tab) {
|
||||
LibraryTab.ALBUMS -> LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 150.dp),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(state.albums, key = { it.id }) { album ->
|
||||
AlbumCard(
|
||||
album = album,
|
||||
coverUrl = coverUrl(album.coverArt),
|
||||
width = 150.dp,
|
||||
onClick = { onAlbumClick(album) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LibraryTab.ARTISTS -> LazyColumn(
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
state.artistIndexes.forEach { idx ->
|
||||
item(key = "header_${idx.name}") {
|
||||
Text(
|
||||
text = idx.name,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = NsAccent,
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
items(idx.artist, key = { it.id }) { artist ->
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(artist.name, fontFamily = Outfit,
|
||||
fontWeight = FontWeight.Medium, fontSize = 15.sp)
|
||||
},
|
||||
supportingContent = if (artist.albumCount > 0) { {
|
||||
Text("${artist.albumCount} Alben",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
} } else null,
|
||||
leadingContent = {
|
||||
AsyncImage(
|
||||
model = coverUrl(artist.coverArt),
|
||||
contentDescription = artist.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(52.dp)
|
||||
.clip(CircleShape)
|
||||
.background(NsSurface2),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().clickable { onArtistClick(artist) },
|
||||
colors = ListItemDefaults.colors(containerColor = NsBackground),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LibraryTab.PLAYLISTS -> LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 150.dp),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(state.playlists, key = { it.id }) { pl ->
|
||||
PlaylistCard(
|
||||
name = pl.name,
|
||||
subtitle = "${pl.songCount} Titel",
|
||||
coverUrl = coverUrl(pl.coverArt),
|
||||
onClick = { onPlaylistClick(pl) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaylistCard(name: String, subtitle: String, coverUrl: String?, onClick: () -> Unit) {
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(shape)
|
||||
.background(NsSurface)
|
||||
.border(1.dp, NsBorder, shape)
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = coverUrl,
|
||||
contentDescription = name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
Text(
|
||||
text = name,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 12.sp,
|
||||
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
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.text.input.*
|
||||
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.ui.theme.NsAccent
|
||||
import com.novasonic.app.ui.theme.NsOnSurface2
|
||||
import com.novasonic.app.viewmodel.LoginViewModel
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(onLoggedIn: () -> Unit) {
|
||||
val vm: LoginViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
|
||||
var serverUrl by remember { mutableStateOf("") }
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var showPw by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(state.success) {
|
||||
if (state.success) onLoggedIn()
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "NovaSonic",
|
||||
color = NsAccent,
|
||||
fontSize = 36.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Connect to your Navidrome server",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = NsOnSurface2,
|
||||
)
|
||||
Spacer(Modifier.height(40.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = serverUrl,
|
||||
onValueChange = { serverUrl = it },
|
||||
label = { Text("Server URL") },
|
||||
placeholder = { Text("https://music.example.com") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = fieldColors(),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = { Text("Username") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = fieldColors(),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("Password") },
|
||||
singleLine = true,
|
||||
visualTransformation = if (showPw) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showPw = !showPw }) {
|
||||
Icon(
|
||||
imageVector = if (showPw) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
contentDescription = null,
|
||||
tint = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = fieldColors(),
|
||||
)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
|
||||
if (state.error != null) {
|
||||
Text(
|
||||
text = state.error!!,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { vm.login(serverUrl.trim(), username.trim(), password) },
|
||||
enabled = !state.isLoading && serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
if (state.isLoading) {
|
||||
CircularProgressIndicator(Modifier.size(20.dp), color = MaterialTheme.colorScheme.background, strokeWidth = 2.dp)
|
||||
} else {
|
||||
Text("Connect", fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun fieldColors() = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NsAccent,
|
||||
focusedLabelColor = NsAccent,
|
||||
unfocusedBorderColor = NsOnSurface2,
|
||||
unfocusedLabelColor = NsOnSurface2,
|
||||
cursorColor = NsAccent,
|
||||
)
|
||||
@@ -0,0 +1,555 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||
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.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
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.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.player.PlayerController
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.LyricsViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private enum class NpTab { PLAYER, LYRICS, QUEUE }
|
||||
|
||||
@Composable
|
||||
fun NowPlayingScreen(
|
||||
state: PlayerState,
|
||||
coverUrl: String?,
|
||||
queueCoverUrl: ((String?) -> String?)? = null,
|
||||
controller: PlayerController,
|
||||
onDismiss: () -> Unit,
|
||||
onStarToggle: (() -> Unit)? = null,
|
||||
onShowOptions: (() -> Unit)? = null,
|
||||
onArtistClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val song = state.currentSong
|
||||
var tab by remember { mutableStateOf(NpTab.PLAYER) }
|
||||
|
||||
val statusBarTop: Dp = with(LocalDensity.current) { WindowInsets.safeDrawing.getTop(this).toDp() }
|
||||
val topPad = maxOf(statusBarTop, 36.dp)
|
||||
val navBarBottom: Dp = with(LocalDensity.current) { WindowInsets.safeDrawing.getBottom(this).toDp() }
|
||||
|
||||
// Apple Music-style: cover scales up while playing
|
||||
val coverScale by animateFloatAsState(
|
||||
targetValue = if (state.isPlaying) 1f else 0.86f,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessLow,
|
||||
),
|
||||
label = "coverScale",
|
||||
)
|
||||
|
||||
val context = LocalContext.current
|
||||
Box(Modifier.fillMaxSize().background(NsBackground)) {
|
||||
// Blurred album art full-screen background — crossfade prevents transparent flash on song change
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context).data(coverUrl).crossfade(500).build(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize().blur(72.dp),
|
||||
)
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(0f to Color(0xCC000000), 1f to Color(0xF2000000))
|
||||
)
|
||||
)
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = topPad, bottom = maxOf(navBarBottom, 20.dp))
|
||||
.pointerInput(Unit) {
|
||||
var dragY = 0f
|
||||
detectVerticalDragGestures(
|
||||
onDragEnd = { if (dragY > 120f) onDismiss(); dragY = 0f },
|
||||
onDragCancel = { dragY = 0f },
|
||||
onVerticalDrag = { _, dy -> if (dy > 0) dragY += dy },
|
||||
)
|
||||
},
|
||||
) {
|
||||
// ── Top bar ────────────────────────────────────────────────────
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp)
|
||||
.padding(bottom = 16.dp),
|
||||
) {
|
||||
NpCircleBtn(onClick = onDismiss) {
|
||||
Icon(Icons.Default.KeyboardArrowDown, "Schließen",
|
||||
tint = Color.White, modifier = Modifier.size(24.dp))
|
||||
}
|
||||
|
||||
// Tab pills
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(Color.White.copy(alpha = 0.1f))
|
||||
.padding(3.dp),
|
||||
) {
|
||||
NpTab.entries.forEach { t ->
|
||||
val sel = tab == t
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.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),
|
||||
) {
|
||||
Text(
|
||||
when (t) {
|
||||
NpTab.PLAYER -> "Player"
|
||||
NpTab.LYRICS -> "Lyrics"
|
||||
NpTab.QUEUE -> "Queue"
|
||||
},
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 11.sp,
|
||||
color = if (sel) Color.White else Color.White.copy(alpha = 0.4f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (onShowOptions != null) {
|
||||
NpCircleBtn(onClick = onShowOptions) {
|
||||
Icon(Icons.Default.MoreVert, "Optionen",
|
||||
tint = Color.White, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.size(36.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// ── PLAYER TAB ─────────────────────────────────────────────────
|
||||
if (tab == NpTab.PLAYER) {
|
||||
var dragX by remember { mutableStateOf(0f) }
|
||||
|
||||
// Cover art — full content width, scale-animates like Apple Music
|
||||
AsyncImage(
|
||||
model = coverUrl,
|
||||
contentDescription = song?.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.graphicsLayer { scaleX = coverScale; scaleY = coverScale }
|
||||
.shadow(40.dp, RoundedCornerShape(20.dp))
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(NsSurface2)
|
||||
.pointerInput(Unit) {
|
||||
detectHorizontalDragGestures(
|
||||
onDragEnd = {
|
||||
if (dragX < -80) controller.skipNext()
|
||||
else if (dragX > 80) controller.skipPrev()
|
||||
dragX = 0f
|
||||
},
|
||||
onDragCancel = { dragX = 0f },
|
||||
onHorizontalDrag = { _, d -> dragX += d },
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
// ── Song info ──────────────────────────────────────────────
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 28.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
song?.title ?: "Nichts spielt",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold, fontSize = 22.sp,
|
||||
letterSpacing = (-0.4).sp, color = Color.White,
|
||||
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?.album.isNullOrBlank()) {
|
||||
Text(
|
||||
song!!.album!!,
|
||||
fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = Color.White.copy(alpha = 0.45f),
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (onStarToggle != null && song != null) {
|
||||
val starred = song.starred != null
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(40.dp).clip(CircleShape)
|
||||
.clickable(onClick = onStarToggle),
|
||||
) {
|
||||
Icon(
|
||||
if (starred) Icons.Default.Star else Icons.Default.StarBorder,
|
||||
null,
|
||||
tint = if (starred) NsAccent else Color.White.copy(0.5f),
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
// ── Seek bar ───────────────────────────────────────────────
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 24.dp)) {
|
||||
val progress = if (state.durationMs > 0)
|
||||
(state.positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f) else 0f
|
||||
|
||||
NpSeekBar(
|
||||
progress = progress,
|
||||
onSeek = { controller.seekTo((it * state.durationMs).toLong()) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(formatMs(state.positionMs), fontFamily = JetBrainsMono, fontSize = 11.sp,
|
||||
color = Color.White.copy(0.4f))
|
||||
Text(formatMs(state.durationMs), fontFamily = JetBrainsMono, fontSize = 11.sp,
|
||||
color = Color.White.copy(0.4f))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// ── Controls ───────────────────────────────────────────────
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
) {
|
||||
NpCircleBtn(onClick = { controller.toggleShuffle() }) {
|
||||
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(22.dp),
|
||||
tint = if (state.shuffled) NsAccent else Color.White.copy(0.65f))
|
||||
}
|
||||
NpCircleBtn(onClick = { controller.skipPrev() }) {
|
||||
Icon(Icons.Default.SkipPrevious, null, modifier = Modifier.size(34.dp),
|
||||
tint = Color.White)
|
||||
}
|
||||
// Big play/pause
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(68.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White)
|
||||
.clickable { controller.togglePlay() },
|
||||
) {
|
||||
Icon(
|
||||
if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||
null,
|
||||
tint = Color.Black, modifier = Modifier.size(34.dp),
|
||||
)
|
||||
}
|
||||
NpCircleBtn(onClick = { controller.skipNext() }) {
|
||||
Icon(Icons.Default.SkipNext, null, modifier = Modifier.size(34.dp), tint = Color.White)
|
||||
}
|
||||
NpCircleBtn(onClick = { controller.toggleRepeat() }) {
|
||||
val (icon, tint) = when (state.repeatMode) {
|
||||
androidx.media3.common.Player.REPEAT_MODE_ONE -> Icons.Default.RepeatOne to NsAccent
|
||||
androidx.media3.common.Player.REPEAT_MODE_ALL -> Icons.Default.Repeat to NsAccent
|
||||
else -> Icons.Default.Repeat to Color.White.copy(0.65f)
|
||||
}
|
||||
Icon(icon, null, modifier = Modifier.size(22.dp), tint = tint)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ── LYRICS TAB ─────────────────────────────────────────────────
|
||||
if (tab == NpTab.LYRICS && song != null) {
|
||||
val lyricsVm: LyricsViewModel = viewModel()
|
||||
val lyricsState by lyricsVm.state.collectAsStateWithLifecycle()
|
||||
val listState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(song.id) { lyricsVm.loadForSong(song) }
|
||||
|
||||
val activeIdx = if (lyricsState.lines.isNotEmpty())
|
||||
lyricsState.lines.indexOfLast { it.timeMs <= state.positionMs }.coerceAtLeast(0)
|
||||
else -1
|
||||
|
||||
// Smooth scroll to active line
|
||||
LaunchedEffect(activeIdx) {
|
||||
if (activeIdx >= 0 && lyricsState.lines.isNotEmpty()) {
|
||||
scope.launch {
|
||||
listState.animateScrollToItem(
|
||||
(activeIdx - 2).coerceAtLeast(0),
|
||||
scrollOffset = 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
lyricsState.isLoading -> Box(
|
||||
Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(color = NsAccent.copy(0.7f),
|
||||
modifier = Modifier.size(28.dp), strokeWidth = 2.dp)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text("Lade Lyrics…", fontFamily = Outfit, fontSize = 13.sp,
|
||||
color = Color.White.copy(0.5f))
|
||||
}
|
||||
}
|
||||
|
||||
lyricsState.notFound -> Box(
|
||||
Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text("Keine Lyrics verfügbar", fontFamily = Outfit, fontSize = 14.sp,
|
||||
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),
|
||||
) {
|
||||
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)
|
||||
}
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
lyricsState.plainText.isNotBlank() -> LazyColumn(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
contentPadding = PaddingValues(vertical = 24.dp),
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = lyricsState.plainText,
|
||||
fontFamily = Outfit, fontSize = 15.sp,
|
||||
color = Color.White.copy(0.8f),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── QUEUE TAB ──────────────────────────────────────────────────
|
||||
if (tab == NpTab.QUEUE) {
|
||||
if (state.queue.isEmpty()) {
|
||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Text("Warteschlange leer", fontFamily = Outfit, fontSize = 14.sp,
|
||||
color = Color.White.copy(0.4f))
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
contentPadding = PaddingValues(vertical = 4.dp),
|
||||
) {
|
||||
itemsIndexed(state.queue, key = { idx, _ -> idx }) { idx, qSong ->
|
||||
val active = state.currentIndex == idx
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(if (active) Color.White.copy(0.08f) else Color.Transparent)
|
||||
.clickable { controller.seekToItem(idx) }
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = queueCoverUrl?.invoke(qSong.coverArt),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(Color.White.copy(0.08f)),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(qSong.title, fontFamily = Outfit, fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp, color = if (active) NsAccent else Color.White,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (!qSong.artist.isNullOrBlank()) {
|
||||
Text(qSong.artist!!, fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = Color.White.copy(0.4f),
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
if (active) {
|
||||
Icon(Icons.AutoMirrored.Filled.VolumeUp, null,
|
||||
tint = NsAccent, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NpSeekBar(
|
||||
progress: Float,
|
||||
onSeek: (Float) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
var dragProgress by remember { mutableStateOf(0f) }
|
||||
val displayProgress = if (isDragging) dragProgress else progress
|
||||
|
||||
val trackHeight by animateDpAsState(
|
||||
targetValue = if (isDragging) 5.dp else 3.dp,
|
||||
animationSpec = tween(120),
|
||||
label = "seekTrackHeight",
|
||||
)
|
||||
|
||||
BoxWithConstraints(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier
|
||||
.height(32.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures { offset ->
|
||||
val p = (offset.x / size.width.toFloat()).coerceIn(0f, 1f)
|
||||
onSeek(p)
|
||||
}
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
detectDragGestures(
|
||||
onDragStart = { offset ->
|
||||
isDragging = true
|
||||
dragProgress = (offset.x / size.width.toFloat()).coerceIn(0f, 1f)
|
||||
},
|
||||
onDragEnd = { onSeek(dragProgress); isDragging = false },
|
||||
onDragCancel = { isDragging = false },
|
||||
onDrag = { change, _ ->
|
||||
dragProgress = (change.position.x / size.width.toFloat()).coerceIn(0f, 1f)
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
// Background track
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(trackHeight)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(Color.White.copy(alpha = 0.2f))
|
||||
)
|
||||
// Filled portion
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(displayProgress.coerceIn(0f, 1f))
|
||||
.height(trackHeight)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(Color.White)
|
||||
.align(Alignment.CenterStart)
|
||||
)
|
||||
// Thumb — visible only while dragging
|
||||
if (isDragging) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(14.dp)
|
||||
.offset(x = (maxWidth * displayProgress - 7.dp).coerceIn(0.dp, maxWidth - 14.dp))
|
||||
.align(Alignment.CenterStart)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NpCircleBtn(onClick: () -> Unit, content: @Composable () -> Unit) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(44.dp).clip(CircleShape).clickable(onClick = onClick),
|
||||
) { content() }
|
||||
}
|
||||
|
||||
private fun formatMs(ms: Long): String {
|
||||
val s = (ms / 1000).coerceAtLeast(0)
|
||||
return "%d:%02d".format(s / 60, s % 60)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
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.Download
|
||||
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.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.ItunesItem
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.PartialAlbumState
|
||||
|
||||
private fun normTrack(s: String?) =
|
||||
(s ?: "").lowercase().replace(Regex("[^a-z0-9]"), "").trim()
|
||||
|
||||
@Composable
|
||||
fun PartialAlbumScreen(
|
||||
partialAlbum: PartialAlbumState,
|
||||
playerState: PlayerState,
|
||||
coverUrl: (String?) -> String?,
|
||||
previewingId: Long?,
|
||||
onPlay: (List<Song>, Int) -> Unit,
|
||||
onPreview: (ItunesItem) -> Unit,
|
||||
onDownload: (ItunesItem) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val ia = partialAlbum.itunesAlbum
|
||||
val nd = partialAlbum.ndAlbum
|
||||
|
||||
val artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb")
|
||||
?: coverUrl(nd.coverArt)
|
||||
|
||||
Column(Modifier.fillMaxSize().background(NsBackground)) {
|
||||
// ── Header ────────────────────────────────────────────────────────────
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 4.dp, top = 4.dp, end = 8.dp, bottom = 4.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück", tint = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
AsyncImage(
|
||||
model = artUrl, contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(42.dp).clip(RoundedCornerShape(6.dp)).background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
nd.name, fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
nd.artist ?: ia.artistName ?: "",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { onDownload(ia) }) {
|
||||
Icon(Icons.Default.Download, "Album laden", tint = NsAccent, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
|
||||
if (partialAlbum.isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
|
||||
// Cross-reference: which iTunes tracks have no ND match?
|
||||
val ndNames = partialAlbum.ndSongs.map { normTrack(it.title) }.toSet()
|
||||
val downloadedSongs = partialAlbum.ndSongs.sortedBy { it.track ?: 999 }
|
||||
val missingTracks = partialAlbum.iTracks
|
||||
.filter { normTrack(it.trackName) !in ndNames }
|
||||
.sortedBy { it.trackNumber ?: 999 }
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
// ── Downloaded tracks ─────────────────────────────────────────────
|
||||
if (downloadedSongs.isNotEmpty()) {
|
||||
item {
|
||||
PartialSectionHeader(
|
||||
title = "Heruntergeladen",
|
||||
count = downloadedSongs.size,
|
||||
accent = false,
|
||||
)
|
||||
}
|
||||
itemsIndexed(downloadedSongs, key = { _, s -> "dl_${s.id}" }) { idx, song ->
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = coverUrl(song.coverArt),
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
starred = song.starred != null,
|
||||
onPlay = { onPlay(downloadedSongs, idx) },
|
||||
onStarToggle = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Missing tracks (iTunes only) ──────────────────────────────────
|
||||
if (missingTracks.isNotEmpty()) {
|
||||
item {
|
||||
PartialSectionHeader(
|
||||
title = "Nicht heruntergeladen",
|
||||
count = missingTracks.size,
|
||||
accent = true,
|
||||
)
|
||||
}
|
||||
itemsIndexed(missingTracks, key = { _, t -> "miss_${t.trackId ?: 0L}" }) { _, track ->
|
||||
ItunesArtistTrackRow(
|
||||
track = track,
|
||||
isPreviewing = previewingId == track.trackId,
|
||||
onPreview = { onPreview(track) },
|
||||
onDownload = { onDownload(track) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (downloadedSongs.isEmpty() && missingTracks.isEmpty()) {
|
||||
item {
|
||||
Box(Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Keine Tracks gefunden", color = NsOnSurface2, fontFamily = Outfit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item { Spacer(Modifier.height(24.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PartialSectionHeader(title: String, count: Int, accent: Boolean) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 8.dp, end = 16.dp),
|
||||
) {
|
||||
Text(
|
||||
title,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = if (accent) NsOnSurface2.copy(0.7f) else NsOnSurface2,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
"$count",
|
||||
fontFamily = Outfit, fontSize = 11.sp,
|
||||
color = NsOnSurface2.copy(0.5f),
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(NsSurface2)
|
||||
.padding(horizontal = 7.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(0.4f),
|
||||
modifier = Modifier.padding(horizontal = 16.dp))
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Shuffle
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.PlaylistViewModel
|
||||
|
||||
@Composable
|
||||
fun PlaylistDetailScreen(
|
||||
coverUrl: (String?) -> String?,
|
||||
playerState: PlayerState,
|
||||
onPlay: (List<Song>, Int) -> Unit,
|
||||
onStarToggle: (Song) -> Unit,
|
||||
onShowContextMenu: ((Song) -> Unit)? = null,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val vm: PlaylistViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
|
||||
if (state.isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val pl = state.playlist ?: run {
|
||||
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
state.error ?: "Playlist nicht gefunden",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedButton(onClick = onBack) { Text("Zurück") }
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
item {
|
||||
// ── Hero ─────────────────────────────────────────────────────────
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
AsyncImage(
|
||||
model = coverUrl(pl.coverArt),
|
||||
contentDescription = pl.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0.0f to Color.Transparent,
|
||||
0.40f to NsBackground.copy(alpha = 0.35f),
|
||||
1.0f to NsBackground,
|
||||
)
|
||||
)
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.statusBarsPadding()
|
||||
.padding(start = 14.dp, top = 14.dp)
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.45f))
|
||||
.clickable(onClick = onBack)
|
||||
.align(Alignment.TopStart),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
||||
tint = Color.White, modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
Column(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(horizontal = 20.dp, vertical = 20.dp),
|
||||
) {
|
||||
Text(
|
||||
pl.name,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 26.sp, color = Color.White,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val meta = listOfNotNull(
|
||||
pl.owner?.takeIf { it.isNotBlank() },
|
||||
if (pl.songCount > 0) "${pl.songCount} Songs" else null,
|
||||
).joinToString(" · ")
|
||||
if (meta.isNotBlank()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
meta, fontFamily = Outfit, fontSize = 13.sp,
|
||||
color = Color.White.copy(alpha = 0.65f),
|
||||
)
|
||||
}
|
||||
if (!pl.comment.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
pl.comment!!, fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = Color.White.copy(alpha = 0.45f),
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Play / Shuffle row ────────────────────────────────────────────
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { if (pl.entry.isNotEmpty()) onPlay(pl.entry, 0) },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Play", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { if (pl.entry.isNotEmpty()) onPlay(pl.entry.shuffled(), 0) },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(20.dp), tint = NsAccent)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = NsBorder)
|
||||
}
|
||||
|
||||
itemsIndexed(pl.entry, key = { _, s -> s.id }) { idx, song ->
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = coverUrl(song.coverArt),
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
starred = song.starred != null,
|
||||
onPlay = { onPlay(pl.entry, idx) },
|
||||
onStarToggle = { onStarToggle(song) },
|
||||
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.novasonic.app.api.model.Album
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.NsAccent
|
||||
import com.novasonic.app.ui.theme.NsOnSurface2
|
||||
import com.novasonic.app.viewmodel.SearchViewModel
|
||||
|
||||
@Composable
|
||||
fun SearchScreen(
|
||||
coverUrl: (String?) -> String?,
|
||||
playerState: PlayerState,
|
||||
onSongPlay: (List<Song>, Int) -> Unit,
|
||||
onAlbumClick: (Album) -> Unit,
|
||||
) {
|
||||
val vm: SearchViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
SearchBar(
|
||||
query = state.query,
|
||||
onQueryChange = vm::onQueryChange,
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
)
|
||||
|
||||
when {
|
||||
state.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
state.query.isBlank() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(Icons.Default.Search, contentDescription = null,
|
||||
tint = NsOnSurface2, modifier = Modifier.size(48.dp))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("Search songs, albums, artists",
|
||||
style = MaterialTheme.typography.bodyMedium, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
state.searchResult != null -> {
|
||||
val r = state.searchResult!!
|
||||
LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
|
||||
if (r.song.isNotEmpty()) {
|
||||
item { ResultHeader("Songs") }
|
||||
items(r.song, key = { it.id }) { song ->
|
||||
val idx = r.song.indexOf(song)
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = coverUrl(song.coverArt),
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
onPlay = { onSongPlay(r.song, idx) },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (r.album.isNotEmpty()) {
|
||||
item { ResultHeader("Albums") }
|
||||
items(r.album, key = { it.id }) { album ->
|
||||
ListItem(
|
||||
headlineContent = { Text(album.name) },
|
||||
supportingContent = { Text(album.artist ?: "",
|
||||
style = MaterialTheme.typography.labelSmall) },
|
||||
modifier = Modifier.fillMaxWidth().clickable { onAlbumClick(album) },
|
||||
colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.background),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (r.artist.isNotEmpty()) {
|
||||
item { ResultHeader("Artists") }
|
||||
items(r.artist, key = { it.id }) { artist ->
|
||||
ListItem(
|
||||
headlineContent = { Text(artist.name) },
|
||||
supportingContent = { Text("${artist.albumCount} albums",
|
||||
style = MaterialTheme.typography.labelSmall) },
|
||||
colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.background),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchBar(query: String, onQueryChange: (String) -> Unit, modifier: Modifier) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
placeholder = { Text("Search…") },
|
||||
leadingIcon = { Icon(Icons.Default.Search, null, tint = NsOnSurface2) },
|
||||
singleLine = true,
|
||||
modifier = modifier,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NsAccent,
|
||||
unfocusedBorderColor = NsOnSurface2,
|
||||
cursorColor = NsAccent,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResultHeader(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = NsAccent,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
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.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.SettingsViewModel
|
||||
|
||||
private val SCROBBLE_OPTIONS = listOf(
|
||||
"15s" to "15 Sekunden",
|
||||
"30s" to "30 Sekunden",
|
||||
"third" to "1/3 der Song-Länge",
|
||||
"two_thirds" to "2/3 der Song-Länge",
|
||||
"end" to "Song-Ende",
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen() {
|
||||
val vm: SettingsViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
|
||||
var showPassword by remember { mutableStateOf(false) }
|
||||
var showLogoutDialog by remember { mutableStateOf(false) }
|
||||
var scrobbleExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
if (showLogoutDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showLogoutDialog = false },
|
||||
title = { Text("Abmelden?", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
|
||||
text = { Text("Wirklich abmelden? Alle lokalen Einstellungen werden gelöscht.", fontFamily = Outfit) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { vm.logout(); showLogoutDialog = false }) {
|
||||
Text("Abmelden", color = MaterialTheme.colorScheme.error, fontFamily = Outfit)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showLogoutDialog = false }) {
|
||||
Text("Abbrechen", fontFamily = Outfit)
|
||||
}
|
||||
},
|
||||
containerColor = NsSurface,
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp, vertical = 24.dp),
|
||||
) {
|
||||
Text(
|
||||
"Einstellungen",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 24.sp,
|
||||
color = NsOnSurface,
|
||||
)
|
||||
|
||||
// ── 🌐 Navidrome Server ──────────────────────────────────────────────
|
||||
SectionHeader("🌐 Navidrome Server")
|
||||
|
||||
NsTextField(
|
||||
value = state.serverUrl,
|
||||
onValueChange = vm::onServerUrl,
|
||||
label = "Server URL",
|
||||
placeholder = "https://musik.beispiel.de",
|
||||
keyboardType = KeyboardType.Uri,
|
||||
)
|
||||
NsTextField(
|
||||
value = state.username,
|
||||
onValueChange = vm::onUsername,
|
||||
label = "Benutzername",
|
||||
)
|
||||
NsTextField(
|
||||
value = state.password,
|
||||
onValueChange = vm::onPassword,
|
||||
label = "Passwort",
|
||||
hint = "(unverändert lassen wenn kein Reset gewünscht)",
|
||||
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardType = KeyboardType.Password,
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showPassword = !showPassword }) {
|
||||
Icon(
|
||||
if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
contentDescription = null, tint = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// ── 📊 Bibliothek ────────────────────────────────────────────────────
|
||||
SectionHeader("📊 Bibliothek")
|
||||
|
||||
// Scrobble-Mode dropdown
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = scrobbleExpanded,
|
||||
onExpandedChange = { scrobbleExpanded = it },
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = SCROBBLE_OPTIONS.firstOrNull { it.first == state.scrobbleMode }?.second
|
||||
?: "30 Sekunden",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Scrobble senden nach", fontFamily = Outfit) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(scrobbleExpanded) },
|
||||
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable, true).fillMaxWidth(),
|
||||
colors = fieldColors(),
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = scrobbleExpanded,
|
||||
onDismissRequest = { scrobbleExpanded = false },
|
||||
containerColor = NsSurface2,
|
||||
) {
|
||||
SCROBBLE_OPTIONS.forEach { (key, label) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(label, fontFamily = Outfit, color = NsOnSurface) },
|
||||
onClick = { vm.onScrobbleMode(key); scrobbleExpanded = false },
|
||||
colors = MenuDefaults.itemColors(
|
||||
textColor = if (key == state.scrobbleMode) NsAccent else NsOnSurface
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show play count toggle
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Abspielhäufigkeit anzeigen", fontFamily = Outfit,
|
||||
fontWeight = FontWeight.Medium, fontSize = 14.sp, color = NsOnSurface)
|
||||
Text("Zeigt Wiedergabezähler in Song-Reihen an",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
Switch(
|
||||
checked = state.showPlayCount,
|
||||
onCheckedChange = vm::onShowPlayCount,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = NsBackground,
|
||||
checkedTrackColor = NsAccent,
|
||||
uncheckedThumbColor = NsOnSurface2,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// ── 🎨 Theme ─────────────────────────────────────────────────────────
|
||||
SectionHeader("🎨 Theme")
|
||||
ThemePicker(
|
||||
currentThemeId = state.theme,
|
||||
onSelect = { id ->
|
||||
vm.onTheme(id)
|
||||
applyNsTheme(id)
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// ── 🔑 APIs ──────────────────────────────────────────────────────────
|
||||
SectionHeader("🔑 APIs")
|
||||
|
||||
NsTextField(
|
||||
value = state.lastFmKey,
|
||||
onValueChange = vm::onLastFmKey,
|
||||
label = "Last.fm API Key",
|
||||
hint = "Kostenlos unter last.fm/api/account/create",
|
||||
)
|
||||
NsTextField(
|
||||
value = state.backendUrl,
|
||||
onValueChange = vm::onBackendUrl,
|
||||
label = "NovaSonic Backend URL",
|
||||
hint = "Für Download-to-Server via yt-dlp",
|
||||
keyboardType = KeyboardType.Uri,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Status + Save
|
||||
if (state.error != null) {
|
||||
Text(state.error!!, color = MaterialTheme.colorScheme.error,
|
||||
fontFamily = Outfit, fontSize = 13.sp)
|
||||
}
|
||||
if (state.savedOk) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.Check, null, tint = NsAccent, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Gespeichert", color = NsAccent, fontFamily = Outfit, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = vm::save,
|
||||
enabled = !state.isSaving,
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = NsBackground),
|
||||
) {
|
||||
if (state.isSaving) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = NsBackground)
|
||||
} else {
|
||||
Text("Speichern", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 15.sp)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp), color = NsBorder)
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { showLogoutDialog = true },
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder.copy(
|
||||
brush = androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.error)
|
||||
),
|
||||
) {
|
||||
Text("Abmelden", color = MaterialTheme.colorScheme.error,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 11.sp,
|
||||
letterSpacing = 0.5.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
|
||||
)
|
||||
HorizontalDivider(color = NsBorder)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NsTextField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
label: String,
|
||||
placeholder: String = "",
|
||||
hint: String = "",
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
visualTransformation: VisualTransformation = VisualTransformation.None,
|
||||
trailingIcon: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = { Text(label, fontFamily = Outfit) },
|
||||
placeholder = if (placeholder.isNotBlank()) { { Text(placeholder, color = NsOnSurface2) } } else null,
|
||||
singleLine = true,
|
||||
visualTransformation = visualTransformation,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
|
||||
trailingIcon = trailingIcon,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = fieldColors(),
|
||||
)
|
||||
if (hint.isNotBlank()) {
|
||||
Text(hint, fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(start = 4.dp, top = 2.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemePicker(currentThemeId: String, onSelect: (String) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
NS_THEMES.forEach { theme ->
|
||||
val isActive = theme.id == currentThemeId
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(9.dp))
|
||||
.background(if (isActive) theme.surface else NsSurface)
|
||||
.border(
|
||||
width = if (isActive) 2.dp else 1.dp,
|
||||
color = if (isActive) theme.accent else NsBorder,
|
||||
shape = RoundedCornerShape(9.dp),
|
||||
)
|
||||
.clickable { onSelect(theme.id) }
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
// Accent dot
|
||||
Box(
|
||||
Modifier
|
||||
.size(22.dp)
|
||||
.clip(CircleShape)
|
||||
.background(theme.accent),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
text = theme.name,
|
||||
fontFamily = Outfit,
|
||||
fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
color = if (isActive) theme.onSurface else NsOnSurface2,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (isActive) {
|
||||
Icon(
|
||||
Icons.Default.Check, null,
|
||||
tint = theme.accent,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun fieldColors() = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NsAccent,
|
||||
focusedLabelColor = NsAccent,
|
||||
cursorColor = NsAccent,
|
||||
unfocusedBorderColor = NsOnSurface3,
|
||||
unfocusedLabelColor = NsOnSurface2,
|
||||
focusedTextColor = NsOnSurface,
|
||||
unfocusedTextColor = NsOnSurface,
|
||||
)
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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 androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
|
||||
enum class StarredSort { NEWEST, OLDEST, AZ, BY_ARTIST }
|
||||
|
||||
@Composable
|
||||
fun StarredScreen(
|
||||
coverUrl: (String?) -> String?,
|
||||
playerState: PlayerState,
|
||||
onPlay: (List<Song>, Int) -> Unit,
|
||||
onStarToggle: (Song) -> Unit,
|
||||
onShowContextMenu: ((Song) -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val repo = context.app.repository
|
||||
|
||||
var songs by remember { mutableStateOf<List<Song>>(emptyList()) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var sort by remember { mutableStateOf(StarredSort.NEWEST) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
try {
|
||||
songs = repo.getStarred().song
|
||||
loading = false
|
||||
} catch (e: Exception) {
|
||||
error = e.message
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
val sorted = remember(songs, sort) {
|
||||
when (sort) {
|
||||
StarredSort.NEWEST -> songs.sortedByDescending { it.starred ?: "" }
|
||||
StarredSort.OLDEST -> songs.sortedBy { it.starred ?: "" }
|
||||
StarredSort.AZ -> songs.sortedBy { it.title.lowercase() }
|
||||
StarredSort.BY_ARTIST -> songs.sortedBy { "${it.artist?.lowercase()}${it.title.lowercase()}" }
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Text(
|
||||
"Favoriten",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 26.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 24.dp, bottom = 8.dp),
|
||||
)
|
||||
|
||||
// Sort chips
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 12.dp),
|
||||
) {
|
||||
StarredSort.entries.forEach { s ->
|
||||
FilterChip(
|
||||
selected = sort == s,
|
||||
onClick = { sort = s },
|
||||
label = {
|
||||
Text(when (s) {
|
||||
StarredSort.NEWEST -> "Neueste"
|
||||
StarredSort.OLDEST -> "Älteste"
|
||||
StarredSort.AZ -> "A-Z"
|
||||
StarredSort.BY_ARTIST -> "Künstler"
|
||||
}, fontSize = 12.sp)
|
||||
},
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
|
||||
selectedLabelColor = NsAccent,
|
||||
selectedLeadingIconColor = NsAccent,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
|
||||
if (loading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
} else if (error != null) {
|
||||
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text(error!!, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
} else if (sorted.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text("Noch keine Favoriten", color = NsOnSurface2)
|
||||
}
|
||||
} else {
|
||||
LazyColumn {
|
||||
itemsIndexed(sorted, key = { _, s -> s.id }) { idx, song ->
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = coverUrl(song.coverArt),
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
starred = song.starred != null,
|
||||
onPlay = { onPlay(sorted, idx) },
|
||||
onStarToggle = { onStarToggle(song) },
|
||||
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.novasonic.app.ui.theme
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// ── Dynamic theme state — Compose tracks reads automatically ─────────────────
|
||||
object NsColors {
|
||||
var accent by mutableStateOf(Color(0xFF4F8EF7))
|
||||
var accentDim by mutableStateOf(Color(0xFF2A6CD6))
|
||||
var bg by mutableStateOf(Color(0xFF070D14))
|
||||
var bg2 by mutableStateOf(Color(0xFF0C1520))
|
||||
var surface by mutableStateOf(Color(0xFF182435))
|
||||
var surface2 by mutableStateOf(Color(0xFF1F2F42))
|
||||
var onSurface by mutableStateOf(Color(0xFFEDF2FF))
|
||||
var onSurface2 by mutableStateOf(Color(0xFF7088AA))
|
||||
var onSurface3 by mutableStateOf(Color(0xFF445566))
|
||||
var border by mutableStateOf(Color(0x12FFFFFF))
|
||||
var error by mutableStateOf(Color(0xFFCF6679))
|
||||
}
|
||||
|
||||
// Convenience accessors — all reads in composables are state-tracked
|
||||
val NsBackground get() = NsColors.bg
|
||||
val NsBg2 get() = NsColors.bg2
|
||||
val NsSurface get() = NsColors.surface
|
||||
val NsSurface2 get() = NsColors.surface2
|
||||
val NsAccent get() = NsColors.accent
|
||||
val NsAccentDim get() = NsColors.accentDim
|
||||
val NsOnSurface get() = NsColors.onSurface
|
||||
val NsOnSurface2 get() = NsColors.onSurface2
|
||||
val NsOnSurface3 get() = NsColors.onSurface3
|
||||
val NsBorder get() = NsColors.border
|
||||
val NsError get() = NsColors.error
|
||||
|
||||
// ── Theme definitions — mirrors src/themes.js ────────────────────────────────
|
||||
data class NsTheme(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val accent: Color,
|
||||
val accentDim: Color,
|
||||
val bg: Color,
|
||||
val bg2: Color,
|
||||
val surface: Color,
|
||||
val surface2: Color,
|
||||
val onSurface: Color,
|
||||
val onSurface2: Color,
|
||||
val onSurface3: Color,
|
||||
)
|
||||
|
||||
val NS_THEMES = listOf(
|
||||
NsTheme(
|
||||
"dark-ocean", "Dark Ocean",
|
||||
Color(0xFF4F8EF7), Color(0xFF2A6CD6),
|
||||
Color(0xFF070D14), Color(0xFF0C1520),
|
||||
Color(0xFF182435), Color(0xFF1F2F42),
|
||||
Color(0xFFEDF2FF), Color(0xFF7088AA), Color(0xFF445566),
|
||||
),
|
||||
NsTheme(
|
||||
"dark-amber", "Dark Amber",
|
||||
Color(0xFFE8A020), Color(0xFFC07010),
|
||||
Color(0xFF07070F), Color(0xFF0D0D1A),
|
||||
Color(0xFF1A1A2E), Color(0xFF22223A),
|
||||
Color(0xFFF0EEFC), Color(0xFF8888AA), Color(0xFF555577),
|
||||
),
|
||||
NsTheme(
|
||||
"forest", "Forest",
|
||||
Color(0xFF4CAF7D), Color(0xFF2D7A52),
|
||||
Color(0xFF060D08), Color(0xFF0B1410),
|
||||
Color(0xFF162218), Color(0xFF1D2E20),
|
||||
Color(0xFFEDFAF2), Color(0xFF7A9E85), Color(0xFF455C4A),
|
||||
),
|
||||
NsTheme(
|
||||
"rose-pine", "Rosé Pine",
|
||||
Color(0xFFEB6F92), Color(0xFFB8415E),
|
||||
Color(0xFF191724), Color(0xFF1F1D2E),
|
||||
Color(0xFF21202E), Color(0xFF403D52),
|
||||
Color(0xFFE0DEF4), Color(0xFF908CAA), Color(0xFF6E6A86),
|
||||
),
|
||||
NsTheme(
|
||||
"jade-green", "Jade",
|
||||
Color(0xFF2ECC71), Color(0xFF1F9E55),
|
||||
Color(0xFF07100C), Color(0xFF0C1813),
|
||||
Color(0xFF162A21), Color(0xFF1E3A2D),
|
||||
Color(0xFFE8FFF1), Color(0xFF8FBFA4), Color(0xFF557A63),
|
||||
),
|
||||
)
|
||||
|
||||
fun applyNsTheme(themeId: String) {
|
||||
val t = NS_THEMES.find { it.id == themeId } ?: NS_THEMES.first()
|
||||
NsColors.accent = t.accent
|
||||
NsColors.accentDim = t.accentDim
|
||||
NsColors.bg = t.bg
|
||||
NsColors.bg2 = t.bg2
|
||||
NsColors.surface = t.surface
|
||||
NsColors.surface2 = t.surface2
|
||||
NsColors.onSurface = t.onSurface
|
||||
NsColors.onSurface2 = t.onSurface2
|
||||
NsColors.onSurface3 = t.onSurface3
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.novasonic.app.ui.theme
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
fun NovaSonicTheme(content: @Composable () -> Unit) {
|
||||
// Reads NsColors mutableState — recomposes automatically when theme changes
|
||||
val colorScheme = darkColorScheme(
|
||||
background = NsColors.bg,
|
||||
surface = NsColors.surface,
|
||||
surfaceVariant = NsColors.surface2,
|
||||
primary = NsColors.accent,
|
||||
onPrimary = NsColors.bg,
|
||||
secondary = NsColors.accentDim,
|
||||
onSecondary = NsColors.bg,
|
||||
onBackground = NsColors.onSurface,
|
||||
onSurface = NsColors.onSurface,
|
||||
onSurfaceVariant = NsColors.onSurface2,
|
||||
outline = NsColors.border,
|
||||
error = NsColors.error,
|
||||
)
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = NovaSonicTypography,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.novasonic.app.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.googlefonts.Font
|
||||
import androidx.compose.ui.text.googlefonts.GoogleFont
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.novasonic.app.R
|
||||
|
||||
private val provider = GoogleFont.Provider(
|
||||
providerAuthority = "com.google.android.gms.fonts",
|
||||
providerPackage = "com.google.android.gms",
|
||||
certificates = R.array.com_google_android_gms_fonts_certs,
|
||||
)
|
||||
|
||||
private val OutfitFont = GoogleFont("Outfit")
|
||||
private val JetBrainsMonoFont = GoogleFont("JetBrains Mono")
|
||||
|
||||
val Outfit = FontFamily(
|
||||
Font(OutfitFont, provider, weight = androidx.compose.ui.text.font.FontWeight.Light),
|
||||
Font(OutfitFont, provider, weight = androidx.compose.ui.text.font.FontWeight.Normal),
|
||||
Font(OutfitFont, provider, weight = androidx.compose.ui.text.font.FontWeight.Medium),
|
||||
Font(OutfitFont, provider, weight = androidx.compose.ui.text.font.FontWeight.SemiBold),
|
||||
Font(OutfitFont, provider, weight = androidx.compose.ui.text.font.FontWeight.Bold),
|
||||
Font(OutfitFont, provider, weight = androidx.compose.ui.text.font.FontWeight.ExtraBold),
|
||||
)
|
||||
|
||||
val JetBrainsMono = FontFamily(
|
||||
Font(JetBrainsMonoFont, provider, weight = androidx.compose.ui.text.font.FontWeight.Normal),
|
||||
Font(JetBrainsMonoFont, provider, weight = androidx.compose.ui.text.font.FontWeight.Medium),
|
||||
)
|
||||
|
||||
val NovaSonicTypography = Typography(
|
||||
displayLarge = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 36.sp, letterSpacing = (-0.5).sp),
|
||||
displayMedium = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 28.sp, letterSpacing = (-0.25).sp),
|
||||
titleLarge = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 22.sp, letterSpacing = 0.sp),
|
||||
titleMedium = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, letterSpacing = 0.15.sp),
|
||||
titleSmall = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Medium, fontSize = 14.sp, letterSpacing = 0.1.sp),
|
||||
bodyLarge = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Normal, fontSize = 16.sp, letterSpacing = 0.5.sp),
|
||||
bodyMedium = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Normal, fontSize = 14.sp, letterSpacing = 0.25.sp),
|
||||
bodySmall = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Normal, fontSize = 12.sp, letterSpacing = 0.4.sp),
|
||||
labelLarge = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Medium, fontSize = 14.sp, letterSpacing = 0.1.sp),
|
||||
labelMedium = TextStyle(fontFamily = JetBrainsMono, fontWeight = FontWeight.Normal, fontSize = 12.sp, letterSpacing = 0.5.sp, color = NsOnSurface2),
|
||||
labelSmall = TextStyle(fontFamily = JetBrainsMono, fontWeight = FontWeight.Normal, fontSize = 11.sp, letterSpacing = 0.5.sp, color = NsOnSurface2),
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.model.Album
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class AlbumUiState(
|
||||
val album: Album? = null,
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class AlbumViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
private val albumId: String? = savedState["albumId"]
|
||||
|
||||
private val _state = MutableStateFlow(AlbumUiState())
|
||||
val state: StateFlow<AlbumUiState> = _state
|
||||
|
||||
init {
|
||||
val id = albumId
|
||||
if (id.isNullOrBlank()) {
|
||||
_state.value = AlbumUiState(isLoading = false, error = "Keine Album-ID übergeben")
|
||||
} else {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val album = repo.getAlbum(id)
|
||||
_state.value = AlbumUiState(album = album, isLoading = false)
|
||||
} catch (e: Exception) {
|
||||
_state.value = AlbumUiState(error = e.message ?: e.toString(), isLoading = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import android.media.MediaPlayer
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.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 kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
data class ArtistUiState(
|
||||
val artist: Artist? = null,
|
||||
val info: ArtistInfo? = null,
|
||||
val topSongs: List<Song> = emptyList(),
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null,
|
||||
// iTunes
|
||||
val iAlbums: List<ItunesItem> = emptyList(),
|
||||
val iTracks: List<ItunesItem> = emptyList(),
|
||||
val itunesLoading: Boolean = false,
|
||||
// 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,
|
||||
// Preview + download
|
||||
val previewingId: Long? = null,
|
||||
val downloadSheetItem: ItunesItem? = null,
|
||||
)
|
||||
|
||||
class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
private val artistId: String? = savedState["artistId"]
|
||||
|
||||
private val _state = MutableStateFlow(ArtistUiState())
|
||||
val state: StateFlow<ArtistUiState> = _state
|
||||
|
||||
private var mediaPlayer: MediaPlayer? = null
|
||||
|
||||
init {
|
||||
if (artistId.isNullOrBlank()) {
|
||||
_state.value = ArtistUiState(isLoading = false, error = "Keine Künstler-ID übergeben")
|
||||
} else {
|
||||
load()
|
||||
}
|
||||
}
|
||||
|
||||
fun load() {
|
||||
val artistId = artistId ?: return
|
||||
viewModelScope.launch {
|
||||
_state.value = ArtistUiState(isLoading = true, itunesLoading = true)
|
||||
try {
|
||||
val artistDef = async { repo.getArtist(artistId) }
|
||||
val infoDef = async { runCatching { repo.getArtistInfo(artistId) }.getOrNull() }
|
||||
|
||||
val artist = artistDef.await()
|
||||
val info = infoDef.await()
|
||||
|
||||
val topDef = async { runCatching { repo.getTopSongs(artist.name) }.getOrDefault(emptyList()) }
|
||||
val top = topDef.await()
|
||||
|
||||
_state.value = ArtistUiState(
|
||||
artist = artist, info = info, topSongs = top,
|
||||
isLoading = false, itunesLoading = true,
|
||||
)
|
||||
|
||||
// Fetch iTunes data without blocking the main screen
|
||||
launch { loadItunesData(artist.name) }
|
||||
} catch (e: Exception) {
|
||||
_state.value = ArtistUiState(isLoading = false, itunesLoading = false, error = e.message ?: e.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadItunesData(artistName: String) {
|
||||
val searchResp = runCatching {
|
||||
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
|
||||
|
||||
if (iArtistId != null) {
|
||||
coroutineScope {
|
||||
val iAlbumsD = async { runCatching { ItunesClient.lookupArtistAlbums(iArtistId) }.getOrDefault(emptyList()) }
|
||||
val iTracksD = async { runCatching { ItunesClient.lookupArtistTopTracks(iArtistId, 10) }.getOrDefault(emptyList()) }
|
||||
_state.value = _state.value.copy(
|
||||
iAlbums = iAlbumsD.await(),
|
||||
iTracks = iTracksD.await(),
|
||||
itunesLoading = false,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_state.value = _state.value.copy(itunesLoading = false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sub-album (not-downloaded iTunes album) ──────────────────────────────
|
||||
|
||||
fun loadSubAlbum(album: ItunesItem) {
|
||||
val id = album.collectionId ?: return
|
||||
_state.value = _state.value.copy(
|
||||
subAlbumItem = album,
|
||||
subAlbumTitle = album.collectionName ?: "",
|
||||
subAlbumTracks = null,
|
||||
)
|
||||
viewModelScope.launch {
|
||||
val tracks = runCatching { ItunesClient.lookupAlbum(id) }.getOrDefault(emptyList())
|
||||
_state.value = _state.value.copy(subAlbumTracks = tracks)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissSubAlbum() {
|
||||
_state.value = _state.value.copy(subAlbumItem = null, subAlbumTracks = null, subAlbumTitle = "")
|
||||
}
|
||||
|
||||
// ── Partial album (incomplete album: shows downloaded + missing iTunes tracks) ──
|
||||
|
||||
fun loadPartialAlbum(nd: com.novasonic.app.api.model.Album, ia: ItunesItem) {
|
||||
_state.value = _state.value.copy(
|
||||
partialAlbum = PartialAlbumState(ndAlbum = nd, itunesAlbum = ia, isLoading = true),
|
||||
)
|
||||
viewModelScope.launch {
|
||||
coroutineScope {
|
||||
val ndSongsD = async { runCatching { repo.getAlbum(nd.id).song }.getOrDefault(emptyList()) }
|
||||
val iTracksD = async {
|
||||
ia.collectionId?.let { id ->
|
||||
runCatching { ItunesClient.lookupAlbum(id) }.getOrDefault(emptyList())
|
||||
} ?: emptyList()
|
||||
}
|
||||
_state.value = _state.value.copy(
|
||||
partialAlbum = PartialAlbumState(
|
||||
ndAlbum = nd,
|
||||
itunesAlbum = ia,
|
||||
ndSongs = ndSongsD.await(),
|
||||
iTracks = iTracksD.await(),
|
||||
isLoading = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissPartialAlbum() {
|
||||
_state.value = _state.value.copy(partialAlbum = null)
|
||||
}
|
||||
|
||||
// ── Download sheet ───────────────────────────────────────────────────────
|
||||
|
||||
fun showDownloadSheet(item: ItunesItem) {
|
||||
_state.value = _state.value.copy(downloadSheetItem = item)
|
||||
}
|
||||
|
||||
fun dismissDownloadSheet() {
|
||||
_state.value = _state.value.copy(downloadSheetItem = null)
|
||||
}
|
||||
|
||||
fun downloadItem(item: ItunesItem, type: String, onDone: (Boolean, String) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
|
||||
if (backendUrl.isBlank()) {
|
||||
onDone(false, "Kein Backend konfiguriert — bitte in Einstellungen eintragen")
|
||||
return@launch
|
||||
}
|
||||
val jobId = "job_${System.currentTimeMillis()}"
|
||||
val artwork = item.artworkUrl100?.replace("100x100bb", "600x600bb")
|
||||
val request = when (type) {
|
||||
"album" -> DownloadRequest(
|
||||
jobId = jobId, type = "album",
|
||||
title = item.collectionName ?: item.artistName ?: "Unknown",
|
||||
artist = item.artistName, artworkUrl = artwork,
|
||||
collectionId = item.collectionId, itunesId = item.collectionId,
|
||||
)
|
||||
"artist" -> DownloadRequest(
|
||||
jobId = jobId, type = "artist",
|
||||
title = item.artistName ?: "Unknown",
|
||||
artist = item.artistName, artworkUrl = artwork,
|
||||
)
|
||||
else -> DownloadRequest(
|
||||
jobId = jobId, type = "track",
|
||||
title = item.trackName ?: item.collectionName ?: "Unknown",
|
||||
artist = item.artistName, album = item.collectionName,
|
||||
artworkUrl = artwork, itunesId = item.trackId, collectionId = item.collectionId,
|
||||
)
|
||||
}
|
||||
val client = ImporterClient(backendUrl)
|
||||
runCatching { client.download(request) }
|
||||
.onSuccess { onDone(true, "✓ Download gestartet") }
|
||||
.onFailure { onDone(false, "Backend nicht erreichbar: ${it.message}") }
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
// ── iTunes 30s preview ───────────────────────────────────────────────────
|
||||
|
||||
fun togglePreview(track: ItunesItem) {
|
||||
val url = track.previewUrl ?: return
|
||||
val id = track.trackId ?: return
|
||||
if (_state.value.previewingId == id) stopPreview() else startPreview(url, id)
|
||||
}
|
||||
|
||||
private fun startPreview(url: String, id: Long) {
|
||||
stopPreview()
|
||||
_state.value = _state.value.copy(previewingId = id)
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val mp = MediaPlayer().apply {
|
||||
setDataSource(url)
|
||||
prepare()
|
||||
start()
|
||||
setOnCompletionListener { _state.value = _state.value.copy(previewingId = null) }
|
||||
}
|
||||
mediaPlayer = mp
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(previewingId = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopPreview() {
|
||||
mediaPlayer?.runCatching { stop(); release() }
|
||||
mediaPlayer = null
|
||||
_state.value = _state.value.copy(previewingId = null)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
stopPreview()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import android.media.MediaPlayer
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.ImporterClient
|
||||
import com.novasonic.app.api.ItunesClient
|
||||
import com.novasonic.app.api.model.Album
|
||||
import com.novasonic.app.api.model.DownloadRequest
|
||||
import com.novasonic.app.api.model.ItunesItem
|
||||
import com.novasonic.app.api.model.ItunesRssEntry
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.api.model.artworkHd
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
enum class ExploreTab { LIBRARY, GLOBAL }
|
||||
|
||||
data class PartialAlbumState(
|
||||
val ndAlbum: Album,
|
||||
val itunesAlbum: ItunesItem,
|
||||
val ndSongs: List<Song> = emptyList(),
|
||||
val iTracks: List<ItunesItem> = emptyList(),
|
||||
val isLoading: Boolean = true,
|
||||
)
|
||||
|
||||
data class ItunesArtistState(
|
||||
val artist: ItunesItem,
|
||||
val isLoading: Boolean = true,
|
||||
val ndAlbums: List<Album> = emptyList(),
|
||||
val iAlbums: List<ItunesItem> = emptyList(),
|
||||
val iTracks: List<ItunesItem> = emptyList(),
|
||||
val topSongs: List<Song> = emptyList(),
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
data class ExploreUiState(
|
||||
val tab: ExploreTab = ExploreTab.LIBRARY,
|
||||
val query: String = "",
|
||||
// iTunes search results
|
||||
val artists: List<ItunesItem> = emptyList(),
|
||||
val albums: List<ItunesItem> = emptyList(),
|
||||
val tracks: List<ItunesItem> = emptyList(),
|
||||
// Album drill-down
|
||||
val albumTracks: List<ItunesItem>? = null,
|
||||
val albumTitle: String = "",
|
||||
val albumItem: ItunesItem? = null,
|
||||
// Artist detail (iTunes)
|
||||
val artistDetail: ItunesArtistState? = null,
|
||||
// Partial album detail (opened from artist's "Unvollständig" section)
|
||||
val partialAlbum: PartialAlbumState? = null,
|
||||
// Download sheet
|
||||
val downloadSheetItem: ItunesItem? = null,
|
||||
// Charts (shown when query is empty in GLOBAL tab)
|
||||
val charts: List<ItunesRssEntry> = emptyList(),
|
||||
// Loading states
|
||||
val isSearching: Boolean = false,
|
||||
val chartsLoading: Boolean = false,
|
||||
// Preview
|
||||
val previewingId: Long? = null, // trackId currently playing preview
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class ExploreViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
|
||||
private val _state = MutableStateFlow(ExploreUiState(chartsLoading = true))
|
||||
val state: StateFlow<ExploreUiState> = _state
|
||||
|
||||
private var searchJob: Job? = null
|
||||
private var mediaPlayer: MediaPlayer? = null
|
||||
|
||||
init { loadCharts() }
|
||||
|
||||
fun selectTab(tab: ExploreTab) {
|
||||
_state.value = _state.value.copy(tab = tab, albumTracks = null)
|
||||
}
|
||||
|
||||
fun onQueryChange(q: String) {
|
||||
_state.value = _state.value.copy(query = q, albumTracks = null)
|
||||
searchJob?.cancel()
|
||||
if (q.isBlank()) {
|
||||
_state.value = _state.value.copy(artists = emptyList(), albums = emptyList(), tracks = emptyList())
|
||||
return
|
||||
}
|
||||
searchJob = viewModelScope.launch {
|
||||
delay(350)
|
||||
search(q)
|
||||
}
|
||||
}
|
||||
|
||||
private fun search(q: String) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isSearching = true, error = null)
|
||||
try {
|
||||
val resp = ItunesClient.search(q)
|
||||
val results = resp.results
|
||||
_state.value = _state.value.copy(
|
||||
artists = results.filter { it.wrapperType == "artist" },
|
||||
albums = results.filter { it.wrapperType == "collection" },
|
||||
tracks = results.filter { it.wrapperType == "track" && it.kind == "song" },
|
||||
isSearching = false,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(isSearching = false, error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadAlbum(album: ItunesItem) {
|
||||
val id = album.collectionId ?: return
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isSearching = true, albumTracks = null,
|
||||
albumTitle = album.collectionName ?: "", albumItem = album)
|
||||
try {
|
||||
val tracks = ItunesClient.lookupAlbum(id)
|
||||
_state.value = _state.value.copy(albumTracks = tracks, isSearching = false)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(isSearching = false, error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissAlbum() {
|
||||
_state.value = _state.value.copy(albumTracks = null, albumItem = null)
|
||||
}
|
||||
|
||||
fun openArtistDetail(artist: ItunesItem) {
|
||||
_state.value = _state.value.copy(
|
||||
artistDetail = ItunesArtistState(artist = artist, isLoading = true),
|
||||
)
|
||||
viewModelScope.launch {
|
||||
val artistId = artist.artistId
|
||||
val artistName = artist.artistName ?: return@launch
|
||||
|
||||
val ndAlbumsD: Deferred<List<Album>> = async { runCatching { repo.getNdAlbumsForArtist(artistName) }.getOrDefault(emptyList()) }
|
||||
val topSongsD: Deferred<List<Song>> = async { runCatching { repo.getTopSongs(artistName) }.getOrDefault(emptyList()) }
|
||||
val iAlbumsD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistAlbums(artistId) }.getOrDefault(emptyList()) else emptyList() }
|
||||
val iTracksD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistTopTracks(artistId, 10) }.getOrDefault(emptyList()) else emptyList() }
|
||||
|
||||
_state.value = _state.value.copy(
|
||||
artistDetail = ItunesArtistState(
|
||||
artist = artist,
|
||||
isLoading = false,
|
||||
ndAlbums = ndAlbumsD.await(),
|
||||
iAlbums = iAlbumsD.await(),
|
||||
iTracks = iTracksD.await(),
|
||||
topSongs = topSongsD.await(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun closeArtistDetail() {
|
||||
_state.value = _state.value.copy(artistDetail = null, partialAlbum = null)
|
||||
}
|
||||
|
||||
fun loadPartialAlbum(nd: Album, ia: ItunesItem) {
|
||||
_state.value = _state.value.copy(
|
||||
partialAlbum = PartialAlbumState(ndAlbum = nd, itunesAlbum = ia, isLoading = true),
|
||||
)
|
||||
viewModelScope.launch {
|
||||
coroutineScope {
|
||||
val ndSongsD = async { runCatching { repo.getAlbum(nd.id).song }.getOrDefault(emptyList()) }
|
||||
val iTracksD = async {
|
||||
ia.collectionId?.let { id ->
|
||||
runCatching { ItunesClient.lookupAlbum(id) }.getOrDefault(emptyList())
|
||||
} ?: emptyList()
|
||||
}
|
||||
_state.value = _state.value.copy(
|
||||
partialAlbum = PartialAlbumState(
|
||||
ndAlbum = nd,
|
||||
itunesAlbum = ia,
|
||||
ndSongs = ndSongsD.await(),
|
||||
iTracks = iTracksD.await(),
|
||||
isLoading = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissPartialAlbum() {
|
||||
_state.value = _state.value.copy(partialAlbum = null)
|
||||
}
|
||||
|
||||
fun showDownloadSheet(item: ItunesItem) {
|
||||
_state.value = _state.value.copy(downloadSheetItem = item)
|
||||
}
|
||||
|
||||
fun dismissDownloadSheet() {
|
||||
_state.value = _state.value.copy(downloadSheetItem = null)
|
||||
}
|
||||
|
||||
fun downloadItem(item: ItunesItem, type: String, onDone: (Boolean, String) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
|
||||
if (backendUrl.isBlank()) {
|
||||
onDone(false, "Kein Backend konfiguriert — bitte in Einstellungen eintragen")
|
||||
return@launch
|
||||
}
|
||||
val jobId = "job_${System.currentTimeMillis()}"
|
||||
val artwork = item.artworkUrl100?.replace("100x100bb", "600x600bb")
|
||||
val request = when (type) {
|
||||
"album" -> DownloadRequest(
|
||||
jobId = jobId, type = "album",
|
||||
title = item.collectionName ?: item.artistName ?: "Unknown",
|
||||
artist = item.artistName,
|
||||
artworkUrl = artwork,
|
||||
collectionId = item.collectionId,
|
||||
itunesId = item.collectionId,
|
||||
)
|
||||
"artist" -> DownloadRequest(
|
||||
jobId = jobId, type = "artist",
|
||||
title = item.artistName ?: "Unknown",
|
||||
artist = item.artistName,
|
||||
artworkUrl = artwork,
|
||||
)
|
||||
else -> DownloadRequest(
|
||||
jobId = jobId, type = "track",
|
||||
title = item.trackName ?: item.collectionName ?: "Unknown",
|
||||
artist = item.artistName,
|
||||
album = item.collectionName,
|
||||
artworkUrl = artwork,
|
||||
itunesId = item.trackId,
|
||||
collectionId = item.collectionId,
|
||||
)
|
||||
}
|
||||
val client = ImporterClient(backendUrl)
|
||||
runCatching { client.download(request) }
|
||||
.onSuccess { onDone(true, "✓ Download gestartet") }
|
||||
.onFailure { onDone(false, "Backend nicht erreichbar: ${it.message}") }
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadCharts() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val entries = ItunesClient.topAlbums()
|
||||
_state.value = _state.value.copy(charts = entries, chartsLoading = false)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(chartsLoading = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 30s preview ─────────────────────────────────────────────────────────
|
||||
|
||||
fun togglePreview(track: ItunesItem) {
|
||||
val url = track.previewUrl ?: return
|
||||
val id = track.trackId ?: return
|
||||
val cur = _state.value.previewingId
|
||||
if (cur == id) {
|
||||
stopPreview()
|
||||
} else {
|
||||
startPreview(url, id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startPreview(url: String, id: Long) {
|
||||
stopPreview()
|
||||
_state.value = _state.value.copy(previewingId = id)
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val mp = MediaPlayer().apply {
|
||||
setDataSource(url)
|
||||
prepare()
|
||||
start()
|
||||
setOnCompletionListener { _state.value = _state.value.copy(previewingId = null) }
|
||||
}
|
||||
mediaPlayer = mp
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(previewingId = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopPreview() {
|
||||
mediaPlayer?.runCatching { stop(); release() }
|
||||
mediaPlayer = null
|
||||
_state.value = _state.value.copy(previewingId = null)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
stopPreview()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.model.Album
|
||||
import com.novasonic.app.api.model.Genre
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class HomeUiState(
|
||||
val heroAlbum: Album? = null,
|
||||
val recentAlbums: List<Album> = emptyList(),
|
||||
val frequentAlbums: List<Album> = emptyList(),
|
||||
val randomAlbums: List<Album> = emptyList(),
|
||||
val genres: List<Genre> = emptyList(),
|
||||
val username: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class HomeViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
|
||||
private val _state = MutableStateFlow(HomeUiState(isLoading = true))
|
||||
val state: StateFlow<HomeUiState> = _state
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
repo.appSettingsFlow().collect { s ->
|
||||
_state.value = _state.value.copy(username = s.username)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val recentDef = async { repo.getRecentAlbums(20) }
|
||||
val frequentDef = async { repo.getFrequentAlbums(20) }
|
||||
val randomDef = async { repo.getRandomAlbums(20) }
|
||||
val genresDef = async { runCatching { repo.getGenres() }.getOrDefault(emptyList()) }
|
||||
|
||||
val recent = recentDef.await()
|
||||
val frequent = frequentDef.await()
|
||||
val random = randomDef.await()
|
||||
val genres = genresDef.await()
|
||||
|
||||
// Hero rotates between first recent and first frequent album
|
||||
val hero = if (System.currentTimeMillis() % 2 == 0L) recent.firstOrNull()
|
||||
else frequent.firstOrNull() ?: recent.firstOrNull()
|
||||
|
||||
_state.value = _state.value.copy(
|
||||
heroAlbum = hero,
|
||||
recentAlbums = recent,
|
||||
frequentAlbums = frequent,
|
||||
randomAlbums = random,
|
||||
genres = genres.filter { it.songCount > 0 }.take(12),
|
||||
isLoading = false,
|
||||
error = null,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_state.value = HomeUiState(error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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 kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class ImporterUiState(
|
||||
val backendUrl: String = "",
|
||||
val link: String = "",
|
||||
val platform: String? = null, // spotify | soundcloud | youtube
|
||||
val isResolving: Boolean = false,
|
||||
val resolved: ImportResolveResponse? = null,
|
||||
val error: String? = null,
|
||||
// Download job
|
||||
val isDownloading: Boolean = false,
|
||||
val jobStatus: String? = null, // pending | downloading | done | error
|
||||
val jobMessage: String? = null,
|
||||
val jobCompleted: Int = 0,
|
||||
val jobTotal: Int = 0,
|
||||
)
|
||||
|
||||
class ImporterViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
|
||||
private val _state = MutableStateFlow(ImporterUiState())
|
||||
val state: StateFlow<ImporterUiState> = _state
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
repo.appSettingsFlow().collect { s ->
|
||||
_state.value = _state.value.copy(backendUrl = s.backendUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onLink(v: String) {
|
||||
_state.value = _state.value.copy(
|
||||
link = v,
|
||||
platform = ImporterClient.platformFor(v.trim()),
|
||||
resolved = null,
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
|
||||
fun resolve() {
|
||||
val s = _state.value
|
||||
val url = s.link.trim()
|
||||
if (url.isBlank()) return
|
||||
if (s.backendUrl.isBlank()) {
|
||||
_state.value = s.copy(error = "Backend-URL in Einstellungen konfigurieren")
|
||||
return
|
||||
}
|
||||
val platform = ImporterClient.platformFor(url)
|
||||
if (platform == null) {
|
||||
_state.value = s.copy(error = "Nicht unterstützter Link. Spotify, YouTube oder SoundCloud einfügen.")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isResolving = true, error = null, resolved = null)
|
||||
val client = ImporterClient(s.backendUrl)
|
||||
try {
|
||||
val resp = client.resolve(platform, url)
|
||||
if (resp.error != null) {
|
||||
_state.value = _state.value.copy(isResolving = false, error = mapError(resp.error))
|
||||
} else {
|
||||
_state.value = _state.value.copy(isResolving = false, resolved = resp)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(
|
||||
isResolving = false,
|
||||
error = "Backend nicht erreichbar: ${e.message}",
|
||||
)
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadAll() {
|
||||
val s = _state.value
|
||||
val resolved = s.resolved ?: return
|
||||
if (s.backendUrl.isBlank() || resolved.tracks.isEmpty()) return
|
||||
|
||||
val jobId = "job_${System.currentTimeMillis()}"
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(
|
||||
isDownloading = true, jobStatus = "pending", jobMessage = null,
|
||||
jobCompleted = 0, jobTotal = resolved.tracks.size,
|
||||
)
|
||||
val client = ImporterClient(s.backendUrl)
|
||||
try {
|
||||
client.download(
|
||||
DownloadRequest(
|
||||
jobId = jobId,
|
||||
type = if (resolved.type == "track") "track" else "playlist",
|
||||
title = resolved.name ?: "Import",
|
||||
artist = resolved.artist,
|
||||
artworkUrl = resolved.artworkUrl,
|
||||
tracks = resolved.tracks,
|
||||
)
|
||||
)
|
||||
pollJob(client, jobId)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(
|
||||
isDownloading = false, jobStatus = "error",
|
||||
jobMessage = "Download fehlgeschlagen: ${e.message}",
|
||||
)
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pollJob(client: ImporterClient, jobId: String) {
|
||||
try {
|
||||
repeat(600) { // up to ~20 min at 2s interval
|
||||
delay(2000)
|
||||
val st = runCatching { client.status(jobId) }.getOrNull() ?: return@repeat
|
||||
_state.value = _state.value.copy(
|
||||
jobStatus = st.status,
|
||||
jobMessage = st.message,
|
||||
jobCompleted = st.completed ?: _state.value.jobCompleted,
|
||||
jobTotal = st.total ?: _state.value.jobTotal,
|
||||
)
|
||||
if (st.status == "done" || st.status == "error") {
|
||||
_state.value = _state.value.copy(isDownloading = false)
|
||||
return
|
||||
}
|
||||
}
|
||||
_state.value = _state.value.copy(isDownloading = false)
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapError(code: String): String = when (code) {
|
||||
"EDITORIAL_PLAYLIST" ->
|
||||
"Editorial Playlist: Spotify sperrt eigene kuratierte Playlists. Kopiere sie in eine eigene Playlist und importiere diese."
|
||||
"RATE_LIMITED" -> "Spotify Rate-Limit erreicht — bitte kurz warten."
|
||||
else -> code
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.model.Album
|
||||
import com.novasonic.app.api.model.ArtistIndex
|
||||
import com.novasonic.app.api.model.Playlist
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class LibraryTab { PLAYLISTS, ALBUMS, ARTISTS }
|
||||
|
||||
data class LibraryUiState(
|
||||
val tab: LibraryTab = LibraryTab.PLAYLISTS,
|
||||
val albums: List<Album> = emptyList(),
|
||||
val artistIndexes: List<ArtistIndex> = emptyList(),
|
||||
val playlists: List<Playlist> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class LibraryViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
|
||||
private val _state = MutableStateFlow(LibraryUiState(isLoading = true))
|
||||
val state: StateFlow<LibraryUiState> = _state
|
||||
|
||||
init { loadPlaylists() }
|
||||
|
||||
fun selectTab(tab: LibraryTab) {
|
||||
_state.value = _state.value.copy(tab = tab)
|
||||
when (tab) {
|
||||
LibraryTab.ALBUMS -> if (_state.value.albums.isEmpty()) loadAlbums()
|
||||
LibraryTab.ARTISTS -> if (_state.value.artistIndexes.isEmpty()) loadArtists()
|
||||
LibraryTab.PLAYLISTS -> if (_state.value.playlists.isEmpty()) loadPlaylists()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadAlbums() = viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val albums = repo.getAlphaAlbums(200)
|
||||
_state.value = _state.value.copy(albums = albums, isLoading = false)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(error = e.message, isLoading = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadArtists() = viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val indexes = repo.getArtists()
|
||||
_state.value = _state.value.copy(artistIndexes = indexes, isLoading = false)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(error = e.message, isLoading = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadPlaylists() = viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val pls = repo.getPlaylists()
|
||||
_state.value = _state.value.copy(playlists = pls, isLoading = false)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(error = e.message, isLoading = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.SubsonicClient
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class LoginUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val success: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class LoginViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
|
||||
private val _state = MutableStateFlow(LoginUiState())
|
||||
val state: StateFlow<LoginUiState> = _state
|
||||
|
||||
fun login(serverUrl: String, username: String, password: String) {
|
||||
viewModelScope.launch {
|
||||
_state.value = LoginUiState(isLoading = true)
|
||||
try {
|
||||
val client = SubsonicClient(serverUrl, username, password)
|
||||
client.ping()
|
||||
client.close()
|
||||
repo.saveCredentials(serverUrl, username, password)
|
||||
_state.value = LoginUiState(success = true)
|
||||
} catch (e: Exception) {
|
||||
val msg = when {
|
||||
e.message?.contains("Unable to resolve host") == true -> "Cannot reach server. Check the URL."
|
||||
e.message?.contains("40") == true -> "Wrong username or password."
|
||||
else -> e.message ?: "Connection failed."
|
||||
}
|
||||
_state.value = LoginUiState(error = msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
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 kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class LyricsUiState(
|
||||
val lines: List<LrcLine> = emptyList(), // non-empty = synced
|
||||
val plainText: String = "", // fallback plain lyrics
|
||||
val isLoading: Boolean = false,
|
||||
val notFound: Boolean = false,
|
||||
val songId: String = "", // which song these are for
|
||||
)
|
||||
|
||||
class LyricsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val _state = MutableStateFlow(LyricsUiState())
|
||||
val state: StateFlow<LyricsUiState> = _state
|
||||
|
||||
fun loadForSong(song: Song) {
|
||||
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)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.model.PlaylistWithSongs
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class PlaylistUiState(
|
||||
val playlist: PlaylistWithSongs? = null,
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class PlaylistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
private val playlistId: String? = savedState["playlistId"]
|
||||
|
||||
private val _state = MutableStateFlow(PlaylistUiState())
|
||||
val state: StateFlow<PlaylistUiState> = _state
|
||||
|
||||
init {
|
||||
val id = playlistId
|
||||
if (id.isNullOrBlank()) {
|
||||
_state.value = PlaylistUiState(isLoading = false, error = "Keine Playlist-ID übergeben")
|
||||
} else {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val pl = repo.getPlaylist(id)
|
||||
_state.value = PlaylistUiState(playlist = pl, isLoading = false)
|
||||
} catch (e: Exception) {
|
||||
_state.value = PlaylistUiState(isLoading = false, error = e.message ?: e.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.model.SearchResult3
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class SearchUiState(
|
||||
val query: String = "",
|
||||
val searchResult: SearchResult3? = null,
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class SearchViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
|
||||
private val _state = MutableStateFlow(SearchUiState())
|
||||
val state: StateFlow<SearchUiState> = _state
|
||||
|
||||
private var searchJob: Job? = null
|
||||
|
||||
fun onQueryChange(q: String) = onSearch(q)
|
||||
|
||||
fun onSearch(q: String) {
|
||||
_state.value = _state.value.copy(query = q)
|
||||
searchJob?.cancel()
|
||||
if (q.isBlank()) {
|
||||
_state.value = _state.value.copy(searchResult = null, isLoading = false)
|
||||
return
|
||||
}
|
||||
searchJob = viewModelScope.launch {
|
||||
delay(300)
|
||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val results = repo.search(q)
|
||||
_state.value = _state.value.copy(searchResult = results, isLoading = false)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(error = e.message, isLoading = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.data.PrefsStore
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class SettingsUiState(
|
||||
val serverUrl: String = "",
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val lastFmKey: String = "",
|
||||
val backendUrl: String = "",
|
||||
val scrobbleMode: String = "30s",
|
||||
val showPlayCount: Boolean = false,
|
||||
val theme: String = "dark-ocean",
|
||||
val isSaving: Boolean = false,
|
||||
val savedOk: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
|
||||
private val _state = MutableStateFlow(SettingsUiState())
|
||||
val state: StateFlow<SettingsUiState> = _state
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
repo.appSettingsFlow().collect { s ->
|
||||
_state.value = _state.value.copy(
|
||||
serverUrl = s.serverUrl,
|
||||
username = s.username,
|
||||
password = s.password,
|
||||
lastFmKey = s.lastFmKey,
|
||||
backendUrl = s.backendUrl,
|
||||
scrobbleMode = s.scrobbleMode,
|
||||
showPlayCount = s.showPlayCount,
|
||||
theme = s.theme,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onServerUrl(v: String) = update { it.copy(serverUrl = v, savedOk = false) }
|
||||
fun onUsername(v: String) = update { it.copy(username = v, savedOk = false) }
|
||||
fun onPassword(v: String) = update { it.copy(password = v, savedOk = false) }
|
||||
fun onLastFmKey(v: String) = update { it.copy(lastFmKey = v, savedOk = false) }
|
||||
fun onBackendUrl(v: String) = update { it.copy(backendUrl = v, savedOk = false) }
|
||||
fun onScrobbleMode(v: String) = update { it.copy(scrobbleMode = v, savedOk = false) }
|
||||
fun onShowPlayCount(v: Boolean) = update { it.copy(showPlayCount = v, savedOk = false) }
|
||||
|
||||
fun onTheme(id: String) {
|
||||
update { it.copy(theme = id) }
|
||||
viewModelScope.launch { repo.saveTheme(id) }
|
||||
}
|
||||
|
||||
fun save() {
|
||||
val s = _state.value
|
||||
if (s.serverUrl.isBlank() || s.username.isBlank() || s.password.isBlank()) {
|
||||
_state.value = s.copy(error = "Server, Benutzername und Passwort sind Pflichtfelder")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.value = s.copy(isSaving = true, error = null)
|
||||
try {
|
||||
repo.saveAppSettings(PrefsStore.AppSettings(
|
||||
serverUrl = s.serverUrl,
|
||||
username = s.username,
|
||||
password = s.password,
|
||||
lastFmKey = s.lastFmKey.trim(),
|
||||
backendUrl = s.backendUrl.trimEnd('/'),
|
||||
scrobbleMode = s.scrobbleMode,
|
||||
showPlayCount = s.showPlayCount,
|
||||
theme = s.theme,
|
||||
))
|
||||
_state.value = _state.value.copy(isSaving = false, savedOk = true)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(isSaving = false, error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch { repo.saveCredentials("", "", "") }
|
||||
}
|
||||
|
||||
private inline fun update(block: (SettingsUiState) -> SettingsUiState) {
|
||||
_state.value = block(_state.value)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#000000">
|
||||
|
||||
<WebView
|
||||
android:id="@+id/web_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<array name="com_google_android_gms_fonts_certs">
|
||||
<item>@array/com_google_android_gms_fonts_certs_dev</item>
|
||||
<item>@array/com_google_android_gms_fonts_certs_prod</item>
|
||||
</array>
|
||||
<string-array name="com_google_android_gms_fonts_certs_dev">
|
||||
<item>
|
||||
MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNU2FuIEZyYW5jaXNjbzETMBEGA1UEChMKR29vZ2xlIEluYzEUMBIGA1UECxMLRW5naW5lZXJpbmcxFTATBgNVBAMTDEdvb2dsZSBJbmMuMB4XDTE4MDMwODEzMDMyMloXDTE4MDQwNjEzMDMyMlowLDEqMCgGA1UEAxMhYW5kcm9pZC5nb29nbGVhcGlzLmNvbS9mb250c2RldjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMNvRiimO7gVMf+m37hgKFJMQp1RnDpq7Q1JBSZ8x8SQGQ1bNKGMNM/xoRg8xCLB3m0rvFCKhRAfgHpCb5EBKjt6Z3vQQ2jLAGbbMxz5zR/RbkVH7hSHXfJFz5Dq4TZyPlCAhTJJRMaqUiS24hV0Y7wQzSa8xCCn0dTnX9c0FUoBh0JVAc4FnGAR6RNGkPfyLH3oBd7y1t1Q0CeX9e6uH7LN8W8DtXEQMzFSBVQtMHoqN/Jlz1xXVSHHCpE7Vb6YYJpWkSmyAOjkmJz3n/PpCeHqAXS5VKkGUuOq6bI10eMIg6aXyMODuDzBnIRX0mH8eqq8Ygr5VCFd3xLNkCAwEAAaOCAT0wggE5MB0GA1UdDgQWBBRjyGPj6t3/2XCfVOtpO/ZTPFP5TDAfBgNVHSMEGDAWgBRjyGPj6t3/2XCfVOtpO/ZTPFP5TDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDAzCBmgYDVR0fBIGSMIGPMIGMoIGJoIGGhoGDaHR0cDovL2NybC5nb29nbGUuY29tL3BraS9jcmxzL2dvb2dsZS1hcGlzL3lvdXItdGVzdC1jZXJ0LmNybD9zdGF0ZT1sZjlwT1Bkb3ZROElBWkFCb1RwTFIzSmxheTVOdUlZMHQwdlh6SVBsbERqWEZxMlRjZVBacmFVWmZ0VW9UcmNNa3dDQStlV2s9MA0GCSqGSIb3DQEBBAUAA4IBAQBP0msbfJhgDBkqPH7u6PQqO9bCNPbTxYbclVTQW1LB9aZpqbFLTIzORnU3lSMi9DQ62pR2jCYkmYVzBDxJxoVA8BFQxmFnl+2J+nxHoMH/mJVqdKCq1T7s9N0Q7BtLv3yDpK4kHuv8GZ5R5tFKgf6lv+UrO8gNTF3F5JNVCMJkWNbPNRbSifL4hNnPmH4T7J2t3T/fjzDSi1oiB4N5bCmRFMSY7gNjy+MJQlbRFzGPqRQrJ2F4eaQr8TcpLhw0Q3bHOsPUbHVfGmMRWKq7MkJj0+JrRVWMT7paxRiCGxAVPyJb1dDjqxVE0wlT/3y9VcwrRR3l7KMGJqY1jcobMHs+
|
||||
</item>
|
||||
</string-array>
|
||||
<string-array name="com_google_android_gms_fonts_certs_prod">
|
||||
<item>
|
||||
MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFBYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFBYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBAOKq8yXC9JQZN5WLKUOA1k+WJblR2B65UeJCcjzXBTIPnCDRuNilzLdp6PJH9ECbgGZ4MdOW7U0H8sR2WmqnWK4CZpqJQhW5MbMFX8rNRQLVhF7J9Bxao9D0xWGiIB97+gJHFyxnN79V+6z9c5jzlpO4oX4Jv5b3MuH6fXIPxd3MoNjk1D4JHg7LS5A/0VR5vqST4UrQEv0LNPrXm8E7IM4a2ioIgFo0+vwMi0xdKuY4ByxL8Lx2b0G5tU7hXm2OT0n8wQRpanrr1WNSxflG+T17vBCEfW4lKjTFW5JLNfNT/cEnSfNXF48c3lIgFHH+OPY+mDuCx+BVVjCqUCAwEAAaOBsDCBrTAdBgNVHQ4EFgQUO3Shgt4KWNZWZS8/sVDVZVdNEwQwfQYDVR0jBHYwdIAUO3Shgt4KWNZWZS8/sVDVZVdNEwShWKRWMFQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFBYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjCBgAYDVR0fBHkwdzB1oHOgcYZvaHR0cDovL2NybC5wa2kuZ29vZ2xlYXBpcy5jb20vQ1JML0NBL2E2Mzk5ZjNiNWQxZGFhYzc2MDkzYzM2NzMzNGE1YmMzNjFhNzZhNTcxNGJiZjkwNGRlMjBlNGFjZDBkN2UzMi5jcmwwDQYJKoZIhvcNAQEEBQADggEBADjY8KJhGDBQtFOq1yWNakLe+e8u5vDOCpI2oLFbhfanIhN3nEBoAyqXTDFiD8Qj65xEAyQNiJQ5P3LTBrBr1m3cMdz6YPKX7EqPTjhMGJp9NvnLjzh2IFlv0k4JpNMHfFNz7C3YPHiqBF5aZSKhcKzU5KPk/E7q5jWEKt5XHHS4JrsPVDiGe5dpMQxu2RaEzh+yJfRnEKBa7wLhTb5gkWsE3WNJGkFQ1SioBjcTGW6LfZLl5JOrmMnEv/9hpz31E6wTVVSHXX9yiRFCqRNjI1TSk0u3N1h6wDe/7NwAJG+ZGgQ1I1pXzrr/aBpK/Z3e5C9I2HupPm/zVJyZpBU=
|
||||
</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
Reference in new issue
Block a user