fix: preserve drag seeking during voice playback

This commit is contained in:
qtjg 2026-08-24 18:13:31 +00:00
parent 2c3e8d4fcf
commit da8f6adddf
5 changed files with 259 additions and 14 deletions

View File

@ -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

View 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
}
}

View File

@ -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()

View File

@ -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,

View 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)
}
}