Skip to content

Commit 7748670

Browse files
committed
Updated "Movie Timecode" example project
1 parent 78d76a9 commit 7748670

File tree

8 files changed

+259
-79
lines changed

8 files changed

+259
-79
lines changed

Examples/Movie Timecode/Movie Timecode.xcodeproj/project.pbxproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@
264264
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
265265
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
266266
CODE_SIGN_ENTITLEMENTS = "Movie Timecode/App/MovieTimecode.entitlements";
267+
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
267268
CODE_SIGN_STYLE = Automatic;
268269
CURRENT_PROJECT_VERSION = 1;
269270
DEAD_CODE_STRIPPING = YES;
@@ -305,6 +306,7 @@
305306
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
306307
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
307308
CODE_SIGN_ENTITLEMENTS = "Movie Timecode/App/MovieTimecode.entitlements";
309+
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
308310
CODE_SIGN_STYLE = Automatic;
309311
CURRENT_PROJECT_VERSION = 1;
310312
DEAD_CODE_STRIPPING = YES;

Examples/Movie Timecode/Movie Timecode/AddOrReplaceTimecodeTrackView.swift

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ struct AddOrReplaceTimecodeTrackView: View {
3030
}
3131
Picker("SubFrames Base", selection: $newStartTimecode.subFramesBase) {
3232
ForEach(Timecode.SubFramesBase.allCases) { subFramesBase in
33-
Text("\(subFramesBase)").tag(subFramesBase)
33+
Text("\(subFramesBase.description)").tag(subFramesBase)
3434
}
3535
}
3636

@@ -55,6 +55,23 @@ struct AddOrReplaceTimecodeTrackView: View {
5555
.formStyle(.grouped)
5656
}
5757
.padding()
58+
59+
#if os(macOS)
60+
// note that SwiftUI's .fileImporter does not produce a security-scoped URL suitable for writing to on macOS (but seems fine on iOS).
61+
// also, SwiftUI's .fileExporter insists on writing the data to disk for us with no way to write to the URL manually.
62+
// hence, our workaround is to use a custom NSOpenPanel wrapper.
63+
.fileOpenPanel(isPresented: $isFolderPickerShown) { openPanel in
64+
openPanel.canCreateDirectories = true
65+
openPanel.canChooseDirectories = true
66+
openPanel.canChooseFiles = false
67+
openPanel.allowsMultipleSelection = false
68+
openPanel.title = "Export"
69+
openPanel.directoryURL = model.defaultFolder
70+
} completion: { urls in
71+
guard let url = urls?.first else { return }
72+
Task { await handleResult(.success(url)) }
73+
}
74+
#else
5875
.fileImporter(
5976
isPresented: $isFolderPickerShown,
6077
allowedContentTypes: [.folder]
@@ -63,23 +80,23 @@ struct AddOrReplaceTimecodeTrackView: View {
6380
}
6481
.fileDialogDefaultDirectory(model.defaultFolder)
6582
.fileDialogConfirmationLabel("Export")
83+
#endif
6684
}
6785

6886
private func handleResult(_ result: Result<URL, Error>) async {
69-
switch result {
70-
case let .success(folderURL):
87+
do {
88+
let folderURL = try result.get()
89+
7190
isExportProgressShown = true
72-
guard let fileURL = model.uniqueExportURL(folder: folderURL) else { return }
73-
print("Exporting to \(fileURL.path)")
91+
defer { isExportProgressShown = false }
7492

75-
await model.exportReplacingTimecodeTrack(
76-
startTimecode: newStartTimecode,
77-
to: fileURL,
78-
revealInFinderOnCompletion: true
93+
await model.export(
94+
action: .replaceTimecodeTrack(startTimecode: newStartTimecode),
95+
toFolder: folderURL,
96+
revealInFinderOnCompletion: true // only applies to macOS
7997
)
80-
isExportProgressShown = false
81-
case let .failure(error):
82-
model.error = ModelError.exportError(error)
98+
} catch {
99+
model.error = .exportError(error)
83100
}
84101
}
85102
}

Examples/Movie Timecode/Movie Timecode/ContentView.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,15 @@ struct ContentView: View {
9595
}
9696
}
9797
.tabViewStyle(.tabBarOnly)
98+
#if os(macOS)
9899
.frame(minHeight: 400)
100+
#else
101+
.frame(minHeight: 600)
102+
#endif
99103
.environment(model)
100104
} else {
101105
ZStack {
102-
Text("Load a movie.")
106+
Text("Load a movie using the panel above.")
103107
}
104108
.frame(maxWidth: .infinity, maxHeight: .infinity)
105109
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
//
2+
// Model.swift
3+
// TimecodeKit • https://github.com/orchetect/TimecodeKit
4+
// © 2020-2024 Steffan Andrews • Licensed under MIT License
5+
//
6+
7+
@preconcurrency import AVFoundation
8+
import Observation
9+
import TimecodeKit
10+
11+
extension Model {
12+
struct Movie: Equatable, Hashable, Sendable {
13+
let avMovie: AVMovie
14+
15+
// cached metadata
16+
let frameRate: TimecodeFrameRate?
17+
let timecodeStart: Timecode?
18+
let containsTimecodeTrack: Bool
19+
20+
init(
21+
avMovie: AVMovie,
22+
frameRate: TimecodeFrameRate?,
23+
timecodeStart: Timecode?,
24+
containsTimecodeTrack: Bool
25+
) {
26+
self.avMovie = avMovie
27+
self.frameRate = frameRate
28+
self.timecodeStart = timecodeStart
29+
self.containsTimecodeTrack = containsTimecodeTrack
30+
}
31+
}
32+
}
33+
34+
extension Model.Movie {
35+
enum ExportAction {
36+
case removeTimecodeTrack
37+
case replaceTimecodeTrack(startTimecode: Timecode)
38+
}
39+
40+
/// Creates a copy of the movie, performs the operation, exports to a new file,
41+
/// and optionally reveals the new file in the Finder (macOS only).
42+
func export(
43+
action: ExportAction,
44+
to url: URL,
45+
revealInFinderOnCompletion: Bool
46+
) async throws(ModelError) {
47+
do {
48+
let mutableMovie = try getMutableMovieCopy()
49+
50+
switch action {
51+
case .removeTimecodeTrack:
52+
try await mutableMovie.removeTimecodeTracks()
53+
case .replaceTimecodeTrack(let startTimecode):
54+
try await mutableMovie.replaceTimecodeTrack(startTimecode: startTimecode, fileType: .mov)
55+
}
56+
57+
try await mutableMovie.export(to: url)
58+
59+
#if os(macOS)
60+
if revealInFinderOnCompletion {
61+
try url.revealInFinder()
62+
}
63+
#endif
64+
} catch let err as ModelError {
65+
throw err
66+
} catch let err {
67+
throw .exportError(err)
68+
}
69+
}
70+
71+
/// Produces a mutable copy of the loaded movie.
72+
fileprivate func getMutableMovieCopy() throws(ModelError) -> AVMutableMovie {
73+
guard let mutableMovie = avMovie.mutableCopy() as? AVMutableMovie else {
74+
throw .errorCreatingMutableMovieCopy
75+
}
76+
return mutableMovie
77+
}
78+
}

Examples/Movie Timecode/Movie Timecode/Model/Model.swift

Lines changed: 21 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,11 @@
88
import Observation
99
import TimecodeKit
1010

11-
@Observable @MainActor class Model {
11+
@Observable @MainActor final class Model {
1212
private(set) var movie: Movie?
1313
var error: ModelError?
1414
}
1515

16-
extension Model {
17-
@MainActor struct Movie {
18-
private(set) var avMovie: AVMovie
19-
private(set) var frameRate: TimecodeFrameRate?
20-
private(set) var timecodeStart: Timecode?
21-
private(set) var containsTimecodeTrack: Bool = false
22-
}
23-
}
24-
2516
// MARK: - Handlers
2617

2718
extension Model {
@@ -161,63 +152,41 @@ extension Model {
161152
// MARK: - Movie Mutation and Export
162153

163154
extension Model {
164-
func exportReplacingTimecodeTrack(
165-
startTimecode: Timecode,
166-
to url: URL,
155+
func export(
156+
action: Movie.ExportAction,
157+
toFolder folderURL: URL,
167158
revealInFinderOnCompletion: Bool
168159
) async {
160+
guard let fileURL = uniqueExportURL(folder: folderURL) else { return }
161+
print("Exporting to \(fileURL.path)")
162+
169163
await export(
170-
to: url,
164+
action: action,
165+
to: fileURL,
171166
revealInFinderOnCompletion: revealInFinderOnCompletion
172-
) { mutableMovie in
173-
try await mutableMovie.replaceTimecodeTrack(startTimecode: startTimecode, fileType: .mov)
174-
}
167+
)
175168
}
176169

177-
func exportRemovingTimecodeTrack(
170+
func export(
171+
action: Movie.ExportAction,
178172
to url: URL,
179173
revealInFinderOnCompletion: Bool
180-
) async {
181-
await export(
182-
to: url,
183-
revealInFinderOnCompletion: revealInFinderOnCompletion
184-
) { mutableMovie in
185-
try await mutableMovie.removeTimecodeTracks()
186-
}
187-
}
188-
189-
/// Creates a copy of the movie, performs the operation, exports to a new file,
190-
/// and optionally reveals the new file in the Finder (macOS only).
191-
private func export(
192-
to url: URL,
193-
revealInFinderOnCompletion: Bool,
194-
_ mutation: @Sendable (_ mutableMovie: AVMutableMovie) async throws -> Void
195174
) async {
196175
do {
197-
let mutableMovie = try getMutableCopy()
198-
try await mutation(mutableMovie)
199-
try await mutableMovie.export(to: url)
200-
201-
#if os(macOS)
202-
if revealInFinderOnCompletion {
203-
try url.revealInFinder()
176+
guard let movie else {
177+
throw ModelError.noMovieLoaded
204178
}
205-
#endif
179+
180+
try await movie.export(
181+
action: action,
182+
to: url,
183+
revealInFinderOnCompletion: revealInFinderOnCompletion
184+
)
185+
206186
} catch let err as ModelError {
207187
error = err
208188
} catch let err {
209189
error = .exportError(err)
210190
}
211191
}
212-
213-
/// Produces a mutable copy of the loaded movie.
214-
private func getMutableCopy() throws -> AVMutableMovie {
215-
guard let movie else {
216-
throw ModelError.noMovieLoaded
217-
}
218-
guard let mutableMovie = movie.avMovie.mutableCopy() as? AVMutableMovie else {
219-
throw ModelError.errorCreatingMutableMovieCopy
220-
}
221-
return mutableMovie
222-
}
223192
}

Examples/Movie Timecode/Movie Timecode/Model/ModelError.swift

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,21 @@ extension ModelError {
1919
var errorDescription: String? {
2020
switch self {
2121
case .errorCreatingMutableMovieCopy:
22-
"Error creating mutable movie copy in memory."
22+
"Error creating mutable movie copy in memory"
2323
case let .fileImportError(error):
24-
"File import error: \(error.localizedDescription)."
24+
"File import error: \(error.localizedDescription)"
2525
case .noMovieLoaded:
26-
"No movie loaded."
26+
"No movie loaded"
2727
case let .exportError(details):
2828
if let details {
29-
"Export error: \(details.localizedDescription)."
29+
"Export error: \(details.localizedDescription)"
3030
} else {
31-
"Export error."
31+
"Export error"
3232
}
3333
case .pathDoesNotExist:
34-
"Path does not exist."
34+
"Path does not exist"
3535
case .pathIsNotFolder:
36-
"Path is not a folder."
36+
"Path is not a folder"
3737
}
3838
}
3939
}

Examples/Movie Timecode/Movie Timecode/RemoveTimecodeTrackView.swift

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,23 @@ struct RemoveTimecodeTrackView: View {
3030
.formStyle(.grouped)
3131
}
3232
.padding()
33+
34+
#if os(macOS)
35+
// note that SwiftUI's .fileImporter does not produce a security-scoped URL suitable for writing to on macOS (but seems fine on iOS).
36+
// also, SwiftUI's .fileExporter insists on writing the data to disk for us with no way to write to the URL manually.
37+
// hence, our workaround is to use a custom NSOpenPanel wrapper.
38+
.fileOpenPanel(isPresented: $isFolderPickerShown) { openPanel in
39+
openPanel.canCreateDirectories = true
40+
openPanel.canChooseDirectories = true
41+
openPanel.canChooseFiles = false
42+
openPanel.allowsMultipleSelection = false
43+
openPanel.title = "Export"
44+
openPanel.directoryURL = model.defaultFolder
45+
} completion: { urls in
46+
guard let url = urls?.first else { return }
47+
Task { await handleResult(.success(url)) }
48+
}
49+
#else
3350
.fileImporter(
3451
isPresented: $isFolderPickerShown,
3552
allowedContentTypes: [.folder]
@@ -38,23 +55,23 @@ struct RemoveTimecodeTrackView: View {
3855
}
3956
.fileDialogDefaultDirectory(model.defaultFolder)
4057
.fileDialogConfirmationLabel("Export")
58+
#endif
4159
}
4260

4361
private func handleResult(_ result: Result<URL, Error>) async {
4462
do {
4563
let folderURL = try result.get()
4664

4765
isExportProgressShown = true
48-
guard let fileURL = model.uniqueExportURL(folder: folderURL) else { return }
49-
print("Exporting to \(fileURL.path)")
66+
defer { isExportProgressShown = false }
5067

51-
await model.exportRemovingTimecodeTrack(
52-
to: fileURL,
53-
revealInFinderOnCompletion: true
68+
await model.export(
69+
action: .removeTimecodeTrack,
70+
toFolder: folderURL,
71+
revealInFinderOnCompletion: true // only applies to macOS
5472
)
55-
isExportProgressShown = false
5673
} catch {
57-
model.error = ModelError.exportError(error)
74+
model.error = .exportError(error)
5875
}
5976
}
6077
}

0 commit comments

Comments
 (0)