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 navigationStyle: NavigationStyle
) { ) {
let recent = RecentItem(from: channel) let recent = RecentItem(from: channel)
player.presentingPlayer = false #if os(macOS)
OpenWindow.main.open()
#else
player.hide()
#endif
let openRecent = { let openRecent = {
recents.add(recent) recents.add(recent)

View File

@ -15,18 +15,15 @@ final class PlayerModel: ObservableObject {
let logger = Logger(label: "stream.yattee.app") let logger = Logger(label: "stream.yattee.app")
private(set) var player = AVPlayer() private(set) var player = AVPlayer()
private(set) var playerView = Player() var playerView = Player()
var controller: PlayerViewController? { didSet { playerView.controller = controller } } var controller: PlayerViewController?
#if os(tvOS)
var avPlayerViewController: AVPlayerViewController?
#endif
@Published var presentingPlayer = false { didSet { pauseOnPlayerDismiss() } } @Published var presentingPlayer = false { didSet { handlePresentationChange() } }
@Published var stream: Stream? @Published var stream: Stream?
@Published var currentRate: Float = 1.0 { didSet { player.rate = currentRate } } @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 streamSelection: Stream? { didSet { rebuildTVMenu() } }
@Published var queue = [PlayerQueueItem]() { didSet { Defaults[.queue] = queue } } @Published var queue = [PlayerQueueItem]() { didSet { Defaults[.queue] = queue } }
@ -35,7 +32,7 @@ final class PlayerModel: ObservableObject {
@Published var preservedTime: CMTime? @Published var preservedTime: CMTime?
@Published var playerNavigationLinkActive = false { didSet { pauseOnChannelPlayerDismiss() } } @Published var playerNavigationLinkActive = false { didSet { handleNavigationViewPlayerPresentationChange() } }
@Published var sponsorBlock = SponsorBlockAPI() @Published var sponsorBlock = SponsorBlockAPI()
@Published var segmentRestorationTime: CMTime? @Published var segmentRestorationTime: CMTime?
@ -70,6 +67,14 @@ final class PlayerModel: ObservableObject {
#endif #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) { init(accounts: AccountsModel? = nil, comments: CommentsModel? = nil) {
self.accounts = accounts ?? AccountsModel() self.accounts = accounts ?? AccountsModel()
self.comments = comments ?? CommentsModel() self.comments = comments ?? CommentsModel()
@ -80,12 +85,41 @@ final class PlayerModel: ObservableObject {
addPlayerTimeControlStatusObserver() 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 presentingPlayer = true
} }
func hide() {
guard presentingPlayer else {
return
}
presentingPlayer = false
}
func togglePlayer() { 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 { var isPlaying: Bool {
@ -189,12 +223,29 @@ final class PlayerModel: ObservableObject {
preservingTime: !currentItem.playbackTime.isNil 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() { private func handleNavigationViewPlayerPresentationChange() {
if !playingInPictureInPicture, !playerNavigationLinkActive { if pauseOnHidingPlayer, !playingInPictureInPicture, !playerNavigationLinkActive {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.pause() self.pause()
} }
@ -371,6 +422,10 @@ final class PlayerModel: ObservableObject {
item.preferredForwardBufferDuration = 5 item.preferredForwardBufferDuration = 5
observePlayerItemStatus(item)
}
private func observePlayerItemStatus(_ item: AVPlayerItem) {
statusObservation?.invalidate() statusObservation?.invalidate()
statusObservation = item.observe(\.status, options: [.old, .new]) { [weak self] playerItem, _ in statusObservation = item.observe(\.status, options: [.old, .new]) { [weak self] playerItem, _ in
guard let self = self else { guard let self = self else {
@ -422,11 +477,9 @@ final class PlayerModel: ObservableObject {
addCurrentItemToHistory() addCurrentItemToHistory()
resetQueue() resetQueue()
#if os(tvOS) #if os(tvOS)
avPlayerViewController!.dismiss(animated: true) { [weak self] in controller?.dismiss(animated: true)
self?.controller!.dismiss(animated: true)
}
#endif #endif
presentingPlayer = false hide()
} else { } else {
advanceToNextItem() advanceToNextItem()
} }
@ -621,4 +674,70 @@ final class PlayerModel: ObservableObject {
currentItem = nil currentItem = nil
player.replaceCurrentItem(with: 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 Defaults
import Foundation import Foundation
import Siesta import Siesta
@ -29,7 +29,10 @@ extension PlayerModel {
} }
func playNow(_ video: Video, at time: TimeInterval? = nil) { func playNow(_ video: Video, at time: TimeInterval? = nil) {
player.replaceCurrentItem(with: nil) if !playingInPictureInPicture || closePiPOnNavigation {
closePiP()
}
addCurrentItemToHistory() addCurrentItemToHistory()
enqueueVideo(video, prepending: true) { _, item in enqueueVideo(video, prepending: true) { _, item in
@ -38,7 +41,12 @@ extension PlayerModel {
} }
func playItem(_ item: PlayerQueueItem, video: Video? = nil, at time: TimeInterval? = nil) { func playItem(_ item: PlayerQueueItem, video: Video? = nil, at time: TimeInterval? = nil) {
if !playingInPictureInPicture {
player.replaceCurrentItem(with: nil)
}
comments.reset() comments.reset()
stream = nil
currentItem = item currentItem = item
if !time.isNil { if !time.isNil {
@ -83,7 +91,6 @@ extension PlayerModel {
} }
func advanceToItem(_ newItem: PlayerQueueItem, at time: TimeInterval? = nil) { func advanceToItem(_ newItem: PlayerQueueItem, at time: TimeInterval? = nil) {
player.replaceCurrentItem(with: nil)
addCurrentItemToHistory() addCurrentItemToHistory()
remove(newItem) remove(newItem)
@ -116,7 +123,7 @@ extension PlayerModel {
} }
func isAutoplaying(_ item: AVPlayerItem) -> Bool { func isAutoplaying(_ item: AVPlayerItem) -> Bool {
player.currentItem == item && (presentingPlayer || playerNavigationLinkActive || playingInPictureInPicture) player.currentItem == item
} }
@discardableResult func enqueueVideo( @discardableResult func enqueueVideo(

View File

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

View File

@ -38,6 +38,13 @@ extension Defaults.Keys {
#if !os(tvOS) #if !os(tvOS)
static let commentsPlacement = Key<CommentsPlacement>("commentsPlacement", default: .separate) static let commentsPlacement = Key<CommentsPlacement>("commentsPlacement", default: .separate)
#endif #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: []) static let recentlyOpened = Key<[RecentItem]>("recentlyOpened", default: [])

View File

@ -62,10 +62,18 @@ struct MenuCommands: Commands {
.disabled(model.player?.queue.isEmpty ?? true) .disabled(model.player?.queue.isEmpty ?? true)
.keyboardShortcut("s") .keyboardShortcut("s")
Button((model.player?.presentingPlayer ?? true) ? "Hide Player" : "Show Player") { Button(togglePlayerLabel) {
model.player?.togglePlayer() model.player?.togglePlayer()
} }
.keyboardShortcut("o") .keyboardShortcut("o")
} }
} }
private var togglePlayerLabel: String {
#if os(macOS)
"Show Player"
#else
(model.player?.presentingPlayer ?? true) ? "Hide Player" : "Show Player"
#endif
}
} }

View File

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

View File

@ -7,16 +7,16 @@ import Siesta
import SwiftUI import SwiftUI
struct ContentView: View { struct ContentView: View {
@StateObject private var accounts = AccountsModel() @EnvironmentObject<AccountsModel> private var accounts
@StateObject private var comments = CommentsModel() @EnvironmentObject<CommentsModel> private var comments
@StateObject private var instances = InstancesModel() @EnvironmentObject<InstancesModel> private var instances
@StateObject private var navigation = NavigationModel() @EnvironmentObject<NavigationModel> private var navigation
@StateObject private var player = PlayerModel() @EnvironmentObject<PlayerModel> private var player
@StateObject private var playlists = PlaylistsModel() @EnvironmentObject<PlaylistsModel> private var playlists
@StateObject private var recents = RecentsModel() @EnvironmentObject<RecentsModel> private var recents
@StateObject private var search = SearchModel() @EnvironmentObject<SearchModel> private var search
@StateObject private var subscriptions = SubscriptionsModel() @EnvironmentObject<SubscriptionsModel> private var subscriptions
@StateObject private var thumbnailsModel = ThumbnailsModel() @EnvironmentObject<ThumbnailsModel> private var thumbnailsModel
@EnvironmentObject<MenuModel> private var menu @EnvironmentObject<MenuModel> private var menu
@ -61,7 +61,6 @@ struct ContentView: View {
} }
) )
#if !os(tvOS) #if !os(tvOS)
.handlesExternalEvents(preferring: Set(["*"]), allowing: Set(["*"]))
.onOpenURL(perform: handleOpenedURL) .onOpenURL(perform: handleOpenedURL)
.background( .background(
EmptyView().sheet(isPresented: $navigation.presentingAddToPlaylist) { EmptyView().sheet(isPresented: $navigation.presentingAddToPlaylist) {
@ -162,7 +161,7 @@ struct ContentView: View {
if let video: Video = response.typedContent() { if let video: Video = response.typedContent() {
player.addCurrentItemToHistory() player.addCurrentItemToHistory()
self.player.playNow(video, at: parser.time) 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 { var body: some View {
HStack { HStack {
closeButton #if !os(macOS)
closeButton
#endif
if player.currentItem != nil { if player.currentItem != nil {
HStack { HStack {
@ -20,6 +22,9 @@ struct PlaybackBar: View {
rateMenu rateMenu
} }
.font(.caption2) .font(.caption2)
#if os(macOS)
.padding(.leading, 4)
#endif
Spacer() Spacer()
@ -68,7 +73,7 @@ struct PlaybackBar: View {
message: Text(player.playerError?.localizedDescription ?? "") message: Text(player.playerError?.localizedDescription ?? "")
) )
} }
.frame(minWidth: 0, maxWidth: .infinity) .frame(minWidth: 0, maxWidth: .infinity, minHeight: 20)
.padding(4) .padding(4)
.background(colorScheme == .dark ? Color.black : Color.white) .background(colorScheme == .dark ? Color.black : Color.white)
} }

View File

@ -1,3 +1,4 @@
import Defaults
import Foundation import Foundation
import SwiftUI import SwiftUI
@ -8,6 +9,8 @@ struct PlayerQueueRow: View {
@EnvironmentObject<PlayerModel> private var player @EnvironmentObject<PlayerModel> private var player
@Default(.closePiPOnNavigation) var closePiPOnNavigation
var body: some View { var body: some View {
Group { Group {
Button { Button {
@ -24,6 +27,10 @@ struct PlayerQueueRow: View {
fullScreen = false fullScreen = false
} }
} }
if closePiPOnNavigation, player.playingInPictureInPicture {
player.closePiP()
}
} label: { } label: {
VideoBanner(video: item.video, playbackTime: item.playbackTime, videoDuration: item.videoDuration) VideoBanner(video: item.video, playbackTime: item.playbackTime, videoDuration: item.videoDuration)
} }

View File

@ -1,4 +1,5 @@
import AVKit import AVKit
import Defaults
import SwiftUI import SwiftUI
final class PlayerViewController: UIViewController { final class PlayerViewController: UIViewController {
@ -7,11 +8,11 @@ final class PlayerViewController: UIViewController {
var navigationModel: NavigationModel! var navigationModel: NavigationModel!
var playerModel: PlayerModel! var playerModel: PlayerModel!
var subscriptionsModel: SubscriptionsModel! var subscriptionsModel: SubscriptionsModel!
var playerViewController = AVPlayerViewController() var playerView = AVPlayerViewController()
#if !os(tvOS) #if !os(tvOS)
var aspectRatio: Double? { 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 { guard ratio.isFinite else {
return VideoPlayerView.defaultAspectRatio // swiftlint:disable:this implicit_return return VideoPlayerView.defaultAspectRatio // swiftlint:disable:this implicit_return
@ -27,24 +28,35 @@ final class PlayerViewController: UIViewController {
loadPlayer() loadPlayer()
#if os(tvOS) #if os(tvOS)
if !playerViewController.isBeingPresented, !playerViewController.isBeingDismissed { if !playerView.isBeingPresented, !playerView.isBeingDismissed {
present(playerViewController, animated: false) present(playerView, animated: false)
} }
#endif #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() { func loadPlayer() {
guard !playerLoaded else { guard !playerLoaded else {
return return
} }
playerModel.controller = self playerModel.controller = self
playerViewController.player = playerModel.player playerView.player = playerModel.player
playerViewController.allowsPictureInPicturePlayback = true playerView.allowsPictureInPicturePlayback = true
playerViewController.delegate = self playerView.delegate = self
#if os(tvOS) #if os(tvOS)
playerModel.avPlayerViewController = playerViewController
var infoViewControllers = [UIHostingController<AnyView>]() var infoViewControllers = [UIHostingController<AnyView>]()
if CommentsModel.enabled { if CommentsModel.enabled {
infoViewControllers.append(infoViewController([.comments], title: "Comments")) infoViewControllers.append(infoViewController([.comments], title: "Comments"))
@ -54,7 +66,7 @@ final class PlayerViewController: UIViewController {
infoViewController([.playingNext, .playedPreviously], title: "Playing Next") infoViewController([.playingNext, .playedPreviously], title: "Playing Next")
]) ])
playerViewController.customInfoViewControllers = infoViewControllers playerView.customInfoViewControllers = infoViewControllers
#else #else
embedViewController() embedViewController()
#endif #endif
@ -81,12 +93,12 @@ final class PlayerViewController: UIViewController {
} }
#else #else
func embedViewController() { func embedViewController() {
playerViewController.view.frame = view.bounds playerView.view.frame = view.bounds
addChild(playerViewController) addChild(playerView)
view.addSubview(playerViewController.view) view.addSubview(playerView.view)
playerViewController.didMove(toParent: self) playerView.didMove(toParent: self)
} }
#endif #endif
} }
@ -127,19 +139,19 @@ extension PlayerViewController: AVPlayerViewControllerDelegate {
} }
func playerViewController( func playerViewController(
_ playerViewController: AVPlayerViewController, _: AVPlayerViewController,
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void
) { ) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
if self.navigationModel.presentingChannel { if self.navigationModel.presentingChannel {
self.playerModel.playerNavigationLinkActive = true self.playerModel.playerNavigationLinkActive = true
} else { } else {
self.playerModel.presentPlayer() self.playerModel.show()
} }
#if os(tvOS) #if os(tvOS)
if self.playerModel.playingInPictureInPicture { if self.playerModel.playingInPictureInPicture {
self.present(playerViewController, animated: false) { self.present(self.playerView, animated: false) {
completionHandler(true) completionHandler(true)
} }
} }

View File

@ -31,7 +31,8 @@ struct VideoPlayerView: View {
HSplitView { HSplitView {
content content
} }
.frame(idealWidth: 1000, maxWidth: 1100, minHeight: 700) .onOpenURL(perform: handleOpenedURL)
.frame(minWidth: 950, minHeight: 700)
#else #else
GeometryReader { geometry in GeometryReader { geometry in
HStack(spacing: 0) { HStack(spacing: 0) {
@ -66,15 +67,16 @@ struct VideoPlayerView: View {
if player.currentItem.isNil { if player.currentItem.isNil {
playerPlaceholder(geometry: geometry) playerPlaceholder(geometry: geometry)
} else if player.playingInPictureInPicture {
pictureInPicturePlaceholder(geometry: geometry)
} else { } else {
#if os(macOS) player.playerView
Player() .modifier(
.modifier(VideoPlayerSizeModifier(geometry: geometry, aspectRatio: player.controller?.aspectRatio)) VideoPlayerSizeModifier(
geometry: geometry,
#else aspectRatio: player.controller?.aspectRatio
player.playerView )
.modifier(VideoPlayerSizeModifier(geometry: geometry, aspectRatio: player.controller?.aspectRatio)) )
#endif
} }
} }
#if os(iOS) #if os(iOS)
@ -143,6 +145,35 @@ struct VideoPlayerView: View {
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: geometry.size.width / VideoPlayerView.defaultAspectRatio) .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 { var sidebarQueue: Bool {
switch Defaults[.playerSidebar] { switch Defaults[.playerSidebar] {
case .never: case .never:
@ -160,6 +191,27 @@ struct VideoPlayerView: View {
set: { _ in } 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 { struct VideoPlayerView_Previews: PreviewProvider {

View File

@ -144,7 +144,7 @@ struct PlaylistsView: View {
Button { Button {
player.playAll(items.compactMap(\.video)) player.playAll(items.compactMap(\.video))
player.presentPlayer() player.show()
} label: { } label: {
HStack(spacing: 15) { HStack(spacing: 15) {
Image(systemName: "play.fill") Image(systemName: "play.fill")

View File

@ -9,6 +9,12 @@ struct PlaybackSettings: View {
@Default(.showKeywords) private var showKeywords @Default(.showKeywords) private var showKeywords
@Default(.showChannelSubscribers) private var channelSubscribers @Default(.showChannelSubscribers) private var channelSubscribers
@Default(.saveHistory) private var saveHistory @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) #if os(iOS)
private var idiom: UIUserInterfaceIdiom { private var idiom: UIUserInterfaceIdiom {
@ -29,6 +35,13 @@ struct PlaybackSettings: View {
keywordsToggle keywordsToggle
channelSubscribersToggle channelSubscribersToggle
pauseOnHidingPlayerToggle
}
Section(header: SettingsHeader(text: "Picture in Picture")) {
closePiPOnNavigationToggle
closePiPOnOpeningPlayerToggle
closePiPAndOpenPlayerOnEnteringForegroundToggle
} }
#else #else
Section(header: SettingsHeader(text: "Source")) { Section(header: SettingsHeader(text: "Source")) {
@ -47,6 +60,15 @@ struct PlaybackSettings: View {
keywordsToggle keywordsToggle
channelSubscribersToggle channelSubscribersToggle
pauseOnHidingPlayerToggle
Section(header: SettingsHeader(text: "Picture in Picture")) {
closePiPOnNavigationToggle
closePiPOnOpeningPlayerToggle
#if !os(macOS)
closePiPAndOpenPlayerOnEnteringForegroundToggle
#endif
}
#endif #endif
} }
@ -114,6 +136,24 @@ struct PlaybackSettings: View {
private var channelSubscribersToggle: some View { private var channelSubscribersToggle: some View {
Toggle("Show channel subscribers count", isOn: $channelSubscribers) 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 { struct PlaybackSettings_Previews: PreviewProvider {

View File

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

View File

@ -27,7 +27,7 @@ struct PlayerControlsView<Content: View>: View {
private var controls: some View { private var controls: some View {
let controls = HStack { let controls = HStack {
Button(action: { Button(action: {
model.presentingPlayer.toggle() model.togglePlayer()
}) { }) {
HStack { HStack {
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: 3) {
@ -96,7 +96,7 @@ struct PlayerControlsView<Content: View>: View {
.borderBottom(height: navigationStyle == .sidebar ? 0 : 0.4, color: Color("ControlsBorderColor")) .borderBottom(height: navigationStyle == .sidebar ? 0 : 0.4, color: Color("ControlsBorderColor"))
#if !os(tvOS) #if !os(tvOS)
.onSwipeGesture(up: { .onSwipeGesture(up: {
model.presentingPlayer = true model.show()
}) })
#endif #endif

View File

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

View File

@ -8,15 +8,45 @@ struct YatteeApp: App {
@StateObject private var updater = UpdaterModel() @StateObject private var updater = UpdaterModel()
#endif #endif
@StateObject private var accounts = AccountsModel()
@StateObject private var comments = CommentsModel()
@StateObject private var instances = InstancesModel()
@StateObject private var menu = MenuModel() @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 { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() ContentView()
.environmentObject(accounts)
.environmentObject(comments)
.environmentObject(instances)
.environmentObject(navigation)
.environmentObject(player)
.environmentObject(playlists)
.environmentObject(recents)
.environmentObject(subscriptions)
.environmentObject(thumbnails)
.environmentObject(menu) .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) #if !os(tvOS)
.handlesExternalEvents(matching: Set(["*"])) .handlesExternalEvents(matching: Set(arrayLiteral: "watch"))
.commands { .commands {
SidebarCommands() SidebarCommands()
@ -34,6 +64,24 @@ struct YatteeApp: App {
#endif #endif
#if os(macOS) #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 { Settings {
SettingsView() SettingsView()
.environmentObject(AccountsModel()) .environmentObject(AccountsModel())

View File

@ -223,6 +223,7 @@
37732FF22703A26300F04329 /* AccountValidationStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37732FEF2703A26300F04329 /* AccountValidationStatus.swift */; }; 37732FF22703A26300F04329 /* AccountValidationStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37732FEF2703A26300F04329 /* AccountValidationStatus.swift */; };
37732FF42703D32400F04329 /* Sidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37732FF32703D32400F04329 /* Sidebar.swift */; }; 37732FF42703D32400F04329 /* Sidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37732FF32703D32400F04329 /* Sidebar.swift */; };
37732FF52703D32400F04329 /* 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 */; }; 3774122A27387B6C00423605 /* InstancesModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3774122927387B6C00423605 /* InstancesModelTests.swift */; };
3774122F27387C7600423605 /* VideosApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376A33DF2720CAD6000C1D6B /* VideosApp.swift */; }; 3774122F27387C7600423605 /* VideosApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376A33DF2720CAD6000C1D6B /* VideosApp.swift */; };
3774123327387CB000423605 /* Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 372915E52687E3B900F5A35B /* Defaults.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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 3782B94E27553A6700990149 /* SearchSuggestions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSuggestions.swift; sourceTree = "<group>"; };
@ -1112,6 +1114,7 @@
37BE7AF227601DBF00DBECED /* Updates */, 37BE7AF227601DBF00DBECED /* Updates */,
374C0542272496E4009BDDBE /* AppDelegate.swift */, 374C0542272496E4009BDDBE /* AppDelegate.swift */,
37FD43DB270470B70073EE42 /* InstancesSettings.swift */, 37FD43DB270470B70073EE42 /* InstancesSettings.swift */,
37737785276F9858000521C1 /* OpenWindow.swift */,
374108D0272B11B2006C5CC8 /* PictureInPictureDelegate.swift */, 374108D0272B11B2006C5CC8 /* PictureInPictureDelegate.swift */,
37BE0BDB26A2367F0092E2DB /* Player.swift */, 37BE0BDB26A2367F0092E2DB /* Player.swift */,
37BE0BD926A214630092E2DB /* PlayerViewController.swift */, 37BE0BD926A214630092E2DB /* PlayerViewController.swift */,
@ -1924,6 +1927,7 @@
374710062755291C00CE0F87 /* SearchField.swift in Sources */, 374710062755291C00CE0F87 /* SearchField.swift in Sources */,
378AE93F274EDFB5006A4EE1 /* Tint+Backport.swift in Sources */, 378AE93F274EDFB5006A4EE1 /* Tint+Backport.swift in Sources */,
37C194C826F6A9C8005D3B96 /* RecentsModel.swift in Sources */, 37C194C826F6A9C8005D3B96 /* RecentsModel.swift in Sources */,
37737786276F9858000521C1 /* OpenWindow.swift in Sources */,
37BE0BDC26A2367F0092E2DB /* Player.swift in Sources */, 37BE0BDC26A2367F0092E2DB /* Player.swift in Sources */,
3743CA53270F284F00E4D32B /* View+Borders.swift in Sources */, 3743CA53270F284F00E4D32B /* View+Borders.swift in Sources */,
37599F39272B4D740087F250 /* FavoriteButton.swift in Sources */, 37599F39272B4D740087F250 /* FavoriteButton.swift in Sources */,
@ -2598,9 +2602,9 @@
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = iOS/Info.plist; INFOPLIST_FILE = iOS/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UIRequiresFullScreen = NO;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 14.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;

View File

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

View File

@ -94,7 +94,7 @@ struct InstancesSettings: View {
} }
.labelsHidden() .labelsHidden()
Text("Used to create links from videos, channels and playlist") Text("Used to create links from videos, channels and playlists")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .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) { func playerViewWillStartPicture(inPicture _: AVPlayerView) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
self?.playerModel.playingInPictureInPicture = true self?.playerModel.playingInPictureInPicture = true
self?.playerModel.presentingPlayer = false self?.playerModel.hide()
} }
} }
func playerViewWillStopPicture(inPicture _: AVPlayerView) { func playerViewWillStopPicture(inPicture _: AVPlayerView) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
self?.playerModel.playingInPictureInPicture = false self?.playerModel.playingInPictureInPicture = false
self?.playerModel.presentPlayer() self?.playerModel.show()
} }
} }
@ -27,7 +27,7 @@ final class PictureInPictureDelegate: NSObject, AVPlayerViewPictureInPictureDele
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: (Bool) -> Void restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: (Bool) -> Void
) { ) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
self?.playerModel.presentingPlayer = true self?.playerModel.show()
} }
completionHandler(true) completionHandler(true)
} }

View File

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

View File

@ -33,7 +33,7 @@ struct NowPlayingView: View {
if sections.contains(.nowPlaying), let item = player.currentItem { if sections.contains(.nowPlaying), let item = player.currentItem {
Section(header: Text("Now Playing")) { Section(header: Text("Now Playing")) {
Button { Button {
player.presentPlayer() player.show()
} label: { } label: {
VideoBanner(video: item.video) VideoBanner(video: item.video)
} }
@ -59,7 +59,7 @@ struct NowPlayingView: View {
ForEach(player.queue) { item in ForEach(player.queue) { item in
Button { Button {
player.advanceToItem(item) player.advanceToItem(item)
player.presentPlayer() player.show()
} label: { } label: {
VideoBanner(video: item.video) VideoBanner(video: item.video)
} }
@ -77,7 +77,7 @@ struct NowPlayingView: View {
ForEach(player.currentVideo!.related) { video in ForEach(player.currentVideo!.related) { video in
Button { Button {
player.playNow(video) player.playNow(video)
player.presentPlayer() player.show()
} label: { } label: {
VideoBanner(video: video) VideoBanner(video: video)
} }
@ -99,7 +99,7 @@ struct NowPlayingView: View {
ForEach(player.history) { item in ForEach(player.history) { item in
Button { Button {
player.playHistory(item) player.playHistory(item)
player.presentPlayer() player.show()
} label: { } label: {
VideoBanner(video: item.video, playbackTime: item.playbackTime, videoDuration: item.videoDuration) VideoBanner(video: item.video, playbackTime: item.playbackTime, videoDuration: item.videoDuration)
} }