Display more details in player view

This commit is contained in:
Arkadiusz Fal 2021-08-22 21:13:33 +02:00
parent ea634390a6
commit f80b61f9c7
22 changed files with 716 additions and 36 deletions

View File

@ -6,17 +6,21 @@ extension Video {
return Video(
id: UUID().uuidString,
title: "Relaxing Piano Music",
title: "Relaxing Piano Music that will make you feel amazingly good",
author: "Fancy Videotuber",
length: 582,
published: "7 years ago",
views: 1024,
views: 21534,
channelID: "AbCdEFgHI",
description: "Some relaxing live piano music",
genre: "Music",
thumbnails: Thumbnail.fixturesForAllQualities(videoId: id),
live: false,
upcoming: false
upcoming: false,
publishedAt: Date.now,
likes: 37333,
dislikes: 30,
keywords: ["very", "cool", "video", "msfs 2020", "757", "747", "A380", "737-900", "MOD", "Zibo", "MD80", "MD11", "Rotate", "Laminar", "787", "A350", "MSFS", "MS2020", "Microsoft Flight Simulator", "Microsoft", "Flight", "Simulator", "SIM", "World", "Ortho", "Flying", "Boeing MAX"]
)
}

25
Model/PlaybackState.swift Normal file
View File

@ -0,0 +1,25 @@
import CoreMedia
import Foundation
final class PlaybackState: ObservableObject {
@Published var stream: Stream?
@Published var time: CMTime?
var aspectRatio: CGFloat? {
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
}
}

View File

@ -14,19 +14,21 @@ final class PlayerState: ObservableObject {
private var compositions = [Stream: AVMutableComposition]()
private(set) var currentTime: CMTime?
private(set) var savedTime: 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]
let maxResolution: Stream.Resolution?
var playbackState: PlaybackState?
var timeObserver: Any?
let maxResolution: Stream.Resolution?
var playingOutsideViewController = false
init(_ video: Video? = nil, maxResolution: Stream.Resolution? = nil) {
init(_ video: Video? = nil, playbackState: PlaybackState? = nil, maxResolution: Stream.Resolution? = nil) {
self.video = video
self.playbackState = playbackState
self.maxResolution = maxResolution
}
@ -101,6 +103,10 @@ final class PlayerState: ObservableObject {
DispatchQueue.main.async {
self.saveTime()
self.player?.replaceCurrentItem(with: self.playerItemWithMetadata(for: stream))
self.playbackState?.stream = stream
if self.timeObserver == nil {
self.addTimeObserver()
}
self.player?.playImmediately(atRate: 1.0)
self.seekToSavedTime()
}
@ -245,9 +251,15 @@ final class PlayerState: ObservableObject {
let interval = CMTime(value: 1, timescale: 1)
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.playbackState?.time = self.player.currentTime()
}
}

View File

@ -24,6 +24,11 @@ struct Video: Identifiable, Equatable {
var streams = [Stream]()
var hlsUrl: URL?
var publishedAt: Date?
var likes: Int?
var dislikes: Int?
var keywords = [String]()
init(
id: String,
title: String,
@ -37,7 +42,11 @@ struct Video: Identifiable, Equatable {
thumbnails: [Thumbnail] = [],
indexID: String? = nil,
live: Bool = false,
upcoming: Bool = false
upcoming: Bool = false,
publishedAt: Date? = nil,
likes: Int? = nil,
dislikes: Int? = nil,
keywords: [String] = []
) {
self.id = id
self.title = title
@ -52,6 +61,10 @@ struct Video: Identifiable, Equatable {
self.indexID = indexID
self.live = live
self.upcoming = upcoming
self.publishedAt = publishedAt
self.likes = likes
self.dislikes = dislikes
self.keywords = keywords
}
init(_ json: JSON) {
@ -79,6 +92,15 @@ struct Video: Identifiable, Equatable {
live = json["liveNow"].boolValue
upcoming = json["isUpcoming"].boolValue
likes = json["likeCount"].int
dislikes = json["dislikeCount"].int
keywords = json["keywords"].arrayValue.map { $0.stringValue }
if let publishedInterval = json["published"].double {
publishedAt = Date(timeIntervalSince1970: publishedInterval)
}
streams = Video.extractFormatStreams(from: json["formatStreams"].arrayValue)
streams.append(contentsOf: Video.extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue))
@ -103,7 +125,23 @@ struct Video: Identifiable, Equatable {
(published.isEmpty || published == "0 seconds ago") ? nil : published
}
var viewsCount: String {
var viewsCount: String? {
views != 0 ? formattedCount(views) : nil
}
var likesCount: String? {
formattedCount(likes)
}
var dislikesCount: String? {
formattedCount(dislikes)
}
func formattedCount(_ count: Int!) -> String? {
guard count != nil else {
return nil
}
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.maximumFractionDigits = 1
@ -111,11 +149,13 @@ struct Video: Identifiable, Equatable {
var number: NSNumber
var unit: String
if views < 1_000_000 {
number = NSNumber(value: Double(views) / 1000.0)
if count < 1000 {
return "\(count!)"
} else if count < 1_000_000 {
number = NSNumber(value: Double(count) / 1000.0)
unit = "K"
} else {
number = NSNumber(value: Double(views) / 1_000_000.0)
number = NSNumber(value: Double(count) / 1_000_000.0)
unit = "M"
}

View File

@ -109,6 +109,17 @@
37B767DC2677C3CA0098BAA8 /* PlayerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B767DA2677C3CA0098BAA8 /* PlayerState.swift */; };
37B767DD2677C3CA0098BAA8 /* PlayerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B767DA2677C3CA0098BAA8 /* PlayerState.swift */; };
37B767E02678C5BF0098BAA8 /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = 37B767DF2678C5BF0098BAA8 /* Logging */; };
37B81AF926D2C9A700675966 /* VideoPlayerSizeModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B81AF826D2C9A700675966 /* VideoPlayerSizeModifier.swift */; };
37B81AFA26D2C9A700675966 /* VideoPlayerSizeModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B81AF826D2C9A700675966 /* VideoPlayerSizeModifier.swift */; };
37B81AFC26D2C9C900675966 /* VideoDetailsPaddingModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B81AFB26D2C9C900675966 /* VideoDetailsPaddingModifier.swift */; };
37B81AFD26D2C9C900675966 /* VideoDetailsPaddingModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B81AFB26D2C9C900675966 /* VideoDetailsPaddingModifier.swift */; };
37B81AFF26D2CA3700675966 /* VideoDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B81AFE26D2CA3700675966 /* VideoDetails.swift */; };
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 /* PlaybackState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B81B0426D2CEDA00675966 /* PlaybackState.swift */; };
37B81B0626D2CEDA00675966 /* PlaybackState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B81B0426D2CEDA00675966 /* PlaybackState.swift */; };
37B81B0726D2D6CF00675966 /* PlaybackState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B81B0426D2CEDA00675966 /* PlaybackState.swift */; };
37BAB54C269B39FD00E75ED1 /* TVNavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BAB54B269B39FD00E75ED1 /* TVNavigationView.swift */; };
37BADCA52699FB72009BE4FB /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = 37BADCA42699FB72009BE4FB /* Alamofire */; };
37BADCA7269A552E009BE4FB /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = 37BADCA6269A552E009BE4FB /* Alamofire */; };
@ -241,6 +252,11 @@
37B17DA3268A285E006AEE9B /* VideoDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDetailsView.swift; sourceTree = "<group>"; };
37B767DA2677C3CA0098BAA8 /* PlayerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerState.swift; sourceTree = "<group>"; };
37B76E95268747C900CE5671 /* OptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionsView.swift; sourceTree = "<group>"; };
37B81AF826D2C9A700675966 /* VideoPlayerSizeModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerSizeModifier.swift; sourceTree = "<group>"; };
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 /* PlaybackState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackState.swift; sourceTree = "<group>"; };
37BAB54B269B39FD00E75ED1 /* TVNavigationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVNavigationView.swift; sourceTree = "<group>"; };
37BD07B42698AA4D003EBB87 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
37BD07BA2698AB60003EBB87 /* AppSidebarNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSidebarNavigation.swift; sourceTree = "<group>"; };
@ -351,8 +367,12 @@
371AAE2426CEBA4100901972 /* Player */ = {
isa = PBXGroup;
children = (
37B81B0126D2CAE700675966 /* PlaybackBar.swift */,
37BE0BD226A1D4780092E2DB /* Player.swift */,
37BE0BD526A1D4A90092E2DB /* PlayerViewController.swift */,
37B81AFE26D2CA3700675966 /* VideoDetails.swift */,
37B81AFB26D2C9C900675966 /* VideoDetailsPaddingModifier.swift */,
37B81AF826D2C9A700675966 /* VideoPlayerSizeModifier.swift */,
37BE0BCE26A0E2D50092E2DB /* VideoPlayerView.swift */,
);
path = Player;
@ -543,10 +563,11 @@
37D4B1B72672CFE300C925CA /* Model */ = {
isa = PBXGroup;
children = (
37141672267A8E10006CA35D /* Country.swift */,
37AAF28F26740715007FC770 /* Channel.swift */,
37141672267A8E10006CA35D /* Country.swift */,
37977582268922F600DD52A8 /* InvidiousAPI.swift */,
371F2F19269B43D300E4A7AB /* NavigationState.swift */,
37B81B0426D2CEDA00675966 /* PlaybackState.swift */,
37B767DA2677C3CA0098BAA8 /* PlayerState.swift */,
376578882685471400D4EA09 /* Playlist.swift */,
37C7A1DB267CE9D90010EAD6 /* Profile.swift */,
@ -822,6 +843,7 @@
37BE0BD626A1D4A90092E2DB /* PlayerViewController.swift in Sources */,
37754C9D26B7500000DBD602 /* VideosView.swift in Sources */,
3711403F26B206A6005B3555 /* SearchState.swift in Sources */,
37B81AF926D2C9A700675966 /* VideoPlayerSizeModifier.swift in Sources */,
37BE0BD326A1D4780092E2DB /* Player.swift in Sources */,
37CEE4C12677B697005A1EFE /* Stream.swift in Sources */,
37F4AE7226828F0900BD60EA /* VideosCellsView.swift in Sources */,
@ -831,6 +853,7 @@
3705B182267B4E4900704544 /* TrendingCategory.swift in Sources */,
37EAD86F267B9ED100D9E01B /* Segment.swift in Sources */,
376578892685471400D4EA09 /* Playlist.swift in Sources */,
37B81B0526D2CEDA00675966 /* PlaybackState.swift in Sources */,
373CFADB269663F1003CB2C6 /* Thumbnail.swift in Sources */,
37C7A1DC267CE9D90010EAD6 /* Profile.swift in Sources */,
373CFAC026966149003CB2C6 /* CoverSectionView.swift in Sources */,
@ -839,12 +862,14 @@
377FC7E3267A084A00A6BBAF /* VideoView.swift in Sources */,
37AAF29026740715007FC770 /* Channel.swift in Sources */,
3748186A26A764FB0084E870 /* Thumbnail+Fixtures.swift in Sources */,
37B81AFF26D2CA3700675966 /* VideoDetails.swift in Sources */,
377FC7E5267A084E00A6BBAF /* SearchView.swift in Sources */,
376578912685490700D4EA09 /* PlaylistsView.swift in Sources */,
377A20A92693C9A2002842B8 /* TypedContentAccessors.swift in Sources */,
37B17DA2268A1F8A006AEE9B /* VideoContextMenuView.swift in Sources */,
379775932689365600DD52A8 /* Array+Next.swift in Sources */,
377FC7E1267A082600A6BBAF /* ChannelView.swift in Sources */,
37B81AFC26D2C9C900675966 /* VideoDetailsPaddingModifier.swift in Sources */,
37C7A1D5267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */,
37F49BA326CAA59B00304AC0 /* Playlist+Fixtures.swift in Sources */,
37B767DB2677C3CA0098BAA8 /* PlayerState.swift in Sources */,
@ -854,6 +879,7 @@
3748186E26A769D60084E870 /* DetailBadge.swift in Sources */,
37AAF2A026741C97007FC770 /* SubscriptionsView.swift in Sources */,
373CFAEB26975CBF003CB2C6 /* PlaylistFormView.swift in Sources */,
37B81B0226D2CAE700675966 /* PlaybackBar.swift in Sources */,
372915E62687E3B900F5A35B /* Defaults.swift in Sources */,
37D4B19726717E1500C925CA /* Video.swift in Sources */,
371F2F1A269B43D300E4A7AB /* NavigationState.swift in Sources */,
@ -876,11 +902,13 @@
371F2F1B269B43D300E4A7AB /* NavigationState.swift in Sources */,
377FC7DD267A081A00A6BBAF /* PopularView.swift in Sources */,
3705B183267B4E4900704544 /* TrendingCategory.swift in Sources */,
37B81B0026D2CA3700675966 /* VideoDetails.swift in Sources */,
37BD07C32698AD4F003EBB87 /* ContentView.swift in Sources */,
37F49BA426CAA59B00304AC0 /* Playlist+Fixtures.swift in Sources */,
37EAD870267B9ED100D9E01B /* Segment.swift in Sources */,
37141670267A8ACC006CA35D /* TrendingView.swift in Sources */,
377FC7E2267A084A00A6BBAF /* VideoView.swift in Sources */,
37B81B0626D2CEDA00675966 /* PlaybackState.swift in Sources */,
3765788A2685471400D4EA09 /* Playlist.swift in Sources */,
373CFACC26966264003CB2C6 /* SearchQuery.swift in Sources */,
373CFAC32696616C003CB2C6 /* CoverSectionRowView.swift in Sources */,
@ -890,9 +918,12 @@
372915E72687E3B900F5A35B /* Defaults.swift in Sources */,
376578922685490700D4EA09 /* PlaylistsView.swift in Sources */,
377FC7E4267A084E00A6BBAF /* SearchView.swift in Sources */,
37B81AFA26D2C9A700675966 /* VideoPlayerSizeModifier.swift in Sources */,
377A20AA2693C9A2002842B8 /* TypedContentAccessors.swift in Sources */,
37B81B0326D2CAE700675966 /* PlaybackBar.swift in Sources */,
376578862685429C00D4EA09 /* CaseIterable+Next.swift in Sources */,
37F4AE7326828F0900BD60EA /* VideosCellsView.swift in Sources */,
37B81AFD26D2C9C900675966 /* VideoDetailsPaddingModifier.swift in Sources */,
377FC7E0267A082600A6BBAF /* ChannelView.swift in Sources */,
379775942689365600DD52A8 /* Array+Next.swift in Sources */,
3748186726A7627F0084E870 /* Video+Fixtures.swift in Sources */,
@ -956,6 +987,7 @@
37141671267A8ACC006CA35D /* TrendingView.swift in Sources */,
37AAF29226740715007FC770 /* Channel.swift in Sources */,
37EAD86D267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */,
37B81B0726D2D6CF00675966 /* PlaybackState.swift in Sources */,
3765788B2685471400D4EA09 /* Playlist.swift in Sources */,
373CFADD269663F1003CB2C6 /* Thumbnail.swift in Sources */,
37C7A1DE267CE9D90010EAD6 /* Profile.swift in Sources */,

View File

@ -0,0 +1,36 @@
{
"colors" : [
{
"color" : {
"color-space" : "extended-gray",
"components" : {
"alpha" : "1.000",
"white" : "0.724"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "display-p3",
"components" : {
"alpha" : "1.000",
"blue" : "0.328",
"green" : "0.328",
"red" : "0.325"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

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

View File

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

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.824",
"green" : "0.659",
"red" : "0.455"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.824",
"green" : "0.659",
"red" : "0.455"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.329",
"green" : "0.224",
"red" : "0.043"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.329",
"green" : "0.224",
"red" : "0.043"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -26,8 +26,9 @@ struct ContentView: View {
.sheet(isPresented: $navigationState.showingVideo) {
if let video = navigationState.video {
VideoPlayerView(video)
#if !os(iOS)
.frame(minWidth: 590, minHeight: 500)
.frame(minWidth: 550, minHeight: 720)
.onExitCommand {
navigationState.showingVideo = false
}

View File

@ -0,0 +1,55 @@
import Foundation
import SwiftUI
struct PlaybackBar: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var playbackState: PlaybackState
let video: Video
var body: some View {
HStack {
closeButton
.frame(minWidth: 0, maxWidth: 60, alignment: .leading)
Text(playbackFinishAtString)
.foregroundColor(.gray)
.font(.caption2)
.frame(minWidth: 0, maxWidth: .infinity)
Text(currentStreamString)
.foregroundColor(.gray)
.font(.caption2)
.frame(minWidth: 0, maxWidth: 60, alignment: .trailing)
}
.padding(4)
.background(.black)
}
var currentStreamString: String {
playbackState.stream != nil ? "\(playbackState.stream!.resolution.height)p" : ""
}
var playbackFinishAtString: String {
guard playbackState.time != nil else {
return "loading..."
}
let remainingSeconds = video.length - playbackState.time!.seconds
let timeFinishAt = Date.now.addingTimeInterval(remainingSeconds)
let timeFinishAtString = timeFinishAt.formatted(date: .omitted, time: .shortened)
return "finishes at \(timeFinishAtString)"
}
var closeButton: some View {
Button(action: { dismiss() }) {
Image(systemName: "chevron.down.circle.fill")
}
.accessibilityLabel(Text("Close"))
.buttonStyle(BorderlessButtonStyle())
.foregroundColor(.gray)
.keyboardShortcut(.cancelAction)
}
}

View File

@ -1,10 +1,13 @@
import SwiftUI
struct Player: UIViewControllerRepresentable {
@ObservedObject var playbackState: PlaybackState
var video: Video?
func makeUIViewController(context _: Context) -> PlayerViewController {
let controller = PlayerViewController()
controller.playbackState = playbackState
controller.video = video
return controller

View File

@ -7,7 +7,8 @@ final class PlayerViewController: UIViewController {
var playerLoaded = false
var player = AVPlayer()
var playerState: PlayerState! = PlayerState()
var playerState: PlayerState!
var playbackState: PlaybackState!
var playerViewController = AVPlayerViewController()
override func viewWillAppear(_ animated: Bool) {
@ -33,6 +34,9 @@ final class PlayerViewController: UIViewController {
}
func loadPlayer() {
playerState = PlayerState()
playerState.playbackState = playbackState
guard !playerLoaded else {
return
}
@ -45,7 +49,6 @@ final class PlayerViewController: UIViewController {
present(playerViewController, animated: false)
addItemDidPlayToEndTimeObserver()
#else
embedViewController()
#endif
@ -111,6 +114,12 @@ extension PlayerViewController: AVPlayerViewControllerDelegate {
coordinator.animate(alongsideTransition: nil) { context in
if !context.isCancelled {
self.playerState.playingOutsideViewController = false
#if os(iOS)
if self.traitCollection.verticalSizeClass == .compact {
self.dismiss(animated: true)
}
#endif
}
}
}

View File

@ -0,0 +1,132 @@
import Foundation
import SwiftUI
struct VideoDetails: View {
var video: Video
var body: some View {
VStack(alignment: .leading) {
Text(video.title)
.font(.title2.bold())
Text(video.author)
.foregroundColor(.secondary)
HStack(spacing: 4) {
if let published = video.publishedDate {
Text(published)
}
if let publishedAt = video.publishedAt {
if video.publishedDate != nil {
Text("")
.foregroundColor(.secondary)
.opacity(0.3)
}
Text(publishedAt.formatted(date: .abbreviated, time: .omitted))
}
}
.padding(.top, 4)
.font(.system(size: 12))
.foregroundColor(.secondary)
HStack {
if let views = video.viewsCount {
VideoDetail(title: "Views", detail: views)
}
if let likes = video.likesCount {
VideoDetail(title: "Likes", detail: likes, symbol: "hand.thumbsup.circle.fill", symbolColor: Color("VideoDetailLikesSymbolColor"))
}
if let dislikes = video.dislikesCount {
VideoDetail(title: "Dislikes", detail: dislikes, symbol: "hand.thumbsdown.circle.fill", symbolColor: Color("VideoDetailDislikesSymbolColor"))
}
}
.padding(.horizontal, 1)
.padding(.vertical, 4)
#if os(macOS)
ScrollView(.vertical) {
Text(video.description)
.font(.caption)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 100, alignment: .leading)
}
#else
Text(video.description)
.font(.caption)
#endif
ScrollView(.horizontal, showsIndicators: showScrollIndicators) {
HStack {
ForEach(video.keywords, id: \.self) { keyword in
HStack(alignment: .center, spacing: 0) {
Text("#")
.font(.system(size: 11).bold())
Text(keyword)
.frame(maxWidth: 500)
}.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])
}
var showScrollIndicators: Bool {
#if os(macOS)
false
#else
true
#endif
}
}
struct VideoDetail: View {
var title: String
var detail: String
var symbol = "eye.fill"
var symbolColor = Color.white
var body: some View {
VStack {
VStack(spacing: 0) {
HStack(alignment: .center, spacing: 4) {
Image(systemName: symbol)
.foregroundColor(symbolColor)
Text(title.uppercased())
Spacer()
}
.font(.caption2)
.padding([.leading, .top], 4)
.frame(alignment: .leading)
Divider()
.background(.gray)
.padding(.vertical, 4)
Text(detail)
.shadow(radius: 1.0)
.font(.title3.bold())
}
}
.foregroundColor(.white)
.background(Color("VideoDetailBackgroundColor"))
.cornerRadius(6)
.overlay(RoundedRectangle(cornerRadius: 6)
.stroke(Color("VideoDetailBorderColor"), lineWidth: 1))
.frame(maxWidth: 90)
}
}

View File

@ -0,0 +1,42 @@
import Foundation
import SwiftUI
struct VideoDetailsPaddingModifier: ViewModifier {
let geometry: GeometryProxy
let aspectRatio: CGFloat?
let minimumHeightLeft: CGFloat
let additionalPadding: CGFloat
init(
geometry: GeometryProxy,
aspectRatio: CGFloat? = nil,
minimumHeightLeft: CGFloat? = nil,
additionalPadding: CGFloat = 35.00
) {
self.geometry = geometry
self.aspectRatio = aspectRatio ?? VideoPlayerView.defaultAspectRatio
self.minimumHeightLeft = minimumHeightLeft ?? VideoPlayerView.defaultMinimumHeightLeft
self.additionalPadding = additionalPadding
}
var usedAspectRatio: CGFloat {
guard aspectRatio != nil else {
return VideoPlayerView.defaultAspectRatio
}
return [aspectRatio!, VideoPlayerView.defaultAspectRatio].min()!
}
var playerHeight: CGFloat {
[geometry.size.width / usedAspectRatio, geometry.size.height - minimumHeightLeft].min()!
}
var topPadding: CGFloat {
playerHeight + additionalPadding
}
func body(content: Content) -> some View {
content
.padding(.top, topPadding)
}
}

View File

@ -0,0 +1,70 @@
import Foundation
import SwiftUI
struct VideoPlayerSizeModifier: ViewModifier {
let geometry: GeometryProxy
let aspectRatio: CGFloat?
let minimumHeightLeft: CGFloat
#if os(iOS)
@Environment(\.verticalSizeClass) private var verticalSizeClass
#endif
init(
geometry: GeometryProxy,
aspectRatio: CGFloat? = nil,
minimumHeightLeft: CGFloat? = nil
) {
self.geometry = geometry
self.aspectRatio = aspectRatio ?? VideoPlayerView.defaultAspectRatio
self.minimumHeightLeft = minimumHeightLeft ?? VideoPlayerView.defaultMinimumHeightLeft
}
func body(content: Content) -> some View {
content
.frame(maxHeight: maxHeight)
.aspectRatio(usedAspectRatio, contentMode: usedAspectRatioContentMode)
.edgesIgnoringSafeArea(edgesIgnoringSafeArea)
}
var usedAspectRatio: CGFloat {
guard aspectRatio != nil else {
return VideoPlayerView.defaultAspectRatio
}
let ratio = [aspectRatio!, VideoPlayerView.defaultAspectRatio].min()!
let viewRatio = geometry.size.width / geometry.size.height
#if os(iOS)
return verticalSizeClass == .regular ? ratio : viewRatio
#else
return ratio
#endif
}
var usedAspectRatioContentMode: ContentMode {
#if os(iOS)
verticalSizeClass == .regular ? .fit : .fill
#else
.fit
#endif
}
var maxHeight: CGFloat {
#if os(iOS)
verticalSizeClass == .regular ? geometry.size.height - minimumHeightLeft : .infinity
#else
geometry.size.height - minimumHeightLeft
#endif
}
var edgesIgnoringSafeArea: Edge.Set {
let empty = Edge.Set()
#if os(iOS)
return verticalSizeClass == .compact ? .all : empty
#else
return empty
#endif
}
}

View File

@ -3,11 +3,24 @@ import Siesta
import SwiftUI
struct VideoPlayerView: View {
static let defaultAspectRatio: CGFloat = 1.77777778
static var defaultMinimumHeightLeft: CGFloat {
#if os(macOS)
300
#else
200
#endif
}
@EnvironmentObject<NavigationState> private var navigationState
@ObservedObject private var store = Store<Video>()
@Environment(\.dismiss) private var dismiss
@ObservedObject private var playbackState = PlaybackState()
#if os(iOS)
@Environment(\.verticalSizeClass) private var verticalSizeClass
#endif
var resource: Resource {
InvidiousAPI.shared.video(video.id)
@ -21,25 +34,50 @@ struct VideoPlayerView: View {
}
var body: some View {
VStack {
Player(video: video)
.frame(alignment: .leading)
#if !os(tvOS)
ScrollView(.vertical) {
VStack(alignment: .leading) {
Text(video.title)
Text(video.author)
Button("Done") {
dismiss()
VStack(spacing: 0) {
#if os(tvOS)
Player(playbackState: playbackState, video: video)
#else
GeometryReader { geometry in
VStack(spacing: 0) {
#if os(iOS)
if verticalSizeClass == .regular {
PlaybackBar(playbackState: playbackState, video: video)
}
.keyboardShortcut(.cancelAction)
#elseif os(macOS)
PlaybackBar(playbackState: playbackState, video: video)
#endif
Player(playbackState: playbackState, video: video)
.modifier(VideoPlayerSizeModifier(geometry: geometry, aspectRatio: playbackState.aspectRatio))
}
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.background(.black)
VStack(spacing: 0) {
#if os(iOS)
if verticalSizeClass == .regular {
ScrollView(.vertical, showsIndicators: showScrollIndicators) {
if let video = store.item {
VideoDetails(video: video)
} else {
VideoDetails(video: video)
}
}
}
#else
if let video = store.item {
VideoDetails(video: video)
} else {
VideoDetails(video: video)
}
#endif
}
.modifier(VideoDetailsPaddingModifier(geometry: geometry, aspectRatio: playbackState.aspectRatio))
}
.animation(.linear(duration: 0.2), value: playbackState.aspectRatio)
#endif
}
.onAppear {
resource.loadIfNeeded()
}
@ -51,8 +89,29 @@ struct VideoPlayerView: View {
}
#if os(macOS)
.navigationTitle(video.title)
.frame(maxWidth: 1000, minHeight: 700)
#elseif os(iOS)
.navigationBarTitle(video.title, displayMode: .inline)
#endif
}
var showScrollIndicators: Bool {
#if os(macOS)
false
#else
true
#endif
}
}
struct VideoPlayerView_Previews: PreviewProvider {
static var previews: some View {
VStack {
Spacer()
}
.sheet(isPresented: .constant(true)) {
VideoPlayerView(Video.fixture)
.environmentObject(NavigationState())
}
}
}

View File

@ -131,7 +131,7 @@ struct VideoView: View {
if video.views != 0 {
Image(systemName: "eye")
Text(video.viewsCount)
Text(video.viewsCount!)
}
}
.foregroundColor(.secondary)
@ -139,7 +139,7 @@ struct VideoView: View {
var thumbnail: some View {
ZStack(alignment: .leading) {
thumbnailImage(quality: .maxres)
thumbnailImage(quality: .maxresdefault)
VStack {
HStack(alignment: .top) {
@ -181,12 +181,13 @@ struct VideoView: View {
ProgressView()
.aspectRatio(contentMode: .fill)
}
.mask(RoundedRectangle(cornerRadius: 12))
} else {
Image(systemName: "exclamationmark.square")
}
}
.frame(minWidth: 320, maxWidth: .infinity, minHeight: 180, maxHeight: .infinity)
.frame(minWidth: 300, maxWidth: .infinity, minHeight: 180, maxHeight: .infinity)
.background(.gray)
.mask(RoundedRectangle(cornerRadius: 12))
#if os(tvOS)
.frame(minHeight: layout == .cells ? 320 : 200)
#endif

View File

@ -1,10 +1,13 @@
import SwiftUI
struct Player: NSViewControllerRepresentable {
@ObservedObject var playbackState: PlaybackState
var video: Video!
func makeNSViewController(context _: Context) -> PlayerViewController {
let controller = PlayerViewController()
controller.playbackState = playbackState
controller.video = video
return controller

View File

@ -5,7 +5,8 @@ final class PlayerViewController: NSViewController {
var video: Video!
var player = AVPlayer()
var playerState: PlayerState! = PlayerState()
var playerState: PlayerState!
var playbackState: PlaybackState!
var playerView = AVPlayerView()
override func viewDidDisappear() {
@ -19,6 +20,9 @@ final class PlayerViewController: NSViewController {
}
override func loadView() {
playerState = PlayerState()
playerState.playbackState = playbackState
guard playerState.player == nil else {
return
}