feat(diagnostics): add in-app logs viewer and fix proxy port mismatch/startup race

Co-Authored-By: pgwiz <pgwiz@users.noreply.github.com>
This commit is contained in:
pgwiz 2026-10-30 03:50:58 +03:00
parent b4f25f0e27
commit cd67806845
13 changed files with 681 additions and 7 deletions

View File

@ -20,6 +20,7 @@ package org.spotube.spotube.core.audioplayer
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.util.Log import android.util.Log
import co.touchlab.kermit.Logger
import androidx.media3.common.AudioAttributes import androidx.media3.common.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
@ -49,6 +50,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
actual val context: Any = context actual val context: Any = context
private val appContext: Context = (context as Context).applicationContext private val appContext: Context = (context as Context).applicationContext
private val kermitLogger = Logger.withTag("AudioPlayer")
private fun ensureServiceStarted() { private fun ensureServiceStarted() {
// A plain start, never startForegroundService: that obliges the service to call // 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) { 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) Log.e("AudioPlayer", "ExoPlayer error: ${error.errorCodeName} (${error.errorCode}): ${error.message}", error)
_error.tryEmit(error) _error.tryEmit(error)
} }

View File

@ -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.search.MetadataSearchResult
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue 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.audioplayer.QueueEntry
import org.spotube.spotube.core.playback.CollectionPlaybackHelper import org.spotube.spotube.core.playback.CollectionPlaybackHelper
import org.spotube.spotube.modules.album.AlbumRepository import org.spotube.spotube.modules.album.AlbumRepository
@ -662,9 +664,12 @@ class MediaBrowseHelper(
} }
private suspend fun buildStreamingUrl(trackId: String): String? { private suspend fun buildStreamingUrl(trackId: String): String? {
val settings = settingsRepository.userSettings.first() val baseUrl = localServer?.awaitBaseUrl() ?: run {
val port = settings.playbackProxyServerPort val settings = settingsRepository.userSettings.first()
if (port == 0) return null val port = settings.playbackProxyServerPort
return "http://127.0.0.1:$port/stream/$trackId" if (port == 0) return null
"http://127.0.0.1:$port"
}
return "${baseUrl.trimEnd('/')}/stream/$trackId"
} }
} }

View File

@ -112,6 +112,17 @@
<string name="settings_section_updates">Updates</string> <string name="settings_section_updates">Updates</string>
<string name="settings_section_support">Support</string> <string name="settings_section_support">Support</string>
<string name="settings_section_about">About</string> <string name="settings_section_about">About</string>
<string name="settings_section_logs">Logs and Diagnostics</string>
<string name="settings_logs_title">Logs and Diagnostics</string>
<string name="settings_logs_subtitle">Inspect player, proxy server, and error logs</string>
<string name="settings_logs_copy">Copy Logs</string>
<string name="settings_logs_clear">Clear Logs</string>
<string name="settings_logs_copied">Copied to clipboard!</string>
<string name="settings_logs_empty">No logs captured yet.</string>
<string name="settings_logs_filter_all">All</string>
<string name="settings_logs_filter_errors">Errors</string>
<string name="settings_logs_filter_warnings">Warnings</string>
<string name="settings_logs_search_hint">Filter by tag or keyword…</string>
<string name="common_close">Close</string> <string name="common_close">Close</string>
<string name="common_save">Save</string> <string name="common_save">Save</string>

View File

@ -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.di.injectLogger
import org.spotube.spotube.core.newpipe.NewPipeService import org.spotube.spotube.core.newpipe.NewPipeService
import org.spotube.spotube.core.newpipe.SearchKind 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.blacklist.BlacklistRepository
import org.spotube.spotube.modules.plugin.PluginProvider import org.spotube.spotube.modules.plugin.PluginProvider
import org.spotube.spotube.modules.settings.SettingsProvider import org.spotube.spotube.modules.settings.SettingsProvider
@ -620,10 +621,15 @@ class DeviceAudioPlayerQueue(
url = url) url = url)
} }
private val localServerOrNull: LocalServer?
get() = runCatching { getKoin().getOrNull<LocalServer>() }.getOrNull()
private suspend fun buildStreamingUrl(trackId: String, protocol: StreamProtocol): String { private suspend fun buildStreamingUrl(trackId: String, protocol: StreamProtocol): String {
val port: Int = val baseUrl = localServerOrNull?.awaitBaseUrl() ?: run {
settingsProvider.settingsState.mapNotNull { it?.playbackProxyServerPort }.first() val port: Int =
val baseUrl = "http://127.0.0.1:$port" settingsProvider.settingsState.mapNotNull { it?.playbackProxyServerPort }.first()
"http://127.0.0.1:$port"
}
return when (protocol) { return when (protocol) {
StreamProtocol.HLS, StreamProtocol.DASH -> "${baseUrl.trimEnd('/')}/manifest/$trackId" StreamProtocol.HLS, StreamProtocol.DASH -> "${baseUrl.trimEnd('/')}/manifest/$trackId"
StreamProtocol.PROGRESSIVE -> "${baseUrl.trimEnd('/')}/stream/$trackId" StreamProtocol.PROGRESSIVE -> "${baseUrl.trimEnd('/')}/stream/$trackId"

View File

@ -21,8 +21,10 @@ import co.touchlab.kermit.Logger
import co.touchlab.kermit.koin.KermitKoinLogger import co.touchlab.kermit.koin.KermitKoinLogger
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
import org.koin.dsl.KoinAppDeclaration import org.koin.dsl.KoinAppDeclaration
import org.spotube.spotube.core.logger.AppLogWriter
fun initKoin(configuration: KoinAppDeclaration? = null) { fun initKoin(configuration: KoinAppDeclaration? = null) {
Logger.addLogWriter(AppLogWriter())
startKoin { startKoin {
configuration?.invoke(this) configuration?.invoke(this)
logger( logger(

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<List<LogEntry>>(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
)
}
}

View File

@ -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.saved_tracks.SavedTracksScreen
import org.spotube.spotube.modules.search.SearchScreen import org.spotube.spotube.modules.search.SearchScreen
import org.spotube.spotube.modules.settings.SettingsScreen 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.settings.JamSettingsScreen
import org.spotube.spotube.modules.welcome.WelcomeScreen import org.spotube.spotube.modules.welcome.WelcomeScreen
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -101,6 +102,9 @@ sealed interface Routes : NavKey {
@Serializable @Serializable
data class LocalFolder(val folderPath: String) : Routes data class LocalFolder(val folderPath: String) : Routes
@Serializable
data object Logs : Routes
} }
@OptIn(KoinExperimentalAPI::class) @OptIn(KoinExperimentalAPI::class)
@ -208,4 +212,7 @@ val navigationModule = module {
navigation<Routes.Welcome> { navigation<Routes.Welcome> {
WelcomeScreen(viewModel = koinViewModel()) WelcomeScreen(viewModel = koinViewModel())
} }
navigation<Routes.Logs> {
LogsScreen()
}
} }

View File

@ -65,6 +65,7 @@ val serializersConfig = SavedStateConfiguration {
subclass(Routes.Jam::class, Routes.Jam.serializer()) subclass(Routes.Jam::class, Routes.Jam.serializer())
subclass(Routes.Welcome::class, Routes.Welcome.serializer()) subclass(Routes.Welcome::class, Routes.Welcome.serializer())
subclass(Routes.LocalFolder::class, Routes.LocalFolder.serializer()) subclass(Routes.LocalFolder::class, Routes.LocalFolder.serializer())
subclass(Routes.Logs::class, Routes.Logs.serializer())
} }
} }
} }

View File

@ -48,6 +48,11 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull 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.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@ -88,6 +93,19 @@ class LocalServer(
port?.let { "http://$HOST_LOCAL:$it" } port?.let { "http://$HOST_LOCAL:$it" }
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) }.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 cachedCacheEnabled = MutableStateFlow(false)
private val streamProxy by lazy { private val streamProxy by lazy {

View File

@ -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.playbackSection
import org.spotube.spotube.modules.settings.sections.pluginsSection import org.spotube.spotube.modules.settings.sections.pluginsSection
import org.spotube.spotube.modules.settings.sections.supportSection 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.settings.sections.updatesSection
import org.spotube.spotube.modules.shell.LocalAppShellBottomInset import org.spotube.spotube.modules.shell.LocalAppShellBottomInset
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@ -161,6 +162,8 @@ private fun CompactSettingsContent(
updatesSection( updatesSection(
settings = settings, settings = settings,
settingsViewModel = settingsViewModel) settingsViewModel = settingsViewModel)
logsSection(
navigatorCommands = navigatorCommands)
} }
} }
} }

View File

@ -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_blacklist_title
import spotube.composeapp.generated.resources.settings_screen_title import spotube.composeapp.generated.resources.settings_screen_title
import spotube.composeapp.generated.resources.settings_section_about 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_appearance
import spotube.composeapp.generated.resources.settings_section_caching import spotube.composeapp.generated.resources.settings_section_caching
import spotube.composeapp.generated.resources.settings_section_desktop import spotube.composeapp.generated.resources.settings_section_desktop
@ -129,6 +131,7 @@ internal enum class SettingsTab(
Desktop(Res.string.settings_section_desktop, Iconsax.IconsaxImportArrow2), Desktop(Res.string.settings_section_desktop, Iconsax.IconsaxImportArrow2),
Support(Res.string.settings_section_support, Iconsax.IconsaxHeart2), Support(Res.string.settings_section_support, Iconsax.IconsaxHeart2),
About(Res.string.settings_section_about, Iconsax.IconsaxInformation), 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()) JamSettingsContent(viewModel = koinViewModel())
} }
SettingsTab.Logs -> {
LogsContent(modifier = Modifier.fillMaxSize())
}
else -> { else -> {
if (settings == null) { if (settings == null) {
Box( Box(

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<LocalServer>() }.getOrNull()
val localServerPort by (localServer?.port?.collectAsStateWithLifecycle() ?: remember { mutableStateOf<Int?>(null) })
val pluginManager = runCatching { koinInject<PluginManager>() }.getOrNull()
val audioPlugin by (pluginManager?.selectedAudioPlugin?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(null) })
val audioPlayerQueue = runCatching { koinInject<AudioPlayerQueue>() }.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)
)
}
}
}
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
)
}
)
}