WIP: add video thumbnails
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
import AppKit
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
final class ClipboardCacheService {
|
||||
typealias VideoThumbnailProvider = (URL) -> NSImage?
|
||||
|
||||
private let thumbnailCache = NSCache<NSString, NSImage>()
|
||||
private let fileManager = FileManager.default
|
||||
private let queue = DispatchQueue(label: "clipboard.cache.service", qos: .utility)
|
||||
@@ -9,8 +12,13 @@ final class ClipboardCacheService {
|
||||
private let attachmentDirectory: URL
|
||||
private let temporaryPreviewDirectory: URL
|
||||
private let encryptionService: ClipboardEncryptionService
|
||||
private let videoThumbnailProvider: VideoThumbnailProvider
|
||||
|
||||
init(baseURL: URL? = nil, encryptionService: ClipboardEncryptionService = ClipboardEncryptionService()) {
|
||||
init(
|
||||
baseURL: URL? = nil,
|
||||
encryptionService: ClipboardEncryptionService = ClipboardEncryptionService(),
|
||||
videoThumbnailProvider: VideoThumbnailProvider? = nil
|
||||
) {
|
||||
let base = baseURL ?? ClipboardStore.storageDirectory()
|
||||
imageDirectory = base.appendingPathComponent("images", isDirectory: true)
|
||||
attachmentDirectory = base.appendingPathComponent("attachments", isDirectory: true)
|
||||
@@ -18,6 +26,7 @@ final class ClipboardCacheService {
|
||||
.appendingPathComponent(AppConfiguration.appName, isDirectory: true)
|
||||
.appendingPathComponent("Previews", isDirectory: true)
|
||||
self.encryptionService = encryptionService
|
||||
self.videoThumbnailProvider = videoThumbnailProvider ?? Self.makeVideoThumbnail
|
||||
thumbnailCache.countLimit = 128
|
||||
try? fileManager.createDirectory(at: imageDirectory, withIntermediateDirectories: true)
|
||||
try? fileManager.createDirectory(at: attachmentDirectory, withIntermediateDirectories: true)
|
||||
@@ -109,7 +118,10 @@ final class ClipboardCacheService {
|
||||
case .file:
|
||||
return filePreviewThumbnail(for: item.payload)
|
||||
|
||||
case .text, .unknown, .audio, .richText, .color, .code, .video:
|
||||
case .video:
|
||||
return videoPreviewThumbnail(for: item)
|
||||
|
||||
case .text, .unknown, .audio, .richText, .color, .code:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -154,6 +166,51 @@ final class ClipboardCacheService {
|
||||
return image
|
||||
}
|
||||
|
||||
private func videoPreviewThumbnail(for item: ClipboardItem) -> NSImage? {
|
||||
let key = NSString(string: "video-preview:\(item.id.uuidString):\(item.payload)")
|
||||
if let cached = thumbnailCache.object(forKey: key) {
|
||||
return cached
|
||||
}
|
||||
|
||||
guard
|
||||
let data = data(for: item.payload),
|
||||
let temporaryURL = writeTemporaryCopy(
|
||||
data: data,
|
||||
id: item.id,
|
||||
fileExtension: VideoPayload.fileExtension(from: item.payload)
|
||||
)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
defer { removeTemporaryCopyIfPossible(temporaryURL) }
|
||||
|
||||
guard let image = videoThumbnailProvider(temporaryURL), hasDrawableSize(image) else {
|
||||
return nil
|
||||
}
|
||||
let thumbnail = image.resized(to: CGSize(width: 260, height: 132))
|
||||
thumbnailCache.setObject(thumbnail, forKey: key)
|
||||
return thumbnail
|
||||
}
|
||||
|
||||
private static func makeVideoThumbnail(from url: URL) -> NSImage? {
|
||||
let asset = AVURLAsset(url: url)
|
||||
let generator = AVAssetImageGenerator(asset: asset)
|
||||
generator.appliesPreferredTrackTransform = true
|
||||
generator.maximumSize = CGSize(width: 520, height: 264)
|
||||
generator.requestedTimeToleranceBefore = .zero
|
||||
generator.requestedTimeToleranceAfter = CMTime(value: 1, timescale: 30)
|
||||
|
||||
for time in [CMTime(seconds: 0.08, preferredTimescale: 600), .zero] {
|
||||
if let cgImage = try? generator.copyCGImage(at: time, actualTime: nil) {
|
||||
return NSImage(
|
||||
cgImage: cgImage,
|
||||
size: NSSize(width: CGFloat(cgImage.width), height: CGFloat(cgImage.height))
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func hasDrawableSize(_ image: NSImage) -> Bool {
|
||||
image.size.width > 0 && image.size.height > 0
|
||||
}
|
||||
@@ -347,6 +404,13 @@ final class ClipboardCacheService {
|
||||
}
|
||||
}
|
||||
|
||||
private func removeTemporaryCopyIfPossible(_ url: URL) {
|
||||
try? fileManager.removeItem(at: url)
|
||||
if ((try? fileManager.contentsOfDirectory(at: temporaryPreviewDirectory, includingPropertiesForKeys: nil)) ?? []).isEmpty {
|
||||
try? fileManager.removeItem(at: temporaryPreviewDirectory)
|
||||
}
|
||||
}
|
||||
|
||||
private func webLocationData(for value: String) -> Data? {
|
||||
let trimmed = value.clipboardTrimmed
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
@@ -3716,6 +3716,9 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
||||
case .audio:
|
||||
return audioPreviewView(for: item)
|
||||
case .video:
|
||||
if let thumbnail {
|
||||
return videoMediaPreviewView(for: item, thumbnail: thumbnail)
|
||||
}
|
||||
return videoPreviewView(for: item)
|
||||
case .color:
|
||||
return colorPreviewView(for: item)
|
||||
@@ -4382,6 +4385,50 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
||||
return container
|
||||
}
|
||||
|
||||
private func videoMediaPreviewView(for item: ClipboardItem, thumbnail: NSImage) -> NSView {
|
||||
let container = NSView()
|
||||
container.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let imageView = AspectFillImageView(image: thumbnail)
|
||||
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let playBadge = NSView()
|
||||
playBadge.wantsLayer = true
|
||||
playBadge.layer?.cornerRadius = 22
|
||||
playBadge.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.48).cgColor
|
||||
playBadge.layer?.borderWidth = 0.8
|
||||
playBadge.layer?.borderColor = NSColor.white.withAlphaComponent(0.30).cgColor
|
||||
playBadge.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let play = headerIcon("play.fill", color: .white)
|
||||
play.translatesAutoresizingMaskIntoConstraints = false
|
||||
playBadge.addSubview(play)
|
||||
|
||||
let extensionPill = capsuleLabel(VideoPayload.kindText(from: item.payload), color: NSColor.black.withAlphaComponent(0.60))
|
||||
extensionPill.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
container.addSubview(imageView)
|
||||
container.addSubview(playBadge)
|
||||
container.addSubview(extensionPill)
|
||||
NSLayoutConstraint.activate([
|
||||
imageView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
imageView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
imageView.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
imageView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||
playBadge.centerXAnchor.constraint(equalTo: container.centerXAnchor),
|
||||
playBadge.centerYAnchor.constraint(equalTo: container.centerYAnchor),
|
||||
playBadge.widthAnchor.constraint(equalToConstant: 44),
|
||||
playBadge.heightAnchor.constraint(equalToConstant: 44),
|
||||
play.centerXAnchor.constraint(equalTo: playBadge.centerXAnchor, constant: 1),
|
||||
play.centerYAnchor.constraint(equalTo: playBadge.centerYAnchor),
|
||||
play.widthAnchor.constraint(equalToConstant: 16),
|
||||
play.heightAnchor.constraint(equalToConstant: 16),
|
||||
extensionPill.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -12),
|
||||
extensionPill.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -10)
|
||||
])
|
||||
return container
|
||||
}
|
||||
|
||||
private func capsuleLabel(_ text: String, color: NSColor) -> NSTextField {
|
||||
let label = NSTextField(labelWithString: text)
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
@@ -4443,7 +4490,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
||||
case .audio:
|
||||
return "audio-preview"
|
||||
case .video:
|
||||
return "video-preview"
|
||||
return thumbnail == nil ? "video-preview" : "video-media-preview"
|
||||
case .color:
|
||||
return "color-preview"
|
||||
case .code:
|
||||
|
||||
Reference in New Issue
Block a user