feat: added media previews and rendering 40d59176
Steve · 2025-12-21 00:38 4 file(s) · +493 −5
Titan.xcodeproj/project.pbxproj +2 −0
400 400
				DEVELOPMENT_TEAM = W8QNM2N67P;
401 401
				ENABLE_PREVIEWS = YES;
402 402
				GENERATE_INFOPLIST_FILE = YES;
403 +
				INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Titan needs access to save images from Gemini pages";
403 404
				INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
404 405
				INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
405 406
				INFOPLIST_KEY_UILaunchScreen_Generation = YES;
432 433
				DEVELOPMENT_TEAM = W8QNM2N67P;
433 434
				ENABLE_PREVIEWS = YES;
434 435
				GENERATE_INFOPLIST_FILE = YES;
436 +
				INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Titan needs access to save images from Gemini pages";
435 437
				INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
436 438
				INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
437 439
				INFOPLIST_KEY_UILaunchScreen_Generation = YES;
Titan/Models/MediaType.swift (added) +83 −0
1 +
//
2 +
//  MediaType.swift
3 +
//  Titan
4 +
//
5 +
6 +
import Foundation
7 +
8 +
enum MediaType {
9 +
    case image
10 +
    case audio
11 +
    case unsupported
12 +
13 +
    init(mimeType: String) {
14 +
        let mime = mimeType.lowercased().trimmingCharacters(in: .whitespaces)
15 +
16 +
        if mime.hasPrefix("image/") {
17 +
            self = .image
18 +
        } else if mime.hasPrefix("audio/") {
19 +
            self = .audio
20 +
        } else {
21 +
            self = .unsupported
22 +
        }
23 +
    }
24 +
25 +
    static func isTextContent(_ mimeType: String) -> Bool {
26 +
        let mime = mimeType.lowercased().trimmingCharacters(in: .whitespaces)
27 +
        return mime.hasPrefix("text/")
28 +
    }
29 +
30 +
    static func isMediaContent(_ mimeType: String) -> Bool {
31 +
        let type = MediaType(mimeType: mimeType)
32 +
        return type != .unsupported
33 +
    }
34 +
}
35 +
36 +
struct MediaContent {
37 +
    let data: Data
38 +
    let mimeType: String
39 +
    let sourceURL: String
40 +
41 +
    var mediaType: MediaType {
42 +
        MediaType(mimeType: mimeType)
43 +
    }
44 +
45 +
    var suggestedFilename: String {
46 +
        if let url = URL(string: sourceURL) {
47 +
            let filename = url.lastPathComponent
48 +
            if !filename.isEmpty && filename != "/" {
49 +
                return filename
50 +
            }
51 +
        }
52 +
53 +
        // Generate filename based on MIME type
54 +
        let ext = fileExtension
55 +
        let timestamp = Int(Date().timeIntervalSince1970)
56 +
        return "titan_\(timestamp).\(ext)"
57 +
    }
58 +
59 +
    var fileExtension: String {
60 +
        let mime = mimeType.lowercased()
61 +
62 +
        // Image types
63 +
        if mime.contains("jpeg") || mime.contains("jpg") { return "jpg" }
64 +
        if mime.contains("png") { return "png" }
65 +
        if mime.contains("gif") { return "gif" }
66 +
        if mime.contains("webp") { return "webp" }
67 +
        if mime.contains("svg") { return "svg" }
68 +
69 +
        // Audio types
70 +
        if mime.contains("mpeg") || mime.contains("mp3") { return "mp3" }
71 +
        if mime.contains("ogg") { return "ogg" }
72 +
        if mime.contains("wav") { return "wav" }
73 +
        if mime.contains("flac") { return "flac" }
74 +
        if mime.contains("aac") { return "aac" }
75 +
        if mime.contains("m4a") { return "m4a" }
76 +
77 +
        // Fallback
78 +
        if mime.hasPrefix("image/") { return "jpg" }
79 +
        if mime.hasPrefix("audio/") { return "mp3" }
80 +
81 +
        return "bin"
82 +
    }
83 +
}
Titan/Views/ContentView.swift +35 −5
21 21
    @State private var history: [String] = []
22 22
    @State private var historyIndex = -1
23 23
24 +
    // Media preview state
25 +
    @State private var showMediaPreview = false
26 +
    @State private var mediaContent: MediaContent?
27 +
24 28
    private let maxRedirects = 5
25 29
26 30
    var body: some View {
49 53
                    Image(systemName: "chevron.left")
50 54
                        .font(.title2)
51 55
                        .padding(.leading, 6)
56 +
                        .foregroundColor(.orange)
52 57
                }
53 58
                .disabled(!canGoBack || isLoading)
54 59
56 61
                Button(action: goForward) {
57 62
                    Image(systemName: "chevron.right")
58 63
                        .font(.title2)
64 +
                        .foregroundColor(.orange)
59 65
                }
60 66
                .disabled(!canGoForward || isLoading)
61 67
102 108
        } message: {
103 109
            Text(inputPromptText)
104 110
        }
105 -
        
111 +
        .fullScreenCover(isPresented: $showMediaPreview) {
112 +
            if let media = mediaContent {
113 +
                MediaPreviewView(media: media) {
114 +
                    showMediaPreview = false
115 +
                    mediaContent = nil
116 +
                }
117 +
            }
118 +
        }
106 119
    }
107 120
108 121
    private func submitInput() {
160 173
161 174
                switch response.statusCategory {
162 175
                case .success:
163 -
                    responseText = response.bodyText ?? "(empty response)"
164 -
                    if addToHistory {
165 -
                        history.append(finalURL)
166 -
                        historyIndex = history.count - 1
176 +
                    let mimeType = response.meta
177 +
178 +
                    if MediaType.isMediaContent(mimeType) {
179 +
                        // Handle media content (images, audio)
180 +
                        if let body = response.body {
181 +
                            mediaContent = MediaContent(
182 +
                                data: body,
183 +
                                mimeType: mimeType,
184 +
                                sourceURL: finalURL
185 +
                            )
186 +
                            showMediaPreview = true
187 +
                        } else {
188 +
                            responseText = "(empty media response)"
189 +
                        }
190 +
                    } else {
191 +
                        // Handle text content (text/gemini, text/plain, etc.)
192 +
                        responseText = response.bodyText ?? "(empty response)"
193 +
                        if addToHistory {
194 +
                            history.append(finalURL)
195 +
                            historyIndex = history.count - 1
196 +
                        }
167 197
                    }
168 198
                case .input:
169 199
                    pendingInputURL = finalURL
Titan/Views/MediaPreviewView.swift (added) +373 −0
1 +
//
2 +
//  MediaPreviewView.swift
3 +
//  Titan
4 +
//
5 +
6 +
import SwiftUI
7 +
import AVFoundation
8 +
import PhotosUI
9 +
import Combine
10 +
11 +
struct MediaPreviewView: View {
12 +
    let media: MediaContent
13 +
    let onDismiss: () -> Void
14 +
15 +
    @State private var showingSaveOptions = false
16 +
    @State private var saveMessage: String?
17 +
    @State private var showingSaveAlert = false
18 +
19 +
    var body: some View {
20 +
        NavigationStack {
21 +
            ZStack {
22 +
                Color.black.ignoresSafeArea()
23 +
24 +
                switch media.mediaType {
25 +
                case .image:
26 +
                    ImagePreviewContent(data: media.data)
27 +
                case .audio:
28 +
                    AudioPreviewContent(data: media.data, filename: media.suggestedFilename)
29 +
                case .unsupported:
30 +
                    UnsupportedContent(mimeType: media.mimeType)
31 +
                }
32 +
            }
33 +
            .navigationBarTitleDisplayMode(.inline)
34 +
            .toolbar {
35 +
                ToolbarItem(placement: .navigationBarLeading) {
36 +
                    Button(action: onDismiss) {
37 +
                        HStack(spacing: 4) {
38 +
                            Image(systemName: "chevron.left")
39 +
                            Text("Back")
40 +
                        }
41 +
                        .foregroundColor(.orange)
42 +
                    }
43 +
                }
44 +
45 +
                ToolbarItem(placement: .navigationBarTrailing) {
46 +
                    Button(action: { showingSaveOptions = true }) {
47 +
                        Image(systemName: "square.and.arrow.down")
48 +
                            .foregroundColor(.orange)
49 +
                    }
50 +
                }
51 +
            }
52 +
            .confirmationDialog("Save File", isPresented: $showingSaveOptions, titleVisibility: .visible) {
53 +
                if media.mediaType == .image {
54 +
                    Button("Save to Photos") {
55 +
                        saveToPhotos()
56 +
                    }
57 +
                }
58 +
                Button("Save to Files") {
59 +
                    saveToFiles()
60 +
                }
61 +
                Button("Cancel", role: .cancel) {}
62 +
            }
63 +
            .alert("Save", isPresented: $showingSaveAlert) {
64 +
                Button("OK") {}
65 +
            } message: {
66 +
                Text(saveMessage ?? "")
67 +
            }
68 +
        }
69 +
    }
70 +
71 +
    private func saveToPhotos() {
72 +
        guard media.mediaType == .image,
73 +
              let image = UIImage(data: media.data) else {
74 +
            saveMessage = "Unable to save image"
75 +
            showingSaveAlert = true
76 +
            return
77 +
        }
78 +
79 +
        PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
80 +
            DispatchQueue.main.async {
81 +
                if status == .authorized || status == .limited {
82 +
                    UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
83 +
                    saveMessage = "Image saved to Photos"
84 +
                    showingSaveAlert = true
85 +
                } else {
86 +
                    saveMessage = "Photo library access denied. Please enable in Settings."
87 +
                    showingSaveAlert = true
88 +
                }
89 +
            }
90 +
        }
91 +
    }
92 +
93 +
    private func saveToFiles() {
94 +
        let filename = media.suggestedFilename
95 +
        let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
96 +
97 +
        do {
98 +
            try media.data.write(to: tempURL)
99 +
100 +
            let activityVC = UIActivityViewController(
101 +
                activityItems: [tempURL],
102 +
                applicationActivities: nil
103 +
            )
104 +
105 +
            if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
106 +
               let rootVC = windowScene.windows.first?.rootViewController {
107 +
                var topVC = rootVC
108 +
                while let presented = topVC.presentedViewController {
109 +
                    topVC = presented
110 +
                }
111 +
                topVC.present(activityVC, animated: true)
112 +
            }
113 +
        } catch {
114 +
            saveMessage = "Failed to save file: \(error.localizedDescription)"
115 +
            showingSaveAlert = true
116 +
        }
117 +
    }
118 +
}
119 +
120 +
// MARK: - Image Preview
121 +
122 +
struct ImagePreviewContent: View {
123 +
    let data: Data
124 +
    @State private var scale: CGFloat = 1.0
125 +
    @State private var lastScale: CGFloat = 1.0
126 +
127 +
    var body: some View {
128 +
        if let uiImage = UIImage(data: data) {
129 +
            Image(uiImage: uiImage)
130 +
                .resizable()
131 +
                .aspectRatio(contentMode: .fit)
132 +
                .scaleEffect(scale)
133 +
                .gesture(
134 +
                    MagnificationGesture()
135 +
                        .onChanged { value in
136 +
                            scale = lastScale * value
137 +
                        }
138 +
                        .onEnded { _ in
139 +
                            lastScale = scale
140 +
                            if scale < 1.0 {
141 +
                                withAnimation {
142 +
                                    scale = 1.0
143 +
                                    lastScale = 1.0
144 +
                                }
145 +
                            }
146 +
                        }
147 +
                )
148 +
                .onTapGesture(count: 2) {
149 +
                    withAnimation {
150 +
                        if scale > 1.0 {
151 +
                            scale = 1.0
152 +
                            lastScale = 1.0
153 +
                        } else {
154 +
                            scale = 2.0
155 +
                            lastScale = 2.0
156 +
                        }
157 +
                    }
158 +
                }
159 +
        } else {
160 +
            VStack(spacing: 16) {
161 +
                Image(systemName: "photo.badge.exclamationmark")
162 +
                    .font(.system(size: 64))
163 +
                    .foregroundColor(.gray)
164 +
                Text("Unable to load image")
165 +
                    .foregroundColor(.gray)
166 +
            }
167 +
        }
168 +
    }
169 +
}
170 +
171 +
// MARK: - Audio Preview
172 +
173 +
struct AudioPreviewContent: View {
174 +
    let data: Data
175 +
    let filename: String
176 +
177 +
    @StateObject private var audioPlayer = AudioPlayerViewModel()
178 +
179 +
    var body: some View {
180 +
        VStack(spacing: 32) {
181 +
            Image(systemName: "waveform.circle.fill")
182 +
                .font(.system(size: 120))
183 +
                .foregroundColor(.orange)
184 +
185 +
            Text(filename)
186 +
                .font(.headline)
187 +
                .foregroundColor(.white)
188 +
                .lineLimit(2)
189 +
                .multilineTextAlignment(.center)
190 +
                .padding(.horizontal)
191 +
192 +
            VStack(spacing: 16) {
193 +
                // Progress slider
194 +
                Slider(
195 +
                    value: Binding(
196 +
                        get: { audioPlayer.currentTime },
197 +
                        set: { audioPlayer.seek(to: $0) }
198 +
                    ),
199 +
                    in: 0...max(audioPlayer.duration, 0.01)
200 +
                )
201 +
                .accentColor(.orange)
202 +
                .padding(.horizontal, 32)
203 +
204 +
                // Time labels
205 +
                HStack {
206 +
                    Text(formatTime(audioPlayer.currentTime))
207 +
                        .font(.caption)
208 +
                        .foregroundColor(.gray)
209 +
                    Spacer()
210 +
                    Text(formatTime(audioPlayer.duration))
211 +
                        .font(.caption)
212 +
                        .foregroundColor(.gray)
213 +
                }
214 +
                .padding(.horizontal, 32)
215 +
216 +
                // Playback controls
217 +
                HStack(spacing: 48) {
218 +
                    Button(action: { audioPlayer.skipBackward() }) {
219 +
                        Image(systemName: "gobackward.15")
220 +
                            .font(.title)
221 +
                            .foregroundColor(.white)
222 +
                    }
223 +
224 +
                    Button(action: { audioPlayer.togglePlayPause() }) {
225 +
                        Image(systemName: audioPlayer.isPlaying ? "pause.circle.fill" : "play.circle.fill")
226 +
                            .font(.system(size: 64))
227 +
                            .foregroundColor(.orange)
228 +
                    }
229 +
230 +
                    Button(action: { audioPlayer.skipForward() }) {
231 +
                        Image(systemName: "goforward.15")
232 +
                            .font(.title)
233 +
                            .foregroundColor(.white)
234 +
                    }
235 +
                }
236 +
            }
237 +
        }
238 +
        .onAppear {
239 +
            audioPlayer.loadData(data)
240 +
        }
241 +
        .onDisappear {
242 +
            audioPlayer.stop()
243 +
        }
244 +
    }
245 +
246 +
    private func formatTime(_ time: TimeInterval) -> String {
247 +
        let minutes = Int(time) / 60
248 +
        let seconds = Int(time) % 60
249 +
        return String(format: "%d:%02d", minutes, seconds)
250 +
    }
251 +
}
252 +
253 +
// MARK: - Audio Player ViewModel
254 +
255 +
class AudioPlayerViewModel: ObservableObject {
256 +
    @Published var isPlaying = false
257 +
    @Published var currentTime: TimeInterval = 0
258 +
    @Published var duration: TimeInterval = 0
259 +
260 +
    private var player: AVAudioPlayer?
261 +
    private var timer: Timer?
262 +
    private var tempFileURL: URL?
263 +
264 +
    func loadData(_ data: Data) {
265 +
        // Write to temp file (AVAudioPlayer works better with files)
266 +
        let tempURL = FileManager.default.temporaryDirectory
267 +
            .appendingPathComponent(UUID().uuidString)
268 +
            .appendingPathExtension("audio")
269 +
270 +
        do {
271 +
            try data.write(to: tempURL)
272 +
            tempFileURL = tempURL
273 +
274 +
            try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
275 +
            try AVAudioSession.sharedInstance().setActive(true)
276 +
277 +
            player = try AVAudioPlayer(contentsOf: tempURL)
278 +
            player?.prepareToPlay()
279 +
            duration = player?.duration ?? 0
280 +
        } catch {
281 +
            print("Failed to load audio: \(error)")
282 +
        }
283 +
    }
284 +
285 +
    func togglePlayPause() {
286 +
        guard let player = player else { return }
287 +
288 +
        if isPlaying {
289 +
            player.pause()
290 +
            stopTimer()
291 +
        } else {
292 +
            player.play()
293 +
            startTimer()
294 +
        }
295 +
        isPlaying = player.isPlaying
296 +
    }
297 +
298 +
    func stop() {
299 +
        player?.stop()
300 +
        stopTimer()
301 +
        isPlaying = false
302 +
303 +
        // Cleanup temp file
304 +
        if let tempURL = tempFileURL {
305 +
            try? FileManager.default.removeItem(at: tempURL)
306 +
        }
307 +
    }
308 +
309 +
    func seek(to time: TimeInterval) {
310 +
        player?.currentTime = time
311 +
        currentTime = time
312 +
    }
313 +
314 +
    func skipForward() {
315 +
        guard let player = player else { return }
316 +
        let newTime = min(player.currentTime + 15, duration)
317 +
        seek(to: newTime)
318 +
    }
319 +
320 +
    func skipBackward() {
321 +
        guard let player = player else { return }
322 +
        let newTime = max(player.currentTime - 15, 0)
323 +
        seek(to: newTime)
324 +
    }
325 +
326 +
    private func startTimer() {
327 +
        timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
328 +
            guard let self = self, let player = self.player else { return }
329 +
            self.currentTime = player.currentTime
330 +
331 +
            if !player.isPlaying && self.isPlaying {
332 +
                self.isPlaying = false
333 +
                self.stopTimer()
334 +
            }
335 +
        }
336 +
    }
337 +
338 +
    private func stopTimer() {
339 +
        timer?.invalidate()
340 +
        timer = nil
341 +
    }
342 +
}
343 +
344 +
// MARK: - Unsupported Content
345 +
346 +
struct UnsupportedContent: View {
347 +
    let mimeType: String
348 +
349 +
    var body: some View {
350 +
        VStack(spacing: 16) {
351 +
            Image(systemName: "doc.questionmark")
352 +
                .font(.system(size: 64))
353 +
                .foregroundColor(.gray)
354 +
            Text("Unsupported media type")
355 +
                .font(.headline)
356 +
                .foregroundColor(.white)
357 +
            Text(mimeType)
358 +
                .font(.caption)
359 +
                .foregroundColor(.gray)
360 +
        }
361 +
    }
362 +
}
363 +
364 +
#Preview {
365 +
    MediaPreviewView(
366 +
        media: MediaContent(
367 +
            data: Data(),
368 +
            mimeType: "image/png",
369 +
            sourceURL: "gemini://example.com/test.png"
370 +
        ),
371 +
        onDismiss: {}
372 +
    )
373 +
}