diff --git a/Front Row.xcodeproj/project.pbxproj b/Front Row.xcodeproj/project.pbxproj index f06c66a..e6fa9bd 100644 --- a/Front Row.xcodeproj/project.pbxproj +++ b/Front Row.xcodeproj/project.pbxproj @@ -375,7 +375,7 @@ STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Debug; }; @@ -434,7 +434,7 @@ SDKROOT = macosx; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Release; }; diff --git a/Front Row/FrontRowApp.swift b/Front Row/FrontRowApp.swift index f57514c..67cdad0 100644 --- a/Front Row/FrontRowApp.swift +++ b/Front Row/FrontRowApp.swift @@ -12,17 +12,13 @@ import SwiftUI @main struct FrontRowApp: App { @NSApplicationDelegateAdaptor private var appDelegate: AppDelegate - @State private var playEngine: PlayEngine - @State private var presentedViewManager: PresentedViewManager - @State private var windowController: WindowController + @State private var playEngine = PlayEngine.shared + @State private var presentedViewManager = PresentedViewManager.shared + @State private var windowController = WindowController.shared private let updaterController: SPUStandardUpdaterController private let keyDownListener = KeyDownListener() init() { - self._playEngine = .init(wrappedValue: .shared) - self._presentedViewManager = .init(wrappedValue: .shared) - self._windowController = .init(wrappedValue: .shared) - updaterController = SPUStandardUpdaterController( startingUpdater: true, updaterDelegate: nil, @@ -39,7 +35,6 @@ struct FrontRowApp: App { ContentView() .preferredColorScheme(.dark) .ignoresSafeArea() - .environment(playEngine) .navigationTitle(playEngine.fileURL?.lastPathComponent ?? "Front Row") .if(playEngine.isLocalFile) { view in view.navigationDocument(playEngine.fileURL!) @@ -75,18 +70,15 @@ struct FrontRowApp: App { } } .restorationBehavior(.disabled) + .environment(playEngine) + .environment(presentedViewManager) + .environment(windowController) .commands { AppCommands(updater: updaterController.updater) - FileCommands(playEngine: $playEngine) - ViewCommands( - playEngine: $playEngine, - windowController: $windowController) - PlaybackCommands( - playEngine: $playEngine, - presentedViewManager: $presentedViewManager) - WindowCommands( - playEngine: $playEngine, - windowController: $windowController) + FileCommands() + ViewCommands() + PlaybackCommands() + WindowCommands() HelpCommands() } } diff --git a/Front Row/Main Menu/FileCommands.swift b/Front Row/Main Menu/FileCommands.swift index 693726d..3b7d0e8 100644 --- a/Front Row/Main Menu/FileCommands.swift +++ b/Front Row/Main Menu/FileCommands.swift @@ -9,8 +9,6 @@ import AVKit import SwiftUI struct FileCommands: Commands { - @Binding var playEngine: PlayEngine - var body: some Commands { CommandGroup(replacing: .newItem) { Button { @@ -38,8 +36,9 @@ struct FileCommands: Commands { Divider() Button { - guard let item = PlayEngine.shared.player.currentItem else { return } - guard let asset = item.asset as? AVURLAsset else { return } + guard let item = PlayEngine.shared.player.currentItem, + let asset = item.asset as? AVURLAsset + else { return } NSWorkspace.shared.activateFileViewerSelecting([asset.url]) } label: { Text( @@ -47,10 +46,11 @@ struct FileCommands: Commands { comment: "Show the currently playing file in Finder" ) } - .disabled(!playEngine.isLocalFile) + .disabled(!PlayEngine.shared.isLocalFile) } } + @MainActor private func showOpenFileDialog() async { let panel = NSOpenPanel() panel.allowedContentTypes = PlayEngine.supportedFileTypes diff --git a/Front Row/Main Menu/PlaybackCommands.swift b/Front Row/Main Menu/PlaybackCommands.swift index 926ca36..b90cacb 100644 --- a/Front Row/Main Menu/PlaybackCommands.swift +++ b/Front Row/Main Menu/PlaybackCommands.swift @@ -9,8 +9,8 @@ import AVKit import SwiftUI struct PlaybackCommands: Commands { - @Binding var playEngine: PlayEngine - @Binding var presentedViewManager: PresentedViewManager + @Bindable private var playEngine = PlayEngine.shared + @Bindable private var presentedViewManager = PresentedViewManager.shared var body: some Commands { CommandMenu("Playback") { @@ -151,7 +151,7 @@ struct PlaybackCommands: Commands { if let group = playEngine.audioGroup { Picker("Audio Track", selection: $playEngine.audioTrack) { Text("Off").tag(nil as AVMediaSelectionOption?) - ForEach(group.options) { option in + ForEach(group.options, id: \.stableID) { option in Text(verbatim: option.displayName).tag(Optional(option)) } } diff --git a/Front Row/Main Menu/ViewCommands.swift b/Front Row/Main Menu/ViewCommands.swift index ff34744..88f4df3 100644 --- a/Front Row/Main Menu/ViewCommands.swift +++ b/Front Row/Main Menu/ViewCommands.swift @@ -9,19 +9,18 @@ import AVKit import SwiftUI struct ViewCommands: Commands { - @Binding var playEngine: PlayEngine - @Binding var windowController: WindowController - var body: some Commands { CommandGroup(replacing: .toolbar) { Button { NSApplication.shared.mainWindow?.toggleFullScreen(nil) } label: { - Text(windowController.isFullscreen ? "Exit Full Screen" : "Enter Full Screen") + Text( + WindowController.shared.isFullscreen + ? "Exit Full Screen" : "Enter Full Screen") } .keyboardShortcut(.return, modifiers: []) - Toggle(isOn: $windowController.isOnTop) { + Toggle(isOn: Bindable(WindowController.shared).isOnTop) { Text("Float on Top") } @@ -32,14 +31,14 @@ struct ViewCommands: Commands { } @ViewBuilder private var subtitlePicker: some View { - if let group = playEngine.subtitleGroup { - Picker("Subtitle", selection: $playEngine.subtitle) { + if let group = PlayEngine.shared.subtitleGroup { + Picker("Subtitle", selection: Bindable(PlayEngine.shared).subtitle) { Text("Off").tag(nil as AVMediaSelectionOption?) let optionsWithoutForcedSubs = group.options.filter { !$0.displayName.contains("Forced") } - ForEach(optionsWithoutForcedSubs) { + ForEach(optionsWithoutForcedSubs, id: \.stableID) { option in Text(verbatim: option.displayName).tag(Optional(option)) } diff --git a/Front Row/Main Menu/WindowCommands.swift b/Front Row/Main Menu/WindowCommands.swift index b448a26..3e49d88 100644 --- a/Front Row/Main Menu/WindowCommands.swift +++ b/Front Row/Main Menu/WindowCommands.swift @@ -8,9 +8,6 @@ import SwiftUI struct WindowCommands: Commands { - @Binding var playEngine: PlayEngine - @Binding var windowController: WindowController - var body: some Commands { CommandGroup(after: .windowSize) { Section { @@ -23,7 +20,7 @@ struct WindowCommands: Commands { ) } .keyboardShortcut("0", modifiers: [.command]) - .disabled(!playEngine.isLoaded || windowController.isFullscreen) + .disabled(!PlayEngine.shared.isLoaded || WindowController.shared.isFullscreen) } } } diff --git a/Front Row/Support/Extensions.swift b/Front Row/Support/Extensions.swift index fadd57b..3d91fba 100644 --- a/Front Row/Support/Extensions.swift +++ b/Front Row/Support/Extensions.swift @@ -51,50 +51,19 @@ struct AnyDropDelegate: DropDelegate { } } -extension NSItemProvider: @unchecked Sendable {} - extension NSItemProvider { - func loadObject(ofClass: T.Type) async throws -> T? where T: NSItemProviderReading { - try await withCheckedThrowingContinuation { continuation in - _ = loadObject(ofClass: ofClass) { data, error in - if let error { - continuation.resume(throwing: error) - return - } - - guard let object = data as? T else { - continuation.resume(returning: nil) - return - } - - continuation.resume(returning: object) + /// Load a file URL from the item provider. + func loadFileURL(completion: @escaping @Sendable (URL?) -> Void) { + loadItem(forTypeIdentifier: "public.file-url", options: nil) { data, _ in + guard let data = data as? Data, + let url = URL(dataRepresentation: data, relativeTo: nil) + else { + completion(nil) + return } + completion(url) } } - - func loadObject(ofClass: T.Type) async throws -> T? - where T: _ObjectiveCBridgeable, T._ObjectiveCType: NSItemProviderReading { - try await withCheckedThrowingContinuation { continuation in - _ = loadObject(ofClass: ofClass) { data, error in - if let error { - continuation.resume(throwing: error) - return - } - - guard let data else { - continuation.resume(returning: nil) - return - } - - continuation.resume(returning: data) - } - } - } - - /// Get a URL from the item provider, if any. - func getURL() async -> URL? { - try? await loadObject(ofClass: URL.self) - } } extension NSSize { @@ -131,8 +100,9 @@ extension NSSize { } } -extension AVMediaSelectionOption: Identifiable { - public var id: String { +extension AVMediaSelectionOption { + /// Provides a stable identifier for the option. + var stableID: String { let dict = propertyList() as? NSDictionary guard let dict, let id = dict.value(forKey: "MediaSelectionOptionsPersistentID") as? Int else { diff --git a/Front Row/Support/KeyDownListener.swift b/Front Row/Support/KeyDownListener.swift index 0b9eb70..7ffab58 100644 --- a/Front Row/Support/KeyDownListener.swift +++ b/Front Row/Support/KeyDownListener.swift @@ -37,20 +37,25 @@ final class KeyDownListener { return event } - let allWindows = NSApp.windows - let firstResponders = allWindows.compactMap { $0.firstResponder } - let fieldEditors = firstResponders.filter { ($0 as? NSText)?.isEditable == true } - guard fieldEditors.isEmpty else { return event } - switch command { case .escape: - if !WindowController.shared.isFullscreen { - NSApp.hide(nil) - PlayEngine.shared.pause() - return nil + let shouldHandle = MainActor.assumeIsolated { + let allWindows = NSApp.windows + let firstResponders = allWindows.compactMap { $0.firstResponder } + let fieldEditors = firstResponders.filter { + ($0 as? NSText)?.isEditable == true + } + guard fieldEditors.isEmpty else { return false } + + if !WindowController.shared.isFullscreen { + NSApp.hide(nil) + PlayEngine.shared.pause() + return true + } + return false } + return shouldHandle ? nil : event } - return event } } diff --git a/Front Row/Support/NowPlayable+RemoteCommands.swift b/Front Row/Support/NowPlayable+RemoteCommands.swift index f66ff35..371df70 100644 --- a/Front Row/Support/NowPlayable+RemoteCommands.swift +++ b/Front Row/Support/NowPlayable+RemoteCommands.swift @@ -8,7 +8,7 @@ import MediaPlayer extension NowPlayable { - func setupRemoteCommandHandlers(playEngine: PlayEngine) { + @MainActor func setupRemoteCommandHandlers(playEngine: PlayEngine) { let commandCenter = MPRemoteCommandCenter.shared() commandCenter.playCommand.isEnabled = true diff --git a/Front Row/Support/NowPlayable.swift b/Front Row/Support/NowPlayable.swift index 801be81..fa1cc4e 100644 --- a/Front Row/Support/NowPlayable.swift +++ b/Front Row/Support/NowPlayable.swift @@ -29,6 +29,7 @@ struct NowPlayableDynamicMetadata { let duration: Float } +@MainActor final class NowPlayable { static let shared = NowPlayable() diff --git a/Front Row/Support/PlayEngine.swift b/Front Row/Support/PlayEngine.swift index ba83c87..8ad0ba5 100644 --- a/Front Row/Support/PlayEngine.swift +++ b/Front Row/Support/PlayEngine.swift @@ -9,6 +9,7 @@ import AVKit import Combine import SwiftUI +@MainActor @Observable public final class PlayEngine { static let shared = PlayEngine() @@ -147,7 +148,7 @@ import SwiftUI private var timeObserver: Any? - init() { + private init() { NowPlayable.shared.sessionStart() NowPlayable.shared.setupRemoteCommandHandlers(playEngine: self) @@ -156,62 +157,46 @@ import SwiftUI player.publisher(for: \.timeControlStatus) .receive(on: DispatchQueue.main) - .sink { status in - self.timeControlStatus = status - self.updateNowPlayingInfo() + .sink { [weak self] status in + self?.timeControlStatus = status + self?.updateNowPlayingInfo() } .store(in: &subs) player.publisher(for: \.rate) .receive(on: DispatchQueue.main) - .sink { rate in - self.updateNowPlayingInfo() + .sink { [weak self] rate in + self?.updateNowPlayingInfo() } .store(in: &subs) player.publisher(for: \.isMuted) .removeDuplicates() .receive(on: DispatchQueue.main) - .sink { isMuted in - self._isMuted = isMuted + .sink { [weak self] isMuted in + self?._isMuted = isMuted } .store(in: &subs) addPeriodicTimeObserver() } - deinit { - NowPlayable.shared.sessionEnd() - NowPlayable.shared.removeRemoteCommandHandlers() - for sub in currentItemSubs { sub.cancel() } - currentItemSubs.removeAll() - removePeriodicTimeObserver() - } - /// Attempts to open file at url. If its not playable, returns false. /// - Parameter url: A URL to a local, remote, or HTTP Live Streaming media resource. /// - Returns: A Boolean value that indicates whether an asset contains playable content. - @MainActor @discardableResult func openFile(url: URL) async -> Bool { if asset != nil { asset!.cancelLoading() } - asset = AVURLAsset(url: url) + let newAsset = AVURLAsset(url: url) + asset = newAsset + do { - let isPlayable = try await asset!.load(.isPlayable) + let isPlayable = try await newAsset.load(.isPlayable) guard isPlayable else { return false } - if let subtitleGroup = try await asset!.loadMediaSelectionGroup(for: .legible) { - self.subtitleGroup = subtitleGroup - } else { - self.subtitleGroup = nil - } - - if let audioGroup = try await asset!.loadMediaSelectionGroup(for: .audible) { - self.audioGroup = audioGroup - } else { - self.audioGroup = nil - } + self.subtitleGroup = try? await newAsset.loadMediaSelectionGroup(for: .legible) + self.audioGroup = try? await newAsset.loadMediaSelectionGroup(for: .audible) } catch { return false } @@ -219,7 +204,7 @@ import SwiftUI for sub in currentItemSubs { sub.cancel() } currentItemSubs.removeAll() - let playerItem = AVPlayerItem(asset: asset!) + let playerItem = AVPlayerItem(asset: newAsset) playerItem.publisher(for: \.status) .removeDuplicates() @@ -228,19 +213,21 @@ import SwiftUI guard let self else { return } switch status { case .readyToPlay: - isLoaded = true - isLocalFile = FileManager.default.fileExists( + self.isLoaded = true + self.isLocalFile = FileManager.default.fileExists( atPath: url.path(percentEncoded: false)) - fileURL = url + self.fileURL = url NowPlayable.shared.setNowPlayingMetadata( NowPlayableStaticMetadata( - assetURL: url, mediaType: videoSize == CGSize.zero ? .audio : .video, - title: url.lastPathComponent)) - updateNowPlayingInfo() + assetURL: url, + mediaType: self.videoSize == .zero ? .audio : .video, + title: url.lastPathComponent + )) + self.updateNowPlayingInfo() case .failed: - isLoaded = false - isLocalFile = false - fileURL = nil + self.isLoaded = false + self.isLocalFile = false + self.fileURL = nil NowPlayable.shared.sessionEnd() default: break @@ -253,25 +240,16 @@ import SwiftUI .receive(on: DispatchQueue.main) .sink { [weak self] size in guard let self else { return } - videoSize = size - fitToVideoSize(skipResize: WindowController.shared.isFullscreen) + self.videoSize = size + self.fitToVideoSize(skipResize: WindowController.shared.isFullscreen) } .store(in: ¤tItemSubs) player.replaceCurrentItem(with: playerItem) player.play() - if let subtitleGroup { - subtitle = subtitleGroup.options.first - } else { - subtitle = nil - } - - if let audioGroup { - audioTrack = audioGroup.options.first - } else { - audioTrack = nil - } + self.subtitle = subtitleGroup?.options.first + self.audioTrack = audioGroup?.options.first return true } @@ -424,17 +402,20 @@ import SwiftUI private func addPeriodicTimeObserver() { let interval = CMTime(seconds: 0.5, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) + timeObserver = player.addPeriodicTimeObserver( forInterval: interval, queue: .main ) { [weak self] time in - guard let self else { return } - _currentTime = time.seconds + MainActor.assumeIsolated { + guard let self else { return } + self._currentTime = time.seconds - guard let duration = player.currentItem?.duration.seconds else { return } - guard !duration.isNaN && !duration.isInfinite else { return } - self.duration = duration - timeRemaining = duration - _currentTime + guard let duration = self.player.currentItem?.duration.seconds else { return } + guard !duration.isNaN && !duration.isInfinite else { return } + self.duration = duration + self.timeRemaining = duration - self._currentTime + } } } diff --git a/Front Row/Support/PresentedViewManager.swift b/Front Row/Support/PresentedViewManager.swift index 2e8ea9b..288c7b5 100644 --- a/Front Row/Support/PresentedViewManager.swift +++ b/Front Row/Support/PresentedViewManager.swift @@ -7,6 +7,7 @@ import SwiftUI +@MainActor @Observable public final class PresentedViewManager { static let shared = PresentedViewManager() diff --git a/Front Row/Support/WindowController.swift b/Front Row/Support/WindowController.swift index 52432b5..3059854 100644 --- a/Front Row/Support/WindowController.swift +++ b/Front Row/Support/WindowController.swift @@ -7,6 +7,7 @@ import SwiftUI +@MainActor @Observable public final class WindowController { static let shared = WindowController() @@ -18,16 +19,10 @@ import SwiftUI private(set) var isMouseInTitleBar = false var isMouseInPlayerControls = false - init() { + private init() { setupMouseTracking() } - deinit { - if let monitor = mouseMovedMonitor { - NSEvent.removeMonitor(monitor) - } - } - private func setupMouseTracking() { mouseMovedMonitor = NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved]) { [weak self] event in diff --git a/Front Row/Views/ContentView.swift b/Front Row/Views/ContentView.swift index e1dd6ac..23952ed 100644 --- a/Front Row/Views/ContentView.swift +++ b/Front Row/Views/ContentView.swift @@ -29,10 +29,12 @@ struct ContentView: View { return false } - Task { - guard let url = await provider.getURL() else { return } - guard await PlayEngine.shared.openFile(url: url) else { return } - NSDocumentController.shared.noteNewRecentDocumentURL(url) + provider.loadFileURL { url in + guard let url else { return } + Task { @MainActor in + guard await PlayEngine.shared.openFile(url: url) else { return } + NSDocumentController.shared.noteNewRecentDocumentURL(url) + } } return true @@ -124,12 +126,14 @@ struct ContentView: View { mouseIdleTimer = nil } - mouseIdleTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { - mouseIdleTimerAction($0) + mouseIdleTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in + MainActor.assumeIsolated { + self.mouseIdleTimerAction() + } } } - private func mouseIdleTimerAction(_ sender: Timer) { + private func mouseIdleTimerAction() { let isHoveringInteractiveArea = WindowController.shared.isMouseInTitleBar || WindowController.shared.isMouseInPlayerControls @@ -148,4 +152,7 @@ struct ContentView: View { #Preview { ContentView() + .environment(PlayEngine.shared) + .environment(PresentedViewManager.shared) + .environment(WindowController.shared) } diff --git a/Front Row/Views/PlayerControlsView.swift b/Front Row/Views/PlayerControlsView.swift index 78d4398..4bc1409 100644 --- a/Front Row/Views/PlayerControlsView.swift +++ b/Front Row/Views/PlayerControlsView.swift @@ -238,7 +238,7 @@ struct PlayerControlsView: View { let optionsWithoutForcedSubs = group.options.filter { !$0.displayName.contains("Forced") } - ForEach(optionsWithoutForcedSubs) { + ForEach(optionsWithoutForcedSubs, id: \.stableID) { option in Text(verbatim: option.displayName).tag(Optional(option)) } @@ -255,4 +255,7 @@ struct PlayerControlsView: View { #Preview { PlayerControlsView() + .environment(PlayEngine.shared) + .environment(PresentedViewManager.shared) + .environment(WindowController.shared) } diff --git a/Front Row/Views/SeekSliderView.swift b/Front Row/Views/SeekSliderView.swift index aa2eb93..04807a5 100644 --- a/Front Row/Views/SeekSliderView.swift +++ b/Front Row/Views/SeekSliderView.swift @@ -128,6 +128,7 @@ struct SeekSliderView: NSViewRepresentable { } } + @MainActor class Coordinator: NSObject { var seekSlider: SeekSliderView