mirror of
https://github.com/yattee/yattee.git
synced 2025-12-26 14:40:15 +00:00
Multiplatform playing first steps
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
19
Apple TV/VideoLoading.swift
Normal file
19
Apple TV/VideoLoading.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user