From 60f38a80aa85b03cf8e0dbb2ceb8fb9e4c1db89d Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Fri, 26 Aug 2022 22:17:21 +0200 Subject: [PATCH] Improve PiP Fix #186 Fix #196 --- Model/Player/Backends/AVPlayerBackend.swift | 74 ++++++++++++++---- Model/Player/Backends/MPVBackend.swift | 5 +- Model/Player/PiPDelegate.swift | 16 ++-- Model/Player/PlayerControlsModel.swift | 20 ----- Model/Player/PlayerModel.swift | 78 +++++++++++++++---- Shared/Defaults.swift | 1 + .../Player/AppleAVPlayerViewController.swift | 15 ++-- Shared/Player/Controls/PlayerControls.swift | 6 +- Shared/Settings/PlayerSettings.swift | 6 ++ Shared/Views/VideoContextMenuView.swift | 10 ++- Yattee.xcodeproj/project.pbxproj | 4 - macOS/PictureInPictureDelegate.swift | 34 -------- macOS/Windows.swift | 8 ++ 13 files changed, 173 insertions(+), 104 deletions(-) delete mode 100644 macOS/PictureInPictureDelegate.swift diff --git a/Model/Player/Backends/AVPlayerBackend.swift b/Model/Player/Backends/AVPlayerBackend.swift index a58472b6..e6017864 100644 --- a/Model/Player/Backends/AVPlayerBackend.swift +++ b/Model/Player/Backends/AVPlayerBackend.swift @@ -59,6 +59,7 @@ final class AVPlayerBackend: PlayerBackend { var controller: AppleAVPlayerViewController? #endif var startPictureInPictureOnPlay = false + var startPictureInPictureOnSwitch = false private var asset: AVURLAsset? private var composition = AVMutableComposition() @@ -124,6 +125,7 @@ final class AVPlayerBackend: PlayerBackend { } avPlayer.play() + model.objectWillChange.send() } func pause() { @@ -132,6 +134,7 @@ final class AVPlayerBackend: PlayerBackend { } avPlayer.pause() + model.objectWillChange.send() } func togglePlay() { @@ -147,7 +150,7 @@ final class AVPlayerBackend: PlayerBackend { avPlayer.seek( to: time, - toleranceBefore: .secondsInDefaultTimescale(1), + toleranceBefore: .zero, toleranceAfter: .zero, completionHandler: completionHandler ?? { _ in } ) @@ -165,6 +168,8 @@ final class AVPlayerBackend: PlayerBackend { func closeItem() { avPlayer.replaceCurrentItem(with: nil) + video = nil + stream = nil } func closePiP() { @@ -294,6 +299,7 @@ final class AVPlayerBackend: PlayerBackend { } if !preservingTime, + !self.model.transitioningToPiP, let segment = self.model.sponsorBlock.segments.first, segment.start < 3, self.model.lastSkipped.isNil @@ -434,11 +440,37 @@ final class AVPlayerBackend: PlayerBackend { switch playerItem.status { case .readyToPlay: + if self.model.playingInPictureInPicture { + self.startPictureInPictureOnSwitch = false + self.startPictureInPictureOnPlay = false + } if self.model.activeBackend == .appleAVPlayer, self.isAutoplaying(playerItem) { self.model.updateAspectRatio() - self.model.play() + + if self.startPictureInPictureOnPlay, + let controller = self.model.pipController, + controller.isPictureInPicturePossible + { + self.tryStartingPictureInPicture() + } else { + self.model.play() + } + } else if self.startPictureInPictureOnPlay { + self.startPictureInPictureOnPlay = false + self.model.stream = self.stream + self.model.streamSelection = self.stream + + if self.model.activeBackend != .appleAVPlayer { + self.startPictureInPictureOnSwitch = true + let seconds = self.model.mpvBackend.currentTime?.seconds ?? 0 + self.seek(to: seconds) { finished in + guard finished else { return } + self.model.pause() + self.model.changeActiveBackend(from: .mpv, to: .appleAVPlayer, changingStream: false) + } + } } case .failed: DispatchQueue.main.async { @@ -483,7 +515,7 @@ final class AVPlayerBackend: PlayerBackend { forInterval: interval, queue: .main ) { [weak self] _ in - guard let self = self else { + guard let self = self, self.model.activeBackend == .appleAVPlayer else { return } @@ -511,6 +543,7 @@ final class AVPlayerBackend: PlayerBackend { if self.controlsUpdates { self.playerTime.duration = self.playerItemDuration ?? .zero self.playerTime.currentTime = self.currentTime ?? .zero + self.model.objectWillChange.send() } } } @@ -553,17 +586,6 @@ final class AVPlayerBackend: PlayerBackend { } if player.timeControlStatus != .waitingToPlayAtSpecifiedRate { - if let controller = self.model.pipController { - if controller.isPictureInPicturePossible { - if self.startPictureInPictureOnPlay { - self.startPictureInPictureOnPlay = false - DispatchQueue.main.async { - self.model.pipController?.startPictureInPicture() - } - } - } - } - DispatchQueue.main.async { [weak self] in self?.model.objectWillChange.send() } @@ -598,10 +620,12 @@ final class AVPlayerBackend: PlayerBackend { } logger.info("starting controls updates") controlsUpdates = true + model.objectWillChange.send() } func stopControlsUpdates() { controlsUpdates = false + model.objectWillChange.send() } func startMusicMode() { @@ -633,13 +657,33 @@ final class AVPlayerBackend: PlayerBackend { } func didChangeTo() { - if model.musicMode { + if startPictureInPictureOnSwitch { + startPictureInPictureOnSwitch = false + tryStartingPictureInPicture() + } else if model.musicMode { startMusicMode() } else { stopMusicMode() } } + func tryStartingPictureInPicture() { + guard let controller = model.pipController else { return } + + var opened = false + for delay in [0.1, 0.3, 0.5, 1, 2, 3, 5] { + Delay.by(delay) { + guard !opened else { return } + if controller.isPictureInPicturePossible { + opened = true + controller.startPictureInPicture() + } else { + print("PiP not possible, waited \(delay) seconds") + } + } + } + } + func setNeedsDrawing(_: Bool) {} func setSize(_: Double, _: Double) {} func setNeedsNetworkStateUpdates(_: Bool) {} diff --git a/Model/Player/Backends/MPVBackend.swift b/Model/Player/Backends/MPVBackend.swift index 6f5cb341..f7938a91 100644 --- a/Model/Player/Backends/MPVBackend.swift +++ b/Model/Player/Backends/MPVBackend.swift @@ -169,7 +169,7 @@ final class MPVBackend: PlayerBackend { stream.resolution != .unknown && stream.format != .av1 } - func playStream(_ stream: Stream, of video: Video, preservingTime: Bool, upgrading _: Bool) { + func playStream(_ stream: Stream, of video: Video, preservingTime: Bool, upgrading: Bool) { #if !os(macOS) if model.presentingPlayer { UIApplication.shared.isIdleTimerDisabled = true @@ -204,6 +204,7 @@ final class MPVBackend: PlayerBackend { self.startClientUpdates() if !preservingTime, + !upgrading, let segment = self.model.sponsorBlock.segments.first, self.model.lastSkipped.isNil { @@ -325,6 +326,8 @@ final class MPVBackend: PlayerBackend { func closeItem() { client?.pause() client?.stop() + self.video = nil + self.stream = nil } func closePiP() {} diff --git a/Model/Player/PiPDelegate.swift b/Model/Player/PiPDelegate.swift index a9502da9..f549132a 100644 --- a/Model/Player/PiPDelegate.swift +++ b/Model/Player/PiPDelegate.swift @@ -1,4 +1,5 @@ import AVKit +import Defaults import Foundation import SwiftUI @@ -15,16 +16,21 @@ final class PiPDelegate: NSObject, AVPictureInPictureControllerDelegate { func pictureInPictureControllerWillStartPictureInPicture(_: AVPictureInPictureController) {} func pictureInPictureControllerDidStartPictureInPicture(_: AVPictureInPictureController) { - player?.playingInPictureInPicture = true - player?.avPlayerBackend.startPictureInPictureOnPlay = false + guard let player = player else { return } + + player.playingInPictureInPicture = true + player.avPlayerBackend.startPictureInPictureOnPlay = false + player.avPlayerBackend.startPictureInPictureOnSwitch = false + player.controls.objectWillChange.send() + + if Defaults[.closePlayerOnOpeningPiP] { Delay.by(0.1) { player.hide() } } } func pictureInPictureControllerDidStopPictureInPicture(_: AVPictureInPictureController) { - guard let player = player else { - return - } + guard let player = player else { return } player.playingInPictureInPicture = false + player.controls.objectWillChange.send() } func pictureInPictureControllerWillStopPictureInPicture(_: AVPictureInPictureController) {} diff --git a/Model/Player/PlayerControlsModel.swift b/Model/Player/PlayerControlsModel.swift index 9413a3fe..795617da 100644 --- a/Model/Player/PlayerControlsModel.swift +++ b/Model/Player/PlayerControlsModel.swift @@ -126,26 +126,6 @@ final class PlayerControlsModel: ObservableObject { } } - func startPiP(startImmediately: Bool = true) { - player?.avPlayerBackend.startPictureInPictureOnPlay = true - - #if !os(macOS) - player.exitFullScreen() - #endif - - if player.activeBackend != PlayerBackendType.appleAVPlayer { - player.saveTime { [weak player] in - player?.changeActiveBackend(from: .mpv, to: .appleAVPlayer) - } - } - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak player] in - if startImmediately { - player?.pipController?.startPictureInPicture() - } - } - } - func removeTimer() { timer?.invalidate() timer = nil diff --git a/Model/Player/PlayerModel.swift b/Model/Player/PlayerModel.swift index 4f3e3912..99a1cf26 100644 --- a/Model/Player/PlayerModel.swift +++ b/Model/Player/PlayerModel.swift @@ -272,6 +272,9 @@ final class PlayerModel: ObservableObject { Orientation.lockOrientation(.allButUpsideDown) } #endif + #if os(macOS) + Windows.player.hide() + #endif } func togglePlayer() { @@ -280,6 +283,13 @@ final class PlayerModel: ObservableObject { Windows.player.open() } Windows.player.focus() + + if Windows.player.visible, + closePiPOnOpeningPlayer + { + closePiP() + } + #else if presentingPlayer { hide() @@ -398,7 +408,8 @@ final class PlayerModel: ObservableObject { _ stream: Stream, of video: Video, preservingTime: Bool = false, - upgrading: Bool = false + upgrading: Bool = false, + withBackend: PlayerBackend? = nil ) { playerError = nil if !upgrading { @@ -420,9 +431,7 @@ final class PlayerModel: ObservableObject { } } - playerTime.reset() - - backend.playStream( + (withBackend ?? backend).playStream( stream, of: video, preservingTime: preservingTime, @@ -515,15 +524,13 @@ final class PlayerModel: ObservableObject { } } - func changeActiveBackend(from: PlayerBackendType, to: PlayerBackendType) { + func changeActiveBackend(from: PlayerBackendType, to: PlayerBackendType, changingStream: Bool = true) { guard activeBackend != to else { return } logger.info("changing backend from \(from.rawValue) to \(to.rawValue)") - pause() - if to == .mpv { closePiP() } @@ -531,15 +538,17 @@ final class PlayerModel: ObservableObject { Defaults[.activeBackend] = to self.activeBackend = to - self.backend.didChangeTo() - - guard var stream = stream else { - return - } - let fromBackend: PlayerBackend = from == .appleAVPlayer ? avPlayerBackend : mpvBackend let toBackend: PlayerBackend = to == .appleAVPlayer ? avPlayerBackend : mpvBackend + self.backend.didChangeTo() + + fromBackend.pause() + + guard var stream = stream, changingStream else { + return + } + if let stream = toBackend.stream, toBackend.video == fromBackend.video { toBackend.seek(to: fromBackend.currentTime?.seconds ?? .zero) { finished in guard finished else { @@ -610,11 +619,54 @@ final class PlayerModel: ObservableObject { #endif } + func startPiP() { + avPlayerBackend.startPictureInPictureOnPlay = false + avPlayerBackend.startPictureInPictureOnSwitch = false + + if activeBackend == .appleAVPlayer { + avPlayerBackend.tryStartingPictureInPicture() + return + } + + guard let video = currentVideo else { return } + guard let stream = avPlayerBackend.bestPlayable(availableStreams, maxResolution: .hd720p30) else { return } + + exitFullScreen() + + if avPlayerBackend.video == video { + if activeBackend != .appleAVPlayer { + avPlayerBackend.startPictureInPictureOnSwitch = true + changeActiveBackend(from: activeBackend, to: .appleAVPlayer) + } + } else { + avPlayerBackend.startPictureInPictureOnPlay = true + playStream(stream, of: video, preservingTime: true, upgrading: true, withBackend: avPlayerBackend) + } + + controls.objectWillChange.send() + } + + var transitioningToPiP: Bool { + avPlayerBackend.startPictureInPictureOnPlay || avPlayerBackend.startPictureInPictureOnSwitch + } + + var pipPossible: Bool { + guard activeBackend == .appleAVPlayer else { return !transitioningToPiP } + + guard let pipController = pipController else { return false } + guard !pipController.isPictureInPictureActive else { return true } + + return pipController.isPictureInPicturePossible && !transitioningToPiP + } + func closePiP() { guard playingInPictureInPicture else { return } + avPlayerBackend.startPictureInPictureOnPlay = false + avPlayerBackend.startPictureInPictureOnSwitch = false + #if os(tvOS) show() #endif diff --git a/Shared/Defaults.swift b/Shared/Defaults.swift index 6699600e..590f889b 100644 --- a/Shared/Defaults.swift +++ b/Shared/Defaults.swift @@ -127,6 +127,7 @@ extension Defaults.Keys { #if !os(macOS) static let closePiPAndOpenPlayerOnEnteringForeground = Key("closePiPAndOpenPlayerOnEnteringForeground", default: false) #endif + static let closePlayerOnOpeningPiP = Key("closePlayerOnOpeningPiP", default: false) static let recentlyOpened = Key<[RecentItem]>("recentlyOpened", default: []) diff --git a/Shared/Player/AppleAVPlayerViewController.swift b/Shared/Player/AppleAVPlayerViewController.swift index 5035d1bf..478376f9 100644 --- a/Shared/Player/AppleAVPlayerViewController.swift +++ b/Shared/Player/AppleAVPlayerViewController.swift @@ -95,7 +95,7 @@ extension AppleAVPlayerViewController: AVPlayerViewControllerDelegate { } func playerViewControllerWillBeginDismissalTransition(_: AVPlayerViewController) { - if Defaults[.pauseOnHidingPlayer] { + if Defaults[.pauseOnHidingPlayer], !playerModel.playingInPictureInPicture { playerModel.pause() } dismiss(animated: false) @@ -121,15 +121,12 @@ extension AppleAVPlayerViewController: AVPlayerViewControllerDelegate { self.playerModel.show() self.playerModel.setNeedsDrawing(true) - #if os(tvOS) - if self.playerModel.playingInPictureInPicture { - self.present(self.playerView, animated: false) { - completionHandler(true) - } + if self.playerModel.playingInPictureInPicture { + self.present(self.playerView, animated: false) { + completionHandler(true) } - #else - completionHandler(true) - #endif + } + completionHandler(true) } } diff --git a/Shared/Player/Controls/PlayerControls.swift b/Shared/Player/Controls/PlayerControls.swift index d7ab0d98..a84c21ab 100644 --- a/Shared/Player/Controls/PlayerControls.swift +++ b/Shared/Player/Controls/PlayerControls.swift @@ -277,9 +277,11 @@ struct PlayerControls: View { } private var pipButton: some View { - button("PiP", systemImage: "pip") { - model.startPiP() + let image = player.transitioningToPiP ? "pip.fill" : player.pipController?.isPictureInPictureActive ?? false ? "pip.exit" : "pip.enter" + return button("PiP", systemImage: image) { + (player.pipController?.isPictureInPictureActive ?? false) ? player.closePiP() : player.startPiP() } + .disabled(!player.pipPossible) } #if os(iOS) diff --git a/Shared/Settings/PlayerSettings.swift b/Shared/Settings/PlayerSettings.swift index cbad9270..15e75992 100644 --- a/Shared/Settings/PlayerSettings.swift +++ b/Shared/Settings/PlayerSettings.swift @@ -17,6 +17,7 @@ struct PlayerSettings: View { #endif @Default(.closePiPOnNavigation) private var closePiPOnNavigation @Default(.closePiPOnOpeningPlayer) private var closePiPOnOpeningPlayer + @Default(.closePlayerOnOpeningPiP) private var closePlayerOnOpeningPiP #if !os(macOS) @Default(.closePlayerOnItemClose) private var closePlayerOnItemClose @Default(.pauseOnEnteringBackground) private var pauseOnEnteringBackground @@ -96,6 +97,7 @@ struct PlayerSettings: View { Section(header: SettingsHeader(text: "Picture in Picture")) { closePiPOnNavigationToggle closePiPOnOpeningPlayerToggle + closePlayerOnOpeningPiPToggle #if !os(macOS) closePiPAndOpenPlayerOnEnteringForegroundToggle #endif @@ -201,6 +203,10 @@ struct PlayerSettings: View { Toggle("Close PiP when player is opened", isOn: $closePiPOnOpeningPlayer) } + private var closePlayerOnOpeningPiPToggle: some View { + Toggle("Close player when starting PiP", isOn: $closePlayerOnOpeningPiP) + } + #if !os(macOS) private var closePiPAndOpenPlayerOnEnteringForegroundToggle: some View { Toggle("Close PiP and open player when application enters foreground", isOn: $closePiPAndOpenPlayerOnEnteringForeground) diff --git a/Shared/Views/VideoContextMenuView.swift b/Shared/Views/VideoContextMenuView.swift index 6bb3fc0e..18449428 100644 --- a/Shared/Views/VideoContextMenuView.swift +++ b/Shared/Views/VideoContextMenuView.swift @@ -167,7 +167,15 @@ struct VideoContextMenuView: View { private var playNowInPictureInPictureButton: some View { Button { - player.controls.startPiP(startImmediately: player.presentingPlayer && player.activeBackend == .appleAVPlayer) + player.avPlayerBackend.startPictureInPictureOnPlay = true + + #if !os(macOS) + player.exitFullScreen() + #endif + + if player.activeBackend != PlayerBackendType.appleAVPlayer { + player.changeActiveBackend(from: .mpv, to: .appleAVPlayer) + } player.hide() DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { diff --git a/Yattee.xcodeproj/project.pbxproj b/Yattee.xcodeproj/project.pbxproj index 616e9d8a..c31351b6 100644 --- a/Yattee.xcodeproj/project.pbxproj +++ b/Yattee.xcodeproj/project.pbxproj @@ -257,7 +257,6 @@ 373CFAEF2697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAEE2697A78B003CB2C6 /* AddToPlaylistView.swift */; }; 373CFAF02697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAEE2697A78B003CB2C6 /* AddToPlaylistView.swift */; }; 373CFAF12697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAEE2697A78B003CB2C6 /* AddToPlaylistView.swift */; }; - 374108D1272B11B2006C5CC8 /* PictureInPictureDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374108D0272B11B2006C5CC8 /* PictureInPictureDelegate.swift */; }; 3741A32C27E7EFFD00D266D1 /* PlayerControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37030FFE27B04DCC00ECDDAA /* PlayerControls.swift */; }; 3743B86927216D3600261544 /* ChannelCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3743B86727216D3600261544 /* ChannelCell.swift */; }; 3743B86A27216D3600261544 /* ChannelCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3743B86727216D3600261544 /* ChannelCell.swift */; }; @@ -1043,7 +1042,6 @@ 373CFADA269663F1003CB2C6 /* Thumbnail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbnail.swift; sourceTree = ""; }; 373CFAEA26975CBF003CB2C6 /* PlaylistFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistFormView.swift; sourceTree = ""; }; 373CFAEE2697A78B003CB2C6 /* AddToPlaylistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddToPlaylistView.swift; sourceTree = ""; }; - 374108D0272B11B2006C5CC8 /* PictureInPictureDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PictureInPictureDelegate.swift; sourceTree = ""; }; 3743B86727216D3600261544 /* ChannelCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelCell.swift; sourceTree = ""; }; 3743CA4D270EFE3400E4D32B /* PlayerQueueRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerQueueRow.swift; sourceTree = ""; }; 3743CA51270F284F00E4D32B /* View+Borders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Borders.swift"; sourceTree = ""; }; @@ -1917,7 +1915,6 @@ 37BE0BDB26A2367F0092E2DB /* AppleAVPlayerView.swift */, 37FD43DB270470B70073EE42 /* InstancesSettings.swift */, 3751BA7D27E63F1D007B1A60 /* MPVOGLView.swift */, - 374108D0272B11B2006C5CC8 /* PictureInPictureDelegate.swift */, 37F7AB5428A951B200FB46B5 /* Power.swift */, 37E04C0E275940FB00172673 /* VerticalScrollingFix.swift */, 3751BA7F27E64244007B1A60 /* VideoLayer.swift */, @@ -3118,7 +3115,6 @@ 375F7411289DC35A00747050 /* PlayerBackendView.swift in Sources */, 37FEF11427EFD8580033912F /* PlaceholderCell.swift in Sources */, 37E64DD226D597EB00C71877 /* SubscriptionsModel.swift in Sources */, - 374108D1272B11B2006C5CC8 /* PictureInPictureDelegate.swift in Sources */, 37F0F4EF286F734400C06C2E /* AdvancedSettings.swift in Sources */, 37C7A1D6267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */, 37319F0627103F94004ECCD0 /* PlayerQueue.swift in Sources */, diff --git a/macOS/PictureInPictureDelegate.swift b/macOS/PictureInPictureDelegate.swift deleted file mode 100644 index 6434eeac..00000000 --- a/macOS/PictureInPictureDelegate.swift +++ /dev/null @@ -1,34 +0,0 @@ -import AVKit -import Foundation - -final class PictureInPictureDelegate: NSObject, AVPlayerViewPictureInPictureDelegate { - var playerModel: PlayerModel! - - func playerViewShouldAutomaticallyDismissAtPicture(inPictureStart _: AVPlayerView) -> Bool { - false - } - - func playerViewWillStartPicture(inPicture _: AVPlayerView) { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in - self?.playerModel.playingInPictureInPicture = true - self?.playerModel.hide() - } - } - - func playerViewWillStopPicture(inPicture _: AVPlayerView) { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in - self?.playerModel.playingInPictureInPicture = false - self?.playerModel.show() - } - } - - func playerView( - _: AVPlayerView, - restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: (Bool) -> Void - ) { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in - self?.playerModel.show() - } - completionHandler(true) - } -} diff --git a/macOS/Windows.swift b/macOS/Windows.swift index 0bd0bae1..70b24e18 100644 --- a/macOS/Windows.swift +++ b/macOS/Windows.swift @@ -47,9 +47,17 @@ enum Windows: String, CaseIterable { } } + func hide() { + window?.close() + } + func toggleFullScreen() { window?.toggleFullScreen(nil) } + + var visible: Bool { + window?.isVisible ?? false + } } struct HostingWindowFinder: NSViewRepresentable {