From 221e3cbb4b560c33a26e055464b1e9280870a0b1 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Mon, 1 Dec 2025 11:28:36 +0100 Subject: [PATCH] Add recording player --- .../music-notes.imageset/Contents.json | 21 ++ .../music-notes.imageset/music-notes.svg | 1 + .../share-network.imageset/share-network.svg | 2 +- .../UI/Main/Fragments/CustomBottomSheet.swift | 10 - .../RecordingMediaPlayerFragment.swift | 238 ++++++++++++++++++ .../Fragments/RecordingsListFragment.swift | 84 ++++--- .../RecordingMediaPlayerViewModel.swift | 104 ++++++++ LinphoneApp.xcodeproj/project.pbxproj | 8 + 8 files changed, 421 insertions(+), 47 deletions(-) create mode 100644 Linphone/Assets.xcassets/music-notes.imageset/Contents.json create mode 100644 Linphone/Assets.xcassets/music-notes.imageset/music-notes.svg create mode 100644 Linphone/UI/Main/Recordings/Fragments/RecordingMediaPlayerFragment.swift create mode 100644 Linphone/UI/Main/Recordings/ViewModel/RecordingMediaPlayerViewModel.swift diff --git a/Linphone/Assets.xcassets/music-notes.imageset/Contents.json b/Linphone/Assets.xcassets/music-notes.imageset/Contents.json new file mode 100644 index 000000000..6194ada41 --- /dev/null +++ b/Linphone/Assets.xcassets/music-notes.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "music-notes.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Linphone/Assets.xcassets/music-notes.imageset/music-notes.svg b/Linphone/Assets.xcassets/music-notes.imageset/music-notes.svg new file mode 100644 index 000000000..fbf942b05 --- /dev/null +++ b/Linphone/Assets.xcassets/music-notes.imageset/music-notes.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Linphone/Assets.xcassets/share-network.imageset/share-network.svg b/Linphone/Assets.xcassets/share-network.imageset/share-network.svg index 02d8619a1..835f930ac 100644 --- a/Linphone/Assets.xcassets/share-network.imageset/share-network.svg +++ b/Linphone/Assets.xcassets/share-network.imageset/share-network.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/Linphone/UI/Main/Fragments/CustomBottomSheet.swift b/Linphone/UI/Main/Fragments/CustomBottomSheet.swift index 18683b01f..b7a30e450 100644 --- a/Linphone/UI/Main/Fragments/CustomBottomSheet.swift +++ b/Linphone/UI/Main/Fragments/CustomBottomSheet.swift @@ -86,13 +86,3 @@ final class CustomHostingController: UIHostingController } } } - -public struct LazyView: View { - private let build: () -> Content - public init(_ build: @autoclosure @escaping () -> Content) { - self.build = build - } - public var body: Content { - build() - } -} diff --git a/Linphone/UI/Main/Recordings/Fragments/RecordingMediaPlayerFragment.swift b/Linphone/UI/Main/Recordings/Fragments/RecordingMediaPlayerFragment.swift new file mode 100644 index 000000000..6d7d90eca --- /dev/null +++ b/Linphone/UI/Main/Recordings/Fragments/RecordingMediaPlayerFragment.swift @@ -0,0 +1,238 @@ +/* + * Copyright (c) 2010-2023 Belledonne Communications SARL. + * + * This file is part of linphone-iphone + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI + +struct RecordingMediaPlayerFragment: View { + + @StateObject private var recordingMediaPlayerViewModel: RecordingMediaPlayerViewModel + + @Environment(\.dismiss) var dismiss + + @State private var showShareSheet: Bool = false + @State private var showPicker: Bool = false + @State private var value: Double = 40.0 + @State private var timer: Timer? + + init(recording: RecordingModel) { + _recordingMediaPlayerViewModel = StateObject(wrappedValue: RecordingMediaPlayerViewModel(recording: recording)) + } + + var body: some View { + ZStack { + GeometryReader { geometry in + VStack(spacing: 1) { + Rectangle() + .foregroundColor(Color.orangeMain500) + .edgesIgnoringSafeArea(.top) + .frame(height: 0) + + HStack { + Image("caret-left") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orangeMain500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) + .padding(.top, 4) + .padding(.leading, -10) + .onTapGesture { + withAnimation { + dismiss() + } + } + + VStack { + Text(recordingMediaPlayerViewModel.recording.displayName) + .default_text_style_700(styleSize: 14) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) + + Text(recordingMediaPlayerViewModel.recording.dateTime) + .default_text_style_300(styleSize: 12) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) + } + .padding(.top, 4) + + Spacer() + + Button { + showShareSheet = true + } label: { + Image("share-network") + .renderingMode(.template) + .resizable() + .frame(width: 28, height: 28) + .foregroundStyle(Color.grayMain2c500) + } + .padding(.all, 6) + .padding(.top, 4) + + + Button { + showPicker = true + } label: { + Image("download-simple") + .renderingMode(.template) + .resizable() + .frame(width: 28, height: 28) + .foregroundStyle(Color.grayMain2c500) + } + .padding(.all, 6) + .padding(.top, 4) + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .padding(.horizontal) + .padding(.bottom, 4) + .background(.white) + + VStack { + Spacer() + + Image("music-notes") + .renderingMode(.template) + .resizable() + .frame(width: 80, height: 80) + .foregroundStyle(.white) + + Spacer() + + HStack(spacing: 0) { + Button { + if recordingMediaPlayerViewModel.isPlaying { + recordingMediaPlayerViewModel.pauseVoiceRecordPlayer() + } else { + recordingMediaPlayerViewModel.startVoiceRecordPlayer() + playProgress() + } + } label: { + Image(recordingMediaPlayerViewModel.isPlaying ? "pause-fill" : "play-fill") + .renderingMode(.template) + .resizable() + .frame(width: 25, height: 25) + .foregroundStyle(.white) + } + .frame(width: 50) + + let radius = geometry.size.height * 0.5 + ZStack(alignment: .leading) { + Rectangle() + .foregroundColor(Color.orangeMain100) + .frame(width: (geometry.size.width - 120), height: 5) + .clipShape(RoundedRectangle(cornerRadius: radius)) + + Rectangle() + .foregroundColor(Color.orangeMain500) + .frame(width: self.value * (geometry.size.width - 120) / 100, height: 5) + .animation(self.value > 0 ? .linear(duration: 0.1) : nil, value: self.value) + .clipShape(RoundedRectangle(cornerRadius: radius)) + } + .clipShape(RoundedRectangle(cornerRadius: radius)) + + Text(recordingMediaPlayerViewModel.recording.formattedDuration) + .default_text_style_white_600(styleSize: 18) + .frame(maxWidth: .infinity, alignment: .center) + .lineLimit(1) + .frame(width: 70) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.black) + } + } + .sheet(isPresented: $showShareSheet) { + if let url = URL(string: "file://" + recordingMediaPlayerViewModel.recording.filePath) { + ShareAnySheet(items: [url]) + .edgesIgnoringSafeArea(.bottom) + } + } + .sheet(isPresented: $showPicker) { + if let url = URL(string: "file://" + recordingMediaPlayerViewModel.recording.filePath) { + DocumentSaver(fileURL: url) + .edgesIgnoringSafeArea(.bottom) + } + } + .onAppear { + playProgress() + } + .onDisappear { + recordingMediaPlayerViewModel.stopVoiceRecordPlayer() + } + } + .navigationTitle("") + .navigationBarHidden(true) + } + + private func playProgress() { + timer?.invalidate() + + var lastValue = -1.0 + + value = recordingMediaPlayerViewModel.getPositionVoiceRecordPlayer() + + timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in + let current = recordingMediaPlayerViewModel.getPositionVoiceRecordPlayer() + + if !recordingMediaPlayerViewModel.isPlaying { + self.value = current + lastValue = current + return + } + + if current > lastValue { + self.value = current + lastValue = current + return + } + + recordingMediaPlayerViewModel.stopVoiceRecordPlayer() + self.timer?.invalidate() + self.value = 0 + } + } +} + +struct SaveToFilesView: View { + let fileURL: URL + @State private var showPicker = false + + var body: some View { + Button("Enregistrer dans Fichiers") { + showPicker = true + } + .sheet(isPresented: $showPicker) { + DocumentSaver(fileURL: fileURL) + } + } +} + +struct DocumentSaver: UIViewControllerRepresentable { + let fileURL: URL + + func makeUIViewController(context: Context) -> UIDocumentPickerViewController { + let picker = UIDocumentPickerViewController( + forExporting: [fileURL], asCopy: true + ) + return picker + } + + func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {} +} diff --git a/Linphone/UI/Main/Recordings/Fragments/RecordingsListFragment.swift b/Linphone/UI/Main/Recordings/Fragments/RecordingsListFragment.swift index 54cb18f7b..7b4920531 100644 --- a/Linphone/UI/Main/Recordings/Fragments/RecordingsListFragment.swift +++ b/Linphone/UI/Main/Recordings/Fragments/RecordingsListFragment.swift @@ -72,48 +72,52 @@ struct RecordingsListFragment: View { .frame(maxWidth: .infinity, alignment: .leading) } - HStack { - VStack { - HStack { - Image("phone") - .renderingMode(.template) - .resizable() - .frame(width: 25, height: 25) - .foregroundStyle(Color.grayMain2c600) + NavigationLink(destination: LazyView { + RecordingMediaPlayerFragment(recording: recording) + }) { + HStack { + VStack { + HStack { + Image("phone") + .renderingMode(.template) + .resizable() + .frame(width: 25, height: 25) + .foregroundStyle(Color.grayMain2c600) + + Text(recording.displayName) + .default_text_style_700(styleSize: 14) + .frame(maxWidth: .infinity, alignment: .leading) + } - Text(recording.displayName) - .default_text_style_700(styleSize: 14) + Spacer() + + Text(recording.dateTime) + .default_text_style(styleSize: 14) .frame(maxWidth: .infinity, alignment: .leading) } - Spacer() - - Text(recording.dateTime) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity, alignment: .leading) + VStack { + Image("play-fill") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orangeMain500) + .frame(width: 30, height: 30) + .padding(.leading, -6) + + Spacer() + + Text(recording.formattedDuration) + .default_text_style(styleSize: 14) + .frame(alignment: .center) + } + .padding(.trailing, 6) } - - VStack { - Image("play-fill") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.orangeMain500) - .frame(width: 30, height: 30) - .padding(.leading, -6) - - Spacer() - - Text(recording.formattedDuration) - .default_text_style(styleSize: 14) - .frame(alignment: .center) - } - .padding(.trailing, 6) + .frame(height: 60) + .padding(20) + .background(.white) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .shadow(color: .gray.opacity(0.4), radius: 4) } - .frame(height: 60) - .padding(20) - .background(.white) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .shadow(color: .gray.opacity(0.4), radius: 4) } } .padding(.all, 20) @@ -140,3 +144,11 @@ struct RecordingsListFragment: View { .default_text_style_500(styleSize: 22) } } + +struct LazyView: View { + let build: () -> Content + init(_ build: @escaping () -> Content) { + self.build = build + } + var body: some View { build() } +} diff --git a/Linphone/UI/Main/Recordings/ViewModel/RecordingMediaPlayerViewModel.swift b/Linphone/UI/Main/Recordings/ViewModel/RecordingMediaPlayerViewModel.swift new file mode 100644 index 000000000..a2b886b9a --- /dev/null +++ b/Linphone/UI/Main/Recordings/ViewModel/RecordingMediaPlayerViewModel.swift @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2010-2023 Belledonne Communications SARL. + * + * This file is part of linphone-iphone + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import Combine + +class RecordingMediaPlayerViewModel: ObservableObject { + private let TAG = "[RecordingMediaPlayerViewModel]" + + private var coreContext = CoreContext.shared + + @Published var recording: RecordingModel + + @Published var isPlaying: Bool = false + + var vrpManager: VoiceRecordPlayerManager? + + init(recording: RecordingModel) { + self.recording = recording + if let url = URL(string: "file://" + recording.filePath) { + startVoiceRecordPlayer(voiceRecordPath: url) + } + } + + func startVoiceRecordPlayer(voiceRecordPath: URL) { + coreContext.doOnCoreQueue { core in + if self.vrpManager == nil || self.vrpManager!.voiceRecordPath != voiceRecordPath { + self.vrpManager = VoiceRecordPlayerManager(core: core, voiceRecordPath: voiceRecordPath) + } + + if self.vrpManager != nil { + self.vrpManager!.startVoiceRecordPlayer() + DispatchQueue.main.async { + self.isPlaying = true + } + } + } + } + + func getPositionVoiceRecordPlayer() -> Double { + if self.vrpManager != nil { + return self.vrpManager!.positionVoiceRecordPlayer() + } else { + return 0 + } + } + + func isPlayingVoiceRecordPlayer(voiceRecordPath: URL) -> Bool { + if self.vrpManager != nil && self.vrpManager!.voiceRecordPath == voiceRecordPath { + return true + } else { + return false + } + } + + func startVoiceRecordPlayer() { + coreContext.doOnCoreQueue { _ in + if self.vrpManager != nil { + self.vrpManager!.startVoiceRecordPlayer() + DispatchQueue.main.async { + self.isPlaying = true + } + } + } + } + + func pauseVoiceRecordPlayer() { + coreContext.doOnCoreQueue { _ in + if self.vrpManager != nil { + self.vrpManager!.pauseVoiceRecordPlayer() + DispatchQueue.main.async { + self.isPlaying = false + } + } + } + } + + func stopVoiceRecordPlayer() { + coreContext.doOnCoreQueue { _ in + if self.vrpManager != nil { + self.vrpManager!.stopVoiceRecordPlayer() + DispatchQueue.main.async { + self.isPlaying = false + } + } + } + } +} diff --git a/LinphoneApp.xcodeproj/project.pbxproj b/LinphoneApp.xcodeproj/project.pbxproj index 2c8e765f1..e42ebfa0b 100644 --- a/LinphoneApp.xcodeproj/project.pbxproj +++ b/LinphoneApp.xcodeproj/project.pbxproj @@ -178,6 +178,8 @@ D7CEE03B2B7A234200FD79B7 /* ConversationsFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CEE03A2B7A234200FD79B7 /* ConversationsFragment.swift */; }; D7CEE03D2B7A23B200FD79B7 /* ConversationsListFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7CEE03C2B7A23B200FD79B7 /* ConversationsListFragment.swift */; }; D7D1698C2AE66FA500109A5C /* MagicSearchSingleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D1698B2AE66FA500109A5C /* MagicSearchSingleton.swift */; }; + D7D1F5262EDD91B30034EEB0 /* RecordingMediaPlayerFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D1F5252EDD91B10034EEB0 /* RecordingMediaPlayerFragment.swift */; }; + D7D1F5282EDD939E0034EEB0 /* RecordingMediaPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D1F5272EDD939D0034EEB0 /* RecordingMediaPlayerViewModel.swift */; }; D7D24D132AC1B4E800C6F35B /* NotoSans-Medium.ttf in Resources */ = {isa = PBXBuildFile; fileRef = D7D24D0D2AC1B4E800C6F35B /* NotoSans-Medium.ttf */; }; D7D24D142AC1B4E800C6F35B /* NotoSans-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = D7D24D0E2AC1B4E800C6F35B /* NotoSans-Regular.ttf */; }; D7D24D152AC1B4E800C6F35B /* NotoSans-Light.ttf in Resources */ = {isa = PBXBuildFile; fileRef = D7D24D0F2AC1B4E800C6F35B /* NotoSans-Light.ttf */; }; @@ -414,6 +416,8 @@ D7CEE03A2B7A234200FD79B7 /* ConversationsFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationsFragment.swift; sourceTree = ""; }; D7CEE03C2B7A23B200FD79B7 /* ConversationsListFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationsListFragment.swift; sourceTree = ""; }; D7D1698B2AE66FA500109A5C /* MagicSearchSingleton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MagicSearchSingleton.swift; sourceTree = ""; }; + D7D1F5252EDD91B10034EEB0 /* RecordingMediaPlayerFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingMediaPlayerFragment.swift; sourceTree = ""; }; + D7D1F5272EDD939D0034EEB0 /* RecordingMediaPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingMediaPlayerViewModel.swift; sourceTree = ""; }; D7D24D0D2AC1B4E800C6F35B /* NotoSans-Medium.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "NotoSans-Medium.ttf"; sourceTree = ""; }; D7D24D0E2AC1B4E800C6F35B /* NotoSans-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "NotoSans-Regular.ttf"; sourceTree = ""; }; D7D24D0F2AC1B4E800C6F35B /* NotoSans-Light.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "NotoSans-Light.ttf"; sourceTree = ""; }; @@ -915,6 +919,7 @@ D795F57B2EC5F8FF0022C17D /* Fragments */ = { isa = PBXGroup; children = ( + D7D1F5252EDD91B10034EEB0 /* RecordingMediaPlayerFragment.swift */, D795F57D2EC5F9480022C17D /* RecordingsListFragment.swift */, ); path = Fragments; @@ -923,6 +928,7 @@ D795F57C2EC5F9090022C17D /* ViewModel */ = { isa = PBXGroup; children = ( + D7D1F5272EDD939D0034EEB0 /* RecordingMediaPlayerViewModel.swift */, D795F57F2EC5F95B0022C17D /* RecordingsListViewModel.swift */, ); path = ViewModel; @@ -1344,6 +1350,7 @@ D7C2DA1D2CA44DE400A2441B /* EventModel.swift in Sources */, D719ABC92ABC6FD700B41C10 /* CoreContext.swift in Sources */, D70A26F22B7F5D95006CC8FC /* ConversationFragment.swift in Sources */, + D7D1F5282EDD939E0034EEB0 /* RecordingMediaPlayerViewModel.swift in Sources */, D7DC096F2CFA1D7600A6D47C /* AccountProfileFragment.swift in Sources */, D717A10E2CEB772300849D92 /* ShareSheetController.swift in Sources */, 66C491FD2B24D36500CEA16D /* AudioRouteUtils.swift in Sources */, @@ -1367,6 +1374,7 @@ C6A5A9452C10B6270070FEA4 /* OIDAuthStateExtension.swift in Sources */, D732A90F2B04C3B400DB42BA /* HistoryFragment.swift in Sources */, D79F1C162CD3D6AD00FF0A05 /* ConversationInfoFragment.swift in Sources */, + D7D1F5262EDD91B30034EEB0 /* RecordingMediaPlayerFragment.swift in Sources */, D7C500422D2BE98100DD53EC /* AccountSettingsViewModel.swift in Sources */, D79622342B1DFE600037EACD /* DialerBottomSheet.swift in Sources */, C67586B02C09F247002E77BF /* URIHandler.swift in Sources */,