mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
2546bab0a6
commit
01eee78317
2 files changed
+335
-90
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,45 +876,80 @@ private fun PlayerControls(
|
||||
CompactIcon(Icons.Default.SkipNext, "Next episode", enabled = hasNext, onClick = onNext)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (episodes.size > 1) {
|
||||
CompactIcon(Icons.Default.VideoLibrary, "Episodes") { showEpisodes = true }
|
||||
}
|
||||
if (chapters.size > 1) {
|
||||
CompactIcon(Icons.AutoMirrored.Filled.Toc, "Chapters") { showChapters = true }
|
||||
}
|
||||
CompactIcon(Icons.Default.Speed, "Playback speed") { showSpeeds = true }
|
||||
CompactIcon(Icons.Default.Audiotrack, "Audio track") { choosing = "audio" }
|
||||
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) }
|
||||
}
|
||||
// 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
|
||||
// has the system volume keys and no width to spare.
|
||||
if (roomForVolume) {
|
||||
var dragging by remember { mutableStateOf<Float?>(null) }
|
||||
Slider(
|
||||
value = dragging ?: state.volume.toFloat().coerceIn(0f, MAX_VOLUME),
|
||||
onValueChange = { dragging = it; host.setVolume(it.toDouble()) },
|
||||
onValueChangeFinished = {
|
||||
val chosen = dragging?.toDouble()
|
||||
dragging = null
|
||||
chosen?.let { scope.launch(Dispatchers.IO) { rememberVolume(volume = it) } }
|
||||
},
|
||||
valueRange = 0f..MAX_VOLUME,
|
||||
modifier = Modifier.width(96.dp),
|
||||
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 }
|
||||
}
|
||||
if (chapters.size > 1) {
|
||||
CompactIcon(Icons.AutoMirrored.Filled.Toc, "Chapters") { showChapters = true }
|
||||
}
|
||||
CompactIcon(Icons.Default.Speed, "Playback speed") { showSpeeds = true }
|
||||
CompactIcon(Icons.Default.Audiotrack, "Audio track") { choosing = "audio" }
|
||||
CompactIcon(Icons.Default.Subtitles, "Subtitles") { choosing = "sub" }
|
||||
CompactIcon(
|
||||
if (state.muted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp,
|
||||
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
|
||||
// has the system volume keys and no width to spare.
|
||||
if (roomForVolume) {
|
||||
var dragging by remember { mutableStateOf<Float?>(null) }
|
||||
Slider(
|
||||
value = dragging ?: state.volume.toFloat().coerceIn(0f, MAX_VOLUME),
|
||||
onValueChange = { dragging = it; host.setVolume(it.toDouble()) },
|
||||
onValueChangeFinished = {
|
||||
val chosen = dragging?.toDouble()
|
||||
dragging = null
|
||||
chosen?.let { scope.launch(Dispatchers.IO) { rememberVolume(volume = it) } }
|
||||
},
|
||||
valueRange = 0f..MAX_VOLUME,
|
||||
modifier = Modifier.width(96.dp),
|
||||
)
|
||||
}
|
||||
CompactIcon(
|
||||
if (landscapeLocked) Icons.Default.FullscreenExit else Icons.Default.Fullscreen,
|
||||
if (landscapeLocked) "Unlock rotation" else "Lock to landscape",
|
||||
onClick = onRotate,
|
||||
)
|
||||
}
|
||||
CompactIcon(
|
||||
if (landscapeLocked) Icons.Default.FullscreenExit else Icons.Default.Fullscreen,
|
||||
if (landscapeLocked) "Unlock rotation" else "Lock to landscape",
|
||||
onClick = onRotate,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,49 +319,86 @@ 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 host.episodes.count > 1 {
|
||||
CompactIcon("rectangle.stack", "Episodes") { onSheet(.episodes) }
|
||||
}
|
||||
if host.chapters.entries.count > 1 {
|
||||
CompactIcon("list.bullet", "Chapters") { onSheet(.chapters) }
|
||||
}
|
||||
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)
|
||||
}
|
||||
// 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 {
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { dragVolume ?? min(max(player.volume, 0), 100) },
|
||||
set: { dragVolume = $0; player.setVolume($0) }
|
||||
),
|
||||
in: 0 ... 100
|
||||
) { editing in
|
||||
onInteract()
|
||||
if !editing, let chosen = dragVolume {
|
||||
dragVolume = nil
|
||||
host.rememberVolume(volume: chosen)
|
||||
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())
|
||||
}
|
||||
.tint(scheme.primary)
|
||||
.frame(width: 96)
|
||||
.accessibilityLabel("More")
|
||||
}
|
||||
CompactIcon(
|
||||
landscapeLocked ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right",
|
||||
landscapeLocked ? "Unlock rotation" : "Lock to landscape",
|
||||
action: onRotate
|
||||
)
|
||||
}
|
||||
}
|
||||
.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) }
|
||||
}
|
||||
if host.chapters.entries.count > 1 {
|
||||
CompactIcon("list.bullet", "Chapters") { onSheet(.chapters) }
|
||||
}
|
||||
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", 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 {
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { dragVolume ?? min(max(player.volume, 0), 100) },
|
||||
set: { dragVolume = $0; player.setVolume($0) }
|
||||
),
|
||||
in: 0 ... 100
|
||||
) { editing in
|
||||
onInteract()
|
||||
if !editing, let chosen = dragVolume {
|
||||
dragVolume = nil
|
||||
host.rememberVolume(volume: chosen)
|
||||
}
|
||||
}
|
||||
.tint(scheme.primary)
|
||||
.frame(width: 96)
|
||||
}
|
||||
CompactIcon(
|
||||
landscapeLocked ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right",
|
||||
landscapeLocked ? "Unlock rotation" : "Lock to landscape",
|
||||
action: onRotate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
Reference in new issue
Block a user