diff --git a/Sources/Cutmark/PostRecordingPreview.swift b/Sources/Cutmark/PostRecordingPreview.swift index 5e4733b..673c1ba 100644 --- a/Sources/Cutmark/PostRecordingPreview.swift +++ b/Sources/Cutmark/PostRecordingPreview.swift @@ -36,9 +36,11 @@ struct PostRecordingPreview: Equatable, Sendable, Identifiable { func matches(_ doc: SessionDoc) -> Bool { doc.meta.id == sessionID } @MainActor - func source(in doc: SessionDoc) -> (take: Take, url: URL)? { + /// A requested source never silently substitutes the other kind. The default still + /// prefers screen and falls back to the only saved/playable file. + func source(in doc: SessionDoc, kind: TakeKind? = nil) -> (take: Take, url: URL)? { guard matches(doc) else { return nil } - let group = doc.meta.takes.filter { $0.number == number } + let group = doc.meta.takes.filter { $0.number == number && (kind == nil || $0.kind == kind) } let ordered = group.sorted { a, b in if a.kind == preferredKind { return true } if b.kind == preferredKind { return false } @@ -138,10 +140,28 @@ struct PostRecordingPreviewCard: View { @State private var player = AVPlayer() @State private var loadedURL: URL? @State private var playing = false + @State private var selectedKind: TakeKind? - private var sourceURL: URL? { + private var visibleSource: (take: Take, url: URL)? { guard app.library.current?.meta.id == recent.sessionID else { return nil } - return recent.source(in: doc)?.url + if let selectedKind, let selected = recent.source(in: doc, kind: selectedKind) { return selected } + return recent.source(in: doc) + } + + private var sourceURL: URL? { + visibleSource?.url + } + + private var labelForCurrentSource: String { + // The card can outlive a missing/removed file. Never claim the other source is saved + // unless both independently playable files still exist. + guard hasBothSources else { return visibleSource?.take.kind == .screen ? "Screen" : "Camera" } + return visibleSource?.take.kind == .camera + ? "Camera preview · screen saved" : "Screen preview · camera saved" + } + + private var hasBothSources: Bool { + recent.source(in: doc, kind: .camera) != nil && recent.source(in: doc, kind: .screen) != nil } var body: some View { @@ -149,7 +169,7 @@ struct PostRecordingPreviewCard: View { HStack(spacing: 8) { VStack(alignment: .leading, spacing: 2) { Text("Take \(recent.number)").font(Theme.sans(12, .semibold)) - Text(recent.sourceLabel).font(Theme.sans(10.5)).foregroundStyle(Theme.muted) + Text(labelForCurrentSource).font(Theme.sans(10.5)).foregroundStyle(Theme.muted) } Spacer(minLength: 8) Button { dismiss() } label: { @@ -160,6 +180,14 @@ struct PostRecordingPreviewCard: View { .help("Dismiss saved-take preview") } + if hasBothSources { + HStack(spacing: 6) { + sourceButton(.screen) + sourceButton(.camera) + } + .accessibilityLabel("Review saved recordings separately") + } + Button { togglePlayback() } label: { ZStack { Theme.stage @@ -249,6 +277,26 @@ struct PostRecordingPreviewCard: View { playing = true } + private func sourceButton(_ kind: TakeKind) -> some View { + let isSelected = visibleSource?.take.kind == kind + return Button { + guard recent.source(in: doc, kind: kind) != nil else { return } + selectedKind = kind + refreshPlayer() + } label: { + Text(kind == .screen ? "Screen" : "Camera") + .font(Theme.sans(10.5, isSelected ? .semibold : .medium)) + .foregroundStyle(isSelected ? Theme.accent : Theme.muted) + .frame(maxWidth: .infinity, minHeight: 24) + .background(isSelected ? Theme.accent.opacity(0.12) : Theme.stage, + in: RoundedRectangle(cornerRadius: 6)) + } + .buttonStyle(.plain) + .help("Review the saved \(kind.rawValue) file separately; this is not a synchronized composite") + .accessibilityLabel("Preview \(kind.rawValue) recording") + .accessibilityAddTraits(isSelected ? .isSelected : []) + } + private func refreshPlayer() { guard let url = sourceURL else { player.pause(); player.replaceCurrentItem(with: nil); loadedURL = nil; playing = false diff --git a/Tests/CutmarkTests/PostRecordingPreviewTests.swift b/Tests/CutmarkTests/PostRecordingPreviewTests.swift index e884a03..3324d8c 100644 --- a/Tests/CutmarkTests/PostRecordingPreviewTests.swift +++ b/Tests/CutmarkTests/PostRecordingPreviewTests.swift @@ -67,6 +67,38 @@ struct PostRecordingPreviewTests { #expect(preview?.sourceCount == 1) } + @MainActor @Test func pairedPreviewSelectsEachSavedFileWithoutChangingOrMixingSources() throws { + let root = FileManager.default.temporaryDirectory.appending(path: "cutmark-paired-source-\(UUID())") + let session = root.appending(path: "Demo/Session") + try FileManager.default.createDirectory(at: session, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let screen = Take(number: 6, kind: .screen, file: "take-06-screen.mov", + startedAt: .distantPast, duration: 8) + let camera = Take(number: 6, kind: .camera, file: "take-06-camera.mov", + startedAt: .distantPast, duration: 7) + for file in [screen.file, camera.file] { + try Data(file.utf8).write(to: session.appending(path: file)) + } + let doc = SessionDoc(url: session) + doc.meta.takes = [camera, screen] + #expect(doc.save()) + let recent = try #require(PostRecordingPreview.make(sessionID: doc.meta.id, session: session, + number: 6, takes: doc.meta.takes)) + #expect(recent.sourceCount == 2) + #expect(recent.source(in: doc)?.take.kind == .screen) + #expect(recent.source(in: doc, kind: .camera)?.url.lastPathComponent == camera.file) + #expect(recent.source(in: doc, kind: .screen)?.url.lastPathComponent == screen.file) + #expect(recent.source(in: doc, kind: .camera)?.take.duration == 7) + + // If one source disappears from the disk, never substitute it for an explicit request. + try FileManager.default.removeItem(at: session.appending(path: camera.file)) + #expect(recent.source(in: doc, kind: .camera) == nil) + #expect(recent.source(in: doc)?.take.kind == .screen) + #expect(doc.meta.takes.count == 2) + #expect(try Data(contentsOf: session.appending(path: screen.file)) == Data(screen.file.utf8)) + } + @Test func floatingCardPinsToLowerLeftOfVisibleDisplay() { let origin = PostRecordingPreviewPlacement.origin( screenFrame: CGRect(x: 1440, y: 24, width: 1920, height: 1056),