Skip to content

Commit 5bb8841

Browse files
committed
feat: add fullscreen playback controls
1 parent ca067f9 commit 5bb8841

9 files changed

Lines changed: 289 additions & 155 deletions

File tree

‎app/src/main/java/dev/typetype/android/feature/player/LoadedPlayer.kt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,8 @@ fun LoadedPlayer(
214214
sponsorBlockPolicy = sponsorBlockPolicy,
215215
chapters = playbackChapters,
216216
gestureConfig = gestureConfig,
217+
autoplayEnabled = userSettings.autoplay,
218+
onAutoplayChange = { onAction(PlayerAction.OnSetAutoplay(it)) },
217219
playbackBrightnessPercent = playbackBrightnessPercent,
218220
onPlaybackBrightnessChange = {
219221
onAction(PlayerAction.OnSetPlaybackBrightness(it))

‎app/src/main/java/dev/typetype/android/feature/player/PlaybackBrightnessPreferenceController.kt‎

Lines changed: 0 additions & 26 deletions
This file was deleted.

‎app/src/main/java/dev/typetype/android/feature/player/PlayerAction.kt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ sealed interface PlayerAction {
1010
data object OnCancelQueueAutoplay : PlayerAction
1111
data object OnToggleQueueAutoplayPause : PlayerAction
1212
data class OnSetPlaybackBrightness(val percent: Int) : PlayerAction
13+
data class OnSetAutoplay(val enabled: Boolean) : PlayerAction
1314
data class OnDownload(val selection: DownloadSelection) : PlayerAction
1415
data object OnOpenPlaylistPicker : PlayerAction
1516
data object OnDismissPlaylistPicker : PlayerAction
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package dev.typetype.android.feature.player
2+
3+
import dev.typetype.android.domain.preferences.PreferencesRepository
4+
import dev.typetype.android.domain.usersettings.UserSettings
5+
import dev.typetype.android.domain.usersettings.UserSettingsRepository
6+
import dev.typetype.android.feature.player.components.PlayerGestureConfig
7+
import kotlinx.coroutines.CoroutineScope
8+
import kotlinx.coroutines.Job
9+
import kotlinx.coroutines.delay
10+
import kotlinx.coroutines.flow.Flow
11+
import kotlinx.coroutines.flow.combine
12+
import kotlinx.coroutines.launch
13+
14+
internal class PlayerPreferenceCoordinator(
15+
private val local: PreferencesRepository,
16+
private val server: UserSettingsRepository,
17+
private val scope: CoroutineScope,
18+
) {
19+
val states: Flow<PlayerPreferenceState> = combine(local.observe(), server.observe()) {
20+
preferences,
21+
userSettings,
22+
->
23+
PlayerPreferenceState(
24+
gestureConfig = PlayerGestureConfig(
25+
doubleTapSeekEnabled = preferences.playerDoubleTapSeekEnabled,
26+
swipeSeekEnabled = preferences.playerSwipeSeekEnabled,
27+
swipeBrightnessVolumeEnabled = preferences.playerSwipeBrightnessVolumeEnabled,
28+
longPressSpeedEnabled = preferences.playerLongPressSpeedEnabled,
29+
),
30+
brightnessPercent = preferences.playerPlaybackBrightnessPercent,
31+
autoplayCountdownSeconds = preferences.playerAutoplayCountdownSeconds,
32+
audioOnlyPlaybackDefault = preferences.playerAudioOnlyPlayback,
33+
userSettings = userSettings,
34+
)
35+
}
36+
37+
private var brightnessJob: Job? = null
38+
private var autoplayJob: Job? = null
39+
40+
suspend fun refresh() = server.refresh()
41+
42+
fun updateBrightness(percent: Int, onChanged: (Int) -> Unit) {
43+
val value = percent.coerceIn(0, 100)
44+
onChanged(value)
45+
brightnessJob?.cancel()
46+
brightnessJob = scope.launch {
47+
delay(BRIGHTNESS_SAVE_DEBOUNCE_MS)
48+
local.setPlayerPlaybackBrightnessPercent(value)
49+
}
50+
}
51+
52+
fun updateAutoplay(
53+
enabled: Boolean,
54+
onChanged: (Boolean) -> Unit,
55+
onFailure: () -> Unit,
56+
) {
57+
autoplayJob?.cancel()
58+
onChanged(enabled)
59+
autoplayJob = scope.launch {
60+
server.update { it.copy(autoplay = enabled) }.onFailure { onFailure() }
61+
}
62+
}
63+
}
64+
65+
internal data class PlayerPreferenceState(
66+
val gestureConfig: PlayerGestureConfig,
67+
val brightnessPercent: Int?,
68+
val autoplayCountdownSeconds: Int,
69+
val audioOnlyPlaybackDefault: Boolean,
70+
val userSettings: UserSettings,
71+
)
72+
73+
private const val BRIGHTNESS_SAVE_DEBOUNCE_MS = 180L

‎app/src/main/java/dev/typetype/android/feature/player/PlayerViewModel.kt‎

Lines changed: 24 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import dev.typetype.android.domain.library.LibraryRepository
1111
import dev.typetype.android.domain.preferences.PreferencesRepository
1212
import dev.typetype.android.domain.stream.Stream
1313
import dev.typetype.android.domain.usersettings.UserSettingsRepository
14-
import dev.typetype.android.feature.player.components.PlayerGestureConfig
1514
import dev.typetype.android.feature.player.error.classifyStreamError
1615
import dev.typetype.android.feature.player.host.PlayerHostController
1716
import dev.typetype.android.services.PlaybackQueueCoordinator
@@ -49,7 +48,6 @@ class PlayerViewModel @Inject constructor(
4948
playbackQueueCoordinator.state,
5049
) { hostUrl, queue -> queue.current?.videoUrl ?: hostUrl }
5150
.distinctUntilChanged()
52-
5351
private val _state = MutableStateFlow(PlayerState())
5452
val state = _state.asStateFlow()
5553
internal val sabrPlayback = sabrPlaybackFactory.create(
@@ -60,16 +58,15 @@ class PlayerViewModel @Inject constructor(
6058
}
6159
},
6260
)
63-
6461
private val _events = Channel<PlayerEvent>(Channel.BUFFERED)
6562
val events: Flow<PlayerEvent> = _events.receiveAsFlow()
6663
val comments = playerCommentsFlow(commentsRepository, videoUrlFlow, viewModelScope)
67-
6864
private var loadStreamJob: Job? = null
6965
private var favoriteJob: Job? = null
7066
private var watchLaterJob: Job? = null
71-
private val brightnessPreferences = PlaybackBrightnessPreferenceController(
67+
private val playerPreferences = PlayerPreferenceCoordinator(
7268
preferencesRepository,
69+
userSettingsRepository,
7370
viewModelScope,
7471
)
7572

@@ -101,7 +98,6 @@ class PlayerViewModel @Inject constructor(
10198
}
10299
}
103100
observePreferences()
104-
observeUserSettings()
105101
viewModelScope.launch {
106102
playbackQueueCoordinator.state.collect { queue ->
107103
_state.update { it.copy(playbackQueue = queue) }
@@ -139,33 +135,19 @@ class PlayerViewModel @Inject constructor(
139135

140136
private fun observePreferences() {
141137
viewModelScope.launch {
142-
preferencesRepository.observe().collect { prefs ->
138+
playerPreferences.states.collect { prefs ->
143139
_state.update {
144140
it.copy(
145-
gestureConfig = PlayerGestureConfig(
146-
doubleTapSeekEnabled = prefs.playerDoubleTapSeekEnabled,
147-
swipeSeekEnabled = prefs.playerSwipeSeekEnabled,
148-
swipeBrightnessVolumeEnabled = prefs.playerSwipeBrightnessVolumeEnabled,
149-
longPressSpeedEnabled = prefs.playerLongPressSpeedEnabled,
150-
),
151-
playbackBrightnessPercent = prefs.playerPlaybackBrightnessPercent,
152-
autoplayCountdownSeconds = prefs.playerAutoplayCountdownSeconds,
153-
audioOnlyPlaybackDefault = prefs.playerAudioOnlyPlayback,
141+
gestureConfig = prefs.gestureConfig,
142+
playbackBrightnessPercent = prefs.brightnessPercent,
143+
autoplayCountdownSeconds = prefs.autoplayCountdownSeconds,
144+
audioOnlyPlaybackDefault = prefs.audioOnlyPlaybackDefault,
145+
userSettings = prefs.userSettings,
154146
)
155147
}
156148
}
157149
}
158-
}
159-
160-
private fun observeUserSettings() {
161-
viewModelScope.launch {
162-
userSettingsRepository.observe().collect { settings ->
163-
_state.update {
164-
it.copy(userSettings = settings)
165-
}
166-
}
167-
}
168-
viewModelScope.launch { userSettingsRepository.refresh() }
150+
viewModelScope.launch { playerPreferences.refresh() }
169151
}
170152

171153
fun onAction(action: PlayerAction) {
@@ -177,9 +159,10 @@ class PlayerViewModel @Inject constructor(
177159
PlayerAction.OnCancelQueueAutoplay -> playbackQueueCoordinator.cancelAutoplay()
178160
PlayerAction.OnToggleQueueAutoplayPause ->
179161
playbackQueueCoordinator.toggleAutoplayPause()
180-
is PlayerAction.OnSetPlaybackBrightness -> brightnessPreferences.update(action.percent) {
162+
is PlayerAction.OnSetPlaybackBrightness -> playerPreferences.updateBrightness(action.percent) {
181163
_state.update { state -> state.copy(playbackBrightnessPercent = it) }
182164
}
165+
is PlayerAction.OnSetAutoplay -> updateAutoplay(action.enabled)
183166
is PlayerAction.OnDownload -> downloadCurrentVideo(action.selection)
184167
PlayerAction.OnOpenPlaylistPicker ->
185168
_state.update { it.copy(playlistPickerVisible = true) }
@@ -198,6 +181,19 @@ class PlayerViewModel @Inject constructor(
198181

199182
suspend fun prefetchMetadata(url: String) = playerStreamLoader.prefetchMetadata(url)
200183

184+
private fun updateAutoplay(enabled: Boolean) {
185+
playerPreferences.updateAutoplay(
186+
enabled = enabled,
187+
onChanged = { value ->
188+
_state.update { it.copy(userSettings = it.userSettings.copy(autoplay = value)) }
189+
},
190+
onFailure = {
191+
_state.update { it.copy(userSettings = it.userSettings.copy(autoplay = !enabled)) }
192+
viewModelScope.launch { _events.send(PlayerEvent.ActionFailed) }
193+
},
194+
)
195+
}
196+
201197
private fun addCurrentToPlaylist(playlistId: String) {
202198
val url = currentUrl() ?: return
203199
val stream = _state.value.stream ?: return

‎app/src/main/java/dev/typetype/android/feature/player/components/PlayerControls.kt‎

Lines changed: 9 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,13 @@ import androidx.compose.foundation.layout.size
1313
import androidx.compose.foundation.layout.statusBars
1414
import androidx.compose.foundation.layout.windowInsetsPadding
1515
import androidx.compose.foundation.shape.RoundedCornerShape
16-
import androidx.compose.material.icons.Icons
17-
import androidx.compose.material.icons.automirrored.filled.ArrowBack
18-
import androidx.compose.material.icons.automirrored.filled.List
19-
import androidx.compose.material.icons.filled.AspectRatio
20-
import androidx.compose.material.icons.filled.Crop
21-
import androidx.compose.material.icons.filled.FitScreen
22-
import androidx.compose.material.icons.filled.Settings
2316
import androidx.compose.material3.Icon
2417
import androidx.compose.material3.IconButton
2518
import androidx.compose.runtime.Composable
2619
import androidx.compose.ui.Alignment
2720
import androidx.compose.ui.Modifier
2821
import androidx.compose.ui.graphics.Brush
2922
import androidx.compose.ui.graphics.Color
30-
import androidx.compose.ui.graphics.vector.ImageVector
3123
import androidx.compose.ui.res.painterResource
3224
import androidx.compose.ui.res.stringResource
3325
import androidx.compose.ui.unit.dp
@@ -40,6 +32,9 @@ import dev.typetype.android.feature.player.state.ResizeMode
4032
@OptIn(ExperimentalLayoutApi::class)
4133
fun PlayerControls(
4234
player: Player,
35+
title: String,
36+
autoplayEnabled: Boolean,
37+
onAutoplayChange: (Boolean) -> Unit,
4338
onNavigateBack: () -> Unit,
4439
modifier: Modifier = Modifier,
4540
onOpenOptions: () -> Unit = {},
@@ -56,13 +51,11 @@ fun PlayerControls(
5651
Box(modifier = modifier) {
5752
TopScrim(modifier = Modifier.align(Alignment.TopCenter))
5853
BottomScrim(modifier = Modifier.align(Alignment.BottomCenter))
59-
BackButton(
54+
PlayerTopBar(
55+
title = title,
56+
autoplayEnabled = autoplayEnabled,
57+
onAutoplayChange = onAutoplayChange,
6058
onNavigateBack = onNavigateBack,
61-
modifier = Modifier
62-
.align(Alignment.TopStart)
63-
.windowInsetsPadding(WindowInsets.statusBars),
64-
)
65-
TopActions(
6659
onOpenChapters = onOpenChapters,
6760
onOpenOptions = onOpenOptions,
6861
onEnterPip = onEnterPip,
@@ -72,7 +65,8 @@ fun PlayerControls(
7265
isPipAvailable = isPipAvailable,
7366
chaptersAvailable = chaptersAvailable,
7467
modifier = Modifier
75-
.align(Alignment.TopEnd)
68+
.align(Alignment.TopCenter)
69+
.fillMaxWidth()
7670
.windowInsetsPadding(WindowInsets.statusBars),
7771
)
7872
PlayerCenterControls(
@@ -132,92 +126,6 @@ private fun BottomScrim(modifier: Modifier = Modifier) {
132126
)
133127
}
134128

135-
@Composable
136-
private fun BackButton(onNavigateBack: () -> Unit, modifier: Modifier = Modifier) {
137-
OverlayIconButton(
138-
onClick = onNavigateBack,
139-
modifier = modifier.padding(8.dp),
140-
) {
141-
Icon(
142-
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
143-
contentDescription = stringResource(R.string.player_back),
144-
tint = Color.White,
145-
)
146-
}
147-
}
148-
149-
@Composable
150-
private fun TopActions(
151-
onOpenChapters: () -> Unit,
152-
onOpenOptions: () -> Unit,
153-
onEnterPip: () -> Unit,
154-
onCycleResizeMode: () -> Unit,
155-
resizeMode: ResizeMode,
156-
isFullscreen: Boolean,
157-
isPipAvailable: Boolean,
158-
chaptersAvailable: Boolean,
159-
modifier: Modifier = Modifier,
160-
) {
161-
Row(modifier = modifier.padding(8.dp)) {
162-
if (isFullscreen) {
163-
OverlayIconButton(onClick = onCycleResizeMode) {
164-
Icon(
165-
imageVector = resizeMode.icon(),
166-
contentDescription = stringResource(R.string.player_resize_mode),
167-
tint = Color.White,
168-
)
169-
}
170-
}
171-
if (isPipAvailable) {
172-
OverlayIconButton(onClick = onEnterPip) {
173-
Icon(
174-
painter = painterResource(R.drawable.ic_pip),
175-
contentDescription = stringResource(R.string.player_pip),
176-
tint = Color.White,
177-
)
178-
}
179-
}
180-
if (chaptersAvailable) {
181-
OverlayIconButton(onClick = onOpenChapters) {
182-
Icon(
183-
imageVector = Icons.AutoMirrored.Filled.List,
184-
contentDescription = stringResource(R.string.player_chapters),
185-
tint = Color.White,
186-
)
187-
}
188-
}
189-
OverlayIconButton(onClick = onOpenOptions) {
190-
Icon(
191-
imageVector = Icons.Filled.Settings,
192-
contentDescription = stringResource(R.string.player_playback_options),
193-
tint = Color.White,
194-
)
195-
}
196-
}
197-
}
198-
199-
@Composable
200-
private fun OverlayIconButton(
201-
onClick: () -> Unit,
202-
modifier: Modifier = Modifier,
203-
content: @Composable () -> Unit,
204-
) {
205-
IconButton(
206-
onClick = onClick,
207-
modifier = modifier
208-
.padding(2.dp)
209-
.size(40.dp),
210-
) {
211-
content()
212-
}
213-
}
214-
215-
private fun ResizeMode.icon(): ImageVector = when (this) {
216-
ResizeMode.Fit -> Icons.Filled.FitScreen
217-
ResizeMode.Crop -> Icons.Filled.Crop
218-
ResizeMode.Stretch -> Icons.Filled.AspectRatio
219-
}
220-
221129
@Composable
222130
private fun BottomBar(
223131
player: Player,

0 commit comments

Comments
 (0)