From c8ef09f89d260ff4c7987bb7cde43b15dd022ee8 Mon Sep 17 00:00:00 2001 From: spectreseven1138 Date: Thu, 13 Jul 2023 16:04:56 +0100 Subject: [PATCH] Show feed load error, improve ErrorInfoDisplay When getHomeFeed fails to parse the feed JSON, repeat the feed request and include its content in the returned result Display feed load error in UI Add JSON data display to ErrorInfoDisplay Add Paste.ee upload button to ErrorInfoDisplay Block main NowPlaying scrolling from the overlay menu Respect OverlayMenu.closeOnTap Improve ErrorReportActivity layout --- gradle.properties | 35 ++- shared/build.gradle.kts | 3 +- .../spmp/ErrorReportActivity.kt | 80 +++-- .../com/toasterofbread/spmp/PlayerService.kt | 15 +- .../kotlin/com/toasterofbread/spmp/api/Api.kt | 2 + .../com/toasterofbread/spmp/api/HomeFeed.kt | 169 +++++----- .../toasterofbread/spmp/api/LoadMediaitem.kt | 4 +- .../com/toasterofbread/spmp/api/Related.kt | 3 +- .../com/toasterofbread/spmp/api/Search.kt | 2 +- .../spmp/api/model/YoutubeiShelf.kt | 11 +- .../spmp/model/mediaitem/Artist.kt | 4 +- .../spmp/ui/component/ErrorInfoDisplay.kt | 258 ++++++++++++++-- .../spmp/ui/component/LikeDislikeButton.kt | 2 - .../spmp/ui/component/PillMenu.kt | 1 - .../spmp/ui/layout/library/LibraryMainPage.kt | 288 ++++++++++-------- .../spmp/ui/layout/mainpage/MainPage.kt | 165 +++++----- .../ui/layout/mainpage/PlayerStateImpl.kt | 32 +- .../nowplaying/NowPlayingThumbnailRow.kt | 73 +++-- .../overlay/RelatedContentOverlayMenu.kt | 3 +- .../kotlin/com/toasterofbread/utils/Common.kt | 2 +- .../utils/composable/NoRipple.kt | 16 +- .../utils/modifier/disableParentScroll.kt | 26 ++ .../resources/assets/values-ja-JP/strings.xml | 14 +- .../resources/assets/values/strings.xml | 8 +- .../platform/composable/platformClickable.kt | 4 +- 25 files changed, 798 insertions(+), 422 deletions(-) create mode 100644 shared/src/commonMain/kotlin/com/toasterofbread/utils/modifier/disableParentScroll.kt diff --git a/gradle.properties b/gradle.properties index 9a609a4d5..cb97888ef 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,16 +1,25 @@ -org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 -kotlin.code.style=official -android.nonTransitiveRClass=true -android.enableR8.fullMode=true -kotlin.mpp.enableCInteropCommonization=true - -# Android -android.useAndroidX=true +## For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx1024m -XX:MaxPermSize=256m +# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 +# +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +#Wed Jul 12 23:12:00 BST 2023 +agp.version=7.4.2 android.compileSdk=33 -android.targetSdk=33 +android.enableR8.fullMode=true android.minSdk=27 - -# Versions +android.nonTransitiveRClass=true +android.targetSdk=33 +android.useAndroidX=true +compose.version=1.4.1 +kotlin.code.style=official +kotlin.mpp.enableCInteropCommonization=true kotlin.version=1.8.20 -agp.version=7.4.2 -compose.version=1.4.1 \ No newline at end of file +org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M" -Dfile.encoding\=UTF-8 diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 07eab68fc..bcc36e102 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -10,7 +10,8 @@ plugins { val KEY_NAMES = mapOf( "DISCORD_BOT_TOKEN" to "String", "DISCORD_CUSTOM_IMAGES_CHANNEL_CATEGORY" to "Long", - "DISCORD_CUSTOM_IMAGES_CHANNEL_NAME_PREFIX" to "String" + "DISCORD_CUSTOM_IMAGES_CHANNEL_NAME_PREFIX" to "String", + "PASTE_EE_TOKEN" to "String" ) val DEBUG_KEY_NAMES = listOf("YTM_CHANNEL_ID", "YTM_COOKIE", "YTM_HEADERS", "DISCORD_ACCOUNT_TOKEN", "DISCORD_ERROR_REPORT_WEBHOOK") diff --git a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/ErrorReportActivity.kt b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/ErrorReportActivity.kt index 2c11da7ea..912974638 100644 --- a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/ErrorReportActivity.kt +++ b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/ErrorReportActivity.kt @@ -5,11 +5,10 @@ import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.Share @@ -17,14 +16,19 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.beust.klaxon.Klaxon import com.toasterofbread.spmp.platform.PlatformContext import com.toasterofbread.spmp.ui.theme.ApplicationTheme +import com.toasterofbread.spmp.resources.getString +import com.toasterofbread.utils.thenIf import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient @@ -53,12 +57,17 @@ class ErrorReportActivity : ComponentActivity() { setContent { ApplicationTheme(context) { Surface(modifier = Modifier.fillMaxSize()) { + var width by remember { mutableStateOf(0) } Column( Modifier .fillMaxSize() - .padding(10.dp)) { - var stack_wrap_enabled by remember { mutableStateOf(false) } + .padding(10.dp) + .onSizeChanged { + width = it.width + } + ) { + var wrap_text by remember { mutableStateOf(false) } Row( Modifier @@ -67,20 +76,10 @@ class ErrorReportActivity : ComponentActivity() { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { - Column(Modifier.weight(1f)) { - Text("An error occurred", fontSize = 22.sp) - Text(message) - } - - Spacer(Modifier.requiredWidth(10.dp)) - - Row(verticalAlignment = Alignment.CenterVertically) { - Column(horizontalAlignment = Alignment.End) { - Text("Text wrap") - Switch(checked = stack_wrap_enabled, onCheckedChange = { stack_wrap_enabled = it }) - } + Column { + Text(getString("error_message_generic"), fontSize = 22.sp) - Column { + Row { IconButton(onClick = { startActivity(share_intent) }) { Icon(Icons.Outlined.Share, null) } @@ -102,7 +101,7 @@ class ErrorReportActivity : ComponentActivity() { val client = OkHttpClient() val klaxon = Klaxon() - for (chunk in listOf("--------------\nMESSAGE: $message\n\nSTACKTRACE:") + stack_trace.chunked(2000)) { + for (chunk in listOf("---\nMESSAGE: $message\n\nSTACKTRACE:") + stack_trace.chunked(2000)) { val body = klaxon.toJsonString(mapOf( "content" to chunk, "username" to message.take(78) + if (message.length > 78) ".." else "", @@ -122,6 +121,8 @@ class ErrorReportActivity : ComponentActivity() { } response.close() + + delay(500) } } }) { @@ -130,27 +131,42 @@ class ErrorReportActivity : ComponentActivity() { } } } + + Spacer(Modifier.requiredWidth(10.dp)) + + Column(horizontalAlignment = Alignment.End) { + Text(getString("wrap_text_switch_label")) + Switch(checked = wrap_text, onCheckedChange = { wrap_text = it }) + } } Spacer(Modifier.height(10.dp)) - Column( - Modifier - .verticalScroll(rememberScrollState()) - .then( - if (stack_wrap_enabled) Modifier else Modifier.horizontalScroll( - rememberScrollState() - ) - ) - ) { - SelectionContainer { - Text(stack_trace, softWrap = stack_wrap_enabled, fontSize = 15.sp) + // Scroll modifiers don't work here, no idea why + LazyRow { + item { + LazyColumn( + Modifier.thenIf(wrap_text) { + width(with(LocalDensity.current) { width.toDp() }) + }, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + item { + SelectionContainer { + Text(message, softWrap = wrap_text, fontSize = 20.sp) + } + } + item { + SelectionContainer { + Text(stack_trace, softWrap = wrap_text, fontSize = 15.sp) + } + } + } } } } - } } } } -} \ No newline at end of file +} diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/PlayerService.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/PlayerService.kt index e25e1b5a6..95be247c7 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/PlayerService.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/PlayerService.kt @@ -14,6 +14,7 @@ import com.toasterofbread.spmp.model.mediaitem.MediaItemThumbnailProvider import com.toasterofbread.spmp.model.mediaitem.Song import com.toasterofbread.spmp.platform.* import com.toasterofbread.spmp.resources.getString +import com.toasterofbread.spmp.resources.getStringTODO import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore @@ -193,7 +194,7 @@ class PlayerService : MediaPlayerService() { assert(b in 0 until song_count) val offset_b = b + (if (b > a) -1 else 1) - + undoableAction { moveSong(a, b) moveSong(offset_b, a) @@ -242,7 +243,7 @@ class PlayerService : MediaPlayerService() { } fun addMultipleToQueue(songs: List, index: Int = 0, skip_first: Boolean = false, save: Boolean = true, is_active_queue: Boolean = false, skip_existing: Boolean = false) { - val to_add: List = + val to_add: List = if (!skip_existing) { songs } @@ -250,10 +251,10 @@ class PlayerService : MediaPlayerService() { songs.toMutableList().apply { iterateSongs { _, song -> removeAll { it == song } - } + } } } - + if (to_add.isEmpty()) { return } @@ -522,12 +523,10 @@ class PlayerService : MediaPlayerService() { return } - check(song.artist?.title != null) - discord_status_update_job = coroutine_scope.launch { fun formatText(text: String): String { return text - .replace("\$artist", song.artist!!.title!!) + .replace("\$artist", song.artist?.title ?: getStringTODO("Unknown")) .replace("\$song", song.title!!) } @@ -547,7 +546,7 @@ class PlayerService : MediaPlayerService() { }.getOrThrow() } } - catch (e: IOException) { + catch (e: Throwable) { return@launch } diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/Api.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/Api.kt index 306af7460..24ab1cb37 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/Api.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/Api.kt @@ -41,6 +41,8 @@ import org.schabi.newpipe.extractor.downloader.Response as NewPipeResponse const val DEFAULT_CONNECT_TIMEOUT = 3000 val PLAIN_HEADERS = listOf("accept-language", "user-agent", "accept-encoding", "content-encoding", "origin") +class JsonParseException(val json_obj: JsonObject, message: String? = null, cause: Throwable? = null): RuntimeException(message, cause) + fun Result.Companion.failure(response: Response, is_gzip: Boolean = true): Result { var body: String if (is_gzip) { diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/HomeFeed.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/HomeFeed.kt index ad2909e56..1b969d71d 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/HomeFeed.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/HomeFeed.kt @@ -2,6 +2,7 @@ package com.toasterofbread.spmp.api import SpMp import com.beust.klaxon.Json +import com.beust.klaxon.JsonObject import com.toasterofbread.spmp.api.Api.Companion.addYtHeaders import com.toasterofbread.spmp.api.Api.Companion.getStream import com.toasterofbread.spmp.api.Api.Companion.ytUrl @@ -20,14 +21,13 @@ import com.toasterofbread.spmp.model.mediaitem.enums.SongType import com.toasterofbread.spmp.resources.uilocalisation.LocalisedYoutubeString import com.toasterofbread.spmp.ui.component.MediaItemLayout import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.job import kotlinx.coroutines.withContext import okhttp3.Request -import java.io.InputStream import java.time.Duration private val CACHE_LIFETIME = Duration.ofDays(1) +// TODO Why doesn't this return a class? suspend fun getHomeFeed( min_rows: Int = -1, allow_cached: Boolean = true, @@ -62,9 +62,7 @@ suspend fun getHomeFeed( } } - var result: Result? = null - - suspend fun performRequest(ctoken: String?) = withContext(Dispatchers.IO) { + fun performRequest(ctoken: String?): Result { val endpoint = "/youtubei/v1/browse" val request = Request.Builder() .ytUrl(if (ctoken == null) endpoint else "$endpoint?ctoken=$ctoken&continuation=$ctoken&type=next") @@ -76,73 +74,91 @@ suspend fun getHomeFeed( ) .build() - result = Api.request(request).cast { - it.getStream() + val result = Api.request(request) + val stream = result.getOrNull()?.getStream() ?: return result.cast() + + try { + return stream.use { + Result.success(Api.klaxon.parse(it)!!) + } + } + catch (error: Throwable) { + val retry_result = Api.request(request) + val retry_stream = retry_result.getOrNull()?.getStream() ?: return retry_result.cast() + + return retry_stream.use { + Result.failure( + JsonParseException( + Api.klaxon.parseJsonObject(it.reader()).apply { + // Remove unneeded keys from JSON object + + remove("responseContext") + + val items: MutableList = mutableListOf(this) + val keys_to_remove = listOf("trackingParams", "clickTrackingParams", "serializedShareEntity", "serializedContextData", "loggingContext") + + while (items.isNotEmpty()) { + val obj = items.removeLast() + + if (obj is Collection<*>) { + items.addAll(obj as Collection) + continue + } + + check(obj is JsonObject) + + for (key in keys_to_remove) { + obj.remove(key) + } + + for (value in obj.values) { + if (value is JsonObject) { + items.add(value) + } + else if (value is Collection<*>) { + items.addAll(value.filterIsInstance()) + } + } + } + }, + cause = error + ) + ) + } } } - coroutineContext.job.invokeOnCompletion { - result?.getOrNull()?.close() - } + return@withContext kotlin.runCatching { + var data = performRequest(continuation).getOrThrow() - performRequest(continuation) + val rows: MutableList = processRows(data.getShelves(continuation != null), hl).toMutableList() + check(rows.isNotEmpty()) - val response_reader = result!!.fold( - { it }, - { return@withContext Result.failure(it) } - ) + val chips = data.getHeaderChips() - var data: YoutubeiBrowseResponse = try { - Api.klaxon.parse(response_reader)!! - } - catch (e: Throwable) { - return@withContext Result.failure(e) - } - finally { - response_reader.close() - } + var ctoken: String? = data.ctoken + while (min_rows >= 1 && rows.size < min_rows) { + if (ctoken == null) { + break + } - val rows: MutableList = processRows(data.getShelves(continuation != null), hl).toMutableList() - check(rows.isNotEmpty()) + data = performRequest(ctoken).getOrThrow() - val chips = data.getHeaderChips() + val shelves = data.getShelves(true) + check(shelves.isNotEmpty()) + rows.addAll(processRows(shelves, hl)) - var ctoken: String? = data.ctoken - while (min_rows >= 1 && rows.size < min_rows) { - if (ctoken == null) { - break + ctoken = data.ctoken } - performRequest(ctoken) - result!!.onFailure { - return@withContext Result.failure(it) + if (continuation == null) { + Cache.set(rows_cache_key, Api.klaxon.toJsonString(rows).reader(), CACHE_LIFETIME) + Cache.set(ctoken_cache_key, ctoken?.reader(), CACHE_LIFETIME) + Cache.set(chips_cache_key, chips?.let { Api.klaxon.toJsonString(it.map { chip -> listOf(chip.first, chip.second) }).reader() }, CACHE_LIFETIME) } - val reader = result!!.data - data = try { - Api.klaxon.parse(reader)!! - } - catch (e: Throwable) { - return@withContext Result.failure(e) - } - finally { - reader.close() - } - - val shelves = data.getShelves(true) - check(shelves.isNotEmpty()) - rows.addAll(processRows(shelves, hl)) - - ctoken = data.ctoken - } - - if (continuation == null) { - Cache.set(rows_cache_key, Api.klaxon.toJsonString(rows).reader(), CACHE_LIFETIME) - Cache.set(ctoken_cache_key, ctoken?.reader(), CACHE_LIFETIME) - Cache.set(chips_cache_key, chips?.let { Api.klaxon.toJsonString(it.map { chip -> listOf(chip.first, chip.second) }).reader() }, CACHE_LIFETIME) + return@runCatching Triple(rows, ctoken, chips) } - - return@withContext Result.success(Triple(rows, ctoken, chips)) } private fun processRows(rows: List, hl: String): List { @@ -154,8 +170,8 @@ private fun processRows(rows: List, hl: String): List continue - is MusicCarouselShelfRenderer -> { - val header = renderer.header.musicCarouselShelfBasicHeaderRenderer!! + is YoutubeiHeaderContainer -> { + val header = renderer.header?.header_renderer ?: continue fun add( title: LocalisedYoutubeString, @@ -180,10 +196,10 @@ private fun processRows(rows: List, hl: String): List) data class ItemSectionRendererContent(val didYouMeanRenderer: DidYouMeanRenderer? = null) data class DidYouMeanRenderer(val correctedQuery: TextRuns) -data class GridRenderer(val items: List, val header: GridHeader? = null) -data class GridHeader(val gridHeaderRenderer: HeaderRenderer) +data class GridRenderer(val items: List, override val header: GridHeader? = null): YoutubeiHeaderContainer +data class GridHeader(val gridHeaderRenderer: HeaderRenderer): YoutubeiHeader { + override val header_renderer: HeaderRenderer? + get() = gridHeaderRenderer +} data class WatchEndpoint(val videoId: String? = null, val playlistId: String? = null) data class BrowseEndpointContextMusicConfig(val pageType: String) @@ -350,7 +369,7 @@ data class Header( val musicDetailHeaderRenderer: HeaderRenderer? = null, val musicEditablePlaylistDetailHeaderRenderer: MusicEditablePlaylistDetailHeaderRenderer? = null, val musicCardShelfHeaderBasicRenderer: HeaderRenderer? = null -) { +): YoutubeiHeader { fun getRenderer(): HeaderRenderer? { return musicCarouselShelfBasicHeaderRenderer ?: musicImmersiveHeaderRenderer @@ -361,6 +380,9 @@ data class Header( } data class MusicEditablePlaylistDetailHeaderRenderer(val header: Header) + + override val header_renderer: HeaderRenderer? + get() = getRenderer() } //val thumbnails = (header.obj("thumbnail") ?: header.obj("foregroundThumbnail")!!) @@ -368,8 +390,15 @@ data class Header( // .obj("thumbnail")!! // .array("thumbnails")!! +interface YoutubeiHeaderContainer { + val header: YoutubeiHeader? +} +interface YoutubeiHeader { + val header_renderer: HeaderRenderer? +} + data class HeaderRenderer( - val title: TextRuns, + val title: TextRuns? = null, val strapline: TextRuns? = null, val subscriptionButton: SubscriptionButton? = null, val description: TextRuns? = null, @@ -418,17 +447,17 @@ data class MusicShelfRenderer( data class MoreContentButton(val buttonRenderer: ButtonRenderer) data class ButtonRenderer(val navigationEndpoint: NavigationEndpoint) data class MusicCarouselShelfRenderer( - val header: Header, + override val header: Header, val contents: List -) +): YoutubeiHeaderContainer data class MusicDescriptionShelfRenderer(val header: TextRuns, val description: TextRuns) data class MusicCardShelfRenderer( val thumbnail: ThumbnailRenderer, val title: TextRuns, val subtitle: TextRuns, val menu: YoutubeiNextResponse.Menu, - val header: Header -) { + override val header: Header +): YoutubeiHeaderContainer { fun getMediaItem(): MediaItem { val item: MediaItem diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/LoadMediaitem.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/LoadMediaitem.kt index 432aa4223..1ef0e9580 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/LoadMediaitem.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/LoadMediaitem.kt @@ -149,7 +149,7 @@ suspend fun processDefaultResponse(item: MediaItem, data: MediaItemData, respons val header_renderer = parsed.header?.getRenderer() if (header_renderer != null) { - data.supplyTitle(header_renderer.title.first_text, true) + data.supplyTitle(header_renderer.title!!.first_text, true) data.supplyDescription(header_renderer.description?.first_text, true) data.supplyThumbnailProvider(MediaItemThumbnailProvider.fromThumbnails(header_renderer.getThumbnails())) @@ -367,7 +367,7 @@ suspend fun loadMediaItemData( stream.close() if (video_data.videoDetails == null) { - return@run Result.failure(NotImplementedError("videoDetails is null ($item_id)")) + return@run Result.success(Unit) } supplyTitle(video_data.videoDetails.title, true) diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/Related.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/Related.kt index cff3e971e..0b1fb123c 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/Related.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/Related.kt @@ -6,6 +6,7 @@ import com.toasterofbread.spmp.api.Api.Companion.getStream import com.toasterofbread.spmp.api.Api.Companion.ytUrl import com.toasterofbread.spmp.model.mediaitem.MediaItem import com.toasterofbread.spmp.model.mediaitem.Song +import com.toasterofbread.spmp.resources.getString import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.Request @@ -33,7 +34,7 @@ private suspend fun loadBrowseEndpoint(browse_id: String): Result RelatedGroup( - group.title!!.text, + group.title?.text ?: getString("song_related_group_other"), group.getMediaItemsOrNull(hl), group.description ) diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/Search.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/Search.kt index 7f6dcaed4..a7888f7d6 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/Search.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/Search.kt @@ -113,7 +113,7 @@ suspend fun searchYoutubeMusic(query: String, params: String?): Result { - check(!is_for_item) - - if (is_own_channel) { + if (is_for_item || is_own_channel) { return Result.success(Unit) } diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/ErrorInfoDisplay.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/ErrorInfoDisplay.kt index 79e9b0ed2..54444738d 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/ErrorInfoDisplay.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/ErrorInfoDisplay.kt @@ -1,72 +1,278 @@ package com.toasterofbread.spmp.ui.component +import androidx.compose.animation.Crossfade import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource 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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider 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.platform.LocalDensity +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.toasterofbread.spmp.resources.getStringTODO +import com.toasterofbread.spmp.ProjectBuildConfig +import com.toasterofbread.spmp.api.Api +import com.toasterofbread.spmp.api.JsonParseException +import com.toasterofbread.spmp.resources.getString import com.toasterofbread.spmp.ui.theme.Theme -import com.toasterofbread.utils.composable.NoRipple +import com.toasterofbread.utils.composable.ShapedIconButton +import com.toasterofbread.utils.composable.WidthShrinkText import com.toasterofbread.utils.modifier.background -import kotlin.math.roundToInt +import com.toasterofbread.utils.modifier.disableParentScroll +import com.toasterofbread.utils.thenIf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody -// TODO @Composable -fun ErrorInfoDisplay(error: Throwable, modifier: Modifier = Modifier) { +fun ErrorInfoDisplay( + error: Throwable, + modifier: Modifier = Modifier, + message: String? = null, + expanded_modifier: Modifier = Modifier, + onDismiss: (() -> Unit)? = null +) { var expanded: Boolean by remember { mutableStateOf(false) } - val shape = RoundedCornerShape(16.dp) + val shape = RoundedCornerShape(20.dp) - NoRipple { + CompositionLocalProvider(LocalContentColor provides Theme.current.background) { Column( - modifier + (if (expanded) expanded_modifier else modifier) + .heightIn(min = 50.dp) .animateContentSize() .background(shape, Theme.current.accent_provider) - .padding(10.dp) - .clickable { + .padding( + vertical = 3.dp, + horizontal = 10.dp + ), + verticalArrangement = Arrangement.Center + ) { + Row( + Modifier.clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { expanded = !expanded }, - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - val message = if (expanded) null else error.message?.let { " - $it" } - Text(error::class.java.simpleName + (message ?: ""), color = Theme.current.on_accent) + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp) + ) { + Crossfade(expanded) { expanded -> + Icon( + if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + null + ) + } + + WidthShrinkText( + message ?: error::class.java.simpleName, + modifier = Modifier.fillMaxWidth().weight(1f) + ) + + if (onDismiss != null) { + ShapedIconButton( + onDismiss, + shape = shape, + colors = IconButtonDefaults.iconButtonColors( + containerColor = Theme.current.background, + contentColor = Theme.current.on_background + ) + ) { + Icon(Icons.Default.Close, null) + } + } + } if (expanded) { - Column( - Modifier - .fillMaxWidth() - .background(shape, Theme.current.background_provider) - .padding(horizontal = 10.dp) - ) { + ExpandedContent(error, shape) + } + } + } +} + +@Composable +private fun ExpandedContent(error: Throwable, shape: Shape) { + val coroutine_scope = rememberCoroutineScope() + var text_to_show: String? by remember { mutableStateOf(null) } + var wrap_text by remember { mutableStateOf(false) } + val button_colours = ButtonDefaults.buttonColors( + containerColor = Theme.current.accent, + contentColor = Theme.current.background + ) + + Box( + Modifier + .fillMaxWidth() + .clip(shape) + .padding(bottom = 10.dp) + .background(Theme.current.background_provider) + .padding(10.dp) + ) { + CompositionLocalProvider(LocalContentColor provides Theme.current.on_background) { + Column( + Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { Text( - error.stackTraceToString(), - Modifier.verticalScroll(rememberScrollState()) + getString("wrap_text_switch_label"), + color = Theme.current.on_background ) + Switch(wrap_text, { wrap_text = !wrap_text }, Modifier.padding(end = 10.dp)) + + Spacer(Modifier.fillMaxWidth().weight(1f)) - Button({ throw error }) { - Text(getStringTODO("Throw")) + val paste_token = ProjectBuildConfig.PASTE_EE_TOKEN + if (paste_token != null) { + Button( + { + coroutine_scope.launch { + text_to_show = uploadErrorToPasteEe(error, paste_token).getOrElse { it.toString() } + } + }, + colors = button_colours, + contentPadding = PaddingValues(0.dp), + ) { + WidthShrinkText(getString("upload_to_paste_dot_ee"), alignment = TextAlign.Center) + } + } + } + + Crossfade(text_to_show ?: error.stackTraceToString()) { text -> + Column( + Modifier + .disableParentScroll(disable_x = false) + .verticalScroll(rememberScrollState()) + .thenIf(!wrap_text) { horizontalScroll(rememberScrollState()) } + ) { + SelectionContainer { + Text( + text, + color = Theme.current.on_background, + softWrap = wrap_text + ) + } + + if (text.none { it == '\n' }) { + Row { + SpMp.context.CopyShareButtons() { + text + } + } + } + + Spacer(Modifier.height(50.dp)) + } + } + } + + Row(Modifier.align(Alignment.BottomStart), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + val extra_button_text = + if (text_to_show != null) getString("action_cancel") + else when (error) { + is JsonParseException -> getString("error_info_display_show_json_data") + else -> null + } + + if (extra_button_text != null) { + Button( + { + if (text_to_show != null) { + text_to_show = null + } else { + when (error) { + is JsonParseException -> { + text_to_show = error.json_obj.toJsonString(true) + } + } + } + }, + shape = shape, + colors = button_colours + ) { + Text(extra_button_text, softWrap = false) + } + } + + if (ProjectBuildConfig.IS_DEBUG) { + Button( + { throw error }, + colors = button_colours + ) { + Text(getString("throw_error")) } } } } } } + +private suspend fun uploadErrorToPasteEe(error: Throwable, token: String): Result = withContext(Dispatchers.IO) { + val data = mapOf( + "sections" to listOf( + mapOf("name" to "MESSAGE", "contents" to error.message.toString()), + mapOf("name" to "STACKTRACE", "contents" to error.stackTraceToString()), + ) + if (error is JsonParseException) listOf(mapOf("name" to "JSON DATA", "syntax" to "json", "contents" to error.json_obj.toJsonString())) + else emptyList() + ) + + val request = Request.Builder() + .url("https://api.paste.ee/v1/pastes") + .header("X-Auth-Token", token) + .post(Api.klaxon.toJsonString(data).toRequestBody("application/json".toMediaType())) + .build() + + try { + val result = OkHttpClient().newCall(request).execute() + val response = result.use { + Api.klaxon.parseJsonObject(it.body!!.charStream()) + } + + if (response["success"] != true || response["link"] == null) { + return@withContext Result.failure(RuntimeException(response.toJsonString(true))) + } + + return@withContext Result.success(response["link"] as String) + } + catch (e: Throwable) { + return@withContext Result.failure(e) + } +} diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/LikeDislikeButton.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/LikeDislikeButton.kt index a79555753..7e682e603 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/LikeDislikeButton.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/LikeDislikeButton.kt @@ -4,7 +4,6 @@ import SpMp import androidx.compose.animation.Crossfade import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.IndicationInstance import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box @@ -12,7 +11,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ThumbUp import androidx.compose.material.icons.outlined.ThumbUp -import androidx.compose.material.ripple.LocalRippleTheme import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.Icon import androidx.compose.runtime.* diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/PillMenu.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/PillMenu.kt index fcbee0398..37557ceb3 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/PillMenu.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/PillMenu.kt @@ -62,7 +62,6 @@ class PillMenu( private val alongsideContent: (@Composable Action.() -> Unit)? = null, private val modifier: Modifier = Modifier ) { - var top by mutableStateOf(top) var left by mutableStateOf(left) var vertical by mutableStateOf(vertical) diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/library/LibraryMainPage.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/library/LibraryMainPage.kt index 31ef1dcc9..d872c3a0f 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/library/LibraryMainPage.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/library/LibraryMainPage.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer 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.lazy.LazyColumn @@ -33,6 +34,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.toasterofbread.spmp.api.Api @@ -52,170 +54,190 @@ import kotlinx.coroutines.launch private const val LOCAL_SONGS_PREVIEW_AMOUNT = 5 @Composable -fun LibraryMainPage( - downloads: List, - multiselect_context: MediaItemMultiSelectContext, - bottom_padding: Dp, - inline: Boolean, - openPage: (LibrarySubPage?) -> Unit, - topContent: (@Composable () -> Unit)? = null, - onSongClicked: (songs: List, song: Song, index: Int) -> Unit -) { - val heading_text_style = MaterialTheme.typography.headlineSmall - val coroutine_scope = rememberCoroutineScope() +private fun PlaylistsRow(heading_text_style: TextStyle, multiselect_context: MediaItemMultiSelectContext) { val player = LocalPlayerState.current + val ytm_auth = Api.ytm_auth + val coroutine_scope = rememberCoroutineScope() - AnimatedVisibility(!inline && multiselect_context.is_active) { - multiselect_context.InfoDisplay() - } + Column(Modifier.fillMaxWidth().animateContentSize(), horizontalAlignment = Alignment.CenterHorizontally) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text(getString("library_row_playlists"), style = heading_text_style) - LazyColumn( - verticalArrangement = Arrangement.spacedBy(30.dp), - contentPadding = PaddingValues(bottom = bottom_padding) - ) { - if (topContent != null) { - item { - topContent.invoke() + Spacer(Modifier.fillMaxWidth().weight(1f)) + + if (ytm_auth.initialised) { + var loading by remember { mutableStateOf(false) } + + fun loadPlaylists(report: Boolean = false) { + coroutine_scope.launch { + check(!loading) + loading = true + + val result = ytm_auth.loadOwnPlaylists() + if (result.isFailure && report) { + SpMp.reportActionError(result.exceptionOrNull()) + } + + loading = false + } + } + + LaunchedEffect(Unit) { + loadPlaylists() + } + + IconButton({ + if (!loading) { + loadPlaylists(true) + } + }) { + Crossfade(loading) { loading -> + if (loading) { + SubtleLoadingIndicator(Modifier.size(24.dp)) + } else { + Icon(Icons.Default.Refresh, null) + } + } + } + } + + IconButton({ + coroutine_scope.launch { + val playlist = LocalPlaylist.createLocalPlaylist(SpMp.context) + player.openMediaItem(playlist) + } + }) { + Icon(Icons.Default.Add, null) } } - // Playlists - item { - val ytm_auth = Api.ytm_auth + val playlists: MutableList = LocalPlaylist.rememberLocalPlaylistsListener().toMutableList() + val show_likes: Boolean by Settings.KEY_SHOW_LIKES_PLAYLIST.rememberMutableState() - Column(Modifier.fillMaxWidth().animateContentSize(), horizontalAlignment = Alignment.CenterHorizontally) { - Row( - Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Text(getString("library_row_playlists"), style = heading_text_style) + for (id in ytm_auth.own_playlists) { + if (!show_likes && AccountPlaylist.formatId(id) == "LM") { + continue + } + playlists.add(AccountPlaylist.fromId(id)) + } - Spacer(Modifier.fillMaxWidth().weight(1f)) + if (playlists.isNotEmpty()) { + MediaItemGrid( + playlists, + Modifier.fillMaxWidth(), + rows = 1, + multiselect_context = multiselect_context + ) + } else { + Text(getString("library_playlists_empty"), Modifier.padding(top = 10.dp)) + } + } +} - if (ytm_auth.initialised) { - var loading by remember { mutableStateOf(false) } +@Composable +internal fun ArtistsRow( + heading_text_style: TextStyle, + multiselect_context: MediaItemMultiSelectContext, + downloads: List, + openPage: (LibrarySubPage?) -> Unit, + onSongClicked: (songs: List, song: Song, index: Int) -> Unit +) { + val player = LocalPlayerState.current - fun loadPlaylists(report: Boolean = false) { - coroutine_scope.launch { - check(!loading) - loading = true + Column(Modifier.fillMaxWidth().animateContentSize(), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + getString("library_row_local_songs"), + style = heading_text_style + ) - val result = ytm_auth.loadOwnPlaylists() - if (result.isFailure && report) { - SpMp.reportActionError(result.exceptionOrNull()) - } + Spacer(Modifier.fillMaxWidth().weight(1f)) - loading = false - } - } + IconButton({ openPage(LibrarySubPage.SONGS) }) { + Icon(Icons.Default.MoreHoriz, null) + } + } - LaunchedEffect(Unit) { - loadPlaylists() - } - IconButton({ - if (!loading) { - loadPlaylists(true) - } - }) { - Crossfade(loading) { loading -> - if (loading) { - SubtleLoadingIndicator(Modifier.size(24.dp)) - } else { - Icon(Icons.Default.Refresh, null) - } - } - } - } + if (downloads.isNotEmpty()) { + CompositionLocalProvider(LocalPlayerState provides remember { player.copy(onClickedOverride = { item, index -> + onSongClicked( + downloads.mapNotNull { if (it.progress < 1f) null else it.song }, + item as Song, + index!! + ) + }) }) { + var shown_songs = 0 - IconButton({ - coroutine_scope.launch { - val playlist = LocalPlaylist.createLocalPlaylist(SpMp.context) - player.openMediaItem(playlist) - } - }) { - Icon(Icons.Default.Add, null) + for (download in downloads) { + if (download.progress < 1f) { + continue } - } - val playlists: MutableList = LocalPlaylist.rememberLocalPlaylistsListener().toMutableList() - val show_likes: Boolean by Settings.KEY_SHOW_LIKES_PLAYLIST.rememberMutableState() + val song = download.song + song.PreviewLong(MediaItemPreviewParams(multiselect_context = multiselect_context), queue_index = shown_songs) - for (id in ytm_auth.own_playlists) { - if (!show_likes && AccountPlaylist.formatId(id) == "LM") { - continue + if (++shown_songs == LOCAL_SONGS_PREVIEW_AMOUNT) { + break } - playlists.add(AccountPlaylist.fromId(id)) } - if (playlists.isNotEmpty()) { - MediaItemGrid( - playlists, - Modifier.fillMaxWidth(), - rows = 1, - multiselect_context = multiselect_context + if (shown_songs == LOCAL_SONGS_PREVIEW_AMOUNT) { + val total_songs = downloads.count { it.progress >= 1f } + Text( + getString("library_x_more_songs").replace("\$x", (total_songs - shown_songs).toString()), + Modifier + .padding(top = 5.dp) + .align(Alignment.CenterHorizontally) + .clickable { openPage(LibrarySubPage.SONGS) } ) - } else { - Text(getString("library_playlists_empty"), Modifier.padding(top = 10.dp)) } } + } else { + Text("No songs downloaded", Modifier.padding(top = 10.dp).align(Alignment.CenterHorizontally)) } + } +} - // Songs - item { - Column(Modifier.fillMaxWidth().animateContentSize(), verticalArrangement = Arrangement.spacedBy(10.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - getString("library_row_local_songs"), - style = heading_text_style - ) - - Spacer(Modifier.fillMaxWidth().weight(1f)) - - IconButton({ openPage(LibrarySubPage.SONGS) }) { - Icon(Icons.Default.MoreHoriz, null) - } - } - +@Composable +fun LibraryMainPage( + downloads: List, + multiselect_context: MediaItemMultiSelectContext, + bottom_padding: Dp, + inline: Boolean, + openPage: (LibrarySubPage?) -> Unit, + topContent: (@Composable () -> Unit)? = null, + onSongClicked: (songs: List, song: Song, index: Int) -> Unit +) { + val spacing = 20.dp + val heading_text_style = MaterialTheme.typography.headlineSmall - if (downloads.isNotEmpty()) { - CompositionLocalProvider(LocalPlayerState provides remember { player.copy(onClickedOverride = { item, index -> - onSongClicked( - downloads.mapNotNull { if (it.progress < 1f) null else it.song }, - item as Song, - index!! - ) - }) }) { - var shown_songs = 0 + AnimatedVisibility(!inline && multiselect_context.is_active) { + multiselect_context.InfoDisplay() + } - for (download in downloads) { - if (download.progress < 1f) { - continue - } + LazyColumn(contentPadding = PaddingValues(bottom = bottom_padding)) { + if (topContent != null) { + item { + topContent.invoke() + } + } - val song = download.song - song.PreviewLong(MediaItemPreviewParams(multiselect_context = multiselect_context), queue_index = shown_songs) + // Playlists + item { + PlaylistsRow(heading_text_style, multiselect_context) + } - if (++shown_songs == LOCAL_SONGS_PREVIEW_AMOUNT) { - break - } - } + item { + Spacer(Modifier.height(spacing)) + } - if (shown_songs == LOCAL_SONGS_PREVIEW_AMOUNT) { - val total_songs = downloads.count { it.progress >= 1f } - Text( - getString("library_x_more_songs").replace("\$x", (total_songs - shown_songs).toString()), - Modifier - .padding(top = 5.dp) - .align(Alignment.CenterHorizontally) - .clickable { openPage(LibrarySubPage.SONGS) } - ) - } - } - } else { - Text("No songs downloaded", Modifier.padding(top = 10.dp).align(Alignment.CenterHorizontally)) - } - } + // Songs + item { + ArtistsRow(heading_text_style, multiselect_context, downloads, openPage, onSongClicked) } } } diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/mainpage/MainPage.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/mainpage/MainPage.kt index 2afa2ef55..013d19ed6 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/mainpage/MainPage.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/mainpage/MainPage.kt @@ -2,6 +2,7 @@ package com.toasterofbread.spmp.ui.layout.mainpage import LocalPlayerState import SpMp +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.animateDpAsState @@ -9,6 +10,7 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -23,7 +25,6 @@ import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -40,6 +41,9 @@ import com.toasterofbread.spmp.model.mediaitem.Artist import com.toasterofbread.spmp.model.mediaitem.MediaItemHolder import com.toasterofbread.spmp.platform.composable.SwipeRefresh import com.toasterofbread.spmp.platform.getDefaultHorizontalPadding +import com.toasterofbread.spmp.platform.getDefaultVerticalPadding +import com.toasterofbread.spmp.resources.getString +import com.toasterofbread.spmp.ui.component.ErrorInfoDisplay import com.toasterofbread.spmp.ui.component.MediaItemLayout import com.toasterofbread.spmp.ui.component.PillMenu import com.toasterofbread.spmp.ui.component.WAVE_BORDER_DEFAULT_HEIGHT @@ -52,6 +56,7 @@ fun MainPage( getLayouts: () -> List, scroll_state: LazyListState, feed_load_state: MutableState, + feed_load_error: Throwable?, can_continue_feed: Boolean, getFilterChips: () -> List>?, getSelectedFilterChip: () -> Int?, @@ -80,7 +85,7 @@ fun MainPage( Column(Modifier.padding(horizontal = padding)) { - val top_padding = WAVE_BORDER_DEFAULT_HEIGHT.dp * 0.5f + val top_padding = WAVE_BORDER_DEFAULT_HEIGHT.dp MainPageTopBar( Api.ytm_auth, getFilterChips, @@ -96,19 +101,18 @@ fun MainPage( swipe_enabled = feed_load_state.value == FeedLoadState.NONE, indicator = false ) { - val layouts_empty by remember { derivedStateOf { getLayouts().isEmpty() } } - val state = if (feed_load_state.value == FeedLoadState.LOADING || feed_load_state.value == FeedLoadState.PREINIT) null else !layouts_empty - var current_state by remember { mutableStateOf(state) } + val target_state = if (feed_load_state.value == FeedLoadState.LOADING || feed_load_state.value == FeedLoadState.PREINIT) null else getLayouts().ifEmpty { feed_load_error ?: false } + var current_state by remember { mutableStateOf(target_state) } val state_alpha = remember { Animatable(1f) } - LaunchedEffect(state) { - if (current_state == state) { + LaunchedEffect(target_state) { + if (current_state == target_state) { state_alpha.animateTo(1f, tween(300)) return@LaunchedEffect } state_alpha.animateTo(0f, tween(300)) - current_state = state + current_state = target_state state_alpha.animateTo(1f, tween(300)) } @@ -118,79 +122,102 @@ fun MainPage( MainPageScrollableTopContent(pinned_items, Modifier.padding(bottom = 10.dp), top_content_visible) } - when (current_state) { - // Loaded - true -> { - val onContinuationRequested = if (can_continue_feed) { - { loadFeed(getSelectedFilterChip(), true) } - } else null - val loading_continuation = feed_load_state.value != FeedLoadState.NONE - - LazyColumn( - Modifier.graphicsLayer { alpha = state_alpha.value }, - state = scroll_state, - contentPadding = PaddingValues( - bottom = LocalPlayerState.current.bottom_padding, - top = top_padding - ), - userScrollEnabled = !state_alpha.isRunning, - verticalArrangement = Arrangement.spacedBy(20.dp) - ) { - if (top_content_visible || artists_layout.items.isNotEmpty()) { - item { - Column { - TopContent() - if (artists_layout.items.isNotEmpty()) { - artists_layout.Layout(multiselect_context = player.main_multiselect_context) + current_state.also { state -> + when (state) { + // Loaded + is List<*> -> { + val onContinuationRequested = if (can_continue_feed) { + { loadFeed(getSelectedFilterChip(), true) } + } else null + val loading_continuation = feed_load_state.value != FeedLoadState.NONE + + LazyColumn( + Modifier.graphicsLayer { alpha = state_alpha.value }, + state = scroll_state, + contentPadding = PaddingValues( + bottom = LocalPlayerState.current.bottom_padding, + top = top_padding + ), + userScrollEnabled = !state_alpha.isRunning, + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + if (top_content_visible || artists_layout.items.isNotEmpty()) { + item { + Column { + TopContent() + if (artists_layout.items.isNotEmpty()) { + artists_layout.Layout(multiselect_context = player.main_multiselect_context) + } } } } - } - itemsIndexed(getLayouts()) { index, layout -> - if (layout.items.isEmpty()) { - return@itemsIndexed - } + itemsIndexed(state as List) { index, layout -> + if (layout.items.isEmpty()) { + return@itemsIndexed + } - val type = layout.type ?: MediaItemLayout.Type.GRID - type.Layout(layout, multiselect_context = player.main_multiselect_context) - } + val type = layout.type ?: MediaItemLayout.Type.GRID + type.Layout(layout, multiselect_context = player.main_multiselect_context) + } - item { - Crossfade(Pair(onContinuationRequested, loading_continuation)) { data -> - Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.Start) { - if (data.second) { - CircularProgressIndicator(color = Theme.current.on_background) - } - else if (data.first != null) { - IconButton({ data.first!!.invoke() }) { - Icon(Icons.Filled.KeyboardDoubleArrowDown, null, tint = Theme.current.on_background) + item { + Crossfade(Pair(onContinuationRequested, loading_continuation)) { data -> + Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.Start) { + if (data.second) { + CircularProgressIndicator(color = Theme.current.on_background) + } + else if (data.first != null) { + IconButton({ data.first!!.invoke() }) { + Icon(Icons.Filled.KeyboardDoubleArrowDown, null, tint = Theme.current.on_background) + } } } } } } } - } - // Offline - false -> { - LibraryPage( - pill_menu, - LocalPlayerState.current.bottom_padding, - Modifier - .graphicsLayer { alpha = state_alpha.value } - .padding(top = top_padding), - close = {}, - inline = true, - outer_multiselect_context = LocalPlayerState.current.main_multiselect_context, - mainTopContent = { TopContent() } - ) - } - // Loading - null -> { - Column(Modifier.fillMaxSize()) { - TopContent() - MainPageLoadingView(Modifier.graphicsLayer { alpha = state_alpha.value }.fillMaxSize()) + + // Loading + null -> { + Column(Modifier.fillMaxSize()) { + TopContent() + MainPageLoadingView(Modifier.graphicsLayer { alpha = state_alpha.value }.fillMaxSize()) + } + } + + // Offline + else -> { + var error_dismissed by remember { mutableStateOf(false) } + + Column( + Modifier + .graphicsLayer { alpha = state_alpha.value } + .padding(top = top_padding) + .fillMaxHeight() + ) { + if (state is Throwable) { + AnimatedVisibility(!error_dismissed) { + ErrorInfoDisplay( + state, + expanded_modifier = Modifier.padding(bottom = SpMp.context.getDefaultVerticalPadding()), + message = getString("error_yt_feed_parse_failed"), + onDismiss = { + error_dismissed = true + } + ) + } + } + + LibraryPage( + pill_menu, + LocalPlayerState.current.bottom_padding, + close = {}, + inline = true, + outer_multiselect_context = LocalPlayerState.current.main_multiselect_context, + mainTopContent = { TopContent() } + ) + } } } } diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/mainpage/PlayerStateImpl.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/mainpage/PlayerStateImpl.kt index da1257394..01af93407 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/mainpage/PlayerStateImpl.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/mainpage/PlayerStateImpl.kt @@ -17,7 +17,6 @@ import androidx.compose.ui.unit.* import com.toasterofbread.spmp.PlayerService import com.toasterofbread.spmp.api.cast import com.toasterofbread.spmp.api.getHomeFeed -import com.toasterofbread.spmp.api.getOrThrowHere import com.toasterofbread.spmp.model.* import com.toasterofbread.spmp.model.mediaitem.* import com.toasterofbread.spmp.platform.* @@ -507,6 +506,7 @@ class PlayerStateImpl(private val context: PlatformContext): PlayerState(null, n private val main_page_scroll_state = LazyListState() private var main_page_layouts: List? by mutableStateOf(null) + private var main_page_load_error: Throwable? by mutableStateOf(null) private var main_page_filter_chips: List>? by mutableStateOf(null) private var main_page_selected_filter_chip: Int? by mutableStateOf(null) @@ -518,8 +518,7 @@ class PlayerStateImpl(private val context: PlatformContext): PlayerState(null, n min_rows: Int, allow_cached: Boolean, continue_feed: Boolean, - filter_chip: Int? = null, - report_error: Boolean = false + filter_chip: Int? = null ): Result = withContext(Dispatchers.IO) { main_page_selected_filter_chip = filter_chip @@ -558,9 +557,7 @@ class PlayerStateImpl(private val context: PlatformContext): PlayerState(null, n feed_continuation = cont }, { error -> - if (report_error) { - SpMp.reportActionError(error) - } + main_page_load_error = error main_page_layouts = emptyList() main_page_filter_chips = null } @@ -615,13 +612,14 @@ class PlayerStateImpl(private val context: PlatformContext): PlayerState(null, n { main_page_layouts ?: emptyList() }, main_page_scroll_state, feed_load_state, + main_page_load_error, remember { derivedStateOf { feed_continuation != null } }.value, { main_page_filter_chips }, { main_page_selected_filter_chip }, pill_menu, { filter_chip: Int?, continuation: Boolean -> feed_coroutine_scope.launchSingle { - loadFeed(-1, false, continuation, filter_chip, report_error = true) + loadFeed(-1, false, continuation, filter_chip) } } ) @@ -659,13 +657,19 @@ class PlayerStateImpl(private val context: PlatformContext): PlayerState(null, n } } -private suspend fun loadFeedLayouts(min_rows: Int, allow_cached: Boolean, params: String?, continuation: String? = null): Result, String?, List>?>> { - val result = getHomeFeed(allow_cached = allow_cached, min_rows = min_rows, params = params, continuation = continuation) - - if (!result.isSuccess) { - return result.cast() - } +private suspend fun loadFeedLayouts( + min_rows: Int, + allow_cached: Boolean, + params: String?, + continuation: String? = null, +): Result, String?, List>?>> { + val result = getHomeFeed( + allow_cached = allow_cached, + min_rows = min_rows, + params = params, + continuation = continuation + ) - val (row_data, new_continuation, chips) = result.getOrThrowHere() + val (row_data, new_continuation, chips) = result.getOrNull() ?: return result.cast() return Result.success(Triple(row_data.filter { it.items.isNotEmpty() }, new_continuation, chips)) } diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/nowplaying/NowPlayingThumbnailRow.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/nowplaying/NowPlayingThumbnailRow.kt index fe59d3e1c..d47a83855 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/nowplaying/NowPlayingThumbnailRow.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/nowplaying/NowPlayingThumbnailRow.kt @@ -3,12 +3,15 @@ package com.toasterofbread.spmp.ui.layout.nowplaying import LocalPlayerState import SpMp import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image -import androidx.compose.foundation.background +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -19,7 +22,9 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.requiredWidth import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Pause import androidx.compose.material.icons.rounded.PlayArrow @@ -61,6 +66,8 @@ import com.toasterofbread.spmp.ui.layout.nowplaying.overlay.DEFAULT_THUMBNAIL_RO import com.toasterofbread.spmp.ui.layout.nowplaying.overlay.MainOverlayMenu import com.toasterofbread.utils.composable.OnChangedEffect import com.toasterofbread.utils.getInnerSquareSizeOfCircle +import com.toasterofbread.utils.modifier.background +import com.toasterofbread.utils.modifier.disableParentScroll import com.toasterofbread.utils.setAlpha import kotlin.math.absoluteValue import kotlin.math.min @@ -144,31 +151,21 @@ fun ThumbnailRow( .onSizeChanged { image_size = it } - .pointerInput(Unit) { - detectTapGestures( - onTap = { offset -> - colourpick_callback?.also { callback -> - current_thumb_image?.also { image -> - handleColourPick(image, image_size, offset, callback) - return@detectTapGestures - }} - - if (expansion.get() in 0.9f .. 1.1f) { - overlay_menu = - if (overlay_menu == null) - MainOverlayMenu( - { overlay_menu = it }, - { colourpick_callback = it }, - { - setThemeColour(it) - overlay_menu = null - }, - { SpMp.context.getScreenWidth() } - ) - else null - } - } - ) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + if (overlay_menu == null && expansion.get() in 0.9f .. 1.1f) { + overlay_menu = MainOverlayMenu( + { overlay_menu = it }, + { colourpick_callback = it }, + { + setThemeColour(it) + overlay_menu = null + }, + { SpMp.context.getScreenWidth() } + ) + } } ) } @@ -176,16 +173,36 @@ fun ThumbnailRow( // Thumbnail overlay menu androidx.compose.animation.AnimatedVisibility( overlay_menu != null, + Modifier.fillMaxSize(), enter = fadeIn(tween(OVERLAY_MENU_ANIMATION_DURATION)), exit = fadeOut(tween(OVERLAY_MENU_ANIMATION_DURATION)) ) { + val overlay_background_alpha by animateFloatAsState(if (colourpick_callback != null) 0.4f else 0.85f) + Box( Modifier + .disableParentScroll(child_does_not_scroll = true) + .pointerInput(Unit) { + detectTapGestures( + onTap = { offset -> + colourpick_callback?.also { callback -> + current_thumb_image?.also { image -> + handleColourPick(image, image_size, offset, callback) + return@detectTapGestures + } + } + + if (expansion.get() in 0.9f .. 1.1f && overlay_menu?.closeOnTap() == true) { + overlay_menu = null + } + } + ) + } .graphicsLayer { alpha = expansion.getAbsolute() } .fillMaxSize() .background( - Color.DarkGray.setAlpha(0.85f), - shape = thumbnail_shape + thumbnail_shape, + { Color.DarkGray.setAlpha(overlay_background_alpha) } ), contentAlignment = Alignment.Center ) { diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/nowplaying/overlay/RelatedContentOverlayMenu.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/nowplaying/overlay/RelatedContentOverlayMenu.kt index 18368f7de..5158b75ae 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/nowplaying/overlay/RelatedContentOverlayMenu.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/nowplaying/overlay/RelatedContentOverlayMenu.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.unit.dp import com.toasterofbread.spmp.model.mediaitem.Song import com.toasterofbread.spmp.ui.component.PillMenu import com.toasterofbread.spmp.ui.layout.SongRelatedPage +import com.toasterofbread.spmp.ui.layout.nowplaying.getNPBackground import com.toasterofbread.spmp.ui.theme.Theme import com.toasterofbread.utils.setAlpha @@ -43,7 +44,7 @@ class RelatedContentOverlayMenu : OverlayMenu() { description_text_style = MaterialTheme.typography.bodyMedium, close = { openMenu(null) }, padding = PaddingValues(10.dp), - accent_colour = LocalContentColor.current.setAlpha(0.75f) + accent_colour = getNPBackground() ) pill_menu.PillMenu( diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/utils/Common.kt b/shared/src/commonMain/kotlin/com/toasterofbread/utils/Common.kt index 5c795416e..3fcd2ad82 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/utils/Common.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/utils/Common.kt @@ -105,7 +105,7 @@ fun PaddingValues.copy( fun Modifier.thenIf(condition: Boolean, modifier: Modifier): Modifier = if (condition) then(modifier) else this @Composable -fun Modifier.thenIf(condition: Boolean, modifierProvider: @Composable () -> Modifier): Modifier = if (condition) then(modifierProvider()) else this +fun Modifier.thenIf(condition: Boolean, modifierProvider: @Composable Modifier.() -> Modifier): Modifier = if (condition) modifierProvider() else this fun MutableList.addUnique(item: T): Boolean { if (!contains(item)) { diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/utils/composable/NoRipple.kt b/shared/src/commonMain/kotlin/com/toasterofbread/utils/composable/NoRipple.kt index 2fcfb9404..b988dfa95 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/utils/composable/NoRipple.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/utils/composable/NoRipple.kt @@ -7,15 +7,17 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.graphics.Color +val EmptyRippleTheme = object : RippleTheme { + @Composable + override fun defaultColor() = Color.Unspecified + + @Composable + override fun rippleAlpha(): RippleAlpha = RippleAlpha(0f, 0f, 0f, 0f) +} + @Composable fun NoRipple(content: @Composable () -> Unit) { - CompositionLocalProvider(LocalRippleTheme provides object : RippleTheme { - @Composable - override fun defaultColor() = Color.Unspecified - - @Composable - override fun rippleAlpha(): RippleAlpha = RippleAlpha(0f, 0f, 0f, 0f) - }) { + CompositionLocalProvider(LocalRippleTheme provides EmptyRippleTheme) { content() } } diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/utils/modifier/disableParentScroll.kt b/shared/src/commonMain/kotlin/com/toasterofbread/utils/modifier/disableParentScroll.kt new file mode 100644 index 000000000..473d69025 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/toasterofbread/utils/modifier/disableParentScroll.kt @@ -0,0 +1,26 @@ +package com.toasterofbread.utils.modifier + +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import com.toasterofbread.utils.thenIf + +@Composable +fun Modifier.disableParentScroll(disable_x: Boolean = true, disable_y: Boolean = true, child_does_not_scroll: Boolean = false) = + nestedScroll(remember { object : NestedScrollConnection { + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { + return Offset( + if (disable_x) available.x else 0f, + if (disable_y) available.y else 0f + ) + } + } }) + .thenIf(child_does_not_scroll) { + verticalScroll(rememberScrollState()) + } diff --git a/shared/src/commonMain/resources/assets/values-ja-JP/strings.xml b/shared/src/commonMain/resources/assets/values-ja-JP/strings.xml index 2384f6ea0..4c58a58ee 100644 --- a/shared/src/commonMain/resources/assets/values-ja-JP/strings.xml +++ b/shared/src/commonMain/resources/assets/values-ja-JP/strings.xml @@ -10,11 +10,17 @@ アルバム 停止 再生 - 関係 プレイヤーサービス 歌詞 プチリリ - エラーが発生しました + + エラーが発生しました + YouTubeフィード解析に失敗しました + + スロー + 折り返す + JSONデータを表示 + Paste.eeにアップロード 入力は範囲外です ($range) 入力は整数ではありません @@ -33,9 +39,7 @@ Default gradient depth フィードをロード中 - プレイヤー アーティスト - 曲キュー 一般 インタフェース言語 データ言語 @@ -390,6 +394,8 @@ 再生リストでの位置: $index ID: $id + その他 + $xアイテムが選択されてます 複数選択を始める 複数選択をやめる diff --git a/shared/src/commonMain/resources/assets/values/strings.xml b/shared/src/commonMain/resources/assets/values/strings.xml index f9f9bc2b6..1bfa8bde2 100644 --- a/shared/src/commonMain/resources/assets/values/strings.xml +++ b/shared/src/commonMain/resources/assets/values/strings.xml @@ -150,7 +150,13 @@ Line sync Word sync - A error occurred + A error occurred + YouTube feed parsing failed + + Throw + Wrap text + Upload to Paste.ee + Show JSON data Enabling accessibility service Service can be enabled automatically by granting write secure settings permission using root diff --git a/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/composable/platformClickable.kt b/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/composable/platformClickable.kt index 417e6e6c1..d21cae3bf 100644 --- a/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/composable/platformClickable.kt +++ b/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/composable/platformClickable.kt @@ -12,9 +12,9 @@ import com.toasterofbread.utils.thenIf @OptIn(ExperimentalFoundationApi::class) @Composable actual fun Modifier.platformClickable(onClick: (() -> Unit)?, onAltClick: (() -> Unit)?, indication: Indication?): Modifier = - this.thenIf(onClick != null) { Modifier.onClick(onClick = onClick!!) } + this.thenIf(onClick != null) { onClick(onClick = onClick!!) } .thenIf(onAltClick != null) { - Modifier.onClick( + onClick( matcher = PointerMatcher.mouse(PointerButton.Secondary), onClick = onAltClick!! )