mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-10-25 07:41:41 +00:00
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:
parent
f61aa98c4a
commit
ca15f66080
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user