Skip to content

Commit 30ab4d4

Browse files
committed
audio preview support, camera capture, minor tweaks
1 parent 201ab6e commit 30ab4d4

File tree

4 files changed

+302
-6
lines changed

4 files changed

+302
-6
lines changed

Django Files/Views/AlbumList.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,6 @@ struct AlbumListView: View {
106106
await refreshAlbumsAsync()
107107
}
108108
}
109-
.listStyle(.plain)
110109
.navigationTitle(server.wrappedValue != nil ? "Albums (\(URL(string: server.wrappedValue!.url)?.host ?? "unknown"))" : "Albums")
111110
.toolbar {
112111
ToolbarItem(placement: .navigationBarTrailing) {

Django Files/Views/FileUploadView.swift

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import SwiftUI
99
import PhotosUI
1010
import UniformTypeIdentifiers
11+
import AVFoundation
1112

1213
struct FileUploadView: View {
1314
let server: DjangoFilesSession
@@ -17,14 +18,48 @@ struct FileUploadView: View {
1718
@State private var isUploading: Bool = false
1819
@State private var uploadProgress: Double = 0.0
1920
@State private var showingFilePicker: Bool = false
21+
@State private var showingCamera: Bool = false
22+
@State private var capturedImage: UIImage?
2023

2124
@State private var uploadPrivate: Bool = false
2225

26+
// Audio recording states
27+
@State private var audioRecorder: AVAudioRecorder?
28+
@State private var isRecording: Bool = false
29+
@State private var recordingURL: URL?
30+
2331
var body: some View {
2432
NavigationView {
2533
VStack(spacing: 20) {
2634
Toggle("Make Private", isOn: $uploadPrivate)
2735

36+
// Audio Recording Button
37+
Button(action: {
38+
if isRecording {
39+
stopRecording()
40+
} else {
41+
startRecording()
42+
}
43+
}) {
44+
Label(isRecording ? "Stop Recording" : "Record Audio", systemImage: isRecording ? "stop.circle.fill" : "mic.circle.fill")
45+
.frame(maxWidth: .infinity)
46+
.padding()
47+
.background(isRecording ? Color.red : Color.accentColor)
48+
.foregroundColor(.white)
49+
.cornerRadius(10)
50+
}
51+
52+
// Camera Button
53+
Button(action: {
54+
showingCamera = true
55+
}) {
56+
Label("Take Photo", systemImage: "camera")
57+
.frame(maxWidth: .infinity)
58+
.padding()
59+
.background(Color.accentColor)
60+
.foregroundColor(.white)
61+
.cornerRadius(10)
62+
}
2863

2964
// Photo Picker
3065
PhotosPicker(
@@ -67,6 +102,17 @@ struct FileUploadView: View {
67102
}
68103
}
69104
}
105+
.sheet(isPresented: $showingCamera) {
106+
ImagePicker(image: $capturedImage)
107+
.ignoresSafeArea()
108+
}
109+
.onChange(of: capturedImage) { _, newImage in
110+
if let image = newImage {
111+
Task {
112+
await uploadCapturedImage(image)
113+
}
114+
}
115+
}
70116
.fileImporter(
71117
isPresented: $showingFilePicker,
72118
allowedContentTypes: [.item],
@@ -90,6 +136,26 @@ struct FileUploadView: View {
90136
}
91137
}
92138

139+
private func uploadCapturedImage(_ image: UIImage) async {
140+
isUploading = true
141+
uploadProgress = 0.0
142+
143+
if let imageData = image.jpegData(compressionQuality: 0.8),
144+
let tempURL = saveTemporaryFile(data: imageData, filename: "ios_photo.jpg") {
145+
let api = DFAPI(url: URL(string: server.url)!, token: server.token)
146+
let delegate = UploadProgressDelegate { progress in
147+
uploadProgress = progress
148+
}
149+
150+
_ = await api.uploadFile(url: tempURL, privateUpload: uploadPrivate, taskDelegate: delegate)
151+
try? FileManager.default.removeItem(at: tempURL)
152+
}
153+
154+
isUploading = false
155+
capturedImage = nil
156+
dismiss()
157+
}
158+
93159
private func uploadPhotos(_ items: [PhotosPickerItem]) async {
94160
isUploading = true
95161
uploadProgress = 0.0
@@ -143,6 +209,60 @@ struct FileUploadView: View {
143209
return nil
144210
}
145211
}
212+
213+
private func startRecording() {
214+
let audioSession = AVAudioSession.sharedInstance()
215+
216+
do {
217+
try audioSession.setCategory(.playAndRecord, mode: .default)
218+
try audioSession.setActive(true)
219+
220+
let documentsPath = FileManager.default.temporaryDirectory
221+
let audioFilename = documentsPath.appendingPathComponent("recording.m4a")
222+
recordingURL = audioFilename
223+
224+
let settings = [
225+
AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
226+
AVSampleRateKey: 44100,
227+
AVNumberOfChannelsKey: 2,
228+
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
229+
]
230+
231+
audioRecorder = try AVAudioRecorder(url: audioFilename, settings: settings)
232+
audioRecorder?.record()
233+
isRecording = true
234+
} catch {
235+
print("Recording failed: \(error)")
236+
}
237+
}
238+
239+
private func stopRecording() {
240+
audioRecorder?.stop()
241+
isRecording = false
242+
243+
if let url = recordingURL {
244+
Task {
245+
await uploadAudioRecording(url)
246+
}
247+
}
248+
}
249+
250+
private func uploadAudioRecording(_ url: URL) async {
251+
isUploading = true
252+
uploadProgress = 0.0
253+
254+
let api = DFAPI(url: URL(string: server.url)!, token: server.token)
255+
let delegate = UploadProgressDelegate { progress in
256+
uploadProgress = progress
257+
}
258+
259+
_ = await api.uploadFile(url: url, privateUpload: uploadPrivate, taskDelegate: delegate)
260+
try? FileManager.default.removeItem(at: url)
261+
262+
isUploading = false
263+
recordingURL = nil
264+
dismiss()
265+
}
146266
}
147267

148268
class UploadProgressDelegate: NSObject, URLSessionTaskDelegate {
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import SwiftUI
2+
import UIKit
3+
4+
struct ImagePicker: UIViewControllerRepresentable {
5+
@Binding var image: UIImage?
6+
@Environment(\.dismiss) private var dismiss
7+
8+
func makeUIViewController(context: Context) -> UIImagePickerController {
9+
let picker = UIImagePickerController()
10+
picker.delegate = context.coordinator
11+
picker.sourceType = .camera
12+
return picker
13+
}
14+
15+
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
16+
17+
func makeCoordinator() -> Coordinator {
18+
Coordinator(self)
19+
}
20+
21+
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
22+
let parent: ImagePicker
23+
24+
init(_ parent: ImagePicker) {
25+
self.parent = parent
26+
}
27+
28+
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
29+
if let image = info[.originalImage] as? UIImage {
30+
parent.image = image
31+
}
32+
parent.dismiss()
33+
}
34+
35+
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
36+
parent.dismiss()
37+
}
38+
}
39+
}

Django Files/Views/Preview.swift

Lines changed: 143 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,17 +50,16 @@ struct ContentPreview: View {
5050
imagePreview
5151
} else if mimeType.starts(with: "video/") {
5252
videoPreview
53+
} else if mimeType.starts(with: "audio/") {
54+
audioPreview
5355
} else {
5456
genericFilePreview
5557
}
5658
}
5759
.sheet(isPresented: showFileInfo, onDismiss: { showFileInfo.wrappedValue = false }) {
5860
PreviewFileInfo(file: file)
5961
.presentationBackground(.ultraThinMaterial)
60-
6162
}
62-
63-
6463
}
6564

6665
// Text Preview
@@ -94,6 +93,12 @@ struct ContentPreview: View {
9493
.aspectRatio(contentMode: .fit)
9594
}
9695

96+
// Audio Preview
97+
private var audioPreview: some View {
98+
AudioPlayerView(url: fileURL)
99+
.padding()
100+
}
101+
97102
// Generic File Preview
98103
private var genericFilePreview: some View {
99104
VStack {
@@ -114,8 +119,8 @@ struct ContentPreview: View {
114119
private func loadContent() {
115120
isLoading = true
116121

117-
// For video, we don't need to download the content as we'll use the URL directly
118-
if mimeType.starts(with: "video/") {
122+
// For video, audio, and audio, we don't need to download the content as we'll use the URL directly
123+
if mimeType.starts(with: "video/") || mimeType.starts(with: "audio/") {
119124
isLoading = false
120125
return
121126
}
@@ -303,3 +308,136 @@ class CustomScrollView: UIScrollView {
303308
}
304309
}
305310
}
311+
312+
// Custom Audio Player View
313+
struct AudioPlayerView: View {
314+
let url: URL
315+
@StateObject private var playerViewModel = AudioPlayerViewModel()
316+
317+
var body: some View {
318+
VStack(spacing: 20) {
319+
Image(systemName: "waveform")
320+
.font(.system(size: 50))
321+
.foregroundColor(.gray)
322+
.padding(.bottom)
323+
324+
// Time and Progress
325+
HStack {
326+
Text(playerViewModel.currentTimeString)
327+
.font(.caption)
328+
.monospacedDigit()
329+
330+
Slider(value: $playerViewModel.progress, in: 0...1) { editing in
331+
if !editing {
332+
playerViewModel.seek(to: playerViewModel.progress)
333+
}
334+
}
335+
336+
Text(playerViewModel.durationString)
337+
.font(.caption)
338+
.monospacedDigit()
339+
}
340+
.padding(.horizontal)
341+
342+
// Playback Controls
343+
HStack(spacing: 30) {
344+
Button(action: { playerViewModel.skipBackward() }) {
345+
Image(systemName: "gobackward.15")
346+
.font(.title2)
347+
}
348+
349+
Button(action: { playerViewModel.togglePlayback() }) {
350+
Image(systemName: playerViewModel.isPlaying ? "pause.circle.fill" : "play.circle.fill")
351+
.font(.system(size: 44))
352+
}
353+
354+
Button(action: { playerViewModel.skipForward() }) {
355+
Image(systemName: "goforward.15")
356+
.font(.title2)
357+
}
358+
}
359+
}
360+
.onAppear {
361+
playerViewModel.setupPlayer(with: url)
362+
}
363+
.onDisappear {
364+
playerViewModel.cleanup()
365+
}
366+
}
367+
}
368+
369+
// Audio Player View Model
370+
class AudioPlayerViewModel: ObservableObject {
371+
private var player: AVPlayer?
372+
private var timeObserver: Any?
373+
374+
@Published var isPlaying = false
375+
@Published var progress: Double = 0
376+
@Published var currentTimeString = "00:00"
377+
@Published var durationString = "00:00"
378+
379+
func setupPlayer(with url: URL) {
380+
player = AVPlayer(url: url)
381+
382+
// Add periodic time observer
383+
timeObserver = player?.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), queue: .main) { [weak self] time in
384+
guard let self = self,
385+
let duration = self.player?.currentItem?.duration.seconds,
386+
!duration.isNaN else { return }
387+
388+
let currentTime = time.seconds
389+
self.progress = currentTime / duration
390+
self.currentTimeString = self.formatTime(currentTime)
391+
self.durationString = self.formatTime(duration)
392+
}
393+
394+
// Update duration when item is ready
395+
player?.currentItem?.asset.loadValuesAsynchronously(forKeys: ["duration"]) {
396+
DispatchQueue.main.async {
397+
if let duration = self.player?.currentItem?.duration.seconds,
398+
!duration.isNaN {
399+
self.durationString = self.formatTime(duration)
400+
}
401+
}
402+
}
403+
}
404+
405+
func togglePlayback() {
406+
if isPlaying {
407+
player?.pause()
408+
} else {
409+
player?.play()
410+
}
411+
isPlaying.toggle()
412+
}
413+
414+
func seek(to progress: Double) {
415+
guard let duration = player?.currentItem?.duration else { return }
416+
let time = CMTime(seconds: progress * duration.seconds, preferredTimescale: 600)
417+
player?.seek(to: time)
418+
}
419+
420+
func skipForward() {
421+
guard let currentTime = player?.currentTime().seconds else { return }
422+
seek(to: (currentTime + 15) / (player?.currentItem?.duration.seconds ?? currentTime + 15))
423+
}
424+
425+
func skipBackward() {
426+
guard let currentTime = player?.currentTime().seconds else { return }
427+
seek(to: (currentTime - 15) / (player?.currentItem?.duration.seconds ?? currentTime))
428+
}
429+
430+
func cleanup() {
431+
if let timeObserver = timeObserver {
432+
player?.removeTimeObserver(timeObserver)
433+
}
434+
player?.pause()
435+
player = nil
436+
}
437+
438+
private func formatTime(_ timeInSeconds: Double) -> String {
439+
let minutes = Int(timeInSeconds / 60)
440+
let seconds = Int(timeInSeconds.truncatingRemainder(dividingBy: 60))
441+
return String(format: "%02d:%02d", minutes, seconds)
442+
}
443+
}

0 commit comments

Comments
 (0)