diff --git a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt index 8840ab5c..785a6124 100644 --- a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt +++ b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt @@ -59,6 +59,8 @@ const val MAX_REPLAY_GAIN_PREAMP_DB = 15f enum class SyncMode { Off, WifiOnly, Always } +enum class SongTapBehavior { InOrder, KeepShuffle, Shuffle } + enum class ReplayGainMode { Off, Track, Album, Auto } data class ArtworkSeedValue( @@ -129,6 +131,19 @@ class UserPreferences( private val syncModeKey = stringPreferencesKey("sync_mode") + fun songTapBehavior(): Flow = + store.data.map { prefs -> + prefs[songTapBehaviorKey]?.let { stored -> + SongTapBehavior.entries.firstOrNull { it.name == stored } + } ?: SongTapBehavior.InOrder + } + + suspend fun setSongTapBehavior(behavior: SongTapBehavior) { + store.edit { prefs -> prefs[songTapBehaviorKey] = behavior.name } + } + + private val songTapBehaviorKey = stringPreferencesKey("song_tap_behavior") + fun lastSyncAt(): Flow = store.data.map { prefs -> prefs[lastSyncAtKey] ?: 0L } suspend fun setLastSyncAt(value: Long) { diff --git a/app/src/main/kotlin/com/subtracks/di/AppModule.kt b/app/src/main/kotlin/com/subtracks/di/AppModule.kt index fb1225d2..dca08d10 100644 --- a/app/src/main/kotlin/com/subtracks/di/AppModule.kt +++ b/app/src/main/kotlin/com/subtracks/di/AppModule.kt @@ -128,7 +128,7 @@ fun appModule( single(createdAtStart = true) { DownloadNotifier(context.applicationContext, get(), scope = get()).also { it.start() } } single(createdAtStart = true) { SyncNotifier(context.applicationContext, get().status, scope = get()).also { it.start() } } single { MediaSessionConnection(context.applicationContext, get(), get()) } - single { PlaybackController(get(), get(), get(), get(), showMessage = toast, scope = get(named("playback"))) } + single { PlaybackController(get(), get(), get(), get(), get(), showMessage = toast, scope = get(named("playback"))) } single(createdAtStart = true) { val playback = get() Scrobbler( diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index d703dc8f..4b06f659 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -6,6 +6,8 @@ import com.subtracks.data.model.AudioEncoding import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.QueueEntry import com.subtracks.data.model.QueueKind +import com.subtracks.data.prefs.SongTapBehavior +import com.subtracks.data.prefs.UserPreferences import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.QueueRepository import com.subtracks.data.repo.QueueSnapshot @@ -22,6 +24,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -106,6 +109,7 @@ private data class ReloadUndo( private data class PendingPlay( val entry: QueueEntry, val position: Long, + val behavior: SongTapBehavior?, ) class PlaybackController( @@ -113,6 +117,7 @@ class PlaybackController( private val queueRepository: QueueRepository, private val connection: PlayerConnection, private val downloads: DownloadRepository, + private val preferences: UserPreferences, private val showMessage: (UiMessage) -> Unit = {}, private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate), ) { @@ -190,7 +195,7 @@ class PlaybackController( val shuffle = pendingShuffle pendingShuffle = null when { - pending != null -> start(pending.entry, pending.position) + pending != null -> startPending(pending) shuffle != null -> startShuffled(shuffle) else -> restore() } @@ -239,6 +244,16 @@ class PlaybackController( startOrdinal: Long, ) = play(queueRepository.starredEntry(sourceId), startOrdinal) + fun playAlbumInOrder( + sourceId: Long, + albumId: String, + ) = play(queueRepository.albumEntry(sourceId, albumId), 0, SongTapBehavior.InOrder) + + fun playPlaylistInOrder( + sourceId: Long, + playlistId: String, + ) = play(queueRepository.playlistEntry(sourceId, playlistId), 0, SongTapBehavior.InOrder) + fun shuffleAlbum( sourceId: Long, albumId: String, @@ -968,22 +983,35 @@ class PlaybackController( private fun play( entry: QueueEntry, startPosition: Long, + behavior: SongTapBehavior? = null, ) { if (player == null) { - pendingPlay = PendingPlay(entry, startPosition) + pendingPlay = PendingPlay(entry, startPosition, behavior) connect() return } - scope.launch { start(entry, startPosition) } + scope.launch { start(entry, startPosition, behavior ?: preferences.songTapBehavior().first()) } + } + + private suspend fun startPending(pending: PendingPlay) { + val behavior = pending.behavior ?: preferences.songTapBehavior().first() + start(pending.entry, pending.position, behavior) } private suspend fun start( entry: QueueEntry, startPosition: Long, + behavior: SongTapBehavior, ) = startLock.withLock { - val snapshot = resetQueue(entry) val modes = queueRepository.modes() - shuffleEnabled = false + val shuffle = + when (behavior) { + SongTapBehavior.InOrder -> false + SongTapBehavior.KeepShuffle -> modes.shuffle + SongTapBehavior.Shuffle -> true + } + val snapshot = resetQueue(entry) + shuffleEnabled = shuffle repeatMode = modes.repeat.toRepeatMode() player?.setRepeatOne(repeatMode == RepeatMode.One) queueSourceId = entry.sourceId @@ -991,7 +1019,14 @@ class PlaybackController( stopLocked() return@withLock } - val position = startPosition.coerceIn(0, snapshot.contextSize - 1) + val start = startPosition.coerceIn(0, snapshot.contextSize - 1) + val position = + if (shuffle) { + queueRepository.setShuffle(pinnedSeed(snapshot.contextSize, start)) + 0L + } else { + start + } queueRepository.setCursor(position) this.snapshot = readSnapshot() loadWindow(position, autoplay = true) diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt index 7443f2a0..0db9e33c 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt @@ -96,7 +96,7 @@ class AlbumDetailViewModel( fun playAll() { viewModelScope.launch { - playbackController.playAlbum(sourceId.first(), albumId, 0) + playbackController.playAlbumInOrder(sourceId.first(), albumId) } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt index 1678ef78..58cd1b49 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt @@ -94,7 +94,7 @@ class PlaylistDetailViewModel( fun playAll() { viewModelScope.launch { - playbackController.playPlaylist(sourceId.first(), playlistId, 0) + playbackController.playPlaylistInOrder(sourceId.first(), playlistId) } } diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt index 849e436f..53b6cd6f 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt @@ -74,6 +74,7 @@ import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES import com.subtracks.data.prefs.MAX_REPLAY_GAIN_PREAMP_DB import com.subtracks.data.prefs.MIN_REPLAY_GAIN_PREAMP_DB import com.subtracks.data.prefs.ReplayGainMode +import com.subtracks.data.prefs.SongTapBehavior import com.subtracks.data.prefs.SortNameMode import com.subtracks.data.prefs.StreamQuality import com.subtracks.data.prefs.SyncMode @@ -103,6 +104,7 @@ private enum class SettingsDialog { ReplayGain, ReplayGainPreamp, SortNames, + SongTap, } @Composable @@ -131,6 +133,7 @@ fun SettingsRoute( val replayGainMode by viewModel.replayGainMode.collectAsStateWithLifecycle() val replayGainPreamp by viewModel.replayGainPreamp.collectAsStateWithLifecycle() val sortNameMode by viewModel.sortNameMode.collectAsStateWithLifecycle() + val songTapBehavior by viewModel.songTapBehavior.collectAsStateWithLifecycle() val context = LocalContext.current val scope = rememberCoroutineScope() SettingsScreen( @@ -147,6 +150,7 @@ fun SettingsRoute( replayGainMode = replayGainMode, replayGainPreamp = replayGainPreamp, sortNameMode = sortNameMode, + songTapBehavior = songTapBehavior, keepScreenOn = keepScreenOn, offline = offline, verboseLogging = verboseLogging, @@ -163,6 +167,7 @@ fun SettingsRoute( onReplayGainModeChange = viewModel::setReplayGainMode, onReplayGainPreampChange = viewModel::setReplayGainPreamp, onSortNameModeChange = viewModel::setSortNameMode, + onSongTapBehaviorChange = viewModel::setSongTapBehavior, onKeepScreenOnChange = viewModel::setKeepScreenOn, onOfflineChange = viewModel::setOfflineMode, onVerboseLoggingChange = viewModel::setVerboseLogging, @@ -211,6 +216,7 @@ fun SettingsScreen( replayGainMode: ReplayGainMode = ReplayGainMode.Auto, replayGainPreamp: Float = 0f, sortNameMode: SortNameMode = SortNameMode.Name, + songTapBehavior: SongTapBehavior = SongTapBehavior.InOrder, onSelectSource: (Long) -> Unit, onEditServer: (Long) -> Unit, onWifiQualityChange: (StreamQuality) -> Unit, @@ -224,6 +230,7 @@ fun SettingsScreen( onReplayGainModeChange: (ReplayGainMode) -> Unit = {}, onReplayGainPreampChange: (Float) -> Unit = {}, onSortNameModeChange: (SortNameMode) -> Unit = {}, + onSongTapBehaviorChange: (SongTapBehavior) -> Unit = {}, onKeepScreenOnChange: (Boolean) -> Unit = {}, onOfflineChange: (Boolean) -> Unit = {}, onVerboseLoggingChange: (Boolean) -> Unit = {}, @@ -421,6 +428,14 @@ fun SettingsScreen( colors = ListItemDefaults.colors(containerColor = Color.Transparent), ) } + item { + ListItem( + headlineContent = { Text(stringResource(R.string.settings_music_options_song_tap_title)) }, + supportingContent = { Text(songTapBehaviorLabel(LocalResources.current, songTapBehavior)) }, + modifier = Modifier.clickable { dialog = SettingsDialog.SongTap }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } item { SectionHeader(stringResource(R.string.downloads_title)) } item { ListItem( @@ -627,6 +642,14 @@ fun SettingsScreen( ) } + SettingsDialog.SongTap -> { + SongTapBehaviorDialog( + selected = songTapBehavior, + onSelect = onSongTapBehaviorChange, + onDismiss = { dialog = null }, + ) + } + null -> { Unit } @@ -826,6 +849,51 @@ private fun SortNameModeDialog( ) } +private val songTapBehaviorOptions = SongTapBehavior.entries + +private fun songTapBehaviorLabel( + resources: Resources, + behavior: SongTapBehavior, +): String = + when (behavior) { + SongTapBehavior.InOrder -> resources.getString(R.string.settings_music_options_song_tap_in_order) + SongTapBehavior.KeepShuffle -> resources.getString(R.string.settings_music_options_song_tap_keep_shuffle) + SongTapBehavior.Shuffle -> resources.getString(R.string.settings_music_options_song_tap_shuffle) + } + +@Composable +private fun SongTapBehaviorDialog( + selected: SongTapBehavior, + onSelect: (SongTapBehavior) -> Unit, + onDismiss: () -> Unit, +) { + val resources = LocalResources.current + var draft by remember(selected) { mutableStateOf(selected) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.settings_music_options_song_tap_title)) }, + text = { + ChoiceGroup( + header = stringResource(R.string.settings_music_options_song_tap_prompt), + options = songTapBehaviorOptions.map { it to songTapBehaviorLabel(resources, it) }, + selected = draft, + onSelect = { draft = it }, + ) + }, + confirmButton = { + TextButton( + onClick = { + onSelect(draft) + onDismiss() + }, + ) { Text(stringResource(R.string.action_done)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) } + }, + ) +} + @Composable private fun ReplayGainModeDialog( selected: ReplayGainMode, diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt index f37a23ee..a4cc0945 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt @@ -6,6 +6,7 @@ import com.subtracks.data.model.Source import com.subtracks.data.net.NetworkMode import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES import com.subtracks.data.prefs.ReplayGainMode +import com.subtracks.data.prefs.SongTapBehavior import com.subtracks.data.prefs.SortNameMode import com.subtracks.data.prefs.StreamQuality import com.subtracks.data.prefs.SyncMode @@ -82,6 +83,11 @@ class SettingsViewModel( .sortNameMode() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SortNameMode.Name) + val songTapBehavior: StateFlow = + userPreferences + .songTapBehavior() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SongTapBehavior.InOrder) + val keepScreenOn: StateFlow = userPreferences .keepScreenOn() @@ -153,4 +159,8 @@ class SettingsViewModel( fun setSortNameMode(mode: SortNameMode) { viewModelScope.launch { userPreferences.setSortNameMode(mode) } } + + fun setSongTapBehavior(behavior: SongTapBehavior) { + viewModelScope.launch { userPreferences.setSongTapBehavior(behavior) } + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ef193ebf..b07d5d54 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -83,6 +83,11 @@ Playback Send now playing and play counts Scrobble to server + Play without shuffling + Keep current shuffle mode + When tapping a song in a list + Play and shuffle + New song tap behavior Album gain Auto Off diff --git a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt index 3b497ea1..dedc5bae 100644 --- a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -26,6 +26,7 @@ import com.subtracks.data.model.Song import com.subtracks.data.model.SongDownload import com.subtracks.data.model.Source import com.subtracks.data.net.NetworkMode +import com.subtracks.data.prefs.SongTapBehavior import com.subtracks.data.prefs.StreamQuality import com.subtracks.data.prefs.UserPreferences import com.subtracks.data.prefs.fakeUserPreferences @@ -113,6 +114,7 @@ class PlaybackControllerTest { queues, FakePlayerConnection(handle), downloads, + prefs, showMessage = { messages += it.resolve(resources) }, scope = controllerScope, ) @@ -2519,6 +2521,60 @@ class PlaybackControllerTest { assertEquals("s4", runBlocking { queues.itemAt(snapshot, controller.state.value.position!!)?.song?.id }) } + @Test + fun tappingASongCanKeepTheCurrentShuffleMode() { + seedAlbum(5, sourceId = 1) + runBlocking { prefs.setSongTapBehavior(SongTapBehavior.KeepShuffle) } + + controller.playAlbum(1, "al1", 0) + await { + controller.state.value.item + ?.id == "s1" + } + controller.toggleShuffle() + await { controller.state.value.shuffle } + + controller.playAlbum(1, "al1", 3) + await { + controller.state.value.item + ?.id == "s4" && controller.state.value.shuffle + } + + assertEquals(0L, controller.state.value.position) + val snapshot = runBlocking { queues.snapshot() } + assertTrue(snapshot.shuffled) + assertEquals("s4", runBlocking { queues.itemAt(snapshot, controller.state.value.position!!)?.song?.id }) + } + + @Test + fun tappingASongCanStartShuffled() { + seedAlbum(5, sourceId = 1) + runBlocking { prefs.setSongTapBehavior(SongTapBehavior.Shuffle) } + + controller.playAlbum(1, "al1", 3) + await { + controller.state.value.item + ?.id == "s4" && controller.state.value.shuffle + } + + assertEquals(0L, controller.state.value.position) + assertTrue(runBlocking { queues.snapshot().shuffled }) + } + + @Test + fun playAllStartsInOrderRegardlessOfTheSongTapBehavior() { + seedAlbum(3, sourceId = 1) + runBlocking { prefs.setSongTapBehavior(SongTapBehavior.Shuffle) } + + controller.playAlbumInOrder(1, "al1") + await { + controller.state.value.item + ?.id == "s1" && !controller.state.value.shuffle + } + + assertEquals(0L, controller.state.value.position) + } + @Test fun jumpingInTheQueueWhileShuffledKeepsTheOrder() { seedAlbum(5, sourceId = 1) @@ -3643,7 +3699,7 @@ class PlaybackControllerTest { handle: FakePlayerHandle, ): PlaybackController { val scope = CoroutineScope(SupervisorJob() + dispatcher).also { extraScopes += it } - return PlaybackController(sources, queues, FakePlayerConnection(handle), downloads, scope = scope) + return PlaybackController(sources, queues, FakePlayerConnection(handle), downloads, prefs, scope = scope) } private fun await(predicate: () -> Boolean) = awaitUntil("waiting for the expected playback state", predicate) diff --git a/app/src/test/kotlin/com/subtracks/ui/ViewModelRepositoryFixture.kt b/app/src/test/kotlin/com/subtracks/ui/ViewModelRepositoryFixture.kt index e75895d6..8d4d62df 100644 --- a/app/src/test/kotlin/com/subtracks/ui/ViewModelRepositoryFixture.kt +++ b/app/src/test/kotlin/com/subtracks/ui/ViewModelRepositoryFixture.kt @@ -59,6 +59,7 @@ class ViewModelRepositoryFixture { queueRepository, FakePlayerConnection(FakePlayerHandle()), downloadRepository, + fakeUserPreferences(), scope = controllerScope, ) val libraryRepository = diff --git a/app/src/test/kotlin/com/subtracks/ui/playback/NowPlayingRouteTest.kt b/app/src/test/kotlin/com/subtracks/ui/playback/NowPlayingRouteTest.kt index 1bc4d9e8..29a7e1f3 100644 --- a/app/src/test/kotlin/com/subtracks/ui/playback/NowPlayingRouteTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/playback/NowPlayingRouteTest.kt @@ -101,6 +101,7 @@ class NowPlayingRouteTest { queues, FakePlayerConnection(handle), downloads, + prefs, showMessage = {}, scope = controllerScope, ) diff --git a/app/src/test/kotlin/com/subtracks/ui/playback/QueueViewModelTest.kt b/app/src/test/kotlin/com/subtracks/ui/playback/QueueViewModelTest.kt index 4e9a5ffa..482e3369 100644 --- a/app/src/test/kotlin/com/subtracks/ui/playback/QueueViewModelTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/playback/QueueViewModelTest.kt @@ -83,7 +83,15 @@ class QueueViewModelTest { artworkFetcher = { ByteArray(0) }, scope = repoScope, ).also { it.start() } - controller = PlaybackController(sources, queues, FakePlayerConnection(FakePlayerHandle()), downloads, scope = controllerScope) + controller = + PlaybackController( + sources, + queues, + FakePlayerConnection(FakePlayerHandle()), + downloads, + fakeUserPreferences(), + scope = controllerScope, + ) viewModel = QueueViewModel(queues, controller, sources, downloads) } diff --git a/app/src/test/kotlin/com/subtracks/ui/settings/SettingsScreenTest.kt b/app/src/test/kotlin/com/subtracks/ui/settings/SettingsScreenTest.kt index 6686964d..0e155112 100644 --- a/app/src/test/kotlin/com/subtracks/ui/settings/SettingsScreenTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/settings/SettingsScreenTest.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollToNode import androidx.test.ext.junit.runners.AndroidJUnit4 import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers +import com.subtracks.data.prefs.SongTapBehavior import com.subtracks.data.prefs.SortNameMode import com.subtracks.data.prefs.StreamQuality import com.subtracks.ui.theme.SubtracksTheme @@ -82,10 +83,29 @@ class SettingsScreenTest { assertEquals(SortNameMode.SortName, requested) } + @Test + fun theSongTapBehaviorRowChangesTheSetting() { + var requested: SongTapBehavior? = null + renderSettings( + keepScreenOn = false, + onKeepScreenOnChange = {}, + onSongTapBehaviorChange = { requested = it }, + scrollTo = "New song tap behavior", + ) + + composeRule.onNodeWithText("New song tap behavior").performClick() + composeRule.onNodeWithText("Play and shuffle").performClick() + composeRule.onNodeWithText("Done").performClick() + composeRule.waitForIdle() + + assertEquals(SongTapBehavior.Shuffle, requested) + } + private fun renderSettings( keepScreenOn: Boolean, onKeepScreenOnChange: (Boolean) -> Unit, onSortNameModeChange: (SortNameMode) -> Unit = {}, + onSongTapBehaviorChange: (SongTapBehavior) -> Unit = {}, scrollTo: String = "Keep screen on", ) { composeRule.setContent { @@ -110,6 +130,7 @@ class SettingsScreenTest { onScrobblingChange = {}, onKeepScreenOnChange = onKeepScreenOnChange, onSortNameModeChange = onSortNameModeChange, + onSongTapBehaviorChange = onSongTapBehaviorChange, onAddServer = {}, onOpenDownloads = {}, onOpenLicenses = {},