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:
HoneyandClaude Sonnet 4.6 committed 2026-06-14 20:54:32 +02:00
1 parent b25e162806
commit afae5d94df
104 files changed
+8839 -9420

No files matched your search

+60 -11
View File
@@ -1,22 +1,25 @@
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
} }
android { android {
namespace = "com.novasonic.app" namespace = "com.novasonic.app"
compileSdk = 34 compileSdk = 35
defaultConfig { defaultConfig {
applicationId = "com.novasonic.app" applicationId = "com.novasonic.app"
minSdk = 24 minSdk = 26
targetSdk = 34 targetSdk = 35
versionCode = 1 versionCode = 1
versionName = "1.0" versionName = "1.0"
buildConfigField("String", "NOVASONIC_URL", "\"https://music.asianxpolish.duckdns.org\"")
} }
buildFeatures { buildFeatures {
compose = true
buildConfig = true buildConfig = true
} }
@@ -28,22 +31,68 @@ android {
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_11
} }
kotlinOptions { kotlinOptions {
jvmTarget = "1.8" jvmTarget = "11"
} }
} }
dependencies { dependencies {
// Core
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
implementation(libs.material) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.splashscreen)
// Compose
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.icons)
debugImplementation(libs.compose.ui.tooling)
// Navigation
implementation(libs.navigation.compose)
// Lifecycle / ViewModel
implementation(libs.lifecycle.viewmodel.compose)
implementation(libs.lifecycle.runtime.compose)
// Media3
implementation(libs.media3.exoplayer) implementation(libs.media3.exoplayer)
implementation(libs.media3.session) implementation(libs.media3.session)
implementation(libs.media3.common) implementation(libs.media3.common)
implementation(libs.media3.datasource) implementation(libs.media3.datasource)
implementation(libs.media3.ui)
// Ktor
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.android)
implementation(libs.ktor.client.content.neg)
implementation(libs.ktor.serialization.json)
implementation(libs.ktor.client.logging)
// Kotlinx
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlin.reflect)
// Room
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
// DataStore
implementation(libs.datastore.preferences)
// Coil
implementation(libs.coil.compose)
// Google Fonts (Outfit)
implementation(libs.compose.ui.google.fonts)
} }
+5 -3
View File
@@ -7,8 +7,10 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/> <uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<application <application
android:name=".NovaSonicApp"
android:allowBackup="true" android:allowBackup="true"
android:icon="@drawable/ic_launcher" android:icon="@drawable/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
@@ -20,7 +22,7 @@
android:largeHeap="true"> android:largeHeap="true">
<activity <activity
android:name=".MainActivity" android:name=".ui.MainActivity"
android:exported="true" android:exported="true"
android:configChanges="orientation|screenSize|keyboardHidden|smallestScreenSize|screenLayout" android:configChanges="orientation|screenSize|keyboardHidden|smallestScreenSize|screenLayout"
android:windowSoftInputMode="adjustResize" android:windowSoftInputMode="adjustResize"
@@ -32,7 +34,7 @@
</activity> </activity>
<service <service
android:name=".MusicService" android:name=".player.MusicService"
android:foregroundServiceType="mediaPlayback" android:foregroundServiceType="mediaPlayback"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
@@ -41,7 +43,7 @@
</intent-filter> </intent-filter>
</service> </service>
<!-- Android Auto support --> <!-- Android Auto -->
<meta-data <meta-data
android:name="com.google.android.gms.car.application" android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc"/> android:resource="@xml/automotive_app_desc"/>
@@ -1,53 +0,0 @@
package com.novasonic.app
import android.net.Uri
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import org.json.JSONArray
import org.json.JSONObject
data class Track(
val id: String,
val url: String,
val title: String,
val artist: String,
val album: String?,
val artworkUrl: String?,
val durationMs: Long
) {
fun toMediaItem(): MediaItem {
val metadata = MediaMetadata.Builder()
.setTitle(title)
.setArtist(artist)
.setAlbumTitle(album)
.apply {
if (!artworkUrl.isNullOrEmpty()) setArtworkUri(Uri.parse(artworkUrl))
if (durationMs > 0) setDurationMs(durationMs)
}
.build()
return MediaItem.Builder()
.setMediaId(id)
.setUri(url)
.setMediaMetadata(metadata)
.build()
}
companion object {
fun fromJson(obj: JSONObject) = Track(
id = obj.optString("id", ""),
url = obj.optString("url", ""),
title = obj.optString("title", ""),
artist = obj.optString("artist", ""),
album = obj.optString("album").takeIf { it.isNotEmpty() },
artworkUrl = obj.optString("artworkUrl").takeIf { it.isNotEmpty() },
durationMs = obj.optLong("durationMs", 0)
)
}
}
fun parseTrackList(json: String): List<Track> = try {
val arr = JSONArray(json)
List(arr.length()) { Track.fromJson(arr.getJSONObject(it)) }
} catch (_: Exception) {
emptyList()
}
@@ -1,321 +0,0 @@
package com.novasonic.app
import android.Manifest
import android.content.ComponentName
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.media3.common.Player
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import com.google.common.util.concurrent.ListenableFuture
import org.json.JSONObject
class MainActivity : AppCompatActivity(), MusicService.StateListener {
private lateinit var webView: WebView
private lateinit var webBridge: WebBridge
private var controllerFuture: ListenableFuture<MediaController>? = null
private var mediaController: MediaController? = null
private var pendingOpenNowPlaying = false
// Debounce onReady: WebView can fire onPageFinished multiple times (hash changes,
// certain JS navigations). Only deliver onReady once per 3-second window.
private var lastOnReadyMs = 0L
// ─── Back-press handling ──────────────────────────────────────────────────
// Always enabled — the callback itself decides whether to handle the event.
private val backCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (webBridge.consumeBackPress()) {
// An overlay is open in JS — ask it to close the topmost layer.
webView.evaluateJavascript(
"if(window.NSPlayer&&window.NSPlayer.handleBack)window.NSPlayer.handleBack()",
null
)
return
}
if (webView.canGoBack()) {
webView.goBack()
return
}
// Nothing left to pop — send the task to background.
// Using moveTaskToBack instead of finish() so the Activity stays alive
// and the MusicService binding is preserved; resuming is instant.
moveTaskToBack(true)
}
}
// ─── Lifecycle ────────────────────────────────────────────────────────────
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
webView = findViewById(R.id.web_view)
webBridge = WebBridge { mediaController }
configureWebView()
requestNotificationPermission()
onBackPressedDispatcher.addCallback(this, backCallback)
// Explicitly start the service before binding so Android 14 lets it
// promote to foreground (bindService alone doesn't satisfy the
// "started from foreground" requirement for foreground services).
startService(Intent(this, MusicService::class.java))
connectToService()
val url = BuildConfig.NOVASONIC_URL.ifEmpty { "https://demo.navidrome.org" }
webView.loadUrl(url)
// Notification tap while app was not running — defer until page is loaded.
if (intent?.getBooleanExtra("open_now_playing", false) == true) {
pendingOpenNowPlaying = true
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Notification tap while app is already running — WebView is ready immediately.
if (intent.getBooleanExtra("open_now_playing", false)) {
// Refresh snapshot so getState() is never stale, then emit onTrackChanged
// + openNowPlaying in a single JS tick to avoid the race where the bottom
// bar and NP overlay show the previous song.
updateStateSnapshot()
val ctrl = mediaController
if (ctrl != null) {
val idx = ctrl.currentMediaItemIndex
val trackId = ctrl.currentMediaItem?.mediaId ?: ""
val json = JSONObject().apply {
put("index", idx)
put("trackId", trackId)
put("reason", "notification")
}.toString()
val escaped = escapeJs(json)
webView.post {
webView.evaluateJavascript(
"(function(){" +
"if(window.NSPlayer&&window.NSPlayer.onTrackChanged)window.NSPlayer.onTrackChanged(\"$escaped\");" +
"if(window.openNowPlaying)window.openNowPlaying();" +
"})()",
null
)
}
} else {
webView.post { webView.evaluateJavascript("if(window.openNowPlaying)window.openNowPlaying()", null) }
}
}
}
override fun onResume() { super.onResume(); webView.onResume() }
override fun onPause() { webView.onPause(); super.onPause() }
override fun onDestroy() {
MusicService.stateListener = null
controllerFuture?.let { MediaController.releaseFuture(it) }
webView.destroy()
super.onDestroy()
}
// ─── Service connection ───────────────────────────────────────────────────
private fun connectToService() {
MusicService.stateListener = this
val token = SessionToken(this, ComponentName(this, MusicService::class.java))
val future = MediaController.Builder(this, token).buildAsync()
controllerFuture = future
future.addListener({
mediaController = try { future.get() } catch (_: Exception) { null }
if (mediaController != null) {
lastOnReadyMs = System.currentTimeMillis()
updateStateSnapshot() // ensure getState() is fresh when JS calls it in onReady
callJs("onReady")
}
}, ContextCompat.getMainExecutor(this))
}
// ─── WebView configuration ────────────────────────────────────────────────
private fun configureWebView() {
WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
webView.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
databaseEnabled = true
cacheMode = WebSettings.LOAD_DEFAULT
mediaPlaybackRequiresUserGesture = false
allowFileAccess = true // needed for bridge-test.html asset
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
useWideViewPort = true
loadWithOverviewMode = true
userAgentString = userAgentString.replace("wv", "") + " NovaSonicApp/1.0"
}
// New full bridge — JS calls window.NSBridge.*
webView.addJavascriptInterface(webBridge, "NSBridge")
webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest) = false
// Re-announce ready + current state after a WebView reload so JS can re-sync.
// Debounced: WebView can fire onPageFinished for hash/state changes mid-session.
override fun onPageFinished(view: WebView, url: String) {
if (mediaController != null) {
val now = System.currentTimeMillis()
if (now - lastOnReadyMs > 3_000) {
lastOnReadyMs = now
updateStateSnapshot()
callJs("onReady")
}
}
if (pendingOpenNowPlaying) {
pendingOpenNowPlaying = false
webView.post { webView.evaluateJavascript("if(window.openNowPlaying)window.openNowPlaying()", null) }
}
}
}
webView.webChromeClient = WebChromeClient()
}
// ─── MusicService.StateListener → push state to JS ───────────────────────
override fun onReady() = callJs("onReady")
override fun onPlaybackStateChanged(isPlaying: Boolean, isBuffering: Boolean, isLoading: Boolean) {
val json = """{"isPlaying":$isPlaying,"isBuffering":$isBuffering,"isLoading":$isLoading}"""
updateStateSnapshot()
callJs("onPlaybackStateChanged", json)
}
override fun onTrackChanged(index: Int, trackId: String, reason: String) {
val json = JSONObject().apply {
put("index", index)
put("trackId", trackId)
put("reason", reason)
}.toString()
updateStateSnapshot()
callJs("onTrackChanged", json)
}
override fun onPositionUpdate(positionMs: Long, durationMs: Long) =
callJs("onPositionUpdate", positionMs, durationMs)
override fun onQueueEnded() = callJs("onQueueEnded")
override fun onError(message: String) = callJs("onError", message)
override fun onSkipToNext() = callJs("onNotificationNext")
override fun onSkipToPrevious() = callJs("onNotificationPrev")
// ─── JS bridge helpers ────────────────────────────────────────────────────
/**
* Calls window.NSPlayer.<method>(<args>) safely on the UI thread.
* Strings are JSON-escaped and quoted; numbers/booleans are passed as-is.
*/
fun callJs(method: String, vararg args: Any?) {
val argsJs = args.joinToString(",") { arg ->
when (arg) {
null -> "null"
is Boolean -> arg.toString()
is Number -> arg.toString()
is String -> "\"${escapeJs(arg)}\""
else -> "\"${escapeJs(arg.toString())}\""
}
}
val js = "if(window.NSPlayer&&window.NSPlayer.$method)window.NSPlayer.$method($argsJs)"
webView.post { webView.evaluateJavascript(js, null) }
}
private fun escapeJs(s: String) = s
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "")
private fun updateStateSnapshot() {
val ctrl = mediaController ?: return
val json = JSONObject().apply {
put("isPlaying", ctrl.isPlaying)
put("isBuffering", ctrl.playbackState == Player.STATE_BUFFERING)
put("isEnded", ctrl.playbackState == Player.STATE_ENDED)
put("currentIndex", ctrl.currentMediaItemIndex)
put("currentId", ctrl.currentMediaItem?.mediaId ?: "")
put("positionMs", ctrl.currentPosition)
put("queueLength", ctrl.mediaItemCount)
}.toString()
webBridge.updateSnapshot(json)
}
// ─── Permissions ──────────────────────────────────────────────────────────
private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
when {
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
== PackageManager.PERMISSION_GRANTED -> return
// System won't show the dialog again after one denial — send user to settings once.
!ActivityCompat.shouldShowRequestPermissionRationale(
this, Manifest.permission.POST_NOTIFICATIONS
) && _notifPermissionAskedBefore() && !_notifSettingsShownBefore() -> {
markNotifSettingsShown()
openNotificationSettings()
}
else -> {
markNotifPermissionAsked()
ActivityCompat.requestPermissions(
this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 42
)
}
}
}
override fun onRequestPermissionsResult(
requestCode: Int, permissions: Array<out String>, grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == 42 &&
grantResults.firstOrNull() == PackageManager.PERMISSION_DENIED &&
!_notifSettingsShownBefore()
) {
markNotifSettingsShown()
openNotificationSettings()
}
}
private fun openNotificationSettings() {
startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", packageName, null)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
)
}
private fun _notifPermissionAskedBefore() =
getSharedPreferences("ns_prefs", MODE_PRIVATE).getBoolean("notif_asked", false)
private fun markNotifPermissionAsked() =
getSharedPreferences("ns_prefs", MODE_PRIVATE).edit()
.putBoolean("notif_asked", true).apply()
private fun _notifSettingsShownBefore() =
getSharedPreferences("ns_prefs", MODE_PRIVATE).getBoolean("notif_settings_shown", false)
private fun markNotifSettingsShown() =
getSharedPreferences("ns_prefs", MODE_PRIVATE).edit()
.putBoolean("notif_settings_shown", true).apply()
}
@@ -1,4 +0,0 @@
package com.novasonic.app
// Replaced by MusicService.kt (Media3 MediaSessionService + ExoPlayer).
// Kept as an empty file so git history is preserved.
@@ -1,280 +0,0 @@
package com.novasonic.app
import android.app.PendingIntent
import android.content.Intent
import android.os.Handler
import android.os.Looper
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.ForwardingPlayer
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.media3.datasource.DataSourceBitmapLoader
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.CacheBitmapLoader
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.google.common.util.concurrent.MoreExecutors
import java.util.concurrent.Executors
class MusicService : MediaSessionService() {
// ─── State callback interface (consumed by MainActivity) ─────────────────
interface StateListener {
fun onReady()
fun onPlaybackStateChanged(isPlaying: Boolean, isBuffering: Boolean, isLoading: Boolean)
fun onTrackChanged(index: Int, trackId: String, reason: String)
fun onPositionUpdate(positionMs: Long, durationMs: Long)
fun onQueueEnded()
fun onError(message: String)
fun onSkipToNext()
fun onSkipToPrevious()
}
companion object {
// Set by MainActivity before connecting; cleared on destroy.
@Volatile var stateListener: StateListener? = null
// Service instance — lets WebBridge trigger artwork pre-warming directly.
@Volatile var instance: MusicService? = null
}
// ─── Fields ───────────────────────────────────────────────────────────────
private lateinit var player: ExoPlayer
private lateinit var mediaSession: MediaSession
private lateinit var bitmapLoader: CacheBitmapLoader
private val handler = Handler(Looper.getMainLooper())
// 250 ms position ticker — only runs while actually playing
private val positionTicker = object : Runnable {
override fun run() {
if (player.isPlaying) {
val dur = player.duration.let { if (it == C.TIME_UNSET) 0L else it }
stateListener?.onPositionUpdate(player.currentPosition, dur)
handler.postDelayed(this, 250)
}
}
}
// ─── Lifecycle ────────────────────────────────────────────────────────────
override fun onCreate() {
super.onCreate()
instance = this
val httpFactory = DefaultHttpDataSource.Factory()
.setUserAgent("NovaSonic-Android/1.0")
.setConnectTimeoutMs(15_000)
.setReadTimeoutMs(15_000)
.setAllowCrossProtocolRedirects(true)
val dataSourceFactory = DefaultDataSource.Factory(this, httpFactory)
// CacheBitmapLoader caches fetched bitmaps in-memory so that
// the notification never has to re-fetch artwork on auto-advance.
val executor = Executors.newSingleThreadExecutor()
bitmapLoader = CacheBitmapLoader(
DataSourceBitmapLoader(
MoreExecutors.listeningDecorator(executor),
dataSourceFactory
)
)
val audioAttributes = AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.setUsage(C.USAGE_MEDIA)
.build()
player = ExoPlayer.Builder(this)
.setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory))
.setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true)
.setHandleAudioBecomingNoisy(true) // pause on headphone unplug
.setWakeMode(C.WAKE_MODE_NETWORK) // holds WakeLock + WifiLock through Doze
.build()
// ForwardingPlayer that always reports next/prev as available commands.
// ExoPlayer drops those commands when its queue has only one item (single-track model),
// which makes Media3 hide the skip buttons in the notification. JS still owns queue
// logic — the MediaSession callback intercepts the commands and delegates to JS.
// seekToNext* are no-oped here so ExoPlayer never tries to advance its single-item
// queue; JS handles all actual navigation via stateListener callbacks.
val sessionPlayer = object : ForwardingPlayer(player) {
override fun getAvailableCommands(): Player.Commands =
super.getAvailableCommands().buildUpon()
.add(Player.COMMAND_SEEK_TO_NEXT)
.add(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
.add(Player.COMMAND_SEEK_TO_PREVIOUS)
.add(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
.build()
override fun isCommandAvailable(command: Int): Boolean = when (command) {
Player.COMMAND_SEEK_TO_NEXT,
Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM,
Player.COMMAND_SEEK_TO_PREVIOUS,
Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM -> true
else -> super.isCommandAvailable(command)
}
override fun seekToNext() {}
override fun seekToNextMediaItem() {}
override fun seekToPrevious() {}
override fun seekToPreviousMediaItem() {}
}
// Tapping the notification opens the app directly to the Now Playing screen.
val notifTapIntent = Intent(this, MainActivity::class.java).apply {
putExtra("open_now_playing", true)
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val notifTapPending = PendingIntent.getActivity(
this, 0, notifTapIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
mediaSession = MediaSession.Builder(this, sessionPlayer)
.setBitmapLoader(bitmapLoader)
.setSessionActivity(notifTapPending)
.setCallback(object : MediaSession.Callback {
// Intercept notification next/prev so JS (not ExoPlayer) handles queue logic.
// With single-track model ExoPlayer has no queue to advance — block the command
// and delegate to JS instead.
override fun onPlayerCommandRequest(
session: MediaSession,
controller: MediaSession.ControllerInfo,
playerCommand: Int
): Int = when (playerCommand) {
Player.COMMAND_SEEK_TO_NEXT,
Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM -> {
stateListener?.onSkipToNext()
// Return SUCCESS so Media3 keeps the button enabled in the notification.
// The ForwardingPlayer no-ops seekToNext so ExoPlayer does nothing.
androidx.media3.session.SessionResult.RESULT_SUCCESS
}
Player.COMMAND_SEEK_TO_PREVIOUS,
Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM -> {
stateListener?.onSkipToPrevious()
androidx.media3.session.SessionResult.RESULT_SUCCESS
}
else -> androidx.media3.session.SessionResult.RESULT_SUCCESS
}
})
.build()
player.addListener(PlayerListener())
// Pre-warm: load the last played track immediately so ExoPlayer starts
// buffering while the WebView is still loading — reduces startup latency.
try {
val prefs = getSharedPreferences("ns_prefs", android.content.Context.MODE_PRIVATE)
val trackJson = prefs.getString("last_played_track", null)
if (!trackJson.isNullOrBlank()) {
val track = Track.fromJson(org.json.JSONObject(trackJson))
player.setMediaItem(track.toMediaItem())
player.prepare()
// No play() — wait for JS to decide whether to auto-play
}
} catch (_: Exception) {}
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo) = mediaSession
override fun onTaskRemoved(rootIntent: Intent?) {
player.stop()
stopSelf()
}
override fun onDestroy() {
instance = null
handler.removeCallbacksAndMessages(null)
mediaSession.release()
player.release()
super.onDestroy()
}
// ─── Artwork pre-warming ──────────────────────────────────────────────────
// Loads the next `count` tracks' artwork into the CacheBitmapLoader so that
// onMediaItemTransition never stalls waiting for a fresh HTTP fetch.
// fromIndex: explicit start index; -1 means use player.currentMediaItemIndex.
private fun warmArtworkCache(count: Int = 3, fromIndex: Int = -1) {
val startIdx = if (fromIndex >= 0) fromIndex else player.currentMediaItemIndex
val total = player.mediaItemCount
for (offset in 0 until count) {
val idx = startIdx + offset
if (idx >= total) break
val item = player.getMediaItemAt(idx)
if (item.mediaMetadata.artworkUri != null) {
bitmapLoader.loadBitmapFromMetadata(item.mediaMetadata)
}
}
}
// Called by WebBridge right after setMediaItems so artwork fetch starts
// before prepare()/play() — avoids the race where the notification renders
// before the bitmap is in the cache.
fun publicWarmArtwork(startIndex: Int) = warmArtworkCache(count = 5, fromIndex = startIndex)
// ─── Player.Listener ─────────────────────────────────────────────────────
private inner class PlayerListener : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
val state = player.playbackState
stateListener?.onPlaybackStateChanged(
isPlaying,
isBuffering = state == Player.STATE_BUFFERING,
isLoading = state == Player.STATE_IDLE || state == Player.STATE_BUFFERING
)
if (isPlaying) handler.post(positionTicker)
else handler.removeCallbacks(positionTicker)
}
override fun onPlaybackStateChanged(playbackState: Int) {
val isPlaying = player.isPlaying
stateListener?.onPlaybackStateChanged(
isPlaying,
isBuffering = playbackState == Player.STATE_BUFFERING,
isLoading = playbackState == Player.STATE_IDLE
)
// STATE_ENDED fires when the last item finishes with no repeat
if (playbackState == Player.STATE_ENDED) stateListener?.onQueueEnded()
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
val reasonStr = when (reason) {
Player.MEDIA_ITEM_TRANSITION_REASON_AUTO -> "auto"
Player.MEDIA_ITEM_TRANSITION_REASON_SEEK -> "seek"
Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED -> "queue"
else -> "seek"
}
// Fire immediately — snapshot must always be fresh so the JS-side
// getState() reconciliation in onTrackChanged sees current native state.
stateListener?.onTrackChanged(
player.currentMediaItemIndex,
mediaItem?.mediaId ?: "",
reasonStr
)
// Pre-warm artwork for current + next tracks so notification
// never shows a blank cover on auto-advance.
warmArtworkCache()
}
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
// Queue was replaced (setQueue / addToQueue from JS) — warm cache from new start.
if (reason == Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) {
warmArtworkCache()
}
}
override fun onPlayerError(error: PlaybackException) {
stateListener?.onError(error.message ?: "Playback error")
}
}
}
@@ -1,4 +0,0 @@
package com.novasonic.app
// Replaced by Media3-managed notifications (MusicService.kt).
// Notification actions are now handled natively by Media3 MediaSession.
@@ -0,0 +1,24 @@
package com.novasonic.app
import android.app.Application
import com.novasonic.app.data.MusicRepository
import com.novasonic.app.player.PlayerController
class NovaSonicApp : Application() {
lateinit var repository: MusicRepository
private set
lateinit var playerController: PlayerController
private set
override fun onCreate() {
super.onCreate()
repository = MusicRepository(applicationContext)
playerController = PlayerController(applicationContext, repository)
playerController.connect()
}
}
// Convenience extension to access app-level singletons from any Activity/Composable context.
val android.content.Context.app: NovaSonicApp
get() = applicationContext as NovaSonicApp
@@ -1,9 +0,0 @@
package com.novasonic.app
/**
* Legacy singleton — no longer used. Kept to avoid breaking any stale git references.
* State is now pushed directly from MusicService.StateListener via MainActivity.callJs().
*/
object NovaSonicBridge {
@Volatile var jsRunner: ((String) -> Unit)? = null
}
@@ -1,4 +0,0 @@
package com.novasonic.app
// Replaced by WebBridge.kt (window.NSBridge).
// Kept as an empty file so git history is preserved.
@@ -1,177 +0,0 @@
package com.novasonic.app
import android.os.Handler
import android.os.Looper
import android.webkit.JavascriptInterface
import androidx.media3.common.Player
import androidx.media3.session.MediaController
import org.json.JSONObject
/**
* Exposed to the WebView as `window.NSBridge`.
* All @JavascriptInterface methods are called on a WebView background thread — every
* MediaController call is posted to the main looper before execution.
*/
class WebBridge(private val controllerProvider: () -> MediaController?) {
// Snapshot of current playback state; read from getState() on any thread.
@Volatile private var stateSnapshot = "{}"
fun updateSnapshot(snapshot: String) { stateSnapshot = snapshot }
// ─── Queue management ─────────────────────────────────────────────────────
@JavascriptInterface
fun setQueue(queueJson: String, startIndex: Int, autoPlay: Boolean) {
val items = parseTrackList(queueJson).map { it.toMediaItem() }
if (items.isEmpty()) return
runOnMain {
val ctrl = controllerProvider() ?: return@runOnMain
val idx = startIndex.coerceAtLeast(0)
// If ExoPlayer already has this exact single track buffered from the cold-start
// pre-warm, skip setMediaItems+prepare so we don't throw away the buffered data.
// Just call play() — this makes the "resume last track" instant instead of ~10s.
val alreadyReady = items.size == 1 &&
ctrl.mediaItemCount == 1 &&
ctrl.currentMediaItem?.mediaId == items[0].mediaId &&
ctrl.playbackState != Player.STATE_IDLE
if (!alreadyReady) {
ctrl.setMediaItems(items, idx, 0L)
// Pre-warm artwork before prepare()/play() so the notification bitmap
// cache is populated before Media3 renders the first notification.
MusicService.instance?.publicWarmArtwork(idx)
ctrl.prepare()
}
if (autoPlay) ctrl.play()
}
}
@JavascriptInterface
fun addToQueue(trackJson: String, position: Int) {
val track = try { Track.fromJson(JSONObject(trackJson)) } catch (_: Exception) { return }
runOnMain {
val ctrl = controllerProvider() ?: return@runOnMain
val item = track.toMediaItem()
if (position < 0 || position >= ctrl.mediaItemCount) ctrl.addMediaItem(item)
else ctrl.addMediaItem(position, item)
}
}
@JavascriptInterface
fun removeFromQueue(index: Int) {
runOnMain { controllerProvider()?.removeMediaItem(index) }
}
@JavascriptInterface
fun clearQueue() {
runOnMain {
val ctrl = controllerProvider() ?: return@runOnMain
ctrl.stop()
ctrl.clearMediaItems()
}
}
// ─── Transport ────────────────────────────────────────────────────────────
@JavascriptInterface
fun play() { runOnMain { controllerProvider()?.play() } }
@JavascriptInterface
fun pause() { runOnMain { controllerProvider()?.pause() } }
@JavascriptInterface
fun togglePlay() {
runOnMain {
val ctrl = controllerProvider() ?: return@runOnMain
if (ctrl.isPlaying) ctrl.pause() else ctrl.play()
}
}
@JavascriptInterface
fun seekTo(positionMs: Long) {
runOnMain { controllerProvider()?.seekTo(positionMs) }
}
@JavascriptInterface
fun seekToIndex(index: Int, positionMs: Long) {
runOnMain { controllerProvider()?.seekTo(index, positionMs) }
}
@JavascriptInterface
fun next() { runOnMain { controllerProvider()?.seekToNextMediaItem() } }
@JavascriptInterface
fun prev() {
runOnMain {
val ctrl = controllerProvider() ?: return@runOnMain
if (ctrl.currentPosition > 3000) ctrl.seekTo(0)
else ctrl.seekToPreviousMediaItem()
}
}
// ─── Playback settings ────────────────────────────────────────────────────
@JavascriptInterface
fun setRepeatMode(mode: String) {
runOnMain {
val ctrl = controllerProvider() ?: return@runOnMain
ctrl.repeatMode = when (mode) {
"one" -> Player.REPEAT_MODE_ONE
"all" -> Player.REPEAT_MODE_ALL
else -> Player.REPEAT_MODE_OFF
}
}
}
@JavascriptInterface
fun setShuffleMode(enabled: Boolean) {
runOnMain { controllerProvider()?.shuffleModeEnabled = enabled }
}
@JavascriptInterface
fun setVolume(volume: Float) {
runOnMain { controllerProvider()?.volume = volume }
}
@JavascriptInterface
fun setPlaybackSpeed(speed: Float) {
runOnMain { controllerProvider()?.setPlaybackSpeed(speed) }
}
// ─── State snapshot (synchronous read from JS) ────────────────────────────
@JavascriptInterface
fun getState(): String = stateSnapshot
// ─── Last played (persisted for startup preload) ──────────────────────────
// Called by JS whenever the current song changes — persists to SharedPreferences
// so MusicService can start buffering immediately on next launch before JS loads.
@JavascriptInterface
fun saveLastPlayed(trackJson: String) {
runOnMain {
try {
val ctx = MusicService.instance ?: return@runOnMain
ctx.getSharedPreferences("ns_prefs", android.content.Context.MODE_PRIVATE)
.edit().putString("last_played_track", trackJson).apply()
} catch (_: Exception) {}
}
}
// ─── Overlay state (for back-button routing) ──────────────────────────────
// Written by WebView thread; read by UI thread — must be @Volatile.
@Volatile private var hasOpenOverlay = false
@JavascriptInterface
fun setOverlayState(active: Boolean) { hasOpenOverlay = active }
// Called by MainActivity on back press; returns true if JS owns the event.
fun consumeBackPress(): Boolean = hasOpenOverlay
// ─── Internal ─────────────────────────────────────────────────────────────
private fun runOnMain(block: () -> Unit) {
Handler(Looper.getMainLooper()).post(block)
}
}
@@ -0,0 +1,58 @@
package com.novasonic.app.api
import com.novasonic.app.api.model.*
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.android.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
/** Talks to the NovaSonic download/importer backend (Express server, default :3700). */
class ImporterClient(private val backendUrl: String) {
private val http = HttpClient(Android) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true; coerceInputValues = true })
}
install(HttpTimeout) {
requestTimeoutMillis = 40_000
connectTimeoutMillis = 12_000
socketTimeoutMillis = 40_000
}
}
private fun base() = backendUrl.trimEnd('/')
companion object {
/** Detect which importer endpoint to hit from a pasted URL. */
fun platformFor(url: String): String? = when {
url.contains("spotify.com") -> "spotify"
url.contains("soundcloud.com") -> "soundcloud"
url.contains("youtube.com") ||
url.contains("youtu.be") -> "youtube"
else -> null
}
}
suspend fun resolve(platform: String, url: String): ImportResolveResponse =
http.post("${base()}/api/$platform/resolve") {
contentType(ContentType.Application.Json)
setBody(ResolveRequest(url))
}.body()
suspend fun download(request: DownloadRequest) {
http.post("${base()}/api/download") {
contentType(ContentType.Application.Json)
setBody(request)
}
}
suspend fun status(jobId: String): DownloadStatus =
http.get("${base()}/api/status/$jobId").body()
fun close() = http.close()
}
@@ -0,0 +1,70 @@
package com.novasonic.app.api
import com.novasonic.app.api.model.*
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.android.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.http.ContentType
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
object ItunesClient {
private val http = HttpClient(Android) {
install(ContentNegotiation) {
// iTunes returns text/javascript instead of application/json — accept any content type
json(Json { ignoreUnknownKeys = true; coerceInputValues = true }, contentType = ContentType.Any)
}
}
suspend fun search(
query: String,
entity: String = "musicTrack,album,musicArtist",
limit: Int = 30,
country: String = "us",
): ItunesSearchResponse = http.get("https://itunes.apple.com/search") {
parameter("term", query)
parameter("media", "music")
parameter("entity", entity)
parameter("limit", limit)
parameter("country", country)
}.body()
suspend fun lookupAlbum(collectionId: Long): List<ItunesItem> {
val resp: ItunesSearchResponse = http.get("https://itunes.apple.com/lookup") {
parameter("id", collectionId)
parameter("entity", "song")
}.body()
// First result is the album itself; rest are tracks
return resp.results.filter { it.kind == "song" }
}
/** All albums for an iTunes artist */
suspend fun lookupArtistAlbums(artistId: Long): List<ItunesItem> {
val resp: ItunesSearchResponse = http.get("https://itunes.apple.com/lookup") {
parameter("id", artistId)
parameter("entity", "album")
parameter("limit", 200)
}.body()
return resp.results.filter { it.wrapperType == "collection" }
}
/** Worldwide top tracks for an iTunes artist */
suspend fun lookupArtistTopTracks(artistId: Long, limit: Int = 10): List<ItunesItem> {
val resp: ItunesSearchResponse = http.get("https://itunes.apple.com/lookup") {
parameter("id", artistId)
parameter("entity", "musicTrack")
parameter("limit", limit + 1) // first result is the artist itself
}.body()
return resp.results.filter { it.wrapperType == "track" && it.kind == "song" }.take(limit)
}
/** Top albums RSS feed for a given country */
suspend fun topAlbums(country: String = "us", limit: Int = 25): List<ItunesRssEntry> {
val resp: ItunesRssResponse =
http.get("https://itunes.apple.com/$country/rss/topalbums/limit=$limit/json").body()
return resp.feed?.entry ?: emptyList()
}
}
@@ -0,0 +1,64 @@
package com.novasonic.app.api
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.android.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class LrcLibResponse(
val id: Long = 0,
val trackName: String = "",
val artistName: String = "",
val albumName: String = "",
val duration: Double = 0.0,
val instrumental: Boolean = false,
val plainLyrics: String? = null,
val syncedLyrics: String? = null,
)
data class LrcLine(val timeMs: Long, val text: String)
fun parseLrc(lrc: String): List<LrcLine> {
val regex = Regex("""\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)""")
return lrc.lines()
.mapNotNull { line ->
regex.find(line.trim())?.let { m ->
val mm = m.groupValues[1].toLong()
val ss = m.groupValues[2].toLong()
val frac = m.groupValues[3]
val ms = mm * 60_000 + ss * 1_000 +
if (frac.length == 2) frac.toLong() * 10 else frac.toLong()
val text = m.groupValues[4].trim()
if (text.isNotEmpty()) LrcLine(ms, text) else null
}
}
.sortedBy { it.timeMs }
}
object LrcLibClient {
private val client = HttpClient(Android) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true; isLenient = true })
}
}
suspend fun getLyrics(
artist: String,
title: String,
album: String?,
durationSecs: Int?,
): LrcLibResponse? = runCatching {
client.get("https://lrclib.net/api/get") {
parameter("artist_name", artist)
parameter("track_name", title)
if (!album.isNullOrBlank()) parameter("album_name", album)
if (durationSecs != null && durationSecs > 0) parameter("duration", durationSecs)
}.body<LrcLibResponse>()
}.getOrNull()
}
@@ -0,0 +1,186 @@
package com.novasonic.app.api
import com.novasonic.app.api.model.*
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.android.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
import java.security.MessageDigest
import kotlin.random.Random
class SubsonicClient(
private val serverUrl: String,
private val username: String,
private val password: String,
private val clientName: String = "NovaSonic",
private val apiVersion: String = "1.16.1",
) {
private val http = HttpClient(Android) {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
coerceInputValues = true
})
}
install(HttpTimeout) {
requestTimeoutMillis = 20_000
connectTimeoutMillis = 12_000
socketTimeoutMillis = 20_000
}
install(Logging) {
level = LogLevel.INFO
}
}
// ── Auth ──────────────────────────────────────────────────────────────────
private fun authParams(): Map<String, String> {
val salt = Random.nextInt(100_000, 999_999).toString()
val token = md5(password + salt)
return mapOf("u" to username, "t" to token, "s" to salt,
"v" to apiVersion, "c" to clientName, "f" to "json")
}
private fun md5(input: String): String =
MessageDigest.getInstance("MD5")
.digest(input.toByteArray())
.joinToString("") { "%02x".format(it) }
private fun url(path: String) = "${serverUrl.trimEnd('/')}/rest/$path"
// ── Core request ──────────────────────────────────────────────────────────
private suspend fun get(path: String, params: Map<String, Any?> = emptyMap()): SubsonicResponse {
val combined = authParams() + params.filterValues { it != null }
.mapValues { it.value.toString() }
val resp: SubsonicWrapper = http.get(url(path)) {
combined.forEach { (k, v) -> parameter(k, v) }
}.body()
if (resp.response.status != "ok") {
val err = resp.response.error
throw SubsonicException(err?.code ?: -1, err?.message ?: "Unknown error")
}
return resp.response
}
// ── Cover art / stream URLs (no auth roundtrip needed for URL building) ───
fun coverArtUrl(id: String, size: Int = 300): String {
val salt = "novasonic"
val token = md5(password + salt)
val base = url("getCoverArt.view")
return "$base?id=$id&size=$size&u=$username&t=$token&s=$salt&v=$apiVersion&c=$clientName&f=json"
}
fun streamUrl(id: String, maxBitRate: Int? = null): String {
val salt = "novasonic"
val token = md5(password + salt)
val base = url("stream.view")
val br = if (maxBitRate != null) "&maxBitRate=$maxBitRate" else ""
return "$base?id=$id$br&u=$username&t=$token&s=$salt&v=$apiVersion&c=$clientName&f=json"
}
// ── API methods ───────────────────────────────────────────────────────────
suspend fun ping() = get("ping.view")
suspend fun getAlbumList(
type: String = "newest",
size: Int = 30,
offset: Int = 0,
): List<Album> = get("getAlbumList2.view",
mapOf("type" to type, "size" to size, "offset" to offset))
.albumList2?.album ?: emptyList()
suspend fun getAlbum(id: String): Album =
get("getAlbum.view", mapOf("id" to id)).album
?: throw SubsonicException(-1, "Album not found")
suspend fun getArtists(): List<ArtistIndex> =
get("getArtists.view").artists?.index ?: emptyList()
suspend fun getArtist(id: String): Artist =
get("getArtist.view", mapOf("id" to id)).artist
?: throw SubsonicException(-1, "Artist not found")
suspend fun getSong(id: String): Song =
get("getSong.view", mapOf("id" to id)).song
?: throw SubsonicException(-1, "Song not found")
suspend fun search(query: String, albumCount: Int = 10, artistCount: Int = 5, songCount: Int = 20): SearchResult3 =
get("search3.view", mapOf(
"query" to query,
"albumCount" to albumCount,
"artistCount" to artistCount,
"songCount" to songCount,
)).searchResult3 ?: SearchResult3()
suspend fun getPlaylists(): List<Playlist> =
get("getPlaylists.view").playlists?.playlist ?: emptyList()
suspend fun getPlaylist(id: String): PlaylistWithSongs =
get("getPlaylist.view", mapOf("id" to id)).playlist
?: throw SubsonicException(-1, "Playlist not found")
suspend fun getStarred(): Starred2 =
get("getStarred2.view").starred2 ?: Starred2()
suspend fun getRandomSongs(size: Int = 50): List<Song> =
get("getRandomSongs.view", mapOf("size" to size)).randomSongs?.song ?: emptyList()
suspend fun star(id: String) = get("star.view", mapOf("id" to id))
suspend fun unstar(id: String) = get("unstar.view", mapOf("id" to id))
suspend fun scrobble(id: String, submission: Boolean = true) =
get("scrobble.view", mapOf("id" to id, "submission" to submission))
suspend fun getArtistInfo(id: String, count: Int = 10): com.novasonic.app.api.model.ArtistInfo =
get("getArtistInfo2.view", mapOf("id" to id, "count" to count)).artistInfo2
?: com.novasonic.app.api.model.ArtistInfo()
suspend fun getTopSongs(artist: String, count: Int = 10): List<com.novasonic.app.api.model.Song> =
get("getTopSongs.view", mapOf("artist" to artist, "count" to count)).topSongs?.song
?: emptyList()
suspend fun getGenres(): List<com.novasonic.app.api.model.Genre> =
get("getGenres.view").genres?.genre ?: emptyList()
suspend fun getSongsByGenre(genre: String, count: Int = 50): List<com.novasonic.app.api.model.Song> =
get("getSongsByGenre.view", mapOf("genre" to genre, "count" to count)).songsByGenre?.song
?: emptyList()
suspend fun getLyrics(songId: String): List<com.novasonic.app.api.model.StructuredLyrics> =
get("getLyricsBySongId.view", mapOf("id" to songId)).lyricsList?.structuredLyrics
?: emptyList()
suspend fun addSongToPlaylist(playlistId: String, songId: String) =
getList("updatePlaylist.view", listOf("playlistId" to playlistId, "songIdToAdd" to songId))
suspend fun createPlaylist(name: String, songIds: List<String> = emptyList()): com.novasonic.app.api.model.PlaylistWithSongs {
val params = mutableListOf("name" to name)
songIds.forEach { params += "songId" to it }
return getList("createPlaylist.view", params).playlist
?: throw SubsonicException(-1, "Failed to create playlist")
}
fun close() = http.close()
private suspend fun getList(path: String, params: List<Pair<String, String>>): SubsonicResponse {
val allParams = authParams().entries.map { (k, v) -> k to v } + params
val resp: SubsonicWrapper = http.get(url(path)) {
allParams.forEach { (k, v) -> parameter(k, v) }
}.body()
if (resp.response.status != "ok") {
val err = resp.response.error
throw SubsonicException(err?.code ?: -1, err?.message ?: "Unknown error")
}
return resp.response
}
}
class SubsonicException(val code: Int, message: String) : Exception(message)
@@ -0,0 +1,53 @@
package com.novasonic.app.api.model
import kotlinx.serialization.Serializable
/** Response from POST {backend}/api/{platform}/resolve */
@Serializable
data class ImportResolveResponse(
val type: String? = null, // "track" | "album" | "playlist"
val name: String? = null,
val artist: String? = null,
val artworkUrl: String? = null,
val tracks: List<ImportTrack> = emptyList(),
val skippedLocal: Int = 0,
val error: String? = null,
)
@Serializable
data class ImportTrack(
val trackName: String? = null,
val artistName: String? = null,
val collectionName: String? = null,
val artworkUrl100: String? = null,
val previewUrl: String? = null,
val durationMs: Long? = null,
val trackNumber: Int? = null,
)
/** Body for POST {backend}/api/download */
@Serializable
data class DownloadRequest(
val jobId: String,
val type: String, // "track" | "album" | "artist"
val title: String,
val artist: String? = null,
val album: String? = null,
val artworkUrl: String? = null,
val itunesId: Long? = null,
val collectionId: Long? = null,
val tracks: List<ImportTrack> = emptyList(),
)
@Serializable
data class ResolveRequest(val url: String)
/** Response from GET {backend}/api/status/{jobId} */
@Serializable
data class DownloadStatus(
val status: String? = null, // "pending" | "downloading" | "done" | "error"
val progress: Int? = null,
val message: String? = null,
val completed: Int? = null,
val total: Int? = null,
)
@@ -0,0 +1,84 @@
package com.novasonic.app.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ItunesSearchResponse(
val resultCount: Int = 0,
val results: List<ItunesItem> = emptyList(),
)
@Serializable
data class ItunesItem(
val wrapperType: String? = null, // "track" | "collection" | "artist"
val kind: String? = null, // "song" | "album" etc
val artistId: Long? = null,
val collectionId: Long? = null,
val trackId: Long? = null,
val artistName: String? = null,
val collectionName: String? = null,
val trackName: String? = null,
val artworkUrl100: String? = null,
val previewUrl: String? = null,
val trackTimeMillis: Long? = null,
val releaseDate: String? = null,
val primaryGenreName: String? = null,
val trackCount: Int? = null,
val trackNumber: Int? = null,
val collectionPrice: Double? = null,
)
// iTunes RSS feed response
@Serializable
data class ItunesRssResponse(
val feed: ItunesFeed? = null,
)
@Serializable
data class ItunesFeed(
val entry: List<ItunesRssEntry> = emptyList(),
)
@Serializable
data class ItunesRssEntry(
@SerialName("im:name") val name: ItunesLabel? = null,
@SerialName("im:artist") val artist: ItunesLabel? = null,
@SerialName("im:image") val image: List<ItunesLabel> = emptyList(),
@SerialName("im:itemCount") val itemCount: ItunesLabel? = null,
val id: ItunesId? = null,
val category: ItunesCategory? = null,
)
@Serializable
data class ItunesLabel(
val label: String? = null,
)
@Serializable
data class ItunesId(
val label: String? = null,
val attributes: ItunesIdAttributes? = null,
)
@Serializable
data class ItunesIdAttributes(
@SerialName("im:id") val imId: String? = null,
)
@Serializable
data class ItunesCategory(
val attributes: ItunesCategoryAttributes? = null,
)
@Serializable
data class ItunesCategoryAttributes(
val term: String? = null,
)
// Helper to get high-res artwork (replace 100x100 with 600x600)
fun ItunesItem.artworkHd(): String? =
artworkUrl100?.replace("100x100bb", "600x600bb")?.replace("/100x100/", "/600x600/")
fun ItunesRssEntry.artworkHd(): String? =
image.lastOrNull()?.label // last image is always the largest in the RSS
@@ -0,0 +1,194 @@
package com.novasonic.app.api.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
// ── Subsonic JSON envelope ────────────────────────────────────────────────────
@Serializable
data class SubsonicWrapper(
@SerialName("subsonic-response") val response: SubsonicResponse,
)
@Serializable
data class SubsonicResponse(
val status: String,
val version: String = "1.16.1",
val error: SubsonicError? = null,
val musicFolders: MusicFoldersResult? = null,
val albumList2: AlbumListResult? = null,
val album: Album? = null,
val artists: ArtistsResult? = null,
val artist: Artist? = null,
val searchResult3: SearchResult3? = null,
val playlists: PlaylistsResult? = null,
val playlist: PlaylistWithSongs? = null,
val song: Song? = null,
val starred2: Starred2? = null,
val randomSongs: SongListResult? = null,
val scanStatus: ScanStatus? = null,
val artistInfo2: ArtistInfo? = null,
val topSongs: SongListResult? = null,
val genres: GenresResult? = null,
val songsByGenre: SongListResult? = null,
val lyricsList: LyricsListResult? = null,
)
@Serializable
data class SubsonicError(val code: Int, val message: String)
// ── Domain models ─────────────────────────────────────────────────────────────
@Serializable
data class Song(
val id: String,
val title: String,
val album: String? = null,
val albumId: String? = null,
val artist: String? = null,
val artistId: String? = null,
val track: Int? = null,
val year: Int? = null,
val genre: String? = null,
val coverArt: String? = null,
val size: Long? = null,
val contentType: String? = null,
val suffix: String? = null,
val duration: Int? = null,
val bitRate: Int? = null,
val path: String? = null,
val playCount: Long? = null,
val starred: String? = null, // ISO date string when starred
)
@Serializable
data class Album(
val id: String,
val name: String,
val artist: String? = null,
val artistId: String? = null,
val coverArt: String? = null,
val songCount: Int = 0,
val duration: Int = 0,
val year: Int? = null,
val genre: String? = null,
val starred: String? = null,
val song: List<Song> = emptyList(),
)
@Serializable
data class Artist(
val id: String,
val name: String,
val coverArt: String? = null,
val albumCount: Int = 0,
val starred: String? = null,
val album: List<Album> = emptyList(),
)
@Serializable
data class ArtistIndex(
val name: String,
val artist: List<Artist> = emptyList(),
)
@Serializable
data class Playlist(
val id: String,
val name: String,
val comment: String? = null,
val owner: String? = null,
val songCount: Int = 0,
val duration: Int = 0,
val coverArt: String? = null,
)
@Serializable
data class PlaylistWithSongs(
val id: String,
val name: String,
val comment: String? = null,
val owner: String? = null,
val songCount: Int = 0,
val duration: Int = 0,
val coverArt: String? = null,
val entry: List<Song> = emptyList(),
)
// ── Result wrappers ───────────────────────────────────────────────────────────
@Serializable
data class MusicFoldersResult(val musicFolder: List<MusicFolder> = emptyList())
@Serializable
data class MusicFolder(val id: String, val name: String? = null)
@Serializable
data class AlbumListResult(val album: List<Album> = emptyList())
@Serializable
data class ArtistsResult(val index: List<ArtistIndex> = emptyList())
@Serializable
data class PlaylistsResult(val playlist: List<Playlist> = emptyList())
@Serializable
data class SongListResult(val song: List<Song> = emptyList())
@Serializable
data class Starred2(
val song: List<Song> = emptyList(),
val album: List<Album> = emptyList(),
val artist: List<Artist> = emptyList(),
)
@Serializable
data class SearchResult3(
val song: List<Song> = emptyList(),
val album: List<Album> = emptyList(),
val artist: List<Artist> = emptyList(),
)
@Serializable
data class ScanStatus(val scanning: Boolean, val count: Long? = null)
// ── Artist info (Last.fm bio + similar artists) ───────────────────────────────
@Serializable
data class ArtistInfo(
val biography: String? = null,
val musicBrainzId: String? = null,
val lastFmUrl: String? = null,
val smallImageUrl: String? = null,
val mediumImageUrl: String? = null,
val largeImageUrl: String? = null,
val similarArtist: List<Artist> = emptyList(),
)
// ── Genres ────────────────────────────────────────────────────────────────────
@Serializable
data class Genre(
val value: String, // genre name is the text value
val songCount: Int = 0,
val albumCount: Int = 0,
)
@Serializable
data class GenresResult(val genre: List<Genre> = emptyList())
// ── Lyrics ────────────────────────────────────────────────────────────────────
@Serializable
data class LyricsLine(val start: Long? = null, val value: String = "")
@Serializable
data class StructuredLyrics(
val lang: String = "",
val synced: Boolean = false,
val line: List<LyricsLine> = emptyList(),
val displayArtist: String? = null,
val displayTitle: String? = null,
val offset: Long? = null,
)
@Serializable
data class LyricsListResult(val structuredLyrics: List<StructuredLyrics> = emptyList())
@@ -0,0 +1,122 @@
package com.novasonic.app.data
import android.content.Context
import com.novasonic.app.api.SubsonicClient
import com.novasonic.app.api.model.*
import kotlinx.coroutines.flow.first
object ClientHolder {
@Volatile private var client: SubsonicClient? = null
@Volatile private var lastCreds: PrefsStore.Credentials? = null
fun get(creds: PrefsStore.Credentials): SubsonicClient {
if (creds != lastCreds) {
client?.close()
client = SubsonicClient(creds.serverUrl, creds.username, creds.password)
lastCreds = creds
}
return client!!
}
fun invalidate() {
client?.close()
client = null
lastCreds = null
}
}
class MusicRepository(private val context: Context) {
private val prefs = PrefsStore(context)
private suspend fun api(): SubsonicClient =
ClientHolder.get(prefs.credentials.first())
suspend fun ping() = api().ping()
suspend fun getRecentAlbums(size: Int = 20) = api().getAlbumList("newest", size)
suspend fun getFrequentAlbums(size: Int = 20) = api().getAlbumList("frequent", size)
suspend fun getRecentlyPlayedAlbums(size: Int = 20) = api().getAlbumList("recent", size)
suspend fun getRandomAlbums(size: Int = 20) = api().getAlbumList("random", size)
suspend fun getAlphaAlbums(size: Int = 40, offset: Int = 0) =
api().getAlbumList("alphabeticalByName", size, offset)
suspend fun getAlbum(id: String) = api().getAlbum(id)
suspend fun getArtists() = api().getArtists()
suspend fun getArtist(id: String) = api().getArtist(id)
suspend fun getSong(id: String) = api().getSong(id)
suspend fun search(query: String) = api().search(query)
suspend fun getPlaylists() = api().getPlaylists()
suspend fun getPlaylist(id: String) = api().getPlaylist(id)
suspend fun getStarred() = api().getStarred()
suspend fun getRandomSongs() = api().getRandomSongs()
suspend fun star(id: String) = api().star(id)
suspend fun unstar(id: String) = api().unstar(id)
suspend fun scrobble(id: String) = api().scrobble(id)
suspend fun getArtistInfo(id: String) = api().getArtistInfo(id)
suspend fun getTopSongs(artistName: String) = api().getTopSongs(artistName)
suspend fun getGenres() = api().getGenres()
suspend fun getSongsByGenre(genre: String) = api().getSongsByGenre(genre)
suspend fun getLyrics(songId: String) = api().getLyrics(songId)
suspend fun addSongToPlaylist(playlistId: String, songId: String) = api().addSongToPlaylist(playlistId, songId)
suspend fun createPlaylist(name: String, songIds: List<String>) = api().createPlaylist(name, songIds)
fun coverArtUrl(id: String?, size: Int = 300): String? {
val creds = lastCredentials ?: return null
return SubsonicClient(creds.serverUrl, creds.username, creds.password)
.coverArtUrl(id ?: return null, size)
}
fun streamUrl(id: String): String? {
val creds = lastCredentials ?: return null
return SubsonicClient(creds.serverUrl, creds.username, creds.password).streamUrl(id)
}
var lastCredentials: PrefsStore.Credentials? = null
fun credentialsFlow() = prefs.credentials
fun appSettingsFlow() = prefs.appSettings
suspend fun getBackendUrl(): String = prefs.appSettings.first().backendUrl
/** Finds Navidrome albums for a given artist name (for iTunes cross-reference). */
suspend fun getNdAlbumsForArtist(artistName: String): List<com.novasonic.app.api.model.Album> {
// Try exact artist match first, then fall back to album search
val artistResult = api().search(artistName, albumCount = 0, artistCount = 10, songCount = 0)
val ndArtist = artistResult.artist.find { a ->
a.name.equals(artistName, ignoreCase = true) ||
a.name.contains(artistName, ignoreCase = true) ||
artistName.contains(a.name, ignoreCase = true)
}
return if (ndArtist != null) {
runCatching { api().getArtist(ndArtist.id).album }.getOrDefault(emptyList())
} else {
api().search(artistName, albumCount = 50, artistCount = 0, songCount = 0).album
.filter { it.artist?.contains(artistName, ignoreCase = true) == true }
}
}
suspend fun saveCredentials(serverUrl: String, username: String, password: String) {
ClientHolder.invalidate()
prefs.save(serverUrl, username, password)
}
suspend fun saveTheme(themeId: String) = prefs.saveTheme(themeId)
suspend fun saveAppSettings(settings: PrefsStore.AppSettings) {
val urlChanged = settings.serverUrl != lastCredentials?.serverUrl ||
settings.username != lastCredentials?.username ||
settings.password != lastCredentials?.password
if (urlChanged) ClientHolder.invalidate()
prefs.saveAll(settings)
}
suspend fun getScrobbleDelayMs(durationMs: Long): Long {
val mode = prefs.appSettings.first().scrobbleMode
return when (mode) {
"15s" -> 15_000L
"30s" -> 30_000L
"third" -> (durationMs / 3).coerceAtLeast(15_000L)
"two_thirds" -> (durationMs * 2 / 3).coerceAtLeast(15_000L)
"end" -> (durationMs - 5_000L).coerceAtLeast(15_000L)
else -> 30_000L
}
}
}
@@ -0,0 +1,90 @@
package com.novasonic.app.data
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.dataStore by preferencesDataStore("novasonic_prefs")
class PrefsStore(private val context: Context) {
companion object {
val KEY_SERVER = stringPreferencesKey("server_url")
val KEY_USERNAME = stringPreferencesKey("username")
val KEY_PASSWORD = stringPreferencesKey("password")
val KEY_LASTFM_KEY = stringPreferencesKey("lastfm_key")
val KEY_BACKEND_URL = stringPreferencesKey("backend_url")
val KEY_SCROBBLE_MODE = stringPreferencesKey("scrobble_mode") // 15s|30s|third|two_thirds|end
val KEY_SHOW_COUNT = booleanPreferencesKey("show_play_count")
val KEY_THEME = stringPreferencesKey("theme")
}
data class Credentials(val serverUrl: String, val username: String, val password: String) {
val isValid get() = serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank()
}
data class AppSettings(
val serverUrl: String = "",
val username: String = "",
val password: String = "",
val lastFmKey: String = "",
val backendUrl: String = "",
val scrobbleMode: String = "30s",
val showPlayCount: Boolean = false,
val theme: String = "dark-ocean",
)
val credentials: Flow<Credentials> = context.dataStore.data.map { prefs ->
Credentials(
serverUrl = prefs[KEY_SERVER] ?: "",
username = prefs[KEY_USERNAME] ?: "",
password = prefs[KEY_PASSWORD] ?: "",
)
}
val appSettings: Flow<AppSettings> = context.dataStore.data.map { prefs ->
AppSettings(
serverUrl = prefs[KEY_SERVER] ?: "",
username = prefs[KEY_USERNAME] ?: "",
password = prefs[KEY_PASSWORD] ?: "",
lastFmKey = prefs[KEY_LASTFM_KEY] ?: "",
backendUrl = prefs[KEY_BACKEND_URL] ?: "",
scrobbleMode = prefs[KEY_SCROBBLE_MODE] ?: "30s",
showPlayCount = prefs[KEY_SHOW_COUNT] ?: false,
theme = prefs[KEY_THEME] ?: "dark-ocean",
)
}
suspend fun save(serverUrl: String, username: String, password: String) {
context.dataStore.edit { prefs ->
prefs[KEY_SERVER] = serverUrl.trimEnd('/')
prefs[KEY_USERNAME] = username
prefs[KEY_PASSWORD] = password
}
}
suspend fun saveAll(settings: AppSettings) {
context.dataStore.edit { prefs ->
prefs[KEY_SERVER] = settings.serverUrl.trimEnd('/')
prefs[KEY_USERNAME] = settings.username
prefs[KEY_PASSWORD] = settings.password
prefs[KEY_LASTFM_KEY] = settings.lastFmKey
prefs[KEY_BACKEND_URL] = settings.backendUrl
prefs[KEY_SCROBBLE_MODE] = settings.scrobbleMode
prefs[KEY_SHOW_COUNT] = settings.showPlayCount
prefs[KEY_THEME] = settings.theme
}
}
suspend fun saveTheme(themeId: String) {
context.dataStore.edit { prefs -> prefs[KEY_THEME] = themeId }
}
suspend fun clear() {
context.dataStore.edit { it.clear() }
}
}
@@ -0,0 +1,307 @@
package com.novasonic.app.player
import android.app.PendingIntent
import android.content.Intent
import android.os.Bundle
import androidx.media3.common.*
import androidx.media3.datasource.DataSourceBitmapLoader
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.*
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.MoreExecutors
import com.google.common.util.concurrent.SettableFuture
import com.novasonic.app.data.MusicRepository
import com.novasonic.app.ui.MainActivity
import kotlinx.coroutines.*
import java.util.concurrent.Executors
class MusicService : MediaLibraryService() {
companion object {
// Android Auto root IDs
const val ROOT_ID = "ROOT"
const val RECENT_ID = "RECENT"
const val ALBUMS_ID = "ALBUMS"
const val ARTISTS_ID = "ARTISTS"
const val PLAYLISTS_ID = "PLAYLISTS"
const val ITEM_PREFIX_ALBUM = "album:"
const val ITEM_PREFIX_ARTIST = "artist:"
const val ITEM_PREFIX_PL = "playlist:"
}
private lateinit var player: ExoPlayer
private lateinit var session: MediaLibrarySession
private lateinit var repo: MusicRepository
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
override fun onCreate() {
super.onCreate()
repo = MusicRepository(applicationContext)
val httpFactory = DefaultHttpDataSource.Factory()
.setUserAgent("NovaSonic-Android/1.0")
.setConnectTimeoutMs(15_000)
.setReadTimeoutMs(15_000)
.setAllowCrossProtocolRedirects(true)
val dataFactory = DefaultDataSource.Factory(this, httpFactory)
val executor = Executors.newSingleThreadExecutor()
val bitmapLoader = CacheBitmapLoader(
DataSourceBitmapLoader(MoreExecutors.listeningDecorator(executor), dataFactory)
)
val audioAttr = AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.setUsage(C.USAGE_MEDIA)
.build()
player = ExoPlayer.Builder(this)
.setMediaSourceFactory(DefaultMediaSourceFactory(dataFactory))
.setAudioAttributes(audioAttr, true)
.setHandleAudioBecomingNoisy(true)
.setWakeMode(C.WAKE_MODE_NETWORK)
.build()
val activityIntent = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
session = MediaLibrarySession.Builder(this, player, LibraryCallback())
.setBitmapLoader(bitmapLoader)
.setSessionActivity(activityIntent)
.build()
}
override fun onGetSession(info: MediaSession.ControllerInfo) = session
override fun onTaskRemoved(rootIntent: Intent?) { player.stop(); stopSelf() }
override fun onDestroy() {
scope.cancel()
session.release()
player.release()
super.onDestroy()
}
// ── Android Auto / MediaBrowser library callbacks ─────────────────────────
private inner class LibraryCallback : MediaLibrarySession.Callback {
override fun onGetLibraryRoot(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
params: LibraryParams?,
): ListenableFuture<LibraryResult<MediaItem>> {
val isAuto = params?.isRecent == true || isAndroidAuto(browser)
val root = MediaItem.Builder()
.setMediaId(ROOT_ID)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle("NovaSonic")
.setIsPlayable(false)
.setIsBrowsable(true)
.setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED)
.build()
).build()
return Futures.immediateFuture(LibraryResult.ofItem(root, null))
}
override fun onGetChildren(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
parentId: String,
page: Int,
pageSize: Int,
params: LibraryParams?,
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
val future = scope.async { buildChildren(parentId) }
val executor = MoreExecutors.directExecutor()
val settable = SettableFuture.create<LibraryResult<ImmutableList<MediaItem>>>()
scope.launch {
try {
settable.set(LibraryResult.ofItemList(future.await(), null))
} catch (e: Exception) {
settable.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_UNKNOWN))
}
}
return settable
}
override fun onGetItem(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
mediaId: String,
): ListenableFuture<LibraryResult<MediaItem>> {
val settable = SettableFuture.create<LibraryResult<MediaItem>>()
scope.launch {
try {
val item = when {
mediaId.startsWith(ITEM_PREFIX_ALBUM) -> {
val album = repo.getAlbum(mediaId.removePrefix(ITEM_PREFIX_ALBUM))
album.toMediaItem(repo)
}
else -> null
}
if (item != null) settable.set(LibraryResult.ofItem(item, null))
else settable.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE))
} catch (e: Exception) {
settable.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_UNKNOWN))
}
}
return settable
}
// When Android Auto selects a browsable item (album/playlist), load its songs.
override fun onAddMediaItems(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
mediaItems: MutableList<MediaItem>,
): ListenableFuture<MutableList<MediaItem>> {
val settable = SettableFuture.create<MutableList<MediaItem>>()
scope.launch {
try {
val resolved = mediaItems.flatMap { item ->
when {
item.mediaId.startsWith(ITEM_PREFIX_ALBUM) -> {
val album = repo.getAlbum(item.mediaId.removePrefix(ITEM_PREFIX_ALBUM))
album.song.map { song -> song.toMediaItem(repo) }
}
item.mediaId.startsWith(ITEM_PREFIX_PL) -> {
val pl = repo.getPlaylist(item.mediaId.removePrefix(ITEM_PREFIX_PL))
pl.entry.map { song -> song.toMediaItem(repo) }
}
else -> listOf(item)
}
}
settable.set(resolved.toMutableList())
} catch (e: Exception) {
settable.set(mediaItems)
}
}
return settable
}
}
// ── Build Android Auto browsable tree ─────────────────────────────────────
private suspend fun buildChildren(parentId: String): ImmutableList<MediaItem> {
val items = when (parentId) {
ROOT_ID -> listOf(
browseFolder(RECENT_ID, "Recently Played", MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS),
browseFolder(ALBUMS_ID, "Albums", MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS),
browseFolder(ARTISTS_ID, "Artists", MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS),
browseFolder(PLAYLISTS_ID, "Playlists", MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS),
)
RECENT_ID -> repo.getRecentlyPlayedAlbums(20).map { it.toMediaItem(repo) }
ALBUMS_ID -> repo.getAlphaAlbums(40).map { it.toMediaItem(repo) }
PLAYLISTS_ID -> repo.getPlaylists().map { it.toMediaItem() }
ARTISTS_ID -> repo.getArtists().flatMap { idx -> idx.artist.map { it.toMediaItem() } }
else -> when {
parentId.startsWith(ITEM_PREFIX_ALBUM) -> {
val album = repo.getAlbum(parentId.removePrefix(ITEM_PREFIX_ALBUM))
album.song.map { it.toMediaItem(repo) }
}
parentId.startsWith(ITEM_PREFIX_ARTIST) -> {
val artist = repo.getArtist(parentId.removePrefix(ITEM_PREFIX_ARTIST))
artist.album.map { it.toMediaItem(repo) }
}
parentId.startsWith(ITEM_PREFIX_PL) -> {
val pl = repo.getPlaylist(parentId.removePrefix(ITEM_PREFIX_PL))
pl.entry.map { it.toMediaItem(repo) }
}
else -> emptyList()
}
}
return ImmutableList.copyOf(items)
}
private fun browseFolder(id: String, title: String, mediaType: Int) =
MediaItem.Builder()
.setMediaId(id)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(title)
.setIsPlayable(false)
.setIsBrowsable(true)
.setMediaType(mediaType)
.build()
).build()
private fun isAndroidAuto(info: MediaSession.ControllerInfo): Boolean =
info.packageName.contains("android.auto", ignoreCase = true) ||
info.packageName.contains("goog.android.projected", ignoreCase = true)
}
// ── Extension: model → MediaItem ─────────────────────────────────────────────
fun com.novasonic.app.api.model.Song.toMediaItem(repo: MusicRepository): MediaItem {
val artUri = coverArt?.let { repo.coverArtUrl(it, 512)?.let { url ->
android.net.Uri.parse(url) } }
val streamUri = repo.streamUrl(id)?.let { android.net.Uri.parse(it) }
return MediaItem.Builder()
.setMediaId(id)
.setUri(streamUri)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(title)
.setArtist(artist)
.setAlbumTitle(album)
.setArtworkUri(artUri)
.setTrackNumber(track)
.setIsPlayable(true)
.setIsBrowsable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
.build()
).build()
}
fun com.novasonic.app.api.model.Album.toMediaItem(repo: MusicRepository): MediaItem {
val artUri = coverArt?.let { repo.coverArtUrl(it, 300)?.let { url ->
android.net.Uri.parse(url) } }
return MediaItem.Builder()
.setMediaId("${MusicService.ITEM_PREFIX_ALBUM}$id")
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(name)
.setArtist(artist)
.setArtworkUri(artUri)
.setIsPlayable(true)
.setIsBrowsable(true)
.setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM)
.build()
).build()
}
fun com.novasonic.app.api.model.Artist.toMediaItem(): MediaItem =
MediaItem.Builder()
.setMediaId("${MusicService.ITEM_PREFIX_ARTIST}$id")
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(name)
.setIsPlayable(false)
.setIsBrowsable(true)
.setMediaType(MediaMetadata.MEDIA_TYPE_ARTIST)
.build()
).build()
fun com.novasonic.app.api.model.Playlist.toMediaItem(): MediaItem =
MediaItem.Builder()
.setMediaId("${MusicService.ITEM_PREFIX_PL}$id")
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(name)
.setIsPlayable(true)
.setIsBrowsable(true)
.setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
.build()
).build()
@@ -0,0 +1,202 @@
package com.novasonic.app.player
import android.content.ComponentName
import android.content.Context
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import com.novasonic.app.api.model.Song
import com.novasonic.app.data.MusicRepository
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
data class PlayerState(
val isPlaying: Boolean = false,
val isBuffering: Boolean = false,
val currentSong: Song? = null,
val currentIndex: Int = -1,
val positionMs: Long = 0L,
val durationMs: Long = 0L,
val queue: List<Song> = emptyList(),
val repeatMode: Int = Player.REPEAT_MODE_OFF,
val shuffled: Boolean = false,
val volume: Float = 1f,
)
class PlayerController(private val context: Context, private val repo: MusicRepository) {
private val _state = MutableStateFlow(PlayerState())
val state: StateFlow<PlayerState> = _state.asStateFlow()
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var controller: MediaController? = null
private var queue: List<Song> = emptyList()
private var originalQueue: List<Song> = emptyList()
private var isManuallyShuffled = false
private var tickerJob: Job? = null
private var scrobbleJob: Job? = null
fun connect() {
if (controller != null) return
val token = SessionToken(context, ComponentName(context, MusicService::class.java))
MediaController.Builder(context, token).buildAsync().also { future ->
future.addListener({
controller = runCatching { future.get() }.getOrNull()
controller?.addListener(PlayerListener())
syncState()
}, context.mainExecutor)
}
}
// ── Playback commands ─────────────────────────────────────────────────────
fun playSongs(songs: List<Song>, startIndex: Int = 0) {
val ctrl = controller ?: return
queue = songs
originalQueue = songs
isManuallyShuffled = false
ctrl.shuffleModeEnabled = false
val items = songs.map { it.toMediaItem(repo) }
ctrl.setMediaItems(items, startIndex, 0L)
ctrl.prepare()
ctrl.play()
syncState()
}
fun play() = controller?.play()
fun pause() = controller?.pause()
fun togglePlay() = controller?.let { if (it.isPlaying) it.pause() else it.play() }
fun seekTo(ms: Long) = controller?.seekTo(ms)
fun skipNext() = controller?.seekToNextMediaItem()
fun skipPrev() = controller?.seekToPreviousMediaItem()
fun seekToItem(index: Int) = controller?.seekToDefaultPosition(index)
fun setVolume(v: Float) {
val clamped = v.coerceIn(0f, 1f)
controller?.volume = clamped
_state.update { it.copy(volume = clamped) }
}
fun insertNext(song: Song) {
val ctrl = controller ?: return
val idx = (ctrl.currentMediaItemIndex + 1).coerceAtMost(queue.size)
queue = queue.toMutableList().apply { add(idx, song) }
originalQueue = originalQueue + song
ctrl.addMediaItem(idx, song.toMediaItem(repo))
}
fun addToQueueEnd(song: Song) {
val ctrl = controller ?: return
queue = queue + song
originalQueue = originalQueue + song
ctrl.addMediaItem(song.toMediaItem(repo))
}
fun toggleRepeat() {
val ctrl = controller ?: return
ctrl.repeatMode = when (ctrl.repeatMode) {
Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ALL
Player.REPEAT_MODE_ALL -> Player.REPEAT_MODE_ONE
else -> Player.REPEAT_MODE_OFF
}
}
fun toggleShuffle() {
val ctrl = controller ?: return
val currentIdx = ctrl.currentMediaItemIndex
if (!isManuallyShuffled) {
// Turn ON: shuffle everything after the current song
isManuallyShuffled = true
val played = queue.take(currentIdx + 1)
val remaining = queue.drop(currentIdx + 1).shuffled()
queue = played + remaining
for (i in ctrl.mediaItemCount - 1 downTo currentIdx + 1) ctrl.removeMediaItem(i)
remaining.forEach { s -> ctrl.addMediaItem(s.toMediaItem(repo)) }
} else {
// Turn OFF: restore original order from the current song's position
isManuallyShuffled = false
val currentSong = queue.getOrNull(currentIdx)
val origIdx = originalQueue.indexOfFirst { it.id == currentSong?.id }.coerceAtLeast(0)
val restOfOriginal = originalQueue.drop(origIdx + 1)
queue = originalQueue.take(origIdx + 1) + restOfOriginal
for (i in ctrl.mediaItemCount - 1 downTo currentIdx + 1) ctrl.removeMediaItem(i)
restOfOriginal.forEach { s -> ctrl.addMediaItem(s.toMediaItem(repo)) }
}
syncState()
}
fun addToQueue(song: Song) = addToQueueEnd(song)
// ── Ticker ────────────────────────────────────────────────────────────────
private fun startTicker() {
tickerJob?.cancel()
tickerJob = scope.launch {
while (isActive) {
delay(500)
val ctrl = controller ?: break
_state.update { it.copy(positionMs = ctrl.currentPosition) }
}
}
}
private fun stopTicker() {
tickerJob?.cancel()
tickerJob = null
}
// ── Scrobbling ────────────────────────────────────────────────────────────
private fun scheduleScrobble(song: Song, durationMs: Long) {
scrobbleJob?.cancel()
scrobbleJob = scope.launch {
val delayMs = runCatching { repo.getScrobbleDelayMs(durationMs) }.getOrDefault(30_000L)
delay(delayMs)
runCatching { repo.scrobble(song.id) }
}
}
// ── Internal ──────────────────────────────────────────────────────────────
private fun syncState() {
val ctrl = controller ?: return
val idx = ctrl.currentMediaItemIndex
_state.value = PlayerState(
isPlaying = ctrl.isPlaying,
isBuffering = ctrl.playbackState == Player.STATE_BUFFERING,
currentSong = queue.getOrNull(idx),
currentIndex = idx,
positionMs = ctrl.currentPosition,
durationMs = ctrl.duration.coerceAtLeast(0),
queue = queue,
repeatMode = ctrl.repeatMode,
shuffled = isManuallyShuffled,
volume = ctrl.volume,
)
}
private inner class PlayerListener : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (isPlaying) startTicker() else stopTicker()
syncState()
}
override fun onPlaybackStateChanged(state: Int) = syncState()
override fun onMediaItemTransition(item: MediaItem?, reason: Int) {
syncState()
val ctrl = controller ?: return
val song = queue.getOrNull(ctrl.currentMediaItemIndex) ?: return
val dur = ctrl.duration.coerceAtLeast(0)
scheduleScrobble(song, dur)
}
override fun onShuffleModeEnabledChanged(enabled: Boolean) = syncState()
override fun onRepeatModeChanged(mode: Int) = syncState()
}
}
@@ -0,0 +1,382 @@
package com.novasonic.app.ui
import androidx.activity.compose.BackHandler
import androidx.compose.animation.*
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import android.app.DownloadManager
import android.content.Context
import android.net.Uri
import android.os.Environment
import android.widget.Toast
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.*
import androidx.navigation.compose.*
import com.novasonic.app.api.model.Song
import com.novasonic.app.app
import com.novasonic.app.ui.component.MiniPlayer
import com.novasonic.app.ui.component.PlaylistPickerSheet
import com.novasonic.app.ui.component.SongContextSheet
import com.novasonic.app.ui.component.SongInfoSheet
import com.novasonic.app.ui.screen.*
import com.novasonic.app.ui.theme.*
import kotlinx.coroutines.launch
private enum class TopLevel(val route: String, val icon: ImageVector, val label: String) {
HOME("home", Icons.Default.Home, "Home"),
LIBRARY("library", Icons.Default.LibraryMusic, "Bibliothek"),
ERKUNDEN("erkunden", Icons.Default.Explore, "Erkunden"),
STARRED("starred", Icons.Default.Favorite, "Favoriten"),
SETTINGS("settings", Icons.Default.Settings, "Einstellungen"),
}
private val NavBg = Color(0xF7080814)
@Composable
fun AppNavigation() {
val context = LocalContext.current
val app = context.app
val repo = app.repository
val player = app.playerController
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
repo.credentialsFlow().collect { creds -> repo.lastCredentials = creds }
}
// Apply saved theme whenever settings change
LaunchedEffect(Unit) {
repo.appSettingsFlow().collect { settings -> applyNsTheme(settings.theme) }
}
val playerState by player.state.collectAsStateWithLifecycle()
val navController = rememberNavController()
var showNowPlaying by remember { mutableStateOf(false) }
var contextMenuSong by remember { mutableStateOf<Song?>(null) }
var playlistPickerSong by remember { mutableStateOf<Song?>(null) }
var infoSheetSong by remember { mutableStateOf<Song?>(null) }
fun coverUrl(coverId: String?) = repo.coverArtUrl(coverId, 600)
fun heroCoverUrl(coverId: String?) = repo.coverArtUrl(coverId, 1000)
fun starToggle(songId: String, currentlyStarred: Boolean) {
scope.launch { runCatching { if (currentlyStarred) repo.unstar(songId) else repo.star(songId) } }
}
fun showContextMenu(song: Song) { contextMenuSong = song }
val currentBackEntry by navController.currentBackStackEntryAsState()
val currentRoute = currentBackEntry?.destination?.route
val isTopLevel = TopLevel.entries.any { it.route == currentRoute }
val bgGlow = Brush.radialGradient(
colors = listOf(Color(0x0AE8A020), Color(0x00000000)),
center = Offset.Unspecified, radius = 900f,
)
Box(Modifier.fillMaxSize()) {
Scaffold(
containerColor = NsBackground,
modifier = Modifier.background(NsBackground).background(bgGlow),
bottomBar = {
if (isTopLevel) {
Column {
AnimatedVisibility(
visible = playerState.currentSong != null,
enter = slideInVertically { it } + fadeIn(tween(220)),
exit = slideOutVertically { it } + fadeOut(tween(180)),
) {
MiniPlayer(
state = playerState,
coverUrl = coverUrl(playerState.currentSong?.coverArt),
onTap = { showNowPlaying = true },
onTogglePlay = { player.togglePlay() },
onSkipPrev = { player.skipPrev() },
onSkipNext = { player.skipNext() },
)
}
NavigationBar(containerColor = NavBg, tonalElevation = 0.dp) {
TopLevel.entries.forEach { dest ->
NavigationBarItem(
selected = currentRoute == dest.route,
onClick = {
navController.navigate(dest.route) {
popUpTo(navController.graph.startDestinationId) { saveState = true }
launchSingleTop = true; restoreState = true
}
},
icon = { Icon(dest.icon, contentDescription = dest.label) },
label = {
Text(dest.label, style = TextStyle(
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 10.sp))
},
colors = NavigationBarItemDefaults.colors(
selectedIconColor = NsAccent,
selectedTextColor = NsAccent,
indicatorColor = NsAccent.copy(alpha = 0.15f),
unselectedIconColor = Color(0xFF8888AA),
unselectedTextColor = Color(0xFF8888AA),
),
)
}
}
}
}
},
) { innerPadding ->
NavHost(
navController = navController,
startDestination = TopLevel.HOME.route,
modifier = Modifier.padding(innerPadding),
enterTransition = {
slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(280, easing = FastOutSlowInEasing)) +
fadeIn(tween(200))
},
exitTransition = {
slideOutHorizontally(targetOffsetX = { -it / 3 }, animationSpec = tween(280, easing = FastOutSlowInEasing)) +
fadeOut(tween(180))
},
popEnterTransition = {
slideInHorizontally(initialOffsetX = { -it / 3 }, animationSpec = tween(280, easing = FastOutSlowInEasing)) +
fadeIn(tween(200))
},
popExitTransition = {
slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(280, easing = FastOutSlowInEasing)) +
fadeOut(tween(180))
},
) {
composable(TopLevel.HOME.route) {
HomeScreen(
coverUrl = { album -> coverUrl(album.coverArt) },
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
onAlbumPlay = { album ->
scope.launch { runCatching {
val full = repo.getAlbum(album.id)
if (full.song.isNotEmpty()) player.playSongs(full.song, 0)
}}
},
onGenreClick = { genre -> navController.navigate("genre/${encodeRoute(genre.value)}") },
onGenrePlay = { genre ->
scope.launch { runCatching {
val songs = repo.getSongsByGenre(genre.value)
if (songs.isNotEmpty()) player.playSongs(songs.shuffled(), 0)
}}
},
)
}
composable(TopLevel.LIBRARY.route) {
LibraryScreen(
coverUrl = { id -> coverUrl(id) },
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
onArtistClick = { artist -> navController.navigate("artist/${artist.id}") },
onPlaylistClick = { pl -> navController.navigate("playlist/${pl.id}") },
)
}
composable(TopLevel.ERKUNDEN.route) {
ExploreScreen(
coverUrl = { id -> coverUrl(id) },
playerState = playerState,
onSongPlay = { songs, idx -> player.playSongs(songs, idx) },
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
onArtistClick = { artist -> navController.navigate("artist/${artist.id}") },
onOpenImporter = { navController.navigate("importer") },
)
}
composable("importer") {
ImporterScreen(onBack = { navController.popBackStack() })
}
composable(TopLevel.STARRED.route) {
StarredScreen(
coverUrl = { id -> coverUrl(id) },
playerState = playerState,
onPlay = { songs, idx -> player.playSongs(songs, idx) },
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
onShowContextMenu = ::showContextMenu,
)
}
composable(TopLevel.SETTINGS.route) { SettingsScreen() }
composable(
"album/{albumId}",
arguments = listOf(navArgument("albumId") { type = NavType.StringType }),
) {
AlbumScreen(
coverUrl = { id -> coverUrl(id) },
playerState = playerState,
onPlay = { songs, idx -> player.playSongs(songs, idx) },
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
onShowContextMenu = ::showContextMenu,
onArtistClick = { artistId -> navController.navigate("artist/$artistId") },
onBack = { navController.popBackStack() },
)
}
composable(
"artist/{artistId}",
arguments = listOf(navArgument("artistId") { type = NavType.StringType }),
) {
ArtistDetailScreen(
coverUrl = { id -> coverUrl(id) },
playerState = playerState,
onPlay = { songs, idx -> player.playSongs(songs, idx) },
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
onShowContextMenu = ::showContextMenu,
onBack = { navController.popBackStack() },
)
}
composable(
"playlist/{playlistId}",
arguments = listOf(navArgument("playlistId") { type = NavType.StringType }),
) {
PlaylistDetailScreen(
coverUrl = { id -> coverUrl(id) },
playerState = playerState,
onPlay = { songs, idx -> player.playSongs(songs, idx) },
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
onShowContextMenu = ::showContextMenu,
onBack = { navController.popBackStack() },
)
}
composable(
"genre/{genreName}",
arguments = listOf(navArgument("genreName") { type = NavType.StringType }),
) { backEntry ->
val genreName = backEntry.arguments?.getString("genreName") ?: ""
GenreSongsScreen(
genreName = genreName,
coverUrl = { id -> coverUrl(id) },
playerState = playerState,
onPlay = { songs, idx -> player.playSongs(songs, idx) },
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
onShowContextMenu = ::showContextMenu,
onBack = { navController.popBackStack() },
)
}
}
}
// Dismiss NowPlaying with hardware back button
BackHandler(enabled = showNowPlaying) { showNowPlaying = false }
// NowPlaying overlay — outside Scaffold so WindowInsets are not double-consumed
AnimatedVisibility(
visible = showNowPlaying,
enter = slideInVertically { it } + fadeIn(tween(220)),
exit = slideOutVertically { it } + fadeOut(tween(180)),
) {
NowPlayingScreen(
state = playerState,
coverUrl = heroCoverUrl(playerState.currentSong?.coverArt),
queueCoverUrl = { id -> coverUrl(id) },
controller = player,
onDismiss = { showNowPlaying = false },
onStarToggle = {
playerState.currentSong?.let { s -> starToggle(s.id, s.starred != null) }
},
onShowOptions = {
playerState.currentSong?.let { s -> contextMenuSong = s }
},
onArtistClick = playerState.currentSong?.artistId?.let { artistId ->
{
showNowPlaying = false
navController.navigate("artist/$artistId")
}
},
)
}
// Song context menu bottom sheet
contextMenuSong?.let { song ->
SongContextSheet(
song = song,
isStarred = song.starred != null,
onDismiss = { contextMenuSong = null },
onPlay = { player.playSongs(listOf(song), 0) },
onInsertNext = { player.insertNext(song) },
onAddToQueue = { player.addToQueueEnd(song) },
onStarToggle = { starToggle(song.id, song.starred != null) },
onAddToPlaylist = {
playlistPickerSong = song
contextMenuSong = null
},
onGoToAlbum = song.albumId?.let { albumId ->
{ navController.navigate("album/$albumId") }
},
onShowInfo = {
infoSheetSong = song
contextMenuSong = null
},
onDownload = {
downloadSong(context, song, repo.streamUrl(song.id) ?: "")
contextMenuSong = null
},
)
}
// Playlist picker sheet
playlistPickerSong?.let { song ->
PlaylistPickerSheet(
song = song,
loadPlaylists = { repo.getPlaylists() },
onAdd = { pl ->
playlistPickerSong = null
scope.launch {
runCatching { repo.addSongToPlaylist(pl.id, song.id) }
.onSuccess { Toast.makeText(context, "✓ ${song.title} → ${pl.name}", Toast.LENGTH_SHORT).show() }
.onFailure { Toast.makeText(context, "Fehler: ${it.message}", Toast.LENGTH_SHORT).show() }
}
},
onCreate = { name ->
playlistPickerSong = null
scope.launch {
runCatching { repo.createPlaylist(name, listOf(song.id)) }
.onSuccess { Toast.makeText(context, "✓ Playlist \"$name\" erstellt", Toast.LENGTH_SHORT).show() }
.onFailure { Toast.makeText(context, "Fehler: ${it.message}", Toast.LENGTH_SHORT).show() }
}
},
onDismiss = { playlistPickerSong = null },
)
}
// Song info sheet
infoSheetSong?.let { song ->
SongInfoSheet(song = song, onDismiss = { infoSheetSong = null })
}
}
}
private fun downloadSong(context: Context, song: Song, streamUrl: String) {
if (streamUrl.isBlank()) {
Toast.makeText(context, "Stream-URL nicht verfügbar", Toast.LENGTH_SHORT).show()
return
}
val safe = (song.title + "." + (song.suffix ?: "mp3"))
.replace(Regex("[/\\\\:*?\"<>|]"), "_")
val request = DownloadManager.Request(Uri.parse(streamUrl))
.setTitle(song.title)
.setDescription(song.artist ?: "")
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationInExternalPublicDir(Environment.DIRECTORY_MUSIC, safe)
.setMimeType(song.contentType ?: "audio/mpeg")
val dm = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
dm.enqueue(request)
Toast.makeText(context, "Download gestartet: ${song.title}", Toast.LENGTH_SHORT).show()
}
private fun encodeRoute(s: String) = java.net.URLEncoder.encode(s, "UTF-8")
@@ -0,0 +1,48 @@
package com.novasonic.app.ui
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.novasonic.app.app
import com.novasonic.app.data.PrefsStore
import com.novasonic.app.ui.screen.LoginScreen
import com.novasonic.app.ui.theme.NovaSonicTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
NovaSonicTheme {
Surface(modifier = Modifier.fillMaxSize()) {
NovaSonicRoot()
}
}
}
}
}
@Composable
private fun NovaSonicRoot() {
val context = LocalContext.current
val prefs = remember { PrefsStore(context) }
val creds by prefs.credentials.collectAsStateWithLifecycle(
initialValue = PrefsStore.Credentials("", "", ""),
)
var forceLogin by remember { mutableStateOf(false) }
if (!creds.isValid || forceLogin) {
LoginScreen(onLoggedIn = { forceLogin = false })
} else {
AppNavigation()
}
}
@@ -0,0 +1,75 @@
package com.novasonic.app.ui.component
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.novasonic.app.api.model.Album
import com.novasonic.app.ui.theme.*
private val cardBorder = Color(0x12FFFFFF) // rgba(255,255,255,0.07)
@Composable
fun AlbumCard(
album: Album,
coverUrl: String?,
width: Dp = 145.dp,
onClick: () -> Unit,
) {
val shape = RoundedCornerShape(12.dp)
Column(
modifier = Modifier
.width(width)
.clip(shape)
.background(NsSurface)
.border(1.dp, cardBorder, shape)
.clickable(onClick = onClick),
) {
AsyncImage(
model = coverUrl,
contentDescription = album.name,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.background(NsSurface2),
)
Column(Modifier.padding(10.dp)) {
Text(
text = album.name,
fontFamily = Outfit,
fontWeight = FontWeight.SemiBold,
fontSize = 12.sp,
color = NsOnSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (!album.artist.isNullOrBlank()) {
Spacer(Modifier.height(2.dp))
Text(
text = album.artist,
fontFamily = Outfit,
fontSize = 11.sp,
color = NsOnSurface2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@@ -0,0 +1,188 @@
package com.novasonic.app.ui.component
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.novasonic.app.api.model.ItunesItem
import com.novasonic.app.ui.theme.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ItunesDownloadSheet(
item: ItunesItem,
onDownload: (type: String) -> Unit,
onDismiss: () -> Unit,
) {
val hasAlbum = !item.collectionName.isNullOrBlank()
val hasArtist = !item.artistName.isNullOrBlank()
var selected by remember {
mutableStateOf(if (item.trackName != null) "track" else if (hasAlbum) "album" else "artist")
}
ModalBottomSheet(
onDismissRequest = onDismiss,
containerColor = NsSurface,
dragHandle = { BottomSheetDefaults.DragHandle(color = NsOnSurface2.copy(0.3f)) },
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
.navigationBarsPadding()
.padding(bottom = 24.dp),
) {
// ── Header ────────────────────────────────────────────────────────
Row(verticalAlignment = Alignment.CenterVertically) {
val art = item.artworkUrl100?.replace("100x100bb", "80x80bb")
AsyncImage(
model = art, contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(56.dp)
.clip(RoundedCornerShape(8.dp))
.background(NsSurface2),
)
Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f)) {
Text(
item.trackName ?: item.collectionName ?: item.artistName ?: "—",
fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 15.sp, color = NsOnSurface,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
if (hasArtist) {
Text(
item.artistName!!,
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
if (hasAlbum && item.trackName != null) {
Text(
item.collectionName!!,
fontFamily = Outfit, fontSize = 12.sp,
color = NsOnSurface2.copy(0.6f),
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
}
Spacer(Modifier.height(20.dp))
Text(
"Was herunterladen?",
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 12.sp, color = NsOnSurface2,
)
Spacer(Modifier.height(10.dp))
// ── Options ───────────────────────────────────────────────────────
if (item.trackName != null) {
DownloadTypeRow(
icon = Icons.Default.MusicNote,
title = "Nur dieser Titel",
subtitle = item.trackName!!,
selected = selected == "track",
onClick = { selected = "track" },
)
Spacer(Modifier.height(8.dp))
}
if (hasAlbum) {
DownloadTypeRow(
icon = Icons.Default.Album,
title = "Ganzes Album",
subtitle = "\"${item.collectionName}\" herunterladen",
selected = selected == "album",
onClick = { selected = "album" },
)
Spacer(Modifier.height(8.dp))
}
if (hasArtist) {
DownloadTypeRow(
icon = Icons.Default.Person,
title = "Ganzer Künstler",
subtitle = "Alle Songs von ${item.artistName}",
selected = selected == "artist",
onClick = { selected = "artist" },
)
}
Spacer(Modifier.height(20.dp))
Button(
onClick = { onDownload(selected) },
modifier = Modifier.fillMaxWidth().height(50.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
) {
Icon(Icons.Default.Download, null, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text("Herunterladen", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp)
}
}
}
}
@Composable
private fun DownloadTypeRow(
icon: ImageVector,
title: String,
subtitle: String,
selected: Boolean,
onClick: () -> Unit,
) {
val borderColor = if (selected) NsAccent else NsBorder
val bgColor = if (selected) NsAccent.copy(alpha = 0.08f) else NsSurface2
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(bgColor)
.border(1.dp, borderColor, RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(if (selected) NsAccent else NsOnSurface2.copy(0.12f)),
) {
Icon(icon, null, modifier = Modifier.size(18.dp),
tint = if (selected) Color.White else NsOnSurface2)
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp, color = if (selected) NsAccent else NsOnSurface)
Text(subtitle, fontFamily = Outfit, fontSize = 12.sp,
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (selected) {
Icon(Icons.Default.CheckCircle, null,
tint = NsAccent, modifier = Modifier.size(20.dp))
}
}
}
@@ -0,0 +1,132 @@
package com.novasonic.app.ui.component
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.theme.*
@Composable
fun MiniPlayer(
state: PlayerState,
coverUrl: String?,
onTap: () -> Unit,
onTogglePlay: () -> Unit,
onSkipPrev: () -> Unit,
onSkipNext: () -> Unit,
modifier: Modifier = Modifier,
) {
val song = state.currentSong ?: return
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.padding(top = 6.dp, bottom = 4.dp)
.shadow(12.dp, RoundedCornerShape(18.dp))
.clip(RoundedCornerShape(18.dp))
.background(NsSurface),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onTap)
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
AsyncImage(
model = coverUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(44.dp)
.clip(RoundedCornerShape(8.dp))
.background(NsSurface2),
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
text = song.title,
fontFamily = Outfit,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
color = NsOnSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (!song.artist.isNullOrBlank()) {
Text(
text = song.artist!!,
fontFamily = Outfit,
fontSize = 12.sp,
color = NsOnSurface2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
IconButton(onClick = onSkipPrev, modifier = Modifier.size(40.dp)) {
Icon(
Icons.Default.SkipPrevious,
contentDescription = null,
tint = NsOnSurface2,
modifier = Modifier.size(24.dp),
)
}
// Accent play/pause circle button
Box(
Modifier
.size(40.dp)
.clip(CircleShape)
.background(NsAccent)
.clickable(onClick = onTogglePlay),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(22.dp),
)
}
Spacer(Modifier.width(4.dp))
IconButton(onClick = onSkipNext, modifier = Modifier.size(40.dp)) {
Icon(
Icons.Default.SkipNext,
contentDescription = null,
tint = NsOnSurface2,
modifier = Modifier.size(24.dp),
)
}
}
// Progress bar anchored to the bottom of the card
if (state.durationMs > 0) {
LinearProgressIndicator(
progress = { (state.positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth().height(3.dp),
color = NsAccent,
trackColor = NsAccent.copy(alpha = 0.15f),
)
}
}
}
@@ -0,0 +1,140 @@
package com.novasonic.app.ui.component
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.QueueMusic
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.novasonic.app.api.model.Playlist
import com.novasonic.app.api.model.Song
import com.novasonic.app.ui.theme.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlaylistPickerSheet(
song: Song,
loadPlaylists: suspend () -> List<Playlist>,
onAdd: (Playlist) -> Unit,
onCreate: (String) -> Unit,
onDismiss: () -> Unit,
) {
var playlists by remember { mutableStateOf<List<Playlist>?>(null) }
var isLoading by remember { mutableStateOf(true) }
var loadError by remember { mutableStateOf<String?>(null) }
var newName by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
isLoading = true
try { playlists = loadPlaylists() }
catch (e: Exception) { loadError = e.message ?: e.toString() }
finally { isLoading = false }
}
ModalBottomSheet(
onDismissRequest = onDismiss,
containerColor = NsSurface,
dragHandle = { BottomSheetDefaults.DragHandle(color = NsOnSurface2) },
) {
Column(Modifier.padding(horizontal = 20.dp).padding(bottom = 8.dp)) {
Text(
"\"${song.title}\" zu Playlist",
fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 16.sp, color = NsOnSurface, maxLines = 1,
)
if (!song.artist.isNullOrBlank()) {
Text(song.artist!!, fontFamily = Outfit, fontSize = 13.sp, color = NsAccent)
}
}
HorizontalDivider(color = NsBorder)
// Create new playlist row
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = newName,
onValueChange = { newName = it },
placeholder = { Text("Neue Playlist…", fontSize = 14.sp) },
singleLine = true,
modifier = Modifier.weight(1f),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = NsAccent,
unfocusedBorderColor = NsBorder,
cursorColor = NsAccent,
focusedTextColor = NsOnSurface,
unfocusedTextColor = NsOnSurface,
),
textStyle = androidx.compose.ui.text.TextStyle(
fontFamily = Outfit, fontSize = 14.sp,
),
)
IconButton(
onClick = {
val name = newName.trim()
if (name.isNotBlank()) { onCreate(name) }
},
enabled = newName.isNotBlank(),
) {
Icon(Icons.Default.Add, contentDescription = "Erstellen", tint = NsAccent)
}
}
HorizontalDivider(color = NsBorder)
when {
isLoading -> Box(
Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator(color = NsAccent, modifier = Modifier.size(24.dp)) }
loadError != null -> Box(
Modifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center,
) { Text(loadError!!, color = MaterialTheme.colorScheme.error, fontSize = 13.sp) }
playlists.isNullOrEmpty() -> Box(
Modifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center,
) { Text("Noch keine Playlists vorhanden", color = NsOnSurface2, fontSize = 13.sp) }
else -> LazyColumn(Modifier.fillMaxWidth()) {
items(playlists!!, key = { it.id }) { pl ->
Row(
Modifier
.fillMaxWidth()
.clickable { onAdd(pl) }
.padding(horizontal = 20.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.AutoMirrored.Filled.QueueMusic, null,
tint = NsOnSurface2, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(14.dp))
Text(
pl.name,
fontFamily = Outfit, fontWeight = FontWeight.Medium,
fontSize = 15.sp, color = NsOnSurface,
modifier = Modifier.weight(1f),
)
Text(
"${pl.songCount} Titel",
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
)
}
}
}
}
Spacer(Modifier.height(24.dp))
}
}
@@ -0,0 +1,102 @@
package com.novasonic.app.ui.component
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
import androidx.compose.material.icons.automirrored.filled.QueueMusic
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.novasonic.app.api.model.Song
import com.novasonic.app.ui.theme.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SongContextSheet(
song: Song,
isStarred: Boolean,
onDismiss: () -> Unit,
onPlay: () -> Unit,
onInsertNext: () -> Unit,
onAddToQueue: () -> Unit,
onStarToggle: () -> Unit,
onAddToPlaylist: (() -> Unit)? = null,
onGoToAlbum: (() -> Unit)? = null,
onShowInfo: (() -> Unit)? = null,
onDownload: (() -> Unit)? = null,
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
containerColor = NsSurface,
dragHandle = { BottomSheetDefaults.DragHandle(color = NsOnSurface2) },
) {
// Header: song title + artist
Column(Modifier.padding(horizontal = 20.dp).padding(bottom = 8.dp)) {
Text(
song.title, fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 16.sp, color = NsOnSurface, maxLines = 1,
)
if (!song.artist.isNullOrBlank()) {
Text(song.artist!!, fontFamily = Outfit, fontSize = 13.sp, color = NsAccent)
}
}
HorizontalDivider(color = NsBorder)
CtxItem(Icons.Default.PlayArrow, "Abspielen", onClick = { onDismiss(); onPlay() })
CtxItem(Icons.AutoMirrored.Filled.QueueMusic, "Als Nächstes einfügen", onClick = { onDismiss(); onInsertNext() })
CtxItem(Icons.Default.AddToQueue, "An Queue anfügen", onClick = { onDismiss(); onAddToQueue() })
HorizontalDivider(color = NsBorder, modifier = Modifier.padding(vertical = 4.dp))
if (onAddToPlaylist != null) {
CtxItem(Icons.AutoMirrored.Filled.PlaylistAdd, "Zu Playlist hinzufügen",
onClick = { onDismiss(); onAddToPlaylist() })
}
CtxItem(
icon = if (isStarred) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
label = if (isStarred) "Aus Favoriten entfernen" else "Zu Favoriten",
tint = if (isStarred) NsAccent else NsOnSurface,
onClick = { onDismiss(); onStarToggle() },
)
if (onGoToAlbum != null || onShowInfo != null || onDownload != null) {
HorizontalDivider(color = NsBorder, modifier = Modifier.padding(vertical = 4.dp))
onGoToAlbum?.let { CtxItem(Icons.Default.Album, "Zum Album", onClick = { onDismiss(); it() }) }
onShowInfo?.let { CtxItem(Icons.Default.Info, "Song-Info", onClick = { onDismiss(); it() }) }
onDownload?.let { CtxItem(Icons.Default.Download, "Herunterladen", onClick = { onDismiss(); it() }) }
}
Spacer(Modifier.height(24.dp))
}
}
@Composable
private fun CtxItem(
icon: ImageVector,
label: String,
onClick: () -> Unit,
tint: androidx.compose.ui.graphics.Color = NsOnSurface,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 20.dp, vertical = 14.dp),
) {
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(22.dp))
Spacer(Modifier.width(16.dp))
Text(label, fontFamily = Outfit, fontWeight = FontWeight.Medium,
fontSize = 15.sp, color = tint)
}
}
@@ -0,0 +1,72 @@
package com.novasonic.app.ui.component
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.novasonic.app.api.model.Song
import com.novasonic.app.ui.theme.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SongInfoSheet(song: Song, onDismiss: () -> Unit) {
ModalBottomSheet(
onDismissRequest = onDismiss,
containerColor = NsSurface,
dragHandle = { BottomSheetDefaults.DragHandle(color = NsOnSurface2) },
) {
Column(Modifier.padding(horizontal = 20.dp).padding(bottom = 32.dp)) {
Text(
"Song-Info",
fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 16.sp, color = NsOnSurface,
)
Spacer(Modifier.height(16.dp))
HorizontalDivider(color = NsBorder, modifier = Modifier.padding(bottom = 12.dp))
InfoRow("Titel", song.title)
song.artist?.takeIf { it.isNotBlank() }?.let { InfoRow("Künstler", it) }
song.album?.takeIf { it.isNotBlank() }?.let { InfoRow("Album", it) }
song.year?.let { InfoRow("Jahr", it.toString()) }
song.genre?.takeIf { it.isNotBlank() }?.let { InfoRow("Genre", it) }
song.track?.let { InfoRow("Track", it.toString()) }
song.duration?.let { InfoRow("Dauer", formatDur(it)) }
song.bitRate?.let { InfoRow("Bitrate", "${it} kbps") }
song.suffix?.let { InfoRow("Format", it.uppercase()) }
song.size?.let { InfoRow("Größe", formatSize(it)) }
}
}
}
@Composable
private fun InfoRow(label: String, value: String) {
Row(
Modifier.fillMaxWidth().padding(vertical = 5.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
label,
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
modifier = Modifier.width(70.dp),
)
Text(
value,
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface,
modifier = Modifier.weight(1f),
)
}
}
private fun formatDur(s: Int): String {
val h = s / 3600; val m = (s % 3600) / 60; val sec = s % 60
return if (h > 0) "%d:%02d:%02d".format(h, m, sec) else "%d:%02d".format(m, sec)
}
private fun formatSize(bytes: Long): String = when {
bytes < 1024L -> "$bytes B"
bytes < 1024L * 1024 -> "${bytes / 1024} KB"
else -> "%.1f MB".format(bytes / (1024.0 * 1024.0))
}
@@ -0,0 +1,133 @@
package com.novasonic.app.ui.component
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.novasonic.app.api.model.Song
import com.novasonic.app.ui.theme.*
@Composable
fun SongRow(
song: Song,
coverUrl: String?,
isPlaying: Boolean = false,
trackNumber: Int? = null,
starred: Boolean = false,
onPlay: () -> Unit,
onStarToggle: (() -> Unit)? = null,
onMore: (() -> Unit)? = null,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onPlay)
.background(if (isPlaying) NsAccent.copy(alpha = 0.08f) else Color.Transparent)
.padding(start = if (isPlaying) 0.dp else 4.dp),
) {
// Accent left bar when playing
if (isPlaying) {
Box(
Modifier
.width(3.dp)
.height(52.dp)
.background(NsAccent, RoundedCornerShape(topEnd = 3.dp, bottomEnd = 3.dp))
)
Spacer(Modifier.width(9.dp))
}
// Track number OR album art
if (trackNumber != null) {
Text(
text = trackNumber.toString(),
fontFamily = JetBrainsMono,
fontSize = 11.sp,
color = if (isPlaying) NsAccent else NsOnSurface2,
modifier = Modifier.width(34.dp).padding(start = if (isPlaying) 0.dp else 12.dp),
)
} else {
Spacer(Modifier.width(if (isPlaying) 0.dp else 12.dp))
AsyncImage(
model = coverUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(46.dp)
.clip(RoundedCornerShape(6.dp))
.background(NsSurface2),
)
Spacer(Modifier.width(12.dp))
}
Column(
Modifier
.weight(1f)
.padding(vertical = 10.dp)
.padding(end = 4.dp),
) {
Text(
text = song.title,
fontFamily = Outfit,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
color = if (isPlaying) NsAccent else NsOnSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val sub = if (trackNumber != null) {
// in album context: show artist only (album name redundant)
song.artist?.takeIf { it.isNotBlank() } ?: ""
} else {
listOfNotNull(song.artist, song.album).joinToString(" · ")
}
if (sub.isNotBlank()) {
Spacer(Modifier.height(2.dp))
Text(
text = sub,
fontFamily = Outfit,
fontSize = 12.sp,
color = NsOnSurface2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (onStarToggle != null) {
IconButton(onClick = onStarToggle, modifier = Modifier.size(38.dp)) {
Icon(
if (starred) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
contentDescription = null,
tint = if (starred) NsAccent else NsOnSurface2.copy(alpha = 0.6f),
modifier = Modifier.size(17.dp),
)
}
}
if (onMore != null) {
IconButton(onClick = onMore, modifier = Modifier.size(38.dp)) {
Icon(
Icons.Default.MoreVert, contentDescription = null,
tint = NsOnSurface2.copy(alpha = 0.6f), modifier = Modifier.size(18.dp),
)
}
}
}
}
@@ -0,0 +1,195 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.novasonic.app.api.model.Song
import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.AlbumViewModel
@Composable
fun AlbumScreen(
coverUrl: (String?) -> String?,
playerState: PlayerState,
onPlay: (List<Song>, Int) -> Unit,
onStarToggle: ((Song) -> Unit)? = null,
onShowContextMenu: ((Song) -> Unit)? = null,
onArtistClick: ((String) -> Unit)? = null,
onBack: () -> Unit,
) {
val vm: AlbumViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
if (state.isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
return
}
val album = state.album ?: run {
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
state.error ?: "Album nicht gefunden",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(12.dp))
OutlinedButton(onClick = onBack) { Text("Zurück") }
}
}
return
}
LazyColumn(Modifier.fillMaxSize()) {
item {
// ── Hero ─────────────────────────────────────────────────────────
Box(Modifier.fillMaxWidth()) {
AsyncImage(
model = coverUrl(album.coverArt),
contentDescription = album.name,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.background(NsSurface2),
)
// gradient overlay
Box(
Modifier
.fillMaxWidth()
.aspectRatio(1f)
.background(
Brush.verticalGradient(
0.0f to Color.Transparent,
0.40f to NsBackground.copy(alpha = 0.35f),
1.0f to NsBackground,
)
)
)
// back button — frosted circle
Box(
Modifier
.statusBarsPadding()
.padding(start = 14.dp, top = 14.dp)
.size(36.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.45f))
.clickable(onClick = onBack)
.align(Alignment.TopStart),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
tint = Color.White, modifier = Modifier.size(18.dp),
)
}
// album info overlaid at bottom
Column(
Modifier
.align(Alignment.BottomStart)
.padding(horizontal = 20.dp, vertical = 20.dp),
) {
Text(
album.name,
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
fontSize = 26.sp, color = Color.White,
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
if (!album.artist.isNullOrBlank()) {
Spacer(Modifier.height(4.dp))
Text(
album.artist!!, fontFamily = Outfit,
fontWeight = FontWeight.Medium, fontSize = 15.sp, color = NsAccent,
modifier = if (onArtistClick != null && !album.artistId.isNullOrBlank())
Modifier.clickable { onArtistClick(album.artistId!!) }
else Modifier,
)
}
val meta = listOfNotNull(
album.year?.toString(),
album.genre?.takeIf { it.isNotBlank() },
if (album.songCount > 0) "${album.songCount} Songs" else null,
).joinToString(" · ")
if (meta.isNotBlank()) {
Spacer(Modifier.height(3.dp))
Text(
meta, fontFamily = Outfit, fontSize = 12.sp,
color = Color.White.copy(alpha = 0.55f),
)
}
}
}
// ── Play / Shuffle row ────────────────────────────────────────────
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Button(
onClick = { onPlay(album.song, 0) },
modifier = Modifier.weight(1f).height(46.dp),
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
shape = RoundedCornerShape(14.dp),
) {
Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(6.dp))
Text("Play", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp)
}
OutlinedButton(
onClick = { onPlay(album.song.shuffled(), 0) },
modifier = Modifier.weight(1f).height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
shape = RoundedCornerShape(14.dp),
) {
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(20.dp), tint = NsAccent)
Spacer(Modifier.width(6.dp))
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent)
}
}
HorizontalDivider(color = NsBorder)
}
itemsIndexed(album.song, key = { _, s -> s.id }) { index, song ->
SongRow(
song = song,
coverUrl = null,
isPlaying = playerState.currentSong?.id == song.id,
trackNumber = song.track ?: (index + 1),
starred = song.starred != null,
onPlay = { onPlay(album.song, index) },
onStarToggle = onStarToggle?.let { cb -> { cb(song) } },
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
)
}
item { Spacer(Modifier.height(16.dp)) }
}
}
@@ -0,0 +1,420 @@
package com.novasonic.app.ui.screen
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.novasonic.app.api.model.Song
import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.component.AlbumCard
import com.novasonic.app.ui.component.ItunesDownloadSheet
import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.ArtistViewModel
@Composable
fun ArtistDetailScreen(
coverUrl: (String?) -> String?,
playerState: PlayerState,
onPlay: (List<Song>, Int) -> Unit,
onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit,
onStarToggle: (Song) -> Unit,
onShowContextMenu: ((Song) -> Unit)? = null,
onBack: () -> Unit,
) {
val vm: ArtistViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
val context = LocalContext.current
BackHandler(enabled = state.partialAlbum != null) { vm.dismissPartialAlbum() }
BackHandler(enabled = state.subAlbumItem != null && state.partialAlbum == null) { vm.dismissSubAlbum() }
// Download sheet
state.downloadSheetItem?.let { item ->
ItunesDownloadSheet(
item = item,
onDownload = { type ->
vm.dismissDownloadSheet()
vm.downloadItem(item, type) { _, msg ->
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
}
},
onDismiss = vm::dismissDownloadSheet,
)
}
if (state.isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
return
}
val artist = state.artist ?: run {
Box(Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center) {
Text(state.error ?: "Artist not found", color = MaterialTheme.colorScheme.error)
}
return
}
// Partial album detail (incomplete album — downloaded + missing tracks split view)
val partialAlbum = state.partialAlbum
if (partialAlbum != null) {
PartialAlbumScreen(
partialAlbum = partialAlbum,
playerState = playerState,
coverUrl = coverUrl,
previewingId = state.previewingId,
onPlay = onPlay,
onPreview = vm::togglePreview,
onDownload = vm::showDownloadSheet,
onBack = vm::dismissPartialAlbum,
)
return
}
// Sub-album detail (not-downloaded iTunes album)
val subAlbumItem = state.subAlbumItem
val subAlbumTracks = state.subAlbumTracks
if (subAlbumItem != null) {
if (subAlbumTracks == null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
} else {
ItunesAlbumDetail(
title = state.subAlbumTitle,
albumItem = subAlbumItem,
tracks = subAlbumTracks,
previewingId = state.previewingId,
onPreview = vm::togglePreview,
onDownload = vm::showDownloadSheet,
onBack = vm::dismissSubAlbum,
)
}
return
}
// Album categories (computed once iTunes data arrives)
val cats = if (state.iAlbums.isNotEmpty()) categorizeAlbums(artist.album, state.iAlbums) else null
LazyColumn(Modifier.fillMaxSize()) {
// Back link
item {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable(onClick = onBack)
.padding(start = 12.dp, end = 16.dp, top = 12.dp, bottom = 12.dp),
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
tint = NsOnSurface2, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text("Alle Künstler", fontFamily = Outfit, fontSize = 14.sp,
fontWeight = FontWeight.Medium, color = NsOnSurface2)
}
}
// Hero card
item {
val imgUrl = state.info?.largeImageUrl?.takeIf { it.isNotBlank() }
?: coverUrl(artist.coverArt)
Box(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.height(320.dp)
.clip(RoundedCornerShape(14.dp))
.background(NsSurface2),
) {
AsyncImage(
model = imgUrl,
contentDescription = artist.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
Box(
Modifier.fillMaxSize().background(
Brush.verticalGradient(
0.35f to Color(0x00000000),
1f to Color(0xD107070F),
)
)
)
Text(
artist.name,
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold, fontSize = 30.sp,
letterSpacing = (-0.5).sp, color = Color.White,
maxLines = 2, overflow = TextOverflow.Ellipsis,
modifier = Modifier.align(Alignment.BottomStart).padding(20.dp),
)
}
Spacer(Modifier.height(16.dp))
}
// Action bar
item {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(bottom = 8.dp),
) {
ArtistActionPill(Icons.Default.PlayArrow, "Top Songs") {
if (state.topSongs.isNotEmpty()) onPlay(state.topSongs, 0)
}
ArtistActionPill(Icons.Default.Shuffle, "Shuffle") {
if (state.topSongs.isNotEmpty()) onPlay(state.topSongs.shuffled(), 0)
}
}
}
// Similar artists (exclude the current artist)
val similarArtists = state.info?.similarArtist
?.filter { !it.name.equals(artist.name, ignoreCase = true) }
?: emptyList()
if (similarArtists.isNotEmpty()) {
item {
SectionLabel("Similar Artists")
LazyRow(
contentPadding = PaddingValues(horizontal = 20.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(bottom = 20.dp),
) {
items(similarArtists) { sim ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.width(70.dp),
) {
AsyncImage(
model = coverUrl(sim.coverArt),
contentDescription = sim.name,
contentScale = ContentScale.Crop,
modifier = Modifier.size(60.dp).clip(CircleShape)
.background(NsSurface2),
)
Spacer(Modifier.height(6.dp))
Text(
sim.name, fontFamily = Outfit, fontSize = 11.sp,
color = NsOnSurface2, maxLines = 2,
overflow = TextOverflow.Ellipsis,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
}
}
}
}
// Navidrome top songs
if (state.topSongs.isNotEmpty()) {
item { SectionLabel("Top Songs") }
itemsIndexed(state.topSongs, key = { _, s -> "top_${s.id}" }) { idx, song ->
SongRow(
song = song,
coverUrl = coverUrl(song.coverArt),
isPlaying = playerState.currentSong?.id == song.id,
starred = song.starred != null,
onPlay = { onPlay(state.topSongs, idx) },
onStarToggle = { onStarToggle(song) },
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
)
}
item { Spacer(Modifier.height(8.dp)) }
}
// ── iTunes: Weltweit populär ──────────────────────────────────────────
if (state.iTracks.isNotEmpty()) {
item { ArtistSectionHeader("Weltweit populär") }
itemsIndexed(state.iTracks, key = { _, t -> t.trackId ?: 0L }) { _, track ->
ItunesArtistTrackRow(
track = track,
isPreviewing = state.previewingId == track.trackId,
onPreview = { vm.togglePreview(track) },
onDownload = { vm.showDownloadSheet(track) },
)
}
} else if (state.itunesLoading) {
item {
Row(
Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(
color = NsAccent, modifier = Modifier.size(16.dp), strokeWidth = 2.dp,
)
Spacer(Modifier.width(10.dp))
Text("iTunes Daten werden geladen…", fontFamily = Outfit,
fontSize = 12.sp, color = NsOnSurface2)
}
}
}
// ── Album sections (categorized once iTunes data is ready) ────────────
if (cats != null) {
if (cats.downloaded.isNotEmpty()) {
item { ArtistSectionHeader("In Library", cats.downloaded.size) }
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(cats.downloaded) { (nd, _) ->
AlbumThumb(
name = nd.name,
year = nd.year?.toString(),
artUrl = coverUrl(nd.coverArt),
onClick = { onAlbumClick(nd) },
)
}
}
Spacer(Modifier.height(12.dp))
}
}
if (cats.partial.isNotEmpty()) {
item { ArtistSectionHeader("Unvollständig", cats.partial.size) }
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(cats.partial) { (nd, ia, counts) ->
AlbumThumb(
name = nd.name,
year = nd.year?.toString(),
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb")
?: coverUrl(nd.coverArt),
badge = "${counts.first}/${counts.second}",
onClick = { vm.loadPartialAlbum(nd, ia) },
onDownload = { vm.showDownloadSheet(ia) },
)
}
}
Spacer(Modifier.height(12.dp))
}
}
if (cats.notDownloaded.isNotEmpty()) {
item { ArtistSectionHeader("Nicht heruntergeladen", cats.notDownloaded.size) }
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(cats.notDownloaded, key = { it.collectionId ?: 0L }) { ia ->
AlbumThumb(
name = ia.collectionName ?: "—",
year = ia.releaseDate?.take(4),
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb"),
dimmed = true,
onClick = { vm.loadSubAlbum(ia) },
onDownload = { vm.showDownloadSheet(ia) },
)
}
}
Spacer(Modifier.height(12.dp))
}
}
} else if (!state.itunesLoading && artist.album.isNotEmpty()) {
// Fallback when no iTunes match found
item { SectionLabel("Alben") }
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 20.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.padding(bottom = 20.dp),
) {
items(artist.album, key = { it.id }) { album ->
AlbumCard(
album = album,
coverUrl = coverUrl(album.coverArt),
onClick = { onAlbumClick(album) },
)
}
}
}
}
// Bio
state.info?.biography?.takeIf { it.isNotBlank() }?.let { bio ->
item {
SectionLabel("Über")
Box(
Modifier
.padding(horizontal = 16.dp)
.padding(bottom = 24.dp)
.clip(RoundedCornerShape(10.dp))
.background(NsSurface)
.border(1.dp, NsBorder, RoundedCornerShape(10.dp))
.padding(16.dp),
) {
Text(
text = bio.replace(Regex("<[^>]+>"), "").trim(),
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
lineHeight = 21.sp,
)
}
}
}
item { Spacer(Modifier.height(24.dp)) }
}
}
@Composable
private fun ArtistActionPill(icon: ImageVector, label: String, onClick: () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(20.dp))
.background(NsSurface)
.border(1.dp, NsBorder, RoundedCornerShape(20.dp))
.clickable(onClick = onClick)
.padding(horizontal = 18.dp, vertical = 9.dp),
) {
Icon(icon, null, tint = NsOnSurface, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text(label, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 13.sp, color = NsOnSurface)
}
}
@Composable
private fun SectionLabel(title: String) {
Text(
text = title,
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 13.sp, color = NsOnSurface2,
modifier = Modifier.padding(start = 20.dp, top = 4.dp, bottom = 12.dp),
)
}
@@ -0,0 +1,601 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import android.widget.Toast
import androidx.compose.ui.platform.LocalContext
import com.novasonic.app.api.model.*
import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.component.ItunesDownloadSheet
import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.ExploreTab
import com.novasonic.app.viewmodel.ExploreViewModel
@Composable
fun ExploreScreen(
coverUrl: (String?) -> String?,
playerState: PlayerState,
onSongPlay: (List<com.novasonic.app.api.model.Song>, Int) -> Unit,
onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit,
onArtistClick: (com.novasonic.app.api.model.Artist) -> Unit,
onOpenImporter: () -> Unit = {},
) {
val vm: ExploreViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
val focusManager = LocalFocusManager.current
val context = LocalContext.current
// Download sheet
state.downloadSheetItem?.let { item ->
ItunesDownloadSheet(
item = item,
onDownload = { type ->
vm.dismissDownloadSheet()
vm.downloadItem(item, type) { _, msg ->
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
}
},
onDismiss = vm::dismissDownloadSheet,
)
}
// ── iTunes artist detail — full-screen overlay above tabs ────────────────
state.artistDetail?.let { artistState ->
val partialAlbum = state.partialAlbum
val albumTracks = state.albumTracks
val albumItem = state.albumItem
when {
partialAlbum != null -> {
PartialAlbumScreen(
partialAlbum = partialAlbum,
playerState = playerState,
coverUrl = coverUrl,
previewingId = state.previewingId,
onPlay = onSongPlay,
onPreview = vm::togglePreview,
onDownload = vm::showDownloadSheet,
onBack = vm::dismissPartialAlbum,
)
}
albumTracks != null && albumItem != null -> {
ItunesAlbumDetail(
title = state.albumTitle,
albumItem = albumItem,
tracks = albumTracks,
previewingId = state.previewingId,
onPreview = vm::togglePreview,
onDownload = vm::showDownloadSheet,
onBack = vm::dismissAlbum,
)
}
else -> {
ItunesArtistScreen(
artistState = artistState,
playerState = playerState,
coverUrl = coverUrl,
onBack = vm::closeArtistDetail,
onAlbumClick = onAlbumClick,
onPartialAlbumOpen = vm::loadPartialAlbum,
onSongPlay = onSongPlay,
onDownload = vm::showDownloadSheet,
onItunesAlbumOpen = vm::loadAlbum,
onItunesTrackPreview = vm::togglePreview,
previewingId = state.previewingId,
)
}
}
return
}
Column(Modifier.fillMaxSize()) {
// ── Tabs ─────────────────────────────────────────────────────────────
TabRow(
selectedTabIndex = state.tab.ordinal,
containerColor = MaterialTheme.colorScheme.background,
contentColor = NsAccent,
) {
Tab(
selected = state.tab == ExploreTab.LIBRARY,
onClick = { vm.selectTab(ExploreTab.LIBRARY); vm.stopPreview() },
text = { Text("Bibliothek", maxLines = 1) },
selectedContentColor = NsAccent,
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
)
Tab(
selected = state.tab == ExploreTab.GLOBAL,
onClick = { vm.selectTab(ExploreTab.GLOBAL); vm.stopPreview() },
text = { Text("Erkunden", maxLines = 1) },
selectedContentColor = NsAccent,
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// ── Search bar ───────────────────────────────────────────────────────
val focusRequester = remember { FocusRequester() }
OutlinedTextField(
value = state.query,
onValueChange = vm::onQueryChange,
placeholder = {
Text(
if (state.tab == ExploreTab.LIBRARY) "Suche in Bibliothek…" else "Global suchen…",
color = NsOnSurface2,
)
},
leadingIcon = { Icon(Icons.Default.Search, null, tint = NsOnSurface2) },
trailingIcon = if (state.query.isNotEmpty()) { {
IconButton(onClick = { vm.onQueryChange(""); focusManager.clearFocus() }) {
Icon(Icons.Default.Close, "Löschen", tint = NsOnSurface2)
}
} } else null,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { focusManager.clearFocus() }),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = NsAccent,
unfocusedBorderColor = NsOnSurface3,
cursorColor = NsAccent,
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp)
.focusRequester(focusRequester),
)
// ── Importer entry (matches PWA "Importer" under Entdecken) ───────────
if (state.tab == ExploreTab.GLOBAL) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 6.dp)
.clip(RoundedCornerShape(12.dp))
.background(NsSurface)
.clickable(onClick = onOpenImporter)
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
Icon(Icons.Default.Download, null, tint = NsAccent, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text("Importer", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp, color = NsOnSurface)
Text("Spotify · YouTube · SoundCloud Link laden",
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
}
Icon(Icons.Default.ChevronRight, null, tint = NsOnSurface2)
}
}
// ── Content ──────────────────────────────────────────────────────────
when (state.tab) {
ExploreTab.LIBRARY -> LibrarySearchContent(
query = state.query,
coverUrl = coverUrl,
playerState = playerState,
onSongPlay = onSongPlay,
onAlbumClick = onAlbumClick,
onArtistClick = onArtistClick,
)
ExploreTab.GLOBAL -> GlobalExploreContent(
state = state,
vm = vm,
coverUrl = coverUrl,
playerState = playerState,
onAlbumClick = onAlbumClick,
onSongPlay = onSongPlay,
onDownload = vm::showDownloadSheet,
)
}
}
}
// ── Library (local Navidrome) search ─────────────────────────────────────────
@Composable
private fun LibrarySearchContent(
query: String,
coverUrl: (String?) -> String?,
playerState: PlayerState,
onSongPlay: (List<com.novasonic.app.api.model.Song>, Int) -> Unit,
onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit,
onArtistClick: (com.novasonic.app.api.model.Artist) -> Unit,
) {
// Reuse SearchScreen's ViewModel
val vm: com.novasonic.app.viewmodel.SearchViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
LaunchedEffect(query) { vm.onSearch(query) }
if (query.isBlank()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.Search, null, tint = NsOnSurface3, modifier = Modifier.size(48.dp))
Spacer(Modifier.height(12.dp))
Text("Bibliothek durchsuchen", color = NsOnSurface2, fontFamily = Outfit)
}
}
return
}
if (state.isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
return
}
LazyColumn {
val artists = state.searchResult?.artist ?: emptyList()
val albums = state.searchResult?.album ?: emptyList()
val songs = state.searchResult?.song ?: emptyList()
if (artists.isEmpty() && albums.isEmpty() && songs.isEmpty()) {
item {
Box(Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) {
Text("Keine Ergebnisse", color = NsOnSurface2)
}
}
return@LazyColumn
}
if (artists.isNotEmpty()) {
item { SectionHeader("Künstler") }
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(artists, key = { it.id }) { a ->
SmallArtistChip(a.name) { onArtistClick(a) }
}
}
}
}
if (albums.isNotEmpty()) {
item { SectionHeader("Alben") }
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(albums, key = { it.id }) { album ->
com.novasonic.app.ui.component.AlbumCard(
album = album, coverUrl = coverUrl(album.coverArt),
onClick = { onAlbumClick(album) },
)
}
}
Spacer(Modifier.height(8.dp))
}
}
if (songs.isNotEmpty()) {
item { SectionHeader("Titel") }
itemsIndexed(songs, key = { _, s -> s.id }) { idx, song ->
SongRow(
song = song,
coverUrl = coverUrl(song.coverArt),
isPlaying = playerState.currentSong?.id == song.id,
onPlay = { onSongPlay(songs, idx) },
)
}
}
item { Spacer(Modifier.height(16.dp)) }
}
}
// ── Global (iTunes) explore ───────────────────────────────────────────────────
@Composable
private fun GlobalExploreContent(
state: com.novasonic.app.viewmodel.ExploreUiState,
vm: ExploreViewModel,
coverUrl: (String?) -> String? = { null },
playerState: PlayerState = com.novasonic.app.player.PlayerState(),
onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit = {},
onSongPlay: (List<com.novasonic.app.api.model.Song>, Int) -> Unit = { _, _ -> },
onDownload: (ItunesItem) -> Unit = {},
) {
// Album drill-down view
if (state.albumTracks != null) {
ItunesAlbumDetail(
title = state.albumTitle,
albumItem = state.albumItem,
tracks = state.albumTracks,
previewingId = state.previewingId,
onPreview = vm::togglePreview,
onDownload = onDownload,
onBack = vm::dismissAlbum,
)
return
}
if (state.isSearching) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
return
}
if (state.query.isBlank()) {
// Show iTunes charts
if (state.chartsLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
return
}
LazyColumn {
item { SectionHeader("Top-Alben") }
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
items(state.charts) { entry ->
ItunesChartCard(entry = entry, onClick = {
val id = entry.id?.attributes?.imId?.toLongOrNull()
if (id != null) vm.loadAlbum(
ItunesItem(
wrapperType = "collection",
collectionId = id,
collectionName = entry.name?.label,
artistName = entry.artist?.label,
artworkUrl100 = entry.artworkHd(),
)
)
})
}
}
Spacer(Modifier.height(16.dp))
}
}
return
}
// Search results
val hasResults = state.artists.isNotEmpty() || state.albums.isNotEmpty() || state.tracks.isNotEmpty()
if (!hasResults && state.error == null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Keine Ergebnisse", color = NsOnSurface2, fontFamily = Outfit)
}
return
}
LazyColumn {
if (state.error != null) {
item { Text(state.error, color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp)) }
}
if (state.artists.isNotEmpty()) {
item { SectionHeader("Künstler") }
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(state.artists) { a ->
SmallArtistChip(a.artistName ?: "") { vm.openArtistDetail(a) }
}
}
}
}
if (state.albums.isNotEmpty()) {
item { SectionHeader("Alben") }
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
items(state.albums) { album ->
ItunesAlbumCard(album = album, onClick = { vm.loadAlbum(album) })
}
}
Spacer(Modifier.height(8.dp))
}
}
if (state.tracks.isNotEmpty()) {
item { SectionHeader("Titel") }
itemsIndexed(state.tracks, key = { _, t -> t.trackId ?: 0 }) { _, track ->
ItunesTrackRow(
track = track,
isPreviewing = state.previewingId == track.trackId,
onPreview = { vm.togglePreview(track) },
onDownload = { onDownload(track) },
)
}
}
item { Spacer(Modifier.height(16.dp)) }
}
}
@Composable
internal fun ItunesAlbumDetail(
title: String,
albumItem: ItunesItem?,
tracks: List<ItunesItem>,
previewingId: Long?,
onPreview: (ItunesItem) -> Unit,
onDownload: (ItunesItem) -> Unit = {},
onBack: () -> Unit,
) {
Column(Modifier.fillMaxSize()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 4.dp, top = 4.dp, bottom = 4.dp),
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
tint = MaterialTheme.colorScheme.onSurface)
}
Text(title, fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f))
if (albumItem != null) {
IconButton(onClick = { onDownload(albumItem) }) {
Icon(Icons.Default.Download, "Album herunterladen",
tint = NsAccent, modifier = Modifier.size(22.dp))
}
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
LazyColumn {
itemsIndexed(tracks, key = { _, t -> t.trackId ?: 0 }) { _, track ->
ItunesTrackRow(
track = track,
isPreviewing = previewingId == track.trackId,
onPreview = { onPreview(track) },
onDownload = { onDownload(track) },
)
}
item { Spacer(Modifier.height(16.dp)) }
}
}
}
// ── Small shared components ───────────────────────────────────────────────────
@Composable
private fun ItunesChartCard(entry: ItunesRssEntry, onClick: () -> Unit) {
Column(
modifier = Modifier.width(130.dp).clickable(onClick = onClick),
) {
AsyncImage(
model = entry.artworkHd(), contentDescription = entry.name?.label,
contentScale = ContentScale.Crop,
modifier = Modifier.size(130.dp).clip(RoundedCornerShape(10.dp))
.background(NsSurface2),
)
Spacer(Modifier.height(6.dp))
Text(entry.name?.label ?: "", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface)
Text(entry.artist?.label ?: "", fontFamily = Outfit, fontSize = 11.sp,
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
@Composable
private fun ItunesAlbumCard(album: ItunesItem, onClick: () -> Unit) {
Column(
modifier = Modifier.width(130.dp).clickable(onClick = onClick),
) {
val art = album.artworkUrl100?.replace("100x100bb", "300x300bb")
AsyncImage(
model = art, contentDescription = album.collectionName,
contentScale = ContentScale.Crop,
modifier = Modifier.size(130.dp).clip(RoundedCornerShape(10.dp))
.background(NsSurface2),
)
Spacer(Modifier.height(6.dp))
Text(album.collectionName ?: "", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface)
Text(album.artistName ?: "", fontFamily = Outfit, fontSize = 11.sp,
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
@Composable
private fun ItunesTrackRow(
track: ItunesItem,
isPreviewing: Boolean,
onPreview: () -> Unit,
onDownload: (() -> Unit)? = null,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
.clickable(onClick = onPreview)
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
val art = track.artworkUrl100?.replace("100x100bb", "60x60bb")
AsyncImage(
model = art, contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(4.dp))
.background(NsSurface2),
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(track.trackName ?: "", style = MaterialTheme.typography.bodyMedium,
color = if (isPreviewing) NsAccent else MaterialTheme.colorScheme.onSurface,
maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
listOfNotNull(track.artistName, track.collectionName).joinToString(" · "),
style = MaterialTheme.typography.labelSmall,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
// Preview button
if (track.previewUrl != null) {
IconButton(onClick = onPreview, modifier = Modifier.size(36.dp)) {
Icon(
if (isPreviewing) Icons.Default.Stop else Icons.Default.PlayCircle,
if (isPreviewing) "Stop" else "Preview",
tint = if (isPreviewing) NsAccent else NsOnSurface2,
modifier = Modifier.size(22.dp),
)
}
}
// Download button
if (onDownload != null) {
IconButton(onClick = onDownload, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.Download, "Herunterladen",
tint = NsOnSurface2, modifier = Modifier.size(20.dp))
}
}
}
}
@Composable
private fun SmallArtistChip(name: String, onClick: () -> Unit) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.clip(RoundedCornerShape(20.dp))
.background(NsSurface2)
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 8.dp),
) {
Text(name, fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface,
maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
@Composable
private fun SectionHeader(title: String) {
Text(title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 13.sp, color = NsOnSurface2,
modifier = Modifier.padding(start = 20.dp, top = 12.dp, bottom = 10.dp))
}
@@ -0,0 +1,96 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.novasonic.app.api.model.Song
import com.novasonic.app.app
import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.*
@Composable
fun GenreSongsScreen(
genreName: String,
coverUrl: (String?) -> String?,
playerState: PlayerState,
onPlay: (List<Song>, Int) -> Unit,
onStarToggle: (Song) -> Unit,
onShowContextMenu: ((Song) -> Unit)? = null,
onBack: () -> Unit,
) {
val context = LocalContext.current
val repo = context.app.repository
val decodedName = remember(genreName) {
java.net.URLDecoder.decode(genreName, "UTF-8")
}
var songs by remember { mutableStateOf<List<Song>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
LaunchedEffect(decodedName) {
try {
songs = repo.getSongsByGenre(decodedName)
loading = false
} catch (e: Exception) {
error = e.message
loading = false
}
}
Column(Modifier.fillMaxSize()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 4.dp, top = 8.dp, bottom = 8.dp),
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back",
tint = MaterialTheme.colorScheme.onSurface)
}
Text(
decodedName,
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 20.sp,
color = MaterialTheme.colorScheme.onSurface,
)
}
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
when {
loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
error != null -> Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Text(error!!, color = MaterialTheme.colorScheme.error)
}
songs.isEmpty() -> Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Text("Keine Songs gefunden", color = NsOnSurface2)
}
else -> LazyColumn {
itemsIndexed(songs, key = { _, s -> s.id }) { idx, song ->
SongRow(
song = song,
coverUrl = coverUrl(song.coverArt),
isPlaying = playerState.currentSong?.id == song.id,
starred = song.starred != null,
onPlay = { onPlay(songs, idx) },
onStarToggle = { onStarToggle(song) },
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
)
}
item { Spacer(Modifier.height(8.dp)) }
}
}
}
}
@@ -0,0 +1,345 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.novasonic.app.api.model.Album
import com.novasonic.app.api.model.Genre
import com.novasonic.app.ui.component.AlbumCard
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.HomeViewModel
import java.util.Calendar
private val genreColors = listOf(
Color(0xFF1a1a3e), Color(0xFF1e2a1a), Color(0xFF2a1a1a),
Color(0xFF1a2a2a), Color(0xFF2a1e1a), Color(0xFF1e1a2a),
Color(0xFF2a2a1a), Color(0xFF1a2a1e), Color(0xFF201520),
Color(0xFF152020), Color(0xFF201515), Color(0xFF151520),
)
@Composable
fun HomeScreen(
coverUrl: (Album) -> String?,
onAlbumClick: (Album) -> Unit,
onAlbumPlay: (Album) -> Unit,
onGenreClick: (Genre) -> Unit,
onGenrePlay: ((Genre) -> Unit)? = null,
) {
val vm: HomeViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
val greeting = remember {
val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY)
when {
hour < 12 -> "Guten Morgen"
hour < 18 -> "Guten Tag"
else -> "Guten Abend"
}
}
LazyColumn(
contentPadding = PaddingValues(bottom = 24.dp),
modifier = Modifier.fillMaxSize(),
) {
item {
val name = state.username.replaceFirstChar { it.uppercaseChar() }
Text(
text = if (name.isBlank()) greeting else "$greeting, $name 👋",
fontFamily = Outfit,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = NsOnSurface,
modifier = Modifier.padding(start = 16.dp, top = 24.dp, bottom = 20.dp),
)
}
if (state.isLoading) {
item {
Box(Modifier.fillMaxWidth().height(170.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
}
return@LazyColumn
}
if (state.error != null) {
item {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().padding(32.dp),
) {
Text(state.error!!, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(12.dp))
Button(onClick = { vm.load() },
colors = ButtonDefaults.buttonColors(containerColor = NsAccent)) {
Text("Retry")
}
}
}
return@LazyColumn
}
// Hero card — matches PWA .home-hero (170px height)
state.heroAlbum?.let { hero ->
item {
HeroCard(
album = hero,
coverUrl = coverUrl(hero),
onPlay = { onAlbumPlay(hero) },
onClick = { onAlbumClick(hero) },
)
Spacer(Modifier.height(28.dp))
}
}
if (state.genres.isNotEmpty()) {
item { SectionHeader("Genres") }
item {
GenreGrid(genres = state.genres, onGenreClick = onGenreClick, onGenrePlay = onGenrePlay)
Spacer(Modifier.height(28.dp))
}
}
if (state.recentAlbums.isNotEmpty()) {
item { SectionHeader("Zuletzt hinzugefügt") }
item {
AlbumRow(albums = state.recentAlbums, coverUrl = coverUrl, onAlbumClick = onAlbumClick)
Spacer(Modifier.height(28.dp))
}
}
if (state.frequentAlbums.isNotEmpty()) {
item { SectionHeader("Meistgespielt") }
item {
AlbumRow(albums = state.frequentAlbums, coverUrl = coverUrl, onAlbumClick = onAlbumClick)
Spacer(Modifier.height(28.dp))
}
}
if (state.randomAlbums.isNotEmpty()) {
item { SectionHeader("Entdecken") }
item {
AlbumRow(albums = state.randomAlbums, coverUrl = coverUrl, onAlbumClick = onAlbumClick)
Spacer(Modifier.height(16.dp))
}
}
}
}
// PWA: .home-hero { height:170px; border-radius:14px }
// background = blur(8px) brightness(.45) + horizontal gradient overlay
// cover = 100x100, border-radius:9px
@Composable
private fun HeroCard(album: Album, coverUrl: String?, onPlay: () -> Unit, onClick: () -> Unit) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(170.dp)
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(14.dp))
.clickable(onClick = onClick),
) {
// Blurred background image — matches .home-hero-bg
AsyncImage(
model = coverUrl, contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().blur(16.dp),
)
// Gradient overlay — matches .home-hero-overlay
// linear-gradient(to right, rgba(7,7,15,.88) 0%, rgba(7,7,15,.3) 100%)
Box(
modifier = Modifier.fillMaxSize().background(
Brush.horizontalGradient(listOf(Color(0xE107070F), Color(0x4D07070F)))
)
)
// Content row
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 16.dp),
) {
// Cover art — 100dp, matches .home-hero-cover (100px, border-radius:9px)
AsyncImage(
model = coverUrl, contentDescription = album.name,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(100.dp)
.clip(RoundedCornerShape(9.dp))
.background(NsSurface2),
)
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) {
// Label — matches .home-hero-label
Text(
text = "AKTUELL",
fontFamily = Outfit,
fontWeight = FontWeight.Bold,
fontSize = 10.sp,
letterSpacing = 1.sp,
color = NsAccent,
)
Spacer(Modifier.height(4.dp))
Text(
text = album.name,
fontFamily = Outfit,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp,
letterSpacing = (-0.3).sp,
color = NsOnSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
if (!album.artist.isNullOrBlank()) {
Text(
text = album.artist!!,
fontFamily = Outfit,
fontSize = 13.sp,
color = NsOnSurface2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.height(12.dp))
// Play button — matches .home-hero-play (42px circle, accent bg)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(42.dp)
.clip(RoundedCornerShape(21.dp))
.background(NsAccent)
.clickable(onClick = onPlay),
) {
Icon(Icons.Default.PlayArrow, "Play",
tint = NsBackground, modifier = Modifier.size(22.dp))
}
}
}
}
}
@Composable
private fun GenreGrid(
genres: List<Genre>,
onGenreClick: (Genre) -> Unit,
onGenrePlay: ((Genre) -> Unit)?,
) {
val rows = genres.chunked(2)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = 16.dp),
) {
rows.forEach { rowGenres ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
rowGenres.forEachIndexed { _, genre ->
val colorIdx = genres.indexOf(genre) % genreColors.size
GenreChip(
genre = genre,
bgColor = genreColors[colorIdx],
onClick = { onGenreClick(genre) },
onPlay = onGenrePlay?.let { cb -> { cb(genre) } },
modifier = Modifier.weight(1f),
)
}
if (rowGenres.size == 1) Spacer(Modifier.weight(1f))
}
}
}
}
@Composable
private fun GenreChip(
genre: Genre,
bgColor: Color,
onClick: () -> Unit,
onPlay: (() -> Unit)?,
modifier: Modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.height(52.dp)
.clip(RoundedCornerShape(10.dp))
.background(bgColor)
.clickable(onClick = onClick)
.padding(start = 14.dp, end = if (onPlay != null) 8.dp else 14.dp),
) {
Box(
Modifier.width(4.dp).height(24.dp)
.clip(RoundedCornerShape(2.dp))
.background(NsAccent)
)
Spacer(Modifier.width(10.dp))
Text(
text = genre.value,
fontFamily = Outfit,
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp,
color = NsOnSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (onPlay != null) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(32.dp)
.clip(RoundedCornerShape(16.dp))
.background(NsAccent.copy(alpha = 0.18f))
.clickable(onClick = onPlay),
) {
Icon(
Icons.Default.PlayArrow, "Play",
tint = NsAccent,
modifier = Modifier.size(18.dp),
)
}
}
}
}
@Composable
private fun AlbumRow(albums: List<Album>, coverUrl: (Album) -> String?, onAlbumClick: (Album) -> Unit) {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(albums, key = { it.id }) { album ->
AlbumCard(album = album, coverUrl = coverUrl(album), onClick = { onAlbumClick(album) })
}
}
}
// PWA: .section-header { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:1.5px }
@Composable
private fun SectionHeader(title: String) {
Text(
text = title.uppercase(),
fontFamily = Outfit,
fontWeight = FontWeight.Bold,
fontSize = 10.sp,
letterSpacing = 1.5.sp,
color = NsOnSurface2,
modifier = Modifier.padding(start = 16.dp, top = 4.dp, bottom = 12.dp),
)
}
@@ -0,0 +1,282 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Download
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.novasonic.app.api.model.ImportTrack
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.ImporterViewModel
// Brand colors for the platform badge
private val Spotify = Color(0xFF1DB954)
private val SoundCloud = Color(0xFFFF5500)
private val YouTube = Color(0xFFFF0000)
@Composable
fun ImporterScreen(onBack: () -> Unit) {
val vm: ImporterViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
val focus = LocalFocusManager.current
val brand = when (state.platform) {
"spotify" -> Spotify
"soundcloud" -> SoundCloud
"youtube" -> YouTube
else -> NsAccent
}
val brandLabel = when (state.platform) {
"spotify" -> "Spotify"
"soundcloud" -> "SoundCloud"
"youtube" -> "YouTube"
else -> "URL"
}
Column(Modifier.fillMaxSize()) {
// Header
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 4.dp, top = 8.dp, bottom = 4.dp),
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück", tint = NsOnSurface)
}
Text("Importer", fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 20.sp, color = NsOnSurface)
}
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Text(
"Spotify-, YouTube-, YouTube-Music- oder SoundCloud-Link einfügen, " +
"um Tracks zu laden.",
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2, lineHeight = 19.sp,
)
}
// Link input + badge
item {
OutlinedTextField(
value = state.link,
onValueChange = vm::onLink,
placeholder = { Text("https://open.spotify.com/…", color = NsOnSurface2) },
singleLine = true,
trailingIcon = {
Box(
Modifier.padding(end = 8.dp)
.clip(RoundedCornerShape(8.dp))
.background(brand.copy(alpha = 0.15f))
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Text(brandLabel, fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 10.sp, color = brand)
}
},
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go),
keyboardActions = KeyboardActions(onGo = { focus.clearFocus(); vm.resolve() }),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = brand,
unfocusedBorderColor = NsOnSurface3,
cursorColor = brand,
focusedContainerColor = NsSurface,
unfocusedContainerColor = NsSurface,
focusedTextColor = NsOnSurface,
unfocusedTextColor = NsOnSurface,
),
modifier = Modifier.fillMaxWidth(),
)
}
item {
Button(
onClick = { focus.clearFocus(); vm.resolve() },
enabled = !state.isResolving && state.link.isNotBlank(),
modifier = Modifier.fillMaxWidth().height(48.dp),
colors = ButtonDefaults.buttonColors(containerColor = brand, contentColor = Color.Black),
) {
if (state.isResolving) {
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp, color = Color.Black)
} else {
Text("Auflösen", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 15.sp)
}
}
}
state.error?.let { err ->
item {
Box(
Modifier.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(NsSurface)
.border(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.5f), RoundedCornerShape(10.dp))
.padding(14.dp),
) {
Text(err, fontFamily = Outfit, fontSize = 13.sp,
color = MaterialTheme.colorScheme.error, lineHeight = 19.sp)
}
}
}
// Resolved preview header
state.resolved?.let { r ->
item {
Row(
modifier = Modifier.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(NsSurface)
.border(1.dp, NsBorder, RoundedCornerShape(14.dp))
.padding(16.dp),
) {
AsyncImage(
model = r.artworkUrl?.replace("100x100", "300x300"),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(72.dp).clip(RoundedCornerShape(8.dp))
.background(NsSurface2),
)
Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f)) {
Text((r.type ?: "").uppercase(), fontFamily = Outfit,
fontWeight = FontWeight.Bold, fontSize = 10.sp,
letterSpacing = 1.5.sp, color = brand)
Spacer(Modifier.height(3.dp))
Text(r.name ?: "—", fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 17.sp, color = NsOnSurface,
maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(
listOfNotNull(r.artist, "${r.tracks.size} Tracks").joinToString(" · "),
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
}
// Download button / job progress
item {
if (state.isDownloading || state.jobStatus == "done" || state.jobStatus == "error") {
JobProgress(
status = state.jobStatus,
message = state.jobMessage,
completed = state.jobCompleted,
total = state.jobTotal,
brand = brand,
)
} else {
Button(
onClick = vm::downloadAll,
modifier = Modifier.fillMaxWidth().height(48.dp),
colors = ButtonDefaults.buttonColors(containerColor = brand, contentColor = Color.Black),
) {
Icon(Icons.Default.Download, null, Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text("Alle ${r.tracks.size} Tracks laden",
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 15.sp)
}
}
}
itemsIndexed(r.tracks) { idx, track ->
ImportTrackRow(idx + 1, track)
}
}
}
}
}
@Composable
private fun JobProgress(
status: String?, message: String?, completed: Int, total: Int, brand: Color,
) {
Column(
Modifier.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(NsSurface)
.border(1.dp, NsBorder, RoundedCornerShape(12.dp))
.padding(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
when (status) {
"done" -> Icon(Icons.Default.Check, null, tint = brand, modifier = Modifier.size(20.dp))
"error" -> Icon(Icons.Default.Check, null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(20.dp))
else -> CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp, color = brand)
}
Spacer(Modifier.width(10.dp))
Text(
when (status) {
"done" -> "Fertig — in Navidrome scannen lassen"
"error" -> "Fehler beim Download"
"downloading" -> "Lädt… ${if (total > 0) "$completed/$total" else ""}"
else -> "In Warteschlange…"
},
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
color = if (status == "error") MaterialTheme.colorScheme.error else NsOnSurface,
)
}
if (total > 0 && status != "done") {
Spacer(Modifier.height(10.dp))
LinearProgressIndicator(
progress = { if (total > 0) completed.toFloat() / total else 0f },
modifier = Modifier.fillMaxWidth(),
color = brand,
trackColor = NsSurface2,
)
}
message?.takeIf { it.isNotBlank() }?.let {
Spacer(Modifier.height(8.dp))
Text(it, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
}
}
}
@Composable
private fun ImportTrackRow(num: Int, track: ImportTrack) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
AsyncImage(
model = track.artworkUrl100,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(6.dp)).background(NsSurface2),
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(track.trackName ?: "—", fontFamily = Outfit, fontWeight = FontWeight.Medium,
fontSize = 14.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (!track.artistName.isNullOrBlank()) {
Text(track.artistName, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
@@ -0,0 +1,403 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.novasonic.app.api.model.Album
import com.novasonic.app.api.model.ItunesItem
import com.novasonic.app.api.model.Song
import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.ItunesArtistState
private fun normAlbum(s: String?): String =
(s ?: "").lowercase().replace(Regex("[^a-z0-9\\s]"), " ").replace(Regex("\\s+"), " ").trim()
data class ArtistAlbumCategories(
val downloaded: List<Pair<Album, ItunesItem?>>,
val partial: List<Triple<Album, ItunesItem, Pair<Int, Int>>>,
val notDownloaded: List<ItunesItem>,
)
fun categorizeAlbums(ndAlbums: List<Album>, iAlbums: List<ItunesItem>): ArtistAlbumCategories {
// Deduplicate iTunes albums by collectionId (iTunes can return the same album multiple times)
val uniqueIAlbums = iAlbums.distinctBy { it.collectionId ?: it.collectionName }
val downloaded = mutableListOf<Pair<Album, ItunesItem?>>()
val partial = mutableListOf<Triple<Album, ItunesItem, Pair<Int, Int>>>()
val notDl = mutableListOf<ItunesItem>()
val usedNdIds = mutableSetOf<String>()
for (ia in uniqueIAlbums) {
val iName = normAlbum(ia.collectionName)
val iCount = ia.trackCount ?: 0
val match = ndAlbums.find { a ->
val n = normAlbum(a.name)
n == iName || (n.length > 3 && iName.startsWith(n)) || (iName.length > 3 && n.startsWith(iName))
}
when {
match == null -> notDl.add(ia)
iCount > 0 && match.songCount >= iCount -> {
downloaded.add(Pair(match, ia)); usedNdIds.add(match.id)
}
else -> {
partial.add(Triple(match, ia, Pair(match.songCount, iCount))); usedNdIds.add(match.id)
}
}
}
for (na in ndAlbums) {
if (na.id !in usedNdIds) downloaded.add(Pair(na, null))
}
return ArtistAlbumCategories(
downloaded = downloaded.sortedByDescending { it.first.year ?: 0 },
partial = partial.sortedByDescending { it.first.year ?: 0 },
notDownloaded = notDl.sortedByDescending { it.releaseDate ?: "" },
)
}
@Composable
fun ItunesArtistScreen(
artistState: ItunesArtistState,
playerState: PlayerState,
coverUrl: (String?) -> String?,
onBack: () -> Unit,
onAlbumClick: (Album) -> Unit,
onPartialAlbumOpen: (Album, ItunesItem) -> Unit,
onSongPlay: (List<Song>, Int) -> Unit,
onDownload: (ItunesItem) -> Unit,
onItunesAlbumOpen: (ItunesItem) -> Unit,
onItunesTrackPreview: (ItunesItem) -> Unit,
previewingId: Long?,
) {
val artist = artistState.artist
val cats = if (!artistState.isLoading)
categorizeAlbums(artistState.ndAlbums, artistState.iAlbums)
else null
// Pick backdrop: first Navidrome album cover, else first iTunes album art
val backdropUrl = artistState.ndAlbums.firstOrNull()?.let { coverUrl(it.coverArt) }
?: artistState.iAlbums.firstOrNull()?.artworkUrl100?.replace("100x100bb", "300x300bb")
Column(Modifier.fillMaxSize().background(NsBackground)) {
// ── Hero ──────────────────────────────────────────────────────────────
Box(
Modifier.fillMaxWidth().height(200.dp)
) {
AsyncImage(
model = backdropUrl, contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().blur(24.dp),
)
Box(Modifier.fillMaxSize().background(
Brush.verticalGradient(0f to Color.Black.copy(0.5f), 1f to NsBackground)
))
// Back button
Box(
Modifier
.statusBarsPadding()
.padding(start = 14.dp, top = 14.dp)
.size(36.dp)
.clip(CircleShape)
.background(Color.Black.copy(0.45f))
.clickable(onClick = onBack)
.align(Alignment.TopStart),
contentAlignment = Alignment.Center,
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
tint = Color.White, modifier = Modifier.size(18.dp))
}
// Artist name
Column(
Modifier.align(Alignment.BottomStart)
.padding(horizontal = 20.dp, vertical = 16.dp),
) {
Text(
artist.artistName ?: "Unbekannter Künstler",
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
fontSize = 28.sp, color = Color.White,
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
if (!artist.primaryGenreName.isNullOrBlank()) {
Text(artist.primaryGenreName!!, fontFamily = Outfit,
fontSize = 13.sp, color = Color.White.copy(0.6f))
}
}
}
// ── Action row ────────────────────────────────────────────────────────
if (artistState.topSongs.isNotEmpty()) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Button(
onClick = { onSongPlay(artistState.topSongs, 0) },
modifier = Modifier.weight(1f).height(42.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = NsAccent),
) {
Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text("Top Songs", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp)
}
OutlinedButton(
onClick = { onSongPlay(artistState.topSongs.shuffled(), 0) },
modifier = Modifier.weight(1f).height(42.dp),
shape = RoundedCornerShape(12.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true)
.copy(brush = androidx.compose.ui.graphics.SolidColor(NsAccent.copy(0.5f))),
) {
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(18.dp), tint = NsAccent)
Spacer(Modifier.width(6.dp))
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 13.sp, color = NsAccent)
}
}
}
if (artistState.isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
return@Column
}
// ── Scrollable content ────────────────────────────────────────────────
LazyColumn(Modifier.fillMaxSize()) {
// iTunes top tracks
if (artistState.iTracks.isNotEmpty()) {
item { ArtistSectionHeader("Weltweit populär (iTunes)") }
itemsIndexed(artistState.iTracks, key = { _, t -> t.trackId ?: 0L }) { _, track ->
ItunesArtistTrackRow(
track = track,
isPreviewing = previewingId == track.trackId,
onPreview = { onItunesTrackPreview(track) },
onDownload = { onDownload(track) },
)
}
}
// ── Downloaded albums ─────────────────────────────────────────────
cats?.let { c ->
if (c.downloaded.isNotEmpty()) {
item { ArtistSectionHeader("In Library", c.downloaded.size) }
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(c.downloaded) { (nd, _) ->
AlbumThumb(
name = nd.name,
year = nd.year?.toString(),
artUrl = coverUrl(nd.coverArt),
onClick = { onAlbumClick(nd) },
)
}
}
Spacer(Modifier.height(12.dp))
}
}
// ── Partial albums ────────────────────────────────────────────
if (c.partial.isNotEmpty()) {
item { ArtistSectionHeader("Unvollständig", c.partial.size) }
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(c.partial) { (nd, ia, counts) ->
AlbumThumb(
name = nd.name,
year = nd.year?.toString(),
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb") ?: coverUrl(nd.coverArt),
badge = "${counts.first}/${counts.second}",
onClick = { onPartialAlbumOpen(nd, ia) },
onDownload = { onDownload(ia) },
)
}
}
Spacer(Modifier.height(12.dp))
}
}
// ── Not downloaded ────────────────────────────────────────────
if (c.notDownloaded.isNotEmpty()) {
item { ArtistSectionHeader("Nicht heruntergeladen", c.notDownloaded.size) }
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(c.notDownloaded, key = { it.collectionId ?: 0L }) { ia ->
AlbumThumb(
name = ia.collectionName ?: "—",
year = ia.releaseDate?.take(4),
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb"),
dimmed = true,
onClick = { onItunesAlbumOpen(ia) },
onDownload = { onDownload(ia) },
)
}
}
Spacer(Modifier.height(12.dp))
}
}
}
item { Spacer(Modifier.height(24.dp)) }
}
}
}
@Composable
internal fun AlbumThumb(
name: String,
year: String?,
artUrl: String?,
badge: String? = null,
dimmed: Boolean = false,
onClick: () -> Unit,
onDownload: (() -> Unit)? = null,
) {
Column(modifier = Modifier.width(130.dp)) {
Box {
AsyncImage(
model = artUrl, contentDescription = name,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(130.dp)
.clip(RoundedCornerShape(10.dp))
.background(NsSurface2)
.clickable(onClick = onClick)
.then(if (dimmed) Modifier.background(Color.Black.copy(0.35f)) else Modifier),
)
// Track count badge for partial
if (badge != null) {
Box(
Modifier
.align(Alignment.BottomStart)
.padding(6.dp)
.clip(RoundedCornerShape(6.dp))
.background(Color.Black.copy(0.7f))
.padding(horizontal = 5.dp, vertical = 2.dp),
) {
Text(badge, fontFamily = JetBrainsMono, fontSize = 10.sp, color = Color.White)
}
}
// Download button (for partial/not-downloaded)
if (onDownload != null) {
Box(
Modifier
.align(Alignment.BottomEnd)
.padding(6.dp)
.size(28.dp)
.clip(CircleShape)
.background(NsAccent)
.clickable(onClick = onDownload),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Download, null,
tint = Color.White, modifier = Modifier.size(14.dp))
}
}
}
Spacer(Modifier.height(5.dp))
Text(name, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 12.sp, color = if (dimmed) NsOnSurface2 else NsOnSurface,
maxLines = 1, overflow = TextOverflow.Ellipsis)
if (!year.isNullOrBlank()) {
Text(year, fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2)
}
}
}
@Composable
internal fun ItunesArtistTrackRow(
track: ItunesItem,
isPreviewing: Boolean,
onPreview: () -> Unit,
onDownload: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onPreview)
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
val art = track.artworkUrl100?.replace("100x100bb", "60x60bb")
AsyncImage(
model = art, contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(4.dp)).background(NsSurface2),
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(track.trackName ?: "", style = MaterialTheme.typography.bodyMedium,
color = if (isPreviewing) NsAccent else MaterialTheme.colorScheme.onSurface,
maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
listOfNotNull(track.collectionName).joinToString(" · "),
style = MaterialTheme.typography.labelSmall,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
if (track.previewUrl != null) {
IconButton(onClick = onPreview, modifier = Modifier.size(36.dp)) {
Icon(
if (isPreviewing) Icons.Default.Stop else Icons.Default.PlayCircle,
null, tint = if (isPreviewing) NsAccent else NsOnSurface2,
modifier = Modifier.size(22.dp),
)
}
}
IconButton(onClick = onDownload, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.Download, null, tint = NsOnSurface2, modifier = Modifier.size(20.dp))
}
}
}
@Composable
internal fun ArtistSectionHeader(title: String, count: Int? = null) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 20.dp, top = 16.dp, bottom = 10.dp, end = 16.dp),
) {
Text(title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 13.sp, color = NsOnSurface2, modifier = Modifier.weight(1f))
if (count != null) {
Text("$count", fontFamily = Outfit, fontSize = 11.sp,
color = NsOnSurface2.copy(0.5f),
modifier = Modifier
.clip(RoundedCornerShape(6.dp))
.background(NsSurface2)
.padding(horizontal = 7.dp, vertical = 2.dp))
}
}
}
@@ -0,0 +1,191 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.border
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.novasonic.app.api.model.Album
import com.novasonic.app.api.model.Artist
import com.novasonic.app.api.model.Playlist
import com.novasonic.app.ui.component.AlbumCard
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.LibraryTab
import com.novasonic.app.viewmodel.LibraryViewModel
@Composable
fun LibraryScreen(
coverUrl: (String?) -> String?,
onAlbumClick: (Album) -> Unit,
onArtistClick: (Artist) -> Unit,
onPlaylistClick: (Playlist) -> Unit,
) {
val vm: LibraryViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
Column(Modifier.fillMaxSize()) {
TabRow(
selectedTabIndex = state.tab.ordinal,
containerColor = MaterialTheme.colorScheme.background,
contentColor = NsAccent,
) {
LibraryTab.entries.forEach { tab ->
Tab(
selected = state.tab == tab,
onClick = { vm.selectTab(tab) },
text = {
Text(
when (tab) {
LibraryTab.PLAYLISTS -> "Playlists"
LibraryTab.ALBUMS -> "Alben"
LibraryTab.ARTISTS -> "Künstler"
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
selectedContentColor = NsAccent,
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (state.isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
return@Column
}
when (state.tab) {
LibraryTab.ALBUMS -> LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 150.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxSize(),
) {
items(state.albums, key = { it.id }) { album ->
AlbumCard(
album = album,
coverUrl = coverUrl(album.coverArt),
width = 150.dp,
onClick = { onAlbumClick(album) },
)
}
}
LibraryTab.ARTISTS -> LazyColumn(
contentPadding = PaddingValues(vertical = 8.dp),
modifier = Modifier.fillMaxSize(),
) {
state.artistIndexes.forEach { idx ->
item(key = "header_${idx.name}") {
Text(
text = idx.name,
style = MaterialTheme.typography.labelSmall,
color = NsAccent,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
)
}
items(idx.artist, key = { it.id }) { artist ->
ListItem(
headlineContent = {
Text(artist.name, fontFamily = Outfit,
fontWeight = FontWeight.Medium, fontSize = 15.sp)
},
supportingContent = if (artist.albumCount > 0) { {
Text("${artist.albumCount} Alben",
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
} } else null,
leadingContent = {
AsyncImage(
model = coverUrl(artist.coverArt),
contentDescription = artist.name,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(52.dp)
.clip(CircleShape)
.background(NsSurface2),
)
},
modifier = Modifier.fillMaxWidth().clickable { onArtistClick(artist) },
colors = ListItemDefaults.colors(containerColor = NsBackground),
)
}
}
}
LibraryTab.PLAYLISTS -> LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 150.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxSize(),
) {
items(state.playlists, key = { it.id }) { pl ->
PlaylistCard(
name = pl.name,
subtitle = "${pl.songCount} Titel",
coverUrl = coverUrl(pl.coverArt),
onClick = { onPlaylistClick(pl) },
)
}
}
}
}
}
@Composable
private fun PlaylistCard(name: String, subtitle: String, coverUrl: String?, onClick: () -> Unit) {
val shape = RoundedCornerShape(12.dp)
Column(
modifier = Modifier
.clip(shape)
.background(NsSurface)
.border(1.dp, NsBorder, shape)
.clickable(onClick = onClick),
) {
AsyncImage(
model = coverUrl,
contentDescription = name,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.background(NsSurface2),
)
Column(Modifier.padding(10.dp)) {
Text(
text = name,
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 12.sp,
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(2.dp))
Text(
text = subtitle,
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -0,0 +1,130 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.*
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.novasonic.app.ui.theme.NsAccent
import com.novasonic.app.ui.theme.NsOnSurface2
import com.novasonic.app.viewmodel.LoginViewModel
@Composable
fun LoginScreen(onLoggedIn: () -> Unit) {
val vm: LoginViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
var serverUrl by remember { mutableStateOf("") }
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var showPw by remember { mutableStateOf(false) }
LaunchedEffect(state.success) {
if (state.success) onLoggedIn()
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
) {
Text(
text = "NovaSonic",
color = NsAccent,
fontSize = 36.sp,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(8.dp))
Text(
text = "Connect to your Navidrome server",
style = MaterialTheme.typography.bodyMedium,
color = NsOnSurface2,
)
Spacer(Modifier.height(40.dp))
OutlinedTextField(
value = serverUrl,
onValueChange = { serverUrl = it },
label = { Text("Server URL") },
placeholder = { Text("https://music.example.com") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
colors = fieldColors(),
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Username") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
colors = fieldColors(),
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
singleLine = true,
visualTransformation = if (showPw) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { showPw = !showPw }) {
Icon(
imageVector = if (showPw) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = null,
tint = NsOnSurface2,
)
}
},
modifier = Modifier.fillMaxWidth(),
colors = fieldColors(),
)
Spacer(Modifier.height(28.dp))
if (state.error != null) {
Text(
text = state.error!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 12.dp),
)
}
Button(
onClick = { vm.login(serverUrl.trim(), username.trim(), password) },
enabled = !state.isLoading && serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
modifier = Modifier.fillMaxWidth().height(52.dp),
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = MaterialTheme.colorScheme.background),
) {
if (state.isLoading) {
CircularProgressIndicator(Modifier.size(20.dp), color = MaterialTheme.colorScheme.background, strokeWidth = 2.dp)
} else {
Text("Connect", fontWeight = FontWeight.SemiBold)
}
}
}
}
@Composable
private fun fieldColors() = OutlinedTextFieldDefaults.colors(
focusedBorderColor = NsAccent,
focusedLabelColor = NsAccent,
unfocusedBorderColor = NsOnSurface2,
unfocusedLabelColor = NsOnSurface2,
cursorColor = NsAccent,
)
@@ -0,0 +1,555 @@
package com.novasonic.app.ui.screen
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import coil.request.ImageRequest
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.novasonic.app.player.PlayerController
import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.LyricsViewModel
import kotlinx.coroutines.launch
private enum class NpTab { PLAYER, LYRICS, QUEUE }
@Composable
fun NowPlayingScreen(
state: PlayerState,
coverUrl: String?,
queueCoverUrl: ((String?) -> String?)? = null,
controller: PlayerController,
onDismiss: () -> Unit,
onStarToggle: (() -> Unit)? = null,
onShowOptions: (() -> Unit)? = null,
onArtistClick: (() -> Unit)? = null,
) {
val song = state.currentSong
var tab by remember { mutableStateOf(NpTab.PLAYER) }
val statusBarTop: Dp = with(LocalDensity.current) { WindowInsets.safeDrawing.getTop(this).toDp() }
val topPad = maxOf(statusBarTop, 36.dp)
val navBarBottom: Dp = with(LocalDensity.current) { WindowInsets.safeDrawing.getBottom(this).toDp() }
// Apple Music-style: cover scales up while playing
val coverScale by animateFloatAsState(
targetValue = if (state.isPlaying) 1f else 0.86f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow,
),
label = "coverScale",
)
val context = LocalContext.current
Box(Modifier.fillMaxSize().background(NsBackground)) {
// Blurred album art full-screen background — crossfade prevents transparent flash on song change
AsyncImage(
model = ImageRequest.Builder(context).data(coverUrl).crossfade(500).build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().blur(72.dp),
)
Box(
Modifier.fillMaxSize().background(
Brush.verticalGradient(0f to Color(0xCC000000), 1f to Color(0xF2000000))
)
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.padding(top = topPad, bottom = maxOf(navBarBottom, 20.dp))
.pointerInput(Unit) {
var dragY = 0f
detectVerticalDragGestures(
onDragEnd = { if (dragY > 120f) onDismiss(); dragY = 0f },
onDragCancel = { dragY = 0f },
onVerticalDrag = { _, dy -> if (dy > 0) dragY += dy },
)
},
) {
// ── Top bar ────────────────────────────────────────────────────
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
.padding(horizontal = 20.dp)
.padding(bottom = 16.dp),
) {
NpCircleBtn(onClick = onDismiss) {
Icon(Icons.Default.KeyboardArrowDown, "Schließen",
tint = Color.White, modifier = Modifier.size(24.dp))
}
// Tab pills
Row(
modifier = Modifier
.clip(RoundedCornerShape(20.dp))
.background(Color.White.copy(alpha = 0.1f))
.padding(3.dp),
) {
NpTab.entries.forEach { t ->
val sel = tab == t
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(if (sel) Color.White.copy(0.18f) else Color.Transparent)
.clickable { tab = t }
.padding(horizontal = 14.dp, vertical = 6.dp),
) {
Text(
when (t) {
NpTab.PLAYER -> "Player"
NpTab.LYRICS -> "Lyrics"
NpTab.QUEUE -> "Queue"
},
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 11.sp,
color = if (sel) Color.White else Color.White.copy(alpha = 0.4f),
)
}
}
}
if (onShowOptions != null) {
NpCircleBtn(onClick = onShowOptions) {
Icon(Icons.Default.MoreVert, "Optionen",
tint = Color.White, modifier = Modifier.size(22.dp))
}
} else {
Spacer(Modifier.size(36.dp))
}
}
// ── PLAYER TAB ─────────────────────────────────────────────────
if (tab == NpTab.PLAYER) {
var dragX by remember { mutableStateOf(0f) }
// Cover art — full content width, scale-animates like Apple Music
AsyncImage(
model = coverUrl,
contentDescription = song?.title,
contentScale = ContentScale.Crop,
modifier = Modifier
.padding(horizontal = 24.dp)
.fillMaxWidth()
.aspectRatio(1f)
.graphicsLayer { scaleX = coverScale; scaleY = coverScale }
.shadow(40.dp, RoundedCornerShape(20.dp))
.clip(RoundedCornerShape(20.dp))
.background(NsSurface2)
.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragEnd = {
if (dragX < -80) controller.skipNext()
else if (dragX > 80) controller.skipPrev()
dragX = 0f
},
onDragCancel = { dragX = 0f },
onHorizontalDrag = { _, d -> dragX += d },
)
},
)
Spacer(Modifier.height(24.dp))
// ── Song info ──────────────────────────────────────────────
Column(Modifier.fillMaxWidth().padding(horizontal = 28.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.weight(1f)) {
Text(
song?.title ?: "Nichts spielt",
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold, fontSize = 22.sp,
letterSpacing = (-0.4).sp, color = Color.White,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(3.dp))
if (!song?.artist.isNullOrBlank()) {
Text(
song!!.artist!!,
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 15.sp, color = NsAccent,
maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = if (onArtistClick != null)
Modifier.clickable(onClick = onArtistClick)
else Modifier,
)
}
if (!song?.album.isNullOrBlank()) {
Text(
song!!.album!!,
fontFamily = Outfit, fontSize = 12.sp,
color = Color.White.copy(alpha = 0.45f),
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
if (onStarToggle != null && song != null) {
val starred = song.starred != null
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(40.dp).clip(CircleShape)
.clickable(onClick = onStarToggle),
) {
Icon(
if (starred) Icons.Default.Star else Icons.Default.StarBorder,
null,
tint = if (starred) NsAccent else Color.White.copy(0.5f),
modifier = Modifier.size(22.dp),
)
}
}
}
}
Spacer(Modifier.height(20.dp))
// ── Seek bar ───────────────────────────────────────────────
Column(Modifier.fillMaxWidth().padding(horizontal = 24.dp)) {
val progress = if (state.durationMs > 0)
(state.positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f) else 0f
NpSeekBar(
progress = progress,
onSeek = { controller.seekTo((it * state.durationMs).toLong()) },
modifier = Modifier.fillMaxWidth(),
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(formatMs(state.positionMs), fontFamily = JetBrainsMono, fontSize = 11.sp,
color = Color.White.copy(0.4f))
Text(formatMs(state.durationMs), fontFamily = JetBrainsMono, fontSize = 11.sp,
color = Color.White.copy(0.4f))
}
}
Spacer(Modifier.height(12.dp))
// ── Controls ───────────────────────────────────────────────
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) {
NpCircleBtn(onClick = { controller.toggleShuffle() }) {
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(22.dp),
tint = if (state.shuffled) NsAccent else Color.White.copy(0.65f))
}
NpCircleBtn(onClick = { controller.skipPrev() }) {
Icon(Icons.Default.SkipPrevious, null, modifier = Modifier.size(34.dp),
tint = Color.White)
}
// Big play/pause
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(68.dp)
.clip(CircleShape)
.background(Color.White)
.clickable { controller.togglePlay() },
) {
Icon(
if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
null,
tint = Color.Black, modifier = Modifier.size(34.dp),
)
}
NpCircleBtn(onClick = { controller.skipNext() }) {
Icon(Icons.Default.SkipNext, null, modifier = Modifier.size(34.dp), tint = Color.White)
}
NpCircleBtn(onClick = { controller.toggleRepeat() }) {
val (icon, tint) = when (state.repeatMode) {
androidx.media3.common.Player.REPEAT_MODE_ONE -> Icons.Default.RepeatOne to NsAccent
androidx.media3.common.Player.REPEAT_MODE_ALL -> Icons.Default.Repeat to NsAccent
else -> Icons.Default.Repeat to Color.White.copy(0.65f)
}
Icon(icon, null, modifier = Modifier.size(22.dp), tint = tint)
}
}
}
// ── LYRICS TAB ─────────────────────────────────────────────────
if (tab == NpTab.LYRICS && song != null) {
val lyricsVm: LyricsViewModel = viewModel()
val lyricsState by lyricsVm.state.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
LaunchedEffect(song.id) { lyricsVm.loadForSong(song) }
val activeIdx = if (lyricsState.lines.isNotEmpty())
lyricsState.lines.indexOfLast { it.timeMs <= state.positionMs }.coerceAtLeast(0)
else -1
// Smooth scroll to active line
LaunchedEffect(activeIdx) {
if (activeIdx >= 0 && lyricsState.lines.isNotEmpty()) {
scope.launch {
listState.animateScrollToItem(
(activeIdx - 2).coerceAtLeast(0),
scrollOffset = 0,
)
}
}
}
when {
lyricsState.isLoading -> Box(
Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(color = NsAccent.copy(0.7f),
modifier = Modifier.size(28.dp), strokeWidth = 2.dp)
Spacer(Modifier.height(10.dp))
Text("Lade Lyrics…", fontFamily = Outfit, fontSize = 13.sp,
color = Color.White.copy(0.5f))
}
}
lyricsState.notFound -> Box(
Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center,
) {
Text("Keine Lyrics verfügbar", fontFamily = Outfit, fontSize = 14.sp,
color = Color.White.copy(0.4f), textAlign = TextAlign.Center)
}
lyricsState.lines.isNotEmpty() -> LazyColumn(
state = listState,
modifier = Modifier.weight(1f).fillMaxWidth(),
contentPadding = PaddingValues(vertical = 40.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
itemsIndexed(lyricsState.lines) { idx, line ->
val isActive = idx == activeIdx
val isPast = idx < activeIdx
val targetColor = when {
isActive -> Color.White
isPast -> Color.White.copy(alpha = 0.32f)
else -> Color.White.copy(alpha = 0.55f)
}
val animColor by animateColorAsState(
targetValue = targetColor,
animationSpec = tween(350),
label = "lyricColor$idx",
)
val animSize by animateFloatAsState(
targetValue = if (isActive) 20f else 16f,
animationSpec = tween(300),
label = "lyricSize$idx",
)
Text(
text = line.text,
fontFamily = Outfit,
fontWeight = if (isActive) FontWeight.ExtraBold else FontWeight.Normal,
fontSize = animSize.sp,
color = animColor,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.clickable { controller.seekTo(line.timeMs) }
.padding(horizontal = 12.dp, vertical = 5.dp),
)
}
}
lyricsState.plainText.isNotBlank() -> LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentPadding = PaddingValues(vertical = 24.dp),
) {
item {
Text(
text = lyricsState.plainText,
fontFamily = Outfit, fontSize = 15.sp,
color = Color.White.copy(0.8f),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
)
}
}
}
}
// ── QUEUE TAB ──────────────────────────────────────────────────
if (tab == NpTab.QUEUE) {
if (state.queue.isEmpty()) {
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Text("Warteschlange leer", fontFamily = Outfit, fontSize = 14.sp,
color = Color.White.copy(0.4f))
}
} else {
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentPadding = PaddingValues(vertical = 4.dp),
) {
itemsIndexed(state.queue, key = { idx, _ -> idx }) { idx, qSong ->
val active = state.currentIndex == idx
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(if (active) Color.White.copy(0.08f) else Color.Transparent)
.clickable { controller.seekToItem(idx) }
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
AsyncImage(
model = queueCoverUrl?.invoke(qSong.coverArt),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(6.dp))
.background(Color.White.copy(0.08f)),
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(qSong.title, fontFamily = Outfit, fontWeight = FontWeight.Medium,
fontSize = 14.sp, color = if (active) NsAccent else Color.White,
maxLines = 1, overflow = TextOverflow.Ellipsis)
if (!qSong.artist.isNullOrBlank()) {
Text(qSong.artist!!, fontFamily = Outfit, fontSize = 12.sp,
color = Color.White.copy(0.4f),
maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
if (active) {
Icon(Icons.AutoMirrored.Filled.VolumeUp, null,
tint = NsAccent, modifier = Modifier.size(16.dp))
}
}
}
}
}
}
}
}
}
@Composable
private fun NpSeekBar(
progress: Float,
onSeek: (Float) -> Unit,
modifier: Modifier = Modifier,
) {
var isDragging by remember { mutableStateOf(false) }
var dragProgress by remember { mutableStateOf(0f) }
val displayProgress = if (isDragging) dragProgress else progress
val trackHeight by animateDpAsState(
targetValue = if (isDragging) 5.dp else 3.dp,
animationSpec = tween(120),
label = "seekTrackHeight",
)
BoxWithConstraints(
contentAlignment = Alignment.Center,
modifier = modifier
.height(32.dp)
.pointerInput(Unit) {
detectTapGestures { offset ->
val p = (offset.x / size.width.toFloat()).coerceIn(0f, 1f)
onSeek(p)
}
}
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { offset ->
isDragging = true
dragProgress = (offset.x / size.width.toFloat()).coerceIn(0f, 1f)
},
onDragEnd = { onSeek(dragProgress); isDragging = false },
onDragCancel = { isDragging = false },
onDrag = { change, _ ->
dragProgress = (change.position.x / size.width.toFloat()).coerceIn(0f, 1f)
},
)
},
) {
// Background track
Box(
Modifier
.fillMaxWidth()
.height(trackHeight)
.clip(RoundedCornerShape(50))
.background(Color.White.copy(alpha = 0.2f))
)
// Filled portion
Box(
Modifier
.fillMaxWidth(displayProgress.coerceIn(0f, 1f))
.height(trackHeight)
.clip(RoundedCornerShape(50))
.background(Color.White)
.align(Alignment.CenterStart)
)
// Thumb — visible only while dragging
if (isDragging) {
Box(
Modifier
.size(14.dp)
.offset(x = (maxWidth * displayProgress - 7.dp).coerceIn(0.dp, maxWidth - 14.dp))
.align(Alignment.CenterStart)
.clip(CircleShape)
.background(Color.White)
)
}
}
}
@Composable
private fun NpCircleBtn(onClick: () -> Unit, content: @Composable () -> Unit) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(44.dp).clip(CircleShape).clickable(onClick = onClick),
) { content() }
}
private fun formatMs(ms: Long): String {
val s = (ms / 1000).coerceAtLeast(0)
return "%d:%02d".format(s / 60, s % 60)
}
@@ -0,0 +1,174 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Download
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.novasonic.app.api.model.ItunesItem
import com.novasonic.app.api.model.Song
import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.PartialAlbumState
private fun normTrack(s: String?) =
(s ?: "").lowercase().replace(Regex("[^a-z0-9]"), "").trim()
@Composable
fun PartialAlbumScreen(
partialAlbum: PartialAlbumState,
playerState: PlayerState,
coverUrl: (String?) -> String?,
previewingId: Long?,
onPlay: (List<Song>, Int) -> Unit,
onPreview: (ItunesItem) -> Unit,
onDownload: (ItunesItem) -> Unit,
onBack: () -> Unit,
) {
val ia = partialAlbum.itunesAlbum
val nd = partialAlbum.ndAlbum
val artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb")
?: coverUrl(nd.coverArt)
Column(Modifier.fillMaxSize().background(NsBackground)) {
// ── Header ────────────────────────────────────────────────────────────
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 4.dp, top = 4.dp, end = 8.dp, bottom = 4.dp),
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück", tint = MaterialTheme.colorScheme.onSurface)
}
AsyncImage(
model = artUrl, contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(42.dp).clip(RoundedCornerShape(6.dp)).background(NsSurface2),
)
Spacer(Modifier.width(10.dp))
Column(Modifier.weight(1f)) {
Text(
nd.name, fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
Text(
nd.artist ?: ia.artistName ?: "",
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
IconButton(onClick = { onDownload(ia) }) {
Icon(Icons.Default.Download, "Album laden", tint = NsAccent, modifier = Modifier.size(20.dp))
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
if (partialAlbum.isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
return@Column
}
// Cross-reference: which iTunes tracks have no ND match?
val ndNames = partialAlbum.ndSongs.map { normTrack(it.title) }.toSet()
val downloadedSongs = partialAlbum.ndSongs.sortedBy { it.track ?: 999 }
val missingTracks = partialAlbum.iTracks
.filter { normTrack(it.trackName) !in ndNames }
.sortedBy { it.trackNumber ?: 999 }
LazyColumn(Modifier.fillMaxSize()) {
// ── Downloaded tracks ─────────────────────────────────────────────
if (downloadedSongs.isNotEmpty()) {
item {
PartialSectionHeader(
title = "Heruntergeladen",
count = downloadedSongs.size,
accent = false,
)
}
itemsIndexed(downloadedSongs, key = { _, s -> "dl_${s.id}" }) { idx, song ->
SongRow(
song = song,
coverUrl = coverUrl(song.coverArt),
isPlaying = playerState.currentSong?.id == song.id,
starred = song.starred != null,
onPlay = { onPlay(downloadedSongs, idx) },
onStarToggle = {},
)
}
}
// ── Missing tracks (iTunes only) ──────────────────────────────────
if (missingTracks.isNotEmpty()) {
item {
PartialSectionHeader(
title = "Nicht heruntergeladen",
count = missingTracks.size,
accent = true,
)
}
itemsIndexed(missingTracks, key = { _, t -> "miss_${t.trackId ?: 0L}" }) { _, track ->
ItunesArtistTrackRow(
track = track,
isPreviewing = previewingId == track.trackId,
onPreview = { onPreview(track) },
onDownload = { onDownload(track) },
)
}
}
if (downloadedSongs.isEmpty() && missingTracks.isEmpty()) {
item {
Box(Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) {
Text("Keine Tracks gefunden", color = NsOnSurface2, fontFamily = Outfit)
}
}
}
item { Spacer(Modifier.height(24.dp)) }
}
}
}
@Composable
private fun PartialSectionHeader(title: String, count: Int, accent: Boolean) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 8.dp, end = 16.dp),
) {
Text(
title,
fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 13.sp, color = if (accent) NsOnSurface2.copy(0.7f) else NsOnSurface2,
modifier = Modifier.weight(1f),
)
Text(
"$count",
fontFamily = Outfit, fontSize = 11.sp,
color = NsOnSurface2.copy(0.5f),
modifier = Modifier
.clip(RoundedCornerShape(6.dp))
.background(NsSurface2)
.padding(horizontal = 7.dp, vertical = 2.dp),
)
}
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(0.4f),
modifier = Modifier.padding(horizontal = 16.dp))
}
@@ -0,0 +1,187 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.novasonic.app.api.model.Song
import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.PlaylistViewModel
@Composable
fun PlaylistDetailScreen(
coverUrl: (String?) -> String?,
playerState: PlayerState,
onPlay: (List<Song>, Int) -> Unit,
onStarToggle: (Song) -> Unit,
onShowContextMenu: ((Song) -> Unit)? = null,
onBack: () -> Unit,
) {
val vm: PlaylistViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
if (state.isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
return
}
val pl = state.playlist ?: run {
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
state.error ?: "Playlist nicht gefunden",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(12.dp))
OutlinedButton(onClick = onBack) { Text("Zurück") }
}
}
return
}
LazyColumn(Modifier.fillMaxSize()) {
item {
// ── Hero ─────────────────────────────────────────────────────────
Box(Modifier.fillMaxWidth()) {
AsyncImage(
model = coverUrl(pl.coverArt),
contentDescription = pl.name,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.background(NsSurface2),
)
Box(
Modifier
.fillMaxWidth()
.aspectRatio(1f)
.background(
Brush.verticalGradient(
0.0f to Color.Transparent,
0.40f to NsBackground.copy(alpha = 0.35f),
1.0f to NsBackground,
)
)
)
Box(
Modifier
.statusBarsPadding()
.padding(start = 14.dp, top = 14.dp)
.size(36.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.45f))
.clickable(onClick = onBack)
.align(Alignment.TopStart),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
tint = Color.White, modifier = Modifier.size(18.dp),
)
}
Column(
Modifier
.align(Alignment.BottomStart)
.padding(horizontal = 20.dp, vertical = 20.dp),
) {
Text(
pl.name,
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
fontSize = 26.sp, color = Color.White,
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
val meta = listOfNotNull(
pl.owner?.takeIf { it.isNotBlank() },
if (pl.songCount > 0) "${pl.songCount} Songs" else null,
).joinToString(" · ")
if (meta.isNotBlank()) {
Spacer(Modifier.height(4.dp))
Text(
meta, fontFamily = Outfit, fontSize = 13.sp,
color = Color.White.copy(alpha = 0.65f),
)
}
if (!pl.comment.isNullOrBlank()) {
Spacer(Modifier.height(3.dp))
Text(
pl.comment!!, fontFamily = Outfit, fontSize = 12.sp,
color = Color.White.copy(alpha = 0.45f),
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
}
}
}
// ── Play / Shuffle row ────────────────────────────────────────────
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Button(
onClick = { if (pl.entry.isNotEmpty()) onPlay(pl.entry, 0) },
modifier = Modifier.weight(1f).height(46.dp),
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
shape = RoundedCornerShape(14.dp),
) {
Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(6.dp))
Text("Play", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp)
}
OutlinedButton(
onClick = { if (pl.entry.isNotEmpty()) onPlay(pl.entry.shuffled(), 0) },
modifier = Modifier.weight(1f).height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
shape = RoundedCornerShape(14.dp),
) {
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(20.dp), tint = NsAccent)
Spacer(Modifier.width(6.dp))
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent)
}
}
HorizontalDivider(color = NsBorder)
}
itemsIndexed(pl.entry, key = { _, s -> s.id }) { idx, song ->
SongRow(
song = song,
coverUrl = coverUrl(song.coverArt),
isPlaying = playerState.currentSong?.id == song.id,
starred = song.starred != null,
onPlay = { onPlay(pl.entry, idx) },
onStarToggle = { onStarToggle(song) },
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
)
}
item { Spacer(Modifier.height(16.dp)) }
}
}
@@ -0,0 +1,123 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.novasonic.app.api.model.Album
import com.novasonic.app.api.model.Song
import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.NsAccent
import com.novasonic.app.ui.theme.NsOnSurface2
import com.novasonic.app.viewmodel.SearchViewModel
@Composable
fun SearchScreen(
coverUrl: (String?) -> String?,
playerState: PlayerState,
onSongPlay: (List<Song>, Int) -> Unit,
onAlbumClick: (Album) -> Unit,
) {
val vm: SearchViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
Column(Modifier.fillMaxSize()) {
SearchBar(
query = state.query,
onQueryChange = vm::onQueryChange,
modifier = Modifier.fillMaxWidth().padding(16.dp),
)
when {
state.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
state.query.isBlank() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.Search, contentDescription = null,
tint = NsOnSurface2, modifier = Modifier.size(48.dp))
Spacer(Modifier.height(12.dp))
Text("Search songs, albums, artists",
style = MaterialTheme.typography.bodyMedium, color = NsOnSurface2)
}
}
state.searchResult != null -> {
val r = state.searchResult!!
LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
if (r.song.isNotEmpty()) {
item { ResultHeader("Songs") }
items(r.song, key = { it.id }) { song ->
val idx = r.song.indexOf(song)
SongRow(
song = song,
coverUrl = coverUrl(song.coverArt),
isPlaying = playerState.currentSong?.id == song.id,
onPlay = { onSongPlay(r.song, idx) },
)
}
}
if (r.album.isNotEmpty()) {
item { ResultHeader("Albums") }
items(r.album, key = { it.id }) { album ->
ListItem(
headlineContent = { Text(album.name) },
supportingContent = { Text(album.artist ?: "",
style = MaterialTheme.typography.labelSmall) },
modifier = Modifier.fillMaxWidth().clickable { onAlbumClick(album) },
colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.background),
)
}
}
if (r.artist.isNotEmpty()) {
item { ResultHeader("Artists") }
items(r.artist, key = { it.id }) { artist ->
ListItem(
headlineContent = { Text(artist.name) },
supportingContent = { Text("${artist.albumCount} albums",
style = MaterialTheme.typography.labelSmall) },
colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.background),
)
}
}
}
}
}
}
}
@Composable
private fun SearchBar(query: String, onQueryChange: (String) -> Unit, modifier: Modifier) {
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
placeholder = { Text("Search…") },
leadingIcon = { Icon(Icons.Default.Search, null, tint = NsOnSurface2) },
singleLine = true,
modifier = modifier,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = NsAccent,
unfocusedBorderColor = NsOnSurface2,
cursorColor = NsAccent,
),
)
}
@Composable
private fun ResultHeader(title: String) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = NsAccent,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
)
}
@@ -0,0 +1,348 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.SettingsViewModel
private val SCROBBLE_OPTIONS = listOf(
"15s" to "15 Sekunden",
"30s" to "30 Sekunden",
"third" to "1/3 der Song-Länge",
"two_thirds" to "2/3 der Song-Länge",
"end" to "Song-Ende",
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen() {
val vm: SettingsViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
var showPassword by remember { mutableStateOf(false) }
var showLogoutDialog by remember { mutableStateOf(false) }
var scrobbleExpanded by remember { mutableStateOf(false) }
if (showLogoutDialog) {
AlertDialog(
onDismissRequest = { showLogoutDialog = false },
title = { Text("Abmelden?", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
text = { Text("Wirklich abmelden? Alle lokalen Einstellungen werden gelöscht.", fontFamily = Outfit) },
confirmButton = {
TextButton(onClick = { vm.logout(); showLogoutDialog = false }) {
Text("Abmelden", color = MaterialTheme.colorScheme.error, fontFamily = Outfit)
}
},
dismissButton = {
TextButton(onClick = { showLogoutDialog = false }) {
Text("Abbrechen", fontFamily = Outfit)
}
},
containerColor = NsSurface,
)
}
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 24.dp),
) {
Text(
"Einstellungen",
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 24.sp,
color = NsOnSurface,
)
// ── 🌐 Navidrome Server ──────────────────────────────────────────────
SectionHeader("🌐 Navidrome Server")
NsTextField(
value = state.serverUrl,
onValueChange = vm::onServerUrl,
label = "Server URL",
placeholder = "https://musik.beispiel.de",
keyboardType = KeyboardType.Uri,
)
NsTextField(
value = state.username,
onValueChange = vm::onUsername,
label = "Benutzername",
)
NsTextField(
value = state.password,
onValueChange = vm::onPassword,
label = "Passwort",
hint = "(unverändert lassen wenn kein Reset gewünscht)",
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
keyboardType = KeyboardType.Password,
trailingIcon = {
IconButton(onClick = { showPassword = !showPassword }) {
Icon(
if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = null, tint = NsOnSurface2,
)
}
},
)
Spacer(Modifier.height(4.dp))
// ── 📊 Bibliothek ────────────────────────────────────────────────────
SectionHeader("📊 Bibliothek")
// Scrobble-Mode dropdown
ExposedDropdownMenuBox(
expanded = scrobbleExpanded,
onExpandedChange = { scrobbleExpanded = it },
) {
OutlinedTextField(
value = SCROBBLE_OPTIONS.firstOrNull { it.first == state.scrobbleMode }?.second
?: "30 Sekunden",
onValueChange = {},
readOnly = true,
label = { Text("Scrobble senden nach", fontFamily = Outfit) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(scrobbleExpanded) },
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable, true).fillMaxWidth(),
colors = fieldColors(),
)
ExposedDropdownMenu(
expanded = scrobbleExpanded,
onDismissRequest = { scrobbleExpanded = false },
containerColor = NsSurface2,
) {
SCROBBLE_OPTIONS.forEach { (key, label) ->
DropdownMenuItem(
text = { Text(label, fontFamily = Outfit, color = NsOnSurface) },
onClick = { vm.onScrobbleMode(key); scrobbleExpanded = false },
colors = MenuDefaults.itemColors(
textColor = if (key == state.scrobbleMode) NsAccent else NsOnSurface
),
)
}
}
}
// Show play count toggle
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
Column(Modifier.weight(1f)) {
Text("Abspielhäufigkeit anzeigen", fontFamily = Outfit,
fontWeight = FontWeight.Medium, fontSize = 14.sp, color = NsOnSurface)
Text("Zeigt Wiedergabezähler in Song-Reihen an",
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
}
Switch(
checked = state.showPlayCount,
onCheckedChange = vm::onShowPlayCount,
colors = SwitchDefaults.colors(
checkedThumbColor = NsBackground,
checkedTrackColor = NsAccent,
uncheckedThumbColor = NsOnSurface2,
),
)
}
Spacer(Modifier.height(4.dp))
// ── 🎨 Theme ─────────────────────────────────────────────────────────
SectionHeader("🎨 Theme")
ThemePicker(
currentThemeId = state.theme,
onSelect = { id ->
vm.onTheme(id)
applyNsTheme(id)
},
)
Spacer(Modifier.height(4.dp))
// ── 🔑 APIs ──────────────────────────────────────────────────────────
SectionHeader("🔑 APIs")
NsTextField(
value = state.lastFmKey,
onValueChange = vm::onLastFmKey,
label = "Last.fm API Key",
hint = "Kostenlos unter last.fm/api/account/create",
)
NsTextField(
value = state.backendUrl,
onValueChange = vm::onBackendUrl,
label = "NovaSonic Backend URL",
hint = "Für Download-to-Server via yt-dlp",
keyboardType = KeyboardType.Uri,
)
Spacer(Modifier.height(8.dp))
// Status + Save
if (state.error != null) {
Text(state.error!!, color = MaterialTheme.colorScheme.error,
fontFamily = Outfit, fontSize = 13.sp)
}
if (state.savedOk) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Check, null, tint = NsAccent, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text("Gespeichert", color = NsAccent, fontFamily = Outfit, fontSize = 13.sp)
}
}
Button(
onClick = vm::save,
enabled = !state.isSaving,
modifier = Modifier.fillMaxWidth().height(48.dp),
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = NsBackground),
) {
if (state.isSaving) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = NsBackground)
} else {
Text("Speichern", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 15.sp)
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp), color = NsBorder)
OutlinedButton(
onClick = { showLogoutDialog = true },
modifier = Modifier.fillMaxWidth().height(48.dp),
border = ButtonDefaults.outlinedButtonBorder.copy(
brush = androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.error)
),
) {
Text("Abmelden", color = MaterialTheme.colorScheme.error,
fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
}
Spacer(Modifier.height(24.dp))
}
}
@Composable
private fun SectionHeader(title: String) {
Text(
text = title,
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 11.sp,
letterSpacing = 0.5.sp, color = NsOnSurface2,
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
)
HorizontalDivider(color = NsBorder)
Spacer(Modifier.height(4.dp))
}
@Composable
private fun NsTextField(
value: String,
onValueChange: (String) -> Unit,
label: String,
placeholder: String = "",
hint: String = "",
keyboardType: KeyboardType = KeyboardType.Text,
visualTransformation: VisualTransformation = VisualTransformation.None,
trailingIcon: (@Composable () -> Unit)? = null,
) {
Column {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label, fontFamily = Outfit) },
placeholder = if (placeholder.isNotBlank()) { { Text(placeholder, color = NsOnSurface2) } } else null,
singleLine = true,
visualTransformation = visualTransformation,
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
trailingIcon = trailingIcon,
modifier = Modifier.fillMaxWidth(),
colors = fieldColors(),
)
if (hint.isNotBlank()) {
Text(hint, fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2,
modifier = Modifier.padding(start = 4.dp, top = 2.dp))
}
}
}
@Composable
private fun ThemePicker(currentThemeId: String, onSelect: (String) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
NS_THEMES.forEach { theme ->
val isActive = theme.id == currentThemeId
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(9.dp))
.background(if (isActive) theme.surface else NsSurface)
.border(
width = if (isActive) 2.dp else 1.dp,
color = if (isActive) theme.accent else NsBorder,
shape = RoundedCornerShape(9.dp),
)
.clickable { onSelect(theme.id) }
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
// Accent dot
Box(
Modifier
.size(22.dp)
.clip(CircleShape)
.background(theme.accent),
)
Spacer(Modifier.width(12.dp))
Text(
text = theme.name,
fontFamily = Outfit,
fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal,
fontSize = 14.sp,
color = if (isActive) theme.onSurface else NsOnSurface2,
modifier = Modifier.weight(1f),
)
if (isActive) {
Icon(
Icons.Default.Check, null,
tint = theme.accent,
modifier = Modifier.size(18.dp),
)
}
}
}
}
}
@Composable
private fun fieldColors() = OutlinedTextFieldDefaults.colors(
focusedBorderColor = NsAccent,
focusedLabelColor = NsAccent,
cursorColor = NsAccent,
unfocusedBorderColor = NsOnSurface3,
unfocusedLabelColor = NsOnSurface2,
focusedTextColor = NsOnSurface,
unfocusedTextColor = NsOnSurface,
)
@@ -0,0 +1,124 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.novasonic.app.api.model.Song
import com.novasonic.app.app
import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.*
enum class StarredSort { NEWEST, OLDEST, AZ, BY_ARTIST }
@Composable
fun StarredScreen(
coverUrl: (String?) -> String?,
playerState: PlayerState,
onPlay: (List<Song>, Int) -> Unit,
onStarToggle: (Song) -> Unit,
onShowContextMenu: ((Song) -> Unit)? = null,
) {
val context = LocalContext.current
val repo = context.app.repository
var songs by remember { mutableStateOf<List<Song>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var sort by remember { mutableStateOf(StarredSort.NEWEST) }
LaunchedEffect(Unit) {
try {
songs = repo.getStarred().song
loading = false
} catch (e: Exception) {
error = e.message
loading = false
}
}
val sorted = remember(songs, sort) {
when (sort) {
StarredSort.NEWEST -> songs.sortedByDescending { it.starred ?: "" }
StarredSort.OLDEST -> songs.sortedBy { it.starred ?: "" }
StarredSort.AZ -> songs.sortedBy { it.title.lowercase() }
StarredSort.BY_ARTIST -> songs.sortedBy { "${it.artist?.lowercase()}${it.title.lowercase()}" }
}
}
Column(Modifier.fillMaxSize()) {
Text(
"Favoriten",
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 26.sp,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(start = 20.dp, top = 24.dp, bottom = 8.dp),
)
// Sort chips
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(start = 16.dp, bottom = 12.dp),
) {
StarredSort.entries.forEach { s ->
FilterChip(
selected = sort == s,
onClick = { sort = s },
label = {
Text(when (s) {
StarredSort.NEWEST -> "Neueste"
StarredSort.OLDEST -> "Älteste"
StarredSort.AZ -> "A-Z"
StarredSort.BY_ARTIST -> "Künstler"
}, fontSize = 12.sp)
},
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
selectedLabelColor = NsAccent,
selectedLeadingIconColor = NsAccent,
),
)
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
if (loading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
} else if (error != null) {
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Text(error!!, color = MaterialTheme.colorScheme.error)
}
} else if (sorted.isEmpty()) {
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Text("Noch keine Favoriten", color = NsOnSurface2)
}
} else {
LazyColumn {
itemsIndexed(sorted, key = { _, s -> s.id }) { idx, song ->
SongRow(
song = song,
coverUrl = coverUrl(song.coverArt),
isPlaying = playerState.currentSong?.id == song.id,
starred = song.starred != null,
onPlay = { onPlay(sorted, idx) },
onStarToggle = { onStarToggle(song) },
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
)
}
item { Spacer(Modifier.height(8.dp)) }
}
}
}
}
@@ -0,0 +1,100 @@
package com.novasonic.app.ui.theme
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
// ── Dynamic theme state — Compose tracks reads automatically ─────────────────
object NsColors {
var accent by mutableStateOf(Color(0xFF4F8EF7))
var accentDim by mutableStateOf(Color(0xFF2A6CD6))
var bg by mutableStateOf(Color(0xFF070D14))
var bg2 by mutableStateOf(Color(0xFF0C1520))
var surface by mutableStateOf(Color(0xFF182435))
var surface2 by mutableStateOf(Color(0xFF1F2F42))
var onSurface by mutableStateOf(Color(0xFFEDF2FF))
var onSurface2 by mutableStateOf(Color(0xFF7088AA))
var onSurface3 by mutableStateOf(Color(0xFF445566))
var border by mutableStateOf(Color(0x12FFFFFF))
var error by mutableStateOf(Color(0xFFCF6679))
}
// Convenience accessors — all reads in composables are state-tracked
val NsBackground get() = NsColors.bg
val NsBg2 get() = NsColors.bg2
val NsSurface get() = NsColors.surface
val NsSurface2 get() = NsColors.surface2
val NsAccent get() = NsColors.accent
val NsAccentDim get() = NsColors.accentDim
val NsOnSurface get() = NsColors.onSurface
val NsOnSurface2 get() = NsColors.onSurface2
val NsOnSurface3 get() = NsColors.onSurface3
val NsBorder get() = NsColors.border
val NsError get() = NsColors.error
// ── Theme definitions — mirrors src/themes.js ────────────────────────────────
data class NsTheme(
val id: String,
val name: String,
val accent: Color,
val accentDim: Color,
val bg: Color,
val bg2: Color,
val surface: Color,
val surface2: Color,
val onSurface: Color,
val onSurface2: Color,
val onSurface3: Color,
)
val NS_THEMES = listOf(
NsTheme(
"dark-ocean", "Dark Ocean",
Color(0xFF4F8EF7), Color(0xFF2A6CD6),
Color(0xFF070D14), Color(0xFF0C1520),
Color(0xFF182435), Color(0xFF1F2F42),
Color(0xFFEDF2FF), Color(0xFF7088AA), Color(0xFF445566),
),
NsTheme(
"dark-amber", "Dark Amber",
Color(0xFFE8A020), Color(0xFFC07010),
Color(0xFF07070F), Color(0xFF0D0D1A),
Color(0xFF1A1A2E), Color(0xFF22223A),
Color(0xFFF0EEFC), Color(0xFF8888AA), Color(0xFF555577),
),
NsTheme(
"forest", "Forest",
Color(0xFF4CAF7D), Color(0xFF2D7A52),
Color(0xFF060D08), Color(0xFF0B1410),
Color(0xFF162218), Color(0xFF1D2E20),
Color(0xFFEDFAF2), Color(0xFF7A9E85), Color(0xFF455C4A),
),
NsTheme(
"rose-pine", "Rosé Pine",
Color(0xFFEB6F92), Color(0xFFB8415E),
Color(0xFF191724), Color(0xFF1F1D2E),
Color(0xFF21202E), Color(0xFF403D52),
Color(0xFFE0DEF4), Color(0xFF908CAA), Color(0xFF6E6A86),
),
NsTheme(
"jade-green", "Jade",
Color(0xFF2ECC71), Color(0xFF1F9E55),
Color(0xFF07100C), Color(0xFF0C1813),
Color(0xFF162A21), Color(0xFF1E3A2D),
Color(0xFFE8FFF1), Color(0xFF8FBFA4), Color(0xFF557A63),
),
)
fun applyNsTheme(themeId: String) {
val t = NS_THEMES.find { it.id == themeId } ?: NS_THEMES.first()
NsColors.accent = t.accent
NsColors.accentDim = t.accentDim
NsColors.bg = t.bg
NsColors.bg2 = t.bg2
NsColors.surface = t.surface
NsColors.surface2 = t.surface2
NsColors.onSurface = t.onSurface
NsColors.onSurface2 = t.onSurface2
NsColors.onSurface3 = t.onSurface3
}
@@ -0,0 +1,29 @@
package com.novasonic.app.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
@Composable
fun NovaSonicTheme(content: @Composable () -> Unit) {
// Reads NsColors mutableState — recomposes automatically when theme changes
val colorScheme = darkColorScheme(
background = NsColors.bg,
surface = NsColors.surface,
surfaceVariant = NsColors.surface2,
primary = NsColors.accent,
onPrimary = NsColors.bg,
secondary = NsColors.accentDim,
onSecondary = NsColors.bg,
onBackground = NsColors.onSurface,
onSurface = NsColors.onSurface,
onSurfaceVariant = NsColors.onSurface2,
outline = NsColors.border,
error = NsColors.error,
)
MaterialTheme(
colorScheme = colorScheme,
typography = NovaSonicTypography,
content = content,
)
}
@@ -0,0 +1,47 @@
package com.novasonic.app.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.googlefonts.Font
import androidx.compose.ui.text.googlefonts.GoogleFont
import androidx.compose.ui.unit.sp
import com.novasonic.app.R
private val provider = GoogleFont.Provider(
providerAuthority = "com.google.android.gms.fonts",
providerPackage = "com.google.android.gms",
certificates = R.array.com_google_android_gms_fonts_certs,
)
private val OutfitFont = GoogleFont("Outfit")
private val JetBrainsMonoFont = GoogleFont("JetBrains Mono")
val Outfit = FontFamily(
Font(OutfitFont, provider, weight = androidx.compose.ui.text.font.FontWeight.Light),
Font(OutfitFont, provider, weight = androidx.compose.ui.text.font.FontWeight.Normal),
Font(OutfitFont, provider, weight = androidx.compose.ui.text.font.FontWeight.Medium),
Font(OutfitFont, provider, weight = androidx.compose.ui.text.font.FontWeight.SemiBold),
Font(OutfitFont, provider, weight = androidx.compose.ui.text.font.FontWeight.Bold),
Font(OutfitFont, provider, weight = androidx.compose.ui.text.font.FontWeight.ExtraBold),
)
val JetBrainsMono = FontFamily(
Font(JetBrainsMonoFont, provider, weight = androidx.compose.ui.text.font.FontWeight.Normal),
Font(JetBrainsMonoFont, provider, weight = androidx.compose.ui.text.font.FontWeight.Medium),
)
val NovaSonicTypography = Typography(
displayLarge = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 36.sp, letterSpacing = (-0.5).sp),
displayMedium = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 28.sp, letterSpacing = (-0.25).sp),
titleLarge = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 22.sp, letterSpacing = 0.sp),
titleMedium = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, letterSpacing = 0.15.sp),
titleSmall = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Medium, fontSize = 14.sp, letterSpacing = 0.1.sp),
bodyLarge = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Normal, fontSize = 16.sp, letterSpacing = 0.5.sp),
bodyMedium = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Normal, fontSize = 14.sp, letterSpacing = 0.25.sp),
bodySmall = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Normal, fontSize = 12.sp, letterSpacing = 0.4.sp),
labelLarge = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.Medium, fontSize = 14.sp, letterSpacing = 0.1.sp),
labelMedium = TextStyle(fontFamily = JetBrainsMono, fontWeight = FontWeight.Normal, fontSize = 12.sp, letterSpacing = 0.5.sp, color = NsOnSurface2),
labelSmall = TextStyle(fontFamily = JetBrainsMono, fontWeight = FontWeight.Normal, fontSize = 11.sp, letterSpacing = 0.5.sp, color = NsOnSurface2),
)
@@ -0,0 +1,42 @@
package com.novasonic.app.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.model.Album
import com.novasonic.app.app
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
data class AlbumUiState(
val album: Album? = null,
val isLoading: Boolean = true,
val error: String? = null,
)
class AlbumViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
private val repo = app.app.repository
private val albumId: String? = savedState["albumId"]
private val _state = MutableStateFlow(AlbumUiState())
val state: StateFlow<AlbumUiState> = _state
init {
val id = albumId
if (id.isNullOrBlank()) {
_state.value = AlbumUiState(isLoading = false, error = "Keine Album-ID übergeben")
} else {
viewModelScope.launch {
try {
val album = repo.getAlbum(id)
_state.value = AlbumUiState(album = album, isLoading = false)
} catch (e: Exception) {
_state.value = AlbumUiState(error = e.message ?: e.toString(), isLoading = false)
}
}
}
}
}
@@ -0,0 +1,246 @@
package com.novasonic.app.viewmodel
import android.app.Application
import android.media.MediaPlayer
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.ImporterClient
import com.novasonic.app.api.ItunesClient
import com.novasonic.app.api.model.Artist
import com.novasonic.app.api.model.ArtistInfo
import com.novasonic.app.api.model.DownloadRequest
import com.novasonic.app.api.model.ItunesItem
import com.novasonic.app.api.model.Song
import com.novasonic.app.app
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
data class ArtistUiState(
val artist: Artist? = null,
val info: ArtistInfo? = null,
val topSongs: List<Song> = emptyList(),
val isLoading: Boolean = true,
val error: String? = null,
// iTunes
val iAlbums: List<ItunesItem> = emptyList(),
val iTracks: List<ItunesItem> = emptyList(),
val itunesLoading: Boolean = false,
// Sub-album detail (tapped not-downloaded album)
val subAlbumItem: ItunesItem? = null,
val subAlbumTracks: List<ItunesItem>? = null,
val subAlbumTitle: String = "",
// Partial album detail (tapped incomplete album — shows downloaded + missing tracks)
val partialAlbum: PartialAlbumState? = null,
// Preview + download
val previewingId: Long? = null,
val downloadSheetItem: ItunesItem? = null,
)
class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
private val repo = app.app.repository
private val artistId: String? = savedState["artistId"]
private val _state = MutableStateFlow(ArtistUiState())
val state: StateFlow<ArtistUiState> = _state
private var mediaPlayer: MediaPlayer? = null
init {
if (artistId.isNullOrBlank()) {
_state.value = ArtistUiState(isLoading = false, error = "Keine Künstler-ID übergeben")
} else {
load()
}
}
fun load() {
val artistId = artistId ?: return
viewModelScope.launch {
_state.value = ArtistUiState(isLoading = true, itunesLoading = true)
try {
val artistDef = async { repo.getArtist(artistId) }
val infoDef = async { runCatching { repo.getArtistInfo(artistId) }.getOrNull() }
val artist = artistDef.await()
val info = infoDef.await()
val topDef = async { runCatching { repo.getTopSongs(artist.name) }.getOrDefault(emptyList()) }
val top = topDef.await()
_state.value = ArtistUiState(
artist = artist, info = info, topSongs = top,
isLoading = false, itunesLoading = true,
)
// Fetch iTunes data without blocking the main screen
launch { loadItunesData(artist.name) }
} catch (e: Exception) {
_state.value = ArtistUiState(isLoading = false, itunesLoading = false, error = e.message ?: e.toString())
}
}
}
private suspend fun loadItunesData(artistName: String) {
val searchResp = runCatching {
ItunesClient.search(artistName, entity = "musicArtist", limit = 5)
}.getOrNull()
val iArtistId = searchResp?.results
?.filter { it.wrapperType == "artist" }
?.find {
it.artistName?.equals(artistName, ignoreCase = true) == true ||
it.artistName?.contains(artistName, ignoreCase = true) == true
}
?.artistId
if (iArtistId != null) {
coroutineScope {
val iAlbumsD = async { runCatching { ItunesClient.lookupArtistAlbums(iArtistId) }.getOrDefault(emptyList()) }
val iTracksD = async { runCatching { ItunesClient.lookupArtistTopTracks(iArtistId, 10) }.getOrDefault(emptyList()) }
_state.value = _state.value.copy(
iAlbums = iAlbumsD.await(),
iTracks = iTracksD.await(),
itunesLoading = false,
)
}
} else {
_state.value = _state.value.copy(itunesLoading = false)
}
}
// ── Sub-album (not-downloaded iTunes album) ──────────────────────────────
fun loadSubAlbum(album: ItunesItem) {
val id = album.collectionId ?: return
_state.value = _state.value.copy(
subAlbumItem = album,
subAlbumTitle = album.collectionName ?: "",
subAlbumTracks = null,
)
viewModelScope.launch {
val tracks = runCatching { ItunesClient.lookupAlbum(id) }.getOrDefault(emptyList())
_state.value = _state.value.copy(subAlbumTracks = tracks)
}
}
fun dismissSubAlbum() {
_state.value = _state.value.copy(subAlbumItem = null, subAlbumTracks = null, subAlbumTitle = "")
}
// ── Partial album (incomplete album: shows downloaded + missing iTunes tracks) ──
fun loadPartialAlbum(nd: com.novasonic.app.api.model.Album, ia: ItunesItem) {
_state.value = _state.value.copy(
partialAlbum = PartialAlbumState(ndAlbum = nd, itunesAlbum = ia, isLoading = true),
)
viewModelScope.launch {
coroutineScope {
val ndSongsD = async { runCatching { repo.getAlbum(nd.id).song }.getOrDefault(emptyList()) }
val iTracksD = async {
ia.collectionId?.let { id ->
runCatching { ItunesClient.lookupAlbum(id) }.getOrDefault(emptyList())
} ?: emptyList()
}
_state.value = _state.value.copy(
partialAlbum = PartialAlbumState(
ndAlbum = nd,
itunesAlbum = ia,
ndSongs = ndSongsD.await(),
iTracks = iTracksD.await(),
isLoading = false,
),
)
}
}
}
fun dismissPartialAlbum() {
_state.value = _state.value.copy(partialAlbum = null)
}
// ── Download sheet ───────────────────────────────────────────────────────
fun showDownloadSheet(item: ItunesItem) {
_state.value = _state.value.copy(downloadSheetItem = item)
}
fun dismissDownloadSheet() {
_state.value = _state.value.copy(downloadSheetItem = null)
}
fun downloadItem(item: ItunesItem, type: String, onDone: (Boolean, String) -> Unit) {
viewModelScope.launch {
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
if (backendUrl.isBlank()) {
onDone(false, "Kein Backend konfiguriert — bitte in Einstellungen eintragen")
return@launch
}
val jobId = "job_${System.currentTimeMillis()}"
val artwork = item.artworkUrl100?.replace("100x100bb", "600x600bb")
val request = when (type) {
"album" -> DownloadRequest(
jobId = jobId, type = "album",
title = item.collectionName ?: item.artistName ?: "Unknown",
artist = item.artistName, artworkUrl = artwork,
collectionId = item.collectionId, itunesId = item.collectionId,
)
"artist" -> DownloadRequest(
jobId = jobId, type = "artist",
title = item.artistName ?: "Unknown",
artist = item.artistName, artworkUrl = artwork,
)
else -> DownloadRequest(
jobId = jobId, type = "track",
title = item.trackName ?: item.collectionName ?: "Unknown",
artist = item.artistName, album = item.collectionName,
artworkUrl = artwork, itunesId = item.trackId, collectionId = item.collectionId,
)
}
val client = ImporterClient(backendUrl)
runCatching { client.download(request) }
.onSuccess { onDone(true, "✓ Download gestartet") }
.onFailure { onDone(false, "Backend nicht erreichbar: ${it.message}") }
client.close()
}
}
// ── iTunes 30s preview ───────────────────────────────────────────────────
fun togglePreview(track: ItunesItem) {
val url = track.previewUrl ?: return
val id = track.trackId ?: return
if (_state.value.previewingId == id) stopPreview() else startPreview(url, id)
}
private fun startPreview(url: String, id: Long) {
stopPreview()
_state.value = _state.value.copy(previewingId = id)
viewModelScope.launch(Dispatchers.IO) {
try {
val mp = MediaPlayer().apply {
setDataSource(url)
prepare()
start()
setOnCompletionListener { _state.value = _state.value.copy(previewingId = null) }
}
mediaPlayer = mp
} catch (e: Exception) {
_state.value = _state.value.copy(previewingId = null)
}
}
}
fun stopPreview() {
mediaPlayer?.runCatching { stop(); release() }
mediaPlayer = null
_state.value = _state.value.copy(previewingId = null)
}
override fun onCleared() {
super.onCleared()
stopPreview()
}
}
@@ -0,0 +1,293 @@
package com.novasonic.app.viewmodel
import android.app.Application
import android.media.MediaPlayer
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.ImporterClient
import com.novasonic.app.api.ItunesClient
import com.novasonic.app.api.model.Album
import com.novasonic.app.api.model.DownloadRequest
import com.novasonic.app.api.model.ItunesItem
import com.novasonic.app.api.model.ItunesRssEntry
import com.novasonic.app.api.model.Song
import com.novasonic.app.api.model.artworkHd
import com.novasonic.app.app
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
enum class ExploreTab { LIBRARY, GLOBAL }
data class PartialAlbumState(
val ndAlbum: Album,
val itunesAlbum: ItunesItem,
val ndSongs: List<Song> = emptyList(),
val iTracks: List<ItunesItem> = emptyList(),
val isLoading: Boolean = true,
)
data class ItunesArtistState(
val artist: ItunesItem,
val isLoading: Boolean = true,
val ndAlbums: List<Album> = emptyList(),
val iAlbums: List<ItunesItem> = emptyList(),
val iTracks: List<ItunesItem> = emptyList(),
val topSongs: List<Song> = emptyList(),
val error: String? = null,
)
data class ExploreUiState(
val tab: ExploreTab = ExploreTab.LIBRARY,
val query: String = "",
// iTunes search results
val artists: List<ItunesItem> = emptyList(),
val albums: List<ItunesItem> = emptyList(),
val tracks: List<ItunesItem> = emptyList(),
// Album drill-down
val albumTracks: List<ItunesItem>? = null,
val albumTitle: String = "",
val albumItem: ItunesItem? = null,
// Artist detail (iTunes)
val artistDetail: ItunesArtistState? = null,
// Partial album detail (opened from artist's "Unvollständig" section)
val partialAlbum: PartialAlbumState? = null,
// Download sheet
val downloadSheetItem: ItunesItem? = null,
// Charts (shown when query is empty in GLOBAL tab)
val charts: List<ItunesRssEntry> = emptyList(),
// Loading states
val isSearching: Boolean = false,
val chartsLoading: Boolean = false,
// Preview
val previewingId: Long? = null, // trackId currently playing preview
val error: String? = null,
)
class ExploreViewModel(app: Application) : AndroidViewModel(app) {
private val repo = app.app.repository
private val _state = MutableStateFlow(ExploreUiState(chartsLoading = true))
val state: StateFlow<ExploreUiState> = _state
private var searchJob: Job? = null
private var mediaPlayer: MediaPlayer? = null
init { loadCharts() }
fun selectTab(tab: ExploreTab) {
_state.value = _state.value.copy(tab = tab, albumTracks = null)
}
fun onQueryChange(q: String) {
_state.value = _state.value.copy(query = q, albumTracks = null)
searchJob?.cancel()
if (q.isBlank()) {
_state.value = _state.value.copy(artists = emptyList(), albums = emptyList(), tracks = emptyList())
return
}
searchJob = viewModelScope.launch {
delay(350)
search(q)
}
}
private fun search(q: String) {
viewModelScope.launch {
_state.value = _state.value.copy(isSearching = true, error = null)
try {
val resp = ItunesClient.search(q)
val results = resp.results
_state.value = _state.value.copy(
artists = results.filter { it.wrapperType == "artist" },
albums = results.filter { it.wrapperType == "collection" },
tracks = results.filter { it.wrapperType == "track" && it.kind == "song" },
isSearching = false,
)
} catch (e: Exception) {
_state.value = _state.value.copy(isSearching = false, error = e.message)
}
}
}
fun loadAlbum(album: ItunesItem) {
val id = album.collectionId ?: return
viewModelScope.launch {
_state.value = _state.value.copy(isSearching = true, albumTracks = null,
albumTitle = album.collectionName ?: "", albumItem = album)
try {
val tracks = ItunesClient.lookupAlbum(id)
_state.value = _state.value.copy(albumTracks = tracks, isSearching = false)
} catch (e: Exception) {
_state.value = _state.value.copy(isSearching = false, error = e.message)
}
}
}
fun dismissAlbum() {
_state.value = _state.value.copy(albumTracks = null, albumItem = null)
}
fun openArtistDetail(artist: ItunesItem) {
_state.value = _state.value.copy(
artistDetail = ItunesArtistState(artist = artist, isLoading = true),
)
viewModelScope.launch {
val artistId = artist.artistId
val artistName = artist.artistName ?: return@launch
val ndAlbumsD: Deferred<List<Album>> = async { runCatching { repo.getNdAlbumsForArtist(artistName) }.getOrDefault(emptyList()) }
val topSongsD: Deferred<List<Song>> = async { runCatching { repo.getTopSongs(artistName) }.getOrDefault(emptyList()) }
val iAlbumsD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistAlbums(artistId) }.getOrDefault(emptyList()) else emptyList() }
val iTracksD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistTopTracks(artistId, 10) }.getOrDefault(emptyList()) else emptyList() }
_state.value = _state.value.copy(
artistDetail = ItunesArtistState(
artist = artist,
isLoading = false,
ndAlbums = ndAlbumsD.await(),
iAlbums = iAlbumsD.await(),
iTracks = iTracksD.await(),
topSongs = topSongsD.await(),
),
)
}
}
fun closeArtistDetail() {
_state.value = _state.value.copy(artistDetail = null, partialAlbum = null)
}
fun loadPartialAlbum(nd: Album, ia: ItunesItem) {
_state.value = _state.value.copy(
partialAlbum = PartialAlbumState(ndAlbum = nd, itunesAlbum = ia, isLoading = true),
)
viewModelScope.launch {
coroutineScope {
val ndSongsD = async { runCatching { repo.getAlbum(nd.id).song }.getOrDefault(emptyList()) }
val iTracksD = async {
ia.collectionId?.let { id ->
runCatching { ItunesClient.lookupAlbum(id) }.getOrDefault(emptyList())
} ?: emptyList()
}
_state.value = _state.value.copy(
partialAlbum = PartialAlbumState(
ndAlbum = nd,
itunesAlbum = ia,
ndSongs = ndSongsD.await(),
iTracks = iTracksD.await(),
isLoading = false,
),
)
}
}
}
fun dismissPartialAlbum() {
_state.value = _state.value.copy(partialAlbum = null)
}
fun showDownloadSheet(item: ItunesItem) {
_state.value = _state.value.copy(downloadSheetItem = item)
}
fun dismissDownloadSheet() {
_state.value = _state.value.copy(downloadSheetItem = null)
}
fun downloadItem(item: ItunesItem, type: String, onDone: (Boolean, String) -> Unit) {
viewModelScope.launch {
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
if (backendUrl.isBlank()) {
onDone(false, "Kein Backend konfiguriert — bitte in Einstellungen eintragen")
return@launch
}
val jobId = "job_${System.currentTimeMillis()}"
val artwork = item.artworkUrl100?.replace("100x100bb", "600x600bb")
val request = when (type) {
"album" -> DownloadRequest(
jobId = jobId, type = "album",
title = item.collectionName ?: item.artistName ?: "Unknown",
artist = item.artistName,
artworkUrl = artwork,
collectionId = item.collectionId,
itunesId = item.collectionId,
)
"artist" -> DownloadRequest(
jobId = jobId, type = "artist",
title = item.artistName ?: "Unknown",
artist = item.artistName,
artworkUrl = artwork,
)
else -> DownloadRequest(
jobId = jobId, type = "track",
title = item.trackName ?: item.collectionName ?: "Unknown",
artist = item.artistName,
album = item.collectionName,
artworkUrl = artwork,
itunesId = item.trackId,
collectionId = item.collectionId,
)
}
val client = ImporterClient(backendUrl)
runCatching { client.download(request) }
.onSuccess { onDone(true, "✓ Download gestartet") }
.onFailure { onDone(false, "Backend nicht erreichbar: ${it.message}") }
client.close()
}
}
private fun loadCharts() {
viewModelScope.launch {
try {
val entries = ItunesClient.topAlbums()
_state.value = _state.value.copy(charts = entries, chartsLoading = false)
} catch (e: Exception) {
_state.value = _state.value.copy(chartsLoading = false)
}
}
}
// ── 30s preview ─────────────────────────────────────────────────────────
fun togglePreview(track: ItunesItem) {
val url = track.previewUrl ?: return
val id = track.trackId ?: return
val cur = _state.value.previewingId
if (cur == id) {
stopPreview()
} else {
startPreview(url, id)
}
}
private fun startPreview(url: String, id: Long) {
stopPreview()
_state.value = _state.value.copy(previewingId = id)
viewModelScope.launch(Dispatchers.IO) {
try {
val mp = MediaPlayer().apply {
setDataSource(url)
prepare()
start()
setOnCompletionListener { _state.value = _state.value.copy(previewingId = null) }
}
mediaPlayer = mp
} catch (e: Exception) {
_state.value = _state.value.copy(previewingId = null)
}
}
}
fun stopPreview() {
mediaPlayer?.runCatching { stop(); release() }
mediaPlayer = null
_state.value = _state.value.copy(previewingId = null)
}
override fun onCleared() {
super.onCleared()
stopPreview()
}
}
@@ -0,0 +1,73 @@
package com.novasonic.app.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.model.Album
import com.novasonic.app.api.model.Genre
import com.novasonic.app.app
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
data class HomeUiState(
val heroAlbum: Album? = null,
val recentAlbums: List<Album> = emptyList(),
val frequentAlbums: List<Album> = emptyList(),
val randomAlbums: List<Album> = emptyList(),
val genres: List<Genre> = emptyList(),
val username: String = "",
val isLoading: Boolean = false,
val error: String? = null,
)
class HomeViewModel(app: Application) : AndroidViewModel(app) {
private val repo = app.app.repository
private val _state = MutableStateFlow(HomeUiState(isLoading = true))
val state: StateFlow<HomeUiState> = _state
init {
viewModelScope.launch {
repo.appSettingsFlow().collect { s ->
_state.value = _state.value.copy(username = s.username)
}
}
load()
}
fun load() {
viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
val recentDef = async { repo.getRecentAlbums(20) }
val frequentDef = async { repo.getFrequentAlbums(20) }
val randomDef = async { repo.getRandomAlbums(20) }
val genresDef = async { runCatching { repo.getGenres() }.getOrDefault(emptyList()) }
val recent = recentDef.await()
val frequent = frequentDef.await()
val random = randomDef.await()
val genres = genresDef.await()
// Hero rotates between first recent and first frequent album
val hero = if (System.currentTimeMillis() % 2 == 0L) recent.firstOrNull()
else frequent.firstOrNull() ?: recent.firstOrNull()
_state.value = _state.value.copy(
heroAlbum = hero,
recentAlbums = recent,
frequentAlbums = frequent,
randomAlbums = random,
genres = genres.filter { it.songCount > 0 }.take(12),
isLoading = false,
error = null,
)
} catch (e: Exception) {
_state.value = HomeUiState(error = e.message)
}
}
}
}
@@ -0,0 +1,150 @@
package com.novasonic.app.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.ImporterClient
import com.novasonic.app.api.model.DownloadRequest
import com.novasonic.app.api.model.ImportResolveResponse
import com.novasonic.app.app
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
data class ImporterUiState(
val backendUrl: String = "",
val link: String = "",
val platform: String? = null, // spotify | soundcloud | youtube
val isResolving: Boolean = false,
val resolved: ImportResolveResponse? = null,
val error: String? = null,
// Download job
val isDownloading: Boolean = false,
val jobStatus: String? = null, // pending | downloading | done | error
val jobMessage: String? = null,
val jobCompleted: Int = 0,
val jobTotal: Int = 0,
)
class ImporterViewModel(app: Application) : AndroidViewModel(app) {
private val repo = app.app.repository
private val _state = MutableStateFlow(ImporterUiState())
val state: StateFlow<ImporterUiState> = _state
init {
viewModelScope.launch {
repo.appSettingsFlow().collect { s ->
_state.value = _state.value.copy(backendUrl = s.backendUrl)
}
}
}
fun onLink(v: String) {
_state.value = _state.value.copy(
link = v,
platform = ImporterClient.platformFor(v.trim()),
resolved = null,
error = null,
)
}
fun resolve() {
val s = _state.value
val url = s.link.trim()
if (url.isBlank()) return
if (s.backendUrl.isBlank()) {
_state.value = s.copy(error = "Backend-URL in Einstellungen konfigurieren")
return
}
val platform = ImporterClient.platformFor(url)
if (platform == null) {
_state.value = s.copy(error = "Nicht unterstützter Link. Spotify, YouTube oder SoundCloud einfügen.")
return
}
viewModelScope.launch {
_state.value = _state.value.copy(isResolving = true, error = null, resolved = null)
val client = ImporterClient(s.backendUrl)
try {
val resp = client.resolve(platform, url)
if (resp.error != null) {
_state.value = _state.value.copy(isResolving = false, error = mapError(resp.error))
} else {
_state.value = _state.value.copy(isResolving = false, resolved = resp)
}
} catch (e: Exception) {
_state.value = _state.value.copy(
isResolving = false,
error = "Backend nicht erreichbar: ${e.message}",
)
} finally {
client.close()
}
}
}
fun downloadAll() {
val s = _state.value
val resolved = s.resolved ?: return
if (s.backendUrl.isBlank() || resolved.tracks.isEmpty()) return
val jobId = "job_${System.currentTimeMillis()}"
viewModelScope.launch {
_state.value = _state.value.copy(
isDownloading = true, jobStatus = "pending", jobMessage = null,
jobCompleted = 0, jobTotal = resolved.tracks.size,
)
val client = ImporterClient(s.backendUrl)
try {
client.download(
DownloadRequest(
jobId = jobId,
type = if (resolved.type == "track") "track" else "playlist",
title = resolved.name ?: "Import",
artist = resolved.artist,
artworkUrl = resolved.artworkUrl,
tracks = resolved.tracks,
)
)
pollJob(client, jobId)
} catch (e: Exception) {
_state.value = _state.value.copy(
isDownloading = false, jobStatus = "error",
jobMessage = "Download fehlgeschlagen: ${e.message}",
)
client.close()
}
}
}
private suspend fun pollJob(client: ImporterClient, jobId: String) {
try {
repeat(600) { // up to ~20 min at 2s interval
delay(2000)
val st = runCatching { client.status(jobId) }.getOrNull() ?: return@repeat
_state.value = _state.value.copy(
jobStatus = st.status,
jobMessage = st.message,
jobCompleted = st.completed ?: _state.value.jobCompleted,
jobTotal = st.total ?: _state.value.jobTotal,
)
if (st.status == "done" || st.status == "error") {
_state.value = _state.value.copy(isDownloading = false)
return
}
}
_state.value = _state.value.copy(isDownloading = false)
} finally {
client.close()
}
}
private fun mapError(code: String): String = when (code) {
"EDITORIAL_PLAYLIST" ->
"Editorial Playlist: Spotify sperrt eigene kuratierte Playlists. Kopiere sie in eine eigene Playlist und importiere diese."
"RATE_LIMITED" -> "Spotify Rate-Limit erreicht — bitte kurz warten."
else -> code
}
}
@@ -0,0 +1,72 @@
package com.novasonic.app.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.model.Album
import com.novasonic.app.api.model.ArtistIndex
import com.novasonic.app.api.model.Playlist
import com.novasonic.app.app
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
enum class LibraryTab { PLAYLISTS, ALBUMS, ARTISTS }
data class LibraryUiState(
val tab: LibraryTab = LibraryTab.PLAYLISTS,
val albums: List<Album> = emptyList(),
val artistIndexes: List<ArtistIndex> = emptyList(),
val playlists: List<Playlist> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
)
class LibraryViewModel(app: Application) : AndroidViewModel(app) {
private val repo = app.app.repository
private val _state = MutableStateFlow(LibraryUiState(isLoading = true))
val state: StateFlow<LibraryUiState> = _state
init { loadPlaylists() }
fun selectTab(tab: LibraryTab) {
_state.value = _state.value.copy(tab = tab)
when (tab) {
LibraryTab.ALBUMS -> if (_state.value.albums.isEmpty()) loadAlbums()
LibraryTab.ARTISTS -> if (_state.value.artistIndexes.isEmpty()) loadArtists()
LibraryTab.PLAYLISTS -> if (_state.value.playlists.isEmpty()) loadPlaylists()
}
}
private fun loadAlbums() = viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
val albums = repo.getAlphaAlbums(200)
_state.value = _state.value.copy(albums = albums, isLoading = false)
} catch (e: Exception) {
_state.value = _state.value.copy(error = e.message, isLoading = false)
}
}
private fun loadArtists() = viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
val indexes = repo.getArtists()
_state.value = _state.value.copy(artistIndexes = indexes, isLoading = false)
} catch (e: Exception) {
_state.value = _state.value.copy(error = e.message, isLoading = false)
}
}
private fun loadPlaylists() = viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
val pls = repo.getPlaylists()
_state.value = _state.value.copy(playlists = pls, isLoading = false)
} catch (e: Exception) {
_state.value = _state.value.copy(error = e.message, isLoading = false)
}
}
}
@@ -0,0 +1,44 @@
package com.novasonic.app.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.SubsonicClient
import com.novasonic.app.app
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
data class LoginUiState(
val isLoading: Boolean = false,
val success: Boolean = false,
val error: String? = null,
)
class LoginViewModel(app: Application) : AndroidViewModel(app) {
private val repo = app.app.repository
private val _state = MutableStateFlow(LoginUiState())
val state: StateFlow<LoginUiState> = _state
fun login(serverUrl: String, username: String, password: String) {
viewModelScope.launch {
_state.value = LoginUiState(isLoading = true)
try {
val client = SubsonicClient(serverUrl, username, password)
client.ping()
client.close()
repo.saveCredentials(serverUrl, username, password)
_state.value = LoginUiState(success = true)
} catch (e: Exception) {
val msg = when {
e.message?.contains("Unable to resolve host") == true -> "Cannot reach server. Check the URL."
e.message?.contains("40") == true -> "Wrong username or password."
else -> e.message ?: "Connection failed."
}
_state.value = LoginUiState(error = msg)
}
}
}
}
@@ -0,0 +1,53 @@
package com.novasonic.app.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.LrcLibClient
import com.novasonic.app.api.LrcLine
import com.novasonic.app.api.parseLrc
import com.novasonic.app.api.model.Song
import com.novasonic.app.app
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
data class LyricsUiState(
val lines: List<LrcLine> = emptyList(), // non-empty = synced
val plainText: String = "", // fallback plain lyrics
val isLoading: Boolean = false,
val notFound: Boolean = false,
val songId: String = "", // which song these are for
)
class LyricsViewModel(app: Application) : AndroidViewModel(app) {
private val _state = MutableStateFlow(LyricsUiState())
val state: StateFlow<LyricsUiState> = _state
fun loadForSong(song: Song) {
if (_state.value.songId == song.id && !_state.value.isLoading) return
_state.value = LyricsUiState(isLoading = true, songId = song.id)
viewModelScope.launch {
val artist = song.artist ?: ""
val title = song.title
val album = song.album
val durSecs = song.duration
val result = LrcLibClient.getLyrics(artist, title, album, durSecs)
when {
result == null -> _state.value = LyricsUiState(
notFound = true, songId = song.id)
result.instrumental -> _state.value = LyricsUiState(
plainText = "🎵 Instrumentalstück", songId = song.id)
!result.syncedLyrics.isNullOrBlank() -> _state.value = LyricsUiState(
lines = parseLrc(result.syncedLyrics), songId = song.id)
!result.plainLyrics.isNullOrBlank() -> _state.value = LyricsUiState(
plainText = result.plainLyrics, songId = song.id)
else -> _state.value = LyricsUiState(
notFound = true, songId = song.id)
}
}
}
}
@@ -0,0 +1,42 @@
package com.novasonic.app.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.model.PlaylistWithSongs
import com.novasonic.app.app
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
data class PlaylistUiState(
val playlist: PlaylistWithSongs? = null,
val isLoading: Boolean = true,
val error: String? = null,
)
class PlaylistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
private val repo = app.app.repository
private val playlistId: String? = savedState["playlistId"]
private val _state = MutableStateFlow(PlaylistUiState())
val state: StateFlow<PlaylistUiState> = _state
init {
val id = playlistId
if (id.isNullOrBlank()) {
_state.value = PlaylistUiState(isLoading = false, error = "Keine Playlist-ID übergeben")
} else {
viewModelScope.launch {
try {
val pl = repo.getPlaylist(id)
_state.value = PlaylistUiState(playlist = pl, isLoading = false)
} catch (e: Exception) {
_state.value = PlaylistUiState(isLoading = false, error = e.message ?: e.toString())
}
}
}
}
}
@@ -0,0 +1,50 @@
package com.novasonic.app.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.model.SearchResult3
import com.novasonic.app.app
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
data class SearchUiState(
val query: String = "",
val searchResult: SearchResult3? = null,
val isLoading: Boolean = false,
val error: String? = null,
)
class SearchViewModel(app: Application) : AndroidViewModel(app) {
private val repo = app.app.repository
private val _state = MutableStateFlow(SearchUiState())
val state: StateFlow<SearchUiState> = _state
private var searchJob: Job? = null
fun onQueryChange(q: String) = onSearch(q)
fun onSearch(q: String) {
_state.value = _state.value.copy(query = q)
searchJob?.cancel()
if (q.isBlank()) {
_state.value = _state.value.copy(searchResult = null, isLoading = false)
return
}
searchJob = viewModelScope.launch {
delay(300)
_state.value = _state.value.copy(isLoading = true, error = null)
try {
val results = repo.search(q)
_state.value = _state.value.copy(searchResult = results, isLoading = false)
} catch (e: Exception) {
_state.value = _state.value.copy(error = e.message, isLoading = false)
}
}
}
}
@@ -0,0 +1,96 @@
package com.novasonic.app.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.novasonic.app.app
import com.novasonic.app.data.PrefsStore
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
data class SettingsUiState(
val serverUrl: String = "",
val username: String = "",
val password: String = "",
val lastFmKey: String = "",
val backendUrl: String = "",
val scrobbleMode: String = "30s",
val showPlayCount: Boolean = false,
val theme: String = "dark-ocean",
val isSaving: Boolean = false,
val savedOk: Boolean = false,
val error: String? = null,
)
class SettingsViewModel(app: Application) : AndroidViewModel(app) {
private val repo = app.app.repository
private val _state = MutableStateFlow(SettingsUiState())
val state: StateFlow<SettingsUiState> = _state
init {
viewModelScope.launch {
repo.appSettingsFlow().collect { s ->
_state.value = _state.value.copy(
serverUrl = s.serverUrl,
username = s.username,
password = s.password,
lastFmKey = s.lastFmKey,
backendUrl = s.backendUrl,
scrobbleMode = s.scrobbleMode,
showPlayCount = s.showPlayCount,
theme = s.theme,
)
}
}
}
fun onServerUrl(v: String) = update { it.copy(serverUrl = v, savedOk = false) }
fun onUsername(v: String) = update { it.copy(username = v, savedOk = false) }
fun onPassword(v: String) = update { it.copy(password = v, savedOk = false) }
fun onLastFmKey(v: String) = update { it.copy(lastFmKey = v, savedOk = false) }
fun onBackendUrl(v: String) = update { it.copy(backendUrl = v, savedOk = false) }
fun onScrobbleMode(v: String) = update { it.copy(scrobbleMode = v, savedOk = false) }
fun onShowPlayCount(v: Boolean) = update { it.copy(showPlayCount = v, savedOk = false) }
fun onTheme(id: String) {
update { it.copy(theme = id) }
viewModelScope.launch { repo.saveTheme(id) }
}
fun save() {
val s = _state.value
if (s.serverUrl.isBlank() || s.username.isBlank() || s.password.isBlank()) {
_state.value = s.copy(error = "Server, Benutzername und Passwort sind Pflichtfelder")
return
}
viewModelScope.launch {
_state.value = s.copy(isSaving = true, error = null)
try {
repo.saveAppSettings(PrefsStore.AppSettings(
serverUrl = s.serverUrl,
username = s.username,
password = s.password,
lastFmKey = s.lastFmKey.trim(),
backendUrl = s.backendUrl.trimEnd('/'),
scrobbleMode = s.scrobbleMode,
showPlayCount = s.showPlayCount,
theme = s.theme,
))
_state.value = _state.value.copy(isSaving = false, savedOk = true)
} catch (e: Exception) {
_state.value = _state.value.copy(isSaving = false, error = e.message)
}
}
}
fun logout() {
viewModelScope.launch { repo.saveCredentials("", "", "") }
}
private inline fun update(block: (SettingsUiState) -> SettingsUiState) {
_state.value = block(_state.value)
}
}
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#000000">
<WebView
android:id="@+id/web_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<array name="com_google_android_gms_fonts_certs">
<item>@array/com_google_android_gms_fonts_certs_dev</item>
<item>@array/com_google_android_gms_fonts_certs_prod</item>
</array>
<string-array name="com_google_android_gms_fonts_certs_dev">
<item>
MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNU2FuIEZyYW5jaXNjbzETMBEGA1UEChMKR29vZ2xlIEluYzEUMBIGA1UECxMLRW5naW5lZXJpbmcxFTATBgNVBAMTDEdvb2dsZSBJbmMuMB4XDTE4MDMwODEzMDMyMloXDTE4MDQwNjEzMDMyMlowLDEqMCgGA1UEAxMhYW5kcm9pZC5nb29nbGVhcGlzLmNvbS9mb250c2RldjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMNvRiimO7gVMf+m37hgKFJMQp1RnDpq7Q1JBSZ8x8SQGQ1bNKGMNM/xoRg8xCLB3m0rvFCKhRAfgHpCb5EBKjt6Z3vQQ2jLAGbbMxz5zR/RbkVH7hSHXfJFz5Dq4TZyPlCAhTJJRMaqUiS24hV0Y7wQzSa8xCCn0dTnX9c0FUoBh0JVAc4FnGAR6RNGkPfyLH3oBd7y1t1Q0CeX9e6uH7LN8W8DtXEQMzFSBVQtMHoqN/Jlz1xXVSHHCpE7Vb6YYJpWkSmyAOjkmJz3n/PpCeHqAXS5VKkGUuOq6bI10eMIg6aXyMODuDzBnIRX0mH8eqq8Ygr5VCFd3xLNkCAwEAAaOCAT0wggE5MB0GA1UdDgQWBBRjyGPj6t3/2XCfVOtpO/ZTPFP5TDAfBgNVHSMEGDAWgBRjyGPj6t3/2XCfVOtpO/ZTPFP5TDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDAzCBmgYDVR0fBIGSMIGPMIGMoIGJoIGGhoGDaHR0cDovL2NybC5nb29nbGUuY29tL3BraS9jcmxzL2dvb2dsZS1hcGlzL3lvdXItdGVzdC1jZXJ0LmNybD9zdGF0ZT1sZjlwT1Bkb3ZROElBWkFCb1RwTFIzSmxheTVOdUlZMHQwdlh6SVBsbERqWEZxMlRjZVBacmFVWmZ0VW9UcmNNa3dDQStlV2s9MA0GCSqGSIb3DQEBBAUAA4IBAQBP0msbfJhgDBkqPH7u6PQqO9bCNPbTxYbclVTQW1LB9aZpqbFLTIzORnU3lSMi9DQ62pR2jCYkmYVzBDxJxoVA8BFQxmFnl+2J+nxHoMH/mJVqdKCq1T7s9N0Q7BtLv3yDpK4kHuv8GZ5R5tFKgf6lv+UrO8gNTF3F5JNVCMJkWNbPNRbSifL4hNnPmH4T7J2t3T/fjzDSi1oiB4N5bCmRFMSY7gNjy+MJQlbRFzGPqRQrJ2F4eaQr8TcpLhw0Q3bHOsPUbHVfGmMRWKq7MkJj0+JrRVWMT7paxRiCGxAVPyJb1dDjqxVE0wlT/3y9VcwrRR3l7KMGJqY1jcobMHs+
</item>
</string-array>
<string-array name="com_google_android_gms_fonts_certs_prod">
<item>
MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFBYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFBYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBAOKq8yXC9JQZN5WLKUOA1k+WJblR2B65UeJCcjzXBTIPnCDRuNilzLdp6PJH9ECbgGZ4MdOW7U0H8sR2WmqnWK4CZpqJQhW5MbMFX8rNRQLVhF7J9Bxao9D0xWGiIB97+gJHFyxnN79V+6z9c5jzlpO4oX4Jv5b3MuH6fXIPxd3MoNjk1D4JHg7LS5A/0VR5vqST4UrQEv0LNPrXm8E7IM4a2ioIgFo0+vwMi0xdKuY4ByxL8Lx2b0G5tU7hXm2OT0n8wQRpanrr1WNSxflG+T17vBCEfW4lKjTFW5JLNfNT/cEnSfNXF48c3lIgFHH+OPY+mDuCx+BVVjCqUCAwEAAaOBsDCBrTAdBgNVHQ4EFgQUO3Shgt4KWNZWZS8/sVDVZVdNEwQwfQYDVR0jBHYwdIAUO3Shgt4KWNZWZS8/sVDVZVdNEwShWKRWMFQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFBYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjCBgAYDVR0fBHkwdzB1oHOgcYZvaHR0cDovL2NybC5wa2kuZ29vZ2xlYXBpcy5jb20vQ1JML0NBL2E2Mzk5ZjNiNWQxZGFhYzc2MDkzYzM2NzMzNGE1YmMzNjFhNzZhNTcxNGJiZjkwNGRlMjBlNGFjZDBkN2UzMi5jcmwwDQYJKoZIhvcNAQEEBQADggEBADjY8KJhGDBQtFOq1yWNakLe+e8u5vDOCpI2oLFbhfanIhN3nEBoAyqXTDFiD8Qj65xEAyQNiJQ5P3LTBrBr1m3cMdz6YPKX7EqPTjhMGJp9NvnLjzh2IFlv0k4JpNMHfFNz7C3YPHiqBF5aZSKhcKzU5KPk/E7q5jWEKt5XHHS4JrsPVDiGe5dpMQxu2RaEzh+yJfRnEKBa7wLhTb5gkWsE3WNJGkFQ1SioBjcTGW6LfZLl5JOrmMnEv/9hpz31E6wTVVSHXX9yiRFCqRNjI1TSk0u3N1h6wDe/7NwAJG+ZGgQ1I1pXzrr/aBpK/Z3e5C9I2HupPm/zVJyZpBU=
</item>
</string-array>
</resources>
+5 -2
View File
@@ -1,4 +1,7 @@
plugins { plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.ksp) apply false
} }
+75 -17
View File
@@ -1,22 +1,80 @@
[versions] [versions]
agp = "8.13.2" agp = "8.13.2"
kotlin = "1.9.23" kotlin = "2.1.20"
coreKtx = "1.13.1" composeBom = "2024.12.01"
appcompat = "1.7.0" coreKtx = "1.13.1"
material = "1.12.0" appcompat = "1.7.0"
media3 = "1.4.1" activityCompose = "1.9.3"
coroutines = "1.8.0" navigationCompose = "2.8.3"
lifecycleVm = "2.8.7"
media3 = "1.4.1"
ktor = "2.3.12"
kotlinxSerialization = "1.7.3"
kotlinxCoroutines = "1.9.0"
room = "2.6.1"
datastore = "1.1.1"
coil = "2.7.0"
splashscreen = "1.0.1"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } # AndroidX core
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" } androidx-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" }
media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "media3" }
media3-datasource = { group = "androidx.media3", name = "media3-datasource", version.ref = "media3" } # Compose BOM + core
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
compose-icons = { group = "androidx.compose.material", name = "material-icons-extended" }
# Navigation
navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
# Lifecycle / ViewModel
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleVm" }
lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleVm" }
# Media3
media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" }
media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "media3" }
media3-datasource = { group = "androidx.media3", name = "media3-datasource", version.ref = "media3" }
media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
# Ktor (HTTP client)
ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
ktor-client-android = { group = "io.ktor", name = "ktor-client-android", version.ref = "ktor" }
ktor-client-content-neg = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" }
# Kotlinx
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
kotlin-reflect = { group = "org.jetbrains.kotlin", name = "kotlin-reflect", version.ref = "kotlin" }
# Room
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
# DataStore (settings / credentials)
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
# Coil (image loading)
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
# Google Fonts (Outfit)
compose-ui-google-fonts = { group = "androidx.compose.ui", name = "ui-text-google-fonts" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version = "2.1.20-1.0.32" }
Binary file not shown.
+251
View File
@@ -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" "$@"
+94
View File
@@ -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
View File
@@ -186,6 +186,26 @@
<div id="itunes-album-songs"></div> <div id="itunes-album-songs"></div>
</div> </div>
<!-- PARTIAL ALBUM DETAIL (incomplete album: downloaded + missing tracks) -->
<div class="page" id="page-partial-album-detail">
<button class="back-btn" id="partial-album-back-btn"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>Zurück</button>
<div class="album-header">
<div class="album-cover" id="partial-album-cover-wrap"><div class="no-art">💿</div></div>
<div class="album-meta">
<div class="album-type">Unvollständig</div>
<div class="album-name" id="partial-album-detail-name">—</div>
<div class="album-artist-name" id="partial-album-detail-artist"></div>
<div class="album-stats" id="partial-album-detail-stats"></div>
<div class="album-actions">
<button class="btn-play-all" id="partial-album-play-btn"><svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>Abspielen</button>
<button class="btn-outline" id="partial-album-dl-btn"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Fehlende laden</button>
</div>
</div>
</div>
<div id="partial-album-downloaded"></div>
<div id="partial-album-missing"></div>
</div>
<!-- ALBUMS --> <!-- ALBUMS -->
<div class="page" id="page-albums"> <div class="page" id="page-albums">
<div class="page-header"><div class="page-title">Alben</div></div> <div class="page-header"><div class="page-title">Alben</div></div>
@@ -1032,7 +1052,7 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
</button> </button>
<div class="np-tabs"> <div class="np-tabs">
<button class="np-tab active" id="np-tab-player" onclick="switchNpTab('player')">Jetzt läuft</button> <button class="np-tab active" id="np-tab-player" onclick="switchNpTab('player')">Player</button>
<button class="np-tab" id="np-tab-lyrics" onclick="switchNpTab('lyrics')">Lyrics</button> <button class="np-tab" id="np-tab-lyrics" onclick="switchNpTab('lyrics')">Lyrics</button>
<button class="np-tab" id="np-tab-queue" onclick="switchNpTab('queue')">Queue</button> <button class="np-tab" id="np-tab-queue" onclick="switchNpTab('queue')">Queue</button>
</div> </div>
-5
View File
@@ -1,5 +0,0 @@
{
"enabledPlugins": {
"expo@claude-plugins-official": true
}
}
-12
View File
@@ -1,12 +0,0 @@
node_modules/
.expo/
dist/
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
android/
ios/
-3
View File
@@ -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
View File
@@ -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
View File
@@ -1 +0,0 @@
@AGENTS.md
-21
View File
@@ -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.
-40
View File
@@ -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

-6
View File
@@ -1,6 +0,0 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
};
};
-17
View File
@@ -1,17 +0,0 @@
{
"cli": {
"version": ">= 16.0.0"
},
"build": {
"preview": {
"android": {
"buildType": "apk"
}
},
"production": {
"android": {
"buildType": "app-bundle"
}
}
}
}
-9
View File
@@ -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);
-6530
View File
File diff suppressed because it is too large. Load diff
-35
View File
@@ -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"
}
}
-217
View File
@@ -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 || [],
};
}
-42
View File
@@ -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')}`;
}
-79
View File
@@ -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);
}
-16
View File
@@ -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();
});
}
-50
View File
@@ -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,
},
});
-110
View File
@@ -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,
},
});
-80
View File
@@ -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,
},
});
-141
View File
@@ -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',
},
});
-101
View File
@@ -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 },
});
-117
View File
@@ -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 },
});
-182
View File
@@ -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 },
});
-159
View File
@@ -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 },
});
-161
View File
@@ -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',
},
});
-157
View File
@@ -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