Skip to content

Commit 31947fb

Browse files
committed
fix: generate video thumbnails locally when server provides none
- Show gray placeholder immediately when no thumbnailSource (instead of perpetual spinner) - Generate thumbnails using AVAssetImageGenerator — downloads the video and extracts the first frame - Cache generated thumbnails in the shared NSCache for reuse
1 parent 3015dd9 commit 31947fb

1 file changed

Lines changed: 53 additions & 1 deletion

File tree

Mactrix/Views/ChatView/MessageVideoView.swift

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ struct MessageVideoView: View {
1010

1111
@State private var fileHandle: MediaFileHandle?
1212
@State private var video: AVPlayer?
13+
@State private var generatedThumbnail: Image?
1314

1415
var aspectRatio: CGFloat? {
1516
guard let info = content.info,
@@ -47,14 +48,60 @@ struct MessageVideoView: View {
4748
}
4849
}
4950

51+
private func generateThumbnail() async {
52+
guard let client = appState.matrixClient?.client else { return }
53+
54+
let cacheKey = NSString(string: "thumb:" + content.source.url())
55+
if let cached = MatrixClient.imageCache.object(forKey: cacheKey) {
56+
generatedThumbnail = Image(nsImage: cached)
57+
return
58+
}
59+
60+
do {
61+
let handle = try await client.getMediaFile(
62+
mediaSource: content.source,
63+
filename: content.filename,
64+
mimeType: content.info?.mimetype ?? "",
65+
useCache: true,
66+
tempDir: NSTemporaryDirectory()
67+
)
68+
fileHandle = handle
69+
let path = try handle.path()
70+
let url = URL(filePath: path, directoryHint: .notDirectory)
71+
72+
let asset = AVURLAsset(url: url)
73+
let generator = AVAssetImageGenerator(asset: asset)
74+
generator.appliesPreferredTrackTransform = true
75+
generator.maximumSize = CGSize(width: 600, height: 600)
76+
77+
let cgImage = try await generator.image(at: .zero).image
78+
let nsImage = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height))
79+
MatrixClient.imageCache.setObject(nsImage, forKey: cacheKey)
80+
generatedThumbnail = Image(nsImage: nsImage)
81+
} catch {
82+
Logger.viewCycle.error("Failed to generate video thumbnail: \(error)")
83+
}
84+
}
85+
86+
@ViewBuilder
87+
var thumbnailView: some View {
88+
if let thumbnailSource = content.info?.thumbnailSource {
89+
MatrixImageView(mediaSource: thumbnailSource, mimeType: content.info?.thumbnailInfo?.mimetype)
90+
} else if let generatedThumbnail {
91+
generatedThumbnail.resizable().scaledToFit()
92+
} else {
93+
Rectangle().fill(Color.gray.opacity(0.3))
94+
}
95+
}
96+
5097
var body: some View {
5198
VStack {
5299
if let video {
53100
TimelineVideoPlayer(videoPlayer: video)
54101
.cornerRadius(6)
55102
} else {
56103
Button(action: { Task { await loadVideo() } }) {
57-
MatrixImageView(mediaSource: content.info?.thumbnailSource, mimeType: content.info?.thumbnailInfo?.mimetype)
104+
thumbnailView
58105
.overlay {
59106
Image(systemName: "play.fill")
60107
.resizable()
@@ -73,5 +120,10 @@ struct MessageVideoView: View {
73120
}
74121
.frame(maxHeight: maxHeight)
75122
.aspectRatio(aspectRatio, contentMode: .fit)
123+
.task(id: content.source.url(), priority: .utility) {
124+
if content.info?.thumbnailSource == nil {
125+
await generateThumbnail()
126+
}
127+
}
76128
}
77129
}

0 commit comments

Comments
 (0)