5// Created by Alex Chan on 08/06/2023.
12struct PhotoReviewer: View {
15 @EnvironmentObject var photosLibrary: PhotosLibrary
17 // Which asset is currently in focus?
19 // i.e. scrolled to in the thumbnail pane, showing a big preview.
21 // This is 0-indexed and counts from the right -- that is, the rightmost item
23 @State var focusedAssetIndex: Int = 0
25 @State var _focusedAsset: PHAsset? = nil
27 var focusedAsset: PHAsset {
28 return photosLibrary.asset(at: focusedAssetIndex)
31 @State var showStatistics: Bool = false
32 @State var showDebug: Bool = false
33 @State var showInfo: Bool = false
36 if !photosLibrary.isPhotoLibraryAuthorized {
38 ProgressView().padding()
40 // When you launch the app, it takes a few seconds to connect to
41 // Photos and confirm that you're authorised to read it -- even if
42 // you've given it Photos permission on a previous launch.
44 // Deferring the display of this message for a few seconds avoids
45 // a confusing interaction for the user, where it seems like the app
46 // is waiting for permission even though they've already granted it.
47 Text("Waiting for Photos Library authorization…")
48 .deferredRendering(for: .seconds(5))
51 } else if photosLibrary.assets.count == 0 {
52 ProgressView().padding()
53 Text("Waiting for Photos Library data…")
57 ThumbnailList(focusedAssetIndex: $focusedAssetIndex)
58 .environmentObject(photosLibrary)
60 .background(.gray.opacity(0.2))
64 focusedAssetImage: photosLibrary.getFullSizedImage(for: focusedAsset)
76 Debug(asset: focusedAsset, focusedAssetIndex: focusedAssetIndex)
83 Info(asset: focusedAsset)
90 Statistics().environmentObject(photosLibrary)
96 NSEvent.addLocalMonitorForEvents(
98 handler: handleKeyDown
101 // These two methods are used to preserve position when there are changes
102 // in the Photos Library, e.g. deleted assets.
104 // We cache the currently focused asset, so we know what we were looking at
105 // before the library changed, and we call the `updateFocusAfterLibraryChange`
106 // handler whenever we see a change.
107 .onChange(of: focusedAssetIndex, perform: { _ in
108 self._focusedAsset = self.focusedAsset
110 .onChange(of: photosLibrary.latestChangeDetails, perform: updateFocusAfterLibraryChange)
114 /// Try to maintain the focused asset when the Photos Library changes.
116 /// The goal is to keep the user looking at the same asset before/after the
117 /// library data changes. This isn't always possible, e.g. if the asset has
118 /// just been deleted, but we do a best effort attempt.
119 private func updateFocusAfterLibraryChange(lastChangeDetails: PHFetchResultChangeDetails<PHAsset>?) -> Void {
121 // Create a change ID. This doesn't mean anything outside the context
122 // of this function, but is useful for correlating log messages.
123 let changeId = UUID()
125 logger.debug("Updating focus after Photos Library change [\(changeId, privacy: .public)]")
127 // Maybe this change doesn't affect the currently focused asset; if so,
128 // we can stop immediately.
130 // e.g. the change is about album data, or all the changes are further
131 // along than the focused asset.
132 if photosLibrary.asset(at: focusedAssetIndex) == self._focusedAsset {
133 logger.debug("Focused asset is in the same place as before, nothing to do [\(changeId, privacy: .public)]")
137 // The ChangeDetails can tell us how many assets were inserted/removed by
138 // the change, but only if it was a small change -- if it was a bigger change,
139 // we're meant to reload from scratch.
141 // Try looking at these properties first -- these deltas will typically be small,
142 // so we can evaluate them quickly. We look for all the indexes which have changed
143 // before the currently focused index.
144 let hasLastChangeDetails = lastChangeDetails != nil
145 let hasIncrementalChanges = lastChangeDetails?.hasIncrementalChanges == true
147 var delta: Int? = nil
149 if hasLastChangeDetails && hasIncrementalChanges {
150 logger.debug("Photos Library update has incremental changes [\(changeId, privacy: .public)]")
151 let removedIndexes = lastChangeDetails!.removedIndexes?
152 .filter { $0 <= focusedAssetIndex }
155 let insertedIndexes = lastChangeDetails!.insertedIndexes?
156 .filter { $0 <= focusedAssetIndex }
159 logger.debug("Removed indexes = \(removedIndexes, privacy: .public), inserted indexes = \(insertedIndexes, privacy: .public) [\(changeId, privacy: .public)]")
161 delta = insertedIndexes - removedIndexes
164 // If we've got a delta, check to see if it points us to the right asset.
166 // If it does, we're done!
167 if photosLibrary.asset(at: focusedAssetIndex + (delta ?? 0)) == self._focusedAsset {
168 logger.debug("Incremental changes found the new position of the asset [\(changeId, privacy: .public)]")
169 focusedAssetIndex += delta ?? 0
173 // If we didn't get incremental changes or the incremental changes pointed us
174 // to the wrong place, then something bigger has changed in the Photos library.
176 // Maybe some assets have "moved" (I don't fully understand what that means without
177 // an example, and I suspect it may not apply to this use case, where we're sorting
178 // all the assets by creationDate), or maybe there were too many updates for
179 // an incremental change.
181 // In this case, let's see if we can find the asset in the update FetchResult.
183 // This is potentially quite slow, especially if we've already gone a long way
184 // into the Photos Library, which is why we leave it for last.
185 let matchingAssetInUpdatedLibrary =
186 (0..<photosLibrary.assets.count)
188 photosLibrary.asset(at: $0).localIdentifier ==
189 self._focusedAsset?.localIdentifier
192 if let newIndex = matchingAssetInUpdatedLibrary {
193 logger.debug("Found an asset with matching identifier by doing a linear search [\(changeId, privacy: .public)]")
194 self.focusedAssetIndex = newIndex
198 // If we still haven't found the asset, then it must have been deleted as
199 // part of this change. We can't keep the user in the same place, but
200 // maybe we can keep them nearby.
202 // Apply the delta from incremental changes (if we have it); there's not much
203 // more we can do without storing ever-larger amounts of state to pick a
204 // suitable restore point.
205 logger.debug("Focused asset was deleted as part of the changes; making best-effort guess at new focus position [\(changeId, privacy: .public)]")
206 self.focusedAssetIndex += (delta ?? 0)
209 /// Handle any keypresses in the app.
211 /// Note: this function should return `nil` for any events that it
212 /// processes; any events it returns will be passed to other event handlers
213 /// to see if anything else knows what to do with them. Among other
214 /// issues, this results in an annoying "funk" sound playing on
215 /// every event, because the OS thinks the event is unhandled.
216 private func handleKeyDown(_ event: NSEvent) -> NSEvent? {
217 let logger = Logger()
220 case let e where e.specialKey == NSEvent.SpecialKey.leftArrow && NSEvent.modifierFlags.contains(.command):
221 focusedAssetIndex = photosLibrary.assets.count - 1
224 case let e where e.specialKey == NSEvent.SpecialKey.leftArrow:
225 print("to the left!")
226 if focusedAssetIndex < photosLibrary.assets.count - 1 {
227 focusedAssetIndex += 1
231 case let e where e.specialKey == NSEvent.SpecialKey.rightArrow && NSEvent.modifierFlags.contains(.command):
232 focusedAssetIndex = 0
235 case let e where e.specialKey == NSEvent.SpecialKey.rightArrow:
236 print("to the right!")
237 if focusedAssetIndex > 0 {
238 focusedAssetIndex -= 1
242 case let e where e.characters == "1" || e.characters == "2" || e.characters == "3":
243 let newState: ReviewState =
244 e.characters == "1" ? .Approved :
245 e.characters == "2" ? .Rejected : .NeedsAction
247 photosLibrary.setState(ofAsset: focusedAsset, to: newState)
249 if focusedAssetIndex < photosLibrary.assets.count - 1 {
250 focusedAssetIndex += 1
255 case let e where e.characters == "2":
256 photosLibrary.setState(ofAsset: focusedAsset, to: .Rejected)
258 if focusedAssetIndex < photosLibrary.assets.count - 1 {
259 focusedAssetIndex += 1
264 case let e where e.characters == "3":
265 photosLibrary.setState(ofAsset: focusedAsset, to: .NeedsAction)
267 if focusedAssetIndex < photosLibrary.assets.count - 1 {
268 focusedAssetIndex += 1
272 case let e where e.characters == "c":
273 let crossStitch = getAlbum(withName: "Cross stitch")
275 try! PHPhotoLibrary.shared().performChangesAndWait {
276 focusedAsset.toggle(inAlbum: crossStitch)
281 case let e where e.characters == "f":
282 try! PHPhotoLibrary.shared().performChangesAndWait {
283 PHAssetChangeRequest(for: focusedAsset).isFavorite = !focusedAsset.isFavorite
288 case let e where e.characters == "d":
292 case let e where e.characters == "s":
293 showStatistics.toggle()
296 case let e where e.characters == "i":
300 case let e where e.characters == "u":
301 if photosLibrary.state(of: focusedAsset) != nil {
302 if let lastUnreviewed = (focusedAssetIndex..<photosLibrary.assets.count).first(where: { index in
303 photosLibrary.state(ofAssetAtIndex: index) == nil
305 focusedAssetIndex = lastUnreviewed
310 case let e where e.characters == "?":
312 let randomIndex = (0..<photosLibrary.assets.count).randomElement()!
314 if photosLibrary.state(ofAssetAtIndex: randomIndex) == nil {
315 focusedAssetIndex = randomIndex
321 case let e where e.characters == "o":
323 task.launchPath = "/usr/bin/osascript"
324 task.arguments = ["-e", """
325 tell application "Photos"
326 spotlight media item id \"\(focusedAsset.localIdentifier)\"
335 logger.info("Received unhandled keyboard event: \(event, privacy: .public)")