diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/PlaylistsApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/PlaylistsApi.kt index 76aa94b0..c322250a 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/PlaylistsApi.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/PlaylistsApi.kt @@ -48,6 +48,16 @@ interface PlaylistsApi { */ @POST("api/playlists/system/{kind}/refresh") suspend fun refreshSystem(@Path("kind") variant: String): RefreshSystemResponse + + /** + * GET /api/playlists/system/{kind}/shuffle (#415 / #411 R2). Returns + * the system playlist's tracks in rotation-aware order without + * rebuilding — used by the Home play-button overlay so taps on For + * You / Discover / Today's mix advance rotation rather than picking + * the stored order. Mirrors `playlists.dart.systemShuffle`. + */ + @GET("api/playlists/system/{kind}/shuffle") + suspend fun systemShuffle(@Path("kind") variant: String): PlaylistDetailWire } /** diff --git a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt index 711bf89d..276caae4 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt @@ -47,7 +47,9 @@ import com.composables.icons.lucide.Heart import com.composables.icons.lucide.History 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.data.LibraryRepository import com.fabledsword.minstrel.library.widgets.AlbumCard import com.fabledsword.minstrel.library.widgets.ArtistCard import com.fabledsword.minstrel.models.AlbumRef @@ -86,9 +88,11 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout import javax.inject.Inject private const val SHARE_STOP_TIMEOUT_MS = 5_000L +private const val PLAYLIST_FETCH_TIMEOUT_MS = 8_000L private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140 private const val RECENTLY_ADDED_CHUNK = 25 @@ -117,6 +121,7 @@ data class HomeSections( class HomeViewModel @Inject constructor( private val homeRepository: HomeRepository, private val playlistsRepository: PlaylistsRepository, + private val libraryRepository: LibraryRepository, private val player: com.fabledsword.minstrel.player.PlayerController, private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource, connectivity: com.fabledsword.minstrel.connectivity.ConnectivityObserver, @@ -178,6 +183,114 @@ class HomeViewModel @Inject constructor( ) } + /** + * Tap the album play-button overlay: fetch the album detail and + * play its tracks from the first. Mirrors Flutter's AlbumCard + * `_playAlbum`. Suspending so the button's spinner waits for the + * fetch + queue setup; the inner work is launched in viewModelScope + * so cancellation from leaving the screen mid-fetch doesn't kill + * playback. Errors surface via the snackbar channel (Android + * improvement over Flutter's silent-fail; matches the existing + * ArtistDetail play handler). + */ + suspend fun playAlbum(albumId: String) { + viewModelScope.launch { + try { + val detail = libraryRepository.refreshAlbumDetail(albumId) + if (detail.tracks.isEmpty()) { + poolMessages.trySend("This album has no tracks to play.") + } else { + player.setQueue(detail.tracks, initialIndex = 0, source = "album:$albumId") + } + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + poolMessages.trySend( + "Couldn't start playback: ${ErrorCopy.fromThrowable(e)}", + ) + } + }.join() + } + + /** + * Tap the artist play-button overlay: fetch every track across the + * artist's albums, Fisher-Yates shuffle, play from index 0. Mirrors + * Flutter's ArtistCard `_playArtistShuffle`. Empty + error semantics + * follow [playAlbum]. + */ + suspend fun playArtistShuffled(artistId: String) { + viewModelScope.launch { + try { + val tracks = libraryRepository.fetchArtistTracks(artistId).shuffled() + if (tracks.isEmpty()) { + poolMessages.trySend("This artist has no tracks to play.") + } else { + player.setQueue(tracks, initialIndex = 0, source = "artist:$artistId") + } + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + poolMessages.trySend( + "Couldn't start playback: ${ErrorCopy.fromThrowable(e)}", + ) + } + }.join() + } + + /** + * Tap the playlist play-button overlay: fetch the playlist's tracks + * (rotation-aware shuffle for refreshable system playlists, stored + * order for everything else), 8s timeout, play from index 0. + * Mirrors Flutter PlaylistCard `_playPlaylist`. Empty-mix and + * timeout get their own copy strings; other errors fall through to + * [ErrorCopy.fromThrowable]. + */ + suspend fun playPlaylist(playlist: PlaylistRef) { + viewModelScope.launch { + val detail = try { + withTimeout(PLAYLIST_FETCH_TIMEOUT_MS) { + if (playlist.refreshable && playlist.systemVariant != null) { + playlistsRepository.systemShuffle(playlist.systemVariant!!) + } else { + playlistsRepository.refreshDetail(playlist.id) + } + } + } catch ( + @Suppress("SwallowedException") _: kotlinx.coroutines.TimeoutCancellationException, + ) { + poolMessages.trySend("Couldn't load playlist - check your connection") + return@launch + } catch ( + @Suppress("TooGenericExceptionCaught") e: Throwable, + ) { + poolMessages.trySend( + "Couldn't load playlist: ${ErrorCopy.fromThrowable(e)}", + ) + return@launch + } + val tracks = detail.tracks.mapNotNull { row -> + row.trackId?.let { trackId -> + TrackRef( + id = trackId, + title = row.title, + albumId = row.albumId.orEmpty(), + albumTitle = row.albumTitle, + artistId = row.artistId.orEmpty(), + artistName = row.artistName, + durationSec = row.durationSec, + streamUrl = row.streamUrl.orEmpty(), + ) + } + } + if (tracks.isEmpty()) { + poolMessages.trySend("Mix isn't ready yet - try again in a moment") + return@launch + } + val source = if (playlist.refreshable) "playlist:${playlist.systemVariant}" else null + player.setQueue(tracks, initialIndex = 0, source = source) + }.join() + } + /** * Pulls both /home/index and the playlists list. Returns the Job * for the combined refresh so a pull-to-refresh wrapper can await @@ -279,6 +392,9 @@ fun HomeScreen( onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) }, onMostPlayedTap = viewModel::playMostPlayed, onPlayPool = viewModel::playPool, + onPlayAlbum = viewModel::playAlbum, + onPlayArtist = viewModel::playArtistShuffled, + onPlayPlaylist = viewModel::playPlaylist, ) } } @@ -340,6 +456,9 @@ private fun HomeSuccessContent( onPlaylistClick: (String) -> Unit, onMostPlayedTap: (List, Int) -> Unit, onPlayPool: (OfflinePoolKind) -> Unit, + onPlayAlbum: suspend (String) -> Unit, + onPlayArtist: suspend (String) -> Unit, + onPlayPlaylist: suspend (PlaylistRef) -> Unit, ) { LazyColumn( modifier = Modifier.fillMaxSize(), @@ -352,19 +471,23 @@ private fun HomeSuccessContent( // offline, the cache-backed pool cards lead the row. PlaylistsRow( rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline), + offline = offline, onPlaylistClick = onPlaylistClick, onPlayPool = onPlayPool, + onPlayPlaylist = onPlayPlaylist, ) } - recentlyAddedSection(sections.recentlyAddedAlbums, onAlbumClick) + recentlyAddedSection(sections.recentlyAddedAlbums, onAlbumClick, onPlayAlbum) rediscoverSection( albums = sections.rediscoverAlbums, artists = sections.rediscoverArtists, onAlbumClick = onAlbumClick, onArtistClick = onArtistClick, + onPlayAlbum = onPlayAlbum, + onPlayArtist = onPlayArtist, ) mostPlayedSection(sections.mostPlayedTracks, onMostPlayedTap) - lastPlayedSection(sections.lastPlayedArtists, onArtistClick) + lastPlayedSection(sections.lastPlayedArtists, onArtistClick, onPlayArtist) item { Spacer(Modifier.height(BOTTOM_PADDING_FOR_MINIPLAYER_DP.dp)) } } } @@ -372,6 +495,7 @@ private fun HomeSuccessContent( private fun LazyListScope.recentlyAddedSection( albums: List>, onAlbumClick: (String) -> Unit, + onPlayAlbum: suspend (String) -> Unit, ) { if (albums.isEmpty()) { item { @@ -394,6 +518,7 @@ private fun LazyListScope.recentlyAddedSection( title = if (index == 0) "Recently added" else "", albums = chunk, onAlbumClick = onAlbumClick, + onPlayAlbum = onPlayAlbum, ) } } @@ -403,10 +528,19 @@ private fun LazyListScope.rediscoverSection( artists: List>, onAlbumClick: (String) -> Unit, onArtistClick: (String) -> Unit, + onPlayAlbum: suspend (String) -> Unit, + onPlayArtist: suspend (String) -> Unit, ) { item { if (albums.isNotEmpty() || artists.isNotEmpty()) { - RediscoverBlock(albums, artists, onAlbumClick, onArtistClick) + RediscoverBlock( + albums = albums, + artists = artists, + onAlbumClick = onAlbumClick, + onArtistClick = onArtistClick, + onPlayAlbum = onPlayAlbum, + onPlayArtist = onPlayArtist, + ) } else { EmptySection( title = "Rediscover", @@ -435,10 +569,11 @@ private fun LazyListScope.mostPlayedSection( private fun LazyListScope.lastPlayedSection( artists: List>, onArtistClick: (String) -> Unit, + onPlayArtist: suspend (String) -> Unit, ) { item { if (artists.isNotEmpty()) { - ArtistsRow("Last played", artists, onArtistClick) + ArtistsRow("Last played", artists, onArtistClick, onPlayArtist) } else { EmptySection( title = "Last played", @@ -480,14 +615,16 @@ private fun RediscoverBlock( artists: List>, onAlbumClick: (String) -> Unit, onArtistClick: (String) -> Unit, + onPlayAlbum: suspend (String) -> Unit, + onPlayArtist: suspend (String) -> Unit, ) { Column { if (albums.isNotEmpty()) { - AlbumsRow("Rediscover", albums, onAlbumClick) + AlbumsRow("Rediscover", albums, onAlbumClick, onPlayAlbum) } if (artists.isNotEmpty()) { val title = if (albums.isEmpty()) "Rediscover" else "" - ArtistsRow(title, artists, onArtistClick) + ArtistsRow(title, artists, onArtistClick, onPlayArtist) } } } @@ -497,6 +634,7 @@ private fun AlbumsRow( title: String, albums: List>, onAlbumClick: (String) -> Unit, + onPlayAlbum: suspend (String) -> Unit, ) { HorizontalScrollRow(title = title) { items(items = albums, key = { it.id }) { tile -> @@ -504,7 +642,11 @@ private fun AlbumsRow( if (album == null) { SkeletonAlbumTile() } else { - AlbumCard(album = album, onClick = { onAlbumClick(album.id) }) + AlbumCard( + album = album, + onClick = { onAlbumClick(album.id) }, + onPlay = { onPlayAlbum(album.id) }, + ) } } } @@ -513,8 +655,10 @@ private fun AlbumsRow( @Composable private fun PlaylistsRow( rowItems: List, + offline: Boolean, onPlaylistClick: (String) -> Unit, onPlayPool: (OfflinePoolKind) -> Unit, + onPlayPlaylist: suspend (PlaylistRef) -> Unit, ) { HorizontalScrollRow(title = "Playlists") { itemsIndexed(items = rowItems) { _, item -> @@ -527,6 +671,14 @@ private fun PlaylistsRow( is PlaylistRowItem.Real -> PlaylistCard( playlist = item.playlist, onClick = { onPlaylistClick(item.playlist.id) }, + onPlay = { onPlayPlaylist(item.playlist) }, + // Match Flutter: refreshable system playlists need + // the live server endpoints, so disable their play + // overlay when offline (user can still tap into the + // detail and shuffle all from cache). User playlists + // play from cache + survive offline. + playEnabled = item.playlist.trackCount > 0 && + !(offline && item.playlist.refreshable), ) is PlaylistRowItem.Placeholder -> PlaylistPlaceholderCard( label = item.label, @@ -602,6 +754,7 @@ private fun ArtistsRow( title: String, artists: List>, onArtistClick: (String) -> Unit, + onPlayArtist: suspend (String) -> Unit, ) { HorizontalScrollRow(title = title) { items(items = artists, key = { it.id }) { tile -> @@ -609,7 +762,11 @@ private fun ArtistsRow( if (artist == null) { SkeletonArtistTile() } else { - ArtistCard(artist = artist, onClick = { onArtistClick(artist.id) }) + ArtistCard( + artist = artist, + onClick = { onArtistClick(artist.id) }, + onPlay = { onPlayArtist(artist.id) }, + ) } } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/AlbumCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/AlbumCard.kt index 37de12e0..462472a2 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/AlbumCard.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/AlbumCard.kt @@ -21,6 +21,8 @@ import androidx.compose.material3.Icon import com.fabledsword.minstrel.models.AlbumRef import com.fabledsword.minstrel.nav.LocalDetailSeedCache import com.fabledsword.minstrel.shared.widgets.CoverTile +import com.fabledsword.minstrel.shared.widgets.PlayCircleButton +import androidx.compose.ui.Alignment /** * One album tile. Mirrors the Flutter `AlbumCard` (~176dp wide, 144dp @@ -28,12 +30,18 @@ import com.fabledsword.minstrel.shared.widgets.CoverTile * Phase 5.5. The tile stashes its `album` in [LocalDetailSeedCache] * before firing onClick so the detail screen's AppBar renders title / * cover / counts immediately without waiting for the fetch. + * + * [onPlay] is an opt-in play-overlay handler. When non-null, a 44dp + * circular play button is overlaid at the bottom-right of the cover + * (mirrors Flutter's PlayCircleButton). Only Home wires it currently; + * Library / Discover / detail surfaces leave it null. */ @Composable fun AlbumCard( album: AlbumRef, onClick: () -> Unit, modifier: Modifier = Modifier, + onPlay: (suspend () -> Unit)? = null, ) { val seedCache = LocalDetailSeedCache.current Surface( @@ -59,6 +67,16 @@ fun AlbumCard( modifier = Modifier.size(56.dp), ) }, + overlay = { + if (onPlay != null) { + PlayCircleButton( + onPlay = onPlay, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(6.dp), + ) + } + }, ) Spacer(Modifier.height(8.dp)) Text( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/ArtistCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/ArtistCard.kt index b0498cd7..3d0e8e34 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/ArtistCard.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/ArtistCard.kt @@ -24,18 +24,26 @@ import com.composables.icons.lucide.User import com.fabledsword.minstrel.models.ArtistRef import com.fabledsword.minstrel.nav.LocalDetailSeedCache import com.fabledsword.minstrel.shared.widgets.CoverTile +import com.fabledsword.minstrel.shared.widgets.PlayCircleButton /** * One artist tile. Circular cover; name centered beneath. Tap fires * `onClick` — wired to `ArtistDetail` nav in Phase 5.5. Stashes the * `artist` in [LocalDetailSeedCache] on click so the ArtistDetail * AppBar renders the name before the fetch lands. + * + * [onPlay] is an opt-in play-overlay handler. When non-null, a 44dp + * circular play button is overlaid at the bottom-right of the avatar + * (mirrors Flutter's PlayCircleButton). Only Home wires it currently. + * Padding is 4dp instead of 6dp because the circular avatar pulls the + * button visually inward at the corner. */ @Composable fun ArtistCard( artist: ArtistRef, onClick: () -> Unit, modifier: Modifier = Modifier, + onPlay: (suspend () -> Unit)? = null, ) { val seedCache = LocalDetailSeedCache.current Surface( @@ -64,6 +72,16 @@ fun ArtistCard( modifier = Modifier.size(56.dp), ) }, + overlay = { + if (onPlay != null) { + PlayCircleButton( + onPlay = onPlay, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(4.dp), + ) + } + }, ) Spacer(Modifier.height(8.dp)) Text( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt index 5bbd26a7..c97012fc 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt @@ -152,6 +152,20 @@ class PlaylistsRepository @Inject constructor( return response.playlistId } + /** + * Pulls the rotation-aware shuffled tracks for a refreshable system + * playlist without rebuilding. Used by Home's play-button overlay + * to advance For You / Discover / Today's mix rotation on tap. No + * Room write — the server's rotation order is per-call, not stored. + */ + suspend fun systemShuffle(variant: String): PlaylistDetailRef { + val wire = api.systemShuffle(variant) + return PlaylistDetailRef( + playlist = wire.toPlaylistRef(), + tracks = wire.tracks.map { it.toDomain() }, + ) + } + suspend fun appendTrack(playlistId: String, trackId: String): AppendOutcome { val nextPos = (playlistTrackDao.maxPosition(playlistId) ?: -1) + 1 playlistTrackDao.insertOrIgnore( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt index cae8abe5..f0a9824d 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/widgets/PlaylistCard.kt @@ -23,6 +23,7 @@ import com.composables.icons.lucide.Lucide import com.fabledsword.minstrel.models.PlaylistRef import com.fabledsword.minstrel.nav.LocalDetailSeedCache import com.fabledsword.minstrel.shared.widgets.CoverTile +import com.fabledsword.minstrel.shared.widgets.PlayCircleButton import com.fabledsword.minstrel.theme.FabledSwordFlatTokens /** @@ -33,12 +34,20 @@ import com.fabledsword.minstrel.theme.FabledSwordFlatTokens * System playlists carry a small "For You" / "Discover" / etc. label * subtitle under the name (substituting for the artist-name line on * AlbumCard). + * + * [onPlay] is an opt-in play-overlay handler. When non-null, a 44dp + * circular play button is overlaid at the bottom-right of the cover. + * [playEnabled] disables the overlay (50% alpha + ignore taps) — Home + * uses it for system playlists in offline mode (their server-side + * shuffle endpoint is unreachable) and for empty playlists. */ @Composable fun PlaylistCard( playlist: PlaylistRef, onClick: () -> Unit, modifier: Modifier = Modifier, + onPlay: (suspend () -> Unit)? = null, + playEnabled: Boolean = true, ) { val seedCache = LocalDetailSeedCache.current Surface( @@ -72,6 +81,15 @@ fun PlaylistCard( .padding(6.dp), ) } + if (onPlay != null) { + PlayCircleButton( + onPlay = onPlay, + enabled = playEnabled, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(6.dp), + ) + } }, ) Spacer(Modifier.height(8.dp)) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/PlayCircleButton.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/PlayCircleButton.kt new file mode 100644 index 00000000..933cfa75 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/PlayCircleButton.kt @@ -0,0 +1,92 @@ +package com.fabledsword.minstrel.shared.widgets + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Play +import kotlinx.coroutines.launch + +/** + * Always-visible circular play button overlaid on Home tile cover art + * (AlbumCard / ArtistCard / PlaylistCard). Mirrors + * `flutter_client/lib/library/widgets/play_circle_button.dart`: 44dp + * accent-colored disc, parchment Play icon, drop shadow, self-managed + * loading spinner. + * + * [onPlay] is a suspend lambda so the caller can await the fetch-and- + * queue setup. While it runs, the icon is swapped for a spinner and + * the button rejects further taps (re-entrancy guard). [enabled] false + * renders the disc semi-transparent and disables the tap. + */ +@Composable +fun PlayCircleButton( + onPlay: suspend () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + size: Dp = PLAY_BUTTON_DEFAULT_SIZE.dp, +) { + val scope = rememberCoroutineScope() + var starting by remember { mutableStateOf(false) } + val iconSize = size * PLAY_BUTTON_ICON_RATIO + val alpha = if (enabled) 1f else PLAY_BUTTON_DISABLED_ALPHA + Surface( + onClick = { + if (starting) return@Surface + starting = true + scope.launch { + try { + onPlay() + } finally { + starting = false + } + } + }, + modifier = modifier + .size(size) + .alpha(alpha), + enabled = enabled && !starting, + shape = CircleShape, + color = MaterialTheme.colorScheme.primary, + tonalElevation = PLAY_BUTTON_ELEVATION.dp, + shadowElevation = PLAY_BUTTON_ELEVATION.dp, + ) { + Box(contentAlignment = Alignment.Center) { + if (starting) { + CircularProgressIndicator( + modifier = Modifier.size(iconSize), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = PLAY_BUTTON_SPINNER_STROKE.dp, + ) + } else { + Icon( + imageVector = Lucide.Play, + contentDescription = "Play", + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(iconSize), + ) + } + } + } +} + +private const val PLAY_BUTTON_DEFAULT_SIZE = 44 +private const val PLAY_BUTTON_ICON_RATIO = 0.5f +private const val PLAY_BUTTON_DISABLED_ALPHA = 0.5f +private const val PLAY_BUTTON_ELEVATION = 2 +private const val PLAY_BUTTON_SPINNER_STROKE = 2