feat(android): ErrorCopy — friendly error messages across the app (audit v3 §4.2)

Ports Flutter's error-copy.json (45 server codes → sentence-case
copy) as a Kotlin ErrorCopy object. fromThrowable(t):
  - Retrofit HttpException → parse {"error":{"code":...}} body,
    map code (e.g. "wrong_password" → "Current password is
    incorrect.", "playlist_not_found" → "That playlist no longer
    exists.")
  - IOException → connection_refused → "Couldn't reach the server.
    Check the URL and try again."
  - anything else → "Something went wrong."

Wired into 22 ViewModels / screens, replacing the raw
`e.message ?: "<generic>"` fallbacks that leaked exception text
(e.g. "HTTP 404", "Unable to resolve host") into the UI. Load-state
errors now read as actionable copy; settings form messages dropped
their "Couldn't save:" prefixes since the friendly strings stand
alone. ArtistDetail's playback path keeps its "Couldn't start
playback: " prefix (local-action context). PlayerController's
controller-connect failure and the About update-check are left on
their own copy (not server-code errors).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 23:25:19 -04:00
parent 04ece65a07
commit 3af8fa7207
23 changed files with 159 additions and 28 deletions
@@ -3,6 +3,7 @@ package com.fabledsword.minstrel.admin.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminInvitesRepository
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.models.Invite
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.channels.Channel
@@ -49,7 +50,7 @@ class AdminInvitesViewModel @Inject constructor(
internal.update {
it.copy(
isLoading = false,
message = "Couldn't load invites: ${e.message ?: "unknown error"}",
message = ErrorCopy.fromThrowable(e),
)
}
}
@@ -67,7 +68,7 @@ class AdminInvitesViewModel @Inject constructor(
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.update {
it.copy(message = "Couldn't generate invite: ${e.message ?: "unknown error"}")
it.copy(message = ErrorCopy.fromThrowable(e))
}
}
}
@@ -37,6 +37,7 @@ import com.composables.icons.lucide.Users
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.nav.Admin
import com.fabledsword.minstrel.nav.AdminQuarantine
import com.fabledsword.minstrel.nav.AdminRequests
@@ -95,7 +96,7 @@ class AdminLandingViewModel @Inject constructor(
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = AdminLandingUiState.Error(
e.message ?: "Admin counts load failed",
ErrorCopy.fromThrowable(e),
)
}
}
@@ -3,6 +3,7 @@ package com.fabledsword.minstrel.admin.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -52,7 +53,7 @@ class AdminQuarantineViewModel @Inject constructor(
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = AdminQuarantineUiState.Error(
e.message ?: "Admin quarantine load failed",
ErrorCopy.fromThrowable(e),
)
}
}
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.RequestRef
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -69,7 +70,7 @@ class AdminRequestsViewModel @Inject constructor(
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = AdminRequestsUiState.Error(
e.message ?: "Admin requests load failed",
ErrorCopy.fromThrowable(e),
)
}
}
@@ -3,6 +3,7 @@ package com.fabledsword.minstrel.admin.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.models.AdminUserRef
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
@@ -43,7 +44,7 @@ class AdminUsersViewModel @Inject constructor(
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = AdminUsersUiState.Error(e.message ?: "Users load failed")
internal.value = AdminUsersUiState.Error(ErrorCopy.fromThrowable(e))
}
}
@@ -0,0 +1,109 @@
package com.fabledsword.minstrel.api
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import retrofit2.HttpException
import java.io.IOException
/**
* Maps server error codes (and common transport failures) to
* friendly, sentence-case copy. Mirrors
* `flutter_client/assets/error-copy.json` + `error_copy.dart`.
*
* Server errors are `{"error":{"code":"...","message":"..."}}`.
* [fromThrowable] pulls the code out of a Retrofit [HttpException]'s
* error body and maps it; network failures (no connection / DNS /
* refused) map to `connection_refused`; anything unrecognised falls
* back to `unknown`.
*
* Replaces the `e.message ?: "unknown error"` pattern scattered
* across ViewModels with copy a user can actually act on.
*/
object ErrorCopy {
private val json = Json { ignoreUnknownKeys = true }
@Serializable
private data class Envelope(val error: Body? = null)
@Serializable
private data class Body(val code: String = "", val message: String = "")
/** Friendly copy for a known error [code], or the `unknown` fallback. */
fun messageFor(code: String): String = TABLE[code] ?: TABLE.getValue("unknown")
/**
* Friendly copy for any caught throwable. Parses Retrofit
* HttpException bodies for the server code; treats IOExceptions
* as connection failures.
*/
fun fromThrowable(t: Throwable): String = when (t) {
is HttpException -> messageFor(codeFromHttp(t))
is IOException -> messageFor("connection_refused")
else -> TABLE.getValue("unknown")
}
private fun codeFromHttp(e: HttpException): String {
val raw = runCatching { e.response()?.errorBody()?.string() }.getOrNull()
?: return "unknown"
val code = runCatching { json.decodeFromString<Envelope>(raw).error?.code }
.getOrNull()
.orEmpty()
return code.ifEmpty { "unknown" }
}
private val TABLE: Map<String, String> = mapOf(
"unknown" to "Something went wrong.",
"unauthenticated" to "Your session has ended. Please sign in again.",
"auth_required" to "You need to sign in to do that.",
"forbidden" to "You don't have permission to do that.",
"not_authorized" to "You don't have permission to do that.",
"invalid_credentials" to "Wrong username or password.",
"wrong_password" to "Current password is incorrect.",
"password_too_short" to "Password must be at least 8 characters.",
"username_invalid" to "That username isn't valid.",
"username_taken" to "That username is already taken.",
"email_invalid" to "Enter a valid email address.",
"email_taken" to "That email is already in use.",
"invalid_token" to "That link has expired or already been used. Request a new one.",
"invite_invalid" to "That invite has expired or already been used.",
"invite_required" to "An invite is required to register on this server.",
"last_admin" to "Can't demote or delete the last admin.",
"no_email_on_file" to "No email is associated with that account.",
"not_configured" to "This integration isn't set up yet.",
"validation" to "Some fields aren't valid. Check and try again.",
"missing_fields" to "Required fields are missing.",
"missing_query" to "Search needs at least one keyword.",
"invalid_id" to "Invalid identifier.",
"invalid_body" to "Couldn't read the request.",
"bad_request" to "Invalid request.",
"bad_body" to "Couldn't read the request.",
"bad_kind" to "Invalid request type.",
"bad_paging" to "Invalid page size or offset.",
"bad_reason" to "Invalid quarantine reason.",
"mbid_required" to "An MBID is required for this lookup.",
"system_playlist_readonly" to "System playlists can't be edited directly.",
"connection_refused" to "Couldn't reach the server. Check the URL and try again.",
"lidarr_unreachable" to
"Lidarr is unreachable right now. Try again, or check Admin → Integrations.",
"lidarr_disabled" to "Lidarr integration is not enabled.",
"lidarr_auth_failed" to "Lidarr authentication failed.",
"lidarr_defaults_incomplete" to
"Lidarr is missing a default quality profile or root folder. " +
"Set them in Admin → Integrations.",
"lidarr_server_error" to "Lidarr returned an error. Check Lidarr's logs for the cause.",
"lidarr_rejected" to
"Lidarr rejected the request. Check the server logs for the field-level reason.",
"lidarr_album_lookup_failed" to
"Lidarr doesn't recognize this album. Try Resolve or Delete file instead.",
"album_mbid_missing" to "This track has no Lidarr album to remove.",
"request_not_pending" to "This request is no longer pending.",
"request_not_found" to "That request no longer exists.",
"track_not_found" to "That track no longer exists.",
"album_not_found" to "That album no longer exists.",
"artist_not_found" to "That artist no longer exists.",
"playlist_not_found" to "That playlist no longer exists.",
"user_not_found" to "That user no longer exists.",
"not_found" to "That no longer exists.",
)
}
@@ -2,6 +2,7 @@ package com.fabledsword.minstrel.auth.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.auth.AuthController
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -49,7 +50,7 @@ class LoginViewModel @Inject constructor(
internal.update {
it.copy(
isSubmitting = false,
errorMessage = e.message ?: "Sign-in failed",
errorMessage = ErrorCopy.fromThrowable(e),
)
}
}
@@ -2,6 +2,7 @@ package com.fabledsword.minstrel.discover.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.discover.data.DiscoverRepository
import com.fabledsword.minstrel.discover.data.RequestOutcome
import com.fabledsword.minstrel.models.ArtistSuggestionRef
@@ -78,7 +79,7 @@ class DiscoverViewModel @Inject constructor(
internal.update {
it.copy(
suggestions = SuggestionState.Error(
e.message ?: "Suggestions failed",
ErrorCopy.fromThrowable(e),
),
)
}
@@ -100,7 +101,7 @@ class DiscoverViewModel @Inject constructor(
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.update {
it.copy(results = ResultsState.Error(e.message ?: "Search failed"))
it.copy(results = ResultsState.Error(ErrorCopy.fromThrowable(e)))
}
}
}
@@ -22,6 +22,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.history.data.HistoryEntry
import com.fabledsword.minstrel.history.data.HistoryRepository
import com.fabledsword.minstrel.models.TrackRef
@@ -81,7 +82,7 @@ class HistoryTabViewModel @Inject constructor(
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = HistoryUiState.Error(e.message ?: "History load failed")
internal.value = HistoryUiState.Error(ErrorCopy.fromThrowable(e))
}
}
@@ -42,6 +42,7 @@ import androidx.navigation.NavHostController
import coil3.compose.AsyncImage
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Music
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.home.data.HomeRepository
import com.fabledsword.minstrel.library.widgets.AlbumCard
import com.fabledsword.minstrel.library.widgets.ArtistCard
@@ -174,7 +175,7 @@ class HomeViewModel @Inject constructor(
observeHomeSections().combine(playlistsRepository.observeAll()) { sections, playlists ->
val merged = sections.copy(playlists = playlists)
if (merged.isAllEmpty) HomeUiState.Empty else HomeUiState.Success(merged)
}.catch { e -> emit(HomeUiState.Error(e.message ?: "Home load failed")) }
}.catch { e -> emit(HomeUiState.Error(ErrorCopy.fromThrowable(e))) }
}
// ─── Screen ──────────────────────────────────────────────────────────
@@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.library.data.LibraryRepository
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.models.AlbumDetailRef
@@ -80,7 +81,7 @@ class AlbumDetailViewModel @Inject constructor(
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = AlbumDetailUiState.Error(e.message ?: "Album load failed")
internal.value = AlbumDetailUiState.Error(ErrorCopy.fromThrowable(e))
}
}
@@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.library.data.LibraryRepository
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.models.ArtistDetailRef
@@ -84,7 +85,7 @@ class ArtistDetailViewModel @Inject constructor(
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = ArtistDetailUiState.Error(e.message ?: "Artist load failed")
internal.value = ArtistDetailUiState.Error(ErrorCopy.fromThrowable(e))
}
}
@@ -112,7 +113,7 @@ class ArtistDetailViewModel @Inject constructor(
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
transientMessagesChannel.trySend(
"Couldn't start playback: ${e.message ?: "unknown error"}",
"Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
)
}
}
@@ -2,6 +2,7 @@ package com.fabledsword.minstrel.library.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.cache.sync.SyncController
import com.fabledsword.minstrel.library.data.LibraryRepository
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -35,7 +36,7 @@ class LibraryViewModel @Inject constructor(
}
}
.catch { e ->
emit(LibraryUiState.Error(e.message ?: "Library load failed"))
emit(LibraryUiState.Error(ErrorCopy.fromThrowable(e)))
}
.stateIn(
scope = viewModelScope,
@@ -27,6 +27,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.library.widgets.AlbumCard
import com.fabledsword.minstrel.library.widgets.ArtistCard
@@ -107,7 +108,7 @@ class LikedTabViewModel @Inject constructor(
val sections = LikedSections(artists, albums, tracks)
if (sections.isAllEmpty) LikedTabUiState.Empty else LikedTabUiState.Success(sections)
}
.catch { e -> emit(LikedTabUiState.Error(e.message ?: "Liked load failed")) }
.catch { e -> emit(LikedTabUiState.Error(ErrorCopy.fromThrowable(e))) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
@@ -58,6 +58,7 @@ import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Play
import com.composables.icons.lucide.RefreshCw
import com.composables.icons.lucide.Shuffle
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.events.LiveEvent
import com.fabledsword.minstrel.models.PlaylistRef
@@ -196,7 +197,7 @@ class PlaylistDetailViewModel @Inject constructor(
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = PlaylistDetailUiState.Error(
e.message ?: "Couldn't load playlist",
ErrorCopy.fromThrowable(e),
)
}
}
@@ -23,6 +23,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.nav.PlaylistDetail
@@ -89,7 +90,7 @@ class PlaylistsListViewModel @Inject constructor(
}
}
.catch { e ->
emit(PlaylistsListUiState.Error(e.message ?: "Playlists load failed"))
emit(PlaylistsListUiState.Error(ErrorCopy.fromThrowable(e)))
}
.stateIn(
scope = viewModelScope,
@@ -2,6 +2,7 @@ package com.fabledsword.minstrel.quarantine.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.models.QuarantineRef
import com.fabledsword.minstrel.quarantine.data.QuarantineRepository
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -43,7 +44,7 @@ class HiddenTabViewModel @Inject constructor(
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = HiddenTabUiState.Error(e.message ?: "Hidden load failed")
internal.value = HiddenTabUiState.Error(ErrorCopy.fromThrowable(e))
}
}
@@ -2,6 +2,7 @@ package com.fabledsword.minstrel.requests.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.RequestRef
import com.fabledsword.minstrel.requests.data.RequestsRepository
@@ -54,7 +55,7 @@ class RequestsViewModel @Inject constructor(
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = RequestsUiState.Error(e.message ?: "Requests load failed")
internal.value = RequestsUiState.Error(ErrorCopy.fromThrowable(e))
}
}
@@ -2,6 +2,7 @@ package com.fabledsword.minstrel.search.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.models.SearchResponseRef
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.PlayerController
@@ -103,7 +104,7 @@ class SearchViewModel @Inject constructor(
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.update {
it.copy(results = SearchResultsState.Error(e.message ?: "Search failed"))
it.copy(results = SearchResultsState.Error(ErrorCopy.fromThrowable(e)))
}
}
}
@@ -2,6 +2,7 @@ package com.fabledsword.minstrel.settings.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.models.ListenBrainzStatus
import com.fabledsword.minstrel.settings.data.MeRepository
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -56,7 +57,7 @@ class ListenBrainzViewModel @Inject constructor(
internal.update {
it.copy(
isLoading = false,
message = "Couldn't load: ${e.message ?: "unknown error"}",
message = ErrorCopy.fromThrowable(e),
)
}
}
@@ -84,7 +85,7 @@ class ListenBrainzViewModel @Inject constructor(
internal.update {
it.copy(
isSaving = false,
message = "Couldn't save: ${e.message ?: "unknown error"}",
message = ErrorCopy.fromThrowable(e),
)
}
}
@@ -104,7 +105,7 @@ class ListenBrainzViewModel @Inject constructor(
internal.update {
it.copy(
isSaving = false,
message = "Couldn't update: ${e.message ?: "unknown error"}",
message = ErrorCopy.fromThrowable(e),
)
}
}
@@ -2,6 +2,7 @@ package com.fabledsword.minstrel.settings.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.settings.data.MeRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -64,7 +65,7 @@ class PasswordViewModel @Inject constructor(
internal.update {
it.copy(
isChanging = false,
message = "Couldn't change password: ${e.message ?: "unknown error"}",
message = ErrorCopy.fromThrowable(e),
)
}
}
@@ -2,6 +2,7 @@ package com.fabledsword.minstrel.settings.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.models.MyProfile
import com.fabledsword.minstrel.settings.data.MeRepository
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -71,7 +72,7 @@ class ProfileViewModel @Inject constructor(
internal.update {
it.copy(
isLoading = false,
message = "Couldn't load profile: ${e.message ?: "unknown error"}",
message = ErrorCopy.fromThrowable(e),
)
}
}
@@ -102,7 +103,7 @@ class ProfileViewModel @Inject constructor(
internal.update {
it.copy(
isSaving = false,
message = "Couldn't save: ${e.message ?: "unknown error"}",
message = ErrorCopy.fromThrowable(e),
)
}
}
@@ -2,6 +2,7 @@ package com.fabledsword.minstrel.shared.widgets.trackactions
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -44,7 +45,7 @@ class AddToPlaylistViewModel @Inject constructor(
}
emit(AddToPlaylistUiState.Loading)
}
.catch { e -> emit(AddToPlaylistUiState.Error(e.message ?: "Failed to load playlists")) }
.catch { e -> emit(AddToPlaylistUiState.Error(ErrorCopy.fromThrowable(e))) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),