WIP: add video clip support

This commit is contained in:
Akshay Kolli
2026-07-01 15:39:43 -07:00
parent 26e4b15093
commit 23788fd136
14 changed files with 457 additions and 35 deletions

View File

@@ -11,6 +11,7 @@ enum ClipboardItemKind: Int {
case audio
case color
case code
case video
var displayName: String {
switch self {
@@ -24,6 +25,7 @@ enum ClipboardItemKind: Int {
case .audio: return "audio"
case .color: return "color"
case .code: return "code"
case .video: return "video"
}
}
}
@@ -31,7 +33,7 @@ enum ClipboardItemKind: Int {
extension ClipboardItemKind {
var canOpen: Bool {
switch self {
case .url, .file, .image, .pdf, .audio:
case .url, .file, .image, .pdf, .audio, .video:
return true
case .text, .richText, .unknown, .color, .code:
return false
@@ -40,7 +42,7 @@ extension ClipboardItemKind {
var canReveal: Bool {
switch self {
case .file, .image, .pdf, .audio:
case .file, .image, .pdf, .audio, .video:
return true
case .text, .richText, .unknown, .url, .color, .code:
return false
@@ -49,7 +51,7 @@ extension ClipboardItemKind {
var hasManagedCacheReference: Bool {
switch self {
case .url, .image, .pdf, .audio, .richText:
case .url, .image, .pdf, .audio, .richText, .video:
return true
case .text, .file, .unknown, .color, .code:
return false
@@ -68,8 +70,9 @@ enum ClipboardSortMode: Int {
case audio
case colors
case code
case videos
static let allCases: [ClipboardSortMode] = [.mostRecent, .mostUsed, .text, .links, .images, .colors, .audio, .files, .pinned, .code]
static let allCases: [ClipboardSortMode] = [.mostRecent, .mostUsed, .text, .links, .images, .colors, .audio, .videos, .files, .pinned, .code]
var title: String {
switch self {
@@ -83,6 +86,7 @@ enum ClipboardSortMode: Int {
case .audio: return "Audio"
case .colors: return "Colors"
case .code: return "Code"
case .videos: return "Videos"
}
}
}
@@ -156,6 +160,7 @@ struct ClipboardItem {
case .audio: return "audio sound"
case .color: return "color swatch hex"
case .code: return "code snippet source programming"
case .video: return "video movie mp4 quicktime"
}
}

View File

@@ -0,0 +1,62 @@
import AppKit
import Foundation
import UniformTypeIdentifiers
enum VideoPayload {
static let pasteboardTypes: [NSPasteboard.PasteboardType] = [
NSPasteboard.PasteboardType(rawValue: UTType.mpeg4Movie.identifier),
NSPasteboard.PasteboardType(rawValue: UTType.quickTimeMovie.identifier),
NSPasteboard.PasteboardType(rawValue: UTType.movie.identifier),
NSPasteboard.PasteboardType(rawValue: UTType.video.identifier),
NSPasteboard.PasteboardType(rawValue: "com.apple.m4v-video")
]
static func data(from pasteboard: NSPasteboard) -> (data: Data, type: NSPasteboard.PasteboardType)? {
for type in pasteboardTypes {
if let data = pasteboard.data(forType: type), !data.isEmpty {
return (data, type)
}
}
return nil
}
static func fileExtension(for type: NSPasteboard.PasteboardType) -> String {
switch type.rawValue {
case UTType.mpeg4Movie.identifier:
return "mp4"
case UTType.quickTimeMovie.identifier, UTType.movie.identifier, UTType.video.identifier:
return "mov"
case "com.apple.m4v-video":
return "m4v"
default:
return "mov"
}
}
static func pasteboardType(forPath path: String) -> NSPasteboard.PasteboardType {
switch URL(fileURLWithPath: path).pathExtension.lowercased() {
case "mp4":
return NSPasteboard.PasteboardType(rawValue: UTType.mpeg4Movie.identifier)
case "m4v":
return NSPasteboard.PasteboardType(rawValue: "com.apple.m4v-video")
case "mov", "qt":
return NSPasteboard.PasteboardType(rawValue: UTType.quickTimeMovie.identifier)
default:
return NSPasteboard.PasteboardType(rawValue: UTType.movie.identifier)
}
}
static func displayTitle(byteCount: Int) -> String {
"Video (\(ByteCountFormatter.string(fromByteCount: Int64(byteCount), countStyle: .file)))"
}
static func fileExtension(from path: String) -> String {
let value = URL(fileURLWithPath: path).pathExtension.clipboardTrimmed
return value.isEmpty ? "mov" : value.lowercased()
}
static func kindText(from path: String) -> String {
let value = fileExtension(from: path)
return value.isEmpty ? "Video" : value.uppercased()
}
}

View File

@@ -55,6 +55,10 @@ final class ClipboardCacheService {
cacheAttachment(data, id: id, fileExtension: "sound")
}
func cacheVideo(_ data: Data, id: UUID, fileExtension: String) -> String? {
cacheAttachment(data, id: id, fileExtension: fileExtension)
}
func cacheRichText(_ data: Data, id: UUID) -> String? {
cacheAttachment(data, id: id, fileExtension: "rtf")
}
@@ -105,7 +109,7 @@ final class ClipboardCacheService {
case .file:
return filePreviewThumbnail(for: item.payload)
case .text, .unknown, .audio, .richText, .color, .code:
case .text, .unknown, .audio, .richText, .color, .code, .video:
return nil
}
}
@@ -174,6 +178,9 @@ final class ClipboardCacheService {
case .audio:
guard let data = data(for: item.payload) else { return nil }
return writeTemporaryCopy(data: data, id: item.id, fileExtension: "sound")
case .video:
guard let data = data(for: item.payload) else { return nil }
return writeTemporaryCopy(data: data, id: item.id, fileExtension: VideoPayload.fileExtension(from: item.payload))
case .richText:
guard let data = data(for: item.payload) else { return nil }
return writeTemporaryCopy(data: data, id: item.id, fileExtension: "rtf")
@@ -197,7 +204,7 @@ final class ClipboardCacheService {
case .url:
guard let data = webLocationData(for: item.payload) else { return nil }
return writeTemporaryCopy(data: data, id: item.id, fileExtension: "webloc")
case .image, .pdf, .audio, .richText:
case .image, .pdf, .audio, .richText, .video:
return temporaryReadableURL(for: item)
}
}
@@ -212,7 +219,7 @@ final class ClipboardCacheService {
if let thumbnailPath = item.thumbnailPath {
_ = self.data(for: thumbnailPath)
}
if (item.kind == .pdf || item.kind == .audio || item.kind == .richText), self.isManagedAttachment(path: item.payload) {
if (item.kind == .pdf || item.kind == .audio || item.kind == .richText || item.kind == .video), self.isManagedAttachment(path: item.payload) {
_ = self.data(for: item.payload)
}
}
@@ -230,7 +237,7 @@ final class ClipboardCacheService {
try? self.fileManager.removeItem(atPath: path)
self.thumbnailCache.removeObject(forKey: NSString(string: path))
}
if (item.kind == .pdf || item.kind == .audio || item.kind == .richText), self.isManagedAttachment(path: item.payload) {
if (item.kind == .pdf || item.kind == .audio || item.kind == .richText || item.kind == .video), self.isManagedAttachment(path: item.payload) {
try? self.fileManager.removeItem(atPath: item.payload)
}
}

View File

@@ -222,6 +222,15 @@ final class ClipboardMonitorService {
return pdfItem
}
if isIgnored(.video), hasVideo(on: pasteboard) {
reportReadFailureStatus(ignoredKindMessage(.video))
return nil
}
if let videoItem = itemFromVideo(pasteboard, sourceApp: source.name, sourceBundleId: source.bundleId) {
return videoItem
}
if isIgnored(.audio), hasAudio(on: pasteboard) {
reportReadFailureStatus(ignoredKindMessage(.audio))
return nil
@@ -440,6 +449,10 @@ final class ClipboardMonitorService {
pasteboard.data(forType: .sound) != nil
}
private func hasVideo(on pasteboard: NSPasteboard) -> Bool {
VideoPayload.data(from: pasteboard) != nil
}
private func hasColor(on pasteboard: NSPasteboard) -> Bool {
NSColor(from: pasteboard) != nil
}
@@ -511,6 +524,32 @@ final class ClipboardMonitorService {
)
}
private func itemFromVideo(_ pasteboard: NSPasteboard, sourceApp: String?, sourceBundleId: String?) -> ClipboardItem? {
guard let video = VideoPayload.data(from: pasteboard) else { return nil }
let id = UUID()
let hash = store.hashString(video.data.base64EncodedString())
guard let path = cacheService.cacheVideo(video.data, id: id, fileExtension: VideoPayload.fileExtension(for: video.type)) else {
reportReadFailureStatus("Failed to cache video for clipboard history.")
return nil
}
return ClipboardItem(
id: id,
kind: .video,
displayText: VideoPayload.displayTitle(byteCount: video.data.count),
payload: path,
payloadHash: hash,
createdAt: Date(),
lastUsedAt: Date(),
useCount: 1,
sourceApp: sourceApp,
imagePath: nil,
thumbnailPath: nil,
isPinned: false,
sourceAppBundleId: sourceBundleId
)
}
private func itemFromColor(_ pasteboard: NSPasteboard, sourceApp: String?, sourceBundleId: String?) -> ClipboardItem? {
guard let color = NSColor(from: pasteboard) else { return nil }
guard let hex = ColorPayload.hexString(from: color) else {

View File

@@ -130,6 +130,13 @@ final class PasteActionService {
pasteboardItem.setString(dragLabel(for: item), forType: .string)
return [pasteboardItem]
case .video:
guard let data = cacheService.data(for: item.payload) else { return [] }
let pasteboardItem = NSPasteboardItem()
pasteboardItem.setData(data, forType: VideoPayload.pasteboardType(forPath: item.payload))
pasteboardItem.setString(dragLabel(for: item), forType: .string)
return [pasteboardItem]
case .color:
guard let color = ColorPayload.color(from: item.payload) else { return [] }
return [color]
@@ -188,6 +195,10 @@ final class PasteActionService {
guard let data = cacheService.data(for: item.payload) else { return false }
board.clearContents()
didWrite = board.setData(data, forType: .sound)
case .video:
guard let data = cacheService.data(for: item.payload) else { return false }
board.clearContents()
didWrite = board.setData(data, forType: VideoPayload.pasteboardType(forPath: item.payload))
case .color:
guard let color = ColorPayload.color(from: item.payload) else { return false }
board.clearContents()
@@ -248,7 +259,7 @@ final class PasteActionService {
return nonEmptyPlainText(richTextFallbackPlainString(for: item))
case .image:
return nonEmptyPlainText(item.ocrText) ?? nonEmptyPlainText(item.displayText)
case .pdf, .audio:
case .pdf, .audio, .video:
return nonEmptyPlainText(item.displayText)
case .color:
return nonEmptyPlainText(ColorPayload.displayHex(from: item.payload)) ?? nonEmptyPlainText(item.displayText)

View File

@@ -64,6 +64,7 @@ private enum ClipboardCollectionVisuals {
case .files: return NSColor(calibratedRed: 0.11, green: 0.68, blue: 0.36, alpha: 1)
case .pinned: return NSColor(calibratedRed: 0.94, green: 0.12, blue: 0.48, alpha: 1)
case .code: return NSColor(calibratedRed: 0.25, green: 0.38, blue: 0.78, alpha: 1)
case .videos: return NSColor(calibratedRed: 0.43, green: 0.32, blue: 0.94, alpha: 1)
}
}
@@ -644,6 +645,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
case .files: return "Files"
case .pinned: return "Pinned"
case .code: return "Code"
case .videos: return "Videos"
}
}
@@ -659,6 +661,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
case .files: return "doc.fill"
case .pinned: return "pin.fill"
case .code: return "chevron.left.forwardslash.chevron.right"
case .videos: return "film"
}
}
@@ -1159,6 +1162,8 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
return ("No text clips yet", "Copied text and rich text appear here.")
case .code:
return ("No code snippets yet", "Copied code snippets appear here.")
case .videos:
return ("No videos yet", "Copied movie and video clips appear here.")
case .files:
return ("No files yet", "Copied files and PDFs appear here.")
case .audio:
@@ -1968,7 +1973,7 @@ private enum ClipboardItemDragPasteboard {
.sound,
.rtf,
.color
]
] + VideoPayload.pasteboardTypes
}
private func shelfSearchText(from event: NSEvent) -> String? {
@@ -3100,7 +3105,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
private var canOpen: Bool {
switch itemKind {
case .url, .file, .image, .pdf, .audio:
case .url, .file, .image, .pdf, .audio, .video:
return true
case .text, .richText, .unknown, .color, .code:
return false
@@ -3109,7 +3114,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
private var canPreview: Bool {
switch itemKind {
case .text, .url, .image, .richText, .file, .pdf, .audio, .unknown, .color, .code:
case .text, .url, .image, .richText, .file, .pdf, .audio, .unknown, .color, .code, .video:
return true
}
}
@@ -3120,7 +3125,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
private var canPlainText: Bool {
switch itemKind {
case .url, .image, .richText, .file, .pdf, .audio, .color:
case .url, .image, .richText, .file, .pdf, .audio, .color, .video:
return true
case .text, .unknown, .code:
return false
@@ -3129,7 +3134,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
private var canReveal: Bool {
switch itemKind {
case .file, .image, .pdf, .audio:
case .file, .image, .pdf, .audio, .video:
return true
case .text, .richText, .url, .unknown, .color, .code:
return false
@@ -3710,6 +3715,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
return filePreviewView(for: item, thumbnail: thumbnail)
case .audio:
return audioPreviewView(for: item)
case .video:
return videoPreviewView(for: item)
case .color:
return colorPreviewView(for: item)
case .code:
@@ -4149,6 +4156,78 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
return container
}
private func videoPreviewView(for item: ClipboardItem) -> NSView {
let container = NSView()
container.wantsLayer = true
container.layer?.backgroundColor = accentColor(for: item.kind).withAlphaComponent(0.10).cgColor
container.translatesAutoresizingMaskIntoConstraints = false
let frame = NSView()
frame.wantsLayer = true
frame.layer?.cornerRadius = 14
frame.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.78).cgColor
frame.layer?.borderWidth = 1
frame.layer?.borderColor = NSColor.white.withAlphaComponent(0.18).cgColor
frame.translatesAutoresizingMaskIntoConstraints = false
let film = headerIcon("film", color: .white)
film.translatesAutoresizingMaskIntoConstraints = false
let play = headerIcon("play.fill", color: .white)
play.translatesAutoresizingMaskIntoConstraints = false
frame.addSubview(film)
frame.addSubview(play)
let extensionPill = capsuleLabel(VideoPayload.kindText(from: item.payload), color: accentColor(for: item.kind))
extensionPill.translatesAutoresizingMaskIntoConstraints = false
frame.addSubview(extensionPill)
let title = NSTextField(labelWithString: titleText(for: item))
title.font = .systemFont(ofSize: 14, weight: .semibold)
title.textColor = .labelColor
title.maximumNumberOfLines = 1
title.lineBreakMode = .byTruncatingTail
title.toolTip = title.stringValue
let detail = NSTextField(labelWithString: previewText(for: item))
detail.font = .systemFont(ofSize: 12)
detail.textColor = .secondaryLabelColor
detail.maximumNumberOfLines = 1
detail.lineBreakMode = .byTruncatingTail
detail.toolTip = detail.stringValue
let labels = NSStackView(views: [title, detail])
labels.orientation = .vertical
labels.alignment = .leading
labels.spacing = 3
labels.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(frame)
container.addSubview(labels)
NSLayoutConstraint.activate([
frame.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: layout.inset),
frame.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.inset),
frame.topAnchor.constraint(equalTo: container.topAnchor, constant: layout.isCompact ? 14 : 16),
frame.heightAnchor.constraint(equalToConstant: layout.isCompact ? 82 : 92),
film.centerXAnchor.constraint(equalTo: frame.centerXAnchor),
film.centerYAnchor.constraint(equalTo: frame.centerYAnchor),
film.widthAnchor.constraint(equalToConstant: 38),
film.heightAnchor.constraint(equalToConstant: 38),
play.centerXAnchor.constraint(equalTo: frame.centerXAnchor, constant: 1),
play.centerYAnchor.constraint(equalTo: frame.centerYAnchor),
play.widthAnchor.constraint(equalToConstant: 16),
play.heightAnchor.constraint(equalToConstant: 16),
extensionPill.trailingAnchor.constraint(equalTo: frame.trailingAnchor, constant: -10),
extensionPill.bottomAnchor.constraint(equalTo: frame.bottomAnchor, constant: -10),
labels.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: layout.inset),
labels.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.inset),
labels.topAnchor.constraint(equalTo: frame.bottomAnchor, constant: 10),
labels.bottomAnchor.constraint(lessThanOrEqualTo: container.bottomAnchor, constant: -8),
title.widthAnchor.constraint(equalTo: labels.widthAnchor),
detail.widthAnchor.constraint(equalTo: labels.widthAnchor)
])
return container
}
private func colorPreviewView(for item: ClipboardItem) -> NSView {
let swatchColor = ColorPayload.color(from: item.payload) ?? accentColor(for: item.kind)
let textColor = ColorPayload.contrastingTextColor(for: swatchColor)
@@ -4363,6 +4442,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
return thumbnail == nil ? "file-preview" : "file-media-preview"
case .audio:
return "audio-preview"
case .video:
return "video-preview"
case .color:
return "color-preview"
case .code:
@@ -4542,6 +4623,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
return fileTitle(for: item, fallback: "PDF document")
case .audio:
return audioTitle(for: item)
case .video:
return videoTitle(for: item)
case .image:
return imageTitle(for: item)
case .color:
@@ -4576,6 +4659,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
return fileLocationText(from: item.payload, fallback: "PDF document")
case .audio:
return "Sound clip"
case .video:
return "Video clip"
case .color:
return ColorPayload.componentSummary(from: item.payload)
case .code:
@@ -4611,6 +4696,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
return "PDF"
case .audio:
return "Audio"
case .video:
return VideoPayload.kindText(from: item.payload)
case .color:
return "Color"
case .code:
@@ -4702,6 +4789,14 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
return "Audio"
}
private func videoTitle(for item: ClipboardItem) -> String {
let display = firstUsefulLine(item.displayText)
if !display.isEmpty, !looksInternal(display), display.lowercased() != "video" {
return display
}
return "Video"
}
private func webComponents(from value: String) -> URLComponents? {
let trimmed = value.clipboardTrimmed
guard !trimmed.isEmpty else { return nil }
@@ -4929,6 +5024,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
return NSColor(calibratedRed: 0.55, green: 0.35, blue: 0.88, alpha: 1)
case .audio:
return NSColor(calibratedRed: 0.93, green: 0.12, blue: 0.34, alpha: 1)
case .video:
return NSColor(calibratedRed: 0.43, green: 0.32, blue: 0.94, alpha: 1)
case .color:
return NSColor(calibratedRed: 0.00, green: 0.65, blue: 0.74, alpha: 1)
case .code:
@@ -4947,6 +5044,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
case .file: return "doc"
case .pdf: return "doc.text.fill"
case .audio: return "music.note"
case .video: return "film"
case .color: return "paintpalette"
case .code: return "chevron.left.forwardslash.chevron.right"
case .unknown: return "questionmark"
@@ -5020,6 +5118,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
case .unknown: return "Unknown"
case .pdf: return "PDF"
case .audio: return "Audio"
case .video: return "Video"
case .color: return "Color"
case .code: return "Code"
}

View File

@@ -454,6 +454,9 @@ final class ClipboardPanelViewModel {
case .audio:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.open(url)
case .video:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.open(url)
case .color:
break
default:
@@ -477,6 +480,9 @@ final class ClipboardPanelViewModel {
case .audio:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.activateFileViewerSelecting([url])
case .video:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.activateFileViewerSelecting([url])
case .color:
break
default:
@@ -792,6 +798,12 @@ final class ClipboardPanelViewModel {
.sorted(by: sortByUsage)
.map(\.1)
case .videos:
return collectionFiltered
.filter { $0.1.kind == .video }
.sorted(by: sortByUsage)
.map(\.1)
case .colors:
return collectionFiltered
.filter { $0.1.kind == .color }
@@ -1016,6 +1028,8 @@ final class ClipboardPanelViewModel {
return [.pdf]
case "audio", "sound", "music":
return [.audio]
case "video", "videos", "movie", "movies", "mp4", "quicktime", "mov":
return [.video]
case "color", "colors", "swatch", "swatches", "hex":
return [.color]
case "unknown", "item":

View File

@@ -172,6 +172,7 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD
kindCheckbox("Images", .image),
kindCheckbox("Colors", .color),
kindCheckbox("Audio", .audio),
kindCheckbox("Videos", .video),
kindCheckbox("Rich text", .richText),
kindCheckbox("PDFs", .pdf),
kindCheckbox("Files", .file)
@@ -201,7 +202,7 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD
}
private func privacySettingsView() -> NSView {
let storageLabel = caption("Clipboard history is stored locally in Application Support. Text, image cache files, audio clips, and PDF attachments are encrypted with Keychain when available, or an owner-only local fallback key if needed.")
let storageLabel = caption("Clipboard history is stored locally in Application Support. Text, image cache files, audio clips, video clips, and PDF attachments are encrypted with Keychain when available, or an owner-only local fallback key if needed.")
let permissionHelpLabel = caption("Clipboard history capture works without this permission. Grant Accessibility to paste selected items into the previous app.")
configureCheckbox(clearHistoryOnQuitButton, title: "Clear history on quit", action: #selector(clearHistoryOnQuitChanged))
configureStatusLabel(accessibilityStatusLabel)