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 {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.novasonic.app"
|
namespace = "com.novasonic.app"
|
||||||
compileSdk = 34
|
compileSdk = 35
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.novasonic.app"
|
applicationId = "com.novasonic.app"
|
||||||
minSdk = 24
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 35
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
buildConfigField("String", "NOVASONIC_URL", "\"https://music.asianxpolish.duckdns.org\"")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
buildConfig = true
|
buildConfig = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,22 +31,68 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
targetCompatibility = JavaVersion.VERSION_1_8
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = "1.8"
|
jvmTarget = "11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
// Core
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.appcompat)
|
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.exoplayer)
|
||||||
implementation(libs.media3.session)
|
implementation(libs.media3.session)
|
||||||
implementation(libs.media3.common)
|
implementation(libs.media3.common)
|
||||||
implementation(libs.media3.datasource)
|
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.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.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
android:name=".NovaSonicApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@drawable/ic_launcher"
|
android:icon="@drawable/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
@@ -20,7 +22,7 @@
|
|||||||
android:largeHeap="true">
|
android:largeHeap="true">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".ui.MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:configChanges="orientation|screenSize|keyboardHidden|smallestScreenSize|screenLayout"
|
android:configChanges="orientation|screenSize|keyboardHidden|smallestScreenSize|screenLayout"
|
||||||
android:windowSoftInputMode="adjustResize"
|
android:windowSoftInputMode="adjustResize"
|
||||||
@@ -32,7 +34,7 @@
|
|||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".MusicService"
|
android:name=".player.MusicService"
|
||||||
android:foregroundServiceType="mediaPlayback"
|
android:foregroundServiceType="mediaPlayback"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@@ -41,7 +43,7 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<!-- Android Auto support -->
|
<!-- Android Auto -->
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="com.google.android.gms.car.application"
|
android:name="com.google.android.gms.car.application"
|
||||||
android:resource="@xml/automotive_app_desc"/>
|
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>
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application) apply false
|
alias(libs.plugins.android.application) apply false
|
||||||
alias(libs.plugins.kotlin.android) apply false
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
|
alias(libs.plugins.ksp) apply false
|
||||||
}
|
}
|
||||||
@@ -1,22 +1,80 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "8.13.2"
|
agp = "8.13.2"
|
||||||
kotlin = "1.9.23"
|
kotlin = "2.1.20"
|
||||||
coreKtx = "1.13.1"
|
composeBom = "2024.12.01"
|
||||||
appcompat = "1.7.0"
|
coreKtx = "1.13.1"
|
||||||
material = "1.12.0"
|
appcompat = "1.7.0"
|
||||||
media3 = "1.4.1"
|
activityCompose = "1.9.3"
|
||||||
coroutines = "1.8.0"
|
navigationCompose = "2.8.3"
|
||||||
|
lifecycleVm = "2.8.7"
|
||||||
|
media3 = "1.4.1"
|
||||||
|
ktor = "2.3.12"
|
||||||
|
kotlinxSerialization = "1.7.3"
|
||||||
|
kotlinxCoroutines = "1.9.0"
|
||||||
|
room = "2.6.1"
|
||||||
|
datastore = "1.1.1"
|
||||||
|
coil = "2.7.0"
|
||||||
|
splashscreen = "1.0.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
# AndroidX core
|
||||||
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||||
media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" }
|
androidx-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" }
|
||||||
media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "media3" }
|
|
||||||
media3-datasource = { group = "androidx.media3", name = "media3-datasource", version.ref = "media3" }
|
# Compose BOM + core
|
||||||
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
|
compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
|
||||||
|
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
compose-icons = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||||
|
|
||||||
|
# Navigation
|
||||||
|
navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||||
|
|
||||||
|
# Lifecycle / ViewModel
|
||||||
|
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleVm" }
|
||||||
|
lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleVm" }
|
||||||
|
|
||||||
|
# Media3
|
||||||
|
media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
|
||||||
|
media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" }
|
||||||
|
media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "media3" }
|
||||||
|
media3-datasource = { group = "androidx.media3", name = "media3-datasource", version.ref = "media3" }
|
||||||
|
media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
|
||||||
|
|
||||||
|
# Ktor (HTTP client)
|
||||||
|
ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
|
||||||
|
ktor-client-android = { group = "io.ktor", name = "ktor-client-android", version.ref = "ktor" }
|
||||||
|
ktor-client-content-neg = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
|
||||||
|
ktor-serialization-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||||
|
ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" }
|
||||||
|
|
||||||
|
# Kotlinx
|
||||||
|
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
|
||||||
|
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
|
||||||
|
kotlin-reflect = { group = "org.jetbrains.kotlin", name = "kotlin-reflect", version.ref = "kotlin" }
|
||||||
|
|
||||||
|
# Room
|
||||||
|
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||||
|
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||||
|
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||||
|
|
||||||
|
# DataStore (settings / credentials)
|
||||||
|
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||||
|
|
||||||
|
# Coil (image loading)
|
||||||
|
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||||
|
|
||||||
|
# Google Fonts (Outfit)
|
||||||
|
compose-ui-google-fonts = { group = "androidx.compose.ui", name = "ui-text-google-fonts" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
|
ksp = { id = "com.google.devtools.ksp", version = "2.1.20-1.0.32" }
|
||||||
BIN
Binary file not shown.
Vendored
+251
@@ -0,0 +1,251 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
Vendored
+94
@@ -0,0 +1,94 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
+21
-1
@@ -186,6 +186,26 @@
|
|||||||
<div id="itunes-album-songs"></div>
|
<div id="itunes-album-songs"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- PARTIAL ALBUM DETAIL (incomplete album: downloaded + missing tracks) -->
|
||||||
|
<div class="page" id="page-partial-album-detail">
|
||||||
|
<button class="back-btn" id="partial-album-back-btn"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>Zurück</button>
|
||||||
|
<div class="album-header">
|
||||||
|
<div class="album-cover" id="partial-album-cover-wrap"><div class="no-art">💿</div></div>
|
||||||
|
<div class="album-meta">
|
||||||
|
<div class="album-type">Unvollständig</div>
|
||||||
|
<div class="album-name" id="partial-album-detail-name">—</div>
|
||||||
|
<div class="album-artist-name" id="partial-album-detail-artist"></div>
|
||||||
|
<div class="album-stats" id="partial-album-detail-stats"></div>
|
||||||
|
<div class="album-actions">
|
||||||
|
<button class="btn-play-all" id="partial-album-play-btn"><svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>Abspielen</button>
|
||||||
|
<button class="btn-outline" id="partial-album-dl-btn"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Fehlende laden</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="partial-album-downloaded"></div>
|
||||||
|
<div id="partial-album-missing"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ALBUMS -->
|
<!-- ALBUMS -->
|
||||||
<div class="page" id="page-albums">
|
<div class="page" id="page-albums">
|
||||||
<div class="page-header"><div class="page-title">Alben</div></div>
|
<div class="page-header"><div class="page-title">Alben</div></div>
|
||||||
@@ -1032,7 +1052,7 @@
|
|||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="np-tabs">
|
<div class="np-tabs">
|
||||||
<button class="np-tab active" id="np-tab-player" onclick="switchNpTab('player')">Jetzt läuft</button>
|
<button class="np-tab active" id="np-tab-player" onclick="switchNpTab('player')">Player</button>
|
||||||
<button class="np-tab" id="np-tab-lyrics" onclick="switchNpTab('lyrics')">Lyrics</button>
|
<button class="np-tab" id="np-tab-lyrics" onclick="switchNpTab('lyrics')">Lyrics</button>
|
||||||
<button class="np-tab" id="np-tab-queue" onclick="switchNpTab('queue')">Queue</button>
|
<button class="np-tab" id="np-tab-queue" onclick="switchNpTab('queue')">Queue</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"enabledPlugins": {
|
|
||||||
"expo@claude-plugins-official": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
.expo/
|
|
||||||
dist/
|
|
||||||
*.jks
|
|
||||||
*.p8
|
|
||||||
*.p12
|
|
||||||
*.key
|
|
||||||
*.mobileprovision
|
|
||||||
*.orig.*
|
|
||||||
web-build/
|
|
||||||
android/
|
|
||||||
ios/
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# Expo HAS CHANGED
|
|
||||||
|
|
||||||
Read the exact versioned docs at https://docs.expo.dev/versions/v56.0.0/ before writing any code.
|
|
||||||
-163
@@ -1,163 +0,0 @@
|
|||||||
import React, { useEffect, useState, Component } from 'react';
|
|
||||||
import { View, StatusBar, Text, ScrollView } from 'react-native';
|
|
||||||
import { NavigationContainer, DarkTheme } from '@react-navigation/native';
|
|
||||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
|
||||||
import { createStackNavigator } from '@react-navigation/stack';
|
|
||||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
|
||||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import TrackPlayer, { Event, State } from 'react-native-track-player';
|
|
||||||
import { setupPlayer } from './src/audio/player';
|
|
||||||
import { useStore } from './src/store';
|
|
||||||
|
|
||||||
import HomeScreen from './src/screens/Home';
|
|
||||||
import { ArtistsScreen, AlbumsScreen } from './src/screens/Library';
|
|
||||||
import ArtistDetailScreen from './src/screens/ArtistDetail';
|
|
||||||
import AlbumDetailScreen from './src/screens/AlbumDetail';
|
|
||||||
import SearchScreen from './src/screens/Search';
|
|
||||||
import LoginScreen from './src/screens/Login';
|
|
||||||
import NowPlayingScreen from './src/screens/NowPlaying';
|
|
||||||
import PlayerBar from './src/components/PlayerBar';
|
|
||||||
|
|
||||||
// ─── ERROR BOUNDARY ───
|
|
||||||
class ErrorBoundary extends Component {
|
|
||||||
state = { error: null };
|
|
||||||
static getDerivedStateFromError(e) { return { error: e }; }
|
|
||||||
render() {
|
|
||||||
if (this.state.error) {
|
|
||||||
return (
|
|
||||||
<ScrollView style={{ flex: 1, backgroundColor: '#121212', padding: 24, paddingTop: 60 }}>
|
|
||||||
<Text style={{ color: '#e05252', fontSize: 16, fontWeight: '700', marginBottom: 12 }}>
|
|
||||||
Fehler beim Starten
|
|
||||||
</Text>
|
|
||||||
<Text style={{ color: '#fff', fontSize: 13, fontFamily: 'monospace' }}>
|
|
||||||
{this.state.error?.message || String(this.state.error)}
|
|
||||||
</Text>
|
|
||||||
<Text style={{ color: '#aaa', fontSize: 12, marginTop: 16 }}>
|
|
||||||
{this.state.error?.stack}
|
|
||||||
</Text>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return this.props.children;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const Tab = createBottomTabNavigator();
|
|
||||||
const Stack = createStackNavigator();
|
|
||||||
|
|
||||||
function HomeTabs() {
|
|
||||||
return (
|
|
||||||
<Tab.Navigator
|
|
||||||
screenOptions={({ route }) => ({
|
|
||||||
headerStyle: {
|
|
||||||
backgroundColor: '#121212',
|
|
||||||
elevation: 0,
|
|
||||||
shadowOpacity: 0,
|
|
||||||
borderBottomWidth: 0,
|
|
||||||
},
|
|
||||||
headerTintColor: '#fff',
|
|
||||||
headerTitleStyle: { fontWeight: '800', fontSize: 20 },
|
|
||||||
tabBarStyle: {
|
|
||||||
backgroundColor: '#1a1a1a',
|
|
||||||
borderTopColor: '#2a2a2a',
|
|
||||||
borderTopWidth: 1,
|
|
||||||
height: 60,
|
|
||||||
paddingBottom: 8,
|
|
||||||
paddingTop: 4,
|
|
||||||
},
|
|
||||||
tabBarActiveTintColor: '#1db954',
|
|
||||||
tabBarInactiveTintColor: '#666',
|
|
||||||
tabBarLabelStyle: { fontSize: 10, fontWeight: '600', marginTop: 2 },
|
|
||||||
tabBarIcon: ({ color, size }) => {
|
|
||||||
const icons = { Home: 'home', Artists: 'person', Albums: 'disc', Search: 'search', Settings: 'settings-outline' };
|
|
||||||
return <Ionicons name={icons[route.name] || 'ellipse'} size={22} color={color} />;
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Tab.Screen name="Home" component={HomeScreen} options={{ title: 'Start', headerTitle: 'NovaSonic' }} />
|
|
||||||
<Tab.Screen name="Artists" component={ArtistsScreen} options={{ title: 'Künstler' }} />
|
|
||||||
<Tab.Screen name="Albums" component={AlbumsScreen} options={{ title: 'Alben' }} />
|
|
||||||
<Tab.Screen name="Search" component={SearchScreen} options={{ title: 'Suche' }} />
|
|
||||||
<Tab.Screen name="Settings" component={LoginScreen} options={{ title: 'Einstellungen' }} />
|
|
||||||
</Tab.Navigator>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RootNavigator() {
|
|
||||||
const currentTrack = useStore(s => s.currentTrack);
|
|
||||||
return (
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
|
||||||
<Stack.Screen name="Tabs" component={HomeTabs} />
|
|
||||||
<Stack.Screen name="NowPlaying" component={NowPlayingScreen} options={{ presentation: 'modal' }} />
|
|
||||||
<Stack.Screen name="AlbumDetail" component={AlbumDetailScreen} />
|
|
||||||
<Stack.Screen name="ArtistDetail" component={ArtistDetailScreen} />
|
|
||||||
</Stack.Navigator>
|
|
||||||
{currentTrack && <PlayerBar />}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
const { loadConfig } = useStore();
|
|
||||||
const [ready, setReady] = useState(false);
|
|
||||||
const [initError, setInitError] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
await loadConfig();
|
|
||||||
await setupPlayer();
|
|
||||||
} catch (e) {
|
|
||||||
setInitError(e?.message || String(e));
|
|
||||||
}
|
|
||||||
setReady(true);
|
|
||||||
})();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const sub1 = TrackPlayer.addEventListener(Event.PlaybackActiveTrackChanged, e => {
|
|
||||||
if (e.track) useStore.getState().setCurrentTrack(e.track._song || e.track);
|
|
||||||
});
|
|
||||||
const sub2 = TrackPlayer.addEventListener(Event.PlaybackState, e => {
|
|
||||||
useStore.getState().setIsPlaying(e.state === State.Playing);
|
|
||||||
});
|
|
||||||
return () => { sub1.remove(); sub2.remove(); };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!ready) return (
|
|
||||||
<View style={{ flex: 1, backgroundColor: '#121212' }} />
|
|
||||||
);
|
|
||||||
|
|
||||||
if (initError) return (
|
|
||||||
<ScrollView style={{ flex: 1, backgroundColor: '#121212', padding: 24, paddingTop: 60 }}>
|
|
||||||
<Text style={{ color: '#e05252', fontSize: 16, fontWeight: '700', marginBottom: 12 }}>Init-Fehler</Text>
|
|
||||||
<Text style={{ color: '#fff', fontSize: 13 }}>{initError}</Text>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ErrorBoundary>
|
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
|
||||||
<SafeAreaProvider>
|
|
||||||
<StatusBar barStyle="light-content" backgroundColor="#121212" />
|
|
||||||
<NavigationContainer theme={{
|
|
||||||
...DarkTheme,
|
|
||||||
colors: {
|
|
||||||
...DarkTheme.colors,
|
|
||||||
primary: '#1db954',
|
|
||||||
background: '#121212',
|
|
||||||
card: '#1e1e1e',
|
|
||||||
text: '#ffffff',
|
|
||||||
border: '#2a2a2a',
|
|
||||||
notification: '#1db954',
|
|
||||||
},
|
|
||||||
}}>
|
|
||||||
<RootNavigator />
|
|
||||||
</NavigationContainer>
|
|
||||||
</SafeAreaProvider>
|
|
||||||
</GestureHandlerRootView>
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
@AGENTS.md
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
The MIT License (MIT)
|
|
||||||
|
|
||||||
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
{
|
|
||||||
"expo": {
|
|
||||||
"name": "NovaSonic",
|
|
||||||
"slug": "NovaSonic_Mobile",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"orientation": "portrait",
|
|
||||||
"icon": "./assets/icon.png",
|
|
||||||
"userInterfaceStyle": "dark",
|
|
||||||
"ios": {
|
|
||||||
"supportsTablet": true,
|
|
||||||
"infoPlist": {
|
|
||||||
"UIBackgroundModes": [
|
|
||||||
"audio"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"android": {
|
|
||||||
"adaptiveIcon": {
|
|
||||||
"backgroundColor": "#121212",
|
|
||||||
"foregroundImage": "./assets/android-icon-foreground.png"
|
|
||||||
},
|
|
||||||
"package": "com.honeyy.novasonic",
|
|
||||||
"permissions": [
|
|
||||||
"android.permission.FOREGROUND_SERVICE",
|
|
||||||
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"web": {
|
|
||||||
"favicon": "./assets/favicon.png"
|
|
||||||
},
|
|
||||||
"extra": {
|
|
||||||
"eas": {
|
|
||||||
"projectId": "8912fd83-6662-4511-97c9-b924e0f9207f"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"plugins": [
|
|
||||||
"expo-font"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 77 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 384 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB |
@@ -1,6 +0,0 @@
|
|||||||
module.exports = function (api) {
|
|
||||||
api.cache(true);
|
|
||||||
return {
|
|
||||||
presets: ['babel-preset-expo'],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"cli": {
|
|
||||||
"version": ">= 16.0.0"
|
|
||||||
},
|
|
||||||
"build": {
|
|
||||||
"preview": {
|
|
||||||
"android": {
|
|
||||||
"buildType": "apk"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"production": {
|
|
||||||
"android": {
|
|
||||||
"buildType": "app-bundle"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import 'react-native-gesture-handler';
|
|
||||||
import { registerRootComponent } from 'expo';
|
|
||||||
import TrackPlayer from 'react-native-track-player';
|
|
||||||
import { PlaybackService } from './src/audio/service';
|
|
||||||
import App from './App';
|
|
||||||
|
|
||||||
TrackPlayer.registerPlaybackService(() => PlaybackService);
|
|
||||||
|
|
||||||
registerRootComponent(App);
|
|
||||||
Generated
-6530
File diff suppressed because it is too large.
Load diff
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "novasonic_mobile",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"main": "index.js",
|
|
||||||
"dependencies": {
|
|
||||||
"@expo/vector-icons": "^15.1.1",
|
|
||||||
"@react-native-community/slider": "^5.2.0",
|
|
||||||
"@react-navigation/bottom-tabs": "^7.17.2",
|
|
||||||
"@react-navigation/native": "^7.3.0",
|
|
||||||
"@react-navigation/stack": "^7.10.2",
|
|
||||||
"expo": "~56.0.9",
|
|
||||||
"expo-file-system": "^56.0.7",
|
|
||||||
"expo-font": "~56.0.5",
|
|
||||||
"expo-image": "^56.0.10",
|
|
||||||
"expo-secure-store": "^56.0.4",
|
|
||||||
"expo-status-bar": "~56.0.4",
|
|
||||||
"react": "19.2.3",
|
|
||||||
"react-native": "0.85.3",
|
|
||||||
"react-native-gesture-handler": "~2.31.1",
|
|
||||||
"react-native-safe-area-context": "~5.7.0",
|
|
||||||
"react-native-screens": "^4.25.2",
|
|
||||||
"react-native-track-player": "^5.0.0-alpha0",
|
|
||||||
"zustand": "^5.0.14"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"start": "expo start",
|
|
||||||
"android": "expo start --android",
|
|
||||||
"ios": "expo start --ios",
|
|
||||||
"web": "expo start --web"
|
|
||||||
},
|
|
||||||
"private": true,
|
|
||||||
"devDependencies": {
|
|
||||||
"babel-preset-expo": "^56.0.14"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
import { useStore } from '../store';
|
|
||||||
import { md5 } from './utils';
|
|
||||||
|
|
||||||
// ─── SUBSONIC AUTH ───
|
|
||||||
function getCfg() { return useStore.getState().cfg; }
|
|
||||||
|
|
||||||
function apiUrl(method, extra = '') {
|
|
||||||
const { url, user, pass } = getCfg();
|
|
||||||
const salt = Math.random().toString(36).slice(2, 10);
|
|
||||||
const token = md5(pass + salt);
|
|
||||||
return `${url}/rest/${method}?u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=NovaSonic&f=json${extra}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function api(method, extra = '') {
|
|
||||||
const res = await fetch(apiUrl(method, extra));
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
const data = await res.json();
|
|
||||||
const r = data['subsonic-response'];
|
|
||||||
if (r.status !== 'ok') throw new Error(r.error?.message || 'API Error');
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── API CACHE ───
|
|
||||||
const _apiCache = new Map();
|
|
||||||
const _CACHE_TTL = 5 * 60 * 1000;
|
|
||||||
|
|
||||||
export function apiIsCached(method, extra = '') {
|
|
||||||
const hit = _apiCache.get(method + extra);
|
|
||||||
return !!(hit && Date.now() - hit.ts < _CACHE_TTL);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function apiCached(method, extra = '', ttl = _CACHE_TTL) {
|
|
||||||
const key = method + extra;
|
|
||||||
const hit = _apiCache.get(key);
|
|
||||||
if (hit && Date.now() - hit.ts < ttl) return hit.data;
|
|
||||||
const data = await api(method, extra);
|
|
||||||
_apiCache.set(key, { data, ts: Date.now() });
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function invalidateApiCache(method, extra) {
|
|
||||||
if (method) _apiCache.delete(method + (extra || ''));
|
|
||||||
else _apiCache.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── STABLE COVER URL (session-cached salt so image cache works) ───
|
|
||||||
const _coverUrlCache = new Map();
|
|
||||||
let _coverSalt = null, _coverToken = null;
|
|
||||||
|
|
||||||
function _coverAuth() {
|
|
||||||
const { user, pass } = getCfg();
|
|
||||||
if (!_coverSalt && user && pass) {
|
|
||||||
_coverSalt = md5(user + pass).slice(0, 8);
|
|
||||||
_coverToken = md5(pass + _coverSalt);
|
|
||||||
}
|
|
||||||
return _coverSalt
|
|
||||||
? `u=${encodeURIComponent(user)}&t=${_coverToken}&s=${_coverSalt}`
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function coverUrl(id, size = 300) {
|
|
||||||
if (!id) return null;
|
|
||||||
const { url } = getCfg();
|
|
||||||
const key = `${id}:${size}`;
|
|
||||||
if (!_coverUrlCache.has(key)) {
|
|
||||||
const auth = _coverAuth();
|
|
||||||
const u = auth
|
|
||||||
? `${url}/rest/getCoverArt?${auth}&v=1.16.1&c=NovaSonic&f=json&id=${id}&size=${size}`
|
|
||||||
: apiUrl('getCoverArt', `&id=${id}&size=${size}`);
|
|
||||||
_coverUrlCache.set(key, u);
|
|
||||||
}
|
|
||||||
return _coverUrlCache.get(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function invalidateCoverCache() {
|
|
||||||
_coverUrlCache.clear(); _coverSalt = null; _coverToken = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function streamUrl(id) { return apiUrl('stream', `&id=${id}&format=raw`); }
|
|
||||||
export function downloadUrl(id) { return apiUrl('download', `&id=${id}`); }
|
|
||||||
|
|
||||||
// ─── ROMAJI / SORT ───
|
|
||||||
let _romajiMap = null;
|
|
||||||
let _artistRomajiMap = null;
|
|
||||||
let _artistSortMap = null;
|
|
||||||
let _albumRomajiMap = null;
|
|
||||||
let _romajiIndex = null;
|
|
||||||
let _romajiIndexFetch = null;
|
|
||||||
let _artistSortFetch = null;
|
|
||||||
|
|
||||||
const _isJp = (s) => /[ぁ-んァ-ン一-龯]/.test(s);
|
|
||||||
|
|
||||||
function _buildRomajiMap(idx) {
|
|
||||||
const m = new Map(), am = new Map(), bm = new Map();
|
|
||||||
if (Array.isArray(idx)) idx.forEach(e => {
|
|
||||||
m.set(String(e.id), e);
|
|
||||||
if (e.artistId && e.artistRomaji) am.set(String(e.artistId), e.artistRomaji);
|
|
||||||
if (e.album && e.albumRomaji && !bm.has(e.album)) bm.set(e.album, e.albumRomaji);
|
|
||||||
});
|
|
||||||
_artistRomajiMap = am;
|
|
||||||
_albumRomajiMap = bm;
|
|
||||||
return m;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchRomajiIndex() {
|
|
||||||
const { backendUrl, url, user, pass } = getCfg();
|
|
||||||
if (!backendUrl || !url || !user || !pass) return null;
|
|
||||||
if (_romajiIndex) return _romajiIndex;
|
|
||||||
if (_romajiIndexFetch) return _romajiIndexFetch;
|
|
||||||
_romajiIndexFetch = fetch(
|
|
||||||
`${backendUrl}/api/romaji-index?ndUrl=${encodeURIComponent(url)}&user=${encodeURIComponent(user)}&pass=${encodeURIComponent(pass)}`
|
|
||||||
).then(r => r.ok ? r.json() : null)
|
|
||||||
.then(idx => { _romajiIndex = idx; _romajiMap = _buildRomajiMap(idx); _romajiIndexFetch = null; return idx; })
|
|
||||||
.catch(() => { _romajiIndexFetch = null; return null; });
|
|
||||||
return _romajiIndexFetch;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function invalidateRomajiIndex() {
|
|
||||||
_romajiIndex = null; _romajiMap = null; _artistRomajiMap = null;
|
|
||||||
_artistSortMap = null; _albumRomajiMap = null; _artistSortFetch = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureArtistSortMap() {
|
|
||||||
if (_artistSortMap) return;
|
|
||||||
if (_artistSortFetch) return _artistSortFetch;
|
|
||||||
_artistSortFetch = apiCached('getArtists')
|
|
||||||
.then(r => {
|
|
||||||
const all = (r?.artists?.index || []).flatMap(i => i.artist || []);
|
|
||||||
cacheArtistSortNames(all);
|
|
||||||
_artistSortFetch = null;
|
|
||||||
})
|
|
||||||
.catch(() => { _artistSortFetch = null; });
|
|
||||||
return _artistSortFetch;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function cacheArtistSortNames(artists) {
|
|
||||||
if (!_artistSortMap) _artistSortMap = new Map();
|
|
||||||
for (const a of artists) {
|
|
||||||
if (a.id && a.sortName && !_isJp(a.sortName))
|
|
||||||
_artistSortMap.set(String(a.id), a.sortName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function romajiLookup(id) { return _romajiMap?.get(String(id)) ?? null; }
|
|
||||||
|
|
||||||
// ─── DISPLAY HELPERS ───
|
|
||||||
export function dispTitle(s) {
|
|
||||||
const { showRomaji } = getCfg();
|
|
||||||
if (!showRomaji) return s.title || '';
|
|
||||||
const st = s.sortName || s.titleSort;
|
|
||||||
if (st && !_isJp(st)) return st;
|
|
||||||
return romajiLookup(s.id)?.titleRomaji || s.title || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dispArtist(s) {
|
|
||||||
const { showRomaji } = getCfg();
|
|
||||||
if (!showRomaji) return s.artist || '';
|
|
||||||
return romajiLookup(s.id)?.artistRomaji || s.artist || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dispAlbum(s) {
|
|
||||||
return s.album || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dispArtistById(id, name) {
|
|
||||||
const { showRomaji } = getCfg();
|
|
||||||
if (!showRomaji) return name || '';
|
|
||||||
const sort = _artistSortMap?.get(String(id));
|
|
||||||
if (sort) return sort;
|
|
||||||
return _artistRomajiMap?.get(String(id)) || name || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dispAlbumName(album) {
|
|
||||||
const name = album?.name || '';
|
|
||||||
const { showRomaji } = getCfg();
|
|
||||||
if (!showRomaji) return name;
|
|
||||||
const sort = album?.sortName;
|
|
||||||
if (sort && !_isJp(sort)) return sort;
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── STAR / UNSTAR ───
|
|
||||||
export async function star(id) { await api('star', `&id=${id}`); }
|
|
||||||
export async function unstar(id) { await api('unstar', `&id=${id}`); }
|
|
||||||
export async function scrobble(id) {
|
|
||||||
try { await api('scrobble', `&id=${id}&submission=true`); } catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── ARTIST IMAGE (Last.fm via Navidrome) ───
|
|
||||||
export async function getArtistImage(artistId) {
|
|
||||||
const cache = useStore.getState().artistImgCache;
|
|
||||||
if (cache[artistId] !== undefined) return cache[artistId];
|
|
||||||
try {
|
|
||||||
const r = await api('getArtistInfo2', `&id=${artistId}&count=0`);
|
|
||||||
const info = r.artistInfo2 || {};
|
|
||||||
const img = info.largeImageUrl || info.mediumImageUrl || info.smallImageUrl || null;
|
|
||||||
const isBlank = img && (
|
|
||||||
img.includes('2a96cbd8b46e442fc41c2b86b821562f') || img.endsWith('/noimage/')
|
|
||||||
);
|
|
||||||
const result = isBlank ? null : img;
|
|
||||||
useStore.getState().setArtistImg(artistId, result);
|
|
||||||
return result;
|
|
||||||
} catch {
|
|
||||||
useStore.getState().setArtistImg(artistId, null);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── SEARCH ───
|
|
||||||
export async function searchAll(query) {
|
|
||||||
const r = await api('search3', `&query=${encodeURIComponent(query)}&artistCount=5&albumCount=5&songCount=20`);
|
|
||||||
return {
|
|
||||||
artists: r.searchResult3?.artist || [],
|
|
||||||
albums: r.searchResult3?.album || [],
|
|
||||||
songs: r.searchResult3?.song || [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
export function md5(input) {
|
|
||||||
function safeAdd(x,y){var l=(x&0xffff)+(y&0xffff);return((x>>16)+(y>>16)+(l>>16)<<16)|(l&0xffff)}
|
|
||||||
function rol(n,c){return(n<<c)|(n>>>32-c)}
|
|
||||||
function cmn(q,a,b,x,s,t){return safeAdd(rol(safeAdd(safeAdd(a,q),safeAdd(x,t)),s),b)}
|
|
||||||
function ff(a,b,c,d,x,s,t){return cmn((b&c)|(~b&d),a,b,x,s,t)}
|
|
||||||
function gg(a,b,c,d,x,s,t){return cmn((b&d)|(c&~d),a,b,x,s,t)}
|
|
||||||
function hh(a,b,c,d,x,s,t){return cmn(b^c^d,a,b,x,s,t)}
|
|
||||||
function ii(a,b,c,d,x,s,t){return cmn(c^(b|~d),a,b,x,s,t)}
|
|
||||||
var l=input.length,m=[];
|
|
||||||
for(var i=0;i<l;i++)m[i>>2]|=(input.charCodeAt(i)&0xff)<<((i%4)*8);
|
|
||||||
m[l>>2]|=0x80<<((l%4)*8);
|
|
||||||
m[((l+8>>6)+1)*16-2]=l*8;
|
|
||||||
var a=1732584193,b=-271733879,c=-1732584194,d=271733878;
|
|
||||||
for(var i=0;i<m.length;i+=16){
|
|
||||||
var A=a,B=b,C=c,D=d;
|
|
||||||
a=ff(a,b,c,d,m[i+0],7,-680876936); d=ff(d,a,b,c,m[i+1],12,-389564586); c=ff(c,d,a,b,m[i+2],17,606105819); b=ff(b,c,d,a,m[i+3],22,-1044525330);
|
|
||||||
a=ff(a,b,c,d,m[i+4],7,-176418897); d=ff(d,a,b,c,m[i+5],12,1200080426); c=ff(c,d,a,b,m[i+6],17,-1473231341); b=ff(b,c,d,a,m[i+7],22,-45705983);
|
|
||||||
a=ff(a,b,c,d,m[i+8],7,1770035416); d=ff(d,a,b,c,m[i+9],12,-1958414417); c=ff(c,d,a,b,m[i+10],17,-42063); b=ff(b,c,d,a,m[i+11],22,-1990404162);
|
|
||||||
a=ff(a,b,c,d,m[i+12],7,1804603682); d=ff(d,a,b,c,m[i+13],12,-40341101); c=ff(c,d,a,b,m[i+14],17,-1502002290); b=ff(b,c,d,a,m[i+15],22,1236535329);
|
|
||||||
a=gg(a,b,c,d,m[i+1],5,-165796510); d=gg(d,a,b,c,m[i+6],9,-1069501632); c=gg(c,d,a,b,m[i+11],14,643717713); b=gg(b,c,d,a,m[i+0],20,-373897302);
|
|
||||||
a=gg(a,b,c,d,m[i+5],5,-701558691); d=gg(d,a,b,c,m[i+10],9,38016083); c=gg(c,d,a,b,m[i+15],14,-660478335); b=gg(b,c,d,a,m[i+4],20,-405537848);
|
|
||||||
a=gg(a,b,c,d,m[i+9],5,568446438); d=gg(d,a,b,c,m[i+14],9,-1019803690); c=gg(c,d,a,b,m[i+3],14,-187363961); b=gg(b,c,d,a,m[i+8],20,1163531501);
|
|
||||||
a=gg(a,b,c,d,m[i+13],5,-1444681467); d=gg(d,a,b,c,m[i+2],9,-51403784); c=gg(c,d,a,b,m[i+7],14,1735328473); b=gg(b,c,d,a,m[i+12],20,-1926607734);
|
|
||||||
a=hh(a,b,c,d,m[i+5],4,-378558); d=hh(d,a,b,c,m[i+8],11,-2022574463); c=hh(c,d,a,b,m[i+11],16,1839030562); b=hh(b,c,d,a,m[i+14],23,-35309556);
|
|
||||||
a=hh(a,b,c,d,m[i+1],4,-1530992060); d=hh(d,a,b,c,m[i+4],11,1272893353); c=hh(c,d,a,b,m[i+7],16,-155497632); b=hh(b,c,d,a,m[i+10],23,-1094730640);
|
|
||||||
a=hh(a,b,c,d,m[i+13],4,681279174); d=hh(d,a,b,c,m[i+0],11,-358537222); c=hh(c,d,a,b,m[i+3],16,-722521979); b=hh(b,c,d,a,m[i+6],23,76029189);
|
|
||||||
a=hh(a,b,c,d,m[i+9],4,-640364487); d=hh(d,a,b,c,m[i+12],11,-421815835); c=hh(c,d,a,b,m[i+15],16,530742520); b=hh(b,c,d,a,m[i+2],23,-995338651);
|
|
||||||
a=ii(a,b,c,d,m[i+0],6,-198630844); d=ii(d,a,b,c,m[i+7],10,1126891415); c=ii(c,d,a,b,m[i+14],15,-1416354905); b=ii(b,c,d,a,m[i+5],21,-57434055);
|
|
||||||
a=ii(a,b,c,d,m[i+12],6,1700485571); d=ii(d,a,b,c,m[i+3],10,-1894986606); c=ii(c,d,a,b,m[i+10],15,-1051523); b=ii(b,c,d,a,m[i+1],21,-2054922799);
|
|
||||||
a=ii(a,b,c,d,m[i+8],6,1873313359); d=ii(d,a,b,c,m[i+15],10,-30611744); c=ii(c,d,a,b,m[i+6],15,-1560198380); b=ii(b,c,d,a,m[i+13],21,1309151649);
|
|
||||||
a=ii(a,b,c,d,m[i+4],6,-145523070); d=ii(d,a,b,c,m[i+11],10,-1120210379);c=ii(c,d,a,b,m[i+2],15,718787259); b=ii(b,c,d,a,m[i+9],21,-343485551);
|
|
||||||
a=safeAdd(a,A);b=safeAdd(b,B);c=safeAdd(c,C);d=safeAdd(d,D);
|
|
||||||
}
|
|
||||||
function hex(n){var s='';for(var j=0;j<4;j++)s+=('0'+((n>>>(j*8))&0xff).toString(16)).slice(-2);return s}
|
|
||||||
return hex(a)+hex(b)+hex(c)+hex(d);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDur(s) {
|
|
||||||
if (!s) return '0:00';
|
|
||||||
const m = Math.floor(s / 60), sec = Math.floor(s % 60);
|
|
||||||
return `${m}:${sec.toString().padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import TrackPlayer, { Capability, RepeatMode, State } from 'react-native-track-player';
|
|
||||||
import { coverUrl, streamUrl, scrobble, dispTitle, dispArtistById } from '../api';
|
|
||||||
import { useStore } from '../store';
|
|
||||||
|
|
||||||
let _setupDone = false;
|
|
||||||
|
|
||||||
export async function setupPlayer() {
|
|
||||||
if (_setupDone) return;
|
|
||||||
try {
|
|
||||||
await TrackPlayer.setupPlayer();
|
|
||||||
await TrackPlayer.updateOptions({
|
|
||||||
capabilities: [
|
|
||||||
Capability.Play,
|
|
||||||
Capability.Pause,
|
|
||||||
Capability.SkipToNext,
|
|
||||||
Capability.SkipToPrevious,
|
|
||||||
Capability.SeekTo,
|
|
||||||
Capability.Stop,
|
|
||||||
],
|
|
||||||
compactCapabilities: [Capability.Play, Capability.Pause, Capability.SkipToNext, Capability.SkipToPrevious],
|
|
||||||
});
|
|
||||||
_setupDone = true;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('TrackPlayer setup failed:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert a Navidrome song object to a react-native-track-player track object
|
|
||||||
function songToTrack(song) {
|
|
||||||
return {
|
|
||||||
id: song.id,
|
|
||||||
url: streamUrl(song.id),
|
|
||||||
title: dispTitle(song),
|
|
||||||
artist: dispArtistById(song.artistId, song.artist || ''),
|
|
||||||
album: song.album || '',
|
|
||||||
artwork: coverUrl(song.coverArt, 512) || undefined,
|
|
||||||
duration: song.duration,
|
|
||||||
// Keep the original song data for the UI
|
|
||||||
_song: song,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function playQueue(songs, startIndex = 0) {
|
|
||||||
if (!songs?.length) return;
|
|
||||||
const tracks = songs.map(songToTrack);
|
|
||||||
await TrackPlayer.reset();
|
|
||||||
await TrackPlayer.add(tracks);
|
|
||||||
await TrackPlayer.skip(startIndex);
|
|
||||||
await TrackPlayer.play();
|
|
||||||
|
|
||||||
const { setQueue, setCurrentTrack } = useStore.getState();
|
|
||||||
setQueue(songs, startIndex);
|
|
||||||
setCurrentTrack(songs[startIndex]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function skipToIndex(index) {
|
|
||||||
await TrackPlayer.skip(index);
|
|
||||||
await TrackPlayer.play();
|
|
||||||
const queue = useStore.getState().queue;
|
|
||||||
if (queue[index]) useStore.getState().setCurrentTrack(queue[index]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function togglePlayPause() {
|
|
||||||
const { state } = await TrackPlayer.getPlaybackState();
|
|
||||||
if (state === State.Playing) await TrackPlayer.pause();
|
|
||||||
else await TrackPlayer.play();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function skipNext() { await TrackPlayer.skipToNext(); }
|
|
||||||
export async function skipPrev() { await TrackPlayer.skipToPrevious(); }
|
|
||||||
|
|
||||||
export async function seekTo(position) { await TrackPlayer.seekTo(position); }
|
|
||||||
|
|
||||||
export async function setRepeatMode(mode) {
|
|
||||||
// mode: 'off' | 'all' | 'one'
|
|
||||||
const rm = mode === 'one' ? RepeatMode.Track : mode === 'all' ? RepeatMode.Queue : RepeatMode.Off;
|
|
||||||
await TrackPlayer.setRepeatMode(rm);
|
|
||||||
useStore.getState().setRepeat(mode);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import TrackPlayer, { Event } from 'react-native-track-player';
|
|
||||||
|
|
||||||
// This file runs in a separate background thread managed by react-native-track-player.
|
|
||||||
// It handles remote control events (lock screen, headphone buttons, Android Auto).
|
|
||||||
export async function PlaybackService() {
|
|
||||||
TrackPlayer.addEventListener(Event.RemotePlay, () => TrackPlayer.play());
|
|
||||||
TrackPlayer.addEventListener(Event.RemotePause, () => TrackPlayer.pause());
|
|
||||||
TrackPlayer.addEventListener(Event.RemoteStop, () => TrackPlayer.stop());
|
|
||||||
TrackPlayer.addEventListener(Event.RemoteNext, () => TrackPlayer.skipToNext());
|
|
||||||
TrackPlayer.addEventListener(Event.RemotePrevious, () => TrackPlayer.skipToPrevious());
|
|
||||||
TrackPlayer.addEventListener(Event.RemoteSeek, e => TrackPlayer.seekTo(e.position));
|
|
||||||
TrackPlayer.addEventListener(Event.RemoteDuck, async e => {
|
|
||||||
if (e.paused) await TrackPlayer.pause();
|
|
||||||
else await TrackPlayer.play();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { View, Text, Image, TouchableOpacity, StyleSheet } from 'react-native';
|
|
||||||
import { coverUrl, dispAlbumName, dispArtistById } from '../api';
|
|
||||||
|
|
||||||
export default function AlbumCard({ album, onPress, size = 150 }) {
|
|
||||||
const art = coverUrl(album.coverArt, 512);
|
|
||||||
return (
|
|
||||||
<TouchableOpacity style={[styles.card, { width: size }]} onPress={onPress} activeOpacity={0.7}>
|
|
||||||
{art ? (
|
|
||||||
<Image source={{ uri: art }} style={[styles.art, { width: size, height: size }]} />
|
|
||||||
) : (
|
|
||||||
<View style={[styles.art, styles.artFallback, { width: size, height: size }]}>
|
|
||||||
<Text style={styles.fallbackIcon}>💿</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<Text style={styles.title} numberOfLines={2}>{dispAlbumName(album)}</Text>
|
|
||||||
<Text style={styles.sub} numberOfLines={1}>
|
|
||||||
{dispArtistById(album.artistId, album.artist || '')}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
card: {
|
|
||||||
marginRight: 12,
|
|
||||||
},
|
|
||||||
art: {
|
|
||||||
borderRadius: 6,
|
|
||||||
backgroundColor: '#2a2a2a',
|
|
||||||
},
|
|
||||||
artFallback: {
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
fallbackIcon: {
|
|
||||||
fontSize: 40,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: '600',
|
|
||||||
marginTop: 6,
|
|
||||||
},
|
|
||||||
sub: {
|
|
||||||
color: '#aaa',
|
|
||||||
fontSize: 12,
|
|
||||||
marginTop: 2,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { View, Text, Image, TouchableOpacity, StyleSheet } from 'react-native';
|
|
||||||
import { useNavigation } from '@react-navigation/native';
|
|
||||||
import { usePlaybackState, useProgress, State } from 'react-native-track-player';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import { useStore } from '../store';
|
|
||||||
import { togglePlayPause, skipNext } from '../audio/player';
|
|
||||||
|
|
||||||
export default function PlayerBar() {
|
|
||||||
const navigation = useNavigation();
|
|
||||||
const { currentTrack, isPlaying } = useStore();
|
|
||||||
const playbackState = usePlaybackState();
|
|
||||||
const { position, duration } = useProgress(500);
|
|
||||||
|
|
||||||
if (!currentTrack) return null;
|
|
||||||
|
|
||||||
const playing = playbackState.state === State.Playing;
|
|
||||||
const progress = duration > 0 ? position / duration : 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.container}
|
|
||||||
activeOpacity={0.9}
|
|
||||||
onPress={() => navigation.navigate('NowPlaying')}
|
|
||||||
>
|
|
||||||
{/* Progress bar at top */}
|
|
||||||
<View style={styles.progressTrack}>
|
|
||||||
<View style={[styles.progressFill, { width: `${progress * 100}%` }]} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.row}>
|
|
||||||
{currentTrack.coverArt ? (
|
|
||||||
<Image
|
|
||||||
source={{ uri: currentTrack.artwork }}
|
|
||||||
style={styles.art}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<View style={[styles.art, styles.artFallback]}>
|
|
||||||
<Ionicons name="musical-note" size={18} color="#888" />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View style={styles.info}>
|
|
||||||
<Text style={styles.title} numberOfLines={1}>{currentTrack.title}</Text>
|
|
||||||
<Text style={styles.artist} numberOfLines={1}>{currentTrack.artist}</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<TouchableOpacity style={styles.btn} onPress={togglePlayPause}>
|
|
||||||
<Ionicons
|
|
||||||
name={playing ? 'pause' : 'play'}
|
|
||||||
size={24}
|
|
||||||
color="#fff"
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity style={styles.btn} onPress={skipNext}>
|
|
||||||
<Ionicons name="play-skip-forward" size={22} color="#fff" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
backgroundColor: '#1e1e1e',
|
|
||||||
borderTopWidth: 1,
|
|
||||||
borderTopColor: '#2a2a2a',
|
|
||||||
},
|
|
||||||
progressTrack: {
|
|
||||||
height: 2,
|
|
||||||
backgroundColor: '#333',
|
|
||||||
},
|
|
||||||
progressFill: {
|
|
||||||
height: 2,
|
|
||||||
backgroundColor: '#1db954',
|
|
||||||
},
|
|
||||||
row: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingVertical: 8,
|
|
||||||
gap: 10,
|
|
||||||
},
|
|
||||||
art: {
|
|
||||||
width: 44,
|
|
||||||
height: 44,
|
|
||||||
borderRadius: 4,
|
|
||||||
},
|
|
||||||
artFallback: {
|
|
||||||
backgroundColor: '#2a2a2a',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
info: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
artist: {
|
|
||||||
color: '#aaa',
|
|
||||||
fontSize: 12,
|
|
||||||
marginTop: 2,
|
|
||||||
},
|
|
||||||
btn: {
|
|
||||||
padding: 6,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { View, Text, Image, TouchableOpacity, StyleSheet } from 'react-native';
|
|
||||||
import { coverUrl, dispTitle, dispArtistById } from '../api';
|
|
||||||
import { formatDur } from '../api/utils';
|
|
||||||
import { useStore } from '../store';
|
|
||||||
|
|
||||||
export default function SongItem({ song, index, onPress, showArt = false }) {
|
|
||||||
const currentTrack = useStore(s => s.currentTrack);
|
|
||||||
const isPlaying = currentTrack?.id === song.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity style={styles.row} onPress={onPress} activeOpacity={0.7}>
|
|
||||||
{showArt ? (
|
|
||||||
<Image
|
|
||||||
source={{ uri: coverUrl(song.coverArt, 128) }}
|
|
||||||
style={styles.art}
|
|
||||||
defaultSource={require('../../assets/icon.png')}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Text style={[styles.num, isPlaying && styles.numPlaying]}>
|
|
||||||
{isPlaying ? '▶' : index + 1}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<View style={styles.info}>
|
|
||||||
<Text style={[styles.title, isPlaying && styles.titlePlaying]} numberOfLines={1}>
|
|
||||||
{dispTitle(song)}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.artist} numberOfLines={1}>
|
|
||||||
{dispArtistById(song.artistId, song.artist || '')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.dur}>{formatDur(song.duration)}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
row: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingVertical: 10,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
gap: 12,
|
|
||||||
},
|
|
||||||
num: {
|
|
||||||
width: 24,
|
|
||||||
textAlign: 'center',
|
|
||||||
color: '#aaa',
|
|
||||||
fontSize: 13,
|
|
||||||
},
|
|
||||||
numPlaying: {
|
|
||||||
color: '#1db954',
|
|
||||||
},
|
|
||||||
art: {
|
|
||||||
width: 44,
|
|
||||||
height: 44,
|
|
||||||
borderRadius: 4,
|
|
||||||
backgroundColor: '#2a2a2a',
|
|
||||||
},
|
|
||||||
info: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
titlePlaying: {
|
|
||||||
color: '#1db954',
|
|
||||||
},
|
|
||||||
artist: {
|
|
||||||
color: '#aaa',
|
|
||||||
fontSize: 12,
|
|
||||||
marginTop: 2,
|
|
||||||
},
|
|
||||||
dur: {
|
|
||||||
color: '#666',
|
|
||||||
fontSize: 12,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import {
|
|
||||||
View, Text, FlatList, Image, TouchableOpacity,
|
|
||||||
StyleSheet, ActivityIndicator,
|
|
||||||
} from 'react-native';
|
|
||||||
import { useNavigation, useRoute } from '@react-navigation/native';
|
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import { api, ensureArtistSortMap, dispAlbumName, dispArtistById, coverUrl } from '../api';
|
|
||||||
import { playQueue, skipToIndex } from '../audio/player';
|
|
||||||
import { useStore } from '../store';
|
|
||||||
import SongItem from '../components/SongItem';
|
|
||||||
|
|
||||||
export default function AlbumDetailScreen() {
|
|
||||||
const navigation = useNavigation();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const { album } = useRoute().params;
|
|
||||||
const { cfg } = useStore();
|
|
||||||
const [songs, setSongs] = useState([]);
|
|
||||||
const [ndAlbum, setNdAlbum] = useState(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
if (cfg.showRomaji) await ensureArtistSortMap().catch(() => {});
|
|
||||||
try {
|
|
||||||
const r = await api('getAlbum', `&id=${album.id}`);
|
|
||||||
setNdAlbum(r.album);
|
|
||||||
setSongs(r.album?.song || []);
|
|
||||||
} catch {}
|
|
||||||
setLoading(false);
|
|
||||||
})();
|
|
||||||
}, [album.id]);
|
|
||||||
|
|
||||||
const displayAlbum = ndAlbum || album;
|
|
||||||
const art = coverUrl(displayAlbum.coverArt, 512);
|
|
||||||
const albumName = dispAlbumName(displayAlbum);
|
|
||||||
const artistName = dispArtistById(displayAlbum.artistId, displayAlbum.artist || '');
|
|
||||||
const totalMin = Math.round(songs.reduce((s, t) => s + (t.duration || 0), 0) / 60);
|
|
||||||
|
|
||||||
async function handlePlay(idx = 0) {
|
|
||||||
if (!songs.length) return;
|
|
||||||
await playQueue(songs, idx);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<FlatList
|
|
||||||
data={songs}
|
|
||||||
keyExtractor={s => s.id}
|
|
||||||
ListHeaderComponent={() => (
|
|
||||||
<View>
|
|
||||||
{/* Back button */}
|
|
||||||
<TouchableOpacity style={[styles.backBtn, { top: insets.top + 8 }]} onPress={() => navigation.goBack()}>
|
|
||||||
<Ionicons name="chevron-back" size={24} color="#fff" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{/* Album art */}
|
|
||||||
<View style={styles.artWrap}>
|
|
||||||
{art ? (
|
|
||||||
<Image source={{ uri: art }} style={styles.art} />
|
|
||||||
) : (
|
|
||||||
<View style={[styles.art, styles.artFallback]}>
|
|
||||||
<Text style={{ fontSize: 64 }}>💿</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<View style={styles.info}>
|
|
||||||
<Text style={styles.albumName}>{albumName}</Text>
|
|
||||||
<TouchableOpacity onPress={() => navigation.navigate('ArtistDetail', {
|
|
||||||
artist: { id: displayAlbum.artistId, name: displayAlbum.artist || '' }
|
|
||||||
})}>
|
|
||||||
<Text style={styles.artistName}>{artistName}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Text style={styles.meta}>
|
|
||||||
{displayAlbum.year ? `${displayAlbum.year} · ` : ''}
|
|
||||||
{songs.length} Titel · {totalMin} Min.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Play buttons */}
|
|
||||||
<View style={styles.btns}>
|
|
||||||
<TouchableOpacity style={styles.playBtn} onPress={() => handlePlay(0)}>
|
|
||||||
<Ionicons name="play" size={18} color="#000" />
|
|
||||||
<Text style={styles.playBtnText}>Abspielen</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity style={styles.shuffleBtn} onPress={async () => {
|
|
||||||
const shuffled = [...songs].sort(() => Math.random() - 0.5);
|
|
||||||
await playQueue(shuffled, 0);
|
|
||||||
}}>
|
|
||||||
<Ionicons name="shuffle" size={18} color="#fff" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{loading && <ActivityIndicator color="#1db954" style={{ marginTop: 20 }} />}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
renderItem={({ item, index }) => (
|
|
||||||
<SongItem
|
|
||||||
song={item}
|
|
||||||
index={index}
|
|
||||||
onPress={() => handlePlay(index)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
contentContainerStyle={{ paddingBottom: 16 }}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: { flex: 1, backgroundColor: '#121212' },
|
|
||||||
backBtn: {
|
|
||||||
position: 'absolute', left: 12, zIndex: 10,
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.5)', borderRadius: 20, padding: 6,
|
|
||||||
},
|
|
||||||
artWrap: { alignItems: 'center', paddingTop: 72, paddingBottom: 16 },
|
|
||||||
art: {
|
|
||||||
width: 220, height: 220, borderRadius: 8,
|
|
||||||
shadowColor: '#000', shadowOpacity: 0.5, shadowRadius: 16,
|
|
||||||
},
|
|
||||||
artFallback: {
|
|
||||||
backgroundColor: '#2a2a2a', alignItems: 'center', justifyContent: 'center',
|
|
||||||
},
|
|
||||||
info: { paddingHorizontal: 20, alignItems: 'center' },
|
|
||||||
albumName: { color: '#fff', fontSize: 20, fontWeight: '800', textAlign: 'center', marginBottom: 6 },
|
|
||||||
artistName: { color: '#1db954', fontSize: 15, fontWeight: '600', marginBottom: 4 },
|
|
||||||
meta: { color: '#aaa', fontSize: 13, marginBottom: 16 },
|
|
||||||
btns: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 20, gap: 12, marginBottom: 8 },
|
|
||||||
playBtn: {
|
|
||||||
flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
|
|
||||||
gap: 8, backgroundColor: '#1db954', borderRadius: 24, paddingVertical: 12,
|
|
||||||
},
|
|
||||||
playBtnText: { color: '#000', fontWeight: '700', fontSize: 15 },
|
|
||||||
shuffleBtn: {
|
|
||||||
width: 48, height: 48, borderRadius: 24,
|
|
||||||
backgroundColor: '#2a2a2a', alignItems: 'center', justifyContent: 'center',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import {
|
|
||||||
View, Text, FlatList, Image, TouchableOpacity,
|
|
||||||
StyleSheet, ActivityIndicator, Dimensions,
|
|
||||||
} from 'react-native';
|
|
||||||
|
|
||||||
const SCREEN_WIDTH = Dimensions.get('window').width;
|
|
||||||
import { useNavigation, useRoute } from '@react-navigation/native';
|
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import { api, apiCached, dispArtistById, dispAlbumName, coverUrl, getArtistImage, cacheArtistSortNames } from '../api';
|
|
||||||
import AlbumCard from '../components/AlbumCard';
|
|
||||||
|
|
||||||
export default function ArtistDetailScreen() {
|
|
||||||
const navigation = useNavigation();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const { artist } = useRoute().params;
|
|
||||||
const [albums, setAlbums] = useState([]);
|
|
||||||
const [img, setImg] = useState(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const r = await apiCached('getArtist', `&id=${artist.id}`);
|
|
||||||
const ndArtist = r.artist;
|
|
||||||
if (ndArtist) cacheArtistSortNames([ndArtist]);
|
|
||||||
setAlbums(ndArtist?.album || []);
|
|
||||||
} catch {}
|
|
||||||
setLoading(false);
|
|
||||||
getArtistImage(artist.id).then(setImg).catch(() => {});
|
|
||||||
})();
|
|
||||||
}, [artist.id]);
|
|
||||||
|
|
||||||
const name = dispArtistById(artist.id, artist.name);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
{/* Hero */}
|
|
||||||
<View style={styles.hero}>
|
|
||||||
{img ? (
|
|
||||||
<Image source={{ uri: img }} style={styles.heroImg} />
|
|
||||||
) : (
|
|
||||||
<View style={[styles.heroImg, styles.heroFallback]}>
|
|
||||||
<Text style={{ fontSize: 64 }}>🎤</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<TouchableOpacity style={[styles.backBtn, { top: insets.top + 8 }]} onPress={() => navigation.goBack()}>
|
|
||||||
<Ionicons name="chevron-back" size={24} color="#fff" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
<View style={styles.heroOverlay}>
|
|
||||||
<Text style={styles.heroName}>{name}</Text>
|
|
||||||
<Text style={styles.heroSub}>{albums.length} Alben</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<View style={styles.center}><ActivityIndicator color="#1db954" /></View>
|
|
||||||
) : (
|
|
||||||
<FlatList
|
|
||||||
data={albums}
|
|
||||||
keyExtractor={a => a.id}
|
|
||||||
numColumns={2}
|
|
||||||
renderItem={({ item }) => (
|
|
||||||
<View style={{ flex: 1, padding: 8 }}>
|
|
||||||
<AlbumCard
|
|
||||||
album={item}
|
|
||||||
size={(SCREEN_WIDTH / 2) - 20}
|
|
||||||
onPress={() => navigation.navigate('AlbumDetail', { album: item })}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
contentContainerStyle={{ padding: 4 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: { flex: 1, backgroundColor: '#121212' },
|
|
||||||
hero: { width: '100%', height: 220, position: 'relative' },
|
|
||||||
heroImg: { width: '100%', height: 220 },
|
|
||||||
heroFallback: {
|
|
||||||
backgroundColor: '#1e1e1e',
|
|
||||||
alignItems: 'center', justifyContent: 'center',
|
|
||||||
},
|
|
||||||
heroOverlay: {
|
|
||||||
position: 'absolute', bottom: 0, left: 0, right: 0,
|
|
||||||
padding: 16, paddingBottom: 20,
|
|
||||||
background: 'transparent',
|
|
||||||
},
|
|
||||||
heroName: { color: '#fff', fontSize: 24, fontWeight: '800', textShadowColor: '#000', textShadowRadius: 8 },
|
|
||||||
heroSub: { color: '#ddd', fontSize: 13, marginTop: 2 },
|
|
||||||
backBtn: {
|
|
||||||
position: 'absolute', left: 12,
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.5)', borderRadius: 20,
|
|
||||||
padding: 6,
|
|
||||||
},
|
|
||||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingTop: 40 },
|
|
||||||
});
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import React, { useEffect, useState, useCallback } from 'react';
|
|
||||||
import {
|
|
||||||
View, Text, FlatList, ScrollView,
|
|
||||||
StyleSheet, ActivityIndicator, TouchableOpacity, RefreshControl,
|
|
||||||
} from 'react-native';
|
|
||||||
import { useNavigation } from '@react-navigation/native';
|
|
||||||
import { apiCached, cacheArtistSortNames, fetchRomajiIndex } from '../api';
|
|
||||||
import { playQueue } from '../audio/player';
|
|
||||||
import { useStore } from '../store';
|
|
||||||
import AlbumCard from '../components/AlbumCard';
|
|
||||||
|
|
||||||
function AlbumRow({ title, albums, onAlbumPress }) {
|
|
||||||
if (!albums?.length) return null;
|
|
||||||
return (
|
|
||||||
<View style={styles.section}>
|
|
||||||
<Text style={styles.sectionTitle}>{title}</Text>
|
|
||||||
<FlatList
|
|
||||||
horizontal
|
|
||||||
data={albums}
|
|
||||||
keyExtractor={a => a.id}
|
|
||||||
renderItem={({ item }) => (
|
|
||||||
<AlbumCard album={item} onPress={() => onAlbumPress(item)} />
|
|
||||||
)}
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
contentContainerStyle={{ paddingHorizontal: 16 }}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HomeScreen() {
|
|
||||||
const navigation = useNavigation();
|
|
||||||
const { cfg } = useStore();
|
|
||||||
const [recent, setRecent] = useState([]);
|
|
||||||
const [frequent, setFrequent] = useState([]);
|
|
||||||
const [newest, setNewest] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [refreshing,setRefreshing]= useState(false);
|
|
||||||
|
|
||||||
const load = useCallback(async (invalidate = false) => {
|
|
||||||
if (!cfg.url) return;
|
|
||||||
try {
|
|
||||||
const [recRes, freqRes, newRes] = await Promise.allSettled([
|
|
||||||
apiCached('getAlbumList2', '&type=recent&size=20'),
|
|
||||||
apiCached('getAlbumList2', '&type=frequent&size=20'),
|
|
||||||
apiCached('getAlbumList2', '&type=newest&size=20'),
|
|
||||||
]);
|
|
||||||
setRecent( recRes.status === 'fulfilled' ? recRes.value.albumList2?.album || [] : []);
|
|
||||||
setFrequent(freqRes.status === 'fulfilled' ? freqRes.value.albumList2?.album || [] : []);
|
|
||||||
setNewest( newRes.status === 'fulfilled' ? newRes.value.albumList2?.album || [] : []);
|
|
||||||
|
|
||||||
// Pre-warm artist sort map
|
|
||||||
const artistRes = await apiCached('getArtists').catch(() => null);
|
|
||||||
if (artistRes) {
|
|
||||||
const all = (artistRes.artists?.index || []).flatMap(i => i.artist || []);
|
|
||||||
cacheArtistSortNames(all);
|
|
||||||
}
|
|
||||||
if (cfg.showRomaji) fetchRomajiIndex().catch(() => {});
|
|
||||||
} catch {}
|
|
||||||
setLoading(false);
|
|
||||||
setRefreshing(false);
|
|
||||||
}, [cfg.url, cfg.showRomaji]);
|
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
|
||||||
|
|
||||||
function goAlbum(album) { navigation.navigate('AlbumDetail', { album }); }
|
|
||||||
|
|
||||||
async function quickPlay(album) {
|
|
||||||
try {
|
|
||||||
const r = await apiCached('getAlbum', `&id=${album.id}`);
|
|
||||||
await playQueue(r.album?.song || [], 0);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cfg.url) {
|
|
||||||
return (
|
|
||||||
<View style={styles.center}>
|
|
||||||
<Text style={styles.emptyText}>Kein Server konfiguriert.</Text>
|
|
||||||
<TouchableOpacity onPress={() => navigation.navigate('Settings')}>
|
|
||||||
<Text style={styles.link}>Einstellungen öffnen →</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <View style={styles.center}><ActivityIndicator color="#1db954" size="large" /></View>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
style={styles.container}
|
|
||||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); load(true); }} tintColor="#1db954" />}
|
|
||||||
>
|
|
||||||
<AlbumRow title="Zuletzt gehört" albums={recent} onAlbumPress={goAlbum} />
|
|
||||||
<AlbumRow title="Meistgespielt" albums={frequent} onAlbumPress={goAlbum} />
|
|
||||||
<AlbumRow title="Neu hinzugefügt" albums={newest} onAlbumPress={goAlbum} />
|
|
||||||
<View style={{ height: 16 }} />
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: { flex: 1, backgroundColor: '#121212' },
|
|
||||||
center: { flex: 1, backgroundColor: '#121212', alignItems: 'center', justifyContent: 'center' },
|
|
||||||
heading: {
|
|
||||||
color: '#fff', fontSize: 22, fontWeight: '700',
|
|
||||||
paddingHorizontal: 16, paddingTop: 16, paddingBottom: 8,
|
|
||||||
},
|
|
||||||
section: { marginTop: 16 },
|
|
||||||
sectionTitle: {
|
|
||||||
color: '#fff', fontSize: 16, fontWeight: '700',
|
|
||||||
paddingHorizontal: 16, marginBottom: 12,
|
|
||||||
},
|
|
||||||
emptyText: { color: '#aaa', fontSize: 15 },
|
|
||||||
link: { color: '#1db954', marginTop: 12, fontSize: 14 },
|
|
||||||
});
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
import React, { useEffect, useState, useCallback } from 'react';
|
|
||||||
import {
|
|
||||||
View, Text, FlatList, TouchableOpacity,
|
|
||||||
StyleSheet, ActivityIndicator, Image, TextInput, RefreshControl, Dimensions,
|
|
||||||
} from 'react-native';
|
|
||||||
|
|
||||||
const SCREEN_WIDTH = Dimensions.get('window').width;
|
|
||||||
import { useNavigation } from '@react-navigation/native';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import { apiCached, cacheArtistSortNames, dispArtistById, dispAlbumName, coverUrl, getArtistImage } from '../api';
|
|
||||||
import { useStore } from '../store';
|
|
||||||
import AlbumCard from '../components/AlbumCard';
|
|
||||||
|
|
||||||
// ─── ARTISTS TAB ───
|
|
||||||
export function ArtistsScreen() {
|
|
||||||
const navigation = useNavigation();
|
|
||||||
const { cfg } = useStore();
|
|
||||||
const [artists, setArtists] = useState([]);
|
|
||||||
const [filtered, setFiltered] = useState([]);
|
|
||||||
const [query, setQuery] = useState('');
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [refreshing,setRefreshing]= useState(false);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
if (!cfg.url) return setLoading(false);
|
|
||||||
try {
|
|
||||||
const r = await apiCached('getArtists');
|
|
||||||
const all = (r.artists?.index || []).flatMap(i => i.artist || []);
|
|
||||||
cacheArtistSortNames(all);
|
|
||||||
setArtists(all);
|
|
||||||
setFiltered(all);
|
|
||||||
} catch {}
|
|
||||||
setLoading(false);
|
|
||||||
setRefreshing(false);
|
|
||||||
}, [cfg.url]);
|
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!query) { setFiltered(artists); return; }
|
|
||||||
const q = query.toLowerCase();
|
|
||||||
setFiltered(artists.filter(a => a.name.toLowerCase().includes(q)));
|
|
||||||
}, [query, artists]);
|
|
||||||
|
|
||||||
if (loading) return <View style={styles.center}><ActivityIndicator color="#1db954" size="large" /></View>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<View style={styles.searchBar}>
|
|
||||||
<Ionicons name="search" size={16} color="#666" />
|
|
||||||
<TextInput
|
|
||||||
style={styles.searchInput}
|
|
||||||
value={query}
|
|
||||||
onChangeText={setQuery}
|
|
||||||
placeholder="Künstler suchen…"
|
|
||||||
placeholderTextColor="#555"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<FlatList
|
|
||||||
data={filtered}
|
|
||||||
keyExtractor={a => a.id}
|
|
||||||
numColumns={2}
|
|
||||||
renderItem={({ item }) => (
|
|
||||||
<ArtistCard artist={item} onPress={() => navigation.navigate('ArtistDetail', { artist: item })} />
|
|
||||||
)}
|
|
||||||
contentContainerStyle={{ padding: 8 }}
|
|
||||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); load(); }} tintColor="#1db954" />}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ArtistCard({ artist, onPress }) {
|
|
||||||
const [img, setImg] = useState(null);
|
|
||||||
useEffect(() => {
|
|
||||||
getArtistImage(artist.id).then(setImg).catch(() => {});
|
|
||||||
}, [artist.id]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity style={styles.artistCard} onPress={onPress} activeOpacity={0.7}>
|
|
||||||
{img ? (
|
|
||||||
<Image source={{ uri: img }} style={styles.artistImg} />
|
|
||||||
) : (
|
|
||||||
<View style={[styles.artistImg, styles.artistImgFallback]}>
|
|
||||||
<Text style={{ fontSize: 32 }}>🎤</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<Text style={styles.artistName} numberOfLines={2}>
|
|
||||||
{dispArtistById(artist.id, artist.name)}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.artistSub}>{artist.albumCount} Alben</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── ALBUMS TAB ───
|
|
||||||
export function AlbumsScreen() {
|
|
||||||
const navigation = useNavigation();
|
|
||||||
const { cfg } = useStore();
|
|
||||||
const [albums, setAlbums] = useState([]);
|
|
||||||
const [filtered, setFiltered] = useState([]);
|
|
||||||
const [query, setQuery] = useState('');
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [refreshing,setRefreshing]= useState(false);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
if (!cfg.url) return setLoading(false);
|
|
||||||
try {
|
|
||||||
const r = await apiCached('getAlbumList2', '&type=alphabeticalByName&size=500');
|
|
||||||
const all = r.albumList2?.album || [];
|
|
||||||
setAlbums(all);
|
|
||||||
setFiltered(all);
|
|
||||||
} catch {}
|
|
||||||
setLoading(false);
|
|
||||||
setRefreshing(false);
|
|
||||||
}, [cfg.url]);
|
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!query) { setFiltered(albums); return; }
|
|
||||||
const q = query.toLowerCase();
|
|
||||||
setFiltered(albums.filter(a =>
|
|
||||||
a.name.toLowerCase().includes(q) || a.artist?.toLowerCase().includes(q)
|
|
||||||
));
|
|
||||||
}, [query, albums]);
|
|
||||||
|
|
||||||
if (loading) return <View style={styles.center}><ActivityIndicator color="#1db954" size="large" /></View>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<View style={styles.searchBar}>
|
|
||||||
<Ionicons name="search" size={16} color="#666" />
|
|
||||||
<TextInput
|
|
||||||
style={styles.searchInput}
|
|
||||||
value={query}
|
|
||||||
onChangeText={setQuery}
|
|
||||||
placeholder="Alben suchen…"
|
|
||||||
placeholderTextColor="#555"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<FlatList
|
|
||||||
data={filtered}
|
|
||||||
keyExtractor={a => a.id}
|
|
||||||
numColumns={2}
|
|
||||||
renderItem={({ item }) => (
|
|
||||||
<View style={{ flex: 1, padding: 6 }}>
|
|
||||||
<AlbumCard
|
|
||||||
album={item}
|
|
||||||
size={(SCREEN_WIDTH / 2) - 18}
|
|
||||||
onPress={() => navigation.navigate('AlbumDetail', { album: item })}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
contentContainerStyle={{ padding: 6 }}
|
|
||||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); load(); }} tintColor="#1db954" />}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: { flex: 1, backgroundColor: '#121212' },
|
|
||||||
center: { flex: 1, backgroundColor: '#121212', alignItems: 'center', justifyContent: 'center' },
|
|
||||||
searchBar: {
|
|
||||||
flexDirection: 'row', alignItems: 'center', gap: 8,
|
|
||||||
backgroundColor: '#1e1e1e', borderRadius: 8, margin: 12,
|
|
||||||
paddingHorizontal: 12, paddingVertical: 8,
|
|
||||||
},
|
|
||||||
searchInput: { flex: 1, color: '#fff', fontSize: 14 },
|
|
||||||
artistCard: {
|
|
||||||
flex: 1, margin: 6, alignItems: 'center', padding: 8,
|
|
||||||
backgroundColor: '#1e1e1e', borderRadius: 10,
|
|
||||||
},
|
|
||||||
artistImg: {
|
|
||||||
width: 100, height: 100, borderRadius: 50,
|
|
||||||
backgroundColor: '#2a2a2a', marginBottom: 8,
|
|
||||||
},
|
|
||||||
artistImgFallback: { alignItems: 'center', justifyContent: 'center' },
|
|
||||||
artistName: { color: '#fff', fontSize: 13, fontWeight: '600', textAlign: 'center' },
|
|
||||||
artistSub: { color: '#aaa', fontSize: 11, marginTop: 2 },
|
|
||||||
});
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
View, Text, TextInput, TouchableOpacity,
|
|
||||||
StyleSheet, ScrollView, ActivityIndicator, KeyboardAvoidingView, Platform,
|
|
||||||
} from 'react-native';
|
|
||||||
import { useStore } from '../store';
|
|
||||||
import { api, invalidateCoverCache, invalidateRomajiIndex, invalidateApiCache } from '../api';
|
|
||||||
|
|
||||||
export default function LoginScreen() {
|
|
||||||
const { cfg, saveConfig } = useStore();
|
|
||||||
const [url, setUrl] = useState(cfg.url || '');
|
|
||||||
const [user, setUser] = useState(cfg.user || '');
|
|
||||||
const [pass, setPass] = useState(cfg.pass || '');
|
|
||||||
const [backendUrl, setBackendUrl] = useState(cfg.backendUrl || '');
|
|
||||||
const [showRomaji, setShowRomaji] = useState(cfg.showRomaji || false);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [saved, setSaved] = useState(false);
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
const trimUrl = url.trim().replace(/\/$/, '');
|
|
||||||
if (!trimUrl || !user.trim() || !pass.trim()) {
|
|
||||||
setError('Server-URL, Benutzer und Passwort sind erforderlich.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoading(true); setError('');
|
|
||||||
// Test connection before saving
|
|
||||||
const prevCfg = useStore.getState().cfg;
|
|
||||||
await saveConfig({ url: trimUrl, user: user.trim(), pass: pass.trim(), backendUrl: backendUrl.trim().replace(/\/$/, ''), showRomaji });
|
|
||||||
try {
|
|
||||||
await api('ping');
|
|
||||||
invalidateCoverCache();
|
|
||||||
invalidateRomajiIndex();
|
|
||||||
invalidateApiCache();
|
|
||||||
setSaved(true);
|
|
||||||
setTimeout(() => setSaved(false), 2000);
|
|
||||||
} catch (e) {
|
|
||||||
// Restore old config on failure
|
|
||||||
await saveConfig(prevCfg);
|
|
||||||
setError('Verbindung fehlgeschlagen: ' + e.message);
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
|
|
||||||
<ScrollView style={styles.container} contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
|
|
||||||
<Text style={styles.logo}>🎵 NovaSonic</Text>
|
|
||||||
<Text style={styles.subtitle}>Verbinde dich mit deinem Navidrome-Server</Text>
|
|
||||||
|
|
||||||
<Text style={styles.label}>Server-URL</Text>
|
|
||||||
<TextInput
|
|
||||||
style={styles.input}
|
|
||||||
value={url}
|
|
||||||
onChangeText={setUrl}
|
|
||||||
placeholder="https://music.example.com"
|
|
||||||
placeholderTextColor="#555"
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
keyboardType="url"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Text style={styles.label}>Benutzername</Text>
|
|
||||||
<TextInput
|
|
||||||
style={styles.input}
|
|
||||||
value={user}
|
|
||||||
onChangeText={setUser}
|
|
||||||
placeholder="admin"
|
|
||||||
placeholderTextColor="#555"
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Text style={styles.label}>Passwort</Text>
|
|
||||||
<TextInput
|
|
||||||
style={styles.input}
|
|
||||||
value={pass}
|
|
||||||
onChangeText={setPass}
|
|
||||||
placeholder="••••••••"
|
|
||||||
placeholderTextColor="#555"
|
|
||||||
secureTextEntry
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Text style={styles.label}>Backend-URL (optional)</Text>
|
|
||||||
<TextInput
|
|
||||||
style={styles.input}
|
|
||||||
value={backendUrl}
|
|
||||||
onChangeText={setBackendUrl}
|
|
||||||
placeholder="https://backend.example.com"
|
|
||||||
placeholderTextColor="#555"
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
keyboardType="url"
|
|
||||||
/>
|
|
||||||
<Text style={styles.hint}>Für Romaji, Downloads und Metadaten-Bearbeitung</Text>
|
|
||||||
|
|
||||||
<TouchableOpacity style={styles.toggleRow} onPress={() => setShowRomaji(v => !v)}>
|
|
||||||
<View style={[styles.toggle, showRomaji && styles.toggleOn]}>
|
|
||||||
<View style={[styles.toggleThumb, showRomaji && styles.toggleThumbOn]} />
|
|
||||||
</View>
|
|
||||||
<Text style={styles.toggleLabel}>Romaji / Sort-Tags anzeigen</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
|
||||||
|
|
||||||
<TouchableOpacity style={styles.btn} onPress={handleSave} disabled={loading}>
|
|
||||||
{loading
|
|
||||||
? <ActivityIndicator color="#000" />
|
|
||||||
: <Text style={styles.btnText}>{saved ? '✅ Gespeichert!' : 'Speichern & Verbinden'}</Text>
|
|
||||||
}
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{cfg.url ? (
|
|
||||||
<Text style={styles.connected}>Verbunden mit: {cfg.url}</Text>
|
|
||||||
) : null}
|
|
||||||
</ScrollView>
|
|
||||||
</KeyboardAvoidingView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: { flex: 1, backgroundColor: '#121212' },
|
|
||||||
content: { padding: 24, paddingTop: 24 },
|
|
||||||
logo: { fontSize: 32, textAlign: 'center', marginBottom: 8 },
|
|
||||||
subtitle: { color: '#aaa', textAlign: 'center', marginBottom: 32, fontSize: 14 },
|
|
||||||
label: { color: '#ccc', fontSize: 13, marginBottom: 6, marginTop: 16 },
|
|
||||||
input: {
|
|
||||||
backgroundColor: '#1e1e1e',
|
|
||||||
color: '#fff',
|
|
||||||
borderRadius: 8,
|
|
||||||
paddingHorizontal: 14,
|
|
||||||
paddingVertical: 12,
|
|
||||||
fontSize: 14,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#2a2a2a',
|
|
||||||
},
|
|
||||||
hint: { color: '#555', fontSize: 12, marginTop: 4 },
|
|
||||||
toggleRow: { flexDirection: 'row', alignItems: 'center', marginTop: 20, gap: 12 },
|
|
||||||
toggle: {
|
|
||||||
width: 44, height: 24, borderRadius: 12,
|
|
||||||
backgroundColor: '#333', justifyContent: 'center', paddingHorizontal: 2,
|
|
||||||
},
|
|
||||||
toggleOn: { backgroundColor: '#1db954' },
|
|
||||||
toggleThumb: {
|
|
||||||
width: 20, height: 20, borderRadius: 10, backgroundColor: '#fff',
|
|
||||||
},
|
|
||||||
toggleThumbOn: { alignSelf: 'flex-end' },
|
|
||||||
toggleLabel: { color: '#ccc', fontSize: 14 },
|
|
||||||
error: { color: '#e05252', marginTop: 16, fontSize: 13 },
|
|
||||||
btn: {
|
|
||||||
backgroundColor: '#1db954',
|
|
||||||
borderRadius: 8,
|
|
||||||
paddingVertical: 14,
|
|
||||||
alignItems: 'center',
|
|
||||||
marginTop: 28,
|
|
||||||
},
|
|
||||||
btnText: { color: '#000', fontWeight: '700', fontSize: 15 },
|
|
||||||
connected: { color: '#555', textAlign: 'center', marginTop: 16, fontSize: 12 },
|
|
||||||
});
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
View, Text, Image, TouchableOpacity,
|
|
||||||
StyleSheet, Dimensions,
|
|
||||||
} from 'react-native';
|
|
||||||
import { useNavigation } from '@react-navigation/native';
|
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import { usePlaybackState, useProgress, State } from 'react-native-track-player';
|
|
||||||
import Slider from '@react-native-community/slider';
|
|
||||||
import { useStore } from '../store';
|
|
||||||
import { togglePlayPause, skipNext, skipPrev, seekTo, setRepeatMode } from '../audio/player';
|
|
||||||
import { formatDur } from '../api/utils';
|
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
|
||||||
|
|
||||||
export default function NowPlayingScreen() {
|
|
||||||
const navigation = useNavigation();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const { currentTrack, repeat, shuffle, setShuffle } = useStore();
|
|
||||||
const playbackState = usePlaybackState();
|
|
||||||
const { position, duration } = useProgress(500);
|
|
||||||
|
|
||||||
const playing = playbackState.state === State.Playing;
|
|
||||||
|
|
||||||
const cycleRepeat = () => {
|
|
||||||
const next = repeat === 'off' ? 'all' : repeat === 'all' ? 'one' : 'off';
|
|
||||||
setRepeatMode(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!currentTrack) {
|
|
||||||
return (
|
|
||||||
<View style={styles.empty}>
|
|
||||||
<Text style={styles.emptyText}>Nichts spielt gerade</Text>
|
|
||||||
<TouchableOpacity onPress={() => navigation.goBack()}>
|
|
||||||
<Text style={styles.back}>Zurück</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={[styles.container, { paddingTop: insets.top, paddingBottom: insets.bottom + 16 }]}>
|
|
||||||
{/* Header */}
|
|
||||||
<View style={styles.header}>
|
|
||||||
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.headerBtn}>
|
|
||||||
<Ionicons name="chevron-down" size={28} color="#fff" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Text style={styles.headerTitle}>Jetzt läuft</Text>
|
|
||||||
<View style={{ width: 40 }} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Album art */}
|
|
||||||
<View style={styles.artWrap}>
|
|
||||||
{currentTrack.artwork ? (
|
|
||||||
<Image source={{ uri: currentTrack.artwork }} style={styles.art} />
|
|
||||||
) : (
|
|
||||||
<View style={[styles.art, styles.artFallback]}>
|
|
||||||
<Text style={{ fontSize: 80 }}>🎵</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Track info */}
|
|
||||||
<View style={styles.trackInfo}>
|
|
||||||
<Text style={styles.title} numberOfLines={2}>{currentTrack.title}</Text>
|
|
||||||
<Text style={styles.artist} numberOfLines={1}>{currentTrack.artist}</Text>
|
|
||||||
<Text style={styles.album} numberOfLines={1}>{currentTrack.album}</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Progress */}
|
|
||||||
<View style={styles.progressWrap}>
|
|
||||||
<Slider
|
|
||||||
style={styles.slider}
|
|
||||||
minimumValue={0}
|
|
||||||
maximumValue={duration || 1}
|
|
||||||
value={position}
|
|
||||||
onSlidingComplete={seekTo}
|
|
||||||
minimumTrackTintColor="#1db954"
|
|
||||||
maximumTrackTintColor="#444"
|
|
||||||
thumbTintColor="#fff"
|
|
||||||
/>
|
|
||||||
<View style={styles.times}>
|
|
||||||
<Text style={styles.time}>{formatDur(position)}</Text>
|
|
||||||
<Text style={styles.time}>{formatDur(duration)}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Controls */}
|
|
||||||
<View style={styles.controls}>
|
|
||||||
<TouchableOpacity onPress={() => setShuffle(!shuffle)} style={styles.sideBtn}>
|
|
||||||
<Ionicons
|
|
||||||
name="shuffle"
|
|
||||||
size={22}
|
|
||||||
color={shuffle ? '#1db954' : '#888'}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity onPress={skipPrev} style={styles.controlBtn}>
|
|
||||||
<Ionicons name="play-skip-back" size={32} color="#fff" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity onPress={togglePlayPause} style={styles.playBtn}>
|
|
||||||
<Ionicons name={playing ? 'pause' : 'play'} size={36} color="#000" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity onPress={skipNext} style={styles.controlBtn}>
|
|
||||||
<Ionicons name="play-skip-forward" size={32} color="#fff" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity onPress={cycleRepeat} style={styles.sideBtn}>
|
|
||||||
<Ionicons
|
|
||||||
name={repeat === 'one' ? 'repeat-outline' : 'repeat'}
|
|
||||||
size={22}
|
|
||||||
color={repeat !== 'off' ? '#1db954' : '#888'}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: { flex: 1, backgroundColor: '#121212' },
|
|
||||||
empty: { flex: 1, backgroundColor: '#121212', alignItems: 'center', justifyContent: 'center' },
|
|
||||||
emptyText: { color: '#aaa', fontSize: 16 },
|
|
||||||
back: { color: '#1db954', marginTop: 12, fontSize: 14 },
|
|
||||||
header: {
|
|
||||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
|
|
||||||
paddingTop: 12, paddingHorizontal: 16, paddingBottom: 12,
|
|
||||||
},
|
|
||||||
headerBtn: { width: 40, alignItems: 'flex-start' },
|
|
||||||
headerTitle: { color: '#fff', fontSize: 14, fontWeight: '700', letterSpacing: 0.5 },
|
|
||||||
artWrap: { alignItems: 'center', marginTop: 8, marginBottom: 24 },
|
|
||||||
art: {
|
|
||||||
width: width - 64, height: width - 64,
|
|
||||||
borderRadius: 12,
|
|
||||||
shadowColor: '#000', shadowOpacity: 0.6, shadowRadius: 24, shadowOffset: { width: 0, height: 8 },
|
|
||||||
elevation: 12,
|
|
||||||
},
|
|
||||||
artFallback: { backgroundColor: '#1e1e1e', alignItems: 'center', justifyContent: 'center' },
|
|
||||||
trackInfo: { paddingHorizontal: 24, marginBottom: 12 },
|
|
||||||
title: { color: '#fff', fontSize: 20, fontWeight: '800', marginBottom: 4 },
|
|
||||||
artist: { color: '#1db954', fontSize: 15, fontWeight: '600', marginBottom: 2 },
|
|
||||||
album: { color: '#888', fontSize: 13 },
|
|
||||||
progressWrap: { paddingHorizontal: 16, marginBottom: 8 },
|
|
||||||
slider: { width: '100%', height: 40 },
|
|
||||||
times: { flexDirection: 'row', justifyContent: 'space-between', marginTop: -8 },
|
|
||||||
time: { color: '#888', fontSize: 12 },
|
|
||||||
controls: {
|
|
||||||
flexDirection: 'row', alignItems: 'center',
|
|
||||||
justifyContent: 'space-between', paddingHorizontal: 24, marginTop: 8,
|
|
||||||
},
|
|
||||||
sideBtn: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' },
|
|
||||||
controlBtn: { width: 56, height: 56, alignItems: 'center', justifyContent: 'center' },
|
|
||||||
playBtn: {
|
|
||||||
width: 68, height: 68, borderRadius: 34,
|
|
||||||
backgroundColor: '#1db954',
|
|
||||||
alignItems: 'center', justifyContent: 'center',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
|
||||||
import {
|
|
||||||
View, Text, TextInput, FlatList,
|
|
||||||
StyleSheet, TouchableOpacity, ActivityIndicator,
|
|
||||||
} from 'react-native';
|
|
||||||
import { useNavigation } from '@react-navigation/native';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import { searchAll, dispTitle, dispArtistById, dispAlbumName } from '../api';
|
|
||||||
import { playQueue } from '../audio/player';
|
|
||||||
import SongItem from '../components/SongItem';
|
|
||||||
import AlbumCard from '../components/AlbumCard';
|
|
||||||
|
|
||||||
let _searchTimer = null;
|
|
||||||
|
|
||||||
export default function SearchScreen() {
|
|
||||||
const navigation = useNavigation();
|
|
||||||
const [query, setQuery] = useState('');
|
|
||||||
const [results, setResults] = useState(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const doSearch = useCallback(async (q) => {
|
|
||||||
if (!q.trim()) { setResults(null); return; }
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const r = await searchAll(q);
|
|
||||||
setResults(r);
|
|
||||||
} catch { setResults({ artists: [], albums: [], songs: [] }); }
|
|
||||||
setLoading(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function onChangeText(text) {
|
|
||||||
setQuery(text);
|
|
||||||
clearTimeout(_searchTimer);
|
|
||||||
_searchTimer = setTimeout(() => doSearch(text), 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = [];
|
|
||||||
if (results?.artists?.length) {
|
|
||||||
data.push({ type: 'header', key: 'h-artists', label: 'Künstler' });
|
|
||||||
results.artists.forEach(a => data.push({ type: 'artist', key: `a-${a.id}`, item: a }));
|
|
||||||
}
|
|
||||||
if (results?.albums?.length) {
|
|
||||||
data.push({ type: 'header', key: 'h-albums', label: 'Alben' });
|
|
||||||
results.albums.forEach(a => data.push({ type: 'album', key: `al-${a.id}`, item: a }));
|
|
||||||
}
|
|
||||||
if (results?.songs?.length) {
|
|
||||||
data.push({ type: 'header', key: 'h-songs', label: 'Titel' });
|
|
||||||
results.songs.forEach((s, i) => data.push({ type: 'song', key: `s-${s.id}`, item: s, index: i }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderItem({ item: row }) {
|
|
||||||
if (row.type === 'header') {
|
|
||||||
return <Text style={styles.sectionHeader}>{row.label}</Text>;
|
|
||||||
}
|
|
||||||
if (row.type === 'artist') {
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.rowItem}
|
|
||||||
onPress={() => navigation.navigate('ArtistDetail', { artist: row.item })}
|
|
||||||
>
|
|
||||||
<Text style={{ fontSize: 22 }}>🎤</Text>
|
|
||||||
<Text style={styles.rowText}>{dispArtistById(row.item.id, row.item.name)}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (row.type === 'album') {
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.rowItem}
|
|
||||||
onPress={() => navigation.navigate('AlbumDetail', { album: row.item })}
|
|
||||||
>
|
|
||||||
<Text style={{ fontSize: 22 }}>💿</Text>
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Text style={styles.rowText}>{dispAlbumName(row.item)}</Text>
|
|
||||||
<Text style={styles.rowSub}>{dispArtistById(row.item.artistId, row.item.artist || '')}</Text>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (row.type === 'song') {
|
|
||||||
return (
|
|
||||||
<SongItem
|
|
||||||
song={row.item}
|
|
||||||
index={row.index}
|
|
||||||
showArt
|
|
||||||
onPress={() => playQueue(results.songs, row.index)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<View style={styles.searchBar}>
|
|
||||||
<Ionicons name="search" size={18} color="#666" />
|
|
||||||
<TextInput
|
|
||||||
style={styles.input}
|
|
||||||
value={query}
|
|
||||||
onChangeText={onChangeText}
|
|
||||||
placeholder="Suchen…"
|
|
||||||
placeholderTextColor="#555"
|
|
||||||
autoFocus
|
|
||||||
returnKeyType="search"
|
|
||||||
onSubmitEditing={() => doSearch(query)}
|
|
||||||
/>
|
|
||||||
{query ? (
|
|
||||||
<TouchableOpacity onPress={() => { setQuery(''); setResults(null); }}>
|
|
||||||
<Ionicons name="close-circle" size={18} color="#666" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{loading && <ActivityIndicator color="#1db954" style={{ marginTop: 20 }} />}
|
|
||||||
|
|
||||||
{!loading && results && data.length === 0 && (
|
|
||||||
<Text style={styles.empty}>Keine Ergebnisse für „{query}"</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && results && data.length > 0 && (
|
|
||||||
<FlatList
|
|
||||||
data={data}
|
|
||||||
keyExtractor={r => r.key}
|
|
||||||
renderItem={renderItem}
|
|
||||||
keyboardShouldPersistTaps="handled"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!results && !loading && (
|
|
||||||
<Text style={styles.hint}>Suche nach Künstlern, Alben oder Titeln</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: { flex: 1, backgroundColor: '#121212' },
|
|
||||||
searchBar: {
|
|
||||||
flexDirection: 'row', alignItems: 'center', gap: 10,
|
|
||||||
backgroundColor: '#1e1e1e', borderRadius: 10,
|
|
||||||
margin: 12, paddingHorizontal: 14, paddingVertical: 10,
|
|
||||||
},
|
|
||||||
input: { flex: 1, color: '#fff', fontSize: 15 },
|
|
||||||
sectionHeader: {
|
|
||||||
color: '#aaa', fontSize: 12, fontWeight: '700',
|
|
||||||
textTransform: 'uppercase', letterSpacing: 1,
|
|
||||||
paddingHorizontal: 16, paddingVertical: 10, paddingTop: 16,
|
|
||||||
},
|
|
||||||
rowItem: {
|
|
||||||
flexDirection: 'row', alignItems: 'center', gap: 12,
|
|
||||||
paddingHorizontal: 16, paddingVertical: 12,
|
|
||||||
},
|
|
||||||
rowText: { color: '#fff', fontSize: 14, fontWeight: '500' },
|
|
||||||
rowSub: { color: '#aaa', fontSize: 12, marginTop: 2 },
|
|
||||||
empty: { color: '#aaa', textAlign: 'center', marginTop: 40, fontSize: 14 },
|
|
||||||
hint: { color: '#555', textAlign: 'center', marginTop: 40, fontSize: 14 },
|
|
||||||
});
|
|
||||||
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user