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

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.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 baseUrl = localServer?.awaitBaseUrl() ?: run {
val settings = settingsRepository.userSettings.first()
val port = settings.playbackProxyServerPort
if (port == 0) return null
return "http://127.0.0.1:$port/stream/$trackId"
"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_support">Support</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_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.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<LocalServer>() }.getOrNull()
private suspend fun buildStreamingUrl(trackId: String, protocol: StreamProtocol): String {
val baseUrl = localServerOrNull?.awaitBaseUrl() ?: run {
val port: Int =
settingsProvider.settingsState.mapNotNull { it?.playbackProxyServerPort }.first()
val baseUrl = "http://127.0.0.1:$port"
"http://127.0.0.1:$port"
}
return when (protocol) {
StreamProtocol.HLS, StreamProtocol.DASH -> "${baseUrl.trimEnd('/')}/manifest/$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 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(

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.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<Routes.Welcome> {
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.Welcome::class, Routes.Welcome.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.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 {

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

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_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(

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