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
This commit is contained in:
Arkadiusz Fal 2021-12-19 18:17:04 +01:00
parent cef0b2594a
commit 61a4951831
25 changed files with 443 additions and 94 deletions

View File

@ -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)

View File

@ -15,13 +15,10 @@ 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 } }
@ -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
}

View File

@ -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(

View File

@ -66,7 +66,7 @@ extension PlayerModel {
func rebuildTVMenu() {
#if os(tvOS)
avPlayerViewController?.transportBarCustomMenuItems = [
controller?.playerView.transportBarCustomMenuItems = [
restoreLastSkippedSegmentAction,
rateMenu,
streamsMenu

View File

@ -38,6 +38,13 @@ extension Defaults.Keys {
#if !os(tvOS)
static let commentsPlacement = Key<CommentsPlacement>("commentsPlacement", default: .separate)
#endif
static let pauseOnHidingPlayer = Key<Bool>("pauseOnHidingPlayer", default: true)
static let closePiPOnNavigation = Key<Bool>("closePiPOnNavigation", default: false)
static let closePiPOnOpeningPlayer = Key<Bool>("closePiPOnOpeningPlayer", default: false)
#if !os(macOS)
static let closePiPAndOpenPlayerOnEnteringForeground = Key<Bool>("closePiPAndOpenPlayerOnEnteringForeground", default: false)
#endif
static let recentlyOpened = Key<[RecentItem]>("recentlyOpened", default: [])

View File

@ -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
}
}

View File

@ -62,6 +62,7 @@ struct AppSidebarNavigation: View {
}
}
}
.environment(\.navigationStyle, .sidebar)
#if os(iOS)
.background(
EmptyView().fullScreenCover(isPresented: $player.presentingPlayer) {
@ -69,16 +70,7 @@ struct AppSidebarNavigation: View {
.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)
}
private var videoPlayer: some View {

View File

@ -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<AccountsModel> private var accounts
@EnvironmentObject<CommentsModel> private var comments
@EnvironmentObject<InstancesModel> private var instances
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<PlaylistsModel> private var playlists
@EnvironmentObject<RecentsModel> private var recents
@EnvironmentObject<SearchModel> private var search
@EnvironmentObject<SubscriptionsModel> private var subscriptions
@EnvironmentObject<ThumbnailsModel> private var thumbnailsModel
@EnvironmentObject<MenuModel> 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()
}
}
}

View File

@ -11,7 +11,9 @@ struct PlaybackBar: View {
var body: some View {
HStack {
#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)
}

View File

@ -1,3 +1,4 @@
import Defaults
import Foundation
import SwiftUI
@ -8,6 +9,8 @@ struct PlayerQueueRow: View {
@EnvironmentObject<PlayerModel> 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)
}

View File

@ -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<AnyView>]()
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)
}
}

View File

@ -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
.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 {

View File

@ -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")

View File

@ -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 {

View File

@ -31,7 +31,7 @@ struct VideoCell: View {
if inNavigationView {
player.playerNavigationLinkActive = true
} else {
player.presentPlayer()
player.show()
}
}) {
content

View File

@ -27,7 +27,7 @@ struct PlayerControlsView<Content: View>: 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<Content: View>: View {
.borderBottom(height: navigationStyle == .sidebar ? 0 : 0.4, color: Color("ControlsBorderColor"))
#if !os(tvOS)
.onSwipeGesture(up: {
model.presentingPlayer = true
model.show()
})
#endif

View File

@ -64,7 +64,7 @@ struct VideoContextMenuView: View {
if inNavigationView {
playerNavigationLinkActive = true
} else {
player.presentPlayer()
player.show()
}
} label: {
Label("Play Now", systemImage: "play")

View File

@ -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())

View File

@ -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 = "<group>"; };
37732FEF2703A26300F04329 /* AccountValidationStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountValidationStatus.swift; sourceTree = "<group>"; };
37732FF32703D32400F04329 /* Sidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar.swift; sourceTree = "<group>"; };
37737785276F9858000521C1 /* OpenWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenWindow.swift; sourceTree = "<group>"; };
3774122927387B6C00423605 /* InstancesModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstancesModelTests.swift; sourceTree = "<group>"; };
377A20A82693C9A2002842B8 /* TypedContentAccessors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypedContentAccessors.swift; sourceTree = "<group>"; };
3782B94E27553A6700990149 /* SearchSuggestions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSuggestions.swift; sourceTree = "<group>"; };
@ -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;

View File

@ -15,6 +15,11 @@
</array>
</dict>
</array>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>

View File

@ -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)
}

40
macOS/OpenWindow.swift Normal file
View File

@ -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()
}
}
}

View File

@ -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)
}

View File

@ -4,7 +4,7 @@ import SwiftUI
struct Player: NSViewControllerRepresentable {
@EnvironmentObject<PlayerModel> private var player
var controller: PlayerViewController?
@State private var controller: PlayerViewController?
init(controller: PlayerViewController? = nil) {
self.controller = controller

View File

@ -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)
}