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) + } + ) + } + ) +}