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.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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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.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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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