mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
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:
parent
b4f25f0e27
commit
cd67806845
@ -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)
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 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"
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user