From 61a4951831e444da37b0d016c16137820efd9095 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sun, 19 Dec 2021 18:17:04 +0100 Subject: [PATCH] Layout and PiP improvements, new settings - player is now a separate window on macOS - add setting to disable pause when player is closed (fixes #40) - add PiP settings: * Close PiP when starting playing other video * Close PiP when player is opened * Close PiP and open player when application enters foreground (iOS/tvOS) (fixes #37) - new player placeholder when in PiP, context menu with exit option --- Model/NavigationModel.swift | 6 +- Model/Player/PlayerModel.swift | 151 +++++++++++++++++-- Model/Player/PlayerQueue.swift | 15 +- Model/Player/PlayerTVMenu.swift | 2 +- Shared/Defaults.swift | 7 + Shared/MenuCommands.swift | 10 +- Shared/Navigation/AppSidebarNavigation.swift | 24 +-- Shared/Navigation/ContentView.swift | 23 ++- Shared/Player/PlaybackBar.swift | 9 +- Shared/Player/PlayerQueueRow.swift | 7 + Shared/Player/PlayerViewController.swift | 44 ++++-- Shared/Player/VideoPlayerView.swift | 70 +++++++-- Shared/Playlists/PlaylistsView.swift | 2 +- Shared/Settings/PlaybackSettings.swift | 40 +++++ Shared/Videos/VideoCell.swift | 2 +- Shared/Views/PlayerControlsView.swift | 4 +- Shared/Views/VideoContextMenuView.swift | 2 +- Shared/YatteeApp.swift | 50 +++++- Yattee.xcodeproj/project.pbxproj | 6 +- iOS/Info.plist | 5 + macOS/InstancesSettings.swift | 2 +- macOS/OpenWindow.swift | 40 +++++ macOS/PictureInPictureDelegate.swift | 6 +- macOS/Player.swift | 2 +- tvOS/NowPlayingView.swift | 8 +- 25 files changed, 443 insertions(+), 94 deletions(-) create mode 100644 macOS/OpenWindow.swift diff --git a/Model/NavigationModel.swift b/Model/NavigationModel.swift index 27491b62..c968c637 100644 --- a/Model/NavigationModel.swift +++ b/Model/NavigationModel.swift @@ -49,7 +49,11 @@ final class NavigationModel: ObservableObject { navigationStyle: NavigationStyle ) { let recent = RecentItem(from: channel) - player.presentingPlayer = false + #if os(macOS) + OpenWindow.main.open() + #else + player.hide() + #endif let openRecent = { recents.add(recent) diff --git a/Model/Player/PlayerModel.swift b/Model/Player/PlayerModel.swift index d75a1a9c..c7f7830f 100644 --- a/Model/Player/PlayerModel.swift +++ b/Model/Player/PlayerModel.swift @@ -15,18 +15,15 @@ final class PlayerModel: ObservableObject { let logger = Logger(label: "stream.yattee.app") private(set) var player = AVPlayer() - private(set) var playerView = Player() - var controller: PlayerViewController? { didSet { playerView.controller = controller } } - #if os(tvOS) - var avPlayerViewController: AVPlayerViewController? - #endif + var playerView = Player() + var controller: PlayerViewController? - @Published var presentingPlayer = false { didSet { pauseOnPlayerDismiss() } } + @Published var presentingPlayer = false { didSet { handlePresentationChange() } } @Published var stream: Stream? @Published var currentRate: Float = 1.0 { didSet { player.rate = currentRate } } - @Published var availableStreams = [Stream]() { didSet { handleAvailableStreamsChange() }} + @Published var availableStreams = [Stream]() { didSet { handleAvailableStreamsChange() } } @Published var streamSelection: Stream? { didSet { rebuildTVMenu() } } @Published var queue = [PlayerQueueItem]() { didSet { Defaults[.queue] = queue } } @@ -35,7 +32,7 @@ final class PlayerModel: ObservableObject { @Published var preservedTime: CMTime? - @Published var playerNavigationLinkActive = false { didSet { pauseOnChannelPlayerDismiss() } } + @Published var playerNavigationLinkActive = false { didSet { handleNavigationViewPlayerPresentationChange() } } @Published var sponsorBlock = SponsorBlockAPI() @Published var segmentRestorationTime: CMTime? @@ -70,6 +67,14 @@ final class PlayerModel: ObservableObject { #endif }} + @Default(.pauseOnHidingPlayer) private var pauseOnHidingPlayer + @Default(.closePiPOnNavigation) var closePiPOnNavigation + @Default(.closePiPOnOpeningPlayer) var closePiPOnOpeningPlayer + + #if !os(macOS) + @Default(.closePiPAndOpenPlayerOnEnteringForeground) var closePiPAndOpenPlayerOnEnteringForeground + #endif + init(accounts: AccountsModel? = nil, comments: CommentsModel? = nil) { self.accounts = accounts ?? AccountsModel() self.comments = comments ?? CommentsModel() @@ -80,12 +85,41 @@ final class PlayerModel: ObservableObject { addPlayerTimeControlStatusObserver() } - func presentPlayer() { + func show() { + guard !presentingPlayer else { + #if os(macOS) + OpenWindow.player.focus() + #endif + return + } + #if os(macOS) + OpenWindow.player.open() + OpenWindow.player.focus() + #endif presentingPlayer = true } + func hide() { + guard presentingPlayer else { + return + } + + presentingPlayer = false + } + func togglePlayer() { - presentingPlayer.toggle() + #if os(macOS) + if !presentingPlayer { + OpenWindow.player.open() + } + OpenWindow.player.focus() + #else + if presentingPlayer { + hide() + } else { + show() + } + #endif } var isPlaying: Bool { @@ -189,12 +223,29 @@ final class PlayerModel: ObservableObject { preservingTime: !currentItem.playbackTime.isNil ) } + + private func handlePresentationChange() { + if presentingPlayer, closePiPOnOpeningPlayer, playingInPictureInPicture { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.closePiP() + } + } + + if !presentingPlayer, pauseOnHidingPlayer, !playingInPictureInPicture { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.pause() + } + } + + if !presentingPlayer, !pauseOnHidingPlayer, isPlaying { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in + self?.play() } } } - private func pauseOnChannelPlayerDismiss() { - if !playingInPictureInPicture, !playerNavigationLinkActive { + private func handleNavigationViewPlayerPresentationChange() { + if pauseOnHidingPlayer, !playingInPictureInPicture, !playerNavigationLinkActive { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { self.pause() } @@ -371,6 +422,10 @@ final class PlayerModel: ObservableObject { item.preferredForwardBufferDuration = 5 + observePlayerItemStatus(item) + } + + private func observePlayerItemStatus(_ item: AVPlayerItem) { statusObservation?.invalidate() statusObservation = item.observe(\.status, options: [.old, .new]) { [weak self] playerItem, _ in guard let self = self else { @@ -422,11 +477,9 @@ final class PlayerModel: ObservableObject { addCurrentItemToHistory() resetQueue() #if os(tvOS) - avPlayerViewController!.dismiss(animated: true) { [weak self] in - self?.controller!.dismiss(animated: true) - } + controller?.dismiss(animated: true) #endif - presentingPlayer = false + hide() } else { advanceToNextItem() } @@ -621,4 +674,70 @@ final class PlayerModel: ObservableObject { currentItem = nil player.replaceCurrentItem(with: nil) } + + func closePiP() { + guard playingInPictureInPicture else { + return + } + + let wasPlaying = isPlaying + pause() + + #if os(tvOS) + show() + closePipByReplacingItem(wasPlaying: wasPlaying) + #else + closePiPByNilingPlayer(wasPlaying: wasPlaying) + #endif + } + + private func closePipByReplacingItem(wasPlaying: Bool) { + let item = player.currentItem + let time = player.currentTime() + + self.player.replaceCurrentItem(with: nil) + + guard !item.isNil else { + return + } + + self.player.seek(to: time) + self.player.replaceCurrentItem(with: item) + + guard wasPlaying else { + return + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in + self?.play() + } + } + + private func closePiPByNilingPlayer(wasPlaying: Bool) { + controller?.playerView.player = nil + controller?.playerView.player = player + + guard wasPlaying else { + return + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.play() + } + } + + #if os(macOS) + var windowTitle: String { + currentVideo.isNil ? "Not playing" : "\(currentVideo!.title) - \(currentVideo!.author)" + } + #else + func handleEnterForeground() { + guard closePiPAndOpenPlayerOnEnteringForeground, playingInPictureInPicture else { + return + } + + show() + closePiP() + } + #endif } diff --git a/Model/Player/PlayerQueue.swift b/Model/Player/PlayerQueue.swift index 4fd78d23..43990e9b 100644 --- a/Model/Player/PlayerQueue.swift +++ b/Model/Player/PlayerQueue.swift @@ -1,4 +1,4 @@ -import AVFoundation +import AVKit import Defaults import Foundation import Siesta @@ -29,7 +29,10 @@ extension PlayerModel { } func playNow(_ video: Video, at time: TimeInterval? = nil) { - player.replaceCurrentItem(with: nil) + if !playingInPictureInPicture || closePiPOnNavigation { + closePiP() + } + addCurrentItemToHistory() enqueueVideo(video, prepending: true) { _, item in @@ -38,7 +41,12 @@ extension PlayerModel { } func playItem(_ item: PlayerQueueItem, video: Video? = nil, at time: TimeInterval? = nil) { + if !playingInPictureInPicture { + player.replaceCurrentItem(with: nil) + } + comments.reset() + stream = nil currentItem = item if !time.isNil { @@ -83,7 +91,6 @@ extension PlayerModel { } func advanceToItem(_ newItem: PlayerQueueItem, at time: TimeInterval? = nil) { - player.replaceCurrentItem(with: nil) addCurrentItemToHistory() remove(newItem) @@ -116,7 +123,7 @@ extension PlayerModel { } func isAutoplaying(_ item: AVPlayerItem) -> Bool { - player.currentItem == item && (presentingPlayer || playerNavigationLinkActive || playingInPictureInPicture) + player.currentItem == item } @discardableResult func enqueueVideo( diff --git a/Model/Player/PlayerTVMenu.swift b/Model/Player/PlayerTVMenu.swift index 8cd5f524..7e003485 100644 --- a/Model/Player/PlayerTVMenu.swift +++ b/Model/Player/PlayerTVMenu.swift @@ -66,7 +66,7 @@ extension PlayerModel { func rebuildTVMenu() { #if os(tvOS) - avPlayerViewController?.transportBarCustomMenuItems = [ + controller?.playerView.transportBarCustomMenuItems = [ restoreLastSkippedSegmentAction, rateMenu, streamsMenu diff --git a/Shared/Defaults.swift b/Shared/Defaults.swift index 9fad8b86..7e143dc8 100644 --- a/Shared/Defaults.swift +++ b/Shared/Defaults.swift @@ -38,6 +38,13 @@ extension Defaults.Keys { #if !os(tvOS) static let commentsPlacement = Key("commentsPlacement", default: .separate) #endif + static let pauseOnHidingPlayer = Key("pauseOnHidingPlayer", default: true) + + static let closePiPOnNavigation = Key("closePiPOnNavigation", default: false) + static let closePiPOnOpeningPlayer = Key("closePiPOnOpeningPlayer", default: false) + #if !os(macOS) + static let closePiPAndOpenPlayerOnEnteringForeground = Key("closePiPAndOpenPlayerOnEnteringForeground", default: false) + #endif static let recentlyOpened = Key<[RecentItem]>("recentlyOpened", default: []) diff --git a/Shared/MenuCommands.swift b/Shared/MenuCommands.swift index 432d0981..db186b6d 100644 --- a/Shared/MenuCommands.swift +++ b/Shared/MenuCommands.swift @@ -62,10 +62,18 @@ struct MenuCommands: Commands { .disabled(model.player?.queue.isEmpty ?? true) .keyboardShortcut("s") - Button((model.player?.presentingPlayer ?? true) ? "Hide Player" : "Show Player") { + Button(togglePlayerLabel) { model.player?.togglePlayer() } .keyboardShortcut("o") } } + + private var togglePlayerLabel: String { + #if os(macOS) + "Show Player" + #else + (model.player?.presentingPlayer ?? true) ? "Hide Player" : "Show Player" + #endif + } } diff --git a/Shared/Navigation/AppSidebarNavigation.swift b/Shared/Navigation/AppSidebarNavigation.swift index dbb477f9..65e85acc 100644 --- a/Shared/Navigation/AppSidebarNavigation.swift +++ b/Shared/Navigation/AppSidebarNavigation.swift @@ -62,23 +62,15 @@ struct AppSidebarNavigation: View { } } } - #if os(iOS) - .background( - EmptyView().fullScreenCover(isPresented: $player.presentingPlayer) { - videoPlayer - .environment(\.navigationStyle, .sidebar) - } - ) - #elseif os(macOS) - .background( - EmptyView().sheet(isPresented: $player.presentingPlayer) { - videoPlayer - .frame(minWidth: 1000, minHeight: 750) - .environment(\.navigationStyle, .sidebar) - } - ) - #endif .environment(\.navigationStyle, .sidebar) + #if os(iOS) + .background( + EmptyView().fullScreenCover(isPresented: $player.presentingPlayer) { + videoPlayer + .environment(\.navigationStyle, .sidebar) + } + ) + #endif } private var videoPlayer: some View { diff --git a/Shared/Navigation/ContentView.swift b/Shared/Navigation/ContentView.swift index 45cc4aa7..5c52cd23 100644 --- a/Shared/Navigation/ContentView.swift +++ b/Shared/Navigation/ContentView.swift @@ -7,16 +7,16 @@ import Siesta import SwiftUI struct ContentView: View { - @StateObject private var accounts = AccountsModel() - @StateObject private var comments = CommentsModel() - @StateObject private var instances = InstancesModel() - @StateObject private var navigation = NavigationModel() - @StateObject private var player = PlayerModel() - @StateObject private var playlists = PlaylistsModel() - @StateObject private var recents = RecentsModel() - @StateObject private var search = SearchModel() - @StateObject private var subscriptions = SubscriptionsModel() - @StateObject private var thumbnailsModel = ThumbnailsModel() + @EnvironmentObject private var accounts + @EnvironmentObject private var comments + @EnvironmentObject private var instances + @EnvironmentObject private var navigation + @EnvironmentObject private var player + @EnvironmentObject private var playlists + @EnvironmentObject private var recents + @EnvironmentObject private var search + @EnvironmentObject private var subscriptions + @EnvironmentObject private var thumbnailsModel @EnvironmentObject private var menu @@ -61,7 +61,6 @@ struct ContentView: View { } ) #if !os(tvOS) - .handlesExternalEvents(preferring: Set(["*"]), allowing: Set(["*"])) .onOpenURL(perform: handleOpenedURL) .background( EmptyView().sheet(isPresented: $navigation.presentingAddToPlaylist) { @@ -162,7 +161,7 @@ struct ContentView: View { if let video: Video = response.typedContent() { player.addCurrentItemToHistory() self.player.playNow(video, at: parser.time) - self.player.presentPlayer() + self.player.show() } } } diff --git a/Shared/Player/PlaybackBar.swift b/Shared/Player/PlaybackBar.swift index 5cc52fe8..3d95083d 100644 --- a/Shared/Player/PlaybackBar.swift +++ b/Shared/Player/PlaybackBar.swift @@ -11,7 +11,9 @@ struct PlaybackBar: View { var body: some View { HStack { - closeButton + #if !os(macOS) + closeButton + #endif if player.currentItem != nil { HStack { @@ -20,6 +22,9 @@ struct PlaybackBar: View { rateMenu } .font(.caption2) + #if os(macOS) + .padding(.leading, 4) + #endif Spacer() @@ -68,7 +73,7 @@ struct PlaybackBar: View { message: Text(player.playerError?.localizedDescription ?? "") ) } - .frame(minWidth: 0, maxWidth: .infinity) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 20) .padding(4) .background(colorScheme == .dark ? Color.black : Color.white) } diff --git a/Shared/Player/PlayerQueueRow.swift b/Shared/Player/PlayerQueueRow.swift index 40183f21..d4266673 100644 --- a/Shared/Player/PlayerQueueRow.swift +++ b/Shared/Player/PlayerQueueRow.swift @@ -1,3 +1,4 @@ +import Defaults import Foundation import SwiftUI @@ -8,6 +9,8 @@ struct PlayerQueueRow: View { @EnvironmentObject private var player + @Default(.closePiPOnNavigation) var closePiPOnNavigation + var body: some View { Group { Button { @@ -24,6 +27,10 @@ struct PlayerQueueRow: View { fullScreen = false } } + + if closePiPOnNavigation, player.playingInPictureInPicture { + player.closePiP() + } } label: { VideoBanner(video: item.video, playbackTime: item.playbackTime, videoDuration: item.videoDuration) } diff --git a/Shared/Player/PlayerViewController.swift b/Shared/Player/PlayerViewController.swift index 27ca744b..36e7d72f 100644 --- a/Shared/Player/PlayerViewController.swift +++ b/Shared/Player/PlayerViewController.swift @@ -1,4 +1,5 @@ import AVKit +import Defaults import SwiftUI final class PlayerViewController: UIViewController { @@ -7,11 +8,11 @@ final class PlayerViewController: UIViewController { var navigationModel: NavigationModel! var playerModel: PlayerModel! var subscriptionsModel: SubscriptionsModel! - var playerViewController = AVPlayerViewController() + var playerView = AVPlayerViewController() #if !os(tvOS) var aspectRatio: Double? { - let ratio = Double(playerViewController.videoBounds.width) / Double(playerViewController.videoBounds.height) + let ratio = Double(playerView.videoBounds.width) / Double(playerView.videoBounds.height) guard ratio.isFinite else { return VideoPlayerView.defaultAspectRatio // swiftlint:disable:this implicit_return @@ -27,24 +28,35 @@ final class PlayerViewController: UIViewController { loadPlayer() #if os(tvOS) - if !playerViewController.isBeingPresented, !playerViewController.isBeingDismissed { - present(playerViewController, animated: false) + if !playerView.isBeingPresented, !playerView.isBeingDismissed { + present(playerView, animated: false) } #endif } + #if os(tvOS) + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + if !playerModel.presentingPlayer, !Defaults[.pauseOnHidingPlayer], !playerModel.isPlaying { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.playerModel.play() + } + } + } + #endif + func loadPlayer() { guard !playerLoaded else { return } playerModel.controller = self - playerViewController.player = playerModel.player - playerViewController.allowsPictureInPicturePlayback = true - playerViewController.delegate = self + playerView.player = playerModel.player + playerView.allowsPictureInPicturePlayback = true + playerView.delegate = self #if os(tvOS) - playerModel.avPlayerViewController = playerViewController var infoViewControllers = [UIHostingController]() if CommentsModel.enabled { infoViewControllers.append(infoViewController([.comments], title: "Comments")) @@ -54,7 +66,7 @@ final class PlayerViewController: UIViewController { infoViewController([.playingNext, .playedPreviously], title: "Playing Next") ]) - playerViewController.customInfoViewControllers = infoViewControllers + playerView.customInfoViewControllers = infoViewControllers #else embedViewController() #endif @@ -81,12 +93,12 @@ final class PlayerViewController: UIViewController { } #else func embedViewController() { - playerViewController.view.frame = view.bounds + playerView.view.frame = view.bounds - addChild(playerViewController) - view.addSubview(playerViewController.view) + addChild(playerView) + view.addSubview(playerView.view) - playerViewController.didMove(toParent: self) + playerView.didMove(toParent: self) } #endif } @@ -127,19 +139,19 @@ extension PlayerViewController: AVPlayerViewControllerDelegate { } func playerViewController( - _ playerViewController: AVPlayerViewController, + _: AVPlayerViewController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void ) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { if self.navigationModel.presentingChannel { self.playerModel.playerNavigationLinkActive = true } else { - self.playerModel.presentPlayer() + self.playerModel.show() } #if os(tvOS) if self.playerModel.playingInPictureInPicture { - self.present(playerViewController, animated: false) { + self.present(self.playerView, animated: false) { completionHandler(true) } } diff --git a/Shared/Player/VideoPlayerView.swift b/Shared/Player/VideoPlayerView.swift index 85ce20fa..ba5befef 100644 --- a/Shared/Player/VideoPlayerView.swift +++ b/Shared/Player/VideoPlayerView.swift @@ -31,7 +31,8 @@ struct VideoPlayerView: View { HSplitView { content } - .frame(idealWidth: 1000, maxWidth: 1100, minHeight: 700) + .onOpenURL(perform: handleOpenedURL) + .frame(minWidth: 950, minHeight: 700) #else GeometryReader { geometry in HStack(spacing: 0) { @@ -66,15 +67,16 @@ struct VideoPlayerView: View { if player.currentItem.isNil { playerPlaceholder(geometry: geometry) + } else if player.playingInPictureInPicture { + pictureInPicturePlaceholder(geometry: geometry) } else { - #if os(macOS) - Player() - .modifier(VideoPlayerSizeModifier(geometry: geometry, aspectRatio: player.controller?.aspectRatio)) - - #else - player.playerView - .modifier(VideoPlayerSizeModifier(geometry: geometry, aspectRatio: player.controller?.aspectRatio)) - #endif + player.playerView + .modifier( + VideoPlayerSizeModifier( + geometry: geometry, + aspectRatio: player.controller?.aspectRatio + ) + ) } } #if os(iOS) @@ -143,6 +145,35 @@ struct VideoPlayerView: View { .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: geometry.size.width / VideoPlayerView.defaultAspectRatio) } + func pictureInPicturePlaceholder(geometry: GeometryProxy) -> some View { + HStack { + Spacer() + VStack { + Spacer() + VStack(spacing: 10) { + #if !os(tvOS) + Image(systemName: "pip") + .font(.system(size: 120)) + #endif + + Text("Playing in Picture in Picture") + } + Spacer() + } + .foregroundColor(.gray) + Spacer() + } + .contextMenu { + Button { + player.closePiP() + } label: { + Label("Exit Picture in Picture", systemImage: "pip.exit") + } + } + .contentShape(Rectangle()) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: geometry.size.width / VideoPlayerView.defaultAspectRatio) + } + var sidebarQueue: Bool { switch Defaults[.playerSidebar] { case .never: @@ -160,6 +191,27 @@ struct VideoPlayerView: View { set: { _ in } ) } + + #if !os(tvOS) + func handleOpenedURL(_ url: URL) { + guard !player.accounts.current.isNil else { + return + } + + let parser = VideoURLParser(url: url) + + guard let id = parser.id else { + return + } + + player.accounts.api.video(id).load().onSuccess { response in + if let video: Video = response.typedContent() { + self.player.playNow(video, at: parser.time) + self.player.show() + } + } + } + #endif } struct VideoPlayerView_Previews: PreviewProvider { diff --git a/Shared/Playlists/PlaylistsView.swift b/Shared/Playlists/PlaylistsView.swift index b08d352f..4c1dfc93 100644 --- a/Shared/Playlists/PlaylistsView.swift +++ b/Shared/Playlists/PlaylistsView.swift @@ -144,7 +144,7 @@ struct PlaylistsView: View { Button { player.playAll(items.compactMap(\.video)) - player.presentPlayer() + player.show() } label: { HStack(spacing: 15) { Image(systemName: "play.fill") diff --git a/Shared/Settings/PlaybackSettings.swift b/Shared/Settings/PlaybackSettings.swift index 253a101e..9b23d7a8 100644 --- a/Shared/Settings/PlaybackSettings.swift +++ b/Shared/Settings/PlaybackSettings.swift @@ -9,6 +9,12 @@ struct PlaybackSettings: View { @Default(.showKeywords) private var showKeywords @Default(.showChannelSubscribers) private var channelSubscribers @Default(.saveHistory) private var saveHistory + @Default(.pauseOnHidingPlayer) private var pauseOnHidingPlayer + @Default(.closePiPOnNavigation) private var closePiPOnNavigation + @Default(.closePiPOnOpeningPlayer) private var closePiPOnOpeningPlayer + #if !os(macOS) + @Default(.closePiPAndOpenPlayerOnEnteringForeground) private var closePiPAndOpenPlayerOnEnteringForeground + #endif #if os(iOS) private var idiom: UIUserInterfaceIdiom { @@ -29,6 +35,13 @@ struct PlaybackSettings: View { keywordsToggle channelSubscribersToggle + pauseOnHidingPlayerToggle + } + + Section(header: SettingsHeader(text: "Picture in Picture")) { + closePiPOnNavigationToggle + closePiPOnOpeningPlayerToggle + closePiPAndOpenPlayerOnEnteringForegroundToggle } #else Section(header: SettingsHeader(text: "Source")) { @@ -47,6 +60,15 @@ struct PlaybackSettings: View { keywordsToggle channelSubscribersToggle + pauseOnHidingPlayerToggle + + Section(header: SettingsHeader(text: "Picture in Picture")) { + closePiPOnNavigationToggle + closePiPOnOpeningPlayerToggle + #if !os(macOS) + closePiPAndOpenPlayerOnEnteringForegroundToggle + #endif + } #endif } @@ -114,6 +136,24 @@ struct PlaybackSettings: View { private var channelSubscribersToggle: some View { Toggle("Show channel subscribers count", isOn: $channelSubscribers) } + + private var pauseOnHidingPlayerToggle: some View { + Toggle("Pause when player is closed", isOn: $pauseOnHidingPlayer) + } + + private var closePiPOnNavigationToggle: some View { + Toggle("Close PiP when starting playing other video", isOn: $closePiPOnNavigation) + } + + private var closePiPOnOpeningPlayerToggle: some View { + Toggle("Close PiP when player is opened", isOn: $closePiPOnOpeningPlayer) + } + + #if !os(macOS) + private var closePiPAndOpenPlayerOnEnteringForegroundToggle: some View { + Toggle("Close PiP and open player when application enters foreground", isOn: $closePiPAndOpenPlayerOnEnteringForeground) + } + #endif } struct PlaybackSettings_Previews: PreviewProvider { diff --git a/Shared/Videos/VideoCell.swift b/Shared/Videos/VideoCell.swift index a6a95faf..8226b9fa 100644 --- a/Shared/Videos/VideoCell.swift +++ b/Shared/Videos/VideoCell.swift @@ -31,7 +31,7 @@ struct VideoCell: View { if inNavigationView { player.playerNavigationLinkActive = true } else { - player.presentPlayer() + player.show() } }) { content diff --git a/Shared/Views/PlayerControlsView.swift b/Shared/Views/PlayerControlsView.swift index ae9230c4..0fe5c53b 100644 --- a/Shared/Views/PlayerControlsView.swift +++ b/Shared/Views/PlayerControlsView.swift @@ -27,7 +27,7 @@ struct PlayerControlsView: View { private var controls: some View { let controls = HStack { Button(action: { - model.presentingPlayer.toggle() + model.togglePlayer() }) { HStack { VStack(alignment: .leading, spacing: 3) { @@ -96,7 +96,7 @@ struct PlayerControlsView: View { .borderBottom(height: navigationStyle == .sidebar ? 0 : 0.4, color: Color("ControlsBorderColor")) #if !os(tvOS) .onSwipeGesture(up: { - model.presentingPlayer = true + model.show() }) #endif diff --git a/Shared/Views/VideoContextMenuView.swift b/Shared/Views/VideoContextMenuView.swift index 67566c5b..3bc4b19c 100644 --- a/Shared/Views/VideoContextMenuView.swift +++ b/Shared/Views/VideoContextMenuView.swift @@ -64,7 +64,7 @@ struct VideoContextMenuView: View { if inNavigationView { playerNavigationLinkActive = true } else { - player.presentPlayer() + player.show() } } label: { Label("Play Now", systemImage: "play") diff --git a/Shared/YatteeApp.swift b/Shared/YatteeApp.swift index 9acc0291..9517b0ac 100644 --- a/Shared/YatteeApp.swift +++ b/Shared/YatteeApp.swift @@ -8,15 +8,45 @@ struct YatteeApp: App { @StateObject private var updater = UpdaterModel() #endif + @StateObject private var accounts = AccountsModel() + @StateObject private var comments = CommentsModel() + @StateObject private var instances = InstancesModel() @StateObject private var menu = MenuModel() + @StateObject private var navigation = NavigationModel() + @StateObject private var player = PlayerModel() + @StateObject private var playlists = PlaylistsModel() + @StateObject private var recents = RecentsModel() + @StateObject private var search = SearchModel() + @StateObject private var subscriptions = SubscriptionsModel() + @StateObject private var thumbnails = ThumbnailsModel() var body: some Scene { WindowGroup { ContentView() + .environmentObject(accounts) + .environmentObject(comments) + .environmentObject(instances) + .environmentObject(navigation) + .environmentObject(player) + .environmentObject(playlists) + .environmentObject(recents) + .environmentObject(subscriptions) + .environmentObject(thumbnails) .environmentObject(menu) + .environmentObject(search) + #if !os(macOS) + .onReceive( + NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification) + ) { _ in + player.handleEnterForeground() + } + #endif + #if !os(tvOS) + .handlesExternalEvents(preferring: Set(["watch"]), allowing: Set(["watch"])) + #endif } #if !os(tvOS) - .handlesExternalEvents(matching: Set(["*"])) + .handlesExternalEvents(matching: Set(arrayLiteral: "watch")) .commands { SidebarCommands() @@ -34,6 +64,24 @@ struct YatteeApp: App { #endif #if os(macOS) + WindowGroup(player.windowTitle) { + VideoPlayerView() + .onAppear { player.presentingPlayer = true } + .onDisappear { player.presentingPlayer = false } + .environment(\.navigationStyle, .sidebar) + .environmentObject(accounts) + .environmentObject(comments) + .environmentObject(instances) + .environmentObject(navigation) + .environmentObject(player) + .environmentObject(playlists) + .environmentObject(recents) + .environmentObject(subscriptions) + .environmentObject(thumbnails) + .handlesExternalEvents(preferring: Set(["player"]), allowing: Set(["player"])) + } + .handlesExternalEvents(matching: Set(["player"])) + Settings { SettingsView() .environmentObject(AccountsModel()) diff --git a/Yattee.xcodeproj/project.pbxproj b/Yattee.xcodeproj/project.pbxproj index a9982ddd..d6e5c22d 100644 --- a/Yattee.xcodeproj/project.pbxproj +++ b/Yattee.xcodeproj/project.pbxproj @@ -223,6 +223,7 @@ 37732FF22703A26300F04329 /* AccountValidationStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37732FEF2703A26300F04329 /* AccountValidationStatus.swift */; }; 37732FF42703D32400F04329 /* Sidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37732FF32703D32400F04329 /* Sidebar.swift */; }; 37732FF52703D32400F04329 /* Sidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37732FF32703D32400F04329 /* Sidebar.swift */; }; + 37737786276F9858000521C1 /* OpenWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37737785276F9858000521C1 /* OpenWindow.swift */; }; 3774122A27387B6C00423605 /* InstancesModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3774122927387B6C00423605 /* InstancesModelTests.swift */; }; 3774122F27387C7600423605 /* VideosApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376A33DF2720CAD6000C1D6B /* VideosApp.swift */; }; 3774123327387CB000423605 /* Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 372915E52687E3B900F5A35B /* Defaults.swift */; }; @@ -642,6 +643,7 @@ 376CD21526FBE18D001E1AC1 /* Instance+Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Instance+Fixtures.swift"; sourceTree = ""; }; 37732FEF2703A26300F04329 /* AccountValidationStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountValidationStatus.swift; sourceTree = ""; }; 37732FF32703D32400F04329 /* Sidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar.swift; sourceTree = ""; }; + 37737785276F9858000521C1 /* OpenWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenWindow.swift; sourceTree = ""; }; 3774122927387B6C00423605 /* InstancesModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstancesModelTests.swift; sourceTree = ""; }; 377A20A82693C9A2002842B8 /* TypedContentAccessors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypedContentAccessors.swift; sourceTree = ""; }; 3782B94E27553A6700990149 /* SearchSuggestions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSuggestions.swift; sourceTree = ""; }; @@ -1112,6 +1114,7 @@ 37BE7AF227601DBF00DBECED /* Updates */, 374C0542272496E4009BDDBE /* AppDelegate.swift */, 37FD43DB270470B70073EE42 /* InstancesSettings.swift */, + 37737785276F9858000521C1 /* OpenWindow.swift */, 374108D0272B11B2006C5CC8 /* PictureInPictureDelegate.swift */, 37BE0BDB26A2367F0092E2DB /* Player.swift */, 37BE0BD926A214630092E2DB /* PlayerViewController.swift */, @@ -1924,6 +1927,7 @@ 374710062755291C00CE0F87 /* SearchField.swift in Sources */, 378AE93F274EDFB5006A4EE1 /* Tint+Backport.swift in Sources */, 37C194C826F6A9C8005D3B96 /* RecentsModel.swift in Sources */, + 37737786276F9858000521C1 /* OpenWindow.swift in Sources */, 37BE0BDC26A2367F0092E2DB /* Player.swift in Sources */, 3743CA53270F284F00E4D32B /* View+Borders.swift in Sources */, 37599F39272B4D740087F250 /* FavoriteButton.swift in Sources */, @@ -2598,9 +2602,9 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iOS/Info.plist; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIRequiresFullScreen = NO; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 14.0; diff --git a/iOS/Info.plist b/iOS/Info.plist index 4526445b..0f3fb55d 100644 --- a/iOS/Info.plist +++ b/iOS/Info.plist @@ -15,6 +15,11 @@ + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UIBackgroundModes audio diff --git a/macOS/InstancesSettings.swift b/macOS/InstancesSettings.swift index 86596919..0e505a6e 100644 --- a/macOS/InstancesSettings.swift +++ b/macOS/InstancesSettings.swift @@ -94,7 +94,7 @@ struct InstancesSettings: View { } .labelsHidden() - Text("Used to create links from videos, channels and playlist") + Text("Used to create links from videos, channels and playlists") .font(.caption) .foregroundColor(.secondary) } diff --git a/macOS/OpenWindow.swift b/macOS/OpenWindow.swift new file mode 100644 index 00000000..a1066f9a --- /dev/null +++ b/macOS/OpenWindow.swift @@ -0,0 +1,40 @@ +import AppKit +import Foundation + +enum OpenWindow: String, CaseIterable { + case player, main + + var window: NSWindow? { + // this is not solid but works as long as there is only two windows in the app + // needs to be changed in case we ever have more windows to handle + + switch self { + case .player: + return NSApplication.shared.windows.last + case .main: + return NSApplication.shared.windows.first + } + } + + func focus() { + window?.makeKeyAndOrderFront(self) + } + + var location: String { + switch self { + case .player: + return rawValue + case .main: + return "" + } + } + + func open() { + switch self { + case .player: + NSWorkspace.shared.open(URL(string: "yattee://player")!) + case .main: + Self.main.focus() + } + } +} diff --git a/macOS/PictureInPictureDelegate.swift b/macOS/PictureInPictureDelegate.swift index 686ae348..6434eeac 100644 --- a/macOS/PictureInPictureDelegate.swift +++ b/macOS/PictureInPictureDelegate.swift @@ -11,14 +11,14 @@ final class PictureInPictureDelegate: NSObject, AVPlayerViewPictureInPictureDele func playerViewWillStartPicture(inPicture _: AVPlayerView) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in self?.playerModel.playingInPictureInPicture = true - self?.playerModel.presentingPlayer = false + self?.playerModel.hide() } } func playerViewWillStopPicture(inPicture _: AVPlayerView) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in self?.playerModel.playingInPictureInPicture = false - self?.playerModel.presentPlayer() + self?.playerModel.show() } } @@ -27,7 +27,7 @@ final class PictureInPictureDelegate: NSObject, AVPlayerViewPictureInPictureDele restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: (Bool) -> Void ) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in - self?.playerModel.presentingPlayer = true + self?.playerModel.show() } completionHandler(true) } diff --git a/macOS/Player.swift b/macOS/Player.swift index eb17d742..63c81b6a 100644 --- a/macOS/Player.swift +++ b/macOS/Player.swift @@ -4,7 +4,7 @@ import SwiftUI struct Player: NSViewControllerRepresentable { @EnvironmentObject private var player - var controller: PlayerViewController? + @State private var controller: PlayerViewController? init(controller: PlayerViewController? = nil) { self.controller = controller diff --git a/tvOS/NowPlayingView.swift b/tvOS/NowPlayingView.swift index 26d0da2c..6e90a6dc 100644 --- a/tvOS/NowPlayingView.swift +++ b/tvOS/NowPlayingView.swift @@ -33,7 +33,7 @@ struct NowPlayingView: View { if sections.contains(.nowPlaying), let item = player.currentItem { Section(header: Text("Now Playing")) { Button { - player.presentPlayer() + player.show() } label: { VideoBanner(video: item.video) } @@ -59,7 +59,7 @@ struct NowPlayingView: View { ForEach(player.queue) { item in Button { player.advanceToItem(item) - player.presentPlayer() + player.show() } label: { VideoBanner(video: item.video) } @@ -77,7 +77,7 @@ struct NowPlayingView: View { ForEach(player.currentVideo!.related) { video in Button { player.playNow(video) - player.presentPlayer() + player.show() } label: { VideoBanner(video: video) } @@ -99,7 +99,7 @@ struct NowPlayingView: View { ForEach(player.history) { item in Button { player.playHistory(item) - player.presentPlayer() + player.show() } label: { VideoBanner(video: item.video, playbackTime: item.playbackTime, videoDuration: item.videoDuration) }