From 5f724941ee4a32f050b2d4be79bcb1573651a229 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 19 Feb 2026 14:02:26 +0100 Subject: [PATCH] Voice message: variable play back speed (#5121) * Implement voice message variable playback speed * Move playback speed string to Untranslated https://github.com/element-hq/element-x-ios/pull/5121#discussion_r2822631371 * Address review feedback for voice message playback speed - Persist voice message playback speed as an enum in AppSettings instead of storing an index. - Update playback speed cycling to derive from enum allCases and safely fall back to `.default` if the stored value cannot be resolved. - Apply runtime speed updates in AudioPlayer only when the player is in the `.playing` state. - Keep MediaPlayerProviderTests formatting/indentation style intact while retaining mock playback speed setup. - Regenerate preview snapshots for: - PlaybackSpeedButton - VoiceMessageRoomPlaybackView - VoiceMessageRoomTimelineView * Move VoiceMessagePlaybackSpeed outside AppSettings, remove speedRatio * Stabilize PlaybackSpeedButton width * Sync voice-message speed label - Add voiceMessagePlaybackSpeed to TimelineViewState and bind it from appSettings.$voiceMessagePlaybackSpeed. - Pass that timeline-level speed into VoiceMessageRoomPlaybackView and use it for PlaybackSpeedButton, so labels update consistently across items. - Use @EnvironmentObject in VoiceMessageRoomTimelineContent so the view re-renders when timeline context state changes. - In WaveformInteractionModifier, add .allowsHitTesting(showCursor) to the cursor interaction view so hidden pre-playback cursor hit area does not steal taps from the speed button. --- .../Sources/GeneratedAccessibilityTests.swift | 4 ++ ElementX.xcodeproj/project.pbxproj | 4 ++ .../en.lproj/Untranslated.strings | 3 ++ .../Application/Settings/AppSettings.swift | 14 ++++- .../Generated/Strings+Untranslated.swift | 4 ++ .../Mocks/Generated/GeneratedMocks.swift | 46 ++++++++++++++++ .../TestablePreviewsDictionary.swift | 1 + .../VoiceMessage/PlaybackSpeedButton.swift | 53 +++++++++++++++++++ .../WaveformInteractionModifier.swift | 1 + .../Timeline/TimelineInteractionHandler.swift | 17 +++++- .../Screens/Timeline/TimelineModels.swift | 2 + .../Screens/Timeline/TimelineViewModel.swift | 8 +++ .../VoiceMessageRoomTimelineView.swift | 20 ++++--- .../View/VoiceMessageRoomPlaybackView.swift | 23 +++++--- .../Services/Audio/Player/AudioPlayer.swift | 14 +++-- .../Audio/Player/AudioPlayerProtocol.swift | 6 ++- .../Audio/Player/AudioPlayerState.swift | 8 ++- .../Sources/GeneratedPreviewTests.swift | 7 +++ .../playbackSpeedButton.iPad-en-GB-0.png | 3 ++ .../playbackSpeedButton.iPad-pseudo-0.png | 3 ++ .../playbackSpeedButton.iPhone-en-GB-0.png | 3 ++ .../playbackSpeedButton.iPhone-pseudo-0.png | 3 ++ ...ceMessageRoomPlaybackView.iPad-en-GB-0.png | 4 +- ...eMessageRoomPlaybackView.iPad-pseudo-0.png | 4 +- ...MessageRoomPlaybackView.iPhone-en-GB-0.png | 4 +- ...essageRoomPlaybackView.iPhone-pseudo-0.png | 4 +- ...ceMessageRoomTimelineView.iPad-en-GB-0.png | 4 +- ...eMessageRoomTimelineView.iPad-pseudo-0.png | 4 +- ...MessageRoomTimelineView.iPhone-en-GB-0.png | 4 +- ...essageRoomTimelineView.iPhone-pseudo-0.png | 4 +- UnitTests/Sources/AudioPlayerStateTests.swift | 26 +++++++++ .../Sources/MediaPlayerProviderTests.swift | 2 + .../Sources/VoiceMessageRecorderTests.swift | 1 + 33 files changed, 269 insertions(+), 39 deletions(-) create mode 100644 ElementX/Sources/Other/VoiceMessage/PlaybackSpeedButton.swift create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/playbackSpeedButton.iPad-en-GB-0.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/playbackSpeedButton.iPad-pseudo-0.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/playbackSpeedButton.iPhone-en-GB-0.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/playbackSpeedButton.iPhone-pseudo-0.png diff --git a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift index 62619a5ea..74e970092 100644 --- a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift +++ b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift @@ -411,6 +411,10 @@ extension AccessibilityTests { try await performAccessibilityAudit(named: "PlaceholderScreen_Previews") } + func testPlaybackSpeedButton() async throws { + try await performAccessibilityAudit(named: "PlaybackSpeedButton_Previews") + } + func testPollFormScreen() async throws { try await performAccessibilityAudit(named: "PollFormScreen_Previews") } diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index fe8d98ca9..a692ea1bf 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -124,6 +124,7 @@ 12EC6BC99F373FE5C6EB9B64 /* TimelineMediaPreviewDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 467498BEA681758BE2F80826 /* TimelineMediaPreviewDetailsView.swift */; }; 1307268DC41730E5BCF7D9A0 /* PollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638790D3F915F0909315C47A /* PollView.swift */; }; 1318721F4E5F307586D98112 /* VoiceMessageButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8516302ACCA94A0E680AB3B /* VoiceMessageButton.swift */; }; + ES17LAX66UIQ5OAE6L2BQQE4 /* PlaybackSpeedButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = HW13ISECOFUYATOTX2RU7GDG /* PlaybackSpeedButton.swift */; }; 13C77FDF17C4C6627CFFC205 /* RoomTimelineItemFactoryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D25A35764C7B3DB78954AB5 /* RoomTimelineItemFactoryProtocol.swift */; }; 13CBC470FB619A6393A21908 /* RoomNotificationSettingsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8296D6FB451E25CEC0767BBA /* RoomNotificationSettingsScreenCoordinator.swift */; }; 1443CEEE42491CF7CD8A146A /* XCTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85A1941B874A3BE9CDDF43EF /* XCTestCase.swift */; }; @@ -2557,6 +2558,7 @@ B8108C8F0ACF6A7EB72D0117 /* RoomScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenCoordinator.swift; sourceTree = ""; }; B81B6170DB690013CEB646F4 /* MapLibreModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapLibreModels.swift; sourceTree = ""; }; B8516302ACCA94A0E680AB3B /* VoiceMessageButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageButton.swift; sourceTree = ""; }; + HW13ISECOFUYATOTX2RU7GDG /* PlaybackSpeedButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackSpeedButton.swift; sourceTree = ""; }; B858A61F2A570DFB8DE570A7 /* AggregratedReaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AggregratedReaction.swift; sourceTree = ""; }; B88CE0A058727BC68EEEC6B6 /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = ""; }; B8A3B7637DDBD6AA97AC2545 /* CameraPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraPicker.swift; sourceTree = ""; }; @@ -5599,6 +5601,7 @@ isa = PBXGroup; children = ( AD0FF64B0E6470F66F42E182 /* EstimatedWaveformView.swift */, + HW13ISECOFUYATOTX2RU7GDG /* PlaybackSpeedButton.swift */, B8516302ACCA94A0E680AB3B /* VoiceMessageButton.swift */, FB9EABCA9348DFA27439A809 /* WaveformCursorView.swift */, BFEE91FB8ABB5F5884B6D940 /* WaveformInteractionModifier.swift */, @@ -8800,6 +8803,7 @@ 2CA61BB208CD82EBDB58CD13 /* VideoRoomTimelineView.swift in Sources */, 6FC10A00D268FCD48B631E37 /* ViewFrameReader.swift in Sources */, EED33AFD9334EFD7398707A6 /* VisualListItem.swift in Sources */, + ES17LAX66UIQ5OAE6L2BQQE4 /* PlaybackSpeedButton.swift in Sources */, 1318721F4E5F307586D98112 /* VoiceMessageButton.swift in Sources */, 4681820102DAC8BA586357D4 /* VoiceMessageCache.swift in Sources */, 4F2DF6138E87A4B8C2488CA3 /* VoiceMessageCacheProtocol.swift in Sources */, diff --git a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings index 2b31da559..41cbcc072 100644 --- a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings +++ b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings @@ -4,6 +4,9 @@ /* Used for testing */ "untranslated" = "Untranslated"; +// MARK: - Voice message playback speed +"a11y_playback_speed" = "Playback speed %1$@"; + // MARK: - Soft logout "soft_logout_signin_title" = "Sign in"; diff --git a/ElementX/Sources/Application/Settings/AppSettings.swift b/ElementX/Sources/Application/Settings/AppSettings.swift index 8a707057a..bb1e2027e 100644 --- a/ElementX/Sources/Application/Settings/AppSettings.swift +++ b/ElementX/Sources/Application/Settings/AppSettings.swift @@ -82,6 +82,8 @@ final class AppSettings { case spaceSettingsEnabled case createSpaceEnabled + case voiceMessagePlaybackSpeed + // Doug's tweaks 🔧 case hideUnreadMessagesBadge case hideQuietNotificationAlerts @@ -359,7 +361,10 @@ final class AppSettings { @UserPreference(key: UserDefaultsKeys.optimizeMediaUploads, defaultValue: true, storageType: .userDefaults(store)) var optimizeMediaUploads - + + @UserPreference(key: UserDefaultsKeys.voiceMessagePlaybackSpeed, defaultValue: VoiceMessagePlaybackSpeed.default, storageType: .userDefaults(store)) + var voiceMessagePlaybackSpeed: VoiceMessagePlaybackSpeed + /// Whether or not to show a warning on the media caption composer so the user knows /// that captions might not be visible to users who are using other Matrix clients. let shouldShowMediaCaptionWarning = true @@ -447,3 +452,10 @@ final class AppSettings { } extension AppSettings: CommonSettingsProtocol { } + +enum VoiceMessagePlaybackSpeed: Float, CaseIterable, Codable { + case `default` = 1.0 + case fast = 1.5 + case fastest = 2.0 + case slow = 0.5 +} diff --git a/ElementX/Sources/Generated/Strings+Untranslated.swift b/ElementX/Sources/Generated/Strings+Untranslated.swift index 55cfabb3e..cf2b21ffa 100644 --- a/ElementX/Sources/Generated/Strings+Untranslated.swift +++ b/ElementX/Sources/Generated/Strings+Untranslated.swift @@ -10,6 +10,10 @@ import Foundation // swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces internal enum UntranslatedL10n { + /// Playback speed %1$@ + internal static func a11yPlaybackSpeed(_ p1: Any) -> String { + return UntranslatedL10n.tr("Untranslated", "a11y_playback_speed", String(describing: p1)) + } /// Clear all data currently stored on this device? /// Sign in again to access your account data and messages. internal static var softLogoutClearDataDialogContent: String { return UntranslatedL10n.tr("Untranslated", "soft_logout_clear_data_dialog_content") } diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 165a64739..84a3af535 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -1222,6 +1222,11 @@ class AudioPlayerMock: AudioPlayerProtocol, @unchecked Sendable { set(value) { underlyingState = value } } var underlyingState: MediaPlayerState! + var playbackSpeed: Float { + get { return underlyingPlaybackSpeed } + set(value) { underlyingPlaybackSpeed = value } + } + var underlyingPlaybackSpeed: Float! var actions: AnyPublisher { get { return underlyingActions } set(value) { underlyingActions = value } @@ -1450,6 +1455,47 @@ class AudioPlayerMock: AudioPlayerProtocol, @unchecked Sendable { } await seekToClosure?(progress) } + //MARK: - setPlaybackSpeed + + var setPlaybackSpeedUnderlyingCallsCount = 0 + var setPlaybackSpeedCallsCount: Int { + get { + if Thread.isMainThread { + return setPlaybackSpeedUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = setPlaybackSpeedUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + setPlaybackSpeedUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + setPlaybackSpeedUnderlyingCallsCount = newValue + } + } + } + } + var setPlaybackSpeedCalled: Bool { + return setPlaybackSpeedCallsCount > 0 + } + var setPlaybackSpeedReceivedSpeed: Float? + var setPlaybackSpeedReceivedInvocations: [Float] = [] + var setPlaybackSpeedClosure: ((Float) -> Void)? + + func setPlaybackSpeed(_ speed: Float) { + setPlaybackSpeedCallsCount += 1 + setPlaybackSpeedReceivedSpeed = speed + DispatchQueue.main.async { + self.setPlaybackSpeedReceivedInvocations.append(speed) + } + setPlaybackSpeedClosure?(speed) + } } class AudioRecorderMock: AudioRecorderProtocol, @unchecked Sendable { var actions: AnyPublisher { diff --git a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift index ce788fc57..7eb70a086 100644 --- a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift +++ b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift @@ -110,6 +110,7 @@ enum TestablePreviewsDictionary { "PinnedItemsIndicatorView_Previews" : PinnedItemsIndicatorView_Previews.self, "PlaceholderAvatarImage_Previews" : PlaceholderAvatarImage_Previews.self, "PlaceholderScreen_Previews" : PlaceholderScreen_Previews.self, + "PlaybackSpeedButton_Previews" : PlaybackSpeedButton_Previews.self, "PollFormScreen_Previews" : PollFormScreen_Previews.self, "PollOptionView_Previews" : PollOptionView_Previews.self, "PollRoomTimelineView_Previews" : PollRoomTimelineView_Previews.self, diff --git a/ElementX/Sources/Other/VoiceMessage/PlaybackSpeedButton.swift b/ElementX/Sources/Other/VoiceMessage/PlaybackSpeedButton.swift new file mode 100644 index 000000000..43d67aa6d --- /dev/null +++ b/ElementX/Sources/Other/VoiceMessage/PlaybackSpeedButton.swift @@ -0,0 +1,53 @@ +// +// Copyright 2025 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import SwiftUI + +struct PlaybackSpeedButton: View { + let speed: Float + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + ZStack { + Text("0.0x") + .font(.compound.bodyXSSemibold) + .hidden() + + Text(speedLabel) + .font(.compound.bodyXSSemibold) + .foregroundColor(.compound.iconSecondary) + } + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(.compound.bgCanvasDefault, in: RoundedRectangle(cornerRadius: 12)) + } + .buttonStyle(.plain) + .accessibilityLabel(UntranslatedL10n.a11yPlaybackSpeed(speedLabel)) + } + + private var speedLabel: String { + if speed == Float(Int(speed)) { + "\(Int(speed))x" + } else { + String(format: "%gx", speed) + } + } +} + +struct PlaybackSpeedButton_Previews: PreviewProvider, TestablePreview { + static var previews: some View { + HStack(spacing: 8) { + PlaybackSpeedButton(speed: 0.5) { } + PlaybackSpeedButton(speed: 1.0) { } + PlaybackSpeedButton(speed: 1.5) { } + PlaybackSpeedButton(speed: 2.0) { } + } + .padding() + .background(Color.gray) + } +} diff --git a/ElementX/Sources/Other/VoiceMessage/WaveformInteractionModifier.swift b/ElementX/Sources/Other/VoiceMessage/WaveformInteractionModifier.swift index c8fb7cabb..d8c346f36 100644 --- a/ElementX/Sources/Other/VoiceMessage/WaveformInteractionModifier.swift +++ b/ElementX/Sources/Other/VoiceMessage/WaveformInteractionModifier.swift @@ -41,6 +41,7 @@ private struct WaveformInteractionModifier: ViewModifier { onSeek(max(0, min(progress, 1.0))) }) .offset(x: -cursorInteractiveSize / 2, y: 0) + .allowsHitTesting(showCursor) } .gesture(SpatialTapGesture() .onEnded { tapGesture in diff --git a/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift b/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift index 6e6c70090..7e27680e4 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift @@ -417,7 +417,21 @@ class TimelineInteractionHandler { } // MARK: Audio Playback - + + func changePlaybackSpeed(for itemID: TimelineItemIdentifier) { + let availableSpeeds = VoiceMessagePlaybackSpeed.allCases + guard let currentIndex = availableSpeeds.firstIndex(of: appSettings.voiceMessagePlaybackSpeed) else { + appSettings.voiceMessagePlaybackSpeed = .default + audioPlayerState(for: itemID)?.setPlaybackSpeed(VoiceMessagePlaybackSpeed.default.rawValue) + return + } + + let nextIndex = (currentIndex + 1) % availableSpeeds.count + let nextSpeed = availableSpeeds[nextIndex] + appSettings.voiceMessagePlaybackSpeed = nextSpeed + audioPlayerState(for: itemID)?.setPlaybackSpeed(nextSpeed.rawValue) + } + func playPauseAudio(for itemID: TimelineItemIdentifier) async { MXLog.info("Toggle play/pause audio for itemID \(itemID)") guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID) else { @@ -447,6 +461,7 @@ class TimelineInteractionHandler { // Ensure this one is attached if !audioPlayerState.isAttached { audioPlayerState.attachAudioPlayer(audioPlayer) + audioPlayerState.setPlaybackSpeed(appSettings.voiceMessagePlaybackSpeed.rawValue) } // Detach all other states diff --git a/ElementX/Sources/Screens/Timeline/TimelineModels.swift b/ElementX/Sources/Screens/Timeline/TimelineModels.swift index 18989834b..b2730eb99 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineModels.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineModels.swift @@ -42,6 +42,7 @@ enum TimelineViewPollAction { enum TimelineAudioPlayerAction { case playPause(itemID: TimelineItemIdentifier) case seek(itemID: TimelineItemIdentifier, progress: Double) + case changePlaybackSpeed(itemID: TimelineItemIdentifier) } enum TimelineViewAction { @@ -114,6 +115,7 @@ struct TimelineViewState: BindableState { var isViewSourceEnabled: Bool var areThreadsEnabled: Bool var linkPreviewsEnabled: Bool + var voiceMessagePlaybackSpeed = VoiceMessagePlaybackSpeed.default.rawValue let hasPredecessor: Bool diff --git a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift index 78d79b959..597c7cee8 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift @@ -102,6 +102,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { isViewSourceEnabled: appSettings.viewSourceEnabled, areThreadsEnabled: appSettings.threadsEnabled, linkPreviewsEnabled: appSettings.linkPreviewsEnabled, + voiceMessagePlaybackSpeed: appSettings.voiceMessagePlaybackSpeed.rawValue, hasPredecessor: roomProxy.predecessorRoom != nil, pinnedEventIDs: roomProxy.infoPublisher.value.pinnedEventIDs, emojiProvider: emojiProvider, @@ -370,6 +371,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { Task { await timelineInteractionHandler.playPauseAudio(for: itemID) } case .seek(let itemID, let progress): Task { await timelineInteractionHandler.seekAudio(for: itemID, progress: progress) } + case .changePlaybackSpeed(let itemID): + timelineInteractionHandler.changePlaybackSpeed(for: itemID) } } @@ -540,6 +543,11 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { appSettings.$threadsEnabled .weakAssign(to: \.state.areThreadsEnabled, on: self) .store(in: &cancellables) + + appSettings.$voiceMessagePlaybackSpeed + .map(\.rawValue) + .weakAssign(to: \.state.voiceMessagePlaybackSpeed, on: self) + .store(in: &cancellables) userSession.clientProxy.timelineMediaVisibilityPublisher .removeDuplicates() diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/VoiceMessageRoomTimelineView.swift b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/VoiceMessageRoomTimelineView.swift index 5fe5c2387..8d80a8ae5 100644 --- a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/VoiceMessageRoomTimelineView.swift +++ b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/VoiceMessageRoomTimelineView.swift @@ -23,7 +23,7 @@ struct VoiceMessageRoomTimelineView: View { } struct VoiceMessageRoomTimelineContent: View { - @Environment(\.timelineContext) private var context + @EnvironmentObject private var context: TimelineViewModel.Context @State private var resumePlaybackAfterScrubbing = false let timelineItem: VoiceMessageRoomTimelineItem @@ -33,27 +33,33 @@ struct VoiceMessageRoomTimelineContent: View { VoiceMessageRoomPlaybackView(playerState: playerState, onPlayPause: onPlaybackPlayPause, onSeek: { onPlaybackSeek($0) }, - onScrubbing: { onPlaybackScrubbing($0) }) + onScrubbing: { onPlaybackScrubbing($0) }, + playbackSpeed: context.viewState.voiceMessagePlaybackSpeed, + onPlaybackSpeedChange: onPlaybackSpeedChange) .fixedSize(horizontal: false, vertical: true) } - + private func onPlaybackPlayPause() { - context?.send(viewAction: .handleAudioPlayerAction(.playPause(itemID: timelineItem.id))) + context.send(viewAction: .handleAudioPlayerAction(.playPause(itemID: timelineItem.id))) + } + + private func onPlaybackSpeedChange() { + context.send(viewAction: .handleAudioPlayerAction(.changePlaybackSpeed(itemID: timelineItem.id))) } private func onPlaybackSeek(_ progress: Double) { - context?.send(viewAction: .handleAudioPlayerAction(.seek(itemID: timelineItem.id, progress: progress))) + context.send(viewAction: .handleAudioPlayerAction(.seek(itemID: timelineItem.id, progress: progress))) } private func onPlaybackScrubbing(_ dragging: Bool) { if dragging { if playerState.playbackState == .playing { resumePlaybackAfterScrubbing = true - context?.send(viewAction: .handleAudioPlayerAction(.playPause(itemID: timelineItem.id))) + context.send(viewAction: .handleAudioPlayerAction(.playPause(itemID: timelineItem.id))) } } else { if resumePlaybackAfterScrubbing { - context?.send(viewAction: .handleAudioPlayerAction(.playPause(itemID: timelineItem.id))) + context.send(viewAction: .handleAudioPlayerAction(.playPause(itemID: timelineItem.id))) resumePlaybackAfterScrubbing = false } } diff --git a/ElementX/Sources/Screens/Timeline/View/VoiceMessageRoomPlaybackView.swift b/ElementX/Sources/Screens/Timeline/View/VoiceMessageRoomPlaybackView.swift index c9ebb7937..c0b0a1e30 100644 --- a/ElementX/Sources/Screens/Timeline/View/VoiceMessageRoomPlaybackView.swift +++ b/ElementX/Sources/Screens/Timeline/View/VoiceMessageRoomPlaybackView.swift @@ -19,18 +19,23 @@ struct VoiceMessageRoomPlaybackView: View { let onPlayPause: () -> Void let onSeek: (Double) -> Void let onScrubbing: (Bool) -> Void - + let playbackSpeed: Float + let onPlaybackSpeedChange: () -> Void + var body: some View { HStack(spacing: 8) { VoiceMessageButton(state: .init(playerState.playerButtonPlaybackState), size: .medium, action: onPlayPause) - Text(timeLabelContent) - .lineLimit(1) - .font(.compound.bodySMSemibold) - .foregroundColor(.compound.textSecondary) - .monospacedDigit() - .fixedSize(horizontal: true, vertical: true) + VStack(spacing: 2) { + PlaybackSpeedButton(speed: playbackSpeed, onTap: onPlaybackSpeedChange) + Text(timeLabelContent) + .lineLimit(1) + .font(.compound.bodyXSSemibold) + .foregroundColor(.compound.textSecondary) + .monospacedDigit() + .fixedSize(horizontal: true, vertical: true) + } waveformView .waveformInteraction(isDragging: $isDragging, @@ -129,7 +134,9 @@ struct VoiceMessageRoomPlaybackView_Previews: PreviewProvider, TestablePreview { VoiceMessageRoomPlaybackView(playerState: playerState, onPlayPause: { }, onSeek: { value in Task { await playerState.updateState(progress: value) } }, - onScrubbing: { _ in }) + onScrubbing: { _ in }, + playbackSpeed: playerState.playbackSpeed, + onPlaybackSpeedChange: { }) .fixedSize(horizontal: false, vertical: true) } } diff --git a/ElementX/Sources/Services/Audio/Player/AudioPlayer.swift b/ElementX/Sources/Services/Audio/Player/AudioPlayer.swift index d88cde3cd..5620b2105 100644 --- a/ElementX/Sources/Services/Audio/Player/AudioPlayer.swift +++ b/ElementX/Sources/Services/Audio/Player/AudioPlayer.swift @@ -48,7 +48,8 @@ class AudioPlayer: NSObject, AudioPlayerProtocol { private let releaseAudioSessionTimeoutInterval = 5.0 private(set) var playbackURL: URL? - + private(set) var playbackSpeed: Float = 1.0 + private var deinitInProgress = false var duration: TimeInterval { @@ -106,7 +107,7 @@ class AudioPlayer: NSObject, AudioPlayerProtocol { func play() { isStopped = false setupAudioSession() - internalAudioPlayer?.play() + internalAudioPlayer?.rate = playbackSpeed } func pause() { @@ -128,7 +129,14 @@ class AudioPlayer: NSObject, AudioPlayerProtocol { let time = progress * duration await internalAudioPlayer.seek(to: CMTime(seconds: time, preferredTimescale: 60)) } - + + func setPlaybackSpeed(_ speed: Float) { + playbackSpeed = speed + if state == .playing { + internalAudioPlayer?.rate = speed + } + } + // MARK: - Private private func setupAudioSession() { diff --git a/ElementX/Sources/Services/Audio/Player/AudioPlayerProtocol.swift b/ElementX/Sources/Services/Audio/Player/AudioPlayerProtocol.swift index 3c00a01ac..d5e0b525f 100644 --- a/ElementX/Sources/Services/Audio/Player/AudioPlayerProtocol.swift +++ b/ElementX/Sources/Services/Audio/Player/AudioPlayerProtocol.swift @@ -40,15 +40,17 @@ protocol AudioPlayerProtocol: AnyObject { var currentTime: TimeInterval { get } var playbackURL: URL? { get } var state: MediaPlayerState { get } - + var playbackSpeed: Float { get } + var actions: AnyPublisher { get } - + func load(sourceURL: URL, playbackURL: URL, autoplay: Bool) func reset() func play() func pause() func stop() func seek(to progress: Double) async + func setPlaybackSpeed(_ speed: Float) } // sourcery: AutoMockable diff --git a/ElementX/Sources/Services/Audio/Player/AudioPlayerState.swift b/ElementX/Sources/Services/Audio/Player/AudioPlayerState.swift index 07b71eb14..823fb49f8 100644 --- a/ElementX/Sources/Services/Audio/Player/AudioPlayerState.swift +++ b/ElementX/Sources/Services/Audio/Player/AudioPlayerState.swift @@ -36,6 +36,7 @@ class AudioPlayerState: ObservableObject, Identifiable { /// It's similar to `playbackState`, with the a difference: `.loading` /// updates are delayed by a fixed amount of time @Published private(set) var playerButtonPlaybackState: AudioPlayerPlaybackState + @Published private(set) var playbackSpeed: Float = 1.0 private weak var audioPlayer: AudioPlayerProtocol? private var audioPlayerSubscription: AnyCancellable? @@ -110,7 +111,12 @@ class AudioPlayerState: ObservableObject, Identifiable { func reportError() { playbackState = .error } - + + func setPlaybackSpeed(_ speed: Float) { + playbackSpeed = speed + audioPlayer?.setPlaybackSpeed(speed) + } + // MARK: - Private private func subscribeToAudioPlayer(audioPlayer: AudioPlayerProtocol) { diff --git a/PreviewTests/Sources/GeneratedPreviewTests.swift b/PreviewTests/Sources/GeneratedPreviewTests.swift index 7a66b05ae..f87416bf4 100644 --- a/PreviewTests/Sources/GeneratedPreviewTests.swift +++ b/PreviewTests/Sources/GeneratedPreviewTests.swift @@ -718,6 +718,13 @@ extension PreviewTests { } } + func testPlaybackSpeedButton() async throws { + AppSettings.resetAllSettings() // Ensure this test's previews start with fresh settings. + for (index, preview) in PlaybackSpeedButton_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + func testPollFormScreen() async throws { AppSettings.resetAllSettings() // Ensure this test's previews start with fresh settings. for (index, preview) in PollFormScreen_Previews._allPreviews.enumerated() { diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/playbackSpeedButton.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/playbackSpeedButton.iPad-en-GB-0.png new file mode 100644 index 000000000..35a8a4bbb --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/playbackSpeedButton.iPad-en-GB-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7473df382774c7badd74735d39bd84d4669172870eb2013b5549f3c31714aef3 +size 71249 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/playbackSpeedButton.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/playbackSpeedButton.iPad-pseudo-0.png new file mode 100644 index 000000000..35a8a4bbb --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/playbackSpeedButton.iPad-pseudo-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7473df382774c7badd74735d39bd84d4669172870eb2013b5549f3c31714aef3 +size 71249 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/playbackSpeedButton.iPhone-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/playbackSpeedButton.iPhone-en-GB-0.png new file mode 100644 index 000000000..dbf30c2d2 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/playbackSpeedButton.iPhone-en-GB-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:567bc2d7980ce4abb548c1851b7a1600b5a4da86a13a90006c0359c378f37ba4 +size 30008 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/playbackSpeedButton.iPhone-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/playbackSpeedButton.iPhone-pseudo-0.png new file mode 100644 index 000000000..dbf30c2d2 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/playbackSpeedButton.iPhone-pseudo-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:567bc2d7980ce4abb548c1851b7a1600b5a4da86a13a90006c0359c378f37ba4 +size 30008 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/voiceMessageRoomPlaybackView.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/voiceMessageRoomPlaybackView.iPad-en-GB-0.png index a5a8b900e..eb8d77fd3 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/voiceMessageRoomPlaybackView.iPad-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/voiceMessageRoomPlaybackView.iPad-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2bae7bf388a08eea2c5e13c0ef6d0c51858dc9f46dcf5cca5b7dc79f6270410f -size 71827 +oid sha256:15ce484f5c7dfcbd8c5f226c11962e5a0cc51f67e38f04f61879c7dbd41a1a78 +size 72637 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/voiceMessageRoomPlaybackView.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/voiceMessageRoomPlaybackView.iPad-pseudo-0.png index a5a8b900e..eb8d77fd3 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/voiceMessageRoomPlaybackView.iPad-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/voiceMessageRoomPlaybackView.iPad-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2bae7bf388a08eea2c5e13c0ef6d0c51858dc9f46dcf5cca5b7dc79f6270410f -size 71827 +oid sha256:15ce484f5c7dfcbd8c5f226c11962e5a0cc51f67e38f04f61879c7dbd41a1a78 +size 72637 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/voiceMessageRoomPlaybackView.iPhone-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/voiceMessageRoomPlaybackView.iPhone-en-GB-0.png index 1663f7aa2..3a3cb5547 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/voiceMessageRoomPlaybackView.iPhone-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/voiceMessageRoomPlaybackView.iPhone-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bc034b48c12a30db34d74e7b105886ce33ed9d37b7ebd97bd59f070d2af806b6 -size 30299 +oid sha256:09fef09be6c353e64124f0f8044140ffca8bab8f3ae542445b4b6d0a2dd78b5f +size 30861 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/voiceMessageRoomPlaybackView.iPhone-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/voiceMessageRoomPlaybackView.iPhone-pseudo-0.png index 1663f7aa2..3a3cb5547 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/voiceMessageRoomPlaybackView.iPhone-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/voiceMessageRoomPlaybackView.iPhone-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bc034b48c12a30db34d74e7b105886ce33ed9d37b7ebd97bd59f070d2af806b6 -size 30299 +oid sha256:09fef09be6c353e64124f0f8044140ffca8bab8f3ae542445b4b6d0a2dd78b5f +size 30861 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/voiceMessageRoomTimelineView.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/voiceMessageRoomTimelineView.iPad-en-GB-0.png index 36228f265..e1f20df35 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/voiceMessageRoomTimelineView.iPad-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/voiceMessageRoomTimelineView.iPad-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f518c5ce36c085ec697501297d0303044042b8df1341fcf0d02b08b3eef58959 -size 77812 +oid sha256:6b2ceba201b14a637977eb6b0f7b15ac119138402a9e502da002305ddce95bc0 +size 78972 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/voiceMessageRoomTimelineView.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/voiceMessageRoomTimelineView.iPad-pseudo-0.png index 943929508..e1f20df35 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/voiceMessageRoomTimelineView.iPad-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/voiceMessageRoomTimelineView.iPad-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3d456a81d249a01c6817c0bb35bf2a501ca641918009533c3a8dbb9db2739456 -size 78571 +oid sha256:6b2ceba201b14a637977eb6b0f7b15ac119138402a9e502da002305ddce95bc0 +size 78972 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/voiceMessageRoomTimelineView.iPhone-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/voiceMessageRoomTimelineView.iPhone-en-GB-0.png index 65b8e5e82..5fe979daa 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/voiceMessageRoomTimelineView.iPhone-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/voiceMessageRoomTimelineView.iPhone-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cef3e1dc07041a0624fe8f900b36c61962dc6d0f956fc532deb3262853448c10 -size 36670 +oid sha256:65d7f7c53532549016386f37ea663fd7cce7f2827db931892622de94c6bff980 +size 37285 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/voiceMessageRoomTimelineView.iPhone-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/voiceMessageRoomTimelineView.iPhone-pseudo-0.png index 1ef516280..5fe979daa 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/voiceMessageRoomTimelineView.iPhone-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/voiceMessageRoomTimelineView.iPhone-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:abf4ef40082a65fa07969637dfdaf2b218953fa45281a7b0d62dd532889c9e44 -size 37417 +oid sha256:65d7f7c53532549016386f37ea663fd7cce7f2827db931892622de94c6bff980 +size 37285 diff --git a/UnitTests/Sources/AudioPlayerStateTests.swift b/UnitTests/Sources/AudioPlayerStateTests.swift index fdd41dac5..be59a2eaa 100644 --- a/UnitTests/Sources/AudioPlayerStateTests.swift +++ b/UnitTests/Sources/AudioPlayerStateTests.swift @@ -31,6 +31,7 @@ struct AudioPlayerStateTests { audioPlayerMock.state = .stopped audioPlayerMock.currentTime = 0.0 audioPlayerMock.duration = 0.0 + audioPlayerMock.playbackSpeed = 1.0 audioPlayerMock.seekToClosure = { [audioPlayerSeekCallsSubject] progress in audioPlayerSeekCallsSubject?.send(progress) } @@ -274,6 +275,31 @@ struct AudioPlayerStateTests { #expect(!audioPlayerState.showProgressIndicator) } + func testSetPlaybackSpeed() { + audioPlayerState.attachAudioPlayer(audioPlayerMock) + + XCTAssertEqual(audioPlayerState.playbackSpeed, 1.0) + + audioPlayerState.setPlaybackSpeed(1.5) + XCTAssertEqual(audioPlayerState.playbackSpeed, 1.5) + XCTAssertEqual(audioPlayerMock.setPlaybackSpeedReceivedSpeed, 1.5) + + audioPlayerState.setPlaybackSpeed(2.0) + XCTAssertEqual(audioPlayerState.playbackSpeed, 2.0) + XCTAssertEqual(audioPlayerMock.setPlaybackSpeedReceivedSpeed, 2.0) + + audioPlayerState.setPlaybackSpeed(0.5) + XCTAssertEqual(audioPlayerState.playbackSpeed, 0.5) + XCTAssertEqual(audioPlayerMock.setPlaybackSpeedReceivedSpeed, 0.5) + } + + func testSetPlaybackSpeedWithoutPlayer() { + XCTAssertEqual(audioPlayerState.playbackSpeed, 1.0) + + audioPlayerState.setPlaybackSpeed(2.0) + XCTAssertEqual(audioPlayerState.playbackSpeed, 2.0) + } + @Test func audioPlayerActionsDidFailed() async throws { audioPlayerState.attachAudioPlayer(audioPlayerMock) diff --git a/UnitTests/Sources/MediaPlayerProviderTests.swift b/UnitTests/Sources/MediaPlayerProviderTests.swift index b47d21775..549b3f541 100644 --- a/UnitTests/Sources/MediaPlayerProviderTests.swift +++ b/UnitTests/Sources/MediaPlayerProviderTests.swift @@ -42,6 +42,7 @@ struct MediaPlayerProviderTests { func detachAllStates() { let audioPlayer = AudioPlayerMock() audioPlayer.actions = PassthroughSubject().eraseToAnyPublisher() + audioPlayer.playbackSpeed = 1.0 let audioPlayerStates = Array(repeating: AudioPlayerState(id: .timelineItemIdentifier(.randomEvent), title: "", duration: 0), count: 10) for audioPlayerState in audioPlayerStates { @@ -62,6 +63,7 @@ struct MediaPlayerProviderTests { func detachAllStatesWithException() { let audioPlayer = AudioPlayerMock() audioPlayer.actions = PassthroughSubject().eraseToAnyPublisher() + audioPlayer.playbackSpeed = 1.0 let audioPlayerStates = Array(repeating: AudioPlayerState(id: .timelineItemIdentifier(.randomEvent), title: "", duration: 0), count: 10) for audioPlayerState in audioPlayerStates { diff --git a/UnitTests/Sources/VoiceMessageRecorderTests.swift b/UnitTests/Sources/VoiceMessageRecorderTests.swift index 2daa26c8f..99d1028a1 100644 --- a/UnitTests/Sources/VoiceMessageRecorderTests.swift +++ b/UnitTests/Sources/VoiceMessageRecorderTests.swift @@ -42,6 +42,7 @@ class VoiceMessageRecorderTests: XCTestCase { audioPlayer = AudioPlayerMock() audioPlayer.actions = audioPlayerActions audioPlayer.state = .stopped + audioPlayer.playbackSpeed = 1.0 mediaPlayerProvider = MediaPlayerProviderMock() mediaPlayerProvider.player = audioPlayer