mirror of
https://github.com/yattee/yattee.git
synced 2025-12-08 17:18:14 +00:00
Initial functionality of player items queue
Fix environment objects Hide video player placeholder on tvOS Queue improvements
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
37
Shared/Player/PlayerQueueRow.swift
Normal file
37
Shared/Player/PlayerQueueRow.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
100
Shared/Player/PlayerQueueView.swift
Normal file
100
Shared/Player/PlayerQueueView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
71
Shared/Videos/VideoBanner.swift
Normal file
71
Shared/Videos/VideoBanner.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
109
Shared/Views/PlayerControlsView.swift
Normal file
109
Shared/Views/PlayerControlsView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user