feat: partial album split view + artist page full-screen + Bibliothek iTunes integration
- Partial album detail: tapping incomplete albums now shows downloaded songs (normal playback) above and missing iTunes tracks (30s preview + download) below — on both Android and web PWA - Android: ItunesArtistScreen now renders full-screen above the tab/search bar - Android: ArtistDetailScreen (Bibliothek) now loads iTunes data in parallel — adds Weltweit populär section and In Library/Unvollständig/Nicht heruntergeladen album sections identical to the Erkunden artist page - Fix: artist no longer appears in its own Similar Artists row - Fix: duplicate album entries deduplicated by collectionId in categorizeAlbums Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
b25e162806
commit
afae5d94df
104 files changed
+8839
-9420
No files matched your search
@@ -1,22 +1,25 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.novasonic.app"
|
||||
compileSdk = 34
|
||||
namespace = "com.novasonic.app"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.novasonic.app"
|
||||
minSdk = 24
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
buildConfigField("String", "NOVASONIC_URL", "\"https://music.asianxpolish.duckdns.org\"")
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
@@ -28,22 +31,68 @@ android {
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
jvmTarget = "11"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Core
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.material)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.splashscreen)
|
||||
|
||||
// Compose
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.ui.tooling.preview)
|
||||
implementation(libs.compose.foundation)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.icons)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
|
||||
// Navigation
|
||||
implementation(libs.navigation.compose)
|
||||
|
||||
// Lifecycle / ViewModel
|
||||
implementation(libs.lifecycle.viewmodel.compose)
|
||||
implementation(libs.lifecycle.runtime.compose)
|
||||
|
||||
// Media3
|
||||
implementation(libs.media3.exoplayer)
|
||||
implementation(libs.media3.session)
|
||||
implementation(libs.media3.common)
|
||||
implementation(libs.media3.datasource)
|
||||
implementation(libs.media3.ui)
|
||||
|
||||
// Ktor
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.android)
|
||||
implementation(libs.ktor.client.content.neg)
|
||||
implementation(libs.ktor.serialization.json)
|
||||
implementation(libs.ktor.client.logging)
|
||||
|
||||
// Kotlinx
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.kotlin.reflect)
|
||||
|
||||
// Room
|
||||
implementation(libs.room.runtime)
|
||||
implementation(libs.room.ktx)
|
||||
ksp(libs.room.compiler)
|
||||
|
||||
// DataStore
|
||||
implementation(libs.datastore.preferences)
|
||||
|
||||
// Coil
|
||||
implementation(libs.coil.compose)
|
||||
|
||||
// Google Fonts (Outfit)
|
||||
implementation(libs.compose.ui.google.fonts)
|
||||
}
|
||||
@@ -7,8 +7,10 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
|
||||
<application
|
||||
android:name=".NovaSonicApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
@@ -20,7 +22,7 @@
|
||||
android:largeHeap="true">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:name=".ui.MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden|smallestScreenSize|screenLayout"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
@@ -32,7 +34,7 @@
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".MusicService"
|
||||
android:name=".player.MusicService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
@@ -41,7 +43,7 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- Android Auto support -->
|
||||
<!-- Android Auto -->
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc"/>
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package com.novasonic.app
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
data class Track(
|
||||
val id: String,
|
||||
val url: String,
|
||||
val title: String,
|
||||
val artist: String,
|
||||
val album: String?,
|
||||
val artworkUrl: String?,
|
||||
val durationMs: Long
|
||||
) {
|
||||
fun toMediaItem(): MediaItem {
|
||||
val metadata = MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
.setArtist(artist)
|
||||
.setAlbumTitle(album)
|
||||
.apply {
|
||||
if (!artworkUrl.isNullOrEmpty()) setArtworkUri(Uri.parse(artworkUrl))
|
||||
if (durationMs > 0) setDurationMs(durationMs)
|
||||
}
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setMediaId(id)
|
||||
.setUri(url)
|
||||
.setMediaMetadata(metadata)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromJson(obj: JSONObject) = Track(
|
||||
id = obj.optString("id", ""),
|
||||
url = obj.optString("url", ""),
|
||||
title = obj.optString("title", ""),
|
||||
artist = obj.optString("artist", ""),
|
||||
album = obj.optString("album").takeIf { it.isNotEmpty() },
|
||||
artworkUrl = obj.optString("artworkUrl").takeIf { it.isNotEmpty() },
|
||||
durationMs = obj.optLong("durationMs", 0)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun parseTrackList(json: String): List<Track> = try {
|
||||
val arr = JSONArray(json)
|
||||
List(arr.length()) { Track.fromJson(arr.getJSONObject(it)) }
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
package com.novasonic.app
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import org.json.JSONObject
|
||||
|
||||
class MainActivity : AppCompatActivity(), MusicService.StateListener {
|
||||
|
||||
private lateinit var webView: WebView
|
||||
private lateinit var webBridge: WebBridge
|
||||
private var controllerFuture: ListenableFuture<MediaController>? = null
|
||||
private var mediaController: MediaController? = null
|
||||
private var pendingOpenNowPlaying = false
|
||||
// Debounce onReady: WebView can fire onPageFinished multiple times (hash changes,
|
||||
// certain JS navigations). Only deliver onReady once per 3-second window.
|
||||
private var lastOnReadyMs = 0L
|
||||
|
||||
// ─── Back-press handling ──────────────────────────────────────────────────
|
||||
|
||||
// Always enabled — the callback itself decides whether to handle the event.
|
||||
private val backCallback = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (webBridge.consumeBackPress()) {
|
||||
// An overlay is open in JS — ask it to close the topmost layer.
|
||||
webView.evaluateJavascript(
|
||||
"if(window.NSPlayer&&window.NSPlayer.handleBack)window.NSPlayer.handleBack()",
|
||||
null
|
||||
)
|
||||
return
|
||||
}
|
||||
if (webView.canGoBack()) {
|
||||
webView.goBack()
|
||||
return
|
||||
}
|
||||
// Nothing left to pop — send the task to background.
|
||||
// Using moveTaskToBack instead of finish() so the Activity stays alive
|
||||
// and the MusicService binding is preserved; resuming is instant.
|
||||
moveTaskToBack(true)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Lifecycle ────────────────────────────────────────────────────────────
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
webView = findViewById(R.id.web_view)
|
||||
|
||||
webBridge = WebBridge { mediaController }
|
||||
configureWebView()
|
||||
requestNotificationPermission()
|
||||
onBackPressedDispatcher.addCallback(this, backCallback)
|
||||
// Explicitly start the service before binding so Android 14 lets it
|
||||
// promote to foreground (bindService alone doesn't satisfy the
|
||||
// "started from foreground" requirement for foreground services).
|
||||
startService(Intent(this, MusicService::class.java))
|
||||
connectToService()
|
||||
|
||||
val url = BuildConfig.NOVASONIC_URL.ifEmpty { "https://demo.navidrome.org" }
|
||||
webView.loadUrl(url)
|
||||
|
||||
// Notification tap while app was not running — defer until page is loaded.
|
||||
if (intent?.getBooleanExtra("open_now_playing", false) == true) {
|
||||
pendingOpenNowPlaying = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
// Notification tap while app is already running — WebView is ready immediately.
|
||||
if (intent.getBooleanExtra("open_now_playing", false)) {
|
||||
// Refresh snapshot so getState() is never stale, then emit onTrackChanged
|
||||
// + openNowPlaying in a single JS tick to avoid the race where the bottom
|
||||
// bar and NP overlay show the previous song.
|
||||
updateStateSnapshot()
|
||||
val ctrl = mediaController
|
||||
if (ctrl != null) {
|
||||
val idx = ctrl.currentMediaItemIndex
|
||||
val trackId = ctrl.currentMediaItem?.mediaId ?: ""
|
||||
val json = JSONObject().apply {
|
||||
put("index", idx)
|
||||
put("trackId", trackId)
|
||||
put("reason", "notification")
|
||||
}.toString()
|
||||
val escaped = escapeJs(json)
|
||||
webView.post {
|
||||
webView.evaluateJavascript(
|
||||
"(function(){" +
|
||||
"if(window.NSPlayer&&window.NSPlayer.onTrackChanged)window.NSPlayer.onTrackChanged(\"$escaped\");" +
|
||||
"if(window.openNowPlaying)window.openNowPlaying();" +
|
||||
"})()",
|
||||
null
|
||||
)
|
||||
}
|
||||
} else {
|
||||
webView.post { webView.evaluateJavascript("if(window.openNowPlaying)window.openNowPlaying()", null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() { super.onResume(); webView.onResume() }
|
||||
override fun onPause() { webView.onPause(); super.onPause() }
|
||||
|
||||
override fun onDestroy() {
|
||||
MusicService.stateListener = null
|
||||
controllerFuture?.let { MediaController.releaseFuture(it) }
|
||||
webView.destroy()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
// ─── Service connection ───────────────────────────────────────────────────
|
||||
|
||||
private fun connectToService() {
|
||||
MusicService.stateListener = this
|
||||
val token = SessionToken(this, ComponentName(this, MusicService::class.java))
|
||||
val future = MediaController.Builder(this, token).buildAsync()
|
||||
controllerFuture = future
|
||||
future.addListener({
|
||||
mediaController = try { future.get() } catch (_: Exception) { null }
|
||||
if (mediaController != null) {
|
||||
lastOnReadyMs = System.currentTimeMillis()
|
||||
updateStateSnapshot() // ensure getState() is fresh when JS calls it in onReady
|
||||
callJs("onReady")
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(this))
|
||||
}
|
||||
|
||||
// ─── WebView configuration ────────────────────────────────────────────────
|
||||
|
||||
private fun configureWebView() {
|
||||
WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
|
||||
|
||||
webView.settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
cacheMode = WebSettings.LOAD_DEFAULT
|
||||
mediaPlaybackRequiresUserGesture = false
|
||||
allowFileAccess = true // needed for bridge-test.html asset
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||
useWideViewPort = true
|
||||
loadWithOverviewMode = true
|
||||
userAgentString = userAgentString.replace("wv", "") + " NovaSonicApp/1.0"
|
||||
}
|
||||
|
||||
// New full bridge — JS calls window.NSBridge.*
|
||||
webView.addJavascriptInterface(webBridge, "NSBridge")
|
||||
|
||||
webView.webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest) = false
|
||||
|
||||
// Re-announce ready + current state after a WebView reload so JS can re-sync.
|
||||
// Debounced: WebView can fire onPageFinished for hash/state changes mid-session.
|
||||
override fun onPageFinished(view: WebView, url: String) {
|
||||
if (mediaController != null) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastOnReadyMs > 3_000) {
|
||||
lastOnReadyMs = now
|
||||
updateStateSnapshot()
|
||||
callJs("onReady")
|
||||
}
|
||||
}
|
||||
if (pendingOpenNowPlaying) {
|
||||
pendingOpenNowPlaying = false
|
||||
webView.post { webView.evaluateJavascript("if(window.openNowPlaying)window.openNowPlaying()", null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
webView.webChromeClient = WebChromeClient()
|
||||
}
|
||||
|
||||
// ─── MusicService.StateListener → push state to JS ───────────────────────
|
||||
|
||||
override fun onReady() = callJs("onReady")
|
||||
|
||||
override fun onPlaybackStateChanged(isPlaying: Boolean, isBuffering: Boolean, isLoading: Boolean) {
|
||||
val json = """{"isPlaying":$isPlaying,"isBuffering":$isBuffering,"isLoading":$isLoading}"""
|
||||
updateStateSnapshot()
|
||||
callJs("onPlaybackStateChanged", json)
|
||||
}
|
||||
|
||||
override fun onTrackChanged(index: Int, trackId: String, reason: String) {
|
||||
val json = JSONObject().apply {
|
||||
put("index", index)
|
||||
put("trackId", trackId)
|
||||
put("reason", reason)
|
||||
}.toString()
|
||||
updateStateSnapshot()
|
||||
callJs("onTrackChanged", json)
|
||||
}
|
||||
|
||||
override fun onPositionUpdate(positionMs: Long, durationMs: Long) =
|
||||
callJs("onPositionUpdate", positionMs, durationMs)
|
||||
|
||||
override fun onQueueEnded() = callJs("onQueueEnded")
|
||||
|
||||
override fun onError(message: String) = callJs("onError", message)
|
||||
|
||||
override fun onSkipToNext() = callJs("onNotificationNext")
|
||||
override fun onSkipToPrevious() = callJs("onNotificationPrev")
|
||||
|
||||
// ─── JS bridge helpers ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Calls window.NSPlayer.<method>(<args>) safely on the UI thread.
|
||||
* Strings are JSON-escaped and quoted; numbers/booleans are passed as-is.
|
||||
*/
|
||||
fun callJs(method: String, vararg args: Any?) {
|
||||
val argsJs = args.joinToString(",") { arg ->
|
||||
when (arg) {
|
||||
null -> "null"
|
||||
is Boolean -> arg.toString()
|
||||
is Number -> arg.toString()
|
||||
is String -> "\"${escapeJs(arg)}\""
|
||||
else -> "\"${escapeJs(arg.toString())}\""
|
||||
}
|
||||
}
|
||||
val js = "if(window.NSPlayer&&window.NSPlayer.$method)window.NSPlayer.$method($argsJs)"
|
||||
webView.post { webView.evaluateJavascript(js, null) }
|
||||
}
|
||||
|
||||
private fun escapeJs(s: String) = s
|
||||
.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "")
|
||||
|
||||
private fun updateStateSnapshot() {
|
||||
val ctrl = mediaController ?: return
|
||||
val json = JSONObject().apply {
|
||||
put("isPlaying", ctrl.isPlaying)
|
||||
put("isBuffering", ctrl.playbackState == Player.STATE_BUFFERING)
|
||||
put("isEnded", ctrl.playbackState == Player.STATE_ENDED)
|
||||
put("currentIndex", ctrl.currentMediaItemIndex)
|
||||
put("currentId", ctrl.currentMediaItem?.mediaId ?: "")
|
||||
put("positionMs", ctrl.currentPosition)
|
||||
put("queueLength", ctrl.mediaItemCount)
|
||||
}.toString()
|
||||
webBridge.updateSnapshot(json)
|
||||
}
|
||||
|
||||
// ─── Permissions ──────────────────────────────────────────────────────────
|
||||
|
||||
private fun requestNotificationPermission() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
||||
when {
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
||||
== PackageManager.PERMISSION_GRANTED -> return
|
||||
|
||||
// System won't show the dialog again after one denial — send user to settings once.
|
||||
!ActivityCompat.shouldShowRequestPermissionRationale(
|
||||
this, Manifest.permission.POST_NOTIFICATIONS
|
||||
) && _notifPermissionAskedBefore() && !_notifSettingsShownBefore() -> {
|
||||
markNotifSettingsShown()
|
||||
openNotificationSettings()
|
||||
}
|
||||
|
||||
else -> {
|
||||
markNotifPermissionAsked()
|
||||
ActivityCompat.requestPermissions(
|
||||
this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 42
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int, permissions: Array<out String>, grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode == 42 &&
|
||||
grantResults.firstOrNull() == PackageManager.PERMISSION_DENIED &&
|
||||
!_notifSettingsShownBefore()
|
||||
) {
|
||||
markNotifSettingsShown()
|
||||
openNotificationSettings()
|
||||
}
|
||||
}
|
||||
|
||||
private fun openNotificationSettings() {
|
||||
startActivity(
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", packageName, null)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun _notifPermissionAskedBefore() =
|
||||
getSharedPreferences("ns_prefs", MODE_PRIVATE).getBoolean("notif_asked", false)
|
||||
|
||||
private fun markNotifPermissionAsked() =
|
||||
getSharedPreferences("ns_prefs", MODE_PRIVATE).edit()
|
||||
.putBoolean("notif_asked", true).apply()
|
||||
|
||||
private fun _notifSettingsShownBefore() =
|
||||
getSharedPreferences("ns_prefs", MODE_PRIVATE).getBoolean("notif_settings_shown", false)
|
||||
|
||||
private fun markNotifSettingsShown() =
|
||||
getSharedPreferences("ns_prefs", MODE_PRIVATE).edit()
|
||||
.putBoolean("notif_settings_shown", true).apply()
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
package com.novasonic.app
|
||||
|
||||
// Replaced by MusicService.kt (Media3 MediaSessionService + ExoPlayer).
|
||||
// Kept as an empty file so git history is preserved.
|
||||
@@ -1,280 +0,0 @@
|
||||
package com.novasonic.app
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.ForwardingPlayer
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.media3.datasource.DataSourceBitmapLoader
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.datasource.DefaultHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.session.CacheBitmapLoader
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class MusicService : MediaSessionService() {
|
||||
|
||||
// ─── State callback interface (consumed by MainActivity) ─────────────────
|
||||
|
||||
interface StateListener {
|
||||
fun onReady()
|
||||
fun onPlaybackStateChanged(isPlaying: Boolean, isBuffering: Boolean, isLoading: Boolean)
|
||||
fun onTrackChanged(index: Int, trackId: String, reason: String)
|
||||
fun onPositionUpdate(positionMs: Long, durationMs: Long)
|
||||
fun onQueueEnded()
|
||||
fun onError(message: String)
|
||||
fun onSkipToNext()
|
||||
fun onSkipToPrevious()
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Set by MainActivity before connecting; cleared on destroy.
|
||||
@Volatile var stateListener: StateListener? = null
|
||||
// Service instance — lets WebBridge trigger artwork pre-warming directly.
|
||||
@Volatile var instance: MusicService? = null
|
||||
}
|
||||
|
||||
// ─── Fields ───────────────────────────────────────────────────────────────
|
||||
|
||||
private lateinit var player: ExoPlayer
|
||||
private lateinit var mediaSession: MediaSession
|
||||
private lateinit var bitmapLoader: CacheBitmapLoader
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
// 250 ms position ticker — only runs while actually playing
|
||||
private val positionTicker = object : Runnable {
|
||||
override fun run() {
|
||||
if (player.isPlaying) {
|
||||
val dur = player.duration.let { if (it == C.TIME_UNSET) 0L else it }
|
||||
stateListener?.onPositionUpdate(player.currentPosition, dur)
|
||||
handler.postDelayed(this, 250)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Lifecycle ────────────────────────────────────────────────────────────
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
|
||||
val httpFactory = DefaultHttpDataSource.Factory()
|
||||
.setUserAgent("NovaSonic-Android/1.0")
|
||||
.setConnectTimeoutMs(15_000)
|
||||
.setReadTimeoutMs(15_000)
|
||||
.setAllowCrossProtocolRedirects(true)
|
||||
|
||||
val dataSourceFactory = DefaultDataSource.Factory(this, httpFactory)
|
||||
|
||||
// CacheBitmapLoader caches fetched bitmaps in-memory so that
|
||||
// the notification never has to re-fetch artwork on auto-advance.
|
||||
val executor = Executors.newSingleThreadExecutor()
|
||||
bitmapLoader = CacheBitmapLoader(
|
||||
DataSourceBitmapLoader(
|
||||
MoreExecutors.listeningDecorator(executor),
|
||||
dataSourceFactory
|
||||
)
|
||||
)
|
||||
|
||||
val audioAttributes = AudioAttributes.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.build()
|
||||
|
||||
player = ExoPlayer.Builder(this)
|
||||
.setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory))
|
||||
.setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true)
|
||||
.setHandleAudioBecomingNoisy(true) // pause on headphone unplug
|
||||
.setWakeMode(C.WAKE_MODE_NETWORK) // holds WakeLock + WifiLock through Doze
|
||||
.build()
|
||||
|
||||
// ForwardingPlayer that always reports next/prev as available commands.
|
||||
// ExoPlayer drops those commands when its queue has only one item (single-track model),
|
||||
// which makes Media3 hide the skip buttons in the notification. JS still owns queue
|
||||
// logic — the MediaSession callback intercepts the commands and delegates to JS.
|
||||
// seekToNext* are no-oped here so ExoPlayer never tries to advance its single-item
|
||||
// queue; JS handles all actual navigation via stateListener callbacks.
|
||||
val sessionPlayer = object : ForwardingPlayer(player) {
|
||||
override fun getAvailableCommands(): Player.Commands =
|
||||
super.getAvailableCommands().buildUpon()
|
||||
.add(Player.COMMAND_SEEK_TO_NEXT)
|
||||
.add(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
|
||||
.add(Player.COMMAND_SEEK_TO_PREVIOUS)
|
||||
.add(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
|
||||
.build()
|
||||
|
||||
override fun isCommandAvailable(command: Int): Boolean = when (command) {
|
||||
Player.COMMAND_SEEK_TO_NEXT,
|
||||
Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM,
|
||||
Player.COMMAND_SEEK_TO_PREVIOUS,
|
||||
Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM -> true
|
||||
else -> super.isCommandAvailable(command)
|
||||
}
|
||||
|
||||
override fun seekToNext() {}
|
||||
override fun seekToNextMediaItem() {}
|
||||
override fun seekToPrevious() {}
|
||||
override fun seekToPreviousMediaItem() {}
|
||||
}
|
||||
|
||||
// Tapping the notification opens the app directly to the Now Playing screen.
|
||||
val notifTapIntent = Intent(this, MainActivity::class.java).apply {
|
||||
putExtra("open_now_playing", true)
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
}
|
||||
val notifTapPending = PendingIntent.getActivity(
|
||||
this, 0, notifTapIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
mediaSession = MediaSession.Builder(this, sessionPlayer)
|
||||
.setBitmapLoader(bitmapLoader)
|
||||
.setSessionActivity(notifTapPending)
|
||||
.setCallback(object : MediaSession.Callback {
|
||||
// Intercept notification next/prev so JS (not ExoPlayer) handles queue logic.
|
||||
// With single-track model ExoPlayer has no queue to advance — block the command
|
||||
// and delegate to JS instead.
|
||||
override fun onPlayerCommandRequest(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
playerCommand: Int
|
||||
): Int = when (playerCommand) {
|
||||
Player.COMMAND_SEEK_TO_NEXT,
|
||||
Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM -> {
|
||||
stateListener?.onSkipToNext()
|
||||
// Return SUCCESS so Media3 keeps the button enabled in the notification.
|
||||
// The ForwardingPlayer no-ops seekToNext so ExoPlayer does nothing.
|
||||
androidx.media3.session.SessionResult.RESULT_SUCCESS
|
||||
}
|
||||
Player.COMMAND_SEEK_TO_PREVIOUS,
|
||||
Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM -> {
|
||||
stateListener?.onSkipToPrevious()
|
||||
androidx.media3.session.SessionResult.RESULT_SUCCESS
|
||||
}
|
||||
else -> androidx.media3.session.SessionResult.RESULT_SUCCESS
|
||||
}
|
||||
})
|
||||
.build()
|
||||
|
||||
player.addListener(PlayerListener())
|
||||
|
||||
// Pre-warm: load the last played track immediately so ExoPlayer starts
|
||||
// buffering while the WebView is still loading — reduces startup latency.
|
||||
try {
|
||||
val prefs = getSharedPreferences("ns_prefs", android.content.Context.MODE_PRIVATE)
|
||||
val trackJson = prefs.getString("last_played_track", null)
|
||||
if (!trackJson.isNullOrBlank()) {
|
||||
val track = Track.fromJson(org.json.JSONObject(trackJson))
|
||||
player.setMediaItem(track.toMediaItem())
|
||||
player.prepare()
|
||||
// No play() — wait for JS to decide whether to auto-play
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo) = mediaSession
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
player.stop()
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
instance = null
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
mediaSession.release()
|
||||
player.release()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
// ─── Artwork pre-warming ──────────────────────────────────────────────────
|
||||
|
||||
// Loads the next `count` tracks' artwork into the CacheBitmapLoader so that
|
||||
// onMediaItemTransition never stalls waiting for a fresh HTTP fetch.
|
||||
// fromIndex: explicit start index; -1 means use player.currentMediaItemIndex.
|
||||
private fun warmArtworkCache(count: Int = 3, fromIndex: Int = -1) {
|
||||
val startIdx = if (fromIndex >= 0) fromIndex else player.currentMediaItemIndex
|
||||
val total = player.mediaItemCount
|
||||
for (offset in 0 until count) {
|
||||
val idx = startIdx + offset
|
||||
if (idx >= total) break
|
||||
val item = player.getMediaItemAt(idx)
|
||||
if (item.mediaMetadata.artworkUri != null) {
|
||||
bitmapLoader.loadBitmapFromMetadata(item.mediaMetadata)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Called by WebBridge right after setMediaItems so artwork fetch starts
|
||||
// before prepare()/play() — avoids the race where the notification renders
|
||||
// before the bitmap is in the cache.
|
||||
fun publicWarmArtwork(startIndex: Int) = warmArtworkCache(count = 5, fromIndex = startIndex)
|
||||
|
||||
// ─── Player.Listener ─────────────────────────────────────────────────────
|
||||
|
||||
private inner class PlayerListener : Player.Listener {
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
val state = player.playbackState
|
||||
stateListener?.onPlaybackStateChanged(
|
||||
isPlaying,
|
||||
isBuffering = state == Player.STATE_BUFFERING,
|
||||
isLoading = state == Player.STATE_IDLE || state == Player.STATE_BUFFERING
|
||||
)
|
||||
if (isPlaying) handler.post(positionTicker)
|
||||
else handler.removeCallbacks(positionTicker)
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
val isPlaying = player.isPlaying
|
||||
stateListener?.onPlaybackStateChanged(
|
||||
isPlaying,
|
||||
isBuffering = playbackState == Player.STATE_BUFFERING,
|
||||
isLoading = playbackState == Player.STATE_IDLE
|
||||
)
|
||||
// STATE_ENDED fires when the last item finishes with no repeat
|
||||
if (playbackState == Player.STATE_ENDED) stateListener?.onQueueEnded()
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
val reasonStr = when (reason) {
|
||||
Player.MEDIA_ITEM_TRANSITION_REASON_AUTO -> "auto"
|
||||
Player.MEDIA_ITEM_TRANSITION_REASON_SEEK -> "seek"
|
||||
Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED -> "queue"
|
||||
else -> "seek"
|
||||
}
|
||||
// Fire immediately — snapshot must always be fresh so the JS-side
|
||||
// getState() reconciliation in onTrackChanged sees current native state.
|
||||
stateListener?.onTrackChanged(
|
||||
player.currentMediaItemIndex,
|
||||
mediaItem?.mediaId ?: "",
|
||||
reasonStr
|
||||
)
|
||||
// Pre-warm artwork for current + next tracks so notification
|
||||
// never shows a blank cover on auto-advance.
|
||||
warmArtworkCache()
|
||||
}
|
||||
|
||||
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
||||
// Queue was replaced (setQueue / addToQueue from JS) — warm cache from new start.
|
||||
if (reason == Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) {
|
||||
warmArtworkCache()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
stateListener?.onError(error.message ?: "Playback error")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
package com.novasonic.app
|
||||
|
||||
// Replaced by Media3-managed notifications (MusicService.kt).
|
||||
// Notification actions are now handled natively by Media3 MediaSession.
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.novasonic.app
|
||||
|
||||
import android.app.Application
|
||||
import com.novasonic.app.data.MusicRepository
|
||||
import com.novasonic.app.player.PlayerController
|
||||
|
||||
class NovaSonicApp : Application() {
|
||||
|
||||
lateinit var repository: MusicRepository
|
||||
private set
|
||||
lateinit var playerController: PlayerController
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
repository = MusicRepository(applicationContext)
|
||||
playerController = PlayerController(applicationContext, repository)
|
||||
playerController.connect()
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience extension to access app-level singletons from any Activity/Composable context.
|
||||
val android.content.Context.app: NovaSonicApp
|
||||
get() = applicationContext as NovaSonicApp
|
||||
@@ -1,9 +0,0 @@
|
||||
package com.novasonic.app
|
||||
|
||||
/**
|
||||
* Legacy singleton — no longer used. Kept to avoid breaking any stale git references.
|
||||
* State is now pushed directly from MusicService.StateListener via MainActivity.callJs().
|
||||
*/
|
||||
object NovaSonicBridge {
|
||||
@Volatile var jsRunner: ((String) -> Unit)? = null
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
package com.novasonic.app
|
||||
|
||||
// Replaced by WebBridge.kt (window.NSBridge).
|
||||
// Kept as an empty file so git history is preserved.
|
||||
@@ -1,177 +0,0 @@
|
||||
package com.novasonic.app
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.webkit.JavascriptInterface
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.session.MediaController
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Exposed to the WebView as `window.NSBridge`.
|
||||
* All @JavascriptInterface methods are called on a WebView background thread — every
|
||||
* MediaController call is posted to the main looper before execution.
|
||||
*/
|
||||
class WebBridge(private val controllerProvider: () -> MediaController?) {
|
||||
|
||||
// Snapshot of current playback state; read from getState() on any thread.
|
||||
@Volatile private var stateSnapshot = "{}"
|
||||
|
||||
fun updateSnapshot(snapshot: String) { stateSnapshot = snapshot }
|
||||
|
||||
// ─── Queue management ─────────────────────────────────────────────────────
|
||||
|
||||
@JavascriptInterface
|
||||
fun setQueue(queueJson: String, startIndex: Int, autoPlay: Boolean) {
|
||||
val items = parseTrackList(queueJson).map { it.toMediaItem() }
|
||||
if (items.isEmpty()) return
|
||||
runOnMain {
|
||||
val ctrl = controllerProvider() ?: return@runOnMain
|
||||
val idx = startIndex.coerceAtLeast(0)
|
||||
// If ExoPlayer already has this exact single track buffered from the cold-start
|
||||
// pre-warm, skip setMediaItems+prepare so we don't throw away the buffered data.
|
||||
// Just call play() — this makes the "resume last track" instant instead of ~10s.
|
||||
val alreadyReady = items.size == 1 &&
|
||||
ctrl.mediaItemCount == 1 &&
|
||||
ctrl.currentMediaItem?.mediaId == items[0].mediaId &&
|
||||
ctrl.playbackState != Player.STATE_IDLE
|
||||
if (!alreadyReady) {
|
||||
ctrl.setMediaItems(items, idx, 0L)
|
||||
// Pre-warm artwork before prepare()/play() so the notification bitmap
|
||||
// cache is populated before Media3 renders the first notification.
|
||||
MusicService.instance?.publicWarmArtwork(idx)
|
||||
ctrl.prepare()
|
||||
}
|
||||
if (autoPlay) ctrl.play()
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun addToQueue(trackJson: String, position: Int) {
|
||||
val track = try { Track.fromJson(JSONObject(trackJson)) } catch (_: Exception) { return }
|
||||
runOnMain {
|
||||
val ctrl = controllerProvider() ?: return@runOnMain
|
||||
val item = track.toMediaItem()
|
||||
if (position < 0 || position >= ctrl.mediaItemCount) ctrl.addMediaItem(item)
|
||||
else ctrl.addMediaItem(position, item)
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun removeFromQueue(index: Int) {
|
||||
runOnMain { controllerProvider()?.removeMediaItem(index) }
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun clearQueue() {
|
||||
runOnMain {
|
||||
val ctrl = controllerProvider() ?: return@runOnMain
|
||||
ctrl.stop()
|
||||
ctrl.clearMediaItems()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Transport ────────────────────────────────────────────────────────────
|
||||
|
||||
@JavascriptInterface
|
||||
fun play() { runOnMain { controllerProvider()?.play() } }
|
||||
|
||||
@JavascriptInterface
|
||||
fun pause() { runOnMain { controllerProvider()?.pause() } }
|
||||
|
||||
@JavascriptInterface
|
||||
fun togglePlay() {
|
||||
runOnMain {
|
||||
val ctrl = controllerProvider() ?: return@runOnMain
|
||||
if (ctrl.isPlaying) ctrl.pause() else ctrl.play()
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun seekTo(positionMs: Long) {
|
||||
runOnMain { controllerProvider()?.seekTo(positionMs) }
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun seekToIndex(index: Int, positionMs: Long) {
|
||||
runOnMain { controllerProvider()?.seekTo(index, positionMs) }
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun next() { runOnMain { controllerProvider()?.seekToNextMediaItem() } }
|
||||
|
||||
@JavascriptInterface
|
||||
fun prev() {
|
||||
runOnMain {
|
||||
val ctrl = controllerProvider() ?: return@runOnMain
|
||||
if (ctrl.currentPosition > 3000) ctrl.seekTo(0)
|
||||
else ctrl.seekToPreviousMediaItem()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Playback settings ────────────────────────────────────────────────────
|
||||
|
||||
@JavascriptInterface
|
||||
fun setRepeatMode(mode: String) {
|
||||
runOnMain {
|
||||
val ctrl = controllerProvider() ?: return@runOnMain
|
||||
ctrl.repeatMode = when (mode) {
|
||||
"one" -> Player.REPEAT_MODE_ONE
|
||||
"all" -> Player.REPEAT_MODE_ALL
|
||||
else -> Player.REPEAT_MODE_OFF
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun setShuffleMode(enabled: Boolean) {
|
||||
runOnMain { controllerProvider()?.shuffleModeEnabled = enabled }
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun setVolume(volume: Float) {
|
||||
runOnMain { controllerProvider()?.volume = volume }
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun setPlaybackSpeed(speed: Float) {
|
||||
runOnMain { controllerProvider()?.setPlaybackSpeed(speed) }
|
||||
}
|
||||
|
||||
// ─── State snapshot (synchronous read from JS) ────────────────────────────
|
||||
|
||||
@JavascriptInterface
|
||||
fun getState(): String = stateSnapshot
|
||||
|
||||
// ─── Last played (persisted for startup preload) ──────────────────────────
|
||||
|
||||
// Called by JS whenever the current song changes — persists to SharedPreferences
|
||||
// so MusicService can start buffering immediately on next launch before JS loads.
|
||||
@JavascriptInterface
|
||||
fun saveLastPlayed(trackJson: String) {
|
||||
runOnMain {
|
||||
try {
|
||||
val ctx = MusicService.instance ?: return@runOnMain
|
||||
ctx.getSharedPreferences("ns_prefs", android.content.Context.MODE_PRIVATE)
|
||||
.edit().putString("last_played_track", trackJson).apply()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Overlay state (for back-button routing) ──────────────────────────────
|
||||
|
||||
// Written by WebView thread; read by UI thread — must be @Volatile.
|
||||
@Volatile private var hasOpenOverlay = false
|
||||
|
||||
@JavascriptInterface
|
||||
fun setOverlayState(active: Boolean) { hasOpenOverlay = active }
|
||||
|
||||
// Called by MainActivity on back press; returns true if JS owns the event.
|
||||
fun consumeBackPress(): Boolean = hasOpenOverlay
|
||||
|
||||
// ─── Internal ─────────────────────────────────────────────────────────────
|
||||
|
||||
private fun runOnMain(block: () -> Unit) {
|
||||
Handler(Looper.getMainLooper()).post(block)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.novasonic.app.api
|
||||
|
||||
import com.novasonic.app.api.model.*
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.android.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/** Talks to the NovaSonic download/importer backend (Express server, default :3700). */
|
||||
class ImporterClient(private val backendUrl: String) {
|
||||
|
||||
private val http = HttpClient(Android) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json { ignoreUnknownKeys = true; coerceInputValues = true })
|
||||
}
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 40_000
|
||||
connectTimeoutMillis = 12_000
|
||||
socketTimeoutMillis = 40_000
|
||||
}
|
||||
}
|
||||
|
||||
private fun base() = backendUrl.trimEnd('/')
|
||||
|
||||
companion object {
|
||||
/** Detect which importer endpoint to hit from a pasted URL. */
|
||||
fun platformFor(url: String): String? = when {
|
||||
url.contains("spotify.com") -> "spotify"
|
||||
url.contains("soundcloud.com") -> "soundcloud"
|
||||
url.contains("youtube.com") ||
|
||||
url.contains("youtu.be") -> "youtube"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun resolve(platform: String, url: String): ImportResolveResponse =
|
||||
http.post("${base()}/api/$platform/resolve") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(ResolveRequest(url))
|
||||
}.body()
|
||||
|
||||
suspend fun download(request: DownloadRequest) {
|
||||
http.post("${base()}/api/download") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(request)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun status(jobId: String): DownloadStatus =
|
||||
http.get("${base()}/api/status/$jobId").body()
|
||||
|
||||
fun close() = http.close()
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.novasonic.app.api
|
||||
|
||||
import com.novasonic.app.api.model.*
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.android.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
object ItunesClient {
|
||||
|
||||
private val http = HttpClient(Android) {
|
||||
install(ContentNegotiation) {
|
||||
// iTunes returns text/javascript instead of application/json — accept any content type
|
||||
json(Json { ignoreUnknownKeys = true; coerceInputValues = true }, contentType = ContentType.Any)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun search(
|
||||
query: String,
|
||||
entity: String = "musicTrack,album,musicArtist",
|
||||
limit: Int = 30,
|
||||
country: String = "us",
|
||||
): ItunesSearchResponse = http.get("https://itunes.apple.com/search") {
|
||||
parameter("term", query)
|
||||
parameter("media", "music")
|
||||
parameter("entity", entity)
|
||||
parameter("limit", limit)
|
||||
parameter("country", country)
|
||||
}.body()
|
||||
|
||||
suspend fun lookupAlbum(collectionId: Long): List<ItunesItem> {
|
||||
val resp: ItunesSearchResponse = http.get("https://itunes.apple.com/lookup") {
|
||||
parameter("id", collectionId)
|
||||
parameter("entity", "song")
|
||||
}.body()
|
||||
// First result is the album itself; rest are tracks
|
||||
return resp.results.filter { it.kind == "song" }
|
||||
}
|
||||
|
||||
/** All albums for an iTunes artist */
|
||||
suspend fun lookupArtistAlbums(artistId: Long): List<ItunesItem> {
|
||||
val resp: ItunesSearchResponse = http.get("https://itunes.apple.com/lookup") {
|
||||
parameter("id", artistId)
|
||||
parameter("entity", "album")
|
||||
parameter("limit", 200)
|
||||
}.body()
|
||||
return resp.results.filter { it.wrapperType == "collection" }
|
||||
}
|
||||
|
||||
/** Worldwide top tracks for an iTunes artist */
|
||||
suspend fun lookupArtistTopTracks(artistId: Long, limit: Int = 10): List<ItunesItem> {
|
||||
val resp: ItunesSearchResponse = http.get("https://itunes.apple.com/lookup") {
|
||||
parameter("id", artistId)
|
||||
parameter("entity", "musicTrack")
|
||||
parameter("limit", limit + 1) // first result is the artist itself
|
||||
}.body()
|
||||
return resp.results.filter { it.wrapperType == "track" && it.kind == "song" }.take(limit)
|
||||
}
|
||||
|
||||
/** Top albums RSS feed for a given country */
|
||||
suspend fun topAlbums(country: String = "us", limit: Int = 25): List<ItunesRssEntry> {
|
||||
val resp: ItunesRssResponse =
|
||||
http.get("https://itunes.apple.com/$country/rss/topalbums/limit=$limit/json").body()
|
||||
return resp.feed?.entry ?: emptyList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.novasonic.app.api
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.android.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Serializable
|
||||
data class LrcLibResponse(
|
||||
val id: Long = 0,
|
||||
val trackName: String = "",
|
||||
val artistName: String = "",
|
||||
val albumName: String = "",
|
||||
val duration: Double = 0.0,
|
||||
val instrumental: Boolean = false,
|
||||
val plainLyrics: String? = null,
|
||||
val syncedLyrics: String? = null,
|
||||
)
|
||||
|
||||
data class LrcLine(val timeMs: Long, val text: String)
|
||||
|
||||
fun parseLrc(lrc: String): List<LrcLine> {
|
||||
val regex = Regex("""\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)""")
|
||||
return lrc.lines()
|
||||
.mapNotNull { line ->
|
||||
regex.find(line.trim())?.let { m ->
|
||||
val mm = m.groupValues[1].toLong()
|
||||
val ss = m.groupValues[2].toLong()
|
||||
val frac = m.groupValues[3]
|
||||
val ms = mm * 60_000 + ss * 1_000 +
|
||||
if (frac.length == 2) frac.toLong() * 10 else frac.toLong()
|
||||
val text = m.groupValues[4].trim()
|
||||
if (text.isNotEmpty()) LrcLine(ms, text) else null
|
||||
}
|
||||
}
|
||||
.sortedBy { it.timeMs }
|
||||
}
|
||||
|
||||
object LrcLibClient {
|
||||
private val client = HttpClient(Android) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json { ignoreUnknownKeys = true; isLenient = true })
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getLyrics(
|
||||
artist: String,
|
||||
title: String,
|
||||
album: String?,
|
||||
durationSecs: Int?,
|
||||
): LrcLibResponse? = runCatching {
|
||||
client.get("https://lrclib.net/api/get") {
|
||||
parameter("artist_name", artist)
|
||||
parameter("track_name", title)
|
||||
if (!album.isNullOrBlank()) parameter("album_name", album)
|
||||
if (durationSecs != null && durationSecs > 0) parameter("duration", durationSecs)
|
||||
}.body<LrcLibResponse>()
|
||||
}.getOrNull()
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package com.novasonic.app.api
|
||||
|
||||
import com.novasonic.app.api.model.*
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.android.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.plugins.logging.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.security.MessageDigest
|
||||
import kotlin.random.Random
|
||||
|
||||
class SubsonicClient(
|
||||
private val serverUrl: String,
|
||||
private val username: String,
|
||||
private val password: String,
|
||||
private val clientName: String = "NovaSonic",
|
||||
private val apiVersion: String = "1.16.1",
|
||||
) {
|
||||
private val http = HttpClient(Android) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
})
|
||||
}
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 20_000
|
||||
connectTimeoutMillis = 12_000
|
||||
socketTimeoutMillis = 20_000
|
||||
}
|
||||
install(Logging) {
|
||||
level = LogLevel.INFO
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private fun authParams(): Map<String, String> {
|
||||
val salt = Random.nextInt(100_000, 999_999).toString()
|
||||
val token = md5(password + salt)
|
||||
return mapOf("u" to username, "t" to token, "s" to salt,
|
||||
"v" to apiVersion, "c" to clientName, "f" to "json")
|
||||
}
|
||||
|
||||
private fun md5(input: String): String =
|
||||
MessageDigest.getInstance("MD5")
|
||||
.digest(input.toByteArray())
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
|
||||
private fun url(path: String) = "${serverUrl.trimEnd('/')}/rest/$path"
|
||||
|
||||
// ── Core request ──────────────────────────────────────────────────────────
|
||||
|
||||
private suspend fun get(path: String, params: Map<String, Any?> = emptyMap()): SubsonicResponse {
|
||||
val combined = authParams() + params.filterValues { it != null }
|
||||
.mapValues { it.value.toString() }
|
||||
val resp: SubsonicWrapper = http.get(url(path)) {
|
||||
combined.forEach { (k, v) -> parameter(k, v) }
|
||||
}.body()
|
||||
if (resp.response.status != "ok") {
|
||||
val err = resp.response.error
|
||||
throw SubsonicException(err?.code ?: -1, err?.message ?: "Unknown error")
|
||||
}
|
||||
return resp.response
|
||||
}
|
||||
|
||||
// ── Cover art / stream URLs (no auth roundtrip needed for URL building) ───
|
||||
|
||||
fun coverArtUrl(id: String, size: Int = 300): String {
|
||||
val salt = "novasonic"
|
||||
val token = md5(password + salt)
|
||||
val base = url("getCoverArt.view")
|
||||
return "$base?id=$id&size=$size&u=$username&t=$token&s=$salt&v=$apiVersion&c=$clientName&f=json"
|
||||
}
|
||||
|
||||
fun streamUrl(id: String, maxBitRate: Int? = null): String {
|
||||
val salt = "novasonic"
|
||||
val token = md5(password + salt)
|
||||
val base = url("stream.view")
|
||||
val br = if (maxBitRate != null) "&maxBitRate=$maxBitRate" else ""
|
||||
return "$base?id=$id$br&u=$username&t=$token&s=$salt&v=$apiVersion&c=$clientName&f=json"
|
||||
}
|
||||
|
||||
// ── API methods ───────────────────────────────────────────────────────────
|
||||
|
||||
suspend fun ping() = get("ping.view")
|
||||
|
||||
suspend fun getAlbumList(
|
||||
type: String = "newest",
|
||||
size: Int = 30,
|
||||
offset: Int = 0,
|
||||
): List<Album> = get("getAlbumList2.view",
|
||||
mapOf("type" to type, "size" to size, "offset" to offset))
|
||||
.albumList2?.album ?: emptyList()
|
||||
|
||||
suspend fun getAlbum(id: String): Album =
|
||||
get("getAlbum.view", mapOf("id" to id)).album
|
||||
?: throw SubsonicException(-1, "Album not found")
|
||||
|
||||
suspend fun getArtists(): List<ArtistIndex> =
|
||||
get("getArtists.view").artists?.index ?: emptyList()
|
||||
|
||||
suspend fun getArtist(id: String): Artist =
|
||||
get("getArtist.view", mapOf("id" to id)).artist
|
||||
?: throw SubsonicException(-1, "Artist not found")
|
||||
|
||||
suspend fun getSong(id: String): Song =
|
||||
get("getSong.view", mapOf("id" to id)).song
|
||||
?: throw SubsonicException(-1, "Song not found")
|
||||
|
||||
suspend fun search(query: String, albumCount: Int = 10, artistCount: Int = 5, songCount: Int = 20): SearchResult3 =
|
||||
get("search3.view", mapOf(
|
||||
"query" to query,
|
||||
"albumCount" to albumCount,
|
||||
"artistCount" to artistCount,
|
||||
"songCount" to songCount,
|
||||
)).searchResult3 ?: SearchResult3()
|
||||
|
||||
suspend fun getPlaylists(): List<Playlist> =
|
||||
get("getPlaylists.view").playlists?.playlist ?: emptyList()
|
||||
|
||||
suspend fun getPlaylist(id: String): PlaylistWithSongs =
|
||||
get("getPlaylist.view", mapOf("id" to id)).playlist
|
||||
?: throw SubsonicException(-1, "Playlist not found")
|
||||
|
||||
suspend fun getStarred(): Starred2 =
|
||||
get("getStarred2.view").starred2 ?: Starred2()
|
||||
|
||||
suspend fun getRandomSongs(size: Int = 50): List<Song> =
|
||||
get("getRandomSongs.view", mapOf("size" to size)).randomSongs?.song ?: emptyList()
|
||||
|
||||
suspend fun star(id: String) = get("star.view", mapOf("id" to id))
|
||||
suspend fun unstar(id: String) = get("unstar.view", mapOf("id" to id))
|
||||
|
||||
suspend fun scrobble(id: String, submission: Boolean = true) =
|
||||
get("scrobble.view", mapOf("id" to id, "submission" to submission))
|
||||
|
||||
suspend fun getArtistInfo(id: String, count: Int = 10): com.novasonic.app.api.model.ArtistInfo =
|
||||
get("getArtistInfo2.view", mapOf("id" to id, "count" to count)).artistInfo2
|
||||
?: com.novasonic.app.api.model.ArtistInfo()
|
||||
|
||||
suspend fun getTopSongs(artist: String, count: Int = 10): List<com.novasonic.app.api.model.Song> =
|
||||
get("getTopSongs.view", mapOf("artist" to artist, "count" to count)).topSongs?.song
|
||||
?: emptyList()
|
||||
|
||||
suspend fun getGenres(): List<com.novasonic.app.api.model.Genre> =
|
||||
get("getGenres.view").genres?.genre ?: emptyList()
|
||||
|
||||
suspend fun getSongsByGenre(genre: String, count: Int = 50): List<com.novasonic.app.api.model.Song> =
|
||||
get("getSongsByGenre.view", mapOf("genre" to genre, "count" to count)).songsByGenre?.song
|
||||
?: emptyList()
|
||||
|
||||
suspend fun getLyrics(songId: String): List<com.novasonic.app.api.model.StructuredLyrics> =
|
||||
get("getLyricsBySongId.view", mapOf("id" to songId)).lyricsList?.structuredLyrics
|
||||
?: emptyList()
|
||||
|
||||
suspend fun addSongToPlaylist(playlistId: String, songId: String) =
|
||||
getList("updatePlaylist.view", listOf("playlistId" to playlistId, "songIdToAdd" to songId))
|
||||
|
||||
suspend fun createPlaylist(name: String, songIds: List<String> = emptyList()): com.novasonic.app.api.model.PlaylistWithSongs {
|
||||
val params = mutableListOf("name" to name)
|
||||
songIds.forEach { params += "songId" to it }
|
||||
return getList("createPlaylist.view", params).playlist
|
||||
?: throw SubsonicException(-1, "Failed to create playlist")
|
||||
}
|
||||
|
||||
fun close() = http.close()
|
||||
|
||||
private suspend fun getList(path: String, params: List<Pair<String, String>>): SubsonicResponse {
|
||||
val allParams = authParams().entries.map { (k, v) -> k to v } + params
|
||||
val resp: SubsonicWrapper = http.get(url(path)) {
|
||||
allParams.forEach { (k, v) -> parameter(k, v) }
|
||||
}.body()
|
||||
if (resp.response.status != "ok") {
|
||||
val err = resp.response.error
|
||||
throw SubsonicException(err?.code ?: -1, err?.message ?: "Unknown error")
|
||||
}
|
||||
return resp.response
|
||||
}
|
||||
}
|
||||
|
||||
class SubsonicException(val code: Int, message: String) : Exception(message)
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.novasonic.app.api.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** Response from POST {backend}/api/{platform}/resolve */
|
||||
@Serializable
|
||||
data class ImportResolveResponse(
|
||||
val type: String? = null, // "track" | "album" | "playlist"
|
||||
val name: String? = null,
|
||||
val artist: String? = null,
|
||||
val artworkUrl: String? = null,
|
||||
val tracks: List<ImportTrack> = emptyList(),
|
||||
val skippedLocal: Int = 0,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ImportTrack(
|
||||
val trackName: String? = null,
|
||||
val artistName: String? = null,
|
||||
val collectionName: String? = null,
|
||||
val artworkUrl100: String? = null,
|
||||
val previewUrl: String? = null,
|
||||
val durationMs: Long? = null,
|
||||
val trackNumber: Int? = null,
|
||||
)
|
||||
|
||||
/** Body for POST {backend}/api/download */
|
||||
@Serializable
|
||||
data class DownloadRequest(
|
||||
val jobId: String,
|
||||
val type: String, // "track" | "album" | "artist"
|
||||
val title: String,
|
||||
val artist: String? = null,
|
||||
val album: String? = null,
|
||||
val artworkUrl: String? = null,
|
||||
val itunesId: Long? = null,
|
||||
val collectionId: Long? = null,
|
||||
val tracks: List<ImportTrack> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ResolveRequest(val url: String)
|
||||
|
||||
/** Response from GET {backend}/api/status/{jobId} */
|
||||
@Serializable
|
||||
data class DownloadStatus(
|
||||
val status: String? = null, // "pending" | "downloading" | "done" | "error"
|
||||
val progress: Int? = null,
|
||||
val message: String? = null,
|
||||
val completed: Int? = null,
|
||||
val total: Int? = null,
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.novasonic.app.api.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ItunesSearchResponse(
|
||||
val resultCount: Int = 0,
|
||||
val results: List<ItunesItem> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ItunesItem(
|
||||
val wrapperType: String? = null, // "track" | "collection" | "artist"
|
||||
val kind: String? = null, // "song" | "album" etc
|
||||
val artistId: Long? = null,
|
||||
val collectionId: Long? = null,
|
||||
val trackId: Long? = null,
|
||||
val artistName: String? = null,
|
||||
val collectionName: String? = null,
|
||||
val trackName: String? = null,
|
||||
val artworkUrl100: String? = null,
|
||||
val previewUrl: String? = null,
|
||||
val trackTimeMillis: Long? = null,
|
||||
val releaseDate: String? = null,
|
||||
val primaryGenreName: String? = null,
|
||||
val trackCount: Int? = null,
|
||||
val trackNumber: Int? = null,
|
||||
val collectionPrice: Double? = null,
|
||||
)
|
||||
|
||||
// iTunes RSS feed response
|
||||
@Serializable
|
||||
data class ItunesRssResponse(
|
||||
val feed: ItunesFeed? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ItunesFeed(
|
||||
val entry: List<ItunesRssEntry> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ItunesRssEntry(
|
||||
@SerialName("im:name") val name: ItunesLabel? = null,
|
||||
@SerialName("im:artist") val artist: ItunesLabel? = null,
|
||||
@SerialName("im:image") val image: List<ItunesLabel> = emptyList(),
|
||||
@SerialName("im:itemCount") val itemCount: ItunesLabel? = null,
|
||||
val id: ItunesId? = null,
|
||||
val category: ItunesCategory? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ItunesLabel(
|
||||
val label: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ItunesId(
|
||||
val label: String? = null,
|
||||
val attributes: ItunesIdAttributes? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ItunesIdAttributes(
|
||||
@SerialName("im:id") val imId: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ItunesCategory(
|
||||
val attributes: ItunesCategoryAttributes? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ItunesCategoryAttributes(
|
||||
val term: String? = null,
|
||||
)
|
||||
|
||||
// Helper to get high-res artwork (replace 100x100 with 600x600)
|
||||
fun ItunesItem.artworkHd(): String? =
|
||||
artworkUrl100?.replace("100x100bb", "600x600bb")?.replace("/100x100/", "/600x600/")
|
||||
|
||||
fun ItunesRssEntry.artworkHd(): String? =
|
||||
image.lastOrNull()?.label // last image is always the largest in the RSS
|
||||
@@ -0,0 +1,194 @@
|
||||
package com.novasonic.app.api.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
// ── Subsonic JSON envelope ────────────────────────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
data class SubsonicWrapper(
|
||||
@SerialName("subsonic-response") val response: SubsonicResponse,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SubsonicResponse(
|
||||
val status: String,
|
||||
val version: String = "1.16.1",
|
||||
val error: SubsonicError? = null,
|
||||
|
||||
val musicFolders: MusicFoldersResult? = null,
|
||||
val albumList2: AlbumListResult? = null,
|
||||
val album: Album? = null,
|
||||
val artists: ArtistsResult? = null,
|
||||
val artist: Artist? = null,
|
||||
val searchResult3: SearchResult3? = null,
|
||||
val playlists: PlaylistsResult? = null,
|
||||
val playlist: PlaylistWithSongs? = null,
|
||||
val song: Song? = null,
|
||||
val starred2: Starred2? = null,
|
||||
val randomSongs: SongListResult? = null,
|
||||
val scanStatus: ScanStatus? = null,
|
||||
val artistInfo2: ArtistInfo? = null,
|
||||
val topSongs: SongListResult? = null,
|
||||
val genres: GenresResult? = null,
|
||||
val songsByGenre: SongListResult? = null,
|
||||
val lyricsList: LyricsListResult? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SubsonicError(val code: Int, val message: String)
|
||||
|
||||
// ── Domain models ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
data class Song(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val album: String? = null,
|
||||
val albumId: String? = null,
|
||||
val artist: String? = null,
|
||||
val artistId: String? = null,
|
||||
val track: Int? = null,
|
||||
val year: Int? = null,
|
||||
val genre: String? = null,
|
||||
val coverArt: String? = null,
|
||||
val size: Long? = null,
|
||||
val contentType: String? = null,
|
||||
val suffix: String? = null,
|
||||
val duration: Int? = null,
|
||||
val bitRate: Int? = null,
|
||||
val path: String? = null,
|
||||
val playCount: Long? = null,
|
||||
val starred: String? = null, // ISO date string when starred
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Album(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val artist: String? = null,
|
||||
val artistId: String? = null,
|
||||
val coverArt: String? = null,
|
||||
val songCount: Int = 0,
|
||||
val duration: Int = 0,
|
||||
val year: Int? = null,
|
||||
val genre: String? = null,
|
||||
val starred: String? = null,
|
||||
val song: List<Song> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Artist(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val coverArt: String? = null,
|
||||
val albumCount: Int = 0,
|
||||
val starred: String? = null,
|
||||
val album: List<Album> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ArtistIndex(
|
||||
val name: String,
|
||||
val artist: List<Artist> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Playlist(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val comment: String? = null,
|
||||
val owner: String? = null,
|
||||
val songCount: Int = 0,
|
||||
val duration: Int = 0,
|
||||
val coverArt: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PlaylistWithSongs(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val comment: String? = null,
|
||||
val owner: String? = null,
|
||||
val songCount: Int = 0,
|
||||
val duration: Int = 0,
|
||||
val coverArt: String? = null,
|
||||
val entry: List<Song> = emptyList(),
|
||||
)
|
||||
|
||||
// ── Result wrappers ───────────────────────────────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
data class MusicFoldersResult(val musicFolder: List<MusicFolder> = emptyList())
|
||||
|
||||
@Serializable
|
||||
data class MusicFolder(val id: String, val name: String? = null)
|
||||
|
||||
@Serializable
|
||||
data class AlbumListResult(val album: List<Album> = emptyList())
|
||||
|
||||
@Serializable
|
||||
data class ArtistsResult(val index: List<ArtistIndex> = emptyList())
|
||||
|
||||
@Serializable
|
||||
data class PlaylistsResult(val playlist: List<Playlist> = emptyList())
|
||||
|
||||
@Serializable
|
||||
data class SongListResult(val song: List<Song> = emptyList())
|
||||
|
||||
@Serializable
|
||||
data class Starred2(
|
||||
val song: List<Song> = emptyList(),
|
||||
val album: List<Album> = emptyList(),
|
||||
val artist: List<Artist> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SearchResult3(
|
||||
val song: List<Song> = emptyList(),
|
||||
val album: List<Album> = emptyList(),
|
||||
val artist: List<Artist> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ScanStatus(val scanning: Boolean, val count: Long? = null)
|
||||
|
||||
// ── Artist info (Last.fm bio + similar artists) ───────────────────────────────
|
||||
@Serializable
|
||||
data class ArtistInfo(
|
||||
val biography: String? = null,
|
||||
val musicBrainzId: String? = null,
|
||||
val lastFmUrl: String? = null,
|
||||
val smallImageUrl: String? = null,
|
||||
val mediumImageUrl: String? = null,
|
||||
val largeImageUrl: String? = null,
|
||||
val similarArtist: List<Artist> = emptyList(),
|
||||
)
|
||||
|
||||
// ── Genres ────────────────────────────────────────────────────────────────────
|
||||
@Serializable
|
||||
data class Genre(
|
||||
val value: String, // genre name is the text value
|
||||
val songCount: Int = 0,
|
||||
val albumCount: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GenresResult(val genre: List<Genre> = emptyList())
|
||||
|
||||
// ── Lyrics ────────────────────────────────────────────────────────────────────
|
||||
@Serializable
|
||||
data class LyricsLine(val start: Long? = null, val value: String = "")
|
||||
|
||||
@Serializable
|
||||
data class StructuredLyrics(
|
||||
val lang: String = "",
|
||||
val synced: Boolean = false,
|
||||
val line: List<LyricsLine> = emptyList(),
|
||||
val displayArtist: String? = null,
|
||||
val displayTitle: String? = null,
|
||||
val offset: Long? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LyricsListResult(val structuredLyrics: List<StructuredLyrics> = emptyList())
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.novasonic.app.data
|
||||
|
||||
import android.content.Context
|
||||
import com.novasonic.app.api.SubsonicClient
|
||||
import com.novasonic.app.api.model.*
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
object ClientHolder {
|
||||
@Volatile private var client: SubsonicClient? = null
|
||||
@Volatile private var lastCreds: PrefsStore.Credentials? = null
|
||||
|
||||
fun get(creds: PrefsStore.Credentials): SubsonicClient {
|
||||
if (creds != lastCreds) {
|
||||
client?.close()
|
||||
client = SubsonicClient(creds.serverUrl, creds.username, creds.password)
|
||||
lastCreds = creds
|
||||
}
|
||||
return client!!
|
||||
}
|
||||
|
||||
fun invalidate() {
|
||||
client?.close()
|
||||
client = null
|
||||
lastCreds = null
|
||||
}
|
||||
}
|
||||
|
||||
class MusicRepository(private val context: Context) {
|
||||
|
||||
private val prefs = PrefsStore(context)
|
||||
|
||||
private suspend fun api(): SubsonicClient =
|
||||
ClientHolder.get(prefs.credentials.first())
|
||||
|
||||
suspend fun ping() = api().ping()
|
||||
suspend fun getRecentAlbums(size: Int = 20) = api().getAlbumList("newest", size)
|
||||
suspend fun getFrequentAlbums(size: Int = 20) = api().getAlbumList("frequent", size)
|
||||
suspend fun getRecentlyPlayedAlbums(size: Int = 20) = api().getAlbumList("recent", size)
|
||||
suspend fun getRandomAlbums(size: Int = 20) = api().getAlbumList("random", size)
|
||||
suspend fun getAlphaAlbums(size: Int = 40, offset: Int = 0) =
|
||||
api().getAlbumList("alphabeticalByName", size, offset)
|
||||
suspend fun getAlbum(id: String) = api().getAlbum(id)
|
||||
suspend fun getArtists() = api().getArtists()
|
||||
suspend fun getArtist(id: String) = api().getArtist(id)
|
||||
suspend fun getSong(id: String) = api().getSong(id)
|
||||
suspend fun search(query: String) = api().search(query)
|
||||
suspend fun getPlaylists() = api().getPlaylists()
|
||||
suspend fun getPlaylist(id: String) = api().getPlaylist(id)
|
||||
suspend fun getStarred() = api().getStarred()
|
||||
suspend fun getRandomSongs() = api().getRandomSongs()
|
||||
suspend fun star(id: String) = api().star(id)
|
||||
suspend fun unstar(id: String) = api().unstar(id)
|
||||
suspend fun scrobble(id: String) = api().scrobble(id)
|
||||
suspend fun getArtistInfo(id: String) = api().getArtistInfo(id)
|
||||
suspend fun getTopSongs(artistName: String) = api().getTopSongs(artistName)
|
||||
suspend fun getGenres() = api().getGenres()
|
||||
suspend fun getSongsByGenre(genre: String) = api().getSongsByGenre(genre)
|
||||
suspend fun getLyrics(songId: String) = api().getLyrics(songId)
|
||||
suspend fun addSongToPlaylist(playlistId: String, songId: String) = api().addSongToPlaylist(playlistId, songId)
|
||||
suspend fun createPlaylist(name: String, songIds: List<String>) = api().createPlaylist(name, songIds)
|
||||
|
||||
fun coverArtUrl(id: String?, size: Int = 300): String? {
|
||||
val creds = lastCredentials ?: return null
|
||||
return SubsonicClient(creds.serverUrl, creds.username, creds.password)
|
||||
.coverArtUrl(id ?: return null, size)
|
||||
}
|
||||
|
||||
fun streamUrl(id: String): String? {
|
||||
val creds = lastCredentials ?: return null
|
||||
return SubsonicClient(creds.serverUrl, creds.username, creds.password).streamUrl(id)
|
||||
}
|
||||
|
||||
var lastCredentials: PrefsStore.Credentials? = null
|
||||
|
||||
fun credentialsFlow() = prefs.credentials
|
||||
fun appSettingsFlow() = prefs.appSettings
|
||||
suspend fun getBackendUrl(): String = prefs.appSettings.first().backendUrl
|
||||
|
||||
/** Finds Navidrome albums for a given artist name (for iTunes cross-reference). */
|
||||
suspend fun getNdAlbumsForArtist(artistName: String): List<com.novasonic.app.api.model.Album> {
|
||||
// Try exact artist match first, then fall back to album search
|
||||
val artistResult = api().search(artistName, albumCount = 0, artistCount = 10, songCount = 0)
|
||||
val ndArtist = artistResult.artist.find { a ->
|
||||
a.name.equals(artistName, ignoreCase = true) ||
|
||||
a.name.contains(artistName, ignoreCase = true) ||
|
||||
artistName.contains(a.name, ignoreCase = true)
|
||||
}
|
||||
return if (ndArtist != null) {
|
||||
runCatching { api().getArtist(ndArtist.id).album }.getOrDefault(emptyList())
|
||||
} else {
|
||||
api().search(artistName, albumCount = 50, artistCount = 0, songCount = 0).album
|
||||
.filter { it.artist?.contains(artistName, ignoreCase = true) == true }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveCredentials(serverUrl: String, username: String, password: String) {
|
||||
ClientHolder.invalidate()
|
||||
prefs.save(serverUrl, username, password)
|
||||
}
|
||||
|
||||
suspend fun saveTheme(themeId: String) = prefs.saveTheme(themeId)
|
||||
|
||||
suspend fun saveAppSettings(settings: PrefsStore.AppSettings) {
|
||||
val urlChanged = settings.serverUrl != lastCredentials?.serverUrl ||
|
||||
settings.username != lastCredentials?.username ||
|
||||
settings.password != lastCredentials?.password
|
||||
if (urlChanged) ClientHolder.invalidate()
|
||||
prefs.saveAll(settings)
|
||||
}
|
||||
|
||||
suspend fun getScrobbleDelayMs(durationMs: Long): Long {
|
||||
val mode = prefs.appSettings.first().scrobbleMode
|
||||
return when (mode) {
|
||||
"15s" -> 15_000L
|
||||
"30s" -> 30_000L
|
||||
"third" -> (durationMs / 3).coerceAtLeast(15_000L)
|
||||
"two_thirds" -> (durationMs * 2 / 3).coerceAtLeast(15_000L)
|
||||
"end" -> (durationMs - 5_000L).coerceAtLeast(15_000L)
|
||||
else -> 30_000L
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.novasonic.app.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.dataStore by preferencesDataStore("novasonic_prefs")
|
||||
|
||||
class PrefsStore(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
val KEY_SERVER = stringPreferencesKey("server_url")
|
||||
val KEY_USERNAME = stringPreferencesKey("username")
|
||||
val KEY_PASSWORD = stringPreferencesKey("password")
|
||||
val KEY_LASTFM_KEY = stringPreferencesKey("lastfm_key")
|
||||
val KEY_BACKEND_URL = stringPreferencesKey("backend_url")
|
||||
val KEY_SCROBBLE_MODE = stringPreferencesKey("scrobble_mode") // 15s|30s|third|two_thirds|end
|
||||
val KEY_SHOW_COUNT = booleanPreferencesKey("show_play_count")
|
||||
val KEY_THEME = stringPreferencesKey("theme")
|
||||
}
|
||||
|
||||
data class Credentials(val serverUrl: String, val username: String, val password: String) {
|
||||
val isValid get() = serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank()
|
||||
}
|
||||
|
||||
data class AppSettings(
|
||||
val serverUrl: String = "",
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val lastFmKey: String = "",
|
||||
val backendUrl: String = "",
|
||||
val scrobbleMode: String = "30s",
|
||||
val showPlayCount: Boolean = false,
|
||||
val theme: String = "dark-ocean",
|
||||
)
|
||||
|
||||
val credentials: Flow<Credentials> = context.dataStore.data.map { prefs ->
|
||||
Credentials(
|
||||
serverUrl = prefs[KEY_SERVER] ?: "",
|
||||
username = prefs[KEY_USERNAME] ?: "",
|
||||
password = prefs[KEY_PASSWORD] ?: "",
|
||||
)
|
||||
}
|
||||
|
||||
val appSettings: Flow<AppSettings> = context.dataStore.data.map { prefs ->
|
||||
AppSettings(
|
||||
serverUrl = prefs[KEY_SERVER] ?: "",
|
||||
username = prefs[KEY_USERNAME] ?: "",
|
||||
password = prefs[KEY_PASSWORD] ?: "",
|
||||
lastFmKey = prefs[KEY_LASTFM_KEY] ?: "",
|
||||
backendUrl = prefs[KEY_BACKEND_URL] ?: "",
|
||||
scrobbleMode = prefs[KEY_SCROBBLE_MODE] ?: "30s",
|
||||
showPlayCount = prefs[KEY_SHOW_COUNT] ?: false,
|
||||
theme = prefs[KEY_THEME] ?: "dark-ocean",
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun save(serverUrl: String, username: String, password: String) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[KEY_SERVER] = serverUrl.trimEnd('/')
|
||||
prefs[KEY_USERNAME] = username
|
||||
prefs[KEY_PASSWORD] = password
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveAll(settings: AppSettings) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[KEY_SERVER] = settings.serverUrl.trimEnd('/')
|
||||
prefs[KEY_USERNAME] = settings.username
|
||||
prefs[KEY_PASSWORD] = settings.password
|
||||
prefs[KEY_LASTFM_KEY] = settings.lastFmKey
|
||||
prefs[KEY_BACKEND_URL] = settings.backendUrl
|
||||
prefs[KEY_SCROBBLE_MODE] = settings.scrobbleMode
|
||||
prefs[KEY_SHOW_COUNT] = settings.showPlayCount
|
||||
prefs[KEY_THEME] = settings.theme
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveTheme(themeId: String) {
|
||||
context.dataStore.edit { prefs -> prefs[KEY_THEME] = themeId }
|
||||
}
|
||||
|
||||
suspend fun clear() {
|
||||
context.dataStore.edit { it.clear() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package com.novasonic.app.player
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.media3.common.*
|
||||
import androidx.media3.datasource.DataSourceBitmapLoader
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.datasource.DefaultHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.session.*
|
||||
import com.google.common.collect.ImmutableList
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
import com.google.common.util.concurrent.SettableFuture
|
||||
import com.novasonic.app.data.MusicRepository
|
||||
import com.novasonic.app.ui.MainActivity
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class MusicService : MediaLibraryService() {
|
||||
|
||||
companion object {
|
||||
// Android Auto root IDs
|
||||
const val ROOT_ID = "ROOT"
|
||||
const val RECENT_ID = "RECENT"
|
||||
const val ALBUMS_ID = "ALBUMS"
|
||||
const val ARTISTS_ID = "ARTISTS"
|
||||
const val PLAYLISTS_ID = "PLAYLISTS"
|
||||
const val ITEM_PREFIX_ALBUM = "album:"
|
||||
const val ITEM_PREFIX_ARTIST = "artist:"
|
||||
const val ITEM_PREFIX_PL = "playlist:"
|
||||
}
|
||||
|
||||
private lateinit var player: ExoPlayer
|
||||
private lateinit var session: MediaLibrarySession
|
||||
private lateinit var repo: MusicRepository
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
repo = MusicRepository(applicationContext)
|
||||
|
||||
val httpFactory = DefaultHttpDataSource.Factory()
|
||||
.setUserAgent("NovaSonic-Android/1.0")
|
||||
.setConnectTimeoutMs(15_000)
|
||||
.setReadTimeoutMs(15_000)
|
||||
.setAllowCrossProtocolRedirects(true)
|
||||
|
||||
val dataFactory = DefaultDataSource.Factory(this, httpFactory)
|
||||
|
||||
val executor = Executors.newSingleThreadExecutor()
|
||||
val bitmapLoader = CacheBitmapLoader(
|
||||
DataSourceBitmapLoader(MoreExecutors.listeningDecorator(executor), dataFactory)
|
||||
)
|
||||
|
||||
val audioAttr = AudioAttributes.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.build()
|
||||
|
||||
player = ExoPlayer.Builder(this)
|
||||
.setMediaSourceFactory(DefaultMediaSourceFactory(dataFactory))
|
||||
.setAudioAttributes(audioAttr, true)
|
||||
.setHandleAudioBecomingNoisy(true)
|
||||
.setWakeMode(C.WAKE_MODE_NETWORK)
|
||||
.build()
|
||||
|
||||
val activityIntent = PendingIntent.getActivity(
|
||||
this, 0,
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
session = MediaLibrarySession.Builder(this, player, LibraryCallback())
|
||||
.setBitmapLoader(bitmapLoader)
|
||||
.setSessionActivity(activityIntent)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onGetSession(info: MediaSession.ControllerInfo) = session
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) { player.stop(); stopSelf() }
|
||||
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
session.release()
|
||||
player.release()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
// ── Android Auto / MediaBrowser library callbacks ─────────────────────────
|
||||
|
||||
private inner class LibraryCallback : MediaLibrarySession.Callback {
|
||||
|
||||
override fun onGetLibraryRoot(
|
||||
session: MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
params: LibraryParams?,
|
||||
): ListenableFuture<LibraryResult<MediaItem>> {
|
||||
val isAuto = params?.isRecent == true || isAndroidAuto(browser)
|
||||
val root = MediaItem.Builder()
|
||||
.setMediaId(ROOT_ID)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle("NovaSonic")
|
||||
.setIsPlayable(false)
|
||||
.setIsBrowsable(true)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED)
|
||||
.build()
|
||||
).build()
|
||||
return Futures.immediateFuture(LibraryResult.ofItem(root, null))
|
||||
}
|
||||
|
||||
override fun onGetChildren(
|
||||
session: MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
parentId: String,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
params: LibraryParams?,
|
||||
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||
val future = scope.async { buildChildren(parentId) }
|
||||
val executor = MoreExecutors.directExecutor()
|
||||
val settable = SettableFuture.create<LibraryResult<ImmutableList<MediaItem>>>()
|
||||
scope.launch {
|
||||
try {
|
||||
settable.set(LibraryResult.ofItemList(future.await(), null))
|
||||
} catch (e: Exception) {
|
||||
settable.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_UNKNOWN))
|
||||
}
|
||||
}
|
||||
return settable
|
||||
}
|
||||
|
||||
override fun onGetItem(
|
||||
session: MediaLibrarySession,
|
||||
browser: MediaSession.ControllerInfo,
|
||||
mediaId: String,
|
||||
): ListenableFuture<LibraryResult<MediaItem>> {
|
||||
val settable = SettableFuture.create<LibraryResult<MediaItem>>()
|
||||
scope.launch {
|
||||
try {
|
||||
val item = when {
|
||||
mediaId.startsWith(ITEM_PREFIX_ALBUM) -> {
|
||||
val album = repo.getAlbum(mediaId.removePrefix(ITEM_PREFIX_ALBUM))
|
||||
album.toMediaItem(repo)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
if (item != null) settable.set(LibraryResult.ofItem(item, null))
|
||||
else settable.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE))
|
||||
} catch (e: Exception) {
|
||||
settable.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_UNKNOWN))
|
||||
}
|
||||
}
|
||||
return settable
|
||||
}
|
||||
|
||||
// When Android Auto selects a browsable item (album/playlist), load its songs.
|
||||
override fun onAddMediaItems(
|
||||
mediaSession: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
mediaItems: MutableList<MediaItem>,
|
||||
): ListenableFuture<MutableList<MediaItem>> {
|
||||
val settable = SettableFuture.create<MutableList<MediaItem>>()
|
||||
scope.launch {
|
||||
try {
|
||||
val resolved = mediaItems.flatMap { item ->
|
||||
when {
|
||||
item.mediaId.startsWith(ITEM_PREFIX_ALBUM) -> {
|
||||
val album = repo.getAlbum(item.mediaId.removePrefix(ITEM_PREFIX_ALBUM))
|
||||
album.song.map { song -> song.toMediaItem(repo) }
|
||||
}
|
||||
item.mediaId.startsWith(ITEM_PREFIX_PL) -> {
|
||||
val pl = repo.getPlaylist(item.mediaId.removePrefix(ITEM_PREFIX_PL))
|
||||
pl.entry.map { song -> song.toMediaItem(repo) }
|
||||
}
|
||||
else -> listOf(item)
|
||||
}
|
||||
}
|
||||
settable.set(resolved.toMutableList())
|
||||
} catch (e: Exception) {
|
||||
settable.set(mediaItems)
|
||||
}
|
||||
}
|
||||
return settable
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build Android Auto browsable tree ─────────────────────────────────────
|
||||
|
||||
private suspend fun buildChildren(parentId: String): ImmutableList<MediaItem> {
|
||||
val items = when (parentId) {
|
||||
ROOT_ID -> listOf(
|
||||
browseFolder(RECENT_ID, "Recently Played", MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS),
|
||||
browseFolder(ALBUMS_ID, "Albums", MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS),
|
||||
browseFolder(ARTISTS_ID, "Artists", MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS),
|
||||
browseFolder(PLAYLISTS_ID, "Playlists", MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS),
|
||||
)
|
||||
RECENT_ID -> repo.getRecentlyPlayedAlbums(20).map { it.toMediaItem(repo) }
|
||||
ALBUMS_ID -> repo.getAlphaAlbums(40).map { it.toMediaItem(repo) }
|
||||
PLAYLISTS_ID -> repo.getPlaylists().map { it.toMediaItem() }
|
||||
ARTISTS_ID -> repo.getArtists().flatMap { idx -> idx.artist.map { it.toMediaItem() } }
|
||||
else -> when {
|
||||
parentId.startsWith(ITEM_PREFIX_ALBUM) -> {
|
||||
val album = repo.getAlbum(parentId.removePrefix(ITEM_PREFIX_ALBUM))
|
||||
album.song.map { it.toMediaItem(repo) }
|
||||
}
|
||||
parentId.startsWith(ITEM_PREFIX_ARTIST) -> {
|
||||
val artist = repo.getArtist(parentId.removePrefix(ITEM_PREFIX_ARTIST))
|
||||
artist.album.map { it.toMediaItem(repo) }
|
||||
}
|
||||
parentId.startsWith(ITEM_PREFIX_PL) -> {
|
||||
val pl = repo.getPlaylist(parentId.removePrefix(ITEM_PREFIX_PL))
|
||||
pl.entry.map { it.toMediaItem(repo) }
|
||||
}
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
return ImmutableList.copyOf(items)
|
||||
}
|
||||
|
||||
private fun browseFolder(id: String, title: String, mediaType: Int) =
|
||||
MediaItem.Builder()
|
||||
.setMediaId(id)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
.setIsPlayable(false)
|
||||
.setIsBrowsable(true)
|
||||
.setMediaType(mediaType)
|
||||
.build()
|
||||
).build()
|
||||
|
||||
private fun isAndroidAuto(info: MediaSession.ControllerInfo): Boolean =
|
||||
info.packageName.contains("android.auto", ignoreCase = true) ||
|
||||
info.packageName.contains("goog.android.projected", ignoreCase = true)
|
||||
}
|
||||
|
||||
// ── Extension: model → MediaItem ─────────────────────────────────────────────
|
||||
|
||||
fun com.novasonic.app.api.model.Song.toMediaItem(repo: MusicRepository): MediaItem {
|
||||
val artUri = coverArt?.let { repo.coverArtUrl(it, 512)?.let { url ->
|
||||
android.net.Uri.parse(url) } }
|
||||
val streamUri = repo.streamUrl(id)?.let { android.net.Uri.parse(it) }
|
||||
return MediaItem.Builder()
|
||||
.setMediaId(id)
|
||||
.setUri(streamUri)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
.setArtist(artist)
|
||||
.setAlbumTitle(album)
|
||||
.setArtworkUri(artUri)
|
||||
.setTrackNumber(track)
|
||||
.setIsPlayable(true)
|
||||
.setIsBrowsable(false)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
|
||||
.build()
|
||||
).build()
|
||||
}
|
||||
|
||||
fun com.novasonic.app.api.model.Album.toMediaItem(repo: MusicRepository): MediaItem {
|
||||
val artUri = coverArt?.let { repo.coverArtUrl(it, 300)?.let { url ->
|
||||
android.net.Uri.parse(url) } }
|
||||
return MediaItem.Builder()
|
||||
.setMediaId("${MusicService.ITEM_PREFIX_ALBUM}$id")
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(name)
|
||||
.setArtist(artist)
|
||||
.setArtworkUri(artUri)
|
||||
.setIsPlayable(true)
|
||||
.setIsBrowsable(true)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM)
|
||||
.build()
|
||||
).build()
|
||||
}
|
||||
|
||||
fun com.novasonic.app.api.model.Artist.toMediaItem(): MediaItem =
|
||||
MediaItem.Builder()
|
||||
.setMediaId("${MusicService.ITEM_PREFIX_ARTIST}$id")
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(name)
|
||||
.setIsPlayable(false)
|
||||
.setIsBrowsable(true)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_ARTIST)
|
||||
.build()
|
||||
).build()
|
||||
|
||||
fun com.novasonic.app.api.model.Playlist.toMediaItem(): MediaItem =
|
||||
MediaItem.Builder()
|
||||
.setMediaId("${MusicService.ITEM_PREFIX_PL}$id")
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(name)
|
||||
.setIsPlayable(true)
|
||||
.setIsBrowsable(true)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
|
||||
.build()
|
||||
).build()
|
||||
@@ -0,0 +1,202 @@
|
||||
package com.novasonic.app.player
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.data.MusicRepository
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
data class PlayerState(
|
||||
val isPlaying: Boolean = false,
|
||||
val isBuffering: Boolean = false,
|
||||
val currentSong: Song? = null,
|
||||
val currentIndex: Int = -1,
|
||||
val positionMs: Long = 0L,
|
||||
val durationMs: Long = 0L,
|
||||
val queue: List<Song> = emptyList(),
|
||||
val repeatMode: Int = Player.REPEAT_MODE_OFF,
|
||||
val shuffled: Boolean = false,
|
||||
val volume: Float = 1f,
|
||||
)
|
||||
|
||||
class PlayerController(private val context: Context, private val repo: MusicRepository) {
|
||||
|
||||
private val _state = MutableStateFlow(PlayerState())
|
||||
val state: StateFlow<PlayerState> = _state.asStateFlow()
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
private var controller: MediaController? = null
|
||||
private var queue: List<Song> = emptyList()
|
||||
private var originalQueue: List<Song> = emptyList()
|
||||
private var isManuallyShuffled = false
|
||||
private var tickerJob: Job? = null
|
||||
private var scrobbleJob: Job? = null
|
||||
|
||||
fun connect() {
|
||||
if (controller != null) return
|
||||
val token = SessionToken(context, ComponentName(context, MusicService::class.java))
|
||||
MediaController.Builder(context, token).buildAsync().also { future ->
|
||||
future.addListener({
|
||||
controller = runCatching { future.get() }.getOrNull()
|
||||
controller?.addListener(PlayerListener())
|
||||
syncState()
|
||||
}, context.mainExecutor)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Playback commands ─────────────────────────────────────────────────────
|
||||
|
||||
fun playSongs(songs: List<Song>, startIndex: Int = 0) {
|
||||
val ctrl = controller ?: return
|
||||
queue = songs
|
||||
originalQueue = songs
|
||||
isManuallyShuffled = false
|
||||
ctrl.shuffleModeEnabled = false
|
||||
val items = songs.map { it.toMediaItem(repo) }
|
||||
ctrl.setMediaItems(items, startIndex, 0L)
|
||||
ctrl.prepare()
|
||||
ctrl.play()
|
||||
syncState()
|
||||
}
|
||||
|
||||
fun play() = controller?.play()
|
||||
fun pause() = controller?.pause()
|
||||
fun togglePlay() = controller?.let { if (it.isPlaying) it.pause() else it.play() }
|
||||
fun seekTo(ms: Long) = controller?.seekTo(ms)
|
||||
fun skipNext() = controller?.seekToNextMediaItem()
|
||||
fun skipPrev() = controller?.seekToPreviousMediaItem()
|
||||
fun seekToItem(index: Int) = controller?.seekToDefaultPosition(index)
|
||||
|
||||
fun setVolume(v: Float) {
|
||||
val clamped = v.coerceIn(0f, 1f)
|
||||
controller?.volume = clamped
|
||||
_state.update { it.copy(volume = clamped) }
|
||||
}
|
||||
|
||||
fun insertNext(song: Song) {
|
||||
val ctrl = controller ?: return
|
||||
val idx = (ctrl.currentMediaItemIndex + 1).coerceAtMost(queue.size)
|
||||
queue = queue.toMutableList().apply { add(idx, song) }
|
||||
originalQueue = originalQueue + song
|
||||
ctrl.addMediaItem(idx, song.toMediaItem(repo))
|
||||
}
|
||||
|
||||
fun addToQueueEnd(song: Song) {
|
||||
val ctrl = controller ?: return
|
||||
queue = queue + song
|
||||
originalQueue = originalQueue + song
|
||||
ctrl.addMediaItem(song.toMediaItem(repo))
|
||||
}
|
||||
|
||||
fun toggleRepeat() {
|
||||
val ctrl = controller ?: return
|
||||
ctrl.repeatMode = when (ctrl.repeatMode) {
|
||||
Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ALL
|
||||
Player.REPEAT_MODE_ALL -> Player.REPEAT_MODE_ONE
|
||||
else -> Player.REPEAT_MODE_OFF
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleShuffle() {
|
||||
val ctrl = controller ?: return
|
||||
val currentIdx = ctrl.currentMediaItemIndex
|
||||
|
||||
if (!isManuallyShuffled) {
|
||||
// Turn ON: shuffle everything after the current song
|
||||
isManuallyShuffled = true
|
||||
val played = queue.take(currentIdx + 1)
|
||||
val remaining = queue.drop(currentIdx + 1).shuffled()
|
||||
queue = played + remaining
|
||||
for (i in ctrl.mediaItemCount - 1 downTo currentIdx + 1) ctrl.removeMediaItem(i)
|
||||
remaining.forEach { s -> ctrl.addMediaItem(s.toMediaItem(repo)) }
|
||||
} else {
|
||||
// Turn OFF: restore original order from the current song's position
|
||||
isManuallyShuffled = false
|
||||
val currentSong = queue.getOrNull(currentIdx)
|
||||
val origIdx = originalQueue.indexOfFirst { it.id == currentSong?.id }.coerceAtLeast(0)
|
||||
val restOfOriginal = originalQueue.drop(origIdx + 1)
|
||||
queue = originalQueue.take(origIdx + 1) + restOfOriginal
|
||||
for (i in ctrl.mediaItemCount - 1 downTo currentIdx + 1) ctrl.removeMediaItem(i)
|
||||
restOfOriginal.forEach { s -> ctrl.addMediaItem(s.toMediaItem(repo)) }
|
||||
}
|
||||
syncState()
|
||||
}
|
||||
|
||||
fun addToQueue(song: Song) = addToQueueEnd(song)
|
||||
|
||||
// ── Ticker ────────────────────────────────────────────────────────────────
|
||||
|
||||
private fun startTicker() {
|
||||
tickerJob?.cancel()
|
||||
tickerJob = scope.launch {
|
||||
while (isActive) {
|
||||
delay(500)
|
||||
val ctrl = controller ?: break
|
||||
_state.update { it.copy(positionMs = ctrl.currentPosition) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopTicker() {
|
||||
tickerJob?.cancel()
|
||||
tickerJob = null
|
||||
}
|
||||
|
||||
// ── Scrobbling ────────────────────────────────────────────────────────────
|
||||
|
||||
private fun scheduleScrobble(song: Song, durationMs: Long) {
|
||||
scrobbleJob?.cancel()
|
||||
scrobbleJob = scope.launch {
|
||||
val delayMs = runCatching { repo.getScrobbleDelayMs(durationMs) }.getOrDefault(30_000L)
|
||||
delay(delayMs)
|
||||
runCatching { repo.scrobble(song.id) }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal ──────────────────────────────────────────────────────────────
|
||||
|
||||
private fun syncState() {
|
||||
val ctrl = controller ?: return
|
||||
val idx = ctrl.currentMediaItemIndex
|
||||
_state.value = PlayerState(
|
||||
isPlaying = ctrl.isPlaying,
|
||||
isBuffering = ctrl.playbackState == Player.STATE_BUFFERING,
|
||||
currentSong = queue.getOrNull(idx),
|
||||
currentIndex = idx,
|
||||
positionMs = ctrl.currentPosition,
|
||||
durationMs = ctrl.duration.coerceAtLeast(0),
|
||||
queue = queue,
|
||||
repeatMode = ctrl.repeatMode,
|
||||
shuffled = isManuallyShuffled,
|
||||
volume = ctrl.volume,
|
||||
)
|
||||
}
|
||||
|
||||
private inner class PlayerListener : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
if (isPlaying) startTicker() else stopTicker()
|
||||
syncState()
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(state: Int) = syncState()
|
||||
|
||||
override fun onMediaItemTransition(item: MediaItem?, reason: Int) {
|
||||
syncState()
|
||||
val ctrl = controller ?: return
|
||||
val song = queue.getOrNull(ctrl.currentMediaItemIndex) ?: return
|
||||
val dur = ctrl.duration.coerceAtLeast(0)
|
||||
scheduleScrobble(song, dur)
|
||||
}
|
||||
|
||||
override fun onShuffleModeEnabledChanged(enabled: Boolean) = syncState()
|
||||
override fun onRepeatModeChanged(mode: Int) = syncState()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
package com.novasonic.app.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.widget.Toast
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.*
|
||||
import androidx.navigation.compose.*
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.ui.component.MiniPlayer
|
||||
import com.novasonic.app.ui.component.PlaylistPickerSheet
|
||||
import com.novasonic.app.ui.component.SongContextSheet
|
||||
import com.novasonic.app.ui.component.SongInfoSheet
|
||||
import com.novasonic.app.ui.screen.*
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private enum class TopLevel(val route: String, val icon: ImageVector, val label: String) {
|
||||
HOME("home", Icons.Default.Home, "Home"),
|
||||
LIBRARY("library", Icons.Default.LibraryMusic, "Bibliothek"),
|
||||
ERKUNDEN("erkunden", Icons.Default.Explore, "Erkunden"),
|
||||
STARRED("starred", Icons.Default.Favorite, "Favoriten"),
|
||||
SETTINGS("settings", Icons.Default.Settings, "Einstellungen"),
|
||||
}
|
||||
|
||||
private val NavBg = Color(0xF7080814)
|
||||
|
||||
@Composable
|
||||
fun AppNavigation() {
|
||||
val context = LocalContext.current
|
||||
val app = context.app
|
||||
val repo = app.repository
|
||||
val player = app.playerController
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
repo.credentialsFlow().collect { creds -> repo.lastCredentials = creds }
|
||||
}
|
||||
|
||||
// Apply saved theme whenever settings change
|
||||
LaunchedEffect(Unit) {
|
||||
repo.appSettingsFlow().collect { settings -> applyNsTheme(settings.theme) }
|
||||
}
|
||||
|
||||
val playerState by player.state.collectAsStateWithLifecycle()
|
||||
val navController = rememberNavController()
|
||||
var showNowPlaying by remember { mutableStateOf(false) }
|
||||
var contextMenuSong by remember { mutableStateOf<Song?>(null) }
|
||||
var playlistPickerSong by remember { mutableStateOf<Song?>(null) }
|
||||
var infoSheetSong by remember { mutableStateOf<Song?>(null) }
|
||||
|
||||
fun coverUrl(coverId: String?) = repo.coverArtUrl(coverId, 600)
|
||||
fun heroCoverUrl(coverId: String?) = repo.coverArtUrl(coverId, 1000)
|
||||
|
||||
fun starToggle(songId: String, currentlyStarred: Boolean) {
|
||||
scope.launch { runCatching { if (currentlyStarred) repo.unstar(songId) else repo.star(songId) } }
|
||||
}
|
||||
|
||||
fun showContextMenu(song: Song) { contextMenuSong = song }
|
||||
|
||||
val currentBackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = currentBackEntry?.destination?.route
|
||||
val isTopLevel = TopLevel.entries.any { it.route == currentRoute }
|
||||
|
||||
val bgGlow = Brush.radialGradient(
|
||||
colors = listOf(Color(0x0AE8A020), Color(0x00000000)),
|
||||
center = Offset.Unspecified, radius = 900f,
|
||||
)
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Scaffold(
|
||||
containerColor = NsBackground,
|
||||
modifier = Modifier.background(NsBackground).background(bgGlow),
|
||||
bottomBar = {
|
||||
if (isTopLevel) {
|
||||
Column {
|
||||
AnimatedVisibility(
|
||||
visible = playerState.currentSong != null,
|
||||
enter = slideInVertically { it } + fadeIn(tween(220)),
|
||||
exit = slideOutVertically { it } + fadeOut(tween(180)),
|
||||
) {
|
||||
MiniPlayer(
|
||||
state = playerState,
|
||||
coverUrl = coverUrl(playerState.currentSong?.coverArt),
|
||||
onTap = { showNowPlaying = true },
|
||||
onTogglePlay = { player.togglePlay() },
|
||||
onSkipPrev = { player.skipPrev() },
|
||||
onSkipNext = { player.skipNext() },
|
||||
)
|
||||
}
|
||||
NavigationBar(containerColor = NavBg, tonalElevation = 0.dp) {
|
||||
TopLevel.entries.forEach { dest ->
|
||||
NavigationBarItem(
|
||||
selected = currentRoute == dest.route,
|
||||
onClick = {
|
||||
navController.navigate(dest.route) {
|
||||
popUpTo(navController.graph.startDestinationId) { saveState = true }
|
||||
launchSingleTop = true; restoreState = true
|
||||
}
|
||||
},
|
||||
icon = { Icon(dest.icon, contentDescription = dest.label) },
|
||||
label = {
|
||||
Text(dest.label, style = TextStyle(
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 10.sp))
|
||||
},
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = NsAccent,
|
||||
selectedTextColor = NsAccent,
|
||||
indicatorColor = NsAccent.copy(alpha = 0.15f),
|
||||
unselectedIconColor = Color(0xFF8888AA),
|
||||
unselectedTextColor = Color(0xFF8888AA),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = TopLevel.HOME.route,
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
enterTransition = {
|
||||
slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(280, easing = FastOutSlowInEasing)) +
|
||||
fadeIn(tween(200))
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutHorizontally(targetOffsetX = { -it / 3 }, animationSpec = tween(280, easing = FastOutSlowInEasing)) +
|
||||
fadeOut(tween(180))
|
||||
},
|
||||
popEnterTransition = {
|
||||
slideInHorizontally(initialOffsetX = { -it / 3 }, animationSpec = tween(280, easing = FastOutSlowInEasing)) +
|
||||
fadeIn(tween(200))
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(280, easing = FastOutSlowInEasing)) +
|
||||
fadeOut(tween(180))
|
||||
},
|
||||
) {
|
||||
composable(TopLevel.HOME.route) {
|
||||
HomeScreen(
|
||||
coverUrl = { album -> coverUrl(album.coverArt) },
|
||||
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
|
||||
onAlbumPlay = { album ->
|
||||
scope.launch { runCatching {
|
||||
val full = repo.getAlbum(album.id)
|
||||
if (full.song.isNotEmpty()) player.playSongs(full.song, 0)
|
||||
}}
|
||||
},
|
||||
onGenreClick = { genre -> navController.navigate("genre/${encodeRoute(genre.value)}") },
|
||||
onGenrePlay = { genre ->
|
||||
scope.launch { runCatching {
|
||||
val songs = repo.getSongsByGenre(genre.value)
|
||||
if (songs.isNotEmpty()) player.playSongs(songs.shuffled(), 0)
|
||||
}}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(TopLevel.LIBRARY.route) {
|
||||
LibraryScreen(
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
|
||||
onArtistClick = { artist -> navController.navigate("artist/${artist.id}") },
|
||||
onPlaylistClick = { pl -> navController.navigate("playlist/${pl.id}") },
|
||||
)
|
||||
}
|
||||
composable(TopLevel.ERKUNDEN.route) {
|
||||
ExploreScreen(
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
playerState = playerState,
|
||||
onSongPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
|
||||
onArtistClick = { artist -> navController.navigate("artist/${artist.id}") },
|
||||
onOpenImporter = { navController.navigate("importer") },
|
||||
)
|
||||
}
|
||||
composable("importer") {
|
||||
ImporterScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
composable(TopLevel.STARRED.route) {
|
||||
StarredScreen(
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
playerState = playerState,
|
||||
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
|
||||
onShowContextMenu = ::showContextMenu,
|
||||
)
|
||||
}
|
||||
composable(TopLevel.SETTINGS.route) { SettingsScreen() }
|
||||
|
||||
composable(
|
||||
"album/{albumId}",
|
||||
arguments = listOf(navArgument("albumId") { type = NavType.StringType }),
|
||||
) {
|
||||
AlbumScreen(
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
playerState = playerState,
|
||||
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
|
||||
onShowContextMenu = ::showContextMenu,
|
||||
onArtistClick = { artistId -> navController.navigate("artist/$artistId") },
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
"artist/{artistId}",
|
||||
arguments = listOf(navArgument("artistId") { type = NavType.StringType }),
|
||||
) {
|
||||
ArtistDetailScreen(
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
playerState = playerState,
|
||||
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
|
||||
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
|
||||
onShowContextMenu = ::showContextMenu,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
"playlist/{playlistId}",
|
||||
arguments = listOf(navArgument("playlistId") { type = NavType.StringType }),
|
||||
) {
|
||||
PlaylistDetailScreen(
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
playerState = playerState,
|
||||
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
|
||||
onShowContextMenu = ::showContextMenu,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
"genre/{genreName}",
|
||||
arguments = listOf(navArgument("genreName") { type = NavType.StringType }),
|
||||
) { backEntry ->
|
||||
val genreName = backEntry.arguments?.getString("genreName") ?: ""
|
||||
GenreSongsScreen(
|
||||
genreName = genreName,
|
||||
coverUrl = { id -> coverUrl(id) },
|
||||
playerState = playerState,
|
||||
onPlay = { songs, idx -> player.playSongs(songs, idx) },
|
||||
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
|
||||
onShowContextMenu = ::showContextMenu,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dismiss NowPlaying with hardware back button
|
||||
BackHandler(enabled = showNowPlaying) { showNowPlaying = false }
|
||||
|
||||
// NowPlaying overlay — outside Scaffold so WindowInsets are not double-consumed
|
||||
AnimatedVisibility(
|
||||
visible = showNowPlaying,
|
||||
enter = slideInVertically { it } + fadeIn(tween(220)),
|
||||
exit = slideOutVertically { it } + fadeOut(tween(180)),
|
||||
) {
|
||||
NowPlayingScreen(
|
||||
state = playerState,
|
||||
coverUrl = heroCoverUrl(playerState.currentSong?.coverArt),
|
||||
queueCoverUrl = { id -> coverUrl(id) },
|
||||
controller = player,
|
||||
onDismiss = { showNowPlaying = false },
|
||||
onStarToggle = {
|
||||
playerState.currentSong?.let { s -> starToggle(s.id, s.starred != null) }
|
||||
},
|
||||
onShowOptions = {
|
||||
playerState.currentSong?.let { s -> contextMenuSong = s }
|
||||
},
|
||||
onArtistClick = playerState.currentSong?.artistId?.let { artistId ->
|
||||
{
|
||||
showNowPlaying = false
|
||||
navController.navigate("artist/$artistId")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Song context menu bottom sheet
|
||||
contextMenuSong?.let { song ->
|
||||
SongContextSheet(
|
||||
song = song,
|
||||
isStarred = song.starred != null,
|
||||
onDismiss = { contextMenuSong = null },
|
||||
onPlay = { player.playSongs(listOf(song), 0) },
|
||||
onInsertNext = { player.insertNext(song) },
|
||||
onAddToQueue = { player.addToQueueEnd(song) },
|
||||
onStarToggle = { starToggle(song.id, song.starred != null) },
|
||||
onAddToPlaylist = {
|
||||
playlistPickerSong = song
|
||||
contextMenuSong = null
|
||||
},
|
||||
onGoToAlbum = song.albumId?.let { albumId ->
|
||||
{ navController.navigate("album/$albumId") }
|
||||
},
|
||||
onShowInfo = {
|
||||
infoSheetSong = song
|
||||
contextMenuSong = null
|
||||
},
|
||||
onDownload = {
|
||||
downloadSong(context, song, repo.streamUrl(song.id) ?: "")
|
||||
contextMenuSong = null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Playlist picker sheet
|
||||
playlistPickerSong?.let { song ->
|
||||
PlaylistPickerSheet(
|
||||
song = song,
|
||||
loadPlaylists = { repo.getPlaylists() },
|
||||
onAdd = { pl ->
|
||||
playlistPickerSong = null
|
||||
scope.launch {
|
||||
runCatching { repo.addSongToPlaylist(pl.id, song.id) }
|
||||
.onSuccess { Toast.makeText(context, "✓ ${song.title} → ${pl.name}", Toast.LENGTH_SHORT).show() }
|
||||
.onFailure { Toast.makeText(context, "Fehler: ${it.message}", Toast.LENGTH_SHORT).show() }
|
||||
}
|
||||
},
|
||||
onCreate = { name ->
|
||||
playlistPickerSong = null
|
||||
scope.launch {
|
||||
runCatching { repo.createPlaylist(name, listOf(song.id)) }
|
||||
.onSuccess { Toast.makeText(context, "✓ Playlist \"$name\" erstellt", Toast.LENGTH_SHORT).show() }
|
||||
.onFailure { Toast.makeText(context, "Fehler: ${it.message}", Toast.LENGTH_SHORT).show() }
|
||||
}
|
||||
},
|
||||
onDismiss = { playlistPickerSong = null },
|
||||
)
|
||||
}
|
||||
|
||||
// Song info sheet
|
||||
infoSheetSong?.let { song ->
|
||||
SongInfoSheet(song = song, onDismiss = { infoSheetSong = null })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadSong(context: Context, song: Song, streamUrl: String) {
|
||||
if (streamUrl.isBlank()) {
|
||||
Toast.makeText(context, "Stream-URL nicht verfügbar", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val safe = (song.title + "." + (song.suffix ?: "mp3"))
|
||||
.replace(Regex("[/\\\\:*?\"<>|]"), "_")
|
||||
val request = DownloadManager.Request(Uri.parse(streamUrl))
|
||||
.setTitle(song.title)
|
||||
.setDescription(song.artist ?: "")
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
.setDestinationInExternalPublicDir(Environment.DIRECTORY_MUSIC, safe)
|
||||
.setMimeType(song.contentType ?: "audio/mpeg")
|
||||
val dm = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
dm.enqueue(request)
|
||||
Toast.makeText(context, "Download gestartet: ${song.title}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun encodeRoute(s: String) = java.net.URLEncoder.encode(s, "UTF-8")
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.novasonic.app.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.data.PrefsStore
|
||||
import com.novasonic.app.ui.screen.LoginScreen
|
||||
import com.novasonic.app.ui.theme.NovaSonicTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
NovaSonicTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
NovaSonicRoot()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NovaSonicRoot() {
|
||||
val context = LocalContext.current
|
||||
val prefs = remember { PrefsStore(context) }
|
||||
val creds by prefs.credentials.collectAsStateWithLifecycle(
|
||||
initialValue = PrefsStore.Credentials("", "", ""),
|
||||
)
|
||||
|
||||
var forceLogin by remember { mutableStateOf(false) }
|
||||
|
||||
if (!creds.isValid || forceLogin) {
|
||||
LoginScreen(onLoggedIn = { forceLogin = false })
|
||||
} else {
|
||||
AppNavigation()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.novasonic.app.ui.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Album
|
||||
import com.novasonic.app.ui.theme.*
|
||||
|
||||
private val cardBorder = Color(0x12FFFFFF) // rgba(255,255,255,0.07)
|
||||
|
||||
@Composable
|
||||
fun AlbumCard(
|
||||
album: Album,
|
||||
coverUrl: String?,
|
||||
width: Dp = 145.dp,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(width)
|
||||
.clip(shape)
|
||||
.background(NsSurface)
|
||||
.border(1.dp, cardBorder, shape)
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = coverUrl,
|
||||
contentDescription = album.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
Text(
|
||||
text = album.name,
|
||||
fontFamily = Outfit,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 12.sp,
|
||||
color = NsOnSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (!album.artist.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = album.artist,
|
||||
fontFamily = Outfit,
|
||||
fontSize = 11.sp,
|
||||
color = NsOnSurface2,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package com.novasonic.app.ui.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.ItunesItem
|
||||
import com.novasonic.app.ui.theme.*
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ItunesDownloadSheet(
|
||||
item: ItunesItem,
|
||||
onDownload: (type: String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val hasAlbum = !item.collectionName.isNullOrBlank()
|
||||
val hasArtist = !item.artistName.isNullOrBlank()
|
||||
|
||||
var selected by remember {
|
||||
mutableStateOf(if (item.trackName != null) "track" else if (hasAlbum) "album" else "artist")
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
containerColor = NsSurface,
|
||||
dragHandle = { BottomSheetDefaults.DragHandle(color = NsOnSurface2.copy(0.3f)) },
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp)
|
||||
.navigationBarsPadding()
|
||||
.padding(bottom = 24.dp),
|
||||
) {
|
||||
// ── Header ────────────────────────────────────────────────────────
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val art = item.artworkUrl100?.replace("100x100bb", "80x80bb")
|
||||
AsyncImage(
|
||||
model = art, contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
item.trackName ?: item.collectionName ?: item.artistName ?: "—",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 15.sp, color = NsOnSurface,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (hasArtist) {
|
||||
Text(
|
||||
item.artistName!!,
|
||||
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (hasAlbum && item.trackName != null) {
|
||||
Text(
|
||||
item.collectionName!!,
|
||||
fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = NsOnSurface2.copy(0.6f),
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(
|
||||
"Was herunterladen?",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 12.sp, color = NsOnSurface2,
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
// ── Options ───────────────────────────────────────────────────────
|
||||
if (item.trackName != null) {
|
||||
DownloadTypeRow(
|
||||
icon = Icons.Default.MusicNote,
|
||||
title = "Nur dieser Titel",
|
||||
subtitle = item.trackName!!,
|
||||
selected = selected == "track",
|
||||
onClick = { selected = "track" },
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
if (hasAlbum) {
|
||||
DownloadTypeRow(
|
||||
icon = Icons.Default.Album,
|
||||
title = "Ganzes Album",
|
||||
subtitle = "\"${item.collectionName}\" herunterladen",
|
||||
selected = selected == "album",
|
||||
onClick = { selected = "album" },
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
if (hasArtist) {
|
||||
DownloadTypeRow(
|
||||
icon = Icons.Default.Person,
|
||||
title = "Ganzer Künstler",
|
||||
subtitle = "Alle Songs von ${item.artistName}",
|
||||
selected = selected == "artist",
|
||||
onClick = { selected = "artist" },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
Button(
|
||||
onClick = { onDownload(selected) },
|
||||
modifier = Modifier.fillMaxWidth().height(50.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
|
||||
) {
|
||||
Icon(Icons.Default.Download, null, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Herunterladen", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DownloadTypeRow(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val borderColor = if (selected) NsAccent else NsBorder
|
||||
val bgColor = if (selected) NsAccent.copy(alpha = 0.08f) else NsSurface2
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(bgColor)
|
||||
.border(1.dp, borderColor, RoundedCornerShape(12.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (selected) NsAccent else NsOnSurface2.copy(0.12f)),
|
||||
) {
|
||||
Icon(icon, null, modifier = Modifier.size(18.dp),
|
||||
tint = if (selected) Color.White else NsOnSurface2)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp, color = if (selected) NsAccent else NsOnSurface)
|
||||
Text(subtitle, fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
if (selected) {
|
||||
Icon(Icons.Default.CheckCircle, null,
|
||||
tint = NsAccent, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.novasonic.app.ui.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.SkipNext
|
||||
import androidx.compose.material.icons.filled.SkipPrevious
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun MiniPlayer(
|
||||
state: PlayerState,
|
||||
coverUrl: String?,
|
||||
onTap: () -> Unit,
|
||||
onTogglePlay: () -> Unit,
|
||||
onSkipPrev: () -> Unit,
|
||||
onSkipNext: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val song = state.currentSong ?: return
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp)
|
||||
.padding(top = 6.dp, bottom = 4.dp)
|
||||
.shadow(12.dp, RoundedCornerShape(18.dp))
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(NsSurface),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onTap)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = coverUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = song.title,
|
||||
fontFamily = Outfit,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp,
|
||||
color = NsOnSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (!song.artist.isNullOrBlank()) {
|
||||
Text(
|
||||
text = song.artist!!,
|
||||
fontFamily = Outfit,
|
||||
fontSize = 12.sp,
|
||||
color = NsOnSurface2,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onSkipPrev, modifier = Modifier.size(40.dp)) {
|
||||
Icon(
|
||||
Icons.Default.SkipPrevious,
|
||||
contentDescription = null,
|
||||
tint = NsOnSurface2,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
// Accent play/pause circle button
|
||||
Box(
|
||||
Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(NsAccent)
|
||||
.clickable(onClick = onTogglePlay),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
IconButton(onClick = onSkipNext, modifier = Modifier.size(40.dp)) {
|
||||
Icon(
|
||||
Icons.Default.SkipNext,
|
||||
contentDescription = null,
|
||||
tint = NsOnSurface2,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Progress bar anchored to the bottom of the card
|
||||
if (state.durationMs > 0) {
|
||||
LinearProgressIndicator(
|
||||
progress = { (state.positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f) },
|
||||
modifier = Modifier.fillMaxWidth().height(3.dp),
|
||||
color = NsAccent,
|
||||
trackColor = NsAccent.copy(alpha = 0.15f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.novasonic.app.ui.component
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.QueueMusic
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.novasonic.app.api.model.Playlist
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.ui.theme.*
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PlaylistPickerSheet(
|
||||
song: Song,
|
||||
loadPlaylists: suspend () -> List<Playlist>,
|
||||
onAdd: (Playlist) -> Unit,
|
||||
onCreate: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var playlists by remember { mutableStateOf<List<Playlist>?>(null) }
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var loadError by remember { mutableStateOf<String?>(null) }
|
||||
var newName by remember { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
isLoading = true
|
||||
try { playlists = loadPlaylists() }
|
||||
catch (e: Exception) { loadError = e.message ?: e.toString() }
|
||||
finally { isLoading = false }
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
containerColor = NsSurface,
|
||||
dragHandle = { BottomSheetDefaults.DragHandle(color = NsOnSurface2) },
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = 20.dp).padding(bottom = 8.dp)) {
|
||||
Text(
|
||||
"\"${song.title}\" zu Playlist",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp, color = NsOnSurface, maxLines = 1,
|
||||
)
|
||||
if (!song.artist.isNullOrBlank()) {
|
||||
Text(song.artist!!, fontFamily = Outfit, fontSize = 13.sp, color = NsAccent)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = NsBorder)
|
||||
|
||||
// Create new playlist row
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = newName,
|
||||
onValueChange = { newName = it },
|
||||
placeholder = { Text("Neue Playlist…", fontSize = 14.sp) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NsAccent,
|
||||
unfocusedBorderColor = NsBorder,
|
||||
cursorColor = NsAccent,
|
||||
focusedTextColor = NsOnSurface,
|
||||
unfocusedTextColor = NsOnSurface,
|
||||
),
|
||||
textStyle = androidx.compose.ui.text.TextStyle(
|
||||
fontFamily = Outfit, fontSize = 14.sp,
|
||||
),
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
val name = newName.trim()
|
||||
if (name.isNotBlank()) { onCreate(name) }
|
||||
},
|
||||
enabled = newName.isNotBlank(),
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Erstellen", tint = NsAccent)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = NsBorder)
|
||||
|
||||
when {
|
||||
isLoading -> Box(
|
||||
Modifier.fillMaxWidth().padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { CircularProgressIndicator(color = NsAccent, modifier = Modifier.size(24.dp)) }
|
||||
|
||||
loadError != null -> Box(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Text(loadError!!, color = MaterialTheme.colorScheme.error, fontSize = 13.sp) }
|
||||
|
||||
playlists.isNullOrEmpty() -> Box(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Text("Noch keine Playlists vorhanden", color = NsOnSurface2, fontSize = 13.sp) }
|
||||
|
||||
else -> LazyColumn(Modifier.fillMaxWidth()) {
|
||||
items(playlists!!, key = { it.id }) { pl ->
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onAdd(pl) }
|
||||
.padding(horizontal = 20.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.QueueMusic, null,
|
||||
tint = NsOnSurface2, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Text(
|
||||
pl.name,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Medium,
|
||||
fontSize = 15.sp, color = NsOnSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
"${pl.songCount} Titel",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.novasonic.app.ui.component
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
|
||||
import androidx.compose.material.icons.automirrored.filled.QueueMusic
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.ui.theme.*
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SongContextSheet(
|
||||
song: Song,
|
||||
isStarred: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onPlay: () -> Unit,
|
||||
onInsertNext: () -> Unit,
|
||||
onAddToQueue: () -> Unit,
|
||||
onStarToggle: () -> Unit,
|
||||
onAddToPlaylist: (() -> Unit)? = null,
|
||||
onGoToAlbum: (() -> Unit)? = null,
|
||||
onShowInfo: (() -> Unit)? = null,
|
||||
onDownload: (() -> Unit)? = null,
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
containerColor = NsSurface,
|
||||
dragHandle = { BottomSheetDefaults.DragHandle(color = NsOnSurface2) },
|
||||
) {
|
||||
// Header: song title + artist
|
||||
Column(Modifier.padding(horizontal = 20.dp).padding(bottom = 8.dp)) {
|
||||
Text(
|
||||
song.title, fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp, color = NsOnSurface, maxLines = 1,
|
||||
)
|
||||
if (!song.artist.isNullOrBlank()) {
|
||||
Text(song.artist!!, fontFamily = Outfit, fontSize = 13.sp, color = NsAccent)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = NsBorder)
|
||||
|
||||
CtxItem(Icons.Default.PlayArrow, "Abspielen", onClick = { onDismiss(); onPlay() })
|
||||
CtxItem(Icons.AutoMirrored.Filled.QueueMusic, "Als Nächstes einfügen", onClick = { onDismiss(); onInsertNext() })
|
||||
CtxItem(Icons.Default.AddToQueue, "An Queue anfügen", onClick = { onDismiss(); onAddToQueue() })
|
||||
|
||||
HorizontalDivider(color = NsBorder, modifier = Modifier.padding(vertical = 4.dp))
|
||||
|
||||
if (onAddToPlaylist != null) {
|
||||
CtxItem(Icons.AutoMirrored.Filled.PlaylistAdd, "Zu Playlist hinzufügen",
|
||||
onClick = { onDismiss(); onAddToPlaylist() })
|
||||
}
|
||||
|
||||
CtxItem(
|
||||
icon = if (isStarred) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
|
||||
label = if (isStarred) "Aus Favoriten entfernen" else "Zu Favoriten",
|
||||
tint = if (isStarred) NsAccent else NsOnSurface,
|
||||
onClick = { onDismiss(); onStarToggle() },
|
||||
)
|
||||
|
||||
if (onGoToAlbum != null || onShowInfo != null || onDownload != null) {
|
||||
HorizontalDivider(color = NsBorder, modifier = Modifier.padding(vertical = 4.dp))
|
||||
onGoToAlbum?.let { CtxItem(Icons.Default.Album, "Zum Album", onClick = { onDismiss(); it() }) }
|
||||
onShowInfo?.let { CtxItem(Icons.Default.Info, "Song-Info", onClick = { onDismiss(); it() }) }
|
||||
onDownload?.let { CtxItem(Icons.Default.Download, "Herunterladen", onClick = { onDismiss(); it() }) }
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CtxItem(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
tint: androidx.compose.ui.graphics.Color = NsOnSurface,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 20.dp, vertical = 14.dp),
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(22.dp))
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Text(label, fontFamily = Outfit, fontWeight = FontWeight.Medium,
|
||||
fontSize = 15.sp, color = tint)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.novasonic.app.ui.component
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.ui.theme.*
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SongInfoSheet(song: Song, onDismiss: () -> Unit) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
containerColor = NsSurface,
|
||||
dragHandle = { BottomSheetDefaults.DragHandle(color = NsOnSurface2) },
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = 20.dp).padding(bottom = 32.dp)) {
|
||||
Text(
|
||||
"Song-Info",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp, color = NsOnSurface,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
HorizontalDivider(color = NsBorder, modifier = Modifier.padding(bottom = 12.dp))
|
||||
|
||||
InfoRow("Titel", song.title)
|
||||
song.artist?.takeIf { it.isNotBlank() }?.let { InfoRow("Künstler", it) }
|
||||
song.album?.takeIf { it.isNotBlank() }?.let { InfoRow("Album", it) }
|
||||
song.year?.let { InfoRow("Jahr", it.toString()) }
|
||||
song.genre?.takeIf { it.isNotBlank() }?.let { InfoRow("Genre", it) }
|
||||
song.track?.let { InfoRow("Track", it.toString()) }
|
||||
song.duration?.let { InfoRow("Dauer", formatDur(it)) }
|
||||
song.bitRate?.let { InfoRow("Bitrate", "${it} kbps") }
|
||||
song.suffix?.let { InfoRow("Format", it.uppercase()) }
|
||||
song.size?.let { InfoRow("Größe", formatSize(it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoRow(label: String, value: String) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 5.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.width(70.dp),
|
||||
)
|
||||
Text(
|
||||
value,
|
||||
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDur(s: Int): String {
|
||||
val h = s / 3600; val m = (s % 3600) / 60; val sec = s % 60
|
||||
return if (h > 0) "%d:%02d:%02d".format(h, m, sec) else "%d:%02d".format(m, sec)
|
||||
}
|
||||
|
||||
private fun formatSize(bytes: Long): String = when {
|
||||
bytes < 1024L -> "$bytes B"
|
||||
bytes < 1024L * 1024 -> "${bytes / 1024} KB"
|
||||
else -> "%.1f MB".format(bytes / (1024.0 * 1024.0))
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.novasonic.app.ui.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun SongRow(
|
||||
song: Song,
|
||||
coverUrl: String?,
|
||||
isPlaying: Boolean = false,
|
||||
trackNumber: Int? = null,
|
||||
starred: Boolean = false,
|
||||
onPlay: () -> Unit,
|
||||
onStarToggle: (() -> Unit)? = null,
|
||||
onMore: (() -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onPlay)
|
||||
.background(if (isPlaying) NsAccent.copy(alpha = 0.08f) else Color.Transparent)
|
||||
.padding(start = if (isPlaying) 0.dp else 4.dp),
|
||||
) {
|
||||
// Accent left bar when playing
|
||||
if (isPlaying) {
|
||||
Box(
|
||||
Modifier
|
||||
.width(3.dp)
|
||||
.height(52.dp)
|
||||
.background(NsAccent, RoundedCornerShape(topEnd = 3.dp, bottomEnd = 3.dp))
|
||||
)
|
||||
Spacer(Modifier.width(9.dp))
|
||||
}
|
||||
|
||||
// Track number OR album art
|
||||
if (trackNumber != null) {
|
||||
Text(
|
||||
text = trackNumber.toString(),
|
||||
fontFamily = JetBrainsMono,
|
||||
fontSize = 11.sp,
|
||||
color = if (isPlaying) NsAccent else NsOnSurface2,
|
||||
modifier = Modifier.width(34.dp).padding(start = if (isPlaying) 0.dp else 12.dp),
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier.width(if (isPlaying) 0.dp else 12.dp))
|
||||
AsyncImage(
|
||||
model = coverUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(46.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 10.dp)
|
||||
.padding(end = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = song.title,
|
||||
fontFamily = Outfit,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
color = if (isPlaying) NsAccent else NsOnSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val sub = if (trackNumber != null) {
|
||||
// in album context: show artist only (album name redundant)
|
||||
song.artist?.takeIf { it.isNotBlank() } ?: ""
|
||||
} else {
|
||||
listOfNotNull(song.artist, song.album).joinToString(" · ")
|
||||
}
|
||||
if (sub.isNotBlank()) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = sub,
|
||||
fontFamily = Outfit,
|
||||
fontSize = 12.sp,
|
||||
color = NsOnSurface2,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (onStarToggle != null) {
|
||||
IconButton(onClick = onStarToggle, modifier = Modifier.size(38.dp)) {
|
||||
Icon(
|
||||
if (starred) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
|
||||
contentDescription = null,
|
||||
tint = if (starred) NsAccent else NsOnSurface2.copy(alpha = 0.6f),
|
||||
modifier = Modifier.size(17.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (onMore != null) {
|
||||
IconButton(onClick = onMore, modifier = Modifier.size(38.dp)) {
|
||||
Icon(
|
||||
Icons.Default.MoreVert, contentDescription = null,
|
||||
tint = NsOnSurface2.copy(alpha = 0.6f), modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Shuffle
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.AlbumViewModel
|
||||
|
||||
@Composable
|
||||
fun AlbumScreen(
|
||||
coverUrl: (String?) -> String?,
|
||||
playerState: PlayerState,
|
||||
onPlay: (List<Song>, Int) -> Unit,
|
||||
onStarToggle: ((Song) -> Unit)? = null,
|
||||
onShowContextMenu: ((Song) -> Unit)? = null,
|
||||
onArtistClick: ((String) -> Unit)? = null,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val vm: AlbumViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
|
||||
if (state.isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val album = state.album ?: run {
|
||||
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
state.error ?: "Album nicht gefunden",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedButton(onClick = onBack) { Text("Zurück") }
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
item {
|
||||
// ── Hero ─────────────────────────────────────────────────────────
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
AsyncImage(
|
||||
model = coverUrl(album.coverArt),
|
||||
contentDescription = album.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.background(NsSurface2),
|
||||
)
|
||||
// gradient overlay
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0.0f to Color.Transparent,
|
||||
0.40f to NsBackground.copy(alpha = 0.35f),
|
||||
1.0f to NsBackground,
|
||||
)
|
||||
)
|
||||
)
|
||||
// back button — frosted circle
|
||||
Box(
|
||||
Modifier
|
||||
.statusBarsPadding()
|
||||
.padding(start = 14.dp, top = 14.dp)
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.45f))
|
||||
.clickable(onClick = onBack)
|
||||
.align(Alignment.TopStart),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
||||
tint = Color.White, modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
// album info overlaid at bottom
|
||||
Column(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(horizontal = 20.dp, vertical = 20.dp),
|
||||
) {
|
||||
Text(
|
||||
album.name,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 26.sp, color = Color.White,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (!album.artist.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
album.artist!!, fontFamily = Outfit,
|
||||
fontWeight = FontWeight.Medium, fontSize = 15.sp, color = NsAccent,
|
||||
modifier = if (onArtistClick != null && !album.artistId.isNullOrBlank())
|
||||
Modifier.clickable { onArtistClick(album.artistId!!) }
|
||||
else Modifier,
|
||||
)
|
||||
}
|
||||
val meta = listOfNotNull(
|
||||
album.year?.toString(),
|
||||
album.genre?.takeIf { it.isNotBlank() },
|
||||
if (album.songCount > 0) "${album.songCount} Songs" else null,
|
||||
).joinToString(" · ")
|
||||
if (meta.isNotBlank()) {
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
meta, fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = Color.White.copy(alpha = 0.55f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Play / Shuffle row ────────────────────────────────────────────
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { onPlay(album.song, 0) },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Play", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { onPlay(album.song.shuffled(), 0) },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(20.dp), tint = NsAccent)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = NsBorder)
|
||||
}
|
||||
|
||||
itemsIndexed(album.song, key = { _, s -> s.id }) { index, song ->
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = null,
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
trackNumber = song.track ?: (index + 1),
|
||||
starred = song.starred != null,
|
||||
onPlay = { onPlay(album.song, index) },
|
||||
onStarToggle = onStarToggle?.let { cb -> { cb(song) } },
|
||||
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Shuffle
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.AlbumCard
|
||||
import com.novasonic.app.ui.component.ItunesDownloadSheet
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.ArtistViewModel
|
||||
|
||||
@Composable
|
||||
fun ArtistDetailScreen(
|
||||
coverUrl: (String?) -> String?,
|
||||
playerState: PlayerState,
|
||||
onPlay: (List<Song>, Int) -> Unit,
|
||||
onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit,
|
||||
onStarToggle: (Song) -> Unit,
|
||||
onShowContextMenu: ((Song) -> Unit)? = null,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val vm: ArtistViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
BackHandler(enabled = state.partialAlbum != null) { vm.dismissPartialAlbum() }
|
||||
BackHandler(enabled = state.subAlbumItem != null && state.partialAlbum == null) { vm.dismissSubAlbum() }
|
||||
|
||||
// Download sheet
|
||||
state.downloadSheetItem?.let { item ->
|
||||
ItunesDownloadSheet(
|
||||
item = item,
|
||||
onDownload = { type ->
|
||||
vm.dismissDownloadSheet()
|
||||
vm.downloadItem(item, type) { _, msg ->
|
||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
onDismiss = vm::dismissDownloadSheet,
|
||||
)
|
||||
}
|
||||
|
||||
if (state.isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val artist = state.artist ?: run {
|
||||
Box(Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center) {
|
||||
Text(state.error ?: "Artist not found", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Partial album detail (incomplete album — downloaded + missing tracks split view)
|
||||
val partialAlbum = state.partialAlbum
|
||||
if (partialAlbum != null) {
|
||||
PartialAlbumScreen(
|
||||
partialAlbum = partialAlbum,
|
||||
playerState = playerState,
|
||||
coverUrl = coverUrl,
|
||||
previewingId = state.previewingId,
|
||||
onPlay = onPlay,
|
||||
onPreview = vm::togglePreview,
|
||||
onDownload = vm::showDownloadSheet,
|
||||
onBack = vm::dismissPartialAlbum,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Sub-album detail (not-downloaded iTunes album)
|
||||
val subAlbumItem = state.subAlbumItem
|
||||
val subAlbumTracks = state.subAlbumTracks
|
||||
if (subAlbumItem != null) {
|
||||
if (subAlbumTracks == null) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
} else {
|
||||
ItunesAlbumDetail(
|
||||
title = state.subAlbumTitle,
|
||||
albumItem = subAlbumItem,
|
||||
tracks = subAlbumTracks,
|
||||
previewingId = state.previewingId,
|
||||
onPreview = vm::togglePreview,
|
||||
onDownload = vm::showDownloadSheet,
|
||||
onBack = vm::dismissSubAlbum,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Album categories (computed once iTunes data arrives)
|
||||
val cats = if (state.iAlbums.isNotEmpty()) categorizeAlbums(artist.album, state.iAlbums) else null
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
// Back link
|
||||
item {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onBack)
|
||||
.padding(start = 12.dp, end = 16.dp, top = 12.dp, bottom = 12.dp),
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
||||
tint = NsOnSurface2, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Alle Künstler", fontFamily = Outfit, fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
|
||||
// Hero card
|
||||
item {
|
||||
val imgUrl = state.info?.largeImageUrl?.takeIf { it.isNotBlank() }
|
||||
?: coverUrl(artist.coverArt)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.height(320.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(NsSurface2),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = imgUrl,
|
||||
contentDescription = artist.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(
|
||||
0.35f to Color(0x00000000),
|
||||
1f to Color(0xD107070F),
|
||||
)
|
||||
)
|
||||
)
|
||||
Text(
|
||||
artist.name,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold, fontSize = 30.sp,
|
||||
letterSpacing = (-0.5).sp, color = Color.White,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.align(Alignment.BottomStart).padding(20.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// Action bar
|
||||
item {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 8.dp),
|
||||
) {
|
||||
ArtistActionPill(Icons.Default.PlayArrow, "Top Songs") {
|
||||
if (state.topSongs.isNotEmpty()) onPlay(state.topSongs, 0)
|
||||
}
|
||||
ArtistActionPill(Icons.Default.Shuffle, "Shuffle") {
|
||||
if (state.topSongs.isNotEmpty()) onPlay(state.topSongs.shuffled(), 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Similar artists (exclude the current artist)
|
||||
val similarArtists = state.info?.similarArtist
|
||||
?.filter { !it.name.equals(artist.name, ignoreCase = true) }
|
||||
?: emptyList()
|
||||
if (similarArtists.isNotEmpty()) {
|
||||
item {
|
||||
SectionLabel("Similar Artists")
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 20.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.padding(bottom = 20.dp),
|
||||
) {
|
||||
items(similarArtists) { sim ->
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.width(70.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = coverUrl(sim.coverArt),
|
||||
contentDescription = sim.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(60.dp).clip(CircleShape)
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
sim.name, fontFamily = Outfit, fontSize = 11.sp,
|
||||
color = NsOnSurface2, maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Navidrome top songs
|
||||
if (state.topSongs.isNotEmpty()) {
|
||||
item { SectionLabel("Top Songs") }
|
||||
itemsIndexed(state.topSongs, key = { _, s -> "top_${s.id}" }) { idx, song ->
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = coverUrl(song.coverArt),
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
starred = song.starred != null,
|
||||
onPlay = { onPlay(state.topSongs, idx) },
|
||||
onStarToggle = { onStarToggle(song) },
|
||||
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
|
||||
// ── iTunes: Weltweit populär ──────────────────────────────────────────
|
||||
if (state.iTracks.isNotEmpty()) {
|
||||
item { ArtistSectionHeader("Weltweit populär") }
|
||||
itemsIndexed(state.iTracks, key = { _, t -> t.trackId ?: 0L }) { _, track ->
|
||||
ItunesArtistTrackRow(
|
||||
track = track,
|
||||
isPreviewing = state.previewingId == track.trackId,
|
||||
onPreview = { vm.togglePreview(track) },
|
||||
onDownload = { vm.showDownloadSheet(track) },
|
||||
)
|
||||
}
|
||||
} else if (state.itunesLoading) {
|
||||
item {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = NsAccent, modifier = Modifier.size(16.dp), strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text("iTunes Daten werden geladen…", fontFamily = Outfit,
|
||||
fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Album sections (categorized once iTunes data is ready) ────────────
|
||||
if (cats != null) {
|
||||
if (cats.downloaded.isNotEmpty()) {
|
||||
item { ArtistSectionHeader("In Library", cats.downloaded.size) }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(cats.downloaded) { (nd, _) ->
|
||||
AlbumThumb(
|
||||
name = nd.name,
|
||||
year = nd.year?.toString(),
|
||||
artUrl = coverUrl(nd.coverArt),
|
||||
onClick = { onAlbumClick(nd) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (cats.partial.isNotEmpty()) {
|
||||
item { ArtistSectionHeader("Unvollständig", cats.partial.size) }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(cats.partial) { (nd, ia, counts) ->
|
||||
AlbumThumb(
|
||||
name = nd.name,
|
||||
year = nd.year?.toString(),
|
||||
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb")
|
||||
?: coverUrl(nd.coverArt),
|
||||
badge = "${counts.first}/${counts.second}",
|
||||
onClick = { vm.loadPartialAlbum(nd, ia) },
|
||||
onDownload = { vm.showDownloadSheet(ia) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (cats.notDownloaded.isNotEmpty()) {
|
||||
item { ArtistSectionHeader("Nicht heruntergeladen", cats.notDownloaded.size) }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(cats.notDownloaded, key = { it.collectionId ?: 0L }) { ia ->
|
||||
AlbumThumb(
|
||||
name = ia.collectionName ?: "—",
|
||||
year = ia.releaseDate?.take(4),
|
||||
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb"),
|
||||
dimmed = true,
|
||||
onClick = { vm.loadSubAlbum(ia) },
|
||||
onDownload = { vm.showDownloadSheet(ia) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
} else if (!state.itunesLoading && artist.album.isNotEmpty()) {
|
||||
// Fallback when no iTunes match found
|
||||
item { SectionLabel("Alben") }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 20.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.padding(bottom = 20.dp),
|
||||
) {
|
||||
items(artist.album, key = { it.id }) { album ->
|
||||
AlbumCard(
|
||||
album = album,
|
||||
coverUrl = coverUrl(album.coverArt),
|
||||
onClick = { onAlbumClick(album) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bio
|
||||
state.info?.biography?.takeIf { it.isNotBlank() }?.let { bio ->
|
||||
item {
|
||||
SectionLabel("Über")
|
||||
Box(
|
||||
Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 24.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(NsSurface)
|
||||
.border(1.dp, NsBorder, RoundedCornerShape(10.dp))
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = bio.replace(Regex("<[^>]+>"), "").trim(),
|
||||
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
|
||||
lineHeight = 21.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item { Spacer(Modifier.height(24.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtistActionPill(icon: ImageVector, label: String, onClick: () -> Unit) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(NsSurface)
|
||||
.border(1.dp, NsBorder, RoundedCornerShape(20.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 18.dp, vertical = 9.dp),
|
||||
) {
|
||||
Icon(icon, null, tint = NsOnSurface, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(label, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = NsOnSurface)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionLabel(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 4.dp, bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import android.widget.Toast
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.novasonic.app.api.model.*
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.ItunesDownloadSheet
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.ExploreTab
|
||||
import com.novasonic.app.viewmodel.ExploreViewModel
|
||||
|
||||
@Composable
|
||||
fun ExploreScreen(
|
||||
coverUrl: (String?) -> String?,
|
||||
playerState: PlayerState,
|
||||
onSongPlay: (List<com.novasonic.app.api.model.Song>, Int) -> Unit,
|
||||
onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit,
|
||||
onArtistClick: (com.novasonic.app.api.model.Artist) -> Unit,
|
||||
onOpenImporter: () -> Unit = {},
|
||||
) {
|
||||
val vm: ExploreViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val focusManager = LocalFocusManager.current
|
||||
val context = LocalContext.current
|
||||
|
||||
// Download sheet
|
||||
state.downloadSheetItem?.let { item ->
|
||||
ItunesDownloadSheet(
|
||||
item = item,
|
||||
onDownload = { type ->
|
||||
vm.dismissDownloadSheet()
|
||||
vm.downloadItem(item, type) { _, msg ->
|
||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
onDismiss = vm::dismissDownloadSheet,
|
||||
)
|
||||
}
|
||||
|
||||
// ── iTunes artist detail — full-screen overlay above tabs ────────────────
|
||||
state.artistDetail?.let { artistState ->
|
||||
val partialAlbum = state.partialAlbum
|
||||
val albumTracks = state.albumTracks
|
||||
val albumItem = state.albumItem
|
||||
when {
|
||||
partialAlbum != null -> {
|
||||
PartialAlbumScreen(
|
||||
partialAlbum = partialAlbum,
|
||||
playerState = playerState,
|
||||
coverUrl = coverUrl,
|
||||
previewingId = state.previewingId,
|
||||
onPlay = onSongPlay,
|
||||
onPreview = vm::togglePreview,
|
||||
onDownload = vm::showDownloadSheet,
|
||||
onBack = vm::dismissPartialAlbum,
|
||||
)
|
||||
}
|
||||
albumTracks != null && albumItem != null -> {
|
||||
ItunesAlbumDetail(
|
||||
title = state.albumTitle,
|
||||
albumItem = albumItem,
|
||||
tracks = albumTracks,
|
||||
previewingId = state.previewingId,
|
||||
onPreview = vm::togglePreview,
|
||||
onDownload = vm::showDownloadSheet,
|
||||
onBack = vm::dismissAlbum,
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
ItunesArtistScreen(
|
||||
artistState = artistState,
|
||||
playerState = playerState,
|
||||
coverUrl = coverUrl,
|
||||
onBack = vm::closeArtistDetail,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onPartialAlbumOpen = vm::loadPartialAlbum,
|
||||
onSongPlay = onSongPlay,
|
||||
onDownload = vm::showDownloadSheet,
|
||||
onItunesAlbumOpen = vm::loadAlbum,
|
||||
onItunesTrackPreview = vm::togglePreview,
|
||||
previewingId = state.previewingId,
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
// ── Tabs ─────────────────────────────────────────────────────────────
|
||||
TabRow(
|
||||
selectedTabIndex = state.tab.ordinal,
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = NsAccent,
|
||||
) {
|
||||
Tab(
|
||||
selected = state.tab == ExploreTab.LIBRARY,
|
||||
onClick = { vm.selectTab(ExploreTab.LIBRARY); vm.stopPreview() },
|
||||
text = { Text("Bibliothek", maxLines = 1) },
|
||||
selectedContentColor = NsAccent,
|
||||
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Tab(
|
||||
selected = state.tab == ExploreTab.GLOBAL,
|
||||
onClick = { vm.selectTab(ExploreTab.GLOBAL); vm.stopPreview() },
|
||||
text = { Text("Erkunden", maxLines = 1) },
|
||||
selectedContentColor = NsAccent,
|
||||
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Search bar ───────────────────────────────────────────────────────
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
OutlinedTextField(
|
||||
value = state.query,
|
||||
onValueChange = vm::onQueryChange,
|
||||
placeholder = {
|
||||
Text(
|
||||
if (state.tab == ExploreTab.LIBRARY) "Suche in Bibliothek…" else "Global suchen…",
|
||||
color = NsOnSurface2,
|
||||
)
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Default.Search, null, tint = NsOnSurface2) },
|
||||
trailingIcon = if (state.query.isNotEmpty()) { {
|
||||
IconButton(onClick = { vm.onQueryChange(""); focusManager.clearFocus() }) {
|
||||
Icon(Icons.Default.Close, "Löschen", tint = NsOnSurface2)
|
||||
}
|
||||
} } else null,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = { focusManager.clearFocus() }),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NsAccent,
|
||||
unfocusedBorderColor = NsOnSurface3,
|
||||
cursorColor = NsAccent,
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
.focusRequester(focusRequester),
|
||||
)
|
||||
|
||||
// ── Importer entry (matches PWA "Importer" under Entdecken) ───────────
|
||||
if (state.tab == ExploreTab.GLOBAL) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 6.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(NsSurface)
|
||||
.clickable(onClick = onOpenImporter)
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Download, null, tint = NsAccent, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Importer", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp, color = NsOnSurface)
|
||||
Text("Spotify · YouTube · SoundCloud Link laden",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
Icon(Icons.Default.ChevronRight, null, tint = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Content ──────────────────────────────────────────────────────────
|
||||
when (state.tab) {
|
||||
ExploreTab.LIBRARY -> LibrarySearchContent(
|
||||
query = state.query,
|
||||
coverUrl = coverUrl,
|
||||
playerState = playerState,
|
||||
onSongPlay = onSongPlay,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistClick = onArtistClick,
|
||||
)
|
||||
ExploreTab.GLOBAL -> GlobalExploreContent(
|
||||
state = state,
|
||||
vm = vm,
|
||||
coverUrl = coverUrl,
|
||||
playerState = playerState,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onSongPlay = onSongPlay,
|
||||
onDownload = vm::showDownloadSheet,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Library (local Navidrome) search ─────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun LibrarySearchContent(
|
||||
query: String,
|
||||
coverUrl: (String?) -> String?,
|
||||
playerState: PlayerState,
|
||||
onSongPlay: (List<com.novasonic.app.api.model.Song>, Int) -> Unit,
|
||||
onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit,
|
||||
onArtistClick: (com.novasonic.app.api.model.Artist) -> Unit,
|
||||
) {
|
||||
// Reuse SearchScreen's ViewModel
|
||||
val vm: com.novasonic.app.viewmodel.SearchViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(query) { vm.onSearch(query) }
|
||||
|
||||
if (query.isBlank()) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(Icons.Default.Search, null, tint = NsOnSurface3, modifier = Modifier.size(48.dp))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("Bibliothek durchsuchen", color = NsOnSurface2, fontFamily = Outfit)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (state.isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn {
|
||||
val artists = state.searchResult?.artist ?: emptyList()
|
||||
val albums = state.searchResult?.album ?: emptyList()
|
||||
val songs = state.searchResult?.song ?: emptyList()
|
||||
|
||||
if (artists.isEmpty() && albums.isEmpty() && songs.isEmpty()) {
|
||||
item {
|
||||
Box(Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Keine Ergebnisse", color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
return@LazyColumn
|
||||
}
|
||||
if (artists.isNotEmpty()) {
|
||||
item { SectionHeader("Künstler") }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(artists, key = { it.id }) { a ->
|
||||
SmallArtistChip(a.name) { onArtistClick(a) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (albums.isNotEmpty()) {
|
||||
item { SectionHeader("Alben") }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(albums, key = { it.id }) { album ->
|
||||
com.novasonic.app.ui.component.AlbumCard(
|
||||
album = album, coverUrl = coverUrl(album.coverArt),
|
||||
onClick = { onAlbumClick(album) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
if (songs.isNotEmpty()) {
|
||||
item { SectionHeader("Titel") }
|
||||
itemsIndexed(songs, key = { _, s -> s.id }) { idx, song ->
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = coverUrl(song.coverArt),
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
onPlay = { onSongPlay(songs, idx) },
|
||||
)
|
||||
}
|
||||
}
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Global (iTunes) explore ───────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun GlobalExploreContent(
|
||||
state: com.novasonic.app.viewmodel.ExploreUiState,
|
||||
vm: ExploreViewModel,
|
||||
coverUrl: (String?) -> String? = { null },
|
||||
playerState: PlayerState = com.novasonic.app.player.PlayerState(),
|
||||
onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit = {},
|
||||
onSongPlay: (List<com.novasonic.app.api.model.Song>, Int) -> Unit = { _, _ -> },
|
||||
onDownload: (ItunesItem) -> Unit = {},
|
||||
) {
|
||||
// Album drill-down view
|
||||
if (state.albumTracks != null) {
|
||||
ItunesAlbumDetail(
|
||||
title = state.albumTitle,
|
||||
albumItem = state.albumItem,
|
||||
tracks = state.albumTracks,
|
||||
previewingId = state.previewingId,
|
||||
onPreview = vm::togglePreview,
|
||||
onDownload = onDownload,
|
||||
onBack = vm::dismissAlbum,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (state.isSearching) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (state.query.isBlank()) {
|
||||
// Show iTunes charts
|
||||
if (state.chartsLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
return
|
||||
}
|
||||
LazyColumn {
|
||||
item { SectionHeader("Top-Alben") }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
items(state.charts) { entry ->
|
||||
ItunesChartCard(entry = entry, onClick = {
|
||||
val id = entry.id?.attributes?.imId?.toLongOrNull()
|
||||
if (id != null) vm.loadAlbum(
|
||||
ItunesItem(
|
||||
wrapperType = "collection",
|
||||
collectionId = id,
|
||||
collectionName = entry.name?.label,
|
||||
artistName = entry.artist?.label,
|
||||
artworkUrl100 = entry.artworkHd(),
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Search results
|
||||
val hasResults = state.artists.isNotEmpty() || state.albums.isNotEmpty() || state.tracks.isNotEmpty()
|
||||
if (!hasResults && state.error == null) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Keine Ergebnisse", color = NsOnSurface2, fontFamily = Outfit)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn {
|
||||
if (state.error != null) {
|
||||
item { Text(state.error, color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(16.dp)) }
|
||||
}
|
||||
if (state.artists.isNotEmpty()) {
|
||||
item { SectionHeader("Künstler") }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(state.artists) { a ->
|
||||
SmallArtistChip(a.artistName ?: "") { vm.openArtistDetail(a) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.albums.isNotEmpty()) {
|
||||
item { SectionHeader("Alben") }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
items(state.albums) { album ->
|
||||
ItunesAlbumCard(album = album, onClick = { vm.loadAlbum(album) })
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
if (state.tracks.isNotEmpty()) {
|
||||
item { SectionHeader("Titel") }
|
||||
itemsIndexed(state.tracks, key = { _, t -> t.trackId ?: 0 }) { _, track ->
|
||||
ItunesTrackRow(
|
||||
track = track,
|
||||
isPreviewing = state.previewingId == track.trackId,
|
||||
onPreview = { vm.togglePreview(track) },
|
||||
onDownload = { onDownload(track) },
|
||||
)
|
||||
}
|
||||
}
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ItunesAlbumDetail(
|
||||
title: String,
|
||||
albumItem: ItunesItem?,
|
||||
tracks: List<ItunesItem>,
|
||||
previewingId: Long?,
|
||||
onPreview: (ItunesItem) -> Unit,
|
||||
onDownload: (ItunesItem) -> Unit = {},
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 4.dp, top = 4.dp, bottom = 4.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
||||
tint = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
Text(title, fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f))
|
||||
if (albumItem != null) {
|
||||
IconButton(onClick = { onDownload(albumItem) }) {
|
||||
Icon(Icons.Default.Download, "Album herunterladen",
|
||||
tint = NsAccent, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
LazyColumn {
|
||||
itemsIndexed(tracks, key = { _, t -> t.trackId ?: 0 }) { _, track ->
|
||||
ItunesTrackRow(
|
||||
track = track,
|
||||
isPreviewing = previewingId == track.trackId,
|
||||
onPreview = { onPreview(track) },
|
||||
onDownload = { onDownload(track) },
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Small shared components ───────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun ItunesChartCard(entry: ItunesRssEntry, onClick: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.width(130.dp).clickable(onClick = onClick),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = entry.artworkHd(), contentDescription = entry.name?.label,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(130.dp).clip(RoundedCornerShape(10.dp))
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(entry.name?.label ?: "", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
Text(entry.artist?.label ?: "", fontFamily = Outfit, fontSize = 11.sp,
|
||||
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ItunesAlbumCard(album: ItunesItem, onClick: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.width(130.dp).clickable(onClick = onClick),
|
||||
) {
|
||||
val art = album.artworkUrl100?.replace("100x100bb", "300x300bb")
|
||||
AsyncImage(
|
||||
model = art, contentDescription = album.collectionName,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(130.dp).clip(RoundedCornerShape(10.dp))
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(album.collectionName ?: "", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
Text(album.artistName ?: "", fontFamily = Outfit, fontSize = 11.sp,
|
||||
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ItunesTrackRow(
|
||||
track: ItunesItem,
|
||||
isPreviewing: Boolean,
|
||||
onPreview: () -> Unit,
|
||||
onDownload: (() -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.clickable(onClick = onPreview)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
val art = track.artworkUrl100?.replace("100x100bb", "60x60bb")
|
||||
AsyncImage(
|
||||
model = art, contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(4.dp))
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(track.trackName ?: "", style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isPreviewing) NsAccent else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
listOfNotNull(track.artistName, track.collectionName).joinToString(" · "),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
// Preview button
|
||||
if (track.previewUrl != null) {
|
||||
IconButton(onClick = onPreview, modifier = Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
if (isPreviewing) Icons.Default.Stop else Icons.Default.PlayCircle,
|
||||
if (isPreviewing) "Stop" else "Preview",
|
||||
tint = if (isPreviewing) NsAccent else NsOnSurface2,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Download button
|
||||
if (onDownload != null) {
|
||||
IconButton(onClick = onDownload, modifier = Modifier.size(36.dp)) {
|
||||
Icon(Icons.Default.Download, "Herunterladen",
|
||||
tint = NsOnSurface2, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SmallArtistChip(name: String, onClick: () -> Unit) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(NsSurface2)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(name, fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(title: String) {
|
||||
Text(title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 12.dp, bottom = 10.dp))
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun GenreSongsScreen(
|
||||
genreName: String,
|
||||
coverUrl: (String?) -> String?,
|
||||
playerState: PlayerState,
|
||||
onPlay: (List<Song>, Int) -> Unit,
|
||||
onStarToggle: (Song) -> Unit,
|
||||
onShowContextMenu: ((Song) -> Unit)? = null,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val repo = context.app.repository
|
||||
|
||||
val decodedName = remember(genreName) {
|
||||
java.net.URLDecoder.decode(genreName, "UTF-8")
|
||||
}
|
||||
|
||||
var songs by remember { mutableStateOf<List<Song>>(emptyList()) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(decodedName) {
|
||||
try {
|
||||
songs = repo.getSongsByGenre(decodedName)
|
||||
loading = false
|
||||
} catch (e: Exception) {
|
||||
error = e.message
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 4.dp, top = 8.dp, bottom = 8.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back",
|
||||
tint = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
Text(
|
||||
decodedName,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 20.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
|
||||
when {
|
||||
loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
error != null -> Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text(error!!, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
songs.isEmpty() -> Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text("Keine Songs gefunden", color = NsOnSurface2)
|
||||
}
|
||||
else -> LazyColumn {
|
||||
itemsIndexed(songs, key = { _, s -> s.id }) { idx, song ->
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = coverUrl(song.coverArt),
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
starred = song.starred != null,
|
||||
onPlay = { onPlay(songs, idx) },
|
||||
onStarToggle = { onStarToggle(song) },
|
||||
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Album
|
||||
import com.novasonic.app.api.model.Genre
|
||||
import com.novasonic.app.ui.component.AlbumCard
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.HomeViewModel
|
||||
import java.util.Calendar
|
||||
|
||||
private val genreColors = listOf(
|
||||
Color(0xFF1a1a3e), Color(0xFF1e2a1a), Color(0xFF2a1a1a),
|
||||
Color(0xFF1a2a2a), Color(0xFF2a1e1a), Color(0xFF1e1a2a),
|
||||
Color(0xFF2a2a1a), Color(0xFF1a2a1e), Color(0xFF201520),
|
||||
Color(0xFF152020), Color(0xFF201515), Color(0xFF151520),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
coverUrl: (Album) -> String?,
|
||||
onAlbumClick: (Album) -> Unit,
|
||||
onAlbumPlay: (Album) -> Unit,
|
||||
onGenreClick: (Genre) -> Unit,
|
||||
onGenrePlay: ((Genre) -> Unit)? = null,
|
||||
) {
|
||||
val vm: HomeViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
|
||||
val greeting = remember {
|
||||
val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY)
|
||||
when {
|
||||
hour < 12 -> "Guten Morgen"
|
||||
hour < 18 -> "Guten Tag"
|
||||
else -> "Guten Abend"
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
item {
|
||||
val name = state.username.replaceFirstChar { it.uppercaseChar() }
|
||||
Text(
|
||||
text = if (name.isBlank()) greeting else "$greeting, $name 👋",
|
||||
fontFamily = Outfit,
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = NsOnSurface,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 24.dp, bottom = 20.dp),
|
||||
)
|
||||
}
|
||||
|
||||
if (state.isLoading) {
|
||||
item {
|
||||
Box(Modifier.fillMaxWidth().height(170.dp), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
}
|
||||
return@LazyColumn
|
||||
}
|
||||
|
||||
if (state.error != null) {
|
||||
item {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxWidth().padding(32.dp),
|
||||
) {
|
||||
Text(state.error!!, color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Button(onClick = { vm.load() },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent)) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
return@LazyColumn
|
||||
}
|
||||
|
||||
// Hero card — matches PWA .home-hero (170px height)
|
||||
state.heroAlbum?.let { hero ->
|
||||
item {
|
||||
HeroCard(
|
||||
album = hero,
|
||||
coverUrl = coverUrl(hero),
|
||||
onPlay = { onAlbumPlay(hero) },
|
||||
onClick = { onAlbumClick(hero) },
|
||||
)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (state.genres.isNotEmpty()) {
|
||||
item { SectionHeader("Genres") }
|
||||
item {
|
||||
GenreGrid(genres = state.genres, onGenreClick = onGenreClick, onGenrePlay = onGenrePlay)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (state.recentAlbums.isNotEmpty()) {
|
||||
item { SectionHeader("Zuletzt hinzugefügt") }
|
||||
item {
|
||||
AlbumRow(albums = state.recentAlbums, coverUrl = coverUrl, onAlbumClick = onAlbumClick)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
}
|
||||
}
|
||||
if (state.frequentAlbums.isNotEmpty()) {
|
||||
item { SectionHeader("Meistgespielt") }
|
||||
item {
|
||||
AlbumRow(albums = state.frequentAlbums, coverUrl = coverUrl, onAlbumClick = onAlbumClick)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
}
|
||||
}
|
||||
if (state.randomAlbums.isNotEmpty()) {
|
||||
item { SectionHeader("Entdecken") }
|
||||
item {
|
||||
AlbumRow(albums = state.randomAlbums, coverUrl = coverUrl, onAlbumClick = onAlbumClick)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PWA: .home-hero { height:170px; border-radius:14px }
|
||||
// background = blur(8px) brightness(.45) + horizontal gradient overlay
|
||||
// cover = 100x100, border-radius:9px
|
||||
@Composable
|
||||
private fun HeroCard(album: Album, coverUrl: String?, onPlay: () -> Unit, onClick: () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(170.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
// Blurred background image — matches .home-hero-bg
|
||||
AsyncImage(
|
||||
model = coverUrl, contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize().blur(16.dp),
|
||||
)
|
||||
// Gradient overlay — matches .home-hero-overlay
|
||||
// linear-gradient(to right, rgba(7,7,15,.88) 0%, rgba(7,7,15,.3) 100%)
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(
|
||||
Brush.horizontalGradient(listOf(Color(0xE107070F), Color(0x4D07070F)))
|
||||
)
|
||||
)
|
||||
// Content row
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
) {
|
||||
// Cover art — 100dp, matches .home-hero-cover (100px, border-radius:9px)
|
||||
AsyncImage(
|
||||
model = coverUrl, contentDescription = album.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(100.dp)
|
||||
.clip(RoundedCornerShape(9.dp))
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
// Label — matches .home-hero-label
|
||||
Text(
|
||||
text = "AKTUELL",
|
||||
fontFamily = Outfit,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 10.sp,
|
||||
letterSpacing = 1.sp,
|
||||
color = NsAccent,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = album.name,
|
||||
fontFamily = Outfit,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp,
|
||||
letterSpacing = (-0.3).sp,
|
||||
color = NsOnSurface,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (!album.artist.isNullOrBlank()) {
|
||||
Text(
|
||||
text = album.artist!!,
|
||||
fontFamily = Outfit,
|
||||
fontSize = 13.sp,
|
||||
color = NsOnSurface2,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
// Play button — matches .home-hero-play (42px circle, accent bg)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(42.dp)
|
||||
.clip(RoundedCornerShape(21.dp))
|
||||
.background(NsAccent)
|
||||
.clickable(onClick = onPlay),
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, "Play",
|
||||
tint = NsBackground, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GenreGrid(
|
||||
genres: List<Genre>,
|
||||
onGenreClick: (Genre) -> Unit,
|
||||
onGenrePlay: ((Genre) -> Unit)?,
|
||||
) {
|
||||
val rows = genres.chunked(2)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
) {
|
||||
rows.forEach { rowGenres ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
rowGenres.forEachIndexed { _, genre ->
|
||||
val colorIdx = genres.indexOf(genre) % genreColors.size
|
||||
GenreChip(
|
||||
genre = genre,
|
||||
bgColor = genreColors[colorIdx],
|
||||
onClick = { onGenreClick(genre) },
|
||||
onPlay = onGenrePlay?.let { cb -> { cb(genre) } },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
if (rowGenres.size == 1) Spacer(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GenreChip(
|
||||
genre: Genre,
|
||||
bgColor: Color,
|
||||
onClick: () -> Unit,
|
||||
onPlay: (() -> Unit)?,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.height(52.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(bgColor)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(start = 14.dp, end = if (onPlay != null) 8.dp else 14.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier.width(4.dp).height(24.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(NsAccent)
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
text = genre.value,
|
||||
fontFamily = Outfit,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp,
|
||||
color = NsOnSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (onPlay != null) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(NsAccent.copy(alpha = 0.18f))
|
||||
.clickable(onClick = onPlay),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.PlayArrow, "Play",
|
||||
tint = NsAccent,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AlbumRow(albums: List<Album>, coverUrl: (Album) -> String?, onAlbumClick: (Album) -> Unit) {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(albums, key = { it.id }) { album ->
|
||||
AlbumCard(album = album, coverUrl = coverUrl(album), onClick = { onAlbumClick(album) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PWA: .section-header { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:1.5px }
|
||||
@Composable
|
||||
private fun SectionHeader(title: String) {
|
||||
Text(
|
||||
text = title.uppercase(),
|
||||
fontFamily = Outfit,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 10.sp,
|
||||
letterSpacing = 1.5.sp,
|
||||
color = NsOnSurface2,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 4.dp, bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.ImportTrack
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.ImporterViewModel
|
||||
|
||||
// Brand colors for the platform badge
|
||||
private val Spotify = Color(0xFF1DB954)
|
||||
private val SoundCloud = Color(0xFFFF5500)
|
||||
private val YouTube = Color(0xFFFF0000)
|
||||
|
||||
@Composable
|
||||
fun ImporterScreen(onBack: () -> Unit) {
|
||||
val vm: ImporterViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val focus = LocalFocusManager.current
|
||||
|
||||
val brand = when (state.platform) {
|
||||
"spotify" -> Spotify
|
||||
"soundcloud" -> SoundCloud
|
||||
"youtube" -> YouTube
|
||||
else -> NsAccent
|
||||
}
|
||||
val brandLabel = when (state.platform) {
|
||||
"spotify" -> "Spotify"
|
||||
"soundcloud" -> "SoundCloud"
|
||||
"youtube" -> "YouTube"
|
||||
else -> "URL"
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
// Header
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 4.dp, top = 8.dp, bottom = 4.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück", tint = NsOnSurface)
|
||||
}
|
||||
Text("Importer", fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp, color = NsOnSurface)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
"Spotify-, YouTube-, YouTube-Music- oder SoundCloud-Link einfügen, " +
|
||||
"um Tracks zu laden.",
|
||||
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2, lineHeight = 19.sp,
|
||||
)
|
||||
}
|
||||
|
||||
// Link input + badge
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = state.link,
|
||||
onValueChange = vm::onLink,
|
||||
placeholder = { Text("https://open.spotify.com/…", color = NsOnSurface2) },
|
||||
singleLine = true,
|
||||
trailingIcon = {
|
||||
Box(
|
||||
Modifier.padding(end = 8.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(brand.copy(alpha = 0.15f))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(brandLabel, fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 10.sp, color = brand)
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go),
|
||||
keyboardActions = KeyboardActions(onGo = { focus.clearFocus(); vm.resolve() }),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = brand,
|
||||
unfocusedBorderColor = NsOnSurface3,
|
||||
cursorColor = brand,
|
||||
focusedContainerColor = NsSurface,
|
||||
unfocusedContainerColor = NsSurface,
|
||||
focusedTextColor = NsOnSurface,
|
||||
unfocusedTextColor = NsOnSurface,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Button(
|
||||
onClick = { focus.clearFocus(); vm.resolve() },
|
||||
enabled = !state.isResolving && state.link.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = brand, contentColor = Color.Black),
|
||||
) {
|
||||
if (state.isResolving) {
|
||||
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp, color = Color.Black)
|
||||
} else {
|
||||
Text("Auflösen", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 15.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.error?.let { err ->
|
||||
item {
|
||||
Box(
|
||||
Modifier.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(NsSurface)
|
||||
.border(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.5f), RoundedCornerShape(10.dp))
|
||||
.padding(14.dp),
|
||||
) {
|
||||
Text(err, fontFamily = Outfit, fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.error, lineHeight = 19.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolved preview header
|
||||
state.resolved?.let { r ->
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(NsSurface)
|
||||
.border(1.dp, NsBorder, RoundedCornerShape(14.dp))
|
||||
.padding(16.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = r.artworkUrl?.replace("100x100", "300x300"),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(72.dp).clip(RoundedCornerShape(8.dp))
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text((r.type ?: "").uppercase(), fontFamily = Outfit,
|
||||
fontWeight = FontWeight.Bold, fontSize = 10.sp,
|
||||
letterSpacing = 1.5.sp, color = brand)
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(r.name ?: "—", fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 17.sp, color = NsOnSurface,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
listOfNotNull(r.artist, "${r.tracks.size} Tracks").joinToString(" · "),
|
||||
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Download button / job progress
|
||||
item {
|
||||
if (state.isDownloading || state.jobStatus == "done" || state.jobStatus == "error") {
|
||||
JobProgress(
|
||||
status = state.jobStatus,
|
||||
message = state.jobMessage,
|
||||
completed = state.jobCompleted,
|
||||
total = state.jobTotal,
|
||||
brand = brand,
|
||||
)
|
||||
} else {
|
||||
Button(
|
||||
onClick = vm::downloadAll,
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = brand, contentColor = Color.Black),
|
||||
) {
|
||||
Icon(Icons.Default.Download, null, Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Alle ${r.tracks.size} Tracks laden",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 15.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
itemsIndexed(r.tracks) { idx, track ->
|
||||
ImportTrackRow(idx + 1, track)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun JobProgress(
|
||||
status: String?, message: String?, completed: Int, total: Int, brand: Color,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(NsSurface)
|
||||
.border(1.dp, NsBorder, RoundedCornerShape(12.dp))
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
when (status) {
|
||||
"done" -> Icon(Icons.Default.Check, null, tint = brand, modifier = Modifier.size(20.dp))
|
||||
"error" -> Icon(Icons.Default.Check, null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(20.dp))
|
||||
else -> CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp, color = brand)
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
when (status) {
|
||||
"done" -> "Fertig — in Navidrome scannen lassen"
|
||||
"error" -> "Fehler beim Download"
|
||||
"downloading" -> "Lädt… ${if (total > 0) "$completed/$total" else ""}"
|
||||
else -> "In Warteschlange…"
|
||||
},
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||
color = if (status == "error") MaterialTheme.colorScheme.error else NsOnSurface,
|
||||
)
|
||||
}
|
||||
if (total > 0 && status != "done") {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { if (total > 0) completed.toFloat() / total else 0f },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = brand,
|
||||
trackColor = NsSurface2,
|
||||
)
|
||||
}
|
||||
message?.takeIf { it.isNotBlank() }?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(it, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImportTrackRow(num: Int, track: ImportTrack) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = track.artworkUrl100,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(6.dp)).background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(track.trackName ?: "—", fontFamily = Outfit, fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (!track.artistName.isNullOrBlank()) {
|
||||
Text(track.artistName, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Album
|
||||
import com.novasonic.app.api.model.ItunesItem
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.ItunesArtistState
|
||||
|
||||
private fun normAlbum(s: String?): String =
|
||||
(s ?: "").lowercase().replace(Regex("[^a-z0-9\\s]"), " ").replace(Regex("\\s+"), " ").trim()
|
||||
|
||||
data class ArtistAlbumCategories(
|
||||
val downloaded: List<Pair<Album, ItunesItem?>>,
|
||||
val partial: List<Triple<Album, ItunesItem, Pair<Int, Int>>>,
|
||||
val notDownloaded: List<ItunesItem>,
|
||||
)
|
||||
|
||||
fun categorizeAlbums(ndAlbums: List<Album>, iAlbums: List<ItunesItem>): ArtistAlbumCategories {
|
||||
// Deduplicate iTunes albums by collectionId (iTunes can return the same album multiple times)
|
||||
val uniqueIAlbums = iAlbums.distinctBy { it.collectionId ?: it.collectionName }
|
||||
val downloaded = mutableListOf<Pair<Album, ItunesItem?>>()
|
||||
val partial = mutableListOf<Triple<Album, ItunesItem, Pair<Int, Int>>>()
|
||||
val notDl = mutableListOf<ItunesItem>()
|
||||
val usedNdIds = mutableSetOf<String>()
|
||||
|
||||
for (ia in uniqueIAlbums) {
|
||||
val iName = normAlbum(ia.collectionName)
|
||||
val iCount = ia.trackCount ?: 0
|
||||
val match = ndAlbums.find { a ->
|
||||
val n = normAlbum(a.name)
|
||||
n == iName || (n.length > 3 && iName.startsWith(n)) || (iName.length > 3 && n.startsWith(iName))
|
||||
}
|
||||
when {
|
||||
match == null -> notDl.add(ia)
|
||||
iCount > 0 && match.songCount >= iCount -> {
|
||||
downloaded.add(Pair(match, ia)); usedNdIds.add(match.id)
|
||||
}
|
||||
else -> {
|
||||
partial.add(Triple(match, ia, Pair(match.songCount, iCount))); usedNdIds.add(match.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (na in ndAlbums) {
|
||||
if (na.id !in usedNdIds) downloaded.add(Pair(na, null))
|
||||
}
|
||||
|
||||
return ArtistAlbumCategories(
|
||||
downloaded = downloaded.sortedByDescending { it.first.year ?: 0 },
|
||||
partial = partial.sortedByDescending { it.first.year ?: 0 },
|
||||
notDownloaded = notDl.sortedByDescending { it.releaseDate ?: "" },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ItunesArtistScreen(
|
||||
artistState: ItunesArtistState,
|
||||
playerState: PlayerState,
|
||||
coverUrl: (String?) -> String?,
|
||||
onBack: () -> Unit,
|
||||
onAlbumClick: (Album) -> Unit,
|
||||
onPartialAlbumOpen: (Album, ItunesItem) -> Unit,
|
||||
onSongPlay: (List<Song>, Int) -> Unit,
|
||||
onDownload: (ItunesItem) -> Unit,
|
||||
onItunesAlbumOpen: (ItunesItem) -> Unit,
|
||||
onItunesTrackPreview: (ItunesItem) -> Unit,
|
||||
previewingId: Long?,
|
||||
) {
|
||||
val artist = artistState.artist
|
||||
val cats = if (!artistState.isLoading)
|
||||
categorizeAlbums(artistState.ndAlbums, artistState.iAlbums)
|
||||
else null
|
||||
|
||||
// Pick backdrop: first Navidrome album cover, else first iTunes album art
|
||||
val backdropUrl = artistState.ndAlbums.firstOrNull()?.let { coverUrl(it.coverArt) }
|
||||
?: artistState.iAlbums.firstOrNull()?.artworkUrl100?.replace("100x100bb", "300x300bb")
|
||||
|
||||
Column(Modifier.fillMaxSize().background(NsBackground)) {
|
||||
// ── Hero ──────────────────────────────────────────────────────────────
|
||||
Box(
|
||||
Modifier.fillMaxWidth().height(200.dp)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = backdropUrl, contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize().blur(24.dp),
|
||||
)
|
||||
Box(Modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(0f to Color.Black.copy(0.5f), 1f to NsBackground)
|
||||
))
|
||||
// Back button
|
||||
Box(
|
||||
Modifier
|
||||
.statusBarsPadding()
|
||||
.padding(start = 14.dp, top = 14.dp)
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(0.45f))
|
||||
.clickable(onClick = onBack)
|
||||
.align(Alignment.TopStart),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
||||
tint = Color.White, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
// Artist name
|
||||
Column(
|
||||
Modifier.align(Alignment.BottomStart)
|
||||
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
) {
|
||||
Text(
|
||||
artist.artistName ?: "Unbekannter Künstler",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 28.sp, color = Color.White,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (!artist.primaryGenreName.isNullOrBlank()) {
|
||||
Text(artist.primaryGenreName!!, fontFamily = Outfit,
|
||||
fontSize = 13.sp, color = Color.White.copy(0.6f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Action row ────────────────────────────────────────────────────────
|
||||
if (artistState.topSongs.isNotEmpty()) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { onSongPlay(artistState.topSongs, 0) },
|
||||
modifier = Modifier.weight(1f).height(42.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent),
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Top Songs", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { onSongPlay(artistState.topSongs.shuffled(), 0) },
|
||||
modifier = Modifier.weight(1f).height(42.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true)
|
||||
.copy(brush = androidx.compose.ui.graphics.SolidColor(NsAccent.copy(0.5f))),
|
||||
) {
|
||||
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(18.dp), tint = NsAccent)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 13.sp, color = NsAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (artistState.isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
|
||||
// ── Scrollable content ────────────────────────────────────────────────
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
|
||||
// iTunes top tracks
|
||||
if (artistState.iTracks.isNotEmpty()) {
|
||||
item { ArtistSectionHeader("Weltweit populär (iTunes)") }
|
||||
itemsIndexed(artistState.iTracks, key = { _, t -> t.trackId ?: 0L }) { _, track ->
|
||||
ItunesArtistTrackRow(
|
||||
track = track,
|
||||
isPreviewing = previewingId == track.trackId,
|
||||
onPreview = { onItunesTrackPreview(track) },
|
||||
onDownload = { onDownload(track) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Downloaded albums ─────────────────────────────────────────────
|
||||
cats?.let { c ->
|
||||
if (c.downloaded.isNotEmpty()) {
|
||||
item { ArtistSectionHeader("In Library", c.downloaded.size) }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(c.downloaded) { (nd, _) ->
|
||||
AlbumThumb(
|
||||
name = nd.name,
|
||||
year = nd.year?.toString(),
|
||||
artUrl = coverUrl(nd.coverArt),
|
||||
onClick = { onAlbumClick(nd) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Partial albums ────────────────────────────────────────────
|
||||
if (c.partial.isNotEmpty()) {
|
||||
item { ArtistSectionHeader("Unvollständig", c.partial.size) }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(c.partial) { (nd, ia, counts) ->
|
||||
AlbumThumb(
|
||||
name = nd.name,
|
||||
year = nd.year?.toString(),
|
||||
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb") ?: coverUrl(nd.coverArt),
|
||||
badge = "${counts.first}/${counts.second}",
|
||||
onClick = { onPartialAlbumOpen(nd, ia) },
|
||||
onDownload = { onDownload(ia) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Not downloaded ────────────────────────────────────────────
|
||||
if (c.notDownloaded.isNotEmpty()) {
|
||||
item { ArtistSectionHeader("Nicht heruntergeladen", c.notDownloaded.size) }
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(c.notDownloaded, key = { it.collectionId ?: 0L }) { ia ->
|
||||
AlbumThumb(
|
||||
name = ia.collectionName ?: "—",
|
||||
year = ia.releaseDate?.take(4),
|
||||
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb"),
|
||||
dimmed = true,
|
||||
onClick = { onItunesAlbumOpen(ia) },
|
||||
onDownload = { onDownload(ia) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item { Spacer(Modifier.height(24.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun AlbumThumb(
|
||||
name: String,
|
||||
year: String?,
|
||||
artUrl: String?,
|
||||
badge: String? = null,
|
||||
dimmed: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
onDownload: (() -> Unit)? = null,
|
||||
) {
|
||||
Column(modifier = Modifier.width(130.dp)) {
|
||||
Box {
|
||||
AsyncImage(
|
||||
model = artUrl, contentDescription = name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(130.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(NsSurface2)
|
||||
.clickable(onClick = onClick)
|
||||
.then(if (dimmed) Modifier.background(Color.Black.copy(0.35f)) else Modifier),
|
||||
)
|
||||
// Track count badge for partial
|
||||
if (badge != null) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(6.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(Color.Black.copy(0.7f))
|
||||
.padding(horizontal = 5.dp, vertical = 2.dp),
|
||||
) {
|
||||
Text(badge, fontFamily = JetBrainsMono, fontSize = 10.sp, color = Color.White)
|
||||
}
|
||||
}
|
||||
// Download button (for partial/not-downloaded)
|
||||
if (onDownload != null) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(6.dp)
|
||||
.size(28.dp)
|
||||
.clip(CircleShape)
|
||||
.background(NsAccent)
|
||||
.clickable(onClick = onDownload),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.Default.Download, null,
|
||||
tint = Color.White, modifier = Modifier.size(14.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(5.dp))
|
||||
Text(name, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 12.sp, color = if (dimmed) NsOnSurface2 else NsOnSurface,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (!year.isNullOrBlank()) {
|
||||
Text(year, fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ItunesArtistTrackRow(
|
||||
track: ItunesItem,
|
||||
isPreviewing: Boolean,
|
||||
onPreview: () -> Unit,
|
||||
onDownload: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onPreview)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
val art = track.artworkUrl100?.replace("100x100bb", "60x60bb")
|
||||
AsyncImage(
|
||||
model = art, contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(4.dp)).background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(track.trackName ?: "", style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isPreviewing) NsAccent else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
listOfNotNull(track.collectionName).joinToString(" · "),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (track.previewUrl != null) {
|
||||
IconButton(onClick = onPreview, modifier = Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
if (isPreviewing) Icons.Default.Stop else Icons.Default.PlayCircle,
|
||||
null, tint = if (isPreviewing) NsAccent else NsOnSurface2,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDownload, modifier = Modifier.size(36.dp)) {
|
||||
Icon(Icons.Default.Download, null, tint = NsOnSurface2, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ArtistSectionHeader(title: String, count: Int? = null) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 16.dp, bottom = 10.dp, end = 16.dp),
|
||||
) {
|
||||
Text(title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = NsOnSurface2, modifier = Modifier.weight(1f))
|
||||
if (count != null) {
|
||||
Text("$count", fontFamily = Outfit, fontSize = 11.sp,
|
||||
color = NsOnSurface2.copy(0.5f),
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(NsSurface2)
|
||||
.padding(horizontal = 7.dp, vertical = 2.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Album
|
||||
import com.novasonic.app.api.model.Artist
|
||||
import com.novasonic.app.api.model.Playlist
|
||||
import com.novasonic.app.ui.component.AlbumCard
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.LibraryTab
|
||||
import com.novasonic.app.viewmodel.LibraryViewModel
|
||||
|
||||
@Composable
|
||||
fun LibraryScreen(
|
||||
coverUrl: (String?) -> String?,
|
||||
onAlbumClick: (Album) -> Unit,
|
||||
onArtistClick: (Artist) -> Unit,
|
||||
onPlaylistClick: (Playlist) -> Unit,
|
||||
) {
|
||||
val vm: LibraryViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
TabRow(
|
||||
selectedTabIndex = state.tab.ordinal,
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = NsAccent,
|
||||
) {
|
||||
LibraryTab.entries.forEach { tab ->
|
||||
Tab(
|
||||
selected = state.tab == tab,
|
||||
onClick = { vm.selectTab(tab) },
|
||||
text = {
|
||||
Text(
|
||||
when (tab) {
|
||||
LibraryTab.PLAYLISTS -> "Playlists"
|
||||
LibraryTab.ALBUMS -> "Alben"
|
||||
LibraryTab.ARTISTS -> "Künstler"
|
||||
},
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
selectedContentColor = NsAccent,
|
||||
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
|
||||
when (state.tab) {
|
||||
LibraryTab.ALBUMS -> LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 150.dp),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(state.albums, key = { it.id }) { album ->
|
||||
AlbumCard(
|
||||
album = album,
|
||||
coverUrl = coverUrl(album.coverArt),
|
||||
width = 150.dp,
|
||||
onClick = { onAlbumClick(album) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LibraryTab.ARTISTS -> LazyColumn(
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
state.artistIndexes.forEach { idx ->
|
||||
item(key = "header_${idx.name}") {
|
||||
Text(
|
||||
text = idx.name,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = NsAccent,
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
items(idx.artist, key = { it.id }) { artist ->
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(artist.name, fontFamily = Outfit,
|
||||
fontWeight = FontWeight.Medium, fontSize = 15.sp)
|
||||
},
|
||||
supportingContent = if (artist.albumCount > 0) { {
|
||||
Text("${artist.albumCount} Alben",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
} } else null,
|
||||
leadingContent = {
|
||||
AsyncImage(
|
||||
model = coverUrl(artist.coverArt),
|
||||
contentDescription = artist.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(52.dp)
|
||||
.clip(CircleShape)
|
||||
.background(NsSurface2),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().clickable { onArtistClick(artist) },
|
||||
colors = ListItemDefaults.colors(containerColor = NsBackground),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LibraryTab.PLAYLISTS -> LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 150.dp),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(state.playlists, key = { it.id }) { pl ->
|
||||
PlaylistCard(
|
||||
name = pl.name,
|
||||
subtitle = "${pl.songCount} Titel",
|
||||
coverUrl = coverUrl(pl.coverArt),
|
||||
onClick = { onPlaylistClick(pl) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaylistCard(name: String, subtitle: String, coverUrl: String?, onClick: () -> Unit) {
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(shape)
|
||||
.background(NsSurface)
|
||||
.border(1.dp, NsBorder, shape)
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = coverUrl,
|
||||
contentDescription = name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
Text(
|
||||
text = name,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 12.sp,
|
||||
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.novasonic.app.ui.theme.NsAccent
|
||||
import com.novasonic.app.ui.theme.NsOnSurface2
|
||||
import com.novasonic.app.viewmodel.LoginViewModel
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(onLoggedIn: () -> Unit) {
|
||||
val vm: LoginViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
|
||||
var serverUrl by remember { mutableStateOf("") }
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var showPw by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(state.success) {
|
||||
if (state.success) onLoggedIn()
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "NovaSonic",
|
||||
color = NsAccent,
|
||||
fontSize = 36.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Connect to your Navidrome server",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = NsOnSurface2,
|
||||
)
|
||||
Spacer(Modifier.height(40.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = serverUrl,
|
||||
onValueChange = { serverUrl = it },
|
||||
label = { Text("Server URL") },
|
||||
placeholder = { Text("https://music.example.com") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = fieldColors(),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = { Text("Username") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = fieldColors(),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("Password") },
|
||||
singleLine = true,
|
||||
visualTransformation = if (showPw) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showPw = !showPw }) {
|
||||
Icon(
|
||||
imageVector = if (showPw) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
contentDescription = null,
|
||||
tint = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = fieldColors(),
|
||||
)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
|
||||
if (state.error != null) {
|
||||
Text(
|
||||
text = state.error!!,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { vm.login(serverUrl.trim(), username.trim(), password) },
|
||||
enabled = !state.isLoading && serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
if (state.isLoading) {
|
||||
CircularProgressIndicator(Modifier.size(20.dp), color = MaterialTheme.colorScheme.background, strokeWidth = 2.dp)
|
||||
} else {
|
||||
Text("Connect", fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun fieldColors() = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NsAccent,
|
||||
focusedLabelColor = NsAccent,
|
||||
unfocusedBorderColor = NsOnSurface2,
|
||||
unfocusedLabelColor = NsOnSurface2,
|
||||
cursorColor = NsAccent,
|
||||
)
|
||||
@@ -0,0 +1,555 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import coil.request.ImageRequest
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.player.PlayerController
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.LyricsViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private enum class NpTab { PLAYER, LYRICS, QUEUE }
|
||||
|
||||
@Composable
|
||||
fun NowPlayingScreen(
|
||||
state: PlayerState,
|
||||
coverUrl: String?,
|
||||
queueCoverUrl: ((String?) -> String?)? = null,
|
||||
controller: PlayerController,
|
||||
onDismiss: () -> Unit,
|
||||
onStarToggle: (() -> Unit)? = null,
|
||||
onShowOptions: (() -> Unit)? = null,
|
||||
onArtistClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val song = state.currentSong
|
||||
var tab by remember { mutableStateOf(NpTab.PLAYER) }
|
||||
|
||||
val statusBarTop: Dp = with(LocalDensity.current) { WindowInsets.safeDrawing.getTop(this).toDp() }
|
||||
val topPad = maxOf(statusBarTop, 36.dp)
|
||||
val navBarBottom: Dp = with(LocalDensity.current) { WindowInsets.safeDrawing.getBottom(this).toDp() }
|
||||
|
||||
// Apple Music-style: cover scales up while playing
|
||||
val coverScale by animateFloatAsState(
|
||||
targetValue = if (state.isPlaying) 1f else 0.86f,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessLow,
|
||||
),
|
||||
label = "coverScale",
|
||||
)
|
||||
|
||||
val context = LocalContext.current
|
||||
Box(Modifier.fillMaxSize().background(NsBackground)) {
|
||||
// Blurred album art full-screen background — crossfade prevents transparent flash on song change
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context).data(coverUrl).crossfade(500).build(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize().blur(72.dp),
|
||||
)
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(0f to Color(0xCC000000), 1f to Color(0xF2000000))
|
||||
)
|
||||
)
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = topPad, bottom = maxOf(navBarBottom, 20.dp))
|
||||
.pointerInput(Unit) {
|
||||
var dragY = 0f
|
||||
detectVerticalDragGestures(
|
||||
onDragEnd = { if (dragY > 120f) onDismiss(); dragY = 0f },
|
||||
onDragCancel = { dragY = 0f },
|
||||
onVerticalDrag = { _, dy -> if (dy > 0) dragY += dy },
|
||||
)
|
||||
},
|
||||
) {
|
||||
// ── Top bar ────────────────────────────────────────────────────
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp)
|
||||
.padding(bottom = 16.dp),
|
||||
) {
|
||||
NpCircleBtn(onClick = onDismiss) {
|
||||
Icon(Icons.Default.KeyboardArrowDown, "Schließen",
|
||||
tint = Color.White, modifier = Modifier.size(24.dp))
|
||||
}
|
||||
|
||||
// Tab pills
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(Color.White.copy(alpha = 0.1f))
|
||||
.padding(3.dp),
|
||||
) {
|
||||
NpTab.entries.forEach { t ->
|
||||
val sel = tab == t
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(if (sel) Color.White.copy(0.18f) else Color.Transparent)
|
||||
.clickable { tab = t }
|
||||
.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
when (t) {
|
||||
NpTab.PLAYER -> "Player"
|
||||
NpTab.LYRICS -> "Lyrics"
|
||||
NpTab.QUEUE -> "Queue"
|
||||
},
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 11.sp,
|
||||
color = if (sel) Color.White else Color.White.copy(alpha = 0.4f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (onShowOptions != null) {
|
||||
NpCircleBtn(onClick = onShowOptions) {
|
||||
Icon(Icons.Default.MoreVert, "Optionen",
|
||||
tint = Color.White, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.size(36.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// ── PLAYER TAB ─────────────────────────────────────────────────
|
||||
if (tab == NpTab.PLAYER) {
|
||||
var dragX by remember { mutableStateOf(0f) }
|
||||
|
||||
// Cover art — full content width, scale-animates like Apple Music
|
||||
AsyncImage(
|
||||
model = coverUrl,
|
||||
contentDescription = song?.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.graphicsLayer { scaleX = coverScale; scaleY = coverScale }
|
||||
.shadow(40.dp, RoundedCornerShape(20.dp))
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(NsSurface2)
|
||||
.pointerInput(Unit) {
|
||||
detectHorizontalDragGestures(
|
||||
onDragEnd = {
|
||||
if (dragX < -80) controller.skipNext()
|
||||
else if (dragX > 80) controller.skipPrev()
|
||||
dragX = 0f
|
||||
},
|
||||
onDragCancel = { dragX = 0f },
|
||||
onHorizontalDrag = { _, d -> dragX += d },
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
// ── Song info ──────────────────────────────────────────────
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 28.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
song?.title ?: "Nichts spielt",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold, fontSize = 22.sp,
|
||||
letterSpacing = (-0.4).sp, color = Color.White,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.height(3.dp))
|
||||
if (!song?.artist.isNullOrBlank()) {
|
||||
Text(
|
||||
song!!.artist!!,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 15.sp, color = NsAccent,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = if (onArtistClick != null)
|
||||
Modifier.clickable(onClick = onArtistClick)
|
||||
else Modifier,
|
||||
)
|
||||
}
|
||||
if (!song?.album.isNullOrBlank()) {
|
||||
Text(
|
||||
song!!.album!!,
|
||||
fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = Color.White.copy(alpha = 0.45f),
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (onStarToggle != null && song != null) {
|
||||
val starred = song.starred != null
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(40.dp).clip(CircleShape)
|
||||
.clickable(onClick = onStarToggle),
|
||||
) {
|
||||
Icon(
|
||||
if (starred) Icons.Default.Star else Icons.Default.StarBorder,
|
||||
null,
|
||||
tint = if (starred) NsAccent else Color.White.copy(0.5f),
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
// ── Seek bar ───────────────────────────────────────────────
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 24.dp)) {
|
||||
val progress = if (state.durationMs > 0)
|
||||
(state.positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f) else 0f
|
||||
|
||||
NpSeekBar(
|
||||
progress = progress,
|
||||
onSeek = { controller.seekTo((it * state.durationMs).toLong()) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(formatMs(state.positionMs), fontFamily = JetBrainsMono, fontSize = 11.sp,
|
||||
color = Color.White.copy(0.4f))
|
||||
Text(formatMs(state.durationMs), fontFamily = JetBrainsMono, fontSize = 11.sp,
|
||||
color = Color.White.copy(0.4f))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// ── Controls ───────────────────────────────────────────────
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
) {
|
||||
NpCircleBtn(onClick = { controller.toggleShuffle() }) {
|
||||
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(22.dp),
|
||||
tint = if (state.shuffled) NsAccent else Color.White.copy(0.65f))
|
||||
}
|
||||
NpCircleBtn(onClick = { controller.skipPrev() }) {
|
||||
Icon(Icons.Default.SkipPrevious, null, modifier = Modifier.size(34.dp),
|
||||
tint = Color.White)
|
||||
}
|
||||
// Big play/pause
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(68.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White)
|
||||
.clickable { controller.togglePlay() },
|
||||
) {
|
||||
Icon(
|
||||
if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||
null,
|
||||
tint = Color.Black, modifier = Modifier.size(34.dp),
|
||||
)
|
||||
}
|
||||
NpCircleBtn(onClick = { controller.skipNext() }) {
|
||||
Icon(Icons.Default.SkipNext, null, modifier = Modifier.size(34.dp), tint = Color.White)
|
||||
}
|
||||
NpCircleBtn(onClick = { controller.toggleRepeat() }) {
|
||||
val (icon, tint) = when (state.repeatMode) {
|
||||
androidx.media3.common.Player.REPEAT_MODE_ONE -> Icons.Default.RepeatOne to NsAccent
|
||||
androidx.media3.common.Player.REPEAT_MODE_ALL -> Icons.Default.Repeat to NsAccent
|
||||
else -> Icons.Default.Repeat to Color.White.copy(0.65f)
|
||||
}
|
||||
Icon(icon, null, modifier = Modifier.size(22.dp), tint = tint)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ── LYRICS TAB ─────────────────────────────────────────────────
|
||||
if (tab == NpTab.LYRICS && song != null) {
|
||||
val lyricsVm: LyricsViewModel = viewModel()
|
||||
val lyricsState by lyricsVm.state.collectAsStateWithLifecycle()
|
||||
val listState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(song.id) { lyricsVm.loadForSong(song) }
|
||||
|
||||
val activeIdx = if (lyricsState.lines.isNotEmpty())
|
||||
lyricsState.lines.indexOfLast { it.timeMs <= state.positionMs }.coerceAtLeast(0)
|
||||
else -1
|
||||
|
||||
// Smooth scroll to active line
|
||||
LaunchedEffect(activeIdx) {
|
||||
if (activeIdx >= 0 && lyricsState.lines.isNotEmpty()) {
|
||||
scope.launch {
|
||||
listState.animateScrollToItem(
|
||||
(activeIdx - 2).coerceAtLeast(0),
|
||||
scrollOffset = 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
lyricsState.isLoading -> Box(
|
||||
Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(color = NsAccent.copy(0.7f),
|
||||
modifier = Modifier.size(28.dp), strokeWidth = 2.dp)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text("Lade Lyrics…", fontFamily = Outfit, fontSize = 13.sp,
|
||||
color = Color.White.copy(0.5f))
|
||||
}
|
||||
}
|
||||
|
||||
lyricsState.notFound -> Box(
|
||||
Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text("Keine Lyrics verfügbar", fontFamily = Outfit, fontSize = 14.sp,
|
||||
color = Color.White.copy(0.4f), textAlign = TextAlign.Center)
|
||||
}
|
||||
|
||||
lyricsState.lines.isNotEmpty() -> LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
contentPadding = PaddingValues(vertical = 40.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
itemsIndexed(lyricsState.lines) { idx, line ->
|
||||
val isActive = idx == activeIdx
|
||||
val isPast = idx < activeIdx
|
||||
|
||||
val targetColor = when {
|
||||
isActive -> Color.White
|
||||
isPast -> Color.White.copy(alpha = 0.32f)
|
||||
else -> Color.White.copy(alpha = 0.55f)
|
||||
}
|
||||
val animColor by animateColorAsState(
|
||||
targetValue = targetColor,
|
||||
animationSpec = tween(350),
|
||||
label = "lyricColor$idx",
|
||||
)
|
||||
val animSize by animateFloatAsState(
|
||||
targetValue = if (isActive) 20f else 16f,
|
||||
animationSpec = tween(300),
|
||||
label = "lyricSize$idx",
|
||||
)
|
||||
|
||||
Text(
|
||||
text = line.text,
|
||||
fontFamily = Outfit,
|
||||
fontWeight = if (isActive) FontWeight.ExtraBold else FontWeight.Normal,
|
||||
fontSize = animSize.sp,
|
||||
color = animColor,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { controller.seekTo(line.timeMs) }
|
||||
.padding(horizontal = 12.dp, vertical = 5.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
lyricsState.plainText.isNotBlank() -> LazyColumn(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
contentPadding = PaddingValues(vertical = 24.dp),
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = lyricsState.plainText,
|
||||
fontFamily = Outfit, fontSize = 15.sp,
|
||||
color = Color.White.copy(0.8f),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── QUEUE TAB ──────────────────────────────────────────────────
|
||||
if (tab == NpTab.QUEUE) {
|
||||
if (state.queue.isEmpty()) {
|
||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Text("Warteschlange leer", fontFamily = Outfit, fontSize = 14.sp,
|
||||
color = Color.White.copy(0.4f))
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
contentPadding = PaddingValues(vertical = 4.dp),
|
||||
) {
|
||||
itemsIndexed(state.queue, key = { idx, _ -> idx }) { idx, qSong ->
|
||||
val active = state.currentIndex == idx
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(if (active) Color.White.copy(0.08f) else Color.Transparent)
|
||||
.clickable { controller.seekToItem(idx) }
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = queueCoverUrl?.invoke(qSong.coverArt),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(Color.White.copy(0.08f)),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(qSong.title, fontFamily = Outfit, fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp, color = if (active) NsAccent else Color.White,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (!qSong.artist.isNullOrBlank()) {
|
||||
Text(qSong.artist!!, fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = Color.White.copy(0.4f),
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
if (active) {
|
||||
Icon(Icons.AutoMirrored.Filled.VolumeUp, null,
|
||||
tint = NsAccent, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NpSeekBar(
|
||||
progress: Float,
|
||||
onSeek: (Float) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
var dragProgress by remember { mutableStateOf(0f) }
|
||||
val displayProgress = if (isDragging) dragProgress else progress
|
||||
|
||||
val trackHeight by animateDpAsState(
|
||||
targetValue = if (isDragging) 5.dp else 3.dp,
|
||||
animationSpec = tween(120),
|
||||
label = "seekTrackHeight",
|
||||
)
|
||||
|
||||
BoxWithConstraints(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier
|
||||
.height(32.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures { offset ->
|
||||
val p = (offset.x / size.width.toFloat()).coerceIn(0f, 1f)
|
||||
onSeek(p)
|
||||
}
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
detectDragGestures(
|
||||
onDragStart = { offset ->
|
||||
isDragging = true
|
||||
dragProgress = (offset.x / size.width.toFloat()).coerceIn(0f, 1f)
|
||||
},
|
||||
onDragEnd = { onSeek(dragProgress); isDragging = false },
|
||||
onDragCancel = { isDragging = false },
|
||||
onDrag = { change, _ ->
|
||||
dragProgress = (change.position.x / size.width.toFloat()).coerceIn(0f, 1f)
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
// Background track
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(trackHeight)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(Color.White.copy(alpha = 0.2f))
|
||||
)
|
||||
// Filled portion
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(displayProgress.coerceIn(0f, 1f))
|
||||
.height(trackHeight)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(Color.White)
|
||||
.align(Alignment.CenterStart)
|
||||
)
|
||||
// Thumb — visible only while dragging
|
||||
if (isDragging) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(14.dp)
|
||||
.offset(x = (maxWidth * displayProgress - 7.dp).coerceIn(0.dp, maxWidth - 14.dp))
|
||||
.align(Alignment.CenterStart)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NpCircleBtn(onClick: () -> Unit, content: @Composable () -> Unit) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(44.dp).clip(CircleShape).clickable(onClick = onClick),
|
||||
) { content() }
|
||||
}
|
||||
|
||||
private fun formatMs(ms: Long): String {
|
||||
val s = (ms / 1000).coerceAtLeast(0)
|
||||
return "%d:%02d".format(s / 60, s % 60)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.ItunesItem
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.PartialAlbumState
|
||||
|
||||
private fun normTrack(s: String?) =
|
||||
(s ?: "").lowercase().replace(Regex("[^a-z0-9]"), "").trim()
|
||||
|
||||
@Composable
|
||||
fun PartialAlbumScreen(
|
||||
partialAlbum: PartialAlbumState,
|
||||
playerState: PlayerState,
|
||||
coverUrl: (String?) -> String?,
|
||||
previewingId: Long?,
|
||||
onPlay: (List<Song>, Int) -> Unit,
|
||||
onPreview: (ItunesItem) -> Unit,
|
||||
onDownload: (ItunesItem) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val ia = partialAlbum.itunesAlbum
|
||||
val nd = partialAlbum.ndAlbum
|
||||
|
||||
val artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb")
|
||||
?: coverUrl(nd.coverArt)
|
||||
|
||||
Column(Modifier.fillMaxSize().background(NsBackground)) {
|
||||
// ── Header ────────────────────────────────────────────────────────────
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 4.dp, top = 4.dp, end = 8.dp, bottom = 4.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück", tint = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
AsyncImage(
|
||||
model = artUrl, contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(42.dp).clip(RoundedCornerShape(6.dp)).background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
nd.name, fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
nd.artist ?: ia.artistName ?: "",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { onDownload(ia) }) {
|
||||
Icon(Icons.Default.Download, "Album laden", tint = NsAccent, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
|
||||
if (partialAlbum.isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
|
||||
// Cross-reference: which iTunes tracks have no ND match?
|
||||
val ndNames = partialAlbum.ndSongs.map { normTrack(it.title) }.toSet()
|
||||
val downloadedSongs = partialAlbum.ndSongs.sortedBy { it.track ?: 999 }
|
||||
val missingTracks = partialAlbum.iTracks
|
||||
.filter { normTrack(it.trackName) !in ndNames }
|
||||
.sortedBy { it.trackNumber ?: 999 }
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
// ── Downloaded tracks ─────────────────────────────────────────────
|
||||
if (downloadedSongs.isNotEmpty()) {
|
||||
item {
|
||||
PartialSectionHeader(
|
||||
title = "Heruntergeladen",
|
||||
count = downloadedSongs.size,
|
||||
accent = false,
|
||||
)
|
||||
}
|
||||
itemsIndexed(downloadedSongs, key = { _, s -> "dl_${s.id}" }) { idx, song ->
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = coverUrl(song.coverArt),
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
starred = song.starred != null,
|
||||
onPlay = { onPlay(downloadedSongs, idx) },
|
||||
onStarToggle = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Missing tracks (iTunes only) ──────────────────────────────────
|
||||
if (missingTracks.isNotEmpty()) {
|
||||
item {
|
||||
PartialSectionHeader(
|
||||
title = "Nicht heruntergeladen",
|
||||
count = missingTracks.size,
|
||||
accent = true,
|
||||
)
|
||||
}
|
||||
itemsIndexed(missingTracks, key = { _, t -> "miss_${t.trackId ?: 0L}" }) { _, track ->
|
||||
ItunesArtistTrackRow(
|
||||
track = track,
|
||||
isPreviewing = previewingId == track.trackId,
|
||||
onPreview = { onPreview(track) },
|
||||
onDownload = { onDownload(track) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (downloadedSongs.isEmpty() && missingTracks.isEmpty()) {
|
||||
item {
|
||||
Box(Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Keine Tracks gefunden", color = NsOnSurface2, fontFamily = Outfit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item { Spacer(Modifier.height(24.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PartialSectionHeader(title: String, count: Int, accent: Boolean) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 8.dp, end = 16.dp),
|
||||
) {
|
||||
Text(
|
||||
title,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = if (accent) NsOnSurface2.copy(0.7f) else NsOnSurface2,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
"$count",
|
||||
fontFamily = Outfit, fontSize = 11.sp,
|
||||
color = NsOnSurface2.copy(0.5f),
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(NsSurface2)
|
||||
.padding(horizontal = 7.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(0.4f),
|
||||
modifier = Modifier.padding(horizontal = 16.dp))
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Shuffle
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.PlaylistViewModel
|
||||
|
||||
@Composable
|
||||
fun PlaylistDetailScreen(
|
||||
coverUrl: (String?) -> String?,
|
||||
playerState: PlayerState,
|
||||
onPlay: (List<Song>, Int) -> Unit,
|
||||
onStarToggle: (Song) -> Unit,
|
||||
onShowContextMenu: ((Song) -> Unit)? = null,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val vm: PlaylistViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
|
||||
if (state.isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val pl = state.playlist ?: run {
|
||||
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
state.error ?: "Playlist nicht gefunden",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedButton(onClick = onBack) { Text("Zurück") }
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
item {
|
||||
// ── Hero ─────────────────────────────────────────────────────────
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
AsyncImage(
|
||||
model = coverUrl(pl.coverArt),
|
||||
contentDescription = pl.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.background(NsSurface2),
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0.0f to Color.Transparent,
|
||||
0.40f to NsBackground.copy(alpha = 0.35f),
|
||||
1.0f to NsBackground,
|
||||
)
|
||||
)
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.statusBarsPadding()
|
||||
.padding(start = 14.dp, top = 14.dp)
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.45f))
|
||||
.clickable(onClick = onBack)
|
||||
.align(Alignment.TopStart),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
||||
tint = Color.White, modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
Column(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(horizontal = 20.dp, vertical = 20.dp),
|
||||
) {
|
||||
Text(
|
||||
pl.name,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 26.sp, color = Color.White,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val meta = listOfNotNull(
|
||||
pl.owner?.takeIf { it.isNotBlank() },
|
||||
if (pl.songCount > 0) "${pl.songCount} Songs" else null,
|
||||
).joinToString(" · ")
|
||||
if (meta.isNotBlank()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
meta, fontFamily = Outfit, fontSize = 13.sp,
|
||||
color = Color.White.copy(alpha = 0.65f),
|
||||
)
|
||||
}
|
||||
if (!pl.comment.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
pl.comment!!, fontFamily = Outfit, fontSize = 12.sp,
|
||||
color = Color.White.copy(alpha = 0.45f),
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Play / Shuffle row ────────────────────────────────────────────
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { if (pl.entry.isNotEmpty()) onPlay(pl.entry, 0) },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Play", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { if (pl.entry.isNotEmpty()) onPlay(pl.entry.shuffled(), 0) },
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(20.dp), tint = NsAccent)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = NsBorder)
|
||||
}
|
||||
|
||||
itemsIndexed(pl.entry, key = { _, s -> s.id }) { idx, song ->
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = coverUrl(song.coverArt),
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
starred = song.starred != null,
|
||||
onPlay = { onPlay(pl.entry, idx) },
|
||||
onStarToggle = { onStarToggle(song) },
|
||||
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.novasonic.app.api.model.Album
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.NsAccent
|
||||
import com.novasonic.app.ui.theme.NsOnSurface2
|
||||
import com.novasonic.app.viewmodel.SearchViewModel
|
||||
|
||||
@Composable
|
||||
fun SearchScreen(
|
||||
coverUrl: (String?) -> String?,
|
||||
playerState: PlayerState,
|
||||
onSongPlay: (List<Song>, Int) -> Unit,
|
||||
onAlbumClick: (Album) -> Unit,
|
||||
) {
|
||||
val vm: SearchViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
SearchBar(
|
||||
query = state.query,
|
||||
onQueryChange = vm::onQueryChange,
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
)
|
||||
|
||||
when {
|
||||
state.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
state.query.isBlank() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(Icons.Default.Search, contentDescription = null,
|
||||
tint = NsOnSurface2, modifier = Modifier.size(48.dp))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("Search songs, albums, artists",
|
||||
style = MaterialTheme.typography.bodyMedium, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
state.searchResult != null -> {
|
||||
val r = state.searchResult!!
|
||||
LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
|
||||
if (r.song.isNotEmpty()) {
|
||||
item { ResultHeader("Songs") }
|
||||
items(r.song, key = { it.id }) { song ->
|
||||
val idx = r.song.indexOf(song)
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = coverUrl(song.coverArt),
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
onPlay = { onSongPlay(r.song, idx) },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (r.album.isNotEmpty()) {
|
||||
item { ResultHeader("Albums") }
|
||||
items(r.album, key = { it.id }) { album ->
|
||||
ListItem(
|
||||
headlineContent = { Text(album.name) },
|
||||
supportingContent = { Text(album.artist ?: "",
|
||||
style = MaterialTheme.typography.labelSmall) },
|
||||
modifier = Modifier.fillMaxWidth().clickable { onAlbumClick(album) },
|
||||
colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.background),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (r.artist.isNotEmpty()) {
|
||||
item { ResultHeader("Artists") }
|
||||
items(r.artist, key = { it.id }) { artist ->
|
||||
ListItem(
|
||||
headlineContent = { Text(artist.name) },
|
||||
supportingContent = { Text("${artist.albumCount} albums",
|
||||
style = MaterialTheme.typography.labelSmall) },
|
||||
colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.background),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchBar(query: String, onQueryChange: (String) -> Unit, modifier: Modifier) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
placeholder = { Text("Search…") },
|
||||
leadingIcon = { Icon(Icons.Default.Search, null, tint = NsOnSurface2) },
|
||||
singleLine = true,
|
||||
modifier = modifier,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NsAccent,
|
||||
unfocusedBorderColor = NsOnSurface2,
|
||||
cursorColor = NsAccent,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResultHeader(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = NsAccent,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.SettingsViewModel
|
||||
|
||||
private val SCROBBLE_OPTIONS = listOf(
|
||||
"15s" to "15 Sekunden",
|
||||
"30s" to "30 Sekunden",
|
||||
"third" to "1/3 der Song-Länge",
|
||||
"two_thirds" to "2/3 der Song-Länge",
|
||||
"end" to "Song-Ende",
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen() {
|
||||
val vm: SettingsViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
|
||||
var showPassword by remember { mutableStateOf(false) }
|
||||
var showLogoutDialog by remember { mutableStateOf(false) }
|
||||
var scrobbleExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
if (showLogoutDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showLogoutDialog = false },
|
||||
title = { Text("Abmelden?", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
|
||||
text = { Text("Wirklich abmelden? Alle lokalen Einstellungen werden gelöscht.", fontFamily = Outfit) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { vm.logout(); showLogoutDialog = false }) {
|
||||
Text("Abmelden", color = MaterialTheme.colorScheme.error, fontFamily = Outfit)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showLogoutDialog = false }) {
|
||||
Text("Abbrechen", fontFamily = Outfit)
|
||||
}
|
||||
},
|
||||
containerColor = NsSurface,
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp, vertical = 24.dp),
|
||||
) {
|
||||
Text(
|
||||
"Einstellungen",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 24.sp,
|
||||
color = NsOnSurface,
|
||||
)
|
||||
|
||||
// ── 🌐 Navidrome Server ──────────────────────────────────────────────
|
||||
SectionHeader("🌐 Navidrome Server")
|
||||
|
||||
NsTextField(
|
||||
value = state.serverUrl,
|
||||
onValueChange = vm::onServerUrl,
|
||||
label = "Server URL",
|
||||
placeholder = "https://musik.beispiel.de",
|
||||
keyboardType = KeyboardType.Uri,
|
||||
)
|
||||
NsTextField(
|
||||
value = state.username,
|
||||
onValueChange = vm::onUsername,
|
||||
label = "Benutzername",
|
||||
)
|
||||
NsTextField(
|
||||
value = state.password,
|
||||
onValueChange = vm::onPassword,
|
||||
label = "Passwort",
|
||||
hint = "(unverändert lassen wenn kein Reset gewünscht)",
|
||||
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardType = KeyboardType.Password,
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showPassword = !showPassword }) {
|
||||
Icon(
|
||||
if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
contentDescription = null, tint = NsOnSurface2,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// ── 📊 Bibliothek ────────────────────────────────────────────────────
|
||||
SectionHeader("📊 Bibliothek")
|
||||
|
||||
// Scrobble-Mode dropdown
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = scrobbleExpanded,
|
||||
onExpandedChange = { scrobbleExpanded = it },
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = SCROBBLE_OPTIONS.firstOrNull { it.first == state.scrobbleMode }?.second
|
||||
?: "30 Sekunden",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Scrobble senden nach", fontFamily = Outfit) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(scrobbleExpanded) },
|
||||
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable, true).fillMaxWidth(),
|
||||
colors = fieldColors(),
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = scrobbleExpanded,
|
||||
onDismissRequest = { scrobbleExpanded = false },
|
||||
containerColor = NsSurface2,
|
||||
) {
|
||||
SCROBBLE_OPTIONS.forEach { (key, label) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(label, fontFamily = Outfit, color = NsOnSurface) },
|
||||
onClick = { vm.onScrobbleMode(key); scrobbleExpanded = false },
|
||||
colors = MenuDefaults.itemColors(
|
||||
textColor = if (key == state.scrobbleMode) NsAccent else NsOnSurface
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show play count toggle
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Abspielhäufigkeit anzeigen", fontFamily = Outfit,
|
||||
fontWeight = FontWeight.Medium, fontSize = 14.sp, color = NsOnSurface)
|
||||
Text("Zeigt Wiedergabezähler in Song-Reihen an",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
Switch(
|
||||
checked = state.showPlayCount,
|
||||
onCheckedChange = vm::onShowPlayCount,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = NsBackground,
|
||||
checkedTrackColor = NsAccent,
|
||||
uncheckedThumbColor = NsOnSurface2,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// ── 🎨 Theme ─────────────────────────────────────────────────────────
|
||||
SectionHeader("🎨 Theme")
|
||||
ThemePicker(
|
||||
currentThemeId = state.theme,
|
||||
onSelect = { id ->
|
||||
vm.onTheme(id)
|
||||
applyNsTheme(id)
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// ── 🔑 APIs ──────────────────────────────────────────────────────────
|
||||
SectionHeader("🔑 APIs")
|
||||
|
||||
NsTextField(
|
||||
value = state.lastFmKey,
|
||||
onValueChange = vm::onLastFmKey,
|
||||
label = "Last.fm API Key",
|
||||
hint = "Kostenlos unter last.fm/api/account/create",
|
||||
)
|
||||
NsTextField(
|
||||
value = state.backendUrl,
|
||||
onValueChange = vm::onBackendUrl,
|
||||
label = "NovaSonic Backend URL",
|
||||
hint = "Für Download-to-Server via yt-dlp",
|
||||
keyboardType = KeyboardType.Uri,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Status + Save
|
||||
if (state.error != null) {
|
||||
Text(state.error!!, color = MaterialTheme.colorScheme.error,
|
||||
fontFamily = Outfit, fontSize = 13.sp)
|
||||
}
|
||||
if (state.savedOk) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.Check, null, tint = NsAccent, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Gespeichert", color = NsAccent, fontFamily = Outfit, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = vm::save,
|
||||
enabled = !state.isSaving,
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = NsBackground),
|
||||
) {
|
||||
if (state.isSaving) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = NsBackground)
|
||||
} else {
|
||||
Text("Speichern", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 15.sp)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp), color = NsBorder)
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { showLogoutDialog = true },
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder.copy(
|
||||
brush = androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.error)
|
||||
),
|
||||
) {
|
||||
Text("Abmelden", color = MaterialTheme.colorScheme.error,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 11.sp,
|
||||
letterSpacing = 0.5.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
|
||||
)
|
||||
HorizontalDivider(color = NsBorder)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NsTextField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
label: String,
|
||||
placeholder: String = "",
|
||||
hint: String = "",
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
visualTransformation: VisualTransformation = VisualTransformation.None,
|
||||
trailingIcon: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = { Text(label, fontFamily = Outfit) },
|
||||
placeholder = if (placeholder.isNotBlank()) { { Text(placeholder, color = NsOnSurface2) } } else null,
|
||||
singleLine = true,
|
||||
visualTransformation = visualTransformation,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
|
||||
trailingIcon = trailingIcon,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = fieldColors(),
|
||||
)
|
||||
if (hint.isNotBlank()) {
|
||||
Text(hint, fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(start = 4.dp, top = 2.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemePicker(currentThemeId: String, onSelect: (String) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
NS_THEMES.forEach { theme ->
|
||||
val isActive = theme.id == currentThemeId
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(9.dp))
|
||||
.background(if (isActive) theme.surface else NsSurface)
|
||||
.border(
|
||||
width = if (isActive) 2.dp else 1.dp,
|
||||
color = if (isActive) theme.accent else NsBorder,
|
||||
shape = RoundedCornerShape(9.dp),
|
||||
)
|
||||
.clickable { onSelect(theme.id) }
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
// Accent dot
|
||||
Box(
|
||||
Modifier
|
||||
.size(22.dp)
|
||||
.clip(CircleShape)
|
||||
.background(theme.accent),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
text = theme.name,
|
||||
fontFamily = Outfit,
|
||||
fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
color = if (isActive) theme.onSurface else NsOnSurface2,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (isActive) {
|
||||
Icon(
|
||||
Icons.Default.Check, null,
|
||||
tint = theme.accent,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun fieldColors() = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NsAccent,
|
||||
focusedLabelColor = NsAccent,
|
||||
cursorColor = NsAccent,
|
||||
unfocusedBorderColor = NsOnSurface3,
|
||||
unfocusedLabelColor = NsOnSurface2,
|
||||
focusedTextColor = NsOnSurface,
|
||||
unfocusedTextColor = NsOnSurface,
|
||||
)
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.SongRow
|
||||
import com.novasonic.app.ui.theme.*
|
||||
|
||||
enum class StarredSort { NEWEST, OLDEST, AZ, BY_ARTIST }
|
||||
|
||||
@Composable
|
||||
fun StarredScreen(
|
||||
coverUrl: (String?) -> String?,
|
||||
playerState: PlayerState,
|
||||
onPlay: (List<Song>, Int) -> Unit,
|
||||
onStarToggle: (Song) -> Unit,
|
||||
onShowContextMenu: ((Song) -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val repo = context.app.repository
|
||||
|
||||
var songs by remember { mutableStateOf<List<Song>>(emptyList()) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var sort by remember { mutableStateOf(StarredSort.NEWEST) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
try {
|
||||
songs = repo.getStarred().song
|
||||
loading = false
|
||||
} catch (e: Exception) {
|
||||
error = e.message
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
val sorted = remember(songs, sort) {
|
||||
when (sort) {
|
||||
StarredSort.NEWEST -> songs.sortedByDescending { it.starred ?: "" }
|
||||
StarredSort.OLDEST -> songs.sortedBy { it.starred ?: "" }
|
||||
StarredSort.AZ -> songs.sortedBy { it.title.lowercase() }
|
||||
StarredSort.BY_ARTIST -> songs.sortedBy { "${it.artist?.lowercase()}${it.title.lowercase()}" }
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Text(
|
||||
"Favoriten",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 26.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 24.dp, bottom = 8.dp),
|
||||
)
|
||||
|
||||
// Sort chips
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 12.dp),
|
||||
) {
|
||||
StarredSort.entries.forEach { s ->
|
||||
FilterChip(
|
||||
selected = sort == s,
|
||||
onClick = { sort = s },
|
||||
label = {
|
||||
Text(when (s) {
|
||||
StarredSort.NEWEST -> "Neueste"
|
||||
StarredSort.OLDEST -> "Älteste"
|
||||
StarredSort.AZ -> "A-Z"
|
||||
StarredSort.BY_ARTIST -> "Künstler"
|
||||
}, fontSize = 12.sp)
|
||||
},
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
|
||||
selectedLabelColor = NsAccent,
|
||||
selectedLeadingIconColor = NsAccent,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
|
||||
if (loading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
} else if (error != null) {
|
||||
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text(error!!, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
} else if (sorted.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text("Noch keine Favoriten", color = NsOnSurface2)
|
||||
}
|
||||
} else {
|
||||
LazyColumn {
|
||||
itemsIndexed(sorted, key = { _, s -> s.id }) { idx, song ->
|
||||
SongRow(
|
||||
song = song,
|
||||
coverUrl = coverUrl(song.coverArt),
|
||||
isPlaying = playerState.currentSong?.id == song.id,
|
||||
starred = song.starred != null,
|
||||
onPlay = { onPlay(sorted, idx) },
|
||||
onStarToggle = { onStarToggle(song) },
|
||||
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.novasonic.app.ui.theme
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// ── Dynamic theme state — Compose tracks reads automatically ─────────────────
|
||||
object NsColors {
|
||||
var accent by mutableStateOf(Color(0xFF4F8EF7))
|
||||
var accentDim by mutableStateOf(Color(0xFF2A6CD6))
|
||||
var bg by mutableStateOf(Color(0xFF070D14))
|
||||
var bg2 by mutableStateOf(Color(0xFF0C1520))
|
||||
var surface by mutableStateOf(Color(0xFF182435))
|
||||
var surface2 by mutableStateOf(Color(0xFF1F2F42))
|
||||
var onSurface by mutableStateOf(Color(0xFFEDF2FF))
|
||||
var onSurface2 by mutableStateOf(Color(0xFF7088AA))
|
||||
var onSurface3 by mutableStateOf(Color(0xFF445566))
|
||||
var border by mutableStateOf(Color(0x12FFFFFF))
|
||||
var error by mutableStateOf(Color(0xFFCF6679))
|
||||
}
|
||||
|
||||
// Convenience accessors — all reads in composables are state-tracked
|
||||
val NsBackground get() = NsColors.bg
|
||||
val NsBg2 get() = NsColors.bg2
|
||||
val NsSurface get() = NsColors.surface
|
||||
val NsSurface2 get() = NsColors.surface2
|
||||
val NsAccent get() = NsColors.accent
|
||||
val NsAccentDim get() = NsColors.accentDim
|
||||
val NsOnSurface get() = NsColors.onSurface
|
||||
val NsOnSurface2 get() = NsColors.onSurface2
|
||||
val NsOnSurface3 get() = NsColors.onSurface3
|
||||
val NsBorder get() = NsColors.border
|
||||
val NsError get() = NsColors.error
|
||||
|
||||
// ── Theme definitions — mirrors src/themes.js ────────────────────────────────
|
||||
data class NsTheme(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val accent: Color,
|
||||
val accentDim: Color,
|
||||
val bg: Color,
|
||||
val bg2: Color,
|
||||
val surface: Color,
|
||||
val surface2: Color,
|
||||
val onSurface: Color,
|
||||
val onSurface2: Color,
|
||||
val onSurface3: Color,
|
||||
)
|
||||
|
||||
val NS_THEMES = listOf(
|
||||
NsTheme(
|
||||
"dark-ocean", "Dark Ocean",
|
||||
Color(0xFF4F8EF7), Color(0xFF2A6CD6),
|
||||
Color(0xFF070D14), Color(0xFF0C1520),
|
||||
Color(0xFF182435), Color(0xFF1F2F42),
|
||||
Color(0xFFEDF2FF), Color(0xFF7088AA), Color(0xFF445566),
|
||||
),
|
||||
NsTheme(
|
||||
"dark-amber", "Dark Amber",
|
||||
Color(0xFFE8A020), Color(0xFFC07010),
|
||||
Color(0xFF07070F), Color(0xFF0D0D1A),
|
||||
Color(0xFF1A1A2E), Color(0xFF22223A),
|
||||
Color(0xFFF0EEFC), Color(0xFF8888AA), Color(0xFF555577),
|
||||
),
|
||||
NsTheme(
|
||||
"forest", "Forest",
|
||||
Color(0xFF4CAF7D), Color(0xFF2D7A52),
|
||||
Color(0xFF060D08), Color(0xFF0B1410),
|
||||
Color(0xFF162218), Color(0xFF1D2E20),
|
||||
Color(0xFFEDFAF2), Color(0xFF7A9E85), Color(0xFF455C4A),
|
||||
),
|
||||
NsTheme(
|
||||
"rose-pine", "Rosé Pine",
|
||||
Color(0xFFEB6F92), Color(0xFFB8415E),
|
||||
Color(0xFF191724), Color(0xFF1F1D2E),
|
||||
Color(0xFF21202E), Color(0xFF403D52),
|
||||
Color(0xFFE0DEF4), Color(0xFF908CAA), Color(0xFF6E6A86),
|
||||
),
|
||||
NsTheme(
|
||||
"jade-green", "Jade",
|
||||
Color(0xFF2ECC71), Color(0xFF1F9E55),
|
||||
Color(0xFF07100C), Color(0xFF0C1813),
|
||||
Color(0xFF162A21), Color(0xFF1E3A2D),
|
||||
Color(0xFFE8FFF1), Color(0xFF8FBFA4), Color(0xFF557A63),
|
||||
),
|
||||
)
|
||||
|
||||
fun applyNsTheme(themeId: String) {
|
||||
val t = NS_THEMES.find { it.id == themeId } ?: NS_THEMES.first()
|
||||
NsColors.accent = t.accent
|
||||
NsColors.accentDim = t.accentDim
|
||||
NsColors.bg = t.bg
|
||||
NsColors.bg2 = t.bg2
|
||||
NsColors.surface = t.surface
|
||||
NsColors.surface2 = t.surface2
|
||||
NsColors.onSurface = t.onSurface
|
||||
NsColors.onSurface2 = t.onSurface2
|
||||
NsColors.onSurface3 = t.onSurface3
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.novasonic.app.ui.theme
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
fun NovaSonicTheme(content: @Composable () -> Unit) {
|
||||
// Reads NsColors mutableState — recomposes automatically when theme changes
|
||||
val colorScheme = darkColorScheme(
|
||||
background = NsColors.bg,
|
||||
surface = NsColors.surface,
|
||||
surfaceVariant = NsColors.surface2,
|
||||
primary = NsColors.accent,
|
||||
onPrimary = NsColors.bg,
|
||||
secondary = NsColors.accentDim,
|
||||
onSecondary = NsColors.bg,
|
||||
onBackground = NsColors.onSurface,
|
||||
onSurface = NsColors.onSurface,
|
||||
onSurfaceVariant = NsColors.onSurface2,
|
||||
outline = NsColors.border,
|
||||
error = NsColors.error,
|
||||
)
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = NovaSonicTypography,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.novasonic.app.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.googlefonts.Font
|
||||
import androidx.compose.ui.text.googlefonts.GoogleFont
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.novasonic.app.R
|
||||
|
||||
private val provider = GoogleFont.Provider(
|
||||
providerAuthority = "com.google.android.gms.fonts",
|
||||
providerPackage = "com.google.android.gms",
|
||||
certificates = R.array.com_google_android_gms_fonts_certs,
|
||||
)
|
||||
|
||||
private val OutfitFont = GoogleFont("Outfit")
|
||||
private val JetBrainsMonoFont = GoogleFont("JetBrains Mono")
|
||||
|
||||
val Outfit = FontFamily(
|
||||
Font(OutfitFont, provider, weight = androidx.compose.ui.text.font.FontWeight.Light),
|
||||
Font(OutfitFont, provider, weight = androidx.compose.ui.text.font.FontWeight.Normal),
|
||||
Font(OutfitFont, provider, weight = androidx.compose.ui.text.font.FontWeight.Medium),
|
||||
Font(OutfitFont, provider, weight = androidx.compose.ui.text.font.FontWeight.SemiBold),
|
||||
Font(OutfitFont, provider, weight = androidx.compose.ui.text.font.FontWeight.Bold),
|
||||
Font(OutfitFont, provider, weight = androidx.compose.ui.text.font.FontWeight.ExtraBold),
|
||||
)
|
||||
|
||||
val JetBrainsMono = FontFamily(
|
||||
Font(JetBrainsMonoFont, provider, weight = androidx.compose.ui.text.font.FontWeight.Normal),
|
||||
Font(JetBrainsMonoFont, provider, weight = androidx.compose.ui.text.font.FontWeight.Medium),
|
||||
)
|
||||
|
||||
val NovaSonicTypography = Typography(
|
||||
displayLarge = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 36.sp, letterSpacing = (-0.5).sp),
|
||||
displayMedium = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 28.sp, letterSpacing = (-0.25).sp),
|
||||
titleLarge = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 22.sp, letterSpacing = 0.sp),
|
||||
titleMedium = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, letterSpacing = 0.15.sp),
|
||||
titleSmall = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Medium, fontSize = 14.sp, letterSpacing = 0.1.sp),
|
||||
bodyLarge = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Normal, fontSize = 16.sp, letterSpacing = 0.5.sp),
|
||||
bodyMedium = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Normal, fontSize = 14.sp, letterSpacing = 0.25.sp),
|
||||
bodySmall = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Normal, fontSize = 12.sp, letterSpacing = 0.4.sp),
|
||||
labelLarge = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Medium, fontSize = 14.sp, letterSpacing = 0.1.sp),
|
||||
labelMedium = TextStyle(fontFamily = JetBrainsMono, fontWeight = FontWeight.Normal, fontSize = 12.sp, letterSpacing = 0.5.sp, color = NsOnSurface2),
|
||||
labelSmall = TextStyle(fontFamily = JetBrainsMono, fontWeight = FontWeight.Normal, fontSize = 11.sp, letterSpacing = 0.5.sp, color = NsOnSurface2),
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.model.Album
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class AlbumUiState(
|
||||
val album: Album? = null,
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class AlbumViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
private val albumId: String? = savedState["albumId"]
|
||||
|
||||
private val _state = MutableStateFlow(AlbumUiState())
|
||||
val state: StateFlow<AlbumUiState> = _state
|
||||
|
||||
init {
|
||||
val id = albumId
|
||||
if (id.isNullOrBlank()) {
|
||||
_state.value = AlbumUiState(isLoading = false, error = "Keine Album-ID übergeben")
|
||||
} else {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val album = repo.getAlbum(id)
|
||||
_state.value = AlbumUiState(album = album, isLoading = false)
|
||||
} catch (e: Exception) {
|
||||
_state.value = AlbumUiState(error = e.message ?: e.toString(), isLoading = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import android.media.MediaPlayer
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.ImporterClient
|
||||
import com.novasonic.app.api.ItunesClient
|
||||
import com.novasonic.app.api.model.Artist
|
||||
import com.novasonic.app.api.model.ArtistInfo
|
||||
import com.novasonic.app.api.model.DownloadRequest
|
||||
import com.novasonic.app.api.model.ItunesItem
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
data class ArtistUiState(
|
||||
val artist: Artist? = null,
|
||||
val info: ArtistInfo? = null,
|
||||
val topSongs: List<Song> = emptyList(),
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null,
|
||||
// iTunes
|
||||
val iAlbums: List<ItunesItem> = emptyList(),
|
||||
val iTracks: List<ItunesItem> = emptyList(),
|
||||
val itunesLoading: Boolean = false,
|
||||
// Sub-album detail (tapped not-downloaded album)
|
||||
val subAlbumItem: ItunesItem? = null,
|
||||
val subAlbumTracks: List<ItunesItem>? = null,
|
||||
val subAlbumTitle: String = "",
|
||||
// Partial album detail (tapped incomplete album — shows downloaded + missing tracks)
|
||||
val partialAlbum: PartialAlbumState? = null,
|
||||
// Preview + download
|
||||
val previewingId: Long? = null,
|
||||
val downloadSheetItem: ItunesItem? = null,
|
||||
)
|
||||
|
||||
class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
private val artistId: String? = savedState["artistId"]
|
||||
|
||||
private val _state = MutableStateFlow(ArtistUiState())
|
||||
val state: StateFlow<ArtistUiState> = _state
|
||||
|
||||
private var mediaPlayer: MediaPlayer? = null
|
||||
|
||||
init {
|
||||
if (artistId.isNullOrBlank()) {
|
||||
_state.value = ArtistUiState(isLoading = false, error = "Keine Künstler-ID übergeben")
|
||||
} else {
|
||||
load()
|
||||
}
|
||||
}
|
||||
|
||||
fun load() {
|
||||
val artistId = artistId ?: return
|
||||
viewModelScope.launch {
|
||||
_state.value = ArtistUiState(isLoading = true, itunesLoading = true)
|
||||
try {
|
||||
val artistDef = async { repo.getArtist(artistId) }
|
||||
val infoDef = async { runCatching { repo.getArtistInfo(artistId) }.getOrNull() }
|
||||
|
||||
val artist = artistDef.await()
|
||||
val info = infoDef.await()
|
||||
|
||||
val topDef = async { runCatching { repo.getTopSongs(artist.name) }.getOrDefault(emptyList()) }
|
||||
val top = topDef.await()
|
||||
|
||||
_state.value = ArtistUiState(
|
||||
artist = artist, info = info, topSongs = top,
|
||||
isLoading = false, itunesLoading = true,
|
||||
)
|
||||
|
||||
// Fetch iTunes data without blocking the main screen
|
||||
launch { loadItunesData(artist.name) }
|
||||
} catch (e: Exception) {
|
||||
_state.value = ArtistUiState(isLoading = false, itunesLoading = false, error = e.message ?: e.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadItunesData(artistName: String) {
|
||||
val searchResp = runCatching {
|
||||
ItunesClient.search(artistName, entity = "musicArtist", limit = 5)
|
||||
}.getOrNull()
|
||||
|
||||
val iArtistId = searchResp?.results
|
||||
?.filter { it.wrapperType == "artist" }
|
||||
?.find {
|
||||
it.artistName?.equals(artistName, ignoreCase = true) == true ||
|
||||
it.artistName?.contains(artistName, ignoreCase = true) == true
|
||||
}
|
||||
?.artistId
|
||||
|
||||
if (iArtistId != null) {
|
||||
coroutineScope {
|
||||
val iAlbumsD = async { runCatching { ItunesClient.lookupArtistAlbums(iArtistId) }.getOrDefault(emptyList()) }
|
||||
val iTracksD = async { runCatching { ItunesClient.lookupArtistTopTracks(iArtistId, 10) }.getOrDefault(emptyList()) }
|
||||
_state.value = _state.value.copy(
|
||||
iAlbums = iAlbumsD.await(),
|
||||
iTracks = iTracksD.await(),
|
||||
itunesLoading = false,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_state.value = _state.value.copy(itunesLoading = false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sub-album (not-downloaded iTunes album) ──────────────────────────────
|
||||
|
||||
fun loadSubAlbum(album: ItunesItem) {
|
||||
val id = album.collectionId ?: return
|
||||
_state.value = _state.value.copy(
|
||||
subAlbumItem = album,
|
||||
subAlbumTitle = album.collectionName ?: "",
|
||||
subAlbumTracks = null,
|
||||
)
|
||||
viewModelScope.launch {
|
||||
val tracks = runCatching { ItunesClient.lookupAlbum(id) }.getOrDefault(emptyList())
|
||||
_state.value = _state.value.copy(subAlbumTracks = tracks)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissSubAlbum() {
|
||||
_state.value = _state.value.copy(subAlbumItem = null, subAlbumTracks = null, subAlbumTitle = "")
|
||||
}
|
||||
|
||||
// ── Partial album (incomplete album: shows downloaded + missing iTunes tracks) ──
|
||||
|
||||
fun loadPartialAlbum(nd: com.novasonic.app.api.model.Album, ia: ItunesItem) {
|
||||
_state.value = _state.value.copy(
|
||||
partialAlbum = PartialAlbumState(ndAlbum = nd, itunesAlbum = ia, isLoading = true),
|
||||
)
|
||||
viewModelScope.launch {
|
||||
coroutineScope {
|
||||
val ndSongsD = async { runCatching { repo.getAlbum(nd.id).song }.getOrDefault(emptyList()) }
|
||||
val iTracksD = async {
|
||||
ia.collectionId?.let { id ->
|
||||
runCatching { ItunesClient.lookupAlbum(id) }.getOrDefault(emptyList())
|
||||
} ?: emptyList()
|
||||
}
|
||||
_state.value = _state.value.copy(
|
||||
partialAlbum = PartialAlbumState(
|
||||
ndAlbum = nd,
|
||||
itunesAlbum = ia,
|
||||
ndSongs = ndSongsD.await(),
|
||||
iTracks = iTracksD.await(),
|
||||
isLoading = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissPartialAlbum() {
|
||||
_state.value = _state.value.copy(partialAlbum = null)
|
||||
}
|
||||
|
||||
// ── Download sheet ───────────────────────────────────────────────────────
|
||||
|
||||
fun showDownloadSheet(item: ItunesItem) {
|
||||
_state.value = _state.value.copy(downloadSheetItem = item)
|
||||
}
|
||||
|
||||
fun dismissDownloadSheet() {
|
||||
_state.value = _state.value.copy(downloadSheetItem = null)
|
||||
}
|
||||
|
||||
fun downloadItem(item: ItunesItem, type: String, onDone: (Boolean, String) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
|
||||
if (backendUrl.isBlank()) {
|
||||
onDone(false, "Kein Backend konfiguriert — bitte in Einstellungen eintragen")
|
||||
return@launch
|
||||
}
|
||||
val jobId = "job_${System.currentTimeMillis()}"
|
||||
val artwork = item.artworkUrl100?.replace("100x100bb", "600x600bb")
|
||||
val request = when (type) {
|
||||
"album" -> DownloadRequest(
|
||||
jobId = jobId, type = "album",
|
||||
title = item.collectionName ?: item.artistName ?: "Unknown",
|
||||
artist = item.artistName, artworkUrl = artwork,
|
||||
collectionId = item.collectionId, itunesId = item.collectionId,
|
||||
)
|
||||
"artist" -> DownloadRequest(
|
||||
jobId = jobId, type = "artist",
|
||||
title = item.artistName ?: "Unknown",
|
||||
artist = item.artistName, artworkUrl = artwork,
|
||||
)
|
||||
else -> DownloadRequest(
|
||||
jobId = jobId, type = "track",
|
||||
title = item.trackName ?: item.collectionName ?: "Unknown",
|
||||
artist = item.artistName, album = item.collectionName,
|
||||
artworkUrl = artwork, itunesId = item.trackId, collectionId = item.collectionId,
|
||||
)
|
||||
}
|
||||
val client = ImporterClient(backendUrl)
|
||||
runCatching { client.download(request) }
|
||||
.onSuccess { onDone(true, "✓ Download gestartet") }
|
||||
.onFailure { onDone(false, "Backend nicht erreichbar: ${it.message}") }
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
// ── iTunes 30s preview ───────────────────────────────────────────────────
|
||||
|
||||
fun togglePreview(track: ItunesItem) {
|
||||
val url = track.previewUrl ?: return
|
||||
val id = track.trackId ?: return
|
||||
if (_state.value.previewingId == id) stopPreview() else startPreview(url, id)
|
||||
}
|
||||
|
||||
private fun startPreview(url: String, id: Long) {
|
||||
stopPreview()
|
||||
_state.value = _state.value.copy(previewingId = id)
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val mp = MediaPlayer().apply {
|
||||
setDataSource(url)
|
||||
prepare()
|
||||
start()
|
||||
setOnCompletionListener { _state.value = _state.value.copy(previewingId = null) }
|
||||
}
|
||||
mediaPlayer = mp
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(previewingId = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopPreview() {
|
||||
mediaPlayer?.runCatching { stop(); release() }
|
||||
mediaPlayer = null
|
||||
_state.value = _state.value.copy(previewingId = null)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
stopPreview()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import android.media.MediaPlayer
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.ImporterClient
|
||||
import com.novasonic.app.api.ItunesClient
|
||||
import com.novasonic.app.api.model.Album
|
||||
import com.novasonic.app.api.model.DownloadRequest
|
||||
import com.novasonic.app.api.model.ItunesItem
|
||||
import com.novasonic.app.api.model.ItunesRssEntry
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.api.model.artworkHd
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
enum class ExploreTab { LIBRARY, GLOBAL }
|
||||
|
||||
data class PartialAlbumState(
|
||||
val ndAlbum: Album,
|
||||
val itunesAlbum: ItunesItem,
|
||||
val ndSongs: List<Song> = emptyList(),
|
||||
val iTracks: List<ItunesItem> = emptyList(),
|
||||
val isLoading: Boolean = true,
|
||||
)
|
||||
|
||||
data class ItunesArtistState(
|
||||
val artist: ItunesItem,
|
||||
val isLoading: Boolean = true,
|
||||
val ndAlbums: List<Album> = emptyList(),
|
||||
val iAlbums: List<ItunesItem> = emptyList(),
|
||||
val iTracks: List<ItunesItem> = emptyList(),
|
||||
val topSongs: List<Song> = emptyList(),
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
data class ExploreUiState(
|
||||
val tab: ExploreTab = ExploreTab.LIBRARY,
|
||||
val query: String = "",
|
||||
// iTunes search results
|
||||
val artists: List<ItunesItem> = emptyList(),
|
||||
val albums: List<ItunesItem> = emptyList(),
|
||||
val tracks: List<ItunesItem> = emptyList(),
|
||||
// Album drill-down
|
||||
val albumTracks: List<ItunesItem>? = null,
|
||||
val albumTitle: String = "",
|
||||
val albumItem: ItunesItem? = null,
|
||||
// Artist detail (iTunes)
|
||||
val artistDetail: ItunesArtistState? = null,
|
||||
// Partial album detail (opened from artist's "Unvollständig" section)
|
||||
val partialAlbum: PartialAlbumState? = null,
|
||||
// Download sheet
|
||||
val downloadSheetItem: ItunesItem? = null,
|
||||
// Charts (shown when query is empty in GLOBAL tab)
|
||||
val charts: List<ItunesRssEntry> = emptyList(),
|
||||
// Loading states
|
||||
val isSearching: Boolean = false,
|
||||
val chartsLoading: Boolean = false,
|
||||
// Preview
|
||||
val previewingId: Long? = null, // trackId currently playing preview
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class ExploreViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
|
||||
private val _state = MutableStateFlow(ExploreUiState(chartsLoading = true))
|
||||
val state: StateFlow<ExploreUiState> = _state
|
||||
|
||||
private var searchJob: Job? = null
|
||||
private var mediaPlayer: MediaPlayer? = null
|
||||
|
||||
init { loadCharts() }
|
||||
|
||||
fun selectTab(tab: ExploreTab) {
|
||||
_state.value = _state.value.copy(tab = tab, albumTracks = null)
|
||||
}
|
||||
|
||||
fun onQueryChange(q: String) {
|
||||
_state.value = _state.value.copy(query = q, albumTracks = null)
|
||||
searchJob?.cancel()
|
||||
if (q.isBlank()) {
|
||||
_state.value = _state.value.copy(artists = emptyList(), albums = emptyList(), tracks = emptyList())
|
||||
return
|
||||
}
|
||||
searchJob = viewModelScope.launch {
|
||||
delay(350)
|
||||
search(q)
|
||||
}
|
||||
}
|
||||
|
||||
private fun search(q: String) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isSearching = true, error = null)
|
||||
try {
|
||||
val resp = ItunesClient.search(q)
|
||||
val results = resp.results
|
||||
_state.value = _state.value.copy(
|
||||
artists = results.filter { it.wrapperType == "artist" },
|
||||
albums = results.filter { it.wrapperType == "collection" },
|
||||
tracks = results.filter { it.wrapperType == "track" && it.kind == "song" },
|
||||
isSearching = false,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(isSearching = false, error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadAlbum(album: ItunesItem) {
|
||||
val id = album.collectionId ?: return
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isSearching = true, albumTracks = null,
|
||||
albumTitle = album.collectionName ?: "", albumItem = album)
|
||||
try {
|
||||
val tracks = ItunesClient.lookupAlbum(id)
|
||||
_state.value = _state.value.copy(albumTracks = tracks, isSearching = false)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(isSearching = false, error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissAlbum() {
|
||||
_state.value = _state.value.copy(albumTracks = null, albumItem = null)
|
||||
}
|
||||
|
||||
fun openArtistDetail(artist: ItunesItem) {
|
||||
_state.value = _state.value.copy(
|
||||
artistDetail = ItunesArtistState(artist = artist, isLoading = true),
|
||||
)
|
||||
viewModelScope.launch {
|
||||
val artistId = artist.artistId
|
||||
val artistName = artist.artistName ?: return@launch
|
||||
|
||||
val ndAlbumsD: Deferred<List<Album>> = async { runCatching { repo.getNdAlbumsForArtist(artistName) }.getOrDefault(emptyList()) }
|
||||
val topSongsD: Deferred<List<Song>> = async { runCatching { repo.getTopSongs(artistName) }.getOrDefault(emptyList()) }
|
||||
val iAlbumsD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistAlbums(artistId) }.getOrDefault(emptyList()) else emptyList() }
|
||||
val iTracksD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistTopTracks(artistId, 10) }.getOrDefault(emptyList()) else emptyList() }
|
||||
|
||||
_state.value = _state.value.copy(
|
||||
artistDetail = ItunesArtistState(
|
||||
artist = artist,
|
||||
isLoading = false,
|
||||
ndAlbums = ndAlbumsD.await(),
|
||||
iAlbums = iAlbumsD.await(),
|
||||
iTracks = iTracksD.await(),
|
||||
topSongs = topSongsD.await(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun closeArtistDetail() {
|
||||
_state.value = _state.value.copy(artistDetail = null, partialAlbum = null)
|
||||
}
|
||||
|
||||
fun loadPartialAlbum(nd: Album, ia: ItunesItem) {
|
||||
_state.value = _state.value.copy(
|
||||
partialAlbum = PartialAlbumState(ndAlbum = nd, itunesAlbum = ia, isLoading = true),
|
||||
)
|
||||
viewModelScope.launch {
|
||||
coroutineScope {
|
||||
val ndSongsD = async { runCatching { repo.getAlbum(nd.id).song }.getOrDefault(emptyList()) }
|
||||
val iTracksD = async {
|
||||
ia.collectionId?.let { id ->
|
||||
runCatching { ItunesClient.lookupAlbum(id) }.getOrDefault(emptyList())
|
||||
} ?: emptyList()
|
||||
}
|
||||
_state.value = _state.value.copy(
|
||||
partialAlbum = PartialAlbumState(
|
||||
ndAlbum = nd,
|
||||
itunesAlbum = ia,
|
||||
ndSongs = ndSongsD.await(),
|
||||
iTracks = iTracksD.await(),
|
||||
isLoading = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissPartialAlbum() {
|
||||
_state.value = _state.value.copy(partialAlbum = null)
|
||||
}
|
||||
|
||||
fun showDownloadSheet(item: ItunesItem) {
|
||||
_state.value = _state.value.copy(downloadSheetItem = item)
|
||||
}
|
||||
|
||||
fun dismissDownloadSheet() {
|
||||
_state.value = _state.value.copy(downloadSheetItem = null)
|
||||
}
|
||||
|
||||
fun downloadItem(item: ItunesItem, type: String, onDone: (Boolean, String) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
|
||||
if (backendUrl.isBlank()) {
|
||||
onDone(false, "Kein Backend konfiguriert — bitte in Einstellungen eintragen")
|
||||
return@launch
|
||||
}
|
||||
val jobId = "job_${System.currentTimeMillis()}"
|
||||
val artwork = item.artworkUrl100?.replace("100x100bb", "600x600bb")
|
||||
val request = when (type) {
|
||||
"album" -> DownloadRequest(
|
||||
jobId = jobId, type = "album",
|
||||
title = item.collectionName ?: item.artistName ?: "Unknown",
|
||||
artist = item.artistName,
|
||||
artworkUrl = artwork,
|
||||
collectionId = item.collectionId,
|
||||
itunesId = item.collectionId,
|
||||
)
|
||||
"artist" -> DownloadRequest(
|
||||
jobId = jobId, type = "artist",
|
||||
title = item.artistName ?: "Unknown",
|
||||
artist = item.artistName,
|
||||
artworkUrl = artwork,
|
||||
)
|
||||
else -> DownloadRequest(
|
||||
jobId = jobId, type = "track",
|
||||
title = item.trackName ?: item.collectionName ?: "Unknown",
|
||||
artist = item.artistName,
|
||||
album = item.collectionName,
|
||||
artworkUrl = artwork,
|
||||
itunesId = item.trackId,
|
||||
collectionId = item.collectionId,
|
||||
)
|
||||
}
|
||||
val client = ImporterClient(backendUrl)
|
||||
runCatching { client.download(request) }
|
||||
.onSuccess { onDone(true, "✓ Download gestartet") }
|
||||
.onFailure { onDone(false, "Backend nicht erreichbar: ${it.message}") }
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadCharts() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val entries = ItunesClient.topAlbums()
|
||||
_state.value = _state.value.copy(charts = entries, chartsLoading = false)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(chartsLoading = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 30s preview ─────────────────────────────────────────────────────────
|
||||
|
||||
fun togglePreview(track: ItunesItem) {
|
||||
val url = track.previewUrl ?: return
|
||||
val id = track.trackId ?: return
|
||||
val cur = _state.value.previewingId
|
||||
if (cur == id) {
|
||||
stopPreview()
|
||||
} else {
|
||||
startPreview(url, id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startPreview(url: String, id: Long) {
|
||||
stopPreview()
|
||||
_state.value = _state.value.copy(previewingId = id)
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val mp = MediaPlayer().apply {
|
||||
setDataSource(url)
|
||||
prepare()
|
||||
start()
|
||||
setOnCompletionListener { _state.value = _state.value.copy(previewingId = null) }
|
||||
}
|
||||
mediaPlayer = mp
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(previewingId = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopPreview() {
|
||||
mediaPlayer?.runCatching { stop(); release() }
|
||||
mediaPlayer = null
|
||||
_state.value = _state.value.copy(previewingId = null)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
stopPreview()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.model.Album
|
||||
import com.novasonic.app.api.model.Genre
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class HomeUiState(
|
||||
val heroAlbum: Album? = null,
|
||||
val recentAlbums: List<Album> = emptyList(),
|
||||
val frequentAlbums: List<Album> = emptyList(),
|
||||
val randomAlbums: List<Album> = emptyList(),
|
||||
val genres: List<Genre> = emptyList(),
|
||||
val username: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class HomeViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
|
||||
private val _state = MutableStateFlow(HomeUiState(isLoading = true))
|
||||
val state: StateFlow<HomeUiState> = _state
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
repo.appSettingsFlow().collect { s ->
|
||||
_state.value = _state.value.copy(username = s.username)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val recentDef = async { repo.getRecentAlbums(20) }
|
||||
val frequentDef = async { repo.getFrequentAlbums(20) }
|
||||
val randomDef = async { repo.getRandomAlbums(20) }
|
||||
val genresDef = async { runCatching { repo.getGenres() }.getOrDefault(emptyList()) }
|
||||
|
||||
val recent = recentDef.await()
|
||||
val frequent = frequentDef.await()
|
||||
val random = randomDef.await()
|
||||
val genres = genresDef.await()
|
||||
|
||||
// Hero rotates between first recent and first frequent album
|
||||
val hero = if (System.currentTimeMillis() % 2 == 0L) recent.firstOrNull()
|
||||
else frequent.firstOrNull() ?: recent.firstOrNull()
|
||||
|
||||
_state.value = _state.value.copy(
|
||||
heroAlbum = hero,
|
||||
recentAlbums = recent,
|
||||
frequentAlbums = frequent,
|
||||
randomAlbums = random,
|
||||
genres = genres.filter { it.songCount > 0 }.take(12),
|
||||
isLoading = false,
|
||||
error = null,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_state.value = HomeUiState(error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.ImporterClient
|
||||
import com.novasonic.app.api.model.DownloadRequest
|
||||
import com.novasonic.app.api.model.ImportResolveResponse
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class ImporterUiState(
|
||||
val backendUrl: String = "",
|
||||
val link: String = "",
|
||||
val platform: String? = null, // spotify | soundcloud | youtube
|
||||
val isResolving: Boolean = false,
|
||||
val resolved: ImportResolveResponse? = null,
|
||||
val error: String? = null,
|
||||
// Download job
|
||||
val isDownloading: Boolean = false,
|
||||
val jobStatus: String? = null, // pending | downloading | done | error
|
||||
val jobMessage: String? = null,
|
||||
val jobCompleted: Int = 0,
|
||||
val jobTotal: Int = 0,
|
||||
)
|
||||
|
||||
class ImporterViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
|
||||
private val _state = MutableStateFlow(ImporterUiState())
|
||||
val state: StateFlow<ImporterUiState> = _state
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
repo.appSettingsFlow().collect { s ->
|
||||
_state.value = _state.value.copy(backendUrl = s.backendUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onLink(v: String) {
|
||||
_state.value = _state.value.copy(
|
||||
link = v,
|
||||
platform = ImporterClient.platformFor(v.trim()),
|
||||
resolved = null,
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
|
||||
fun resolve() {
|
||||
val s = _state.value
|
||||
val url = s.link.trim()
|
||||
if (url.isBlank()) return
|
||||
if (s.backendUrl.isBlank()) {
|
||||
_state.value = s.copy(error = "Backend-URL in Einstellungen konfigurieren")
|
||||
return
|
||||
}
|
||||
val platform = ImporterClient.platformFor(url)
|
||||
if (platform == null) {
|
||||
_state.value = s.copy(error = "Nicht unterstützter Link. Spotify, YouTube oder SoundCloud einfügen.")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isResolving = true, error = null, resolved = null)
|
||||
val client = ImporterClient(s.backendUrl)
|
||||
try {
|
||||
val resp = client.resolve(platform, url)
|
||||
if (resp.error != null) {
|
||||
_state.value = _state.value.copy(isResolving = false, error = mapError(resp.error))
|
||||
} else {
|
||||
_state.value = _state.value.copy(isResolving = false, resolved = resp)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(
|
||||
isResolving = false,
|
||||
error = "Backend nicht erreichbar: ${e.message}",
|
||||
)
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadAll() {
|
||||
val s = _state.value
|
||||
val resolved = s.resolved ?: return
|
||||
if (s.backendUrl.isBlank() || resolved.tracks.isEmpty()) return
|
||||
|
||||
val jobId = "job_${System.currentTimeMillis()}"
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(
|
||||
isDownloading = true, jobStatus = "pending", jobMessage = null,
|
||||
jobCompleted = 0, jobTotal = resolved.tracks.size,
|
||||
)
|
||||
val client = ImporterClient(s.backendUrl)
|
||||
try {
|
||||
client.download(
|
||||
DownloadRequest(
|
||||
jobId = jobId,
|
||||
type = if (resolved.type == "track") "track" else "playlist",
|
||||
title = resolved.name ?: "Import",
|
||||
artist = resolved.artist,
|
||||
artworkUrl = resolved.artworkUrl,
|
||||
tracks = resolved.tracks,
|
||||
)
|
||||
)
|
||||
pollJob(client, jobId)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(
|
||||
isDownloading = false, jobStatus = "error",
|
||||
jobMessage = "Download fehlgeschlagen: ${e.message}",
|
||||
)
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pollJob(client: ImporterClient, jobId: String) {
|
||||
try {
|
||||
repeat(600) { // up to ~20 min at 2s interval
|
||||
delay(2000)
|
||||
val st = runCatching { client.status(jobId) }.getOrNull() ?: return@repeat
|
||||
_state.value = _state.value.copy(
|
||||
jobStatus = st.status,
|
||||
jobMessage = st.message,
|
||||
jobCompleted = st.completed ?: _state.value.jobCompleted,
|
||||
jobTotal = st.total ?: _state.value.jobTotal,
|
||||
)
|
||||
if (st.status == "done" || st.status == "error") {
|
||||
_state.value = _state.value.copy(isDownloading = false)
|
||||
return
|
||||
}
|
||||
}
|
||||
_state.value = _state.value.copy(isDownloading = false)
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapError(code: String): String = when (code) {
|
||||
"EDITORIAL_PLAYLIST" ->
|
||||
"Editorial Playlist: Spotify sperrt eigene kuratierte Playlists. Kopiere sie in eine eigene Playlist und importiere diese."
|
||||
"RATE_LIMITED" -> "Spotify Rate-Limit erreicht — bitte kurz warten."
|
||||
else -> code
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.model.Album
|
||||
import com.novasonic.app.api.model.ArtistIndex
|
||||
import com.novasonic.app.api.model.Playlist
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class LibraryTab { PLAYLISTS, ALBUMS, ARTISTS }
|
||||
|
||||
data class LibraryUiState(
|
||||
val tab: LibraryTab = LibraryTab.PLAYLISTS,
|
||||
val albums: List<Album> = emptyList(),
|
||||
val artistIndexes: List<ArtistIndex> = emptyList(),
|
||||
val playlists: List<Playlist> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class LibraryViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
|
||||
private val _state = MutableStateFlow(LibraryUiState(isLoading = true))
|
||||
val state: StateFlow<LibraryUiState> = _state
|
||||
|
||||
init { loadPlaylists() }
|
||||
|
||||
fun selectTab(tab: LibraryTab) {
|
||||
_state.value = _state.value.copy(tab = tab)
|
||||
when (tab) {
|
||||
LibraryTab.ALBUMS -> if (_state.value.albums.isEmpty()) loadAlbums()
|
||||
LibraryTab.ARTISTS -> if (_state.value.artistIndexes.isEmpty()) loadArtists()
|
||||
LibraryTab.PLAYLISTS -> if (_state.value.playlists.isEmpty()) loadPlaylists()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadAlbums() = viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val albums = repo.getAlphaAlbums(200)
|
||||
_state.value = _state.value.copy(albums = albums, isLoading = false)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(error = e.message, isLoading = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadArtists() = viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val indexes = repo.getArtists()
|
||||
_state.value = _state.value.copy(artistIndexes = indexes, isLoading = false)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(error = e.message, isLoading = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadPlaylists() = viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val pls = repo.getPlaylists()
|
||||
_state.value = _state.value.copy(playlists = pls, isLoading = false)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(error = e.message, isLoading = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.SubsonicClient
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class LoginUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val success: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class LoginViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
|
||||
private val _state = MutableStateFlow(LoginUiState())
|
||||
val state: StateFlow<LoginUiState> = _state
|
||||
|
||||
fun login(serverUrl: String, username: String, password: String) {
|
||||
viewModelScope.launch {
|
||||
_state.value = LoginUiState(isLoading = true)
|
||||
try {
|
||||
val client = SubsonicClient(serverUrl, username, password)
|
||||
client.ping()
|
||||
client.close()
|
||||
repo.saveCredentials(serverUrl, username, password)
|
||||
_state.value = LoginUiState(success = true)
|
||||
} catch (e: Exception) {
|
||||
val msg = when {
|
||||
e.message?.contains("Unable to resolve host") == true -> "Cannot reach server. Check the URL."
|
||||
e.message?.contains("40") == true -> "Wrong username or password."
|
||||
else -> e.message ?: "Connection failed."
|
||||
}
|
||||
_state.value = LoginUiState(error = msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.LrcLibClient
|
||||
import com.novasonic.app.api.LrcLine
|
||||
import com.novasonic.app.api.parseLrc
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class LyricsUiState(
|
||||
val lines: List<LrcLine> = emptyList(), // non-empty = synced
|
||||
val plainText: String = "", // fallback plain lyrics
|
||||
val isLoading: Boolean = false,
|
||||
val notFound: Boolean = false,
|
||||
val songId: String = "", // which song these are for
|
||||
)
|
||||
|
||||
class LyricsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val _state = MutableStateFlow(LyricsUiState())
|
||||
val state: StateFlow<LyricsUiState> = _state
|
||||
|
||||
fun loadForSong(song: Song) {
|
||||
if (_state.value.songId == song.id && !_state.value.isLoading) return
|
||||
_state.value = LyricsUiState(isLoading = true, songId = song.id)
|
||||
viewModelScope.launch {
|
||||
val artist = song.artist ?: ""
|
||||
val title = song.title
|
||||
val album = song.album
|
||||
val durSecs = song.duration
|
||||
|
||||
val result = LrcLibClient.getLyrics(artist, title, album, durSecs)
|
||||
|
||||
when {
|
||||
result == null -> _state.value = LyricsUiState(
|
||||
notFound = true, songId = song.id)
|
||||
result.instrumental -> _state.value = LyricsUiState(
|
||||
plainText = "🎵 Instrumentalstück", songId = song.id)
|
||||
!result.syncedLyrics.isNullOrBlank() -> _state.value = LyricsUiState(
|
||||
lines = parseLrc(result.syncedLyrics), songId = song.id)
|
||||
!result.plainLyrics.isNullOrBlank() -> _state.value = LyricsUiState(
|
||||
plainText = result.plainLyrics, songId = song.id)
|
||||
else -> _state.value = LyricsUiState(
|
||||
notFound = true, songId = song.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.model.PlaylistWithSongs
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class PlaylistUiState(
|
||||
val playlist: PlaylistWithSongs? = null,
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class PlaylistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
private val playlistId: String? = savedState["playlistId"]
|
||||
|
||||
private val _state = MutableStateFlow(PlaylistUiState())
|
||||
val state: StateFlow<PlaylistUiState> = _state
|
||||
|
||||
init {
|
||||
val id = playlistId
|
||||
if (id.isNullOrBlank()) {
|
||||
_state.value = PlaylistUiState(isLoading = false, error = "Keine Playlist-ID übergeben")
|
||||
} else {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val pl = repo.getPlaylist(id)
|
||||
_state.value = PlaylistUiState(playlist = pl, isLoading = false)
|
||||
} catch (e: Exception) {
|
||||
_state.value = PlaylistUiState(isLoading = false, error = e.message ?: e.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.model.SearchResult3
|
||||
import com.novasonic.app.app
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class SearchUiState(
|
||||
val query: String = "",
|
||||
val searchResult: SearchResult3? = null,
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class SearchViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
|
||||
private val _state = MutableStateFlow(SearchUiState())
|
||||
val state: StateFlow<SearchUiState> = _state
|
||||
|
||||
private var searchJob: Job? = null
|
||||
|
||||
fun onQueryChange(q: String) = onSearch(q)
|
||||
|
||||
fun onSearch(q: String) {
|
||||
_state.value = _state.value.copy(query = q)
|
||||
searchJob?.cancel()
|
||||
if (q.isBlank()) {
|
||||
_state.value = _state.value.copy(searchResult = null, isLoading = false)
|
||||
return
|
||||
}
|
||||
searchJob = viewModelScope.launch {
|
||||
delay(300)
|
||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
val results = repo.search(q)
|
||||
_state.value = _state.value.copy(searchResult = results, isLoading = false)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(error = e.message, isLoading = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.data.PrefsStore
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class SettingsUiState(
|
||||
val serverUrl: String = "",
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val lastFmKey: String = "",
|
||||
val backendUrl: String = "",
|
||||
val scrobbleMode: String = "30s",
|
||||
val showPlayCount: Boolean = false,
|
||||
val theme: String = "dark-ocean",
|
||||
val isSaving: Boolean = false,
|
||||
val savedOk: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
|
||||
private val _state = MutableStateFlow(SettingsUiState())
|
||||
val state: StateFlow<SettingsUiState> = _state
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
repo.appSettingsFlow().collect { s ->
|
||||
_state.value = _state.value.copy(
|
||||
serverUrl = s.serverUrl,
|
||||
username = s.username,
|
||||
password = s.password,
|
||||
lastFmKey = s.lastFmKey,
|
||||
backendUrl = s.backendUrl,
|
||||
scrobbleMode = s.scrobbleMode,
|
||||
showPlayCount = s.showPlayCount,
|
||||
theme = s.theme,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onServerUrl(v: String) = update { it.copy(serverUrl = v, savedOk = false) }
|
||||
fun onUsername(v: String) = update { it.copy(username = v, savedOk = false) }
|
||||
fun onPassword(v: String) = update { it.copy(password = v, savedOk = false) }
|
||||
fun onLastFmKey(v: String) = update { it.copy(lastFmKey = v, savedOk = false) }
|
||||
fun onBackendUrl(v: String) = update { it.copy(backendUrl = v, savedOk = false) }
|
||||
fun onScrobbleMode(v: String) = update { it.copy(scrobbleMode = v, savedOk = false) }
|
||||
fun onShowPlayCount(v: Boolean) = update { it.copy(showPlayCount = v, savedOk = false) }
|
||||
|
||||
fun onTheme(id: String) {
|
||||
update { it.copy(theme = id) }
|
||||
viewModelScope.launch { repo.saveTheme(id) }
|
||||
}
|
||||
|
||||
fun save() {
|
||||
val s = _state.value
|
||||
if (s.serverUrl.isBlank() || s.username.isBlank() || s.password.isBlank()) {
|
||||
_state.value = s.copy(error = "Server, Benutzername und Passwort sind Pflichtfelder")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.value = s.copy(isSaving = true, error = null)
|
||||
try {
|
||||
repo.saveAppSettings(PrefsStore.AppSettings(
|
||||
serverUrl = s.serverUrl,
|
||||
username = s.username,
|
||||
password = s.password,
|
||||
lastFmKey = s.lastFmKey.trim(),
|
||||
backendUrl = s.backendUrl.trimEnd('/'),
|
||||
scrobbleMode = s.scrobbleMode,
|
||||
showPlayCount = s.showPlayCount,
|
||||
theme = s.theme,
|
||||
))
|
||||
_state.value = _state.value.copy(isSaving = false, savedOk = true)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(isSaving = false, error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch { repo.saveCredentials("", "", "") }
|
||||
}
|
||||
|
||||
private inline fun update(block: (SettingsUiState) -> SettingsUiState) {
|
||||
_state.value = block(_state.value)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#000000">
|
||||
|
||||
<WebView
|
||||
android:id="@+id/web_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<array name="com_google_android_gms_fonts_certs">
|
||||
<item>@array/com_google_android_gms_fonts_certs_dev</item>
|
||||
<item>@array/com_google_android_gms_fonts_certs_prod</item>
|
||||
</array>
|
||||
<string-array name="com_google_android_gms_fonts_certs_dev">
|
||||
<item>
|
||||
MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNU2FuIEZyYW5jaXNjbzETMBEGA1UEChMKR29vZ2xlIEluYzEUMBIGA1UECxMLRW5naW5lZXJpbmcxFTATBgNVBAMTDEdvb2dsZSBJbmMuMB4XDTE4MDMwODEzMDMyMloXDTE4MDQwNjEzMDMyMlowLDEqMCgGA1UEAxMhYW5kcm9pZC5nb29nbGVhcGlzLmNvbS9mb250c2RldjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMNvRiimO7gVMf+m37hgKFJMQp1RnDpq7Q1JBSZ8x8SQGQ1bNKGMNM/xoRg8xCLB3m0rvFCKhRAfgHpCb5EBKjt6Z3vQQ2jLAGbbMxz5zR/RbkVH7hSHXfJFz5Dq4TZyPlCAhTJJRMaqUiS24hV0Y7wQzSa8xCCn0dTnX9c0FUoBh0JVAc4FnGAR6RNGkPfyLH3oBd7y1t1Q0CeX9e6uH7LN8W8DtXEQMzFSBVQtMHoqN/Jlz1xXVSHHCpE7Vb6YYJpWkSmyAOjkmJz3n/PpCeHqAXS5VKkGUuOq6bI10eMIg6aXyMODuDzBnIRX0mH8eqq8Ygr5VCFd3xLNkCAwEAAaOCAT0wggE5MB0GA1UdDgQWBBRjyGPj6t3/2XCfVOtpO/ZTPFP5TDAfBgNVHSMEGDAWgBRjyGPj6t3/2XCfVOtpO/ZTPFP5TDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDAzCBmgYDVR0fBIGSMIGPMIGMoIGJoIGGhoGDaHR0cDovL2NybC5nb29nbGUuY29tL3BraS9jcmxzL2dvb2dsZS1hcGlzL3lvdXItdGVzdC1jZXJ0LmNybD9zdGF0ZT1sZjlwT1Bkb3ZROElBWkFCb1RwTFIzSmxheTVOdUlZMHQwdlh6SVBsbERqWEZxMlRjZVBacmFVWmZ0VW9UcmNNa3dDQStlV2s9MA0GCSqGSIb3DQEBBAUAA4IBAQBP0msbfJhgDBkqPH7u6PQqO9bCNPbTxYbclVTQW1LB9aZpqbFLTIzORnU3lSMi9DQ62pR2jCYkmYVzBDxJxoVA8BFQxmFnl+2J+nxHoMH/mJVqdKCq1T7s9N0Q7BtLv3yDpK4kHuv8GZ5R5tFKgf6lv+UrO8gNTF3F5JNVCMJkWNbPNRbSifL4hNnPmH4T7J2t3T/fjzDSi1oiB4N5bCmRFMSY7gNjy+MJQlbRFzGPqRQrJ2F4eaQr8TcpLhw0Q3bHOsPUbHVfGmMRWKq7MkJj0+JrRVWMT7paxRiCGxAVPyJb1dDjqxVE0wlT/3y9VcwrRR3l7KMGJqY1jcobMHs+
|
||||
</item>
|
||||
</string-array>
|
||||
<string-array name="com_google_android_gms_fonts_certs_prod">
|
||||
<item>
|
||||
MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFBYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFBYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBAOKq8yXC9JQZN5WLKUOA1k+WJblR2B65UeJCcjzXBTIPnCDRuNilzLdp6PJH9ECbgGZ4MdOW7U0H8sR2WmqnWK4CZpqJQhW5MbMFX8rNRQLVhF7J9Bxao9D0xWGiIB97+gJHFyxnN79V+6z9c5jzlpO4oX4Jv5b3MuH6fXIPxd3MoNjk1D4JHg7LS5A/0VR5vqST4UrQEv0LNPrXm8E7IM4a2ioIgFo0+vwMi0xdKuY4ByxL8Lx2b0G5tU7hXm2OT0n8wQRpanrr1WNSxflG+T17vBCEfW4lKjTFW5JLNfNT/cEnSfNXF48c3lIgFHH+OPY+mDuCx+BVVjCqUCAwEAAaOBsDCBrTAdBgNVHQ4EFgQUO3Shgt4KWNZWZS8/sVDVZVdNEwQwfQYDVR0jBHYwdIAUO3Shgt4KWNZWZS8/sVDVZVdNEwShWKRWMFQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFBYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjCBgAYDVR0fBHkwdzB1oHOgcYZvaHR0cDovL2NybC5wa2kuZ29vZ2xlYXBpcy5jb20vQ1JML0NBL2E2Mzk5ZjNiNWQxZGFhYzc2MDkzYzM2NzMzNGE1YmMzNjFhNzZhNTcxNGJiZjkwNGRlMjBlNGFjZDBkN2UzMi5jcmwwDQYJKoZIhvcNAQEEBQADggEBADjY8KJhGDBQtFOq1yWNakLe+e8u5vDOCpI2oLFbhfanIhN3nEBoAyqXTDFiD8Qj65xEAyQNiJQ5P3LTBrBr1m3cMdz6YPKX7EqPTjhMGJp9NvnLjzh2IFlv0k4JpNMHfFNz7C3YPHiqBF5aZSKhcKzU5KPk/E7q5jWEKt5XHHS4JrsPVDiGe5dpMQxu2RaEzh+yJfRnEKBa7wLhTb5gkWsE3WNJGkFQ1SioBjcTGW6LfZLl5JOrmMnEv/9hpz31E6wTVVSHXX9yiRFCqRNjI1TSk0u3N1h6wDe/7NwAJG+ZGgQ1I1pXzrr/aBpK/Z3e5C9I2HupPm/zVJyZpBU=
|
||||
</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
@@ -1,4 +1,7 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.android) apply false
|
||||
alias(libs.plugins.android.application) 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]
|
||||
agp = "8.13.2"
|
||||
kotlin = "1.9.23"
|
||||
coreKtx = "1.13.1"
|
||||
appcompat = "1.7.0"
|
||||
material = "1.12.0"
|
||||
media3 = "1.4.1"
|
||||
coroutines = "1.8.0"
|
||||
agp = "8.13.2"
|
||||
kotlin = "2.1.20"
|
||||
composeBom = "2024.12.01"
|
||||
coreKtx = "1.13.1"
|
||||
appcompat = "1.7.0"
|
||||
activityCompose = "1.9.3"
|
||||
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]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||
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" }
|
||||
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||
# AndroidX core
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||
androidx-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" }
|
||||
|
||||
# Compose BOM + core
|
||||
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]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
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>
|
||||
|
||||
<!-- 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 -->
|
||||
<div class="page" id="page-albums">
|
||||
<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>
|
||||
</button>
|
||||
<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-queue" onclick="switchNpTab('queue')">Queue</button>
|
||||
</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