fix: release the playback slot when a note is paused

pause() left activeController set, so the slot meant "a row was played at
some point" rather than "audio is audible". Any reader of
hasActivePlayback — the swipe-to-leave policy above all — kept seeing
playback after the user paused, until the row disappeared or something
called stop().

A paused note has nothing to yield to the next playback, so it releases
the slot. hasActivePlayback becomes stored and published, so a view can
react to it rather than sample it once.
This commit is contained in:
Taksh 2026-08-17 10:48:29 +05:30
parent f61aa98c4a
commit ca15f66080
2 changed files with 50 additions and 4 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,6 +344,7 @@ final class VoiceNotePlaybackCoordinator {
}
activeController?.pauseForExclusivity()
activeController = controller
hasActivePlayback = true
return true
}
@ -347,9 +353,11 @@ final class VoiceNotePlaybackCoordinator {
/// 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`.
var hasActivePlayback: Bool {
activeController != nil
}
///
/// 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
@ -358,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

@ -8,6 +8,7 @@
import Testing
import CoreGraphics
import Foundation
@testable import bitchat
/// A stand-in for the playback slot holder. The coordinator only needs identity
@ -136,6 +137,42 @@ struct VoiceNotePlaybackCoordinatorActivityTests {
#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()