Compare commits

..

9 Commits

Author SHA1 Message Date
Arkadiusz Fal
f132ba9683 Bump build number 2021-12-19 18:22:13 +01:00
Arkadiusz Fal
efce339234 Add context menu to close current video from player bar 2021-12-19 18:21:10 +01:00
Arkadiusz Fal
f89c5ff055 Improve player queue rows buttons labels 2021-12-19 18:18:33 +01:00
Arkadiusz Fal
9b2209c9b5 Update default list of favorites 2021-12-19 18:18:01 +01:00
Arkadiusz Fal
61a4951831 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
2021-12-19 18:17:04 +01:00
Arkadiusz Fal
cef0b2594a Better loading and handling streams 2021-12-19 17:56:47 +01:00
Arkadiusz Fal
1fbb0cfa80 Remove favorites drag opacity effect on iOS (fixes #43)
No workaround for how to handle drag and drop effect on opening
context menu
2021-12-19 17:32:28 +01:00
Arkadiusz Fal
984e9e7b16 Fix visibility of likes/dislikes 2021-12-19 17:15:27 +01:00
Arkadiusz Fal
4793fc9a38 Fix visibility of Subscriptions tab navigation item on tvOS 2021-12-19 17:08:48 +01:00
32 changed files with 544 additions and 178 deletions

View File

@@ -6,6 +6,14 @@ final class InstancesModel: ObservableObject {
Defaults[.instances] Defaults[.instances]
} }
static var forPlayer: Instance? {
guard let id = Defaults[.playerInstanceID] else {
return nil
}
return InstancesModel.find(id)
}
var lastUsed: Instance? { var lastUsed: Instance? {
guard let id = Defaults[.lastInstanceID] else { guard let id = Defaults[.lastInstanceID] else {
return nil return nil

View File

@@ -27,15 +27,13 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
self.account = account self.account = account
signedIn = false signedIn = false
if account.anonymous { validInstance = account.anonymous
validInstance = true
return
}
validInstance = false
configure() configure()
validate()
if !account.anonymous {
validate()
}
} }
func validate() { func validate() {

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 { rebuildTVMenu() } } @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 {
@@ -171,16 +205,47 @@ final class PlayerModel: ObservableObject {
} }
} }
private func pauseOnPlayerDismiss() { private func handleAvailableStreamsChange() {
if !playingInPictureInPicture, !presentingPlayer { rebuildTVMenu()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.pause() guard stream.isNil else {
return
}
guard let stream = preferredStream(availableStreams) else {
return
}
streamSelection = stream
playStream(
stream,
of: currentVideo!,
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()
} }
@@ -357,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 {
@@ -408,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()
} }
@@ -607,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 {
@@ -54,21 +62,10 @@ extension PlayerModel {
preservedTime = currentItem.playbackTime preservedTime = currentItem.playbackTime
restoreLoadedChannel() restoreLoadedChannel()
loadAvailableStreams(currentVideo!) { streams in loadAvailableStreams(currentVideo!)
guard let stream = self.preferredStream(streams) else {
return
}
self.streamSelection = stream
self.playStream(
stream,
of: self.currentVideo!,
preservingTime: !self.currentItem.playbackTime.isNil
)
}
} }
private func preferredStream(_ streams: [Stream]) -> Stream? { func preferredStream(_ streams: [Stream]) -> Stream? {
let quality = Defaults[.quality] let quality = Defaults[.quality]
var streams = streams var streams = streams
@@ -94,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)
@@ -127,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

@@ -15,21 +15,20 @@ extension PlayerModel {
availableStreams.sorted(by: streamsSorter) availableStreams.sorted(by: streamsSorter)
} }
func loadAvailableStreams( func loadAvailableStreams(_ video: Video) {
_ video: Video,
completionHandler: @escaping ([Stream]) -> Void = { _ in }
) {
availableStreams = [] availableStreams = []
var instancesWithLoadedStreams = [Instance]() let playerInstance = InstancesModel.forPlayer ?? InstancesModel.all.first
InstancesModel.all.forEach { instance in guard !playerInstance.isNil else {
fetchStreams(instance.anonymous.video(video.videoID), instance: instance, video: video) { _ in return
self.completeIfAllInstancesLoaded( }
instance: instance,
streams: self.availableStreams, logger.info("loading streams from \(playerInstance!.description)")
instancesWithLoadedStreams: &instancesWithLoadedStreams,
completionHandler: completionHandler fetchStreams(playerInstance!.anonymous.video(video.videoID), instance: playerInstance!, video: video) { _ in
) InstancesModel.all.filter { $0 != playerInstance }.forEach { instance in
self.logger.info("loading streams from \(instance.description)")
self.fetchStreams(instance.anonymous.video(video.videoID), instance: instance, video: video)
} }
} }
} }
@@ -45,25 +44,13 @@ extension PlayerModel {
.onSuccess { response in .onSuccess { response in
if let video: Video = response.typedContent() { if let video: Video = response.typedContent() {
self.availableStreams += self.streamsWithInstance(instance: instance, streams: video.streams) self.availableStreams += self.streamsWithInstance(instance: instance, streams: video.streams)
} else {
self.logger.critical("no streams available from \(instance.description)")
} }
} }
.onCompletion(onCompletion) .onCompletion(onCompletion)
} }
private func completeIfAllInstancesLoaded(
instance: Instance,
streams: [Stream],
instancesWithLoadedStreams: inout [Instance],
completionHandler: @escaping ([Stream]) -> Void
) {
instancesWithLoadedStreams.append(instance)
rebuildTVMenu()
if InstancesModel.all.count == instancesWithLoadedStreams.count {
completionHandler(streams.sorted { $0.kind < $1.kind })
}
}
func streamsWithInstance(instance: Instance, streams: [Stream]) -> [Stream] { func streamsWithInstance(instance: Instance, streams: [Stream]) -> [Stream] {
streams.map { stream in streams.map { stream in
stream.instance = instance stream.instance = instance

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

@@ -85,7 +85,7 @@ struct Video: Identifiable, Equatable, Hashable {
} }
var likesCount: String? { var likesCount: String? {
guard likes != -1 else { guard (likes ?? 0) > 0 else {
return nil return nil
} }
@@ -93,7 +93,7 @@ struct Video: Identifiable, Equatable, Hashable {
} }
var dislikesCount: String? { var dislikesCount: String? {
guard dislikes != -1 else { guard (dislikes ?? 0) > 0 else {
return nil return nil
} }

View File

@@ -22,7 +22,12 @@ extension Defaults.Keys {
static let favorites = Key<[FavoriteItem]>("favorites", default: [ static let favorites = Key<[FavoriteItem]>("favorites", default: [
.init(section: .trending("US", "default")), .init(section: .trending("US", "default")),
.init(section: .trending("GB", "default")),
.init(section: .trending("ES", "default")),
.init(section: .channel("UC-lHJZR3Gqxm24_Vd_AJ5Yw", "PewDiePie")), .init(section: .channel("UC-lHJZR3Gqxm24_Vd_AJ5Yw", "PewDiePie")),
.init(section: .channel("UCXuqSBlHAE6Xw-yeJA0Tunw", "Linus Tech Tips")),
.init(section: .channel("UCBJycsmduvYEL83R_U4JriQ", "Marques Brownlee")),
.init(section: .channel("UCE_M8A5yxnLfW0KghEeajjw", "Apple")),
.init(section: .searchQuery("Apple Pie Recipes", "", "", "")) .init(section: .searchQuery("Apple Pie Recipes", "", "", ""))
]) ])
@@ -38,6 +43,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

@@ -49,20 +49,22 @@ struct FavoriteItemView: View {
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
.opacity(dragging?.id == item.id ? 0.5 : 1) #if os(macOS)
.onAppear { .opacity(dragging?.id == item.id ? 0.5 : 1)
resource?.addObserver(store) #endif
resource?.load() .onAppear {
} resource?.addObserver(store)
resource?.load()
}
#if !os(tvOS) #if !os(tvOS)
.onDrag { .onDrag {
dragging = item dragging = item
return NSItemProvider(object: item.id as NSString) return NSItemProvider(object: item.id as NSString)
} }
.onDrop( .onDrop(
of: [UTType.text], of: [UTType.text],
delegate: DropFavorite(item: item, favorites: $favorites, current: $dragging) delegate: DropFavorite(item: item, favorites: $favorites, current: $dragging)
) )
#endif #endif
} }
} }

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

@@ -76,11 +76,16 @@ struct PlayerQueueView: View {
ForEach(player.currentVideo!.related) { video in ForEach(player.currentVideo!.related) { video in
PlayerQueueRow(item: PlayerQueueItem(video), fullScreen: $fullScreen) PlayerQueueRow(item: PlayerQueueItem(video), fullScreen: $fullScreen)
.contextMenu { .contextMenu {
Button("Play Next") { Button {
player.playNext(video) player.playNext(video)
} label: {
Label("Play Next", systemImage: "text.insert")
} }
Button("Play Last") {
Button {
player.enqueueVideo(video) player.enqueueVideo(video)
} label: {
Label("Play Last", systemImage: "text.append")
} }
} }
} }

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) {
@@ -42,6 +42,15 @@ struct PlayerControlsView<Content: View>: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
.lineLimit(1) .lineLimit(1)
} }
.contextMenu {
Button {
model.closeCurrentItem()
} label: {
Label("Close Video", systemImage: "xmark.circle")
.labelStyle(.automatic)
}
.disabled(model.currentItem.isNil)
}
Spacer() Spacer()
} }
@@ -96,7 +105,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 */,
@@ -2301,7 +2305,7 @@
CODE_SIGN_ENTITLEMENTS = "Open in Yattee/Open in Yattee.entitlements"; CODE_SIGN_ENTITLEMENTS = "Open in Yattee/Open in Yattee.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9; CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -2314,7 +2318,7 @@
"@executable_path/../../../../Frameworks", "@executable_path/../../../../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 11.0; MACOSX_DEPLOYMENT_TARGET = 11.0;
MARKETING_VERSION = 1.2; MARKETING_VERSION = 1.3;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"-framework", "-framework",
SafariServices, SafariServices,
@@ -2335,7 +2339,7 @@
CODE_SIGN_ENTITLEMENTS = "Open in Yattee/Open in Yattee.entitlements"; CODE_SIGN_ENTITLEMENTS = "Open in Yattee/Open in Yattee.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9; CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -2348,7 +2352,7 @@
"@executable_path/../../../../Frameworks", "@executable_path/../../../../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 11.0; MACOSX_DEPLOYMENT_TARGET = 11.0;
MARKETING_VERSION = 1.2; MARKETING_VERSION = 1.3;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"-framework", "-framework",
SafariServices, SafariServices,
@@ -2367,7 +2371,7 @@
buildSettings = { buildSettings = {
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9; CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Open in Yattee/Info.plist"; INFOPLIST_FILE = "Open in Yattee/Info.plist";
@@ -2379,7 +2383,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.2; MARKETING_VERSION = 1.3;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"-framework", "-framework",
SafariServices, SafariServices,
@@ -2399,7 +2403,7 @@
buildSettings = { buildSettings = {
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9; CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Open in Yattee/Info.plist"; INFOPLIST_FILE = "Open in Yattee/Info.plist";
@@ -2411,7 +2415,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.2; MARKETING_VERSION = 1.3;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"-framework", "-framework",
SafariServices, SafariServices,
@@ -2562,14 +2566,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10; CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
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;
@@ -2593,14 +2597,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10; CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
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;
@@ -2628,7 +2632,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 10; CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@@ -2661,7 +2665,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 10; CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@@ -2792,7 +2796,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10; CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -2824,7 +2828,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10; CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;

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

View File

@@ -17,7 +17,7 @@ struct TVNavigationView: View {
.tag(TabSelection.favorites) .tag(TabSelection.favorites)
} }
if visibleSections.contains(.subscriptions), accounts.app.supportsSubscriptions { if visibleSections.contains(.subscriptions), accounts.app.supportsSubscriptions, accounts.api.signedIn {
SubscriptionsView() SubscriptionsView()
.tabItem { Text("Subscriptions") } .tabItem { Text("Subscriptions") }
.tag(TabSelection.subscriptions) .tag(TabSelection.subscriptions)