Front Row is moving from releasing updates on the Mac App Store back to GitHub. Apple behavior of temporarily removing your app from the store unless you pay their ongoing developer fee is hostile. Releasing new versions on GitHub allows anyone to download and run the app in the future even if I don't continue to pay Apple.
48 | ]]> 49 |
2 |
3 |
Play HDR videos & spatial audio natively
8 | 9 | 14 | 15 |  16 | 17 | Experience color accurate HDR videos with full surround sound using spatial audio. 18 | 19 | ## Compatibility 20 | 21 | - [HDR video compatible Macs](https://support.apple.com/en-us/102205) and/or [spatial audio compatible devices](https://support.apple.com/en-us/102469) 22 | - Apple Silicon (M1 and later) 23 | - macOS Sonoma 15.0 and later 24 | - Xcode 16 (to build) 25 | 26 | ## Frequently Asked Questions 27 | 28 | ### Why do I need this? 29 | 30 | Many movies and TV shows are available with multichannel audio. However you would need to have a full surround sound setup in order to fully enjoy that experience. Apple introduced spatial audio which allows playing multichannel audio into regular headphones, such as the AirPods Pro. This vastly improves the roominess and depth of the played audio. Unfortunately, not many video players support Apple's spatial audio. So I created a simple video player with AVKit, which is able to use spatial audio. 31 | 32 | ### What about just using QuickTime Player? 33 | 34 | Sure, that works too. But I didn't like QuickTime Player's keyboard shortcuts nor its large on screen controls which blocks the video and subtitles. 35 | 36 | ### Where is feature XYZ? 37 | 38 | I created Front Row to play those rare video files that are in HDR and/or multichannel with spatial audio. For everything else, I use IINA like you. 39 | 40 | ### Help! My video file is in MKV and doesn't open with Front Row 41 | 42 | As Front Row is based on AVKit (which is what QuickTime Player uses), it can't directly open MKV files. However MKV is a container format and it usually contains Apple supported streams such as MPEG-4 video with AAC audio. If so, you can remux the file into an MP4 file using `ffmpeg`. 43 | 44 | ``` 45 | ffmpeg -i ./input.mkv -map 0 -c copy -tag:v hvc1 ./output.mp4 46 | ``` 47 | 48 | Note: 49 | - Add `-c:s mov_text` after `-c copy` if there are built in subtitles 50 | - Use `-tag:v hvc1` for video streams encoded in H265. Use `-tag:v avc1` instead for H264 51 | 52 | ### I followed the steps above but don't hear any audio 53 | 54 | The audio stream is in a codec that is not natively supported by Apple. You'll need to transcode the audio stream into a supported format. 55 | 56 | ``` 57 | ffmpeg -i ./input.mkv -map 0 -c copy -c:a aac_at -b:a 448k -tag:v hvc1 ./output.mp4 58 | ``` 59 | 60 | Note: 61 | - Add `-c:s mov_text` after `-c copy` if there are built in subtitles 62 | - Use `-tag:v hvc1` for video streams encoded in H265. Use `-tag:v avc1` instead for H264 63 | 64 | ### I don't hear spatial audio through my supported device 65 | 66 | First, make sure that the audio track contains more than 2 channels. Also, make sure to turn on spatial audio under the audio menu bar while the video is playing. 67 | -------------------------------------------------------------------------------- /Front Row/Views/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // Front Row 4 | // 5 | // Created by Joshua Park on 3/4/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ContentView: View { 11 | @Environment(PlayEngine.self) var playEngine: PlayEngine 12 | @State private var mouseIdleTimer: Timer! 13 | @State private var mouseInsideWindow = false 14 | @State private var playerControlsShown = true 15 | 16 | var body: some View { 17 | @Bindable var playEngine = playEngine 18 | 19 | ZStack(alignment: .bottom) { 20 | PlayerView(player: PlayEngine.shared.player) 21 | .onDrop( 22 | of: [.fileURL], 23 | delegate: AnyDropDelegate( 24 | onValidate: { 25 | $0.hasItemsConforming(to: PlayEngine.supportedFileTypes) 26 | }, 27 | onPerform: { 28 | guard let provider = $0.itemProviders(for: [.fileURL]).first else { 29 | return false 30 | } 31 | 32 | Task { 33 | guard let url = await provider.getURL() else { return } 34 | guard await PlayEngine.shared.openFile(url: url) else { return } 35 | NSDocumentController.shared.noteNewRecentDocumentURL(url) 36 | } 37 | 38 | return true 39 | } 40 | ) 41 | ) 42 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) 43 | .ignoresSafeArea() 44 | 45 | if !playEngine.isLocalFile 46 | && playEngine.timeControlStatus == .waitingToPlayAtSpecifiedRate 47 | { 48 | ProgressView() 49 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) 50 | } 51 | 52 | PlayerControlsView() 53 | .animation(.linear(duration: 0.4), value: playerControlsShown) 54 | .opacity(playerControlsShown ? 1.0 : 0.0) 55 | } 56 | .background { 57 | Color.black.ignoresSafeArea() 58 | } 59 | .onContinuousHover { phase in 60 | switch phase { 61 | case .active: 62 | mouseInsideWindow = true 63 | resetMouseIdleTimer() 64 | showPlayerControls() 65 | WindowController.shared.showTitlebar() 66 | WindowController.shared.showCursor() 67 | case .ended: 68 | mouseInsideWindow = false 69 | hidePlayerControls() 70 | WindowController.shared.hideTitlebar() 71 | WindowController.shared.showCursor() 72 | } 73 | } 74 | } 75 | 76 | private func hidePlayerControls() { 77 | withAnimation { 78 | playerControlsShown = false 79 | } 80 | } 81 | 82 | private func showPlayerControls() { 83 | withAnimation { 84 | playerControlsShown = true 85 | } 86 | } 87 | 88 | private func resetMouseIdleTimer() { 89 | if mouseIdleTimer != nil { 90 | mouseIdleTimer.invalidate() 91 | mouseIdleTimer = nil 92 | } 93 | 94 | mouseIdleTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { 95 | mouseIdleTimerAction($0) 96 | } 97 | } 98 | 99 | private func mouseIdleTimerAction(_ sender: Timer) { 100 | hidePlayerControls() 101 | WindowController.shared.hideTitlebar() 102 | if mouseInsideWindow { 103 | WindowController.shared.hideCursor() 104 | } 105 | } 106 | } 107 | 108 | #Preview { 109 | ContentView() 110 | } 111 | -------------------------------------------------------------------------------- /Front Row/FrontRowApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FrontRowApp.swift 3 | // Front Row 4 | // 5 | // Created by Joshua Park on 3/4/24. 6 | // 7 | 8 | import Sparkle 9 | import SwiftUI 10 | 11 | @main 12 | struct FrontRowApp: App { 13 | @NSApplicationDelegateAdaptor private var appDelegate: AppDelegate 14 | @State private var playEngine: PlayEngine 15 | @State private var presentedViewManager: PresentedViewManager 16 | @State private var windowController: WindowController 17 | private let updaterController: SPUStandardUpdaterController 18 | private let keyDownListener = KeyDownListener() 19 | 20 | init() { 21 | self._playEngine = .init(wrappedValue: .shared) 22 | self._presentedViewManager = .init(wrappedValue: .shared) 23 | self._windowController = .init(wrappedValue: .shared) 24 | 25 | updaterController = SPUStandardUpdaterController( 26 | startingUpdater: true, 27 | updaterDelegate: nil, 28 | userDriverDelegate: nil 29 | ) 30 | 31 | keyDownListener.startMonitoringKeyEvents() 32 | 33 | UserDefaults.standard.set(false, forKey: "NSFullScreenMenuItemEverywhere") 34 | } 35 | 36 | var body: some Scene { 37 | Window("Front Row", id: "main") { 38 | ContentView() 39 | .preferredColorScheme(.dark) 40 | .environment(playEngine) 41 | .sheet(isPresented: $presentedViewManager.isPresentingOpenURLView) { 42 | OpenURLView() 43 | .frame(minWidth: 600) 44 | } 45 | .alert("Go to Time", isPresented: $presentedViewManager.isPresentingGoToTimeView) { 46 | GoToTimeView() 47 | } message: { 48 | Text("Enter the time you want to go to") 49 | } 50 | .onReceive( 51 | NotificationCenter.default.publisher( 52 | for: NSWindow.willEnterFullScreenNotification) 53 | ) { _ in 54 | windowController.showTitlebar(immediately: true) 55 | } 56 | .onReceive( 57 | NotificationCenter.default.publisher( 58 | for: NSWindow.didEnterFullScreenNotification) 59 | ) { _ in 60 | keyDownListener.stopMonitoringKeyEvents() 61 | windowController.setIsFullscreen(true) 62 | } 63 | .onReceive( 64 | NotificationCenter.default.publisher( 65 | for: NSWindow.didExitFullScreenNotification) 66 | ) { _ in 67 | keyDownListener.startMonitoringKeyEvents() 68 | windowController.setIsFullscreen(false) 69 | } 70 | } 71 | .windowStyle(.hiddenTitleBar) 72 | .restorationBehavior(.disabled) 73 | .commands { 74 | AppCommands(updater: updaterController.updater) 75 | FileCommands(playEngine: $playEngine) 76 | ViewCommands( 77 | playEngine: $playEngine, 78 | windowController: $windowController) 79 | PlaybackCommands( 80 | playEngine: $playEngine, 81 | presentedViewManager: $presentedViewManager) 82 | WindowCommands( 83 | playEngine: $playEngine, 84 | windowController: $windowController) 85 | HelpCommands() 86 | } 87 | } 88 | } 89 | 90 | class AppDelegate: NSObject, NSApplicationDelegate { 91 | func application(_ application: NSApplication, open urls: [URL]) { 92 | guard urls.count == 1, let url = urls.first else { return } 93 | Task { 94 | guard await PlayEngine.shared.openFile(url: url) else { return } 95 | NSDocumentController.shared.noteNewRecentDocumentURL(url) 96 | } 97 | } 98 | 99 | func applicationDidFinishLaunching(_ notification: Notification) { 100 | if let window = NSApp.windows.first { 101 | window.isMovableByWindowBackground = true 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /Front Row/Support/Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Extensions.swift 3 | // Front Row 4 | // 5 | // Created by Joshua Park on 3/4/24. 6 | // 7 | 8 | import AVKit 9 | import Foundation 10 | import SwiftUI 11 | 12 | struct AnyDropDelegate: DropDelegate { 13 | var isTargeted: Binding