From b88f30c706da8c088336c474d67e76a1d4b782ca Mon Sep 17 00:00:00 2001 From: 25huizengek1 <50515369+25huizengek1@users.noreply.github.com> Date: Sun, 22 Sep 2024 17:57:25 +0200 Subject: [PATCH] Resolve some TODO's (clean up along the way) --- app/build.gradle.kts | 1 + .../app/vitune/android/MainApplication.kt | 136 ++++---- .../android/preferences/PlayerPreferences.kt | 14 +- .../vitune/android/service/PlayerService.kt | 204 +++++------ .../app/vitune/android/ui/components/Menu.kt | 3 +- .../android/ui/components/themed/Dialog.kt | 92 +++-- .../android/ui/components/themed/TextField.kt | 51 ++- .../app/vitune/android/ui/screens/Routes.kt | 36 ++ .../android/ui/screens/album/AlbumScreen.kt | 67 ++-- .../android/ui/screens/home/HomeScreen.kt | 18 +- .../android/ui/screens/player/Lyrics.kt | 316 ++++++++++-------- .../ui/screens/player/PlaybackError.kt | 7 +- .../ui/screens/settings/AppearanceSettings.kt | 2 +- .../kotlin/app/vitune/android/utils/Cursor.kt | 2 - .../app/vitune/android/utils/ExoPlayer.kt | 39 ++- .../android/utils/SynchronizedLyrics.kt | 10 + app/src/main/res/values-de/strings.xml | 10 +- app/src/main/res/values-es/strings.xml | 8 +- app/src/main/res/values-in/strings.xml | 8 +- app/src/main/res/values-ja/strings.xml | 8 +- app/src/main/res/values-nl/strings.xml | 8 +- app/src/main/res/values-tr/strings.xml | 8 +- app/src/main/res/values-zh/strings.xml | 8 +- app/src/main/res/values/strings.xml | 8 +- build.gradle.kts | 1 + .../app/vitune/core/data/utils/RingBuffer.kt | 6 +- gradle/libs.versions.toml | 1 + .../providers/innertube/requests/Player.kt | 76 ++--- .../app/vitune/providers/lrclib/LrcLib.kt | 145 ++++++-- .../vitune/providers/lrclib/models/Track.kt | 6 +- 30 files changed, 774 insertions(+), 525 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8691f683de..ccf64669e0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.ksp) } diff --git a/app/src/main/kotlin/app/vitune/android/MainApplication.kt b/app/src/main/kotlin/app/vitune/android/MainApplication.kt index 749c3c1636..9b2bc0f139 100644 --- a/app/src/main/kotlin/app/vitune/android/MainApplication.kt +++ b/app/src/main/kotlin/app/vitune/android/MainApplication.kt @@ -121,6 +121,7 @@ import com.kieronquinn.monetcompat.core.MonetCompat import com.kieronquinn.monetcompat.interfaces.MonetColorsChangedListener import com.valentinilk.shimmer.LocalShimmerTheme import dev.kdrag0n.monet.theme.ColorScheme +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first @@ -128,6 +129,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext private const val TAG = "MainActivity" +private val coroutineScope = CoroutineScope(Dispatchers.IO) // Viewmodel in order to avoid recreating the entire Player state (WORKAROUND) class MainViewModel : ViewModel() { @@ -287,15 +289,7 @@ class MainActivity : ComponentActivity(), MonetColorsChangedListener { val isDownloading by downloadState.collectAsState() Box { - HomeScreen( - onPlaylistUrl = { url -> - runCatching { - handleUrl(url.toUri()) - }.onFailure { - toast(getString(R.string.error_url, url)) - } - } - ) + HomeScreen() } AnimatedVisibility( @@ -374,7 +368,7 @@ class MainActivity : ComponentActivity(), MonetColorsChangedListener { intent.data = null extras?.text = null - handleUrl(uri) + handleUrl(uri, vm.awaitBinder()) } MediaStore.INTENT_ACTION_MEDIA_PLAY_FROM_SEARCH -> { @@ -398,68 +392,6 @@ class MainActivity : ComponentActivity(), MonetColorsChangedListener { } } - private fun handleUrl(uri: Uri) { - val path = uri.pathSegments.firstOrNull() - Log.d(TAG, "Opening url: $uri ($path)") - - lifecycleScope.launch(Dispatchers.IO) { - when (path) { - "search" -> uri.getQueryParameter("q")?.let { query -> - searchResultRoute.ensureGlobal(query) - } - - "playlist" -> uri.getQueryParameter("list")?.let { playlistId -> - val browseId = "VL$playlistId" - - if (playlistId.startsWith("OLAK5uy_")) Innertube.playlistPage( - body = BrowseBody(browseId = browseId) - ) - ?.getOrNull() - ?.let { page -> - page.songsPage?.items?.firstOrNull()?.album?.endpoint?.browseId - ?.let { albumRoute.ensureGlobal(it) } - } ?: withContext(Dispatchers.Main) { - toast( - getString( - R.string.error_url, - uri - ) - ) - } - else playlistRoute.ensureGlobal( - p0 = browseId, - p1 = uri.getQueryParameter("params"), - p2 = null, - p3 = playlistId.startsWith("RDCLAK5uy_") - ) - } - - "channel", "c" -> uri.lastPathSegment?.let { channelId -> - artistRoute.ensureGlobal(channelId) - } - - else -> when { - path == "watch" -> uri.getQueryParameter("v") - uri.host == "youtu.be" -> path - else -> { - withContext(Dispatchers.Main) { - toast(getString(R.string.error_url, uri)) - } - null - } - }?.let { videoId -> - Innertube.song(videoId)?.getOrNull()?.let { song -> - val binder = vm.awaitBinder() - - withContext(Dispatchers.Main) { - binder.player.forcePlay(song.asMediaItem) - } - } - } - } - } - } - override fun onDestroy() { super.onDestroy() monet.removeMonetColorsChangedListener(this) @@ -488,6 +420,65 @@ class MainActivity : ComponentActivity(), MonetColorsChangedListener { } } +context(Context) +fun handleUrl( + uri: Uri, + binder: PlayerService.Binder? +) { + val path = uri.pathSegments.firstOrNull() + Log.d(TAG, "Opening url: $uri ($path)") + + coroutineScope.launch { + when (path) { + "search" -> uri.getQueryParameter("q")?.let { query -> + searchResultRoute.ensureGlobal(query) + } + + "playlist" -> uri.getQueryParameter("list")?.let { playlistId -> + val browseId = "VL$playlistId" + + if (playlistId.startsWith("OLAK5uy_")) Innertube.playlistPage( + body = BrowseBody(browseId = browseId) + ) + ?.getOrNull() + ?.let { page -> + page.songsPage?.items?.firstOrNull()?.album?.endpoint?.browseId + ?.let { albumRoute.ensureGlobal(it) } + } ?: withContext(Dispatchers.Main) { + toast(getString(R.string.error_url, uri)) + } + else playlistRoute.ensureGlobal( + p0 = browseId, + p1 = uri.getQueryParameter("params"), + p2 = null, + p3 = playlistId.startsWith("RDCLAK5uy_") + ) + } + + "channel", "c" -> uri.lastPathSegment?.let { channelId -> + artistRoute.ensureGlobal(channelId) + } + + else -> when { + path == "watch" -> uri.getQueryParameter("v") + uri.host == "youtu.be" -> path + else -> { + withContext(Dispatchers.Main) { + toast(getString(R.string.error_url, uri)) + } + null + } + }?.let { videoId -> + Innertube.song(videoId)?.getOrNull()?.let { song -> + withContext(Dispatchers.Main) { + binder?.player?.forcePlay(song.asMediaItem) + } + } + } + } + } +} + val LocalPlayerServiceBinder = staticCompositionLocalOf { null } val LocalPlayerAwareWindowInsets = compositionLocalOf { error("No player insets provided") } @@ -497,7 +488,6 @@ class MainApplication : Application(), ImageLoaderFactory, Configuration.Provide override fun onCreate() { StrictMode.setVmPolicy( VmPolicy.Builder() - // TODO: check all intent launchers for 'unsafe' intents (new rules like 'all intents should have an action') .let { if (isAtLeastAndroid12) it.detectUnsafeIntentLaunch() else it diff --git a/app/src/main/kotlin/app/vitune/android/preferences/PlayerPreferences.kt b/app/src/main/kotlin/app/vitune/android/preferences/PlayerPreferences.kt index 623883c914..004b8880a9 100644 --- a/app/src/main/kotlin/app/vitune/android/preferences/PlayerPreferences.kt +++ b/app/src/main/kotlin/app/vitune/android/preferences/PlayerPreferences.kt @@ -84,31 +84,31 @@ object PlayerPreferences : GlobalPreferencesHolder() { ) { None( preset = PresetReverb.PRESET_NONE, - displayName = { stringResource(R.string.thumbnail_roundness_none) } // TODO: rename + displayName = { stringResource(R.string.none) } ), SmallRoom( preset = PresetReverb.PRESET_SMALLROOM, - displayName = { "Small room" } + displayName = { stringResource(R.string.reverb_small_room) } ), MediumRoom( preset = PresetReverb.PRESET_MEDIUMROOM, - displayName = { "Medium room" } + displayName = { stringResource(R.string.reverb_medium_room) } ), LargeRoom( preset = PresetReverb.PRESET_LARGEROOM, - displayName = { "Large room" } + displayName = { stringResource(R.string.reverb_large_room) } ), MediumHall( preset = PresetReverb.PRESET_MEDIUMHALL, - displayName = { "Medium hall" } + displayName = { stringResource(R.string.reverb_medium_hall) } ), LargeHall( preset = PresetReverb.PRESET_LARGEHALL, - displayName = { "Large hall" } + displayName = { stringResource(R.string.reverb_large_hall) } ), Plate( preset = PresetReverb.PRESET_PLATE, - displayName = { "Plate" } + displayName = { stringResource(R.string.reverb_plate) } ) } } diff --git a/app/src/main/kotlin/app/vitune/android/service/PlayerService.kt b/app/src/main/kotlin/app/vitune/android/service/PlayerService.kt index 695c05e9de..cf735976fa 100644 --- a/app/src/main/kotlin/app/vitune/android/service/PlayerService.kt +++ b/app/src/main/kotlin/app/vitune/android/service/PlayerService.kt @@ -45,12 +45,9 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.database.StandaloneDatabaseProvider import androidx.media3.datasource.DataSource import androidx.media3.datasource.DataSpec -import androidx.media3.datasource.DefaultDataSource -import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.datasource.HttpDataSource.InvalidResponseCodeException import androidx.media3.datasource.ResolvingDataSource import androidx.media3.datasource.cache.Cache -import androidx.media3.datasource.cache.CacheDataSource import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor import androidx.media3.datasource.cache.NoOpCacheEvictor import androidx.media3.datasource.cache.SimpleCache @@ -87,7 +84,9 @@ import app.vitune.android.utils.InvincibleService import app.vitune.android.utils.TimerJob import app.vitune.android.utils.YouTubeRadio import app.vitune.android.utils.activityPendingIntent +import app.vitune.android.utils.asDataSource import app.vitune.android.utils.broadcastPendingIntent +import app.vitune.android.utils.defaultDataSource import app.vitune.android.utils.findCause import app.vitune.android.utils.findNextMediaItemById import app.vitune.android.utils.forcePlayFromBeginning @@ -95,6 +94,7 @@ import app.vitune.android.utils.forceSeekToNext import app.vitune.android.utils.forceSeekToPrevious import app.vitune.android.utils.get import app.vitune.android.utils.handleRangeErrors +import app.vitune.android.utils.handleUnknownErrors import app.vitune.android.utils.intent import app.vitune.android.utils.mediaItems import app.vitune.android.utils.progress @@ -1218,125 +1218,105 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene context: Context, cache: Cache, chunkLength: Long? = DEFAULT_CHUNK_LENGTH, - ringBuffer: RingBuffer?> = RingBuffer(2) { null } + uriCache: RingBuffer?> = RingBuffer(16) { null } ): DataSource.Factory = ResolvingDataSource.Factory( ConditionalCacheDataSourceFactory( - cacheDataSourceFactory = CacheDataSource.Factory().setCache(cache), - upstreamDataSourceFactory = DefaultDataSource.Factory( - /* context = */ context, - /* baseDataSourceFactory = */ DefaultHttpDataSource.Factory() - .setConnectTimeoutMs(16000) - .setReadTimeoutMs(8000) - .setUserAgent("Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0") - ) + cacheDataSourceFactory = cache.asDataSource, + upstreamDataSourceFactory = context.defaultDataSource ) { !it.isLocal } ) { dataSpec -> - runCatching { - // Thank you Android, for enforcing a Uri in the download request - val videoId = dataSpec.key?.removePrefix("https://youtube.com/watch?v=") - ?: error("A key must be set") - - when { - dataSpec.isLocal || cache.isCached( - videoId, - dataSpec.position, - chunkLength ?: DEFAULT_CHUNK_LENGTH - ) -> dataSpec - - videoId == ringBuffer[0]?.first -> - dataSpec.withUri(ringBuffer[0]!!.second) - - videoId == ringBuffer[1]?.first -> - dataSpec.withUri(ringBuffer[1]!!.second) - - else -> { - val body = runBlocking(Dispatchers.IO) { - Innertube.player(PlayerBody(videoId = videoId)) - }?.getOrThrow() - - if (body?.videoDetails?.videoId != videoId) throw VideoIdMismatchException() - - val format = body.streamingData?.highestQualityFormat - val url = when (val status = body.playabilityStatus?.status) { - "OK" -> format?.let { _ -> - val mediaItem = runCatching { findMediaItem(videoId) }.getOrNull() - val extras = mediaItem?.mediaMetadata?.extras?.songBundle - - if (extras?.durationText == null) format.approxDurationMs - ?.div(1000) - ?.let(DateUtils::formatElapsedTime) - ?.removePrefix("0") - ?.let { durationText -> - extras?.durationText = durationText - Database.updateDurationText(videoId, durationText) - } + val mediaId = dataSpec.key?.removePrefix("https://youtube.com/watch?v=") + ?: error("A key must be set") + + if ( + dataSpec.isLocal || cache.isCached( + /* key = */ mediaId, + /* position = */ dataSpec.position, + /* length = */ chunkLength ?: DEFAULT_CHUNK_LENGTH + ) + ) dataSpec else uriCache + .find { it?.first == mediaId } + ?.second + ?.let { dataSpec.withUri(it) } ?: run { + val body = runBlocking(Dispatchers.IO) { + Innertube.player(PlayerBody(videoId = mediaId)) + }?.getOrThrow() + + if (body?.videoDetails?.videoId != mediaId) throw VideoIdMismatchException() + + val format = body.streamingData?.highestQualityFormat ?: throw PlayableFormatNotFoundException() + val url = when (val status = body.playabilityStatus?.status) { + "OK" -> { + val mediaItem = runCatching { findMediaItem(mediaId) }.getOrNull() + val extras = mediaItem?.mediaMetadata?.extras?.songBundle + + if (extras?.durationText == null) format.approxDurationMs + ?.div(1000) + ?.let(DateUtils::formatElapsedTime) + ?.removePrefix("0") + ?.let { durationText -> + extras?.durationText = durationText + Database.updateDurationText(mediaId, durationText) + } - transaction { - runCatching { - mediaItem?.let(Database::insert) - - Database.insert( - Format( - songId = videoId, - itag = format.itag, - mimeType = format.mimeType, - bitrate = format.bitrate, - loudnessDb = body.playerConfig?.audioConfig?.normalizedLoudnessDb, - contentLength = format.contentLength, - lastModified = format.lastModified - ) - ) - } - } + transaction { + runCatching { + mediaItem?.let(Database::insert) + + Database.insert( + Format( + songId = mediaId, + itag = format.itag, + mimeType = format.mimeType, + bitrate = format.bitrate, + loudnessDb = body.playerConfig?.audioConfig?.normalizedLoudnessDb, + contentLength = format.contentLength, + lastModified = format.lastModified + ) + ) + } + } - format.url - } ?: throw PlayableFormatNotFoundException() + format.url + } - "UNPLAYABLE" -> throw UnplayableException() - "LOGIN_REQUIRED" -> throw LoginRequiredException() + "UNPLAYABLE" -> throw UnplayableException() + "LOGIN_REQUIRED" -> throw LoginRequiredException() - else -> throw PlaybackException( - status, - null, - PlaybackException.ERROR_CODE_REMOTE_ERROR + else -> throw PlaybackException( + /* message = */ status, + /* cause = */ null, + /* errorCode = */ PlaybackException.ERROR_CODE_REMOTE_ERROR + ) + } ?: throw UnplayableException() + + uriCache += mediaId to url.toUri() + dataSpec.buildUpon() + .setKey(mediaId) + .setUri(url.toUri()) + .build() + .let { spec -> + format.contentLength?.let { contentLength -> + val start = dataSpec.uriPositionOffset + val length = (contentLength - start).coerceAtMost( + chunkLength ?: (contentLength - start) ) - } - - ringBuffer += videoId to url.toUri() - dataSpec.buildUpon() - .setKey(videoId) - .setUri(url.toUri()) - .build() - .let { spec -> - format.contentLength?.let { contentLength -> - val start = dataSpec.uriPositionOffset - val length = (contentLength - start).coerceAtMost( - chunkLength ?: (contentLength - start) - ) - val range = "$start-${start + length}" - - spec - .subrange(start, length) - .withAdditionalHeaders(mapOf("Range" to range)) - .withUri( - spec.uri - .buildUpon() - .appendQueryParameter("range", range) - .build() - ) - } ?: spec - } + val range = "$start-${start + length}" + + spec + .subrange(start, length) + .withAdditionalHeaders(mapOf("Range" to range)) + .withUri( + spec.uri + .buildUpon() + .appendQueryParameter("range", range) + .build() + ) + } ?: spec } - } - }.getOrElse { - it.printStackTrace() - - if (it is PlaybackException) throw it else throw PlaybackException( - /* message = */ "Unknown playback error", - /* cause = */ it, - /* errorCode = */ PlaybackException.ERROR_CODE_UNSPECIFIED - ) } - }.handleRangeErrors() + } + .handleUnknownErrors() + .handleRangeErrors() } } diff --git a/app/src/main/kotlin/app/vitune/android/ui/components/Menu.kt b/app/src/main/kotlin/app/vitune/android/ui/components/Menu.kt index 466fa4b584..90b7a15cbf 100644 --- a/app/src/main/kotlin/app/vitune/android/ui/components/Menu.kt +++ b/app/src/main/kotlin/app/vitune/android/ui/components/Menu.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times import app.vitune.android.LocalPlayerAwareWindowInsets import app.vitune.android.ui.modifiers.pressable @@ -60,7 +61,7 @@ fun BottomSheetMenu( ) = BoxWithConstraints(modifier = modifier) { val windowInsets = LocalPlayerAwareWindowInsets.current - val height = maxHeight - 256.dp + val height = 0.8f * maxHeight val bottomSheetState = rememberBottomSheetState( dismissedBound = -windowInsets diff --git a/app/src/main/kotlin/app/vitune/android/ui/components/themed/Dialog.kt b/app/src/main/kotlin/app/vitune/android/ui/components/themed/Dialog.kt index 94d4f0f4dd..f385416ba1 100644 --- a/app/src/main/kotlin/app/vitune/android/ui/components/themed/Dialog.kt +++ b/app/src/main/kotlin/app/vitune/android/ui/components/themed/Dialog.kt @@ -66,63 +66,61 @@ fun TextFieldDialog( onCancel: () -> Unit = onDismiss, isTextInputValid: (String) -> Boolean = { it.isNotEmpty() }, keyboardOptions: KeyboardOptions = KeyboardOptions() +) = DefaultDialog( + onDismiss = onDismiss, + modifier = modifier ) { val focusRequester = remember { FocusRequester() } val (_, typography) = LocalAppearance.current var value by rememberSaveable(initialTextInput) { mutableStateOf(initialTextInput) } - DefaultDialog( - onDismiss = onDismiss, - modifier = modifier - ) { - TextField( - value = value, - onValueChange = { value = it }, - textStyle = typography.xs.semiBold.center, - singleLine = singleLine, - maxLines = maxLines, - hintText = hintText, - keyboardActions = KeyboardActions( - onDone = { - if (isTextInputValid(value)) { - onDismiss() - onAccept(value) - } + LaunchedEffect(Unit) { + delay(300) + focusRequester.requestFocus() + } + + TextField( + value = value, + onValueChange = { value = it }, + textStyle = typography.xs.semiBold.center, + singleLine = singleLine, + maxLines = maxLines, + hintText = hintText, + keyboardActions = KeyboardActions( + onDone = { + if (isTextInputValid(value)) { + onDismiss() + onAccept(value) } - ), - keyboardOptions = keyboardOptions, - modifier = Modifier - .padding(all = 16.dp) - .weight(weight = 1f, fill = false) - .focusRequester(focusRequester) - ) + } + ), + keyboardOptions = keyboardOptions, + modifier = Modifier + .padding(all = 16.dp) + .weight(weight = 1f, fill = false) + .focusRequester(focusRequester) + ) - Row( - horizontalArrangement = Arrangement.SpaceEvenly, - modifier = Modifier.fillMaxWidth() - ) { - DialogTextButton( - text = cancelText, - onClick = onCancel - ) + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier.fillMaxWidth() + ) { + DialogTextButton( + text = cancelText, + onClick = onCancel + ) - DialogTextButton( - primary = true, - text = doneText, - onClick = { - if (isTextInputValid(value)) { - onDismiss() - onAccept(value) - } + DialogTextButton( + primary = true, + text = doneText, + onClick = { + if (isTextInputValid(value)) { + onDismiss() + onAccept(value) } - ) - } - } - - LaunchedEffect(Unit) { - delay(300) - focusRequester.requestFocus() + } + ) } } diff --git a/app/src/main/kotlin/app/vitune/android/ui/components/themed/TextField.kt b/app/src/main/kotlin/app/vitune/android/ui/components/themed/TextField.kt index cf5eaaf1aa..8c2e42354a 100644 --- a/app/src/main/kotlin/app/vitune/android/ui/components/themed/TextField.kt +++ b/app/src/main/kotlin/app/vitune/android/ui/components/themed/TextField.kt @@ -5,7 +5,6 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.text.BasicText @@ -64,19 +63,18 @@ fun ColumnScope.TextField( cursorBrush = SolidColor(appearance.colorPalette.text), decorationBox = { innerTextField -> hintText?.let { text -> - Box(modifier = Modifier.weight(1f)) { - this@TextField.AnimatedVisibility( - visible = value.isEmpty(), - enter = fadeIn(tween(100)), - exit = fadeOut(tween(100)) - ) { - BasicText( - text = text, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = textStyle.secondary - ) - } + this@TextField.AnimatedVisibility( + visible = value.isEmpty(), + enter = fadeIn(tween(100)), + exit = fadeOut(tween(100)), + modifier = Modifier.weight(1f) + ) { + BasicText( + text = text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = textStyle.secondary + ) } } @@ -122,19 +120,18 @@ fun RowScope.TextField( cursorBrush = SolidColor(appearance.colorPalette.text), decorationBox = { innerTextField -> hintText?.let { text -> - Box(modifier = Modifier.weight(1f)) { - this@TextField.AnimatedVisibility( - visible = value.isEmpty(), - enter = fadeIn(tween(100)), - exit = fadeOut(tween(100)) - ) { - BasicText( - text = text, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = textStyle.secondary - ) - } + this@TextField.AnimatedVisibility( + visible = value.isEmpty(), + enter = fadeIn(tween(100)), + exit = fadeOut(tween(100)), + modifier = Modifier.weight(1f) + ) { + BasicText( + text = text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = textStyle.secondary + ) } } diff --git a/app/src/main/kotlin/app/vitune/android/ui/screens/Routes.kt b/app/src/main/kotlin/app/vitune/android/ui/screens/Routes.kt index 7c613c7d60..ad45062f32 100644 --- a/app/src/main/kotlin/app/vitune/android/ui/screens/Routes.kt +++ b/app/src/main/kotlin/app/vitune/android/ui/screens/Routes.kt @@ -1,13 +1,24 @@ package app.vitune.android.ui.screens import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.core.net.toUri +import app.vitune.android.Database +import app.vitune.android.LocalPlayerServiceBinder +import app.vitune.android.R +import app.vitune.android.handleUrl import app.vitune.android.models.Mood +import app.vitune.android.models.SearchQuery +import app.vitune.android.preferences.DataPreferences +import app.vitune.android.query import app.vitune.android.ui.screens.album.AlbumScreen import app.vitune.android.ui.screens.artist.ArtistScreen import app.vitune.android.ui.screens.pipedplaylist.PipedPlaylistScreen import app.vitune.android.ui.screens.playlist.PlaylistScreen +import app.vitune.android.ui.screens.search.SearchScreen import app.vitune.android.ui.screens.searchresult.SearchResultScreen import app.vitune.android.ui.screens.settings.SettingsScreen +import app.vitune.android.utils.toast import app.vitune.compose.routing.Route0 import app.vitune.compose.routing.Route1 import app.vitune.compose.routing.Route3 @@ -38,6 +49,9 @@ val settingsRoute = Route0("settingsRoute") @Composable fun RouteHandlerScope.GlobalRoutes() { + val context = LocalContext.current + val binder = LocalPlayerServiceBinder.current + albumRoute { browseId -> AlbumScreen(browseId = browseId) } @@ -70,6 +84,28 @@ fun RouteHandlerScope.GlobalRoutes() { SettingsScreen() } + searchRoute { initialTextInput -> + SearchScreen( + initialTextInput = initialTextInput, + onSearch = { query -> + searchResultRoute(query) + + if (!DataPreferences.pauseSearchHistory) query { + Database.insert(SearchQuery(query = query)) + } + }, + onViewPlaylist = { url -> + with(context) { + runCatching { + handleUrl(url.toUri(), binder) + }.onFailure { + toast(getString(R.string.error_url, url)) + } + } + } + ) + } + searchResultRoute { query -> SearchResultScreen( query = query, diff --git a/app/src/main/kotlin/app/vitune/android/ui/screens/album/AlbumScreen.kt b/app/src/main/kotlin/app/vitune/android/ui/screens/album/AlbumScreen.kt index 248e3a5c50..c34a36c7b7 100644 --- a/app/src/main/kotlin/app/vitune/android/ui/screens/album/AlbumScreen.kt +++ b/app/src/main/kotlin/app/vitune/android/ui/screens/album/AlbumScreen.kt @@ -19,6 +19,7 @@ import app.vitune.android.models.Album import app.vitune.android.models.Song import app.vitune.android.models.SongAlbumMap import app.vitune.android.query +import app.vitune.android.transaction import app.vitune.android.ui.components.themed.Header import app.vitune.android.ui.components.themed.HeaderIconButton import app.vitune.android.ui.components.themed.HeaderPlaceholder @@ -69,14 +70,14 @@ fun AlbumScreen(browseId: String) { LaunchedEffect(Unit) { Database - .album(browseId) + .albumSongs(browseId) .distinctUntilChanged() .combine( Database - .albumSongs(browseId) + .album(browseId) .distinctUntilChanged() .cancellable() - ) { currentAlbum, currentSongs -> + ) { currentSongs, currentAlbum -> album = currentAlbum songs = currentSongs.toImmutableList() @@ -87,35 +88,37 @@ fun AlbumScreen(browseId: String) { ?.onSuccess { newAlbumPage -> albumPage = newAlbumPage - Database.clearAlbum(browseId) - - Database.upsert( - album = Album( - id = browseId, - title = newAlbumPage.title, - description = newAlbumPage.description, - thumbnailUrl = newAlbumPage.thumbnail?.url, - year = newAlbumPage.year, - authorsText = newAlbumPage.authors - ?.joinToString("") { it.name.orEmpty() }, - shareUrl = newAlbumPage.url, - timestamp = System.currentTimeMillis(), - bookmarkedAt = album?.bookmarkedAt, - otherInfo = newAlbumPage.otherInfo - ), - songAlbumMaps = newAlbumPage - .songsPage - ?.items - ?.map { it.asMediaItem } - ?.onEach { Database.insert(it) } - ?.mapIndexed { position, mediaItem -> - SongAlbumMap( - songId = mediaItem.mediaId, - albumId = browseId, - position = position - ) - } ?: emptyList() - ) + transaction { + Database.clearAlbum(browseId) + + Database.upsert( + album = Album( + id = browseId, + title = newAlbumPage.title, + description = newAlbumPage.description, + thumbnailUrl = newAlbumPage.thumbnail?.url, + year = newAlbumPage.year, + authorsText = newAlbumPage.authors + ?.joinToString("") { it.name.orEmpty() }, + shareUrl = newAlbumPage.url, + timestamp = System.currentTimeMillis(), + bookmarkedAt = album?.bookmarkedAt, + otherInfo = newAlbumPage.otherInfo + ), + songAlbumMaps = newAlbumPage + .songsPage + ?.items + ?.map { it.asMediaItem } + ?.onEach { Database.insert(it) } + ?.mapIndexed { position, mediaItem -> + SongAlbumMap( + songId = mediaItem.mediaId, + albumId = browseId, + position = position + ) + } ?: emptyList() + ) + } }?.exceptionOrNull()?.printStackTrace() } }.collect() diff --git a/app/src/main/kotlin/app/vitune/android/ui/screens/home/HomeScreen.kt b/app/src/main/kotlin/app/vitune/android/ui/screens/home/HomeScreen.kt index e8b4df9d4e..a6e0811a5f 100644 --- a/app/src/main/kotlin/app/vitune/android/ui/screens/home/HomeScreen.kt +++ b/app/src/main/kotlin/app/vitune/android/ui/screens/home/HomeScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.ui.res.stringResource import app.vitune.android.Database import app.vitune.android.R +import app.vitune.android.handleUrl import app.vitune.android.models.SearchQuery import app.vitune.android.models.toUiMood import app.vitune.android.preferences.DataPreferences @@ -29,6 +30,7 @@ import app.vitune.android.ui.screens.search.SearchScreen import app.vitune.android.ui.screens.searchResultRoute import app.vitune.android.ui.screens.searchRoute import app.vitune.android.ui.screens.settingsRoute +import app.vitune.android.utils.toast import app.vitune.compose.persist.PersistMapCleanup import app.vitune.compose.routing.Route0 import app.vitune.compose.routing.RouteHandler @@ -38,7 +40,7 @@ private val moreAlbumsRoute = Route0("moreAlbumsRoute") @Route @Composable -fun HomeScreen(onPlaylistUrl: (String) -> Unit) { +fun HomeScreen() { val saveableStateHolder = rememberSaveableStateHolder() PersistMapCleanup("home/") @@ -54,20 +56,6 @@ fun HomeScreen(onPlaylistUrl: (String) -> Unit) { BuiltInPlaylistScreen(builtInPlaylist = builtInPlaylist) } - searchRoute { initialTextInput -> - SearchScreen( - initialTextInput = initialTextInput, - onSearch = { query -> - searchResultRoute(query) - - if (!DataPreferences.pauseSearchHistory) query { - Database.insert(SearchQuery(query = query)) - } - }, - onViewPlaylist = onPlaylistUrl - ) - } - moodRoute { mood -> MoodScreen(mood = mood) } diff --git a/app/src/main/kotlin/app/vitune/android/ui/screens/player/Lyrics.kt b/app/src/main/kotlin/app/vitune/android/ui/screens/player/Lyrics.kt index d92e33aaa7..360ed3faad 100644 --- a/app/src/main/kotlin/app/vitune/android/ui/screens/player/Lyrics.kt +++ b/app/src/main/kotlin/app/vitune/android/ui/screens/player/Lyrics.kt @@ -53,6 +53,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.media3.common.C import androidx.media3.common.MediaMetadata @@ -75,9 +76,11 @@ import app.vitune.android.ui.components.themed.TextPlaceholder import app.vitune.android.ui.components.themed.ValueSelectorDialogBody import app.vitune.android.ui.modifiers.verticalFadingEdge import app.vitune.android.utils.SynchronizedLyrics +import app.vitune.android.utils.SynchronizedLyricsState import app.vitune.android.utils.center import app.vitune.android.utils.color import app.vitune.android.utils.disabled +import app.vitune.android.utils.isInPip import app.vitune.android.utils.medium import app.vitune.android.utils.semiBold import app.vitune.android.utils.toast @@ -90,19 +93,24 @@ import app.vitune.providers.innertube.models.bodies.NextBody import app.vitune.providers.innertube.requests.lyrics import app.vitune.providers.kugou.KuGou import app.vitune.providers.lrclib.LrcLib +import app.vitune.providers.lrclib.LrcParser import app.vitune.providers.lrclib.models.Track +import app.vitune.providers.lrclib.toLrcFile import com.valentinilk.shimmer.shimmer import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.withContext import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds +private const val UPDATE_DELAY = 50L + @Composable fun Lyrics( mediaId: String, @@ -137,20 +145,22 @@ fun Lyrics( val density = LocalDensity.current val view = LocalView.current + val pip = isInPip() + var lyrics by remember { mutableStateOf(null) } val showSynchronizedLyrics = remember(shouldShowSynchronizedLyrics, lyrics) { shouldShowSynchronizedLyrics && lyrics?.synced?.isBlank() != true } - var isEditing by remember(mediaId, shouldShowSynchronizedLyrics) { mutableStateOf(false) } - var isPicking by remember(mediaId, shouldShowSynchronizedLyrics) { mutableStateOf(false) } - var isError by remember(mediaId, shouldShowSynchronizedLyrics) { mutableStateOf(false) } - var invalidLrc by remember(mediaId, shouldShowSynchronizedLyrics) { mutableStateOf(false) } + var editing by remember(mediaId, shouldShowSynchronizedLyrics) { mutableStateOf(false) } + var picking by remember(mediaId, shouldShowSynchronizedLyrics) { mutableStateOf(false) } + var error by remember(mediaId, shouldShowSynchronizedLyrics) { mutableStateOf(false) } val text = remember(lyrics, showSynchronizedLyrics) { if (showSynchronizedLyrics) lyrics?.synced else lyrics?.fixed } + var invalidLrc by remember(text) { mutableStateOf(false) } DisposableEffect(shouldKeepScreenAwake) { view.keepScreenOn = shouldKeepScreenAwake @@ -193,45 +203,42 @@ fun Lyrics( } lyrics = null - isError = false + error = false + + val fixed = currentLyrics?.fixed ?: Innertube + .lyrics(NextBody(videoId = mediaId)) + ?.getOrNull() + ?: LrcLib.bestLyrics( + artist = artist, + title = title, + duration = duration.milliseconds, + album = album, + synced = false + )?.map { it?.text }?.getOrNull() + + val synced = currentLyrics?.synced ?: LrcLib.bestLyrics( + artist = artist, + title = title, + duration = duration.milliseconds, + album = album + )?.map { it?.text }?.getOrNull() ?: LrcLib.bestLyrics( + artist = artist, + title = title.split("(")[0].trim(), + duration = duration.milliseconds, + album = album + )?.map { it?.text }?.getOrNull() ?: KuGou.lyrics( + artist = artist, + title = title, + duration = duration / 1000 + )?.map { it?.value }?.getOrNull() Lyrics( songId = mediaId, - fixed = ( - if (currentLyrics?.fixed == null) - Innertube.lyrics(NextBody(videoId = mediaId)) - ?.getOrNull() - ?: LrcLib.bestLyrics( - artist = artist, - title = title, - duration = duration.milliseconds, - album = album, - synced = false - )?.map { it?.text }?.getOrNull() - else currentLyrics.fixed - ).orEmpty(), - synced = ( - if (currentLyrics?.synced == null) - LrcLib.bestLyrics( - artist = artist, - title = title, - duration = duration.milliseconds, - album = album - )?.map { it?.text }?.getOrNull() - ?: KuGou.lyrics( - artist = artist, - title = title, - duration = duration / 1000 - )?.map { it?.value }?.getOrNull() - ?: LrcLib.bestLyrics( - artist = artist, - title = title.split("(")[0].trim(), - duration = duration.milliseconds, - album = album - )?.map { it?.text }?.getOrNull() - else currentLyrics.synced - ).orEmpty() + fixed = fixed.orEmpty(), + synced = synced.orEmpty() ).also { + ensureActive() + transaction { runCatching { currentEnsureSongInserted() @@ -241,22 +248,24 @@ fun Lyrics( } } - isError = + error = (shouldShowSynchronizedLyrics && lyrics?.synced?.isBlank() == true) || (!shouldShowSynchronizedLyrics && lyrics?.fixed?.isBlank() == true) } } - }.exceptionOrNull() - ?.let { if (it is CancellationException) throw it else it.printStackTrace() } + }.exceptionOrNull()?.let { + if (it is CancellationException) throw it + else it.printStackTrace() + } } - if (isEditing) TextFieldDialog( + if (editing) TextFieldDialog( hintText = stringResource(R.string.enter_lyrics), initialTextInput = (if (shouldShowSynchronizedLyrics) lyrics?.synced else lyrics?.fixed).orEmpty(), singleLine = false, maxLines = 10, isTextInputValid = { true }, - onDismiss = { isEditing = false }, + onDismiss = { editing = false }, onAccept = { transaction { runCatching { @@ -278,14 +287,7 @@ fun Lyrics( } ) - if (isPicking && shouldShowSynchronizedLyrics) DefaultDialog( - onDismiss = { isPicking = false }, - horizontalPadding = 0.dp - ) { - val tracks = remember { mutableStateListOf() } - var loading by remember { mutableStateOf(true) } - var error by remember { mutableStateOf(false) } - + if (picking && shouldShowSynchronizedLyrics) { var query by rememberSaveable { mutableStateOf( currentMediaMetadataProvider().title?.toString().orEmpty().let { @@ -297,73 +299,24 @@ fun Lyrics( ) } - LaunchedEffect(query) { - loading = true - error = false - - delay(500) - - LrcLib.lyrics(query = query)?.onSuccess { - tracks.clear() - tracks.addAll(it) - loading = false - error = false - }?.onFailure { - loading = false - error = true - } ?: run { loading = false } - } - - TextField( - value = query, - onValueChange = { query = it }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp), - maxLines = 1, - singleLine = true - ) - Spacer(modifier = Modifier.height(8.dp)) - - when { - loading -> CircularProgressIndicator( - modifier = Modifier.align(Alignment.CenterHorizontally) - ) - - error || tracks.isEmpty() -> BasicText( - text = stringResource(R.string.no_lyrics_found), - style = typography.s.semiBold.center, - modifier = Modifier - .padding(all = 24.dp) - .align(Alignment.CenterHorizontally) - ) - - else -> ValueSelectorDialogBody( - onDismiss = { isPicking = false }, - title = stringResource(R.string.choose_lyric_track), - selectedValue = null, - values = tracks.toImmutableList(), - onValueSelect = { + LrcLibSearchDialog( + query = query, + setQuery = { query = it }, + onDismiss = { picking = false }, + onPick = { + runCatching { transaction { Database.upsert( Lyrics( songId = mediaId, fixed = lyrics?.fixed, - synced = it.syncedLyrics.orEmpty() + synced = it.syncedLyrics ) ) - isPicking = false } - }, - valueText = { - "${it.artistName} - ${it.trackName} (${ - it.duration.seconds.toComponents { minutes, seconds, _ -> - "$minutes:${seconds.toString().padStart(2, '0')}" - } - })" } - ) - } + } + ) } BoxWithConstraints( @@ -381,7 +334,7 @@ fun Lyrics( ) AnimatedVisibility( - visible = isError, + visible = error, enter = slideInVertically { -it }, exit = slideOutVertically { -it }, modifier = Modifier.align(Alignment.TopCenter) @@ -395,12 +348,14 @@ fun Lyrics( modifier = Modifier .background(Color.Black.copy(0.4f)) .padding(all = 8.dp) - .fillMaxWidth() + .fillMaxWidth(), + maxLines = if (pip) 1 else Int.MAX_VALUE, + overflow = TextOverflow.Ellipsis ) } AnimatedVisibility( - visible = invalidLrc && shouldShowSynchronizedLyrics, + visible = !text.isNullOrBlank() && !error && invalidLrc && shouldShowSynchronizedLyrics, enter = slideInVertically { -it }, exit = slideOutVertically { -it }, modifier = Modifier.align(Alignment.TopCenter) @@ -411,31 +366,41 @@ fun Lyrics( modifier = Modifier .background(Color.Black.copy(0.4f)) .padding(all = 8.dp) - .fillMaxWidth() + .fillMaxWidth(), + maxLines = if (pip) 1 else Int.MAX_VALUE, + overflow = TextOverflow.Ellipsis + ) + } + + val lyricsState = rememberSaveable(text) { + val file = lyrics?.synced?.takeIf { it.isNotBlank() }?.let { + LrcParser.parse(it)?.toLrcFile() + } + + SynchronizedLyricsState( + sentences = file?.lines, + offset = file?.offset?.inWholeMilliseconds ?: 0L ) } + val synchronizedLyrics = remember(lyricsState) { + invalidLrc = lyricsState.sentences == null + lyricsState.sentences?.let { + SynchronizedLyrics(it.toImmutableMap()) { + binder?.player?.let { player -> + player.currentPosition + UPDATE_DELAY + lyricsState.offset - (lyrics?.startTime ?: 0L) + } ?: 0L + } + } + } + AnimatedContent( targetState = showSynchronizedLyrics, transitionSpec = { fadeIn() togetherWith fadeOut() }, label = "" ) { synchronized -> + val lazyListState = rememberLazyListState() if (synchronized) { - val lazyListState = rememberLazyListState() - val synchronizedLyrics = remember(text) { - val lrc = lyrics?.synced ?: return@remember null - val sentences = LrcLib.Lyrics(lrc).sentences?.toImmutableMap() - - invalidLrc = sentences == null - sentences?.let { - SynchronizedLyrics(sentences) { - binder?.player?.let { player -> - player.currentPosition + 50L - (lyrics?.startTime ?: 0L) - } ?: 0L - } - } - } - LaunchedEffect(synchronizedLyrics, density, animatedHeight) { val currentSynchronizedLyrics = synchronizedLyrics ?: return@LaunchedEffect val centerOffset = with(density) { (-animatedHeight / 3).roundToPx() } @@ -446,7 +411,7 @@ fun Lyrics( ) while (true) { - delay(50) + delay(UPDATE_DELAY) if (!currentSynchronizedLyrics.update()) continue lazyListState.animateScrollToItem( @@ -495,7 +460,7 @@ fun Lyrics( ) } - if (text == null && !isError) Column( + if (text == null && !error) Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.shimmer() ) { @@ -558,7 +523,7 @@ fun Lyrics( text = stringResource(R.string.edit_lyrics), onClick = { menuState.hide() - isEditing = true + editing = true } ) @@ -617,7 +582,7 @@ fun Lyrics( text = stringResource(R.string.pick_from_lrclib), onClick = { menuState.hide() - isPicking = true + picking = true } ) MenuEntry( @@ -648,3 +613,88 @@ fun Lyrics( } } } + +@Composable +fun LrcLibSearchDialog( + query: String, + setQuery: (String) -> Unit, + onDismiss: () -> Unit, + onPick: (Track) -> Unit, + modifier: Modifier = Modifier +) = DefaultDialog( + onDismiss = onDismiss, + horizontalPadding = 0.dp, + modifier = modifier +) { + val (_, typography) = LocalAppearance.current + + val tracks = remember { mutableStateListOf() } + var loading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(false) } + + LaunchedEffect(query) { + loading = true + error = false + + delay(1000) + + LrcLib.lyrics( + query = query, + synced = true + )?.onSuccess { newTracks -> + tracks.clear() + tracks.addAll(newTracks.filter { !it.syncedLyrics.isNullOrBlank() }) + loading = false + error = false + }?.onFailure { + loading = false + error = true + it.printStackTrace() + } ?: run { loading = false } + } + + TextField( + value = query, + onValueChange = setQuery, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + maxLines = 1, + singleLine = true + ) + Spacer(modifier = Modifier.height(8.dp)) + + when { + loading -> CircularProgressIndicator( + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + + error || tracks.isEmpty() -> BasicText( + text = stringResource(R.string.no_lyrics_found), + style = typography.s.semiBold.center, + modifier = Modifier + .padding(all = 24.dp) + .align(Alignment.CenterHorizontally) + ) + + else -> ValueSelectorDialogBody( + onDismiss = onDismiss, + title = stringResource(R.string.choose_lyric_track), + selectedValue = null, + values = tracks.toImmutableList(), + onValueSelect = { + transaction { + onPick(it) + onDismiss() + } + }, + valueText = { + "${it.artistName} - ${it.trackName} (${ + it.duration.seconds.toComponents { minutes, seconds, _ -> + "$minutes:${seconds.toString().padStart(2, '0')}" + } + })" + } + ) + } +} diff --git a/app/src/main/kotlin/app/vitune/android/ui/screens/player/PlaybackError.kt b/app/src/main/kotlin/app/vitune/android/ui/screens/player/PlaybackError.kt index 63502bb204..03da9a1817 100644 --- a/app/src/main/kotlin/app/vitune/android/ui/screens/player/PlaybackError.kt +++ b/app/src/main/kotlin/app/vitune/android/ui/screens/player/PlaybackError.kt @@ -21,9 +21,11 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import app.vitune.android.utils.center import app.vitune.android.utils.color +import app.vitune.android.utils.isInPip import app.vitune.android.utils.medium import app.vitune.core.ui.LocalAppearance import app.vitune.core.ui.onOverlay @@ -38,6 +40,7 @@ fun PlaybackError( ) = Box(modifier = modifier) { val (colorPalette, typography) = LocalAppearance.current val message by rememberUpdatedState(newValue = messageProvider()) + val pip = isInPip() AnimatedVisibility( visible = isDisplayed, @@ -72,7 +75,9 @@ fun PlaybackError( modifier = Modifier .background(colorPalette.overlay.copy(alpha = 0.4f)) .padding(all = 8.dp) - .fillMaxWidth() + .fillMaxWidth(), + maxLines = if (pip) 1 else Int.MAX_VALUE, + overflow = TextOverflow.Ellipsis ) } } diff --git a/app/src/main/kotlin/app/vitune/android/ui/screens/settings/AppearanceSettings.kt b/app/src/main/kotlin/app/vitune/android/ui/screens/settings/AppearanceSettings.kt index fb7f7aa2d4..d5ce7939a5 100644 --- a/app/src/main/kotlin/app/vitune/android/ui/screens/settings/AppearanceSettings.kt +++ b/app/src/main/kotlin/app/vitune/android/ui/screens/settings/AppearanceSettings.kt @@ -260,7 +260,7 @@ val Darkness.nameLocalized val ThumbnailRoundness.nameLocalized @Composable get() = stringResource( when (this) { - ThumbnailRoundness.None -> R.string.thumbnail_roundness_none + ThumbnailRoundness.None -> R.string.none ThumbnailRoundness.Light -> R.string.thumbnail_roundness_light ThumbnailRoundness.Medium -> R.string.thumbnail_roundness_medium ThumbnailRoundness.Heavy -> R.string.thumbnail_roundness_heavy diff --git a/app/src/main/kotlin/app/vitune/android/utils/Cursor.kt b/app/src/main/kotlin/app/vitune/android/utils/Cursor.kt index 91c76d9b2e..3355ba893e 100644 --- a/app/src/main/kotlin/app/vitune/android/utils/Cursor.kt +++ b/app/src/main/kotlin/app/vitune/android/utils/Cursor.kt @@ -184,5 +184,3 @@ class AudioMediaCursor(cursor: Cursor) : CursorDao(cursor) { val albumUri get() = ContentUris.withAppendedId(ALBUM_URI_BASE, albumId) } - -// TODO: bundle accessors? diff --git a/app/src/main/kotlin/app/vitune/android/utils/ExoPlayer.kt b/app/src/main/kotlin/app/vitune/android/utils/ExoPlayer.kt index a05a11f31d..2c31a41075 100644 --- a/app/src/main/kotlin/app/vitune/android/utils/ExoPlayer.kt +++ b/app/src/main/kotlin/app/vitune/android/utils/ExoPlayer.kt @@ -1,12 +1,19 @@ +@file:OptIn(UnstableApi::class) + package app.vitune.android.utils +import android.content.Context import androidx.annotation.OptIn +import androidx.media3.common.PlaybackException import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DataSource import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.datasource.HttpDataSource.InvalidResponseCodeException +import androidx.media3.datasource.cache.Cache +import androidx.media3.datasource.cache.CacheDataSource -@OptIn(UnstableApi::class) class RangeHandlerDataSourceFactory(private val parent: DataSource.Factory) : DataSource.Factory { class Source(private val parent: DataSource) : DataSource by parent { override fun open(dataSpec: DataSpec) = runCatching { @@ -27,4 +34,34 @@ class RangeHandlerDataSourceFactory(private val parent: DataSource.Factory) : Da override fun createDataSource() = Source(parent.createDataSource()) } +class CatchingDataSourceFactory(private val parent: DataSource.Factory) : DataSource.Factory { + class Source(private val parent: DataSource) : DataSource by parent { + override fun open(dataSpec: DataSpec) = runCatching { + parent.open(dataSpec) + }.getOrElse { + it.printStackTrace() + + if (it is PlaybackException) throw it + else throw PlaybackException( + /* message = */ "Unknown playback error", + /* cause = */ it, + /* errorCode = */ PlaybackException.ERROR_CODE_UNSPECIFIED + ) + } + } + + override fun createDataSource() = Source(parent.createDataSource()) +} + fun DataSource.Factory.handleRangeErrors(): DataSource.Factory = RangeHandlerDataSourceFactory(this) +fun DataSource.Factory.handleUnknownErrors(): DataSource.Factory = CatchingDataSourceFactory(this) + +val Cache.asDataSource get() = CacheDataSource.Factory().setCache(this) + +val Context.defaultDataSource + get() = DefaultDataSource.Factory( + this, + DefaultHttpDataSource.Factory().setConnectTimeoutMs(16000) + .setReadTimeoutMs(8000) + .setUserAgent("Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0") + ) diff --git a/app/src/main/kotlin/app/vitune/android/utils/SynchronizedLyrics.kt b/app/src/main/kotlin/app/vitune/android/utils/SynchronizedLyrics.kt index 8b83264220..d2dffc9117 100644 --- a/app/src/main/kotlin/app/vitune/android/utils/SynchronizedLyrics.kt +++ b/app/src/main/kotlin/app/vitune/android/utils/SynchronizedLyrics.kt @@ -1,10 +1,13 @@ package app.vitune.android.utils +import android.os.Parcelable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.setValue import kotlinx.collections.immutable.ImmutableMap +import kotlinx.parcelize.Parcelize @Stable class SynchronizedLyrics( @@ -32,3 +35,10 @@ class SynchronizedLyrics( } else false } } + +@Parcelize +@Immutable +data class SynchronizedLyricsState( + val sentences: Map?, + val offset: Long +) : Parcelable diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index fcf010728d..3784fa904b 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -203,7 +203,7 @@ System Rundheit der Vorschaubilder - Keine + Keine Leicht Mittel Stark @@ -387,7 +387,13 @@ Überspringt das aktuelle Lied, wenn ein Fehler auftritt. %s wegen eines Fehlers übersprungen Das aktuelle Lied wurde aufgrund eines Fehlers übersprungen. - + Small room + Medium room + Large room + Medium hall + Large hall + Plate + Entferne %1$s Lied von der schwarzen Liste Alle %1$s Songs von der Schwarzen Liste entfernen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 93f090643d..e4f07c105c 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -204,7 +204,7 @@ Sistema Redondez de miniaturas - Ninguna + Ninguna Ligera Media Fuerte @@ -395,6 +395,12 @@ Salta la canción actual cuando hay un error. Se saltó %s debido a un error Se saltó la canción actual debido a un error + Small room + Medium room + Large room + Medium hall + Large hall + Plate Eliminar %1$s canción de la lista negra diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 66b6475103..bdf0b1f4de 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -197,7 +197,7 @@ Sistem Kebulatan thumbnail - Tidak ada + Tidak ada Ringan Sedang Berat @@ -366,6 +366,12 @@ Thumbnail dinamis Ukuran maksimal thumbnail dinamis Ukuran maksimal thumbnail saat thumbnail dinamis digunakan + Small room + Medium room + Large room + Medium hall + Large hall + Plate Hapus semua %1$s lagu dari daftar hitam diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index d7b9eb1afb..07cc7a1933 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -203,7 +203,7 @@ デバイスの設定に合わせる サムネイルの丸み - なし + なし 20% 35%(標準) 50% @@ -388,6 +388,12 @@ エラーが発生したときに再生中の曲をスキップする エラーが発生したため %s がスキップされました エラーが発生したため再生中の曲をスキップしました + Small room + Medium room + Large room + Medium hall + Large hall + Plate ブラックリストから %1$s の曲をすべて削除する diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 07e0a5b463..90025320b1 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -204,7 +204,7 @@ Systeem Rondheid van miniaturen - Geen + Geen Licht Gemiddeld Sterk @@ -400,6 +400,12 @@ De speler slaat het huidige nummer over wanneer er zich een error voordoet. %s overgeslagen vanwege een error Het huidige nummer is overgeslagen vanwege een error + Small room + Medium room + Large room + Medium hall + Large hall + Plate Haal %1$s nummer van de zwarte lijst diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 4af8325e73..d9f7630e3e 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -201,7 +201,7 @@ Sistem Küçük resim yuvarlaklığı - Yok + Yok Açık Orta Ağır @@ -383,6 +383,12 @@ Bir hata olduğunda mevcut şarkıyı atlar. Bir hata nedeniyle %s atlandı Bir hata nedeniyle mevcut şarkı atlandı + Small room + Medium room + Large room + Medium hall + Large hall + Plate %1$s şarkıyı kara listeden kaldır diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 2eb009e4a8..3eb2782e27 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -204,7 +204,7 @@ 系统 缩略图圆角 - + 轻微 中等 较重 @@ -393,6 +393,12 @@ 当发生错误时跳过当前歌曲 由于错误,已跳过 %s 由于错误,已跳过当前歌曲 + Small room + Medium room + Large room + Medium hall + Large hall + Plate 从黑名单中移除所有 %1$s 首歌曲 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b46a872648..383cfe91b4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -204,7 +204,7 @@ System Thumbnail roundness - None + None Light Medium Heavy @@ -400,6 +400,12 @@ Skips the current song when there is an error. Skipped %s because of an error Skipped the current song because of an error + Small room + Medium room + Large room + Medium hall + Large hall + Plate Remove %1$s song from the blacklist diff --git a/build.gradle.kts b/build.gradle.kts index 3520085170..58e788b058 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.kotlin.parcelize) apply false alias(libs.plugins.android.application) apply false alias(libs.plugins.android.library) apply false alias(libs.plugins.android.lint) apply false diff --git a/core/data/src/main/kotlin/app/vitune/core/data/utils/RingBuffer.kt b/core/data/src/main/kotlin/app/vitune/core/data/utils/RingBuffer.kt index 3d21b582e7..ce62134c4e 100644 --- a/core/data/src/main/kotlin/app/vitune/core/data/utils/RingBuffer.kt +++ b/core/data/src/main/kotlin/app/vitune/core/data/utils/RingBuffer.kt @@ -1,10 +1,14 @@ package app.vitune.core.data.utils -class RingBuffer(val size: Int, init: (index: Int) -> T) { +class RingBuffer(val size: Int, init: (index: Int) -> T) : Iterable { private val list = MutableList(size, init) + @get:Synchronized + @set:Synchronized private var index = 0 operator fun get(index: Int) = list.getOrNull(index) operator fun plusAssign(element: T) { list[index++ % size] = element } + + override fun iterator() = list.iterator() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c7afb9ccc6..968edca19c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,6 +20,7 @@ kotlin_jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin_android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin_serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlin_compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin_parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } diff --git a/providers/innertube/src/main/kotlin/app/vitune/providers/innertube/requests/Player.kt b/providers/innertube/src/main/kotlin/app/vitune/providers/innertube/requests/Player.kt index a4dcfe786a..13613ce609 100644 --- a/providers/innertube/src/main/kotlin/app/vitune/providers/innertube/requests/Player.kt +++ b/providers/innertube/src/main/kotlin/app/vitune/providers/innertube/requests/Player.kt @@ -13,55 +13,53 @@ import io.ktor.http.ContentType import io.ktor.http.contentType import kotlinx.serialization.Serializable -suspend fun Innertube.player(body: PlayerBody) = runCatchingCancellable { +suspend fun Innertube.player( + body: PlayerBody, + pipedHost: String = "pipedapi.adminforge.de" +) = runCatchingCancellable { val response = client.post(PLAYER) { setBody(body) mask("playabilityStatus.status,playerConfig.audioConfig,streamingData.adaptiveFormats,videoDetails.videoId") }.body() - if (response.playabilityStatus?.status == "OK") { - response - } else { - @Serializable - data class AudioStream( - val url: String, - val bitrate: Long - ) - - @Serializable - data class PipedResponse( - val audioStreams: List - ) - - val safePlayerResponse = client.post(PLAYER) { - setBody( - body.copy( - context = Context.DefaultAgeRestrictionBypass.copy( - thirdParty = Context.ThirdParty( - embedUrl = "https://www.youtube.com/watch?v=${body.videoId}" - ) + if (response.playabilityStatus?.status == "OK") return@runCatchingCancellable response + val safePlayerResponse = client.post(PLAYER) { + setBody( + body.copy( + context = Context.DefaultAgeRestrictionBypass.copy( + thirdParty = Context.ThirdParty( + embedUrl = "https://www.youtube.com/watch?v=${body.videoId}" ) ) ) - mask("playabilityStatus.status,playerConfig.audioConfig,streamingData.adaptiveFormats,videoDetails.videoId") - }.body() + ) + mask("playabilityStatus.status,playerConfig.audioConfig,streamingData.adaptiveFormats,videoDetails.videoId") + }.body() - if (safePlayerResponse.playabilityStatus?.status != "OK") { - return@runCatchingCancellable response - } + if (safePlayerResponse.playabilityStatus?.status != "OK") return@runCatchingCancellable response - val audioStreams = client.get("https://pipedapi.adminforge.de/streams/${body.videoId}") { - contentType(ContentType.Application.Json) - }.body().audioStreams + val audioStreams = client.get("https://$pipedHost/streams/${body.videoId}") { + contentType(ContentType.Application.Json) + }.body().audioStreams - safePlayerResponse.copy( - streamingData = safePlayerResponse.streamingData?.copy( - adaptiveFormats = safePlayerResponse.streamingData.adaptiveFormats?.map { adaptiveFormat -> - adaptiveFormat.copy( - url = audioStreams.find { it.bitrate == adaptiveFormat.bitrate }?.url - ) - } - ) + safePlayerResponse.copy( + streamingData = safePlayerResponse.streamingData?.copy( + adaptiveFormats = safePlayerResponse.streamingData.adaptiveFormats?.map { adaptiveFormat -> + adaptiveFormat.copy( + url = audioStreams.find { it.bitrate == adaptiveFormat.bitrate }?.url + ) + } ) - } + ) } + +@Serializable +data class AudioStream( + val url: String, + val bitrate: Long +) + +@Serializable +data class PipedResponse( + val audioStreams: List +) diff --git a/providers/lrclib/src/main/kotlin/app/vitune/providers/lrclib/LrcLib.kt b/providers/lrclib/src/main/kotlin/app/vitune/providers/lrclib/LrcLib.kt index 754b1e3823..f1f87c6022 100644 --- a/providers/lrclib/src/main/kotlin/app/vitune/providers/lrclib/LrcLib.kt +++ b/providers/lrclib/src/main/kotlin/app/vitune/providers/lrclib/LrcLib.kt @@ -10,10 +10,17 @@ import io.ktor.client.plugins.UserAgent import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest import io.ktor.client.request.get +import io.ktor.client.request.header import io.ktor.client.request.parameter import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.CancellationException import kotlinx.serialization.json.Json import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +private const val AGENT = "ViTune (https://github.com/25huizengek1/ViTune)" object LrcLib { private val client by lazy { @@ -29,10 +36,11 @@ object LrcLib { defaultRequest { url("https://lrclib.net") + header("Lrclib-Client", AGENT) } install(UserAgent) { - agent = "ViTune (https://github.com/25huizengek1/ViTune)" + agent = AGENT } expectSuccess = true @@ -91,29 +99,120 @@ object LrcLib { )?.mapCatching { tracks -> tracks.bestMatchingFor(title, duration) ?.let { if (synced) it.syncedLyrics else it.plainLyrics } - ?.let(LrcLib::Lyrics) + ?.let { + Lyrics( + text = it, + synced = synced + ) + } } - @JvmInline - value class Lyrics(val text: String) { - val sentences - get() = runCatching { - buildMap { - put(0L, "") - - // TODO: fix this mess - text.trim().lines().filter { it.length >= 10 }.forEach { - put( - it[8].digitToInt() * 10L + - it[7].digitToInt() * 100 + - it[5].digitToInt() * 1000 + - it[4].digitToInt() * 10000 + - it[2].digitToInt() * 60 * 1000 + - it[1].digitToInt() * 600 * 1000, - it.substring(10) - ) - } - } - }.getOrNull() + data class Lyrics( + val text: String, + val synced: Boolean + ) { + fun asLrc() = LrcParser.parse(text)?.toLrcFile() + } +} + +object LrcParser { + private val lyricRegex = "^\\[(\\d{2,}):(\\d{2}).(\\d{2,3})](.*)$".toRegex() + private val metadataRegex = "^\\[(.+?):(.*?)]$".toRegex() + + sealed interface Line { + val raw: String? + + data object Invalid : Line { + override val raw: String? = null + } + + data class Metadata( + val key: String, + val value: String, + override val raw: String + ) : Line + + data class Lyric( + val timestamp: Long, + val line: String, + override val raw: String + ) : Line + } + + private fun Result.handleError(logging: Boolean) = onFailure { + when { + it is CancellationException -> throw it + logging -> it.printStackTrace() + } + } + + fun parse( + raw: String, + logging: Boolean = false + ) = raw.lines().mapNotNull { line -> + line.substringBefore('#').trim().takeIf { it.isNotBlank() } + }.map { line -> + runCatching { + val results = lyricRegex.find(line)?.groups ?: error("Invalid lyric") + val (minutes, seconds, millis, lyric) = results.drop(1).take(4).mapNotNull { it?.value } + val duration = minutes.toInt().minutes + + seconds.toInt().seconds + + millis.padEnd(length = 3, padChar = '0').toInt().milliseconds + + Line.Lyric( + timestamp = duration.inWholeMilliseconds, + line = lyric.trim(), + raw = line + ) + }.handleError(logging).recoverCatching { + val results = metadataRegex.find(line)?.groups ?: error("Invalid metadata") + val (key, value) = results.drop(1).take(2).mapNotNull { it?.value } + + Line.Metadata( + key = key.trim(), + value = value.trim(), + raw = line + ) + }.handleError(logging).getOrDefault(Line.Invalid) + }.takeIf { lrc -> lrc.isNotEmpty() && !lrc.all { it == Line.Invalid } } + + data class LrcFile( + val metadata: Map, + val lines: Map, + val errors: Int + ) { + val title get() = metadata["ti"] + val artist get() = metadata["ar"] + val album get() = metadata["al"] + val author get() = metadata["au"] + val duration + get() = metadata["length"]?.runCatching { + val (minutes, seconds) = split(":", limit = 2) + minutes.toInt().minutes + seconds.toInt().seconds + }?.getOrNull() + val fileAuthor get() = metadata["by"] + val offset get() = metadata["offset"]?.removePrefix("+")?.toIntOrNull()?.milliseconds + val tool get() = metadata["re"] ?: metadata["tool"] + val version get() = metadata["ve"] } } + +fun List.toLrcFile(): LrcParser.LrcFile { + val metadata = mutableMapOf() + val lines = mutableMapOf(0L to "") + var errors = 0 + + forEach { + when (it) { + LrcParser.Line.Invalid -> errors++ + is LrcParser.Line.Lyric -> lines += it.timestamp to it.line + is LrcParser.Line.Metadata -> metadata += it.key to it.value + } + } + + return LrcParser.LrcFile( + metadata = metadata, + lines = lines, + errors = errors + ) +} diff --git a/providers/lrclib/src/main/kotlin/app/vitune/providers/lrclib/models/Track.kt b/providers/lrclib/src/main/kotlin/app/vitune/providers/lrclib/models/Track.kt index a36d4cb1bc..52656b07ff 100644 --- a/providers/lrclib/src/main/kotlin/app/vitune/providers/lrclib/models/Track.kt +++ b/providers/lrclib/src/main/kotlin/app/vitune/providers/lrclib/models/Track.kt @@ -1,5 +1,7 @@ package app.vitune.providers.lrclib.models +import app.vitune.providers.lrclib.LrcParser +import app.vitune.providers.lrclib.toLrcFile import kotlinx.serialization.Serializable import kotlin.math.abs import kotlin.time.Duration @@ -12,7 +14,9 @@ data class Track( val duration: Double, val plainLyrics: String?, val syncedLyrics: String? -) +) { + val lrc by lazy { syncedLyrics?.let { LrcParser.parse(it)?.toLrcFile() } } +} internal fun List.bestMatchingFor(title: String, duration: Duration) = firstOrNull { it.duration.toLong() == duration.inWholeSeconds }