mirror of
https://github.com/yattee/yattee.git
synced 2025-08-05 02:04:07 +00:00
Initial functionality of player items queue
Fix environment objects Hide video player placeholder on tvOS Queue improvements
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user