Skip to content

Commit aff4840

Browse files
committed
Add ability to rewind, forward and seek
1 parent af71d21 commit aff4840

File tree

9 files changed

+213
-109
lines changed

9 files changed

+213
-109
lines changed

.swiftlint.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@ included:
55
- Package.swift
66
excluded:
77
- .build
8+
type_body_length:
9+
- 500
Lines changed: 54 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,93 +1,105 @@
11
import SwiftUI
22
import ChunkedAudioPlayer
33

4-
struct AudioButtonStyle: ButtonStyle {
4+
struct AudioControlButtonStyle: ButtonStyle {
5+
@Environment(\.isEnabled) private var isEnabled: Bool
6+
57
func makeBody(configuration: Configuration) -> some View {
68
configuration
79
.label
810
.font(.system(size: 20))
911
.frame(width: 48, height: 48)
10-
.foregroundStyle(.primary)
12+
.foregroundStyle(.primary.opacity(isEnabled ? 1.0 : 0.3))
1113
}
1214
}
1315

14-
struct AudioPlayPauseButton: View {
15-
let player: AudioPlayer
16+
struct AudioControlButton: View {
17+
let image: () -> Image
1618
let onTap: () -> Void
1719

18-
var body: some View {
19-
Button {
20-
withAnimation {
21-
onTap()
22-
}
23-
} label: {
24-
image.contentTransition(.symbolEffect(.replace))
25-
}
26-
.buttonStyle(AudioButtonStyle())
20+
init(image: @escaping () -> Image, onTap: @escaping () -> Void) {
21+
self.image = image
22+
self.onTap = onTap
2723
}
2824

29-
private var image: Image {
30-
switch player.currentState {
31-
case .initial, .failed, .completed, .paused: Image(systemName: "play.fill")
32-
case .playing: Image(systemName: "pause.fill")
33-
}
25+
init(image: Image, onTap: @escaping () -> Void) {
26+
self.init(image: { image }, onTap: onTap)
3427
}
35-
}
36-
37-
struct AudioStopButton: View {
38-
let player: AudioPlayer
39-
let onTap: () -> Void
4028

4129
var body: some View {
4230
Button {
4331
withAnimation {
4432
onTap()
4533
}
4634
} label: {
47-
Image(systemName: "stop.fill")
35+
image()
4836
.contentTransition(.symbolEffect(.replace))
4937
}
50-
.buttonStyle(AudioButtonStyle())
38+
.buttonStyle(AudioControlButtonStyle())
5139
}
5240
}
5341

5442
struct AudioControlsView: View {
55-
let timeFormat = Duration.UnitsFormatStyle(
56-
allowedUnits: [.hours, .minutes, .seconds],
57-
width: .narrow
58-
)
43+
let timeFormat = Duration.TimeFormatStyle(pattern: .minuteSecond(padMinuteToLength: 2))
5944

60-
let player: AudioPlayer
45+
@StateObject var player: AudioPlayer
6146
let onPlayPause: () -> Void
6247
let onStop: () -> Void
48+
let onRewind: () -> Void
49+
let onForward: () -> Void
6350

64-
var duration: Duration {
51+
private var currentTime: Duration {
6552
Duration.seconds(player.currentTime.seconds)
6653
}
6754

68-
var formattedTime: String {
69-
duration.formatted(timeFormat)
55+
private var currentDuration: Duration {
56+
Duration.seconds(player.currentDuration.seconds)
57+
}
58+
59+
private var formattedTime: String {
60+
currentTime.formatted(timeFormat) + " / " + currentDuration.formatted(timeFormat)
7061
}
7162

7263
var body: some View {
7364
HStack {
74-
AudioPlayPauseButton(player: player, onTap: onPlayPause)
65+
AudioControlButton(image: Image(systemName: "gobackward.5"), onTap: onRewind)
66+
.disabled(!player.currentState.isActive)
67+
AudioControlButton(image: {
68+
switch player.currentState {
69+
case .initial, .failed, .completed, .paused: Image(systemName: "play.fill")
70+
case .playing: Image(systemName: "pause.fill")
71+
}
72+
}, onTap: onPlayPause)
7573
Text(formattedTime)
7674
.padding()
7775
.font(.headline.monospaced())
7876
.fontWeight(.bold)
79-
switch player.currentState {
80-
case .initial, .completed, .failed:
81-
EmptyView()
82-
case .playing, .paused:
83-
AudioStopButton(player: player, onTap: onStop)
84-
}
77+
.foregroundStyle(Color.primary.opacity(player.currentState.isActive ? 1.0 : 0.3))
78+
AudioControlButton(image: Image(systemName: "stop.fill"), onTap: onStop)
79+
.disabled(!player.currentState.isActive)
80+
AudioControlButton(image: Image(systemName: "goforward.5"), onTap: onForward)
81+
.disabled(!player.currentState.isActive)
8582
}
86-
.background(Color.gray)
83+
.background(Color(UIColor.secondarySystemBackground))
8784
.clipShape(Capsule())
8885
}
8986
}
9087

88+
private extension AudioPlayerState {
89+
var isActive: Bool {
90+
switch self {
91+
case .playing, .paused: true
92+
case .initial, .completed, .failed: false
93+
}
94+
}
95+
}
96+
9197
#Preview {
92-
AudioControlsView(player: AudioPlayer(), onPlayPause: {}, onStop: {})
98+
AudioControlsView(
99+
player: AudioPlayer(),
100+
onPlayPause: {},
101+
onStop: {},
102+
onRewind: {},
103+
onForward: {}
104+
)
93105
}

Example/Example/LocalFileView.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import AudioToolbox
2+
import CoreMedia
23
import Combine
34
import SwiftUI
45
import ChunkedAudioPlayer
@@ -84,6 +85,10 @@ struct LocalFileView: View {
8485
}
8586
} onStop: {
8687
player.stop()
88+
} onRewind: {
89+
player.rewind(CMTime(seconds: 5.0, preferredTimescale: player.currentTime.timescale))
90+
} onForward: {
91+
player.forward(CMTime(seconds: 5.0, preferredTimescale: player.currentTime.timescale))
8792
}
8893
}
8994

Example/Example/TextToSpeechView.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import SwiftUI
2+
import CoreMedia
23
import ChunkedAudioPlayer
34

45
struct Shake: GeometryEffect {
@@ -116,6 +117,10 @@ struct TextToSpeechView: View {
116117
}
117118
} onStop: {
118119
player.stop()
120+
} onRewind: {
121+
player.rewind(CMTime(seconds: 5.0, preferredTimescale: player.currentTime.timescale))
122+
} onForward: {
123+
player.forward(CMTime(seconds: 5.0, preferredTimescale: player.currentTime.timescale))
119124
}
120125
}
121126

Sources/AudioBuffersQueue.swift

Lines changed: 39 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@ import os
33

44
final class AudioBuffersQueue {
55
private let audioDescription: AudioStreamBasicDescription
6-
private var allBuffers = OSAllocatedUnfairLock(initialState: [CMSampleBuffer]())
7-
private var buffers = OSAllocatedUnfairLock(initialState: [CMSampleBuffer]())
6+
private var allBuffers = [CMSampleBuffer]()
7+
private var buffers = [CMSampleBuffer]()
8+
private let lock = NSLock()
89

910
private(set) var duration = CMTime.zero
1011

1112
var isEmpty: Bool {
12-
buffers.withLock(\.isEmpty)
13+
withLock { buffers.isEmpty }
1314
}
1415

1516
init(audioDescription: AudioStreamBasicDescription) {
@@ -23,22 +24,24 @@ final class AudioBuffersQueue {
2324
numberOfPackets: UInt32,
2425
packets: UnsafeMutablePointer<AudioStreamPacketDescription>?
2526
) throws {
26-
guard let buffer = try makeSampleBuffer(
27-
from: Data(bytes: bytes, count: Int(numberOfBytes)),
28-
packetCount: numberOfPackets,
29-
packetDescriptions: packets
30-
) else { return }
31-
updateTimeOffset(for: buffer)
32-
buffers.withLock { $0.append(buffer) }
33-
allBuffers.withLock { $0.append(buffer) }
27+
try withLock {
28+
guard let buffer = try makeSampleBuffer(
29+
from: Data(bytes: bytes, count: Int(numberOfBytes)),
30+
packetCount: numberOfPackets,
31+
packetDescriptions: packets
32+
) else { return }
33+
updateDuration(for: buffer)
34+
buffers.append(buffer)
35+
allBuffers.append(buffer)
36+
}
3437
}
3538

3639
func peek() -> CMSampleBuffer? {
37-
buffers.withLock { $0.first }
40+
withLock { buffers.first }
3841
}
3942

4043
func dequeue() -> CMSampleBuffer? {
41-
buffers.withLock { buffers in
44+
withLock {
4245
if buffers.isEmpty { return nil }
4346
return buffers.removeFirst()
4447
}
@@ -49,20 +52,27 @@ final class AudioBuffersQueue {
4952
}
5053

5154
func removeAll() {
52-
allBuffers.withLock { $0.removeAll() }
53-
buffers.withLock { $0.removeAll() }
54-
duration = .zero
55+
withLock {
56+
allBuffers.removeAll()
57+
buffers.removeAll()
58+
duration = .zero
59+
}
5560
}
5661

5762
func buffer(at time: CMTime) -> CMSampleBuffer? {
58-
allBuffers.withLock { buffers in
59-
buffers.first { $0.timeRange.containsTime(time) }
60-
}
63+
withLock { allBuffers.first { $0.timeRange.containsTime(time) } }
6164
}
6265

63-
func removeBuffer(at time: CMTime) {
64-
allBuffers.withLock { buffers in
65-
buffers.removeAll { $0.timeRange.containsTime(time) }
66+
func flush() {
67+
withLock { buffers.removeAll() }
68+
}
69+
70+
func seek(to time: CMTime) {
71+
withLock {
72+
guard let index = allBuffers.enumerated().first(where: { _, buffer in
73+
buffer.timeRange.containsTime(time)
74+
})?.offset else { return }
75+
buffers = Array(allBuffers[index...])
6676
}
6777
}
6878

@@ -119,7 +129,13 @@ final class AudioBuffersQueue {
119129
}
120130
}
121131

122-
private func updateTimeOffset(for buffer: CMSampleBuffer) {
132+
private func updateDuration(for buffer: CMSampleBuffer) {
123133
duration = buffer.presentationTimeStamp + buffer.duration
124134
}
135+
136+
private func withLock<T>(_ perform: () throws -> T) rethrows -> T {
137+
lock.lock()
138+
defer { lock.unlock() }
139+
return try perform()
140+
}
125141
}

0 commit comments

Comments
 (0)