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

@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "display-p3",
"components" : {
"alpha" : "1.000",
"blue" : "0.757",
"green" : "0.761",
"red" : "0.757"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "display-p3",
"components" : {
"alpha" : "1.000",
"blue" : "0.259",
"green" : "0.259",
"red" : "0.259"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -2,6 +2,7 @@ import SwiftUI
struct AppSidebarPlaylists: View {
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<PlaylistsModel> private var playlists
var body: some View {
@@ -15,6 +16,11 @@ struct AppSidebarPlaylists: View {
}
.id(playlist.id)
.contextMenu {
Button("Add to queue...") {
playlists.find(id: playlist.id)?.videos.forEach { video in
player.enqueueVideo(video)
}
}
Button("Edit") {
navigation.presentEditPlaylistForm(playlists.find(id: playlist.id))
}

View File

@@ -2,9 +2,14 @@ import Defaults
import SwiftUI
struct ContentView: View {
@StateObject private var api = InvidiousAPI()
@StateObject private var instances = InstancesModel()
@StateObject private var navigation = NavigationModel()
@StateObject private var playback = PlaybackModel()
@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()
#if os(iOS)
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@@ -24,34 +29,62 @@ struct ContentView: View {
TVNavigationView()
#endif
}
.onAppear(perform: configureAPI)
.environmentObject(api)
.environmentObject(instances)
.environmentObject(navigation)
.environmentObject(playback)
.environmentObject(player)
.environmentObject(playlists)
.environmentObject(recents)
#if !os(tvOS)
.sheet(isPresented: $navigation.showingVideo) {
if let video = navigation.video {
VideoPlayerView(video)
.environmentObject(playback)
#if !os(iOS)
.frame(minWidth: 550, minHeight: 720)
.onExitCommand {
navigation.showingVideo = false
}
#endif
}
.environmentObject(search)
.environmentObject(subscriptions)
#if os(iOS)
.fullScreenCover(isPresented: $player.presentingPlayer) {
VideoPlayerView()
.environmentObject(api)
.environmentObject(navigation)
.environmentObject(player)
.environmentObject(subscriptions)
}
#elseif os(macOS)
.sheet(isPresented: $player.presentingPlayer) {
VideoPlayerView()
.frame(minWidth: 900, minHeight: 800)
.environmentObject(api)
.environmentObject(navigation)
.environmentObject(player)
.environmentObject(subscriptions)
}
#endif
#if !os(tvOS)
.sheet(isPresented: $navigation.presentingAddToPlaylist) {
AddToPlaylistView(video: navigation.videoToAddToPlaylist)
.environmentObject(api)
.environmentObject(playlists)
}
.sheet(isPresented: $navigation.presentingPlaylistForm) {
PlaylistFormView(playlist: $navigation.editedPlaylist)
.environmentObject(api)
.environmentObject(playlists)
}
.sheet(isPresented: $navigation.presentingSettings) {
SettingsView()
.environmentObject(api)
.environmentObject(instances)
}
#endif
}
func configureAPI() {
if let account = instances.defaultAccount, api.account.isEmpty {
api.setAccount(account)
}
player.api = api
playlists.api = api
search.api = api
subscriptions.api = api
}
}
struct ContentView_Previews: PreviewProvider {

View File

@@ -3,21 +3,9 @@ import SwiftUI
@main
struct PearvidiousApp: App {
@StateObject private var api = InvidiousAPI()
@StateObject private var instances = InstancesModel()
@StateObject private var playlists = PlaylistsModel()
@StateObject private var search = SearchModel()
@StateObject private var subscriptions = SubscriptionsModel()
var body: some Scene {
WindowGroup {
ContentView()
.onAppear(perform: configureAPI)
.environmentObject(api)
.environmentObject(instances)
.environmentObject(playlists)
.environmentObject(search)
.environmentObject(subscriptions)
}
#if !os(tvOS)
.commands {
@@ -28,20 +16,9 @@ struct PearvidiousApp: App {
#if os(macOS)
Settings {
SettingsView()
.onAppear(perform: configureAPI)
.environmentObject(api)
.environmentObject(instances)
.environmentObject(InvidiousAPI())
.environmentObject(InstancesModel())
}
#endif
}
fileprivate func configureAPI() {
playlists.api = api
search.api = api
subscriptions.api = api
if let account = instances.defaultAccount, api.account.isEmpty {
api.setAccount(account)
}
}
}

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

View File

@@ -79,14 +79,8 @@ struct AddToPlaylistView: View {
private var form: some View {
VStack(alignment: formAlignment) {
VStack(alignment: .leading, spacing: 10) {
Text(video.title)
.font(.headline)
Text(video.author)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 40)
VideoBanner(video: video)
.padding(.vertical, 40)
VStack(alignment: formAlignment) {
#if os(tvOS)

View File

@@ -3,6 +3,7 @@ import Siesta
import SwiftUI
struct PlaylistsView: View {
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<PlaylistsModel> private var model
@State private var showingNewPlaylist = false
@@ -18,24 +19,26 @@ struct PlaylistsView: View {
}
var body: some View {
SignInRequiredView(title: "Playlists") {
VStack {
#if os(tvOS)
toolbar
#endif
if model.currentPlaylist != nil, videos.isEmpty {
hintText("Playlist is empty\n\nTap and hold on a video and then tap \"Add to Playlist\"")
} else if model.all.isEmpty {
hintText("You have no playlists\n\nTap on \"New Playlist\" to create one")
} else {
PlayerControlsView {
SignInRequiredView(title: "Playlists") {
VStack {
#if os(tvOS)
VideosCellsHorizontal(videos: videos)
.padding(.top, 40)
Spacer()
#else
VideosCellsVertical(videos: videos)
toolbar
#endif
if model.currentPlaylist != nil, videos.isEmpty {
hintText("Playlist is empty\n\nTap and hold on a video and then tap \"Add to Playlist\"")
} else if model.all.isEmpty {
hintText("You have no playlists\n\nTap on \"New Playlist\" to create one")
} else {
#if os(tvOS)
VideosCellsHorizontal(videos: videos)
.padding(.top, 40)
Spacer()
#else
VideosCellsVertical(videos: videos)
#endif
}
}
}
}
@@ -113,6 +116,16 @@ struct PlaylistsView: View {
selectPlaylistButton
}
Button {
player.playAll(videos)
player.presentPlayer()
} label: {
HStack(spacing: 15) {
Image(systemName: "play.fill")
Text("Play All")
}
}
if model.currentPlaylist != nil {
editPlaylistButton
}

View File

@@ -25,17 +25,19 @@ struct TrendingView: View {
}
var body: some View {
Section {
VStack(alignment: .center, spacing: 0) {
#if os(tvOS)
toolbar
VideosCellsHorizontal(videos: store.collection)
.padding(.top, 40)
PlayerControlsView {
Section {
VStack(alignment: .center, spacing: 0) {
#if os(tvOS)
toolbar
VideosCellsHorizontal(videos: store.collection)
.padding(.top, 40)
Spacer()
#else
VideosCellsVertical(videos: store.collection)
#endif
Spacer()
#else
VideosCellsVertical(videos: store.collection)
#endif
}
}
}
#if os(tvOS)

View File

@@ -0,0 +1,71 @@
import Foundation
import SwiftUI
struct VideoBanner: View {
let video: Video
var body: some View {
HStack(alignment: .center, spacing: 12) {
smallThumbnail
VStack(alignment: .leading, spacing: 4) {
Text(video.title)
.truncationMode(.middle)
.lineLimit(2)
.font(.headline)
.frame(alignment: .leading)
HStack {
Text(video.author)
.lineLimit(1)
Spacer()
if let time = video.playTime {
Text(time)
.fontWeight(.light)
}
}
.foregroundColor(.secondary)
}
}
.contentShape(Rectangle())
.buttonStyle(.plain)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 100, alignment: .center)
}
var smallThumbnail: some View {
Group {
if let url = video.thumbnailURL(quality: .medium) {
AsyncImage(url: url) { image in
image
.resizable()
} placeholder: {
HStack {
ProgressView()
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
}
}
} else {
Image(systemName: "exclamationmark.square")
}
}
.background(.gray)
#if os(tvOS)
.frame(width: 177, height: 100)
.mask(RoundedRectangle(cornerRadius: 12))
#else
.frame(width: 88, height: 50)
.mask(RoundedRectangle(cornerRadius: 6))
#endif
}
}
struct VideoBanner_Previews: PreviewProvider {
static var previews: some View {
VStack(spacing: 20) {
VideoBanner(video: Video.fixture)
VideoBanner(video: Video.fixtureUpcomingWithoutPublishedOrViews)
}
.frame(maxWidth: 900)
}
}

View File

@@ -2,32 +2,43 @@ import Defaults
import SwiftUI
struct VideoView: View {
@EnvironmentObject<NavigationModel> private var navigation
var video: Video
@State private var playerNavigationLinkActive = false
@Environment(\.inNavigationView) private var inNavigationView
#if os(iOS)
@Environment(\.verticalSizeClass) private var verticalSizeClass
@Environment(\.horizontalCells) private var horizontalCells
#endif
@Environment(\.inNavigationView) private var inNavigationView
var video: Video
@EnvironmentObject<PlayerModel> private var player
var body: some View {
Group {
if inNavigationView {
NavigationLink(destination: VideoPlayerView(video)) {
content
}
} else {
Button(action: { navigation.playVideo(video) }) {
content
Button(action: {
player.playNow(video)
if inNavigationView {
playerNavigationLinkActive = true
} else {
player.presentPlayer()
}
}) {
content
}
NavigationLink(isActive: $playerNavigationLinkActive, destination: {
VideoPlayerView()
.environment(\.inNavigationView, true)
}) {
EmptyView()
}
}
.buttonStyle(.plain)
.contentShape(RoundedRectangle(cornerRadius: 12))
.contextMenu { VideoContextMenuView(video: video) }
.contextMenu { VideoContextMenuView(video: video, playerNavigationLinkActive: $playerNavigationLinkActive) }
}
var content: some View {
@@ -131,7 +142,7 @@ struct VideoView: View {
#else
.frame(minHeight: 50, alignment: .top)
#endif
.padding(.bottom)
.padding(.bottom, 4)
Group {
if additionalDetailsAvailable {

View File

@@ -28,14 +28,14 @@ struct VideosCellsHorizontal: View {
.padding(.vertical, 30)
#else
.padding(.horizontal, 15)
.padding(.vertical, 20)
.padding(.vertical, 10)
#endif
}
.id(UUID())
#if os(tvOS)
.frame(height: 560)
#else
.frame(height: 280)
.frame(height: 250)
#endif
.edgesIgnoringSafeArea(.horizontal)

View File

@@ -20,6 +20,22 @@ struct ChannelVideosView: View {
@Namespace private var focusNamespace
var body: some View {
#if os(iOS)
if inNavigationView {
content
} else {
PlayerControlsView {
content
}
}
#else
PlayerControlsView {
content
}
#endif
}
var content: some View {
VStack {
#if os(tvOS)
HStack {

View File

@@ -0,0 +1,109 @@
import Foundation
import SwiftUI
struct PlayerControlsView<Content: View>: View {
let content: Content
@Environment(\.navigationStyle) private var navigationStyle
@EnvironmentObject<PlayerModel> private var model
@EnvironmentObject<NavigationModel> private var navigation
init(@ViewBuilder content: @escaping () -> Content) {
self.content = content()
}
var body: some View {
ZStack(alignment: .bottomLeading) {
content
#if !os(tvOS)
.frame(minHeight: 0, maxHeight: .infinity)
.padding(.bottom, 50)
#endif
#if !os(tvOS)
controls
#endif
}
}
private var controls: some View {
HStack {
Button(action: {
model.presentingPlayer.toggle()
}) {
HStack {
if let item = model.currentItem {
HStack(spacing: 3) {
Text(item.video.title)
.fontWeight(.bold)
.foregroundColor(.accentColor)
.lineLimit(1)
Text("\(item.video.author)")
.fontWeight(.semibold)
.foregroundColor(.secondary)
.lineLimit(1)
}
} else {
Text("Not playing")
.foregroundColor(.secondary)
}
Spacer()
}
.padding(.vertical, 20)
.contentShape(Rectangle())
}
Group {
if model.isPlaying {
Button(action: {
model.pause()
}) {
Label("Pause", systemImage: "pause.fill")
}
} else {
Button(action: {
model.play()
}) {
Label("Play", systemImage: "play.fill")
}
.disabled(model.player.currentItem == nil)
}
}
.frame(minWidth: 30)
.scaleEffect(1.7)
#if !os(tvOS)
.keyboardShortcut("p")
#endif
Button(action: { model.advanceToNextItem() }) {
Label("Next", systemImage: "forward.fill")
}
.disabled(model.queue.isEmpty)
}
.buttonStyle(.plain)
.labelStyle(.iconOnly)
.padding(.horizontal)
.frame(minWidth: 0, maxWidth: .infinity)
.padding(.vertical, 0)
.background(.ultraThinMaterial)
.borderTop(height: 0.4, color: Color("PlayerControlsBorderColor"))
.borderBottom(height: navigationStyle == .sidebar ? 0 : 0.4, color: Color("PlayerControlsBorderColor"))
#if !os(tvOS)
.onSwipeGesture(up: {
model.presentingPlayer = true
})
#endif
}
}
struct PlayerControlsView_Previews: PreviewProvider {
static var previews: some View {
PlayerControlsView {
VStack {
Spacer()
Text("Hello")
Spacer()
}
}
.injectFixtureEnvironmentObjects()
}
}

View File

@@ -9,9 +9,11 @@ struct PlaylistVideosView: View {
}
var body: some View {
VideosCellsVertical(videos: playlist.videos)
#if !os(tvOS)
.navigationTitle("\(playlist.title) Playlist")
#endif
PlayerControlsView {
VideosCellsVertical(videos: playlist.videos)
#if !os(tvOS)
.navigationTitle("\(playlist.title) Playlist")
#endif
}
}
}

View File

@@ -11,13 +11,15 @@ struct PopularView: View {
}
var body: some View {
VideosCellsVertical(videos: store.collection)
.onAppear {
resource.addObserver(store)
resource.loadIfNeeded()
}
#if !os(tvOS)
.navigationTitle("Popular")
#endif
PlayerControlsView {
VideosCellsVertical(videos: store.collection)
.onAppear {
resource.addObserver(store)
resource.loadIfNeeded()
}
#if !os(tvOS)
.navigationTitle("Popular")
#endif
}
}
}

View File

@@ -30,29 +30,31 @@ struct SearchView: View {
}
var body: some View {
VStack {
if showRecentQueries {
recentQueries
} else {
#if os(tvOS)
ScrollView(.vertical, showsIndicators: false) {
filtersHorizontalStack
PlayerControlsView {
VStack {
if showRecentQueries {
recentQueries
} else {
#if os(tvOS)
ScrollView(.vertical, showsIndicators: false) {
filtersHorizontalStack
VideosCellsHorizontal(videos: state.store.collection)
VideosCellsHorizontal(videos: state.store.collection)
}
.edgesIgnoringSafeArea(.horizontal)
#else
VideosCellsVertical(videos: state.store.collection)
#endif
if noResults {
Text("No results")
if searchFiltersActive {
Button("Reset search filters", action: resetFilters)
}
Spacer()
}
.edgesIgnoringSafeArea(.horizontal)
#else
VideosCellsVertical(videos: state.store.collection)
#endif
if noResults {
Text("No results")
if searchFiltersActive {
Button("Reset search filters", action: resetFilters)
}
Spacer()
}
}
}

View File

@@ -56,6 +56,7 @@ struct SignInRequiredView<Content: View>: View {
openSettingsButton
#endif
}
.frame(minWidth: 0, maxWidth: .infinity, alignment: .center)
}
var openSettingsButton: some View {
@@ -74,9 +75,12 @@ struct SignInRequiredView<Content: View>: View {
struct SignInRequiredView_Previews: PreviewProvider {
static var previews: some View {
SignInRequiredView(title: "Subscriptions") {
Text("Only when signed in")
PlayerControlsView {
SignInRequiredView(title: "Subscriptions") {
Text("Only when signed in")
}
}
.environmentObject(PlayerModel())
.environmentObject(InvidiousAPI())
}
}

View File

@@ -11,17 +11,19 @@ struct SubscriptionsView: View {
}
var body: some View {
SignInRequiredView(title: "Subscriptions") {
VideosCellsVertical(videos: store.collection)
.onAppear {
loadResources()
}
.onChange(of: api.account) { _ in
loadResources(force: true)
}
.onChange(of: feed) { _ in
loadResources(force: true)
}
PlayerControlsView {
SignInRequiredView(title: "Subscriptions") {
VideosCellsVertical(videos: store.collection)
.onAppear {
loadResources()
}
.onChange(of: api.account) { _ in
loadResources(force: true)
}
.onChange(of: feed) { _ in
loadResources(force: true)
}
}
}
.refreshable {
loadResources(force: true)

View File

@@ -2,26 +2,72 @@ import Defaults
import SwiftUI
struct VideoContextMenuView: View {
let video: Video
@Binding var playerNavigationLinkActive: Bool
@Environment(\.inNavigationView) private var inNavigationView
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<PlaylistsModel> private var playlists
@EnvironmentObject<RecentsModel> private var recents
@EnvironmentObject<SubscriptionsModel> private var subscriptions
let video: Video
var body: some View {
openChannelButton
subscriptionButton
if navigation.tabSelection != .playlists {
addToPlaylistButton
} else if let playlist = playlists.currentPlaylist {
removeFromPlaylistButton(playlistID: playlist.id)
Section {
playNowButton
}
Section {
playNextButton
addToQueueButton
}
if case let .playlist(id) = navigation.tabSelection {
removeFromPlaylistButton(playlistID: id)
Section {
openChannelButton
subscriptionButton
}
Section {
if navigation.tabSelection != .playlists {
addToPlaylistButton
} else if let playlist = playlists.currentPlaylist {
removeFromPlaylistButton(playlistID: playlist.id)
}
if case let .playlist(id) = navigation.tabSelection {
removeFromPlaylistButton(playlistID: id)
}
}
}
var playNowButton: some View {
Button {
player.playNow(video)
if inNavigationView {
playerNavigationLinkActive = true
} else {
player.presentPlayer()
}
} label: {
Label("Play Now", systemImage: "play")
}
}
var playNextButton: some View {
Button {
player.playNext(video)
} label: {
Label("Play Next", systemImage: "text.insert")
}
}
var addToQueueButton: some View {
Button {
player.enqueueVideo(video)
} label: {
Label("Play Last", systemImage: "text.append")
}
}

View File

@@ -6,33 +6,35 @@ struct WatchNowView: View {
@EnvironmentObject<InvidiousAPI> private var api
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
if api.validInstance {
VStack(alignment: .leading, spacing: 0) {
if api.signedIn {
WatchNowSection(resource: api.feed, label: "Subscriptions")
}
WatchNowSection(resource: api.popular, label: "Popular")
WatchNowSection(resource: api.trending(category: .default, country: .pl), label: "Trending")
WatchNowSection(resource: api.trending(category: .movies, country: .pl), label: "Movies")
WatchNowSection(resource: api.trending(category: .music, country: .pl), label: "Music")
PlayerControlsView {
ScrollView(.vertical, showsIndicators: false) {
if api.validInstance {
VStack(alignment: .leading, spacing: 0) {
if api.signedIn {
WatchNowSection(resource: api.feed, label: "Subscriptions")
}
WatchNowSection(resource: api.popular, label: "Popular")
WatchNowSection(resource: api.trending(category: .default, country: .pl), label: "Trending")
WatchNowSection(resource: api.trending(category: .movies, country: .pl), label: "Movies")
WatchNowSection(resource: api.trending(category: .music, country: .pl), label: "Music")
// TODO: adding sections to view
// ===================
// WatchNowPlaylistSection(id: "IVPLmRFYLGYZpq61SpujNw3EKbzzGNvoDmH")
// WatchNowSection(resource: api.channelVideos("UCBJycsmduvYEL83R_U4JriQ"), label: "MKBHD")
}
}
}
#if os(tvOS)
.edgesIgnoringSafeArea(.horizontal)
#else
.navigationTitle("Watch Now")
#endif
#if os(macOS)
.background()
.frame(minWidth: 360)
#endif
}
#if os(tvOS)
.edgesIgnoringSafeArea(.horizontal)
#else
.navigationTitle("Watch Now")
#endif
#if os(macOS)
.background()
.frame(minWidth: 360)
#endif
}
}