Multiplatform playing first steps

This commit is contained in:
Arkadiusz Fal
2021-07-19 00:32:46 +02:00
parent 24a767e51c
commit fa07e47a22
19 changed files with 491 additions and 501 deletions

View File

@@ -1,79 +0,0 @@
import AVKit
import Foundation
import Siesta
import SwiftUI
struct PlayerView: View {
@ObservedObject private var store = Store<Video>()
let resource: Resource
init(id: String) {
resource = InvidiousAPI.shared.video(id)
resource.addObserver(store)
}
var body: some View {
VStack {
#if os(tvOS)
pvc
.edgesIgnoringSafeArea(.all)
#else
if let video = store.item {
VStack(alignment: .leading) {
Text(video.title)
.bold()
Text("\(video.author)")
.foregroundColor(.secondary)
.bold()
if !video.published.isEmpty || video.views != 0 {
HStack(spacing: 8) {
#if os(iOS)
Text(video.playTime ?? "?")
.layoutPriority(1)
#endif
if !video.published.isEmpty {
Image(systemName: "calendar")
Text(video.published)
.lineLimit(1)
.truncationMode(.middle)
}
if video.views != 0 {
Image(systemName: "eye")
Text(video.viewsCount)
}
}
.padding(.top)
}
}
#if os(tvOS)
.padding()
#else
#endif
}
#endif
}
.onAppear {
resource.loadIfNeeded()
}
}
// swiftlint:disable implicit_return
#if !os(macOS)
var pvc: PlayerViewController? {
guard store.item != nil else {
return nil
}
return PlayerViewController(video: store.item!)
}
#endif
// swiftlint:enable implicit_return
}

View File

@@ -1,184 +0,0 @@
import AVKit
import Foundation
import Logging
import SwiftUI
struct PlayerViewController: UIViewControllerRepresentable {
#if os(tvOS)
typealias PlayerController = StreamAVPlayerViewController
#else
typealias PlayerController = AVPlayerViewController
#endif
let logger = Logger(label: "net.arekf.Pearvidious.pvc")
@ObservedObject private var state: PlayerState
var video: Video
init(video: Video) {
self.video = video
state = PlayerState(video)
loadStream(video.defaultStreamForProfile(state.profile), loadBest: state.profile.defaultStreamResolution == .hd720pFirstThenBest)
}
fileprivate func loadStream(_ stream: Stream?, loadBest: Bool = false) {
if stream != state.nextStream {
state.loadStream(stream)
addTracksAndLoadAssets(stream!, loadBest: loadBest)
}
}
fileprivate func addTracksAndLoadAssets(_ stream: Stream, loadBest: Bool = false) {
logger.info("adding tracks and loading assets for: \(stream.type), \(stream.description)")
stream.assets.forEach { asset in
asset.loadValuesAsynchronously(forKeys: ["playable"]) {
handleAssetLoad(stream, type: asset == stream.videoAsset ? .video : .audio, loadBest: loadBest)
}
}
}
fileprivate func handleAssetLoad(_ stream: Stream, type: AVMediaType, loadBest: Bool = false) {
logger.info("handling asset load: \(stream.type), \(stream.description)")
guard stream != state.currentStream else {
logger.warning("IGNORING assets loaded: \(stream.type), \(stream.description)")
return
}
stream.loadedAssets.forEach { asset in
addTrack(asset, stream: stream, type: type)
if stream.assetsLoaded {
DispatchQueue.main.async {
logger.info("ALL assets loaded: \(stream.type), \(stream.description)")
state.playStream(stream)
}
if loadBest {
loadBestStream()
}
}
}
}
fileprivate func addTrack(_ asset: AVURLAsset, stream: Stream, type: AVMediaType? = nil) {
let types: [AVMediaType] = stream.type == .adaptive ? [type!] : [.video, .audio]
types.forEach { state.addTrackToNextComposition(asset, type: $0) }
}
fileprivate func loadBestStream() {
guard state.currentStream != video.bestStream else {
return
}
loadStream(video.bestStream)
}
func makeUIViewController(context _: Context) -> PlayerController {
let controller = PlayerController()
#if os(tvOS)
controller.state = state
controller.transportBarCustomMenuItems = [streamingQualityMenu]
#endif
controller.modalPresentationStyle = .fullScreen
controller.player = state.player
return controller
}
func updateUIViewController(_ controller: PlayerController, context _: Context) {
var items: [UIMenuElement] = []
if state.nextStream != nil {
items.append(actionsMenu)
}
items.append(playbackRateMenu)
items.append(streamingQualityMenu)
#if os(tvOS)
controller.transportBarCustomMenuItems = items
if let skip = skipSegmentAction {
if controller.contextualActions.isEmpty {
controller.contextualActions = [skip]
}
} else {
controller.contextualActions = []
}
#endif
}
fileprivate var streamingQualityMenu: UIMenu {
UIMenu(title: "Streaming quality", image: UIImage(systemName: "waveform"), children: streamingQualityMenuActions)
}
fileprivate var streamingQualityMenuActions: [UIAction] {
video.selectableStreams.map { stream in
let image = self.state.currentStream == stream ? UIImage(systemName: "checkmark") : nil
return UIAction(title: stream.description, image: image) { _ in
guard state.currentStream != stream else {
return
}
loadStream(stream)
}
}
}
fileprivate var actionsMenu: UIMenu {
UIMenu(title: "Actions", image: UIImage(systemName: "bolt.horizontal.fill"), children: [cancelLoadingAction])
}
fileprivate var cancelLoadingAction: UIAction {
UIAction(title: "Cancel loading \(state.nextStream.description) stream") { _ in
DispatchQueue.main.async {
state.nextStream.cancelLoadingAssets()
state.cancelLoadingStream(state.nextStream)
}
}
}
private var skipSegmentAction: UIAction? {
if state.currentSegment == nil {
return nil
}
return UIAction(title: "Skip \(state.currentSegment!.title())") { _ in
DispatchQueue.main.async {
state.player.seek(to: state.currentSegment!.skipTo)
}
}
}
private var playbackRateMenu: UIMenu {
UIMenu(title: "Playback rate", image: UIImage(systemName: playbackRateMenuImageSystemName), children: playbackRateMenuActions)
}
private var playbackRateMenuImageSystemName: String {
if [0.0, 1.0].contains(state.player.rate) {
return "speedometer"
}
return state.player.rate < 1.0 ? "tortoise.fill" : "hare.fill"
}
private var playbackRateMenuActions: [UIAction] {
PlayerState.availablePlaybackRates.map { rate in
let image = state.currentRate == Float(rate) ? UIImage(systemName: "checkmark") : nil
return UIAction(title: "\(rate)x", image: image) { _ in
DispatchQueue.main.async {
state.setPlayerRate(Float(rate))
}
}
}
}
}

View File

@@ -35,7 +35,9 @@ struct TVNavigationView: View {
VideoDetailsView(video)
}
}
.fullScreenCover(isPresented: $navigationState.showingChannel) {
.fullScreenCover(isPresented: $navigationState.showingChannel, onDismiss: {
navigationState.showVideoDetailsIfNeeded()
}) {
if let channel = navigationState.channel {
ChannelView(id: channel.id)
}

View File

@@ -4,10 +4,12 @@ import URLImageStore
import SwiftUI
struct VideoCellView: View {
@EnvironmentObject<NavigationState> private var navigationState
var video: Video
var body: some View {
NavigationLink(destination: PlayerView(id: video.id)) {
Button(action: { navigationState.playVideo(video) }) {
VStack(alignment: .leading) {
ZStack(alignment: .trailing) {
if let thumbnail = video.thumbnailURL(quality: .high) {

View File

@@ -10,6 +10,8 @@ struct VideoDetailsView: View {
@ObservedObject private var store = Store<Video>()
@State private var playVideoLinkActive = false
var resource: Resource {
InvidiousAPI.shared.video(video.id)
}
@@ -22,73 +24,88 @@ struct VideoDetailsView: View {
}
var body: some View {
HStack {
Spacer()
VStack {
NavigationView {
HStack {
Spacer()
ScrollView(.vertical, showsIndicators: false) {
if let video = store.item {
VStack(alignment: .center) {
ZStack(alignment: .bottom) {
Group {
if let thumbnail = video.thumbnailURL(quality: .maxres) {
// to replace with AsyncImage when it is fixed with lazy views
URLImage(thumbnail) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 1600, height: 800)
}
}
}
.frame(width: 1600, height: 800)
VStack(alignment: .leading) {
Text(video.title)
.font(.system(size: 40))
VStack {
Spacer()
HStack {
NavigationLink(destination: PlayerView(id: video.id)) {
HStack(spacing: 8) {
Image(systemName: "play.rectangle.fill")
Text("Play")
ScrollView(.vertical, showsIndicators: false) {
if let video = store.item {
VStack(alignment: .center) {
ZStack(alignment: .bottom) {
Group {
if let thumbnail = video.thumbnailURL(quality: .maxres) {
// to replace with AsyncImage when it is fixed with lazy views
URLImage(thumbnail) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 1600, height: 800)
}
}
openChannelButton
}
.frame(width: 1600, height: 800)
VStack(alignment: .leading) {
Text(video.title)
.font(.system(size: 40))
HStack {
playVideoButton
openChannelButton
}
}
.padding(40)
.frame(width: 1600, alignment: .leading)
.background(.thinMaterial)
}
.padding(40)
.frame(width: 1600, alignment: .leading)
.background(.thinMaterial)
.mask(RoundedRectangle(cornerRadius: 20))
VStack {
Text(video.description)
.lineLimit(nil)
.focusable()
}.frame(width: 1600, alignment: .leading)
}
.mask(RoundedRectangle(cornerRadius: 20))
VStack {
Text(video.description)
.lineLimit(nil)
.focusable()
}.frame(width: 1600, alignment: .leading)
}
}
Spacer()
}
Spacer()
}
Spacer()
}
.background(.thinMaterial)
.onAppear {
resource.loadIfNeeded()
}
.edgesIgnoringSafeArea(.all)
}
var playVideoButton: some View {
Button(action: {
navigationState.returnToDetails = true
playVideoLinkActive = true
}) {
HStack(spacing: 8) {
Image(systemName: "play.rectangle.fill")
Text("Play")
}
}
.background(NavigationLink(destination: VideoPlayerView(video), isActive: $playVideoLinkActive) { EmptyView() }.hidden())
}
var openChannelButton: some View {
let channel = Channel.from(video: store.item!)
return Button("Open \(channel.name) channel") {
navigationState.openChannel(channel)
navigationState.returnToDetails = true
dismiss()
}
}

View File

@@ -3,6 +3,8 @@ import URLImage
import URLImageStore
struct VideoListRowView: View {
@EnvironmentObject<NavigationState> private var navigationState
@Environment(\.isFocused) private var focused: Bool
#if os(iOS)
@@ -12,23 +14,27 @@ struct VideoListRowView: View {
var video: Video
var body: some View {
#if os(tvOS) || os(macOS)
NavigationLink(destination: PlayerView(id: video.id)) {
#if os(tvOS)
horizontalRow(detailsOnThumbnail: false)
#elseif os(macOS)
verticalRow
#endif
#if os(tvOS)
Button(action: { navigationState.playVideo(video) }) {
horizontalRow(detailsOnThumbnail: false)
}
#elseif os(macOS)
NavigationLink(destination: VideoPlayerView(video)) {
verticalRow
}
#else
ZStack {
if verticalSizeClass == .compact {
horizontalRow(padding: 4)
} else {
#if os(macOS)
verticalRow
}
#else
if verticalSizeClass == .compact {
horizontalRow(padding: 4)
} else {
verticalRow
}
#endif
NavigationLink(destination: PlayerView(id: video.id)) {
NavigationLink(destination: VideoPlayerView(video)) {
EmptyView()
}
.buttonStyle(PlainButtonStyle())

View File

@@ -0,0 +1,19 @@
import SwiftUI
struct VideoLoading: View {
var video: Video
var body: some View {
VStack {
Spacer()
VStack {
Text(video.title)
Text("Loading...")
}
Spacer()
}
}
}

View File

@@ -2,9 +2,13 @@ import Defaults
import SwiftUI
struct VideosView: View {
@EnvironmentObject<NavigationState> private var navigationState
@State private var profile = Profile()
@Default(.layout) var layout
#if os(tvOS)
@Default(.layout) var layout
#endif
@Default(.showingAddToPlaylist) var showingAddToPlaylist
@@ -31,9 +35,15 @@ struct VideosView: View {
}
#if os(tvOS)
.fullScreenCover(isPresented: $navigationState.showingVideo) {
if let video = navigationState.video {
VideoPlayerView(video)
}
}
.fullScreenCover(isPresented: $showingAddToPlaylist) {
AddToPlaylistView()
}
#endif
}
}