feat(android,ios): double-tap to seek, controls that fit upright, and a player TalkBack can use

- Double-tap the left or right third of the picture to seek ten seconds,
  with a mark showing the seek happened; the middle plays or pauses.
- A wide picture turns the phone's player to landscape by itself, once
  per episode, until the viewer uses the rotation button.
- Upright, the pickers past subtitles go behind a More menu instead of
  running off the edge. Controls are 48dp on Android and 44pt on iOS.
- With TalkBack or VoiceOver on, the controls stay up: a control that
  fades four seconds after it is reached cannot be found again. The tap
  layer is announced as Show or Hide controls.
This commit is contained in:
nvrl committed 2026-10-13 16:50:54 +02:00
1 parent 2546bab0a6
commit 01eee78317
2 files changed
+278 -33

No files matched your search

@@ -5,6 +5,7 @@ import android.content.pm.ActivityInfo
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.WindowManager
import android.view.accessibility.AccessibilityManager
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
@@ -12,7 +13,6 @@ import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -39,6 +39,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.Forward30
import androidx.compose.material.icons.filled.Fullscreen
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.FullscreenExit
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
@@ -48,6 +49,8 @@ import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.material.icons.filled.Subtitles
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -63,6 +66,11 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.semantics
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -118,6 +126,12 @@ private const val CONTROLS_SECONDS = 4L
/** How long the "up next" card counts down before it loads the next episode. */
private const val UP_NEXT_SECONDS = 10
/** How far a double tap on either side of the picture seeks. */
private const val DOUBLE_TAP_SECONDS = 10
/** How long the double-tap seek mark stays up. */
private const val NUDGE_MS = 700L
/** How close to the end the next episode's opening blocks are fetched. */
private const val WARM_NEXT_SECONDS = 120.0
@@ -133,7 +147,10 @@ private const val MAX_VOLUME = 100f
* Below it — a phone held upright — the transport row is already five controls
* wide, and the device's own volume keys are the better answer.
*/
private val VOLUME_SLIDER_MIN_WIDTH = 600.dp
private val VOLUME_SLIDER_MIN_WIDTH = 720.dp
/** Narrower than this, the pickers past subtitles go behind "More". */
private val INLINE_CONTROLS_MIN_WIDTH = 560.dp
/**
* The player, as its own screen on the activity's own window.
@@ -388,24 +405,71 @@ fun PlayerScreen(
}
var landscapeLocked by remember { mutableStateOf(false) }
// Set by the rotation button: from then on the viewer decides.
var rotationChosen by remember { mutableStateOf(false) }
// A wide picture fills a phone only on its side, so it turns there by
// itself, once per episode, until the viewer picks for themselves.
LaunchedEffect(key, playingThis, state.videoWidth > state.videoHeight) {
if (!playingThis || rotationChosen || landscapeLocked) return@LaunchedEffect
if (state.videoHeight > 0.0 && state.videoWidth > state.videoHeight) {
landscapeLocked = activity?.lockLandscape() ?: false
}
}
var showControls by remember { mutableStateOf(true) }
LaunchedEffect(showControls, state.paused) {
if (showControls && !state.paused) {
// TalkBack moves between controls by swiping, and a control that fades
// four seconds after it is reached cannot be found again.
val exploring = rememberTouchExploration()
LaunchedEffect(exploring) { if (exploring) showControls = true }
LaunchedEffect(showControls, state.paused, exploring) {
if (showControls && !state.paused && !exploring) {
delay(CONTROLS_SECONDS * 1_000)
showControls = false
}
}
// Which side was double-tapped, shown for a moment so the seek is seen
// to have happened; the count makes a second tap on one side show again.
var nudge by remember { mutableStateOf<Pair<Int, Int>?>(null) }
LaunchedEffect(nudge) {
if (nudge != null) {
delay(NUDGE_MS)
nudge = null
}
}
val current by rememberUpdatedState(state)
if (minimized) return
Box(
Modifier
.fillMaxSize()
.background(Color.Black)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { showControls = !showControls },
),
// Double-tap a side to seek ten seconds, as every phone video
// player has taught; the middle plays or pauses. A single tap
// waits out the double-tap timeout, which is the price of both.
.pointerInput(host) {
detectTapGestures(
onTap = { showControls = !showControls },
onDoubleTap = { offset ->
val third = size.width / 3f
val seconds = when {
offset.x < third -> -DOUBLE_TAP_SECONDS
offset.x > size.width - third -> DOUBLE_TAP_SECONDS
else -> 0
}
if (seconds == 0) {
host.setPaused(!current.paused)
} else {
host.seek((current.position + seconds).coerceAtLeast(0.0))
nudge = seconds to (nudge?.second ?: 0) + 1
}
},
)
}
.semantics {
onClick(label = if (showControls) "Hide controls" else "Show controls") {
showControls = !showControls
true
}
},
) {
AndroidView(
modifier = Modifier.fillMaxSize(),
@@ -497,7 +561,10 @@ fun PlayerScreen(
episodes = episodes,
playing = index,
onEpisode = { jumpTo(it) },
onRotate = { landscapeLocked = activity?.toggleLandscape() ?: false },
onRotate = {
rotationChosen = true
landscapeLocked = activity?.toggleLandscape() ?: false
},
landscapeLocked = landscapeLocked,
)
}
@@ -515,6 +582,20 @@ fun PlayerScreen(
) { Text(skip.label) }
}
nudge?.let { (seconds, _) ->
Box(
Modifier
.align(if (seconds < 0) Alignment.CenterStart else Alignment.CenterEnd)
.padding(horizontal = 48.dp)
.size(72.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center,
) {
Text(if (seconds < 0) "−${-seconds} s" else "+$seconds s", color = Color.White)
}
}
// A file with no chapters has no credits to count over, so the card
// takes its last seconds instead: without it the next episode cut in
// with no warning and no way to stay. Counted on the file's own clock,
@@ -659,6 +740,26 @@ private fun ImmersiveWindow(activity: Activity?) {
}
}
/** Lock to landscape. Reports whether it is. */
private fun Activity.lockLandscape(): Boolean {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
return true
}
/** Whether TalkBack, or anything else that explores by touch, is on. */
@Composable
private fun rememberTouchExploration(): Boolean {
val context = LocalContext.current
val manager = remember(context) { context.getSystemService(AccessibilityManager::class.java) }
var exploring by remember { mutableStateOf(manager?.isTouchExplorationEnabled == true) }
DisposableEffect(manager) {
val listener = AccessibilityManager.TouchExplorationStateChangeListener { exploring = it }
manager?.addTouchExplorationStateChangeListener(listener)
onDispose { manager?.removeTouchExplorationStateChangeListener(listener) }
}
return exploring
}
/** Lock to landscape, or hand rotation back to the sensor. Reports which. */
private fun Activity.toggleLandscape(): Boolean {
val lock = requestedOrientation != ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
@@ -705,8 +806,15 @@ private fun PlayerControls(
}
val duration = state.duration.toFloat().coerceAtLeast(1f)
val density = LocalDensity.current
val roomForVolume = with(density) {
LocalWindowInfo.current.containerSize.width.toDp() >= VOLUME_SLIDER_MIN_WIDTH
val width = with(density) { LocalWindowInfo.current.containerSize.width.toDp() }
val roomForVolume = width >= VOLUME_SLIDER_MIN_WIDTH
// A phone in portrait has room for the transport and two more buttons at
// a thumb's size. The rest go behind "More" there rather than off the edge.
val roomForAll = width >= INLINE_CONTROLS_MIN_WIDTH
val toggleMute = {
host.setMuted(!state.muted)
scope.launch(Dispatchers.IO) { rememberVolume(muted = !state.muted) }
Unit
}
Column(
@@ -768,6 +876,42 @@ private fun PlayerControls(
CompactIcon(Icons.Default.SkipNext, "Next episode", enabled = hasNext, onClick = onNext)
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (!roomForAll) {
CompactIcon(Icons.Default.Subtitles, "Subtitles") { choosing = "sub" }
var more by remember { mutableStateOf(false) }
Box {
CompactIcon(Icons.Default.MoreVert, "More") { more = true }
DropdownMenu(expanded = more, onDismissRequest = { more = false }) {
val entries = buildList {
if (episodes.size > 1) add(Triple(Icons.Default.VideoLibrary, "Episodes") { showEpisodes = true })
if (chapters.size > 1) add(Triple(Icons.AutoMirrored.Filled.Toc, "Chapters") { showChapters = true })
add(Triple(Icons.Default.Speed, "Playback speed") { showSpeeds = true })
add(Triple(Icons.Default.Audiotrack, "Audio track") { choosing = "audio" })
add(
Triple(
if (state.muted) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff,
if (state.muted) "Unmute" else "Mute",
toggleMute,
),
)
add(
Triple(
if (landscapeLocked) Icons.Default.FullscreenExit else Icons.Default.Fullscreen,
if (landscapeLocked) "Unlock rotation" else "Lock to landscape",
onRotate,
),
)
}
entries.forEach { (icon, label, action) ->
DropdownMenuItem(
text = { Text(label) },
leadingIcon = { Icon(icon, contentDescription = null) },
onClick = { more = false; action() },
)
}
}
}
} else {
if (episodes.size > 1) {
CompactIcon(Icons.Default.VideoLibrary, "Episodes") { showEpisodes = true }
}
@@ -779,11 +923,9 @@ private fun PlayerControls(
CompactIcon(Icons.Default.Subtitles, "Subtitles") { choosing = "sub" }
CompactIcon(
if (state.muted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp,
"Mute",
) {
host.setMuted(!state.muted)
scope.launch(Dispatchers.IO) { rememberVolume(muted = !state.muted) }
}
if (state.muted) "Unmute" else "Mute",
onClick = toggleMute,
)
// The app's own volume, which is not the device's: a file
// mastered quiet is turned up here and stays up for the next
// one. Only where there is room for it — a phone in portrait
@@ -810,6 +952,7 @@ private fun PlayerControls(
}
}
}
}
if (showSpeeds) {
SpeedChooser(
@@ -1238,12 +1381,14 @@ private fun CompactIcon(
enabled: Boolean = true,
onClick: () -> Unit,
) {
IconButton(onClick = onClick, enabled = enabled, modifier = Modifier.size(40.dp)) {
// 48dp: the smallest target Android's accessibility guidance allows, and
// the size a thumb on a moving train actually hits.
IconButton(onClick = onClick, enabled = enabled, modifier = Modifier.size(48.dp)) {
Icon(
icon,
contentDescription = description,
tint = if (enabled) Color.White else Color.White.copy(alpha = 0.35f),
modifier = Modifier.size(22.dp),
modifier = Modifier.size(24.dp),
)
}
}
+114 -14
View File
@@ -7,7 +7,13 @@ private let controlsSeconds = 4.0
/// The narrowest layout the in-app volume slider is worth the room it costs.
/// Below it — a phone held upright — the hardware volume buttons are the
/// better answer.
private let volumeSliderMinWidth: CGFloat = 600
private let volumeSliderMinWidth: CGFloat = 720
/// Narrower than this, the pickers past subtitles go behind "More".
private let inlineControlsMinWidth: CGFloat = 560
/// How far a double tap on either side of the picture seeks.
private let doubleTapSeconds = 10
/// The rates worth offering. A short list rather than a slider: the useful
/// rates are a handful of ratios, and a slider lands on 1.07 as often as 1.0.
@@ -24,17 +30,47 @@ struct PlayerView: View {
@State private var showControls = true
@State private var hideTask: Task<Void, Never>?
@State private var landscapeLocked = OrientationLock.landscape
/// Set by the rotation button: from then on the viewer decides.
@State private var rotationChosen = false
@State private var sheet: PlayerSheet?
/// Which side was double-tapped, shown for a moment so the seek is seen
/// to have happened; the count makes a second tap on one side show again.
@State private var nudge: (seconds: Int, count: Int)?
@Environment(\.accessibilityVoiceOverEnabled) private var voiceOver
var body: some View {
GeometryReader { geometry in
ZStack {
Color.black.ignoresSafeArea()
MpvView(player: player).ignoresSafeArea()
// Double-tap a side to seek ten seconds, as every phone video
// player has taught; the middle plays or pauses. A single tap
// waits out the double-tap, which is the price of both.
Color.clear
.contentShape(Rectangle())
.ignoresSafeArea()
.onTapGesture(count: 2) { location in doubleTap(at: location.x, width: geometry.size.width) }
.onTapGesture { setControls(!showControls) }
.accessibilityElement()
.accessibilityLabel(showControls ? "Hide controls" : "Show controls")
.accessibilityAddTraits(.isButton)
.accessibilityAction { setControls(!showControls) }
if let nudge {
Text(nudge.seconds < 0 ? "−\(-nudge.seconds) s" : "+\(nudge.seconds) s")
.textStyle(.labelLarge)
.foregroundStyle(.white)
.frame(width: 72, height: 72)
.background(Circle().fill(scheme.scrim.opacity(0.5)))
.padding(.horizontal, 48)
.frame(maxWidth: .infinity, alignment: nudge.seconds < 0 ? .leading : .trailing)
.allowsHitTesting(false)
.accessibilityHidden(true)
.task(id: nudge.count) {
try? await Task.sleep(for: .milliseconds(700))
if !Task.isCancelled { self.nudge = nil }
}
}
VStack(spacing: 0) {
if showControls { topBar.transition(.opacity) }
@@ -44,8 +80,12 @@ struct PlayerView: View {
host: host,
player: player,
roomForVolume: geometry.size.width >= volumeSliderMinWidth,
roomForAll: geometry.size.width >= inlineControlsMinWidth,
landscapeLocked: landscapeLocked,
onRotate: { landscapeLocked = OrientationLock.toggle() },
onRotate: {
rotationChosen = true
landscapeLocked = OrientationLock.toggle()
},
onSheet: { sheet = $0 },
onInteract: { setControls(true) }
)
@@ -135,6 +175,16 @@ struct PlayerView: View {
.persistentSystemOverlays(.hidden)
.onAppear { setControls(true) }
.onChange(of: player.paused) { setControls(showControls) }
// VoiceOver moves between controls by swiping, and a control that
// fades four seconds after it is reached cannot be found again.
.onChange(of: voiceOver) { setControls(true) }
// A wide picture fills a phone only on its side, so it turns there by
// itself, once per episode, until the viewer picks for themselves.
.onChange(of: player.videoWidth > player.videoHeight && player.videoHeight > 0) { _, wide in
guard wide, host.playingOpenEpisode, !rotationChosen, !landscapeLocked else { return }
OrientationLock.set(true)
landscapeLocked = true
}
.sheet(item: $sheet) { which in
switch which {
case .speed: SpeedSheet(current: host.speed, onSelect: host.setSpeed)
@@ -180,12 +230,23 @@ struct PlayerView: View {
private func setControls(_ shown: Bool) {
showControls = shown
hideTask?.cancel()
guard shown, !player.paused else { return }
guard shown, !player.paused, !voiceOver else { return }
hideTask = Task {
try? await Task.sleep(for: .seconds(controlsSeconds))
if !Task.isCancelled { showControls = false }
}
}
private func doubleTap(at x: CGFloat, width: CGFloat) {
let third = width / 3
let seconds = x < third ? -doubleTapSeconds : x > width - third ? doubleTapSeconds : 0
if seconds == 0 {
host.setPaused(!player.paused)
} else {
host.seek(to: max(player.position + Double(seconds), 0))
nudge = (seconds, (nudge?.count ?? 0) + 1)
}
}
}
enum PlayerSheet: Identifiable {
@@ -209,6 +270,7 @@ private struct PlayerControls: View {
let host: PlayerHost
let player: MpvPlayer
let roomForVolume: Bool
let roomForAll: Bool
let landscapeLocked: Bool
let onRotate: () -> Void
let onSheet: (PlayerSheet) -> Void
@@ -257,6 +319,51 @@ private struct PlayerControls: View {
CompactIcon("goforward.30", "Forward thirty seconds") { host.seek(to: player.position + 30) }
CompactIcon("forward.end.fill", "Next episode", enabled: host.next != nil, action: host.advance)
Spacer(minLength: 4)
if roomForAll {
pickers
} else {
// A phone held upright has room for the transport and two
// more buttons at a thumb's size; the rest go behind More
// rather than off the edge.
CompactIcon("captions.bubble", "Subtitles") { onSheet(.tracks("sub")) }
Menu {
if host.episodes.count > 1 {
Button("Episodes", systemImage: "rectangle.stack") { onSheet(.episodes) }
}
if host.chapters.entries.count > 1 {
Button("Chapters", systemImage: "list.bullet") { onSheet(.chapters) }
}
Button("Playback speed", systemImage: "speedometer") { onSheet(.speed) }
Button("Audio track", systemImage: "waveform") { onSheet(.tracks("audio")) }
Button(player.muted ? "Unmute" : "Mute", systemImage: player.muted ? "speaker.wave.2.fill" : "speaker.slash.fill", action: toggleMute)
Button(
landscapeLocked ? "Unlock rotation" : "Lock to landscape",
systemImage: landscapeLocked ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right",
action: onRotate
)
} label: {
Image(systemName: "ellipsis.circle")
.font(.system(size: 20))
.foregroundStyle(.white)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.accessibilityLabel("More")
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(scheme.scrim.opacity(0.68).ignoresSafeArea(edges: [.bottom, .horizontal]))
}
private func toggleMute() {
player.setMuted(!player.muted)
host.rememberVolume(muted: !player.muted)
}
/// The pickers, laid out in a row where there is room for them all.
@ViewBuilder private var pickers: some View {
if host.episodes.count > 1 {
CompactIcon("rectangle.stack", "Episodes") { onSheet(.episodes) }
}
@@ -266,10 +373,7 @@ private struct PlayerControls: View {
CompactIcon("speedometer", "Playback speed") { onSheet(.speed) }
CompactIcon("waveform", "Audio track") { onSheet(.tracks("audio")) }
CompactIcon("captions.bubble", "Subtitles") { onSheet(.tracks("sub")) }
CompactIcon(player.muted ? "speaker.slash.fill" : "speaker.wave.2.fill", "Mute") {
player.setMuted(!player.muted)
host.rememberVolume(muted: !player.muted)
}
CompactIcon(player.muted ? "speaker.slash.fill" : "speaker.wave.2.fill", player.muted ? "Unmute" : "Mute", action: toggleMute)
// The app's own volume, which is not the device's: a file
// mastered quiet is turned up here and stays up for the next.
if roomForVolume {
@@ -295,11 +399,6 @@ private struct PlayerControls: View {
action: onRotate
)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(scheme.scrim.opacity(0.68).ignoresSafeArea(edges: [.bottom, .horizontal]))
}
}
private struct CompactIcon: View {
@@ -318,9 +417,10 @@ private struct CompactIcon: View {
var body: some View {
Button(action: action) {
Image(systemName: icon)
.font(.system(size: 19))
.font(.system(size: 20))
.foregroundStyle(enabled ? Color.white : Color.white.opacity(0.35))
.frame(width: 40, height: 40)
// 44pt: the smallest target Apple's guidelines allow.
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.buttonStyle(PressableStyle(circle: true, wash: .white))