diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 1a17af8..1672e75 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,22 +1,25 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) } android { - namespace = "com.novasonic.app" - compileSdk = 34 + namespace = "com.novasonic.app" + compileSdk = 35 defaultConfig { applicationId = "com.novasonic.app" - minSdk = 24 - targetSdk = 34 - versionCode = 1 - versionName = "1.0" - buildConfigField("String", "NOVASONIC_URL", "\"https://music.asianxpolish.duckdns.org\"") + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" } buildFeatures { + compose = true buildConfig = true } @@ -28,22 +31,68 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } kotlinOptions { - jvmTarget = "1.8" + jvmTarget = "11" } } dependencies { + // Core implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) - implementation(libs.material) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.splashscreen) + + // Compose + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.compose.foundation) + implementation(libs.compose.material3) + implementation(libs.compose.icons) + debugImplementation(libs.compose.ui.tooling) + + // Navigation + implementation(libs.navigation.compose) + + // Lifecycle / ViewModel + implementation(libs.lifecycle.viewmodel.compose) + implementation(libs.lifecycle.runtime.compose) + + // Media3 implementation(libs.media3.exoplayer) implementation(libs.media3.session) implementation(libs.media3.common) implementation(libs.media3.datasource) + implementation(libs.media3.ui) + + // Ktor + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.android) + implementation(libs.ktor.client.content.neg) + implementation(libs.ktor.serialization.json) + implementation(libs.ktor.client.logging) + + // Kotlinx + implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlin.reflect) + + // Room + implementation(libs.room.runtime) + implementation(libs.room.ktx) + ksp(libs.room.compiler) + + // DataStore + implementation(libs.datastore.preferences) + + // Coil + implementation(libs.coil.compose) + + // Google Fonts (Outfit) + implementation(libs.compose.ui.google.fonts) } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 034821e..e0c7d50 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -7,8 +7,10 @@ + @@ -41,7 +43,7 @@ - + diff --git a/android/app/src/main/kotlin/com/novasonic/app/BridgeMessages.kt b/android/app/src/main/kotlin/com/novasonic/app/BridgeMessages.kt deleted file mode 100644 index d46f975..0000000 --- a/android/app/src/main/kotlin/com/novasonic/app/BridgeMessages.kt +++ /dev/null @@ -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 = try { - val arr = JSONArray(json) - List(arr.length()) { Track.fromJson(arr.getJSONObject(it)) } -} catch (_: Exception) { - emptyList() -} diff --git a/android/app/src/main/kotlin/com/novasonic/app/MainActivity.kt b/android/app/src/main/kotlin/com/novasonic/app/MainActivity.kt deleted file mode 100644 index d136300..0000000 --- a/android/app/src/main/kotlin/com/novasonic/app/MainActivity.kt +++ /dev/null @@ -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? = 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.() 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, 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() -} diff --git a/android/app/src/main/kotlin/com/novasonic/app/MediaPlaybackService.kt b/android/app/src/main/kotlin/com/novasonic/app/MediaPlaybackService.kt deleted file mode 100644 index d946399..0000000 --- a/android/app/src/main/kotlin/com/novasonic/app/MediaPlaybackService.kt +++ /dev/null @@ -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. diff --git a/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt b/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt deleted file mode 100644 index 34ae55e..0000000 --- a/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt +++ /dev/null @@ -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") - } - } -} diff --git a/android/app/src/main/kotlin/com/novasonic/app/NotificationActionReceiver.kt b/android/app/src/main/kotlin/com/novasonic/app/NotificationActionReceiver.kt deleted file mode 100644 index 2eed7d0..0000000 --- a/android/app/src/main/kotlin/com/novasonic/app/NotificationActionReceiver.kt +++ /dev/null @@ -1,4 +0,0 @@ -package com.novasonic.app - -// Replaced by Media3-managed notifications (MusicService.kt). -// Notification actions are now handled natively by Media3 MediaSession. diff --git a/android/app/src/main/kotlin/com/novasonic/app/NovaSonicApp.kt b/android/app/src/main/kotlin/com/novasonic/app/NovaSonicApp.kt new file mode 100644 index 0000000..1b5c736 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/NovaSonicApp.kt @@ -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 diff --git a/android/app/src/main/kotlin/com/novasonic/app/NovaSonicBridge.kt b/android/app/src/main/kotlin/com/novasonic/app/NovaSonicBridge.kt deleted file mode 100644 index 3cb3bf1..0000000 --- a/android/app/src/main/kotlin/com/novasonic/app/NovaSonicBridge.kt +++ /dev/null @@ -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 -} diff --git a/android/app/src/main/kotlin/com/novasonic/app/NovaSonicInterface.kt b/android/app/src/main/kotlin/com/novasonic/app/NovaSonicInterface.kt deleted file mode 100644 index 653cffb..0000000 --- a/android/app/src/main/kotlin/com/novasonic/app/NovaSonicInterface.kt +++ /dev/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. diff --git a/android/app/src/main/kotlin/com/novasonic/app/WebBridge.kt b/android/app/src/main/kotlin/com/novasonic/app/WebBridge.kt deleted file mode 100644 index 8d4da59..0000000 --- a/android/app/src/main/kotlin/com/novasonic/app/WebBridge.kt +++ /dev/null @@ -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) - } -} diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/ImporterClient.kt b/android/app/src/main/kotlin/com/novasonic/app/api/ImporterClient.kt new file mode 100644 index 0000000..f93c986 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/api/ImporterClient.kt @@ -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() +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/ItunesClient.kt b/android/app/src/main/kotlin/com/novasonic/app/api/ItunesClient.kt new file mode 100644 index 0000000..025e31e --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/api/ItunesClient.kt @@ -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 { + 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 { + 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 { + 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 { + val resp: ItunesRssResponse = + http.get("https://itunes.apple.com/$country/rss/topalbums/limit=$limit/json").body() + return resp.feed?.entry ?: emptyList() + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/LrcLibClient.kt b/android/app/src/main/kotlin/com/novasonic/app/api/LrcLibClient.kt new file mode 100644 index 0000000..33aac32 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/api/LrcLibClient.kt @@ -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 { + 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() + }.getOrNull() +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/SubsonicClient.kt b/android/app/src/main/kotlin/com/novasonic/app/api/SubsonicClient.kt new file mode 100644 index 0000000..7fa8eca --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/api/SubsonicClient.kt @@ -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 { + 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 = 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 = 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 = + 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 = + 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 = + 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 = + get("getTopSongs.view", mapOf("artist" to artist, "count" to count)).topSongs?.song + ?: emptyList() + + suspend fun getGenres(): List = + get("getGenres.view").genres?.genre ?: emptyList() + + suspend fun getSongsByGenre(genre: String, count: Int = 50): List = + get("getSongsByGenre.view", mapOf("genre" to genre, "count" to count)).songsByGenre?.song + ?: emptyList() + + suspend fun getLyrics(songId: String): List = + 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 = 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>): 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) diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/model/ImporterModels.kt b/android/app/src/main/kotlin/com/novasonic/app/api/model/ImporterModels.kt new file mode 100644 index 0000000..6721cd8 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/api/model/ImporterModels.kt @@ -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 = 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 = 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, +) diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/model/ItunesModels.kt b/android/app/src/main/kotlin/com/novasonic/app/api/model/ItunesModels.kt new file mode 100644 index 0000000..b09a5f3 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/api/model/ItunesModels.kt @@ -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 = 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 = 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 = 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 diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/model/Models.kt b/android/app/src/main/kotlin/com/novasonic/app/api/model/Models.kt new file mode 100644 index 0000000..5e0d628 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/api/model/Models.kt @@ -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 = 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 = emptyList(), +) + +@Serializable +data class ArtistIndex( + val name: String, + val artist: List = 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 = emptyList(), +) + +// ── Result wrappers ─────────────────────────────────────────────────────────── + +@Serializable +data class MusicFoldersResult(val musicFolder: List = emptyList()) + +@Serializable +data class MusicFolder(val id: String, val name: String? = null) + +@Serializable +data class AlbumListResult(val album: List = emptyList()) + +@Serializable +data class ArtistsResult(val index: List = emptyList()) + +@Serializable +data class PlaylistsResult(val playlist: List = emptyList()) + +@Serializable +data class SongListResult(val song: List = emptyList()) + +@Serializable +data class Starred2( + val song: List = emptyList(), + val album: List = emptyList(), + val artist: List = emptyList(), +) + +@Serializable +data class SearchResult3( + val song: List = emptyList(), + val album: List = emptyList(), + val artist: List = 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 = 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 = 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 = emptyList(), + val displayArtist: String? = null, + val displayTitle: String? = null, + val offset: Long? = null, +) + +@Serializable +data class LyricsListResult(val structuredLyrics: List = emptyList()) diff --git a/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt b/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt new file mode 100644 index 0000000..b0f631c --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt @@ -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) = 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 { + // 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 + } + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/novasonic/app/data/PrefsStore.kt b/android/app/src/main/kotlin/com/novasonic/app/data/PrefsStore.kt new file mode 100644 index 0000000..d089c94 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/data/PrefsStore.kt @@ -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 = context.dataStore.data.map { prefs -> + Credentials( + serverUrl = prefs[KEY_SERVER] ?: "", + username = prefs[KEY_USERNAME] ?: "", + password = prefs[KEY_PASSWORD] ?: "", + ) + } + + val appSettings: Flow = 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() } + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/novasonic/app/player/MusicService.kt b/android/app/src/main/kotlin/com/novasonic/app/player/MusicService.kt new file mode 100644 index 0000000..03329a1 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/player/MusicService.kt @@ -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> { + 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>> { + val future = scope.async { buildChildren(parentId) } + val executor = MoreExecutors.directExecutor() + val settable = SettableFuture.create>>() + 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> { + val settable = SettableFuture.create>() + 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, + ): ListenableFuture> { + val settable = SettableFuture.create>() + 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 { + 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() diff --git a/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt b/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt new file mode 100644 index 0000000..1f7a3fa --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt @@ -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 = 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 = _state.asStateFlow() + + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private var controller: MediaController? = null + private var queue: List = emptyList() + private var originalQueue: List = 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, 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() + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt new file mode 100644 index 0000000..2972748 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt @@ -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(null) } + var playlistPickerSong by remember { mutableStateOf(null) } + var infoSheetSong by remember { mutableStateOf(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") \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/MainActivity.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/MainActivity.kt new file mode 100644 index 0000000..926afa2 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/MainActivity.kt @@ -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() + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/component/AlbumCard.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/component/AlbumCard.kt new file mode 100644 index 0000000..8c1b9d0 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/component/AlbumCard.kt @@ -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, + ) + } + } + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/component/ItunesDownloadSheet.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/component/ItunesDownloadSheet.kt new file mode 100644 index 0000000..d8e16fb --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/component/ItunesDownloadSheet.kt @@ -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)) + } + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/component/MiniPlayer.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/component/MiniPlayer.kt new file mode 100644 index 0000000..ad2d4df --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/component/MiniPlayer.kt @@ -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), + ) + } + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/component/PlaylistPickerSheet.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/component/PlaylistPickerSheet.kt new file mode 100644 index 0000000..91f82bd --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/component/PlaylistPickerSheet.kt @@ -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, + onAdd: (Playlist) -> Unit, + onCreate: (String) -> Unit, + onDismiss: () -> Unit, +) { + var playlists by remember { mutableStateOf?>(null) } + var isLoading by remember { mutableStateOf(true) } + var loadError by remember { mutableStateOf(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)) + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/component/SongContextSheet.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/component/SongContextSheet.kt new file mode 100644 index 0000000..070876e --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/component/SongContextSheet.kt @@ -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) + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/component/SongInfoSheet.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/component/SongInfoSheet.kt new file mode 100644 index 0000000..47a2ea5 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/component/SongInfoSheet.kt @@ -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)) +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/component/SongRow.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/component/SongRow.kt new file mode 100644 index 0000000..4daa4ba --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/component/SongRow.kt @@ -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), + ) + } + } + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/AlbumScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/AlbumScreen.kt new file mode 100644 index 0000000..7b679d5 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/AlbumScreen.kt @@ -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, 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)) } + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistDetailScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistDetailScreen.kt new file mode 100644 index 0000000..cc58189 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistDetailScreen.kt @@ -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, 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), + ) +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ExploreScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ExploreScreen.kt new file mode 100644 index 0000000..c4ad4c9 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ExploreScreen.kt @@ -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, 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, 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, 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, + 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)) +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/GenreSongsScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/GenreSongsScreen.kt new file mode 100644 index 0000000..6771677 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/GenreSongsScreen.kt @@ -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, 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>(emptyList()) } + var loading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(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)) } + } + } + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/HomeScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/HomeScreen.kt new file mode 100644 index 0000000..2896ec8 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/HomeScreen.kt @@ -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, + 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, 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), + ) +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ImporterScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ImporterScreen.kt new file mode 100644 index 0000000..68cb18c --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ImporterScreen.kt @@ -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) + } + } + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ItunesArtistScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ItunesArtistScreen.kt new file mode 100644 index 0000000..aeaf5f2 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ItunesArtistScreen.kt @@ -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>, + val partial: List>>, + val notDownloaded: List, +) + +fun categorizeAlbums(ndAlbums: List, iAlbums: List): 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>() + val partial = mutableListOf>>() + val notDl = mutableListOf() + val usedNdIds = mutableSetOf() + + 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, 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)) + } + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LibraryScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LibraryScreen.kt new file mode 100644 index 0000000..1761f16 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LibraryScreen.kt @@ -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, + ) + } + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LoginScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LoginScreen.kt new file mode 100644 index 0000000..f536b25 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LoginScreen.kt @@ -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, +) diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt new file mode 100644 index 0000000..241f1f3 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt @@ -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) +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/PartialAlbumScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/PartialAlbumScreen.kt new file mode 100644 index 0000000..97f7bc2 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/PartialAlbumScreen.kt @@ -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, 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)) +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/PlaylistDetailScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/PlaylistDetailScreen.kt new file mode 100644 index 0000000..287096c --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/PlaylistDetailScreen.kt @@ -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, 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)) } + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SearchScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SearchScreen.kt new file mode 100644 index 0000000..7cb87ca --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SearchScreen.kt @@ -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, 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), + ) +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SettingsScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SettingsScreen.kt new file mode 100644 index 0000000..67f43a1 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SettingsScreen.kt @@ -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, +) \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/StarredScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/StarredScreen.kt new file mode 100644 index 0000000..f60ea60 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/StarredScreen.kt @@ -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, Int) -> Unit, + onStarToggle: (Song) -> Unit, + onShowContextMenu: ((Song) -> Unit)? = null, +) { + val context = LocalContext.current + val repo = context.app.repository + + var songs by remember { mutableStateOf>(emptyList()) } + var loading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(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)) } + } + } + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/theme/Color.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/theme/Color.kt new file mode 100644 index 0000000..e48dd27 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/theme/Color.kt @@ -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 +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/theme/Theme.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/theme/Theme.kt new file mode 100644 index 0000000..fd16e84 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/theme/Theme.kt @@ -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, + ) +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/theme/Type.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/theme/Type.kt new file mode 100644 index 0000000..edd01a9 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/theme/Type.kt @@ -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), +) diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/AlbumViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/AlbumViewModel.kt new file mode 100644 index 0000000..3384bb7 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/AlbumViewModel.kt @@ -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 = _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) + } + } + } + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistViewModel.kt new file mode 100644 index 0000000..1062637 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistViewModel.kt @@ -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 = emptyList(), + val isLoading: Boolean = true, + val error: String? = null, + // iTunes + val iAlbums: List = emptyList(), + val iTracks: List = emptyList(), + val itunesLoading: Boolean = false, + // Sub-album detail (tapped not-downloaded album) + val subAlbumItem: ItunesItem? = null, + val subAlbumTracks: List? = 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 = _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() + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ExploreViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ExploreViewModel.kt new file mode 100644 index 0000000..5d23b48 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ExploreViewModel.kt @@ -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 = emptyList(), + val iTracks: List = emptyList(), + val isLoading: Boolean = true, +) + +data class ItunesArtistState( + val artist: ItunesItem, + val isLoading: Boolean = true, + val ndAlbums: List = emptyList(), + val iAlbums: List = emptyList(), + val iTracks: List = emptyList(), + val topSongs: List = emptyList(), + val error: String? = null, +) + +data class ExploreUiState( + val tab: ExploreTab = ExploreTab.LIBRARY, + val query: String = "", + // iTunes search results + val artists: List = emptyList(), + val albums: List = emptyList(), + val tracks: List = emptyList(), + // Album drill-down + val albumTracks: List? = 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 = 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 = _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> = async { runCatching { repo.getNdAlbumsForArtist(artistName) }.getOrDefault(emptyList()) } + val topSongsD: Deferred> = async { runCatching { repo.getTopSongs(artistName) }.getOrDefault(emptyList()) } + val iAlbumsD: Deferred> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistAlbums(artistId) }.getOrDefault(emptyList()) else emptyList() } + val iTracksD: Deferred> = 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() + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/HomeViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/HomeViewModel.kt new file mode 100644 index 0000000..7434767 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/HomeViewModel.kt @@ -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 = emptyList(), + val frequentAlbums: List = emptyList(), + val randomAlbums: List = emptyList(), + val genres: List = 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 = _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) + } + } + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ImporterViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ImporterViewModel.kt new file mode 100644 index 0000000..296de66 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ImporterViewModel.kt @@ -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 = _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 + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/LibraryViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/LibraryViewModel.kt new file mode 100644 index 0000000..79089d3 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/LibraryViewModel.kt @@ -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 = emptyList(), + val artistIndexes: List = emptyList(), + val playlists: List = 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 = _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) + } + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/LoginViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/LoginViewModel.kt new file mode 100644 index 0000000..a20b9c6 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/LoginViewModel.kt @@ -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 = _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) + } + } + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/LyricsViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/LyricsViewModel.kt new file mode 100644 index 0000000..6cf63f3 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/LyricsViewModel.kt @@ -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 = 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 = _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) + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/PlaylistViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/PlaylistViewModel.kt new file mode 100644 index 0000000..f0b1a5d --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/PlaylistViewModel.kt @@ -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 = _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()) + } + } + } + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SearchViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SearchViewModel.kt new file mode 100644 index 0000000..58323c2 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SearchViewModel.kt @@ -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 = _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) + } + } + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SettingsViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SettingsViewModel.kt new file mode 100644 index 0000000..1966f50 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SettingsViewModel.kt @@ -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 = _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) + } +} \ No newline at end of file diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 75d037a..0000000 --- a/android/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - diff --git a/android/app/src/main/res/values/font_certs.xml b/android/app/src/main/res/values/font_certs.xml new file mode 100644 index 0000000..3fdd151 --- /dev/null +++ b/android/app/src/main/res/values/font_certs.xml @@ -0,0 +1,17 @@ + + + + @array/com_google_android_gms_fonts_certs_dev + @array/com_google_android_gms_fonts_certs_prod + + + + MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNU2FuIEZyYW5jaXNjbzETMBEGA1UEChMKR29vZ2xlIEluYzEUMBIGA1UECxMLRW5naW5lZXJpbmcxFTATBgNVBAMTDEdvb2dsZSBJbmMuMB4XDTE4MDMwODEzMDMyMloXDTE4MDQwNjEzMDMyMlowLDEqMCgGA1UEAxMhYW5kcm9pZC5nb29nbGVhcGlzLmNvbS9mb250c2RldjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMNvRiimO7gVMf+m37hgKFJMQp1RnDpq7Q1JBSZ8x8SQGQ1bNKGMNM/xoRg8xCLB3m0rvFCKhRAfgHpCb5EBKjt6Z3vQQ2jLAGbbMxz5zR/RbkVH7hSHXfJFz5Dq4TZyPlCAhTJJRMaqUiS24hV0Y7wQzSa8xCCn0dTnX9c0FUoBh0JVAc4FnGAR6RNGkPfyLH3oBd7y1t1Q0CeX9e6uH7LN8W8DtXEQMzFSBVQtMHoqN/Jlz1xXVSHHCpE7Vb6YYJpWkSmyAOjkmJz3n/PpCeHqAXS5VKkGUuOq6bI10eMIg6aXyMODuDzBnIRX0mH8eqq8Ygr5VCFd3xLNkCAwEAAaOCAT0wggE5MB0GA1UdDgQWBBRjyGPj6t3/2XCfVOtpO/ZTPFP5TDAfBgNVHSMEGDAWgBRjyGPj6t3/2XCfVOtpO/ZTPFP5TDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDAzCBmgYDVR0fBIGSMIGPMIGMoIGJoIGGhoGDaHR0cDovL2NybC5nb29nbGUuY29tL3BraS9jcmxzL2dvb2dsZS1hcGlzL3lvdXItdGVzdC1jZXJ0LmNybD9zdGF0ZT1sZjlwT1Bkb3ZROElBWkFCb1RwTFIzSmxheTVOdUlZMHQwdlh6SVBsbERqWEZxMlRjZVBacmFVWmZ0VW9UcmNNa3dDQStlV2s9MA0GCSqGSIb3DQEBBAUAA4IBAQBP0msbfJhgDBkqPH7u6PQqO9bCNPbTxYbclVTQW1LB9aZpqbFLTIzORnU3lSMi9DQ62pR2jCYkmYVzBDxJxoVA8BFQxmFnl+2J+nxHoMH/mJVqdKCq1T7s9N0Q7BtLv3yDpK4kHuv8GZ5R5tFKgf6lv+UrO8gNTF3F5JNVCMJkWNbPNRbSifL4hNnPmH4T7J2t3T/fjzDSi1oiB4N5bCmRFMSY7gNjy+MJQlbRFzGPqRQrJ2F4eaQr8TcpLhw0Q3bHOsPUbHVfGmMRWKq7MkJj0+JrRVWMT7paxRiCGxAVPyJb1dDjqxVE0wlT/3y9VcwrRR3l7KMGJqY1jcobMHs+ + + + + + MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFBYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFBYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBAOKq8yXC9JQZN5WLKUOA1k+WJblR2B65UeJCcjzXBTIPnCDRuNilzLdp6PJH9ECbgGZ4MdOW7U0H8sR2WmqnWK4CZpqJQhW5MbMFX8rNRQLVhF7J9Bxao9D0xWGiIB97+gJHFyxnN79V+6z9c5jzlpO4oX4Jv5b3MuH6fXIPxd3MoNjk1D4JHg7LS5A/0VR5vqST4UrQEv0LNPrXm8E7IM4a2ioIgFo0+vwMi0xdKuY4ByxL8Lx2b0G5tU7hXm2OT0n8wQRpanrr1WNSxflG+T17vBCEfW4lKjTFW5JLNfNT/cEnSfNXF48c3lIgFHH+OPY+mDuCx+BVVjCqUCAwEAAaOBsDCBrTAdBgNVHQ4EFgQUO3Shgt4KWNZWZS8/sVDVZVdNEwQwfQYDVR0jBHYwdIAUO3Shgt4KWNZWZS8/sVDVZVdNEwShWKRWMFQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFBYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjCBgAYDVR0fBHkwdzB1oHOgcYZvaHR0cDovL2NybC5wa2kuZ29vZ2xlYXBpcy5jb20vQ1JML0NBL2E2Mzk5ZjNiNWQxZGFhYzc2MDkzYzM2NzMzNGE1YmMzNjFhNzZhNTcxNGJiZjkwNGRlMjBlNGFjZDBkN2UzMi5jcmwwDQYJKoZIhvcNAQEEBQADggEBADjY8KJhGDBQtFOq1yWNakLe+e8u5vDOCpI2oLFbhfanIhN3nEBoAyqXTDFiD8Qj65xEAyQNiJQ5P3LTBrBr1m3cMdz6YPKX7EqPTjhMGJp9NvnLjzh2IFlv0k4JpNMHfFNz7C3YPHiqBF5aZSKhcKzU5KPk/E7q5jWEKt5XHHS4JrsPVDiGe5dpMQxu2RaEzh+yJfRnEKBa7wLhTb5gkWsE3WNJGkFQ1SioBjcTGW6LfZLl5JOrmMnEv/9hpz31E6wTVVSHXX9yiRFCqRNjI1TSk0u3N1h6wDe/7NwAJG+ZGgQ1I1pXzrr/aBpK/Z3e5C9I2HupPm/zVJyZpBU= + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 7629126..4144f7c 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,4 +1,7 @@ plugins { - alias(libs.plugins.android.application) apply false - alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.ksp) apply false } diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 4213ae8..d6517a0 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -1,22 +1,80 @@ [versions] -agp = "8.13.2" -kotlin = "1.9.23" -coreKtx = "1.13.1" -appcompat = "1.7.0" -material = "1.12.0" -media3 = "1.4.1" -coroutines = "1.8.0" +agp = "8.13.2" +kotlin = "2.1.20" +composeBom = "2024.12.01" +coreKtx = "1.13.1" +appcompat = "1.7.0" +activityCompose = "1.9.3" +navigationCompose = "2.8.3" +lifecycleVm = "2.8.7" +media3 = "1.4.1" +ktor = "2.3.12" +kotlinxSerialization = "1.7.3" +kotlinxCoroutines = "1.9.0" +room = "2.6.1" +datastore = "1.1.1" +coil = "2.7.0" +splashscreen = "1.0.1" [libraries] -androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } -androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } -material = { group = "com.google.android.material", name = "material", version.ref = "material" } -media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } -media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" } -media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "media3" } -media3-datasource = { group = "androidx.media3", name = "media3-datasource", version.ref = "media3" } -kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +# AndroidX core +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" } + +# Compose BOM + core +compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +compose-ui = { group = "androidx.compose.ui", name = "ui" } +compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } +compose-material3 = { group = "androidx.compose.material3", name = "material3" } +compose-icons = { group = "androidx.compose.material", name = "material-icons-extended" } + +# Navigation +navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } + +# Lifecycle / ViewModel +lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleVm" } +lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleVm" } + +# Media3 +media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } +media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" } +media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "media3" } +media3-datasource = { group = "androidx.media3", name = "media3-datasource", version.ref = "media3" } +media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" } + +# Ktor (HTTP client) +ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } +ktor-client-android = { group = "io.ktor", name = "ktor-client-android", version.ref = "ktor" } +ktor-client-content-neg = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } +ktor-serialization-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" } + +# Kotlinx +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } +kotlin-reflect = { group = "org.jetbrains.kotlin", name = "kotlin-reflect", version.ref = "kotlin" } + +# Room +room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } + +# DataStore (settings / credentials) +datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } + +# Coil (image loading) +coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } + +# Google Fonts (Outfit) +compose-ui-google-fonts = { group = "androidx.compose.ui", name = "ui-text-google-fonts" } [plugins] -android-application = { id = "com.android.application", version.ref = "agp" } -kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version = "2.1.20-1.0.32" } diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..9bbc975 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradlew b/android/gradlew new file mode 100644 index 0000000..faf9300 --- /dev/null +++ b/android/gradlew @@ -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" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/android/gradlew.bat @@ -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 diff --git a/index.html b/index.html index 2b9ccf0..d305fed 100644 --- a/index.html +++ b/index.html @@ -186,6 +186,26 @@
+ +
+ +
+
💿
+
+
Unvollständig
+
—
+
+
+
+ + +
+
+
+
+
+
+
@@ -1032,7 +1052,7 @@
- +
diff --git a/mobile/.claude/settings.json b/mobile/.claude/settings.json deleted file mode 100644 index 176e6a5..0000000 --- a/mobile/.claude/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "enabledPlugins": { - "expo@claude-plugins-official": true - } -} diff --git a/mobile/.gitignore b/mobile/.gitignore deleted file mode 100644 index e5a4c9f..0000000 --- a/mobile/.gitignore +++ /dev/null @@ -1,12 +0,0 @@ -node_modules/ -.expo/ -dist/ -*.jks -*.p8 -*.p12 -*.key -*.mobileprovision -*.orig.* -web-build/ -android/ -ios/ diff --git a/mobile/AGENTS.md b/mobile/AGENTS.md deleted file mode 100644 index a26b4bb..0000000 --- a/mobile/AGENTS.md +++ /dev/null @@ -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. diff --git a/mobile/App.js b/mobile/App.js deleted file mode 100644 index c2c51ae..0000000 --- a/mobile/App.js +++ /dev/null @@ -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 ( - - - Fehler beim Starten - - - {this.state.error?.message || String(this.state.error)} - - - {this.state.error?.stack} - - - ); - } - return this.props.children; - } -} - -const Tab = createBottomTabNavigator(); -const Stack = createStackNavigator(); - -function HomeTabs() { - return ( - ({ - 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 ; - }, - })} - > - - - - - - - ); -} - -function RootNavigator() { - const currentTrack = useStore(s => s.currentTrack); - return ( - - - - - - - - {currentTrack && } - - ); -} - -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 ( - - ); - - if (initError) return ( - - Init-Fehler - {initError} - - ); - - return ( - - - - - - - - - - - ); -} diff --git a/mobile/CLAUDE.md b/mobile/CLAUDE.md deleted file mode 100644 index 43c994c..0000000 --- a/mobile/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -@AGENTS.md diff --git a/mobile/LICENSE b/mobile/LICENSE deleted file mode 100644 index 30b20e3..0000000 --- a/mobile/LICENSE +++ /dev/null @@ -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. diff --git a/mobile/app.json b/mobile/app.json deleted file mode 100644 index 2a2b430..0000000 --- a/mobile/app.json +++ /dev/null @@ -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" - ] - } -} diff --git a/mobile/assets/android-icon-background.png b/mobile/assets/android-icon-background.png deleted file mode 100644 index 5ffefc5..0000000 Binary files a/mobile/assets/android-icon-background.png and /dev/null differ diff --git a/mobile/assets/android-icon-foreground.png b/mobile/assets/android-icon-foreground.png deleted file mode 100644 index 3a9e501..0000000 Binary files a/mobile/assets/android-icon-foreground.png and /dev/null differ diff --git a/mobile/assets/android-icon-monochrome.png b/mobile/assets/android-icon-monochrome.png deleted file mode 100644 index 77484eb..0000000 Binary files a/mobile/assets/android-icon-monochrome.png and /dev/null differ diff --git a/mobile/assets/favicon.png b/mobile/assets/favicon.png deleted file mode 100644 index 408bd74..0000000 Binary files a/mobile/assets/favicon.png and /dev/null differ diff --git a/mobile/assets/icon.png b/mobile/assets/icon.png deleted file mode 100644 index 7165a53..0000000 Binary files a/mobile/assets/icon.png and /dev/null differ diff --git a/mobile/assets/splash-icon.png b/mobile/assets/splash-icon.png deleted file mode 100644 index 03d6f6b..0000000 Binary files a/mobile/assets/splash-icon.png and /dev/null differ diff --git a/mobile/babel.config.js b/mobile/babel.config.js deleted file mode 100644 index 9d89e13..0000000 --- a/mobile/babel.config.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = function (api) { - api.cache(true); - return { - presets: ['babel-preset-expo'], - }; -}; diff --git a/mobile/eas.json b/mobile/eas.json deleted file mode 100644 index b35ce08..0000000 --- a/mobile/eas.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "cli": { - "version": ">= 16.0.0" - }, - "build": { - "preview": { - "android": { - "buildType": "apk" - } - }, - "production": { - "android": { - "buildType": "app-bundle" - } - } - } -} diff --git a/mobile/index.js b/mobile/index.js deleted file mode 100644 index 3987e41..0000000 --- a/mobile/index.js +++ /dev/null @@ -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); diff --git a/mobile/package-lock.json b/mobile/package-lock.json deleted file mode 100644 index 084b60d..0000000 --- a/mobile/package-lock.json +++ /dev/null @@ -1,6530 +0,0 @@ -{ - "name": "novasonic_mobile", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "novasonic_mobile", - "version": "1.0.0", - "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" - }, - "devDependencies": { - "babel-preset-expo": "^56.0.14" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", - "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", - "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.29.7", - "@babel/helper-member-expression-to-functions": "^7.29.7", - "@babel/helper-optimise-call-expression": "^7.29.7", - "@babel/helper-replace-supers": "^7.29.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", - "@babel/traverse": "^7.29.7", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.7.tgz", - "integrity": "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==", - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.29.7", - "regexpu-core": "^6.3.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.8", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", - "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", - "license": "MIT", - "dependencies": { - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "debug": "^4.4.3", - "lodash.debounce": "^4.0.8", - "resolve": "^1.22.11" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", - "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", - "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", - "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.29.7.tgz", - "integrity": "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==", - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.29.7", - "@babel/helper-wrap-function": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-replace-supers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", - "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.29.7", - "@babel/helper-optimise-call-expression": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", - "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-wrap-function": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.29.7.tgz", - "integrity": "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==", - "license": "MIT", - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", - "license": "MIT", - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.7" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-proposal-decorators": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.29.7.tgz", - "integrity": "sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg==", - "license": "MIT", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/plugin-syntax-decorators": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-proposal-export-default-from": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.29.7.tgz", - "integrity": "sha512-p+G5BNXDcy3bOXplhY4HybQ1GxH3i2Tppmdm/3epyRu2VgJJZuUlZ61MqRTg582Q7ZLBdP7fePYvsumSEkMxcQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-decorators": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.29.7.tgz", - "integrity": "sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-dynamic-import": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", - "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-export-default-from": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.29.7.tgz", - "integrity": "sha512-foag0BB37ROhdeIX9O8G0jX7hw0UekJc04cHMrYLOnrErsnBKqJGHJ8eDRpoCFZBvEPPygmmtw4qyU97qa4oOw==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-flow": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.29.7.tgz", - "integrity": "sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", - "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", - "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz", - "integrity": "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/helper-remap-async-to-generator": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.29.7.tgz", - "integrity": "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==", - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/helper-remap-async-to-generator": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.7.tgz", - "integrity": "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz", - "integrity": "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==", - "license": "MIT", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.29.7.tgz", - "integrity": "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==", - "license": "MIT", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.12.0" - } - }, - "node_modules/@babel/plugin-transform-classes": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.7.tgz", - "integrity": "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==", - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-globals": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/helper-replace-supers": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", - "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.29.7.tgz", - "integrity": "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-flow-strip-types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.29.7.tgz", - "integrity": "sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/plugin-syntax-flow": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-for-of": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz", - "integrity": "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.29.7.tgz", - "integrity": "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", - "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.7.tgz", - "integrity": "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz", - "integrity": "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.29.7.tgz", - "integrity": "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==", - "license": "MIT", - "dependencies": { - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/plugin-transform-destructuring": "^7.29.7", - "@babel/plugin-transform-parameters": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.29.7.tgz", - "integrity": "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz", - "integrity": "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-parameters": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.29.7.tgz", - "integrity": "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz", - "integrity": "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==", - "license": "MIT", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.29.7.tgz", - "integrity": "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==", - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.29.7", - "@babel/helper-create-class-features-plugin": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-display-name": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.29.7.tgz", - "integrity": "sha512-+1wdDMGNb4UPeY3Q4L5yLiYe6TXPXubs4NjrgRFw13hPRLJfEMw2Q5OXkee6/IfdqePIeW4Jjwe3aBh7SdKz4Q==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.29.7.tgz", - "integrity": "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A==", - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.29.7", - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/plugin-syntax-jsx": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-development": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.29.7.tgz", - "integrity": "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g==", - "license": "MIT", - "dependencies": { - "@babel/plugin-transform-react-jsx": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-pure-annotations": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.29.7.tgz", - "integrity": "sha512-H5E+HBgDpr6Q5t+Aj11tL7XkIui1jhbIoArVQnqjgXo5/3YxkN7ZEBcWF4RQlB0T4rrxJQbXS6kiFV6B7XTqUA==", - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-runtime": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.7.tgz", - "integrity": "sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q==", - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7", - "babel-plugin-polyfill-corejs2": "^0.4.14", - "babel-plugin-polyfill-corejs3": "^0.13.0", - "babel-plugin-polyfill-regenerator": "^0.6.5", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/plugin-transform-typescript": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", - "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==", - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.29.7", - "@babel/helper-create-class-features-plugin": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", - "@babel/plugin-syntax-typescript": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.29.7.tgz", - "integrity": "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==", - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/preset-typescript": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz", - "integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "@babel/plugin-syntax-jsx": "^7.29.7", - "@babel/plugin-transform-modules-commonjs": "^7.29.7", - "@babel/plugin-transform-typescript": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", - "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@egjs/hammerjs": { - "version": "2.0.17", - "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", - "integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==", - "license": "MIT", - "dependencies": { - "@types/hammerjs": "^2.0.36" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@expo/code-signing-certificates": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", - "integrity": "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==", - "license": "MIT", - "dependencies": { - "node-forge": "^1.3.3" - } - }, - "node_modules/@expo/config": { - "version": "56.0.9", - "resolved": "https://registry.npmjs.org/@expo/config/-/config-56.0.9.tgz", - "integrity": "sha512-/lqFeWGSrhpKJVP8tTN8LjuoIe8u8q2w7FzBL0C+wHgl+WM8l1qUIEYWy/sMvsG/NbpUIUsDHJRhQvOkU58eIw==", - "license": "MIT", - "dependencies": { - "@expo/config-plugins": "~56.0.8", - "@expo/config-types": "^56.0.5", - "@expo/json-file": "^10.2.0", - "@expo/require-utils": "^56.1.3", - "deepmerge": "^4.3.1", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "resolve-workspace-root": "^2.0.0", - "semver": "^7.6.0", - "slugify": "^1.3.4" - } - }, - "node_modules/@expo/config-plugins": { - "version": "56.0.8", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-56.0.8.tgz", - "integrity": "sha512-phTuyBhgVLfqUHMjQkAfRtbyoY6yTxoKja1awtpVnEkoJDxPJuXx1KX5uvq1eZtt4bJQ08OBJ6P95INqRSHpRg==", - "license": "MIT", - "dependencies": { - "@expo/config-types": "^56.0.5", - "@expo/json-file": "~10.2.0", - "@expo/plist": "^0.7.0", - "@expo/require-utils": "^56.1.3", - "@expo/sdk-runtime-versions": "^1.0.0", - "chalk": "^4.1.2", - "debug": "^4.3.5", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "semver": "^7.5.4", - "slugify": "^1.6.6", - "xcode": "^3.0.1", - "xml2js": "0.6.0" - } - }, - "node_modules/@expo/config-types": { - "version": "56.0.5", - "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-56.0.5.tgz", - "integrity": "sha512-GsAHO/MwW9ZRdgnmyfRXqVGLCP/zejD6rWnp5OROp8mBGRObKm4HfrjlUyT1skjMwCj1OrURx9ZfIc6yeBAkIA==", - "license": "MIT" - }, - "node_modules/@expo/devcert": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.1.tgz", - "integrity": "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==", - "license": "MIT", - "dependencies": { - "@expo/sudo-prompt": "^9.3.1", - "debug": "^3.1.0" - } - }, - "node_modules/@expo/devcert/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/@expo/devtools": { - "version": "56.0.2", - "resolved": "https://registry.npmjs.org/@expo/devtools/-/devtools-56.0.2.tgz", - "integrity": "sha512-ANl4kPdbe0/HQYWkDEN79S6bQhI+i/ZCnPxuC853pPsB4svhINC7Ku9lmGOKPsUUWWnrHg1spkDGQBZ4sD6JxQ==", - "license": "MIT", - "dependencies": { - "chalk": "^4.1.2" - }, - "peerDependencies": { - "react": "*", - "react-native": "*" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-native": { - "optional": true - } - } - }, - "node_modules/@expo/env": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.3.0.tgz", - "integrity": "sha512-9HnnIbzwTTdbwSjNLXTk0fPm9ZwMJ7c1/31tsni8HZ8Q62KzYCyspahH+V365vg5J6lr001DzNwBxVWSaYCQLg==", - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "debug": "^4.3.4", - "getenv": "^2.0.0" - }, - "engines": { - "node": ">=20.12.0" - } - }, - "node_modules/@expo/expo-modules-macros-plugin": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@expo/expo-modules-macros-plugin/-/expo-modules-macros-plugin-0.0.9.tgz", - "integrity": "sha512-odai6D7ng/gA7At8ukFcWcauNEeDdyVqzVPbQxDkyU2NTJ4kgphA4I5iigS5C4LXFicSIzEt2nzdlLM8sjsTdA==", - "license": "MIT" - }, - "node_modules/@expo/fingerprint": { - "version": "0.19.4", - "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.19.4.tgz", - "integrity": "sha512-PsowRlO8+S7JlO8go7yhNEXp7sqlsWDE2AlCwoss7zH0dcajXFo74Fy0KdXEc4UXK7kKoHD37oDgsZ8aHSLr7A==", - "license": "MIT", - "dependencies": { - "@expo/env": "^2.3.0", - "@expo/spawn-async": "^1.8.0", - "arg": "^5.0.2", - "chalk": "^4.1.2", - "debug": "^4.3.4", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "ignore": "^5.3.1", - "minimatch": "^10.2.2", - "resolve-from": "^5.0.0", - "semver": "^7.6.0" - }, - "bin": { - "fingerprint": "bin/cli.js" - } - }, - "node_modules/@expo/image-utils": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.10.1.tgz", - "integrity": "sha512-YDeefvmYdihS7Wp3ESDUVnOgOSWmj2Cczm9lVNDdm4MqQLdAKm/LPYg83HtFQPfefRlAxyHrQR/O9kIXN9C1Wg==", - "license": "MIT", - "dependencies": { - "@expo/require-utils": "^56.1.3", - "@expo/spawn-async": "^1.8.0", - "chalk": "^4.0.0", - "getenv": "^2.0.0", - "jimp-compact": "0.16.1", - "parse-png": "^2.1.0", - "semver": "^7.6.0" - } - }, - "node_modules/@expo/inline-modules": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/@expo/inline-modules/-/inline-modules-0.0.11.tgz", - "integrity": "sha512-ZlIfKL61DPnW8YUTdMEjMA31xrDDV6p7Xi8rWYyhd5qXBV8MwGwjuJ7vKeaVaMjRqxJk1N9lv7zlfyvQpRCNNw==", - "license": "MIT", - "dependencies": { - "@expo/config-plugins": "~56.0.8" - } - }, - "node_modules/@expo/json-file": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", - "integrity": "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.20.0", - "json5": "^2.2.3" - } - }, - "node_modules/@expo/local-build-cache-provider": { - "version": "56.0.8", - "resolved": "https://registry.npmjs.org/@expo/local-build-cache-provider/-/local-build-cache-provider-56.0.8.tgz", - "integrity": "sha512-UsuXwpNi57MNhzZ3be4XThc8xW6nzk3Wu37s1+2qcfZGeJcMLKDFfwO6n8YXeIiGlCsOi0Ee1rsTdgjrKt/YJQ==", - "license": "MIT", - "dependencies": { - "@expo/config": "~56.0.9", - "chalk": "^4.1.2" - } - }, - "node_modules/@expo/metro": { - "version": "56.0.0", - "resolved": "https://registry.npmjs.org/@expo/metro/-/metro-56.0.0.tgz", - "integrity": "sha512-5gIgQHtEpjjvsjKfVtIv23a98LLRV0/y07PDShEwYSytAMlE3FSF8RHXqtHc1sUJL6dn7hnuIBpIbrLXXuVi0A==", - "license": "MIT", - "dependencies": { - "metro": "0.84.4", - "metro-babel-transformer": "0.84.4", - "metro-cache": "0.84.4", - "metro-cache-key": "0.84.4", - "metro-config": "0.84.4", - "metro-core": "0.84.4", - "metro-file-map": "0.84.4", - "metro-minify-terser": "0.84.4", - "metro-resolver": "0.84.4", - "metro-runtime": "0.84.4", - "metro-source-map": "0.84.4", - "metro-symbolicate": "0.84.4", - "metro-transform-plugins": "0.84.4", - "metro-transform-worker": "0.84.4" - } - }, - "node_modules/@expo/metro-file-map": { - "version": "56.0.3", - "resolved": "https://registry.npmjs.org/@expo/metro-file-map/-/metro-file-map-56.0.3.tgz", - "integrity": "sha512-5OGW3z8LgEYgMJOR7F3pC8llFLkb1fVqwAewbCl6S4Vkha8AFQMwOjT+9Wbka+V4rmpljpGqOnMhF4xZbD961w==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.4", - "fb-watchman": "^2.0.2", - "invariant": "^2.2.4", - "jest-worker": "^29.7.0", - "micromatch": "^4.0.4", - "walker": "^1.0.8" - } - }, - "node_modules/@expo/osascript": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.6.0.tgz", - "integrity": "sha512-QvqDBlJXa8CS2vRORJ4wEflY1m0vVI07uSJdIRgBrLxRPBcsrXxrtU7+wXRXMqfq9zLwNP9XbvRsXF2omoDylg==", - "license": "MIT", - "dependencies": { - "@expo/spawn-async": "^1.8.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@expo/package-manager": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.12.1.tgz", - "integrity": "sha512-fQLiFAcFRWF53mtuLK32SUJQ1ahhrTcBZPZPedYTiUT5ha5FF+UO6bPtCc0Y/hgj0/m3HCGBAuSHjbg2kI9oPQ==", - "license": "MIT", - "dependencies": { - "@expo/json-file": "^10.2.0", - "@expo/spawn-async": "^1.8.0", - "chalk": "^4.0.0", - "npm-package-arg": "^11.0.0", - "ora": "^3.4.0", - "resolve-workspace-root": "^2.0.0" - } - }, - "node_modules/@expo/plist": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.7.0.tgz", - "integrity": "sha512-vrpryU1GoqSIRNqRB2D3IjXDmzNYfiQpEF6AH/xknlD7eiYmEDt3mb26V7cLcedcPG8PY/1xWHdBXVQJfEAh6Q==", - "license": "MIT", - "dependencies": { - "@xmldom/xmldom": "^0.8.8", - "base64-js": "^1.5.1", - "xmlbuilder": "^15.1.1" - } - }, - "node_modules/@expo/prebuild-config": { - "version": "56.0.15", - "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-56.0.15.tgz", - "integrity": "sha512-6GC+QjdCkzp/5wjsqgfu/B2+2yf5MyZMtzf9szIPrLt9uKhzV2PdyM0vU0kvbj1YT8weHCtO7bsrzimman0sjA==", - "license": "MIT", - "dependencies": { - "@expo/config": "~56.0.9", - "@expo/config-plugins": "~56.0.8", - "@expo/config-types": "^56.0.5", - "@expo/image-utils": "^0.10.1", - "@expo/json-file": "^10.2.0", - "@react-native/normalize-colors": "0.85.3", - "debug": "^4.3.1", - "expo-modules-autolinking": "~56.0.15", - "resolve-from": "^5.0.0", - "semver": "^7.6.0" - } - }, - "node_modules/@expo/require-utils": { - "version": "56.1.3", - "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.3.tgz", - "integrity": "sha512-KyLeOn/zzQSvuPpV5YhB/FPKnpQytno4luN918bGdPDssLBoS3N/0UbC3W0rJAn9kSFu+XpfR81eABRVsSdfgQ==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.20.0", - "@babel/core": "^7.25.2", - "@babel/plugin-transform-modules-commonjs": "^7.24.8" - }, - "peerDependencies": { - "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@expo/schema-utils": { - "version": "56.0.1", - "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-56.0.1.tgz", - "integrity": "sha512-CZ/+mYbQmWeOnkCGlWy9K+lFxbJSMFY7+TqBZcKzBSTU5Q7IGRvn/sOG3TdNjIdLPmbA8xe7R/c3UUQ28R9i9w==", - "license": "MIT" - }, - "node_modules/@expo/sdk-runtime-versions": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@expo/sdk-runtime-versions/-/sdk-runtime-versions-1.0.0.tgz", - "integrity": "sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==", - "license": "MIT" - }, - "node_modules/@expo/spawn-async": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.8.0.tgz", - "integrity": "sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==", - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.6" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@expo/sudo-prompt": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/@expo/sudo-prompt/-/sudo-prompt-9.3.2.tgz", - "integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==", - "license": "MIT" - }, - "node_modules/@expo/vector-icons": { - "version": "15.1.1", - "resolved": "https://registry.npmjs.org/@expo/vector-icons/-/vector-icons-15.1.1.tgz", - "integrity": "sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw==", - "license": "MIT", - "peerDependencies": { - "expo-font": ">=14.0.4", - "react": "*", - "react-native": "*" - } - }, - "node_modules/@expo/ws-tunnel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-1.0.6.tgz", - "integrity": "sha512-nDRbLmSrJar7abvUjp3smDwH8HcbZcoOEa5jVPUv9/9CajgmWw20JNRwTuBRzWIWIkEJDkz20GoNA+tSwUqk0Q==", - "license": "MIT" - }, - "node_modules/@expo/xcpretty": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.4.tgz", - "integrity": "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==", - "license": "BSD-3-Clause", - "dependencies": { - "@babel/code-frame": "^7.20.0", - "chalk": "^4.1.0", - "js-yaml": "^4.1.0" - }, - "bin": { - "excpretty": "build/cli.js" - } - }, - "node_modules/@isaacs/ttlcache": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", - "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", - "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", - "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", - "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", - "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", - "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", - "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", - "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@react-native-community/slider": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@react-native-community/slider/-/slider-5.2.0.tgz", - "integrity": "sha512-484sH8aWEaSjxaZ7HT3YZ8CKDcNes2synko1vdEz5DFEdvKAduxKJTj22L/qBMD7rtIkfbX69DMzWDAGbOAV6w==", - "license": "MIT" - }, - "node_modules/@react-native/assets-registry": { - "version": "0.85.3", - "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.85.3.tgz", - "integrity": "sha512-u9ZiYP23vA2IFtdFQFmetzSmk6SM0xgKIoiOsr1hXNHjHaLhOm+/Ph1ud57wX6+Dbwdzx8coJgnzSKL3W21PCg==", - "license": "MIT", - "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" - } - }, - "node_modules/@react-native/babel-plugin-codegen": { - "version": "0.85.3", - "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.85.3.tgz", - "integrity": "sha512-Wc94zGfeFG8Njf9SHMPfYZP04kjigkOps6F1TYTvd7ZVXuGxqseCDgxc50LWcOhOCLypI9n3oVVqz81C3p44ZA==", - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.0", - "@react-native/codegen": "0.85.3" - }, - "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" - } - }, - "node_modules/@react-native/codegen": { - "version": "0.85.3", - "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.85.3.tgz", - "integrity": "sha512-/JkS1lGLyzBWP1FbgDwaqEf7qShIC6pUC1M0a/YMAd/v4iqR24MRkQWe7jkYvcBQ2LpEhs5NGE9InhxSv21zCA==", - "license": "MIT", - "dependencies": { - "@babel/core": "^7.25.2", - "@babel/parser": "^7.29.0", - "hermes-parser": "0.33.3", - "invariant": "^2.2.4", - "nullthrows": "^1.1.1", - "tinyglobby": "^0.2.15", - "yargs": "^17.6.2" - }, - "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" - }, - "peerDependencies": { - "@babel/core": "*" - } - }, - "node_modules/@react-native/codegen/node_modules/hermes-estree": { - "version": "0.33.3", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.33.3.tgz", - "integrity": "sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg==", - "license": "MIT" - }, - "node_modules/@react-native/codegen/node_modules/hermes-parser": { - "version": "0.33.3", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.33.3.tgz", - "integrity": "sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==", - "license": "MIT", - "dependencies": { - "hermes-estree": "0.33.3" - } - }, - "node_modules/@react-native/community-cli-plugin": { - "version": "0.85.3", - "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.85.3.tgz", - "integrity": "sha512-fs85dmbIqNmtzEixDb0g+q6R3Vt4H9eAt8/inIZdDKfjN76+sUJA2r1nxODQ76bU23MrIbz8sI7KFBPaWk/zQw==", - "license": "MIT", - "dependencies": { - "@react-native/dev-middleware": "0.85.3", - "debug": "^4.4.0", - "invariant": "^2.2.4", - "metro": "^0.84.3", - "metro-config": "^0.84.3", - "metro-core": "^0.84.3", - "semver": "^7.1.3" - }, - "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" - }, - "peerDependencies": { - "@react-native-community/cli": "*", - "@react-native/metro-config": "0.85.3" - }, - "peerDependenciesMeta": { - "@react-native-community/cli": { - "optional": true - }, - "@react-native/metro-config": { - "optional": true - } - } - }, - "node_modules/@react-native/debugger-frontend": { - "version": "0.85.3", - "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.85.3.tgz", - "integrity": "sha512-uAu7rM5o/Np1zgp6fi5zM1sP1aB8DcS7DdOLcj/TkSutOAjkMqqd2lWt1/+3S7qXexRHVK5XcP+o3VXo4L/V0A==", - "license": "BSD-3-Clause", - "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" - } - }, - "node_modules/@react-native/debugger-shell": { - "version": "0.85.3", - "resolved": "https://registry.npmjs.org/@react-native/debugger-shell/-/debugger-shell-0.85.3.tgz", - "integrity": "sha512-/jRAaT9boiCttIcEwS02WPwYkUihqsjSaK/TMtHz05vT6uMgac9PaQt5kzBQLIABv5aEIa5gtrMmKVz49MjkjQ==", - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.6", - "debug": "^4.4.0", - "fb-dotslash": "0.5.8" - }, - "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" - } - }, - "node_modules/@react-native/dev-middleware": { - "version": "0.85.3", - "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.85.3.tgz", - "integrity": "sha512-JYzBiT4A8w+KQt+dOD5v+ti+tDrGoPnsSTuApq3Ls4RB5sfWbDlYMyz3dbc8qBIHz9tv0sQ5+eOu6Xwqzr5AQA==", - "license": "MIT", - "dependencies": { - "@isaacs/ttlcache": "^1.4.1", - "@react-native/debugger-frontend": "0.85.3", - "@react-native/debugger-shell": "0.85.3", - "chrome-launcher": "^0.15.2", - "chromium-edge-launcher": "^0.3.0", - "connect": "^3.6.5", - "debug": "^4.4.0", - "invariant": "^2.2.4", - "nullthrows": "^1.1.1", - "open": "^7.0.3", - "serve-static": "^1.16.2", - "ws": "^7.5.10" - }, - "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" - } - }, - "node_modules/@react-native/gradle-plugin": { - "version": "0.85.3", - "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.85.3.tgz", - "integrity": "sha512-39dY2j50Q1pntejzwt3XL7vwXtrj8jcIfHq6E+gyu3jzYxZJVvMkMutQ39vSg6zinIQOX36oQDhidXUbCXzgoA==", - "license": "MIT", - "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" - } - }, - "node_modules/@react-native/js-polyfills": { - "version": "0.85.3", - "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.85.3.tgz", - "integrity": "sha512-U2+aMshIXf1uFn77tpBb/xhHWB9vkVrMpt7kkucAugF8hJKYTDGB587X7WwelHduK2KBfhl4giSv0rzZGoef9A==", - "license": "MIT", - "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" - } - }, - "node_modules/@react-native/normalize-colors": { - "version": "0.85.3", - "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.85.3.tgz", - "integrity": "sha512-hj0PScZEhIbcOvQV5yMKX3ha4XEIOy/SVE1Rrpp0beW0dpNLOgSC7KDxGewmDnIHK9YdQUXGY9eMEfShUMIaZw==", - "license": "MIT" - }, - "node_modules/@react-native/virtualized-lists": { - "version": "0.85.3", - "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.85.3.tgz", - "integrity": "sha512-dsCjI//OIPEUJMyNHp4l7zNLVjCx7bcaRUceOCkU+IB17hkbtbGWvi7HjGFSzy7FJGmS/MOlcfpb72xXiy1Oig==", - "license": "MIT", - "dependencies": { - "invariant": "^2.2.4", - "nullthrows": "^1.1.1" - }, - "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" - }, - "peerDependencies": { - "@types/react": "^19.2.0", - "react": "*", - "react-native": "0.85.3" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@react-navigation/bottom-tabs": { - "version": "7.17.2", - "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.17.2.tgz", - "integrity": "sha512-Ojo0LTUTqQhrjIwlA2yGpof0mSjdIri1tdIaqKnsFX8yAEyvMxy64Dkgp57sO/+Jy6Xzc/1yzcORYLx59W3cYA==", - "license": "MIT", - "dependencies": { - "@react-navigation/elements": "^2.9.22", - "color": "^4.2.3", - "sf-symbols-typescript": "^2.1.0" - }, - "peerDependencies": { - "@react-navigation/native": "^7.3.0", - "react": ">= 18.2.0", - "react-native": "*", - "react-native-safe-area-context": ">= 4.0.0", - "react-native-screens": ">= 4.0.0" - } - }, - "node_modules/@react-navigation/core": { - "version": "7.19.0", - "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.19.0.tgz", - "integrity": "sha512-OEXT9sSx+md4bqBVvg0sfFfVjRWHK+E7YHXobB/IQ/l8jqP3ThCwZk0DhxYmjwEGmUcCCkquXMxDG+hgMDeDSg==", - "license": "MIT", - "dependencies": { - "@react-navigation/routers": "^7.5.6", - "escape-string-regexp": "^4.0.0", - "fast-deep-equal": "^3.1.3", - "nanoid": "^3.3.11", - "query-string": "^7.1.3", - "react-is": "^19.1.0", - "use-latest-callback": "^0.2.4", - "use-sync-external-store": "^1.5.0" - }, - "peerDependencies": { - "react": ">= 18.2.0" - } - }, - "node_modules/@react-navigation/core/node_modules/react-is": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", - "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", - "license": "MIT" - }, - "node_modules/@react-navigation/elements": { - "version": "2.9.22", - "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.22.tgz", - "integrity": "sha512-n2u2ls34mvUVWLUpNYOWvE9agP5rGt/Fp7x8lo/oZnh03Bydr2HnGeovIWrt4OOI/p1gHor3PhWT+xhf10Xl7w==", - "license": "MIT", - "dependencies": { - "color": "^4.2.3", - "use-latest-callback": "^0.2.4", - "use-sync-external-store": "^1.5.0" - }, - "peerDependencies": { - "@react-native-masked-view/masked-view": ">= 0.2.0", - "@react-navigation/native": "^7.3.0", - "react": ">= 18.2.0", - "react-native": "*", - "react-native-safe-area-context": ">= 4.0.0" - }, - "peerDependenciesMeta": { - "@react-native-masked-view/masked-view": { - "optional": true - } - } - }, - "node_modules/@react-navigation/native": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.3.0.tgz", - "integrity": "sha512-ExKP2Y0d6e72JLEWEQFXmJe9QObjVWHYeKBUxaQm8ep+RbQbcBrbNOLrtN9Eb6tbGVGTVdzvAdQZbAYPh4dQww==", - "license": "MIT", - "dependencies": { - "@react-navigation/core": "^7.19.0", - "escape-string-regexp": "^4.0.0", - "fast-deep-equal": "^3.1.3", - "nanoid": "^3.3.11", - "standard-navigation": "^0.0.7", - "use-latest-callback": "^0.2.4" - }, - "peerDependencies": { - "react": ">= 18.2.0", - "react-native": "*" - } - }, - "node_modules/@react-navigation/routers": { - "version": "7.5.6", - "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.5.6.tgz", - "integrity": "sha512-tV/vSXX1va9xCXcqmLqr8qn56ygkwiC9tYoEEWfkqvTCh8LR9FEG7ZVzHBkzrQ/2hJQz+j/3KoglGSHXYrOK4w==", - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11" - } - }, - "node_modules/@react-navigation/stack": { - "version": "7.10.2", - "resolved": "https://registry.npmjs.org/@react-navigation/stack/-/stack-7.10.2.tgz", - "integrity": "sha512-vvEhZUDwrSJplylSlvzCzvbJ3Ku2IBog/SfXHF9BsdF1+pbMg3mjU6L3ajvs0HUN1MlctXIZD2bkBSHAAv9Rgg==", - "license": "MIT", - "dependencies": { - "@react-navigation/elements": "^2.9.22", - "color": "^4.2.3", - "use-latest-callback": "^0.2.4" - }, - "peerDependencies": { - "@react-navigation/native": "^7.3.0", - "react": ">= 18.2.0", - "react-native": "*", - "react-native-gesture-handler": ">= 2.0.0", - "react-native-safe-area-context": ">= 4.0.0", - "react-native-screens": ">= 4.0.0" - } - }, - "node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", - "license": "MIT" - }, - "node_modules/@types/hammerjs": { - "version": "2.0.46", - "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", - "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==", - "license": "MIT" - }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "license": "MIT" - }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", - "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-coverage": "*" - } - }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", - "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-report": "*" - } - }, - "node_modules/@types/node": { - "version": "25.9.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz", - "integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==", - "license": "MIT", - "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" - } - }, - "node_modules/@types/react": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", - "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-test-renderer": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz", - "integrity": "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==", - "license": "MIT", - "dependencies": { - "@types/react": "*" - } - }, - "node_modules/@types/yargs": { - "version": "17.0.35", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", - "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", - "license": "MIT", - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "license": "MIT" - }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", - "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", - "license": "ISC" - }, - "node_modules/@xmldom/xmldom": { - "version": "0.8.13", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", - "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/anser": { - "version": "1.4.10", - "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", - "integrity": "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==", - "license": "MIT" - }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "license": "MIT", - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-escapes/node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "license": "MIT" - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, - "node_modules/asap": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "license": "MIT" - }, - "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.17", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", - "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-define-polyfill-provider": "^0.6.8", - "semver": "^6.3.1" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", - "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", - "license": "MIT", - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.5", - "core-js-compat": "^3.43.0" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.8", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", - "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", - "license": "MIT", - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.8" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-react-compiler": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", - "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.26.0" - } - }, - "node_modules/babel-plugin-react-native-web": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/babel-plugin-react-native-web/-/babel-plugin-react-native-web-0.21.2.tgz", - "integrity": "sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA==", - "license": "MIT" - }, - "node_modules/babel-plugin-syntax-hermes-parser": { - "version": "0.33.3", - "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.33.3.tgz", - "integrity": "sha512-/Z9xYdaJ1lC0pT9do6TqCqhOSLfZ5Ot8D5za1p+feEfWYupCOfGbhhEXN9r2ZgJtDNUNRw/Z+T2CvAGKBqtqWA==", - "license": "MIT", - "dependencies": { - "hermes-parser": "0.33.3" - } - }, - "node_modules/babel-plugin-syntax-hermes-parser/node_modules/hermes-estree": { - "version": "0.33.3", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.33.3.tgz", - "integrity": "sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg==", - "license": "MIT" - }, - "node_modules/babel-plugin-syntax-hermes-parser/node_modules/hermes-parser": { - "version": "0.33.3", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.33.3.tgz", - "integrity": "sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==", - "license": "MIT", - "dependencies": { - "hermes-estree": "0.33.3" - } - }, - "node_modules/babel-plugin-transform-flow-enums": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-flow-enums/-/babel-plugin-transform-flow-enums-0.0.2.tgz", - "integrity": "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==", - "license": "MIT", - "dependencies": { - "@babel/plugin-syntax-flow": "^7.12.1" - } - }, - "node_modules/babel-preset-expo": { - "version": "56.0.14", - "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-56.0.14.tgz", - "integrity": "sha512-+JKVMYf3HajO3tPRA9DlKd/VhZOPTHyTzUo2yZajfMAoQ3l5VEdGVxm2MzX4DXMNKXwsC8GOeTRx7CrO/5dBDA==", - "license": "MIT", - "dependencies": { - "@babel/generator": "^7.20.5", - "@babel/helper-module-imports": "^7.25.9", - "@babel/plugin-proposal-decorators": "^7.12.9", - "@babel/plugin-proposal-export-default-from": "^7.24.7", - "@babel/plugin-syntax-dynamic-import": "^7.8.3", - "@babel/plugin-syntax-export-default-from": "^7.24.7", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-transform-async-generator-functions": "^7.25.4", - "@babel/plugin-transform-async-to-generator": "^7.24.7", - "@babel/plugin-transform-block-scoping": "^7.25.0", - "@babel/plugin-transform-class-properties": "^7.25.4", - "@babel/plugin-transform-class-static-block": "^7.27.1", - "@babel/plugin-transform-classes": "^7.25.4", - "@babel/plugin-transform-destructuring": "^7.24.8", - "@babel/plugin-transform-export-namespace-from": "^7.25.9", - "@babel/plugin-transform-flow-strip-types": "^7.25.2", - "@babel/plugin-transform-for-of": "^7.24.7", - "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", - "@babel/plugin-transform-modules-commonjs": "^7.24.8", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", - "@babel/plugin-transform-object-rest-spread": "^7.24.7", - "@babel/plugin-transform-optional-catch-binding": "^7.24.7", - "@babel/plugin-transform-optional-chaining": "^7.24.8", - "@babel/plugin-transform-parameters": "^7.24.7", - "@babel/plugin-transform-private-methods": "^7.24.7", - "@babel/plugin-transform-private-property-in-object": "^7.24.7", - "@babel/plugin-transform-react-display-name": "^7.24.7", - "@babel/plugin-transform-react-jsx": "^7.28.6", - "@babel/plugin-transform-react-jsx-development": "^7.27.1", - "@babel/plugin-transform-react-pure-annotations": "^7.27.1", - "@babel/plugin-transform-runtime": "^7.24.7", - "@babel/plugin-transform-typescript": "^7.25.2", - "@babel/plugin-transform-unicode-regex": "^7.24.7", - "@babel/preset-typescript": "^7.23.0", - "@react-native/babel-plugin-codegen": "0.85.3", - "babel-plugin-react-compiler": "^1.0.0", - "babel-plugin-react-native-web": "~0.21.0", - "babel-plugin-syntax-hermes-parser": "^0.33.3", - "babel-plugin-transform-flow-enums": "^0.0.2", - "debug": "^4.3.4" - }, - "peerDependencies": { - "@babel/runtime": "^7.20.0", - "expo": "*", - "expo-widgets": "^56.0.16", - "react-refresh": ">=0.14.0 <1.0.0" - }, - "peerDependenciesMeta": { - "@babel/runtime": { - "optional": true - }, - "expo": { - "optional": true - }, - "expo-widgets": { - "optional": true - } - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.35", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz", - "integrity": "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg==", - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/big-integer": { - "version": "1.6.52", - "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", - "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", - "license": "Unlicense", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/bplist-creator": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", - "integrity": "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==", - "license": "MIT", - "dependencies": { - "stream-buffers": "2.2.x" - } - }, - "node_modules/bplist-parser": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.1.tgz", - "integrity": "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==", - "license": "MIT", - "dependencies": { - "big-integer": "1.6.x" - }, - "engines": { - "node": ">= 5.10.0" - } - }, - "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "license": "Apache-2.0", - "dependencies": { - "node-int64": "^0.4.0" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "license": "MIT" - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001797", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", - "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chrome-launcher": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz", - "integrity": "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==", - "license": "Apache-2.0", - "dependencies": { - "@types/node": "*", - "escape-string-regexp": "^4.0.0", - "is-wsl": "^2.2.0", - "lighthouse-logger": "^1.0.0" - }, - "bin": { - "print-chrome-path": "bin/print-chrome-path.js" - }, - "engines": { - "node": ">=12.13.0" - } - }, - "node_modules/chromium-edge-launcher": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/chromium-edge-launcher/-/chromium-edge-launcher-0.3.0.tgz", - "integrity": "sha512-p03azHlGjtyRvFEee3cyvtsRYdniSkwjkzmM/KmVnqT5d7QkkwpJBhis/zCLMYdQMVJ5tt140TBNqqrZPaWeFA==", - "license": "Apache-2.0", - "dependencies": { - "@types/node": "*", - "escape-string-regexp": "^4.0.0", - "is-wsl": "^2.2.0", - "lighthouse-logger": "^1.0.0", - "mkdirp": "^1.0.4" - } - }, - "node_modules/ci-info": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", - "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", - "license": "MIT" - }, - "node_modules/cli-cursor": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", - "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", - "license": "MIT", - "dependencies": { - "restore-cursor": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "license": "MIT", - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, - "node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/compressible": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", - "license": "MIT", - "dependencies": { - "mime-db": ">= 1.43.0 < 2" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/compression": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", - "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "compressible": "~2.0.18", - "debug": "2.6.9", - "negotiator": "~0.6.4", - "on-headers": "~1.1.0", - "safe-buffer": "5.2.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/compression/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/compression/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/compression/node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/connect": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", - "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "finalhandler": "1.1.2", - "parseurl": "~1.3.3", - "utils-merge": "1.0.1" - }, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/connect/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/connect/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "license": "MIT" - }, - "node_modules/core-js-compat": { - "version": "3.49.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", - "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decode-uri-component": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", - "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", - "license": "MIT", - "engines": { - "node": ">=0.10" - } - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "license": "MIT", - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/dnssd-advertise": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/dnssd-advertise/-/dnssd-advertise-1.1.4.tgz", - "integrity": "sha512-AmGyK9WpNf06WeP5TjHZq/wNzP76OuEeaiTlKr9E/EEelYLczywUKoqRz+DPRq/ErssjT4lU+/W7wzJW+7K/ZA==", - "license": "MIT" - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.370", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.370.tgz", - "integrity": "sha512-D5tSHJReAb/Kf3Hu9F/GO4lJuSWzEWHwvQ/kKSUP7pimNgvxkSKj+gUQhHpKKACwrin7rS3byU7IxreF56rl5g==", - "license": "ISC" - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/error-stack-parser": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", - "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", - "license": "MIT", - "dependencies": { - "stackframe": "^1.3.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/expo": { - "version": "56.0.9", - "resolved": "https://registry.npmjs.org/expo/-/expo-56.0.9.tgz", - "integrity": "sha512-Zd/fhhyC600PO4cA14r+K+DlhhUZLNaDNF6dYg+hgne2kLvg9HMnkZ902sTPZYLkW56JOXLJ5dk7hsIoH26N2A==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.20.0", - "@expo/cli": "^56.1.14", - "@expo/config": "~56.0.9", - "@expo/config-plugins": "~56.0.8", - "@expo/devtools": "~56.0.2", - "@expo/dom-webview": "~56.0.5", - "@expo/fingerprint": "^0.19.4", - "@expo/local-build-cache-provider": "^56.0.8", - "@expo/log-box": "^56.0.12", - "@expo/metro": "~56.0.0", - "@expo/metro-config": "~56.0.13", - "@ungap/structured-clone": "^1.3.0", - "babel-preset-expo": "~56.0.14", - "expo-asset": "~56.0.16", - "expo-constants": "~56.0.17", - "expo-file-system": "~56.0.7", - "expo-font": "~56.0.5", - "expo-keep-awake": "~56.0.3", - "expo-modules-autolinking": "~56.0.15", - "expo-modules-core": "~56.0.15", - "pretty-format": "^29.7.0", - "react-refresh": "^0.14.2", - "whatwg-url-minimum": "^0.1.2" - }, - "bin": { - "expo": "bin/cli", - "expo-modules-autolinking": "bin/autolinking", - "fingerprint": "bin/fingerprint" - }, - "peerDependencies": { - "@expo/dom-webview": "*", - "@expo/metro-runtime": "*", - "react": "*", - "react-dom": "*", - "react-native": "*", - "react-native-web": "*", - "react-native-webview": "*" - }, - "peerDependenciesMeta": { - "@expo/dom-webview": { - "optional": true - }, - "@expo/metro-runtime": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "react-native-web": { - "optional": true - }, - "react-native-webview": { - "optional": true - } - } - }, - "node_modules/expo-file-system": { - "version": "56.0.7", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-56.0.7.tgz", - "integrity": "sha512-dcKzo8ShPloM7jgfnMcJStgQebhP8owVjCkNI/aX6NMFV1CYB8bxKGMdnzJ3mXk5nfaiW+F/lSKr2UIJ02WAUA==", - "license": "MIT", - "peerDependencies": { - "expo": "*", - "react-native": "*" - } - }, - "node_modules/expo-font": { - "version": "56.0.5", - "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-56.0.5.tgz", - "integrity": "sha512-WLoDu9hlEgPRKXJRR01HFLJ6Z2tFcORX/WFPRYBndmYc5kjQrFGH/j4BRaF3aBRPyYEAUXiUJybNLXkKCwEXQw==", - "license": "MIT", - "dependencies": { - "fontfaceobserver": "^2.1.0" - }, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" - } - }, - "node_modules/expo-image": { - "version": "56.0.10", - "resolved": "https://registry.npmjs.org/expo-image/-/expo-image-56.0.10.tgz", - "integrity": "sha512-pi1XjD/fqIe4En7Mue6Y2RowPUT4yWYMjR7WWdE3pk4FCe4jAUPDNxB2EOrrhWNID17p4Vd78XNHKOAxXxCOsg==", - "license": "MIT", - "dependencies": { - "sf-symbols-typescript": "^2.2.0" - }, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*", - "react-native-web": "*" - }, - "peerDependenciesMeta": { - "react-native-web": { - "optional": true - } - } - }, - "node_modules/expo-modules-autolinking": { - "version": "56.0.15", - "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-56.0.15.tgz", - "integrity": "sha512-WqpBFwLzn7DsrUkWltIjVmAjwuI1VdQ2jRMlvk1nh2kVadwdJBkSjUBQWRifsEePNhiMT/rFOovBolUU/ARt5w==", - "license": "MIT", - "dependencies": { - "@expo/require-utils": "^56.1.3", - "@expo/spawn-async": "^1.8.0", - "chalk": "^4.1.0", - "commander": "^7.2.0" - }, - "bin": { - "expo-modules-autolinking": "bin/expo-modules-autolinking.js" - } - }, - "node_modules/expo-modules-core": { - "version": "56.0.15", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-56.0.15.tgz", - "integrity": "sha512-XOXuWjtUA/xF8VjMHoRTRxuAmrAeUv8QyASX3h/CpTNS58fOt3stV8EYW7BinJPJyqwV7BZoYV83iN0p2FzyZw==", - "license": "MIT", - "dependencies": { - "@expo/expo-modules-macros-plugin": "~0.0.9", - "expo-modules-jsi": "~56.0.8", - "invariant": "^2.2.4" - }, - "peerDependencies": { - "react": "*", - "react-native": "*", - "react-native-worklets": "^0.7.4 || ^0.8.0" - }, - "peerDependenciesMeta": { - "react-native-worklets": { - "optional": true - } - } - }, - "node_modules/expo-modules-jsi": { - "version": "56.0.8", - "resolved": "https://registry.npmjs.org/expo-modules-jsi/-/expo-modules-jsi-56.0.8.tgz", - "integrity": "sha512-tXqFU1MHrf7Ctq+Pw0qOeIPDFl1W51p9nRRZy9vVUn4GNuAk1Av0vrj0SGLvcxJvDf3aGwSzr8o8dgUsX5sG0g==", - "license": "MIT", - "peerDependencies": { - "react-native": "*" - } - }, - "node_modules/expo-secure-store": { - "version": "56.0.4", - "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-56.0.4.tgz", - "integrity": "sha512-hjEi/gmpdFFJ9lYbdp3k3p/WchV7Gi0Qt8jt/m/0WJadqQrskafHAlDxbZkII1cN3Yd7zp9Lvkeq3UfGhSwirQ==", - "license": "MIT", - "peerDependencies": { - "expo": "*" - } - }, - "node_modules/expo-server": { - "version": "56.0.5", - "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-56.0.5.tgz", - "integrity": "sha512-SmM2p2g3Jrktpiazcst+OxhjSzOHXKAY4BPURHYHXvApzzoybMmrNF4IEZ8DKZ145BhSe4ydAmlEFCRTsdtgUQ==", - "license": "MIT", - "engines": { - "node": ">=20.16.0" - } - }, - "node_modules/expo-status-bar": { - "version": "56.0.4", - "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-56.0.4.tgz", - "integrity": "sha512-IGs/fDfkHXofy2ZQrGiXayhFK04HB85FZXorhcEhDZEcqASKgSqpak+HwUtAaR0MeTJwWyHNF7I6VmVbbp8EcA==", - "license": "MIT", - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" - } - }, - "node_modules/expo/node_modules/@expo/cli": { - "version": "56.1.14", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-56.1.14.tgz", - "integrity": "sha512-rSH3ygjEPipEYG6dgiJ116J8KqCQ/BYKcwQDipStSh4IFWJ10RZaYP4u5B74jxfeIWjWrOeqvwB6NZfQBjaQ4Q==", - "license": "MIT", - "dependencies": { - "@expo/code-signing-certificates": "^0.0.6", - "@expo/config": "~56.0.9", - "@expo/config-plugins": "~56.0.8", - "@expo/devcert": "^1.2.1", - "@expo/env": "~2.3.0", - "@expo/image-utils": "^0.10.1", - "@expo/inline-modules": "^0.0.11", - "@expo/json-file": "^10.2.0", - "@expo/log-box": "^56.0.12", - "@expo/metro": "~56.0.0", - "@expo/metro-config": "~56.0.13", - "@expo/metro-file-map": "^56.0.3", - "@expo/osascript": "^2.6.0", - "@expo/package-manager": "^1.12.1", - "@expo/plist": "^0.7.0", - "@expo/prebuild-config": "^56.0.15", - "@expo/require-utils": "^56.1.3", - "@expo/router-server": "^56.0.13", - "@expo/schema-utils": "^56.0.0", - "@expo/spawn-async": "^1.8.0", - "@expo/ws-tunnel": "^1.0.1", - "@expo/xcpretty": "^4.4.4", - "@react-native/dev-middleware": "0.85.3", - "accepts": "^1.3.8", - "arg": "^5.0.2", - "bplist-creator": "0.1.0", - "bplist-parser": "^0.3.1", - "chalk": "^4.0.0", - "ci-info": "^3.3.0", - "compression": "^1.7.4", - "connect": "^3.7.0", - "debug": "^4.3.4", - "dnssd-advertise": "^1.1.4", - "expo-server": "^56.0.5", - "fetch-nodeshim": "^0.4.10", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "lan-network": "^0.2.1", - "multitars": "^1.0.0", - "node-forge": "^1.3.3", - "npm-package-arg": "^11.0.0", - "ora": "^3.4.0", - "picomatch": "^4.0.4", - "pretty-format": "^29.7.0", - "progress": "^2.0.3", - "prompts": "^2.3.2", - "resolve-from": "^5.0.0", - "semver": "^7.6.0", - "send": "^0.19.0", - "slugify": "^1.3.4", - "stacktrace-parser": "^0.1.10", - "structured-headers": "^0.4.1", - "terminal-link": "^2.1.1", - "toqr": "^0.1.1", - "wrap-ansi": "^7.0.0", - "ws": "^8.12.1", - "zod": "^3.25.76" - }, - "bin": { - "expo-internal": "main.js" - }, - "peerDependencies": { - "expo": "*", - "expo-router": "*", - "react-native": "*" - }, - "peerDependenciesMeta": { - "expo-router": { - "optional": true - }, - "react-native": { - "optional": true - } - } - }, - "node_modules/expo/node_modules/@expo/cli/node_modules/@expo/router-server": { - "version": "56.0.13", - "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-56.0.13.tgz", - "integrity": "sha512-M2H2zHlRBKIPENCWV8Gqo3/9WANCS9vvOMCcdWfS9wD8XXMnDASFniS0bBoGwwS1qq1LIpYzX8m8wdv7Awy88g==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.4" - }, - "peerDependencies": { - "@expo/metro-runtime": "^56.0.14", - "expo": "*", - "expo-constants": "^56.0.17", - "expo-font": "^56.0.5", - "expo-router": "*", - "expo-server": "^56.0.5", - "react": "*", - "react-dom": "*", - "react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1" - }, - "peerDependenciesMeta": { - "@expo/metro-runtime": { - "optional": true - }, - "expo-router": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "react-server-dom-webpack": { - "optional": true - } - } - }, - "node_modules/expo/node_modules/@expo/dom-webview": { - "version": "56.0.5", - "resolved": "https://registry.npmjs.org/@expo/dom-webview/-/dom-webview-56.0.5.tgz", - "integrity": "sha512-UIEJxkLg6cHqofKrpWpkn9E6ApxVRtCgZhZkARPr9VV7rBVloJgeroTHs31YgU/JpbI5lLQOnfOlGo54W6C2Ew==", - "license": "MIT", - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" - } - }, - "node_modules/expo/node_modules/@expo/log-box": { - "version": "56.0.12", - "resolved": "https://registry.npmjs.org/@expo/log-box/-/log-box-56.0.12.tgz", - "integrity": "sha512-budE6AGmJbpOJfGSOz+JVP3+FevElT82IEIg+ukQ4gZpW/dGO7QX1unFjanKdSaYgudBwJ4FCFGMwWhW/1tXVQ==", - "license": "MIT", - "dependencies": { - "@expo/dom-webview": "^56.0.5", - "anser": "^1.4.9", - "stacktrace-parser": "^0.1.10" - }, - "peerDependencies": { - "@expo/dom-webview": "^56.0.5", - "expo": "*", - "react": "*", - "react-native": "*" - } - }, - "node_modules/expo/node_modules/@expo/metro-config": { - "version": "56.0.13", - "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-56.0.13.tgz", - "integrity": "sha512-OPyNYiex/6Ms8zT2POdIZsLhcAZYk7O+yJvpz5uG/4QRA7aiESfCy1I+0YHewMlR4P1YQeyxIrfTurs6m9xfZA==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.20.0", - "@babel/core": "^7.20.0", - "@babel/generator": "^7.20.5", - "@expo/config": "~56.0.9", - "@expo/env": "~2.3.0", - "@expo/json-file": "~10.2.0", - "@expo/metro": "~56.0.0", - "@expo/require-utils": "^56.1.3", - "@expo/spawn-async": "^1.8.0", - "@jridgewell/gen-mapping": "^0.3.13", - "@jridgewell/remapping": "^2.3.5", - "@jridgewell/sourcemap-codec": "^1.5.5", - "browserslist": "^4.25.0", - "chalk": "^4.1.0", - "debug": "^4.3.2", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "hermes-parser": "^0.33.3", - "jsc-safe-url": "^0.2.4", - "lightningcss": "^1.30.1", - "msgpackr": "^2.0.1", - "picomatch": "^4.0.4", - "postcss": "^8.5.14", - "resolve-from": "^5.0.0" - }, - "peerDependencies": { - "expo": "*" - }, - "peerDependenciesMeta": { - "expo": { - "optional": true - } - } - }, - "node_modules/expo/node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/expo/node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/expo/node_modules/expo-asset": { - "version": "56.0.16", - "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-56.0.16.tgz", - "integrity": "sha512-iIxPo6C6+/d8JxGV74ZKZbIcCz2s8//dVl7oBAj124NcPMFhzdwycFBpMqq5LUxin+lVy5cCoEjv2LD8ulnkiQ==", - "license": "MIT", - "dependencies": { - "@expo/image-utils": "^0.10.1", - "expo-constants": "~56.0.17" - }, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" - } - }, - "node_modules/expo/node_modules/expo-constants": { - "version": "56.0.17", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-56.0.17.tgz", - "integrity": "sha512-bU8iU1+7cI7QzfGQVnz2C1nlbXD08YPwD6h8ZEuNspgUuD2prXfmrhrdLe1GjCPYGw4hB3BNjWPjpenNyyymfQ==", - "license": "MIT", - "dependencies": { - "@expo/env": "~2.3.0" - }, - "peerDependencies": { - "expo": "*", - "react-native": "*" - } - }, - "node_modules/expo/node_modules/expo-keep-awake": { - "version": "56.0.3", - "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-56.0.3.tgz", - "integrity": "sha512-CLMJXtEiMKknD3Rpm8CRwE6ZJUzu2yCEmRk1sgfHAJ1zIbuEWY3dpPDubtsnuzWm+2k6Sru+yaFbYsvPWmTiBA==", - "license": "MIT", - "peerDependencies": { - "expo": "*", - "react": "*" - } - }, - "node_modules/expo/node_modules/hermes-estree": { - "version": "0.33.3", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.33.3.tgz", - "integrity": "sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg==", - "license": "MIT" - }, - "node_modules/expo/node_modules/hermes-parser": { - "version": "0.33.3", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.33.3.tgz", - "integrity": "sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==", - "license": "MIT", - "dependencies": { - "hermes-estree": "0.33.3" - } - }, - "node_modules/expo/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/expo/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/expo/node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/expo/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/expo/node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/exponential-backoff": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", - "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", - "license": "Apache-2.0" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fb-dotslash": { - "version": "0.5.8", - "resolved": "https://registry.npmjs.org/fb-dotslash/-/fb-dotslash-0.5.8.tgz", - "integrity": "sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==", - "license": "(MIT OR Apache-2.0)", - "bin": { - "dotslash": "bin/dotslash" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", - "license": "Apache-2.0", - "dependencies": { - "bser": "2.1.1" - } - }, - "node_modules/fetch-nodeshim": { - "version": "0.4.10", - "resolved": "https://registry.npmjs.org/fetch-nodeshim/-/fetch-nodeshim-0.4.10.tgz", - "integrity": "sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==", - "license": "MIT" - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/filter-obj": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", - "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/finalhandler": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", - "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "~2.3.0", - "parseurl": "~1.3.3", - "statuses": "~1.5.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/flow-enums-runtime": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz", - "integrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==", - "license": "MIT" - }, - "node_modules/fontfaceobserver": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz", - "integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==", - "license": "BSD-2-Clause" - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/getenv": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", - "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hermes-compiler": { - "version": "250829098.0.10", - "resolved": "https://registry.npmjs.org/hermes-compiler/-/hermes-compiler-250829098.0.10.tgz", - "integrity": "sha512-TcRlZ0/TlyfJqquRFAWoyElVNnkdYRi/sEp4/Qy8/GYxjg8j2cS9D4MjuaQ+qimkmLN7AmO+44IznRf06mAr0w==", - "license": "MIT" - }, - "node_modules/hermes-estree": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz", - "integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==", - "license": "MIT" - }, - "node_modules/hermes-parser": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.35.0.tgz", - "integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==", - "license": "MIT", - "dependencies": { - "hermes-estree": "0.35.0" - } - }, - "node_modules/hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "license": "BSD-3-Clause", - "dependencies": { - "react-is": "^16.7.0" - } - }, - "node_modules/hoist-non-react-statics/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, - "node_modules/hosted-git-info": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", - "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", - "license": "ISC", - "dependencies": { - "lru-cache": "^10.0.1" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/hosted-git-info/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/http-errors/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/image-size": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", - "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", - "license": "MIT", - "dependencies": { - "queue": "6.0.2" - }, - "bin": { - "image-size": "bin/image-size.js" - }, - "engines": { - "node": ">=16.x" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/invariant": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", - "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.0.0" - } - }, - "node_modules/is-arrayish": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", - "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", - "license": "MIT" - }, - "node_modules/is-core-module": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", - "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", - "license": "MIT", - "dependencies": { - "hasown": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "license": "MIT", - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-util/node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-validate": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", - "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "leven": "^3.1.0", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-worker": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", - "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "jest-util": "^29.7.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/jimp-compact": { - "version": "0.16.1", - "resolved": "https://registry.npmjs.org/jimp-compact/-/jimp-compact-0.16.1.tgz", - "integrity": "sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==", - "license": "MIT" - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsc-safe-url": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/jsc-safe-url/-/jsc-safe-url-0.2.4.tgz", - "integrity": "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==", - "license": "0BSD" - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/lan-network": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/lan-network/-/lan-network-0.2.1.tgz", - "integrity": "sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==", - "license": "MIT", - "bin": { - "lan-network": "dist/lan-network-cli.js" - } - }, - "node_modules/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/lighthouse-logger": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", - "integrity": "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==", - "license": "Apache-2.0", - "dependencies": { - "debug": "^2.6.9", - "marky": "^1.2.2" - } - }, - "node_modules/lighthouse-logger/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/lighthouse-logger/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lodash.debounce": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "license": "MIT" - }, - "node_modules/lodash.throttle": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", - "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", - "license": "MIT" - }, - "node_modules/log-symbols": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", - "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", - "license": "MIT", - "dependencies": { - "chalk": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/log-symbols/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "license": "MIT", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/log-symbols/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/log-symbols/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "license": "MIT", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/log-symbols/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "license": "MIT" - }, - "node_modules/log-symbols/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/log-symbols/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/log-symbols/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", - "license": "BSD-3-Clause", - "dependencies": { - "tmpl": "1.0.5" - } - }, - "node_modules/marky": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", - "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", - "license": "Apache-2.0" - }, - "node_modules/memoize-one": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", - "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", - "license": "MIT" - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "license": "MIT" - }, - "node_modules/metro": { - "version": "0.84.4", - "resolved": "https://registry.npmjs.org/metro/-/metro-0.84.4.tgz", - "integrity": "sha512-8ETTubqfD6ornDy2zYDvRcKnVDOXdFJsjetYDBsY4oAsb6NJkiwFR+FaMESyGppFmQUyBQA4H4sFGxzcQSGtFA==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/core": "^7.25.2", - "@babel/generator": "^7.29.1", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", - "accepts": "^2.0.0", - "ci-info": "^2.0.0", - "connect": "^3.6.5", - "debug": "^4.4.0", - "error-stack-parser": "^2.0.6", - "flow-enums-runtime": "^0.0.6", - "graceful-fs": "^4.2.4", - "hermes-parser": "0.35.0", - "image-size": "^1.0.2", - "invariant": "^2.2.4", - "jest-worker": "^29.7.0", - "jsc-safe-url": "^0.2.2", - "lodash.throttle": "^4.1.1", - "metro-babel-transformer": "0.84.4", - "metro-cache": "0.84.4", - "metro-cache-key": "0.84.4", - "metro-config": "0.84.4", - "metro-core": "0.84.4", - "metro-file-map": "0.84.4", - "metro-resolver": "0.84.4", - "metro-runtime": "0.84.4", - "metro-source-map": "0.84.4", - "metro-symbolicate": "0.84.4", - "metro-transform-plugins": "0.84.4", - "metro-transform-worker": "0.84.4", - "mime-types": "^3.0.1", - "nullthrows": "^1.1.1", - "serialize-error": "^2.1.0", - "source-map": "^0.5.6", - "throat": "^5.0.0", - "ws": "^7.5.10", - "yargs": "^17.6.2" - }, - "bin": { - "metro": "src/cli.js" - }, - "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" - } - }, - "node_modules/metro-babel-transformer": { - "version": "0.84.4", - "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.84.4.tgz", - "integrity": "sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==", - "license": "MIT", - "dependencies": { - "@babel/core": "^7.25.2", - "flow-enums-runtime": "^0.0.6", - "hermes-parser": "0.35.0", - "metro-cache-key": "0.84.4", - "nullthrows": "^1.1.1" - }, - "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" - } - }, - "node_modules/metro-cache": { - "version": "0.84.4", - "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.84.4.tgz", - "integrity": "sha512-gpcFQdSLUwUCk71saKoE64jLFbx2nwTfVCcPSULMNT8QYq0p1eZZE29Jvd0HtT/UlhC3ZOutLxJME5xqD2JUZg==", - "license": "MIT", - "dependencies": { - "exponential-backoff": "^3.1.1", - "flow-enums-runtime": "^0.0.6", - "https-proxy-agent": "^7.0.5", - "metro-core": "0.84.4" - }, - "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" - } - }, - "node_modules/metro-cache-key": { - "version": "0.84.4", - "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.84.4.tgz", - "integrity": "sha512-wVO79aGrkYImpnaVS4+d5RrRBRPX31QtvKB3wKGBuiNSznduZTQHzsrJZRroFJSwnygrzdsGUtDQPuqqFjFdvw==", - "license": "MIT", - "dependencies": { - "flow-enums-runtime": "^0.0.6" - }, - "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" - } - }, - "node_modules/metro-config": { - "version": "0.84.4", - "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.84.4.tgz", - "integrity": "sha512-PMotGDjXcXLWo2TMRH+VR99phFNgYTwqh4OoieIKK3yTJa1Jmkl+fZJxDO0jfBvNF+WESHciHvpNuBtXaF3B0Q==", - "license": "MIT", - "dependencies": { - "connect": "^3.6.5", - "flow-enums-runtime": "^0.0.6", - "jest-validate": "^29.7.0", - "metro": "0.84.4", - "metro-cache": "0.84.4", - "metro-core": "0.84.4", - "metro-runtime": "0.84.4", - "yaml": "^2.6.1" - }, - "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" - } - }, - "node_modules/metro-core": { - "version": "0.84.4", - "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.84.4.tgz", - "integrity": "sha512-HONpWC5LGXZn3ffkd4Hu6AIrfE7j4Z0g0wMo/goV24WOB3lhuFZ40KgvaDiSw8iyQHloMYay5N/wPX+z8oN/PQ==", - "license": "MIT", - "dependencies": { - "flow-enums-runtime": "^0.0.6", - "lodash.throttle": "^4.1.1", - "metro-resolver": "0.84.4" - }, - "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" - } - }, - "node_modules/metro-file-map": { - "version": "0.84.4", - "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.84.4.tgz", - "integrity": "sha512-KSVDi/u60hKPx++NLu3MTIvyjzNoJnFAF8PQFxaj1jiSka/wjw+Ua6sNuJ0TDHQv+7AAoFQxeMgaRAe8Yic5wQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "fb-watchman": "^2.0.0", - "flow-enums-runtime": "^0.0.6", - "graceful-fs": "^4.2.4", - "invariant": "^2.2.4", - "jest-worker": "^29.7.0", - "micromatch": "^4.0.4", - "nullthrows": "^1.1.1", - "walker": "^1.0.7" - }, - "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" - } - }, - "node_modules/metro-minify-terser": { - "version": "0.84.4", - "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.84.4.tgz", - "integrity": "sha512-5qpbaVOMC7CPitIpuewzVeGw7E+C3ykbv2mqTjQLl85Z3annSVGlSCTcsZjqXZzjupfK4Ztj3dDc4kc44NZwtQ==", - "license": "MIT", - "dependencies": { - "flow-enums-runtime": "^0.0.6", - "terser": "^5.15.0" - }, - "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" - } - }, - "node_modules/metro-resolver": { - "version": "0.84.4", - "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.84.4.tgz", - "integrity": "sha512-1qLgbxQ5ZGhhutuPot1Yp348ofDsATL2WkrHF65TobqTT9K3P9qJXw38bomk7ncp5B7OYMfWwtyBZo1lCV792A==", - "license": "MIT", - "dependencies": { - "flow-enums-runtime": "^0.0.6" - }, - "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" - } - }, - "node_modules/metro-runtime": { - "version": "0.84.4", - "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.84.4.tgz", - "integrity": "sha512-Jibypds4g7AhzdRKY+kDoj51s5EXMwgyp5ddtlreDAsWefMdOx+agWqgm0H2XSZ/ueanHHVM89fnf5OJnlxa8Q==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.25.0", - "flow-enums-runtime": "^0.0.6" - }, - "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" - } - }, - "node_modules/metro-source-map": { - "version": "0.84.4", - "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.84.4.tgz", - "integrity": "sha512-jbWkPxIesVuo1IWkvezmMJld6iu8nD62GsrZiV6jP37AOdbo4OBq1FJ+qkOg8sV05wAHB//jAbziuW0SlJfW4g==", - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", - "flow-enums-runtime": "^0.0.6", - "invariant": "^2.2.4", - "metro-symbolicate": "0.84.4", - "nullthrows": "^1.1.1", - "ob1": "0.84.4", - "source-map": "^0.5.6", - "vlq": "^1.0.0" - }, - "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" - } - }, - "node_modules/metro-symbolicate": { - "version": "0.84.4", - "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.84.4.tgz", - "integrity": "sha512-OnfpacxUqGPZQ27t8qK9mFa7uqHIlVWeqRqkCbvMvreEBiamEeOn8krKtcwgP5M4cYDPwuSmCTopHMVthqG4zA==", - "license": "MIT", - "dependencies": { - "flow-enums-runtime": "^0.0.6", - "invariant": "^2.2.4", - "metro-source-map": "0.84.4", - "nullthrows": "^1.1.1", - "source-map": "^0.5.6", - "vlq": "^1.0.0" - }, - "bin": { - "metro-symbolicate": "src/index.js" - }, - "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" - } - }, - "node_modules/metro-transform-plugins": { - "version": "0.84.4", - "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.84.4.tgz", - "integrity": "sha512-kehr6HbAecqD0/a3xLXobELdPaAmRAl8bel0qagPF4vhZtux93nS8S4eq2kgKt6J2GnQpVjSoW1PXdst04mwow==", - "license": "MIT", - "dependencies": { - "@babel/core": "^7.25.2", - "@babel/generator": "^7.29.1", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "flow-enums-runtime": "^0.0.6", - "nullthrows": "^1.1.1" - }, - "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" - } - }, - "node_modules/metro-transform-worker": { - "version": "0.84.4", - "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.84.4.tgz", - "integrity": "sha512-W1IYMvvXTu4MxYr7d9h7CeG2vpIr3bmLLIavkPY4O1ilzDrvS8z/NEe6y+pC44Ff7raMXQgYSfdqDUwN/i39gg==", - "license": "MIT", - "dependencies": { - "@babel/core": "^7.25.2", - "@babel/generator": "^7.29.1", - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "flow-enums-runtime": "^0.0.6", - "metro": "0.84.4", - "metro-babel-transformer": "0.84.4", - "metro-cache": "0.84.4", - "metro-cache-key": "0.84.4", - "metro-minify-terser": "0.84.4", - "metro-source-map": "0.84.4", - "metro-transform-plugins": "0.84.4", - "nullthrows": "^1.1.1" - }, - "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/mimic-fn": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", - "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/msgpackr": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.4.tgz", - "integrity": "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA==", - "license": "MIT", - "optionalDependencies": { - "msgpackr-extract": "^3.0.4" - } - }, - "node_modules/msgpackr-extract": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", - "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build-optional-packages": "5.2.2" - }, - "bin": { - "download-msgpackr-prebuilds": "bin/download-prebuilds.js" - }, - "optionalDependencies": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" - } - }, - "node_modules/multitars": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/multitars/-/multitars-1.0.0.tgz", - "integrity": "sha512-H/J4fMLedtudftaYMOg7ajzLYgT3/rwbWVJbqr/iUgB8DQztn38ys5HOqI1CzSxx8QhXXwOOnnBvd4v3jG5+Mg==", - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-forge": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", - "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", - "license": "(BSD-3-Clause OR GPL-2.0)", - "engines": { - "node": ">= 6.13.0" - } - }, - "node_modules/node-gyp-build-optional-packages": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", - "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.1" - }, - "bin": { - "node-gyp-build-optional-packages": "bin.js", - "node-gyp-build-optional-packages-optional": "optional.js", - "node-gyp-build-optional-packages-test": "build-test.js" - } - }, - "node_modules/node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.47", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", - "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/npm-package-arg": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.3.tgz", - "integrity": "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==", - "license": "ISC", - "dependencies": { - "hosted-git-info": "^7.0.0", - "proc-log": "^4.0.0", - "semver": "^7.3.5", - "validate-npm-package-name": "^5.0.0" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/nullthrows": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", - "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", - "license": "MIT" - }, - "node_modules/ob1": { - "version": "0.84.4", - "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.84.4.tgz", - "integrity": "sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA==", - "license": "MIT", - "dependencies": { - "flow-enums-runtime": "^0.0.6" - }, - "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" - } - }, - "node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/on-headers": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", - "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/onetime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", - "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", - "license": "MIT", - "dependencies": { - "mimic-fn": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/open": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", - "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", - "license": "MIT", - "dependencies": { - "is-docker": "^2.0.0", - "is-wsl": "^2.1.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/ora/-/ora-3.4.0.tgz", - "integrity": "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==", - "license": "MIT", - "dependencies": { - "chalk": "^2.4.2", - "cli-cursor": "^2.1.0", - "cli-spinners": "^2.0.0", - "log-symbols": "^2.2.0", - "strip-ansi": "^5.2.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/ora/node_modules/ansi-regex": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", - "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/ora/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "license": "MIT", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/ora/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/ora/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "license": "MIT", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/ora/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "license": "MIT" - }, - "node_modules/ora/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/ora/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/ora/node_modules/strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^4.1.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/ora/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/parse-png": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/parse-png/-/parse-png-2.1.0.tgz", - "integrity": "sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ==", - "license": "MIT", - "dependencies": { - "pngjs": "^3.3.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "license": "MIT" - }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/plist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", - "integrity": "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==", - "license": "MIT", - "dependencies": { - "@xmldom/xmldom": "^0.9.10", - "base64-js": "^1.5.1", - "xmlbuilder": "^15.1.1" - }, - "engines": { - "node": ">=10.4.0" - } - }, - "node_modules/plist/node_modules/@xmldom/xmldom": { - "version": "0.9.10", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", - "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", - "license": "MIT", - "engines": { - "node": ">=14.6" - } - }, - "node_modules/pngjs": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz", - "integrity": "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/proc-log": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", - "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==", - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/promise": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/promise/-/promise-8.3.0.tgz", - "integrity": "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==", - "license": "MIT", - "dependencies": { - "asap": "~2.0.6" - } - }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "license": "MIT", - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/query-string": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", - "integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==", - "license": "MIT", - "dependencies": { - "decode-uri-component": "^0.2.2", - "filter-obj": "^1.1.0", - "split-on-first": "^1.0.0", - "strict-uri-encode": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/queue": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", - "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", - "license": "MIT", - "dependencies": { - "inherits": "~2.0.3" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/react": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", - "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-devtools-core": { - "version": "6.1.5", - "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-6.1.5.tgz", - "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", - "license": "MIT", - "dependencies": { - "shell-quote": "^1.6.1", - "ws": "^7" - } - }, - "node_modules/react-freeze": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz", - "integrity": "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "react": ">=17.0.0" - } - }, - "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" - }, - "node_modules/react-native": { - "version": "0.85.3", - "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.85.3.tgz", - "integrity": "sha512-HN/fGC+3nZVcDNcw7gfbM/DuqZAvI9Mz+/SxuhODaua4JY0BPzhfTzWXRyTR4mRgMHmShTPpH2PYMTxvZrsdZA==", - "license": "MIT", - "dependencies": { - "@react-native/assets-registry": "0.85.3", - "@react-native/codegen": "0.85.3", - "@react-native/community-cli-plugin": "0.85.3", - "@react-native/gradle-plugin": "0.85.3", - "@react-native/js-polyfills": "0.85.3", - "@react-native/normalize-colors": "0.85.3", - "@react-native/virtualized-lists": "0.85.3", - "abort-controller": "^3.0.0", - "anser": "^1.4.9", - "ansi-regex": "^5.0.0", - "babel-plugin-syntax-hermes-parser": "0.33.3", - "base64-js": "^1.5.1", - "commander": "^12.0.0", - "flow-enums-runtime": "^0.0.6", - "hermes-compiler": "250829098.0.10", - "invariant": "^2.2.4", - "memoize-one": "^5.0.0", - "metro-runtime": "^0.84.3", - "metro-source-map": "^0.84.3", - "nullthrows": "^1.1.1", - "pretty-format": "^29.7.0", - "promise": "^8.3.0", - "react-devtools-core": "^6.1.5", - "react-refresh": "^0.14.0", - "regenerator-runtime": "^0.13.2", - "scheduler": "0.27.0", - "semver": "^7.1.3", - "stacktrace-parser": "^0.1.10", - "tinyglobby": "^0.2.15", - "whatwg-fetch": "^3.0.0", - "ws": "^7.5.10", - "yargs": "^17.6.2" - }, - "bin": { - "react-native": "cli.js" - }, - "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" - }, - "peerDependencies": { - "@react-native/jest-preset": "0.85.3", - "@types/react": "^19.1.1", - "react": "^19.2.3" - }, - "peerDependenciesMeta": { - "@react-native/jest-preset": { - "optional": true - }, - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-native-gesture-handler": { - "version": "2.31.2", - "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.31.2.tgz", - "integrity": "sha512-rw5q74i2AfS7YGYdbxQDhOU7xqgY6WRM1132/CCm3erqjblhECZDZFHIm0tteHoC9ih24wogVBVVzcTBQtZ+5A==", - "license": "MIT", - "dependencies": { - "@egjs/hammerjs": "^2.0.17", - "@types/react-test-renderer": "^19.1.0", - "hoist-non-react-statics": "^3.3.0", - "invariant": "^2.2.4" - }, - "peerDependencies": { - "react": "*", - "react-native": "*" - } - }, - "node_modules/react-native-safe-area-context": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.7.0.tgz", - "integrity": "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ==", - "license": "MIT", - "peerDependencies": { - "react": "*", - "react-native": "*" - } - }, - "node_modules/react-native-screens": { - "version": "4.25.2", - "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.25.2.tgz", - "integrity": "sha512-1Nj1fusFd+rIMKU/qC9yGKVG+3ofh11d3OdBQKL1iVvQfKvcB8vhvTGQf2TkfxW3bamxN+hCZIXmNuU0mRkyDg==", - "license": "MIT", - "dependencies": { - "react-freeze": "^1.0.0", - "warn-once": "^0.1.0" - }, - "peerDependencies": { - "react": "*", - "react-native": ">=0.82.0" - } - }, - "node_modules/react-native-track-player": { - "version": "5.0.0-alpha0", - "resolved": "https://registry.npmjs.org/react-native-track-player/-/react-native-track-player-5.0.0-alpha0.tgz", - "integrity": "sha512-dxMI8aX75wS9S5/DM7dWN060uGjEitX3fhvLGtrQ96AgIKvOorKy3HkBU7DgvCF/6YDAMxez2PaZA+4A3DQWAQ==", - "license": "Apache-2.0", - "workspaces": [ - "example" - ], - "funding": { - "url": "https://github.com/doublesymmetry/react-native-track-player?sponsor=1" - }, - "peerDependencies": { - "react": "*", - "react-native": "*", - "shaka-player": "^4.7.9" - }, - "peerDependenciesMeta": { - "shaka-player": { - "optional": true - } - } - }, - "node_modules/react-native/node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/react-refresh": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", - "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/regenerate": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", - "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", - "license": "MIT" - }, - "node_modules/regenerate-unicode-properties": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", - "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", - "license": "MIT", - "dependencies": { - "regenerate": "^1.4.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "license": "MIT" - }, - "node_modules/regexpu-core": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", - "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", - "license": "MIT", - "dependencies": { - "regenerate": "^1.4.2", - "regenerate-unicode-properties": "^10.2.2", - "regjsgen": "^0.8.0", - "regjsparser": "^0.13.0", - "unicode-match-property-ecmascript": "^2.0.0", - "unicode-match-property-value-ecmascript": "^2.2.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/regjsgen": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", - "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", - "license": "MIT" - }, - "node_modules/regjsparser": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.1.tgz", - "integrity": "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==", - "license": "BSD-2-Clause", - "dependencies": { - "jsesc": "~3.1.0" - }, - "bin": { - "regjsparser": "bin/parser" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.12", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-workspace-root": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/resolve-workspace-root/-/resolve-workspace-root-2.0.1.tgz", - "integrity": "sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w==", - "license": "MIT" - }, - "node_modules/restore-cursor": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", - "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", - "license": "MIT", - "dependencies": { - "onetime": "^2.0.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/sax": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", - "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=11.0.0" - } - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.3.tgz", - "integrity": "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/send/node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/send/node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/send/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/serialize-error": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-2.1.0.tgz", - "integrity": "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/serve-static/node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/sf-symbols-typescript": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/sf-symbols-typescript/-/sf-symbols-typescript-2.2.0.tgz", - "integrity": "sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/shell-quote": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", - "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" - }, - "node_modules/simple-plist": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-1.3.1.tgz", - "integrity": "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==", - "license": "MIT", - "dependencies": { - "bplist-creator": "0.1.0", - "bplist-parser": "0.3.1", - "plist": "^3.0.5" - } - }, - "node_modules/simple-swizzle": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", - "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "license": "MIT" - }, - "node_modules/slugify": { - "version": "1.6.9", - "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz", - "integrity": "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==", - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/split-on-first": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", - "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/stackframe": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", - "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", - "license": "MIT" - }, - "node_modules/stacktrace-parser": { - "version": "0.1.11", - "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", - "integrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==", - "license": "MIT", - "dependencies": { - "type-fest": "^0.7.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/standard-navigation": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/standard-navigation/-/standard-navigation-0.0.7.tgz", - "integrity": "sha512-NCGLCNyuXrFOkGHxdNZFnpsehGtiq1oXbPhKl7ZuxFO5J//H2evqqOchmD4YwEUJnkjO4kH9Xp4hQX6hdAYCKQ==", - "license": "MIT" - }, - "node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/stream-buffers": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", - "integrity": "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==", - "license": "Unlicense", - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/strict-uri-encode": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", - "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/structured-headers": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/structured-headers/-/structured-headers-0.4.1.tgz", - "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==", - "license": "MIT" - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-hyperlinks": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", - "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0", - "supports-color": "^7.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/terminal-link": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", - "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", - "license": "MIT", - "dependencies": { - "ansi-escapes": "^4.2.1", - "supports-hyperlinks": "^2.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/terser": { - "version": "5.48.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", - "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", - "license": "BSD-2-Clause", - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.15.0", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT" - }, - "node_modules/throat": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", - "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/tmpl": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", - "license": "BSD-3-Clause" - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/toqr": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/toqr/-/toqr-0.1.1.tgz", - "integrity": "sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA==", - "license": "MIT" - }, - "node_modules/type-fest": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", - "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=8" - } - }, - "node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "license": "MIT" - }, - "node_modules/unicode-canonical-property-names-ecmascript": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", - "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-ecmascript": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", - "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", - "license": "MIT", - "dependencies": { - "unicode-canonical-property-names-ecmascript": "^2.0.0", - "unicode-property-aliases-ecmascript": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-value-ecmascript": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", - "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-property-aliases-ecmascript": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", - "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/use-latest-callback": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/use-latest-callback/-/use-latest-callback-0.2.6.tgz", - "integrity": "sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==", - "license": "MIT", - "peerDependencies": { - "react": ">=16.8" - } - }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/uuid": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", - "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/validate-npm-package-name": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", - "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==", - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/vlq": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", - "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", - "license": "MIT" - }, - "node_modules/walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", - "license": "Apache-2.0", - "dependencies": { - "makeerror": "1.0.12" - } - }, - "node_modules/warn-once": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/warn-once/-/warn-once-0.1.1.tgz", - "integrity": "sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==", - "license": "MIT" - }, - "node_modules/wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", - "license": "MIT", - "dependencies": { - "defaults": "^1.0.3" - } - }, - "node_modules/whatwg-fetch": { - "version": "3.6.20", - "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", - "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", - "license": "MIT" - }, - "node_modules/whatwg-url-minimum": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/whatwg-url-minimum/-/whatwg-url-minimum-0.1.2.tgz", - "integrity": "sha512-XPEm0XFQWNVG292lII1PrRRJl3sItrs7CettZ4ncYxuDVpLyy+NwlGyut2hXI0JswcJUxeCH+CyOJK0ZzAXD6A==", - "license": "MIT" - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/ws": { - "version": "7.5.11", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", - "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", - "license": "MIT", - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xcode": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/xcode/-/xcode-3.0.1.tgz", - "integrity": "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==", - "license": "Apache-2.0", - "dependencies": { - "simple-plist": "^1.1.0", - "uuid": "^7.0.3" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/xml2js": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz", - "integrity": "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==", - "license": "MIT", - "dependencies": { - "sax": ">=0.6.0", - "xmlbuilder": "~11.0.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/xml2js/node_modules/xmlbuilder": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", - "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", - "license": "MIT", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/xmlbuilder": { - "version": "15.1.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", - "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", - "license": "MIT", - "engines": { - "node": ">=8.0" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "license": "ISC" - }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zustand": { - "version": "5.0.14", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", - "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", - "license": "MIT", - "engines": { - "node": ">=12.20.0" - }, - "peerDependencies": { - "@types/react": ">=18.0.0", - "immer": ">=9.0.6", - "react": ">=18.0.0", - "use-sync-external-store": ">=1.2.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - }, - "use-sync-external-store": { - "optional": true - } - } - } - } -} diff --git a/mobile/package.json b/mobile/package.json deleted file mode 100644 index 69c5eee..0000000 --- a/mobile/package.json +++ /dev/null @@ -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" - } -} diff --git a/mobile/src/api/index.js b/mobile/src/api/index.js deleted file mode 100644 index d163f76..0000000 --- a/mobile/src/api/index.js +++ /dev/null @@ -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 || [], - }; -} diff --git a/mobile/src/api/utils.js b/mobile/src/api/utils.js deleted file mode 100644 index 9146e2e..0000000 --- a/mobile/src/api/utils.js +++ /dev/null @@ -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<>>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>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>>(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')}`; -} diff --git a/mobile/src/audio/player.js b/mobile/src/audio/player.js deleted file mode 100644 index 726429a..0000000 --- a/mobile/src/audio/player.js +++ /dev/null @@ -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); -} diff --git a/mobile/src/audio/service.js b/mobile/src/audio/service.js deleted file mode 100644 index f0967e4..0000000 --- a/mobile/src/audio/service.js +++ /dev/null @@ -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(); - }); -} diff --git a/mobile/src/components/AlbumCard.js b/mobile/src/components/AlbumCard.js deleted file mode 100644 index 01e70f9..0000000 --- a/mobile/src/components/AlbumCard.js +++ /dev/null @@ -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 ( - - {art ? ( - - ) : ( - - 💿 - - )} - {dispAlbumName(album)} - - {dispArtistById(album.artistId, album.artist || '')} - - - ); -} - -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, - }, -}); diff --git a/mobile/src/components/PlayerBar.js b/mobile/src/components/PlayerBar.js deleted file mode 100644 index 6999fd9..0000000 --- a/mobile/src/components/PlayerBar.js +++ /dev/null @@ -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 ( - navigation.navigate('NowPlaying')} - > - {/* Progress bar at top */} - - - - - - {currentTrack.coverArt ? ( - - ) : ( - - - - )} - - - {currentTrack.title} - {currentTrack.artist} - - - - - - - - - - - ); -} - -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, - }, -}); diff --git a/mobile/src/components/SongItem.js b/mobile/src/components/SongItem.js deleted file mode 100644 index 7244d8a..0000000 --- a/mobile/src/components/SongItem.js +++ /dev/null @@ -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 ( - - {showArt ? ( - - ) : ( - - {isPlaying ? '▶' : index + 1} - - )} - - - {dispTitle(song)} - - - {dispArtistById(song.artistId, song.artist || '')} - - - {formatDur(song.duration)} - - ); -} - -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, - }, -}); diff --git a/mobile/src/screens/AlbumDetail.js b/mobile/src/screens/AlbumDetail.js deleted file mode 100644 index 3fe6eff..0000000 --- a/mobile/src/screens/AlbumDetail.js +++ /dev/null @@ -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 ( - - s.id} - ListHeaderComponent={() => ( - - {/* Back button */} - navigation.goBack()}> - - - - {/* Album art */} - - {art ? ( - - ) : ( - - 💿 - - )} - - - {/* Info */} - - {albumName} - navigation.navigate('ArtistDetail', { - artist: { id: displayAlbum.artistId, name: displayAlbum.artist || '' } - })}> - {artistName} - - - {displayAlbum.year ? `${displayAlbum.year} · ` : ''} - {songs.length} Titel · {totalMin} Min. - - - - {/* Play buttons */} - - handlePlay(0)}> - - Abspielen - - { - const shuffled = [...songs].sort(() => Math.random() - 0.5); - await playQueue(shuffled, 0); - }}> - - - - - {loading && } - - )} - renderItem={({ item, index }) => ( - handlePlay(index)} - /> - )} - contentContainerStyle={{ paddingBottom: 16 }} - /> - - ); -} - -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', - }, -}); diff --git a/mobile/src/screens/ArtistDetail.js b/mobile/src/screens/ArtistDetail.js deleted file mode 100644 index eb81ed5..0000000 --- a/mobile/src/screens/ArtistDetail.js +++ /dev/null @@ -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 ( - - {/* Hero */} - - {img ? ( - - ) : ( - - 🎤 - - )} - navigation.goBack()}> - - - - {name} - {albums.length} Alben - - - - {loading ? ( - - ) : ( - a.id} - numColumns={2} - renderItem={({ item }) => ( - - navigation.navigate('AlbumDetail', { album: item })} - /> - - )} - contentContainerStyle={{ padding: 4 }} - /> - )} - - ); -} - -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 }, -}); diff --git a/mobile/src/screens/Home.js b/mobile/src/screens/Home.js deleted file mode 100644 index 700dfae..0000000 --- a/mobile/src/screens/Home.js +++ /dev/null @@ -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 ( - - {title} - a.id} - renderItem={({ item }) => ( - onAlbumPress(item)} /> - )} - showsHorizontalScrollIndicator={false} - contentContainerStyle={{ paddingHorizontal: 16 }} - /> - - ); -} - -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 ( - - Kein Server konfiguriert. - navigation.navigate('Settings')}> - Einstellungen öffnen → - - - ); - } - - if (loading) { - return ; - } - - return ( - { setRefreshing(true); load(true); }} tintColor="#1db954" />} - > - - - - - - ); -} - -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 }, -}); diff --git a/mobile/src/screens/Library.js b/mobile/src/screens/Library.js deleted file mode 100644 index 62916e8..0000000 --- a/mobile/src/screens/Library.js +++ /dev/null @@ -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 ; - - return ( - - - - - - a.id} - numColumns={2} - renderItem={({ item }) => ( - navigation.navigate('ArtistDetail', { artist: item })} /> - )} - contentContainerStyle={{ padding: 8 }} - refreshControl={ { setRefreshing(true); load(); }} tintColor="#1db954" />} - /> - - ); -} - -function ArtistCard({ artist, onPress }) { - const [img, setImg] = useState(null); - useEffect(() => { - getArtistImage(artist.id).then(setImg).catch(() => {}); - }, [artist.id]); - - return ( - - {img ? ( - - ) : ( - - 🎤 - - )} - - {dispArtistById(artist.id, artist.name)} - - {artist.albumCount} Alben - - ); -} - -// ─── 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 ; - - return ( - - - - - - a.id} - numColumns={2} - renderItem={({ item }) => ( - - navigation.navigate('AlbumDetail', { album: item })} - /> - - )} - contentContainerStyle={{ padding: 6 }} - refreshControl={ { setRefreshing(true); load(); }} tintColor="#1db954" />} - /> - - ); -} - -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 }, -}); diff --git a/mobile/src/screens/Login.js b/mobile/src/screens/Login.js deleted file mode 100644 index ce36504..0000000 --- a/mobile/src/screens/Login.js +++ /dev/null @@ -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 ( - - - 🎵 NovaSonic - Verbinde dich mit deinem Navidrome-Server - - Server-URL - - - Benutzername - - - Passwort - - - Backend-URL (optional) - - Für Romaji, Downloads und Metadaten-Bearbeitung - - setShowRomaji(v => !v)}> - - - - Romaji / Sort-Tags anzeigen - - - {error ? {error} : null} - - - {loading - ? - : {saved ? '✅ Gespeichert!' : 'Speichern & Verbinden'} - } - - - {cfg.url ? ( - Verbunden mit: {cfg.url} - ) : null} - - - ); -} - -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 }, -}); diff --git a/mobile/src/screens/NowPlaying.js b/mobile/src/screens/NowPlaying.js deleted file mode 100644 index 634c0b9..0000000 --- a/mobile/src/screens/NowPlaying.js +++ /dev/null @@ -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 ( - - Nichts spielt gerade - navigation.goBack()}> - Zurück - - - ); - } - - return ( - - {/* Header */} - - navigation.goBack()} style={styles.headerBtn}> - - - Jetzt läuft - - - - {/* Album art */} - - {currentTrack.artwork ? ( - - ) : ( - - 🎵 - - )} - - - {/* Track info */} - - {currentTrack.title} - {currentTrack.artist} - {currentTrack.album} - - - {/* Progress */} - - - - {formatDur(position)} - {formatDur(duration)} - - - - {/* Controls */} - - setShuffle(!shuffle)} style={styles.sideBtn}> - - - - - - - - - - - - - - - - - - - - - ); -} - -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', - }, -}); diff --git a/mobile/src/screens/Search.js b/mobile/src/screens/Search.js deleted file mode 100644 index 7b985ec..0000000 --- a/mobile/src/screens/Search.js +++ /dev/null @@ -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 {row.label}; - } - if (row.type === 'artist') { - return ( - navigation.navigate('ArtistDetail', { artist: row.item })} - > - 🎤 - {dispArtistById(row.item.id, row.item.name)} - - ); - } - if (row.type === 'album') { - return ( - navigation.navigate('AlbumDetail', { album: row.item })} - > - 💿 - - {dispAlbumName(row.item)} - {dispArtistById(row.item.artistId, row.item.artist || '')} - - - ); - } - if (row.type === 'song') { - return ( - playQueue(results.songs, row.index)} - /> - ); - } - return null; - } - - return ( - - - - doSearch(query)} - /> - {query ? ( - { setQuery(''); setResults(null); }}> - - - ) : null} - - - {loading && } - - {!loading && results && data.length === 0 && ( - Keine Ergebnisse für „{query}" - )} - - {!loading && results && data.length > 0 && ( - r.key} - renderItem={renderItem} - keyboardShouldPersistTaps="handled" - /> - )} - - {!results && !loading && ( - Suche nach Künstlern, Alben oder Titeln - )} - - ); -} - -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 }, -}); diff --git a/mobile/src/store/index.js b/mobile/src/store/index.js deleted file mode 100644 index 2ebf572..0000000 --- a/mobile/src/store/index.js +++ /dev/null @@ -1,64 +0,0 @@ -import { create } from 'zustand'; -import * as SecureStore from 'expo-secure-store'; - -const CFG_KEY = 'novasonic_config'; - -export const useStore = create((set, get) => ({ - // ─── Config ─── - cfg: { - url: '', - user: '', - pass: '', - backendUrl: '', - showRomaji: false, - showPlayCount: false, - }, - configLoaded: false, - - loadConfig: async () => { - try { - const raw = await SecureStore.getItemAsync(CFG_KEY); - if (raw) { - const cfg = JSON.parse(raw); - set({ cfg: { ...get().cfg, ...cfg }, configLoaded: true }); - } else { - set({ configLoaded: true }); - } - } catch { - set({ configLoaded: true }); - } - }, - - saveConfig: async (patch) => { - const cfg = { ...get().cfg, ...patch }; - set({ cfg }); - try { - await SecureStore.setItemAsync(CFG_KEY, JSON.stringify(cfg)); - } catch {} - }, - - // ─── Playback ─── - queue: [], - qIdx: -1, - shuffle: false, - repeat: false, // 'off' | 'all' | 'one' - currentTrack: null, // full song object - isPlaying: false, - progress: { position: 0, duration: 0 }, - - setQueue: (queue, idx) => set({ queue, qIdx: idx }), - setCurrentTrack: (track) => set({ currentTrack: track }), - setIsPlaying: (isPlaying) => set({ isPlaying }), - setProgress: (progress) => set({ progress }), - setShuffle: (shuffle) => set({ shuffle }), - setRepeat: (repeat) => set({ repeat }), - - // ─── Navigation aux ─── - artistImgCache: {}, - setArtistImg: (id, url) => - set(s => ({ artistImgCache: { ...s.artistImgCache, [id]: url } })), - - // ─── Navidrome REST token ─── - _ndToken: null, - setNdToken: (t) => set({ _ndToken: t }), -})); diff --git a/src/themes.js b/src/themes.js index df2dfa3..bad534b 100644 --- a/src/themes.js +++ b/src/themes.js @@ -1,5 +1,5 @@ const THEME_KEY = 'ns_theme'; -const DEFAULT_ID = 'dark-amber'; +const DEFAULT_ID = 'dark-ocean'; export const THEMES = [ { diff --git a/src/views/detail.js b/src/views/detail.js index a2b55cd..dbed21c 100644 --- a/src/views/detail.js +++ b/src/views/detail.js @@ -291,7 +291,7 @@ function _renderArtistAlbums(el, ndAlbums, iAlbums, artist) { const art = itunes?.artworkUrl100?.replace('100x100','512x512') || coverUrl(nd.coverArt, 512); const c = makeCard(nd.name, `${have} / ${total} Tracks`, art, '💿'); c.querySelector('.card-art').onclick = (e) => { e.stopPropagation(); quickPlayAlbum(nd); }; - c.querySelector('.card-info').onclick = () => itunes ? navigate('itunes-album-detail', itunes) : navigate('album-detail', { ...nd, _fromArtist: artist }); + c.querySelector('.card-info').onclick = () => itunes ? navigate('partial-album-detail', { nd, itunes }) : navigate('album-detail', { ...nd, _fromArtist: artist }); const artEl = c.querySelector('.card-art'); const badge = document.createElement('div'); badge.className = 'album-partial-badge'; badge.textContent = `${have}/${total}`; @@ -376,6 +376,75 @@ export async function loadItunesAlbumDetail(album) { } catch(e) { sl.innerHTML = `

${e.message}

`; } } +export async function loadPartialAlbumDetail({ nd, itunes }) { + document.getElementById('partial-album-back-btn').onclick = () => _navGoBack(); + document.getElementById('partial-album-detail-name').textContent = nd.name || '—'; + document.getElementById('partial-album-detail-artist').textContent = nd.artist || itunes?.artistName || ''; + const art = (itunes?.artworkUrl100 || '').replace('100x100bb','600x600bb').replace('100x100','600x600') + || (nd.coverArt ? coverUrl(nd.coverArt, 512) : ''); + const cw = document.getElementById('partial-album-cover-wrap'); + cw.innerHTML = art ? `` : '
💿
'; + + const dlEl = document.getElementById('partial-album-downloaded'); + const missEl = document.getElementById('partial-album-missing'); + const statsEl = document.getElementById('partial-album-detail-stats'); + dlEl.innerHTML = missEl.innerHTML = '
'; + + try { + const [ndRes, iTracks] = await Promise.all([ + api('getAlbum', `&id=${nd.id}`), + itunes?.collectionId ? itunesLookupAlbum(itunes.collectionId) : Promise.resolve([]), + ]); + const ndSongs = (ndRes.album?.song || []).sort((a,b) => (a.track||999)-(b.track||999)); + + // Cross-reference by normalized track name + const norm = s => (s||'').toLowerCase().replace(/[^a-z0-9]/g,'').trim(); + const ndNames = new Set(ndSongs.map(s => norm(s.title))); + const missing = iTracks.filter(t => !ndNames.has(norm(t.trackName))).sort((a,b)=>(a.trackNumber||999)-(b.trackNumber||999)); + + statsEl.textContent = `${ndSongs.length} heruntergeladen · ${missing.length} fehlen`; + + // Play button plays downloaded songs + document.getElementById('partial-album-play-btn').onclick = () => { + if(ndSongs.length) playQueue(ndSongs, 0); + }; + + // Download button offers missing via iTunes + document.getElementById('partial-album-dl-btn').onclick = () => { + if(itunes) openDlModal({ artistName: itunes.artistName, trackName: itunes.collectionName, collectionName: itunes.collectionName, artworkUrl100: itunes.artworkUrl100||'', isAlbum: true, itunesData: itunes, collectionId: itunes.collectionId }); + }; + + // Section: downloaded + dlEl.innerHTML = ''; + if(ndSongs.length) { + const h = document.createElement('div'); + h.className = 'section-title'; + h.style.cssText = 'margin:16px 0 8px'; + h.innerHTML = `Heruntergeladen ${ndSongs.length}`; + dlEl.appendChild(h); + const list = document.createElement('div'); list.className = 'song-list'; dlEl.appendChild(list); + ndSongs.forEach((s,i) => list.appendChild(makeSongItem(s, i, ndSongs))); + } + + // Section: missing + missEl.innerHTML = ''; + if(missing.length) { + const h = document.createElement('div'); + h.className = 'section-title'; + h.style.cssText = 'margin:16px 0 8px;opacity:0.7'; + h.innerHTML = `Nicht heruntergeladen ${missing.length}`; + missEl.appendChild(h); + const list = document.createElement('div'); list.className = 'song-list'; missEl.appendChild(list); + missing.forEach((t,i) => list.appendChild(makeGlobalTrackItem(t, i, missing))); + } else { + missEl.innerHTML = ''; + } + } catch(e) { + dlEl.innerHTML = `

${e.message}

`; + missEl.innerHTML = ''; + } +} + // ─── ALBUM DETAIL ─── export async function loadAlbumDetail(album) { if (S.cfg.showRomaji) await ensureArtistSortMap().catch(() => {}); diff --git a/src/views/navigation.js b/src/views/navigation.js index 79773a8..bda8ff9 100644 --- a/src/views/navigation.js +++ b/src/views/navigation.js @@ -5,7 +5,7 @@ import { loadSpotifyImporter } from './spotify.js'; import { loadSoundCloudImporter } from './soundcloud.js'; // Circular — safe (all calls happen inside function bodies) import { loadHomeData, loadAlbumRow, loadArtists, loadAlbums, loadGenreDetail } from './home.js'; -import { loadArtistDetail, loadArtistAllSongs, loadAlbumDetail, loadItunesAlbumDetail } from './detail.js'; +import { loadArtistDetail, loadArtistAllSongs, loadAlbumDetail, loadItunesAlbumDetail, loadPartialAlbumDetail } from './detail.js'; import { loadPlaylists, loadPlaylistDetail, loadStarred, loadRandom } from './playlists.js'; import { loadNewReleases, loadRecommendations } from './discover.js'; @@ -57,7 +57,8 @@ export function navigate(page, data=null) { if(page==='artist-all-songs' && data) loadArtistAllSongs(data); if(page==='album-detail' && data) loadAlbumDetail(data); if(page==='genre-detail' && data) loadGenreDetail(data); - if(page==='itunes-album-detail'&& data) loadItunesAlbumDetail(data); + if(page==='itunes-album-detail' && data) loadItunesAlbumDetail(data); + if(page==='partial-album-detail' && data) loadPartialAlbumDetail(data); if(page==='new-releases') loadNewReleases(); if(page==='playlists') loadPlaylists(); if(page==='playlist-detail' && data) loadPlaylistDetail(data);