diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt
index 8888103f..c7217660 100644
--- a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt
+++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.android.kt
@@ -20,6 +20,7 @@ package org.spotube.spotube.core.audioplayer
import android.content.Context
import android.content.Intent
import android.util.Log
+import co.touchlab.kermit.Logger
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaMetadata
@@ -49,6 +50,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
actual val context: Any = context
private val appContext: Context = (context as Context).applicationContext
+ private val kermitLogger = Logger.withTag("AudioPlayer")
private fun ensureServiceStarted() {
// A plain start, never startForegroundService: that obliges the service to call
@@ -127,6 +129,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
}
override fun onPlayerError(error: PlaybackException) {
+ kermitLogger.e(error) { "ExoPlayer error: ${error.errorCodeName} (${error.errorCode}): ${error.message}" }
Log.e("AudioPlayer", "ExoPlayer error: ${error.errorCodeName} (${error.errorCode}): ${error.message}", error)
_error.tryEmit(error)
}
diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/MediaBrowseHelper.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/MediaBrowseHelper.kt
index c3ba32df..a1d0a0ac 100644
--- a/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/MediaBrowseHelper.kt
+++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/MediaBrowseHelper.kt
@@ -29,6 +29,8 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlayl
import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchResult
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
+import org.spotube.spotube.core.server.LocalServer
+import org.koin.core.component.KoinComponent
import org.spotube.spotube.core.audioplayer.QueueEntry
import org.spotube.spotube.core.playback.CollectionPlaybackHelper
import org.spotube.spotube.modules.album.AlbumRepository
@@ -662,9 +664,12 @@ class MediaBrowseHelper(
}
private suspend fun buildStreamingUrl(trackId: String): String? {
- val settings = settingsRepository.userSettings.first()
- val port = settings.playbackProxyServerPort
- if (port == 0) return null
- return "http://127.0.0.1:$port/stream/$trackId"
+ val baseUrl = localServer?.awaitBaseUrl() ?: run {
+ val settings = settingsRepository.userSettings.first()
+ val port = settings.playbackProxyServerPort
+ if (port == 0) return null
+ "http://127.0.0.1:$port"
+ }
+ return "${baseUrl.trimEnd('/')}/stream/$trackId"
}
}
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index a5481e2e..00471e06 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -112,6 +112,17 @@
Updates
Support
About
+ Logs and Diagnostics
+ Logs and Diagnostics
+ Inspect player, proxy server, and error logs
+ Copy Logs
+ Clear Logs
+ Copied to clipboard!
+ No logs captured yet.
+ All
+ Errors
+ Warnings
+ Filter by tag or keyword…
Close
Save
diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt
index 33f52350..7f65d64c 100644
--- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt
+++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt
@@ -24,6 +24,7 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import org.spotube.spotube.core.di.injectLogger
import org.spotube.spotube.core.newpipe.NewPipeService
import org.spotube.spotube.core.newpipe.SearchKind
+import org.spotube.spotube.core.server.LocalServer
import org.spotube.spotube.modules.blacklist.BlacklistRepository
import org.spotube.spotube.modules.plugin.PluginProvider
import org.spotube.spotube.modules.settings.SettingsProvider
@@ -620,10 +621,15 @@ class DeviceAudioPlayerQueue(
url = url)
}
+ private val localServerOrNull: LocalServer?
+ get() = runCatching { getKoin().getOrNull() }.getOrNull()
+
private suspend fun buildStreamingUrl(trackId: String, protocol: StreamProtocol): String {
- val port: Int =
- settingsProvider.settingsState.mapNotNull { it?.playbackProxyServerPort }.first()
- val baseUrl = "http://127.0.0.1:$port"
+ val baseUrl = localServerOrNull?.awaitBaseUrl() ?: run {
+ val port: Int =
+ settingsProvider.settingsState.mapNotNull { it?.playbackProxyServerPort }.first()
+ "http://127.0.0.1:$port"
+ }
return when (protocol) {
StreamProtocol.HLS, StreamProtocol.DASH -> "${baseUrl.trimEnd('/')}/manifest/$trackId"
StreamProtocol.PROGRESSIVE -> "${baseUrl.trimEnd('/')}/stream/$trackId"
diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Init.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Init.kt
index b0491bc4..343d161c 100644
--- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Init.kt
+++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Init.kt
@@ -21,8 +21,10 @@ import co.touchlab.kermit.Logger
import co.touchlab.kermit.koin.KermitKoinLogger
import org.koin.core.context.startKoin
import org.koin.dsl.KoinAppDeclaration
+import org.spotube.spotube.core.logger.AppLogWriter
fun initKoin(configuration: KoinAppDeclaration? = null) {
+ Logger.addLogWriter(AppLogWriter())
startKoin {
configuration?.invoke(this)
logger(
diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/logger/AppLogBuffer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/logger/AppLogBuffer.kt
new file mode 100644
index 00000000..c7198753
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/logger/AppLogBuffer.kt
@@ -0,0 +1,95 @@
+/*
+ * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.spotube.spotube.core.logger
+
+import co.touchlab.kermit.LogWriter
+import co.touchlab.kermit.Severity
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.datetime.Clock
+import kotlinx.datetime.Instant
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toLocalDateTime
+
+data class LogEntry(
+ val id: Long,
+ val timestamp: Instant = Clock.System.now(),
+ val severity: Severity,
+ val tag: String,
+ val message: String,
+ val throwable: Throwable? = null
+) {
+ val formattedTime: String
+ get() {
+ val dt = timestamp.toLocalDateTime(TimeZone.currentSystemDefault())
+ val h = dt.hour.toString().padStart(2, '0')
+ val m = dt.minute.toString().padStart(2, '0')
+ val s = dt.second.toString().padStart(2, '0')
+ val ms = (dt.nanosecond / 1_000_000).toString().padStart(3, '0')
+ return "$h:$m:$s.$ms"
+ }
+}
+
+object AppLogBuffer {
+ private const val MAX_LOGS = 1000
+ private var counter = 0L
+
+ private val _logs = MutableStateFlow>(emptyList())
+ val logs = _logs.asStateFlow()
+
+ fun append(severity: Severity, tag: String, message: String, throwable: Throwable? = null) {
+ val entry = LogEntry(
+ id = ++counter,
+ severity = severity,
+ tag = tag,
+ message = message,
+ throwable = throwable
+ )
+ _logs.update { list ->
+ if (list.size >= MAX_LOGS) {
+ list.drop(list.size - MAX_LOGS + 1) + entry
+ } else {
+ list + entry
+ }
+ }
+ }
+
+ fun clear() {
+ _logs.value = emptyList()
+ }
+
+ fun formatAllLogs(): String {
+ return _logs.value.joinToString("\n") { entry ->
+ val header = "[${entry.formattedTime}] [${entry.severity.name}] [${entry.tag}] ${entry.message}"
+ val err = entry.throwable?.stackTraceToString()?.let { "\n$it" }.orEmpty()
+ header + err
+ }
+ }
+}
+
+class AppLogWriter : LogWriter() {
+ override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
+ AppLogBuffer.append(
+ severity = severity,
+ tag = tag,
+ message = message,
+ throwable = throwable
+ )
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt
index 0410a281..3f9eeb4f 100644
--- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt
+++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt
@@ -36,6 +36,7 @@ import org.spotube.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID
import org.spotube.spotube.modules.saved_tracks.SavedTracksScreen
import org.spotube.spotube.modules.search.SearchScreen
import org.spotube.spotube.modules.settings.SettingsScreen
+import org.spotube.spotube.modules.settings.logs.LogsScreen
import org.spotube.spotube.modules.settings.JamSettingsScreen
import org.spotube.spotube.modules.welcome.WelcomeScreen
import kotlinx.serialization.Serializable
@@ -101,6 +102,9 @@ sealed interface Routes : NavKey {
@Serializable
data class LocalFolder(val folderPath: String) : Routes
+
+ @Serializable
+ data object Logs : Routes
}
@OptIn(KoinExperimentalAPI::class)
@@ -208,4 +212,7 @@ val navigationModule = module {
navigation {
WelcomeScreen(viewModel = koinViewModel())
}
+ navigation {
+ LogsScreen()
+ }
}
diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt
index 3dc7461e..89b579ab 100644
--- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt
+++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationState.kt
@@ -65,6 +65,7 @@ val serializersConfig = SavedStateConfiguration {
subclass(Routes.Jam::class, Routes.Jam.serializer())
subclass(Routes.Welcome::class, Routes.Welcome.serializer())
subclass(Routes.LocalFolder::class, Routes.LocalFolder.serializer())
+ subclass(Routes.Logs::class, Routes.Logs.serializer())
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt
index 14e4837c..b90affa0 100644
--- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt
+++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt
@@ -48,6 +48,11 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.withTimeoutOrNull
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
@@ -88,6 +93,19 @@ class LocalServer(
port?.let { "http://$HOST_LOCAL:$it" }
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null)
+ suspend fun awaitPort(timeout: Duration = 3.seconds): Int {
+ val current = activePort.value
+ if (current != null) return current
+ return withTimeoutOrNull(timeout) {
+ activePort.filterNotNull().first()
+ } ?: settingsViewModel.settingsState.value?.playbackProxyServerPort ?: 14769
+ }
+
+ suspend fun awaitBaseUrl(timeout: Duration = 3.seconds): String {
+ val port = awaitPort(timeout)
+ return "http://$HOST_LOCAL:$port"
+ }
+
private val cachedCacheEnabled = MutableStateFlow(false)
private val streamProxy by lazy {
diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsScreen.kt
index e79ebe0b..cf8d4453 100644
--- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsScreen.kt
+++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsScreen.kt
@@ -51,6 +51,7 @@ import org.spotube.spotube.modules.settings.sections.languageRegionSection
import org.spotube.spotube.modules.settings.sections.playbackSection
import org.spotube.spotube.modules.settings.sections.pluginsSection
import org.spotube.spotube.modules.settings.sections.supportSection
+import org.spotube.spotube.modules.settings.sections.logsSection
import org.spotube.spotube.modules.settings.sections.updatesSection
import org.spotube.spotube.modules.shell.LocalAppShellBottomInset
import org.jetbrains.compose.resources.stringResource
@@ -161,6 +162,8 @@ private fun CompactSettingsContent(
updatesSection(
settings = settings,
settingsViewModel = settingsViewModel)
+ logsSection(
+ navigatorCommands = navigatorCommands)
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsSidebarLayout.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsSidebarLayout.kt
index 3ff64bac..1c95f1e3 100644
--- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsSidebarLayout.kt
+++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsSidebarLayout.kt
@@ -97,6 +97,8 @@ import spotube.composeapp.generated.resources.common_close
import spotube.composeapp.generated.resources.settings_blacklist_title
import spotube.composeapp.generated.resources.settings_screen_title
import spotube.composeapp.generated.resources.settings_section_about
+import spotube.composeapp.generated.resources.settings_section_logs
+import org.spotube.spotube.modules.settings.logs.LogsContent
import spotube.composeapp.generated.resources.settings_section_appearance
import spotube.composeapp.generated.resources.settings_section_caching
import spotube.composeapp.generated.resources.settings_section_desktop
@@ -129,6 +131,7 @@ internal enum class SettingsTab(
Desktop(Res.string.settings_section_desktop, Iconsax.IconsaxImportArrow2),
Support(Res.string.settings_section_support, Iconsax.IconsaxHeart2),
About(Res.string.settings_section_about, Iconsax.IconsaxInformation),
+ Logs(Res.string.settings_section_logs, Iconsax.IconsaxDocumentText),
}
/**
@@ -283,6 +286,10 @@ private fun SettingsTabContent(
JamSettingsContent(viewModel = koinViewModel())
}
+ SettingsTab.Logs -> {
+ LogsContent(modifier = Modifier.fillMaxSize())
+ }
+
else -> {
if (settings == null) {
Box(
diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/logs/LogsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/logs/LogsScreen.kt
new file mode 100644
index 00000000..23681b0b
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/logs/LogsScreen.kt
@@ -0,0 +1,460 @@
+/*
+ * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.spotube.spotube.modules.settings.logs
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.input.rememberTextFieldState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+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.platform.LocalClipboardManager
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.font.FontFamily
+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 co.touchlab.kermit.Severity
+import com.composeunstyled.Text
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.stringResource
+import org.koin.compose.koinInject
+import org.spotube.millennium.composables.MillenniumButton
+import org.spotube.millennium.composables.MillenniumButtonStyle
+import org.spotube.millennium.composables.MillenniumIcon
+import org.spotube.millennium.composables.MillenniumIconButton
+import org.spotube.millennium.composables.MillenniumIconButtonStyle
+import org.spotube.millennium.composables.MillenniumTextField
+import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
+import org.spotube.spotube.core.logger.AppLogBuffer
+import org.spotube.spotube.core.logger.LogEntry
+import org.spotube.spotube.core.server.LocalServer
+import org.spotube.spotube.core.ui.component.ApplicationMainBar
+import org.spotube.spotube.core.ui.theming.AppTheme
+import org.spotube.spotube.modules.plugin.PluginManager
+import org.spotube.spotube.modules.shell.LocalAppShellBottomInset
+import org.spotube.spotube.resources.iconsax.Iconsax
+import org.spotube.spotube.resources.iconsax.IconsaxDocumentText
+import org.spotube.spotube.resources.iconsax.IconsaxFilterSearch
+import org.spotube.spotube.resources.iconsax.IconsaxShare
+import org.spotube.spotube.resources.iconsax.IconsaxTrash
+import spotube.composeapp.generated.resources.Res
+import spotube.composeapp.generated.resources.settings_logs_clear
+import spotube.composeapp.generated.resources.settings_logs_copied
+import spotube.composeapp.generated.resources.settings_logs_copy
+import spotube.composeapp.generated.resources.settings_logs_empty
+import spotube.composeapp.generated.resources.settings_logs_filter_all
+import spotube.composeapp.generated.resources.settings_logs_filter_errors
+import spotube.composeapp.generated.resources.settings_logs_filter_warnings
+import spotube.composeapp.generated.resources.settings_logs_search_hint
+import spotube.composeapp.generated.resources.settings_logs_title
+
+private enum class LogFilter {
+ ALL,
+ ERRORS,
+ WARNINGS
+}
+
+@Composable
+fun LogsScreen() {
+ Column(modifier = Modifier.fillMaxSize()) {
+ ApplicationMainBar(
+ backButton = true,
+ title = { Text(stringResource(Res.string.settings_logs_title)) }
+ )
+ LogsContent(modifier = Modifier.weight(1f))
+ }
+}
+
+@Composable
+internal fun LogsContent(modifier: Modifier = Modifier) {
+ val logs by AppLogBuffer.logs.collectAsStateWithLifecycle()
+ val shellBottomInset = LocalAppShellBottomInset.current
+ val clipboardManager = LocalClipboardManager.current
+ val coroutineScope = rememberCoroutineScope()
+
+ val localServer = runCatching { koinInject() }.getOrNull()
+ val localServerPort by (localServer?.port?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(null) })
+ val pluginManager = runCatching { koinInject() }.getOrNull()
+ val audioPlugin by (pluginManager?.selectedAudioPlugin?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(null) })
+ val audioPlayerQueue = runCatching { koinInject() }.getOrNull()
+ val currentQueueEntry by (audioPlayerQueue?.currentQueueEntryFlow?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(null) })
+
+ val searchState = rememberTextFieldState("")
+ val searchQuery = searchState.text.toString().trim().lowercase()
+ var selectedFilter by remember { mutableStateOf(LogFilter.ALL) }
+ var copiedFeedback by remember { mutableStateOf(false) }
+
+ val filteredLogs = remember(logs, searchQuery, selectedFilter) {
+ logs.filter { entry ->
+ val matchesSeverity = when (selectedFilter) {
+ LogFilter.ALL -> true
+ LogFilter.ERRORS -> entry.severity == Severity.Error || entry.severity == Severity.Assert
+ LogFilter.WARNINGS -> entry.severity == Severity.Warn || entry.severity == Severity.Error || entry.severity == Severity.Assert
+ }
+ val matchesSearch = if (searchQuery.isBlank()) {
+ true
+ } else {
+ entry.tag.lowercase().contains(searchQuery) || entry.message.lowercase().contains(searchQuery)
+ }
+ matchesSeverity && matchesSearch
+ }.asReversed()
+ }
+
+ val listState = rememberLazyListState()
+
+ Column(modifier = modifier.fillMaxSize().padding(horizontal = 16.dp)) {
+ // Diagnostic status overview card
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .background(AppTheme.colors.surfaceVariant.copy(alpha = 0.5f))
+ .padding(12.dp)
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(
+ text = "System Diagnostics",
+ style = AppTheme.typography.labelLarge,
+ fontWeight = FontWeight.Bold,
+ color = AppTheme.colors.primary
+ )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = "Proxy Server:",
+ style = AppTheme.typography.bodySmall,
+ color = AppTheme.colors.textMuted
+ )
+ Text(
+ text = if (localServerPort != null) "127.0.0.1:$localServerPort (Active)" else "Starting...",
+ style = AppTheme.typography.bodySmall,
+ fontWeight = FontWeight.Medium,
+ color = if (localServerPort != null) AppTheme.colors.primary else AppTheme.colors.textMuted
+ )
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = "Audio Plugin:",
+ style = AppTheme.typography.bodySmall,
+ color = AppTheme.colors.textMuted
+ )
+ Text(
+ text = audioPlugin?.name ?: "NewPipe YouTube (Built-in)",
+ style = AppTheme.typography.bodySmall,
+ fontWeight = FontWeight.Medium,
+ color = AppTheme.colors.onSurface
+ )
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = "Current Track:",
+ style = AppTheme.typography.bodySmall,
+ color = AppTheme.colors.textMuted
+ )
+ Text(
+ text = currentQueueEntry?.let { entry ->
+ when (entry) {
+ is org.spotube.spotube.core.audioplayer.QueueEntry.StreamingTrack -> entry.track.title
+ is org.spotube.spotube.core.audioplayer.QueueEntry.LocalTrack -> entry.name
+ }
+ } ?: "None",
+ style = AppTheme.typography.bodySmall,
+ fontWeight = FontWeight.Medium,
+ color = AppTheme.colors.onSurface
+ )
+ }
+ }
+ }
+
+ // Action & Filter row
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ MillenniumTextField(
+ state = searchState,
+ placeholder = { Text(stringResource(Res.string.settings_logs_search_hint), fontSize = 12.sp) },
+ leadingIcon = {
+ MillenniumIcon(
+ imageVector = Iconsax.IconsaxFilterSearch,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp)
+ )
+ },
+ modifier = Modifier.weight(1f)
+ )
+
+ MillenniumButton(
+ onClick = {
+ val fullLogs = AppLogBuffer.formatAllLogs()
+ clipboardManager.setText(AnnotatedString(fullLogs))
+ copiedFeedback = true
+ coroutineScope.launch {
+ delay(2000)
+ copiedFeedback = false
+ }
+ },
+ style = MillenniumButtonStyle.Secondary
+ ) {
+ MillenniumIcon(
+ imageVector = Iconsax.IconsaxShare,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp)
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = if (copiedFeedback) stringResource(Res.string.settings_logs_copied) else stringResource(Res.string.settings_logs_copy),
+ style = AppTheme.typography.labelSmall
+ )
+ }
+
+ MillenniumIconButton(
+ onClick = { AppLogBuffer.clear() },
+ style = MillenniumIconButtonStyle.Secondary,
+ contentDescription = stringResource(Res.string.settings_logs_clear)
+ ) {
+ MillenniumIcon(
+ imageVector = Iconsax.IconsaxTrash,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp)
+ )
+ }
+ }
+
+ // Filter chips row
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ LogFilterChip(
+ label = stringResource(Res.string.settings_logs_filter_all),
+ selected = selectedFilter == LogFilter.ALL,
+ count = logs.size,
+ onClick = { selectedFilter = LogFilter.ALL }
+ )
+ LogFilterChip(
+ label = stringResource(Res.string.settings_logs_filter_errors),
+ selected = selectedFilter == LogFilter.ERRORS,
+ count = logs.count { it.severity == Severity.Error || it.severity == Severity.Assert },
+ chipColor = Color(0xFFE53935),
+ onClick = { selectedFilter = LogFilter.ERRORS }
+ )
+ LogFilterChip(
+ label = stringResource(Res.string.settings_logs_filter_warnings),
+ selected = selectedFilter == LogFilter.WARNINGS,
+ count = logs.count { it.severity == Severity.Warn },
+ chipColor = Color(0xFFFB8C00),
+ onClick = { selectedFilter = LogFilter.WARNINGS }
+ )
+ }
+
+ // Logs list
+ if (filteredLogs.isEmpty()) {
+ Box(
+ modifier = Modifier.weight(1f).fillMaxWidth(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ MillenniumIcon(
+ imageVector = Iconsax.IconsaxDocumentText,
+ contentDescription = null,
+ tint = AppTheme.colors.textMuted,
+ modifier = Modifier.size(36.dp)
+ )
+ Text(
+ text = stringResource(Res.string.settings_logs_empty),
+ style = AppTheme.typography.bodyMedium,
+ color = AppTheme.colors.textMuted
+ )
+ }
+ }
+ } else {
+ LazyColumn(
+ state = listState,
+ modifier = Modifier.weight(1f).fillMaxWidth(),
+ contentPadding = PaddingValues(top = 4.dp, bottom = 16.dp + shellBottomInset),
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ items(filteredLogs, key = { it.id }) { logEntry ->
+ LogEntryCard(entry = logEntry)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun LogFilterChip(
+ label: String,
+ selected: Boolean,
+ count: Int,
+ chipColor: Color = AppTheme.colors.primary,
+ onClick: () -> Unit
+) {
+ Box(
+ modifier = Modifier
+ .clip(RoundedCornerShape(8.dp))
+ .background(
+ if (selected) chipColor.copy(alpha = 0.2f) else AppTheme.colors.surfaceVariant.copy(alpha = 0.3f)
+ )
+ .clickable(onClick = onClick)
+ .padding(horizontal = 10.dp, vertical = 6.dp)
+ ) {
+ Text(
+ text = "$label ($count)",
+ style = AppTheme.typography.labelSmall,
+ fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
+ color = if (selected) chipColor else AppTheme.colors.onSurface
+ )
+ }
+}
+
+@Composable
+private fun LogEntryCard(entry: LogEntry) {
+ var expanded by remember { mutableStateOf(false) }
+
+ val severityColor = when (entry.severity) {
+ Severity.Error, Severity.Assert -> Color(0xFFE53935)
+ Severity.Warn -> Color(0xFFFB8C00)
+ Severity.Info -> AppTheme.colors.primary
+ Severity.Debug -> AppTheme.colors.textMuted
+ Severity.Verbose -> AppTheme.colors.textMuted.copy(alpha = 0.7f)
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(8.dp))
+ .background(AppTheme.colors.surfaceVariant.copy(alpha = 0.35f))
+ .clickable(enabled = entry.throwable != null) { expanded = !expanded }
+ .padding(8.dp)
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp)
+ ) {
+ // Severity label
+ Box(
+ modifier = Modifier
+ .clip(RoundedCornerShape(4.dp))
+ .background(severityColor.copy(alpha = 0.18f))
+ .padding(horizontal = 4.dp, vertical = 1.dp)
+ ) {
+ Text(
+ text = entry.severity.name.take(4),
+ fontSize = 10.sp,
+ fontWeight = FontWeight.Bold,
+ color = severityColor
+ )
+ }
+
+ // Timestamp
+ Text(
+ text = entry.formattedTime,
+ fontSize = 11.sp,
+ fontFamily = FontFamily.Monospace,
+ color = AppTheme.colors.textMuted
+ )
+
+ // Tag
+ Text(
+ text = "[${entry.tag}]",
+ fontSize = 11.sp,
+ fontFamily = FontFamily.Monospace,
+ fontWeight = FontWeight.SemiBold,
+ color = AppTheme.colors.onSurface
+ )
+ }
+
+ // Message
+ Text(
+ text = entry.message,
+ fontSize = 12.sp,
+ fontFamily = FontFamily.Monospace,
+ color = AppTheme.colors.onSurface,
+ lineHeight = 16.sp
+ )
+
+ // Throwable toggle / stack trace
+ if (entry.throwable != null) {
+ Text(
+ text = if (expanded) "▲ Hide stack trace" else "▼ Show stack trace (${entry.throwable::class.simpleName})",
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Medium,
+ color = severityColor
+ )
+ AnimatedVisibility(visible = expanded) {
+ Text(
+ text = entry.throwable.stackTraceToString(),
+ fontSize = 10.sp,
+ fontFamily = FontFamily.Monospace,
+ color = AppTheme.colors.textMuted,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 4.dp)
+ .clip(RoundedCornerShape(4.dp))
+ .background(Color.Black.copy(alpha = 0.3f))
+ .padding(6.dp)
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/LogsSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/LogsSection.kt
new file mode 100644
index 00000000..dc124e61
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/LogsSection.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.spotube.spotube.modules.settings.sections
+
+import androidx.compose.foundation.lazy.LazyListScope
+import org.spotube.spotube.core.navigation.NavigationCommands
+import org.spotube.spotube.core.navigation.Routes
+import org.spotube.spotube.modules.settings.components.SettingCardItem
+import org.spotube.spotube.resources.iconsax.Iconsax
+import org.spotube.spotube.resources.iconsax.IconsaxDocumentText
+import org.jetbrains.compose.resources.stringResource
+import spotube.composeapp.generated.resources.Res
+import spotube.composeapp.generated.resources.settings_section_logs
+import spotube.composeapp.generated.resources.settings_logs_title
+import spotube.composeapp.generated.resources.settings_logs_subtitle
+
+internal fun LazyListScope.logsSection(
+ navigatorCommands: NavigationCommands,
+ includeHeader: Boolean = true
+) {
+ if (includeHeader) {
+ settingsSectionHeader(Res.string.settings_section_logs)
+ }
+ settingsSectionCard(
+ items = listOf {
+ SettingCardItem(
+ title = stringResource(Res.string.settings_logs_title),
+ subtitle = stringResource(Res.string.settings_logs_subtitle),
+ icon = {
+ SettingsItemIcon(
+ imageVector = Iconsax.IconsaxDocumentText,
+ contentDescription = stringResource(Res.string.settings_logs_title)
+ )
+ },
+ onClick = {
+ navigatorCommands.navigateTo(Routes.Logs)
+ }
+ )
+ }
+ )
+}