JohnCoates/Aerial

View on GitHub
Aerial/Source/Views/AerialView.swift

Summary

Maintainability
F
6 days
Test Coverage
//
//  AerialView.swift
//  Aerial
//
//  Created by John Coates on 10/22/15.
//  Copyright © 2015 John Coates. All rights reserved.
//

import Foundation
import ScreenSaver
import AVFoundation
import AVKit

@objc(AerialView)
// swiftlint:disable:next type_body_length
final class AerialView: ScreenSaverView, CAAnimationDelegate {
    var layerManager: LayerManager
    var playerLayer: AVPlayerLayer!

    static var players: [AVPlayer] = [AVPlayer]()
    static var previewPlayer: AVPlayer?
    static var previewView: AerialView?

    var player: AVPlayer?
    var currentVideo: AerialVideo?

    var preferencesController: PanelWindowController?

    var observerWasSet = false
    var hasStartedPlaying = false
    var wasStopped = false
    var isDisabled = false

    var isQuickFading = false

    var brightnessToRestore: Float?

    var globalSpeed: Float = 1.0
    var globalPause = false

    // We use this for tentative Catalina bug workaround
    var originalWidth, originalHeight: CGFloat

    // We use this for tentative Sonoma bug workaround
    var foundScreen: Screen?
    var foundFrame: NSRect?

    // Tentative improvement when only one video in playlist
    var shouldLoop = false

    static var shouldFade: Bool {
        return (PrefsVideos.fadeMode != .disabled)
    }

    static var fadeDuration: Double {
        switch PrefsVideos.fadeMode {
        case .t0_5:
            return 0.5
        case .t1:
            return 1
        case .t2:
            return 2
        default:
            return 0.10
        }
    }

    static var textFadeDuration: Double {
        switch PrefsInfo.fadeModeText {
        case .t0_5:
            return 0.5
        case .t1:
            return 1
        case .t2:
            return 2
        default:
            return 0.10
        }
    }

    // Mirrored/cloned viewing mode and Spanned viewing mode share the same player for sync & ressource saving
    static var sharingPlayers: Bool {
        switch PrefsDisplays.viewingMode {
        case .cloned, .mirrored, .spanned:
            return true
        default:
            return false
        }
    }

    static var sharedViews: [AerialView] = []
    // Because of lifecycle in Preview, we may pile up old/no longer
    // shared instanciated views that we need to track to not reuse
    static var instanciatedViews: [AerialView] = []

    // MARK: - Shared Player
    static var singlePlayerAlreadySetup: Bool = false
    static var sharedPlayerIndex: Int?
    static var didSkipMain: Bool = false

    class var sharedPlayer: AVPlayer {
        struct Static {
            static let instance: AVPlayer = AVPlayer()
            // swiftlint:disable:next identifier_name
            static var _player: AVPlayer?
            static var player: AVPlayer {
                if let activePlayer = _player {
                    return activePlayer
                }

                _player = AVPlayer()
                return _player!
            }
        }

        return Static.player
    }

    // MARK: - Init / Setup
    // This is the one used by System Preferences/ScreenSaverEngine
    override init?(frame: NSRect, isPreview: Bool) {
        Aerial.helper.checkCompanion()

        // Clear log if > 1MB on startup
        rollLogIfNeeded()

        // Set Companion bridge notifications under Sonoma, but not under Companion
        if !Aerial.helper.underCompanion {
            if #available(macOS 14, *) {
                CompanionBridge.setNotifications()
            }
        }
        
        // legacyScreenSaver always return true for isPreview on Catalina
        // We need to detect and override ourselves
        // This is finally fixed in Ventura
        var preview = false
        self.originalWidth = frame.width
        self.originalHeight = frame.height

        if frame.width < 400 && frame.height < 300 {
            preview = true
        }
        
        // This is where we manage our location info layers, clock, etc
        self.layerManager = LayerManager(isPreview: preview)

        super.init(frame: frame, isPreview: preview)
        debugLog("🖼️ AVinit (.saver) \(frame) p: \(isPreview) o: \(preview)")

        self.animationTimeInterval = 1.0 / 30.0

        if Aerial.helper.underCompanion && isPreview {
            debugLog("Running under companion in preview mode, preventing setup")
        } else {
            // We need to delay things under Sonoma because legacyScreenSaver is awesome
            if #available(macOS 14.0, *) {
                var delay = 0.01
                
                // If nightshift we delay more
                if !Aerial.helper.underCompanion && PrefsTime.timeMode == .nightShift {
                    delay = 0.5
                }

                DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
                    debugLog("🖼️ AVinit delayed setup!")
                    self.setup()
                }
            } else {
                setup()
            }
        }
    }

    // This is the one used by our App target used for debugging
    required init?(coder: NSCoder) {
        Aerial.helper.appMode = true

        Aerial.helper.checkCompanion()

        // Clear log if > 1MB on startup
        rollLogIfNeeded()

        // Set Companion bridge notifications under Sonoma, but not under Companion
        if !Aerial.helper.underCompanion {
            if #available(macOS 14, *) {
                CompanionBridge.setNotifications()
            }
        }

        
        self.layerManager = LayerManager(isPreview: false)

        // ...
        self.originalWidth = 0
        self.originalHeight = 0

        super.init(coder: coder)
        self.originalWidth = frame.width
        self.originalHeight = frame.height

        debugLog("🖼️ AVinit .app")
        
        // We need to delay things under Sonoma because legacyScreenSaver is awesome
        if #available(macOS 14.0, *) {
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
                debugLog("🖼️ AVinit delayed setup!")
                self.setup()
            }
        } else {
            setup()
        }
    }

    deinit {
        Aerial.helper.maybeUnmuteSound()
        
        debugLog("🖼️ \(self.description) AVdeinit ")
        NotificationCenter.default.removeObserver(self)
    }

    func ensureCorrectFormat() {
        if #available(OSX 10.15, *) {
        } else {
            // No HDR allowed here
            if PrefsVideos.videoFormat == .v4KHDR {
                debugLog("🖼️⚠️ Fixing 4K HDR not allowed prior to Catalina")
                PrefsVideos.videoFormat = .v4KHEVC
            } else if PrefsVideos.videoFormat == .v1080pHDR {
                debugLog("🖼️⚠️ Fixing 1080p HDR not allowed prior to Catalina")
                PrefsVideos.videoFormat = .v1080pHEVC
            }
        }
    }

    // swiftlint:disable:next cyclomatic_complexity
    func setup() {
        // Disable HDR only on macOS Ventura
        if !Aerial.helper.canHDR() {
            if isPreview && (PrefsVideos.videoFormat == .v4KHDR || PrefsVideos.videoFormat == .v1080pHDR) {
                // This will lead to crashing in up to Ventura beta5 so disable
                let debugTextView = NSTextView(frame: bounds.insetBy(dx: 20, dy: 20))
                debugTextView.font = .labelFont(ofSize: 10)
                debugTextView.string += "HDR Previews hidden on Ventura"
                isDisabled = true
                
                self.addSubview(debugTextView)
                return
            }
        }


        
        
        // First we check the system appearance, as it relies on our view
        Aerial.helper.computeDarkMode(view: self)

        // Then check if we need to mute/unmute sound
        Aerial.helper.maybeMuteSound()

        // Kick up the timezone detection
        _ = TimeManagement.sharedInstance

        // This is to make sure we don't start in a format that's unsupported
        ensureCorrectFormat()
        
        if let version = Bundle(identifier: "com.JohnCoates.Aerial")?.infoDictionary?["CFBundleShortVersionString"] as? String {
            debugLog("🖼️ \(self.description) AV setup init (V\(version)) preview: \(self.isPreview)")
            debugLog("🖼️ Running \(ProcessInfo.processInfo.operatingSystemVersionString)")
        }

        // First thing, we may need to migrate the cache !
        Cache.migrate()

        // Now we need to check if we should remove lingering stuff from the cache !
        if Cache.canNetwork() {
            Cache.removeCruft()
        }

        // Check early if we need to enable power saver mode,
        // black screen with minimal brightness
        if !isPreview {
            if (PrefsVideos.onBatteryMode == .alwaysDisabled && Battery.isUnplugged())
                || (PrefsVideos.onBatteryMode == .disableOnLow && Battery.isLow()) {
                debugLog("🖼️ Engaging power saving mode")
                isDisabled = true
                Brightness.set(level: 0.0)
                return
            }
        }

        // We may need to set timers to progressively dim the screen
        checkIfShouldSetBrightness()

        // Shared views can get stuck, we may need to clean them up here
        cleanupSharedViews()

        // We look for the screen in our detected list.
        // In case of preview or unknown screen result will be nil
        let displayDetection = DisplayDetection.sharedInstance

        let screenCount = displayDetection.getScreenCount()
        debugLog("🖼️ Real screen count : \(screenCount)")

        var thisScreen: Screen? = nil
        if #available(macOS 14.0, *) {
            if foundScreen == nil {
                debugLog("🖼️ missing foundScreen, workarounding \(String(describing: self.window?.screen))")
                if let missingScreen = self.window?.screen {
                    debugLog("🖼️ screen attached")
                    matchScreen(thisScreen: missingScreen)
                } else {
                    errorLog("🖼️ still missing screen")
                }
            } else {
                debugLog("🖼️ early foundScreen ok \(String(describing: foundScreen))")
            }
        } else {
            thisScreen = displayDetection.findScreenWith(frame: self.frame)
        }
        
        // We note the foundFrame as this is more accurate than the reported one! We need this for coordinates mapping
        if let thisScreen = thisScreen {
            foundFrame = thisScreen.bottomLeftFrame
            foundScreen = thisScreen
            debugLog("🖼️ Using : \(String(describing: thisScreen))")
        }

        for twindow in NSApplication.shared.windows {
            debugLog("window : \(twindow.debugDescription)")
        }
        
        var localPlayer: AVPlayer?
        
        // Is the current screen disabled by user ?
        if !isPreview {
            // If it's an unknown screen, we leave it enabled
            if let screen = foundScreen {
                if !displayDetection.isScreenActive(id: screen.id) {
                    // Then we disable and exit
                    debugLog("🖼️ This display is not active, disabling")
                    isDisabled = true
                    return
                } else {
                    debugLog("Screen is active")
                }
            }
        } else {
            AerialView.previewView = self
        }

        // Track which views are sharing the sharedPlayer
        if AerialView.sharingPlayers {
            AerialView.sharedViews.append(self)
        }

        // We track all instanciated views here, independand of their shared status
        AerialView.instanciatedViews.append(self)

        // Setup the AVPlayer
        if AerialView.sharingPlayers {
            localPlayer = AerialView.sharedPlayer
        } else {
            localPlayer = AVPlayer()
        }

        guard let player = localPlayer else {
            errorLog("\(self.description) Couldn't create AVPlayer!")
            return
        }

        self.player = player

        if isPreview {
            AerialView.previewPlayer = player
        } else if !AerialView.sharingPlayers {
            // add to player list
            AerialView.players.append(player)
        }
        
        setupPlayerLayer(withPlayer: player)

        // In mirror mode we use the main instance player
        if AerialView.sharingPlayers && AerialView.singlePlayerAlreadySetup {
            if let index = AerialView.sharedPlayerIndex {
                self.playerLayer.player = AerialView.instanciatedViews[index].player
                self.playerLayer.opacity = 0
                return
            }
        }

        // We're never sharing the preview !
        if !isPreview {
            AerialView.singlePlayerAlreadySetup = true
            AerialView.sharedPlayerIndex = AerialView.instanciatedViews.count-1
        }

        // So first we wait for our list to be ready
        VideoList.instance.addCallback {
            // Then we may need to delay things a bit if we haven't gathered the coordinates yet
            if PrefsTime.timeMode == .locationService && Locations.sharedInstance.coordinates == nil {
                debugLog("🖼️⚠️ No coordinates yet, delaying a bit...")
                DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) {
                    self.playNextVideo()
                }
            } else {
                self.playNextVideo()
            }
        }
    }
    
    
    override func viewDidMoveToWindow() {
        super.viewDidMoveToWindow()
        if foundScreen != nil {
            debugLog("🖼️ \(self.description) viewDidMoveToWindow frame: \(self.frame) window: \(String(describing: self.window))")
            debugLog(self.window?.screen.debugDescription ?? "Unknown")
            
            if let thisScreen = self.window?.screen {
                matchScreen(thisScreen: thisScreen)
            } else {
                // For some reason we may not have a screen here!
                debugLog("🖼️ no screen attached, will try again later")
            }
        } else {
            debugLog("🖼️ wdmtw after we already have a screen, ignoring")
        }
        
    }

    func matchScreen(thisScreen: NSScreen) {
        let screenID = thisScreen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as! CGDirectDisplayID
        
        debugLog(screenID.description)
        
        foundScreen = DisplayDetection.sharedInstance.findScreenWith(id: screenID)
        if let foundScreen = foundScreen {
            foundFrame = foundScreen.bottomLeftFrame
            if #available(macOS 14, *) {
                self.frame = foundFrame!

                // remove it from the list of unused screens
                DisplayDetection.sharedInstance.markScreenAsUsed(id: screenID)
            }
        }

        debugLog("🖼️🌾 Using : \(String(describing: foundScreen))")
        debugLog("🥬🌾 window.screen \(String(describing: self.window?.screen.debugDescription))")
        debugLog("🖼️🌾 self.frame : \(String(describing: self.frame))")
    }
    
    // Handle window resize
    override func viewDidEndLiveResize() {
        layerManager.redrawAllCorners()
    }
    
    override func viewDidChangeBackingProperties() {
        debugLog("🖼️ \(self.description) backing change \((self.window?.backingScaleFactor) ?? 1.0) isDisabled: \(isDisabled) frame: \(self.frame) preview: \(self.isPreview)")

        // Tentative workaround for a Catalina+ bug
        if self.frame.width < 300 && !isPreview {
            debugLog("🖼️☢️ Frame size bug, trying to override to \(originalWidth)x\(originalHeight)!")
            self.frame = CGRect(x: 0, y: 0, width: originalWidth, height: originalHeight)
        }

        if !isDisabled {
            if let layer = layer {
                layer.contentsScale = (self.window?.backingScaleFactor) ?? 1.0
                self.playerLayer.contentsScale = (self.window?.backingScaleFactor) ?? 1.0

                // And our additional layers
                layerManager.setContentScale(scale: (self.window?.backingScaleFactor) ?? 1.0)
            }
        }
    }

    // On previews, it's possible that our shared player was stopped and is not reusable
    func cleanupSharedViews() {
        if AerialView.singlePlayerAlreadySetup {
            if let index = AerialView.sharedPlayerIndex {
                if AerialView.instanciatedViews[index].wasStopped {
                    AerialView.singlePlayerAlreadySetup = false
                    AerialView.sharedPlayerIndex = nil

                    AerialView.instanciatedViews = [AerialView]()   // Clear the list of instanciated stuff
                    AerialView.sharedViews = [AerialView]()         // And the list of sharedViews
                }
            }
        }
    }

    // MARK: - Lifecycle stuff
    override func startAnimation() {
        super.startAnimation()
        debugLog("🖼️ \(self.description) startAnimation frame \(self.frame) bounds \(self.bounds)")

        if !isDisabled {
            // Previews may be restarted, but our layer will get hidden (somehow) so show it back
            if isPreview && player?.currentTime() != CMTime.zero {
                debugLog("restarting playback")
                playerLayer.opacity = 1
                player?.play()
            }
        }
    }

    override func stopAnimation() {
        Aerial.helper.maybeUnmuteSound()

        wasStopped = true
        debugLog("🖼️ \(self.description) stopAnimation")
        if !isDisabled {
            player?.pause()
            player?.rate = 0
            layerManager.removeAllLayers()
            playerLayer.removeAllAnimations()
            player?.replaceCurrentItem(with: nil)

            isDisabled = true
        }

        if PrefsDisplays.dimBrightness {
            if !isPreview, let brightnessToRestore = brightnessToRestore {
                Brightness.set(level: brightnessToRestore)
                self.brightnessToRestore = nil
            }
        }
        
        
        teardown()
    }

    func teardown() {
        debugLog("🖼️ \(self.description) teardown")

        // Remove notifications observer
        debugLog("🖼️ \(self.description) clear notif")
        clearNotifications()
        // Clear layer animations
        debugLog("🖼️ \(self.description) clear anims")
        clearAllLayerAnimations()

        if let player = player {
            // Remove from player index
            let indexMaybe = AerialView.players.firstIndex(of: player)

            guard let index = indexMaybe else {
                return
            }
            AerialView.players.remove(at: index)
        }
        
        // Remove any download
        VideoManager.sharedInstance.cancelAll()
       
        debugLog("🖼️ end teardown, exiting")
    }
    
    // Wait for the player to be ready
    // swiftlint:disable:next block_based_kvo
    internal override func observeValue(forKeyPath keyPath: String?,
                                        of object: Any?, change: [NSKeyValueChangeKey: Any]?,
                                        context: UnsafeMutableRawPointer?) {
        debugLog("🖼️ \(description) observeValue \(String(describing: keyPath)) \(playerLayer.isReadyForDisplay)")

        if let player = player, let currentVideo = currentVideo, playerLayer.isReadyForDisplay {
            player.play()
            hasStartedPlaying = true

            if Aerial.helper.underCompanion {
                player.rate = globalSpeed
            } else {
                player.rate = PlaybackSpeed.forVideo(currentVideo.id)
            }

            debugLog("🖼️ start playback: \(frame) \(bounds) rate: \(player.rate)")
            debugLog("🥬🥬 window2 \(String(describing: window?.screen))")
            // If we share a player, we need to add the fades and the text to all the
            // instanciated views using it (eg: in mirrored mode)
            if AerialView.sharingPlayers {
                for view in AerialView.sharedViews {
                    self.addPlayerFades(view: view, player: player, video: currentVideo)
                    
                    if (Aerial.helper.underCompanion && PrefsInfo.hideUnderCompanion) {
                        debugLog("🖼️ Disable overlays under Companion")
                    } else {
                        view.layerManager.setupLayersForVideo(video: currentVideo, player: player)
                    }
                }
            } else {
                self.addPlayerFades(view: self, player: player, video: currentVideo)
                if (Aerial.helper.underCompanion && PrefsInfo.hideUnderCompanion) {
                    debugLog("🖼️ Disable overlays under Companion")
                } else {
                    self.layerManager.setupLayersForVideo(video: currentVideo, player: player)
                }
            }
        }
    }

    // Remove all the layer animations on all shared views
    func clearAllLayerAnimations() {
        // Clear everything
        if let player = player {
            layerManager.clearLayerAnimations(player: player)
            for view in AerialView.sharedViews {
                view.layerManager.clearLayerAnimations(player: player)
            }
        }
    }

    func clearNotifications() {
        NotificationCenter.default.removeObserver(self)
        DistributedNotificationCenter.default.removeObserver(self)
    }
    
    func setNotifications(_ currentItem: AVPlayerItem) {
        let notificationCenter = NotificationCenter.default

        notificationCenter.addObserver(self,
                                       selector: #selector(AerialView.playerItemDidReachEnd(_:)),
                                       name: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
                                       object: currentItem)
        notificationCenter.addObserver(self,
                                       selector: #selector(AerialView.playerItemNewErrorLogEntryNotification(_:)),
                                       name: NSNotification.Name.AVPlayerItemNewErrorLogEntry,
                                       object: currentItem)
        notificationCenter.addObserver(self,
                                       selector: #selector(AerialView.playerItemFailedtoPlayToEnd(_:)),
                                       name: NSNotification.Name.AVPlayerItemFailedToPlayToEndTime,
                                       object: currentItem)
        notificationCenter.addObserver(self,
                                       selector: #selector(AerialView.playerItemPlaybackStalledNotification(_:)),
                                       name: NSNotification.Name.AVPlayerItemPlaybackStalled,
                                       object: currentItem)

        NSWorkspace.shared.notificationCenter.addObserver(
                self, selector: #selector(onSleepNote(note:)),
                name: NSWorkspace.willSleepNotification, object: nil)

        DistributedNotificationCenter.default.addObserver(self,
            selector: #selector(AerialView.willStart(_:)),
            name: Notification.Name("com.apple.screensaver.willstart"), object: nil)
        DistributedNotificationCenter.default.addObserver(self,
            selector: #selector(AerialView.willStop(_:)),
            name: Notification.Name("com.apple.screensaver.willstop"), object: nil)
        /*DistributedNotificationCenter.default.addObserver(self,
            selector: #selector(AerialView.screenIsUnlocked(_:)),
            name: Notification.Name("com.apple.screenIsUnlocked"), object: nil)
        */
        Music.instance.setup()
    }

    func sendNotification(video: AerialVideo) {
        DistributedNotificationCenter.default.post(name: Notification.Name("com.glouel.aerial.nextvideo"), object: "aerialtest : " + video.name)
    }

    
    @objc func willStart(_ aNotification: Notification) {
        if Aerial.helper.underCompanion {
            debugLog("🖼️ 📢📢📢 willStart")
            player?.pause()
        }
    }

    @objc func screenIsUnlocked(_ aNotification: Notification) {
        if #available(macOS 14.0, *) {
            debugLog("🖼️ 📢📢📢 ☢️sonoma☢️ workaround screenIsUnlocked")
            if !Aerial.helper.underCompanion {
                if let player = player {
                    layerManager.removeAllLayers()
                    player.pause()
                }
                self.stopAnimation()
            } else {
                if !globalPause {
                    player?.play()
                    player?.rate = globalSpeed
                }
            }
        }
    }
    
    @objc func onSleepNote(note: Notification) {
        debugLog("🖼️ 📢📢📢 onSleepNote")
        if !Aerial.helper.underCompanion {
            if #available(macOS 14.0, *) {
                exit(0)
            }
        }
    }
    
    @objc func willStop(_ aNotification: Notification) {
        DisplayDetection.sharedInstance.resetUnusedScreens()

/*        if #available(macOS 14.0, *) {
            debugLog("🖼️ 📢📢📢 🖼️ 📢📢📢 ☢️sonoma☢️ workaround IGNORING willStop")
        } else {*/
        debugLog("🖼️ 📢📢📢 willStop")
        if !Aerial.helper.underCompanion {
            if let player = player {
                player.pause()
            }
            
            if #available(macOS 14.0, *) {
                exit(0)
            }

            self.stopAnimation()
        } else {
            if !globalPause {
                player?.play()
                player?.rate = globalSpeed
            }
        }
        //}
    }

    // Tentative integration with companion of extra features
    @objc func togglePause() {
        debugLog("🖼️ Toggling pause")
        if player?.rate == 0 {
            player?.play()
            player?.rate = globalSpeed
            globalPause = false
        } else {
            player?.pause()
            globalPause = true
        }
        removePlayerFades()
    }

    @objc func nextVideo() {
        debugLog("🖼️ Next video")
        fastFadeOut(andPlayNext: true)
    }

    @objc func skipAndHide() {
        guard let currentVideo = currentVideo else {
            errorLog("skipAndHide, no currentVideo")
            return
        }

        debugLog("🖼️ Skip video and hide")
        PrefsVideos.hidden.append(currentVideo.id)
        fastFadeOut(andPlayNext: true)
    }

    @objc func getGlobalSpeed() -> Float {
        guard let player = player else {
            errorLog("getGlobalSpeed, no player")
            return 0
        }
        debugLog("🖼️ Current global speed : " + String(globalSpeed))
        return player.rate
    }

    @objc func setGlobalSpeed(_ speed : Float)  {
        debugLog("🖼️ Setting speed to : " + String(speed))
        globalSpeed = speed

        // Apply now if playing
        if let player = player {
            if (player.rate != 0) {
                player.rate = globalSpeed
            }
        }
    }

    
    
    // MARK: - playNextVideo()
    // swiftlint:disable:next cyclomatic_complexity
    func playNextVideo() {
        debugLog("🖼️ \(self) pnv")

        clearAllLayerAnimations()

        clearNotifications()

        // play another video
        let player = AVPlayer()
        let oldPlayer = self.player
        self.player = player
        player.isMuted = PrefsAdvanced.muteSound

        self.playerLayer.player = self.player
        self.playerLayer.opacity = AerialView.shouldFade ? 0 : 1.0
        if self.isPreview {
            AerialView.previewPlayer = player
        }

        debugLog("🖼️ \(self.description) Setting player for all player layers in \(AerialView.sharedViews)")
        for view in AerialView.sharedViews {
            view.playerLayer.player = player
        }

        if oldPlayer == AerialView.previewPlayer {
            AerialView.previewView?.playerLayer.player = self.player
        }

        playerLayer.drawsAsynchronously = true

        // get a list of current videos that should be excluded from the candidate selection
        // for the next video. This prevents the same video from being shown twice in a row
        // as well as the same video being shown on two different monitors even when sharingPlayers
        // is false
        let currentVideos: [AerialVideo] = AerialView.players.compactMap { (player) -> AerialVideo? in
            (player.currentItem as? AerialPlayerItem)?.video
        }

        let (randomVideo, pshouldLoop) = VideoList.instance.randomVideo(excluding: currentVideos, isVertical: isScreenVertical())

        // If we only have one video in the playlist, we can rewind it for seamless transitions
        self.shouldLoop = pshouldLoop

        guard let video = randomVideo else {
            errorLog("\(self.description) Error grabbing random video!")
            return
        }
        self.currentVideo = video

        // Workaround to avoid local playback making network calls
        let item = AerialPlayerItem(video: video)
        if !video.isAvailableOffline {
            if let value = PrefsVideos.vibrance[video.id], !video.isHDR() {
                item.setVibrance(value)
            }

            player.replaceCurrentItem(with: item)
            debugLog("🖼️ \(self.description) streaming video (not fully available offline) : \(video.url)")

            guard let currentItem = player.currentItem else {
                errorLog("\(self.description) No current item!")
                return
            }

            debugLog("🖼️ \(self.description) observing current item \(currentItem)")

            // Descriptions and fades are set when we begin playback
            if !self.observerWasSet {
                observerWasSet = true
                playerLayer.addObserver(self, forKeyPath: "readyForDisplay", options: .initial, context: nil)
            }
            
            sendNotification(video: video)
            setNotifications(currentItem)

            player.actionAtItemEnd = AVPlayer.ActionAtItemEnd.none

            // Let's never download stuff in preview...
            if !isPreview {
                Cache.fillOrRollCache()
            }
        } else {
            // The new localpath getter
            let localPath = VideoList.instance.localPathFor(video: video)

            // let localurl = URL(fileURLWithPath: VideoCache.cachePath(forVideo: video)!)
            let localurl = URL(fileURLWithPath: localPath)
            let localitem = AVPlayerItem(url: localurl)
            if !video.isHDR() {
                let value = PrefsVideos.vibrance[video.id] ?? 0
                localitem.setVibrance(value)
            }
            DispatchQueue.global(qos: .default).async { [self] in
                player.replaceCurrentItem(with: localitem)
                debugLog("🖼️ \(self.description) playing video (OFFLINE MODE) : \(localurl)")
                guard let currentItem = player.currentItem else {
                    errorLog("\(self.description) No current item!")
                    return
                }

                debugLog("🖼️ \(self.description) observing current item \(currentItem)")

                // Descriptions and fades are set when we begin playback
                if !self.observerWasSet {
                    observerWasSet = true
                    playerLayer.addObserver(self, forKeyPath: "readyForDisplay", options: .initial, context: nil)
                }

                sendNotification(video: video)
                setNotifications(currentItem)

                player.actionAtItemEnd = AVPlayer.ActionAtItemEnd.none

                // Let's never download stuff in preview...
                if !isPreview {
                    Cache.fillOrRollCache()
                }
            }
        }
    }

    // Is the current screen vertical?
    func isScreenVertical() -> Bool {
        return self.frame.size.width < self.frame.size.height
    }

    override func keyDown(with event: NSEvent) {
        debugLog("🖼️ keyDown")

        if PrefsVideos.allowSkips {
            if event.keyCode == 124 {
                if !isQuickFading {
                    // If we share, just call this on our main view
                    if AerialView.sharingPlayers {
                        // The first view with the player gets the fade and the play next instruction,
                        // it controls the others
                        for view in AerialView.sharedViews where AerialView.sharedViews.first != view {
                            view.fastFadeOut(andPlayNext: false)
                        }
                        AerialView.sharedViews.first!.fastFadeOut(andPlayNext: true)

                    } else {
                        // If we do independant playback we have to skip all views
                        for view in AerialView.instanciatedViews {
                            view.fastFadeOut(andPlayNext: true)
                        }
                    }
                } else {
                    debugLog("🖼️⚠️ Right arrow key currently locked")
                }
            } else if event.keyCode == 125 {
                stopAnimation()
            } else {
                self.nextResponder!.keyDown(with: event)
                // super.keyDown(with: event)
            }
        } else {
            self.nextResponder?.keyDown(with: event)
            // super.keyDown(with: event)
        }
    }

    override var acceptsFirstResponder: Bool {
        // swiftlint:disable:next implicit_getter
        get {
            return true
        }
    }

    // MARK: - Extra Animations
    private func fastFadeOut(andPlayNext: Bool) {
        // We need to clear the current animations running on playerLayer
        isQuickFading = true    // Lock the use of keydown
        playerLayer.removeAllAnimations()
        let fadeOutAnimation = CAKeyframeAnimation(keyPath: "opacity")
        fadeOutAnimation.values = [1, 0] as [Int]
        fadeOutAnimation.keyTimes = [0, AerialView.fadeDuration] as [NSNumber]
        if !Aerial.helper.underCompanion {
            fadeOutAnimation.duration = AerialView.fadeDuration
        } else {
            fadeOutAnimation.values = [1, 1] as [Int]
            fadeOutAnimation.duration = 0.1
        }
        fadeOutAnimation.delegate = self
        fadeOutAnimation.isRemovedOnCompletion = false
        fadeOutAnimation.calculationMode = CAAnimationCalculationMode.cubic
        if andPlayNext {
            playerLayer.add(fadeOutAnimation, forKey: "quickfadeandnext")
        } else {
            playerLayer.add(fadeOutAnimation, forKey: "quickfade")
        }
    }

    // Stop callback for fastFadeOut
    func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
        isQuickFading = false   // Release our ugly lock
        playerLayer.opacity = 0
        if anim == playerLayer.animation(forKey: "quickfadeandnext") {
            debugLog("🖼️ stop and next")
            playerLayer.removeAllAnimations()   // Make sure we get rid of our anim
            playNextVideo()
        } else {
            debugLog("🖼️ stop")
            playerLayer.removeAllAnimations()   // Make sure we get rid of our anim
        }
    }

    // Create a move animation
    func createMoveAnimation(layer: CALayer, to: CGPoint, duration: Double) -> CABasicAnimation {
        let moveAnimation = CABasicAnimation(keyPath: "position")
        moveAnimation.fromValue = layer.position
        moveAnimation.toValue = to
        moveAnimation.duration = duration
        layer.position = to
        return moveAnimation
    }

    // MARK: - Preferences

    override var hasConfigureSheet: Bool {
        return true
    }

    override var configureSheet: NSWindow? {
        if let controller = preferencesController {
            return controller.window
        }

        let controller = PanelWindowController()
        preferencesController = controller
        return controller.window
    }
}