mirror of
https://github.com/yattee/yattee.git
synced 2025-08-06 10:44:06 +00:00
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:
@@ -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: [])
|
||||
|
||||
|
@@ -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
|
||||
}
|
||||
}
|
||||
|
@@ -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 {
|
||||
|
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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)
|
||||
}
|
||||
|
@@ -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)
|
||||
}
|
||||
|
@@ -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)
|
||||
}
|
||||
}
|
||||
|
@@ -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 {
|
||||
|
@@ -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")
|
||||
|
@@ -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 {
|
||||
|
@@ -31,7 +31,7 @@ struct VideoCell: View {
|
||||
if inNavigationView {
|
||||
player.playerNavigationLinkActive = true
|
||||
} else {
|
||||
player.presentPlayer()
|
||||
player.show()
|
||||
}
|
||||
}) {
|
||||
content
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -64,7 +64,7 @@ struct VideoContextMenuView: View {
|
||||
if inNavigationView {
|
||||
playerNavigationLinkActive = true
|
||||
} else {
|
||||
player.presentPlayer()
|
||||
player.show()
|
||||
}
|
||||
} label: {
|
||||
Label("Play Now", systemImage: "play")
|
||||
|
@@ -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())
|
||||
|
Reference in New Issue
Block a user