From 608cf131543109bcc2b0a4432fe4075ba30bc36f Mon Sep 17 00:00:00 2001 From: spectreseven1138 Date: Sat, 1 Jul 2023 10:51:30 +0100 Subject: [PATCH] Add Discord manual login (closes #46) Implement manual login for Discord Fix WebViewLogin dark mode for A13 --- build.gradle | 6 +- gradle.properties | 2 +- shared/build.gradle.kts | 20 +- .../spmp/platform/WebViewLogin.android.kt | 10 +- .../platform/composable/PlatformDialog.kt | 14 -- .../settings/model/SettingsItem.kt | 4 +- .../settings/ui/SettingsInterface.kt | 2 + .../kotlin/com/toasterofbread/spmp/api/Api.kt | 23 +- .../platform/composable/PlatformDialog.kt | 6 - .../spmp/ui/layout/DiscordLogin.kt | 102 +++----- .../spmp/ui/layout/DiscordManualLogin.kt | 51 ++++ .../spmp/ui/layout/ManualLoginPage.kt | 190 +++++++++++++++ .../spmp/ui/layout/YoutubeMusicManualLogin.kt | 221 +++--------------- .../ui/layout/mainpage/PlayerStateImpl.kt | 2 +- .../ui/layout/prefspage/DiscordLoginPage.kt | 25 +- .../ui/layout/prefspage/DiscordStatusGroup.kt | 15 +- .../spmp/ui/layout/prefspage/YtmAuthItem.kt | 6 +- .../utils/composable/WidthShrinkText.kt | 8 +- .../resources/assets/values-ja-JP/strings.xml | 31 ++- .../resources/assets/values/strings.xml | 29 ++- 20 files changed, 431 insertions(+), 336 deletions(-) create mode 100644 shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/DiscordManualLogin.kt create mode 100644 shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/ManualLoginPage.kt diff --git a/build.gradle b/build.gradle index 48f2b821e..701f7b3b3 100644 --- a/build.gradle +++ b/build.gradle @@ -5,11 +5,11 @@ buildscript { } plugins { - id 'com.android.application' version '7.4.2' apply false - id 'com.android.library' version '7.4.2' apply false + id 'com.android.application' version '7.3.0' apply false + id 'com.android.library' version '7.3.0' apply false id 'org.jetbrains.kotlin.android' version '1.7.10' apply false } -task clean(type: Delete) { +tasks.register('clean', Delete) { delete rootProject.buildDir } diff --git a/gradle.properties b/gradle.properties index 4b1455e45..b867b7783 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,4 +14,4 @@ android.minSdk=31 # Versions kotlin.version=1.8.20 agp.version=7.4.2 -compose.version=1.4.0 \ No newline at end of file +compose.version=1.4.1 \ No newline at end of file diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 1a602ff0f..fa3921884 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -99,6 +99,7 @@ kotlin { implementation("com.google.accompanist:accompanist-swiperefresh:0.21.2-beta") implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0") implementation("androidx.palette:palette:1.0.0") + //noinspection GradleDependency implementation("com.github.andob:android-awt:1.0.0") implementation("io.coil-kt:coil-compose:2.3.0") implementation("com.github.dead8309:KizzyRPC:1.0.71") @@ -160,27 +161,28 @@ open class GenerateBuildConfig : DefaultTask() { dir.deleteRecursively() dir.mkdirs() - val fqName = class_fq_name.get() - val parts = fqName.split(".") - val className = parts.last() - val file = dir.resolve("$className.kt") + val class_parts = class_fq_name.get().split(".") + val class_name = class_parts.last() + val file = dir.resolve("$class_name.kt") + val content = buildString { - if (parts.size > 1) { + if (class_parts.size > 1) { appendLine("@file:Suppress(\"RedundantNullableReturnType\", \"MayBeConstant\")\n") - appendLine("package ${parts.dropLast(1).joinToString(".")}") + appendLine("package ${class_parts.dropLast(1).joinToString(".")}") } appendLine() - appendLine("/* GENERATED ON BUILD */") - appendLine("object $className {") + appendLine("/* Generated on build in shared/build.gradle.kts */") + appendLine("object $class_name {") for (field in fields_to_generate.get().sortedBy { it.first }) { - val type = if (field.second == null) "" else ": ${field.second}" + val type = field.second?.let { ": $it" } ?: "" appendLine(" val ${field.first}$type = ${field.third}") } appendLine("}") } + file.writeText(content) } } diff --git a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/WebViewLogin.android.kt b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/WebViewLogin.android.kt index 12a27a010..358181ad5 100644 --- a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/WebViewLogin.android.kt +++ b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/WebViewLogin.android.kt @@ -1,6 +1,7 @@ package com.toasterofbread.spmp.platform import android.graphics.Bitmap +import android.os.Build import android.view.ViewGroup import android.webkit.* import androidx.compose.animation.AnimatedVisibility @@ -48,8 +49,13 @@ actual fun WebViewLogin( OnChangedEffect(web_view, is_dark) { web_view?.apply { - @Suppress("DEPRECATION") - settings.forceDark = if (is_dark) WebSettings.FORCE_DARK_ON else WebSettings.FORCE_DARK_OFF + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + settings.isAlgorithmicDarkeningAllowed = is_dark + } + else { + @Suppress("DEPRECATION") + settings.forceDark = if (is_dark) WebSettings.FORCE_DARK_ON else WebSettings.FORCE_DARK_OFF + } } } diff --git a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/composable/PlatformDialog.kt b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/composable/PlatformDialog.kt index 2dd42fddc..dd567cb7d 100644 --- a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/composable/PlatformDialog.kt +++ b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/composable/PlatformDialog.kt @@ -29,20 +29,6 @@ actual fun PlatformDialog( } } -@Composable -actual fun PlatformDialog( - onDismissRequest: () -> Unit, - content: @Composable () -> Unit -) { - Dialog( - onDismissRequest, - DialogProperties(decorFitsSystemWindows = false) - ) { - content() - } -} - - @Composable actual fun PlatformAlertDialog( onDismissRequest: () -> Unit, diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/settings/model/SettingsItem.kt b/shared/src/commonMain/kotlin/com/toasterofbread/settings/model/SettingsItem.kt index e4e7075ab..1e3308585 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/settings/model/SettingsItem.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/settings/model/SettingsItem.kt @@ -760,14 +760,14 @@ class SettingsItemLargeToggle( if (!enabled) theme.background else theme.vibrant_accent, shape ) - .border(Dp.Hairline, theme.vibrant_accent, shape) + .border(2.dp, theme.vibrant_accent, shape) .padding(horizontal = 10.dp) .fillMaxWidth() .height(IntrinsicSize.Max), horizontalArrangement = Arrangement.spacedBy(3.dp), verticalAlignment = Alignment.CenterVertically ) { (if (enabled) enabled_content else disabled_content)?.invoke(Modifier.weight(1f).padding(vertical = 5.dp)) - (if (enabled) enabled_text else disabled_text)?.also { Text(it, Modifier.fillMaxWidth().weight(1f)) } + (if (enabled) enabled_text else disabled_text)?.also { WidthShrinkText(it, Modifier.fillMaxWidth().weight(1f)) } Button( { diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/settings/ui/SettingsInterface.kt b/shared/src/commonMain/kotlin/com/toasterofbread/settings/ui/SettingsInterface.kt index fa2ae0d3c..5301b03a9 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/settings/ui/SettingsInterface.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/settings/ui/SettingsInterface.kt @@ -38,6 +38,8 @@ class SettingsInterface( } suspend fun goBack() { + pill_menu?.clearAlongsideActions() + pill_menu?.clearExtraActions() if (page_stack.size > 0) { val target_page = page_stack.removeLast() if (current_page != target_page) { 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 2b23cdc1a..91a130419 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/Api.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/Api.kt @@ -35,17 +35,24 @@ import org.schabi.newpipe.extractor.downloader.Response as NewPipeResponse const val DEFAULT_CONNECT_TIMEOUT = 3000 -fun Result.Companion.failure(response: Response): Result { +fun Result.Companion.failure(response: Response, is_gzip: Boolean = true): Result { var body: String - try { - val stream = response.getStream() - body = stream.reader().readText() - stream.close() + if (is_gzip) { + try { + val stream = response.getStream() + body = stream.reader().readText() + stream.close() + } + catch (e: ZipException) { + body = response.body!!.string() + } } - catch (e: ZipException) { + else { body = response.body!!.string() } + println("FAAAAAAAAIL $body") + response.close() return failure(RuntimeException(body)) } @@ -239,13 +246,13 @@ class Api { } } - fun request(request: Request, allow_fail_response: Boolean = false): Result { + fun request(request: Request, allow_fail_response: Boolean = false, is_gzip: Boolean = true): Result { try { val response = client.newCall(request).execute() if (response.isSuccessful || allow_fail_response) { return Result.success(response) } - return Result.failure(response) + return Result.failure(response, is_gzip) } catch (e: Throwable) { return Result.failure(e) diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/composable/PlatformDialog.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/composable/PlatformDialog.kt index 0dd11c397..2af04276f 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/composable/PlatformDialog.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/composable/PlatformDialog.kt @@ -13,12 +13,6 @@ expect fun PlatformDialog( content: @Composable () -> Unit ) -@Composable -expect fun PlatformDialog( - onDismissRequest: () -> Unit, - content: @Composable () -> Unit -) - @Composable expect fun PlatformAlertDialog( onDismissRequest: () -> Unit, diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/DiscordLogin.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/DiscordLogin.kt index da4d43f52..f39e37204 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/DiscordLogin.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/DiscordLogin.kt @@ -24,34 +24,40 @@ import com.toasterofbread.spmp.platform.composable.PlatformAlertDialog import com.toasterofbread.spmp.platform.composable.rememberImagePainter import com.toasterofbread.spmp.platform.isWebViewLoginSupported import com.toasterofbread.spmp.resources.getString -import com.toasterofbread.spmp.resources.getStringTODO -import com.toasterofbread.utils.catchInterrupts import com.toasterofbread.utils.composable.LinkifyText import com.toasterofbread.utils.composable.SubtleLoadingIndicator +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import okhttp3.Request -import kotlin.concurrent.thread private const val DISCORD_LOGIN_URL = "https://discord.com/login" private const val DISCORD_API_URL = "https://discord.com/api/" private const val DISCORD_DEFAULT_AVATAR = "https://discord.com/assets/1f0bfc0865d324c2587920a7d80c609b.png" @Composable -fun DiscordLoginConfirmation(info_only: Boolean = false, onFinished: (proceed: Boolean) -> Unit) { +fun DiscordLoginConfirmation(info_only: Boolean = false, onFinished: (manual: Boolean?) -> Unit) { PlatformAlertDialog( { onFinished(false) }, confirmButton = { FilledTonalButton({ - onFinished(!info_only) + onFinished(if (info_only) null else false) }) { Text(getString("action_confirm_action")) } }, dismissButton = if (info_only) null else ({ - TextButton({ onFinished(false) }) { Text(getString("action_deny_action")) } + TextButton({ onFinished(null) }) { Text(getString("action_deny_action")) } }), title = if (info_only) null else ({ Text(getString("prompt_confirm_action")) }), text = { - LinkifyText(getString(if (info_only) "info_discord_login" else "warning_discord_login")) + Column { + LinkifyText(getString(if (info_only) "info_discord_login" else "warning_discord_login")) + if (!info_only) { + FilledTonalButton({ onFinished(true) }, Modifier.fillMaxWidth().padding(top = 5.dp).offset(y = 20.dp)) { + Text(getString("action_login_manually")) + } + } + } } ) } @@ -80,33 +86,7 @@ fun DiscordLogin(modifier: Modifier = Modifier, manual: Boolean = false, onFinis } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun DiscordManualLogin(modifier: Modifier = Modifier, onFinished: (Result?) -> Unit) { - Column(modifier) { - Text(getStringTODO("TODO")) - - var auth_value by remember { mutableStateOf("") } - TextField( - auth_value, - { auth_value = it }, - Modifier.fillMaxWidth(), - label = { - Text("Authorization") - } - ) - - Button({ - onFinished(Result.success( - TODO(auth_value) - )) - }) { - Text(getStringTODO("Done")) - } - } -} - -private data class DiscordMeResponse( +data class DiscordMeResponse( val id: String? = null, val username: String? = null, val avatar: String? = null, @@ -130,23 +110,28 @@ private data class DiscordMeResponse( } } -private fun getDiscordAccountInfo(account_token: String): Result { +suspend fun getDiscordAccountInfo(account_token: String): Result = withContext(Dispatchers.IO) { val request = Request.Builder() .url("https://discord.com/api/v9/users/@me") .addHeader("authorization", account_token) .build() - val result = Api.request(request) - if (result.isFailure) { - return result.cast() - } + val result = Api.request(request, is_gzip = false) + val response = result.getOrNull() ?: return@withContext result.cast() - val response = result.getOrThrow() - val me: DiscordMeResponse = Klaxon().parse(response.body!!.charStream())!! + val stream = response.body!!.charStream() + val me: DiscordMeResponse = try { + Klaxon().parse(stream)!! + } + catch (e: Throwable) { + return@withContext Result.failure(e) + } + finally { + stream.close() + } me.token = account_token - response.close() - return Result.success(me) + return@withContext Result.success(me) } private val DiscordMeResponseSaver = run { @@ -169,31 +154,21 @@ private val DiscordMeResponseSaver = run { @Composable fun DiscordAccountPreview(account_token: String, modifier: Modifier = Modifier) { - var load_thread: Thread? by remember { mutableStateOf(null) } - var me by rememberSaveable(stateSaver = DiscordMeResponseSaver) { mutableStateOf(DiscordMeResponse.EMPTY) } + var account_info by rememberSaveable(stateSaver = DiscordMeResponseSaver) { mutableStateOf(DiscordMeResponse.EMPTY) } var started by remember { mutableStateOf(false) } + var loading by remember { mutableStateOf(false) } - DisposableEffect(account_token) { - load_thread?.interrupt() - - if (me.token != account_token) { - load_thread = thread { - catchInterrupts { - me = getDiscordAccountInfo(account_token).getOrReport("DiscordAccountPreview") ?: DiscordMeResponse.EMPTY - load_thread = null - } - } - - me = DiscordMeResponse.EMPTY + LaunchedEffect(account_token) { + if (account_info.token != account_token) { + account_info = DiscordMeResponse.EMPTY + loading = true started = true + account_info = getDiscordAccountInfo(account_token).getOrReport("DiscordAccountPreview") ?: DiscordMeResponse.EMPTY } - - onDispose { - load_thread?.interrupt() - } + loading = false } - Crossfade(if (!me.isEmpty()) me else if (started) load_thread != null else null, modifier.fillMaxHeight()) { state -> + Crossfade(if (!account_info.isEmpty()) account_info else if (started) loading else null, modifier.fillMaxHeight()) { state -> Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) { when (state) { true -> { @@ -208,8 +183,7 @@ fun DiscordAccountPreview(account_token: String, modifier: Modifier = Modifier) Image(rememberImagePainter(state.getAvatarUrl()), null, Modifier.fillMaxHeight().aspectRatio(1f).clip(CircleShape)) Column(Modifier.fillMaxHeight(), verticalArrangement = Arrangement.SpaceEvenly) { - Text(state.username!!, overflow = TextOverflow.Ellipsis, maxLines = 1) - Text("#${state.discriminator}") + Text(state.username ?: "?", overflow = TextOverflow.Ellipsis, maxLines = 1) } } } diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/DiscordManualLogin.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/DiscordManualLogin.kt new file mode 100644 index 000000000..aad2518e8 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/DiscordManualLogin.kt @@ -0,0 +1,51 @@ +package com.toasterofbread.spmp.ui.layout + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import com.toasterofbread.spmp.api.Api +import com.toasterofbread.spmp.resources.getString +import com.toasterofbread.spmp.resources.getStringArray +import com.toasterofbread.utils.launchSingle + +@Composable +fun DiscordManualLogin(modifier: Modifier = Modifier, onFinished: (Result?) -> Unit) { + val coroutine_scope = rememberCoroutineScope() + + ManualLoginPage( + title = getString("discord_manual_login_title"), + steps = getStringArray("discord_manual_login_steps"), + suffix = getString("discord_manual_login_suffix"), + entry_label = getString("discord_manual_login_field"), + modifier + ) { entry -> + if (entry == null) { + onFinished(Result.success(null)) + return@ManualLoginPage null + } + + coroutine_scope.launchSingle { + onFinished( + getDiscordAccountInfo(entry).fold( + { Result.success(entry) }, + { error -> + val content = error.message + if (content != null) { + try { + val parsed = Api.klaxon.parseJsonObject(content.reader()) + val message = parsed["message"] as String? + if (message != null) { + return@fold Result.failure(RuntimeException(message)) + } + } + catch (_: Throwable) {} + } + return@fold Result.failure(error) + } + ) + ) + } + + return@ManualLoginPage null + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/ManualLoginPage.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/ManualLoginPage.kt new file mode 100644 index 000000000..9fefacaa1 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/ManualLoginPage.kt @@ -0,0 +1,190 @@ +package com.toasterofbread.spmp.ui.layout + +import LocalPlayerState +import SpMp +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Done +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.toasterofbread.spmp.platform.composable.PlatformAlertDialog +import com.toasterofbread.spmp.platform.getDefaultHorizontalPadding +import com.toasterofbread.spmp.platform.getDefaultVerticalPadding +import com.toasterofbread.spmp.resources.getString +import com.toasterofbread.spmp.ui.component.PillMenu +import com.toasterofbread.utils.composable.Marquee +import com.toasterofbread.utils.composable.WidthShrinkText +import com.toasterofbread.utils.setAlpha + +@Composable +fun ManualLoginPage( + title: String, + steps: List, + suffix: String, + entry_label: String, + modifier: Modifier = Modifier, + desktop_browser_needed: Boolean = true, + onFinished: (String?) -> Pair?, +) { + val player = LocalPlayerState.current + + InfoEntry(entry_label, onFinished) + + Column( + modifier + .padding( + horizontal = SpMp.context.getDefaultHorizontalPadding(), + vertical = SpMp.context.getDefaultVerticalPadding() + ) + .padding(bottom = player.nowPlayingBottomPadding(true)) + .fillMaxHeight(), + verticalArrangement = Arrangement.spacedBy(30.dp) + ) { + Text( + title, + Modifier.align(Alignment.CenterHorizontally).padding(bottom = 25.dp), + style = MaterialTheme.typography.headlineLarge + ) + + if (desktop_browser_needed) { + Text( + getString("manual_login_desktop_browser_may_be_needed"), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold + ) + } + + @Composable + fun step(text: String, index: Int, modifier: Modifier = Modifier, shrink: Boolean = false) { + Row(modifier.alpha(0.85f), horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.Bottom) { + Text( + index.toString(), + style = MaterialTheme.typography.bodySmall + ) + + if (shrink) { + WidthShrinkText( + text, + style = MaterialTheme.typography.bodyLarge + ) + } else { + Text( + text, + style = MaterialTheme.typography.bodyLarge, + overflow = TextOverflow.Ellipsis, + softWrap = false + ) + } + } + } + + Marquee { + Column( + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + for (i in 0 until if (suffix.isBlank()) steps.size else steps.size - 1) { + step(steps[i], i) + } + } + } + + if (suffix.isNotBlank()) { + Column( + Modifier + .border( + 1.dp, + LocalContentColor.current.setAlpha(0.5f), + RoundedCornerShape(16.dp) + ) + .padding(10.dp) + ) { + step(steps.last(), steps.lastIndex, Modifier.fillMaxWidth(), shrink = true) + + Text( + '\n' + suffix, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun InfoEntry(label: String, onFinished: (String?) -> Pair?) { + val player = LocalPlayerState.current + var parse_error: Pair? by remember { mutableStateOf(null) } + + parse_error?.also { error -> + ErrorDialog(error) { parse_error = null } + } + + DisposableEffect(Unit) { + var headers_value by mutableStateOf("") + val action: @Composable PillMenu.Action.(Int) -> Unit = { + ActionButton(Icons.Default.Done) { + parse_error = onFinished(headers_value) + } + } + val field_action: @Composable PillMenu.Action.() -> Unit = { + TextField( + headers_value, + { headers_value = it }, + label = { + Text(label) + }, + singleLine = true + ) + } + + player.pill_menu.addExtraAction(false, action) + player.pill_menu.addAlongsideAction(field_action) + + onDispose { + player.pill_menu.removeExtraAction(action) + player.pill_menu.removeAlongsideAction(field_action) + } + } +} + +@Composable +private fun ErrorDialog(error: Pair, close: () -> Unit) { + PlatformAlertDialog( + onDismissRequest = close, + confirmButton = { + Button(close) { + Text(getString("action_close")) + } + }, + title = { + WidthShrinkText(error.first) + }, + text = { + Text(error.second, fontSize = 15.sp) + } + ) +} diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/YoutubeMusicManualLogin.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/YoutubeMusicManualLogin.kt index a1abe8533..7b661fc04 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/YoutubeMusicManualLogin.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/YoutubeMusicManualLogin.kt @@ -1,217 +1,56 @@ package com.toasterofbread.spmp.ui.layout -import LocalPlayerState -import SpMp -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Done -import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextField import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.text.capitalize -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.toasterofbread.spmp.model.YoutubeMusicAuthInfo -import com.toasterofbread.spmp.platform.composable.PlatformAlertDialog -import com.toasterofbread.spmp.platform.getDefaultHorizontalPadding -import com.toasterofbread.spmp.platform.getDefaultVerticalPadding import com.toasterofbread.spmp.resources.getString import com.toasterofbread.spmp.resources.getStringArray -import com.toasterofbread.spmp.ui.component.PillMenu -import com.toasterofbread.utils.composable.Marquee -import com.toasterofbread.utils.composable.WidthShrinkText import com.toasterofbread.utils.indexOfOrNull -import com.toasterofbread.utils.setAlpha import kotlinx.coroutines.launch -import java.util.Locale @Composable fun YoutubeMusicManualLogin(modifier: Modifier = Modifier, onFinished: (Result?) -> Unit) { - val player = LocalPlayerState.current - - HeadersEntry(onFinished) - - Column( - modifier - .padding( - horizontal = SpMp.context.getDefaultHorizontalPadding(), - vertical = SpMp.context.getDefaultVerticalPadding() - ) - .padding(bottom = player.nowPlayingBottomPadding(true)) - .fillMaxHeight(), - verticalArrangement = Arrangement.spacedBy(30.dp) - ) { - Text( - getString("info_youtube_manual_login_title"), - Modifier.align(Alignment.CenterHorizontally).padding(bottom = 25.dp), - style = MaterialTheme.typography.headlineLarge - ) - - Text( - getString("info_youtube_manual_login_prefix"), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold - ) - - @Composable - fun step(text: String, index: Int, modifier: Modifier = Modifier, shrink: Boolean = false) { - Row(modifier.alpha(0.85f), horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.Bottom) { - Text( - index.toString(), - style = MaterialTheme.typography.bodySmall - ) - - if (shrink) { - WidthShrinkText( - text, - style = MaterialTheme.typography.bodyLarge - ) - } else { - Text( - text, - style = MaterialTheme.typography.bodyLarge, - overflow = TextOverflow.Ellipsis, - softWrap = false - ) - } - } + val coroutine_scope = rememberCoroutineScope() + ManualLoginPage( + title = getString("youtube_manual_login_title"), + steps = getStringArray("youtube_manual_login_steps"), + suffix = getString("youtube_manual_login_suffix"), + entry_label = getString("youtube_manual_login_field"), + modifier = modifier + ) { entry -> + if (entry == null) { + onFinished(null) + return@ManualLoginPage null } - val steps = getStringArray("info_youtube_manual_login_steps") - val suffix = getString("info_youtube_manual_login_suffix") - - Marquee { - Column( - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - for (i in 0 until if (suffix.isBlank()) steps.size else steps.size - 1) { - step(steps[i], i) + getHeadersFromManualEntry(entry).fold( + { headers -> + coroutine_scope.launch { + onFinished(YoutubeMusicAuthInfo.fromHeaders(headers)) } - } - } - - if (suffix.isNotBlank()) { - Column( - Modifier - .border( - 1.dp, - LocalContentColor.current.setAlpha(0.5f), - RoundedCornerShape(16.dp) - ) - .padding(10.dp) - ) { - step(steps.last(), steps.lastIndex, Modifier.fillMaxWidth(), shrink = true) - - Text( - '\n' + suffix, - style = MaterialTheme.typography.bodyMedium - ) - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun HeadersEntry(onFinished: (Result?) -> Unit) { - val player = LocalPlayerState.current - val coroutine_scope = rememberCoroutineScope() - - var parse_error: Throwable? by remember { mutableStateOf(null) } - - parse_error?.also { error -> - ParseErrorDialog(error) { parse_error = null } - } - - DisposableEffect(Unit) { - var headers_value by mutableStateOf("") - val action: @Composable PillMenu.Action.(Int) -> Unit = { - ActionButton(Icons.Default.Done) { - getHeadersFromManualEntry(headers_value).fold( - { headers -> - coroutine_scope.launch { - onFinished(YoutubeMusicAuthInfo.fromHeaders(headers)) + null + }, + { error -> + if (error is MissingHeadersException) { + error.keys.joinToString("\n") { header -> + header.replaceFirstChar { it.uppercase() } + } + Pair( + getString("manual_login_error_missing_following_headers"), + error.keys.joinToString("\n") { header -> + header.replaceFirstChar { it.uppercase() } } - }, - { parse_error = it } - ) + ) + } + else Pair(error.javaClass.simpleName, error.message ?: "") } - } - val field_action: @Composable PillMenu.Action.() -> Unit = { - TextField( - headers_value, - { headers_value = it }, - label = { - Text(getString("info_youtube_manual_login_field")) - }, - singleLine = true - ) - } - - player.pill_menu.addExtraAction(false, action) - player.pill_menu.addAlongsideAction(field_action) - - onDispose { - player.pill_menu.removeExtraAction(action) - player.pill_menu.removeAlongsideAction(field_action) - } + ) } } private class MissingHeadersException(val keys: List): RuntimeException() -@Composable -private fun ParseErrorDialog(error: Throwable, close: () -> Unit) { - PlatformAlertDialog( - onDismissRequest = close, - confirmButton = { - Button(close) { - Text(getString("action_close")) - } - }, - title = { - WidthShrinkText( - if (error is MissingHeadersException) getString("manual_login_error_missing_following_headers") - else error.javaClass.simpleName - ) - }, - text = { - if (error is MissingHeadersException) { - Column { - for (missing_key in error.keys) { - Text(missing_key.replaceFirstChar { it.uppercase() }, fontSize = 15.sp) - } - } - } - else { - Text(error.message ?: "", fontSize = 15.sp) - } - } - ) -} - private fun getHeadersFromManualEntry(headers_text: String): Result> { val ret: MutableMap = mutableMapOf() val required_keys = YoutubeMusicAuthInfo.REQUIRED_KEYS.toMutableList() 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 bebab11bc..723b8019f 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 @@ -599,7 +599,7 @@ class PlayerStateImpl(private val context: PlatformContext): PlayerState(null, n page.first.getPage( pill_menu, page.second, - (if (session_started) MINIMISED_NOW_PLAYING_HEIGHT.dp else 0.dp) + 10.dp, + (if (session_started) MINIMISED_NOW_PLAYING_HEIGHT.dp * 2 else MINIMISED_NOW_PLAYING_HEIGHT.dp) + 10.dp, close ) } diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/prefspage/DiscordLoginPage.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/prefspage/DiscordLoginPage.kt index 4715caf46..6b4b7ebdb 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/prefspage/DiscordLoginPage.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/prefspage/DiscordLoginPage.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.toasterofbread.composesettings.ui.SettingsPage import com.toasterofbread.settings.model.SettingsValueState +import com.toasterofbread.spmp.api.getOrReport import com.toasterofbread.spmp.ui.layout.DiscordLogin internal fun getDiscordLoginPage(discord_auth: SettingsValueState, manual: Boolean = false): SettingsPage { @@ -21,12 +22,24 @@ internal fun getDiscordLoginPage(discord_auth: SettingsValueState, manua goBack: () -> Unit, ) { DiscordLogin(Modifier.fillMaxSize(), manual = manual) { auth_info -> - auth_info?.fold({ - discord_auth.value = it ?: "" - }, { - throw RuntimeException(it) - }) - goBack() + if (auth_info == null) { + goBack() + return@DiscordLogin + } + + auth_info.fold( + { + if (it != null) { + discord_auth.value = it + } + goBack() + }, + { error -> + error.message?.also { + SpMp.context.sendToast(it) + } + } + ) } } diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/prefspage/DiscordStatusGroup.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/prefspage/DiscordStatusGroup.kt index d8b8ceaad..7215c70bd 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/prefspage/DiscordStatusGroup.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/prefspage/DiscordStatusGroup.kt @@ -43,14 +43,17 @@ internal fun getDiscordStatusGroup(discord_auth: SettingsValueState): Li DiscordAccountPreview(account_token, modifier) } }, - disabled_text = "Not signed in", - enable_button = "Sign in", - disable_button = "Sign out", + disabled_text = getString("auth_not_signed_in"), + enable_button = getString("auth_sign_in"), + disable_button = getString("auth_sign_out"), warningDialog = { dismiss, openPage -> - DiscordLoginConfirmation { proceed -> + DiscordLoginConfirmation { manual -> dismiss() - if (proceed) { - openPage(PrefsPageScreen.DISCORD_LOGIN.ordinal) + if (manual != null) { + openPage( + if (manual) PrefsPageScreen.DISCORD_MANUAL_LOGIN.ordinal + else PrefsPageScreen.DISCORD_LOGIN.ordinal + ) } } }, diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/prefspage/YtmAuthItem.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/prefspage/YtmAuthItem.kt index 781154a9b..cbbd596c8 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/prefspage/YtmAuthItem.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/prefspage/YtmAuthItem.kt @@ -41,9 +41,9 @@ internal fun getYtmAuthItem(ytm_auth: SettingsValueState, ) ) }, - disabled_text = getString("ytm_auth_not_signed_in"), - enable_button = getString("ytm_auth_sign_in"), - disable_button = getString("ytm_auth_sign_out"), + disabled_text = getString("auth_not_signed_in"), + enable_button = getString("auth_sign_in"), + disable_button = getString("auth_sign_out"), warningDialog = { dismiss, openPage -> YoutubeMusicLoginConfirmation { manual -> dismiss() diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/utils/composable/WidthShrinkText.kt b/shared/src/commonMain/kotlin/com/toasterofbread/utils/composable/WidthShrinkText.kt index 71f0ae992..23ff01e21 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/utils/composable/WidthShrinkText.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/utils/composable/WidthShrinkText.kt @@ -1,12 +1,14 @@ package com.toasterofbread.utils.composable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.requiredHeight import androidx.compose.foundation.text.InlineTextContent import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.Color @@ -30,10 +32,10 @@ fun WidthShrinkText( val delta = 0.05 - Box { + Box(modifier) { Text( string, - modifier.drawWithContent { if (ready_to_draw) drawContent() }, + Modifier.fillMaxWidth().drawWithContent { if (ready_to_draw) drawContent() }, maxLines = 1, softWrap = false, style = text_style, @@ -51,7 +53,7 @@ fun WidthShrinkText( text_style_large?.also { Text( string, - modifier.drawWithContent {}.requiredHeight(1.dp), + Modifier.fillMaxWidth().drawWithContent {}.requiredHeight(1.dp), maxLines = 1, softWrap = false, style = it, 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 40c719c69..26d705ab2 100644 --- a/shared/src/commonMain/resources/assets/values-ja-JP/strings.xml +++ b/shared/src/commonMain/resources/assets/values-ja-JP/strings.xml @@ -312,9 +312,9 @@ Visit project page 手動でログインする - ログインされていません - ログイン - ログアウト + ログインされていません + ログイン + ログアウト 今からアプリ内で YouTube Music のログインページに移動します。 ログインを終えたら、セッションクッキーを取って、保存し、オススメフィードやチャンネル登録などの YouTube Music への API リクエストのみに使います。 よろしいですか? アプリ内で YouTube Music のログインページに移動します。 ログインを終えたら、セッションクッキーを取って、保存し、オススメフィードやチャンネル登録などの YouTube Music への API リクエストのみに使います。 @@ -322,19 +322,32 @@ 今からアプリ内で Discord のログインページに移動します。 ログインを終えたら、ユーザトーケンを取って、保存し、ステータスを変えるための Discord への API リクエストのみに使います。 詳しくは https://github.com/dead8309/Kizzy をご覧ください。 よろしいですか? アプリ内で Discord のログインページに移動します。 ログインを終えたら、ユーザトーケンを取って、保存し、ステータスを変えるための Discord への API リクエストのみに使います。 詳しくは https://github.com/dead8309/Kizzy をご覧ください。 - YouTube手動ログイン - この手順を行うには、デスクトップ上のブラウザーが必要かもしれません - + YouTube手動ログイン + ブラウザーで新しいタブを開く 開発者コンソールを開く (多くのブラウザーではctrl+shift+I) 「ネットワーク」タブへ移動 - そのタブでhttps://music.youtube.com/を開く + そのタブでhttps://music.youtube.comを開く https://music.youtube.com/youtubei/v1/... へ配信されたPOSTリクエストを探す(/browse や /account_menu など) リクエストのヘッダーをコピして、以下に入力 - Firefoxではリクエストを右クリックし、「Copy value」そして「Copy Request Headers」を選択すると必要な情報がコピーされます。 他のブラウザーでは, リクエストを選択して「Headers」タブを開き、「Request headers」まで降りて「Accept: */*」からの情報をすべてコピーしてください。 - ヘッダー + Firefoxではリクエストを右クリックし、「Copy value」そして「Copy Request Headers」を選択すると必要な情報がコピーされます。 他のブラウザーでは, リクエストを選択して「Headers」タブを開き、「Request headers」まで降りて「Accept: */*」からの情報をすべてコピーしてください。 + ヘッダー + + Discord manual log-in + + ブラウザーで新しいタブを開く + 開発者コンソールを開く (多くのブラウザーではctrl+shift+I) + 「ネットワーク」タブへ移動 + そのタブでhttps://discord.com/appを開く + https://discord.com/api/v9/... へ配信されたPOSTリクエストを探す(/survey や /player など) + 「Authorization」のリクエストのヘッダーをコピして、以下に入力 + + + Authorization + 以下のヘッダーが含まれていません: + この手順を行うには、デスクトップ上のブラウザーが必要かもしれません ラジオを始める 再生 diff --git a/shared/src/commonMain/resources/assets/values/strings.xml b/shared/src/commonMain/resources/assets/values/strings.xml index 387bea1dd..dd55f2706 100644 --- a/shared/src/commonMain/resources/assets/values/strings.xml +++ b/shared/src/commonMain/resources/assets/values/strings.xml @@ -440,18 +440,17 @@ Visit project page Log in manually - Not signed in - Sign in - Sign out + Not signed in + Sign in + Sign out You will be directed to an in-app webview in order to log into YouTube Music. Once logged in, your session cookie will be extracted, stored, and used only for API requests to YouTube Music such as getting your recommended feed and subscribing to artists. Continue? You will be directed to an in-app webview in order to log into YouTube Music. Once logged in, your session cookie will be extracted, stored, and used only for API requests to YouTube Music such as getting your recommended feed and subscribing to artists. You will be directed to an in-app webview in order to log into Discord. Once logged in, your account token will be extracted, stored, and used only for gateway connections to Discord for setting your current status. See https://github.com/dead8309/Kizzy for more information. Continue? You will be directed to an in-app webview in order to log into Discord. Once logged in, your account token will be extracted, stored, and used only for gateway connections to Discord for setting your current status. See https://github.com/dead8309/Kizzy for more information. - YouTube manual log-in - You may need to use a desktop browser to complete these steps - + YouTube manual log-in + Open a new browser tab Open the developer console (ctrl+shift+I on most browsers) Go to the network tab @@ -459,9 +458,23 @@ Find any POST request to https://music.youtube.com/youtubei/v1/... such as /browse or /account_menu Copy the request headers and enter them below - On Firefox, this can be done by right-clicking the request and selecting 'Copy value' and then 'Copy Request Headers'. In other browsers, click on the request and go to the 'Headers' tab, scroll down to 'Request headers', and copy all of the headers in that section starting from 'Accept: */*'. - Headers + On Firefox, this can be done by right-clicking the request and selecting 'Copy value' and then 'Copy Request Headers'. In other browsers, click on the request and go to the 'Headers' tab, scroll down to 'Request headers', and copy all of the headers in that section starting from 'Accept: */*'. + Headers + Missing the following headers: + You may need to use a desktop browser to complete these steps + + Discord manual log-in + + Open a new browser tab + Open the developer console (ctrl+shift+I on most browsers) + Go to the network tab + Go to https://discord.com/app + Find a POST request to https://discord.com/api/v9/... with such as /survey or /player + Copy the 'Authorization' header from the request headers section and enter it below + + + Authorization Could not get root privledges Granting secure settings permission failed