From c4adb1073109a62f2485b2129551686a8d08adf5 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Fri, 15 May 2026 17:26:32 -0400 Subject: [PATCH 1/4] Better transition between controls & marker row --- .../ui/components/playback/PlaybackOverlay.kt | 228 ++++++++++-------- 1 file changed, 130 insertions(+), 98 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/PlaybackOverlay.kt index e44d898e..be615ad4 100644 --- a/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/PlaybackOverlay.kt @@ -2,9 +2,17 @@ package com.github.damontecres.stashapp.ui.components.playback import android.util.Log import androidx.annotation.IntRange +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable import androidx.compose.foundation.interaction.DragInteraction import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState @@ -18,7 +26,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState @@ -33,8 +40,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter @@ -262,107 +269,131 @@ fun PlaybackOverlay( if (!uiConfig.showTitleDuringPlayback || scene.title.isNullOrBlank()) height -= 24.dp if (!uiConfig.showTitleDuringPlayback || scene.subtitle.isNullOrBlank()) height -= 24.dp if (markers.isEmpty()) height -= 24.dp - LazyColumn( - state = listState, + var showControls by remember { mutableStateOf(true) } + AnimatedContent( + targetState = showControls, + transitionSpec = { + if (targetState) { + // Moving up to show controls + (slideInVertically { -it / 2 } + fadeIn()).togetherWith(slideOutVertically { it / 2 } + fadeOut()) + } else { + // Moving down to show markers + (slideInVertically { it / 2 } + fadeIn()).togetherWith(slideOutVertically { -it / 2 } + fadeOut()) + } + }, modifier = Modifier .align(Alignment.BottomCenter) - .fillMaxWidth() - .height(height), -// .fillMaxHeight(controlHeight), -// contentPadding = PaddingValues(top = 420.dp), - ) { - if (uiConfig.showTitleDuringPlayback) { - item { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .padding(start = 8.dp) - .fillMaxWidth(.7f), - ) { - if (scene.title.isNotNullOrBlank()) { - Text( - text = scene.title, - color = MaterialTheme.colorScheme.onBackground, - style = - MaterialTheme.typography.titleLarge.copy( - fontSize = 24.sp, - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - if (scene.subtitle.isNotNullOrBlank()) { - Text( - text = scene.subtitle, - color = MaterialTheme.colorScheme.onBackground, - style = - MaterialTheme.typography.titleMedium.copy( - fontSize = 16.sp, - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + .fillMaxWidth(), + ) { targetState -> + if (targetState) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + if (uiConfig.showTitleDuringPlayback) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .padding(start = 8.dp) + .fillMaxWidth(.7f), + ) { + if (scene.title.isNotNullOrBlank()) { + Text( + text = scene.title, + color = MaterialTheme.colorScheme.onBackground, + fontSize = 24.sp, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (scene.subtitle.isNotNullOrBlank()) { + Text( + text = scene.subtitle, + color = MaterialTheme.colorScheme.onBackground, + fontSize = 16.sp, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } + + PlaybackControls( + modifier = Modifier.fillMaxWidth(), + scene = scene, + captions = captions, + oCounter = oCounter, + playerControls = playerControls, + onPlaybackActionClick = onPlaybackActionClick, + controllerViewState = controllerViewState, + showDebugInfo = showDebugInfo, + onSeekProgress = { + seekProgress = it + onSeekBarChange(it) + }, + showPlay = showPlay, + previousEnabled = previousEnabled, + nextEnabled = nextEnabled, + seekEnabled = seekEnabled, + seekBarInteractionSource = seekBarInteractionSource, + moreButtonOptions = moreButtonOptions, + subtitleIndex = subtitleIndex, + audioIndex = audioIndex, + audioOptions = audioOptions, + playbackSpeed = playbackSpeed, + scale = scale, + seekBarIntervals = uiConfig.preferences.playbackPreferences.seekBarSteps, + sfwMode = uiConfig.sfwMode, + ) + if (markers.isNotEmpty()) { + Text( + modifier = + Modifier + .padding(start = 8.dp) + .onFocusChanged { + if (it.isFocused) showControls = false + }.focusable() + .clickable { showControls = false }, + text = stringResource(DataType.MARKER.pluralStringId), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + } } - } - item { - PlaybackControls( - modifier = Modifier.fillMaxWidth(), - scene = scene, - captions = captions, - oCounter = oCounter, - playerControls = playerControls, - onPlaybackActionClick = onPlaybackActionClick, - controllerViewState = controllerViewState, - showDebugInfo = showDebugInfo, - onSeekProgress = { - seekProgress = it - onSeekBarChange(it) - }, - showPlay = showPlay, - previousEnabled = previousEnabled, - nextEnabled = nextEnabled, - seekEnabled = seekEnabled, - seekBarInteractionSource = seekBarInteractionSource, - moreButtonOptions = moreButtonOptions, - subtitleIndex = subtitleIndex, - audioIndex = audioIndex, - audioOptions = audioOptions, - playbackSpeed = playbackSpeed, - scale = scale, - seekBarIntervals = uiConfig.preferences.playbackPreferences.seekBarSteps, - sfwMode = uiConfig.sfwMode, - ) - } - if (markers.isNotEmpty()) { - item { -// Spacer(Modifier.height(12.dp)) + } else { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + Column( + modifier = + Modifier + .padding(start = 8.dp) + .fillMaxWidth(), + ) { Text( - modifier = Modifier.padding(start = 8.dp), + modifier = + Modifier + .onFocusChanged { + if (it.isFocused) showControls = true + }.focusable() + .clickable { showControls = true }, text = stringResource(DataType.MARKER.pluralStringId), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, ) - } - item { + SceneMarkerBar( modifier = Modifier - .padding(start = 8.dp, top = 64.dp, bottom = 64.dp) - .fillMaxWidth() - .height(height), + .focusRequester(focusRequester) + .fillMaxWidth(), markers = markers, player = playerControls, controllerViewState = controllerViewState, uiConfig = uiConfig, - onCardFocus = { -// scope.launch { -// listState.scrollToItem(2) -// } - }, + onCardFocus = {}, ) } } @@ -397,7 +428,7 @@ fun PlaybackOverlay( seekProgress = playerControls.currentPosition.toFloat() / playerControls.duration } val yOffsetDp = - 180.dp + + 160.dp + (if (spriteData.isNotEmpty()) (160.dp) else 24.dp) + (if (markers.isEmpty()) (-24).dp else 0.dp) val heightPx = with(LocalDensity.current) { yOffsetDp.toPx().toInt() } @@ -509,21 +540,22 @@ fun SceneMarkerBar( remember(markers.size) { List(markers.size) { FocusRequester() } } + val firstFocus = + remember { + val pos = player.currentPosition.milliseconds + val nextMarkerIndex = + markers.indexOfFirstOrNull { it.seconds >= pos } + ?: markers.lastIndex + if (nextMarkerIndex in focusRequesters.indices) { + focusRequesters[nextMarkerIndex] + } else { + focusRequesters.first() + } + } LazyRow( modifier = modifier - .focusProperties { - onEnter = { - // Start on the next marker from the current position - val pos = player.currentPosition.milliseconds - val nextMarkerIndex = - markers.indexOfFirstOrNull { it.seconds >= pos } - ?: markers.lastIndex - if (nextMarkerIndex in focusRequesters.indices) { - focusRequesters[nextMarkerIndex].tryRequestFocus() - } - } - }, + .focusRestorer(firstFocus), contentPadding = PaddingValues(8.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), ) { From fc34c3ac9ffdf78f00af605a29cf3f2205a1ff58 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Fri, 15 May 2026 17:26:43 -0400 Subject: [PATCH 2/4] Reduce playback button size --- .../components/playback/PlaybackControls.kt | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/PlaybackControls.kt index e7b9c4c8..5660dad9 100644 --- a/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/PlaybackControls.kt +++ b/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/PlaybackControls.kt @@ -169,7 +169,7 @@ fun PlaybackControls( intervals = seekBarIntervals, modifier = Modifier - .padding(vertical = 8.dp) + .padding(vertical = 4.dp) .fillMaxWidth(.95f), ) Row( @@ -555,6 +555,9 @@ fun PlaybackButtons( } } +private val buttonPadding = 4.dp +private val buttonSize = 40.dp + @Composable fun PlaybackButton( @DrawableRes iconRes: Int, @@ -573,11 +576,11 @@ fun PlaybackButton( containerColor = AppColors.TransparentBlack25, focusedContainerColor = selectedColor, ), - contentPadding = PaddingValues(8.dp), + contentPadding = PaddingValues(buttonPadding), modifier = modifier - .padding(8.dp) - .size(56.dp, 56.dp) + .padding(buttonPadding) + .size(buttonSize) .onFocusChanged { onControllerInteraction.invoke() }, ) { Icon( @@ -607,11 +610,11 @@ fun PlaybackFaButton( containerColor = AppColors.TransparentBlack25, focusedContainerColor = selectedColor, ), - contentPadding = PaddingValues(8.dp), + contentPadding = PaddingValues(buttonPadding), modifier = modifier - .padding(8.dp) - .size(56.dp, 56.dp) + .padding(buttonPadding) + .size(buttonSize) .onFocusChanged { onControllerInteraction.invoke() }, ) { Box( @@ -621,7 +624,7 @@ fun PlaybackFaButton( text = stringResource(iconRes), fontFamily = FontAwesome, color = MaterialTheme.colorScheme.onSurface, - fontSize = 28.sp, + fontSize = 22.sp, modifier = Modifier.align(Alignment.Center), ) } From ffa0ecbfe560cbd6a5cd3ce504e300a6cdce4c3a Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sat, 16 May 2026 10:01:44 -0400 Subject: [PATCH 3/4] Refactor playback page to use state flow --- .../ui/components/playback/PlaybackOverlay.kt | 32 ++- .../playback/PlaybackPageContent.kt | 197 ++++++++++-------- 2 files changed, 125 insertions(+), 104 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/PlaybackOverlay.kt index be615ad4..1545a847 100644 --- a/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/PlaybackOverlay.kt @@ -1,6 +1,5 @@ package com.github.damontecres.stashapp.ui.components.playback -import android.util.Log import androidx.annotation.IntRange import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility @@ -28,14 +27,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf 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 @@ -98,6 +95,7 @@ import kotlinx.coroutines.channels.Channel.Factory.CONFLATED import kotlinx.coroutines.delay import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.debounce +import timber.log.Timber import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -178,13 +176,10 @@ fun PlaybackOverlay( seekPreviewPlaceholder: Painter? = null, seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { - val context = LocalContext.current - val scope = rememberCoroutineScope() var seekProgress by remember { mutableFloatStateOf(-1f) } val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState() var seekBarDragging by remember { mutableStateOf(false) } - val previewImageUrl = scene.spriteUrl val imageLoader = SingletonImageLoader.get(LocalPlatformContext.current) if (showDebugInfo) { @@ -201,8 +196,8 @@ fun PlaybackOverlay( val msg = "Native heap: ${nativeAllocated / 1024}k/${nativeSize / 1024}k, nativeFree=${nativeFree / 1024}k " + "JVM: ${usedSize / 1024}k/${totalSize / 1024}k" - Log.d("PlaybackOverlay", msg) - delay(500L) + Timber.d(msg) + delay(1500L) } } } @@ -263,12 +258,6 @@ fun PlaybackOverlay( } } } - val controlHeight = .4f - val listState = rememberLazyListState() - var height = 208.dp - if (!uiConfig.showTitleDuringPlayback || scene.title.isNullOrBlank()) height -= 24.dp - if (!uiConfig.showTitleDuringPlayback || scene.subtitle.isNullOrBlank()) height -= 24.dp - if (markers.isEmpty()) height -= 24.dp var showControls by remember { mutableStateOf(true) } AnimatedContent( targetState = showControls, @@ -427,11 +416,16 @@ fun PlaybackOverlay( LaunchedEffect(Unit) { seekProgress = playerControls.currentPosition.toFloat() / playerControls.duration } - val yOffsetDp = - 160.dp + - (if (spriteData.isNotEmpty()) (160.dp) else 24.dp) + - (if (markers.isEmpty()) (-24).dp else 0.dp) - val heightPx = with(LocalDensity.current) { yOffsetDp.toPx().toInt() } + val density = LocalDensity.current + val heightPx = + remember(density, spriteData, markers) { + val yOffsetDp = + 160.dp + + (if (spriteData.isNotEmpty()) (160.dp) else 24.dp) + + (if (markers.isEmpty()) (-24).dp else 0.dp) + with(density) { yOffsetDp.toPx().toInt() } + } + SeekPreviewImage( modifier = Modifier diff --git a/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/PlaybackPageContent.kt b/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/PlaybackPageContent.kt index 2b89678e..36e91d5a 100644 --- a/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/PlaybackPageContent.kt +++ b/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/PlaybackPageContent.kt @@ -19,8 +19,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf @@ -51,7 +51,6 @@ import androidx.compose.ui.window.DialogProperties import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.viewModelScope @@ -89,7 +88,6 @@ import com.github.damontecres.stashapp.api.fragment.FullSceneData import com.github.damontecres.stashapp.api.fragment.PerformerData import com.github.damontecres.stashapp.api.fragment.StashData import com.github.damontecres.stashapp.data.DataType -import com.github.damontecres.stashapp.data.ThrottledLiveData import com.github.damontecres.stashapp.data.VideoFilter import com.github.damontecres.stashapp.data.room.PlaybackEffect import com.github.damontecres.stashapp.navigation.NavigationManager @@ -106,6 +104,7 @@ import com.github.damontecres.stashapp.playback.switchToTranscode import com.github.damontecres.stashapp.proto.PlaybackFinishBehavior import com.github.damontecres.stashapp.ui.ComposeUiConfig import com.github.damontecres.stashapp.ui.LocalGlobalContext +import com.github.damontecres.stashapp.ui.compat.detectTvDevice import com.github.damontecres.stashapp.ui.compat.isNotTvDevice import com.github.damontecres.stashapp.ui.compat.isTvDevice import com.github.damontecres.stashapp.ui.components.ItemOnClicker @@ -128,11 +127,20 @@ import com.github.damontecres.stashapp.util.showSetRatingToast import com.github.damontecres.stashapp.util.toLongMilliseconds import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.Request +import timber.log.Timber import java.util.Locale import kotlin.properties.Delegates import kotlin.time.Duration @@ -152,17 +160,15 @@ class PlaybackViewModel : ViewModel() { private var trackActivity by Delegates.notNull() private var trackActivityListener: TrackActivityPlaybackListener? = null - val scene = MutableLiveData() - val performers = MutableLiveData>(listOf()) + private val _state = MutableStateFlow(PlaybackState()) + val state: StateFlow = _state - val mediaItemTag = MutableLiveData() - val markers = MutableLiveData>(listOf()) - val oCount = MutableLiveData(0) - val rating100 = MutableLiveData(0) - val spriteImageLoaded = MutableLiveData>(emptyList()) - - private val _videoFilter = MutableLiveData(null) - val videoFilter = ThrottledLiveData(_videoFilter, 500L) + @kotlin.OptIn(FlowPreview::class) + val videoFilter = + state + .map { it.videoFilter ?: VideoFilter() } + .debounce(if (detectTvDevice) 500L else DRAG_THROTTLE_DELAY) + .stateIn(viewModelScope, SharingStarted.Lazily, VideoFilter()) fun init( server: StashServer, @@ -193,16 +199,17 @@ class PlaybackViewModel : ViewModel() { fun changeScene(tag: PlaylistFragment.MediaItemTag) { sceneJob.cancelChildren() - this.mediaItemTag.value = tag - this.oCount.value = 0 - this.rating100.value = 0 - this.markers.value = listOf() - this.spriteImageLoaded.value = emptyList() + _state.update { + PlaybackState( + mediaItemTag = tag, + ) + } if (trackActivity) { - Log.v( - TAG, - "Setting up activity tracking scene ${tag.item.id}, removing=${trackActivityListener?.scene?.id}", + Timber.v( + "Setting up activity tracking scene %s, removing=%s", + tag.item.id, + trackActivityListener?.scene?.id, ) trackActivityListener?.apply { release() @@ -233,14 +240,13 @@ class PlaybackViewModel : ViewModel() { .playbackEffectsDao() .getPlaybackEffect(server.url, tag.item.id, DataType.SCENE) if (vf != null) { - Log.d( - TAG, - "Loaded VideoFilter for scene ${tag.item.id}", - ) - withContext(Dispatchers.Main) { - videoFilter.stopThrottling(true) - updateVideoFilter(vf.videoFilter) - videoFilter.startThrottling() + Timber.d("Loaded VideoFilter for scene %s", tag.item.id) + _state.update { + if (it.mediaItemTag?.item?.id == tag.item.id) { + it.copy(videoFilter = vf.videoFilter) + } else { + it + } } } } @@ -248,7 +254,7 @@ class PlaybackViewModel : ViewModel() { } // Fetch preview sprites - viewModelScope.launch(sceneJob + StashCoroutineExceptionHandler()) { + viewModelScope.launch(sceneJob + StashCoroutineExceptionHandler() + Dispatchers.IO) { val context = StashApplication.getApplication() val imageLoader = SingletonImageLoader.get(context) if (tag.item.spriteUrl.isNotNullOrBlank() && tag.item.vttUrl.isNotNullOrBlank()) { @@ -261,7 +267,14 @@ class PlaybackViewModel : ViewModel() { .build() val result = imageLoader.enqueue(request).job.await() if (result.image != null) { - spriteImageLoaded.value = fetchSprites(tag.item.id, tag.item.vttUrl) + val spriteImageLoaded = fetchSprites(tag.item.id, tag.item.vttUrl) + _state.update { + if (it.mediaItemTag?.item?.id == tag.item.id) { + it.copy(spriteImageLoaded = spriteImageLoaded) + } else { + it + } + } } } } @@ -330,28 +343,37 @@ class PlaybackViewModel : ViewModel() { private fun refreshScene(sceneId: String) { // Fetch o count & markers viewModelScope.launch(sceneJob + exceptionHandler) { - oCount.value = 0 - rating100.value = 0 - markers.value = listOf() - performers.value = listOf() - val queryEngine = QueryEngine(server) val scene = queryEngine.getScene(sceneId) if (scene != null) { - oCount.value = scene.o_counter ?: 0 - rating100.value = scene.rating100 ?: 0 - if (markersEnabled) { - markers.value = + val markers = + if (markersEnabled) { scene.scene_markers .sortedBy { it.seconds } .map(::BasicMarker) - } - if (scene.performers.isNotEmpty()) { - performers.value = + } else { + emptyList() + } + val performers = + if (scene.performers.isNotEmpty()) { queryEngine.findPerformers(performerIds = scene.performers.map { it.id }) + } else { + emptyList() + } + _state.update { + if (it.mediaItemTag?.item?.id == sceneId) { + it.copy( + scene = scene, + oCount = scene.o_counter ?: 0, + rating100 = scene.rating100 ?: 0, + markers = markers, + performers = performers, + ) + } else { + it + } } } - this@PlaybackViewModel.scene.value = scene } } @@ -359,7 +381,7 @@ class PlaybackViewModel : ViewModel() { position: Long, tagId: String, ) { - mediaItemTag.value?.let { + state.value.mediaItemTag?.let { viewModelScope.launch(exceptionHandler) { val mutationEngine = MutationEngine(server) val newMarker = mutationEngine.createMarker(it.item.id, position, tagId) @@ -378,26 +400,27 @@ class PlaybackViewModel : ViewModel() { } fun incrementOCount(sceneId: String) { - viewModelScope.launch(exceptionHandler) { + viewModelScope.launchIO(exceptionHandler) { val mutationEngine = MutationEngine(server) - oCount.value = mutationEngine.incrementOCounter(sceneId).count + val result = mutationEngine.incrementOCounter(sceneId).count + _state.update { it.copy(oCount = result) } } } fun updateVideoFilter(newFilter: VideoFilter?) { - _videoFilter.value = newFilter + _state.update { it.copy(videoFilter = newFilter) } } fun saveVideoFilter() { - mediaItemTag.value?.item?.let { + state.value.mediaItemTag?.item?.let { viewModelScope.launchIO(StashCoroutineExceptionHandler(autoToast = true)) { - val vf = _videoFilter.value + val vf = state.value.videoFilter if (vf != null) { StashApplication .getDatabase() .playbackEffectsDao() .insert(PlaybackEffect(server.url, it.id, DataType.SCENE, vf)) - Log.d(TAG, "Saved VideoFilter for scene ${it.id}") + Timber.d("Saved VideoFilter for scene %s", it.id) withContext(Dispatchers.Main) { Toast .makeText( @@ -418,7 +441,7 @@ class PlaybackViewModel : ViewModel() { viewModelScope.launch(exceptionHandler) { val newRating = MutationEngine(server).setRating(sceneId, rating100)?.rating100 ?: 0 - this@PlaybackViewModel.rating100.value = newRating + _state.update { it.copy(rating100 = newRating) } showSetRatingToast(StashApplication.getApplication(), newRating) } } @@ -445,6 +468,17 @@ data class SpriteData( val h: Int, ) +data class PlaybackState( + val mediaItemTag: PlaylistFragment.MediaItemTag? = null, + val markers: List = emptyList(), + val performers: List = emptyList(), + val oCount: Int = 0, + val rating100: Int = 0, + val scene: FullSceneData? = null, + val videoFilter: VideoFilter? = null, + val spriteImageLoaded: List = emptyList(), +) + @OptIn(UnstableApi::class) @Composable fun PlaybackPageContent( @@ -470,13 +504,12 @@ fun PlaybackPageContent( val context = LocalContext.current val navigationManager = LocalGlobalContext.current.navigationManager - val currentScene by viewModel.mediaItemTag.observeAsState( - playlist[currentPlaylistIndex].localConfiguration!!.tag as PlaylistFragment.MediaItemTag, - ) - val markers by viewModel.markers.observeAsState(listOf()) - val oCount by viewModel.oCount.observeAsState(0) - val rating100 by viewModel.rating100.observeAsState(0) - val spriteImageLoaded by viewModel.spriteImageLoaded.observeAsState(emptyList()) + val state by viewModel.state.collectAsState() + val currentScene = state.mediaItemTag + val markers = state.markers + val oCount = state.oCount + val rating100 = state.rating100 + val spriteImageLoaded = state.spriteImageLoaded var currentTracks by remember { mutableStateOf>(listOf()) } var captions by remember { mutableStateOf>(listOf()) } var subtitles by remember { mutableStateOf?>(null) } @@ -485,11 +518,11 @@ fun PlaybackPageContent( var audioIndex by remember { mutableStateOf(null) } var audioOptions by remember { mutableStateOf>(listOf()) } var showFilterDialog by rememberSaveable { mutableStateOf(false) } - val videoFilter by viewModel.videoFilter.observeAsState() + val videoFilter by viewModel.videoFilter.collectAsState() var showSceneDetails by rememberSaveable { mutableStateOf(false) } - val scene by viewModel.scene.observeAsState() - val performers by viewModel.performers.observeAsState(listOf()) + val scene = state.scene + val performers = state.performers AmbientPlayerListener(player) @@ -501,7 +534,6 @@ fun PlaybackPageContent( } } - var showControls by remember { mutableStateOf(true) } var showPlaylist by remember { mutableStateOf(false) } var contentScale by remember { mutableStateOf(ContentScale.Fit) } var playbackSpeed by remember { mutableFloatStateOf(1.0f) } @@ -511,8 +543,6 @@ fun PlaybackPageContent( val scaledModifier = Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp) - var contentCurrentPosition by remember { mutableLongStateOf(0L) } - var createMarkerPosition by remember { mutableLongStateOf(-1L) } var playingBeforeDialog by remember { mutableStateOf(false) } @@ -565,9 +595,6 @@ fun PlaybackPageContent( useVideoFilters, ) viewModel.changeScene(playlist[currentPlaylistIndex].localConfiguration!!.tag as PlaylistFragment.MediaItemTag) - if (!isTvDevice) { - viewModel.videoFilter.startThrottling(DRAG_THROTTLE_DELAY) - } maybeMuteAudio(uiConfig.preferences, false, player) player.setMediaItems(playlist, startIndex, savedStartPosition) if (playlistPager == null) { @@ -615,7 +642,7 @@ fun PlaybackPageContent( mediaIndexSubtitlesActivated = currentPlaylistIndex val languageCode = Locale.getDefault().language captions.indexOfFirstOrNull { it.format.language == languageCode }?.let { - Log.v(TAG, "Found default subtitle track for $languageCode: $it") + Timber.v("Found default subtitle track for $languageCode: $it") if (toggleSubtitles(player, null, it)) { subtitleIndex = it } @@ -636,10 +663,11 @@ fun PlaybackPageContent( } override fun onPlayerError(error: PlaybackException) { - Log.e( - TAG, - "PlaybackException on scene ${currentScene.item.id}, errorCode=${error.errorCode}", + Timber.e( error, + "PlaybackException on scene %s, errorCode=%s", + currentScene?.item?.id, + error.errorCode, ) val showError = when (error.errorCode) { @@ -670,8 +698,7 @@ fun PlaybackPageContent( ) val newTag = newMediaItem.localConfiguration!!.tag as PlaylistFragment.MediaItemTag - Log.d( - TAG, + Timber.d( "Using new transcoding media item: ${newTag.streamDecision}", ) viewModel.changeScene(newTag) @@ -686,8 +713,7 @@ fun PlaybackPageContent( true } } else { - Log.w( - TAG, + Timber.w( "No current media item, cannot fallback to transcoding", ) true @@ -712,8 +738,8 @@ fun PlaybackPageContent( player.prepare() if (useVideoFilters) { - Log.d(TAG, "Enabling video effects") - (player as? ExoPlayer)?.setVideoEffects(listOf()) + Timber.d("Enabling video effects") + (player as? ExoPlayer)?.setVideoEffects(emptyList()) } } @@ -826,7 +852,7 @@ fun PlaybackPageContent( .padding(bottom = 70.dp), ) if (showSkipProgress) { - currentScene.item.duration?.let { + currentScene?.item?.duration?.let { val percent = skipPosition.toFloat() / (it.toLongMilliseconds).toFloat() Box( @@ -1015,14 +1041,15 @@ fun PlaybackPageContent( modifier = Modifier, ) } - videoFilter?.let { - val effectList = it.createEffectList() - Log.d(TAG, "Applying ${effectList.size} effects") - (player as? ExoPlayer)?.setVideoEffects(effectList) - + if (useVideoFilters) { + LaunchedEffect(videoFilter) { + val effectList = videoFilter.createEffectList() + Timber.d("Applying %s effects", effectList.size) + (player as? ExoPlayer)?.setVideoEffects(effectList) + } AnimatedVisibility(showFilterDialog) { ImageFilterDialog( - filter = it, + filter = videoFilter, showVideoOptions = true, showSaveGalleryButton = false, uiConfig = uiConfig, From 9d7d65137bf887eab5a0f1c0d8a2b1759a6c0128 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sat, 16 May 2026 11:40:06 -0400 Subject: [PATCH 4/4] Move more state to PlaybackViewModel --- .../playback/PlaybackPageContent.kt | 222 ++++++++++-------- 1 file changed, 126 insertions(+), 96 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/PlaybackPageContent.kt b/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/PlaybackPageContent.kt index 36e91d5a..f9658a18 100644 --- a/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/PlaybackPageContent.kt +++ b/app/src/main/java/com/github/damontecres/stashapp/ui/components/playback/PlaybackPageContent.kt @@ -1,6 +1,5 @@ package com.github.damontecres.stashapp.ui.components.playback -import android.util.Log import android.widget.Toast import androidx.annotation.OptIn import androidx.compose.animation.AnimatedVisibility @@ -91,7 +90,6 @@ import com.github.damontecres.stashapp.data.DataType import com.github.damontecres.stashapp.data.VideoFilter import com.github.damontecres.stashapp.data.room.PlaybackEffect import com.github.damontecres.stashapp.navigation.NavigationManager -import com.github.damontecres.stashapp.playback.PlaybackSceneFragment import com.github.damontecres.stashapp.playback.PlaylistFragment import com.github.damontecres.stashapp.playback.TrackActivityPlaybackListener import com.github.damontecres.stashapp.playback.TrackSupport @@ -122,6 +120,7 @@ import com.github.damontecres.stashapp.util.StashCoroutineExceptionHandler import com.github.damontecres.stashapp.util.StashServer import com.github.damontecres.stashapp.util.findActivity import com.github.damontecres.stashapp.util.isNotNullOrBlank +import com.github.damontecres.stashapp.util.launchDefault import com.github.damontecres.stashapp.util.launchIO import com.github.damontecres.stashapp.util.showSetRatingToast import com.github.damontecres.stashapp.util.toLongMilliseconds @@ -149,9 +148,12 @@ import kotlin.time.Duration.Companion.milliseconds const val TAG = "PlaybackPageContent" -class PlaybackViewModel : ViewModel() { +class PlaybackViewModel : + ViewModel(), + Player.Listener { private lateinit var server: StashServer private lateinit var exceptionHandler: CoroutineExceptionHandler + private lateinit var uiConfig: ComposeUiConfig private var markersEnabled by Delegates.notNull() private var saveFilters = true private var videoFiltersEnabled = false @@ -163,6 +165,11 @@ class PlaybackViewModel : ViewModel() { private val _state = MutableStateFlow(PlaybackState()) val state: StateFlow = _state + private val _info = MutableStateFlow(PlaybackInfo()) + val info: StateFlow = _info + + val subtitles = MutableStateFlow>(emptyList()) + @kotlin.OptIn(FlowPreview::class) val videoFilter = state @@ -177,6 +184,7 @@ class PlaybackViewModel : ViewModel() { markersEnabled: Boolean, saveFilters: Boolean, videoFiltersEnabled: Boolean, + uiConfig: ComposeUiConfig, ) { this.server = server this.player = player @@ -185,6 +193,8 @@ class PlaybackViewModel : ViewModel() { this.saveFilters = saveFilters this.videoFiltersEnabled = videoFiltersEnabled this.exceptionHandler = LoggingCoroutineExceptionHandler(server, viewModelScope) + player.addListener(this) + addCloseable { player.removeListener(this@PlaybackViewModel) } if (trackActivity) { addCloseable("tracking") { trackActivityListener?.let { @@ -328,20 +338,20 @@ class PlaybackViewModel : ViewModel() { } return@withContext spriteData } catch (ex: Exception) { - Log.w(TAG, "Error parsing sprites for $sceneId", ex) + Timber.w(ex, "Error parsing sprites for %s", sceneId) return@withContext emptyList() } } emptyList() } } else { - Log.d(TAG, "No sprites for $sceneId") + Timber.d("No sprites for %s", sceneId) return@withContext emptyList() } } private fun refreshScene(sceneId: String) { - // Fetch o count & markers + // Fetch o-count & markers viewModelScope.launch(sceneJob + exceptionHandler) { val queryEngine = QueryEngine(server) val scene = queryEngine.getScene(sceneId) @@ -445,6 +455,87 @@ class PlaybackViewModel : ViewModel() { showSetRatingToast(StashApplication.getApplication(), newRating) } } + + override fun onCues(cueGroup: CueGroup) { + subtitles.update { cueGroup.cues } + } + + override fun onTracksChanged(tracks: Tracks) { + viewModelScope.launchDefault { + val trackInfo = checkForSupport(tracks) + val audioTracks = + trackInfo + .filter { it.type == TrackType.AUDIO && it.supported == TrackSupportReason.HANDLED } + val audioIndex = audioTracks.indexOfFirstOrNull { it.selected } + val audioOptions = + audioTracks.map { it.labels.joinToString(", ").ifBlank { "Default" } } + val captions = + trackInfo.filter { it.type == TrackType.TEXT && it.supported == TrackSupportReason.HANDLED } + _info.update { + it.copy( + currentTracks = trackInfo, + captions = captions, + audioIndex = audioIndex, + audioOptions = audioOptions, + ) + } + + val captionsByDefault = + uiConfig.preferences.interfacePreferences.captionsByDefault + if (captionsByDefault && captions.isNotEmpty() && info.value.sceneSubtitlesActivated != state.value.scene?.id) { + // Captions will be empty when transitioning to new media item + // Only want to activate subtitles once in case the user turns them off + val sceneSubtitlesActivated = state.value.scene?.id + val languageCode = Locale.getDefault().language + val subtitleIndex = + captions.indexOfFirstOrNull { it.format.language == languageCode }?.let { + Timber.v("Found default subtitle track for $languageCode: $it") + withContext(Dispatchers.Main) { + if (toggleSubtitles(player, null, it)) { + it + } else { + null + } + } + } + _info.update { + it.copy( + sceneSubtitlesActivated = sceneSubtitlesActivated, + subtitleIndex = subtitleIndex, + ) + } + } + } + } + + override fun onMediaItemTransition( + mediaItem: MediaItem?, + reason: Int, + ) { + _info.update { PlaybackInfo(currentPlaylistIndex = player.currentMediaItemIndex) } + if (mediaItem != null) { + changeScene(mediaItem.localConfiguration!!.tag as PlaylistFragment.MediaItemTag) + } + } + + fun toggleCaptions(index: Int) { + if (toggleSubtitles(player, info.value.subtitleIndex, index)) { + _info.update { it.copy(subtitleIndex = index) } + } else { + _info.update { it.copy(subtitleIndex = null) } + subtitles.value = emptyList() + } + } + + fun toggleAudion(index: Int) { + val audioIndex = + if (toggleAudio(player, info.value.audioIndex, index)) { + index + } else { + null + } + _info.update { it.copy(audioIndex = audioIndex) } + } } val playbackScaleOptions = @@ -479,6 +570,16 @@ data class PlaybackState( val spriteImageLoaded: List = emptyList(), ) +data class PlaybackInfo( + val currentPlaylistIndex: Int = 0, + val currentTracks: List = emptyList(), + val captions: List = emptyList(), + val subtitleIndex: Int? = null, + val sceneSubtitlesActivated: String? = null, + val audioIndex: Int? = null, + val audioOptions: List = emptyList(), +) + @OptIn(UnstableApi::class) @Composable fun PlaybackPageContent( @@ -510,19 +611,20 @@ fun PlaybackPageContent( val oCount = state.oCount val rating100 = state.rating100 val spriteImageLoaded = state.spriteImageLoaded - var currentTracks by remember { mutableStateOf>(listOf()) } - var captions by remember { mutableStateOf>(listOf()) } - var subtitles by remember { mutableStateOf?>(null) } - var subtitleIndex by remember { mutableStateOf(null) } - var mediaIndexSubtitlesActivated by remember { mutableStateOf(-1) } - var audioIndex by remember { mutableStateOf(null) } - var audioOptions by remember { mutableStateOf>(listOf()) } - var showFilterDialog by rememberSaveable { mutableStateOf(false) } + val scene = state.scene + val performers = state.performers + + val info by viewModel.info.collectAsState() + val subtitles by viewModel.subtitles.collectAsState() val videoFilter by viewModel.videoFilter.collectAsState() + val currentTracks = info.currentTracks + val captions = info.captions + val subtitleIndex = info.subtitleIndex + val audioIndex = info.audioIndex + val audioOptions = info.audioOptions + var showFilterDialog by rememberSaveable { mutableStateOf(false) } var showSceneDetails by rememberSaveable { mutableStateOf(false) } - val scene = state.scene - val performers = state.performers AmbientPlayerListener(player) @@ -593,6 +695,7 @@ fun PlaybackPageContent( markersEnabled, uiConfig.persistVideoFilters, useVideoFilters, + uiConfig, ) viewModel.changeScene(playlist[currentPlaylistIndex].localConfiguration!!.tag as PlaylistFragment.MediaItemTag) maybeMuteAudio(uiConfig.preferences, false, player) @@ -613,55 +716,6 @@ fun PlaybackPageContent( ) StashExoPlayer.addListener( object : Player.Listener { - override fun onCues(cueGroup: CueGroup) { -// val cues = -// cueGroup.cues -// .mapNotNull { it.text } -// .joinToString("\n") -// Log.v(TAG, "onCues: \n$cues") - subtitles = cueGroup.cues.ifEmpty { null } - } - - override fun onTracksChanged(tracks: Tracks) { - val trackInfo = checkForSupport(tracks) - currentTracks = trackInfo - val audioTracks = - trackInfo - .filter { it.type == TrackType.AUDIO && it.supported == TrackSupportReason.HANDLED } - audioIndex = audioTracks.indexOfFirstOrNull { it.selected } - audioOptions = - audioTracks.map { it.labels.joinToString(", ").ifBlank { "Default" } } - captions = - trackInfo.filter { it.type == TrackType.TEXT && it.supported == TrackSupportReason.HANDLED } - - val captionsByDefault = - uiConfig.preferences.interfacePreferences.captionsByDefault - if (captionsByDefault && captions.isNotEmpty() && mediaIndexSubtitlesActivated != currentPlaylistIndex) { - // Captions will be empty when transitioning to new media item - // Only want to activate subtitles once in case the user turns them off - mediaIndexSubtitlesActivated = currentPlaylistIndex - val languageCode = Locale.getDefault().language - captions.indexOfFirstOrNull { it.format.language == languageCode }?.let { - Timber.v("Found default subtitle track for $languageCode: $it") - if (toggleSubtitles(player, null, it)) { - subtitleIndex = it - } - } - } - } - - override fun onMediaItemTransition( - mediaItem: MediaItem?, - reason: Int, - ) { - if (mediaItem != null) { - viewModel.changeScene(mediaItem.localConfiguration!!.tag as PlaylistFragment.MediaItemTag) - } - subtitles = null - subtitleIndex = null - currentPlaylistIndex = player.currentMediaItemIndex - } - override fun onPlayerError(error: PlaybackException) { Timber.e( error, @@ -941,12 +995,7 @@ fun PlaybackPageContent( } is PlaybackAction.ToggleCaptions -> { - if (toggleSubtitles(player, subtitleIndex, it.index)) { - subtitleIndex = it.index - } else { - subtitleIndex = null - subtitles = null - } + viewModel.toggleCaptions(it.index) controllerViewState.hideControls() } @@ -959,11 +1008,7 @@ fun PlaybackPageContent( } is PlaybackAction.ToggleAudio -> { - if (toggleAudio(player, audioIndex, it.index)) { - audioIndex = it.index - } else { - audioIndex = null - } + viewModel.toggleAudion(it.index) } PlaybackAction.ShowSceneDetails -> { @@ -1134,10 +1179,7 @@ fun Player.setupFinishedBehavior( } else -> { - Log.w( - PlaybackSceneFragment.TAG, - "Unknown playbackFinishedBehavior: $finishedBehavior", - ) + Timber.w("Unknown playbackFinishedBehavior: %s", finishedBehavior) } } } @@ -1270,10 +1312,7 @@ fun toggleSubtitles( val subtitleTracks = player.currentTracks.groups.filter { it.type == C.TRACK_TYPE_TEXT && it.isSupported } if (index !in subtitleTracks.indices || (currentActiveSubtitleIndex != null && currentActiveSubtitleIndex == index)) { - Log.v( - TAG, - "Deactivating subtitles", - ) + Timber.v("Deactivating subtitles") player.trackSelectionParameters = player.trackSelectionParameters .buildUpon() @@ -1281,10 +1320,7 @@ fun toggleSubtitles( .build() return false } else { - Log.v( - TAG, - "Activating subtitle ${subtitleTracks[index].mediaTrackGroup.id}", - ) + Timber.v("Activating subtitle %s", subtitleTracks[index].mediaTrackGroup.id) player.trackSelectionParameters = player.trackSelectionParameters .buildUpon() @@ -1308,10 +1344,7 @@ fun toggleAudio( val audioTracks = player.currentTracks.groups.filter { it.type == C.TRACK_TYPE_AUDIO && it.isSupported } if (index !in audioTracks.indices || (audioIndex != null && audioIndex == index)) { - Log.v( - TAG, - "Deactivating audio", - ) + Timber.v("Deactivating audio") player.trackSelectionParameters = player.trackSelectionParameters .buildUpon() @@ -1319,10 +1352,7 @@ fun toggleAudio( .build() return false } else { - Log.v( - TAG, - "Activating audio ${audioTracks[index].mediaTrackGroup.id}", - ) + Timber.v("Activating audio %s", audioTracks[index].mediaTrackGroup.id) player.trackSelectionParameters = player.trackSelectionParameters .buildUpon()