mirror of
https://github.com/yattee/yattee.git
synced 2024-12-22 13:33:42 +00:00
Initial functionality of player items queue
Fix environment objects Hide video player placeholder on tvOS Queue improvements
This commit is contained in:
parent
d6b3c6637d
commit
70c089e696
16
Extensions/View+Borders.swift
Normal file
16
Extensions/View+Borders.swift
Normal file
@ -0,0 +1,16 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
func borderTop(height: Double, color: Color = Color(white: 0.7, opacity: 1)) -> some View {
|
||||
verticalEdgeBorder(.top, height: height, color: color)
|
||||
}
|
||||
|
||||
func borderBottom(height: Double, color: Color = Color(white: 0.7, opacity: 1)) -> some View {
|
||||
verticalEdgeBorder(.bottom, height: height, color: color)
|
||||
}
|
||||
|
||||
private func verticalEdgeBorder(_ edge: Alignment, height: Double, color: Color) -> some View {
|
||||
overlay(Rectangle().frame(width: nil, height: height, alignment: .top).foregroundColor(color), alignment: edge)
|
||||
}
|
||||
}
|
@ -5,7 +5,7 @@ extension Video {
|
||||
let id = "D2sxamzaHkM"
|
||||
|
||||
return Video(
|
||||
id: UUID().uuidString,
|
||||
videoID: UUID().uuidString,
|
||||
title: "Relaxing Piano Music that will make you feel amazingly good",
|
||||
author: "Fancy Videotuber",
|
||||
length: 582,
|
||||
|
@ -7,11 +7,11 @@ struct FixtureEnvironmentObjectsModifier: ViewModifier {
|
||||
.environmentObject(InstancesModel())
|
||||
.environmentObject(api)
|
||||
.environmentObject(NavigationModel())
|
||||
.environmentObject(PlaybackModel())
|
||||
.environmentObject(player)
|
||||
.environmentObject(PlaylistsModel())
|
||||
.environmentObject(RecentsModel())
|
||||
.environmentObject(SearchModel())
|
||||
.environmentObject(SubscriptionsModel(api: api))
|
||||
.environmentObject(subscriptions)
|
||||
}
|
||||
|
||||
private var api: InvidiousAPI {
|
||||
@ -22,6 +22,24 @@ struct FixtureEnvironmentObjectsModifier: ViewModifier {
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
private var player: PlayerModel {
|
||||
let player = PlayerModel()
|
||||
|
||||
player.currentItem = PlayerQueueItem(Video.fixture)
|
||||
player.queue = Video.allFixtures.map { PlayerQueueItem($0) }
|
||||
player.history = player.queue
|
||||
|
||||
return player
|
||||
}
|
||||
|
||||
private var subscriptions: SubscriptionsModel {
|
||||
let subscriptions = SubscriptionsModel()
|
||||
|
||||
subscriptions.channels = Video.allFixtures.map { $0.channel }
|
||||
|
||||
return subscriptions
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
|
@ -3,14 +3,20 @@ import SwiftUI
|
||||
|
||||
final class NavigationModel: ObservableObject {
|
||||
enum TabSelection: Hashable {
|
||||
case watchNow, subscriptions, popular, trending, playlists, channel(String), playlist(String), recentlyOpened(String), search
|
||||
case watchNow
|
||||
case subscriptions
|
||||
case popular
|
||||
case trending
|
||||
case playlists
|
||||
case channel(String)
|
||||
case playlist(String)
|
||||
case recentlyOpened(String)
|
||||
case nowPlaying
|
||||
case search
|
||||
}
|
||||
|
||||
@Published var tabSelection: TabSelection! = .watchNow
|
||||
|
||||
@Published var showingVideo = false
|
||||
@Published var video: Video?
|
||||
|
||||
@Published var presentingAddToPlaylist = false
|
||||
@Published var videoToAddToPlaylist: Video!
|
||||
|
||||
@ -25,11 +31,6 @@ final class NavigationModel: ObservableObject {
|
||||
|
||||
@Published var presentingSettings = false
|
||||
|
||||
func playVideo(_ video: Video) {
|
||||
self.video = video
|
||||
showingVideo = true
|
||||
}
|
||||
|
||||
var tabSelectionBinding: Binding<TabSelection> {
|
||||
Binding<TabSelection>(
|
||||
get: {
|
||||
|
@ -1,31 +0,0 @@
|
||||
import CoreMedia
|
||||
import Foundation
|
||||
|
||||
final class PlaybackModel: ObservableObject {
|
||||
@Published var live = false
|
||||
@Published var stream: Stream?
|
||||
@Published var time: CMTime?
|
||||
|
||||
var aspectRatio: Double? {
|
||||
let tracks = stream?.videoAsset.tracks(withMediaType: .video)
|
||||
|
||||
guard tracks != nil else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let size: CGSize! = tracks!.first.flatMap {
|
||||
tracks!.isEmpty ? nil : $0.naturalSize.applying($0.preferredTransform)
|
||||
}
|
||||
|
||||
guard size != nil else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return size.width / size.height
|
||||
}
|
||||
|
||||
func reset() {
|
||||
stream = nil
|
||||
time = nil
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import AVFoundation
|
||||
import AVKit
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Logging
|
||||
#if !os(macOS)
|
||||
@ -8,127 +9,114 @@ import Logging
|
||||
final class PlayerModel: ObservableObject {
|
||||
let logger = Logger(label: "net.arekf.Pearvidious.ps")
|
||||
|
||||
var video: Video!
|
||||
private(set) var player = AVPlayer()
|
||||
var controller: PlayerViewController?
|
||||
#if os(tvOS)
|
||||
var avPlayerViewController: AVPlayerViewController?
|
||||
#endif
|
||||
|
||||
var player: AVPlayer!
|
||||
@Published var presentingPlayer = false
|
||||
|
||||
private var compositions = [Stream: AVMutableComposition]()
|
||||
@Published var stream: Stream?
|
||||
@Published var currentRate: Float?
|
||||
|
||||
private(set) var savedTime: CMTime?
|
||||
@Published var queue = [PlayerQueueItem]()
|
||||
@Published var currentItem: PlayerQueueItem!
|
||||
@Published var live = false
|
||||
@Published var time: CMTime?
|
||||
|
||||
private(set) var currentRate: Float = 0.0
|
||||
static let availableRates: [Double] = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]
|
||||
@Published var history = [PlayerQueueItem]()
|
||||
|
||||
var api: InvidiousAPI
|
||||
var playback: PlaybackModel
|
||||
var timeObserver: Any?
|
||||
|
||||
let resolution: Stream.ResolutionSetting?
|
||||
private var statusObservation: NSKeyValueObservation?
|
||||
|
||||
var playingOutsideViewController = false
|
||||
|
||||
init(_ video: Video? = nil, playback: PlaybackModel, api: InvidiousAPI, resolution: Stream.ResolutionSetting? = nil) {
|
||||
self.video = video
|
||||
self.playback = playback
|
||||
self.api = api
|
||||
self.resolution = resolution
|
||||
var isPlaying: Bool {
|
||||
stream != nil && currentRate != 0.0
|
||||
}
|
||||
|
||||
deinit {
|
||||
destroyPlayer()
|
||||
init(api: InvidiousAPI? = nil) {
|
||||
self.api = api ?? InvidiousAPI()
|
||||
addItemDidPlayToEndTimeObserver()
|
||||
}
|
||||
|
||||
func loadVideo(_ video: Video?) {
|
||||
guard video != nil else {
|
||||
func presentPlayer() {
|
||||
presentingPlayer = true
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
isPlaying ? pause() : play()
|
||||
}
|
||||
|
||||
func play() {
|
||||
guard !isPlaying else {
|
||||
return
|
||||
}
|
||||
|
||||
playback.reset()
|
||||
|
||||
loadExtendedVideoDetails(video) { video in
|
||||
self.video = video
|
||||
self.playVideo(video)
|
||||
}
|
||||
player.play()
|
||||
}
|
||||
|
||||
func loadExtendedVideoDetails(_ video: Video?, onSuccess: @escaping (Video) -> Void) {
|
||||
guard video != nil else {
|
||||
func pause() {
|
||||
guard isPlaying else {
|
||||
return
|
||||
}
|
||||
|
||||
api.video(video!.id).load().onSuccess { response in
|
||||
if let video: Video = response.typedContent() {
|
||||
onSuccess(video)
|
||||
}
|
||||
}
|
||||
player.pause()
|
||||
}
|
||||
|
||||
var requestedResolution: Bool {
|
||||
resolution != nil && resolution != .hd720pFirstThenBest
|
||||
}
|
||||
|
||||
fileprivate func playVideo(_ video: Video) {
|
||||
playback.live = video.live
|
||||
|
||||
func playVideo(_ video: Video) {
|
||||
if video.live {
|
||||
playHlsUrl()
|
||||
playHlsUrl(video)
|
||||
return
|
||||
}
|
||||
|
||||
let stream = requestedResolution ? video.streamWithResolution(resolution!.value) : video.defaultStream
|
||||
|
||||
guard stream != nil else {
|
||||
guard let stream = video.streamWithResolution(Defaults[.quality].value) ?? video.defaultStream else {
|
||||
return
|
||||
}
|
||||
|
||||
if stream.oneMeaningfullAsset {
|
||||
playStream(stream, for: video)
|
||||
} else {
|
||||
Task {
|
||||
await self.loadStream(stream!)
|
||||
|
||||
if resolution == .hd720pFirstThenBest {
|
||||
await self.loadBestStream()
|
||||
await playComposition(video, for: stream)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func playHlsUrl() {
|
||||
player.replaceCurrentItem(with: playerItemWithMetadata())
|
||||
private func playHlsUrl(_ video: Video) {
|
||||
player.replaceCurrentItem(with: playerItemWithMetadata(video))
|
||||
player.playImmediately(atRate: 1.0)
|
||||
}
|
||||
|
||||
fileprivate func loadStream(_ stream: Stream) async {
|
||||
if stream.oneMeaningfullAsset {
|
||||
playStream(stream)
|
||||
|
||||
return
|
||||
} else {
|
||||
await playComposition(for: stream)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func playStream(_ stream: Stream) {
|
||||
guard player != nil else {
|
||||
return
|
||||
}
|
||||
|
||||
private func playStream(_ stream: Stream, for video: Video) {
|
||||
logger.warning("loading \(stream.description) to player")
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.saveTime()
|
||||
self.player?.replaceCurrentItem(with: self.playerItemWithMetadata(for: stream))
|
||||
self.playback.stream = stream
|
||||
if self.timeObserver.isNil {
|
||||
self.addTimeObserver()
|
||||
let playerItem: AVPlayerItem! = playerItemWithMetadata(video, for: stream)
|
||||
guard playerItem != nil else {
|
||||
return
|
||||
}
|
||||
self.player?.play()
|
||||
self.seekToSavedTime()
|
||||
|
||||
if let index = queue.firstIndex(where: { $0.video.id == video.id }) {
|
||||
queue[index].playerItems.append(playerItem)
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.stream = stream
|
||||
self.player.replaceCurrentItem(with: playerItem)
|
||||
}
|
||||
|
||||
if timeObserver.isNil {
|
||||
addTimeObserver()
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func playComposition(for stream: Stream) async {
|
||||
private func playComposition(_ video: Video, for stream: Stream) async {
|
||||
async let assetAudioTrack = stream.audioAsset.loadTracks(withMediaType: .audio)
|
||||
async let assetVideoTrack = stream.videoAsset.loadTracks(withMediaType: .video)
|
||||
|
||||
if let audioTrack = composition(for: stream).addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid),
|
||||
logger.info("loading audio track")
|
||||
if let audioTrack = composition(video, for: stream)?.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid),
|
||||
let assetTrack = try? await assetAudioTrack.first
|
||||
{
|
||||
try! audioTrack.insertTimeRange(
|
||||
@ -138,10 +126,11 @@ final class PlayerModel: ObservableObject {
|
||||
)
|
||||
logger.critical("audio loaded")
|
||||
} else {
|
||||
fatalError("no track")
|
||||
logger.critical("NO audio track")
|
||||
}
|
||||
|
||||
if let videoTrack = composition(for: stream).addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid),
|
||||
logger.info("loading video track")
|
||||
if let videoTrack = composition(video, for: stream)?.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid),
|
||||
let assetTrack = try? await assetVideoTrack.first
|
||||
{
|
||||
try! videoTrack.insertTimeRange(
|
||||
@ -150,27 +139,35 @@ final class PlayerModel: ObservableObject {
|
||||
at: .zero
|
||||
)
|
||||
logger.critical("video loaded")
|
||||
|
||||
playStream(stream)
|
||||
playStream(stream, for: video)
|
||||
} else {
|
||||
fatalError("no track")
|
||||
logger.critical("NO video track")
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func playerItem(for stream: Stream? = nil) -> AVPlayerItem {
|
||||
private func playerItem(_ video: Video, for stream: Stream? = nil) -> AVPlayerItem? {
|
||||
if stream != nil {
|
||||
if stream!.oneMeaningfullAsset {
|
||||
return AVPlayerItem(asset: stream!.videoAsset, automaticallyLoadedAssetKeys: [.isPlayable])
|
||||
logger.info("stream has one meaningfull asset")
|
||||
return AVPlayerItem(asset: AVURLAsset(url: stream!.videoAsset.url))
|
||||
}
|
||||
if let composition = composition(video, for: stream!) {
|
||||
logger.info("stream has MANY assets, using composition")
|
||||
return AVPlayerItem(asset: composition)
|
||||
} else {
|
||||
return AVPlayerItem(asset: composition(for: stream!))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return AVPlayerItem(url: video.hlsUrl!)
|
||||
}
|
||||
|
||||
fileprivate func playerItemWithMetadata(for stream: Stream? = nil) -> AVPlayerItem {
|
||||
let playerItemWithMetadata = playerItem(for: stream)
|
||||
private func playerItemWithMetadata(_ video: Video, for stream: Stream? = nil) -> AVPlayerItem? {
|
||||
logger.info("building player item metadata")
|
||||
let playerItemWithMetadata: AVPlayerItem! = playerItem(video, for: stream)
|
||||
guard playerItemWithMetadata != nil else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var externalMetadata = [
|
||||
makeMetadataItem(.commonIdentifierTitle, value: video.title),
|
||||
@ -179,7 +176,7 @@ final class PlayerModel: ObservableObject {
|
||||
]
|
||||
|
||||
#if !os(macOS)
|
||||
if let thumbnailData = try? Data(contentsOf: video.thumbnailURL(quality: .high)!),
|
||||
if let thumbnailData = try? Data(contentsOf: video.thumbnailURL(quality: .medium)!),
|
||||
let image = UIImage(data: thumbnailData),
|
||||
let pngData = image.pngData()
|
||||
{
|
||||
@ -190,92 +187,69 @@ final class PlayerModel: ObservableObject {
|
||||
playerItemWithMetadata.externalMetadata = externalMetadata
|
||||
#endif
|
||||
|
||||
playerItemWithMetadata.preferredForwardBufferDuration = 10
|
||||
playerItemWithMetadata.preferredForwardBufferDuration = 15
|
||||
|
||||
statusObservation?.invalidate()
|
||||
statusObservation = playerItemWithMetadata.observe(\.status, options: [.old, .new]) { playerItem, _ in
|
||||
switch playerItem.status {
|
||||
case .readyToPlay:
|
||||
if self.isAutoplaying(playerItem) {
|
||||
self.player.play()
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("item metadata retrieved")
|
||||
return playerItemWithMetadata
|
||||
}
|
||||
|
||||
func setPlayerRate(_ rate: Float) {
|
||||
currentRate = rate
|
||||
player.rate = rate
|
||||
func addItemDidPlayToEndTimeObserver() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(itemDidPlayToEndTime),
|
||||
name: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
fileprivate func composition(for stream: Stream) -> AVMutableComposition {
|
||||
if compositions[stream].isNil {
|
||||
compositions[stream] = AVMutableComposition()
|
||||
@objc func itemDidPlayToEndTime() {
|
||||
if queue.isEmpty {
|
||||
resetQueue()
|
||||
#if os(tvOS)
|
||||
avPlayerViewController!.dismiss(animated: true) {
|
||||
self.controller!.dismiss(animated: true)
|
||||
}
|
||||
|
||||
return compositions[stream]!
|
||||
}
|
||||
|
||||
fileprivate func loadBestStream() async {
|
||||
if let bestStream = video.bestStream {
|
||||
await loadStream(bestStream)
|
||||
#endif
|
||||
presentingPlayer = false
|
||||
} else {
|
||||
advanceToNextItem()
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func saveTime() {
|
||||
guard player != nil else {
|
||||
return
|
||||
private func composition(_ video: Video, for stream: Stream) -> AVMutableComposition? {
|
||||
if let index = queue.firstIndex(where: { $0.video == video }) {
|
||||
if queue[index].compositions[stream].isNil {
|
||||
queue[index].compositions[stream] = AVMutableComposition()
|
||||
}
|
||||
return queue[index].compositions[stream]!
|
||||
}
|
||||
|
||||
let currentTime = player.currentTime()
|
||||
|
||||
guard currentTime.seconds > 0 else {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
savedTime = currentTime
|
||||
}
|
||||
|
||||
fileprivate func seekToSavedTime() {
|
||||
guard player != nil else {
|
||||
return
|
||||
}
|
||||
|
||||
if let time = savedTime {
|
||||
logger.info("seeking to \(time.seconds)")
|
||||
player.seek(to: time, toleranceBefore: CMTime.zero, toleranceAfter: CMTime.zero)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func destroyPlayer() {
|
||||
logger.critical("destroying player")
|
||||
|
||||
guard !playingOutsideViewController else {
|
||||
logger.critical("cannot destroy, playing outside view controller")
|
||||
return
|
||||
}
|
||||
|
||||
player?.currentItem?.tracks.forEach { $0.assetTrack?.asset?.cancelLoading() }
|
||||
|
||||
player?.replaceCurrentItem(with: nil)
|
||||
|
||||
if timeObserver != nil {
|
||||
player?.removeTimeObserver(timeObserver!)
|
||||
timeObserver = nil
|
||||
}
|
||||
|
||||
player = nil
|
||||
}
|
||||
|
||||
fileprivate func addTimeObserver() {
|
||||
let interval = CMTime(value: 1, timescale: 1)
|
||||
private func addTimeObserver() {
|
||||
let interval = CMTime(seconds: 0.5, preferredTimescale: 1000)
|
||||
|
||||
timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { _ in
|
||||
guard self.player != nil else {
|
||||
return
|
||||
}
|
||||
|
||||
if self.player.rate != self.currentRate, self.player.rate != 0, self.currentRate != 0 {
|
||||
self.player.rate = self.currentRate
|
||||
}
|
||||
|
||||
self.playback.time = self.player.currentTime()
|
||||
self.currentRate = self.player.rate
|
||||
self.live = self.currentVideo?.live ?? false
|
||||
self.time = self.player.currentTime()
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func makeMetadataItem(_ identifier: AVMetadataIdentifier, value: Any) -> AVMetadataItem {
|
||||
private func makeMetadataItem(_ identifier: AVMetadataIdentifier, value: Any) -> AVMetadataItem {
|
||||
let item = AVMutableMetadataItem()
|
||||
|
||||
item.identifier = identifier
|
||||
|
145
Model/PlayerQueue.swift
Normal file
145
Model/PlayerQueue.swift
Normal file
@ -0,0 +1,145 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
extension PlayerModel {
|
||||
var currentVideo: Video? {
|
||||
currentItem?.video
|
||||
}
|
||||
|
||||
func playAll(_ videos: [Video]) {
|
||||
let first = videos.first
|
||||
|
||||
videos.forEach { video in
|
||||
enqueueVideo(video) { _, item in
|
||||
if item.video == first {
|
||||
self.advanceToItem(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func playNext(_ video: Video) {
|
||||
enqueueVideo(video, prepending: true) { _, item in
|
||||
if self.currentItem == nil {
|
||||
self.advanceToItem(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func playNow(_ video: Video) {
|
||||
addCurrentItemToHistory()
|
||||
|
||||
enqueueVideo(video, prepending: true) { _, item in
|
||||
self.advanceToItem(item)
|
||||
}
|
||||
}
|
||||
|
||||
func playItem(_ item: PlayerQueueItem, video: Video? = nil) {
|
||||
currentItem = item
|
||||
|
||||
if video != nil {
|
||||
currentItem.video = video!
|
||||
}
|
||||
|
||||
playVideo(currentItem.video)
|
||||
}
|
||||
|
||||
func advanceToNextItem() {
|
||||
addCurrentItemToHistory()
|
||||
|
||||
if let nextItem = queue.first {
|
||||
advanceToItem(nextItem)
|
||||
}
|
||||
}
|
||||
|
||||
func advanceToItem(_ newItem: PlayerQueueItem) {
|
||||
let item = remove(newItem)!
|
||||
loadDetails(newItem.video) { video in
|
||||
self.playItem(item, video: video)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult func remove(_ item: PlayerQueueItem) -> PlayerQueueItem? {
|
||||
if let index = queue.firstIndex(where: { $0 == item }) {
|
||||
return queue.remove(at: index)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func resetQueue() {
|
||||
DispatchQueue.main.async {
|
||||
self.currentItem = nil
|
||||
self.stream = nil
|
||||
self.removeQueueItems()
|
||||
self.timeObserver = nil
|
||||
}
|
||||
|
||||
player.replaceCurrentItem(with: nil)
|
||||
}
|
||||
|
||||
func isAutoplaying(_ item: AVPlayerItem) -> Bool {
|
||||
player.currentItem == item
|
||||
}
|
||||
|
||||
@discardableResult func enqueueVideo(
|
||||
_ video: Video,
|
||||
play: Bool = false,
|
||||
prepending: Bool = false,
|
||||
videoDetailsLoadHandler: @escaping (Video, PlayerQueueItem) -> Void = { _, _ in }
|
||||
) -> PlayerQueueItem? {
|
||||
let item = PlayerQueueItem(video)
|
||||
|
||||
queue.insert(item, at: prepending ? 0 : queue.endIndex)
|
||||
|
||||
loadDetails(video) { video in
|
||||
videoDetailsLoadHandler(video, item)
|
||||
|
||||
if play {
|
||||
self.playItem(item, video: video)
|
||||
}
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
private func loadDetails(_ video: Video?, onSuccess: @escaping (Video) -> Void) {
|
||||
guard video != nil else {
|
||||
return
|
||||
}
|
||||
|
||||
if !video!.streams.isEmpty {
|
||||
logger.critical("not loading video details again")
|
||||
onSuccess(video!)
|
||||
return
|
||||
}
|
||||
|
||||
api.video(video!.videoID).load().onSuccess { response in
|
||||
if let video: Video = response.typedContent() {
|
||||
onSuccess(video)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addCurrentItemToHistory() {
|
||||
if let item = currentItem, !history.contains(where: { $0.video.videoID == item.video.videoID }) {
|
||||
history.insert(item, at: 0)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult func removeHistory(_ item: PlayerQueueItem) -> PlayerQueueItem? {
|
||||
if let index = history.firstIndex(where: { $0 == item }) {
|
||||
return history.remove(at: index)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeQueueItems() {
|
||||
queue.removeAll()
|
||||
}
|
||||
|
||||
func removeHistoryItems() {
|
||||
history.removeAll()
|
||||
}
|
||||
}
|
14
Model/PlayerQueueItem.swift
Normal file
14
Model/PlayerQueueItem.swift
Normal file
@ -0,0 +1,14 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
struct PlayerQueueItem: Hashable, Identifiable {
|
||||
var id = UUID()
|
||||
var video: Video
|
||||
|
||||
init(_ video: Video) {
|
||||
self.video = video
|
||||
}
|
||||
|
||||
var playerItems = [AVPlayerItem]()
|
||||
var compositions = [Stream: AVMutableComposition]()
|
||||
}
|
@ -84,7 +84,7 @@ class Stream: Equatable, Hashable {
|
||||
}
|
||||
|
||||
var oneMeaningfullAsset: Bool {
|
||||
assets.dropFirst().allSatisfy { $0 == assets.first }
|
||||
assets.dropFirst().allSatisfy { $0.url == assets.first!.url }
|
||||
}
|
||||
|
||||
static func == (lhs: Stream, rhs: Stream) -> Bool {
|
||||
|
@ -3,8 +3,9 @@ import AVKit
|
||||
import Foundation
|
||||
import SwiftyJSON
|
||||
|
||||
struct Video: Identifiable, Equatable {
|
||||
struct Video: Identifiable, Equatable, Hashable {
|
||||
let id: String
|
||||
let videoID: String
|
||||
var title: String
|
||||
var thumbnails: [Thumbnail]
|
||||
var author: String
|
||||
@ -31,7 +32,8 @@ struct Video: Identifiable, Equatable {
|
||||
var channel: Channel
|
||||
|
||||
init(
|
||||
id: String,
|
||||
id: String? = nil,
|
||||
videoID: String,
|
||||
title: String,
|
||||
author: String,
|
||||
length: TimeInterval,
|
||||
@ -49,7 +51,8 @@ struct Video: Identifiable, Equatable {
|
||||
dislikes: Int? = nil,
|
||||
keywords: [String] = []
|
||||
) {
|
||||
self.id = id
|
||||
self.id = id ?? UUID().uuidString
|
||||
self.videoID = videoID
|
||||
self.title = title
|
||||
self.author = author
|
||||
self.length = length
|
||||
@ -69,7 +72,7 @@ struct Video: Identifiable, Equatable {
|
||||
}
|
||||
|
||||
init(_ json: JSON) {
|
||||
let videoID = json["videoId"].stringValue
|
||||
videoID = json["videoId"].stringValue
|
||||
|
||||
if let id = json["indexId"].string {
|
||||
indexID = id
|
||||
@ -206,4 +209,8 @@ struct Video: Identifiable, Equatable {
|
||||
static func == (lhs: Video, rhs: Video) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
|
@ -55,6 +55,10 @@
|
||||
372915E62687E3B900F5A35B /* Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 372915E52687E3B900F5A35B /* Defaults.swift */; };
|
||||
372915E72687E3B900F5A35B /* Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 372915E52687E3B900F5A35B /* Defaults.swift */; };
|
||||
372915E82687E3B900F5A35B /* Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 372915E52687E3B900F5A35B /* Defaults.swift */; };
|
||||
3730D8A02712E2B70020ED53 /* NowPlayingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3730D89F2712E2B70020ED53 /* NowPlayingView.swift */; };
|
||||
37319F0527103F94004ECCD0 /* PlayerQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37319F0427103F94004ECCD0 /* PlayerQueue.swift */; };
|
||||
37319F0627103F94004ECCD0 /* PlayerQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37319F0427103F94004ECCD0 /* PlayerQueue.swift */; };
|
||||
37319F0727103F94004ECCD0 /* PlayerQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37319F0427103F94004ECCD0 /* PlayerQueue.swift */; };
|
||||
373CFACB26966264003CB2C6 /* SearchQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFACA26966264003CB2C6 /* SearchQuery.swift */; };
|
||||
373CFACC26966264003CB2C6 /* SearchQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFACA26966264003CB2C6 /* SearchQuery.swift */; };
|
||||
373CFACD26966264003CB2C6 /* SearchQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFACA26966264003CB2C6 /* SearchQuery.swift */; };
|
||||
@ -66,6 +70,14 @@
|
||||
373CFAEF2697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAEE2697A78B003CB2C6 /* AddToPlaylistView.swift */; };
|
||||
373CFAF02697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAEE2697A78B003CB2C6 /* AddToPlaylistView.swift */; };
|
||||
373CFAF12697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373CFAEE2697A78B003CB2C6 /* AddToPlaylistView.swift */; };
|
||||
3743CA4A270EF79400E4D32B /* SwiftUIKit in Frameworks */ = {isa = PBXBuildFile; productRef = 3743CA49270EF79400E4D32B /* SwiftUIKit */; };
|
||||
3743CA4C270EF7A500E4D32B /* SwiftUIKit in Frameworks */ = {isa = PBXBuildFile; productRef = 3743CA4B270EF7A500E4D32B /* SwiftUIKit */; };
|
||||
3743CA4E270EFE3400E4D32B /* PlayerQueueRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3743CA4D270EFE3400E4D32B /* PlayerQueueRow.swift */; };
|
||||
3743CA4F270EFE3400E4D32B /* PlayerQueueRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3743CA4D270EFE3400E4D32B /* PlayerQueueRow.swift */; };
|
||||
3743CA50270EFE3400E4D32B /* PlayerQueueRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3743CA4D270EFE3400E4D32B /* PlayerQueueRow.swift */; };
|
||||
3743CA52270F284F00E4D32B /* View+Borders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3743CA51270F284F00E4D32B /* View+Borders.swift */; };
|
||||
3743CA53270F284F00E4D32B /* View+Borders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3743CA51270F284F00E4D32B /* View+Borders.swift */; };
|
||||
3743CA54270F284F00E4D32B /* View+Borders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3743CA51270F284F00E4D32B /* View+Borders.swift */; };
|
||||
3748186626A7627F0084E870 /* Video+Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3748186526A7627F0084E870 /* Video+Fixtures.swift */; };
|
||||
3748186726A7627F0084E870 /* Video+Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3748186526A7627F0084E870 /* Video+Fixtures.swift */; };
|
||||
3748186826A7627F0084E870 /* Video+Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3748186526A7627F0084E870 /* Video+Fixtures.swift */; };
|
||||
@ -193,9 +205,6 @@
|
||||
37B81B0026D2CA3700675966 /* VideoDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B81AFE26D2CA3700675966 /* VideoDetails.swift */; };
|
||||
37B81B0226D2CAE700675966 /* PlaybackBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B81B0126D2CAE700675966 /* PlaybackBar.swift */; };
|
||||
37B81B0326D2CAE700675966 /* PlaybackBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B81B0126D2CAE700675966 /* PlaybackBar.swift */; };
|
||||
37B81B0526D2CEDA00675966 /* PlaybackModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B81B0426D2CEDA00675966 /* PlaybackModel.swift */; };
|
||||
37B81B0626D2CEDA00675966 /* PlaybackModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B81B0426D2CEDA00675966 /* PlaybackModel.swift */; };
|
||||
37B81B0726D2D6CF00675966 /* PlaybackModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B81B0426D2CEDA00675966 /* PlaybackModel.swift */; };
|
||||
37BA793B26DB8EE4002A0235 /* PlaylistVideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA793A26DB8EE4002A0235 /* PlaylistVideosView.swift */; };
|
||||
37BA793C26DB8EE4002A0235 /* PlaylistVideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA793A26DB8EE4002A0235 /* PlaylistVideosView.swift */; };
|
||||
37BA793D26DB8EE4002A0235 /* PlaylistVideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA793A26DB8EE4002A0235 /* PlaylistVideosView.swift */; };
|
||||
@ -245,6 +254,15 @@
|
||||
37C7A1D6267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */; };
|
||||
37C7A1D7267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */; };
|
||||
37C7A1DA267CACF50010EAD6 /* TrendingCountry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3705B17F267B4DFB00704544 /* TrendingCountry.swift */; };
|
||||
37CC3F45270CE30600608308 /* PlayerQueueItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CC3F44270CE30600608308 /* PlayerQueueItem.swift */; };
|
||||
37CC3F46270CE30600608308 /* PlayerQueueItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CC3F44270CE30600608308 /* PlayerQueueItem.swift */; };
|
||||
37CC3F47270CE30600608308 /* PlayerQueueItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CC3F44270CE30600608308 /* PlayerQueueItem.swift */; };
|
||||
37CC3F4C270CFE1700608308 /* PlayerQueueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CC3F4B270CFE1700608308 /* PlayerQueueView.swift */; };
|
||||
37CC3F4D270CFE1700608308 /* PlayerQueueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CC3F4B270CFE1700608308 /* PlayerQueueView.swift */; };
|
||||
37CC3F4E270CFE1700608308 /* PlayerQueueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CC3F4B270CFE1700608308 /* PlayerQueueView.swift */; };
|
||||
37CC3F50270D010D00608308 /* VideoBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CC3F4F270D010D00608308 /* VideoBanner.swift */; };
|
||||
37CC3F51270D010D00608308 /* VideoBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CC3F4F270D010D00608308 /* VideoBanner.swift */; };
|
||||
37CC3F52270D010D00608308 /* VideoBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CC3F4F270D010D00608308 /* VideoBanner.swift */; };
|
||||
37CEE4BD2677B670005A1EFE /* SingleAssetStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEE4BC2677B670005A1EFE /* SingleAssetStream.swift */; };
|
||||
37CEE4BE2677B670005A1EFE /* SingleAssetStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEE4BC2677B670005A1EFE /* SingleAssetStream.swift */; };
|
||||
37CEE4BF2677B670005A1EFE /* SingleAssetStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CEE4BC2677B670005A1EFE /* SingleAssetStream.swift */; };
|
||||
@ -266,6 +284,9 @@
|
||||
37D4B19826717E1500C925CA /* Video.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B19626717E1500C925CA /* Video.swift */; };
|
||||
37D4B19926717E1500C925CA /* Video.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D4B19626717E1500C925CA /* Video.swift */; };
|
||||
37D4B19D2671817900C925CA /* SwiftyJSON in Frameworks */ = {isa = PBXBuildFile; productRef = 37D4B19C2671817900C925CA /* SwiftyJSON */; };
|
||||
37E2EEAB270656EC00170416 /* PlayerControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E2EEAA270656EC00170416 /* PlayerControlsView.swift */; };
|
||||
37E2EEAC270656EC00170416 /* PlayerControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E2EEAA270656EC00170416 /* PlayerControlsView.swift */; };
|
||||
37E2EEAD270656EC00170416 /* PlayerControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E2EEAA270656EC00170416 /* PlayerControlsView.swift */; };
|
||||
37E64DD126D597EB00C71877 /* SubscriptionsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E64DD026D597EB00C71877 /* SubscriptionsModel.swift */; };
|
||||
37E64DD226D597EB00C71877 /* SubscriptionsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E64DD026D597EB00C71877 /* SubscriptionsModel.swift */; };
|
||||
37E64DD326D597EB00C71877 /* SubscriptionsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E64DD026D597EB00C71877 /* SubscriptionsModel.swift */; };
|
||||
@ -335,10 +356,14 @@
|
||||
37152EE926EFEB95004FB96D /* LazyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyView.swift; sourceTree = "<group>"; };
|
||||
371F2F19269B43D300E4A7AB /* NavigationModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationModel.swift; sourceTree = "<group>"; };
|
||||
372915E52687E3B900F5A35B /* Defaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Defaults.swift; sourceTree = "<group>"; };
|
||||
3730D89F2712E2B70020ED53 /* NowPlayingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlayingView.swift; sourceTree = "<group>"; };
|
||||
37319F0427103F94004ECCD0 /* PlayerQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerQueue.swift; sourceTree = "<group>"; };
|
||||
373CFACA26966264003CB2C6 /* SearchQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchQuery.swift; sourceTree = "<group>"; };
|
||||
373CFADA269663F1003CB2C6 /* Thumbnail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbnail.swift; sourceTree = "<group>"; };
|
||||
373CFAEA26975CBF003CB2C6 /* PlaylistFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistFormView.swift; sourceTree = "<group>"; };
|
||||
373CFAEE2697A78B003CB2C6 /* AddToPlaylistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddToPlaylistView.swift; sourceTree = "<group>"; };
|
||||
3743CA4D270EFE3400E4D32B /* PlayerQueueRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerQueueRow.swift; sourceTree = "<group>"; };
|
||||
3743CA51270F284F00E4D32B /* View+Borders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Borders.swift"; sourceTree = "<group>"; };
|
||||
3748186526A7627F0084E870 /* Video+Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Video+Fixtures.swift"; sourceTree = "<group>"; };
|
||||
3748186926A764FB0084E870 /* Thumbnail+Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Thumbnail+Fixtures.swift"; sourceTree = "<group>"; };
|
||||
3748186D26A769D60084E870 /* DetailBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailBadge.swift; sourceTree = "<group>"; };
|
||||
@ -384,7 +409,6 @@
|
||||
37B81AFB26D2C9C900675966 /* VideoDetailsPaddingModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDetailsPaddingModifier.swift; sourceTree = "<group>"; };
|
||||
37B81AFE26D2CA3700675966 /* VideoDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDetails.swift; sourceTree = "<group>"; };
|
||||
37B81B0126D2CAE700675966 /* PlaybackBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackBar.swift; sourceTree = "<group>"; };
|
||||
37B81B0426D2CEDA00675966 /* PlaybackModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackModel.swift; sourceTree = "<group>"; };
|
||||
37BA793A26DB8EE4002A0235 /* PlaylistVideosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistVideosView.swift; sourceTree = "<group>"; };
|
||||
37BA793E26DB8F97002A0235 /* ChannelVideosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelVideosView.swift; sourceTree = "<group>"; };
|
||||
37BA794226DBA973002A0235 /* PlaylistsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistsModel.swift; sourceTree = "<group>"; };
|
||||
@ -404,6 +428,9 @@
|
||||
37BE0BDB26A2367F0092E2DB /* Player.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Player.swift; sourceTree = "<group>"; };
|
||||
37C194C626F6A9C8005D3B96 /* RecentsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentsModel.swift; sourceTree = "<group>"; };
|
||||
37C7A1D4267BFD9D0010EAD6 /* SponsorBlockSegment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SponsorBlockSegment.swift; sourceTree = "<group>"; };
|
||||
37CC3F44270CE30600608308 /* PlayerQueueItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerQueueItem.swift; sourceTree = "<group>"; };
|
||||
37CC3F4B270CFE1700608308 /* PlayerQueueView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerQueueView.swift; sourceTree = "<group>"; };
|
||||
37CC3F4F270D010D00608308 /* VideoBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoBanner.swift; sourceTree = "<group>"; };
|
||||
37CEE4BC2677B670005A1EFE /* SingleAssetStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleAssetStream.swift; sourceTree = "<group>"; };
|
||||
37CEE4C02677B697005A1EFE /* Stream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stream.swift; sourceTree = "<group>"; };
|
||||
37D4B0C22671614700C925CA /* PearvidiousApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PearvidiousApp.swift; sourceTree = "<group>"; };
|
||||
@ -422,6 +449,7 @@
|
||||
37D4B18B26717B3800C925CA /* VideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoView.swift; sourceTree = "<group>"; };
|
||||
37D4B19626717E1500C925CA /* Video.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Video.swift; sourceTree = "<group>"; };
|
||||
37D4B1AE26729DEB00C925CA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
37E2EEAA270656EC00170416 /* PlayerControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerControlsView.swift; sourceTree = "<group>"; };
|
||||
37E64DD026D597EB00C71877 /* SubscriptionsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionsModel.swift; sourceTree = "<group>"; };
|
||||
37EAD86A267B9C5600D9E01B /* SponsorBlockAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SponsorBlockAPI.swift; sourceTree = "<group>"; };
|
||||
37EAD86E267B9ED100D9E01B /* Segment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Segment.swift; sourceTree = "<group>"; };
|
||||
@ -445,6 +473,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
3743CA4A270EF79400E4D32B /* SwiftUIKit in Frameworks */,
|
||||
37BD07B72698AB2E003EBB87 /* Defaults in Frameworks */,
|
||||
37BADCA52699FB72009BE4FB /* Alamofire in Frameworks */,
|
||||
377FC7D5267A080300A6BBAF /* SwiftyJSON in Frameworks */,
|
||||
@ -459,6 +488,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
37BD07BE2698AC96003EBB87 /* Defaults in Frameworks */,
|
||||
3743CA4C270EF7A500E4D32B /* SwiftUIKit in Frameworks */,
|
||||
37BADCA7269A552E009BE4FB /* Alamofire in Frameworks */,
|
||||
377FC7ED267A0A0800A6BBAF /* SwiftyJSON in Frameworks */,
|
||||
37BD07C02698AC97003EBB87 /* Siesta in Frameworks */,
|
||||
@ -522,6 +552,8 @@
|
||||
children = (
|
||||
37B81B0126D2CAE700675966 /* PlaybackBar.swift */,
|
||||
37BE0BD226A1D4780092E2DB /* Player.swift */,
|
||||
3743CA4D270EFE3400E4D32B /* PlayerQueueRow.swift */,
|
||||
37CC3F4B270CFE1700608308 /* PlayerQueueView.swift */,
|
||||
37BE0BD526A1D4A90092E2DB /* PlayerViewController.swift */,
|
||||
37B81AFE26D2CA3700675966 /* VideoDetails.swift */,
|
||||
37B81AFB26D2C9C900675966 /* VideoDetailsPaddingModifier.swift */,
|
||||
@ -554,6 +586,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3748186D26A769D60084E870 /* DetailBadge.swift */,
|
||||
37CC3F4F270D010D00608308 /* VideoBanner.swift */,
|
||||
37A9965926D6F8CA006E3224 /* VideosCellsHorizontal.swift */,
|
||||
37F4AE7126828F0900BD60EA /* VideosCellsVertical.swift */,
|
||||
37D4B18B26717B3800C925CA /* VideoView.swift */,
|
||||
@ -566,6 +599,7 @@
|
||||
children = (
|
||||
37BA793E26DB8F97002A0235 /* ChannelVideosView.swift */,
|
||||
37152EE926EFEB95004FB96D /* LazyView.swift */,
|
||||
37E2EEAA270656EC00170416 /* PlayerControlsView.swift */,
|
||||
37BA793A26DB8EE4002A0235 /* PlaylistVideosView.swift */,
|
||||
37AAF27D26737323007FC770 /* PopularView.swift */,
|
||||
37AAF27F26737550007FC770 /* SearchView.swift */,
|
||||
@ -670,6 +704,7 @@
|
||||
376578842685429C00D4EA09 /* CaseIterable+Next.swift */,
|
||||
37BA794E26DC3E0E002A0235 /* Int+Format.swift */,
|
||||
377A20A82693C9A2002842B8 /* TypedContentAccessors.swift */,
|
||||
3743CA51270F284F00E4D32B /* View+Borders.swift */,
|
||||
);
|
||||
path = Extensions;
|
||||
sourceTree = "<group>";
|
||||
@ -749,6 +784,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
37666BA927023AF000F869E5 /* AccountSelectionView.swift */,
|
||||
3730D89F2712E2B70020ED53 /* NowPlayingView.swift */,
|
||||
37BAB54B269B39FD00E75ED1 /* TVNavigationView.swift */,
|
||||
37D4B15E267164AF00C925CA /* Assets.xcassets */,
|
||||
37D4B1AE26729DEB00C925CA /* Info.plist */,
|
||||
@ -767,15 +803,16 @@
|
||||
37D4B1B72672CFE300C925CA /* Model */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
37484C3026FCB8F900287258 /* AccountValidator.swift */,
|
||||
37AAF28F26740715007FC770 /* Channel.swift */,
|
||||
37141672267A8E10006CA35D /* Country.swift */,
|
||||
378E50FA26FE8B9F00F49626 /* Instance.swift */,
|
||||
37484C3026FCB8F900287258 /* AccountValidator.swift */,
|
||||
375DFB5726F9DA010013F468 /* InstancesModel.swift */,
|
||||
37977582268922F600DD52A8 /* InvidiousAPI.swift */,
|
||||
371F2F19269B43D300E4A7AB /* NavigationModel.swift */,
|
||||
37B81B0426D2CEDA00675966 /* PlaybackModel.swift */,
|
||||
37B767DA2677C3CA0098BAA8 /* PlayerModel.swift */,
|
||||
37319F0427103F94004ECCD0 /* PlayerQueue.swift */,
|
||||
37CC3F44270CE30600608308 /* PlayerQueueItem.swift */,
|
||||
376578882685471400D4EA09 /* Playlist.swift */,
|
||||
37BA794226DBA973002A0235 /* PlaylistsModel.swift */,
|
||||
37C194C626F6A9C8005D3B96 /* RecentsModel.swift */,
|
||||
@ -832,6 +869,7 @@
|
||||
37D4B0C52671614900C925CA /* Sources */,
|
||||
37D4B0C62671614900C925CA /* Frameworks */,
|
||||
37D4B0C72671614900C925CA /* Resources */,
|
||||
37CC3F48270CE89B00608308 /* ShellScript */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@ -845,6 +883,7 @@
|
||||
37BD07B82698AB2E003EBB87 /* Siesta */,
|
||||
37BD07C62698B27B003EBB87 /* Introspect */,
|
||||
37BADCA42699FB72009BE4FB /* Alamofire */,
|
||||
3743CA49270EF79400E4D32B /* SwiftUIKit */,
|
||||
);
|
||||
productName = "Pearvidious (iOS)";
|
||||
productReference = 37D4B0C92671614900C925CA /* Pearvidious.app */;
|
||||
@ -857,6 +896,7 @@
|
||||
37D4B0CB2671614900C925CA /* Sources */,
|
||||
37D4B0CC2671614900C925CA /* Frameworks */,
|
||||
37D4B0CD2671614900C925CA /* Resources */,
|
||||
37CC3F4A270CE8D000608308 /* ShellScript */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@ -869,6 +909,7 @@
|
||||
37BD07BD2698AC96003EBB87 /* Defaults */,
|
||||
37BD07BF2698AC97003EBB87 /* Siesta */,
|
||||
37BADCA6269A552E009BE4FB /* Alamofire */,
|
||||
3743CA4B270EF7A500E4D32B /* SwiftUIKit */,
|
||||
);
|
||||
productName = "Pearvidious (macOS)";
|
||||
productReference = 37D4B0CF2671614900C925CA /* Pearvidious.app */;
|
||||
@ -917,6 +958,7 @@
|
||||
37D4B154267164AE00C925CA /* Sources */,
|
||||
37D4B155267164AE00C925CA /* Frameworks */,
|
||||
37D4B156267164AE00C925CA /* Resources */,
|
||||
37CC3F49270CE8CA00608308 /* ShellScript */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@ -1011,6 +1053,7 @@
|
||||
3797757B268922D100DD52A8 /* XCRemoteSwiftPackageReference "siesta" */,
|
||||
37BD07C52698B27B003EBB87 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */,
|
||||
37BADCA32699FB72009BE4FB /* XCRemoteSwiftPackageReference "Alamofire" */,
|
||||
3743CA48270EF79400E4D32B /* XCRemoteSwiftPackageReference "SwiftUIKit" */,
|
||||
);
|
||||
productRefGroup = 37D4B0CA2671614900C925CA /* Products */;
|
||||
projectDirPath = "";
|
||||
@ -1086,6 +1129,57 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
37CC3F48270CE89B00608308 /* ShellScript */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "if which swiftlint >/dev/null; then\n swiftlint\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n";
|
||||
};
|
||||
37CC3F49270CE8CA00608308 /* ShellScript */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "if which swiftlint >/dev/null; then\n swiftlint\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n";
|
||||
};
|
||||
37CC3F4A270CE8D000608308 /* ShellScript */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "if which swiftlint >/dev/null; then\n swiftlint\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n";
|
||||
};
|
||||
37FD43EA2704A2350073EE42 /* ShellScript */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@ -1137,8 +1231,10 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
37CEE4BD2677B670005A1EFE /* SingleAssetStream.swift in Sources */,
|
||||
37CC3F45270CE30600608308 /* PlayerQueueItem.swift in Sources */,
|
||||
37BD07C82698B71C003EBB87 /* AppTabNavigation.swift in Sources */,
|
||||
37EAD86B267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */,
|
||||
3743CA52270F284F00E4D32B /* View+Borders.swift in Sources */,
|
||||
3763495126DFF59D00B9A393 /* AppSidebarRecents.swift in Sources */,
|
||||
376CD21626FBE18D001E1AC1 /* Instance+Fixtures.swift in Sources */,
|
||||
37BA793B26DB8EE4002A0235 /* PlaylistVideosView.swift in Sources */,
|
||||
@ -1165,18 +1261,19 @@
|
||||
37BA794726DC2E56002A0235 /* AppSidebarSubscriptions.swift in Sources */,
|
||||
377FC7DC267A081800A6BBAF /* PopularView.swift in Sources */,
|
||||
37FD43DE2704717F0073EE42 /* DefaultAccountHint.swift in Sources */,
|
||||
37CC3F4C270CFE1700608308 /* PlayerQueueView.swift in Sources */,
|
||||
3705B182267B4E4900704544 /* TrendingCategory.swift in Sources */,
|
||||
37EAD86F267B9ED100D9E01B /* Segment.swift in Sources */,
|
||||
375168D62700FAFF008F96A6 /* Debounce.swift in Sources */,
|
||||
37E64DD126D597EB00C71877 /* SubscriptionsModel.swift in Sources */,
|
||||
376578892685471400D4EA09 /* Playlist.swift in Sources */,
|
||||
37B81B0526D2CEDA00675966 /* PlaybackModel.swift in Sources */,
|
||||
373CFADB269663F1003CB2C6 /* Thumbnail.swift in Sources */,
|
||||
3788AC2B26F6842D00F6BAA9 /* WatchNowSectionBody.swift in Sources */,
|
||||
3714166F267A8ACC006CA35D /* TrendingView.swift in Sources */,
|
||||
373CFAEF2697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */,
|
||||
37B044B726F7AB9000E1419D /* SettingsView.swift in Sources */,
|
||||
377FC7E3267A084A00A6BBAF /* VideoView.swift in Sources */,
|
||||
37E2EEAB270656EC00170416 /* PlayerControlsView.swift in Sources */,
|
||||
37BA794326DBA973002A0235 /* PlaylistsModel.swift in Sources */,
|
||||
37AAF29026740715007FC770 /* Channel.swift in Sources */,
|
||||
3748186A26A764FB0084E870 /* Thumbnail+Fixtures.swift in Sources */,
|
||||
@ -1187,7 +1284,9 @@
|
||||
37484C2D26FC844700287258 /* AccountsSettingsView.swift in Sources */,
|
||||
377A20A92693C9A2002842B8 /* TypedContentAccessors.swift in Sources */,
|
||||
37484C3126FCB8F900287258 /* AccountValidator.swift in Sources */,
|
||||
37319F0527103F94004ECCD0 /* PlayerQueue.swift in Sources */,
|
||||
376B2E0726F920D600B1D64D /* SignInRequiredView.swift in Sources */,
|
||||
3743CA4E270EFE3400E4D32B /* PlayerQueueRow.swift in Sources */,
|
||||
37BD672426F13D65004BE0C1 /* AppSidebarPlaylists.swift in Sources */,
|
||||
37B17DA2268A1F8A006AEE9B /* VideoContextMenuView.swift in Sources */,
|
||||
379775932689365600DD52A8 /* Array+Next.swift in Sources */,
|
||||
@ -1212,6 +1311,7 @@
|
||||
37484C2926FC83FF00287258 /* AccountFormView.swift in Sources */,
|
||||
371F2F1A269B43D300E4A7AB /* NavigationModel.swift in Sources */,
|
||||
37BE0BCF26A0E2D50092E2DB /* VideoPlayerView.swift in Sources */,
|
||||
37CC3F50270D010D00608308 /* VideoBanner.swift in Sources */,
|
||||
378E50FB26FE8B9F00F49626 /* Instance.swift in Sources */,
|
||||
37484C1D26FC83A400287258 /* InstancesSettingsView.swift in Sources */,
|
||||
37BD07BB2698AB60003EBB87 /* AppSidebarNavigation.swift in Sources */,
|
||||
@ -1227,6 +1327,7 @@
|
||||
files = (
|
||||
37C194C826F6A9C8005D3B96 /* RecentsModel.swift in Sources */,
|
||||
37BE0BDC26A2367F0092E2DB /* Player.swift in Sources */,
|
||||
3743CA53270F284F00E4D32B /* View+Borders.swift in Sources */,
|
||||
3788AC2C26F6842D00F6BAA9 /* WatchNowSectionBody.swift in Sources */,
|
||||
37CEE4BE2677B670005A1EFE /* SingleAssetStream.swift in Sources */,
|
||||
37BA794826DC2E56002A0235 /* AppSidebarSubscriptions.swift in Sources */,
|
||||
@ -1236,11 +1337,13 @@
|
||||
37FD43DF2704717F0073EE42 /* DefaultAccountHint.swift in Sources */,
|
||||
3761ABFE26F0F8DE00AA496F /* EnvironmentValues.swift in Sources */,
|
||||
37BA795026DC3E0E002A0235 /* Int+Format.swift in Sources */,
|
||||
3743CA4F270EFE3400E4D32B /* PlayerQueueRow.swift in Sources */,
|
||||
377FC7DD267A081A00A6BBAF /* PopularView.swift in Sources */,
|
||||
375DFB5926F9DA010013F468 /* InstancesModel.swift in Sources */,
|
||||
3705B183267B4E4900704544 /* TrendingCategory.swift in Sources */,
|
||||
37FD43DC270470B70073EE42 /* InstancesSettingsView.swift in Sources */,
|
||||
376B2E0826F920D600B1D64D /* SignInRequiredView.swift in Sources */,
|
||||
37CC3F4D270CFE1700608308 /* PlayerQueueView.swift in Sources */,
|
||||
37B81B0026D2CA3700675966 /* VideoDetails.swift in Sources */,
|
||||
37484C1A26FC837400287258 /* PlaybackSettingsView.swift in Sources */,
|
||||
37BD07C32698AD4F003EBB87 /* ContentView.swift in Sources */,
|
||||
@ -1254,10 +1357,11 @@
|
||||
37141670267A8ACC006CA35D /* TrendingView.swift in Sources */,
|
||||
37152EEB26EFEB95004FB96D /* LazyView.swift in Sources */,
|
||||
377FC7E2267A084A00A6BBAF /* VideoView.swift in Sources */,
|
||||
37CC3F51270D010D00608308 /* VideoBanner.swift in Sources */,
|
||||
378E50FC26FE8B9F00F49626 /* Instance.swift in Sources */,
|
||||
37B81B0626D2CEDA00675966 /* PlaybackModel.swift in Sources */,
|
||||
37B044B826F7AB9000E1419D /* SettingsView.swift in Sources */,
|
||||
3765788A2685471400D4EA09 /* Playlist.swift in Sources */,
|
||||
37E2EEAC270656EC00170416 /* PlayerControlsView.swift in Sources */,
|
||||
373CFACC26966264003CB2C6 /* SearchQuery.swift in Sources */,
|
||||
37AAF29126740715007FC770 /* Channel.swift in Sources */,
|
||||
37BD07BC2698AB60003EBB87 /* AppSidebarNavigation.swift in Sources */,
|
||||
@ -1281,6 +1385,7 @@
|
||||
37BE0BDA26A214630092E2DB /* PlayerViewController.swift in Sources */,
|
||||
37E64DD226D597EB00C71877 /* SubscriptionsModel.swift in Sources */,
|
||||
37C7A1D6267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */,
|
||||
37319F0627103F94004ECCD0 /* PlayerQueue.swift in Sources */,
|
||||
37B767DC2677C3CA0098BAA8 /* PlayerModel.swift in Sources */,
|
||||
3797758C2689345500DD52A8 /* Store.swift in Sources */,
|
||||
37141674267A8E10006CA35D /* Country.swift in Sources */,
|
||||
@ -1288,6 +1393,7 @@
|
||||
37AAF2A126741C97007FC770 /* SubscriptionsView.swift in Sources */,
|
||||
37732FF12703A26300F04329 /* ValidationStatusView.swift in Sources */,
|
||||
37BA794C26DC30EC002A0235 /* AppSidebarPlaylists.swift in Sources */,
|
||||
37CC3F46270CE30600608308 /* PlayerQueueItem.swift in Sources */,
|
||||
37D4B19826717E1500C925CA /* Video.swift in Sources */,
|
||||
375168D72700FDB8008F96A6 /* Debounce.swift in Sources */,
|
||||
37D4B0E52671614900C925CA /* PearvidiousApp.swift in Sources */,
|
||||
@ -1333,6 +1439,7 @@
|
||||
37AAF28026737550007FC770 /* SearchView.swift in Sources */,
|
||||
3788AC2D26F6842D00F6BAA9 /* WatchNowSectionBody.swift in Sources */,
|
||||
37EAD871267B9ED100D9E01B /* Segment.swift in Sources */,
|
||||
37CC3F52270D010D00608308 /* VideoBanner.swift in Sources */,
|
||||
37F49BA526CAA59B00304AC0 /* Playlist+Fixtures.swift in Sources */,
|
||||
376CD21826FBE18D001E1AC1 /* Instance+Fixtures.swift in Sources */,
|
||||
37CEE4BF2677B670005A1EFE /* SingleAssetStream.swift in Sources */,
|
||||
@ -1348,11 +1455,11 @@
|
||||
376B2E0926F920D600B1D64D /* SignInRequiredView.swift in Sources */,
|
||||
37141671267A8ACC006CA35D /* TrendingView.swift in Sources */,
|
||||
3788AC2926F6840700F6BAA9 /* WatchNowSection.swift in Sources */,
|
||||
37319F0727103F94004ECCD0 /* PlayerQueue.swift in Sources */,
|
||||
375168D82700FDB9008F96A6 /* Debounce.swift in Sources */,
|
||||
37BA794126DB8F97002A0235 /* ChannelVideosView.swift in Sources */,
|
||||
37AAF29226740715007FC770 /* Channel.swift in Sources */,
|
||||
37EAD86D267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */,
|
||||
37B81B0726D2D6CF00675966 /* PlaybackModel.swift in Sources */,
|
||||
37732FF22703A26300F04329 /* ValidationStatusView.swift in Sources */,
|
||||
37666BAA27023AF000F869E5 /* AccountSelectionView.swift in Sources */,
|
||||
3765788B2685471400D4EA09 /* Playlist.swift in Sources */,
|
||||
@ -1362,18 +1469,23 @@
|
||||
37B767DD2677C3CA0098BAA8 /* PlayerModel.swift in Sources */,
|
||||
373CFAF12697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */,
|
||||
3761AC1126F0F9A600AA496F /* UnsubscribeAlertModifier.swift in Sources */,
|
||||
3730D8A02712E2B70020ED53 /* NowPlayingView.swift in Sources */,
|
||||
37D4B18E26717B3800C925CA /* VideoView.swift in Sources */,
|
||||
37BE0BD126A0E2D50092E2DB /* VideoPlayerView.swift in Sources */,
|
||||
37AAF27E26737323007FC770 /* PopularView.swift in Sources */,
|
||||
3743CA50270EFE3400E4D32B /* PlayerQueueRow.swift in Sources */,
|
||||
37A9966026D6F9B9006E3224 /* WatchNowView.swift in Sources */,
|
||||
37484C1F26FC83A400287258 /* InstancesSettingsView.swift in Sources */,
|
||||
37C7A1D7267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */,
|
||||
376578932685490700D4EA09 /* PlaylistsView.swift in Sources */,
|
||||
37CC3F47270CE30600608308 /* PlayerQueueItem.swift in Sources */,
|
||||
37BA795126DC3E0E002A0235 /* Int+Format.swift in Sources */,
|
||||
3748186C26A764FB0084E870 /* Thumbnail+Fixtures.swift in Sources */,
|
||||
377A20AB2693C9A2002842B8 /* TypedContentAccessors.swift in Sources */,
|
||||
3748186826A7627F0084E870 /* Video+Fixtures.swift in Sources */,
|
||||
3743CA54270F284F00E4D32B /* View+Borders.swift in Sources */,
|
||||
371F2F1C269B43D300E4A7AB /* NavigationModel.swift in Sources */,
|
||||
37E2EEAD270656EC00170416 /* PlayerControlsView.swift in Sources */,
|
||||
37BA794526DBA973002A0235 /* PlaylistsModel.swift in Sources */,
|
||||
37B17DA0268A1F89006AEE9B /* VideoContextMenuView.swift in Sources */,
|
||||
37BE0BD726A1D4A90092E2DB /* PlayerViewController.swift in Sources */,
|
||||
@ -1399,6 +1511,7 @@
|
||||
37AAF2A226741C97007FC770 /* SubscriptionsView.swift in Sources */,
|
||||
37484C1B26FC837400287258 /* PlaybackSettingsView.swift in Sources */,
|
||||
372915E82687E3B900F5A35B /* Defaults.swift in Sources */,
|
||||
37CC3F4E270CFE1700608308 /* PlayerQueueView.swift in Sources */,
|
||||
37BAB54C269B39FD00E75ED1 /* TVNavigationView.swift in Sources */,
|
||||
3797758D2689345500DD52A8 /* Store.swift in Sources */,
|
||||
37484C2F26FC844700287258 /* AccountsSettingsView.swift in Sources */,
|
||||
@ -2086,6 +2199,14 @@
|
||||
minimumVersion = 5.0.0;
|
||||
};
|
||||
};
|
||||
3743CA48270EF79400E4D32B /* XCRemoteSwiftPackageReference "SwiftUIKit" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/danielsaidi/SwiftUIKit.git";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 2.0.0;
|
||||
};
|
||||
};
|
||||
3797757B268922D100DD52A8 /* XCRemoteSwiftPackageReference "siesta" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/bustoutsolutions/siesta";
|
||||
@ -2134,6 +2255,16 @@
|
||||
package = 372915E22687E33E00F5A35B /* XCRemoteSwiftPackageReference "Defaults" */;
|
||||
productName = Defaults;
|
||||
};
|
||||
3743CA49270EF79400E4D32B /* SwiftUIKit */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 3743CA48270EF79400E4D32B /* XCRemoteSwiftPackageReference "SwiftUIKit" */;
|
||||
productName = SwiftUIKit;
|
||||
};
|
||||
3743CA4B270EF7A500E4D32B /* SwiftUIKit */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 3743CA48270EF79400E4D32B /* XCRemoteSwiftPackageReference "SwiftUIKit" */;
|
||||
productName = SwiftUIKit;
|
||||
};
|
||||
377FC7D4267A080300A6BBAF /* SwiftyJSON */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 37D4B19B2671817900C925CA /* XCRemoteSwiftPackageReference "SwiftyJSON" */;
|
||||
|
@ -46,6 +46,15 @@
|
||||
"version": "0.1.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "SwiftUIKit",
|
||||
"repositoryURL": "https://github.com/danielsaidi/SwiftUIKit.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "ad509355ba9bc87f8375a297c3df93acd42e6c01",
|
||||
"version": "2.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "SwiftyJSON",
|
||||
"repositoryURL": "https://github.com/SwiftyJSON/SwiftyJSON.git",
|
||||
|
@ -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
|
||||
.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,26 +2,27 @@ 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)
|
||||
|
||||
if player.currentItem != nil {
|
||||
Text(playbackStatus)
|
||||
.foregroundColor(.gray)
|
||||
.font(.caption2)
|
||||
.frame(minWidth: 60, maxWidth: .infinity)
|
||||
.frame(minWidth: 130, maxWidth: .infinity)
|
||||
|
||||
VStack {
|
||||
if playback.stream != nil {
|
||||
if player.stream != nil {
|
||||
Text(currentStreamString)
|
||||
} else {
|
||||
if video.live {
|
||||
if player.currentVideo!.live {
|
||||
Image(systemName: "dot.radiowaves.left.and.right")
|
||||
} else {
|
||||
Image(systemName: "bolt.horizontal.fill")
|
||||
@ -30,27 +31,30 @@ struct PlaybackBar: View {
|
||||
}
|
||||
.foregroundColor(.gray)
|
||||
.font(.caption2)
|
||||
.frame(width: 60, alignment: .trailing)
|
||||
.frame(width: 80, alignment: .trailing)
|
||||
.fixedSize(horizontal: true, vertical: true)
|
||||
} else {
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.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 {
|
||||
if player.live {
|
||||
return "LIVE"
|
||||
} else {
|
||||
return "loading..."
|
||||
}
|
||||
}
|
||||
|
||||
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,31 +2,152 @@ 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
|
||||
|
||||
toggleFullScreenDetailsButton
|
||||
}
|
||||
#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()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
switch currentPage {
|
||||
case .details:
|
||||
ScrollView(.vertical) {
|
||||
detailsPage
|
||||
}
|
||||
case .queue:
|
||||
PlayerQueueView(fullScreen: $fullScreen)
|
||||
.edgesIgnoringSafeArea(.horizontal)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
guard video != nil else {
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
.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)
|
||||
Text(video!.channel.name)
|
||||
.font(.system(size: 13))
|
||||
.bold()
|
||||
if let subscribers = video.channel.subscriptionsString {
|
||||
if let subscribers = video!.channel.subscriptionsString {
|
||||
Text("\(subscribers) subscribers")
|
||||
.font(.caption2)
|
||||
}
|
||||
@ -44,9 +165,9 @@ struct VideoDetails: View {
|
||||
#if os(iOS)
|
||||
.tint(.gray)
|
||||
#endif
|
||||
.confirmationDialog("Are you you want to unsubscribe from \(video.channel.name)?", isPresented: $confirmationShown) {
|
||||
.confirmationDialog("Are you you want to unsubscribe from \(video!.channel.name)?", isPresented: $confirmationShown) {
|
||||
Button("Unsubscribe") {
|
||||
subscriptions.unsubscribe(video.channel.id)
|
||||
subscriptions.unsubscribe(video!.channel.id)
|
||||
|
||||
withAnimation {
|
||||
subscribed.toggle()
|
||||
@ -55,7 +176,7 @@ struct VideoDetails: View {
|
||||
}
|
||||
} else {
|
||||
Button("Subscribe") {
|
||||
subscriptions.subscribe(video.channel.id)
|
||||
subscriptions.subscribe(video!.channel.id)
|
||||
|
||||
withAnimation {
|
||||
subscribed.toggle()
|
||||
@ -68,10 +189,26 @@ struct VideoDetails: View {
|
||||
.buttonStyle(.borderless)
|
||||
.buttonBorderShape(.roundedRectangle)
|
||||
}
|
||||
.padding(.bottom, -1)
|
||||
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@ -89,9 +226,13 @@ struct VideoDetails: View {
|
||||
.font(.system(size: 12))
|
||||
.padding(.bottom, -1)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
var countsSection: some View {
|
||||
Group {
|
||||
if let video = player.currentItem.video {
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
@ -115,19 +256,26 @@ struct VideoDetails: View {
|
||||
}
|
||||
.frame(maxHeight: 35)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var detailsPage: some View {
|
||||
Group {
|
||||
if let video = player.currentItem?.video {
|
||||
Group {
|
||||
publishedDateSection
|
||||
|
||||
Divider()
|
||||
|
||||
#if os(macOS)
|
||||
ScrollView(.vertical) {
|
||||
Text(video.description)
|
||||
.font(.caption)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 100, alignment: .leading)
|
||||
countsSection
|
||||
}
|
||||
#else
|
||||
|
||||
Divider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(video.description)
|
||||
.font(.caption)
|
||||
#endif
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: showScrollIndicators) {
|
||||
HStack {
|
||||
@ -138,25 +286,22 @@ struct VideoDetails: View {
|
||||
|
||||
Text(keyword)
|
||||
.frame(maxWidth: 500)
|
||||
}.foregroundColor(.white)
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.white)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 8)
|
||||
|
||||
.background(Color("VideoDetailLikesSymbolColor"))
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.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 {
|
||||
VStack(spacing: 4) {
|
||||
@ -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 {
|
||||
// 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(macOS)
|
||||
HSplitView {
|
||||
content
|
||||
}
|
||||
.frame(idealWidth: 1000, maxWidth: 1100, minHeight: 700)
|
||||
#else
|
||||
HStack(spacing: 0) {
|
||||
content
|
||||
}
|
||||
#if os(iOS)
|
||||
.navigationBarHidden(true)
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
var content: some View {
|
||||
Group {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
#if os(tvOS)
|
||||
Player(video: video)
|
||||
.environmentObject(playback)
|
||||
player()
|
||||
#else
|
||||
GeometryReader { geometry in
|
||||
VStack(spacing: 0) {
|
||||
#if os(iOS)
|
||||
if verticalSizeClass == .regular {
|
||||
PlaybackBar(video: video)
|
||||
PlaybackBar()
|
||||
}
|
||||
#elseif os(macOS)
|
||||
PlaybackBar(video: video)
|
||||
PlaybackBar()
|
||||
#endif
|
||||
|
||||
Player(video: video)
|
||||
.environmentObject(playback)
|
||||
.modifier(VideoPlayerSizeModifier(geometry: geometry, aspectRatio: playback.aspectRatio))
|
||||
if player.currentItem.isNil {
|
||||
playerPlaceholder(geometry: geometry)
|
||||
} else {
|
||||
player(geometry: geometry)
|
||||
}
|
||||
.background(.black)
|
||||
}
|
||||
#if os(iOS)
|
||||
.onSwipeGesture(
|
||||
up: {
|
||||
withAnimation {
|
||||
fullScreen = true
|
||||
}
|
||||
},
|
||||
down: { dismiss() }
|
||||
)
|
||||
#endif
|
||||
|
||||
VStack(spacing: 0) {
|
||||
.background(.black)
|
||||
.onAppear {
|
||||
self.playerSize = geometry.size
|
||||
}
|
||||
.onChange(of: geometry.size) { size in
|
||||
self.playerSize = size
|
||||
}
|
||||
|
||||
Group {
|
||||
#if os(iOS)
|
||||
if verticalSizeClass == .regular {
|
||||
ScrollView(.vertical, showsIndicators: showScrollIndicators) {
|
||||
if let video = store.item {
|
||||
VideoDetails(video: video)
|
||||
} else {
|
||||
VideoDetails(video: video)
|
||||
}
|
||||
}
|
||||
VideoDetails(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreen)
|
||||
}
|
||||
|
||||
#else
|
||||
if let video = store.item {
|
||||
VideoDetails(video: video)
|
||||
} else {
|
||||
VideoDetails(video: video)
|
||||
}
|
||||
VideoDetails(fullScreen: $fullScreen)
|
||||
#endif
|
||||
}
|
||||
.modifier(VideoDetailsPaddingModifier(geometry: geometry, aspectRatio: playback.aspectRatio))
|
||||
.background()
|
||||
.modifier(VideoDetailsPaddingModifier(geometry: geometry, fullScreen: fullScreen))
|
||||
}
|
||||
.animation(.linear(duration: 0.2), value: playback.aspectRatio)
|
||||
#endif
|
||||
}
|
||||
.onAppear {
|
||||
resource.addObserver(store)
|
||||
resource.loadIfNeeded()
|
||||
}
|
||||
.onDisappear {
|
||||
resource.removeObservers(ownedBy: store)
|
||||
resource.invalidate()
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(maxWidth: 1000, minHeight: 700)
|
||||
#elseif os(iOS)
|
||||
.navigationBarHidden(true)
|
||||
.frame(minWidth: 650)
|
||||
#endif
|
||||
#if os(iOS)
|
||||
if sidebarQueue {
|
||||
PlayerQueueView(fullScreen: $fullScreen)
|
||||
.frame(maxWidth: 350)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
PlayerQueueView(fullScreen: $fullScreen)
|
||||
.frame(minWidth: 250)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
.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)
|
||||
VideoPlayerView()
|
||||
// .frame(minWidth: 1200, minHeight: 1400)
|
||||
.injectFixtureEnvironmentObjects()
|
||||
}
|
||||
|
||||
VideoPlayerView()
|
||||
.injectFixtureEnvironmentObjects()
|
||||
.previewInterfaceOrientation(.landscapeRight)
|
||||
}
|
||||
}
|
||||
|
@ -79,13 +79,7 @@ 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)
|
||||
VideoBanner(video: video)
|
||||
.padding(.vertical, 40)
|
||||
|
||||
VStack(alignment: formAlignment) {
|
||||
|
@ -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,6 +19,7 @@ struct PlaylistsView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
PlayerControlsView {
|
||||
SignInRequiredView(title: "Playlists") {
|
||||
VStack {
|
||||
#if os(tvOS)
|
||||
@ -39,6 +41,7 @@ struct PlaylistsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
.fullScreenCover(isPresented: $showingNewPlaylist, onDismiss: selectCreatedPlaylist) {
|
||||
PlaylistFormView(playlist: $createdPlaylist)
|
||||
@ -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,6 +25,7 @@ struct TrendingView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
PlayerControlsView {
|
||||
Section {
|
||||
VStack(alignment: .center, spacing: 0) {
|
||||
#if os(tvOS)
|
||||
@ -38,6 +39,7 @@ struct TrendingView: View {
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
.fullScreenCover(isPresented: $presentingCountrySelection) {
|
||||
TrendingCountry(selectedCountry: $country)
|
||||
|
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 {
|
||||
Button(action: {
|
||||
player.playNow(video)
|
||||
|
||||
if inNavigationView {
|
||||
NavigationLink(destination: VideoPlayerView(video)) {
|
||||
content
|
||||
}
|
||||
playerNavigationLinkActive = true
|
||||
} else {
|
||||
Button(action: { navigation.playVideo(video) }) {
|
||||
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 {
|
||||
PlayerControlsView {
|
||||
VideosCellsVertical(videos: playlist.videos)
|
||||
#if !os(tvOS)
|
||||
.navigationTitle("\(playlist.title) Playlist")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ struct PopularView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
PlayerControlsView {
|
||||
VideosCellsVertical(videos: store.collection)
|
||||
.onAppear {
|
||||
resource.addObserver(store)
|
||||
@ -21,3 +22,4 @@ struct PopularView: View {
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -30,6 +30,7 @@ struct SearchView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
PlayerControlsView {
|
||||
VStack {
|
||||
if showRecentQueries {
|
||||
recentQueries
|
||||
@ -56,6 +57,7 @@ struct SearchView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
#if !os(tvOS)
|
||||
ToolbarItemGroup(placement: toolbarPlacement) {
|
||||
|
@ -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 {
|
||||
PlayerControlsView {
|
||||
SignInRequiredView(title: "Subscriptions") {
|
||||
Text("Only when signed in")
|
||||
}
|
||||
}
|
||||
.environmentObject(PlayerModel())
|
||||
.environmentObject(InvidiousAPI())
|
||||
}
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ struct SubscriptionsView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
PlayerControlsView {
|
||||
SignInRequiredView(title: "Subscriptions") {
|
||||
VideosCellsVertical(videos: store.collection)
|
||||
.onAppear {
|
||||
@ -23,6 +24,7 @@ struct SubscriptionsView: View {
|
||||
loadResources(force: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
loadResources(force: true)
|
||||
}
|
||||
|
@ -2,18 +2,33 @@ 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 {
|
||||
Section {
|
||||
playNowButton
|
||||
}
|
||||
Section {
|
||||
playNextButton
|
||||
addToQueueButton
|
||||
}
|
||||
|
||||
Section {
|
||||
openChannelButton
|
||||
|
||||
subscriptionButton
|
||||
}
|
||||
|
||||
Section {
|
||||
if navigation.tabSelection != .playlists {
|
||||
addToPlaylistButton
|
||||
} else if let playlist = playlists.currentPlaylist {
|
||||
@ -24,6 +39,37 @@ struct VideoContextMenuView: View {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
var openChannelButton: some View {
|
||||
Button {
|
||||
|
@ -6,6 +6,7 @@ struct WatchNowView: View {
|
||||
@EnvironmentObject<InvidiousAPI> private var api
|
||||
|
||||
var body: some View {
|
||||
PlayerControlsView {
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
if api.validInstance {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
@ -35,6 +36,7 @@ struct WatchNowView: View {
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WatchNowView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
|
@ -2,19 +2,22 @@ import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct Player: NSViewControllerRepresentable {
|
||||
var video: Video!
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
|
||||
@EnvironmentObject<InvidiousAPI> private var api
|
||||
@EnvironmentObject<PlaybackModel> private var playback
|
||||
var controller: PlayerViewController?
|
||||
|
||||
init(controller: PlayerViewController? = nil) {
|
||||
self.controller = controller
|
||||
}
|
||||
|
||||
func makeNSViewController(context _: Context) -> PlayerViewController {
|
||||
if self.controller != nil {
|
||||
return self.controller!
|
||||
}
|
||||
|
||||
let controller = PlayerViewController()
|
||||
|
||||
controller.video = video
|
||||
controller.playback = playback
|
||||
controller.api = api
|
||||
|
||||
controller.resolution = Defaults[.quality]
|
||||
controller.playerModel = player
|
||||
|
||||
return controller
|
||||
}
|
||||
|
@ -2,42 +2,20 @@ import AVKit
|
||||
import SwiftUI
|
||||
|
||||
final class PlayerViewController: NSViewController {
|
||||
var video: Video!
|
||||
|
||||
var api: InvidiousAPI!
|
||||
var player = AVPlayer()
|
||||
var playerModel: PlayerModel!
|
||||
var playback: PlaybackModel!
|
||||
var playerView = AVPlayerView()
|
||||
var resolution: Stream.ResolutionSetting!
|
||||
|
||||
override func viewDidDisappear() {
|
||||
playerView.player?.replaceCurrentItem(with: nil)
|
||||
playerView.player = nil
|
||||
|
||||
playerModel.player = nil
|
||||
playerModel = nil
|
||||
|
||||
// TODO: pause on disappear settings
|
||||
super.viewDidDisappear()
|
||||
}
|
||||
|
||||
override func loadView() {
|
||||
playerModel = PlayerModel(playback: playback, api: api, resolution: resolution)
|
||||
|
||||
guard playerModel.player.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
playerModel.player = player
|
||||
playerView.player = playerModel.player
|
||||
|
||||
playerView.allowsPictureInPicturePlayback = true
|
||||
playerView.showsFullScreenToggleButton = true
|
||||
|
||||
view = playerView
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.playerModel.loadVideo(self.video)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
81
tvOS/NowPlayingView.swift
Normal file
81
tvOS/NowPlayingView.swift
Normal file
@ -0,0 +1,81 @@
|
||||
import SwiftUI
|
||||
|
||||
struct NowPlayingView: View {
|
||||
var infoViewController = false
|
||||
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
|
||||
var body: some View {
|
||||
if infoViewController {
|
||||
content
|
||||
.background(.thinMaterial)
|
||||
.mask(RoundedRectangle(cornerRadius: 24))
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
var content: some View {
|
||||
ScrollView(.vertical) {
|
||||
VStack(alignment: .leading) {
|
||||
if !infoViewController, let item = player.currentItem {
|
||||
Group {
|
||||
header("Now Playing")
|
||||
|
||||
Button {
|
||||
player.presentPlayer()
|
||||
} label: {
|
||||
VideoBanner(video: item.video)
|
||||
}
|
||||
}
|
||||
.onPlayPauseCommand(perform: player.togglePlay)
|
||||
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
|
||||
if !infoViewController {
|
||||
header("Playing Next")
|
||||
}
|
||||
|
||||
if player.queue.isEmpty {
|
||||
Spacer()
|
||||
|
||||
Text("Playback queue is empty")
|
||||
.padding(.leading, 40)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
ForEach(player.queue) { item in
|
||||
Button {
|
||||
player.advanceToItem(item)
|
||||
player.presentPlayer()
|
||||
} label: {
|
||||
VideoBanner(video: item.video)
|
||||
}
|
||||
.contextMenu {
|
||||
Button("Delete", role: .destructive) {
|
||||
player.remove(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 260, maxHeight: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
func header(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.title3.bold())
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.leading, 40)
|
||||
}
|
||||
}
|
||||
|
||||
struct NowPlayingView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
NowPlayingView()
|
||||
.injectFixtureEnvironmentObjects()
|
||||
}
|
||||
}
|
@ -2,8 +2,8 @@ import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct TVNavigationView: View {
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlaybackModel> private var playback
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SearchModel> private var search
|
||||
|
||||
@ -29,6 +29,10 @@ struct TVNavigationView: View {
|
||||
.tabItem { Text("Playlists") }
|
||||
.tag(TabSelection.playlists)
|
||||
|
||||
NowPlayingView()
|
||||
.tabItem { Text("Now Playing") }
|
||||
.tag(TabSelection.nowPlaying)
|
||||
|
||||
SearchView()
|
||||
.tabItem { Image(systemName: "magnifyingglass") }
|
||||
.tag(TabSelection.search)
|
||||
@ -39,11 +43,8 @@ struct TVNavigationView: View {
|
||||
AddToPlaylistView(video: video)
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $navigation.showingVideo) {
|
||||
if let video = navigation.video {
|
||||
VideoPlayerView(video)
|
||||
.environmentObject(playback)
|
||||
}
|
||||
.fullScreenCover(isPresented: $player.presentingPlayer) {
|
||||
VideoPlayerView()
|
||||
}
|
||||
.fullScreenCover(isPresented: $navigation.isChannelOpen) {
|
||||
if let channel = recents.presentedChannel {
|
||||
|
Loading…
Reference in New Issue
Block a user