mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-10-18 07:33:46 +00:00
fix: preserve drag seeking during voice playback
This commit is contained in:
parent
2c3e8d4fcf
commit
da8f6adddf
@ -113,6 +113,11 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
||||
updateProgress()
|
||||
isPlaying = false
|
||||
releaseSession()
|
||||
// The slot means "audio is audible", not "a row is selected": a paused
|
||||
// note has nothing to yield to the next playback, and readers of
|
||||
// `hasActivePlayback` would otherwise see playback that stopped being
|
||||
// heard when the user paused it.
|
||||
exclusivity.deactivate(self)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
@ -296,7 +301,7 @@ extension VoiceNotePlaybackController: ExclusivePlayback {
|
||||
}
|
||||
|
||||
/// Ensures only one voice playback (note or live burst) runs at a time.
|
||||
final class VoiceNotePlaybackCoordinator {
|
||||
final class VoiceNotePlaybackCoordinator: ObservableObject {
|
||||
static let shared = VoiceNotePlaybackCoordinator()
|
||||
|
||||
struct Reservation: Equatable {
|
||||
@ -339,9 +344,21 @@ final class VoiceNotePlaybackCoordinator {
|
||||
}
|
||||
activeController?.pauseForExclusivity()
|
||||
activeController = controller
|
||||
hasActivePlayback = true
|
||||
return true
|
||||
}
|
||||
|
||||
/// True while some controller holds the playback slot.
|
||||
///
|
||||
/// Read by the private-chat swipe-to-leave gesture, which stands down while
|
||||
/// a voice note is audible so a waveform seek is not starved by the
|
||||
/// high-priority ancestor drag. See `PrivateChatSwipeToLeavePolicy`.
|
||||
///
|
||||
/// Published rather than computed so a view can drop the gesture for the
|
||||
/// duration: a gesture that is armed at all starves its descendants, so
|
||||
/// reading this only once the drag ends comes too late for the seek.
|
||||
@Published private(set) var hasActivePlayback: Bool = false
|
||||
|
||||
func isCurrent(_ reservation: Reservation, for controller: any ExclusivePlayback) -> Bool {
|
||||
latestReservation == reservation && latestReservedController === controller
|
||||
}
|
||||
@ -349,6 +366,7 @@ final class VoiceNotePlaybackCoordinator {
|
||||
func deactivate(_ controller: any ExclusivePlayback) {
|
||||
if activeController === controller {
|
||||
activeController = nil
|
||||
hasActivePlayback = false
|
||||
}
|
||||
if latestReservedController === controller {
|
||||
latestReservedController = nil
|
||||
|
||||
27
bitchat/ViewModels/PrivateChatSwipeToLeavePolicy.swift
Normal file
27
bitchat/ViewModels/PrivateChatSwipeToLeavePolicy.swift
Normal file
@ -0,0 +1,27 @@
|
||||
import CoreGraphics
|
||||
|
||||
/// Decides whether a drag on the private-chat message list should end the
|
||||
/// conversation.
|
||||
///
|
||||
/// The swipe-right-to-leave gesture is attached to the message list with
|
||||
/// `highPriorityGesture`, which starves gestures on descendant views. A playing
|
||||
/// voice note overlays its waveform with a seek `DragGesture(minimumDistance: 0)`,
|
||||
/// so a drag meant to scrub playback never reaches the waveform, and one that
|
||||
/// happens to travel far enough to the right leaves the conversation instead.
|
||||
///
|
||||
/// While audio is playing the seek is the intent the reader is far more likely
|
||||
/// to have, so the leave gesture stands down for the duration of playback.
|
||||
/// Leaving remains available through the sidebar.
|
||||
enum PrivateChatSwipeToLeavePolicy {
|
||||
/// Rightward travel a drag must exceed before it counts as a leave.
|
||||
static let minimumHorizontalTranslation: CGFloat = 80
|
||||
|
||||
/// Vertical travel above which a drag reads as a scroll, not a leave.
|
||||
static let maximumVerticalTranslation: CGFloat = 60
|
||||
|
||||
static func shouldLeave(translation: CGSize, isVoiceNotePlaying: Bool) -> Bool {
|
||||
guard !isVoiceNotePlaying else { return false }
|
||||
return translation.width > minimumHorizontalTranslation
|
||||
&& abs(translation.height) < maximumVerticalTranslation
|
||||
}
|
||||
}
|
||||
@ -462,6 +462,9 @@ private extension ContentPeopleListView {
|
||||
|
||||
private struct ContentPrivateChatSheetView: View {
|
||||
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
|
||||
/// Observed so the swipe gesture can be dropped for the duration of
|
||||
/// playback rather than merely declining to act when it ends.
|
||||
@ObservedObject private var playbackCoordinator = VoiceNotePlaybackCoordinator.shared
|
||||
|
||||
@Binding var showSidebar: Bool
|
||||
@Binding var messageText: String
|
||||
@ -572,7 +575,14 @@ private struct ContentPrivateChatSheetView: View {
|
||||
// whole sheet it preempted the composer's press-and-hold mic
|
||||
// gesture (a high-priority ancestor drag cancels child gestures
|
||||
// within milliseconds — same starvation as the image-reveal bug).
|
||||
.highPriorityGesture(swipeToLeaveGesture)
|
||||
// `.subviews` while a note is audible: recognition then belongs to
|
||||
// the waveform's seek drag. Guarding in `onEnded` is not enough --
|
||||
// an armed high-priority ancestor drag starves its descendants
|
||||
// before either gesture ends, so the scrub would still be lost.
|
||||
.highPriorityGesture(
|
||||
swipeToLeaveGesture,
|
||||
including: playbackCoordinator.hasActivePlayback ? .subviews : .all
|
||||
)
|
||||
|
||||
if !theme.usesGlassChrome {
|
||||
Divider()
|
||||
@ -608,9 +618,14 @@ private struct ContentPrivateChatSheetView: View {
|
||||
private var swipeToLeaveGesture: some Gesture {
|
||||
DragGesture(minimumDistance: 25, coordinateSpace: .local)
|
||||
.onEnded { value in
|
||||
let horizontal = value.translation.width
|
||||
let vertical = abs(value.translation.height)
|
||||
guard horizontal > 80, vertical < 60 else { return }
|
||||
// Stands down while a voice note is audible: this gesture is
|
||||
// attached with `highPriorityGesture` and would otherwise starve
|
||||
// the waveform's seek drag, and a scrub that drifts right would
|
||||
// end the conversation mid-playback.
|
||||
guard PrivateChatSwipeToLeavePolicy.shouldLeave(
|
||||
translation: value.translation,
|
||||
isVoiceNotePlaying: VoiceNotePlaybackCoordinator.shared.hasActivePlayback
|
||||
) else { return }
|
||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||
showSidebar = true
|
||||
privateConversationModel.endConversation()
|
||||
|
||||
@ -8,7 +8,7 @@ struct WaveformView: View {
|
||||
let isInteractive: Bool
|
||||
@ThemedPalette private var palette
|
||||
|
||||
/// Converts a tap location into a bounded playback fraction.
|
||||
/// Converts a gesture location into a bounded playback fraction.
|
||||
///
|
||||
/// Keep this separate from the gesture so the coordinate contract can be
|
||||
/// tested without mounting SwiftUI. A zero-sized geometry can occur while
|
||||
@ -62,15 +62,14 @@ struct WaveformView: View {
|
||||
if isInteractive, let onSeek = onSeek {
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
// The private conversation owns a high-priority
|
||||
// swipe-to-leave drag on the message list. A drag here
|
||||
// is therefore interpreted as navigation instead of
|
||||
// seeking and can close the conversation. Seeking is
|
||||
// intentionally a tap: it preserves the swipe gesture
|
||||
// while keeping the waveform's direct-manipulation
|
||||
// affordance deterministic.
|
||||
// The private conversation drops its high-priority
|
||||
// swipe-to-leave gesture while a note is playing, so
|
||||
// drag-seeking remains available without allowing a
|
||||
// scrub to close the conversation. Keep the original
|
||||
// zero-distance drag: users can press, slide to a
|
||||
// target, and release to seek.
|
||||
.gesture(
|
||||
SpatialTapGesture(count: 1, coordinateSpace: .local)
|
||||
DragGesture(minimumDistance: 0, coordinateSpace: .local)
|
||||
.onEnded { value in
|
||||
guard let fraction = Self.seekFraction(
|
||||
forX: value.location.x,
|
||||
|
||||
186
bitchatTests/PrivateChatSwipeToLeavePolicyTests.swift
Normal file
186
bitchatTests/PrivateChatSwipeToLeavePolicyTests.swift
Normal file
@ -0,0 +1,186 @@
|
||||
//
|
||||
// PrivateChatSwipeToLeavePolicyTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
/// A stand-in for the playback slot holder. The coordinator only needs identity
|
||||
/// and the ability to be told to yield.
|
||||
private final class StubPlayback: ExclusivePlayback {
|
||||
private(set) var pauseCount = 0
|
||||
func pauseForExclusivity() { pauseCount += 1 }
|
||||
}
|
||||
|
||||
@Suite("PrivateChatSwipeToLeavePolicy")
|
||||
struct PrivateChatSwipeToLeavePolicyTests {
|
||||
|
||||
// MARK: - Thresholds preserved
|
||||
|
||||
@Test("a decisive rightward drag leaves when nothing is playing")
|
||||
func decisiveDragLeaves() {
|
||||
#expect(PrivateChatSwipeToLeavePolicy.shouldLeave(
|
||||
translation: CGSize(width: 120, height: 10),
|
||||
isVoiceNotePlaying: false
|
||||
))
|
||||
}
|
||||
|
||||
@Test("a drag that does not clear the horizontal threshold does not leave")
|
||||
func shortDragStays() {
|
||||
#expect(!PrivateChatSwipeToLeavePolicy.shouldLeave(
|
||||
translation: CGSize(width: 80, height: 0),
|
||||
isVoiceNotePlaying: false
|
||||
))
|
||||
}
|
||||
|
||||
@Test("a leftward drag never leaves")
|
||||
func leftwardDragStays() {
|
||||
#expect(!PrivateChatSwipeToLeavePolicy.shouldLeave(
|
||||
translation: CGSize(width: -120, height: 0),
|
||||
isVoiceNotePlaying: false
|
||||
))
|
||||
}
|
||||
|
||||
@Test("a steep drag reads as a scroll, not a leave", arguments: [60.0, 61.0, -60.0, -90.0])
|
||||
func steepDragStays(vertical: Double) {
|
||||
#expect(!PrivateChatSwipeToLeavePolicy.shouldLeave(
|
||||
translation: CGSize(width: 200, height: vertical),
|
||||
isVoiceNotePlaying: false
|
||||
))
|
||||
}
|
||||
|
||||
@Test("vertical travel is judged on magnitude, so a shallow upward drag still leaves")
|
||||
func shallowUpwardDragLeaves() {
|
||||
#expect(PrivateChatSwipeToLeavePolicy.shouldLeave(
|
||||
translation: CGSize(width: 200, height: -59),
|
||||
isVoiceNotePlaying: false
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: - The regression this policy exists for
|
||||
|
||||
@Test("a drag that would otherwise leave is suppressed while a voice note plays")
|
||||
func playbackSuppressesLeave() {
|
||||
// The reported bug: scrubbing a playing waveform drifts right, the
|
||||
// high-priority ancestor claims the drag, and the conversation ends.
|
||||
#expect(!PrivateChatSwipeToLeavePolicy.shouldLeave(
|
||||
translation: CGSize(width: 200, height: 5),
|
||||
isVoiceNotePlaying: true
|
||||
))
|
||||
}
|
||||
|
||||
@Test("suppression lifts once playback stops")
|
||||
func leaveResumesAfterPlayback() {
|
||||
let translation = CGSize(width: 200, height: 5)
|
||||
#expect(!PrivateChatSwipeToLeavePolicy.shouldLeave(
|
||||
translation: translation, isVoiceNotePlaying: true))
|
||||
#expect(PrivateChatSwipeToLeavePolicy.shouldLeave(
|
||||
translation: translation, isVoiceNotePlaying: false))
|
||||
}
|
||||
|
||||
@Test("playback does not rescue a drag that never qualified")
|
||||
func playbackDoesNotInventALeave() {
|
||||
#expect(!PrivateChatSwipeToLeavePolicy.shouldLeave(
|
||||
translation: CGSize(width: 10, height: 200),
|
||||
isVoiceNotePlaying: true
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("VoiceNotePlaybackCoordinator.hasActivePlayback")
|
||||
struct VoiceNotePlaybackCoordinatorActivityTests {
|
||||
|
||||
@Test("an idle coordinator reports no active playback")
|
||||
func idleCoordinator() {
|
||||
#expect(!VoiceNotePlaybackCoordinator().hasActivePlayback)
|
||||
}
|
||||
|
||||
@Test("activating a controller marks playback active")
|
||||
func activationMarksActive() {
|
||||
let coordinator = VoiceNotePlaybackCoordinator()
|
||||
let playback = StubPlayback()
|
||||
coordinator.activate(playback)
|
||||
#expect(coordinator.hasActivePlayback)
|
||||
}
|
||||
|
||||
@Test("deactivating the active controller clears it")
|
||||
func deactivationClears() {
|
||||
let coordinator = VoiceNotePlaybackCoordinator()
|
||||
let playback = StubPlayback()
|
||||
coordinator.activate(playback)
|
||||
coordinator.deactivate(playback)
|
||||
#expect(!coordinator.hasActivePlayback)
|
||||
}
|
||||
|
||||
@Test("a reservation alone does not count as playing")
|
||||
func reservationIsNotPlayback() {
|
||||
// `reserve` records intent before an async starter has audio ready;
|
||||
// treating that as playing would suppress the leave gesture on a note
|
||||
// that never becomes audible.
|
||||
let coordinator = VoiceNotePlaybackCoordinator()
|
||||
_ = coordinator.reserve(StubPlayback())
|
||||
#expect(!coordinator.hasActivePlayback)
|
||||
}
|
||||
|
||||
@Test("deactivating a controller that does not hold the slot leaves it active")
|
||||
func foreignDeactivateIsIgnored() {
|
||||
let coordinator = VoiceNotePlaybackCoordinator()
|
||||
let holder = StubPlayback()
|
||||
coordinator.activate(holder)
|
||||
coordinator.deactivate(StubPlayback())
|
||||
#expect(coordinator.hasActivePlayback)
|
||||
}
|
||||
|
||||
@Test("a controller that pauses releases the slot")
|
||||
func pauseReleasesSlot() throws {
|
||||
// The slot means audible playback. A paused note holds nothing, and
|
||||
// leaving it in place kept the leave gesture suppressed for the rest
|
||||
// of the row's life.
|
||||
let coordinator = VoiceNotePlaybackCoordinator()
|
||||
let url = try Self.makeSilentVoiceNote()
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
let controller = VoiceNotePlaybackController(url: url, exclusivity: coordinator)
|
||||
|
||||
controller.play()
|
||||
#expect(coordinator.hasActivePlayback)
|
||||
|
||||
controller.pause()
|
||||
#expect(!coordinator.hasActivePlayback)
|
||||
}
|
||||
|
||||
/// A one-frame WAV: enough for AVAudioPlayer to prepare and start.
|
||||
private static func makeSilentVoiceNote() throws -> URL {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("swipe-policy-\(UUID().uuidString).wav")
|
||||
var data = Data()
|
||||
func append(_ string: String) { data.append(contentsOf: Array(string.utf8)) }
|
||||
func append(_ value: UInt32) { withUnsafeBytes(of: value.littleEndian) { data.append(contentsOf: $0) } }
|
||||
func append(_ value: UInt16) { withUnsafeBytes(of: value.littleEndian) { data.append(contentsOf: $0) } }
|
||||
let frames: UInt32 = 8_000 // one second at 8 kHz, 16-bit mono
|
||||
let dataBytes = frames * 2
|
||||
append("RIFF"); append(36 + dataBytes); append("WAVE")
|
||||
append("fmt "); append(UInt32(16)); append(UInt16(1)); append(UInt16(1))
|
||||
append(UInt32(8_000)); append(UInt32(16_000)); append(UInt16(2)); append(UInt16(16))
|
||||
append("data"); append(dataBytes)
|
||||
data.append(Data(repeating: 0, count: Int(dataBytes)))
|
||||
try data.write(to: url)
|
||||
return url
|
||||
}
|
||||
|
||||
@Test("taking over the slot pauses the previous holder and stays active")
|
||||
func takeoverKeepsPlaybackActive() {
|
||||
let coordinator = VoiceNotePlaybackCoordinator()
|
||||
let first = StubPlayback()
|
||||
let second = StubPlayback()
|
||||
coordinator.activate(first)
|
||||
coordinator.activate(second)
|
||||
#expect(first.pauseCount == 1)
|
||||
#expect(coordinator.hasActivePlayback)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user