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