feat: added media previews and rendering
40d59176
4 file(s) · +493 −5
| 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; |
|
| 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 | + | } |
| 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 |
|
| 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 | + | } |