Remove Outcome class

This commit is contained in:
vfsfitvnm 2022-07-01 20:19:05 +02:00
parent 17cf2454c7
commit fc9b023174
10 changed files with 172 additions and 423 deletions

View file

@ -51,7 +51,7 @@ import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.models.QueuedMediaItem
import it.vfsfitvnm.vimusic.query
import it.vfsfitvnm.vimusic.utils.*
import it.vfsfitvnm.youtubemusic.Outcome
import it.vfsfitvnm.youtubemusic.YouTube
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.StateFlow
@ -427,9 +427,9 @@ class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback
ringBuffer.getOrNull(0)?.first -> dataSpec.withUri(ringBuffer.getOrNull(0)!!.second)
ringBuffer.getOrNull(1)?.first -> dataSpec.withUri(ringBuffer.getOrNull(1)!!.second)
else -> {
val url = runBlocking(Dispatchers.IO) {
it.vfsfitvnm.youtubemusic.YouTube.player(videoId)
}.flatMap { body ->
val urlResult = runBlocking(Dispatchers.IO) {
YouTube.player(videoId)
}?.mapCatching { body ->
val loudnessDb = body.playerConfig?.audioConfig?.loudnessDb?.toFloat()
songPendingLoudnessDb[videoId] = loudnessDb
@ -462,42 +462,25 @@ class PlayerService : Service(), Player.Listener, PlaybackStatsListener.Callback
}
}
Outcome.Success(format.url)
} ?: Outcome.Error.Unhandled(
PlaybackException(
"Couldn't find a playable audio format",
null,
PlaybackException.ERROR_CODE_REMOTE_ERROR
)
format.url
} ?: throw PlaybackException(
"Couldn't find a playable audio format",
null,
PlaybackException.ERROR_CODE_REMOTE_ERROR
)
else -> Outcome.Error.Unhandled(
PlaybackException(
status,
null,
PlaybackException.ERROR_CODE_REMOTE_ERROR
)
else -> throw PlaybackException(
status,
null,
PlaybackException.ERROR_CODE_REMOTE_ERROR
)
}
}
when (url) {
is Outcome.Success -> {
ringBuffer.append(videoId to url.value.toUri())
dataSpec.withUri(url.value.toUri())
.subrange(dataSpec.uriPositionOffset, chunkLength)
}
is Outcome.Error.Network -> throw PlaybackException(
"Couldn't reach the internet",
null,
PlaybackException.ERROR_CODE_REMOTE_ERROR
)
is Outcome.Error.Unhandled -> throw url.throwable
else -> throw PlaybackException(
"Unexpected error",
null,
PlaybackException.ERROR_CODE_REMOTE_ERROR
)
}
urlResult?.getOrThrow()?.let { url ->
ringBuffer.append(videoId to url.toUri())
dataSpec.withUri(url.toUri())
.subrange(dataSpec.uriPositionOffset, chunkLength)
} ?: throw PlaybackException(null, null, PlaybackException.ERROR_CODE_REMOTE_ERROR)
}
}
}

View file

@ -1,127 +0,0 @@
package it.vfsfitvnm.vimusic.ui.components
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
import it.vfsfitvnm.vimusic.utils.italic
import it.vfsfitvnm.vimusic.utils.medium
import it.vfsfitvnm.vimusic.utils.secondary
import it.vfsfitvnm.youtubemusic.Outcome
@Composable
fun <T> OutcomeItem(
outcome: Outcome<T>,
onInitialize: (() -> Unit)? = null,
onRetry: (() -> Unit)? = onInitialize,
onUninitialized: @Composable () -> Unit = {
onInitialize?.let {
SideEffect(it)
}
},
onLoading: @Composable () -> Unit = {},
onError: @Composable (Outcome.Error) -> Unit = {
Error(
error = it,
onRetry = onRetry,
)
},
onSuccess: @Composable (T) -> Unit
) {
when (outcome) {
is Outcome.Initial -> onUninitialized()
is Outcome.Loading -> onLoading()
is Outcome.Error -> onError(outcome)
is Outcome.Recovered -> onError(outcome.error)
is Outcome.Success -> onSuccess(outcome.value)
}
}
@Composable
fun Error(
error: Outcome.Error,
modifier: Modifier = Modifier,
onRetry: (() -> Unit)? = null
) {
Column(
verticalArrangement = Arrangement.spacedBy(
space = 8.dp,
alignment = Alignment.CenterVertically
),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
) {
Image(
painter = painterResource(R.drawable.alert_circle),
contentDescription = null,
colorFilter = ColorFilter.tint(Color(0xFFFC5F5F)),
modifier = Modifier
.padding(horizontal = 16.dp)
.size(48.dp)
)
BasicText(
text = when (error) {
is Outcome.Error.Network -> "Couldn't reach the Internet"
is Outcome.Error.Unhandled -> (error.throwable.message ?: error.throwable.toString())
},
style = LocalTypography.current.xxs.medium.secondary,
)
onRetry?.let { retry ->
BasicText(
text = "Retry",
style = LocalTypography.current.xxs.medium,
modifier = Modifier
.clickable(onClick = retry)
.padding(vertical = 8.dp)
.padding(horizontal = 16.dp)
)
}
}
}
@Composable
fun Message(
text: String,
modifier: Modifier = Modifier,
@DrawableRes icon: Int = R.drawable.alert_circle
) {
Column(
verticalArrangement = Arrangement.spacedBy(
space = 8.dp,
alignment = Alignment.CenterVertically
),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
) {
Image(
painter = painterResource(icon),
contentDescription = null,
colorFilter = ColorFilter.tint(LocalColorPalette.current.darkGray),
modifier = Modifier
.padding(horizontal = 16.dp)
.size(36.dp)
)
BasicText(
text = text,
style = LocalTypography.current.xs.medium.secondary.italic,
)
}
}

View file

@ -17,8 +17,6 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.valentinilk.shimmer.ShimmerBounds
import com.valentinilk.shimmer.rememberShimmer
import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
@ -26,19 +24,15 @@ import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.models.Playlist
import it.vfsfitvnm.vimusic.models.SongPlaylistMap
import it.vfsfitvnm.vimusic.transaction
import it.vfsfitvnm.vimusic.ui.components.Error
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
import it.vfsfitvnm.vimusic.ui.components.Message
import it.vfsfitvnm.vimusic.ui.components.TopAppBar
import it.vfsfitvnm.vimusic.ui.components.themed.Menu
import it.vfsfitvnm.vimusic.ui.components.themed.MenuCloseButton
import it.vfsfitvnm.vimusic.ui.components.themed.MenuEntry
import it.vfsfitvnm.vimusic.ui.components.themed.TextFieldDialog
import it.vfsfitvnm.vimusic.ui.components.themed.*
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
import it.vfsfitvnm.vimusic.utils.*
import it.vfsfitvnm.youtubemusic.Outcome
import it.vfsfitvnm.vimusic.utils.asMediaItem
import it.vfsfitvnm.vimusic.utils.enqueue
import it.vfsfitvnm.vimusic.utils.forcePlayAtIndex
import it.vfsfitvnm.vimusic.utils.relaunchableEffect
import it.vfsfitvnm.youtubemusic.YouTube
import it.vfsfitvnm.youtubemusic.toNullable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ -69,19 +63,21 @@ fun IntentUriScreen(uri: Uri) {
val density = LocalDensity.current
val binder = LocalPlayerServiceBinder.current
var items by remember(uri) {
mutableStateOf<Outcome<List<YouTube.Item.Song>>>(Outcome.Loading)
var itemsResult by remember(uri) {
mutableStateOf<Result<List<YouTube.Item.Song>>?>(null)
}
val onLoad = relaunchableEffect(uri) {
items = withContext(Dispatchers.IO) {
uri.getQueryParameter("list")?.let { playlistId ->
YouTube.queue(playlistId).toNullable()?.map { songList ->
songList
withContext(Dispatchers.IO) {
itemsResult = uri.getQueryParameter("list")?.let { playlistId ->
YouTube.queue(playlistId)?.map { songList ->
songList ?: emptyList()
}
} ?: uri.getQueryParameter("v")?.let { videoId ->
YouTube.song(videoId).toNullable()?.map { listOf(it) }
} ?: Outcome.Error.Unhandled(Error("Missing URL parameters"))
YouTube.song(videoId)?.map { song ->
song?.let { listOf(song) } ?: emptyList()
}
} ?: Result.failure(Error("Missing URL parameters"))
}
}
@ -101,7 +97,8 @@ fun IntentUriScreen(uri: Uri) {
transaction {
val playlistId = Database.insert(Playlist(name = text))
items.valueOrNull
itemsResult
?.getOrNull()
?.map(YouTube.Item.Song::asMediaItem)
?.forEachIndexed { index, mediaItem ->
Database.insert(mediaItem)
@ -159,7 +156,8 @@ fun IntentUriScreen(uri: Uri) {
onClick = {
menuState.hide()
items.valueOrNull
itemsResult
?.getOrNull()
?.map(YouTube.Item.Song::asMediaItem)
?.let { mediaItems ->
binder?.player?.enqueue(
@ -185,59 +183,62 @@ fun IntentUriScreen(uri: Uri) {
}
}
when (val currentItems = items) {
is Outcome.Error -> item {
Error(
error = currentItems,
onRetry = onLoad,
modifier = Modifier
.padding(vertical = 16.dp)
)
}
is Outcome.Recovered -> item {
Error(
error = currentItems.error,
onRetry = onLoad,
modifier = Modifier
.padding(vertical = 16.dp)
)
}
is Outcome.Loading, is Outcome.Initial -> items(count = 5) { index ->
SmallSongItemShimmer(
thumbnailSizeDp = 54.dp,
modifier = Modifier
.alpha(1f - index * 0.175f)
.fillMaxWidth()
.padding(vertical = 4.dp, horizontal = 16.dp)
)
}
is Outcome.Success -> {
if (currentItems.value.isEmpty()) {
item {
Message(
text = "No songs were found",
modifier = Modifier
)
}
} else {
itemsIndexed(
items = currentItems.value,
contentType = { _, item -> item }
) { index, item ->
SmallSongItem(
song = item,
thumbnailSizePx = density.run { 54.dp.roundToPx() },
onClick = {
binder?.stopRadio()
binder?.player?.forcePlayAtIndex(currentItems.value.map(YouTube.Item.Song::asMediaItem), index)
}
)
itemsResult?.getOrNull()?.let { items ->
if (items.isEmpty()) {
item {
TextCard(icon = R.drawable.sad) {
Title(text = "No songs found")
Text(text = "Please try a different query or category.")
}
}
} else {
itemsIndexed(
items = items,
contentType = { _, item -> item }
) { index, item ->
SmallSongItem(
song = item,
thumbnailSizePx = density.run { 54.dp.roundToPx() },
onClick = {
binder?.stopRadio()
binder?.player?.forcePlayAtIndex(items.map(YouTube.Item.Song::asMediaItem), index)
}
)
}
}
else -> {}
} ?: itemsResult?.exceptionOrNull()?.let { throwable ->
item {
LoadingOrError(
errorMessage = throwable.javaClass.canonicalName,
onRetry = onLoad
)
}
} ?: item {
LoadingOrError()
}
}
}
}
}
@Composable
private fun LoadingOrError(
errorMessage: String? = null,
onRetry: (() -> Unit)? = null
) {
LoadingOrError(
errorMessage = errorMessage,
onRetry = onRetry
) {
repeat(5) { index ->
SmallSongItemShimmer(
thumbnailSizeDp = 54.dp,
modifier = Modifier
.alpha(1f - index * 0.175f)
.fillMaxWidth()
.padding(vertical = 4.dp, horizontal = 16.dp)
)
}
}
}

View file

@ -250,9 +250,7 @@ fun SearchResultScreen(
} ?: continuationResult?.let {
if (items.isEmpty()) {
item {
TextCard(
icon = R.drawable.sad
) {
TextCard(icon = R.drawable.sad) {
Title(text = "No results found")
Text(text = "Please try a different query or category.")
}

View file

@ -16,7 +16,7 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.valentinilk.shimmer.shimmer
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.ui.components.Message
import it.vfsfitvnm.vimusic.ui.components.themed.TextCard
import it.vfsfitvnm.vimusic.ui.components.themed.TextFieldDialog
import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
@ -64,10 +64,12 @@ fun LyricsView(
.padding(horizontal = 48.dp)
) {
if (lyrics.isEmpty()) {
Message(
text = "Lyrics not available",
icon = R.drawable.text,
)
TextCard(
icon = R.drawable.sad
) {
Title(text = "No results found")
Text(text = "Please try a different query or category.")
}
} else {
BasicText(
text = lyrics,

View file

@ -43,12 +43,12 @@ import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness
import it.vfsfitvnm.vimusic.models.Song
import it.vfsfitvnm.vimusic.query
import it.vfsfitvnm.vimusic.ui.components.*
import it.vfsfitvnm.vimusic.ui.components.themed.LoadingOrError
import it.vfsfitvnm.vimusic.ui.components.themed.QueuedMediaItemMenu
import it.vfsfitvnm.vimusic.ui.styling.BlackColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
import it.vfsfitvnm.vimusic.utils.*
import it.vfsfitvnm.youtubemusic.Outcome
import it.vfsfitvnm.youtubemusic.YouTube
import it.vfsfitvnm.youtubemusic.models.PlayerResponse
import kotlinx.coroutines.Dispatchers
@ -416,7 +416,7 @@ fun PlayerView(
coroutineScope.launch(Dispatchers.IO) {
YouTube
.player(song.id)
.map { body ->
?.map { body ->
Database.update(
song.copy(
loudnessDb = body.playerConfig?.audioConfig?.loudnessDb?.toFloat(),
@ -450,14 +450,14 @@ fun PlayerView(
.padding(horizontal = 32.dp)
.size(thumbnailSizeDp)
) {
Error(
error = Outcome.Error.Unhandled(playerState.error!!),
LoadingOrError(
errorMessage = playerState.error?.javaClass?.canonicalName,
onRetry = {
player?.playWhenReady = true
player?.prepare()
playerState.error = null
}
)
) {}
}
}

View file

@ -1,49 +0,0 @@
package it.vfsfitvnm.youtubemusic
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.util.network.*
import io.ktor.utils.io.*
fun <T> Result<T>.recoverIfCancelled(): Result<T>? {
return when (exceptionOrNull()) {
is CancellationException -> null
else -> this
}
}
suspend inline fun <reified T> Outcome<HttpResponse>.bodyCatching(): Outcome<T> {
return when (this) {
is Outcome.Success -> value.bodyCatching()
is Outcome.Recovered -> value.bodyCatching()
is Outcome.Initial -> this
is Outcome.Loading -> this
is Outcome.Error -> this
}
}
suspend inline fun HttpClient.postCatching(
urlString: String,
block: HttpRequestBuilder.() -> Unit = {}
): Outcome<HttpResponse> {
return runCatching {
Outcome.Success(post(urlString, block))
}.getOrElse { throwable ->
when (throwable) {
is CancellationException -> Outcome.Loading
is UnresolvedAddressException -> Outcome.Error.Network
else -> Outcome.Error.Unhandled(throwable)
}
}
}
suspend inline fun <reified T> HttpResponse.bodyCatching(): Outcome<T> {
return runCatching {
Outcome.Success(body<T>())
}.getOrElse { throwable ->
Outcome.Error.Unhandled(throwable)
}
}

View file

@ -1,72 +0,0 @@
package it.vfsfitvnm.youtubemusic
sealed class Outcome<out T> {
val valueOrNull: T?
get() = when (this) {
is Success -> value
is Recovered -> value
else -> null
}
fun recoverWith(value: @UnsafeVariance T): Outcome<T> {
return when (this) {
is Error -> Recovered(value, this)
else -> this
}
}
inline fun <R> map(block: (T) -> R): Outcome<R> {
return when (this) {
is Success -> Success(block(value))
is Recovered -> Success(block(value))
is Initial -> this
is Loading -> this
is Error -> this
}
}
inline fun <R> flatMap(block: (T) -> Outcome<R>): Outcome<R> {
return when (this) {
is Success -> block(value)
is Recovered -> block(value)
is Initial -> this
is Loading -> this
is Error -> this
}
}
object Initial : Outcome<Nothing>()
object Loading : Outcome<Nothing>()
sealed class Error : Outcome<Nothing>() {
object Network : Error()
class Unhandled(val throwable: Throwable) : Error()
}
class Recovered<T>(val value: T, val error: Error) : Outcome<T>()
class Success<T>(val value: T) : Outcome<T>()
}
fun <T> Outcome<T>?.toNotNull(): Outcome<T?> {
return when (this) {
null -> Outcome.Success(null)
else -> this
}
}
fun <T> Outcome<T?>.toNullable(error: Outcome.Error? = null): Outcome<T>? {
return when (this) {
is Outcome.Success -> value?.let { Outcome.Success(it) } ?: error
is Outcome.Recovered -> value?.let { Outcome.Success(it) } ?: error
is Outcome.Initial -> this
is Outcome.Loading -> this
is Outcome.Error -> this
}
}
val Outcome<*>.isEvaluable: Boolean
get() = this !is Outcome.Success && this !is Outcome.Loading

View file

@ -0,0 +1,11 @@
package it.vfsfitvnm.youtubemusic
import io.ktor.utils.io.*
internal fun <T> Result<T>.recoverIfCancelled(): Result<T>? {
return when (exceptionOrNull()) {
is CancellationException -> null
else -> this
}
}

View file

@ -450,77 +450,79 @@ object YouTube {
}.recoverIfCancelled()
}
suspend fun player(videoId: String, playlistId: String? = null): Outcome<PlayerResponse> {
return client.postCatching("/youtubei/v1/player") {
contentType(ContentType.Application.Json)
setBody(
PlayerBody(
context = Context.DefaultAndroid,
videoId = videoId,
playlistId = playlistId,
suspend fun player(videoId: String, playlistId: String? = null): Result<PlayerResponse>? {
return runCatching {
client.post("/youtubei/v1/player") {
contentType(ContentType.Application.Json)
setBody(
PlayerBody(
context = Context.DefaultAndroid,
videoId = videoId,
playlistId = playlistId,
)
)
)
parameter("key", Key)
parameter("prettyPrint", false)
}.bodyCatching()
parameter("key", Key)
parameter("prettyPrint", false)
}.body<PlayerResponse>()
}.recoverIfCancelled()
}
private suspend fun getQueue(body: GetQueueBody): Outcome<List<Item.Song>?> {
return client.postCatching("/youtubei/v1/music/get_queue") {
contentType(ContentType.Application.Json)
setBody(body)
parameter("key", Key)
parameter("prettyPrint", false)
}
.bodyCatching<GetQueueResponse>()
.map { body ->
body.queueDatas?.mapNotNull { queueData ->
queueData.content?.playlistPanelVideoRenderer?.let { renderer ->
Item.Song(
info = Info(
name = renderer
.title
?.text ?: return@let null,
endpoint = renderer
.navigationEndpoint
.watchEndpoint
),
authors = renderer
.longBylineText
?.splitBySeparator()
?.getOrNull(0)
?.map { Info.from(it) }
?: emptyList(),
album = renderer
.longBylineText
?.splitBySeparator()
?.getOrNull(1)
?.getOrNull(0)
?.let { Info.from(it) },
thumbnail = renderer
.thumbnail
.thumbnails
.getOrNull(0),
durationText = renderer
.lengthText
?.text
)
}
private suspend fun getQueue(body: GetQueueBody): Result<List<Item.Song>?>? {
return runCatching {
val body = client.post("/youtubei/v1/music/get_queue") {
contentType(ContentType.Application.Json)
setBody(body)
parameter("key", Key)
parameter("prettyPrint", false)
}.body<GetQueueResponse>()
body.queueDatas?.mapNotNull { queueData ->
queueData.content?.playlistPanelVideoRenderer?.let { renderer ->
Item.Song(
info = Info(
name = renderer
.title
?.text ?: return@let null,
endpoint = renderer
.navigationEndpoint
.watchEndpoint
),
authors = renderer
.longBylineText
?.splitBySeparator()
?.getOrNull(0)
?.map { Info.from(it) }
?: emptyList(),
album = renderer
.longBylineText
?.splitBySeparator()
?.getOrNull(1)
?.getOrNull(0)
?.let { Info.from(it) },
thumbnail = renderer
.thumbnail
.thumbnails
.getOrNull(0),
durationText = renderer
.lengthText
?.text
)
}
}
}.recoverIfCancelled()
}
suspend fun song(videoId: String): Outcome<Item.Song?> {
suspend fun song(videoId: String): Result<Item.Song?>? {
return getQueue(
GetQueueBody(
context = Context.DefaultWeb,
videoIds = listOf(videoId),
playlistId = null
)
).map { it?.firstOrNull() }
)?.map { it?.firstOrNull() }
}
suspend fun queue(playlistId: String): Outcome<List<Item.Song>?> {
suspend fun queue(playlistId: String): Result<List<Item.Song>?>? {
return getQueue(
GetQueueBody(
context = Context.DefaultWeb,
@ -674,7 +676,7 @@ object YouTube {
}
suspend fun browse(browseId: String): Result<BrowseResponse>? {
return runCatching<YouTube, BrowseResponse> {
return runCatching {
client.post("/youtubei/v1/browse") {
contentType(ContentType.Application.Json)
setBody(
@ -685,7 +687,7 @@ object YouTube {
)
parameter("key", Key)
parameter("prettyPrint", false)
}.body()
}.body<BrowseResponse>()
}.recoverIfCancelled()
}