diff --git a/Mactrix/Extensions/MatrixRustSDK+MessageContent.swift b/Mactrix/Extensions/MatrixRustSDK+MessageContent.swift new file mode 100644 index 0000000..fc13dd8 --- /dev/null +++ b/Mactrix/Extensions/MatrixRustSDK+MessageContent.swift @@ -0,0 +1,13 @@ +import MatrixRustSDK + +protocol MessageContent { + var filename: String { get } + var caption: String? { get } + var formattedCaption: FormattedBody? { get } + var source: MediaSource { get } +} + +extension FileMessageContent: MessageContent {} +extension AudioMessageContent: MessageContent {} +extension VideoMessageContent: MessageContent {} +extension ImageMessageContent: MessageContent {} diff --git a/Mactrix/Views/ChatView/ChatMessageView.swift b/Mactrix/Views/ChatView/ChatMessageView.swift index 8a4baf6..5937e2d 100644 --- a/Mactrix/Views/ChatView/ChatMessageView.swift +++ b/Mactrix/Views/ChatView/ChatMessageView.swift @@ -71,7 +71,7 @@ struct ChatMessageView: View, UI.MessageEventActions { case let .audio(content: content): Text("Audio: \(content.caption ?? "no caption") \(content.filename)").textSelection(.enabled) case let .video(content: content): - Text("Video: \(content.caption ?? "no caption") \(content.filename)").textSelection(.enabled) + MessageVideoView(content: content) case let .file(content: content): MessageFileView(content: content) case let .gallery(content: content): diff --git a/Mactrix/Views/ChatView/MessageVideoView.swift b/Mactrix/Views/ChatView/MessageVideoView.swift new file mode 100644 index 0000000..95d3e12 --- /dev/null +++ b/Mactrix/Views/ChatView/MessageVideoView.swift @@ -0,0 +1,77 @@ +import AVKit +import MatrixRustSDK +import Models +import OSLog +import SwiftUI + +struct MessageVideoView: View { + @Environment(AppState.self) private var appState + let content: VideoMessageContent + + @State private var fileHandle: MediaFileHandle? + @State private var video: AVPlayer? + + var aspectRatio: CGFloat? { + guard let info = content.info, + let height = info.height, + let width = info.width else { return nil } + + return CGFloat(width) / CGFloat(height) + } + + var maxHeight: CGFloat { + guard let height = content.info?.height else { return 300 } + return min(CGFloat(height), 300) + } + + func loadVideo() async { + guard let client = appState.matrixClient?.client else { return } + + do { + let handle = try await client.getMediaFile( + mediaSource: content.source, + filename: content.filename, + mimeType: content.info?.mimetype ?? "", + useCache: true, + tempDir: NSTemporaryDirectory() + ) + + fileHandle = handle + let path = try handle.path() + let url = URL(filePath: path, directoryHint: .notDirectory) + + video = AVPlayer(url: url) + video?.play() + } catch { + Logger.viewCycle.error("Failed to load video: \(error)") + } + } + + var body: some View { + VStack { + if let video { + TimelineVideoPlayer(videoPlayer: video) + .cornerRadius(6) + } else { + Button(action: { Task { await loadVideo() } }) { + MatrixImageView(mediaSource: content.info?.thumbnailSource, mimeType: content.info?.thumbnailInfo?.mimetype) + .overlay { + Image(systemName: "play.fill") + .resizable() + .foregroundStyle(.white) + .shadow(radius: 4) + .frame(width: 48, height: 48) + .opacity(0.9) + } + } + .buttonStyle(.plain) + } + if let caption = content.caption, !caption.isEmpty { + Text(caption.formatAsMarkdown) + .textSelection(.enabled) + } + } + .frame(maxHeight: maxHeight) + .aspectRatio(aspectRatio, contentMode: .fit) + } +} diff --git a/Mactrix/Views/ChatView/TimelineVideoPlayer.swift b/Mactrix/Views/ChatView/TimelineVideoPlayer.swift new file mode 100644 index 0000000..1e6327e --- /dev/null +++ b/Mactrix/Views/ChatView/TimelineVideoPlayer.swift @@ -0,0 +1,28 @@ +import AppKit +import AVKit +import SwiftUI + +struct TimelineVideoPlayer: NSViewRepresentable { + let videoPlayer: AVPlayer + + func makeNSView(context: Context) -> AVPlayerView { + let playerView = AVPlayerView() + playerView.allowsPictureInPicturePlayback = true + playerView.showsFullScreenToggleButton = true + playerView.showsSharingServiceButton = true + + playerView.player = videoPlayer + + return playerView + } + + func updateNSView(_ playerView: AVPlayerView, context: Context) { + playerView.player = videoPlayer + } + + func makeCoordinator() -> Coordinator { + return Coordinator() + } + + class Coordinator {} +} diff --git a/Mactrix/Views/MatrixImageView.swift b/Mactrix/Views/MatrixImageView.swift new file mode 100644 index 0000000..180379b --- /dev/null +++ b/Mactrix/Views/MatrixImageView.swift @@ -0,0 +1,50 @@ +import MatrixRustSDK +import SwiftUI +import UniformTypeIdentifiers + +struct MatrixImageView: View { + let mediaSource: MediaSource? + let mimeType: String? + + @Environment(AppState.self) private var appState + @State private var image: Image? = nil + @State private var errorMessage: String? = nil + + @ViewBuilder + var content: some View { + if let image { + image + } else if let errorMessage { + VStack { + ContentUnavailableView("Error loading image", image: "photo.badge.exclamationmark") + Text(errorMessage) + } + } else { + ProgressView { + Text("Fetching image") + } + } + } + + var body: some View { + content + .task(id: mediaSource?.url(), priority: .utility) { + guard let matrixClient = appState.matrixClient else { + errorMessage = "Matrix client not available" + return + } + + guard let mediaSource else { + return + } + + do { + let data = try await matrixClient.client.getMediaContent(mediaSource: mediaSource) + let contentType = mimeType.flatMap { UTType(mimeType: $0) } + image = try await Image(importing: data, contentType: contentType) + } catch { + errorMessage = error.localizedDescription + } + } + } +}