Skip to content

Commit c8d6440

Browse files
nuno-vieiraStream-SDK-BotStream Bot
authored
Add native audio attachment playback in the message list (#1574)
* Extract shared audio playback into AudioSessionHandler Voice recordings and audio files both drive the same player, so the handler no longer lives next to voice-only views. * Add a linear playback progress bar Audio files need a seekable track instead of the voice-recording waveform. * Add native audio file attachments in the message list Audio files now play inline with a dedicated player instead of rendering as generic file attachments. * Snapshot audio attachments with and without captions Cover both Figma layouts in the message bubble and drop the redundant MessageAttachmentsView snapshot. * Share AudioSessionHandler across audio and voice playback Keep play/pause state in sync when switching between audio files and voice recordings by using a single shared handler. * Share audio playback logic between audio and voice attachments Extract the queue advancing, playback state updates, play button chrome and duration loading into shared components, and group the audio utils under Utils/Audio. * Update CHANGELOG for native audio attachment playback * Update CHANGELOG.md * Initialize audio attachment container from view options Keep the public initializer stable by taking AudioAttachmentViewOptions instead of individual parameters. * Stop audio playback on channel cleanup regardless of voice recording Clean up the shared player whenever one is active so audio attachments do not keep playing after leaving a channel with voice recording off. * Advance audio queues only for the stopped attachment Ignore stop events from other assets so a shared session cannot start the next file in an unrelated message. * Use the shared CommonUI audio file icon Drop the local file-audio asset and pin stream-chat-swift so SwiftUI can use Appearance.Images.iconAudio. * Point StreamChat dependency at the LLC audio branch Track fix/audio-type-attachments-support while the UIKit PR is in review so SwiftUI can build against iconAudio and related changes. * Update StreamChat dependency to latest develop Pin stream-chat-swift at d1bba90 now that the UIKit audio attachment work is merged. * [CI] Snapshots (#1576) Co-authored-by: Stream Bot <ci@getstream.io> --------- Co-authored-by: Stream SDK Bot <60655709+Stream-SDK-Bot@users.noreply.github.com> Co-authored-by: Stream Bot <ci@getstream.io>
1 parent cab0154 commit c8d6440

155 files changed

Lines changed: 1281 additions & 244 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
44
# Upcoming
55

66
### ✅ Added
7+
- Add native audio attachment playback UI in the message list [#1574](https://github.com/GetStream/stream-chat-swiftui/pull/1574)
78
- Add `ViewFactory.makeChannelInfoActionsView` for customizing the actions in the channel info screen [#1573](https://github.com/GetStream/stream-chat-swiftui/pull/1573)
89
- Allow overriding the leave conversation, block user and add members actions in `ChatChannelInfoViewModel` [#1573](https://github.com/GetStream/stream-chat-swiftui/pull/1573)
910

Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/MediaAttachmentsView.swift

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -241,17 +241,9 @@ public struct MediaAttachmentContentView<Factory: ViewFactory>: View {
241241
.onAppear {
242242
guard mediaItem.isVideo,
243243
let url = mediaItem.videoAttachment?.payload.videoURL else { return }
244-
durationTask = Task {
245-
let asset = AVURLAsset(url: url)
246-
await withCheckedContinuation { continuation in
247-
asset.loadValuesAsynchronously(forKeys: ["duration"]) {
248-
let seconds = asset.duration.seconds
249-
if seconds.isFinite && seconds > 0 {
250-
Task { @MainActor in videoDuration = seconds }
251-
}
252-
continuation.resume()
253-
}
254-
}
244+
durationTask = Task { @MainActor in
245+
guard let duration = await AVURLAsset(url: url).loadDuration() else { return }
246+
videoDuration = duration
255247
}
256248
}
257249
.onDisappear {

Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -889,11 +889,12 @@ import SwiftUI
889889
}
890890

891891
private func cleanupAudioPlayer() {
892-
guard utils.composerConfig.isVoiceRecordingEnabled else { return }
893-
utils.audioPlayer.seek(to: 0)
894-
utils.audioPlayer.updateRate(.normal)
895-
utils.audioPlayer.stop()
892+
guard let audioPlayer = utils._audioPlayer else { return }
893+
audioPlayer.seek(to: 0)
894+
audioPlayer.updateRate(.normal)
895+
audioPlayer.stop()
896896
utils._audioPlayer = nil
897+
utils._audioSessionHandler?.resetPlaybackState()
897898
}
898899

899900
deinit {

Sources/StreamChatSwiftUI/ChatComposer/Attachments/ComposerVoiceRecordingAttachmentView.swift

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,15 @@ public struct ComposerVoiceRecordingContainerView: View {
99
@Injected(\.tokens) private var tokens
1010
@Injected(\.utils) private var utils
1111

12-
@StateObject var voiceRecordingHandler = VoiceRecordingHandler()
12+
@ObservedObject var handler: AudioSessionHandler
1313

1414
var addedVoiceRecordings: [AddedVoiceRecording]
1515
var onDiscardAttachment: (String) -> Void
1616

1717
public init(addedVoiceRecordings: [AddedVoiceRecording], onDiscardAttachment: @escaping (String) -> Void) {
1818
self.addedVoiceRecordings = addedVoiceRecordings
1919
self.onDiscardAttachment = onDiscardAttachment
20+
_handler = ObservedObject(wrappedValue: InjectedValues[\.utils].audioSessionHandler)
2021
}
2122

2223
private var player: AudioPlaying {
@@ -27,14 +28,14 @@ public struct ComposerVoiceRecordingContainerView: View {
2728
VStack(spacing: tokens.spacingXxs) {
2829
ForEach(addedVoiceRecordings) { recording in
2930
ComposerVoiceRecordingAttachmentView(
30-
handler: voiceRecordingHandler,
31+
handler: handler,
3132
recording: recording,
3233
onDiscardAttachment: onDiscardAttachment
3334
)
3435
}
3536
}
3637
.onAppear {
37-
player.subscribe(voiceRecordingHandler)
38+
player.subscribe(handler)
3839
}
3940
}
4041
}
@@ -47,7 +48,7 @@ struct ComposerVoiceRecordingAttachmentView: View {
4748
@Injected(\.tokens) private var tokens
4849
@Injected(\.utils) private var utils
4950

50-
@ObservedObject var handler: VoiceRecordingHandler
51+
@ObservedObject var handler: AudioSessionHandler
5152

5253
let recording: AddedVoiceRecording
5354
var onDiscardAttachment: (String) -> Void

Sources/StreamChatSwiftUI/ChatComposer/Attachments/VoiceRecording/ComposerVoiceRecordingInputView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ struct ComposerVoiceRecordingInputView<Factory: ViewFactory>: View {
2323
var discardRecording: @MainActor () -> Void
2424
var previewRecording: @MainActor () -> Void
2525

26-
@StateObject private var handler = VoiceRecordingHandler()
26+
@ObservedObject private var handler = InjectedValues[\.utils].audioSessionHandler
2727

2828
private var player: AudioPlaying { utils.audioPlayer }
2929

Sources/StreamChatSwiftUI/ChatMessageList/AsyncVoiceMessages/VoiceRecordingContainerView.swift

Lines changed: 18 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
// Copyright © 2026 Stream.io Inc. All rights reserved.
33
//
44

5-
import Combine
65
import StreamChat
76
import SwiftUI
87

@@ -18,12 +17,7 @@ public struct VoiceRecordingContainerView<Factory: ViewFactory>: View {
1817
let isFirst: Bool
1918
@Binding var scrolledId: String?
2019

21-
@StateObject var handler = VoiceRecordingHandler()
22-
@State var playingIndex: Int?
23-
24-
private var player: AudioPlaying {
25-
utils.audioPlayer
26-
}
20+
@ObservedObject var handler: AudioSessionHandler
2721

2822
public init(
2923
factory: Factory,
@@ -37,6 +31,7 @@ public struct VoiceRecordingContainerView<Factory: ViewFactory>: View {
3731
self.width = width
3832
self.isFirst = isFirst
3933
_scrolledId = scrolledId
34+
_handler = ObservedObject(wrappedValue: InjectedValues[\.utils].audioSessionHandler)
4035
}
4136

4237
public var body: some View {
@@ -66,27 +61,10 @@ public struct VoiceRecordingContainerView<Factory: ViewFactory>: View {
6661
}
6762
}
6863
.frame(width: width, alignment: message.isRightAligned ? .trailing : .leading)
69-
.onReceive(handler.$context, perform: { value in
70-
guard message.voiceRecordingAttachments.count > 1 else { return }
71-
if value.state == .playing {
72-
let index = message.voiceRecordingAttachments.firstIndex { payload in
73-
payload.voiceRecordingURL == value.assetLocation
74-
}
75-
if index != playingIndex {
76-
playingIndex = index
77-
}
78-
} else if value.state == .stopped, let playingIndex {
79-
if playingIndex < (message.voiceRecordingAttachments.count - 1) {
80-
let next = playingIndex + 1
81-
let nextURL = message.voiceRecordingAttachments[next].voiceRecordingURL
82-
player.loadAsset(from: nextURL)
83-
}
84-
self.playingIndex = nil
85-
}
86-
})
87-
.onAppear {
88-
player.subscribe(handler)
89-
}
64+
.audioPlaybackQueue(
65+
handler: handler,
66+
urls: message.voiceRecordingAttachments.map(\.voiceRecordingURL)
67+
)
9068
}
9169

9270
private func voiceMessageAccessibilityLabel(duration: TimeInterval) -> String {
@@ -105,21 +83,22 @@ struct VoiceRecordingView: View {
10583
@Injected(\.tokens) var tokens
10684
@Injected(\.utils) var utils
10785

108-
@State var loading: Bool = false
109-
@ObservedObject var handler: VoiceRecordingHandler
86+
@ObservedObject var handler: AudioSessionHandler
11087

11188
let addedVoiceRecording: AddedVoiceRecording
11289
var isSentByCurrentUser: Bool = false
11390
var accessibilityLabel: String = ""
11491

11592
private var isActive: Bool { handler.isActive(for: addedVoiceRecording.url) }
11693

94+
private var isLoading: Bool { isActive && handler.context.state == .loading }
95+
11796
private var displayedPlaybackTime: TimeInterval {
11897
handler.displayedTime(for: addedVoiceRecording.url, duration: addedVoiceRecording.duration)
11998
}
12099

121-
private var controlBorderColor: Color? {
122-
isSentByCurrentUser ? Color(colors.chatBorderOnChatOutgoing) : Color(colors.chatBorderOnChatIncoming)
100+
private var controlBorderColor: Color {
101+
colors.chatControlBorder(isSentByCurrentUser: isSentByCurrentUser)
123102
}
124103

125104
var body: some View {
@@ -137,31 +116,17 @@ struct VoiceRecordingView: View {
137116

138117
PlaybackSpeedToggle(handler: handler, borderColor: controlBorderColor)
139118
}
140-
.onReceive(handler.$context) { value in
141-
guard value.assetLocation == addedVoiceRecording.url else { return }
142-
if value.state == .loading {
143-
loading = true
144-
return
145-
} else if loading {
146-
loading = false
147-
}
148-
handler.updatePlaybackState(for: addedVoiceRecording.url)
149-
}
119+
.audioPlaybackStateUpdates(handler: handler, url: addedVoiceRecording.url)
150120
}
151121

152122
private var playButton: some View {
153-
PlayPauseButton(isPlaying: handler.isPlaying && isActive) {
123+
AudioPlaybackButton(
124+
isPlaying: handler.isPlaying && isActive,
125+
isLoading: isLoading,
126+
isSentByCurrentUser: isSentByCurrentUser
127+
) {
154128
handler.togglePlayback(for: addedVoiceRecording.url)
155129
}
156-
.overlay(
157-
Group {
158-
if let controlBorderColor {
159-
Circle().stroke(controlBorderColor, lineWidth: 1)
160-
}
161-
}
162-
)
163-
.opacity(loading ? 0 : 1)
164-
.overlay(loading ? ProgressView() : nil)
165130
}
166131

167132
private var durationAndWaveform: some View {
@@ -200,7 +165,7 @@ struct PlaybackSpeedToggle: View {
200165
@Injected(\.fonts) private var fonts
201166
@Injected(\.tokens) private var tokens
202167

203-
@ObservedObject var handler: VoiceRecordingHandler
168+
@ObservedObject var handler: AudioSessionHandler
204169
var borderColor: Color?
205170

206171
private var resolvedBorderColor: Color {
@@ -225,86 +190,3 @@ struct PlaybackSpeedToggle: View {
225190
.accessibilityValue(Text(handler.rateTitle))
226191
}
227192
}
228-
229-
class VoiceRecordingHandler: ObservableObject, AudioPlayingDelegate {
230-
@Injected(\.utils) private var utils
231-
232-
@Published var context: AudioPlaybackContext = .notLoaded
233-
@Published var isPlaying: Bool = false
234-
@Published var rate: AudioPlaybackRate = .normal
235-
236-
private var player: AudioPlaying { utils.audioPlayer }
237-
238-
func audioPlayer(
239-
_ audioPlayer: AudioPlaying,
240-
didUpdateContext context: AudioPlaybackContext
241-
) {
242-
self.context = context
243-
}
244-
245-
// MARK: - Shared Playback Helpers
246-
247-
var rateTitle: String {
248-
switch rate {
249-
case .half: "x0.5"
250-
default: "x\(Int(rate.rawValue))"
251-
}
252-
}
253-
254-
func updatePlaybackState(for url: URL) {
255-
guard context.assetLocation == url else { return }
256-
switch context.state {
257-
case .playing:
258-
if !isPlaying {
259-
isPlaying = true
260-
player.updateRate(rate)
261-
}
262-
case .stopped, .paused:
263-
isPlaying = false
264-
default:
265-
break
266-
}
267-
}
268-
269-
func togglePlayback(for url: URL) {
270-
if isPlaying {
271-
player.pause()
272-
} else {
273-
player.loadAsset(from: url)
274-
}
275-
}
276-
277-
func cycleRate() {
278-
switch rate {
279-
case .normal: rate = .double
280-
case .double: rate = .half
281-
default: rate = .normal
282-
}
283-
if isPlaying {
284-
player.updateRate(rate)
285-
}
286-
}
287-
288-
func isActive(for url: URL) -> Bool {
289-
context.assetLocation == url
290-
}
291-
292-
/// Returns remaining playback time when playing/paused, or the total duration otherwise.
293-
func displayedTime(for url: URL, duration: TimeInterval) -> TimeInterval {
294-
guard isActive(for: url) else { return duration }
295-
switch context.state {
296-
case .playing, .paused:
297-
let resolvedDuration = max(duration, context.duration)
298-
return max(resolvedDuration - context.currentTime, 0)
299-
default:
300-
return duration
301-
}
302-
}
303-
304-
func seek(to time: TimeInterval, loadingFrom url: URL? = nil) {
305-
if let url, !isActive(for: url) {
306-
player.loadAsset(from: url)
307-
}
308-
player.seek(to: time)
309-
}
310-
}

0 commit comments

Comments
 (0)