From de6386aa1352170f90dedbb1e2dd4646a8c3d8c6 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 7 Sep 2026 20:31:44 +0200 Subject: [PATCH 01/11] refactor: split player surface responsibilities --- .../player/PlayerTopProgressPadding.kt | 25 +++ .../player/components/PlayerSurfaceBox.kt | 152 ++++++++---------- .../player/components/PlayerSurfaceChrome.kt | 97 +++++++++++ .../components/PlayerSurfaceGestures.kt | 96 +++++++++++ .../components/PlayerSurfaceLifecycle.kt | 21 +-- 5 files changed, 300 insertions(+), 91 deletions(-) create mode 100644 app/src/main/java/dev/typetype/android/feature/player/PlayerTopProgressPadding.kt create mode 100644 app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceChrome.kt create mode 100644 app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceGestures.kt diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerTopProgressPadding.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerTopProgressPadding.kt new file mode 100644 index 00000000..03b78187 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerTopProgressPadding.kt @@ -0,0 +1,25 @@ +package dev.typetype.android.feature.player + +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout +import kotlin.math.roundToInt + +internal fun Modifier.playerTopProgressPadding( + maxTopPx: Float, + progress: () -> Float, +): Modifier = layout { measurable, constraints -> + val topPx = (maxTopPx * (1f - progress().coerceIn(0f, 1f))).roundToInt() + val maxHeight = if (constraints.hasBoundedHeight) { + (constraints.maxHeight - topPx).coerceAtLeast(0) + } else { + constraints.maxHeight + } + val childConstraints = constraints.copy( + minHeight = constraints.minHeight.coerceAtMost(maxHeight), + maxHeight = maxHeight, + ) + val placeable = measurable.measure(childConstraints) + layout(placeable.width, placeable.height + topPx) { + placeable.placeRelative(0, topPx) + } +} diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceBox.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceBox.kt index 2315e1e3..7b4387c3 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceBox.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceBox.kt @@ -4,9 +4,6 @@ import android.media.AudioManager import android.graphics.Rect import androidx.activity.compose.LocalActivity import androidx.annotation.OptIn -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.SnackbarHostState @@ -24,6 +21,7 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.unit.dp import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaController import androidx.media3.ui.compose.state.rememberPresentationState @@ -39,6 +37,7 @@ import dev.typetype.android.feature.player.PlayerDanmakuState import dev.typetype.android.feature.player.SponsorBlockPlaybackPolicy import dev.typetype.android.feature.player.key import dev.typetype.android.feature.player.state.PlayerGestureState +import dev.typetype.android.feature.player.state.DragMode import dev.typetype.android.feature.player.state.ResizeMode import dev.typetype.android.feature.player.state.next import kotlinx.coroutines.delay @@ -92,6 +91,7 @@ internal fun PlayerSurfaceBox( var controlsVisible by remember { mutableStateOf(true) } var optionsVisible by remember { mutableStateOf(false) } var chaptersVisible by remember { mutableStateOf(false) } + var timelineScrubbing by remember { mutableStateOf(false) } val accessibleControls = rememberAccessiblePlayerControls( gestureConfig.accessibleControlsEnabled, ) @@ -126,8 +126,16 @@ internal fun PlayerSurfaceBox( LaunchedEffect(accessibleControls) { if (accessibleControls) controlsVisible = true } - LaunchedEffect(controlsVisible, playbackStatus.isPlaying, accessibleControls) { + val isSeekDragging = gestureState.dragMode.value == DragMode.Seek + LaunchedEffect( + controlsVisible, + playbackStatus.isPlaying, + accessibleControls, + isSeekDragging, + timelineScrubbing, + ) { if (controlsVisible && playbackStatus.isPlaying && !accessibleControls) { + if (isSeekDragging || timelineScrubbing) return@LaunchedEffect delay(AUTO_HIDE_DELAY_MS) controlsVisible = false } @@ -202,84 +210,66 @@ internal fun PlayerSurfaceBox( modifier = chromeModifier.align(Alignment.Center), ) - if (!isInPip && playbackStatus.acceptsInput && !accessibleControls && - gesturesVisible - ) { - PlayerGestureLayer( - player = player, - state = gestureState, - onSingleTap = { - controlsVisible = !controlsVisible - }, - onAdjustBrightness = { fraction -> - val percent = (fraction * 100).toInt() - if (percent != appliedBrightnessPercent) activity?.window?.let { window -> - appliedBrightnessPercent = percent - onPlaybackBrightnessChange(percent) - window.applyPlaybackBrightness(percent) - } - }, - onAdjustVolume = { fraction -> - audioManager?.let { manager -> - val maxVolume = manager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) - val target = (fraction * maxVolume).toInt().coerceIn(0, maxVolume) - if (target != appliedVolumeLevel) { - appliedVolumeLevel = target - manager.setStreamVolume(AudioManager.STREAM_MUSIC, target, 0) - } - } - }, - onGestureFeedback = { - controlsVisible = false - hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) - }, - isFullscreen = isFullscreen, - onEnterFullscreenGesture = { - if (!isFullscreen) onToggleFullscreen() - }, - onExitFullscreenGesture = { - if (isFullscreen) onToggleFullscreen() - }, - fullscreenExitGestureEnabled = false, - config = gestureConfig, - modifier = Modifier.fillMaxSize(), - ) - } + PlayerSurfaceGestureLayer( + player = player, + state = gestureState, + activity = activity, + audioManager = audioManager, + hapticFeedback = hapticFeedback, + playbackStatus = playbackStatus, + isInPip = isInPip, + isFullscreen = isFullscreen, + accessibleControls = accessibleControls, + gesturesVisible = gesturesVisible, + controlsVisible = controlsVisible, + config = gestureConfig, + appliedBrightnessPercent = appliedBrightnessPercent, + onAppliedBrightnessChange = { appliedBrightnessPercent = it }, + onPlaybackBrightnessChange = onPlaybackBrightnessChange, + appliedVolumeLevel = appliedVolumeLevel, + onAppliedVolumeChange = { appliedVolumeLevel = it }, + onControlsVisibleChange = { controlsVisible = it }, + onToggleFullscreen = onToggleFullscreen, + modifier = Modifier.fillMaxSize(), + ) - AnimatedVisibility( - visible = controlsAllowedByProgress && - (controlsVisible || accessibleControls) && - !isInPip && playbackStatus.acceptsInput, - enter = fadeIn(), - exit = fadeOut(), - ) { - PlayerControls( - player = player, - title = stream.title, - onNavigateBack = onNavigateBack, - onOpenOptions = { optionsVisible = true }, - onOpenChapters = { chaptersVisible = true }, - onEnterPip = { - controlsVisible = false - enterPictureInPicture( - activity, - isPlaying = playbackStatus.isPlaying, - audioOnlyAvailable = audioOnlyState.available, - sourceRect = pipSourceRect, - ) - }, - onToggleFullscreen = onToggleFullscreen, - onCycleResizeMode = { - gestureState.resizeMode.value = gestureState.resizeMode.value.next() - }, - resizeMode = gestureState.resizeMode.value, - isFullscreen = isFullscreen, - isPipAvailable = isPipAvailable, - chaptersAvailable = chapters.isNotEmpty(), - sponsorBlockSegments = sponsorBlockPolicy.visibleSegments, - modifier = chromeModifier.fillMaxSize(), - ) - } + PlayerSurfaceChrome( + player = player, + title = stream.title, + sponsorBlockSegments = sponsorBlockPolicy.visibleSegments, + seekPreviewPositionMs = gestureState.seekDragTargetMs.longValue + .takeIf { gestureState.seekDragOverlayActive.value }, + seekDragOverlayVisible = gestureState.seekDragOverlayActive.value, + seekDragPositionMs = gestureState.seekDragTargetMs.longValue, + isFullscreen = isFullscreen, + isInPip = isInPip, + controlsAllowedByProgress = controlsAllowedByProgress, + controlsVisible = controlsVisible, + accessibleControls = accessibleControls, + acceptsInput = playbackStatus.acceptsInput, + timelineScrubbing = timelineScrubbing, + onTimelineScrubbingChange = { timelineScrubbing = it }, + onNavigateBack = onNavigateBack, + onOpenOptions = { optionsVisible = true }, + onOpenChapters = { chaptersVisible = true }, + onEnterPip = { + controlsVisible = false + enterPictureInPicture( + activity, + isPlaying = playbackStatus.isPlaying, + audioOnlyAvailable = audioOnlyState.available, + sourceRect = pipSourceRect, + ) + }, + onToggleFullscreen = onToggleFullscreen, + onCycleResizeMode = { + gestureState.resizeMode.value = gestureState.resizeMode.value.next() + }, + resizeMode = gestureState.resizeMode.value, + isPipAvailable = isPipAvailable, + chaptersAvailable = chapters.isNotEmpty(), + modifier = chromeModifier, + ) PlayerSurfaceFeedback( player = player, diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceChrome.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceChrome.kt new file mode 100644 index 00000000..f93e944c --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceChrome.kt @@ -0,0 +1,97 @@ +package dev.typetype.android.feature.player.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.media3.session.MediaController +import dev.typetype.android.domain.stream.SponsorBlockSegment +import dev.typetype.android.feature.player.state.ResizeMode + +@Composable +internal fun PlayerSurfaceChrome( + player: MediaController, + title: String, + sponsorBlockSegments: List, + seekPreviewPositionMs: Long?, + seekDragOverlayVisible: Boolean, + seekDragPositionMs: Long, + isFullscreen: Boolean, + isInPip: Boolean, + controlsAllowedByProgress: Boolean, + controlsVisible: Boolean, + accessibleControls: Boolean, + acceptsInput: Boolean, + timelineScrubbing: Boolean, + onTimelineScrubbingChange: (Boolean) -> Unit, + onNavigateBack: () -> Unit, + onOpenOptions: () -> Unit, + onOpenChapters: () -> Unit, + onEnterPip: () -> Unit, + onToggleFullscreen: () -> Unit, + onCycleResizeMode: () -> Unit, + resizeMode: ResizeMode, + isPipAvailable: Boolean, + chaptersAvailable: Boolean, + modifier: Modifier = Modifier, +) { + Box(Modifier.fillMaxSize()) { + AnimatedVisibility( + visible = seekDragOverlayVisible, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.BottomCenter), + ) { + PlayerSeekScrubOverlay( + player = player, + positionMs = seekDragPositionMs, + segments = sponsorBlockSegments, + isFullscreen = isFullscreen, + modifier = Modifier + .fillMaxWidth() + .then( + if (isFullscreen) { + Modifier.padding(start = 12.dp, end = 8.dp, bottom = 6.dp) + } else { + Modifier.padding(start = 4.dp, end = 4.dp) + }, + ), + ) + } + + AnimatedVisibility( + visible = controlsAllowedByProgress && + (controlsVisible || accessibleControls) && + !isInPip && acceptsInput, + enter = fadeIn(), + exit = fadeOut(), + ) { + PlayerControls( + player = player, + title = title, + onNavigateBack = onNavigateBack, + onOpenOptions = onOpenOptions, + onOpenChapters = onOpenChapters, + onEnterPip = onEnterPip, + onToggleFullscreen = onToggleFullscreen, + onCycleResizeMode = onCycleResizeMode, + resizeMode = resizeMode, + isFullscreen = isFullscreen, + isPipAvailable = isPipAvailable, + chaptersAvailable = chaptersAvailable, + sponsorBlockSegments = sponsorBlockSegments, + seekPreviewPositionMs = seekPreviewPositionMs, + onTimelineScrubbingChange = onTimelineScrubbingChange, + timelineScrubbing = timelineScrubbing, + modifier = modifier.fillMaxSize(), + ) + } + } +} diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceGestures.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceGestures.kt new file mode 100644 index 00000000..c308e2e0 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceGestures.kt @@ -0,0 +1,96 @@ +package dev.typetype.android.feature.player.components + +import android.app.Activity +import android.media.AudioManager +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.media3.session.MediaController +import dev.typetype.android.feature.player.state.PlayerGestureState + +@Composable +internal fun PlayerSurfaceGestureLayer( + player: MediaController, + state: PlayerGestureState, + activity: Activity?, + audioManager: AudioManager?, + hapticFeedback: HapticFeedback, + playbackStatus: PlayerPlaybackStatus, + isInPip: Boolean, + isFullscreen: Boolean, + accessibleControls: Boolean, + gesturesVisible: Boolean, + controlsVisible: Boolean, + config: PlayerGestureConfig, + appliedBrightnessPercent: Int, + onAppliedBrightnessChange: (Int) -> Unit, + onPlaybackBrightnessChange: (Int) -> Unit, + appliedVolumeLevel: Int, + onAppliedVolumeChange: (Int) -> Unit, + onControlsVisibleChange: (Boolean) -> Unit, + onToggleFullscreen: () -> Unit, + modifier: Modifier = Modifier, +) { + if (!isInPip && playbackStatus.acceptsInput && !accessibleControls && gesturesVisible) { + PlayerGestureLayer( + player = player, + state = state, + onSingleTap = { onControlsVisibleChange(!controlsVisible) }, + onAdjustBrightness = { fraction -> + val percent = (fraction * 100).toInt() + if (percent != appliedBrightnessPercent) activity?.window?.let { window -> + onAppliedBrightnessChange(percent) + onPlaybackBrightnessChange(percent) + window.applyPlaybackBrightness(percent) + } + }, + onAdjustVolume = { fraction -> + audioManager?.let { manager -> + val maxVolume = manager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + val target = (fraction * maxVolume).toInt().coerceIn(0, maxVolume) + if (target != appliedVolumeLevel) { + onAppliedVolumeChange(target) + manager.setStreamVolume(AudioManager.STREAM_MUSIC, target, 0) + } + } + }, + onBrightnessGestureStart = { + val fraction = activity?.window?.attributes?.screenBrightness + ?.takeIf { it in 0f..1f } + ?: appliedBrightnessPercent + .takeIf { it in 0..100 } + ?.div(100f) + ?: state.brightnessFraction.floatValue + state.brightnessFraction.floatValue = fraction + fraction + }, + onVolumeGestureStart = { + val fraction = audioManager?.let { manager -> + val maxVolume = manager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + if (maxVolume > 0) { + manager.getStreamVolume(AudioManager.STREAM_MUSIC) / maxVolume.toFloat() + } else { + 0f + } + } ?: state.volumeFraction.floatValue + state.volumeFraction.floatValue = fraction + fraction + }, + onGestureFeedback = { + onControlsVisibleChange(false) + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + }, + isFullscreen = isFullscreen, + onEnterFullscreenGesture = { + if (!isFullscreen) onToggleFullscreen() + }, + onExitFullscreenGesture = { + if (isFullscreen) onToggleFullscreen() + }, + fullscreenExitGestureEnabled = false, + config = config, + modifier = modifier, + ) + } +} diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceLifecycle.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceLifecycle.kt index bf26c946..86e41ca8 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceLifecycle.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceLifecycle.kt @@ -25,8 +25,9 @@ internal fun rememberPlayerSurfaceKey(streamId: String): String { DisposableEffect(lifecycleOwner, streamId) { val observer = LifecycleEventObserver { _, event -> when (event) { - Lifecycle.Event.ON_STOP -> refreshGate.invalidate() - Lifecycle.Event.ON_START -> if (refreshGate.refresh()) epoch += 1 + Lifecycle.Event.ON_START -> if (refreshGate.consumeScreenOff()) { + epoch += 1 + } else -> Unit } } @@ -37,12 +38,12 @@ internal fun rememberPlayerSurfaceKey(streamId: String): String { val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { when (intent.action) { - Intent.ACTION_SCREEN_OFF -> refreshGate.invalidate() + Intent.ACTION_SCREEN_OFF -> refreshGate.markScreenOff() Intent.ACTION_SCREEN_ON, Intent.ACTION_USER_PRESENT, -> if ( lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) && - refreshGate.refresh() + refreshGate.consumeScreenOff() ) { epoch += 1 } @@ -66,15 +67,15 @@ internal fun rememberPlayerSurfaceKey(streamId: String): String { } internal class PlayerSurfaceRefreshGate { - private var invalid = false + private var screenOff = false - fun invalidate() { - invalid = true + fun markScreenOff() { + screenOff = true } - fun refresh(): Boolean { - if (!invalid) return false - invalid = false + fun consumeScreenOff(): Boolean { + if (!screenOff) return false + screenOff = false return true } } From a2fddcd6b85b7456af072080f8eddb000a1be3f7 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 7 Sep 2026 20:31:56 +0200 Subject: [PATCH 02/11] feat: refine player controls and gestures --- .../android/feature/player/LoadedPlayer.kt | 32 +--- .../feature/player/PlayerInteractionRow.kt | 115 +++++++++---- .../player/components/PlayerCenterControls.kt | 8 + .../player/components/PlayerControls.kt | 162 ++++++++++-------- .../player/components/PlayerGestureLayer.kt | 60 +++++-- .../player/components/PlayerLevelOverlay.kt | 119 +++++-------- .../player/components/PlayerTopControls.kt | 32 +++- .../android/feature/player/host/PlayerHost.kt | 11 +- 8 files changed, 313 insertions(+), 226 deletions(-) diff --git a/app/src/main/java/dev/typetype/android/feature/player/LoadedPlayer.kt b/app/src/main/java/dev/typetype/android/feature/player/LoadedPlayer.kt index c0b698f5..06f521e4 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/LoadedPlayer.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/LoadedPlayer.kt @@ -5,10 +5,10 @@ import androidx.activity.compose.LocalActivity import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.derivedStateOf @@ -26,7 +26,6 @@ import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.unit.Constraints import androidx.compose.ui.platform.LocalContext import androidx.paging.PagingData import dev.typetype.android.domain.comments.Comment @@ -206,7 +205,7 @@ fun LoadedPlayer( maxTopPx = expandedTopPaddingPx, progress = hostTransitionProgress, ) - } + }, ), ) { PlayerContentLayout( @@ -281,6 +280,12 @@ fun LoadedPlayer( } } }, + recommendations = { recommendationsModifier -> + PlayerRecommendations( + stream, userSettings, onPlayVideo, onOpenChannel, onAction, + recommendationsModifier, + ) + }, details = { detailsModifier -> PlayerDetails( stream = stream, @@ -297,7 +302,6 @@ fun LoadedPlayer( onAction = onAction, onShowComments = { commentsVisible = true }, onShowDownloads = { downloadPickerVisible = true }, - onPlayVideo = onPlayVideo, onOpenChannel = onOpenChannel, onToggleSubscription = onToggleSubscription, modifier = detailsModifier, @@ -322,23 +326,3 @@ fun LoadedPlayer( onAction = onAction, ) } - -private fun Modifier.playerTopProgressPadding( - maxTopPx: Float, - progress: () -> Float, -): Modifier = layout { measurable, constraints -> - val topPx = (maxTopPx * (1f - progress().coerceIn(0f, 1f))).roundToInt() - val maxHeight = if (constraints.hasBoundedHeight) { - (constraints.maxHeight - topPx).coerceAtLeast(0) - } else { - constraints.maxHeight - } - val childConstraints = constraints.copy( - minHeight = constraints.minHeight.coerceAtMost(maxHeight), - maxHeight = maxHeight, - ) - val placeable = measurable.measure(childConstraints) - layout(placeable.width, placeable.height + topPx) { - placeable.placeRelative(0, topPx) - } -} diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerInteractionRow.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerInteractionRow.kt index 062573e6..2bc4362f 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlayerInteractionRow.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerInteractionRow.kt @@ -1,9 +1,15 @@ package dev.typetype.android.feature.player -import android.content.Intent import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.rememberScrollState @@ -22,17 +28,24 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.text.style.TextAlign import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import dev.typetype.android.R import dev.typetype.android.core.ui.share.LocalServerBaseUrl -import dev.typetype.android.core.ui.share.buildShareUrl +import dev.typetype.android.core.ui.share.ShareChooserSheet @Composable +@OptIn(ExperimentalLayoutApi::class) fun PlayerInteractionRow( isFavorited: Boolean, isInWatchLater: Boolean, @@ -48,14 +61,11 @@ fun PlayerInteractionRow( audioOnlyChanging: Boolean = false, onToggleAudioOnly: () -> Unit = {}, ) { - val context = LocalContext.current - val shareChooserTitle = stringResource(R.string.video_menu_share_chooser) val serverBaseUrl = LocalServerBaseUrl.current - Row( - modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { + var shareSheetOpen by remember { mutableStateOf(false) } + val actions: @Composable (Boolean) -> Unit = { expanded -> PlayerActionButton( + expanded = expanded, icon = if (isFavorited) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder, contentDescription = stringResource( if (isFavorited) R.string.player_remove_from_favorites @@ -65,6 +75,7 @@ fun PlayerInteractionRow( onClick = onToggleFavorite, ) PlayerActionButton( + expanded = expanded, icon = if (isInWatchLater) Icons.Filled.WatchLater else Icons.Outlined.WatchLater, contentDescription = stringResource( if (isInWatchLater) R.string.player_remove_from_watch_later @@ -74,12 +85,14 @@ fun PlayerInteractionRow( onClick = onToggleWatchLater, ) PlayerActionButton( + expanded = expanded, icon = Icons.AutoMirrored.Filled.PlaylistAdd, contentDescription = stringResource(R.string.player_add_to_playlist), onClick = onAddToPlaylist, ) onShowComments?.let { PlayerActionButton( + expanded = expanded, icon = Icons.Outlined.ChatBubbleOutline, contentDescription = stringResource(R.string.comments_title), onClick = it, @@ -87,6 +100,7 @@ fun PlayerInteractionRow( } if (audioOnlyAvailable) { PlayerActionButton( + expanded = expanded, icon = Icons.Filled.GraphicEq, contentDescription = stringResource(R.string.player_audio_only), selected = audioOnlyEnabled, @@ -95,21 +109,37 @@ fun PlayerInteractionRow( ) } PlayerActionButton( + expanded = expanded, icon = Icons.Filled.Download, contentDescription = stringResource(R.string.player_download), enabled = !downloadInFlight, onClick = onDownload, ) PlayerActionButton( + expanded = expanded, icon = Icons.Filled.Share, contentDescription = stringResource(R.string.video_menu_share), - onClick = { - val intent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, buildShareUrl(serverBaseUrl, shareUrl)) - } - context.startActivity(Intent.createChooser(intent, shareChooserTitle)) - }, + onClick = { shareSheetOpen = true }, + ) + } + BoxWithConstraints(Modifier.fillMaxWidth()) { + if (maxWidth >= 600.dp) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { actions(true) } + } else { + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { actions(false) } + } + } + if (shareSheetOpen) { + ShareChooserSheet( + serverBaseUrl = serverBaseUrl, + videoUrl = shareUrl, + onDismiss = { shareSheetOpen = false }, ) } } @@ -119,27 +149,46 @@ private fun PlayerActionButton( icon: ImageVector, contentDescription: String, onClick: () -> Unit, + expanded: Boolean, selected: Boolean = false, enabled: Boolean = true, ) { - Surface( - shape = CircleShape, - color = if (selected) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) - } else { - MaterialTheme.colorScheme.surfaceVariant - }, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + Column( + modifier = if (expanded) Modifier.width(100.dp) else Modifier, + horizontalAlignment = Alignment.CenterHorizontally, ) { - IconButton(onClick = onClick, enabled = enabled) { - Icon( - imageVector = icon, - contentDescription = contentDescription, - tint = when { - selected -> MaterialTheme.colorScheme.primary - enabled -> MaterialTheme.colorScheme.onSurface - else -> MaterialTheme.colorScheme.onSurfaceVariant - }, + Surface( + shape = CircleShape, + color = if (selected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + IconButton( + onClick = onClick, + enabled = enabled, + modifier = Modifier.size(if (expanded) 72.dp else 48.dp), + ) { + Icon( + imageVector = icon, + modifier = Modifier.size(if (expanded) 36.dp else 24.dp), + contentDescription = contentDescription, + tint = when { + selected -> MaterialTheme.colorScheme.primary + enabled -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + if (expanded) { + Text( + text = contentDescription, + style = MaterialTheme.typography.titleSmall, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 6.dp), ) } } diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerCenterControls.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerCenterControls.kt index 0885843d..d516d683 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerCenterControls.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerCenterControls.kt @@ -28,12 +28,14 @@ internal fun PlayerCenterControls( player: Player, isFullscreen: Boolean, compact: Boolean = false, + expanded: Boolean = false, modifier: Modifier = Modifier, ) { val playPauseState = rememberPlayPauseButtonState(player) val seekBackState = rememberSeekBackButtonState(player) val seekForwardState = rememberSeekForwardButtonState(player) val spacing = when { + expanded -> 48.dp isFullscreen -> 44.dp compact -> 18.dp else -> 26.dp @@ -50,11 +52,13 @@ internal fun PlayerCenterControls( enabled = seekBackState.isEnabled, onClick = { seekBackState.onClick() }, buttonSize = when { + expanded -> 72.dp isFullscreen -> 62.dp compact -> 40.dp else -> 50.dp }, iconSize = when { + expanded -> 40.dp isFullscreen -> 32.dp compact -> 22.dp else -> 26.dp @@ -68,11 +72,13 @@ internal fun PlayerCenterControls( enabled = playPauseState.isEnabled, onClick = { playPauseState.onClick() }, buttonSize = when { + expanded -> 96.dp isFullscreen -> 74.dp compact -> 48.dp else -> 62.dp }, iconSize = when { + expanded -> 60.dp isFullscreen -> 48.dp compact -> 30.dp else -> 38.dp @@ -85,11 +91,13 @@ internal fun PlayerCenterControls( enabled = seekForwardState.isEnabled, onClick = { seekForwardState.onClick() }, buttonSize = when { + expanded -> 72.dp isFullscreen -> 62.dp compact -> 40.dp else -> 50.dp }, iconSize = when { + expanded -> 40.dp isFullscreen -> 32.dp compact -> 22.dp else -> 26.dp diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControls.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControls.kt index cd44edfc..acb53053 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControls.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControls.kt @@ -8,12 +8,10 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.navigationBarsIgnoringVisibility import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable @@ -47,68 +45,76 @@ fun PlayerControls( isPipAvailable: Boolean = false, chaptersAvailable: Boolean = false, sponsorBlockSegments: List = emptyList(), + seekPreviewPositionMs: Long? = null, + timelineScrubbing: Boolean = false, + onTimelineScrubbingChange: (Boolean) -> Unit = {}, ) { BoxWithConstraints(modifier = modifier) { val compactControls = !isFullscreen && maxHeight < COMPACT_CONTROLS_HEIGHT - TopScrim( - compact = compactControls, - modifier = Modifier.align(Alignment.TopCenter), - ) + val expandedControls = maxWidth >= 600.dp && maxHeight >= 300.dp + if (!timelineScrubbing) { + TopScrim( + compact = compactControls, + modifier = Modifier.align(Alignment.TopCenter), + ) + } BottomScrim( compact = compactControls, - modifier = Modifier.align(Alignment.BottomCenter), - ) - PlayerTopBar( - title = title, - onNavigateBack = onNavigateBack, - onOpenChapters = onOpenChapters, - onOpenOptions = onOpenOptions, - onEnterPip = onEnterPip, - onCycleResizeMode = onCycleResizeMode, - resizeMode = resizeMode, isFullscreen = isFullscreen, - isPipAvailable = isPipAvailable, - chaptersAvailable = chaptersAvailable, - compact = compactControls, - modifier = Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .testTag(PLAYER_TOP_CONTROLS_TAG) - .then( - if (isFullscreen) { - Modifier.windowInsetsPadding(WindowInsets.statusBars) - } else { - Modifier - }, - ), - ) - PlayerCenterControls( - player = player, - isFullscreen = isFullscreen, - compact = compactControls, - modifier = Modifier.align(Alignment.Center).testTag(PLAYER_CENTER_CONTROLS_TAG), + modifier = Modifier.align(Alignment.BottomCenter), ) + if (!timelineScrubbing) { + PlayerTopBar( + title = title, + onNavigateBack = onNavigateBack, + onOpenChapters = onOpenChapters, + onOpenOptions = onOpenOptions, + onEnterPip = onEnterPip, + onCycleResizeMode = onCycleResizeMode, + resizeMode = resizeMode, + isFullscreen = isFullscreen, + isPipAvailable = isPipAvailable, + chaptersAvailable = chaptersAvailable, + compact = compactControls, + expanded = expandedControls, + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .testTag(PLAYER_TOP_CONTROLS_TAG) + .then( + if (isFullscreen) { + Modifier.windowInsetsPadding(WindowInsets.statusBars) + } else { + Modifier + }, + ), + ) + PlayerCenterControls( + player = player, + expanded = expandedControls, + isFullscreen = isFullscreen, + compact = compactControls, + modifier = Modifier.align(Alignment.Center).testTag(PLAYER_CENTER_CONTROLS_TAG), + ) + } BottomBar( player = player, sponsorBlockSegments = sponsorBlockSegments, + seekPreviewPositionMs = seekPreviewPositionMs, + timelineScrubbing = timelineScrubbing, + onTimelineScrubbingChange = onTimelineScrubbingChange, isFullscreen = isFullscreen, compact = compactControls, + expanded = expandedControls, onToggleFullscreen = onToggleFullscreen, modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth() .testTag(PLAYER_BOTTOM_CONTROLS_TAG) - .then( - if (isFullscreen) { - Modifier.windowInsetsPadding(WindowInsets.navigationBarsIgnoringVisibility) - } else { - Modifier - }, - ) .padding( start = if (isFullscreen) 12.dp else 4.dp, end = if (isFullscreen) 8.dp else 4.dp, - bottom = if (isFullscreen) 12.dp else 0.dp, + bottom = if (isFullscreen) 6.dp else 0.dp, ), ) } @@ -129,11 +135,21 @@ private fun TopScrim(compact: Boolean, modifier: Modifier = Modifier) { } @Composable -private fun BottomScrim(compact: Boolean, modifier: Modifier = Modifier) { +private fun BottomScrim( + compact: Boolean, + isFullscreen: Boolean, + modifier: Modifier = Modifier, +) { Box( modifier = modifier .fillMaxWidth() - .height(if (compact) 88.dp else 152.dp) + .height( + when { + isFullscreen -> 116.dp + compact -> 88.dp + else -> 152.dp + }, + ) .background( Brush.verticalGradient( colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.72f)), @@ -146,52 +162,59 @@ private fun BottomScrim(compact: Boolean, modifier: Modifier = Modifier) { private fun BottomBar( player: Player, sponsorBlockSegments: List, + seekPreviewPositionMs: Long?, + timelineScrubbing: Boolean, + onTimelineScrubbingChange: (Boolean) -> Unit, isFullscreen: Boolean, compact: Boolean, + expanded: Boolean, onToggleFullscreen: () -> Unit, modifier: Modifier = Modifier, ) { - val backgroundModifier = if (isFullscreen) { - Modifier.background(Color.Black.copy(alpha = 0.34f), RoundedCornerShape(14.dp)) - } else { - Modifier - } + // I SPEND 2 HOURS FIGURING OUT HOW TO ALIGN THIS FREAKING SEEKBAR WITH THE BUTTON FULLSCREEN IM SUCH A FAILURE Row( modifier = modifier .height( when { + expanded -> 72.dp isFullscreen -> 52.dp compact -> 36.dp else -> 40.dp }, ) - .then(backgroundModifier) .padding(start = if (isFullscreen) 8.dp else 2.dp, end = 2.dp), verticalAlignment = Alignment.CenterVertically, ) { PlayerTimeBar( player = player, segments = sponsorBlockSegments, - compact = !isFullscreen, + previewPositionMs = seekPreviewPositionMs, + compact = !isFullscreen && !expanded, + expanded = expanded, + onScrubbingChange = onTimelineScrubbingChange, modifier = Modifier.weight(1f), ) - IconButton( - onClick = onToggleFullscreen, - modifier = Modifier.size( - when { - isFullscreen -> 48.dp - compact -> 36.dp - else -> 40.dp - }, - ), - ) { - Icon( - painter = painterResource( - if (isFullscreen) R.drawable.ic_fullscreen_exit else R.drawable.ic_fullscreen, + if (!timelineScrubbing) { + IconButton( + onClick = onToggleFullscreen, + modifier = Modifier.size( + when { + expanded -> 64.dp + isFullscreen -> 48.dp + compact -> 36.dp + else -> 40.dp + }, ), - contentDescription = stringResource(R.string.player_fullscreen), - tint = Color.White, - ) + ) { + Icon( + painter = painterResource( + if (isFullscreen) R.drawable.ic_fullscreen_exit else R.drawable.ic_fullscreen, + ), + contentDescription = stringResource(R.string.player_fullscreen), + tint = Color.White, + modifier = Modifier.size(if (expanded) 36.dp else 24.dp), + ) + } } } } @@ -199,4 +222,5 @@ private fun BottomBar( internal const val PLAYER_TOP_CONTROLS_TAG = "player_top_controls" internal const val PLAYER_CENTER_CONTROLS_TAG = "player_center_controls" internal const val PLAYER_BOTTOM_CONTROLS_TAG = "player_bottom_controls" +internal const val PLAYER_CONTROLS_VIEWPORT_TAG = "player_controls_viewport" private val COMPACT_CONTROLS_HEIGHT = 180.dp diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureLayer.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureLayer.kt index d9161584..9b5fbd7b 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureLayer.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureLayer.kt @@ -8,7 +8,12 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.VolumeUp -import androidx.compose.material.icons.filled.Brightness6 +import androidx.compose.material.icons.automirrored.filled.VolumeDown +import androidx.compose.material.icons.automirrored.filled.VolumeMute +import androidx.compose.material.icons.automirrored.filled.VolumeOff +import androidx.compose.material.icons.filled.BrightnessHigh +import androidx.compose.material.icons.filled.BrightnessLow +import androidx.compose.material.icons.filled.BrightnessMedium import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -17,6 +22,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.res.stringResource @@ -30,6 +36,7 @@ import kotlin.math.abs private const val SEEK_DRAG_MS_PER_PIXEL = 80f private const val DIRECTION_LOCK_THRESHOLD_PX = 18f +private const val LEVEL_DRAG_VIEW_FRACTION = 0.75f private const val LONG_PRESS_SPEED_FACTOR = 2f data class PlayerGestureConfig( @@ -55,6 +62,8 @@ fun PlayerGestureLayer( onExitFullscreenGesture: () -> Unit = {}, fullscreenExitGestureEnabled: Boolean = true, config: PlayerGestureConfig = PlayerGestureConfig(), + onBrightnessGestureStart: () -> Float = { state.brightnessFraction.floatValue }, + onVolumeGestureStart: () -> Float = { state.volumeFraction.floatValue }, ) { var savedSpeed by remember { mutableFloatStateOf(1f) } Box( @@ -115,11 +124,17 @@ fun PlayerGestureLayer( } if (!allowed) continue mode = candidate - onGestureFeedback() state.dragMode.value = candidate + onGestureFeedback() when (candidate) { - DragMode.Brightness -> state.brightnessOverlayActive.value = true - DragMode.Volume -> state.volumeOverlayActive.value = true + DragMode.Brightness -> { + state.brightnessFraction.floatValue = onBrightnessGestureStart() + state.brightnessOverlayActive.value = true + } + DragMode.Volume -> { + state.volumeFraction.floatValue = onVolumeGestureStart() + state.volumeOverlayActive.value = true + } DragMode.Seek -> state.seekDragOverlayActive.value = true DragMode.FullscreenEnter -> Unit DragMode.FullscreenExit -> Unit @@ -133,7 +148,10 @@ fun PlayerGestureLayer( state = state, mode = mode, delta = delta, - levelDragRangePx = (size.height * 0.72f).coerceAtLeast(320f), + levelDragRangePx = levelDragRangePx( + size.width.toFloat(), + size.height.toFloat(), + ), onAdjustBrightness = onAdjustBrightness, onAdjustVolume = onAdjustVolume, ) @@ -206,19 +224,16 @@ fun PlayerGestureLayer( visible = state.brightnessOverlayActive.value, fraction = state.brightnessFraction.floatValue, label = stringResource(R.string.player_gesture_brightness), - icon = Icons.Filled.Brightness6, - side = GestureSide.Left, - modifier = Modifier.align(Alignment.CenterStart), + icon = brightnessLevelIcon(state.brightnessFraction.floatValue), + modifier = Modifier.align(Alignment.Center), ) PlayerLevelOverlay( visible = state.volumeOverlayActive.value, fraction = state.volumeFraction.floatValue, label = stringResource(R.string.player_gesture_volume), - icon = Icons.AutoMirrored.Filled.VolumeUp, - side = GestureSide.Right, - modifier = Modifier.align(Alignment.CenterEnd), + icon = volumeLevelIcon(state.volumeFraction.floatValue), + modifier = Modifier.align(Alignment.Center), ) - SeekDragOverlay(state = state, durationMs = player.duration) SpeedBoostBadge(visible = state.longPressBoostActive.value, factor = LONG_PRESS_SPEED_FACTOR) } } @@ -270,12 +285,29 @@ private fun handleDragMode( internal fun adjustLevelFraction(current: Float, deltaY: Float, dragRangePx: Float): Float = (current - deltaY / dragRangePx.coerceAtLeast(1f)).coerceIn(0f, 1f) -private fun pickDragMode(dragAmount: Offset, startX: Float, width: Float): DragMode = when { +internal fun levelDragRangePx(width: Float, height: Float): Float = + minOf(width, height) * LEVEL_DRAG_VIEW_FRACTION + +internal fun brightnessLevelIcon(fraction: Float): ImageVector = when { + fraction < 0.25f -> Icons.Filled.BrightnessLow + fraction < 0.75f -> Icons.Filled.BrightnessMedium + else -> Icons.Filled.BrightnessHigh +} + +internal fun volumeLevelIcon(fraction: Float): ImageVector = when { + fraction <= 0f -> Icons.AutoMirrored.Filled.VolumeOff + fraction < 0.25f -> Icons.AutoMirrored.Filled.VolumeMute + fraction < 0.75f -> Icons.AutoMirrored.Filled.VolumeDown + else -> Icons.AutoMirrored.Filled.VolumeUp +} + +internal fun pickDragMode(dragAmount: Offset, startX: Float, width: Float): DragMode = when { abs(dragAmount.x) > abs(dragAmount.y) -> DragMode.Seek startX in (width * 0.35f)..(width * 0.65f) && dragAmount.y < 0f -> DragMode.FullscreenEnter startX in (width * 0.35f)..(width * 0.65f) && dragAmount.y > 0f -> DragMode.FullscreenExit startX < width / 2f -> DragMode.Brightness - else -> DragMode.Volume + startX > width * 2f / 3f -> DragMode.Volume + else -> DragMode.None } private fun resetDragState(state: PlayerGestureState) { diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerLevelOverlay.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerLevelOverlay.kt index 4165dcf4..65800c1b 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerLevelOverlay.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerLevelOverlay.kt @@ -1,31 +1,26 @@ package dev.typetype.android.feature.player.components -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp -import dev.typetype.android.feature.player.state.GestureSide -import kotlin.math.abs -import kotlin.math.sin @Composable internal fun PlayerLevelOverlay( @@ -33,70 +28,44 @@ internal fun PlayerLevelOverlay( fraction: Float, label: String, icon: ImageVector, - side: GestureSide, modifier: Modifier = Modifier, ) { - if (!visible) return - val animatedFraction by animateFloatAsState( - targetValue = fraction.coerceIn(0f, 1f), - animationSpec = tween(80), - label = "playerLevel", - ) - val edgeColor = Color.Black.copy(alpha = 0.58f) - val gradient = if (side == GestureSide.Left) { - Brush.horizontalGradient(listOf(edgeColor, Color.Transparent)) - } else { - Brush.horizontalGradient(listOf(Color.Transparent, edgeColor)) - } - Column( - modifier = modifier - .width(92.dp) - .fillMaxHeight(0.72f) - .background(gradient) - .padding(horizontal = 18.dp, vertical = 20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp), + AnimatedVisibility( + visible = visible, + enter = fadeIn() + scaleIn(initialScale = 0.86f), + exit = fadeOut() + scaleOut(targetScale = 0.92f), + modifier = modifier, ) { - Icon( - imageVector = icon, - contentDescription = label, - tint = Color.White, - modifier = Modifier.size(24.dp), - ) - VerticalLevelMeter( - fraction = animatedFraction, - activeColor = MaterialTheme.colorScheme.primary, - modifier = Modifier.weight(1f).width(32.dp), - ) - Text( - text = "${(animatedFraction * 100).toInt()}%", - color = Color.White, - style = MaterialTheme.typography.labelMedium, - ) - } -} - -@Composable -private fun VerticalLevelMeter( - fraction: Float, - activeColor: Color, - modifier: Modifier = Modifier, -) { - Canvas(modifier = modifier) { - val bars = 24 - val spacing = size.height / bars - val activeBars = (bars * fraction).toInt() - repeat(bars) { index -> - val active = index < activeBars - val y = size.height - spacing * (index + 0.5f) - val wave = 0.45f + abs(sin(index * 0.72f)) * 0.55f - val halfWidth = size.width * wave / 2f - drawLine( - color = if (active) activeColor else Color.White.copy(alpha = 0.22f), - start = Offset(size.width / 2f - halfWidth, y), - end = Offset(size.width / 2f + halfWidth, y), - strokeWidth = if (active) 3.dp.toPx() else 2.dp.toPx(), - cap = StrokeCap.Round, + Box( + modifier = Modifier + .size(128.dp) + .background(Color.Black.copy(alpha = 0.62f), CircleShape), + contentAlignment = Alignment.Center, + ) { + Canvas(modifier = Modifier.fillMaxSize().padding(10.dp)) { + drawArc( + color = Color.White.copy(alpha = 0.24f), + startAngle = -90f, + sweepAngle = 360f, + useCenter = false, + style = Stroke(width = 8.dp.toPx(), cap = StrokeCap.Round), + ) + val clampedFraction = fraction.coerceIn(0f, 1f) + if (clampedFraction > 0f) { + drawArc( + color = Color.White, + startAngle = -90f, + sweepAngle = 360f * clampedFraction, + useCenter = false, + style = Stroke(width = 8.dp.toPx(), cap = StrokeCap.Round), + ) + } + } + Icon( + imageVector = icon, + contentDescription = label, + tint = Color.White, + modifier = Modifier.size(64.dp), ) } } diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerTopControls.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerTopControls.kt index fd3d0b6b..209f5b1b 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerTopControls.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerTopControls.kt @@ -40,17 +40,18 @@ internal fun PlayerTopBar( isPipAvailable: Boolean, chaptersAvailable: Boolean, compact: Boolean = false, + expanded: Boolean = false, modifier: Modifier = Modifier, ) { Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { - BackButton(onNavigateBack, compact) + BackButton(onNavigateBack, compact, expanded) if (isFullscreen) { Text( text = title, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.titleMedium, + style = if (expanded) MaterialTheme.typography.titleLarge else MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f), ) } else { @@ -66,20 +67,23 @@ internal fun PlayerTopBar( isPipAvailable, chaptersAvailable, compact, + expanded, ) } } @Composable -private fun BackButton(onNavigateBack: () -> Unit, compact: Boolean) { +private fun BackButton(onNavigateBack: () -> Unit, compact: Boolean, expanded: Boolean) { OverlayIconButton( onClick = onNavigateBack, compact = compact, + expanded = expanded, modifier = Modifier.padding(if (compact) 4.dp else 8.dp), ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.player_back), + modifier = Modifier.size(if (expanded) 32.dp else 24.dp), tint = Color.White, ) } @@ -96,42 +100,47 @@ private fun TopActions( isPipAvailable: Boolean, chaptersAvailable: Boolean, compact: Boolean, + expanded: Boolean, ) { Row( modifier = Modifier.padding(if (compact) 4.dp else 8.dp), verticalAlignment = Alignment.CenterVertically, ) { if (isFullscreen) { - OverlayIconButton(onCycleResizeMode, compact = compact) { + OverlayIconButton(onCycleResizeMode, compact = compact, expanded = expanded) { Icon( imageVector = resizeMode.icon(), contentDescription = stringResource(R.string.player_resize_mode), + modifier = Modifier.size(if (expanded) 32.dp else 24.dp), tint = Color.White, ) } } if (isPipAvailable) { - OverlayIconButton(onEnterPip, compact = compact) { + OverlayIconButton(onEnterPip, compact = compact, expanded = expanded) { Icon( painter = painterResource(R.drawable.ic_pip), contentDescription = stringResource(R.string.player_pip), + modifier = Modifier.size(if (expanded) 32.dp else 24.dp), tint = Color.White, ) } } if (chaptersAvailable) { - OverlayIconButton(onOpenChapters, compact = compact) { + OverlayIconButton(onOpenChapters, compact = compact, expanded = expanded) { Icon( imageVector = Icons.AutoMirrored.Filled.List, contentDescription = stringResource(R.string.player_chapters), + modifier = Modifier.size(if (expanded) 32.dp else 24.dp), tint = Color.White, ) } } - OverlayIconButton(onOpenOptions, compact = compact) { + OverlayIconButton(onOpenOptions, compact = compact, expanded = expanded) { Icon( imageVector = Icons.Filled.Settings, contentDescription = stringResource(R.string.player_playback_options), + modifier = Modifier.size(if (expanded) 32.dp else 24.dp), tint = Color.White, ) } @@ -143,11 +152,18 @@ private fun OverlayIconButton( onClick: () -> Unit, modifier: Modifier = Modifier, compact: Boolean = false, + expanded: Boolean = false, content: @Composable () -> Unit, ) { IconButton( onClick = onClick, - modifier = modifier.padding(2.dp).size(if (compact) 36.dp else 40.dp), + modifier = modifier.padding(2.dp).size( + when { + expanded -> 64.dp + compact -> 36.dp + else -> 40.dp + }, + ), content = content, ) } diff --git a/app/src/main/java/dev/typetype/android/feature/player/host/PlayerHost.kt b/app/src/main/java/dev/typetype/android/feature/player/host/PlayerHost.kt index d2189e4b..9281112e 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/host/PlayerHost.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/host/PlayerHost.kt @@ -40,6 +40,8 @@ fun PlayerHost( onOpenChannel: (channelUrl: String) -> Unit, onOpenAccounts: () -> Unit, onClosePlayback: () -> Unit, + modifier: Modifier = Modifier, + reserveNavigationBarInset: Boolean = true, accessibleControlsEnabled: Boolean = false, onTransitionProgressChange: (Float) -> Unit = {}, content: @Composable () -> Unit, @@ -60,9 +62,12 @@ fun PlayerHost( stateSaver = FullscreenOrientationState.Saver, ) { mutableStateOf(FullscreenOrientationState()) } - val navigationBarsBottom = WindowInsets.navigationBars.asPaddingValues() - .calculateBottomPadding() - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val navigationBarsBottom = if (reserveNavigationBarInset) { + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + } else { + 0.dp + } + BoxWithConstraints(modifier = modifier.fillMaxSize()) { val containerHeightPx = constraints.maxHeight.toFloat().coerceAtLeast(1f) val miniHeightPx = with(density) { MINI_PLAYER_HEIGHT.toPx() } val bottomBarPx = with(density) { bottomBarHeightDp.dp.toPx() } From cde2860eb225855916e136cba4c990ff7a9d5a81 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 7 Sep 2026 20:32:10 +0200 Subject: [PATCH 03/11] fix: tune player layout and seeking --- .../feature/player/PlayerContentLayout.kt | 19 +++- .../android/feature/player/PlayerDetails.kt | 35 +++--- .../components/PlayerGestureOverlays.kt | 75 ------------- .../player/components/PlayerTimeBar.kt | 103 +++++++++++++++--- 4 files changed, 123 insertions(+), 109 deletions(-) diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerContentLayout.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerContentLayout.kt index 27ca382d..77042a7a 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlayerContentLayout.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerContentLayout.kt @@ -59,6 +59,7 @@ internal fun PlayerContentLayout( modifier: Modifier = Modifier, viewport: @Composable (Modifier) -> Unit, details: @Composable (Modifier) -> Unit, + recommendations: @Composable (Modifier) -> Unit = {}, ) { val currentViewport by rememberUpdatedState(viewport) val retainedViewport = remember { @@ -113,6 +114,11 @@ internal fun PlayerContentLayout( .playerDetailsTransition(hostTransitionProgress) .then(if (detailsHidden) Modifier.clearAndSetSemantics { } else Modifier), ) + recommendations( + Modifier.fillMaxWidth() + .playerDetailsTransition(hostTransitionProgress) + .then(if (detailsHidden) Modifier.clearAndSetSemantics { } else Modifier), + ) } } PlayerContentLayoutMode.TwoPane -> { @@ -121,11 +127,11 @@ internal fun PlayerContentLayout( .fillMaxSize() .testTag(PLAYER_TWO_PANE_LAYOUT_TAG), ) { - Box( + Column( modifier = Modifier .weight(PLAYER_PANE_WEIGHT) - .fillMaxHeight(), - contentAlignment = Alignment.TopStart, + .fillMaxHeight() + .verticalScroll(rememberScrollState()), ) { retainedViewport( Modifier @@ -140,8 +146,13 @@ internal fun PlayerContentLayout( .aspectRatio(VIDEO_ASPECT_RATIO) .testTag(PLAYER_VIEWPORT_TAG), ) + details( + Modifier.fillMaxWidth() + .playerDetailsTransition(hostTransitionProgress) + .then(if (detailsHidden) Modifier.clearAndSetSemantics { } else Modifier), + ) } - details( + recommendations( Modifier .weight(DETAILS_PANE_WEIGHT) .fillMaxHeight() diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerDetails.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerDetails.kt index 184796b6..a039357b 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlayerDetails.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerDetails.kt @@ -2,7 +2,6 @@ package dev.typetype.android.feature.player import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme @@ -38,12 +37,10 @@ internal fun PlayerDetails( onAction: (PlayerAction) -> Unit, onShowComments: () -> Unit, onShowDownloads: () -> Unit, - onPlayVideo: (String) -> Unit, onOpenChannel: (String) -> Unit, onToggleSubscription: () -> Unit, modifier: Modifier = Modifier, ) { - val videoMenuScope = rememberVideoMenuScope(onOpenChannel = onOpenChannel) val branding = rememberVideoBranding( sourceUrl = videoUrl, title = stream.title, @@ -92,16 +89,26 @@ internal fun PlayerDetails( onSubscribeClick = onToggleSubscription, ) HorizontalDivider(color = MaterialTheme.colorScheme.outline) - if (!userSettings.hideRelatedVideos) { - RelatedStreamsSection( - videos = stream.relatedStreams, - onPlayVideo = onPlayVideo, - menuScope = videoMenuScope, - onOpenChannel = onOpenChannel, - autoplayEnabled = userSettings.autoplay, - onAutoplayChange = { onAction(PlayerAction.OnSetAutoplay(it)) }, - modifier = Modifier.fillMaxWidth(), - ) - } } } + +@Composable +internal fun PlayerRecommendations( + stream: Stream, + userSettings: UserSettings, + onPlayVideo: (String) -> Unit, + onOpenChannel: (String) -> Unit, + onAction: (PlayerAction) -> Unit, + modifier: Modifier = Modifier, +) { + if (userSettings.hideRelatedVideos) return + RelatedStreamsSection( + videos = stream.relatedStreams, + onPlayVideo = onPlayVideo, + menuScope = rememberVideoMenuScope(onOpenChannel = onOpenChannel), + onOpenChannel = onOpenChannel, + autoplayEnabled = userSettings.autoplay, + onAutoplayChange = { onAction(PlayerAction.OnSetAutoplay(it)) }, + modifier = modifier.padding(16.dp), + ) +} diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureOverlays.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureOverlays.kt index c0670290..b675c9db 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureOverlays.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureOverlays.kt @@ -5,15 +5,11 @@ import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize 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.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -134,38 +130,6 @@ private fun SeekTapWave( } } -@Composable -internal fun SeekDragOverlay(state: PlayerGestureState, durationMs: Long) { - if (!state.seekDragOverlayActive.value) return - val target = state.seekDragTargetMs.longValue - val delta = target - state.seekDragStartMs.longValue - val sign = if (delta >= 0) "+" else "-" - val deltaSec = abs(delta) / 1000 - val targetText = formatTimeMs(target) - val durationText = if (durationMs > 0) " / ${formatTimeMs(durationMs)}" else "" - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column( - modifier = Modifier - .width(288.dp) - .clip(RoundedCornerShape(20.dp)) - .background(Color.Black.copy(alpha = 0.72f)) - .padding(horizontal = 18.dp, vertical = 14.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = "${sign}${deltaSec}s ($targetText$durationText)", - color = Color.White, - style = MaterialTheme.typography.titleMedium, - ) - SeekWave( - fraction = if (durationMs > 0L) target / durationMs.toFloat() else 0f, - modifier = Modifier.fillMaxWidth().height(32.dp), - ) - } - } -} - @Composable internal fun SpeedBoostBadge(visible: Boolean, factor: Float) { if (!visible) return @@ -186,42 +150,3 @@ internal fun SpeedBoostBadge(visible: Boolean, factor: Float) { } } } - -@Composable -private fun SeekWave( - fraction: Float, - modifier: Modifier = Modifier, -) { - val activeColor = MaterialTheme.colorScheme.primary - Canvas(modifier = modifier) { - val centerY = size.height / 2f - val progressX = size.width * fraction.coerceIn(0f, 1f) - val bars = 32 - val spacing = size.width / bars - repeat(bars) { index -> - val x = spacing * (index + 0.5f) - val amplitude = 0.2f + abs(sin(index * 0.82f)) * 0.8f - val halfHeight = size.height * amplitude * 0.42f - drawLine( - color = if (x <= progressX) activeColor else Color.White.copy(alpha = 0.3f), - start = Offset(x, centerY - halfHeight), - end = Offset(x, centerY + halfHeight), - strokeWidth = if (x <= progressX) 3.dp.toPx() else 2.dp.toPx(), - cap = StrokeCap.Round, - ) - } - drawCircle( - color = activeColor, - radius = 4.dp.toPx(), - center = Offset(progressX, centerY), - ) - } -} - -private fun formatTimeMs(ms: Long): String { - val totalSec = ms / 1000 - val h = totalSec / 3600 - val m = (totalSec % 3600) / 60 - val s = totalSec % 60 - return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s) -} diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerTimeBar.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerTimeBar.kt index 29a2b29a..931ec824 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerTimeBar.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerTimeBar.kt @@ -6,11 +6,13 @@ import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.width @@ -54,6 +56,8 @@ private val THUMB_WIDTH = 14.dp private val THUMB_HEIGHT = 14.dp private val COMPACT_THUMB_WIDTH = 10.dp private val COMPACT_THUMB_HEIGHT = 10.dp +private val SCRUBBING_TRACK_HEIGHT = 6.dp +private val SCRUBBING_THUMB_SIZE = 22.dp @OptIn(markerClass = [UnstableApi::class]) @Composable @@ -62,12 +66,18 @@ fun PlayerTimeBar( modifier: Modifier = Modifier, segments: List = emptyList(), compact: Boolean = false, + expanded: Boolean = false, + previewPositionMs: Long? = null, + onScrubbingChange: (Boolean) -> Unit = {}, ) { val progressState = rememberProgressStateWithTickInterval(player, TICK_INTERVAL_MS) var scrubPositionMs by remember { mutableStateOf(null) } val durationMs = progressState.durationMs.coerceAtLeast(0L) - val displayedPosMs = scrubPositionMs ?: progressState.currentPositionMs.coerceIn(0L, durationMs) + val displayedPosMs = scrubPositionMs + ?: previewPositionMs?.takeIf { durationMs > 0L } + ?: progressState.currentPositionMs.coerceIn(0L, durationMs) + val emphasized = scrubPositionMs != null || previewPositionMs != null val positionLabel = formatPlayerTime(displayedPosMs) val durationLabel = formatPlayerTime(durationMs) @@ -78,12 +88,12 @@ fun PlayerTimeBar( ) { Text( text = positionLabel, - style = MaterialTheme.typography.labelSmall.copy(fontSize = 12.sp), + style = MaterialTheme.typography.labelSmall.copy(fontSize = if (expanded) 16.sp else 12.sp), color = Color.White, modifier = if (compact) { Modifier.widthIn(min = COMPACT_TIME_LABEL_MIN_WIDTH) } else { - Modifier.width(TIME_LABEL_WIDTH) + Modifier.width(if (expanded) 68.dp else TIME_LABEL_WIDTH) }, textAlign = TextAlign.End, ) @@ -92,12 +102,21 @@ fun PlayerTimeBar( durationMs = durationMs, segments = segments, compact = compact, - onScrub = { scrubPositionMs = it }, + expanded = expanded, + emphasized = emphasized, + onScrub = { + onScrubbingChange(true) + scrubPositionMs = it + }, onScrubFinished = { targetMs -> player.seekTo(targetMs) + onScrubbingChange(false) + scrubPositionMs = null + }, + onScrubCancelled = { + onScrubbingChange(false) scrubPositionMs = null }, - onScrubCancelled = { scrubPositionMs = null }, accessibilityLabel = stringResource(R.string.player_timeline), accessibilityStateDescription = stringResource( R.string.player_timeline_position, @@ -107,16 +126,16 @@ fun PlayerTimeBar( modifier = Modifier .weight(1f) .padding(horizontal = if (compact) 2.dp else 4.dp) - .height(if (compact) COMPACT_TIMELINE_HEIGHT else TIMELINE_HEIGHT), + .height(if (expanded) 56.dp else if (compact) COMPACT_TIMELINE_HEIGHT else TIMELINE_HEIGHT), ) Text( text = durationLabel, - style = MaterialTheme.typography.labelSmall.copy(fontSize = 12.sp), + style = MaterialTheme.typography.labelSmall.copy(fontSize = if (expanded) 16.sp else 12.sp), color = Color.White.copy(alpha = 0.7f), modifier = if (compact) { Modifier.widthIn(min = COMPACT_TIME_LABEL_MIN_WIDTH) } else { - Modifier.width(TIME_LABEL_WIDTH) + Modifier.width(if (expanded) 68.dp else TIME_LABEL_WIDTH) }, ) } @@ -128,6 +147,9 @@ internal fun TimelineTrack( durationMs: Long, segments: List, compact: Boolean, + emphasized: Boolean = false, + expanded: Boolean = false, + interactive: Boolean = true, onScrub: (Long) -> Unit, onScrubFinished: (Long) -> Unit, onScrubCancelled: () -> Unit, @@ -136,9 +158,9 @@ internal fun TimelineTrack( modifier: Modifier = Modifier, ) { val activeColor = MaterialTheme.colorScheme.primary - val inactiveColor = Color.White.copy(alpha = 0.3f) - Box( - modifier = modifier + val inactiveColor = if (expanded) Color.White.copy(alpha = 0.3f) else Color.Black.copy(alpha = 0.38f) + val timelineModifier = if (interactive) { + modifier .semantics { contentDescription = accessibilityLabel stateDescription = accessibilityStateDescription @@ -174,12 +196,29 @@ internal fun TimelineTrack( onDragEnd = { onScrubFinished(lastTargetMs) }, onDragCancel = onScrubCancelled, ) - }, + } + } else { + modifier + } + Box( + modifier = timelineModifier, ) { Canvas(modifier = Modifier.fillMaxSize()) { - val trackHeight = if (compact) COMPACT_TRACK_HEIGHT.toPx() else TRACK_HEIGHT.toPx() - val thumbWidth = if (compact) COMPACT_THUMB_WIDTH.toPx() else THUMB_WIDTH.toPx() - val thumbHeight = if (compact) COMPACT_THUMB_HEIGHT.toPx() else THUMB_HEIGHT.toPx() + val targetTrackHeight = when { + expanded -> if (emphasized) 10.dp else 6.dp + emphasized -> SCRUBBING_TRACK_HEIGHT + compact -> COMPACT_TRACK_HEIGHT + else -> TRACK_HEIGHT + } + val targetThumbSize = when { + expanded -> if (emphasized) 32.dp else 24.dp + emphasized -> SCRUBBING_THUMB_SIZE + compact -> COMPACT_THUMB_WIDTH + else -> THUMB_WIDTH + } + val trackHeight = targetTrackHeight.toPx() + val thumbWidth = targetThumbSize.toPx() + val thumbHeight = targetThumbSize.toPx() val trackTop = (size.height - trackHeight) / 2f val trackRadius = trackHeight / 2f val progress = if (durationMs > 0) positionMs.toFloat() / durationMs.toFloat() else 0f @@ -209,7 +248,7 @@ internal fun TimelineTrack( ) } drawRoundRect( - color = activeColor, + color = Color.White, topLeft = Offset( x = playerTimeBarThumbStartX( progressX = progressX, @@ -234,6 +273,38 @@ internal fun playerTimeBarThumbStartX( return (progressX - thumbWidth / 2f).coerceIn(0f, maximumStartX) } +@Composable +internal fun PlayerSeekScrubOverlay( + player: Player, + positionMs: Long, + segments: List, + isFullscreen: Boolean, + modifier: Modifier = Modifier, +) { + BoxWithConstraints(modifier = modifier) { + val expanded = maxWidth >= 600.dp + TimelineTrack( + positionMs = positionMs, + durationMs = player.duration.coerceAtLeast(0L), + segments = segments, + compact = false, + emphasized = true, + expanded = expanded, + interactive = false, + onScrub = {}, + onScrubFinished = {}, + onScrubCancelled = {}, + accessibilityLabel = "", + accessibilityStateDescription = "", + modifier = Modifier + .align(Alignment.Center) + .fillMaxWidth() + .height(if (expanded) 72.dp else if (isFullscreen) 52.dp else 40.dp) + .padding(horizontal = 4.dp), + ) + } +} + private fun Float.toPositionMs(width: Float, durationMs: Long): Long { if (durationMs <= 0 || width <= 0f) return 0L return ((this / width).coerceIn(0f, 1f) * durationMs).toLong() From dd8f1b2cad7e6a82001e7c4770774c353bebb6a8 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 7 Sep 2026 20:32:25 +0200 Subject: [PATCH 04/11] feat: adapt app shell for wide screens --- app/src/main/AndroidManifest.xml | 3 +- .../typetype/android/AdaptiveSettingsHost.kt | 113 ++++++++++++++++++ .../java/dev/typetype/android/AppNavHost.kt | 7 +- .../dev/typetype/android/AppNavigation.kt | 48 ++++---- .../java/dev/typetype/android/AppShell.kt | 68 +++++++---- .../java/dev/typetype/android/MainActivity.kt | 9 ++ .../typetype/android/SettingsNavigation.kt | 5 + .../ui/components/TypeTypeAuthBackdrop.kt | 90 ++++++++++++++ .../android/feature/settings/SettingsRoute.kt | 2 + .../feature/settings/SettingsScreen.kt | 17 ++- .../feature/setup/login/LoginScreen.kt | 39 +++--- .../feature/setup/welcome/WelcomeScreen.kt | 6 +- 12 files changed, 339 insertions(+), 68 deletions(-) create mode 100644 app/src/main/java/dev/typetype/android/AdaptiveSettingsHost.kt create mode 100644 app/src/main/java/dev/typetype/android/core/ui/components/TypeTypeAuthBackdrop.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 830826c0..0927a54b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -49,7 +49,8 @@ android:exported="true" android:launchMode="singleTask" android:supportsPictureInPicture="true" - android:theme="@style/Theme.TypeType.Splash"> + android:theme="@style/Theme.TypeType.Splash" + android:windowSoftInputMode="adjustResize"> diff --git a/app/src/main/java/dev/typetype/android/AdaptiveSettingsHost.kt b/app/src/main/java/dev/typetype/android/AdaptiveSettingsHost.kt new file mode 100644 index 00000000..6f0d9c68 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/AdaptiveSettingsHost.kt @@ -0,0 +1,113 @@ +package dev.typetype.android + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.navigation.NavDestination +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.NavHostController +import androidx.navigation.compose.currentBackStackEntryAsState +import dev.typetype.android.core.ui.navigation.AboutRoute +import dev.typetype.android.core.ui.navigation.AccountsRoute +import dev.typetype.android.core.ui.navigation.AppearanceRoute +import dev.typetype.android.core.ui.navigation.BlockedSettingsRoute +import dev.typetype.android.core.ui.navigation.ContentSettingsRoute +import dev.typetype.android.core.ui.navigation.DiagnosticsRoute +import dev.typetype.android.core.ui.navigation.ImportDataRoute +import dev.typetype.android.core.ui.navigation.LicensesRoute +import dev.typetype.android.core.ui.navigation.PlayerSettingsRoute +import dev.typetype.android.core.ui.navigation.PrivacySettingsRoute +import dev.typetype.android.core.ui.navigation.ProfileSettingsRoute +import dev.typetype.android.core.ui.navigation.RssFeedsRoute +import dev.typetype.android.core.ui.navigation.SettingsRoute +import dev.typetype.android.core.ui.navigation.StorageSettingsRoute +import dev.typetype.android.feature.settings.SettingsRoute as SettingsMenu + +internal val LocalSettingsTwoPane = staticCompositionLocalOf { false } + +@Composable +internal fun AdaptiveSettingsHost( + navController: NavHostController, + onSignOut: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val entry by navController.currentBackStackEntryAsState() + val selectedTitle = entry?.destination?.settingsTitle() + BoxWithConstraints(modifier.fillMaxSize()) { + val twoPane = maxWidth >= 840.dp && maxHeight >= 480.dp && selectedTitle != null + CompositionLocalProvider(LocalSettingsTwoPane provides twoPane) { + Row(Modifier.fillMaxSize()) { + if (twoPane) { + Box(Modifier.width(300.dp)) { + SettingsMenu( + selectedTitleRes = selectedTitle, + onNavigateBack = { + if (entry?.destination?.hasRoute() == true) { + navController.popBackStack() + } else if (navController.popBackStack(false)) { + navController.popBackStack() + } else { + navController.popBackStack() + } + }, + onOpenAccounts = { navController.selectSettings(AccountsRoute) }, + onOpenProfile = { navController.selectSettings(ProfileSettingsRoute) }, + onOpenImport = { navController.selectSettings(ImportDataRoute) }, + onOpenRssFeeds = { navController.selectSettings(RssFeedsRoute) }, + onOpenAppearance = { navController.selectSettings(AppearanceRoute) }, + onOpenContent = { navController.selectSettings(ContentSettingsRoute) }, + onOpenPlayer = { navController.selectSettings(PlayerSettingsRoute) }, + onOpenStorage = { navController.selectSettings(StorageSettingsRoute) }, + onOpenPrivacy = { navController.selectSettings(PrivacySettingsRoute) }, + onOpenDiagnostics = { navController.selectSettings(DiagnosticsRoute) }, + onOpenBlocked = { navController.selectSettings(BlockedSettingsRoute) }, + onOpenAbout = { navController.selectSettings(AboutRoute) }, + onSignOut = onSignOut, + ) + } + VerticalDivider() + } + Box(Modifier.weight(1f).fillMaxSize()) { content() } + } + } + } +} + +internal fun NavHostController.selectSettings(route: Any) { + val current = currentDestination ?: return + if (current.hasRoute(route::class)) return + navigate(route) { + popUpTo(current.id) { + inclusive = !current.hasRoute() + saveState = true + } + launchSingleTop = true + restoreState = true + } +} + +private fun NavDestination.settingsTitle(): Int? = when { + hasRoute() || hasRoute() -> R.string.settings_appearance_title + hasRoute() -> R.string.accounts_title + hasRoute() -> R.string.settings_profile_title + hasRoute() -> R.string.settings_import_title + hasRoute() -> R.string.rss_settings_title + hasRoute() -> R.string.settings_content_title + hasRoute() -> R.string.settings_player_title + hasRoute() -> R.string.settings_storage_title + hasRoute() -> R.string.settings_privacy_title + hasRoute() -> R.string.diagnostics_title + hasRoute() -> R.string.settings_blocked_title + hasRoute() || hasRoute() -> R.string.settings_about_title + else -> null +} diff --git a/app/src/main/java/dev/typetype/android/AppNavHost.kt b/app/src/main/java/dev/typetype/android/AppNavHost.kt index b8cfdcba..b43f93d3 100644 --- a/app/src/main/java/dev/typetype/android/AppNavHost.kt +++ b/app/src/main/java/dev/typetype/android/AppNavHost.kt @@ -152,10 +152,14 @@ fun AppNavHost(startRoute: Any, mainViewModel: MainViewModel) { onOpenChannel = onOpenChannel, onClosePlayback = mainViewModel::closePlayback, ) { innerModifier -> + AdaptiveSettingsHost( + navController = navController, + onSignOut = mainViewModel::signOut, + modifier = innerModifier, + ) { NavHost( navController = navController, startDestination = startRoute, - modifier = innerModifier, enterTransition = { slideIntoContainer( AnimatedContentTransitionScope.SlideDirection.Left, @@ -311,6 +315,7 @@ fun AppNavHost(startRoute: Any, mainViewModel: MainViewModel) { onOpenChannel = onOpenChannel, ) } + } } } } diff --git a/app/src/main/java/dev/typetype/android/AppNavigation.kt b/app/src/main/java/dev/typetype/android/AppNavigation.kt index a6244a30..e6a8b9e2 100644 --- a/app/src/main/java/dev/typetype/android/AppNavigation.kt +++ b/app/src/main/java/dev/typetype/android/AppNavigation.kt @@ -1,5 +1,9 @@ package dev.typetype.android +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.navigationBars + import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -142,25 +146,30 @@ internal fun AppTopBar( @Composable internal fun AppBottomBar( - currentDestination: NavDestination?, - fallbackTabRouteQualifiedName: String?, - onTabClick: (Any) -> Unit, + selectedTabRouteQualifiedName: String?, + onTabClick: (TopLevelTab) -> Unit, tabs: List = topLevelTabs, modifier: Modifier = Modifier, + expanded: Boolean = false, ) { androidx.compose.foundation.layout.Column(modifier = modifier) { HorizontalDivider(color = MaterialTheme.colorScheme.outline) NavigationBar( - modifier = Modifier.testTag(APP_BOTTOM_NAVIGATION_TAG), + modifier = Modifier.testTag(APP_BOTTOM_NAVIGATION_TAG) + .then(if (expanded) Modifier.height( + 96.dp + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding(), + ) else Modifier), containerColor = MaterialTheme.colorScheme.surface, ) { - tabs.forEach { tab -> - val selected = tab.isSelected(currentDestination, fallbackTabRouteQualifiedName, tabs) + tabs.forEach { tab: TopLevelTab -> + val selected = tab.route::class.qualifiedName == selectedTabRouteQualifiedName NavigationBarItem( selected = selected, - onClick = { if (!currentDestination.matchesRoute(tab.route)) onTabClick(tab.route) }, - icon = { Icon(painterResource(tab.iconRes), contentDescription = null) }, - label = { Text(stringResource(tab.labelRes)) }, + onClick = { onTabClick(tab) }, + icon = { Icon(painterResource(tab.iconRes), contentDescription = null, + modifier = Modifier.size(if (expanded) 32.dp else 24.dp)) }, + label = { Text(stringResource(tab.labelRes), style = if (expanded) + MaterialTheme.typography.titleSmall else MaterialTheme.typography.labelMedium) }, ) } } @@ -169,9 +178,8 @@ internal fun AppBottomBar( @Composable internal fun AppNavigationRail( - currentDestination: NavDestination?, - fallbackTabRouteQualifiedName: String?, - onTabClick: (Any) -> Unit, + selectedTabRouteQualifiedName: String?, + onTabClick: (TopLevelTab) -> Unit, tabs: List = topLevelTabs, modifier: Modifier = Modifier, ) { @@ -183,11 +191,11 @@ internal fun AppNavigationRail( containerColor = MaterialTheme.colorScheme.surface, ) { Spacer(Modifier.weight(1f)) - tabs.forEach { tab -> - val selected = tab.isSelected(currentDestination, fallbackTabRouteQualifiedName, tabs) + tabs.forEach { tab: TopLevelTab -> + val selected = tab.route::class.qualifiedName == selectedTabRouteQualifiedName NavigationRailItem( selected = selected, - onClick = { if (!currentDestination.matchesRoute(tab.route)) onTabClick(tab.route) }, + onClick = { onTabClick(tab) }, icon = { Icon(painterResource(tab.iconRes), contentDescription = null) }, label = { Text(stringResource(tab.labelRes)) }, ) @@ -220,16 +228,6 @@ private fun ProfileAvatarButton( } } -private fun TopLevelTab.isSelected( - destination: NavDestination?, - fallbackRouteName: String?, - tabs: List, -): Boolean { - val direct = destination.matchesRoute(route) - val anyDirect = tabs.any { destination.matchesRoute(it.route) } - return direct || (!anyDirect && route::class.qualifiedName == fallbackRouteName) -} - internal fun NavDestination?.matchesRoute(route: Any): Boolean { val destination = this ?: return false return when (route) { diff --git a/app/src/main/java/dev/typetype/android/AppShell.kt b/app/src/main/java/dev/typetype/android/AppShell.kt index d78af414..40731a8f 100644 --- a/app/src/main/java/dev/typetype/android/AppShell.kt +++ b/app/src/main/java/dev/typetype/android/AppShell.kt @@ -1,7 +1,14 @@ package dev.typetype.android +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity + import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.background +import androidx.compose.material3.MaterialTheme import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -90,8 +97,10 @@ fun AppShell( currentDestination?.hasRoute() == true || currentDestination?.hasRoute() == true ) - var activeTabRoute by rememberSaveable { mutableStateOf(null) } + var selectedTabRoute by rememberSaveable { mutableStateOf(null) } var isPlayerFullscreen by remember { mutableStateOf(false) } + var bottomNavigationHeight by remember { mutableStateOf(0.dp) } + val density = LocalDensity.current var playerTransitionProgress by remember { mutableFloatStateOf(0f) } val playerHostState by playerHostController.state.collectAsStateWithLifecycle() val appChromeVisible = isAppChromeVisible(playerHostState.target, isPlayerFullscreen) @@ -103,9 +112,13 @@ fun AppShell( ) LaunchedEffect(currentDestination) { topLevelTabs.firstOrNull { currentDestination.matchesRoute(it.route) }?.let { - activeTabRoute = it.route::class.qualifiedName + selectedTabRoute = it.route::class.qualifiedName } } + val selectedTabRouteQualifiedName = selectedTabRoute + ?: topLevelTabs.firstOrNull { currentDestination.matchesRoute(it.route) } + ?.route + ?.let { it::class.qualifiedName } val mediaController = rememberMediaController().value val snackbarHostState = remember { SnackbarHostState() } @@ -113,18 +126,10 @@ fun AppShell( LocalMediaController provides mediaController, LocalAppSnackbarHost provides snackbarHostState, ) { - BoxWithConstraints(modifier = modifier.fillMaxSize()) { - val usesNavigationRail = + BoxWithConstraints(modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { + val tabletLayout = minOf(maxWidth, maxHeight) >= WIDE_NAVIGATION_THRESHOLD Row(modifier = Modifier.fillMaxSize()) { - if (usesNavigationRail && showsNavigation && appChromeVisible) { - AppNavigationRail( - currentDestination = currentDestination, - fallbackTabRouteQualifiedName = activeTabRoute, - onTabClick = navController::navigateTopLevel, - tabs = navigationTabs, - ) - } Box(modifier = Modifier.weight(1f)) { Scaffold( contentWindowInsets = if (isPlayerFullscreen || isShorts) { @@ -133,7 +138,7 @@ fun AppShell( WindowInsets.systemBars }, topBar = { - if (isTopLevel && !isShorts && !usesNavigationRail) { + if (isTopLevel && !isShorts && !tabletLayout) { AppTopBar( onOpenSearch = onOpenSearch, onOpenNotifications = onOpenNotifications, @@ -159,13 +164,20 @@ fun AppShell( } }, bottomBar = { - if (!usesNavigationRail && showsNavigation) { + if (showsNavigation && !isPlayerFullscreen) { AppBottomBar( - currentDestination = currentDestination, - fallbackTabRouteQualifiedName = activeTabRoute, - onTabClick = navController::navigateTopLevel, + expanded = tabletLayout, + selectedTabRouteQualifiedName = selectedTabRouteQualifiedName, + onTabClick = { tab: TopLevelTab -> + if (tabletLayout && playerHostState.target == PlayerHostTarget.Expanded) { + playerHostController.minimize() + } + navController.navigateTopLevel(tab.route, selectedTabRouteQualifiedName) + selectedTabRoute = tab.route::class.qualifiedName + }, tabs = navigationTabs, - modifier = Modifier.playerChrome(phoneChromeAlpha), + modifier = (if (tabletLayout) Modifier else Modifier.playerChrome(phoneChromeAlpha)) + .onSizeChanged { bottomNavigationHeight = with(density) { it.height.toDp() } }, ) } }, @@ -175,6 +187,7 @@ fun AppShell( Modifier .fillMaxSize() .padding(padding) + .consumeWindowInsets(padding) .padding( bottom = if (playerHostState.target == PlayerHostTarget.Mini) { 64.dp @@ -186,8 +199,14 @@ fun AppShell( } PlayerHost( controller = playerHostController, + reserveNavigationBarInset = !tabletLayout || !showsNavigation || isPlayerFullscreen, + modifier = Modifier.padding( + bottom = if (tabletLayout && showsNavigation && !isPlayerFullscreen) bottomNavigationHeight else 0.dp, + ).consumeWindowInsets(PaddingValues( + bottom = if (tabletLayout && showsNavigation && !isPlayerFullscreen) bottomNavigationHeight else 0.dp, + )), bottomBarHeightDp = if ( - !usesNavigationRail && showsNavigation + !tabletLayout && showsNavigation && !isPlayerFullscreen ) { NAV_BAR_HEIGHT_DP } else { @@ -234,7 +253,16 @@ internal fun playerPhoneChromeAlpha( else -> 0f } -private fun NavHostController.navigateTopLevel(route: Any) { +internal fun NavHostController.navigateTopLevel( + route: Any, + activeTabRouteQualifiedName: String?, +) { + val currentDestination = currentDestination + if (currentDestination.matchesRoute(route)) return + + val isSameTab = activeTabRouteQualifiedName == route::class.qualifiedName + if (isSameTab && popBackStack(route = route, inclusive = false, saveState = true)) return + if (currentDestination?.hasRoute() == true) { popBackStack() } diff --git a/app/src/main/java/dev/typetype/android/MainActivity.kt b/app/src/main/java/dev/typetype/android/MainActivity.kt index 0f60404f..82093334 100644 --- a/app/src/main/java/dev/typetype/android/MainActivity.kt +++ b/app/src/main/java/dev/typetype/android/MainActivity.kt @@ -15,6 +15,10 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.compose.runtime.getValue +import androidx.compose.runtime.SideEffect +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.graphics.luminance +import androidx.core.view.WindowCompat import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -88,6 +92,11 @@ class MainActivity : ComponentActivity(), PictureInPictureActionStateOwner { setContent { val preferences by viewModel.preferences.collectAsStateWithLifecycle() TypeTypeTheme(preferences = preferences) { + val lightBackground = MaterialTheme.colorScheme.background.luminance() > 0.5f + SideEffect { + WindowCompat.getInsetsController(window, window.decorView) + .isAppearanceLightStatusBars = lightBackground + } val state by viewModel.state.collectAsStateWithLifecycle() val startRoute = state.startRoute val pendingCrashReport = state.pendingCrashReport diff --git a/app/src/main/java/dev/typetype/android/SettingsNavigation.kt b/app/src/main/java/dev/typetype/android/SettingsNavigation.kt index ffe525fa..231d9292 100644 --- a/app/src/main/java/dev/typetype/android/SettingsNavigation.kt +++ b/app/src/main/java/dev/typetype/android/SettingsNavigation.kt @@ -19,6 +19,7 @@ import dev.typetype.android.core.ui.navigation.SettingsRoute import dev.typetype.android.core.ui.navigation.StorageSettingsRoute import dev.typetype.android.feature.settings.SettingsRoute as SettingsRouteScreen import dev.typetype.android.feature.settings.about.LicensesScreen +import dev.typetype.android.feature.settings.appearance.AppearanceRoute as AppearanceScreen import dev.typetype.android.feature.settings.imports.PortabilityRoute as ImportDataRouteScreen import dev.typetype.android.feature.settings.rss.RssFeedsRoute as RssFeedsRouteScreen @@ -27,6 +28,9 @@ internal fun NavGraphBuilder.settingsDestinations( onSignOut: () -> Unit, ) { composable { + if (LocalSettingsTwoPane.current) { + AppearanceScreen(onNavigateBack = { navController.popBackStack() }) + } else { SettingsRouteScreen( onNavigateBack = { navController.popBackStack() }, onOpenAccounts = { navController.navigate(AccountsRoute) }, @@ -43,6 +47,7 @@ internal fun NavGraphBuilder.settingsDestinations( onOpenAbout = { navController.navigate(AboutRoute) }, onSignOut = onSignOut, ) + } } composable { LicensesScreen( diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/TypeTypeAuthBackdrop.kt b/app/src/main/java/dev/typetype/android/core/ui/components/TypeTypeAuthBackdrop.kt new file mode 100644 index 00000000..03e15f23 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/core/ui/components/TypeTypeAuthBackdrop.kt @@ -0,0 +1,90 @@ +package dev.typetype.android.core.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import dev.typetype.android.R + +@Composable +fun TypeTypeAuthBackdrop(content: @Composable () -> Unit) { + val background = androidx.compose.material3.MaterialTheme.colorScheme.background + val dark = background.luminance() < 0.5f + Box(Modifier.fillMaxSize().background(background)) { + Box( + Modifier + .fillMaxSize() + .graphicsLayer { + rotationZ = -12f + scaleX = 1.18f + scaleY = 1.18f + } + .alpha(if (dark) 0.16f else 0.09f), + ) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.SpaceEvenly, + ) { + repeat(7) { + Row( + modifier = Modifier.fillMaxWidth().weight(1f), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + repeat(12) { + Image( + painter = painterResource(R.drawable.ic_typetype_brand), + contentDescription = null, + modifier = Modifier.size(132.dp), + ) + } + } + } + } + } + Box( + Modifier.fillMaxSize().background( + Brush.radialGradient( + colors = if (dark) { + listOf(Color.Transparent, Color.Black.copy(alpha = 0.46f)) + } else { + listOf(Color.Transparent, Color(0xFF64748B).copy(alpha = 0.34f)) + }, + ), + ), + ) + Box( + Modifier + .size(300.dp) + .offset(x = (-52).dp, y = 36.dp) + .alpha(if (dark) 0.12f else 0.22f) + .background( + Brush.radialGradient(listOf(Color(0xFF7DD3FC), Color.Transparent)), + ), + ) + Box( + Modifier + .size(340.dp) + .offset(x = 36.dp, y = 440.dp) + .alpha(if (dark) 0.10f else 0.18f) + .background( + Brush.radialGradient(listOf(Color(0xFF22D3EE), Color.Transparent)), + ), + ) + content() + } +} diff --git a/app/src/main/java/dev/typetype/android/feature/settings/SettingsRoute.kt b/app/src/main/java/dev/typetype/android/feature/settings/SettingsRoute.kt index 06ed4b00..a7ddcd5a 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/SettingsRoute.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/SettingsRoute.kt @@ -21,10 +21,12 @@ fun SettingsRoute( onOpenBlocked: () -> Unit, onOpenAbout: () -> Unit, onSignOut: () -> Unit, + selectedTitleRes: Int? = null, viewModel: SettingsViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() SettingsScreen( + selectedTitleRes = selectedTitleRes, onNavigateBack = onNavigateBack, onOpenAccounts = onOpenAccounts, onOpenProfile = onOpenProfile, diff --git a/app/src/main/java/dev/typetype/android/feature/settings/SettingsScreen.kt b/app/src/main/java/dev/typetype/android/feature/settings/SettingsScreen.kt index 45312b4d..3d7e1d76 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/SettingsScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/SettingsScreen.kt @@ -1,6 +1,9 @@ package dev.typetype.android.feature.settings import androidx.compose.foundation.clickable +import androidx.compose.foundation.background +import androidx.compose.foundation.selection.selectable +import androidx.compose.ui.semantics.Role import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -63,6 +66,7 @@ fun SettingsScreen( onOpenBlocked: () -> Unit = {}, onOpenAbout: () -> Unit = {}, onSignOut: () -> Unit, + selectedTitleRes: Int? = null, ) { val entries = buildList { add(SettingsEntry(R.string.accounts_title, R.string.accounts_subtitle, Icons.Filled.ManageAccounts, onOpenAccounts)) @@ -102,7 +106,7 @@ fun SettingsScreen( modifier = Modifier.weight(1f), ) { items(entries, contentType = { "settings-entry" }) { entry -> - SettingsCategoryRow(entry = entry) + SettingsCategoryRow(entry = entry, selected = selectedTitleRes?.let { it == entry.titleRes }) HorizontalDivider( modifier = Modifier.padding(start = 64.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.48f), @@ -145,11 +149,18 @@ private fun SettingsTopBar(onNavigateBack: () -> Unit) { } @Composable -private fun SettingsCategoryRow(entry: SettingsEntry) { +private fun SettingsCategoryRow(entry: SettingsEntry, selected: Boolean?) { Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = entry.onClick) + .background( + if (selected == true) MaterialTheme.colorScheme.secondaryContainer + else MaterialTheme.colorScheme.background, + ) + .then( + if (selected == null) Modifier.clickable(onClick = entry.onClick) + else Modifier.selectable(selected = selected, role = Role.Tab, onClick = entry.onClick), + ) .padding(horizontal = 20.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { diff --git a/app/src/main/java/dev/typetype/android/feature/setup/login/LoginScreen.kt b/app/src/main/java/dev/typetype/android/feature/setup/login/LoginScreen.kt index 59bc39b7..daebaa1b 100644 --- a/app/src/main/java/dev/typetype/android/feature/setup/login/LoginScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/setup/login/LoginScreen.kt @@ -4,14 +4,15 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -22,13 +23,13 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType @@ -40,7 +41,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.typetype.android.R import dev.typetype.android.core.ui.components.SectionHeader -import dev.typetype.android.core.ui.components.TypeTypeCard +import dev.typetype.android.core.ui.components.TypeTypeAuthBackdrop import dev.typetype.android.core.ui.components.TypeTypePrimaryButton import dev.typetype.android.core.ui.components.TypeTypeSecondaryButton import dev.typetype.android.core.ui.components.TypeTypeTextField @@ -85,16 +86,8 @@ fun LoginScreen( onAction: (LoginAction) -> Unit, ) { val scrollState = rememberScrollState() - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - ) { - Box( - modifier = Modifier - .fillMaxSize() - .windowInsetsPadding(WindowInsets.systemBars) - .imePadding(), - ) { + TypeTypeAuthBackdrop { + Box(modifier = Modifier.fillMaxSize()) { IconButton( onClick = { onAction(LoginAction.OnBackClick) }, modifier = Modifier.padding(8.dp), @@ -107,12 +100,24 @@ fun LoginScreen( } Column( modifier = Modifier - .fillMaxSize() + .fillMaxHeight() .padding(horizontal = 24.dp) + .widthIn(max = 480.dp) + .fillMaxWidth() + .align(Alignment.Center) + .imePadding() .padding(top = 72.dp, bottom = 24.dp) .verticalScroll(scrollState), verticalArrangement = Arrangement.Top, ) { + Image( + modifier = Modifier + .size(76.dp) + .align(Alignment.CenterHorizontally), + painter = painterResource(R.drawable.ic_typetype_brand), + contentDescription = stringResource(R.string.about_app_name), + ) + Spacer(Modifier.height(20.dp)) SectionHeader(text = stringResource(R.string.setup_section)) Spacer(Modifier.height(8.dp)) Text( @@ -170,7 +175,7 @@ fun LoginScreen( } Spacer(Modifier.height(16.dp)) } - if (state.localLoginEnabled) TypeTypeCard { + if (state.localLoginEnabled) { TypeTypeTextField( value = state.identifier, onValueChange = { onAction(LoginAction.OnIdentifierChange(it)) }, diff --git a/app/src/main/java/dev/typetype/android/feature/setup/welcome/WelcomeScreen.kt b/app/src/main/java/dev/typetype/android/feature/setup/welcome/WelcomeScreen.kt index beba92a9..c81dd381 100644 --- a/app/src/main/java/dev/typetype/android/feature/setup/welcome/WelcomeScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/setup/welcome/WelcomeScreen.kt @@ -7,6 +7,9 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -60,7 +63,8 @@ fun WelcomeScreen(onAction: (WelcomeAction) -> Unit) { .padding(horizontal = 24.dp, vertical = 48.dp), ) { Column( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxHeight().widthIn(max = 480.dp) + .fillMaxWidth().align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally, ) { Spacer(Modifier.weight(1f)) From 658d5220989257ecb5c846197d737ab4cfc05494 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 7 Sep 2026 20:32:38 +0200 Subject: [PATCH 05/11] feat: adapt content layouts for tablets --- .../core/ui/components/RelatedVideoCard.kt | 2 +- .../android/core/ui/components/VideoCard.kt | 2 +- .../android/core/ui/util/WindowHelper.kt | 2 +- .../android/feature/channel/ChannelHeader.kt | 40 ++++-- .../library/LibraryPlaylistContextTab.kt | 22 ++-- .../library/playlist/PlaylistScreen.kt | 27 ++-- .../android/feature/menu/VideoMenuHost.kt | 23 ++-- .../android/feature/podcast/PodcastHeader.kt | 20 ++- .../android/feature/podcast/PodcastScreen.kt | 109 ++++++++++------- .../publicplaylist/PublicPlaylistHeader.kt | 20 ++- .../publicplaylist/PublicPlaylistScreen.kt | 115 +++++++++++------- 11 files changed, 234 insertions(+), 148 deletions(-) diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt b/app/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt index 884c7ecf..35362233 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt @@ -139,7 +139,7 @@ fun RelatedVideoCard( Text( text = stringResource(R.string.video_views_short, formatRelatedViews(video.viewCount)), style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.outlineVariant, + color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, ) } diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt b/app/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt index ac670f48..b506092b 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt @@ -158,7 +158,7 @@ fun VideoCard( Text( text = it, style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.outlineVariant, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } diff --git a/app/src/main/java/dev/typetype/android/core/ui/util/WindowHelper.kt b/app/src/main/java/dev/typetype/android/core/ui/util/WindowHelper.kt index 07d6e076..834afdd4 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/util/WindowHelper.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/util/WindowHelper.kt @@ -20,7 +20,7 @@ object WindowHelper { } } - WindowCompat.setDecorFitsSystemWindows(window, !isFullscreen) + WindowCompat.setDecorFitsSystemWindows(window, false) val noLimits = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS if (isFullscreen) { diff --git a/app/src/main/java/dev/typetype/android/feature/channel/ChannelHeader.kt b/app/src/main/java/dev/typetype/android/feature/channel/ChannelHeader.kt index 78b4e007..ea8acbb5 100644 --- a/app/src/main/java/dev/typetype/android/feature/channel/ChannelHeader.kt +++ b/app/src/main/java/dev/typetype/android/feature/channel/ChannelHeader.kt @@ -3,6 +3,7 @@ package dev.typetype.android.feature.channel import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -51,6 +52,27 @@ internal fun ChannelHeader( subscribeInFlight: Boolean, onToggleSubscribe: () -> Unit, onNavigateBack: () -> Unit, +) { + BoxWithConstraints(Modifier.fillMaxWidth()) { + ChannelHeaderContent( + channel = channel, + isSubscribed = isSubscribed, + subscribeInFlight = subscribeInFlight, + onToggleSubscribe = onToggleSubscribe, + onNavigateBack = onNavigateBack, + expanded = maxWidth >= 600.dp, + ) + } +} + +@Composable +private fun ChannelHeaderContent( + channel: Channel, + isSubscribed: Boolean, + subscribeInFlight: Boolean, + onToggleSubscribe: () -> Unit, + onNavigateBack: () -> Unit, + expanded: Boolean, ) { val serverBaseUrl = LocalServerBaseUrl.current Column(modifier = Modifier.fillMaxWidth()) { @@ -60,7 +82,10 @@ internal fun ChannelHeader( Box( modifier = Modifier .fillMaxWidth() - .aspectRatio(BannerAspectRatio) + .then( + if (expanded) Modifier.height(220.dp) + else Modifier.aspectRatio(BannerAspectRatio), + ) .background(MaterialTheme.colorScheme.surfaceVariant), ) { AsyncImage( @@ -82,12 +107,12 @@ internal fun ChannelHeader( contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier - .size(AvatarSize) + .size(if (expanded) 88.dp else AvatarSize) .clip(CircleShape) .background(MaterialTheme.colorScheme.surfaceVariant), ) Column(modifier = Modifier.weight(1f)) { - ChannelName(channel) + ChannelName(channel, expanded) Spacer(Modifier.height(2.dp)) Text( text = formatSubscribers(channel.subscriberCount), @@ -99,6 +124,7 @@ internal fun ChannelHeader( isSubscribed = isSubscribed, enabled = !subscribeInFlight, onClick = onToggleSubscribe, + expanded = expanded, ) } } @@ -120,11 +146,11 @@ private fun ChannelBackButton(onNavigateBack: () -> Unit) { } @Composable -private fun ChannelName(channel: Channel) { +private fun ChannelName(channel: Channel, expanded: Boolean) { Row(verticalAlignment = Alignment.CenterVertically) { Text( text = channel.name, - style = MaterialTheme.typography.titleMedium.copy( + style = (if (expanded) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.titleMedium).copy( fontWeight = FontWeight.SemiBold, letterSpacing = (-0.2).sp, ), @@ -146,7 +172,7 @@ private fun ChannelName(channel: Channel) { } @Composable -private fun SubscribeButton(isSubscribed: Boolean, enabled: Boolean, onClick: () -> Unit) { +private fun SubscribeButton(isSubscribed: Boolean, enabled: Boolean, onClick: () -> Unit, expanded: Boolean) { Button( onClick = onClick, enabled = enabled, @@ -160,7 +186,7 @@ private fun SubscribeButton(isSubscribed: Boolean, enabled: Boolean, onClick: () contentColor = MaterialTheme.colorScheme.onSurface, ), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 0.dp), - modifier = Modifier.height(34.dp), + modifier = Modifier.height(if (expanded) 48.dp else 34.dp), ) { Text( text = stringResource( diff --git a/app/src/main/java/dev/typetype/android/feature/library/LibraryPlaylistContextTab.kt b/app/src/main/java/dev/typetype/android/feature/library/LibraryPlaylistContextTab.kt index cb7f09be..da1d0158 100644 --- a/app/src/main/java/dev/typetype/android/feature/library/LibraryPlaylistContextTab.kt +++ b/app/src/main/java/dev/typetype/android/feature/library/LibraryPlaylistContextTab.kt @@ -1,6 +1,5 @@ package dev.typetype.android.feature.library -import android.content.Intent import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.grid.GridCells @@ -14,7 +13,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -24,7 +22,7 @@ import dev.typetype.android.core.ui.components.LocalAppSnackbarHost import dev.typetype.android.core.ui.components.PlaylistVideoActionsSheet import dev.typetype.android.core.ui.components.PlaylistVideoCard import dev.typetype.android.core.ui.share.LocalServerBaseUrl -import dev.typetype.android.core.ui.share.buildShareUrl +import dev.typetype.android.core.ui.share.ShareChooserSheet import dev.typetype.android.domain.library.PlaylistVideo import dev.typetype.android.feature.library.components.rememberVideoMetas import dev.typetype.android.feature.menu.VideoMenuEvent @@ -68,10 +66,9 @@ fun PlaylistContextTab( EmptyTab(emptyMessageFor(filter, emptyDefault)) return } - val context = LocalContext.current - val shareChooserTitle = stringResource(R.string.video_menu_share_chooser) val serverBaseUrl = LocalServerBaseUrl.current var pendingMenu by remember { mutableStateOf(null) } + var shareVideoUrl by remember { mutableStateOf(null) } val urlsMissingInfo = items .filter { it.channelAvatarUrl.isBlank() || it.channelName.isBlank() } .map { it.url } @@ -103,15 +100,16 @@ fun PlaylistContextTab( onAddToQueue = { onAddToQueue(video) }, onRemoveFromList = { onRemove(video) }, onToggleWatched = { onToggleWatched(video, video.url in watchedUrls) }, - onShare = { - val intent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, buildShareUrl(serverBaseUrl, video.url)) - } - context.startActivity(Intent.createChooser(intent, shareChooserTitle)) - }, + onShare = { shareVideoUrl = video.url }, onBlockVideo = { onBlockVideo(video) }, onDismiss = { pendingMenu = null }, ) } + shareVideoUrl?.let { videoUrl -> + ShareChooserSheet( + serverBaseUrl = serverBaseUrl, + videoUrl = videoUrl, + onDismiss = { shareVideoUrl = null }, + ) + } } diff --git a/app/src/main/java/dev/typetype/android/feature/library/playlist/PlaylistScreen.kt b/app/src/main/java/dev/typetype/android/feature/library/playlist/PlaylistScreen.kt index 0f5f4610..98243226 100644 --- a/app/src/main/java/dev/typetype/android/feature/library/playlist/PlaylistScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/library/playlist/PlaylistScreen.kt @@ -1,6 +1,5 @@ package dev.typetype.android.feature.library.playlist -import android.content.Intent import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -21,7 +20,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -41,6 +39,7 @@ import dev.typetype.android.feature.menu.VideoMenuHandlerViewModel import dev.typetype.android.feature.menu.blockVideoUrl import dev.typetype.android.feature.menu.removeFromPlaylist import dev.typetype.android.feature.menu.toggleWatchedUrl +import dev.typetype.android.core.ui.share.ShareChooserSheet @Composable fun PlaylistRoute( @@ -194,10 +193,9 @@ internal fun PlaylistScreen( } } } - val context = LocalContext.current - val shareChooserTitle = stringResource(R.string.video_menu_share_chooser) val serverBaseUrl = dev.typetype.android.core.ui.share.LocalServerBaseUrl.current var pendingMenu by remember { mutableStateOf(null) } + var shareVideoUrl by remember { mutableStateOf(null) } val urlsMissingInfo = visible .filter { it.channelAvatarUrl.isBlank() || it.channelName.isBlank() } .map { it.url } @@ -252,22 +250,17 @@ internal fun PlaylistScreen( isCurrentlyWatched = video.url in watchedUrls, ) }, - onShare = { - val intent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra( - Intent.EXTRA_TEXT, - dev.typetype.android.core.ui.share.buildShareUrl( - serverBaseUrl, - video.url, - ), - ) - } - context.startActivity(Intent.createChooser(intent, shareChooserTitle)) - }, + onShare = { shareVideoUrl = video.url }, onBlockVideo = { menuVm.blockVideoUrl(video.url) }, onDismiss = { pendingMenu = null }, ) } + shareVideoUrl?.let { videoUrl -> + ShareChooserSheet( + serverBaseUrl = serverBaseUrl, + videoUrl = videoUrl, + onDismiss = { shareVideoUrl = null }, + ) + } } } diff --git a/app/src/main/java/dev/typetype/android/feature/menu/VideoMenuHost.kt b/app/src/main/java/dev/typetype/android/feature/menu/VideoMenuHost.kt index 9c1d5b0b..3a6390e2 100644 --- a/app/src/main/java/dev/typetype/android/feature/menu/VideoMenuHost.kt +++ b/app/src/main/java/dev/typetype/android/feature/menu/VideoMenuHost.kt @@ -1,6 +1,5 @@ package dev.typetype.android.feature.menu -import android.content.Intent import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable @@ -10,7 +9,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -19,7 +17,7 @@ import dev.typetype.android.core.ui.components.LocalAppSnackbarHost import dev.typetype.android.core.ui.components.VideoMenuAction import dev.typetype.android.core.ui.components.VideoMenuItemState import dev.typetype.android.core.ui.share.LocalServerBaseUrl -import dev.typetype.android.core.ui.share.buildShareUrl +import dev.typetype.android.core.ui.share.ShareChooserSheet import dev.typetype.android.domain.feed.Video import dev.typetype.android.domain.navigation.canonicalVideoIdentity import dev.typetype.android.domain.actions.titleMatchesBlockedKeyword @@ -52,7 +50,6 @@ class VideoMenuScope internal constructor( fun rememberVideoMenuScope( onOpenChannel: (channelUrl: String) -> Unit, ): VideoMenuScope { - val context = LocalContext.current val viewModel: VideoMenuHandlerViewModel = hiltViewModel() val playlists by viewModel.playlists.collectAsStateWithLifecycle() val favorites by viewModel.favoriteUrls.collectAsStateWithLifecycle() @@ -61,11 +58,11 @@ fun rememberVideoMenuScope( val blockedVideos by viewModel.blockedVideoUrls.collectAsStateWithLifecycle() val blockedChannels by viewModel.blockedChannelUrls.collectAsStateWithLifecycle() val blockedKeywords by viewModel.blockedKeywords.collectAsStateWithLifecycle() - val shareChooserTitle = stringResource(R.string.video_menu_share_chooser) val serverBaseUrl = LocalServerBaseUrl.current var pickerVideo by remember { mutableStateOf(null) } var downloadVideo by remember { mutableStateOf(null) } + var shareVideoUrl by remember { mutableStateOf(null) } val effectiveHost: SnackbarHostState = LocalAppSnackbarHost.current ?: remember { SnackbarHostState() } @@ -80,6 +77,14 @@ fun rememberVideoMenuScope( } } + shareVideoUrl?.let { videoUrl -> + ShareChooserSheet( + serverBaseUrl = serverBaseUrl, + videoUrl = videoUrl, + onDismiss = { shareVideoUrl = null }, + ) + } + pickerVideo?.let { video -> PlaylistPickerSheet( playlists = playlists, @@ -125,13 +130,7 @@ fun rememberVideoMenuScope( canonicalVideoIdentity(video.url) in watched, ) VideoMenuAction.Download -> downloadVideo = video - VideoMenuAction.Share -> { - val intent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, buildShareUrl(serverBaseUrl, video.url)) - } - context.startActivity(Intent.createChooser(intent, shareChooserTitle)) - } + VideoMenuAction.Share -> shareVideoUrl = video.url VideoMenuAction.OpenChannel -> onOpenChannel(video.uploaderUrl) VideoMenuAction.BlockVideo -> viewModel.blockVideo(video) VideoMenuAction.BlockChannel -> viewModel.blockChannel(video) diff --git a/app/src/main/java/dev/typetype/android/feature/podcast/PodcastHeader.kt b/app/src/main/java/dev/typetype/android/feature/podcast/PodcastHeader.kt index 223c52dd..ea0e4d7d 100644 --- a/app/src/main/java/dev/typetype/android/feature/podcast/PodcastHeader.kt +++ b/app/src/main/java/dev/typetype/android/feature/podcast/PodcastHeader.kt @@ -38,21 +38,23 @@ fun PodcastHeader( hasMore: Boolean, onPlay: () -> Unit, onShuffle: () -> Unit, + stacked: Boolean = false, ) { val serverBaseUrl = LocalServerBaseUrl.current Column( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { - Row(modifier = Modifier.fillMaxWidth()) { + val thumbnail: @Composable (Modifier) -> Unit = { imageModifier -> AsyncImage( model = buildImageUrl(serverBaseUrl, podcast.thumbnailUrl), contentDescription = null, contentScale = ContentScale.Crop, - modifier = Modifier.width(132.dp).aspectRatio(1f).clip(RoundedCornerShape(14.dp)), + modifier = imageModifier.aspectRatio(1f).clip(RoundedCornerShape(14.dp)), ) - Spacer(Modifier.width(14.dp)) - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(5.dp)) { + } + val identity: @Composable (Modifier) -> Unit = { identityModifier -> + Column(modifier = identityModifier, verticalArrangement = Arrangement.spacedBy(5.dp)) { Text( text = podcast.title, style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), @@ -78,6 +80,16 @@ fun PodcastHeader( ) } } + if (stacked) { + thumbnail(Modifier.fillMaxWidth()) + identity(Modifier.fillMaxWidth()) + } else { + Row(Modifier.fillMaxWidth()) { + thumbnail(Modifier.width(132.dp)) + Spacer(Modifier.width(14.dp)) + identity(Modifier.weight(1f)) + } + } Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp), diff --git a/app/src/main/java/dev/typetype/android/feature/podcast/PodcastScreen.kt b/app/src/main/java/dev/typetype/android/feature/podcast/PodcastScreen.kt index 9a4d790a..5474a02b 100644 --- a/app/src/main/java/dev/typetype/android/feature/podcast/PodcastScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/podcast/PodcastScreen.kt @@ -2,6 +2,13 @@ package dev.typetype.android.feature.podcast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -128,50 +135,66 @@ internal fun PodcastContentGrid( ) { val podcast = requireNotNull(state.podcast) val episodes = state.episodes.filterNot(menuScope::isHidden) - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 300.dp), - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - item(span = { GridItemSpan(maxLineSpan) }, key = "podcast-header") { - PodcastHeader( - podcast = podcast, - loadedCount = episodes.size, - hasMore = state.nextPage != null, - onPlay = { onPlayQueue(podcast.title, episodes, false) }, - onShuffle = { onPlayQueue(podcast.title, episodes, true) }, - ) - } - items(episodes, key = { it.url }, contentType = { "podcast-episode" }) { episode -> - VideoCard( - video = episode, - onClick = { onPlayVideo(episode.url) }, - onChannelClick = episode.uploaderUrl.takeIf(String::isNotBlank)?.let { url -> - { onOpenChannel(url) } - }, - onMenuAction = { action -> menuScope.onAction(action, episode) }, - menuItemState = menuScope.stateFor(episode), - modifier = Modifier.padding(horizontal = 8.dp), - ) - } - if (episodes.isEmpty()) { - item(span = { GridItemSpan(maxLineSpan) }) { - Text( - text = stringResource(R.string.podcast_empty), - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth().padding(24.dp), - ) + val gridState = rememberLazyGridState() + val header: @Composable (Boolean) -> Unit = { stacked -> + PodcastHeader( + stacked = stacked, + podcast = podcast, + loadedCount = episodes.size, + hasMore = state.nextPage != null, + onPlay = { onPlayQueue(podcast.title, episodes, false) }, + onShuffle = { onPlayQueue(podcast.title, episodes, true) }, + ) + } + BoxWithConstraints(Modifier.fillMaxSize()) { + val twoPane = maxWidth >= 840.dp && maxHeight >= 480.dp + Row(Modifier.fillMaxSize()) { + if (twoPane) { + Column(Modifier.width(320.dp).fillMaxHeight().verticalScroll(rememberScrollState())) { + header(true) + } + } + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 300.dp), + state = gridState, + modifier = Modifier.weight(1f).fillMaxHeight(), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + if (!twoPane) { + item(span = { GridItemSpan(maxLineSpan) }, key = "podcast-header") { header(false) } + } + items(episodes, key = { it.url }, contentType = { "podcast-episode" }) { episode -> + VideoCard( + video = episode, + onClick = { onPlayVideo(episode.url) }, + onChannelClick = episode.uploaderUrl.takeIf(String::isNotBlank)?.let { url -> + { onOpenChannel(url) } + }, + onMenuAction = { action -> menuScope.onAction(action, episode) }, + menuItemState = menuScope.stateFor(episode), + modifier = Modifier.padding(horizontal = 8.dp), + ) + } + if (episodes.isEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { + Text( + text = stringResource(R.string.podcast_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth().padding(24.dp), + ) + } + } + item(span = { GridItemSpan(maxLineSpan) }, key = "podcast-pagination") { + PodcastPagination( + cursor = state.nextPage, + loading = state.isLoadingMore, + failed = state.loadMoreError, + onLoadMore = onLoadMore, + ) + } } - } - item(span = { GridItemSpan(maxLineSpan) }, key = "podcast-pagination") { - PodcastPagination( - cursor = state.nextPage, - loading = state.isLoadingMore, - failed = state.loadMoreError, - onLoadMore = onLoadMore, - ) } } } diff --git a/app/src/main/java/dev/typetype/android/feature/publicplaylist/PublicPlaylistHeader.kt b/app/src/main/java/dev/typetype/android/feature/publicplaylist/PublicPlaylistHeader.kt index bf70ac71..1f8c86a8 100644 --- a/app/src/main/java/dev/typetype/android/feature/publicplaylist/PublicPlaylistHeader.kt +++ b/app/src/main/java/dev/typetype/android/feature/publicplaylist/PublicPlaylistHeader.kt @@ -46,21 +46,23 @@ fun PublicPlaylistHeader( onPlay: () -> Unit, onShuffle: () -> Unit, onToggleSaved: () -> Unit, + stacked: Boolean = false, ) { val serverBaseUrl = LocalServerBaseUrl.current Column( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { - Row(modifier = Modifier.fillMaxWidth()) { + val thumbnail: @Composable (Modifier) -> Unit = { imageModifier -> AsyncImage( model = buildImageUrl(serverBaseUrl, playlist.thumbnailUrl), contentDescription = null, contentScale = ContentScale.Crop, - modifier = Modifier.width(150.dp).aspectRatio(16f / 9f).clip(RoundedCornerShape(12.dp)), + modifier = imageModifier.aspectRatio(16f / 9f).clip(RoundedCornerShape(12.dp)), ) - Spacer(Modifier.width(14.dp)) - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + } + val identity: @Composable (Modifier) -> Unit = { identityModifier -> + Column(modifier = identityModifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( text = playlist.title, style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), @@ -85,6 +87,16 @@ fun PublicPlaylistHeader( ) } } + if (stacked) { + thumbnail(Modifier.fillMaxWidth()) + identity(Modifier.fillMaxWidth()) + } else { + Row(Modifier.fillMaxWidth()) { + thumbnail(Modifier.width(150.dp)) + Spacer(Modifier.width(14.dp)) + identity(Modifier.weight(1f)) + } + } Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp), diff --git a/app/src/main/java/dev/typetype/android/feature/publicplaylist/PublicPlaylistScreen.kt b/app/src/main/java/dev/typetype/android/feature/publicplaylist/PublicPlaylistScreen.kt index 2953ea8c..1f2abbe6 100644 --- a/app/src/main/java/dev/typetype/android/feature/publicplaylist/PublicPlaylistScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/publicplaylist/PublicPlaylistScreen.kt @@ -2,6 +2,13 @@ package dev.typetype.android.feature.publicplaylist import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -132,53 +139,69 @@ internal fun PublicPlaylistContentGrid( ) { val playlist = requireNotNull(state.playlist) val videos = state.videos.filterNot(menuScope::isHidden) - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 300.dp), - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - item(span = { GridItemSpan(maxLineSpan) }, key = "playlist-header") { - PublicPlaylistHeader( - playlist = playlist, - loadedCount = videos.size, - hasMore = state.nextPage != null, - canSave = state.canSave, - isSaved = state.savedItemId != null, - saveInFlight = state.saveInFlight, - saveErrorMessage = state.saveErrorMessage, - onPlay = { onPlayQueue(playlist.title, videos, false) }, - onShuffle = { onPlayQueue(playlist.title, videos, true) }, - onToggleSaved = onToggleSaved, - ) - } - items(videos, key = { it.url }, contentType = { "public-playlist-video" }) { video -> - VideoCard( - video = video, - onClick = { onPlayVideo(video.url) }, - onChannelClick = { onOpenChannel(video.uploaderUrl) }, - onMenuAction = { action -> menuScope.onAction(action, video) }, - menuItemState = menuScope.stateFor(video), - modifier = Modifier.padding(horizontal = 8.dp), - ) - } - if (videos.isEmpty()) { - item(span = { GridItemSpan(maxLineSpan) }) { - Text( - text = stringResource(R.string.public_playlist_empty), - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth().padding(24.dp), - ) + val gridState = rememberLazyGridState() + val header: @Composable (Boolean) -> Unit = { stacked -> + PublicPlaylistHeader( + stacked = stacked, + playlist = playlist, + loadedCount = videos.size, + hasMore = state.nextPage != null, + canSave = state.canSave, + isSaved = state.savedItemId != null, + saveInFlight = state.saveInFlight, + saveErrorMessage = state.saveErrorMessage, + onPlay = { onPlayQueue(playlist.title, videos, false) }, + onShuffle = { onPlayQueue(playlist.title, videos, true) }, + onToggleSaved = onToggleSaved, + ) + } + BoxWithConstraints(Modifier.fillMaxSize()) { + val twoPane = maxWidth >= 840.dp && maxHeight >= 480.dp + Row(Modifier.fillMaxSize()) { + if (twoPane) { + Column(Modifier.width(320.dp).fillMaxHeight().verticalScroll(rememberScrollState())) { + header(true) + } + } + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 300.dp), + state = gridState, + modifier = Modifier.weight(1f).fillMaxHeight(), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + if (!twoPane) { + item(span = { GridItemSpan(maxLineSpan) }, key = "playlist-header") { header(false) } + } + items(videos, key = { it.url }, contentType = { "public-playlist-video" }) { video -> + VideoCard( + video = video, + onClick = { onPlayVideo(video.url) }, + onChannelClick = { onOpenChannel(video.uploaderUrl) }, + onMenuAction = { action -> menuScope.onAction(action, video) }, + menuItemState = menuScope.stateFor(video), + modifier = Modifier.padding(horizontal = 8.dp), + ) + } + if (videos.isEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { + Text( + text = stringResource(R.string.public_playlist_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth().padding(24.dp), + ) + } + } + item(span = { GridItemSpan(maxLineSpan) }, key = "playlist-pagination") { + PublicPlaylistPagination( + cursor = state.nextPage, + loading = state.isLoadingMore, + failed = state.loadMoreError, + onLoadMore = onLoadMore, + ) + } } - } - item(span = { GridItemSpan(maxLineSpan) }, key = "playlist-pagination") { - PublicPlaylistPagination( - cursor = state.nextPage, - loading = state.isLoadingMore, - failed = state.loadMoreError, - onLoadMore = onLoadMore, - ) } } } From 8499b97553e712fe24f954fcf11c61ec5924e341 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 7 Sep 2026 20:32:54 +0200 Subject: [PATCH 06/11] feat: improve sharing and appearance themes --- .../core/ui/share/ShareChooserSheet.kt | 159 ++++++++++++++++++ .../android/core/ui/share/ShareUrls.kt | 45 +++++ .../core/ui/theme/AppearanceColorSchemes.kt | 12 ++ .../typetype/android/core/ui/theme/Theme.kt | 15 +- app/src/main/res/values/strings.xml | 8 + 5 files changed, 231 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/dev/typetype/android/core/ui/share/ShareChooserSheet.kt diff --git a/app/src/main/java/dev/typetype/android/core/ui/share/ShareChooserSheet.kt b/app/src/main/java/dev/typetype/android/core/ui/share/ShareChooserSheet.kt new file mode 100644 index 00000000..9b8f9cc5 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/core/ui/share/ShareChooserSheet.kt @@ -0,0 +1,159 @@ +package dev.typetype.android.core.ui.share + +import android.content.Context +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.platform.LocalContext +import dev.typetype.android.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ShareChooserSheet( + serverBaseUrl: String?, + videoUrl: String, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val choices = buildShareChoices(serverBaseUrl, videoUrl) + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(start = 20.dp, end = 20.dp, bottom = 28.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.video_menu_share_sheet_title), + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), + ) + Text( + text = stringResource(R.string.video_menu_share_sheet_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp), + ) + choices.forEach { choice -> + ShareTargetRow( + choice = choice, + onClick = { + shareUrl(context, choice.url) + onDismiss() + }, + ) + } + } + } +} + +@Composable +private fun ShareTargetRow( + choice: ShareChoice, + onClick: () -> Unit, +) { + val isTypeType = choice.target == ShareTarget.TypeType + val providerName = choice.providerName.orEmpty() + val iconResource = when { + isTypeType -> R.drawable.ic_typetype_brand + providerName == "YouTube" -> R.drawable.ic_service_youtube + providerName == "NicoNico" -> R.drawable.ic_service_niconico + else -> R.drawable.ic_service_bilibili + } + val iconBackground = when { + isTypeType -> MaterialTheme.colorScheme.surfaceVariant + providerName == "YouTube" -> Color(0xFFCC0000) + providerName == "NicoNico" -> Color(0xFFCC6688) + else -> Color(0xFFEA4C89) + } + val title = if (isTypeType) { + stringResource(R.string.video_menu_share_target_typetype) + } else { + stringResource(R.string.video_menu_share_target_source, providerName) + } + val subtitle = if (isTypeType) { + stringResource(R.string.video_menu_share_target_typetype_description) + } else { + stringResource(R.string.video_menu_share_target_source_description, providerName) + } + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.42f)) + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(14.dp)) + .background(iconBackground), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(iconResource), + contentDescription = null, + tint = if (isTypeType) Color.Unspecified else Color.White, + modifier = Modifier.size(28.dp), + ) + } + Spacer(Modifier.width(14.dp)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium), + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + imageVector = Icons.Filled.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +private fun shareUrl(context: Context, url: String) { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, url) + } + context.startActivity( + Intent.createChooser(intent, context.getString(R.string.video_menu_share_chooser)), + ) +} diff --git a/app/src/main/java/dev/typetype/android/core/ui/share/ShareUrls.kt b/app/src/main/java/dev/typetype/android/core/ui/share/ShareUrls.kt index 5aef0f6c..9fbb1c6f 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/share/ShareUrls.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/share/ShareUrls.kt @@ -1,6 +1,7 @@ package dev.typetype.android.core.ui.share import androidx.compose.runtime.compositionLocalOf +import dev.typetype.android.domain.navigation.resolveIncomingVideoUrl import dev.typetype.android.domain.navigation.toPublicWatchParameter import java.net.URI import java.net.URLEncoder @@ -19,6 +20,50 @@ fun buildShareUrl(serverBaseUrl: String?, videoUrl: String): String { return "$origin/watch?v=$encoded" } +fun buildSourceShareUrl(videoUrl: String): String = + resolveIncomingVideoUrl(videoUrl) ?: videoUrl.trim() + +internal enum class ShareTarget { + TypeType, + Source, +} + +internal data class ShareChoice( + val target: ShareTarget, + val url: String, + val providerName: String? = null, +) + +internal fun buildShareChoices( + serverBaseUrl: String?, + videoUrl: String, +): List { + val sourceUrl = buildSourceShareUrl(videoUrl) + val typeTypeUrl = buildShareUrl(serverBaseUrl, videoUrl) + return buildList { + add(ShareChoice(target = ShareTarget.TypeType, url = typeTypeUrl)) + sourceProvider(sourceUrl)?.takeIf { sourceUrl != typeTypeUrl }?.let { provider -> + add( + ShareChoice( + target = ShareTarget.Source, + url = sourceUrl, + providerName = provider, + ), + ) + } + } +} + +private fun sourceProvider(sourceUrl: String): String? { + val provider = sourceUrl.lowercase() + return when { + "youtube.com" in provider || "youtu.be" in provider -> "YouTube" + "nicovideo.jp" in provider || "nico.ms" in provider -> "NicoNico" + "bilibili.com" in provider || "b23.tv" in provider -> "BiliBili" + else -> null + } +} + fun buildImageUrl(serverBaseUrl: String?, imageUrl: String): String { val source = imageUrl.trim().let { value -> if (value.startsWith("httpss://")) "https://${value.removePrefix("httpss://")}" else value diff --git a/app/src/main/java/dev/typetype/android/core/ui/theme/AppearanceColorSchemes.kt b/app/src/main/java/dev/typetype/android/core/ui/theme/AppearanceColorSchemes.kt index 2a4d26a5..c7913edc 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/theme/AppearanceColorSchemes.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/theme/AppearanceColorSchemes.kt @@ -87,6 +87,18 @@ internal fun themedDarkScheme( ) } +internal fun ColorScheme.withAmoledSurfaces(): ColorScheme = copy( + background = Color.Black, + surface = Color.Black, + surfaceContainerLowest = Color.Black, + surfaceContainerLow = Color.Black, + surfaceContainer = Color.Black, + surfaceContainerHigh = Color.Black, + surfaceContainerHighest = Color.Black, + surfaceDim = Color.Black, + surfaceBright = Color.Black, +) + internal fun themedLightScheme( theme: AppearanceTheme, accent: Color, diff --git a/app/src/main/java/dev/typetype/android/core/ui/theme/Theme.kt b/app/src/main/java/dev/typetype/android/core/ui/theme/Theme.kt index 153524c9..a91085c3 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/theme/Theme.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/theme/Theme.kt @@ -43,6 +43,11 @@ fun TypeTypeTheme( (preferences.appearanceTheme == AppearanceTheme.Dynamic || (preferences.appearanceTheme == AppearanceTheme.TypeType && preferences.accentColor == AccentColor.System)) + val dynamicScheme = when { + dynamic && dark -> dynamicDarkColorScheme(context) + dynamic -> dynamicLightColorScheme(context) + else -> null + } val colorScheme = when { preferences.appearancePersonality == AppearancePersonality.Manga -> mangaScheme( @@ -53,14 +58,8 @@ fun TypeTypeTheme( amoled = effectiveAmoled, isDark = dark, ) - dark && effectiveAmoled -> themedDarkScheme( - preferences.appearanceTheme, - accent, - accentSoft, - amoled = effectiveAmoled, - ) - dynamic && dark -> dynamicDarkColorScheme(context) - dynamic -> dynamicLightColorScheme(context) + dynamicScheme != null && effectiveAmoled -> dynamicScheme.withAmoledSurfaces() + dynamicScheme != null -> dynamicScheme dark -> themedDarkScheme( preferences.appearanceTheme, accent, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c05d5139..9ea579f3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -921,6 +921,14 @@ Block video Block channel Share via + Share TypeType link + Share %1$s link + Share this video + Choose which link you want to share. + TypeType + %1$s + Open with this TypeType instance + Open the original %1$s video Video actions Open %1$s channel Open %1$s From cb8b5c1739574195ce806428089877b33c110a9a Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 7 Sep 2026 20:33:13 +0200 Subject: [PATCH 07/11] test: cover player control layouts --- .../player/PlayerContentLayoutComposeTest.kt | 11 ++- .../player/PlayerInteractionRowComposeTest.kt | 71 +++++++++++++++++++ .../components/PlayerControlsLayoutTest.kt | 38 +++++++++- .../components/PlayerSurfaceLifecycleTest.kt | 16 ++++- .../components/PlayerLevelGestureTest.kt | 16 +++++ .../PlayerSurfaceRefreshGateTest.kt | 23 +++--- 6 files changed, 157 insertions(+), 18 deletions(-) create mode 100644 app/src/androidTest/java/dev/typetype/android/feature/player/PlayerInteractionRowComposeTest.kt diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerContentLayoutComposeTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerContentLayoutComposeTest.kt index bb6f7605..bf6d75e4 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerContentLayoutComposeTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerContentLayoutComposeTest.kt @@ -30,14 +30,17 @@ class PlayerContentLayoutComposeTest { val composeRule = createAndroidComposeRule() @Test - fun wideTabletPlacesDetailsBesideThePlayer() { + fun wideTabletKeepsDetailsUnderVideoAndRecommendationsBesideIt() { setLayout(width = 1280.dp, height = 800.dp) val viewport = bounds(VIEWPORT_TAG) val details = bounds(DETAILS_TAG) - assertTrue(viewport.right <= details.left) - assertTrue(details.top == viewport.top) + val recommendations = bounds(RECOMMENDATIONS_TAG) + assertTrue(viewport.bottom <= details.top) + assertEquals(viewport.left, details.left, 1f) + assertTrue(viewport.right <= recommendations.left) + assertEquals(viewport.top, recommendations.top, 1f) assertNodeCount(PLAYER_TWO_PANE_LAYOUT_TAG, 1) assertNodeCount(PLAYER_SINGLE_COLUMN_LAYOUT_TAG, 0) } @@ -143,6 +146,7 @@ class PlayerContentLayoutComposeTest { Box(Modifier.height(600.dp)) } }, + recommendations = { Box(it.testTag(RECOMMENDATIONS_TAG).height(600.dp)) }, ) } } @@ -162,3 +166,4 @@ class PlayerContentLayoutComposeTest { private const val VIEWPORT_TAG = "player_viewport" private const val DETAILS_TAG = "player_details" +private const val RECOMMENDATIONS_TAG = "player_recommendations" diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerInteractionRowComposeTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerInteractionRowComposeTest.kt new file mode 100644 index 00000000..a4ebd3dc --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerInteractionRowComposeTest.kt @@ -0,0 +1,71 @@ +package dev.typetype.android.feature.player + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertHeightIsAtLeast +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import dev.typetype.android.R +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class PlayerInteractionRowComposeTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun tabletShowsLabelsAndLargerTouchTargets() { + var commentClicks = 0 + setActions(720.dp) { commentClicks += 1 } + + val comments = composeRule.activity.getString(R.string.comments_title) + composeRule.onNodeWithText(comments).assertIsDisplayed() + composeRule.onNodeWithContentDescription(comments) + .assertHeightIsAtLeast(72.dp) + .performClick() + assertEquals(1, commentClicks) + composeRule.onNodeWithText( + composeRule.activity.getString(R.string.video_menu_share), + ).assertIsDisplayed() + } + + @Test + fun narrowWindowRetainsCompactActions() { + setActions(400.dp) + + val comments = composeRule.activity.getString(R.string.comments_title) + composeRule.onNodeWithText(comments).assertDoesNotExist() + composeRule.onNodeWithContentDescription(comments) + .assertIsDisplayed() + .assertHeightIsAtLeast(48.dp) + } + + private fun setActions(width: Dp, onComments: () -> Unit = {}) { + composeRule.setContent { + MaterialTheme { + Box(Modifier.requiredWidth(width)) { + PlayerInteractionRow( + isFavorited = false, + isInWatchLater = false, + shareUrl = "https://www.youtube.com/watch?v=8E-cXrEgz2U", + onToggleFavorite = {}, + onToggleWatchLater = {}, + onAddToPlaylist = {}, + onShowComments = onComments, + onDownload = {}, + audioOnlyAvailable = true, + ) + } + } + } + } +} diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlsLayoutTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlsLayoutTest.kt index 5f968535..dc92b42e 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlsLayoutTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlsLayoutTest.kt @@ -5,9 +5,14 @@ import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.test.assertHeightIsAtLeast +import androidx.compose.ui.test.onNodeWithContentDescription +import dev.typetype.android.R import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player import androidx.media3.common.Timeline @@ -15,6 +20,7 @@ import androidx.media3.common.Tracks import androidx.media3.common.VideoSize import androidx.test.ext.junit.runners.AndroidJUnit4 import java.lang.reflect.Proxy +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -27,9 +33,32 @@ class PlayerControlsLayoutTest { @Test fun portraitControlsDoNotOverlapInsideShortVideoViewport() { + setControls(360.dp, 202.dp) + assertControlsDoNotOverlap() + } + + @Test + fun tabletControlsHaveLargerTargetsWithoutOverlapping() { + setControls(720.dp, 405.dp) + assertControlsDoNotOverlap() + composeRule.onNodeWithTag(PLAYER_CENTER_CONTROLS_TAG) + .assertHeightIsAtLeast(96.dp) + composeRule.onNodeWithContentDescription( + composeRule.activity.getString(R.string.player_fullscreen), + ).assertHeightIsAtLeast(64.dp) + composeRule.onNodeWithContentDescription( + composeRule.activity.getString(R.string.player_playback_options), + ).assertHeightIsAtLeast(64.dp) + } + + private fun setControls(width: Dp, height: Dp) { val player = controlsLayoutPlayer() composeRule.setContent { - Box(Modifier.size(width = 360.dp, height = 202.dp)) { + Box( + Modifier + .size(width = width, height = height) + .testTag(PLAYER_CONTROLS_VIEWPORT_TAG), + ) { PlayerControls( player = player, title = "Portrait controls", @@ -40,7 +69,9 @@ class PlayerControlsLayoutTest { ) } } + } + private fun assertControlsDoNotOverlap() { val top = composeRule.onNodeWithTag(PLAYER_TOP_CONTROLS_TAG, useUnmergedTree = true) .fetchSemanticsNode() .boundsInRoot @@ -50,9 +81,14 @@ class PlayerControlsLayoutTest { val bottom = composeRule.onNodeWithTag(PLAYER_BOTTOM_CONTROLS_TAG, useUnmergedTree = true) .fetchSemanticsNode() .boundsInRoot + val viewport = composeRule.onNodeWithTag( + PLAYER_CONTROLS_VIEWPORT_TAG, + useUnmergedTree = true, + ).fetchSemanticsNode().boundsInRoot assertTrue("Top controls overlap center controls", top.bottom <= center.top) assertTrue("Center controls overlap bottom controls", center.bottom <= bottom.top) + assertEquals("Portrait controls stop at the viewport bottom", viewport.bottom, bottom.bottom, 1f) } } diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerSurfaceLifecycleTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerSurfaceLifecycleTest.kt index 3a3294b8..5f611cc4 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerSurfaceLifecycleTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerSurfaceLifecycleTest.kt @@ -4,6 +4,8 @@ import androidx.activity.ComponentActivity import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.lifecycle.Lifecycle import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -12,7 +14,7 @@ class PlayerSurfaceLifecycleTest { val composeRule = createAndroidComposeRule() @Test - fun activeLifecycleDoesNotRecreateSurfaceUntilARealStop() { + fun pausedBackgroundCyclePreservesSurfaceWithoutScreenOff() { var surfaceKey = "" composeRule.setContent { surfaceKey = rememberPlayerSurfaceKey("video") @@ -26,7 +28,17 @@ class PlayerSurfaceLifecycleTest { composeRule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED) composeRule.runOnIdle { - assertEquals("video:1", surfaceKey) + assertEquals("video:0", surfaceKey) } } + + @Test + fun onlyScreenOffRequestsSurfaceRefresh() { + val gate = PlayerSurfaceRefreshGate() + + assertFalse(gate.consumeScreenOff()) + gate.markScreenOff() + assertTrue(gate.consumeScreenOff()) + assertFalse(gate.consumeScreenOff()) + } } diff --git a/app/src/test/java/dev/typetype/android/feature/player/components/PlayerLevelGestureTest.kt b/app/src/test/java/dev/typetype/android/feature/player/components/PlayerLevelGestureTest.kt index e158f05c..e1e6fc4e 100644 --- a/app/src/test/java/dev/typetype/android/feature/player/components/PlayerLevelGestureTest.kt +++ b/app/src/test/java/dev/typetype/android/feature/player/components/PlayerLevelGestureTest.kt @@ -1,6 +1,8 @@ package dev.typetype.android.feature.player.components import org.junit.Assert.assertEquals +import androidx.compose.ui.geometry.Offset +import dev.typetype.android.feature.player.state.DragMode import org.junit.Test class PlayerLevelGestureTest { @@ -19,4 +21,18 @@ class PlayerLevelGestureTest { fun `short drag range cannot divide by zero`() { assertEquals(0f, adjustLevelFraction(0.5f, 1f, 0f), 0.001f) } + + @Test + fun `level drag range follows the smaller viewport side`() { + assertEquals(300f, levelDragRangePx(400f, 500f), 0.001f) + assertEquals(300f, levelDragRangePx(500f, 400f), 0.001f) + } + + @Test + fun `side gestures use PipePipe compatible thirds`() { + assertEquals(DragMode.Brightness, pickDragMode(Offset(0f, -30f), 100f, 300f)) + assertEquals(DragMode.Volume, pickDragMode(Offset(0f, -30f), 250f, 300f)) + assertEquals(DragMode.FullscreenEnter, pickDragMode(Offset(0f, -30f), 150f, 300f)) + } + } diff --git a/app/src/test/java/dev/typetype/android/feature/player/components/PlayerSurfaceRefreshGateTest.kt b/app/src/test/java/dev/typetype/android/feature/player/components/PlayerSurfaceRefreshGateTest.kt index 392fb07a..23d39da7 100644 --- a/app/src/test/java/dev/typetype/android/feature/player/components/PlayerSurfaceRefreshGateTest.kt +++ b/app/src/test/java/dev/typetype/android/feature/player/components/PlayerSurfaceRefreshGateTest.kt @@ -6,31 +6,30 @@ import org.junit.Test class PlayerSurfaceRefreshGateTest { @Test - fun `initial lifecycle events do not recreate the surface`() { + fun `background cycle without screen off does not request refresh`() { val gate = PlayerSurfaceRefreshGate() - assertFalse(gate.refresh()) - assertFalse(gate.refresh()) + assertFalse(gate.consumeScreenOff()) + assertFalse(gate.consumeScreenOff()) } @Test - fun `multiple wake signals recreate the surface once`() { + fun `screen off requests refresh once`() { val gate = PlayerSurfaceRefreshGate() - gate.invalidate() - assertTrue(gate.refresh()) - assertFalse(gate.refresh()) + gate.markScreenOff() + assertTrue(gate.consumeScreenOff()) + assertFalse(gate.consumeScreenOff()) } @Test - fun `each inactive cycle permits one surface recreation`() { + fun `each screen off cycle permits one surface recreation`() { val gate = PlayerSurfaceRefreshGate() repeat(1_000) { - gate.invalidate() - gate.invalidate() - assertTrue(gate.refresh()) - assertFalse(gate.refresh()) + gate.markScreenOff() + assertTrue(gate.consumeScreenOff()) + assertFalse(gate.consumeScreenOff()) } } } From 95900fa97adff3b9cf21f92fc5ed7387fb0bccd8 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 7 Sep 2026 20:33:23 +0200 Subject: [PATCH 08/11] test: cover adaptive navigation layouts --- .../typetype/android/AppShellAdaptiveTest.kt | 171 +++++---------- .../android/AppShellNavigationLayoutTest.kt | 197 ++++++++++++++++++ 2 files changed, 245 insertions(+), 123 deletions(-) create mode 100644 app/src/androidTest/java/dev/typetype/android/AppShellNavigationLayoutTest.kt diff --git a/app/src/androidTest/java/dev/typetype/android/AppShellAdaptiveTest.kt b/app/src/androidTest/java/dev/typetype/android/AppShellAdaptiveTest.kt index 6970ab49..b868b550 100644 --- a/app/src/androidTest/java/dev/typetype/android/AppShellAdaptiveTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/AppShellAdaptiveTest.kt @@ -41,8 +41,10 @@ import androidx.compose.ui.unit.dp import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import androidx.navigation.NavHostController import androidx.test.espresso.Espresso.closeSoftKeyboard import dev.typetype.android.core.ui.navigation.HomeRoute +import dev.typetype.android.core.ui.navigation.ChannelRoute import dev.typetype.android.core.ui.navigation.LibraryRoute import dev.typetype.android.core.ui.navigation.SearchRoute import dev.typetype.android.core.ui.navigation.SubscriptionsRoute @@ -62,76 +64,6 @@ class AppShellAdaptiveTest { @get:Rule val composeRule = createAndroidComposeRule() - @Test - fun wideWindowUsesNavigationRail() { - setShellWidth(700.dp) - - assertNodeCount(APP_NAVIGATION_RAIL_TAG, 1) - assertNodeCount(APP_BOTTOM_NAVIGATION_TAG, 0) - } - - @Test - fun compactWindowUsesBottomNavigation() { - setShellWidth(400.dp) - - assertNodeCount(APP_BOTTOM_NAVIGATION_TAG, 1) - assertNodeCount(APP_NAVIGATION_RAIL_TAG, 0) - } - - @Test - fun landscapePhoneUsesBottomNavigation() { - setShellSize(width = 800.dp, height = 400.dp) - - assertNodeCount(APP_BOTTOM_NAVIGATION_TAG, 1) - assertNodeCount(APP_NAVIGATION_RAIL_TAG, 0) - } - - @Test - fun shortsTabFollowsTheServerVisibilitySetting() { - val showShorts = mutableStateOf(false) - setShellSize(width = 300.dp, height = 500.dp, showShorts = showShorts) - - composeRule.onNodeWithText("Shorts").assertDoesNotExist() - - composeRule.runOnIdle { showShorts.value = true } - - composeRule.onNodeWithText("Shorts").assertIsDisplayed() - } - - @Test - fun compactNavigationRemainsVisibleAtTwoHundredPercentText() { - setShellSize(width = 320.dp, height = 500.dp, fontScale = 2f) - - listOf("Home", "Shorts", "Subscriptions", "Library").forEach { - composeRule.onNodeWithText(it).assertIsDisplayed() - } - } - - @Test - fun rightToLeftLayoutMirrorsTheTopLevelTabs() { - setShellSize( - width = 400.dp, - height = 800.dp, - layoutDirection = LayoutDirection.Rtl, - ) - - val home = composeRule.onNodeWithText("Home").bounds() - val library = composeRule.onNodeWithText("Library").bounds() - assertTrue(home.left > library.left) - } - - @Test - fun directionalKeysMoveFocusAcrossTopLevelTabs() { - setShellSize(width = 400.dp, height = 800.dp, keyboardInput = true) - val home = composeRule.onNodeWithText("Home") - - home.performSemanticsAction(SemanticsActions.RequestFocus) - home.assertIsFocused() - home.performKeyInput { pressKey(Key.DirectionRight) } - - composeRule.onNodeWithText("Shorts").assertIsFocused() - } - @Test fun topLevelNavigationDoesNotRestoreSearchOverItsOpeningTab() { composeRule.setContent { @@ -174,6 +106,52 @@ class AppShellAdaptiveTest { composeRule.onNodeWithText("Search content").assertDoesNotExist() } + @Test + fun selectingActiveTabReturnsToTheOriginalTabPage() { + var navController: NavHostController? = null + composeRule.setContent { + val controller = rememberNavController() + navController = controller + AppShell( + navController = controller, + playerHostController = PlayerHostController(FakePlaybackQueueController()), + onOpenSearch = {}, + onOpenSettings = {}, + onPlayVideo = {}, + onOpenChannel = {}, + onOpenAccounts = {}, + onClosePlayback = {}, + ) { contentModifier -> + NavHost( + navController = controller, + startDestination = HomeRoute, + modifier = contentModifier, + ) { + composable { androidx.compose.material3.Text("Home content") } + composable { + androidx.compose.material3.Text("Subscriptions content") + } + composable { + androidx.compose.material3.Text("Channel content") + } + composable { androidx.compose.material3.Text("Library content") } + } + } + } + + composeRule.onNodeWithText("Subscriptions").performClick() + composeRule.onNodeWithText("Subscriptions content").assertIsDisplayed() + + composeRule.runOnIdle { + navController?.navigateToChannel("https://example.com/channel") + } + composeRule.onNodeWithText("Channel content").assertIsDisplayed() + + composeRule.onNodeWithText("Subscriptions").performClick() + composeRule.onNodeWithText("Subscriptions content").assertIsDisplayed() + composeRule.onNodeWithText("Channel content").assertDoesNotExist() + } + @Test fun closedKeyboardDoesNotTrapSearchNavigationOrBack() { composeRule.setContent { @@ -270,59 +248,6 @@ class AppShellAdaptiveTest { composeRule.onNodeWithTag(SEARCH_FIELD_TAG).assertTextEquals("") } - private fun setShellWidth(width: Dp) { - setShellSize(width = width, height = 800.dp) - } - - private fun setShellSize( - width: Dp, - height: Dp, - showShorts: MutableState = mutableStateOf(true), - fontScale: Float = 1f, - layoutDirection: LayoutDirection = LayoutDirection.Ltr, - keyboardInput: Boolean = false, - ) { - composeRule.setContent { - val systemDensity = LocalDensity.current - val inputModeManager = LocalInputModeManager.current - LaunchedEffect(keyboardInput) { - if (keyboardInput) inputModeManager.requestInputMode(InputMode.Keyboard) - } - CompositionLocalProvider( - LocalDensity provides Density(systemDensity.density, fontScale), - LocalLayoutDirection provides layoutDirection, - ) { - val navController = rememberNavController() - AppShell( - navController = navController, - playerHostController = PlayerHostController(FakePlaybackQueueController()), - onOpenSettings = {}, - onPlayVideo = {}, - onOpenChannel = {}, - onOpenAccounts = {}, - onClosePlayback = {}, - showShorts = showShorts.value, - modifier = Modifier.requiredWidth(width).requiredHeight(height), - ) { contentModifier -> - NavHost( - navController = navController, - startDestination = HomeRoute, - modifier = contentModifier, - ) { - composable { } - } - } - } - } - } - - private fun androidx.compose.ui.test.SemanticsNodeInteraction.bounds(): Rect = - fetchSemanticsNode().boundsInRoot - - private fun assertNodeCount(tag: String, expected: Int) { - val count = composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().size - assertEquals(expected, count) - } } private const val SEARCH_FIELD_TAG = "search_field" diff --git a/app/src/androidTest/java/dev/typetype/android/AppShellNavigationLayoutTest.kt b/app/src/androidTest/java/dev/typetype/android/AppShellNavigationLayoutTest.kt new file mode 100644 index 00000000..ef91c118 --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/AppShellNavigationLayoutTest.kt @@ -0,0 +1,197 @@ +package dev.typetype.android + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalInputModeManager +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.junit4.StateRestorationTester +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.NavHostController +import androidx.test.espresso.Espresso.closeSoftKeyboard +import dev.typetype.android.core.ui.navigation.HomeRoute +import dev.typetype.android.core.ui.navigation.ChannelRoute +import dev.typetype.android.core.ui.navigation.LibraryRoute +import dev.typetype.android.core.ui.navigation.SearchRoute +import dev.typetype.android.core.ui.navigation.SubscriptionsRoute +import dev.typetype.android.domain.playback.PlaybackQueueController +import dev.typetype.android.domain.playback.PlaybackQueueEntry +import dev.typetype.android.domain.playback.PlaybackQueueSnapshot +import dev.typetype.android.domain.playback.PlaybackQueueState +import dev.typetype.android.feature.player.host.PlayerHostController +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.junit.Rule +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class AppShellNavigationLayoutTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun wideWindowUsesBottomNavigation() { + setShellWidth(700.dp) + + assertNodeCount(APP_NAVIGATION_RAIL_TAG, 0) + assertNodeCount(APP_BOTTOM_NAVIGATION_TAG, 1) + } + + @Test + fun compactWindowUsesBottomNavigation() { + setShellWidth(400.dp) + + assertNodeCount(APP_BOTTOM_NAVIGATION_TAG, 1) + assertNodeCount(APP_NAVIGATION_RAIL_TAG, 0) + } + + @Test + fun landscapePhoneUsesBottomNavigation() { + setShellSize(width = 800.dp, height = 400.dp) + + assertNodeCount(APP_BOTTOM_NAVIGATION_TAG, 1) + assertNodeCount(APP_NAVIGATION_RAIL_TAG, 0) + } + + @Test + fun shortsTabFollowsTheServerVisibilitySetting() { + val showShorts = mutableStateOf(false) + setShellSize(width = 300.dp, height = 500.dp, showShorts = showShorts) + + composeRule.onNodeWithText("Shorts").assertDoesNotExist() + + composeRule.runOnIdle { showShorts.value = true } + + composeRule.onNodeWithText("Shorts").assertIsDisplayed() + } + + @Test + fun compactNavigationRemainsVisibleAtTwoHundredPercentText() { + setShellSize(width = 320.dp, height = 500.dp, fontScale = 2f) + + listOf("Home", "Shorts", "Subscriptions", "Library").forEach { + composeRule.onNodeWithText(it).assertIsDisplayed() + } + } + + @Test + fun rightToLeftLayoutMirrorsTheTopLevelTabs() { + setShellSize( + width = 400.dp, + height = 800.dp, + layoutDirection = LayoutDirection.Rtl, + ) + + val home = composeRule.onNodeWithText("Home").bounds() + val library = composeRule.onNodeWithText("Library").bounds() + assertTrue(home.left > library.left) + } + + @Test + fun directionalKeysMoveFocusAcrossTopLevelTabs() { + setShellSize(width = 400.dp, height = 800.dp, keyboardInput = true) + val home = composeRule.onNodeWithText("Home") + + home.performSemanticsAction(SemanticsActions.RequestFocus) + home.assertIsFocused() + home.performKeyInput { pressKey(Key.DirectionRight) } + + composeRule.onNodeWithText("Shorts").assertIsFocused() + } + + private fun setShellWidth(width: Dp) { + setShellSize(width = width, height = 800.dp) + } + + private fun setShellSize( + width: Dp, + height: Dp, + showShorts: MutableState = mutableStateOf(true), + fontScale: Float = 1f, + layoutDirection: LayoutDirection = LayoutDirection.Ltr, + keyboardInput: Boolean = false, + ) { + composeRule.setContent { + val systemDensity = LocalDensity.current + val inputModeManager = LocalInputModeManager.current + LaunchedEffect(keyboardInput) { + if (keyboardInput) inputModeManager.requestInputMode(InputMode.Keyboard) + } + CompositionLocalProvider( + LocalDensity provides Density(systemDensity.density, fontScale), + LocalLayoutDirection provides layoutDirection, + ) { + val navController = rememberNavController() + AppShell( + navController = navController, + playerHostController = PlayerHostController(TabletLayoutQueueController()), + onOpenSettings = {}, + onPlayVideo = {}, + onOpenChannel = {}, + onOpenAccounts = {}, + onClosePlayback = {}, + showShorts = showShorts.value, + modifier = Modifier.requiredWidth(width).requiredHeight(height), + ) { contentModifier -> + NavHost( + navController = navController, + startDestination = HomeRoute, + modifier = contentModifier, + ) { + composable { } + } + } + } + } + } + + private fun androidx.compose.ui.test.SemanticsNodeInteraction.bounds(): Rect = + fetchSemanticsNode().boundsInRoot + + private fun assertNodeCount(tag: String, expected: Int) { + val count = composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().size + assertEquals(expected, count) + } +} + +private class TabletLayoutQueueController : PlaybackQueueController { + override val state: StateFlow = MutableStateFlow(PlaybackQueueState()) + override fun start(title: String, entries: List, shuffle: Boolean) = Unit + override fun restore(snapshot: PlaybackQueueSnapshot) = Unit + override fun clear() = Unit +} From 0a365758eb6596f3da5c496eeb5073307ddb414f Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 7 Sep 2026 20:33:37 +0200 Subject: [PATCH 09/11] test: cover settings and tab navigation --- .../android/SettingsNavigationTest.kt | 56 +++++++++++++++++++ .../android/TopLevelNavigationTest.kt | 52 +++++++++++++++++ .../feature/podcast/PodcastScreenStateTest.kt | 33 ++++++++++- .../PublicPlaylistScreenStateTest.kt | 33 ++++++++++- .../SettingsScreenAccessibilityTest.kt | 1 - 5 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 app/src/androidTest/java/dev/typetype/android/SettingsNavigationTest.kt create mode 100644 app/src/androidTest/java/dev/typetype/android/TopLevelNavigationTest.kt diff --git a/app/src/androidTest/java/dev/typetype/android/SettingsNavigationTest.kt b/app/src/androidTest/java/dev/typetype/android/SettingsNavigationTest.kt new file mode 100644 index 00000000..f7f07a8b --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/SettingsNavigationTest.kt @@ -0,0 +1,56 @@ +package dev.typetype.android + +import androidx.activity.ComponentActivity +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.foundation.clickable +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import dev.typetype.android.core.ui.navigation.AppearanceRoute +import dev.typetype.android.core.ui.navigation.PlayerSettingsRoute +import dev.typetype.android.core.ui.navigation.SettingsRoute +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class SettingsNavigationTest { + @get:Rule + val composeRule = createAndroidComposeRule() + private lateinit var controller: NavHostController + + @Test + fun switchingCategoriesRestoresStateAndDoesNotAccumulateBackEntries() { + composeRule.setContent { + controller = rememberNavController() + NavHost(controller, startDestination = SettingsRoute) { + composable { Text("Settings index") } + composable { + var count by rememberSaveable { mutableIntStateOf(0) } + Text("Appearance $count", Modifier.clickable { count += 1 }) + } + composable { Text("Player settings") } + } + } + + composeRule.runOnIdle { controller.selectSettings(AppearanceRoute) } + composeRule.onNodeWithText("Appearance 0").performClick() + composeRule.runOnIdle { controller.selectSettings(PlayerSettingsRoute) } + composeRule.onNodeWithText("Player settings").assertExists() + composeRule.runOnIdle { controller.selectSettings(AppearanceRoute) } + composeRule.onNodeWithText("Appearance 1").assertExists() + composeRule.runOnIdle { controller.selectSettings(AppearanceRoute) } + composeRule.runOnIdle { assertTrue(controller.popBackStack()) } + composeRule.onNodeWithText("Settings index").assertExists() + composeRule.runOnIdle { assertFalse(controller.popBackStack()) } + } +} diff --git a/app/src/androidTest/java/dev/typetype/android/TopLevelNavigationTest.kt b/app/src/androidTest/java/dev/typetype/android/TopLevelNavigationTest.kt new file mode 100644 index 00000000..27885d58 --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/TopLevelNavigationTest.kt @@ -0,0 +1,52 @@ +package dev.typetype.android + +import androidx.activity.ComponentActivity +import androidx.compose.material3.Text +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import dev.typetype.android.core.ui.navigation.ChannelRoute +import dev.typetype.android.core.ui.navigation.HomeRoute +import dev.typetype.android.core.ui.navigation.LibraryRoute +import dev.typetype.android.core.ui.navigation.SubscriptionsRoute +import org.junit.Rule +import org.junit.Test + +class TopLevelNavigationTest { + @get:Rule + val composeRule = createAndroidComposeRule() + private lateinit var controller: NavHostController + + @Test + fun changingTabsRestoresChannelButReselectingReturnsToSubscriptions() { + composeRule.setContent { + controller = rememberNavController() + NavHost(controller, startDestination = HomeRoute) { + composable { Text("Home") } + composable { Text("Subscriptions") } + composable { Text("Library") } + composable { Text("Channel") } + } + } + composeRule.runOnIdle { + controller.navigateTopLevel(SubscriptionsRoute, HomeRoute::class.qualifiedName) + } + composeRule.runOnIdle { controller.navigate(ChannelRoute("https://www.youtube.com/@channel")) } + composeRule.onNodeWithText("Channel").assertExists() + composeRule.runOnIdle { + controller.navigateTopLevel(LibraryRoute, SubscriptionsRoute::class.qualifiedName) + } + composeRule.onNodeWithText("Library").assertExists() + composeRule.runOnIdle { + controller.navigateTopLevel(SubscriptionsRoute, LibraryRoute::class.qualifiedName) + } + composeRule.onNodeWithText("Channel").assertExists() + composeRule.runOnIdle { + controller.navigateTopLevel(SubscriptionsRoute, SubscriptionsRoute::class.qualifiedName) + } + composeRule.onNodeWithText("Subscriptions").assertExists() + } +} diff --git a/app/src/androidTest/java/dev/typetype/android/feature/podcast/PodcastScreenStateTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/podcast/PodcastScreenStateTest.kt index 7476ab3d..95650e06 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/podcast/PodcastScreenStateTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/podcast/PodcastScreenStateTest.kt @@ -1,6 +1,12 @@ package dev.typetype.android.feature.podcast import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription @@ -11,6 +17,7 @@ import dev.typetype.android.domain.podcast.Podcast import dev.typetype.android.feature.menu.VideoMenuScope import org.junit.Rule import org.junit.Test +import org.junit.Assert.assertTrue class PodcastScreenStateTest { @get:Rule @@ -37,8 +44,32 @@ class PodcastScreenStateTest { @Test fun emptyPodcastHasAnExplicitState() { + showEmptyPodcast(400.dp) + composeRule.onNodeWithText("This podcast has no available episodes.").assertIsDisplayed() + } + + @Test + fun tabletKeepsPodcastIdentityBesideEpisodes() { + showEmptyPodcast(1200.dp) + val title = composeRule.onNodeWithText("Podcast").fetchSemanticsNode().boundsInRoot + val content = composeRule.onNodeWithText("This podcast has no available episodes.") + .fetchSemanticsNode().boundsInRoot + assertTrue(title.right < content.left) + } + + @Test + fun narrowWindowKeepsPodcastIdentityAboveEpisodes() { + showEmptyPodcast(400.dp) + val title = composeRule.onNodeWithText("Podcast").fetchSemanticsNode().boundsInRoot + val content = composeRule.onNodeWithText("This podcast has no available episodes.") + .fetchSemanticsNode().boundsInRoot + assertTrue(title.bottom < content.top) + } + + private fun showEmptyPodcast(width: Dp) { composeRule.setContent { TypeTypeTheme { + Box(Modifier.requiredWidth(width).requiredHeight(650.dp)) { PodcastContentGrid( state = PodcastState(isLoading = false, podcast = podcast()), onPlayVideo = {}, @@ -47,9 +78,9 @@ class PodcastScreenStateTest { onLoadMore = {}, menuScope = emptyMenuScope(), ) + } } } - composeRule.onNodeWithText("This podcast has no available episodes.").assertIsDisplayed() } private fun showScreen(state: PodcastState) { diff --git a/app/src/androidTest/java/dev/typetype/android/feature/publicplaylist/PublicPlaylistScreenStateTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/publicplaylist/PublicPlaylistScreenStateTest.kt index 1dea65e7..80befb27 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/publicplaylist/PublicPlaylistScreenStateTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/publicplaylist/PublicPlaylistScreenStateTest.kt @@ -1,6 +1,12 @@ package dev.typetype.android.feature.publicplaylist import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription @@ -11,6 +17,7 @@ import dev.typetype.android.domain.search.SearchPlaylist import dev.typetype.android.feature.menu.VideoMenuScope import org.junit.Rule import org.junit.Test +import org.junit.Assert.assertTrue class PublicPlaylistScreenStateTest { @get:Rule @@ -37,8 +44,32 @@ class PublicPlaylistScreenStateTest { @Test fun emptyPlaylistHasAnExplicitState() { + showEmptyPlaylist(400.dp) + composeRule.onNodeWithText("This playlist has no available videos.").assertIsDisplayed() + } + + @Test + fun tabletKeepsPlaylistIdentityBesideItsContent() { + showEmptyPlaylist(1200.dp) + val title = composeRule.onNodeWithText("Playlist").fetchSemanticsNode().boundsInRoot + val content = composeRule.onNodeWithText("This playlist has no available videos.") + .fetchSemanticsNode().boundsInRoot + assertTrue(title.right < content.left) + } + + @Test + fun narrowWindowKeepsPlaylistIdentityAboveItsContent() { + showEmptyPlaylist(400.dp) + val title = composeRule.onNodeWithText("Playlist").fetchSemanticsNode().boundsInRoot + val content = composeRule.onNodeWithText("This playlist has no available videos.") + .fetchSemanticsNode().boundsInRoot + assertTrue(title.bottom < content.top) + } + + private fun showEmptyPlaylist(width: Dp) { composeRule.setContent { TypeTypeTheme { + Box(Modifier.requiredWidth(width).requiredHeight(650.dp)) { PublicPlaylistContentGrid( state = PublicPlaylistState(isLoading = false, playlist = playlist()), onPlayVideo = {}, @@ -48,9 +79,9 @@ class PublicPlaylistScreenStateTest { onToggleSaved = {}, menuScope = emptyMenuScope(), ) + } } } - composeRule.onNodeWithText("This playlist has no available videos.").assertIsDisplayed() } private fun showScreen(state: PublicPlaylistState) { diff --git a/app/src/androidTest/java/dev/typetype/android/feature/settings/SettingsScreenAccessibilityTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/settings/SettingsScreenAccessibilityTest.kt index 3b3447ad..9a6e5155 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/settings/SettingsScreenAccessibilityTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/settings/SettingsScreenAccessibilityTest.kt @@ -139,7 +139,6 @@ class SettingsScreenAccessibilityTest { R.string.accounts_title, R.string.settings_profile_title, R.string.settings_import_title, - R.string.youtube_session_settings_title, R.string.rss_settings_title, R.string.settings_appearance_title, R.string.settings_content_title, From b8d5de210c0f3bd6a048e83fbfa9cdf25b8b7bf7 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 7 Sep 2026 20:33:52 +0200 Subject: [PATCH 10/11] test: cover sharing and theme ordering --- .../core/ui/theme/TypeTypeThemeOrderTest.kt | 59 +++++++++++++++++++ .../android/core/ui/share/ShareUrlsTest.kt | 50 ++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 app/src/androidTest/java/dev/typetype/android/core/ui/theme/TypeTypeThemeOrderTest.kt diff --git a/app/src/androidTest/java/dev/typetype/android/core/ui/theme/TypeTypeThemeOrderTest.kt b/app/src/androidTest/java/dev/typetype/android/core/ui/theme/TypeTypeThemeOrderTest.kt new file mode 100644 index 00000000..c1294a95 --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/core/ui/theme/TypeTypeThemeOrderTest.kt @@ -0,0 +1,59 @@ +package dev.typetype.android.core.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.activity.ComponentActivity +import androidx.compose.runtime.mutableStateOf +import dev.typetype.android.domain.preferences.AccentColor +import dev.typetype.android.domain.preferences.AppearanceMode +import dev.typetype.android.domain.preferences.AppearancePersonality +import dev.typetype.android.domain.preferences.AppearanceTheme +import dev.typetype.android.domain.preferences.AppPreferences +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class TypeTypeThemeOrderTest { + @get:Rule + val composeRule = createAndroidComposeRule() + private val preferences = mutableStateOf(AppPreferences()) + private var scheme: ColorScheme? = null + + @Test + fun oledKeepsDynamicPaletteAndReplacesOnlySurfaces() { + preferences.value = AppPreferences( + accentColor = AccentColor.System, + appearancePersonality = AppearancePersonality.Classic, + appearanceMode = AppearanceMode.Dark, + appearanceTheme = AppearanceTheme.Dynamic, + ) + composeRule.setContent { + TypeTypeTheme(preferences = preferences.value) { + scheme = MaterialTheme.colorScheme + } + } + val dynamic = requireNotNull(scheme) + val oled = colorSchemeFor( + preferences.value.copy(appearanceAmoled = true), + ) + assertEquals(dynamic.primary, oled.primary) + assertEquals(dynamic.secondaryContainer, oled.secondaryContainer) + assertEquals(dynamic.tertiary, oled.tertiary) + assertEquals(dynamic.error, oled.error) + assertEquals(dynamic.surfaceVariant, oled.surfaceVariant) + assertEquals(dynamic.outline, oled.outline) + assertEquals(Color.Black, oled.background) + assertEquals(Color.Black, oled.surface) + } + + private fun colorSchemeFor(preferences: AppPreferences): ColorScheme { + scheme = null + composeRule.runOnIdle { + this.preferences.value = preferences + } + composeRule.waitForIdle() + return requireNotNull(scheme) + } +} diff --git a/app/src/test/java/dev/typetype/android/core/ui/share/ShareUrlsTest.kt b/app/src/test/java/dev/typetype/android/core/ui/share/ShareUrlsTest.kt index 773bfa67..fdb62230 100644 --- a/app/src/test/java/dev/typetype/android/core/ui/share/ShareUrlsTest.kt +++ b/app/src/test/java/dev/typetype/android/core/ui/share/ShareUrlsTest.kt @@ -5,6 +5,22 @@ import org.junit.Test class ShareUrlsTest { + @Test + fun sharesCanonicalProviderSourceLinks() { + assertEquals( + "https://www.youtube.com/watch?v=AbCdEfGhI_1", + buildSourceShareUrl("AbCdEfGhI_1"), + ) + assertEquals( + "https://www.bilibili.com/video/BV1UbX3B2EZQ?p=3", + buildSourceShareUrl("BV1UbX3B2EZQ?p=3"), + ) + assertEquals( + "https://www.nicovideo.jp/watch/sm46525483", + buildSourceShareUrl("sm46525483"), + ) + } + @Test fun sharesCompactPublicWatchRoutes() { assertEquals( @@ -32,6 +48,40 @@ class ShareUrlsTest { ) } + @Test + fun offersTypeTypeAndOriginalProviderLinks() { + assertEquals( + listOf( + ShareChoice( + target = ShareTarget.TypeType, + url = "https://watch.example/watch?v=dQw4w9WgXcQ", + ), + ShareChoice( + target = ShareTarget.Source, + url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + providerName = "YouTube", + ), + ), + buildShareChoices( + "https://watch.example/api", + "https://youtube.com/watch?v=dQw4w9WgXcQ", + ), + ) + } + + @Test + fun offersOnlyTypeTypeLinkForUnknownProvider() { + assertEquals( + listOf( + ShareChoice( + target = ShareTarget.TypeType, + url = "https://watch.example/watch?v=https%3A%2F%2Fexample.com%2Fvideo", + ), + ), + buildShareChoices("https://watch.example/api", "https://example.com/video"), + ) + } + @Test fun proxiesRemoteImagesThroughTheActiveServer() { assertEquals( From 8daaa7a653233d161aff79049a846553aed38db7 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 7 Sep 2026 20:34:03 +0200 Subject: [PATCH 11/11] build: prepare Android 1.7.0 release --- app/build.gradle.kts | 4 +-- release-notes/v1.7.0.md | 64 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 release-notes/v1.7.0.md diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4ab4d6f6..486f325d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,8 +26,8 @@ android { applicationId = "dev.typetype.android" minSdk = 23 targetSdk = 37 - versionCode = 10601 - versionName = "1.6.1" + versionCode = 10700 + versionName = "1.7.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resValue("string", "app_name", "TypeType") } diff --git a/release-notes/v1.7.0.md b/release-notes/v1.7.0.md new file mode 100644 index 00000000..72b90945 --- /dev/null +++ b/release-notes/v1.7.0.md @@ -0,0 +1,64 @@ +# TypeType for Android 1.7.0 + +TypeType for Android 1.7.0 brings a smoother player, a more responsive tablet experience, and more consistent navigation across the application. + +The application is still in beta. + +## Playback + +- Keep the seekbar visible while seeking after the player controls have faded out. +- Improve seekbar positioning in portrait, landscape, and full screen, with a clearer scrubber during an active seek. +- Keep the player surface stable when returning from the background and preserve the current playback layout through player transitions. +- Improve brightness and volume gesture feedback, including haptic feedback and the PipePipe-inspired interaction model. +- Keep player actions, fullscreen controls, and the mini-player aligned across window sizes. + +## Tablet experience + +- Add responsive two-pane layouts for channels, podcasts, playlists, and settings. +- Use larger navigation, player actions, labels, and touch targets on wide screens. +- Keep portrait and landscape spacing independent so the player and bottom navigation do not overlap. +- Preserve the mobile layout while making tablet browsing and playback easier to use. + +## Navigation and sharing + +- Synchronize the selected top-level tab immediately when navigation changes. +- Return to a tab's root when its active navigation item is selected again. +- Restore channel and player state more consistently when moving between recommendations and uploaders. +- Add provider-aware source links and a redesigned mobile share target chooser. +- Improve the login layout and keyboard insets, including the TypeType visual backdrop. + +## Appearance and accessibility + +- Preserve Dynamic Color accents when the OLED surface option is enabled. +- Improve contrast and readability across the adaptive layouts and shared cards. +- Expose larger tablet actions and labels without removing compact controls on phones. + +## Compatibility and validation + +- Keep the Android 6.0 through Android 17 support range, API 23 through API 37. +- Keep core behavior independent from Google Play Services and preserve the server-first playback boundary. +- The release candidate passed the Android unit-test, lint, debug-build, and release-build checks locally. + +Android TV remains a separate workstream and is not part of this mobile release. + +## Thx + +Thx to @therealresonix for the detailed player, fullscreen, seeking, navigation, theme, and tablet reports, plus the follow-up testing that helped verify each correction. + +Thx to @gursuj for the playback-control and codec feedback, and to @ollietheotter983 for testing across instances and sharing real-world playback problems. + +Thx to @Alifoss, @303inmyheart, @Toni-Vide, @pinembour, @LuckeeSoft, @VitoItalianGamer, @Slashic, and @arcoast for the earlier reports and suggestions that continue to shape TypeType Android. + +And thank you to everyone testing the beta, sharing reproductions, reporting regressions, and helping TypeType Android improve. + +## Installing + +Download the APK attached below. It is built from the tagged source, signed by the release workflow, checked for 16 KiB page alignment, and verified against the Gradle application version. A SHA-256 checksum is provided alongside it. + +The stable and beta F-Droid channels use the same application identity. Switching channels updates the existing installation instead of installing a second TypeType application. + +If something does not work correctly, please open an issue in the [TypeType-Android repository](https://github.com/TypeType-Video/TypeType-Android/issues). + +If you want to support TypeType Android, sharing it with others is the best way to help. GitHub Sponsors also helps cover the server, domain, and coffee costs that keep the project moving. + +**Full changelog:** https://github.com/TypeType-Video/TypeType-Android/compare/v1.6.1...v1.7.0