diff --git a/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings b/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings index 06910c8ec..7388a2ca3 100644 --- a/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings @@ -20,6 +20,7 @@ "a11y_paused_voice_message" = "Voice message, duration: %1$@, current position: %2$@"; "a11y_pin_field" = "PIN field"; "a11y_play" = "Play"; +"a11y_playback_speed" = "Playback speed"; "a11y_poll_end" = "Ended poll"; "a11y_polls_will_remove_selection" = "Will remove previous selection"; "a11y_polls_winning_answer" = "This is the winning answer"; diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index b3b857ac8..9e0b8cd73 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -20,6 +20,7 @@ "a11y_paused_voice_message" = "Voice message, duration: %1$@, current position: %2$@"; "a11y_pin_field" = "PIN field"; "a11y_play" = "Play"; +"a11y_playback_speed" = "Playback speed"; "a11y_poll_end" = "Ended poll"; "a11y_polls_will_remove_selection" = "Will remove previous selection"; "a11y_polls_winning_answer" = "This is the winning answer"; diff --git a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings index 41cbcc072..2b31da559 100644 --- a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings +++ b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings @@ -4,9 +4,6 @@ /* 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 bb1e2027e..3768a9d74 100644 --- a/ElementX/Sources/Application/Settings/AppSettings.swift +++ b/ElementX/Sources/Application/Settings/AppSettings.swift @@ -458,4 +458,17 @@ enum VoiceMessagePlaybackSpeed: Float, CaseIterable, Codable { case fast = 1.5 case fastest = 2.0 case slow = 0.5 + + var label: String { + switch self { + case .default, .fastest: + rawValue.formatted(.number.precision(.fractionLength(0))) + "×" + case .fast, .slow: + rawValue.formatted(.number.precision(.fractionLength(1))) + "×" + } + } + + var placeholder: String { + 0.0.formatted(.number.precision(.fractionLength(1))) + "×" + } } diff --git a/ElementX/Sources/Generated/Strings+Untranslated.swift b/ElementX/Sources/Generated/Strings+Untranslated.swift index cf2b21ffa..55cfabb3e 100644 --- a/ElementX/Sources/Generated/Strings+Untranslated.swift +++ b/ElementX/Sources/Generated/Strings+Untranslated.swift @@ -10,10 +10,6 @@ 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/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index e766fb34d..b071947b8 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -64,6 +64,8 @@ internal enum L10n { internal static var a11yPinField: String { return L10n.tr("Localizable", "a11y_pin_field") } /// Play internal static var a11yPlay: String { return L10n.tr("Localizable", "a11y_play") } + /// Playback speed + internal static var a11yPlaybackSpeed: String { return L10n.tr("Localizable", "a11y_playback_speed") } /// Poll internal static var a11yPoll: String { return L10n.tr("Localizable", "a11y_poll") } /// Ended poll diff --git a/ElementX/Sources/Other/VoiceMessage/PlaybackSpeedButton.swift b/ElementX/Sources/Other/VoiceMessage/PlaybackSpeedButton.swift index 43d67aa6d..ffb83c47b 100644 --- a/ElementX/Sources/Other/VoiceMessage/PlaybackSpeedButton.swift +++ b/ElementX/Sources/Other/VoiceMessage/PlaybackSpeedButton.swift @@ -8,17 +8,17 @@ import SwiftUI struct PlaybackSpeedButton: View { - let speed: Float + let speed: VoiceMessagePlaybackSpeed let onTap: () -> Void var body: some View { Button(action: onTap) { ZStack { - Text("0.0x") + Text(speed.placeholder) .font(.compound.bodyXSSemibold) .hidden() - Text(speedLabel) + Text(speed.label) .font(.compound.bodyXSSemibold) .foregroundColor(.compound.iconSecondary) } @@ -27,25 +27,17 @@ struct PlaybackSpeedButton: View { .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) - } + .accessibilityLabel(L10n.a11yPlaybackSpeed) + .accessibilityValue(speed.label) } } 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) { } + ForEach(VoiceMessagePlaybackSpeed.allCases, id: \.self) { speed in + PlaybackSpeedButton(speed: speed) { } + } } .padding() .background(Color.gray) diff --git a/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift b/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift index 7e27680e4..aa919070e 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift @@ -422,14 +422,14 @@ class TimelineInteractionHandler { let availableSpeeds = VoiceMessagePlaybackSpeed.allCases guard let currentIndex = availableSpeeds.firstIndex(of: appSettings.voiceMessagePlaybackSpeed) else { appSettings.voiceMessagePlaybackSpeed = .default - audioPlayerState(for: itemID)?.setPlaybackSpeed(VoiceMessagePlaybackSpeed.default.rawValue) + audioPlayerState(for: itemID)?.setPlaybackSpeed(VoiceMessagePlaybackSpeed.default) return } let nextIndex = (currentIndex + 1) % availableSpeeds.count let nextSpeed = availableSpeeds[nextIndex] appSettings.voiceMessagePlaybackSpeed = nextSpeed - audioPlayerState(for: itemID)?.setPlaybackSpeed(nextSpeed.rawValue) + audioPlayerState(for: itemID)?.setPlaybackSpeed(nextSpeed) } func playPauseAudio(for itemID: TimelineItemIdentifier) async { @@ -461,7 +461,6 @@ class TimelineInteractionHandler { // Ensure this one is attached if !audioPlayerState.isAttached { audioPlayerState.attachAudioPlayer(audioPlayer) - audioPlayerState.setPlaybackSpeed(appSettings.voiceMessagePlaybackSpeed.rawValue) } // Detach all other states @@ -518,7 +517,9 @@ class TimelineInteractionHandler { let playerState = AudioPlayerState(id: .timelineItemIdentifier(itemID), title: L10n.commonVoiceMessage, duration: voiceMessageRoomTimelineItem.content.duration, - waveform: voiceMessageRoomTimelineItem.content.waveform) + waveform: voiceMessageRoomTimelineItem.content.waveform, + playbackSpeed: appSettings.voiceMessagePlaybackSpeed, + playbackSpeedPublisher: appSettings.$voiceMessagePlaybackSpeed) mediaPlayerProvider.register(audioPlayerState: playerState) return playerState } diff --git a/ElementX/Sources/Screens/Timeline/TimelineModels.swift b/ElementX/Sources/Screens/Timeline/TimelineModels.swift index b2730eb99..186a5ecf5 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineModels.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineModels.swift @@ -115,7 +115,6 @@ 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 597c7cee8..a3d6f1666 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift @@ -102,7 +102,6 @@ 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, @@ -543,11 +542,6 @@ 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 8d80a8ae5..8a35a9bb0 100644 --- a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/VoiceMessageRoomTimelineView.swift +++ b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/VoiceMessageRoomTimelineView.swift @@ -6,6 +6,7 @@ // Please see LICENSE files in the repository root for full details. // +import Combine import Foundation import SwiftUI @@ -23,7 +24,7 @@ struct VoiceMessageRoomTimelineView: View { } struct VoiceMessageRoomTimelineContent: View { - @EnvironmentObject private var context: TimelineViewModel.Context + @Environment(\.timelineContext) private var context @State private var resumePlaybackAfterScrubbing = false let timelineItem: VoiceMessageRoomTimelineItem @@ -34,32 +35,31 @@ struct VoiceMessageRoomTimelineContent: View { onPlayPause: onPlaybackPlayPause, onSeek: { onPlaybackSeek($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))) + 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 c0b0a1e30..899c33512 100644 --- a/ElementX/Sources/Screens/Timeline/View/VoiceMessageRoomPlaybackView.swift +++ b/ElementX/Sources/Screens/Timeline/View/VoiceMessageRoomPlaybackView.swift @@ -19,7 +19,6 @@ struct VoiceMessageRoomPlaybackView: View { let onPlayPause: () -> Void let onSeek: (Double) -> Void let onScrubbing: (Bool) -> Void - let playbackSpeed: Float let onPlaybackSpeedChange: () -> Void var body: some View { @@ -28,7 +27,8 @@ struct VoiceMessageRoomPlaybackView: View { size: .medium, action: onPlayPause) VStack(spacing: 2) { - PlaybackSpeedButton(speed: playbackSpeed, onTap: onPlaybackSpeedChange) + PlaybackSpeedButton(speed: playerState.playbackSpeed, + onTap: onPlaybackSpeedChange) Text(timeLabelContent) .lineLimit(1) .font(.compound.bodyXSSemibold) @@ -135,7 +135,6 @@ struct VoiceMessageRoomPlaybackView_Previews: PreviewProvider, TestablePreview { onPlayPause: { }, onSeek: { value in Task { await playerState.updateState(progress: value) } }, onScrubbing: { _ in }, - playbackSpeed: playerState.playbackSpeed, onPlaybackSpeedChange: { }) .fixedSize(horizontal: false, vertical: true) } diff --git a/ElementX/Sources/Services/Audio/Player/AudioPlayerState.swift b/ElementX/Sources/Services/Audio/Player/AudioPlayerState.swift index 823fb49f8..d6c7d850c 100644 --- a/ElementX/Sources/Services/Audio/Player/AudioPlayerState.swift +++ b/ElementX/Sources/Services/Audio/Player/AudioPlayerState.swift @@ -36,7 +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 + @Published private(set) var playbackSpeed: VoiceMessagePlaybackSpeed private weak var audioPlayer: AudioPlayerProtocol? private var audioPlayerSubscription: AnyCancellable? @@ -59,14 +59,21 @@ class AudioPlayerState: ObservableObject, Identifiable { displayLink != nil } - init(id: AudioPlayerStateIdentifier, title: String, duration: Double, waveform: EstimatedWaveform? = nil, progress: Double = 0.0) { + init(id: AudioPlayerStateIdentifier, title: String, + duration: Double, + waveform: EstimatedWaveform? = nil, + progress: Double = 0.0, + playbackSpeed: VoiceMessagePlaybackSpeed = .default, + playbackSpeedPublisher: AnyPublisher? = nil) { self.id = id self.title = title self.duration = duration self.waveform = waveform ?? EstimatedWaveform(data: []) self.progress = progress + self.playbackSpeed = playbackSpeed playbackState = .stopped playerButtonPlaybackState = .stopped + playbackSpeedPublisher?.assign(to: &$playbackSpeed) setupPlaybackStateSubscription() } @@ -98,6 +105,7 @@ class AudioPlayerState: ObservableObject, Identifiable { playbackState = .loading self.audioPlayer = audioPlayer subscribeToAudioPlayer(audioPlayer: audioPlayer) + setPlaybackSpeed(playbackSpeed) } func detachAudioPlayer() { @@ -112,9 +120,9 @@ class AudioPlayerState: ObservableObject, Identifiable { playbackState = .error } - func setPlaybackSpeed(_ speed: Float) { + func setPlaybackSpeed(_ speed: VoiceMessagePlaybackSpeed) { playbackSpeed = speed - audioPlayer?.setPlaybackSpeed(speed) + audioPlayer?.setPlaybackSpeed(speed.rawValue) } // MARK: - Private