Skip to content

Commit

Permalink
player: Wait to reconnect, display status in miniplayer, refactor pla…
Browse files Browse the repository at this point in the history
…y fail error handling (#198)

Co-authored-by: mikooomich <[email protected]>
  • Loading branch information
mattcarter11 and mikooomich authored Jan 30, 2025
1 parent 4828c90 commit 25d7777
Show file tree
Hide file tree
Showing 7 changed files with 129 additions and 55 deletions.
96 changes: 71 additions & 25 deletions app/src/main/java/com/dd3boh/outertune/playback/MusicService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ import com.dd3boh.outertune.playback.queues.Queue
import com.dd3boh.outertune.playback.queues.YouTubeQueue
import com.dd3boh.outertune.utils.CoilBitmapLoader
import com.dd3boh.outertune.utils.YTPlayerUtils
import com.dd3boh.outertune.utils.NetworkConnectivityObserver
import com.dd3boh.outertune.utils.dataStore
import com.dd3boh.outertune.utils.enumPreference
import com.dd3boh.outertune.utils.get
Expand Down Expand Up @@ -159,6 +160,10 @@ class MusicService : MediaLibraryService(),

private lateinit var connectivityManager: ConnectivityManager

lateinit var connectivityObserver: NetworkConnectivityObserver
val waitingForNetworkConnection = MutableStateFlow(false)
private val isNetworkConnected = MutableStateFlow(false)

private val audioQuality by enumPreference(this, AudioQualityKey, AudioQuality.AUTO)

var queueTitle: String? = null
Expand Down Expand Up @@ -195,6 +200,20 @@ class MusicService : MediaLibraryService(),
override fun onCreate() {
super.onCreate()

connectivityObserver = NetworkConnectivityObserver(this)

scope.launch {
connectivityObserver.networkStatus.collect { isConnected ->
isNetworkConnected.value = isConnected

if (isConnected && waitingForNetworkConnection.value) {
waitingForNetworkConnection.value = false
player.prepare()
player.play()
}
}
}

player = ExoPlayer.Builder(this)
.setMediaSourceFactory(DefaultMediaSourceFactory(createDataSourceFactory()))
.setRenderersFactory(createRenderersFactory())
Expand All @@ -218,43 +237,33 @@ class MusicService : MediaLibraryService(),
addListener(object : Player.Listener {
override fun onPlayerError(error: PlaybackException) {
super.onPlayerError(error)
if (!dataStore.get(SkipOnErrorKey, true)) {

// wait for reconnection
val isConnectionError = (error.cause?.cause is PlaybackException)
&& (error.cause?.cause as PlaybackException).errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED
if (!isNetworkConnected.value || isConnectionError) {
waitOnNetworkError()
return
}

consecutivePlaybackErr += 2
if (dataStore.get(SkipOnErrorKey, true)) {
skipOnError()
}
else {
stopOnError()
}

Toast.makeText(
this@MusicService,
"Playback error: ${error.message} (${error.errorCode}): ${error.cause?.message?: "No further errors."} ",
Toast.LENGTH_SHORT
"Error: ${error.message} (${error.errorCode}): ${error.cause?.message?: "No further errors."} ",
Toast.LENGTH_LONG
).show()

/**
* Auto skip to the next media item on error.
*
* To prevent a "runaway diesel engine" scenario, force the user to take action after
* too many errors come up too quickly. Pause to show player "stopped" state
*/
val nextWindowIndex = player.nextMediaItemIndex
if (consecutivePlaybackErr <= MAX_CONSECUTIVE_ERR && nextWindowIndex != C.INDEX_UNSET) {
player.seekTo(nextWindowIndex, C.TIME_UNSET)
player.prepare()
player.play()
} else {
player.pause()
Toast.makeText(
this@MusicService,
"Playback stopped due to too many errors",
Toast.LENGTH_SHORT
).show()
consecutivePlaybackErr = 0
}
}

// start playback again on seek
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
super.onMediaItemTransition(mediaItem, reason)

// +2 when and error happens, and -1 when transition. Thus when error, number increments by 1, else doesn't change
if (consecutivePlaybackErr > 0) {
consecutivePlaybackErr --
Expand Down Expand Up @@ -386,6 +395,40 @@ class MusicService : MediaLibraryService(),
playerNotificationManager.setMediaSessionToken(mediaSession.platformToken)
}

fun waitOnNetworkError() {
waitingForNetworkConnection.value = true
Toast.makeText(this@MusicService, getString(R.string.wait_to_reconnect), Toast.LENGTH_LONG).show()
}

fun skipOnError() {
/**
* Auto skip to the next media item on error.
*
* To prevent a "runaway diesel engine" scenario, force the user to take action after
* too many errors come up too quickly. Pause to show player "stopped" state
*/
consecutivePlaybackErr += 2
val nextWindowIndex = player.nextMediaItemIndex

if (consecutivePlaybackErr <= MAX_CONSECUTIVE_ERR && nextWindowIndex != C.INDEX_UNSET) {
player.seekTo(nextWindowIndex, C.TIME_UNSET)
player.prepare()
player.play()

Toast.makeText(this@MusicService, getString(R.string.err_play_next_on_error), Toast.LENGTH_SHORT).show()
return
}

player.pause()
Toast.makeText(this@MusicService, getString(R.string.err_stop_on_too_many_errors), Toast.LENGTH_LONG).show()
consecutivePlaybackErr = 0
}

fun stopOnError() {
player.pause()
Toast.makeText(this@MusicService, getString(R.string.err_stop_on_error), Toast.LENGTH_LONG).show()
}

fun initQueue() {
if (dataStore.get(PersistentQueueKey, true)) {
queueBoard = QueueBoard(database.readQueue().toMutableList())
Expand Down Expand Up @@ -635,6 +678,9 @@ class MusicService : MediaLibraryService(),
openAudioEffectSession()
} else {
closeAudioEffectSession()
if (!player.playWhenReady) {
waitingForNetworkConnection.value = false
}
}
}
if (events.containsAny(EVENT_TIMELINE_CHANGED, EVENT_POSITION_DISCONTINUITY)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
Expand All @@ -42,6 +44,7 @@ class PlayerConnection(
val isPlaying = combine(playbackState, playWhenReady) { playbackState, playWhenReady ->
playWhenReady && playbackState != STATE_ENDED
}.stateIn(scope, SharingStarted.Lazily, player.playWhenReady && player.playbackState != STATE_ENDED)
val waitingForNetworkConnection: StateFlow<Boolean> = service.waitingForNetworkConnection.asStateFlow()
val mediaMetadata = MutableStateFlow(player.currentMetadata)
val currentSong = mediaMetadata.flatMapLatest {
database.song(it?.id)
Expand Down
27 changes: 12 additions & 15 deletions app/src/main/java/com/dd3boh/outertune/ui/component/Items.kt
Original file line number Diff line number Diff line change
Expand Up @@ -238,17 +238,18 @@ fun ListItem(
trailingContent: @Composable RowScope.() -> Unit = {},
isSelected: Boolean? = false,
isActive: Boolean = false,
isLocalSong: Boolean? = null,
isLocalSong: Boolean = false,
isLiked: Boolean = false,
inLibrary: Boolean = false,
available: Boolean = true,
) = ListItem(
title = title,
subtitle = {
badges()

// local song indicator
if (isLocalSong == true) {
FolderCopy()
}
if (isLiked)Icon.Favorite()
if (inLibrary) Icon.Library()
if (isLocalSong) FolderCopy()

if (!subtitle.isNullOrEmpty()) {
Text(
Expand Down Expand Up @@ -397,20 +398,11 @@ fun SongListItem(
makeTimeString(song.song.duration * 1000L)
),
badges = {
if (showLikedIcon && song.song.liked) {
Icon.Favorite()
}
if (showInLibraryIcon && song.song.inLibrary != null) {
Icon.Library()
}
if (showDownloadIcon) {
val download by LocalDownloadUtil.current.getDownload(song.id)
.collectAsState(initial = null)
Icon.Download(download?.state)
}
if (showLocalIcon && song.song.isLocal) {
FolderCopy()
}
},
thumbnailContent = {
ItemThumbnail(
Expand Down Expand Up @@ -469,6 +461,9 @@ fun SongListItem(
},
isSelected = inSelectMode == true && isSelected,
isActive = isActive,
isLocalSong = showLocalIcon && song.song.isLocal,
isLiked = showLikedIcon && song.song.liked,
inLibrary = showInLibraryIcon && song.song.inLibrary != null,
available = available,
modifier = modifier.combinedClickable(
onClick = {
Expand Down Expand Up @@ -1121,7 +1116,9 @@ fun MediaMetadataListItem(
modifier = modifier,
isSelected = isSelected,
isActive = isActive,
isLocalSong = mediaMetadata.isLocal
isLocalSong = mediaMetadata.isLocal,
isLiked = mediaMetadata.liked,
inLibrary = mediaMetadata.inLibrary != null
)

@Composable
Expand Down
19 changes: 12 additions & 7 deletions app/src/main/java/com/dd3boh/outertune/ui/menu/PlayerMenu.kt
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
Expand Down Expand Up @@ -111,9 +113,9 @@ fun PlayerMenu(
val currentFormat by playerConnection.currentFormat.collectAsState(initial = null)
val librarySong by database.song(mediaMetadata.id).collectAsState(initial = null)
val coroutineScope = rememberCoroutineScope()

val download by LocalDownloadUtil.current.getDownload(mediaMetadata.id).collectAsState(initial = null)


var showChooseQueueDialog by rememberSaveable {
mutableStateOf(false)
}
Expand Down Expand Up @@ -228,7 +230,7 @@ fun PlayerMenu(
}

var sleepTimerValue by remember {
mutableStateOf(30f)
mutableFloatStateOf(30f)
}

if (showSleepTimerDialog) {
Expand Down Expand Up @@ -329,7 +331,7 @@ fun PlayerMenu(
bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding()
)
) {
if (mediaMetadata.isLocal != true)
if (!mediaMetadata.isLocal)
GridMenuItem(
icon = Icons.Rounded.Radio,
title = R.string.start_radio
Expand All @@ -349,7 +351,7 @@ fun PlayerMenu(
) {
showChoosePlaylistDialog = true
}
if (mediaMetadata.isLocal != true)
if (!mediaMetadata.isLocal)
DownloadGridMenu(
state = download?.state,
onDownload = {
Expand Down Expand Up @@ -410,7 +412,7 @@ fun PlayerMenu(
}
}

if (mediaMetadata.isLocal != true)
if (!mediaMetadata.isLocal)
GridMenuItem(
icon = Icons.Rounded.Share,
title = R.string.share
Expand All @@ -423,19 +425,22 @@ fun PlayerMenu(
context.startActivity(Intent.createChooser(intent, null))
onDismiss()
}

GridMenuItem(
icon = Icons.Rounded.Info,
title = R.string.details
) {
showDetailsDialog = true
}

SleepTimerGridMenu(
sleepTimerTimeLeft = sleepTimerTimeLeft,
enabled = sleepTimerEnabled
) {
if (sleepTimerEnabled) playerConnection.service.sleepTimer.clear()
else showSleepTimerDialog = true
}

GridMenuItem(
icon = Icons.Rounded.Tune,
title = R.string.advanced
Expand All @@ -451,10 +456,10 @@ fun PitchTempoDialog(
) {
val playerConnection = LocalPlayerConnection.current ?: return
var tempo by remember {
mutableStateOf(playerConnection.player.playbackParameters.speed)
mutableFloatStateOf(playerConnection.player.playbackParameters.speed)
}
var transposeValue by remember {
mutableStateOf(round(12 * log2(playerConnection.player.playbackParameters.pitch)).toInt())
mutableIntStateOf(round(12 * log2(playerConnection.player.playbackParameters.pitch)).toInt())
}
val updatePlaybackParameters = {
playerConnection.player.playbackParameters = PlaybackParameters(tempo, 2f.pow(transposeValue.toFloat() / 12))
Expand Down
32 changes: 24 additions & 8 deletions app/src/main/java/com/dd3boh/outertune/ui/player/MiniPlayer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Pause
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Replay
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
Expand All @@ -32,6 +33,8 @@ import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
Expand Down Expand Up @@ -133,6 +136,9 @@ fun MiniMediaInfo(
error: PlaybackException?,
modifier: Modifier = Modifier,
) {
val playerConnection = LocalPlayerConnection.current
val isWaitingForNetwork by playerConnection?.waitingForNetworkConnection?.collectAsState(initial = false) ?: remember { mutableStateOf(false) }

Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
Expand Down Expand Up @@ -161,7 +167,7 @@ fun MiniMediaInfo(
)
}
androidx.compose.animation.AnimatedVisibility(
visible = error != null,
visible = error != null || isWaitingForNetwork,
enter = fadeIn(),
exit = fadeOut()
) {
Expand All @@ -173,13 +179,23 @@ fun MiniMediaInfo(
shape = RoundedCornerShape(ThumbnailCornerRadius)
)
) {
Icon(
imageVector = Icons.Rounded.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier
.align(Alignment.Center)
)
if (isWaitingForNetwork) {
CircularProgressIndicator(
modifier = Modifier
.align(Alignment.Center)
.size(24.dp),
strokeWidth = 2.dp,
color = Color.White
)
} else {
Icon(
imageVector = Icons.Rounded.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier
.align(Alignment.Center)
)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ fun Thumbnail(
modifier = Modifier
.padding(32.dp)
.align(Alignment.Center)
.fillMaxSize()
) {
error?.let { error ->
PlaybackError(
Expand Down
Loading

0 comments on commit 25d7777

Please sign in to comment.