Initial functionality of player items queue

Fix environment objects

Hide video player placeholder on tvOS

Queue improvements
This commit is contained in:
Arkadiusz Fal
2021-10-05 22:20:09 +02:00
parent d6b3c6637d
commit 70c089e696
44 changed files with 1711 additions and 689 deletions

View File

@@ -2,55 +2,59 @@ import Foundation
import SwiftUI
struct PlaybackBar: View {
let video: Video
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var playback: PlaybackModel
@Environment(\.inNavigationView) private var inNavigationView
@EnvironmentObject<PlayerModel> private var player
var body: some View {
HStack {
closeButton
.frame(width: 60, alignment: .leading)
.frame(width: 80, alignment: .leading)
Text(playbackStatus)
.foregroundColor(.gray)
.font(.caption2)
.frame(minWidth: 60, maxWidth: .infinity)
if player.currentItem != nil {
Text(playbackStatus)
.foregroundColor(.gray)
.font(.caption2)
.frame(minWidth: 130, maxWidth: .infinity)
VStack {
if playback.stream != nil {
Text(currentStreamString)
} else {
if video.live {
Image(systemName: "dot.radiowaves.left.and.right")
VStack {
if player.stream != nil {
Text(currentStreamString)
} else {
Image(systemName: "bolt.horizontal.fill")
if player.currentVideo!.live {
Image(systemName: "dot.radiowaves.left.and.right")
} else {
Image(systemName: "bolt.horizontal.fill")
}
}
}
.foregroundColor(.gray)
.font(.caption2)
.frame(width: 80, alignment: .trailing)
.fixedSize(horizontal: true, vertical: true)
} else {
Spacer()
}
.foregroundColor(.gray)
.font(.caption2)
.frame(width: 60, alignment: .trailing)
.fixedSize(horizontal: true, vertical: true)
}
.padding(4)
.background(.black)
}
var currentStreamString: String {
playback.stream != nil ? "\(playback.stream!.resolution.height)p" : ""
"\(player.stream!.resolution.height)p"
}
var playbackStatus: String {
guard playback.time != nil else {
if playback.live {
return "LIVE"
} else {
return "loading..."
}
if player.live {
return "LIVE"
}
let remainingSeconds = video.length - playback.time!.seconds
guard player.time != nil, player.time!.isValid else {
return "loading..."
}
let remainingSeconds = player.currentVideo!.length - player.time!.seconds
if remainingSeconds < 60 {
return "less than a minute"
@@ -59,12 +63,15 @@ struct PlaybackBar: View {
let timeFinishAt = Date.now.addingTimeInterval(remainingSeconds)
let timeFinishAtString = timeFinishAt.formatted(date: .omitted, time: .shortened)
return "finishes at \(timeFinishAtString)"
return "ends at \(timeFinishAtString)"
}
var closeButton: some View {
Button(action: { dismiss() }) {
Image(systemName: "xmark.circle.fill")
Button {
dismiss()
} label: {
Label("Close", systemImage: inNavigationView ? "chevron.backward.circle.fill" : "chevron.down.circle.fill")
.labelStyle(.iconOnly)
}
.accessibilityLabel(Text("Close"))
.buttonStyle(.borderless)

View File

@@ -3,15 +3,23 @@ import SwiftUI
struct Player: UIViewControllerRepresentable {
@EnvironmentObject<InvidiousAPI> private var api
@EnvironmentObject<PlaybackModel> private var playback
@EnvironmentObject<PlayerModel> private var player
var video: Video?
var controller: PlayerViewController?
init(controller: PlayerViewController? = nil) {
self.controller = controller
}
func makeUIViewController(context _: Context) -> PlayerViewController {
if self.controller != nil {
return self.controller!
}
let controller = PlayerViewController()
controller.video = video
controller.playback = playback
player.controller = controller
controller.playerModel = player
controller.api = api
controller.resolution = Defaults[.quality]

View File

@@ -0,0 +1,37 @@
import Foundation
import SwiftUI
struct PlayerQueueRow: View {
let item: PlayerQueueItem
var history = false
@Binding var fullScreen: Bool
@EnvironmentObject<PlayerModel> private var player
var body: some View {
Group {
Button {
player.addCurrentItemToHistory()
if history {
let newItem = player.enqueueVideo(item.video, prepending: true)
player.advanceToItem(newItem!)
if let historyItemIndex = player.history.firstIndex(of: item) {
player.history.remove(at: historyItemIndex)
}
} else {
player.advanceToItem(item)
}
if fullScreen {
withAnimation {
fullScreen = false
}
}
} label: {
VideoBanner(video: item.video)
}
.buttonStyle(.plain)
}
}
}

View File

@@ -0,0 +1,100 @@
import Foundation
import SwiftUI
struct PlayerQueueView: View {
@Binding var fullScreen: Bool
@EnvironmentObject<PlayerModel> private var player
var body: some View {
List {
playingNext
playedPreviously
}
#if os(macOS)
.listStyle(.groupedWithInsets)
#elseif os(iOS)
.listStyle(.insetGrouped)
#else
.listStyle(.plain)
#endif
}
var playingNext: some View {
Section(header: Text("Playing Next")) {
if player.queue.isEmpty {
Text("Playback queue is empty")
.foregroundColor(.secondary)
}
ForEach(player.queue) { item in
PlayerQueueRow(item: item, fullScreen: $fullScreen)
.contextMenu {
removeButton(item, history: false)
removeAllButton(history: false)
}
#if os(iOS)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
removeButton(item, history: false)
}
#endif
}
}
}
var playedPreviously: some View {
Section(header: Text("Played Previously")) {
if player.history.isEmpty {
Text("History is empty")
.foregroundColor(.secondary)
}
ForEach(player.history) { item in
PlayerQueueRow(item: item, history: true, fullScreen: $fullScreen)
.contextMenu {
removeButton(item, history: true)
removeAllButton(history: true)
}
#if os(iOS)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
removeButton(item, history: true)
}
#endif
}
}
}
func removeButton(_ item: PlayerQueueItem, history: Bool) -> some View {
Button(role: .destructive) {
if history {
player.removeHistory(item)
} else {
player.remove(item)
}
} label: {
Label("Remove", systemImage: "trash")
}
}
func removeAllButton(history: Bool) -> some View {
Button(role: .destructive) {
if history {
player.removeHistoryItems()
} else {
player.removeQueueItems()
}
} label: {
Label("Remove All", systemImage: "trash.fill")
}
}
}
struct PlayerQueueView_Previews: PreviewProvider {
static var previews: some View {
VStack {
PlayerQueueView(fullScreen: .constant(true))
}
.injectFixtureEnvironmentObjects()
}
}

View File

@@ -3,15 +3,12 @@ import Logging
import SwiftUI
final class PlayerViewController: UIViewController {
var video: Video!
var api: InvidiousAPI!
var playerLoaded = false
var player = AVPlayer()
var playerModel: PlayerModel!
var playback: PlaybackModel!
var playerViewController = AVPlayerViewController()
var resolution: Stream.ResolutionSetting!
var shouldResume = false
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
@@ -22,61 +19,42 @@ final class PlayerViewController: UIViewController {
try? AVAudioSession.sharedInstance().setActive(true)
}
override func viewDidDisappear(_ animated: Bool) {
#if os(iOS)
if !playerModel.playingOutsideViewController {
playerViewController.player?.replaceCurrentItem(with: nil)
playerViewController.player = nil
try? AVAudioSession.sharedInstance().setActive(false)
}
#endif
super.viewDidDisappear(animated)
}
func loadPlayer() {
playerModel = PlayerModel(playback: playback, api: api, resolution: resolution)
guard !playerLoaded else {
return
}
playerModel.player = player
playerModel.controller = self
playerViewController.player = playerModel.player
playerModel.loadVideo(video)
playerViewController.allowsPictureInPicturePlayback = true
playerViewController.delegate = self
#if os(tvOS)
playerModel.avPlayerViewController = playerViewController
playerViewController.customInfoViewControllers = [playerQueueInfoViewController]
present(playerViewController, animated: false)
addItemDidPlayToEndTimeObserver()
#else
embedViewController()
#endif
playerViewController.allowsPictureInPicturePlayback = true
playerViewController.delegate = self
playerLoaded = true
}
#if os(tvOS)
func addItemDidPlayToEndTimeObserver() {
NotificationCenter.default.addObserver(
self,
selector: #selector(itemDidPlayToEndTime),
name: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
object: nil
var playerQueueInfoViewController: UIHostingController<AnyView> {
let controller = UIHostingController(rootView:
AnyView(
NowPlayingView(infoViewController: true)
.environmentObject(playerModel)
)
)
}
@objc func itemDidPlayToEndTime() {
playerViewController.dismiss(animated: true) {
self.dismiss(animated: false)
}
controller.title = "Playing Next"
return controller
}
#else
func embedViewController() {
playerViewController.exitsFullScreenWhenPlaybackEnds = true
playerViewController.view.frame = view.bounds
addChild(playerViewController)
@@ -96,17 +74,22 @@ extension PlayerViewController: AVPlayerViewControllerDelegate {
false
}
func playerViewControllerWillBeginDismissalTransition(_: AVPlayerViewController) {
shouldResume = playerModel.isPlaying
}
func playerViewControllerDidEndDismissalTransition(_: AVPlayerViewController) {
playerModel.playingOutsideViewController = false
if shouldResume {
playerModel.player.play()
}
dismiss(animated: false)
}
func playerViewController(
_: AVPlayerViewController,
willBeginFullScreenPresentationWithAnimationCoordinator _: UIViewControllerTransitionCoordinator
) {
playerModel.playingOutsideViewController = true
}
) {}
func playerViewController(
_: AVPlayerViewController,
@@ -114,8 +97,6 @@ extension PlayerViewController: AVPlayerViewControllerDelegate {
) {
coordinator.animate(alongsideTransition: nil) { context in
if !context.isCancelled {
self.playerModel.playingOutsideViewController = false
#if os(iOS)
if self.traitCollection.verticalSizeClass == .compact {
self.dismiss(animated: true)
@@ -125,11 +106,7 @@ extension PlayerViewController: AVPlayerViewControllerDelegate {
}
}
func playerViewControllerWillStartPictureInPicture(_: AVPlayerViewController) {
playerModel.playingOutsideViewController = true
}
func playerViewControllerWillStartPictureInPicture(_: AVPlayerViewController) {}
func playerViewControllerWillStopPictureInPicture(_: AVPlayerViewController) {
playerModel.playingOutsideViewController = false
}
func playerViewControllerWillStopPictureInPicture(_: AVPlayerViewController) {}
}

View File

@@ -2,160 +2,305 @@ import Foundation
import SwiftUI
struct VideoDetails: View {
@EnvironmentObject<SubscriptionsModel> private var subscriptions
enum Page {
case details, queue
}
@Binding var sidebarQueue: Bool
@Binding var fullScreen: Bool
@State private var subscribed = false
@State private var confirmationShown = false
var video: Video
@State private var currentPage = Page.details
@Environment(\.dismiss) private var dismiss
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<SubscriptionsModel> private var subscriptions
init(
sidebarQueue: Binding<Bool>? = nil,
fullScreen: Binding<Bool>? = nil
) {
_sidebarQueue = sidebarQueue ?? .constant(true)
_fullScreen = fullScreen ?? .constant(false)
}
var video: Video? {
player.currentItem?.video
}
var body: some View {
VStack(alignment: .leading) {
Text(video.title)
.font(.title2.bold())
.padding(.bottom, 0)
Group {
Group {
HStack(spacing: 0) {
title
Divider()
HStack(alignment: .center) {
HStack(spacing: 4) {
if subscribed {
Image(systemName: "star.circle.fill")
toggleFullScreenDetailsButton
}
VStack(alignment: .leading) {
Text(video.channel.name)
.font(.system(size: 13))
.bold()
if let subscribers = video.channel.subscriptionsString {
Text("\(subscribers) subscribers")
.font(.caption2)
#if os(macOS)
.padding(.top, 10)
#endif
if !video.isNil {
Divider()
}
subscriptionsSection
}
.padding(.horizontal)
if !video.isNil, !sidebarQueue {
pagePicker
.padding(.horizontal)
}
}
.contentShape(Rectangle())
.onSwipeGesture(
up: {
withAnimation {
fullScreen = true
}
},
down: {
withAnimation {
if fullScreen {
fullScreen = false
} else {
self.dismiss()
}
}
}
.foregroundColor(.secondary)
)
Spacer()
switch currentPage {
case .details:
ScrollView(.vertical) {
detailsPage
}
case .queue:
PlayerQueueView(fullScreen: $fullScreen)
.edgesIgnoringSafeArea(.horizontal)
}
}
.onAppear {
guard video != nil else {
return
}
Section {
if subscribed {
Button("Unsubscribe") {
confirmationShown = true
}
#if os(iOS)
.tint(.gray)
subscribed = subscriptions.isSubscribing(video!.channel.id)
}
.edgesIgnoringSafeArea(.horizontal)
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
}
var title: some View {
Group {
if video != nil {
Text(video!.title)
.onAppear {
#if !os(macOS)
currentPage = .details
#endif
.confirmationDialog("Are you you want to unsubscribe from \(video.channel.name)?", isPresented: $confirmationShown) {
}
.font(.title2.bold())
} else {
Text("Not playing")
.foregroundColor(.secondary)
.onAppear {
#if !os(macOS)
currentPage = .queue
#endif
}
}
Spacer()
}
}
var toggleFullScreenDetailsButton: some View {
Button {
withAnimation {
fullScreen.toggle()
}
} label: {
Label("Resize", systemImage: fullScreen ? "chevron.down" : "chevron.up")
.labelStyle(.iconOnly)
}
.help("Toggle fullscreen details")
.buttonStyle(.plain)
.keyboardShortcut("t")
}
var subscriptionsSection: some View {
Group {
if video != nil {
HStack(alignment: .center) {
HStack(spacing: 4) {
if subscribed {
Image(systemName: "star.circle.fill")
}
VStack(alignment: .leading) {
Text(video!.channel.name)
.font(.system(size: 13))
.bold()
if let subscribers = video!.channel.subscriptionsString {
Text("\(subscribers) subscribers")
.font(.caption2)
}
}
}
.foregroundColor(.secondary)
Spacer()
Section {
if subscribed {
Button("Unsubscribe") {
subscriptions.unsubscribe(video.channel.id)
confirmationShown = true
}
#if os(iOS)
.tint(.gray)
#endif
.confirmationDialog("Are you you want to unsubscribe from \(video!.channel.name)?", isPresented: $confirmationShown) {
Button("Unsubscribe") {
subscriptions.unsubscribe(video!.channel.id)
withAnimation {
subscribed.toggle()
}
}
}
} else {
Button("Subscribe") {
subscriptions.subscribe(video!.channel.id)
withAnimation {
subscribed.toggle()
}
}
.tint(.blue)
}
} else {
Button("Subscribe") {
subscriptions.subscribe(video.channel.id)
}
.font(.system(size: 13))
.buttonStyle(.borderless)
.buttonBorderShape(.roundedRectangle)
}
Divider()
}
}
}
withAnimation {
subscribed.toggle()
}
var pagePicker: some View {
Picker("Page", selection: $currentPage) {
Text("Details").tag(Page.details)
Text("Queue").tag(Page.queue)
}
.pickerStyle(.segmented)
.onDisappear {
currentPage = .details
}
}
var publishedDateSection: some View {
Group {
if let video = player.currentItem.video {
HStack(spacing: 4) {
if let published = video.publishedDate {
Text(published)
}
if let publishedAt = video.publishedAt {
if video.publishedDate != nil {
Text("")
.foregroundColor(.secondary)
.opacity(0.3)
}
.tint(.blue)
Text(publishedAt.formatted(date: .abbreviated, time: .omitted))
}
}
.font(.system(size: 13))
.buttonStyle(.borderless)
.buttonBorderShape(.roundedRectangle)
.font(.system(size: 12))
.padding(.bottom, -1)
.foregroundColor(.secondary)
}
.padding(.bottom, -1)
}
}
Divider()
var countsSection: some View {
Group {
if let video = player.currentItem.video {
HStack {
Spacer()
HStack(spacing: 4) {
if let published = video.publishedDate {
Text(published)
}
if let publishedAt = video.publishedAt {
if video.publishedDate != nil {
Text("")
.foregroundColor(.secondary)
.opacity(0.3)
if let views = video.viewsCount {
videoDetail(label: "Views", value: views, symbol: "eye.fill")
}
Text(publishedAt.formatted(date: .abbreviated, time: .omitted))
if let likes = video.likesCount {
Divider()
videoDetail(label: "Likes", value: likes, symbol: "hand.thumbsup.circle.fill")
}
if let dislikes = video.dislikesCount {
Divider()
videoDetail(label: "Dislikes", value: dislikes, symbol: "hand.thumbsdown.circle.fill")
}
Spacer()
}
.frame(maxHeight: 35)
.foregroundColor(.secondary)
}
.font(.system(size: 12))
.padding(.bottom, -1)
.foregroundColor(.secondary)
}
}
Divider()
var detailsPage: some View {
Group {
if let video = player.currentItem?.video {
Group {
publishedDateSection
HStack {
Spacer()
if let views = video.viewsCount {
videoDetail(label: "Views", value: views, symbol: "eye.fill")
}
if let likes = video.likesCount {
Divider()
videoDetail(label: "Likes", value: likes, symbol: "hand.thumbsup.circle.fill")
countsSection
}
if let dislikes = video.dislikesCount {
Divider()
Divider()
videoDetail(label: "Dislikes", value: dislikes, symbol: "hand.thumbsdown.circle.fill")
}
Spacer()
}
.frame(maxHeight: 35)
.foregroundColor(.secondary)
Divider()
#if os(macOS)
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 10) {
Text(video.description)
.font(.caption)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 100, alignment: .leading)
}
#else
Text(video.description)
.font(.caption)
#endif
ScrollView(.horizontal, showsIndicators: showScrollIndicators) {
HStack {
ForEach(video.keywords, id: \.self) { keyword in
HStack(alignment: .center, spacing: 0) {
Text("#")
.font(.system(size: 11).bold())
ScrollView(.horizontal, showsIndicators: showScrollIndicators) {
HStack {
ForEach(video.keywords, id: \.self) { keyword in
HStack(alignment: .center, spacing: 0) {
Text("#")
.font(.system(size: 11).bold())
Text(keyword)
.frame(maxWidth: 500)
}.foregroundColor(.white)
.padding(.vertical, 4)
.padding(.horizontal, 8)
.background(Color("VideoDetailLikesSymbolColor"))
.mask(RoundedRectangle(cornerRadius: 3))
.font(.caption)
Text(keyword)
.frame(maxWidth: 500)
}
.font(.caption)
.foregroundColor(.white)
.padding(.vertical, 4)
.padding(.horizontal, 8)
.background(Color("VideoDetailLikesSymbolColor"))
.mask(RoundedRectangle(cornerRadius: 3))
}
}
.padding(.bottom, 10)
}
}
.padding(.bottom, 10)
}
}
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.padding([.horizontal, .bottom])
.onAppear {
subscribed = subscriptions.isSubscribing(video.channel.id)
}
.padding(.horizontal)
}
func videoDetail(label: String, value: String, symbol: String) -> some View {
@@ -185,7 +330,7 @@ struct VideoDetails: View {
struct VideoDetails_Previews: PreviewProvider {
static var previews: some View {
VideoDetails(video: Video.fixture)
VideoDetails(sidebarQueue: .constant(false))
.injectFixtureEnvironmentObjects()
}
}

View File

@@ -2,21 +2,32 @@ import Foundation
import SwiftUI
struct VideoDetailsPaddingModifier: ViewModifier {
static var defaultAdditionalDetailsPadding: Double {
#if os(macOS)
20
#else
35
#endif
}
let geometry: GeometryProxy
let aspectRatio: Double?
let minimumHeightLeft: Double
let additionalPadding: Double
let fullScreen: Bool
init(
geometry: GeometryProxy,
aspectRatio: Double? = nil,
minimumHeightLeft: Double? = nil,
additionalPadding: Double = 35.00
additionalPadding: Double? = nil,
fullScreen: Bool = false
) {
self.geometry = geometry
self.aspectRatio = aspectRatio ?? VideoPlayerView.defaultAspectRatio
self.minimumHeightLeft = minimumHeightLeft ?? VideoPlayerView.defaultMinimumHeightLeft
self.additionalPadding = additionalPadding
self.additionalPadding = additionalPadding ?? VideoDetailsPaddingModifier.defaultAdditionalDetailsPadding
self.fullScreen = fullScreen
}
var usedAspectRatio: Double {
@@ -32,7 +43,7 @@ struct VideoDetailsPaddingModifier: ViewModifier {
}
var topPadding: Double {
playerHeight + additionalPadding
fullScreen ? 0 : (playerHeight + additionalPadding)
}
func body(content: Content) -> some View {

View File

@@ -2,7 +2,7 @@ import Foundation
import SwiftUI
struct VideoPlayerSizeModifier: ViewModifier {
let geometry: GeometryProxy
let geometry: GeometryProxy!
let aspectRatio: Double?
let minimumHeightLeft: Double
@@ -11,7 +11,7 @@ struct VideoPlayerSizeModifier: ViewModifier {
#endif
init(
geometry: GeometryProxy,
geometry: GeometryProxy? = nil,
aspectRatio: Double? = nil,
minimumHeightLeft: Double? = nil
) {
@@ -21,10 +21,15 @@ struct VideoPlayerSizeModifier: ViewModifier {
}
func body(content: Content) -> some View {
content
.frame(maxHeight: maxHeight)
.aspectRatio(usedAspectRatio, contentMode: usedAspectRatioContentMode)
.edgesIgnoringSafeArea(edgesIgnoringSafeArea)
// TODO: verify if optional GeometryProxy is still used
if geometry != nil {
content
.frame(maxHeight: maxHeight)
.aspectRatio(usedAspectRatio, contentMode: usedAspectRatioContentMode)
.edgesIgnoringSafeArea(edgesIgnoringSafeArea)
} else {
content.edgesIgnoringSafeArea(edgesIgnoringSafeArea)
}
}
var usedAspectRatio: Double {

View File

@@ -1,6 +1,10 @@
import AVKit
import Defaults
import Siesta
import SwiftUI
#if !os(tvOS)
import SwiftUIKit
#endif
struct VideoPlayerView: View {
static let defaultAspectRatio: Double = 1.77777778
@@ -12,103 +16,154 @@ struct VideoPlayerView: View {
#endif
}
@StateObject private var store = Store<Video>()
@State private var playerSize: CGSize = .zero
@State private var fullScreen = false
#if os(iOS)
@Environment(\.dismiss) private var dismiss
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.verticalSizeClass) private var verticalSizeClass
#endif
@EnvironmentObject<InvidiousAPI> private var api
@EnvironmentObject<PlaybackModel> private var playback
var resource: Resource {
api.video(video.id)
}
var video: Video
init(_ video: Video) {
self.video = video
}
@EnvironmentObject<PlayerModel> private var player
var body: some View {
VStack(spacing: 0) {
#if os(tvOS)
Player(video: video)
.environmentObject(playback)
#else
GeometryReader { geometry in
VStack(spacing: 0) {
#if os(iOS)
if verticalSizeClass == .regular {
PlaybackBar(video: video)
}
#elseif os(macOS)
PlaybackBar(video: video)
#endif
#if os(macOS)
HSplitView {
content
}
.frame(idealWidth: 1000, maxWidth: 1100, minHeight: 700)
#else
HStack(spacing: 0) {
content
}
#if os(iOS)
.navigationBarHidden(true)
#endif
#endif
}
Player(video: video)
.environmentObject(playback)
.modifier(VideoPlayerSizeModifier(geometry: geometry, aspectRatio: playback.aspectRatio))
}
.background(.black)
VStack(spacing: 0) {
#if os(iOS)
if verticalSizeClass == .regular {
ScrollView(.vertical, showsIndicators: showScrollIndicators) {
if let video = store.item {
VideoDetails(video: video)
} else {
VideoDetails(video: video)
}
var content: some View {
Group {
VStack(alignment: .leading, spacing: 0) {
#if os(tvOS)
player()
#else
GeometryReader { geometry in
VStack(spacing: 0) {
#if os(iOS)
if verticalSizeClass == .regular {
PlaybackBar()
}
}
#else
if let video = store.item {
VideoDetails(video: video)
#elseif os(macOS)
PlaybackBar()
#endif
if player.currentItem.isNil {
playerPlaceholder(geometry: geometry)
} else {
VideoDetails(video: video)
player(geometry: geometry)
}
}
#if os(iOS)
.onSwipeGesture(
up: {
withAnimation {
fullScreen = true
}
},
down: { dismiss() }
)
#endif
.background(.black)
.onAppear {
self.playerSize = geometry.size
}
.onChange(of: geometry.size) { size in
self.playerSize = size
}
Group {
#if os(iOS)
if verticalSizeClass == .regular {
VideoDetails(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreen)
}
#else
VideoDetails(fullScreen: $fullScreen)
#endif
}
.background()
.modifier(VideoDetailsPaddingModifier(geometry: geometry, fullScreen: fullScreen))
}
.modifier(VideoDetailsPaddingModifier(geometry: geometry, aspectRatio: playback.aspectRatio))
#endif
}
#if os(macOS)
.frame(minWidth: 650)
#endif
#if os(iOS)
if sidebarQueue {
PlayerQueueView(fullScreen: $fullScreen)
.frame(maxWidth: 350)
}
.animation(.linear(duration: 0.2), value: playback.aspectRatio)
#elseif os(macOS)
PlayerQueueView(fullScreen: $fullScreen)
.frame(minWidth: 250)
#endif
}
.onAppear {
resource.addObserver(store)
resource.loadIfNeeded()
}
func playerPlaceholder(geometry: GeometryProxy) -> some View {
HStack {
Spacer()
VStack {
Spacer()
VStack(spacing: 10) {
#if !os(tvOS)
Image(systemName: "ticket")
.font(.system(size: 80))
Text("What are we watching next?")
#endif
}
Spacer()
}
.foregroundColor(.gray)
Spacer()
}
.onDisappear {
resource.removeObservers(ownedBy: store)
resource.invalidate()
}
#if os(macOS)
.frame(maxWidth: 1000, minHeight: 700)
#elseif os(iOS)
.navigationBarHidden(true)
.contentShape(Rectangle())
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: geometry.size.width / VideoPlayerView.defaultAspectRatio)
}
func player(geometry: GeometryProxy? = nil) -> some View {
Player()
#if !os(tvOS)
.modifier(VideoPlayerSizeModifier(geometry: geometry))
#endif
}
var showScrollIndicators: Bool {
#if os(macOS)
false
#else
true
#endif
}
#if os(iOS)
var sidebarQueue: Bool {
horizontalSizeClass == .regular && playerSize.width > 750
}
var sidebarQueueBinding: Binding<Bool> {
Binding(
get: { self.sidebarQueue },
set: { _ in }
)
}
#endif
}
struct VideoPlayerView_Previews: PreviewProvider {
static var previews: some View {
VStack {
Spacer()
}
.sheet(isPresented: .constant(true)) {
VideoPlayerView(Video.fixture)
.injectFixtureEnvironmentObjects()
}
VideoPlayerView()
// .frame(minWidth: 1200, minHeight: 1400)
.injectFixtureEnvironmentObjects()
VideoPlayerView()
.injectFixtureEnvironmentObjects()
.previewInterfaceOrientation(.landscapeRight)
}
}