Initial implementation over
This commit is contained in:
parent
25c2bcc3c8
commit
59465a3c4a
|
@ -11,15 +11,22 @@ import SwiftUI
|
|||
|
||||
struct MediaPlayButtonRowView: View {
|
||||
|
||||
@EnvironmentObject var itemRouter: ItemCoordinator.Router
|
||||
@ObservedObject var viewModel: ItemViewModel
|
||||
@State var wrappedScrollView: UIScrollView?
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack {
|
||||
NavigationLink(destination: VideoPlayerView(item: viewModel.item).ignoresSafeArea()) {
|
||||
// NavigationLink(destination: VideoPlayerView(item: viewModel.item).ignoresSafeArea()) {
|
||||
// MediaViewActionButton(icon: "play.fill", scrollView: $wrappedScrollView)
|
||||
// }
|
||||
Button {
|
||||
itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!)
|
||||
} label: {
|
||||
MediaViewActionButton(icon: "play.fill", scrollView: $wrappedScrollView)
|
||||
}
|
||||
|
||||
Text(viewModel.item.getItemProgressString() != "" ? "\(viewModel.item.getItemProgressString()) left" : L10n.play)
|
||||
.font(.caption)
|
||||
}
|
||||
|
|
|
@ -11,6 +11,8 @@ import SwiftUI
|
|||
import JellyfinAPI
|
||||
|
||||
struct EpisodeItemView: View {
|
||||
|
||||
@EnvironmentObject var itemRouter: ItemCoordinator.Router
|
||||
@ObservedObject var viewModel: EpisodeItemViewModel
|
||||
|
||||
@State var actors: [BaseItemPerson] = []
|
||||
|
@ -130,7 +132,13 @@ struct EpisodeItemView: View {
|
|||
.font(.caption)
|
||||
}
|
||||
VStack {
|
||||
NavigationLink(destination: VideoPlayerView(item: viewModel.item).ignoresSafeArea()) {
|
||||
// NavigationLink(destination: VideoPlayerView(item: viewModel.item).ignoresSafeArea()) {
|
||||
// MediaViewActionButton(icon: "play.fill")
|
||||
// }
|
||||
Button {
|
||||
itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!)
|
||||
} label: {
|
||||
// MediaViewActionButton(icon: "play.fill", scrollView: $wrappedScrollView)
|
||||
MediaViewActionButton(icon: "play.fill")
|
||||
}
|
||||
Text(viewModel.item.getItemProgressString() != "" ? "\(viewModel.item.getItemProgressString()) left" : L10n.play)
|
||||
|
|
|
@ -27,7 +27,7 @@ struct LatestMediaView: View {
|
|||
viewDidLoad = true
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
UserLibraryAPI.getLatestMedia(userId: SessionManager.main.currentLogin.user.id, parentId: library_id, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], enableUserData: true, limit: 12)
|
||||
UserLibraryAPI.getLatestMedia(userId: SessionManager.main.currentLogin.user.id, parentId: library_id, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters], enableUserData: true, limit: 12)
|
||||
.sink(receiveCompletion: { completion in
|
||||
print(completion)
|
||||
}, receiveValue: { response in
|
||||
|
|
|
@ -1,66 +0,0 @@
|
|||
//
|
||||
/*
|
||||
* SwiftFin is subject to the terms of the Mozilla Public
|
||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||
*/
|
||||
|
||||
import SwiftUI
|
||||
|
||||
class AudioViewController: InfoTabViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
tabBarItem.title = "Audio"
|
||||
|
||||
}
|
||||
|
||||
func prepareAudioView(audioTracks: [AudioTrack], selectedTrack: Int32, delegate: VideoPlayerSettingsDelegate) {
|
||||
let contentView = UIHostingController(rootView: AudioView(selectedTrack: selectedTrack, audioTrackArray: audioTracks, delegate: delegate))
|
||||
self.view.addSubview(contentView.view)
|
||||
contentView.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
|
||||
contentView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
|
||||
contentView.view.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
|
||||
contentView.view.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct AudioView: View {
|
||||
|
||||
@State var selectedTrack: Int32 = -1
|
||||
@State var audioTrackArray: [AudioTrack] = []
|
||||
|
||||
weak var delegate: VideoPlayerSettingsDelegate?
|
||||
|
||||
var body : some View {
|
||||
NavigationView {
|
||||
VStack {
|
||||
List(audioTrackArray, id: \.id) { track in
|
||||
Button(action: {
|
||||
delegate?.selectNew(audioTrack: track.id)
|
||||
selectedTrack = track.id
|
||||
}, label: {
|
||||
HStack(spacing: 10) {
|
||||
if track.id == selectedTrack {
|
||||
Image(systemName: "checkmark")
|
||||
} else {
|
||||
Image(systemName: "checkmark")
|
||||
.hidden()
|
||||
}
|
||||
Text(track.name)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
.frame(width: 400)
|
||||
.frame(maxHeight: 400)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,59 +0,0 @@
|
|||
//
|
||||
/*
|
||||
* SwiftFin is subject to the terms of the Mozilla Public
|
||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||
*/
|
||||
|
||||
import TVUIKit
|
||||
import JellyfinAPI
|
||||
|
||||
class InfoTabViewController: UIViewController {
|
||||
var height: CGFloat = 420
|
||||
}
|
||||
|
||||
class InfoTabBarViewController: UITabBarController, UIGestureRecognizerDelegate {
|
||||
|
||||
var videoPlayer: VideoPlayerViewController?
|
||||
var subtitleViewController: SubtitlesViewController?
|
||||
var audioViewController: AudioViewController?
|
||||
var mediaInfoController: MediaInfoViewController?
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
mediaInfoController = MediaInfoViewController()
|
||||
audioViewController = AudioViewController()
|
||||
subtitleViewController = SubtitlesViewController()
|
||||
|
||||
viewControllers = [mediaInfoController!, audioViewController!, subtitleViewController!]
|
||||
|
||||
}
|
||||
|
||||
func setupInfoViews(mediaItem: BaseItemDto, subtitleTracks: [Subtitle], selectedSubtitleTrack: Int32, audioTracks: [AudioTrack], selectedAudioTrack: Int32, delegate: VideoPlayerSettingsDelegate) {
|
||||
|
||||
mediaInfoController?.setMedia(item: mediaItem)
|
||||
|
||||
audioViewController?.prepareAudioView(audioTracks: audioTracks, selectedTrack: selectedAudioTrack, delegate: delegate)
|
||||
|
||||
subtitleViewController?.prepareSubtitleView(subtitleTracks: subtitleTracks, selectedTrack: selectedSubtitleTrack, delegate: delegate)
|
||||
|
||||
}
|
||||
|
||||
override func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
|
||||
|
||||
if let index = tabBar.items?.firstIndex(of: item),
|
||||
let tabViewController = viewControllers?[index] as? InfoTabViewController,
|
||||
let width = videoPlayer?.infoPanelContainerView.frame.width {
|
||||
let height = tabViewController.height + tabBar.frame.size.height
|
||||
UIView.animate(withDuration: 0.6, delay: 0, options: .curveEaseOut) { [self] in
|
||||
videoPlayer?.infoPanelContainerView.frame = CGRect(x: 88, y: 87, width: width, height: height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
return true
|
||||
}
|
||||
}
|
|
@ -1,121 +0,0 @@
|
|||
//
|
||||
/*
|
||||
* SwiftFin is subject to the terms of the Mozilla Public
|
||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||
*/
|
||||
|
||||
import SwiftUI
|
||||
import JellyfinAPI
|
||||
|
||||
class MediaInfoViewController: InfoTabViewController {
|
||||
private var contentView: UIHostingController<MediaInfoView>!
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
tabBarItem.title = "Info"
|
||||
}
|
||||
|
||||
func setMedia(item: BaseItemDto) {
|
||||
contentView = UIHostingController(rootView: MediaInfoView(item: item))
|
||||
self.view.addSubview(contentView.view)
|
||||
contentView.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
|
||||
contentView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
|
||||
contentView.view.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
|
||||
contentView.view.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true
|
||||
|
||||
height = self.view.frame.height
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
struct MediaInfoView: View {
|
||||
@State var item: BaseItemDto?
|
||||
|
||||
var body: some View {
|
||||
if let item = item {
|
||||
HStack(spacing: 30) {
|
||||
VStack {
|
||||
ImageView(src: item.type == "Episode" ? item.getSeriesPrimaryImage(maxWidth: 200) : item.getPrimaryImage(maxWidth: 200), bh: item.type == "Episode" ? item.getSeriesPrimaryImageBlurHash() : item.getPrimaryImageBlurHash())
|
||||
.frame(width: 200, height: 300)
|
||||
.cornerRadius(10)
|
||||
.ignoresSafeArea()
|
||||
Spacer()
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if item.type == "Episode" {
|
||||
Text(item.seriesName ?? "Series")
|
||||
.fontWeight(.bold)
|
||||
|
||||
HStack {
|
||||
Text(item.name ?? "Episode")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(item.getEpisodeLocator() ?? "")
|
||||
|
||||
if let date = item.premiereDate {
|
||||
Text(formatDate(date: date))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(item.name ?? "Movie")
|
||||
.fontWeight(.bold)
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
if item.type != "Episode" {
|
||||
if let year = item.productionYear {
|
||||
Text(String(year))
|
||||
}
|
||||
}
|
||||
|
||||
if item.runTimeTicks != nil {
|
||||
Text("•")
|
||||
Text(item.getItemRuntime())
|
||||
}
|
||||
|
||||
if let rating = item.officialRating {
|
||||
Text("•")
|
||||
|
||||
Text("\(rating)").font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4))
|
||||
.overlay(RoundedRectangle(cornerRadius: 2)
|
||||
.stroke(Color.secondary, lineWidth: 1))
|
||||
|
||||
}
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if let overview = item.overview {
|
||||
Text(overview)
|
||||
.padding(.top)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
}
|
||||
.padding(.leading, 350)
|
||||
.padding(.trailing, 125)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func formatDate(date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM d, yyyy"
|
||||
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,159 @@
|
|||
//
|
||||
// NativePlayerViewController.swift
|
||||
// JellyfinVideoPlayerDev
|
||||
//
|
||||
// Created by Ethan Pippin on 11/20/21.
|
||||
//
|
||||
|
||||
import AVKit
|
||||
import Combine
|
||||
import JellyfinAPI
|
||||
import UIKit
|
||||
|
||||
class NativePlayerViewController: AVPlayerViewController {
|
||||
|
||||
let viewModel: VideoPlayerViewModel
|
||||
|
||||
var timeObserverToken: Any?
|
||||
|
||||
var lastProgressTicks: Int64 = 0
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(viewModel: VideoPlayerViewModel) {
|
||||
|
||||
self.viewModel = viewModel
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
let player = AVPlayer(url: viewModel.hlsURL)
|
||||
|
||||
player.appliesMediaSelectionCriteriaAutomatically = false
|
||||
player.currentItem?.externalMetadata = createMetadata()
|
||||
player.currentItem?.navigationMarkerGroups = createNavigationMarkerGroups()
|
||||
|
||||
// let chevron = UIImage(systemName: "chevron.right.circle.fill")!
|
||||
// let testAction = UIAction(title: "Next", image: chevron) { action in
|
||||
// SessionAPI.sendSystemCommand(sessionId: viewModel.response.playSessionId!, command: .setSubtitleStreamIndex)
|
||||
// .sink { completion in
|
||||
// print(completion)
|
||||
// } receiveValue: { _ in
|
||||
// print("idk but we're here")
|
||||
// }
|
||||
// .store(in: &self.cancellables)
|
||||
// }
|
||||
|
||||
// self.transportBarCustomMenuItems = [testAction]
|
||||
|
||||
// self.infoViewActions.append(testAction)
|
||||
|
||||
let timeScale = CMTimeScale(NSEC_PER_SEC)
|
||||
let time = CMTime(seconds: 5, preferredTimescale: timeScale)
|
||||
|
||||
timeObserverToken = player.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] time in
|
||||
// print("Timer timed: \(time)")
|
||||
|
||||
if time.seconds != 0 {
|
||||
self?.sendProgressReport(seconds: time.seconds)
|
||||
}
|
||||
}
|
||||
|
||||
self.player = player
|
||||
|
||||
self.allowsPictureInPicturePlayback = true
|
||||
self.player?.allowsExternalPlayback = true
|
||||
}
|
||||
|
||||
private func createMetadata() -> [AVMetadataItem] {
|
||||
let allMetadata: [AVMetadataIdentifier: Any] = [
|
||||
.commonIdentifierTitle: viewModel.title,
|
||||
.iTunesMetadataTrackSubTitle: viewModel.subtitle ?? "",
|
||||
.commonIdentifierArtwork: UIImage(data: try! Data(contentsOf: viewModel.item.getBackdropImage(maxWidth: 200)))?.pngData() as Any,
|
||||
.commonIdentifierDescription: viewModel.item.overview ?? "",
|
||||
.iTunesMetadataContentRating: viewModel.item.officialRating ?? "",
|
||||
.quickTimeMetadataGenre: viewModel.item.genres?.first ?? ""
|
||||
]
|
||||
|
||||
return allMetadata.compactMap { createMetadataItem(for:$0, value:$1) }
|
||||
}
|
||||
|
||||
private func createMetadataItem(for identifier: AVMetadataIdentifier,
|
||||
value: Any) -> AVMetadataItem {
|
||||
let item = AVMutableMetadataItem()
|
||||
item.identifier = identifier
|
||||
item.value = value as? NSCopying & NSObjectProtocol
|
||||
// Specify "und" to indicate an undefined language.
|
||||
item.extendedLanguageTag = "und"
|
||||
return item.copy() as! AVMetadataItem
|
||||
}
|
||||
|
||||
private func createNavigationMarkerGroups() -> [AVNavigationMarkersGroup] {
|
||||
guard let chapters = viewModel.item.chapters else { return [] }
|
||||
|
||||
var metadataGroups: [AVTimedMetadataGroup] = []
|
||||
|
||||
// TODO: Determine range between chapters
|
||||
chapters.forEach { chapterInfo in
|
||||
var chapterMetadata: [AVMetadataItem] = []
|
||||
|
||||
let titleItem = createMetadataItem(for: .commonIdentifierTitle, value: chapterInfo.name ?? "No Name")
|
||||
chapterMetadata.append(titleItem)
|
||||
|
||||
let imageItem = createMetadataItem(for: .commonIdentifierArtwork, value: UIImage(data: try! Data(contentsOf: viewModel.item.getBackdropImage(maxWidth: 200)))?.pngData() as Any)
|
||||
chapterMetadata.append(imageItem)
|
||||
|
||||
let startTime = CMTimeMake(value: chapterInfo.startPositionTicks ?? 0, timescale: 10_000_000)
|
||||
let endTime = CMTimeMake(value: (chapterInfo.startPositionTicks ?? 0) + 50_000_000, timescale: 10_000_000)
|
||||
let timeRange = CMTimeRangeFromTimeToTime(start: startTime, end: endTime)
|
||||
|
||||
metadataGroups.append(AVTimedMetadataGroup(items: chapterMetadata, timeRange: timeRange))
|
||||
}
|
||||
|
||||
return [AVNavigationMarkersGroup(title: nil, timedNavigationMarkers: metadataGroups)]
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
stop()
|
||||
removePeriodicTimeObserver()
|
||||
}
|
||||
|
||||
func removePeriodicTimeObserver() {
|
||||
if let timeObserverToken = timeObserverToken {
|
||||
player?.removeTimeObserver(timeObserverToken)
|
||||
self.timeObserverToken = nil
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
player?.seek(to: CMTimeMake(value: viewModel.item.userData?.playbackPositionTicks ?? 0, timescale: 10_000_000), toleranceBefore: CMTimeMake(value: 5, timescale: 1), toleranceAfter: CMTimeMake(value: 5, timescale: 1), completionHandler: { _ in
|
||||
self.play()
|
||||
})
|
||||
}
|
||||
|
||||
private func play() {
|
||||
player?.play()
|
||||
|
||||
viewModel.sendPlayReport(startTimeTicks: viewModel.item.userData?.playbackPositionTicks ?? 0)
|
||||
}
|
||||
|
||||
private func sendProgressReport(seconds: Double) {
|
||||
viewModel.sendProgressReport(ticks: Int64(seconds) * 10_000_000)
|
||||
}
|
||||
|
||||
private func stop() {
|
||||
self.player?.pause()
|
||||
viewModel.sendStopReport(ticks: 10_000_000)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
//
|
||||
// PlayerOverlayDelegate.swift
|
||||
// JellyfinVideoPlayerDev
|
||||
//
|
||||
// Created by Ethan Pippin on 12/27/21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol PlayerOverlayDelegate {
|
||||
|
||||
func didSelectClose()
|
||||
func didSelectGoogleCast()
|
||||
func didSelectAirplay()
|
||||
func didSelectCaptions()
|
||||
func didSelectMenu()
|
||||
func didDeselectMenu()
|
||||
|
||||
func didSelectBackward()
|
||||
func didSelectForward()
|
||||
func didSelectMain()
|
||||
|
||||
func didGenerallyTap()
|
||||
|
||||
func didBeginScrubbing()
|
||||
func didEndScrubbing(position: Double)
|
||||
|
||||
func didSelectAudioStream(index: Int)
|
||||
func didSelectSubtitleStream(index: Int)
|
||||
}
|
|
@ -1,67 +0,0 @@
|
|||
//
|
||||
/*
|
||||
* SwiftFin is subject to the terms of the Mozilla Public
|
||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||
*/
|
||||
|
||||
import SwiftUI
|
||||
|
||||
class SubtitlesViewController: InfoTabViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
tabBarItem.title = "Subtitles"
|
||||
|
||||
}
|
||||
|
||||
func prepareSubtitleView(subtitleTracks: [Subtitle], selectedTrack: Int32, delegate: VideoPlayerSettingsDelegate) {
|
||||
let contentView = UIHostingController(rootView: SubtitleView(selectedTrack: selectedTrack, subtitleTrackArray: subtitleTracks, delegate: delegate))
|
||||
self.view.addSubview(contentView.view)
|
||||
contentView.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
|
||||
contentView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
|
||||
contentView.view.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
|
||||
contentView.view.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
struct SubtitleView: View {
|
||||
|
||||
@State var selectedTrack: Int32 = -1
|
||||
@State var subtitleTrackArray: [Subtitle] = []
|
||||
|
||||
weak var delegate: VideoPlayerSettingsDelegate?
|
||||
|
||||
var body : some View {
|
||||
NavigationView {
|
||||
VStack {
|
||||
List(subtitleTrackArray, id: \.id) { track in
|
||||
Button(action: {
|
||||
delegate?.selectNew(subtitleTrack: track.id)
|
||||
selectedTrack = track.id
|
||||
}, label: {
|
||||
HStack(spacing: 10) {
|
||||
if track.id == selectedTrack {
|
||||
Image(systemName: "checkmark")
|
||||
} else {
|
||||
Image(systemName: "checkmark")
|
||||
.hidden()
|
||||
}
|
||||
Text(track.name)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
.frame(width: 400)
|
||||
.frame(maxHeight: 400)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -1,126 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder.AppleTV.Storyboard" version="3.0" toolsVersion="18122" targetRuntime="AppleTV" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<device id="appleTV" appearance="light"/>
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Info Tab Bar View Controller-->
|
||||
<scene sceneID="NE2-Ez-3qW">
|
||||
<objects>
|
||||
<tabBarController id="odZ-Ww-zvF" customClass="InfoTabBarViewController" customModule="JellyfinPlayer_tvOS" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<tabBar key="tabBar" opaque="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" translucent="NO" id="YVR-nj-bPt">
|
||||
<rect key="frame" x="0.0" y="0.0" width="1920" height="68"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<color key="tintColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<color key="barTintColor" red="0.101966925" green="0.1019589528" blue="0.1101123616" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</tabBar>
|
||||
</tabBarController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="LdX-BO-e71" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="-5664" y="320"/>
|
||||
</scene>
|
||||
<!--Video Player View Controller-->
|
||||
<scene sceneID="9lE-WX-96o">
|
||||
<objects>
|
||||
<viewController storyboardIdentifier="VideoPlayer" id="Xgj-up-wSf" customClass="VideoPlayerViewController" customModule="JellyfinPlayer_tvOS" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="aTn-mJ-5lt"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="BrO-eL-FPV"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="Oo6-ab-TCE">
|
||||
<rect key="frame" x="0.0" y="0.0" width="1920" height="1080"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<view userInteractionEnabled="NO" contentMode="scaleToFill" id="Ibg-CP-gb2" userLabel="VideoPlayer">
|
||||
<rect key="frame" x="0.0" y="0.0" width="1920" height="1080"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<viewLayoutGuide key="safeArea" id="jNU-Xf-Kyx"/>
|
||||
<accessibility key="accessibilityConfiguration">
|
||||
<accessibilityTraits key="traits" notEnabled="YES"/>
|
||||
</accessibility>
|
||||
</view>
|
||||
<view hidden="YES" contentMode="scaleToFill" id="OG6-kk-N7Z" userLabel="Controls">
|
||||
<rect key="frame" x="-1" y="0.0" width="1920" height="1080"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<view contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="eh8-uG-9Wz" userLabel="GradientView">
|
||||
<rect key="frame" x="0.0" y="700" width="1925" height="391"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
</view>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" fixedFrame="YES" text="-00:00:00" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" translatesAutoresizingMaskIntoConstraints="NO" id="NyZ-z0-56J" userLabel="RemainingTimeLabel">
|
||||
<rect key="frame" x="1694" y="1007" width="139" height="36"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="30"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" fixedFrame="YES" text="00:00:00" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="CL5-ko-ceu" userLabel="CurrentTimeLabel">
|
||||
<rect key="frame" x="88" y="1007" width="140" height="36"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="30"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<view contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="CQO-wl-bxv" userLabel="TransportBar">
|
||||
<rect key="frame" x="88" y="981" width="1744" height="11"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<subviews>
|
||||
<view contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="yrg-ru-QSH" userLabel="Scrubber">
|
||||
<rect key="frame" x="0.0" y="0.0" width="2" height="10"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</view>
|
||||
<label hidden="YES" opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" fixedFrame="YES" text="00:00" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="HfD-p0-JMA" userLabel="ScrubLabel">
|
||||
<rect key="frame" x="50" y="-60" width="140" height="36"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="28"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
<color key="backgroundColor" white="0.33333333333333331" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</view>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="IS7-IU-teh"/>
|
||||
<accessibility key="accessibilityConfiguration">
|
||||
<accessibilityTraits key="traits" allowsDirectInteraction="YES"/>
|
||||
</accessibility>
|
||||
</view>
|
||||
<containerView opaque="NO" contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="lie-K8-LNT">
|
||||
<rect key="frame" x="88" y="87" width="1744" height="635"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMinY="YES"/>
|
||||
<connections>
|
||||
<segue destination="odZ-Ww-zvF" kind="embed" identifier="infoView" id="i7y-hI-hVh"/>
|
||||
</connections>
|
||||
</containerView>
|
||||
<activityIndicatorView opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" fixedFrame="YES" animating="YES" style="large" translatesAutoresizingMaskIntoConstraints="NO" id="WHW-kl-wkr">
|
||||
<rect key="frame" x="928" y="508" width="64" height="64"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<color key="color" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</activityIndicatorView>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="Rbh-h3-eDf"/>
|
||||
<color key="backgroundColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</view>
|
||||
<connections>
|
||||
<outlet property="activityIndicator" destination="WHW-kl-wkr" id="7aF-qL-t4E"/>
|
||||
<outlet property="controlsView" destination="OG6-kk-N7Z" id="8Ed-du-EpL"/>
|
||||
<outlet property="currentTimeLabel" destination="CL5-ko-ceu" id="gZB-h5-TGd"/>
|
||||
<outlet property="gradientView" destination="eh8-uG-9Wz" id="fBa-EG-C6z"/>
|
||||
<outlet property="infoPanelContainerView" destination="lie-K8-LNT" id="4io-B3-qE3"/>
|
||||
<outlet property="remainingTimeLabel" destination="NyZ-z0-56J" id="Opj-7c-cIE"/>
|
||||
<outlet property="scrubLabel" destination="HfD-p0-JMA" id="R28-Fa-v9d"/>
|
||||
<outlet property="scrubberView" destination="yrg-ru-QSH" id="ylv-C7-RNl"/>
|
||||
<outlet property="transportBarView" destination="CQO-wl-bxv" id="tQv-bp-jYq"/>
|
||||
<outlet property="videoContentView" destination="Ibg-CP-gb2" id="vnQ-7F-8AU"/>
|
||||
</connections>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="7uX-ET-Cqw" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="-5664" y="-1259"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
|
@ -1,28 +0,0 @@
|
|||
//
|
||||
/*
|
||||
* SwiftFin is subject to the terms of the Mozilla Public
|
||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||
*/
|
||||
|
||||
import SwiftUI
|
||||
import JellyfinAPI
|
||||
|
||||
struct VideoPlayerView: UIViewControllerRepresentable {
|
||||
var item: BaseItemDto
|
||||
|
||||
func makeUIViewController(context: Context) -> some UIViewController {
|
||||
|
||||
let storyboard = UIStoryboard(name: "VideoPlayer", bundle: nil)
|
||||
let viewController = storyboard.instantiateViewController(withIdentifier: "VideoPlayer") as! VideoPlayerViewController
|
||||
viewController.manifest = item
|
||||
|
||||
return viewController
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
|
||||
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
//
|
||||
// VideoPlayerView.swift
|
||||
// JellyfinVideoPlayerDev
|
||||
//
|
||||
// Created by Ethan Pippin on 11/12/21.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
struct NativePlayerView: UIViewControllerRepresentable {
|
||||
|
||||
let viewModel: VideoPlayerViewModel
|
||||
|
||||
typealias UIViewControllerType = NativePlayerViewController
|
||||
|
||||
func makeUIViewController(context: Context) -> NativePlayerViewController {
|
||||
|
||||
return NativePlayerViewController(viewModel: viewModel)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: NativePlayerViewController, context: Context) {
|
||||
|
||||
}
|
||||
}
|
|
@ -1,772 +0,0 @@
|
|||
//
|
||||
/*
|
||||
* SwiftFin is subject to the terms of the Mozilla Public
|
||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||
*/
|
||||
|
||||
import TVUIKit
|
||||
import TVVLCKit
|
||||
import MediaPlayer
|
||||
import JellyfinAPI
|
||||
import Combine
|
||||
import Defaults
|
||||
|
||||
protocol VideoPlayerSettingsDelegate: AnyObject {
|
||||
func selectNew(audioTrack id: Int32)
|
||||
func selectNew(subtitleTrack id: Int32)
|
||||
}
|
||||
|
||||
class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate, VLCMediaPlayerDelegate, VLCMediaDelegate, UIGestureRecognizerDelegate {
|
||||
|
||||
@IBOutlet weak var videoContentView: UIView!
|
||||
@IBOutlet weak var controlsView: UIView!
|
||||
|
||||
@IBOutlet weak var activityIndicator: UIActivityIndicatorView!
|
||||
|
||||
@IBOutlet weak var transportBarView: UIView!
|
||||
@IBOutlet weak var scrubberView: UIView!
|
||||
@IBOutlet weak var scrubLabel: UILabel!
|
||||
@IBOutlet weak var gradientView: UIView!
|
||||
|
||||
@IBOutlet weak var currentTimeLabel: UILabel!
|
||||
@IBOutlet weak var remainingTimeLabel: UILabel!
|
||||
|
||||
@IBOutlet weak var infoPanelContainerView: UIView!
|
||||
|
||||
var infoTabBarViewController: InfoTabBarViewController?
|
||||
var focusedOnTabBar: Bool = false
|
||||
var showingInfoPanel: Bool = false
|
||||
|
||||
var mediaPlayer = VLCMediaPlayer()
|
||||
|
||||
var lastProgressReportTime: Double = 0
|
||||
var lastTime: Float = 0.0
|
||||
var startTime: Int = 0
|
||||
|
||||
var selectedAudioTrack: Int32 = -1
|
||||
var selectedCaptionTrack: Int32 = -1
|
||||
|
||||
var subtitleTrackArray: [Subtitle] = []
|
||||
var audioTrackArray: [AudioTrack] = []
|
||||
|
||||
var playing: Bool = false
|
||||
var seeking: Bool = false
|
||||
var showingControls: Bool = false
|
||||
var loading: Bool = true
|
||||
|
||||
var initialSeekPos: CGFloat = 0
|
||||
var videoPos: Double = 0
|
||||
var videoDuration: Double = 0
|
||||
var controlsAppearTime: Double = 0
|
||||
|
||||
var manifest: BaseItemDto = BaseItemDto()
|
||||
var playbackItem = PlaybackItem()
|
||||
var playSessionId: String = ""
|
||||
|
||||
var cancellables = Set<AnyCancellable>()
|
||||
|
||||
override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) {
|
||||
|
||||
super.didUpdateFocus(in: context, with: coordinator)
|
||||
|
||||
// Check if focused on the tab bar, allows for swipe up to dismiss the info panel
|
||||
if let nextFocused = context.nextFocusedView,
|
||||
nextFocused.description.contains("UITabBarButton") {
|
||||
// Set value after half a second so info panel is not dismissed instantly when swiping up from content
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
self.focusedOnTabBar = true
|
||||
}
|
||||
} else {
|
||||
focusedOnTabBar = false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
activityIndicator.isHidden = false
|
||||
activityIndicator.startAnimating()
|
||||
|
||||
mediaPlayer.delegate = self
|
||||
mediaPlayer.drawable = videoContentView
|
||||
|
||||
if let runTimeTicks = manifest.runTimeTicks {
|
||||
videoDuration = Double(runTimeTicks / 10_000_000)
|
||||
}
|
||||
|
||||
// Black gradient behind transport bar
|
||||
let gradientLayer: CAGradientLayer = CAGradientLayer()
|
||||
gradientLayer.frame.size = self.gradientView.frame.size
|
||||
gradientLayer.colors = [UIColor.black.withAlphaComponent(0.6).cgColor, UIColor.black.withAlphaComponent(0).cgColor]
|
||||
gradientLayer.startPoint = CGPoint(x: 0.0, y: 1.0)
|
||||
gradientLayer.endPoint = CGPoint(x: 0.0, y: 0.0)
|
||||
self.gradientView.layer.addSublayer(gradientLayer)
|
||||
|
||||
infoPanelContainerView.center = CGPoint(x: infoPanelContainerView.center.x, y: -infoPanelContainerView.frame.height)
|
||||
infoPanelContainerView.layer.cornerRadius = 40
|
||||
|
||||
let blurEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
|
||||
blurEffectView.frame = infoPanelContainerView.bounds
|
||||
blurEffectView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
blurEffectView.layer.cornerRadius = 40
|
||||
blurEffectView.clipsToBounds = true
|
||||
infoPanelContainerView.addSubview(blurEffectView)
|
||||
infoPanelContainerView.sendSubviewToBack(blurEffectView)
|
||||
|
||||
transportBarView.layer.cornerRadius = CGFloat(5)
|
||||
|
||||
setupGestures()
|
||||
|
||||
fetchVideo()
|
||||
|
||||
setupNowPlayingCC()
|
||||
|
||||
// Adjust subtitle size
|
||||
mediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 16)
|
||||
|
||||
}
|
||||
|
||||
func fetchVideo() {
|
||||
// Fetch max bitrate from UserDefaults depending on current connection mode
|
||||
let maxBitrate = Defaults[.inNetworkBandwidth]
|
||||
|
||||
// Build a device profile
|
||||
let builder = DeviceProfileBuilder()
|
||||
builder.setMaxBitrate(bitrate: maxBitrate)
|
||||
let profile = builder.buildProfile()
|
||||
|
||||
let currentUser = SessionManager.main.currentLogin.user
|
||||
|
||||
let playbackInfo = PlaybackInfoDto(userId: currentUser.id, maxStreamingBitrate: Int(maxBitrate), startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, deviceProfile: profile, autoOpenLiveStream: true)
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async { [self] in
|
||||
MediaInfoAPI.getPostedPlaybackInfo(itemId: manifest.id!, userId: currentUser.id, maxStreamingBitrate: Int(maxBitrate), startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, autoOpenLiveStream: true, playbackInfoDto: playbackInfo)
|
||||
.sink(receiveCompletion: { result in
|
||||
print(result)
|
||||
}, receiveValue: { [self] response in
|
||||
|
||||
videoContentView.setNeedsLayout()
|
||||
videoContentView.setNeedsDisplay()
|
||||
|
||||
playSessionId = response.playSessionId ?? ""
|
||||
|
||||
guard let mediaSource = response.mediaSources?.first.self else {
|
||||
return
|
||||
}
|
||||
|
||||
let item = PlaybackItem()
|
||||
let streamURL: URL
|
||||
|
||||
// Item is being transcoded by request of server
|
||||
if let transcodiungUrl = mediaSource.transcodingUrl {
|
||||
item.videoType = .transcode
|
||||
streamURL = URL(string: "\(SessionManager.main.currentLogin.server.currentURI)\(transcodiungUrl)")!
|
||||
}
|
||||
// Item will be directly played by the client
|
||||
else {
|
||||
item.videoType = .directPlay
|
||||
// streamURL = URL(string: "\(SessionManager.main.currentLogin.server.currentURI)/Videos/\(manifest.id!)/stream?Static=true&mediaSourceId=\(manifest.id!)&deviceId=\(SessionManager.current.deviceID)&api_key=\(SessionManager.current.accessToken)&Tag=\(mediaSource.eTag!)")!
|
||||
streamURL = URL(string: "\(SessionManager.main.currentLogin.server.currentURI)/Videos/\(manifest.id!)/stream?Static=true&mediaSourceId=\(manifest.id!)&Tag=\(mediaSource.eTag ?? "")")!
|
||||
}
|
||||
|
||||
item.videoUrl = streamURL
|
||||
|
||||
let disableSubtitleTrack = Subtitle(name: "None", id: -1, url: nil, delivery: .embed, codec: "", languageCode: "")
|
||||
subtitleTrackArray.append(disableSubtitleTrack)
|
||||
|
||||
// Loop through media streams and add to array
|
||||
for stream in mediaSource.mediaStreams! {
|
||||
|
||||
if stream.type == .subtitle {
|
||||
var deliveryUrl: URL?
|
||||
|
||||
if stream.deliveryMethod == .external {
|
||||
deliveryUrl = URL(string: "\(SessionManager.main.currentLogin.server.currentURI)\(stream.deliveryUrl!)")!
|
||||
}
|
||||
|
||||
let subtitle = Subtitle(name: stream.displayTitle ?? "Unknown", id: Int32(stream.index!), url: deliveryUrl, delivery: stream.deliveryMethod!, codec: stream.codec ?? "webvtt", languageCode: stream.language ?? "")
|
||||
|
||||
if stream.isDefault == true {
|
||||
selectedCaptionTrack = Int32(stream.index!)
|
||||
}
|
||||
|
||||
if subtitle.delivery != .encode {
|
||||
subtitleTrackArray.append(subtitle)
|
||||
}
|
||||
}
|
||||
|
||||
if stream.type == .audio {
|
||||
let track = AudioTrack(name: stream.displayTitle!, languageCode: stream.language ?? "", id: Int32(stream.index!))
|
||||
|
||||
if stream.isDefault! == true {
|
||||
selectedAudioTrack = Int32(stream.index!)
|
||||
}
|
||||
|
||||
audioTrackArray.append(track)
|
||||
}
|
||||
}
|
||||
|
||||
// If no default audio tracks select the first one
|
||||
if selectedAudioTrack == -1 && !audioTrackArray.isEmpty {
|
||||
selectedAudioTrack = audioTrackArray.first!.id
|
||||
}
|
||||
|
||||
self.sendPlayReport()
|
||||
playbackItem = item
|
||||
|
||||
mediaPlayer.media = VLCMedia(url: playbackItem.videoUrl)
|
||||
mediaPlayer.media.delegate = self
|
||||
mediaPlayer.play()
|
||||
|
||||
// 1 second = 10,000,000 ticks
|
||||
|
||||
if let rawStartTicks = manifest.userData?.playbackPositionTicks {
|
||||
mediaPlayer.jumpForward(Int32(rawStartTicks / 10_000_000))
|
||||
}
|
||||
|
||||
subtitleTrackArray.forEach { sub in
|
||||
if sub.id != -1 && sub.delivery == .external && sub.codec != "subrip" {
|
||||
mediaPlayer.addPlaybackSlave(sub.url!, type: .subtitle, enforce: false)
|
||||
}
|
||||
}
|
||||
|
||||
playing = true
|
||||
setupInfoPanel()
|
||||
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func setupNowPlayingCC() {
|
||||
let commandCenter = MPRemoteCommandCenter.shared()
|
||||
commandCenter.playCommand.isEnabled = true
|
||||
commandCenter.pauseCommand.isEnabled = true
|
||||
|
||||
commandCenter.skipBackwardCommand.isEnabled = true
|
||||
commandCenter.skipBackwardCommand.preferredIntervals = [15]
|
||||
|
||||
commandCenter.skipForwardCommand.isEnabled = true
|
||||
commandCenter.skipForwardCommand.preferredIntervals = [30]
|
||||
|
||||
commandCenter.changePlaybackPositionCommand.isEnabled = true
|
||||
commandCenter.enableLanguageOptionCommand.isEnabled = true
|
||||
|
||||
// Add handler for Pause Command
|
||||
commandCenter.pauseCommand.addTarget { _ in
|
||||
self.pause()
|
||||
self.showingControls = true
|
||||
self.controlsView.isHidden = false
|
||||
self.controlsAppearTime = CACurrentMediaTime()
|
||||
return .success
|
||||
}
|
||||
|
||||
// Add handler for Play command
|
||||
commandCenter.playCommand.addTarget { _ in
|
||||
self.play()
|
||||
self.showingControls = false
|
||||
self.controlsView.isHidden = true
|
||||
return .success
|
||||
}
|
||||
|
||||
// Add handler for FF command
|
||||
commandCenter.skipForwardCommand.addTarget { _ in
|
||||
self.mediaPlayer.jumpForward(30)
|
||||
self.sendProgressReport(eventName: "timeupdate")
|
||||
return .success
|
||||
}
|
||||
|
||||
// Add handler for RW command
|
||||
commandCenter.skipBackwardCommand.addTarget { _ in
|
||||
self.mediaPlayer.jumpBackward(15)
|
||||
self.sendProgressReport(eventName: "timeupdate")
|
||||
return .success
|
||||
}
|
||||
|
||||
// Scrubber
|
||||
commandCenter.changePlaybackPositionCommand.addTarget { [weak self](remoteEvent) -> MPRemoteCommandHandlerStatus in
|
||||
guard let self = self else {return .commandFailed}
|
||||
|
||||
if let event = remoteEvent as? MPChangePlaybackPositionCommandEvent {
|
||||
let targetSeconds = event.positionTime
|
||||
let videoPosition = Double(self.mediaPlayer.time.intValue / 1000)
|
||||
let offset = targetSeconds - videoPosition
|
||||
|
||||
if offset > 0 {
|
||||
self.mediaPlayer.jumpForward(Int32(offset))
|
||||
} else {
|
||||
self.mediaPlayer.jumpBackward(Int32(abs(offset)))
|
||||
}
|
||||
self.sendProgressReport(eventName: "unpause")
|
||||
|
||||
return .success
|
||||
} else {
|
||||
return .commandFailed
|
||||
}
|
||||
}
|
||||
|
||||
var runTicks = 0
|
||||
var playbackTicks = 0
|
||||
|
||||
if let ticks = manifest.runTimeTicks {
|
||||
runTicks = Int(ticks / 10_000_000)
|
||||
}
|
||||
|
||||
if let ticks = manifest.userData?.playbackPositionTicks {
|
||||
playbackTicks = Int(ticks / 10_000_000)
|
||||
}
|
||||
|
||||
var nowPlayingInfo = [String: Any]()
|
||||
|
||||
nowPlayingInfo[MPMediaItemPropertyTitle] = manifest.name ?? "Jellyfin Video"
|
||||
if manifest.type == "Episode" {
|
||||
nowPlayingInfo[MPMediaItemPropertyArtist] = "\(manifest.seriesName ?? manifest.name ?? "") • \(manifest.getEpisodeLocator())"
|
||||
}
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 0.0
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = AVMediaType.video
|
||||
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = runTicks
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = playbackTicks
|
||||
|
||||
if let imageData = NSData(contentsOf: manifest.getPrimaryImage(maxWidth: 500)) {
|
||||
if let artworkImage = UIImage(data: imageData as Data) {
|
||||
let artwork = MPMediaItemArtwork.init(boundsSize: artworkImage.size, requestHandler: { (_) -> UIImage in
|
||||
return artworkImage
|
||||
})
|
||||
nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork
|
||||
}
|
||||
}
|
||||
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
||||
|
||||
UIApplication.shared.beginReceivingRemoteControlEvents()
|
||||
}
|
||||
|
||||
func updateNowPlayingCenter(time: Double?, playing: Bool?) {
|
||||
|
||||
var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [String: Any]()
|
||||
|
||||
if let playing = playing {
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = playing ? 1.0 : 0.0
|
||||
}
|
||||
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = mediaPlayer.time.intValue / 1000
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
||||
}
|
||||
|
||||
// Grabs a reference to the info panel view controller
|
||||
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
|
||||
if segue.identifier == "infoView" {
|
||||
infoTabBarViewController = segue.destination as? InfoTabBarViewController
|
||||
infoTabBarViewController?.videoPlayer = self
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Player functions
|
||||
// Animate the scrubber when playing state changes
|
||||
func animateScrubber() {
|
||||
let y: CGFloat = playing ? 0 : -20
|
||||
let height: CGFloat = playing ? 10 : 30
|
||||
|
||||
UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseIn, animations: {
|
||||
self.scrubberView.frame = CGRect(x: self.scrubberView.frame.minX, y: y, width: 2, height: height)
|
||||
})
|
||||
}
|
||||
|
||||
func pause() {
|
||||
playing = false
|
||||
mediaPlayer.pause()
|
||||
|
||||
self.sendProgressReport(eventName: "pause")
|
||||
|
||||
self.updateNowPlayingCenter(time: nil, playing: false)
|
||||
|
||||
animateScrubber()
|
||||
|
||||
self.scrubLabel.frame = CGRect(x: self.scrubberView.frame.minX - self.scrubLabel.frame.width/2, y: self.scrubLabel.frame.minY, width: self.scrubLabel.frame.width, height: self.scrubLabel.frame.height)
|
||||
}
|
||||
|
||||
func play () {
|
||||
playing = true
|
||||
mediaPlayer.play()
|
||||
|
||||
self.updateNowPlayingCenter(time: nil, playing: true)
|
||||
|
||||
self.sendProgressReport(eventName: "unpause")
|
||||
|
||||
animateScrubber()
|
||||
}
|
||||
|
||||
func toggleInfoContainer() {
|
||||
showingInfoPanel.toggle()
|
||||
|
||||
infoTabBarViewController?.view.isUserInteractionEnabled = showingInfoPanel
|
||||
|
||||
if showingInfoPanel && seeking {
|
||||
scrubLabel.isHidden = true
|
||||
UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut, animations: {
|
||||
self.scrubberView.frame = CGRect(x: self.initialSeekPos, y: self.scrubberView.frame.minY, width: 2, height: self.scrubberView.frame.height)
|
||||
}) { _ in
|
||||
self.scrubLabel.frame = CGRect(x: (self.initialSeekPos - self.scrubLabel.frame.width/2), y: self.scrubLabel.frame.minY, width: self.scrubLabel.frame.width, height: self.scrubLabel.frame.height)
|
||||
self.scrubLabel.text = self.currentTimeLabel.text
|
||||
}
|
||||
seeking = false
|
||||
|
||||
}
|
||||
|
||||
UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut) { [self] in
|
||||
let size = infoPanelContainerView.frame.size
|
||||
let y: CGFloat = showingInfoPanel ? 87 : -size.height
|
||||
|
||||
infoPanelContainerView.frame = CGRect(x: 88, y: y, width: size.width, height: size.height)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: Gestures
|
||||
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
|
||||
for item in presses {
|
||||
if item.type == .select {
|
||||
selectButtonTapped()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setupGestures() {
|
||||
self.becomeFirstResponder()
|
||||
|
||||
// vlc crap
|
||||
videoContentView.gestureRecognizers?.forEach { gr in
|
||||
videoContentView.removeGestureRecognizer(gr)
|
||||
}
|
||||
videoContentView.subviews.forEach { sv in
|
||||
sv.gestureRecognizers?.forEach { gr in
|
||||
sv.removeGestureRecognizer(gr)
|
||||
}
|
||||
}
|
||||
|
||||
let playPauseGesture = UITapGestureRecognizer(target: self, action: #selector(self.selectButtonTapped))
|
||||
let playPauseType = UIPress.PressType.playPause
|
||||
playPauseGesture.allowedPressTypes = [NSNumber(value: playPauseType.rawValue)]
|
||||
view.addGestureRecognizer(playPauseGesture)
|
||||
|
||||
let backTapGesture = UITapGestureRecognizer(target: self, action: #selector(self.backButtonPressed(tap:)))
|
||||
let backPress = UIPress.PressType.menu
|
||||
backTapGesture.allowedPressTypes = [NSNumber(value: backPress.rawValue)]
|
||||
view.addGestureRecognizer(backTapGesture)
|
||||
|
||||
let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.userPanned(panGestureRecognizer:)))
|
||||
view.addGestureRecognizer(panGestureRecognizer)
|
||||
}
|
||||
|
||||
@objc func backButtonPressed(tap: UITapGestureRecognizer) {
|
||||
// Dismiss info panel
|
||||
if showingInfoPanel {
|
||||
if focusedOnTabBar {
|
||||
toggleInfoContainer()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Cancel seek and move back to initial position
|
||||
if seeking {
|
||||
scrubLabel.isHidden = true
|
||||
UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut, animations: {
|
||||
self.scrubberView.frame = CGRect(x: self.initialSeekPos, y: 0, width: 2, height: 10)
|
||||
})
|
||||
play()
|
||||
seeking = false
|
||||
} else {
|
||||
// Dismiss view
|
||||
self.resignFirstResponder()
|
||||
mediaPlayer.stop()
|
||||
sendStopReport()
|
||||
self.navigationController?.popViewController(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func userPanned(panGestureRecognizer: UIPanGestureRecognizer) {
|
||||
if loading {
|
||||
return
|
||||
}
|
||||
|
||||
let translation = panGestureRecognizer.translation(in: view)
|
||||
let velocity = panGestureRecognizer.velocity(in: view)
|
||||
|
||||
// Swiped up - Handle dismissing info panel
|
||||
if translation.y < -200 && (focusedOnTabBar && showingInfoPanel) {
|
||||
toggleInfoContainer()
|
||||
return
|
||||
}
|
||||
|
||||
if showingInfoPanel {
|
||||
return
|
||||
}
|
||||
|
||||
// Swiped down - Show the info panel
|
||||
if translation.y > 200 {
|
||||
toggleInfoContainer()
|
||||
return
|
||||
}
|
||||
|
||||
// Ignore seek if video is playing
|
||||
if playing {
|
||||
return
|
||||
}
|
||||
|
||||
// Save current position if seek is cancelled and show the scrubLabel
|
||||
if !seeking {
|
||||
initialSeekPos = self.scrubberView.frame.minX
|
||||
seeking = true
|
||||
self.scrubLabel.isHidden = false
|
||||
}
|
||||
|
||||
let newPos = (self.scrubberView.frame.minX + velocity.x/100).clamped(to: 0...transportBarView.frame.width)
|
||||
|
||||
UIView.animate(withDuration: 0.8, delay: 0, options: .curveEaseOut, animations: {
|
||||
let time = (Double(self.scrubberView.frame.minX) * self.videoDuration) / Double(self.transportBarView.frame.width)
|
||||
|
||||
self.scrubberView.frame = CGRect(x: newPos, y: self.scrubberView.frame.minY, width: 2, height: 30)
|
||||
self.scrubLabel.frame = CGRect(x: (newPos - self.scrubLabel.frame.width/2), y: self.scrubLabel.frame.minY, width: self.scrubLabel.frame.width, height: self.scrubLabel.frame.height)
|
||||
self.scrubLabel.text = (self.formatSecondsToHMS(time))
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
/// Play/Pause or Select is pressed on the AppleTV remote
|
||||
@objc func selectButtonTapped() {
|
||||
print("select")
|
||||
if loading {
|
||||
return
|
||||
}
|
||||
|
||||
showingControls = true
|
||||
controlsView.isHidden = false
|
||||
controlsAppearTime = CACurrentMediaTime()
|
||||
|
||||
// Move to seeked position
|
||||
if seeking {
|
||||
scrubLabel.isHidden = true
|
||||
|
||||
// Move current time to the scrubbed position
|
||||
UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut, animations: { [self] in
|
||||
|
||||
self.currentTimeLabel.frame = CGRect(x: CGFloat(scrubLabel.frame.minX + transportBarView.frame.minX), y: currentTimeLabel.frame.minY, width: currentTimeLabel.frame.width, height: currentTimeLabel.frame.height)
|
||||
|
||||
})
|
||||
|
||||
let time = (Double(self.scrubberView.frame.minX) * self.videoDuration) / Double(self.transportBarView.frame.width)
|
||||
|
||||
self.currentTimeLabel.text = self.scrubLabel.text
|
||||
self.remainingTimeLabel.text = "-" + formatSecondsToHMS(videoDuration - time)
|
||||
|
||||
mediaPlayer.position = Float(self.scrubberView.frame.minX) / Float(self.transportBarView.frame.width)
|
||||
|
||||
play()
|
||||
|
||||
seeking = false
|
||||
return
|
||||
}
|
||||
|
||||
playing ? pause() : play()
|
||||
}
|
||||
|
||||
// MARK: Jellyfin Playstate updates
|
||||
func sendProgressReport(eventName: String) {
|
||||
updateNowPlayingCenter(time: nil, playing: mediaPlayer.state == .playing)
|
||||
|
||||
if (eventName == "timeupdate" && mediaPlayer.state == .playing) || eventName != "timeupdate" {
|
||||
var ticks: Int64 = Int64(mediaPlayer.position * Float(manifest.runTimeTicks!))
|
||||
if ticks == 0 {
|
||||
ticks = manifest.userData?.playbackPositionTicks ?? 0
|
||||
}
|
||||
|
||||
let progressInfo = PlaybackProgressInfo(canSeek: true, item: manifest, itemId: manifest.id, sessionId: playSessionId, mediaSourceId: manifest.id, audioStreamIndex: Int(selectedAudioTrack), subtitleStreamIndex: Int(selectedCaptionTrack), isPaused: (!playing), isMuted: false, positionTicks: ticks, playbackStartTimeTicks: Int64(startTime), volumeLevel: 100, brightness: 100, aspectRatio: nil, playMethod: playbackItem.videoType, liveStreamId: nil, playSessionId: playSessionId, repeatMode: .repeatNone, nowPlayingQueue: [], playlistItemId: "playlistItem0")
|
||||
|
||||
PlaystateAPI.reportPlaybackProgress(playbackProgressInfo: progressInfo)
|
||||
.sink(receiveCompletion: { result in
|
||||
print(result)
|
||||
}, receiveValue: { _ in
|
||||
print("Playback progress report sent!")
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
|
||||
func sendStopReport() {
|
||||
let stopInfo = PlaybackStopInfo(item: manifest, itemId: manifest.id, sessionId: playSessionId, mediaSourceId: manifest.id, positionTicks: Int64(mediaPlayer.position * Float(manifest.runTimeTicks!)), liveStreamId: nil, playSessionId: playSessionId, failed: nil, nextMediaType: nil, playlistItemId: "playlistItem0", nowPlayingQueue: [])
|
||||
|
||||
PlaystateAPI.reportPlaybackStopped(playbackStopInfo: stopInfo)
|
||||
.sink(receiveCompletion: { result in
|
||||
print(result)
|
||||
}, receiveValue: { _ in
|
||||
print("Playback stop report sent!")
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func sendPlayReport() {
|
||||
startTime = Int(Date().timeIntervalSince1970) * 10000000
|
||||
|
||||
print("sending play report!")
|
||||
|
||||
let startInfo = PlaybackStartInfo(canSeek: true, item: manifest, itemId: manifest.id, sessionId: playSessionId, mediaSourceId: manifest.id, audioStreamIndex: Int(selectedAudioTrack), subtitleStreamIndex: Int(selectedCaptionTrack), isPaused: false, isMuted: false, positionTicks: manifest.userData?.playbackPositionTicks, playbackStartTimeTicks: Int64(startTime), volumeLevel: 100, brightness: 100, aspectRatio: nil, playMethod: playbackItem.videoType, liveStreamId: nil, playSessionId: playSessionId, repeatMode: .repeatNone, nowPlayingQueue: [], playlistItemId: "playlistItem0")
|
||||
|
||||
PlaystateAPI.reportPlaybackStart(playbackStartInfo: startInfo)
|
||||
.sink(receiveCompletion: { result in
|
||||
print(result)
|
||||
}, receiveValue: { _ in
|
||||
print("Playback start report sent!")
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// MARK: VLC Delegate
|
||||
|
||||
func mediaPlayerStateChanged(_ aNotification: Notification!) {
|
||||
let currentState: VLCMediaPlayerState = mediaPlayer.state
|
||||
switch currentState {
|
||||
case .buffering:
|
||||
print("Video is buffering")
|
||||
loading = true
|
||||
activityIndicator.isHidden = false
|
||||
activityIndicator.startAnimating()
|
||||
mediaPlayer.pause()
|
||||
usleep(10000)
|
||||
mediaPlayer.play()
|
||||
break
|
||||
case .stopped:
|
||||
print("stopped")
|
||||
|
||||
break
|
||||
case .ended:
|
||||
print("ended")
|
||||
|
||||
break
|
||||
case .opening:
|
||||
print("opening")
|
||||
|
||||
break
|
||||
case .paused:
|
||||
print("paused")
|
||||
|
||||
break
|
||||
case .playing:
|
||||
print("Video is playing")
|
||||
loading = false
|
||||
sendProgressReport(eventName: "unpause")
|
||||
DispatchQueue.main.async { [self] in
|
||||
activityIndicator.isHidden = true
|
||||
activityIndicator.stopAnimating()
|
||||
}
|
||||
playing = true
|
||||
break
|
||||
case .error:
|
||||
print("error")
|
||||
break
|
||||
case .esAdded:
|
||||
print("esAdded")
|
||||
break
|
||||
default:
|
||||
print("default")
|
||||
break
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Move time along transport bar
|
||||
func mediaPlayerTimeChanged(_ aNotification: Notification!) {
|
||||
|
||||
if loading {
|
||||
loading = false
|
||||
DispatchQueue.main.async { [self] in
|
||||
activityIndicator.isHidden = true
|
||||
activityIndicator.stopAnimating()
|
||||
}
|
||||
updateNowPlayingCenter(time: nil, playing: true)
|
||||
}
|
||||
|
||||
let time = mediaPlayer.position
|
||||
if time != lastTime {
|
||||
self.currentTimeLabel.text = formatSecondsToHMS(Double(mediaPlayer.time.intValue/1000))
|
||||
self.remainingTimeLabel.text = "-" + formatSecondsToHMS(Double(abs(mediaPlayer.remainingTime.intValue/1000)))
|
||||
|
||||
self.videoPos = Double(mediaPlayer.position)
|
||||
|
||||
let newPos = videoPos * Double(self.transportBarView.frame.width)
|
||||
if !newPos.isNaN && self.playing {
|
||||
self.scrubberView.frame = CGRect(x: newPos, y: 0, width: 2, height: 10)
|
||||
self.currentTimeLabel.frame = CGRect(x: CGFloat(newPos) + transportBarView.frame.minX - currentTimeLabel.frame.width/2, y: currentTimeLabel.frame.minY, width: currentTimeLabel.frame.width, height: currentTimeLabel.frame.height)
|
||||
}
|
||||
|
||||
if showingControls {
|
||||
if CACurrentMediaTime() - controlsAppearTime > 5 {
|
||||
showingControls = false
|
||||
UIView.animate(withDuration: 0.5, delay: 0, options: .curveEaseOut, animations: {
|
||||
self.controlsView.alpha = 0.0
|
||||
}, completion: { (_: Bool) in
|
||||
self.controlsView.isHidden = true
|
||||
self.controlsView.alpha = 1
|
||||
})
|
||||
controlsAppearTime = 999_999_999_999_999
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
lastTime = time
|
||||
|
||||
if CACurrentMediaTime() - lastProgressReportTime > 5 {
|
||||
sendProgressReport(eventName: "timeupdate")
|
||||
lastProgressReportTime = CACurrentMediaTime()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Settings Delegate
|
||||
func selectNew(audioTrack id: Int32) {
|
||||
selectedAudioTrack = id
|
||||
mediaPlayer.currentAudioTrackIndex = id
|
||||
}
|
||||
|
||||
func selectNew(subtitleTrack id: Int32) {
|
||||
selectedCaptionTrack = id
|
||||
mediaPlayer.currentVideoSubTitleIndex = id
|
||||
}
|
||||
|
||||
func setupInfoPanel() {
|
||||
infoTabBarViewController?.setupInfoViews(mediaItem: manifest, subtitleTracks: subtitleTrackArray, selectedSubtitleTrack: selectedCaptionTrack, audioTracks: audioTrackArray, selectedAudioTrack: selectedAudioTrack, delegate: self)
|
||||
}
|
||||
|
||||
func formatSecondsToHMS(_ seconds: Double) -> String {
|
||||
let timeHMSFormatter: DateComponentsFormatter = {
|
||||
let formatter = DateComponentsFormatter()
|
||||
formatter.unitsStyle = .positional
|
||||
formatter.allowedUnits = seconds >= 3600 ?
|
||||
[.hour, .minute, .second] :
|
||||
[.minute, .second]
|
||||
formatter.zeroFormattingBehavior = .pad
|
||||
return formatter
|
||||
}()
|
||||
|
||||
guard !seconds.isNaN,
|
||||
let text = timeHMSFormatter.string(from: seconds) else {
|
||||
return "00:00"
|
||||
}
|
||||
|
||||
return text.hasPrefix("0") && text.count > 4 ?
|
||||
.init(text.dropFirst()) : text
|
||||
}
|
||||
}
|
||||
|
||||
extension Comparable {
|
||||
func clamped(to limits: ClosedRange<Self>) -> Self {
|
||||
return min(max(self, limits.lowerBound), limits.upperBound)
|
||||
}
|
||||
}
|
|
@ -14,15 +14,7 @@
|
|||
09389CC526814E4500AE350E /* DeviceProfileBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */; };
|
||||
09389CC726819B4600AE350E /* VideoPlayerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09389CC626819B4500AE350E /* VideoPlayerModel.swift */; };
|
||||
09389CC826819B4600AE350E /* VideoPlayerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09389CC626819B4500AE350E /* VideoPlayerModel.swift */; };
|
||||
0959A5FD2686D29800C7C9A9 /* VideoUpNextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0959A5FC2686D29800C7C9A9 /* VideoUpNextView.swift */; };
|
||||
363CADF08820D3B2055CF1D8 /* Pods_JellyfinPlayer_tvOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4BE2D324B040DCA2629C110D /* Pods_JellyfinPlayer_tvOS.framework */; };
|
||||
531069572684E7EE00CFFDBA /* InfoTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531069502684E7EE00CFFDBA /* InfoTabBarViewController.swift */; };
|
||||
531069582684E7EE00CFFDBA /* MediaInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531069512684E7EE00CFFDBA /* MediaInfoView.swift */; };
|
||||
531069592684E7EE00CFFDBA /* SubtitlesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531069522684E7EE00CFFDBA /* SubtitlesView.swift */; };
|
||||
5310695A2684E7EE00CFFDBA /* VideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531069532684E7EE00CFFDBA /* VideoPlayer.swift */; };
|
||||
5310695B2684E7EE00CFFDBA /* AudioView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531069542684E7EE00CFFDBA /* AudioView.swift */; };
|
||||
5310695C2684E7EE00CFFDBA /* VideoPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531069552684E7EE00CFFDBA /* VideoPlayerViewController.swift */; };
|
||||
5310695D2684E7EE00CFFDBA /* VideoPlayer.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 531069562684E7EE00CFFDBA /* VideoPlayer.storyboard */; };
|
||||
53116A17268B919A003024C9 /* SeriesItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53116A16268B919A003024C9 /* SeriesItemView.swift */; };
|
||||
53116A19268B947A003024C9 /* PlainLinkButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53116A18268B947A003024C9 /* PlainLinkButton.swift */; };
|
||||
531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690E6267ABD79005D8AB9 /* HomeView.swift */; };
|
||||
|
@ -39,8 +31,6 @@
|
|||
53272532268BF09D0035FBF1 /* MediaViewActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53272531268BF09D0035FBF1 /* MediaViewActionButton.swift */; };
|
||||
53272537268C1DBB0035FBF1 /* SeasonItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53272536268C1DBB0035FBF1 /* SeasonItemView.swift */; };
|
||||
53272539268C20100035FBF1 /* EpisodeItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53272538268C20100035FBF1 /* EpisodeItemView.swift */; };
|
||||
532E68CF267D9F6B007B9F13 /* VideoPlayerCastDeviceSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 532E68CE267D9F6B007B9F13 /* VideoPlayerCastDeviceSelector.swift */; };
|
||||
53313B90265EEA6D00947AA3 /* VideoPlayer.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 53313B8F265EEA6D00947AA3 /* VideoPlayer.storyboard */; };
|
||||
53352571265EA0A0006CCA86 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 53352570265EA0A0006CCA86 /* Introspect */; };
|
||||
5338F74E263B61370014BF09 /* ConnectToServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5338F74D263B61370014BF09 /* ConnectToServerView.swift */; };
|
||||
534D4FF026A7D7CC000A7A48 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 534D4FEE26A7D7CC000A7A48 /* Localizable.strings */; };
|
||||
|
@ -64,7 +54,6 @@
|
|||
535870AA2669D8AE00D05A09 /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5389277B263CC3DB0035E14B /* BlurHashDecode.swift */; };
|
||||
535870AD2669D8DD00D05A09 /* Typings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535870AC2669D8DD00D05A09 /* Typings.swift */; };
|
||||
535BAE9F2649E569005FA86D /* ItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535BAE9E2649E569005FA86D /* ItemView.swift */; };
|
||||
535BAEA5264A151C005FA86D /* VideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535BAEA4264A151C005FA86D /* VideoPlayer.swift */; };
|
||||
53649AAD269CFAEA00A2D8B7 /* Puppy in Frameworks */ = {isa = PBXBuildFile; productRef = 53649AAC269CFAEA00A2D8B7 /* Puppy */; };
|
||||
53649AAF269CFAF600A2D8B7 /* Puppy in Frameworks */ = {isa = PBXBuildFile; productRef = 53649AAE269CFAF600A2D8B7 /* Puppy */; };
|
||||
53649AB1269CFB1900A2D8B7 /* LogManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53649AB0269CFB1900A2D8B7 /* LogManager.swift */; };
|
||||
|
@ -150,7 +139,6 @@
|
|||
53DF641E263D9C0600A7CD1A /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53DF641D263D9C0600A7CD1A /* LibraryView.swift */; };
|
||||
53E4E649263F725B00F67C6B /* MultiSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E4E648263F725B00F67C6B /* MultiSelectorView.swift */; };
|
||||
53EE24E6265060780068F029 /* LibrarySearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53EE24E5265060780068F029 /* LibrarySearchView.swift */; };
|
||||
53F8377D265FF67C00F456B3 /* VideoPlayerSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53F8377C265FF67C00F456B3 /* VideoPlayerSettingsView.swift */; };
|
||||
53F866442687A45F00DCD1D7 /* PortraitItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53F866432687A45F00DCD1D7 /* PortraitItemView.swift */; };
|
||||
53FF7F2A263CF3F500585C35 /* LatestMediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53FF7F29263CF3F500585C35 /* LatestMediaView.swift */; };
|
||||
560CA59B3956A4CA13EDAC05 /* Pods_JellyfinPlayer_iOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 86BAC42C3764D232C8DF8F5E /* Pods_JellyfinPlayer_iOS.framework */; };
|
||||
|
@ -165,7 +153,7 @@
|
|||
6220D0B726D5EE1100B8E046 /* SearchCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0B626D5EE1100B8E046 /* SearchCoordinator.swift */; };
|
||||
6220D0BA26D6092100B8E046 /* FilterCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0B926D6092100B8E046 /* FilterCoordinator.swift */; };
|
||||
6220D0C026D61C5000B8E046 /* ItemCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0BF26D61C5000B8E046 /* ItemCoordinator.swift */; };
|
||||
6220D0C626D62D8700B8E046 /* VideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0C526D62D8700B8E046 /* VideoPlayerCoordinator.swift */; };
|
||||
6220D0C626D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0C526D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift */; };
|
||||
6220D0C926D63F3700B8E046 /* Stinsen in Frameworks */ = {isa = PBXBuildFile; productRef = 6220D0C826D63F3700B8E046 /* Stinsen */; };
|
||||
6220D0CC26D640C400B8E046 /* AppURLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0CB26D640C400B8E046 /* AppURLHandler.swift */; };
|
||||
6225FCCB2663841E00E067F6 /* ParallaxHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6225FCCA2663841E00E067F6 /* ParallaxHeader.swift */; };
|
||||
|
@ -317,7 +305,6 @@
|
|||
E193D53B27193F9200900D82 /* SettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0B026D5EC9900B8E046 /* SettingsCoordinator.swift */; };
|
||||
E193D53C27193F9500900D82 /* UserListCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD4012717EE79009D4DAF /* UserListCoordinator.swift */; };
|
||||
E193D53D27193F9700900D82 /* UserSignInCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3F127179378009D4DAF /* UserSignInCoordinator.swift */; };
|
||||
E193D53E27193F9A00900D82 /* VideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0C526D62D8700B8E046 /* VideoPlayerCoordinator.swift */; };
|
||||
E193D5432719407E00900D82 /* tvOSMainCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E193D5422719407E00900D82 /* tvOSMainCoordinator.swift */; };
|
||||
E193D547271941C500900D82 /* UserListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E193D546271941C500900D82 /* UserListView.swift */; };
|
||||
E193D549271941CC00900D82 /* UserSignInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E193D548271941CC00900D82 /* UserSignInView.swift */; };
|
||||
|
@ -340,6 +327,21 @@
|
|||
E1AD106226D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD106126D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift */; };
|
||||
E1B6DCE8271A23780015B715 /* CombineExt in Frameworks */ = {isa = PBXBuildFile; productRef = E1B6DCE7271A23780015B715 /* CombineExt */; };
|
||||
E1B6DCEA271A23880015B715 /* SwiftyJSON in Frameworks */ = {isa = PBXBuildFile; productRef = E1B6DCE9271A23880015B715 /* SwiftyJSON */; };
|
||||
E1C812BC277A8E5D00918266 /* PlaybackSpeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */; };
|
||||
E1C812BD277A8E5D00918266 /* PlayerOverlayDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B5277A8E5D00918266 /* PlayerOverlayDelegate.swift */; };
|
||||
E1C812BE277A8E5D00918266 /* VLCPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B6277A8E5D00918266 /* VLCPlayerViewController.swift */; };
|
||||
E1C812BF277A8E5D00918266 /* VLCPlayerOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B7277A8E5D00918266 /* VLCPlayerOverlayView.swift */; };
|
||||
E1C812C0277A8E5D00918266 /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B8277A8E5D00918266 /* VideoPlayerView.swift */; };
|
||||
E1C812C1277A8E5D00918266 /* NativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B9277A8E5D00918266 /* NativePlayerViewController.swift */; };
|
||||
E1C812C3277A8E5D00918266 /* VLCPlayerCompactOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812BB277A8E5D00918266 /* VLCPlayerCompactOverlayView.swift */; };
|
||||
E1C812C5277A90B200918266 /* URLComponentsExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C4277A90B200918266 /* URLComponentsExtensions.swift */; };
|
||||
E1C812CA277AE40900918266 /* PlayerOverlayDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C6277AE40900918266 /* PlayerOverlayDelegate.swift */; };
|
||||
E1C812CB277AE40900918266 /* NativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C7277AE40900918266 /* NativePlayerViewController.swift */; };
|
||||
E1C812CC277AE40A00918266 /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C8277AE40900918266 /* VideoPlayerView.swift */; };
|
||||
E1C812CD277AE40A00918266 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C9277AE40900918266 /* VideoPlayerViewModel.swift */; };
|
||||
E1C812CE277AE43100918266 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C9277AE40900918266 /* VideoPlayerViewModel.swift */; };
|
||||
E1C812D1277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812D0277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift */; };
|
||||
E1C812D2277AE50A00918266 /* URLComponentsExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C4277A90B200918266 /* URLComponentsExtensions.swift */; };
|
||||
E1D4BF7C2719D05000A11E64 /* BasicAppSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF7B2719D05000A11E64 /* BasicAppSettingsView.swift */; };
|
||||
E1D4BF7E2719D1DD00A11E64 /* BasicAppSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF7D2719D1DC00A11E64 /* BasicAppSettingsViewModel.swift */; };
|
||||
E1D4BF7F2719D1DD00A11E64 /* BasicAppSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF7D2719D1DC00A11E64 /* BasicAppSettingsViewModel.swift */; };
|
||||
|
@ -402,18 +404,10 @@
|
|||
091B5A872683142E00D78B61 /* ServerDiscovery.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServerDiscovery.swift; sourceTree = "<group>"; };
|
||||
091B5A882683142E00D78B61 /* UDPBroadCastConnection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UDPBroadCastConnection.swift; sourceTree = "<group>"; };
|
||||
09389CC626819B4500AE350E /* VideoPlayerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerModel.swift; sourceTree = "<group>"; };
|
||||
0959A5FC2686D29800C7C9A9 /* VideoUpNextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoUpNextView.swift; sourceTree = "<group>"; };
|
||||
14E199C7BBA98782CAD2F0D4 /* Pods-JellyfinPlayer iOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JellyfinPlayer iOS.release.xcconfig"; path = "Target Support Files/Pods-JellyfinPlayer iOS/Pods-JellyfinPlayer iOS.release.xcconfig"; sourceTree = "<group>"; };
|
||||
20CA36DDD247EED8D16438A5 /* Pods-JellyfinPlayer tvOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JellyfinPlayer tvOS.release.xcconfig"; path = "Target Support Files/Pods-JellyfinPlayer tvOS/Pods-JellyfinPlayer tvOS.release.xcconfig"; sourceTree = "<group>"; };
|
||||
4BDCEE3B49CF70A9E9BA3CD8 /* Pods-WidgetExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetExtension.debug.xcconfig"; path = "Target Support Files/Pods-WidgetExtension/Pods-WidgetExtension.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
4BE2D324B040DCA2629C110D /* Pods_JellyfinPlayer_tvOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_JellyfinPlayer_tvOS.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
531069502684E7EE00CFFDBA /* InfoTabBarViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InfoTabBarViewController.swift; sourceTree = "<group>"; };
|
||||
531069512684E7EE00CFFDBA /* MediaInfoView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaInfoView.swift; sourceTree = "<group>"; };
|
||||
531069522684E7EE00CFFDBA /* SubtitlesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubtitlesView.swift; sourceTree = "<group>"; };
|
||||
531069532684E7EE00CFFDBA /* VideoPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = "<group>"; };
|
||||
531069542684E7EE00CFFDBA /* AudioView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AudioView.swift; sourceTree = "<group>"; };
|
||||
531069552684E7EE00CFFDBA /* VideoPlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewController.swift; sourceTree = "<group>"; };
|
||||
531069562684E7EE00CFFDBA /* VideoPlayer.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = VideoPlayer.storyboard; sourceTree = "<group>"; };
|
||||
53116A16268B919A003024C9 /* SeriesItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeriesItemView.swift; sourceTree = "<group>"; };
|
||||
53116A18268B947A003024C9 /* PlainLinkButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlainLinkButton.swift; sourceTree = "<group>"; };
|
||||
531690E6267ABD79005D8AB9 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
|
||||
|
@ -427,8 +421,6 @@
|
|||
53272531268BF09D0035FBF1 /* MediaViewActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaViewActionButton.swift; sourceTree = "<group>"; };
|
||||
53272536268C1DBB0035FBF1 /* SeasonItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeasonItemView.swift; sourceTree = "<group>"; };
|
||||
53272538268C20100035FBF1 /* EpisodeItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeItemView.swift; sourceTree = "<group>"; };
|
||||
532E68CE267D9F6B007B9F13 /* VideoPlayerCastDeviceSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerCastDeviceSelector.swift; sourceTree = "<group>"; };
|
||||
53313B8F265EEA6D00947AA3 /* VideoPlayer.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = VideoPlayer.storyboard; sourceTree = "<group>"; };
|
||||
5338F74D263B61370014BF09 /* ConnectToServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectToServerView.swift; sourceTree = "<group>"; };
|
||||
534D4FE826A7D7CC000A7A48 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = Localizable.strings; sourceTree = "<group>"; };
|
||||
534D4FEC26A7D7CC000A7A48 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = Localizable.strings; sourceTree = "<group>"; };
|
||||
|
@ -440,7 +432,6 @@
|
|||
535870702669D21700D05A09 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
535870AC2669D8DD00D05A09 /* Typings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Typings.swift; sourceTree = "<group>"; };
|
||||
535BAE9E2649E569005FA86D /* ItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemView.swift; sourceTree = "<group>"; };
|
||||
535BAEA4264A151C005FA86D /* VideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = "<group>"; };
|
||||
5362E4A7267D4067000E2F71 /* GoogleCast.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = GoogleCast.framework; path = "../../Downloads/GoogleCastSDK-ios-4.6.0_dynamic/GoogleCast.framework"; sourceTree = "<group>"; };
|
||||
5362E4AA267D40AD000E2F71 /* AVFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFoundation.framework; path = System/Library/Frameworks/AVFoundation.framework; sourceTree = SDKROOT; };
|
||||
5362E4AC267D40B1000E2F71 /* Accelerate.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Accelerate.framework; path = System/Library/Frameworks/Accelerate.framework; sourceTree = SDKROOT; };
|
||||
|
@ -501,7 +492,6 @@
|
|||
53E4E646263F6CF100F67C6B /* LibraryFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryFilterView.swift; sourceTree = "<group>"; };
|
||||
53E4E648263F725B00F67C6B /* MultiSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiSelectorView.swift; sourceTree = "<group>"; };
|
||||
53EE24E5265060780068F029 /* LibrarySearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySearchView.swift; sourceTree = "<group>"; };
|
||||
53F8377C265FF67C00F456B3 /* VideoPlayerSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerSettingsView.swift; sourceTree = "<group>"; };
|
||||
53F866432687A45F00DCD1D7 /* PortraitItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitItemView.swift; sourceTree = "<group>"; };
|
||||
53FF7F29263CF3F500585C35 /* LatestMediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestMediaView.swift; sourceTree = "<group>"; };
|
||||
59AFF849629F3C787909A911 /* Pods_WidgetExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_WidgetExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
|
@ -514,7 +504,7 @@
|
|||
6220D0B626D5EE1100B8E046 /* SearchCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchCoordinator.swift; sourceTree = "<group>"; };
|
||||
6220D0B926D6092100B8E046 /* FilterCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterCoordinator.swift; sourceTree = "<group>"; };
|
||||
6220D0BF26D61C5000B8E046 /* ItemCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemCoordinator.swift; sourceTree = "<group>"; };
|
||||
6220D0C526D62D8700B8E046 /* VideoPlayerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerCoordinator.swift; sourceTree = "<group>"; };
|
||||
6220D0C526D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSVideoPlayerCoordinator.swift; sourceTree = "<group>"; };
|
||||
6220D0CB26D640C400B8E046 /* AppURLHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppURLHandler.swift; sourceTree = "<group>"; };
|
||||
6225FCCA2663841E00E067F6 /* ParallaxHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParallaxHeader.swift; sourceTree = "<group>"; };
|
||||
624C21742685CF60007F1390 /* SearchablePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchablePickerView.swift; sourceTree = "<group>"; };
|
||||
|
@ -610,6 +600,19 @@
|
|||
E1AD105B26D9ABDD003E4A08 /* PillHStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillHStackView.swift; sourceTree = "<group>"; };
|
||||
E1AD105E26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameGUIDPairExtensions.swift; sourceTree = "<group>"; };
|
||||
E1AD106126D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemPortraitHeaderOverlayView.swift; sourceTree = "<group>"; };
|
||||
E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlaybackSpeed.swift; sourceTree = "<group>"; };
|
||||
E1C812B5277A8E5D00918266 /* PlayerOverlayDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerOverlayDelegate.swift; sourceTree = "<group>"; };
|
||||
E1C812B6277A8E5D00918266 /* VLCPlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VLCPlayerViewController.swift; sourceTree = "<group>"; };
|
||||
E1C812B7277A8E5D00918266 /* VLCPlayerOverlayView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VLCPlayerOverlayView.swift; sourceTree = "<group>"; };
|
||||
E1C812B8277A8E5D00918266 /* VideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = "<group>"; };
|
||||
E1C812B9277A8E5D00918266 /* NativePlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NativePlayerViewController.swift; sourceTree = "<group>"; };
|
||||
E1C812BB277A8E5D00918266 /* VLCPlayerCompactOverlayView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VLCPlayerCompactOverlayView.swift; sourceTree = "<group>"; };
|
||||
E1C812C4277A90B200918266 /* URLComponentsExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLComponentsExtensions.swift; sourceTree = "<group>"; };
|
||||
E1C812C6277AE40900918266 /* PlayerOverlayDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerOverlayDelegate.swift; sourceTree = "<group>"; };
|
||||
E1C812C7277AE40900918266 /* NativePlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NativePlayerViewController.swift; sourceTree = "<group>"; };
|
||||
E1C812C8277AE40900918266 /* VideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = "<group>"; };
|
||||
E1C812C9277AE40900918266 /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = "<group>"; };
|
||||
E1C812D0277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvOSVideoPlayerCoordinator.swift; sourceTree = "<group>"; };
|
||||
E1D4BF7B2719D05000A11E64 /* BasicAppSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAppSettingsView.swift; sourceTree = "<group>"; };
|
||||
E1D4BF7D2719D1DC00A11E64 /* BasicAppSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAppSettingsViewModel.swift; sourceTree = "<group>"; };
|
||||
E1D4BF802719D22800A11E64 /* AppAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAppearance.swift; sourceTree = "<group>"; };
|
||||
|
@ -695,13 +698,9 @@
|
|||
5310694F2684E7EE00CFFDBA /* VideoPlayer */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
531069512684E7EE00CFFDBA /* MediaInfoView.swift */,
|
||||
531069522684E7EE00CFFDBA /* SubtitlesView.swift */,
|
||||
531069542684E7EE00CFFDBA /* AudioView.swift */,
|
||||
531069502684E7EE00CFFDBA /* InfoTabBarViewController.swift */,
|
||||
531069532684E7EE00CFFDBA /* VideoPlayer.swift */,
|
||||
531069552684E7EE00CFFDBA /* VideoPlayerViewController.swift */,
|
||||
531069562684E7EE00CFFDBA /* VideoPlayer.storyboard */,
|
||||
E1C812C7277AE40900918266 /* NativePlayerViewController.swift */,
|
||||
E1C812C6277AE40900918266 /* PlayerOverlayDelegate.swift */,
|
||||
E1C812C8277AE40900918266 /* VideoPlayerView.swift */,
|
||||
);
|
||||
path = VideoPlayer;
|
||||
sourceTree = "<group>";
|
||||
|
@ -720,17 +719,18 @@
|
|||
62E632DB267D2E130063E547 /* LibrarySearchViewModel.swift */,
|
||||
62E632DF267D30CA0063E547 /* LibraryViewModel.swift */,
|
||||
536D3D75267BA9BB0004248C /* MainTabViewModel.swift */,
|
||||
C40CD924271F8D1E000FB198 /* MovieLibrariesViewModel.swift */,
|
||||
C4BE0765271FC109003F4AD1 /* TVLibrariesViewModel.swift */,
|
||||
62E632E2267D3BA60063E547 /* MovieItemViewModel.swift */,
|
||||
C40CD924271F8D1E000FB198 /* MovieLibrariesViewModel.swift */,
|
||||
62E632E8267D3FF50063E547 /* SeasonItemViewModel.swift */,
|
||||
62E632EB267D410B0063E547 /* SeriesItemViewModel.swift */,
|
||||
E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */,
|
||||
E13DD3E027176BD3009D4DAF /* ServerListViewModel.swift */,
|
||||
5321753A2671BCFC005491E6 /* SettingsViewModel.swift */,
|
||||
C4BE0765271FC109003F4AD1 /* TVLibrariesViewModel.swift */,
|
||||
E13DD3F82717E961009D4DAF /* UserListViewModel.swift */,
|
||||
E13DD3EB27178A54009D4DAF /* UserSignInViewModel.swift */,
|
||||
09389CC626819B4500AE350E /* VideoPlayerModel.swift */,
|
||||
E1C812C9277AE40900918266 /* VideoPlayerViewModel.swift */,
|
||||
625CB57B2678CE1000530A6E /* ViewModel.swift */,
|
||||
);
|
||||
path = ViewModels;
|
||||
|
@ -1073,6 +1073,7 @@
|
|||
E1AD105226D96D5F003E4A08 /* JellyfinAPIExtensions */,
|
||||
621338922660107500A81A2A /* StringExtensions.swift */,
|
||||
E13DD3C727164B1E009D4DAF /* UIDeviceExtensions.swift */,
|
||||
E1C812C4277A90B200918266 /* URLComponentsExtensions.swift */,
|
||||
6220D0AC26D5EABB00B8E046 /* ViewExtensions.swift */,
|
||||
);
|
||||
path = Extensions;
|
||||
|
@ -1116,7 +1117,7 @@
|
|||
C4BE0762271FC0BB003F4AD1 /* TVLibrariesCoordinator.swift */,
|
||||
E13DD4012717EE79009D4DAF /* UserListCoordinator.swift */,
|
||||
E13DD3F127179378009D4DAF /* UserSignInCoordinator.swift */,
|
||||
6220D0C526D62D8700B8E046 /* VideoPlayerCoordinator.swift */,
|
||||
E1C812CF277AE4C700918266 /* VideoPlayerCoordinator */,
|
||||
);
|
||||
path = Coordinators;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1284,11 +1285,13 @@
|
|||
E193D5452719418B00900D82 /* VideoPlayer */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
53313B8F265EEA6D00947AA3 /* VideoPlayer.storyboard */,
|
||||
535BAEA4264A151C005FA86D /* VideoPlayer.swift */,
|
||||
532E68CE267D9F6B007B9F13 /* VideoPlayerCastDeviceSelector.swift */,
|
||||
53F8377C265FF67C00F456B3 /* VideoPlayerSettingsView.swift */,
|
||||
0959A5FC2686D29800C7C9A9 /* VideoUpNextView.swift */,
|
||||
E1C812B9277A8E5D00918266 /* NativePlayerViewController.swift */,
|
||||
E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */,
|
||||
E1C812B5277A8E5D00918266 /* PlayerOverlayDelegate.swift */,
|
||||
E1C812B8277A8E5D00918266 /* VideoPlayerView.swift */,
|
||||
E1C812BB277A8E5D00918266 /* VLCPlayerCompactOverlayView.swift */,
|
||||
E1C812B7277A8E5D00918266 /* VLCPlayerOverlayView.swift */,
|
||||
E1C812B6277A8E5D00918266 /* VLCPlayerViewController.swift */,
|
||||
);
|
||||
path = VideoPlayer;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1331,6 +1334,15 @@
|
|||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E1C812CF277AE4C700918266 /* VideoPlayerCoordinator */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
6220D0C526D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift */,
|
||||
E1C812D0277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift */,
|
||||
);
|
||||
path = VideoPlayerCoordinator;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E1DD1127271E7D15005BE12F /* Objects */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -1531,7 +1543,6 @@
|
|||
53913C0226D323FE00EB3286 /* Localizable.strings in Resources */,
|
||||
53913C1426D323FE00EB3286 /* Localizable.strings in Resources */,
|
||||
53913BF926D323FE00EB3286 /* Localizable.strings in Resources */,
|
||||
5310695D2684E7EE00CFFDBA /* VideoPlayer.storyboard in Resources */,
|
||||
534D4FF726A7D7CC000A7A48 /* Localizable.strings in Resources */,
|
||||
53913BF326D323FE00EB3286 /* Localizable.strings in Resources */,
|
||||
53913BF626D323FE00EB3286 /* Localizable.strings in Resources */,
|
||||
|
@ -1562,7 +1573,6 @@
|
|||
534D4FF626A7D7CC000A7A48 /* Localizable.strings in Resources */,
|
||||
53913BF226D323FE00EB3286 /* Localizable.strings in Resources */,
|
||||
53913BF526D323FE00EB3286 /* Localizable.strings in Resources */,
|
||||
53313B90265EEA6D00947AA3 /* VideoPlayer.storyboard in Resources */,
|
||||
53913C0426D323FE00EB3286 /* Localizable.strings in Resources */,
|
||||
53913BFE26D323FE00EB3286 /* Localizable.strings in Resources */,
|
||||
53913C0D26D323FE00EB3286 /* Localizable.strings in Resources */,
|
||||
|
@ -1762,13 +1772,11 @@
|
|||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
E193D53627193F8500900D82 /* LibraryCoordinator.swift in Sources */,
|
||||
531069572684E7EE00CFFDBA /* InfoTabBarViewController.swift in Sources */,
|
||||
E18845F626DD631E00B0C5B7 /* BaseItemDto+Stackable.swift in Sources */,
|
||||
E193D4DC27193CCA00900D82 /* PillStackable.swift in Sources */,
|
||||
E193D53327193F7D00900D82 /* FilterCoordinator.swift in Sources */,
|
||||
6267B3DC2671139500A7371D /* ImageExtensions.swift in Sources */,
|
||||
C40CD929271F8DAB000FB198 /* MovieLibrariesView.swift in Sources */,
|
||||
531069592684E7EE00CFFDBA /* SubtitlesView.swift in Sources */,
|
||||
C4E5081D2703F8370045C9AB /* LibrarySearchView.swift in Sources */,
|
||||
53ABFDE9267974EF00886593 /* HomeViewModel.swift in Sources */,
|
||||
53116A17268B919A003024C9 /* SeriesItemView.swift in Sources */,
|
||||
|
@ -1776,15 +1784,16 @@
|
|||
531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */,
|
||||
E11D224327378428003F9CB3 /* ServerDetailCoordinator.swift in Sources */,
|
||||
E1D4BF8B2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */,
|
||||
E1C812CB277AE40900918266 /* NativePlayerViewController.swift in Sources */,
|
||||
E13DD3FA2717E961009D4DAF /* UserListViewModel.swift in Sources */,
|
||||
C40CD926271F8D1E000FB198 /* MovieLibrariesViewModel.swift in Sources */,
|
||||
62E632DE267D2E170063E547 /* LatestMediaViewModel.swift in Sources */,
|
||||
E1FCD09726C47118007C8DCF /* ErrorMessage.swift in Sources */,
|
||||
E193D53527193F8100900D82 /* ItemCoordinator.swift in Sources */,
|
||||
53116A19268B947A003024C9 /* PlainLinkButton.swift in Sources */,
|
||||
E193D53E27193F9A00900D82 /* VideoPlayerCoordinator.swift in Sources */,
|
||||
536D3D88267C17350004248C /* PublicUserButton.swift in Sources */,
|
||||
62E632EA267D3FF50063E547 /* SeasonItemViewModel.swift in Sources */,
|
||||
E1C812CC277AE40A00918266 /* VideoPlayerView.swift in Sources */,
|
||||
53CD2A40268A49C2002ABD4E /* ItemView.swift in Sources */,
|
||||
53CD2A42268A4B38002ABD4E /* MovieItemView.swift in Sources */,
|
||||
536D3D7F267BDF100004248C /* LatestMediaView.swift in Sources */,
|
||||
|
@ -1804,7 +1813,6 @@
|
|||
62E632ED267D410B0063E547 /* SeriesItemViewModel.swift in Sources */,
|
||||
5398514526B64DA100101B49 /* SettingsView.swift in Sources */,
|
||||
62E632F0267D43320063E547 /* LibraryFilterViewModel.swift in Sources */,
|
||||
5310695A2684E7EE00CFFDBA /* VideoPlayer.swift in Sources */,
|
||||
E193D54B271941D300900D82 /* ServerListView.swift in Sources */,
|
||||
53ABFDE6267974EF00886593 /* SettingsViewModel.swift in Sources */,
|
||||
62E632F4267D54030063E547 /* ItemViewModel.swift in Sources */,
|
||||
|
@ -1823,13 +1831,13 @@
|
|||
E193D53427193F7F00900D82 /* HomeCoordinator.swift in Sources */,
|
||||
E193D5502719430400900D82 /* ServerDetailView.swift in Sources */,
|
||||
E11B1B6D2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */,
|
||||
E1C812D1277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift in Sources */,
|
||||
E193D53D27193F9700900D82 /* UserSignInCoordinator.swift in Sources */,
|
||||
535870A72669D8AE00D05A09 /* MultiSelectorView.swift in Sources */,
|
||||
E1AD104E26D96CE3003E4A08 /* BaseItemDtoExtensions.swift in Sources */,
|
||||
62E632DD267D2E130063E547 /* LibrarySearchViewModel.swift in Sources */,
|
||||
536D3D81267BDFC60004248C /* PortraitItemElement.swift in Sources */,
|
||||
62E1DCC4273CE19800C9AE76 /* URLExtensions.swift in Sources */,
|
||||
5310695B2684E7EE00CFFDBA /* AudioView.swift in Sources */,
|
||||
5398514726B64E4100101B49 /* SearchBarView.swift in Sources */,
|
||||
091B5A8D268315D400D78B61 /* ServerDiscovery.swift in Sources */,
|
||||
E1D4BF882719D27100A11E64 /* Bitrates.swift in Sources */,
|
||||
|
@ -1844,24 +1852,25 @@
|
|||
535870AA2669D8AE00D05A09 /* BlurHashDecode.swift in Sources */,
|
||||
53ABFDE5267974EF00886593 /* ViewModel.swift in Sources */,
|
||||
E19169CF272514760085832A /* HTTPScheme.swift in Sources */,
|
||||
E1C812CD277AE40A00918266 /* VideoPlayerViewModel.swift in Sources */,
|
||||
C45B29BB26FAC5B600CEF5E0 /* ColorExtension.swift in Sources */,
|
||||
531069582684E7EE00CFFDBA /* MediaInfoView.swift in Sources */,
|
||||
E1D4BF822719D22800A11E64 /* AppAppearance.swift in Sources */,
|
||||
C40CD923271F8CD8000FB198 /* MoviesLibrariesCoordinator.swift in Sources */,
|
||||
53272537268C1DBB0035FBF1 /* SeasonItemView.swift in Sources */,
|
||||
09389CC526814E4500AE350E /* DeviceProfileBuilder.swift in Sources */,
|
||||
E1C812D2277AE50A00918266 /* URLComponentsExtensions.swift in Sources */,
|
||||
E193D53C27193F9500900D82 /* UserListCoordinator.swift in Sources */,
|
||||
E13DD3C927164B1E009D4DAF /* UIDeviceExtensions.swift in Sources */,
|
||||
535870A62669D8AE00D05A09 /* LazyView.swift in Sources */,
|
||||
E193D53A27193F9000900D82 /* ServerListCoordinator.swift in Sources */,
|
||||
6220D0AE26D5EABB00B8E046 /* ViewExtensions.swift in Sources */,
|
||||
5321753E2671DE9C005491E6 /* Typings.swift in Sources */,
|
||||
E1C812CA277AE40900918266 /* PlayerOverlayDelegate.swift in Sources */,
|
||||
E1F0204F26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */,
|
||||
53ABFDEB2679753200886593 /* ConnectToServerView.swift in Sources */,
|
||||
6264E88D273850380081A12A /* Strings.swift in Sources */,
|
||||
536D3D76267BA9BB0004248C /* MainTabViewModel.swift in Sources */,
|
||||
E193D5512719432400900D82 /* ServerDetailViewModel.swift in Sources */,
|
||||
5310695C2684E7EE00CFFDBA /* VideoPlayerViewController.swift in Sources */,
|
||||
C4E5081B2703F82A0045C9AB /* LibraryListView.swift in Sources */,
|
||||
E193D53B27193F9200900D82 /* SettingsCoordinator.swift in Sources */,
|
||||
536D3D74267BA8170004248C /* BackgroundManager.swift in Sources */,
|
||||
|
@ -1896,6 +1905,7 @@
|
|||
E13DD3F227179378009D4DAF /* UserSignInCoordinator.swift in Sources */,
|
||||
621338932660107500A81A2A /* StringExtensions.swift in Sources */,
|
||||
53FF7F2A263CF3F500585C35 /* LatestMediaView.swift in Sources */,
|
||||
E1C812C5277A90B200918266 /* URLComponentsExtensions.swift in Sources */,
|
||||
62E632EC267D410B0063E547 /* SeriesItemViewModel.swift in Sources */,
|
||||
625CB5732678C32A00530A6E /* HomeViewModel.swift in Sources */,
|
||||
62C29EA826D103D500C1D2E7 /* LibraryListCoordinator.swift in Sources */,
|
||||
|
@ -1906,6 +1916,7 @@
|
|||
E1AD105626D981CE003E4A08 /* PortraitHStackView.swift in Sources */,
|
||||
62C29EA126D102A500C1D2E7 /* iOSMainTabCoordinator.swift in Sources */,
|
||||
C4BE076E2720FEA8003F4AD1 /* PortraitItemElement.swift in Sources */,
|
||||
E1C812C0277A8E5D00918266 /* VideoPlayerView.swift in Sources */,
|
||||
535BAE9F2649E569005FA86D /* ItemView.swift in Sources */,
|
||||
C40CD925271F8D1E000FB198 /* MovieLibrariesViewModel.swift in Sources */,
|
||||
6225FCCB2663841E00E067F6 /* ParallaxHeader.swift in Sources */,
|
||||
|
@ -1918,36 +1929,37 @@
|
|||
E18845F826DEA9C900B0C5B7 /* ItemViewBody.swift in Sources */,
|
||||
E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */,
|
||||
E188460426DEF04800B0C5B7 /* EpisodeCardVStackView.swift in Sources */,
|
||||
53F8377D265FF67C00F456B3 /* VideoPlayerSettingsView.swift in Sources */,
|
||||
E19169CE272514760085832A /* HTTPScheme.swift in Sources */,
|
||||
53192D5D265AA78A008A4215 /* DeviceProfileBuilder.swift in Sources */,
|
||||
62133890265F83A900A81A2A /* LibraryListView.swift in Sources */,
|
||||
62C29EA326D1030F00C1D2E7 /* ConnectToServerCoodinator.swift in Sources */,
|
||||
0959A5FD2686D29800C7C9A9 /* VideoUpNextView.swift in Sources */,
|
||||
62E632DA267D2BC40063E547 /* LatestMediaViewModel.swift in Sources */,
|
||||
E1AD105C26D9ABDD003E4A08 /* PillHStackView.swift in Sources */,
|
||||
625CB56F2678C23300530A6E /* HomeView.swift in Sources */,
|
||||
E1C812BC277A8E5D00918266 /* PlaybackSpeed.swift in Sources */,
|
||||
E173DA5226D04AAF00CC4EB7 /* ColorExtension.swift in Sources */,
|
||||
53892770263C25230035E14B /* NextUpView.swift in Sources */,
|
||||
E11D224227378428003F9CB3 /* ServerDetailCoordinator.swift in Sources */,
|
||||
6264E88C273850380081A12A /* Strings.swift in Sources */,
|
||||
C4BE0766271FC109003F4AD1 /* TVLibrariesViewModel.swift in Sources */,
|
||||
62ECA01826FA685A00E8EBB7 /* DeepLink.swift in Sources */,
|
||||
535BAEA5264A151C005FA86D /* VideoPlayer.swift in Sources */,
|
||||
62E632E6267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */,
|
||||
E131691726C583BC0074BFEE /* LogConstructor.swift in Sources */,
|
||||
5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */,
|
||||
532E68CF267D9F6B007B9F13 /* VideoPlayerCastDeviceSelector.swift in Sources */,
|
||||
E14F7D0926DB36F7007C3AE6 /* ItemLandscapeMainView.swift in Sources */,
|
||||
532175402671EE4F005491E6 /* LibraryFilterView.swift in Sources */,
|
||||
C4BE0763271FC0BB003F4AD1 /* TVLibrariesCoordinator.swift in Sources */,
|
||||
53DF641E263D9C0600A7CD1A /* LibraryView.swift in Sources */,
|
||||
E1C812BF277A8E5D00918266 /* VLCPlayerOverlayView.swift in Sources */,
|
||||
E1C812CE277AE43100918266 /* VideoPlayerViewModel.swift in Sources */,
|
||||
E1C812C3277A8E5D00918266 /* VLCPlayerCompactOverlayView.swift in Sources */,
|
||||
E188460026DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift in Sources */,
|
||||
091B5A8B2683142E00D78B61 /* UDPBroadCastConnection.swift in Sources */,
|
||||
6267B3D626710B8900A7371D /* CollectionExtensions.swift in Sources */,
|
||||
E13DD3F5271793BB009D4DAF /* UserSignInView.swift in Sources */,
|
||||
E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */,
|
||||
53649AB1269CFB1900A2D8B7 /* LogManager.swift in Sources */,
|
||||
E1C812C1277A8E5D00918266 /* NativePlayerViewController.swift in Sources */,
|
||||
E13DD3E127176BD3009D4DAF /* ServerListViewModel.swift in Sources */,
|
||||
62E632E9267D3FF50063E547 /* SeasonItemViewModel.swift in Sources */,
|
||||
62E632F3267D54030063E547 /* ItemViewModel.swift in Sources */,
|
||||
|
@ -1960,14 +1972,16 @@
|
|||
E14F7D0726DB36EF007C3AE6 /* ItemPortraitMainView.swift in Sources */,
|
||||
E1AD106226D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift in Sources */,
|
||||
53E4E649263F725B00F67C6B /* MultiSelectorView.swift in Sources */,
|
||||
6220D0C626D62D8700B8E046 /* VideoPlayerCoordinator.swift in Sources */,
|
||||
6220D0C626D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift in Sources */,
|
||||
E11B1B6C2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */,
|
||||
E1D4BF812719D22800A11E64 /* AppAppearance.swift in Sources */,
|
||||
621338B32660A07800A81A2A /* LazyView.swift in Sources */,
|
||||
6220D0B126D5EC9900B8E046 /* SettingsCoordinator.swift in Sources */,
|
||||
E1C812BE277A8E5D00918266 /* VLCPlayerViewController.swift in Sources */,
|
||||
62C29EA626D1036A00C1D2E7 /* HomeCoordinator.swift in Sources */,
|
||||
531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */,
|
||||
E13DD3E927177ED6009D4DAF /* ServerListCoordinator.swift in Sources */,
|
||||
E1C812BD277A8E5D00918266 /* PlayerOverlayDelegate.swift in Sources */,
|
||||
E13DD3BD27163C63009D4DAF /* EmailHelper.swift in Sources */,
|
||||
E13DD3C227164941009D4DAF /* SwiftfinStore.swift in Sources */,
|
||||
E1AD104A26D94822003E4A08 /* DetailItem.swift in Sources */,
|
||||
|
@ -2379,7 +2393,7 @@
|
|||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 66;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
DEVELOPMENT_TEAM = TY84JMYEFE;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
EXCLUDED_ARCHS = "";
|
||||
|
@ -2391,7 +2405,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.swiftfin;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pips.swiftfin;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
|
@ -2416,7 +2430,7 @@
|
|||
CURRENT_PROJECT_VERSION = 66;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
DEVELOPMENT_TEAM = TY84JMYEFE;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
EXCLUDED_ARCHS = "";
|
||||
|
@ -2428,7 +2442,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.swiftfin;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pips.swiftfin;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
|
@ -2447,7 +2461,7 @@
|
|||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 66;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
DEVELOPMENT_TEAM = TY84JMYEFE;
|
||||
INFOPLIST_FILE = WidgetExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
|
@ -2456,7 +2470,7 @@
|
|||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.swiftfin.widget;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pips.swiftfin.widget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
@ -2474,7 +2488,7 @@
|
|||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 66;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
DEVELOPMENT_TEAM = TY84JMYEFE;
|
||||
INFOPLIST_FILE = WidgetExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
|
@ -2483,7 +2497,7 @@
|
|||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.swiftfin.widget;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pips.swiftfin.widget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||
*/
|
||||
|
||||
import AVFAudio
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
|
@ -18,6 +19,13 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
|||
// Lazily initialize datastack
|
||||
_ = SwiftfinStore.dataStack
|
||||
|
||||
let audioSession = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try audioSession.setCategory(.playback)
|
||||
} catch {
|
||||
print("setting category AVAudioSessionCategoryPlayback failed")
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
|
@ -9,11 +9,6 @@ import Introspect
|
|||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
class VideoPlayerItem: ObservableObject {
|
||||
@Published var shouldShowPlayer: Bool = false
|
||||
@Published var itemToPlay = BaseItemDto()
|
||||
}
|
||||
|
||||
// Intermediary view for ItemView to set navigation bar settings
|
||||
struct ItemNavigationView: View {
|
||||
private let item: BaseItemDto
|
||||
|
@ -31,10 +26,7 @@ struct ItemNavigationView: View {
|
|||
private struct ItemView: View {
|
||||
@EnvironmentObject var itemRouter: ItemCoordinator.Router
|
||||
|
||||
@State private var videoIsLoading: Bool = false // This variable is only changed by the underlying VLC view.
|
||||
@State private var viewDidLoad: Bool = false
|
||||
@State private var orientation: UIDeviceOrientation = .unknown
|
||||
@StateObject private var videoPlayerItem = VideoPlayerItem()
|
||||
@Environment(\.horizontalSizeClass) private var hSizeClass
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
|
||||
|
@ -91,19 +83,13 @@ private struct ItemView: View {
|
|||
var body: some View {
|
||||
Group {
|
||||
if hSizeClass == .compact && vSizeClass == .regular {
|
||||
ItemPortraitMainView(videoIsLoading: $videoIsLoading)
|
||||
.environmentObject(videoPlayerItem)
|
||||
ItemPortraitMainView()
|
||||
.environmentObject(viewModel)
|
||||
} else {
|
||||
ItemLandscapeMainView(videoIsLoading: $videoIsLoading)
|
||||
.environmentObject(videoPlayerItem)
|
||||
ItemLandscapeMainView()
|
||||
.environmentObject(viewModel)
|
||||
}
|
||||
}
|
||||
.onReceive(videoPlayerItem.$shouldShowPlayer) { flag in
|
||||
guard flag else { return }
|
||||
self.itemRouter.route(to: \.videoPlayer, viewModel.item)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
||||
toolbarItemContent
|
||||
|
|
|
@ -12,13 +12,7 @@ import SwiftUI
|
|||
|
||||
struct ItemLandscapeMainView: View {
|
||||
@EnvironmentObject var itemRouter: ItemCoordinator.Router
|
||||
@Binding private var videoIsLoading: Bool
|
||||
@EnvironmentObject private var viewModel: ItemViewModel
|
||||
@EnvironmentObject private var videoPlayerItem: VideoPlayerItem
|
||||
|
||||
init(videoIsLoading: Binding<Bool>) {
|
||||
self._videoIsLoading = videoIsLoading
|
||||
}
|
||||
|
||||
// MARK: innerBody
|
||||
|
||||
|
@ -34,14 +28,10 @@ struct ItemLandscapeMainView: View {
|
|||
|
||||
Spacer().frame(height: 15)
|
||||
|
||||
Button {
|
||||
if let playButtonItem = viewModel.playButtonItem {
|
||||
self.videoPlayerItem.itemToPlay = playButtonItem
|
||||
self.videoPlayerItem.shouldShowPlayer = true
|
||||
}
|
||||
} label: {
|
||||
// MARK: Play
|
||||
|
||||
Button {
|
||||
self.itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "play.fill")
|
||||
.foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white)
|
||||
|
|
|
@ -12,8 +12,8 @@ import JellyfinAPI
|
|||
|
||||
struct PortraitHeaderOverlayView: View {
|
||||
|
||||
@EnvironmentObject var itemRouter: ItemCoordinator.Router
|
||||
@EnvironmentObject private var viewModel: ItemViewModel
|
||||
@EnvironmentObject private var videoPlayerItem: VideoPlayerItem
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
|
@ -75,10 +75,7 @@ struct PortraitHeaderOverlayView: View {
|
|||
|
||||
// MARK: Play
|
||||
Button {
|
||||
if let playButtonItem = viewModel.playButtonItem {
|
||||
self.videoPlayerItem.itemToPlay = playButtonItem
|
||||
self.videoPlayerItem.shouldShowPlayer = true
|
||||
}
|
||||
self.itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "play.fill")
|
||||
|
|
|
@ -11,14 +11,9 @@ import JellyfinAPI
|
|||
import SwiftUI
|
||||
|
||||
struct ItemPortraitMainView: View {
|
||||
@EnvironmentObject var itemRouter: ItemCoordinator.Router
|
||||
@Binding private var videoIsLoading: Bool
|
||||
@EnvironmentObject private var viewModel: ItemViewModel
|
||||
@EnvironmentObject private var videoPlayerItem: VideoPlayerItem
|
||||
|
||||
init(videoIsLoading: Binding<Bool>) {
|
||||
self._videoIsLoading = videoIsLoading
|
||||
}
|
||||
@EnvironmentObject var itemRouter: ItemCoordinator.Router
|
||||
@EnvironmentObject private var viewModel: ItemViewModel
|
||||
|
||||
// MARK: portraitHeaderView
|
||||
|
||||
|
|
|
@ -23,6 +23,7 @@ struct SettingsView: View {
|
|||
@Default(.appAppearance) var appAppearance
|
||||
@Default(.videoPlayerJumpForward) var jumpForwardLength
|
||||
@Default(.videoPlayerJumpBackward) var jumpBackwardLength
|
||||
@Default(.nativeVideoPlayer) var nativeVideoPlayer
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
|
@ -83,6 +84,7 @@ struct SettingsView: View {
|
|||
}
|
||||
|
||||
Section(header: Text("Playback")) {
|
||||
Toggle("Native Player", isOn: $nativeVideoPlayer)
|
||||
Picker("Default local quality", selection: $inNetworkStreamBitrate) {
|
||||
ForEach(self.viewModel.bitrates, id: \.self) { bitrate in
|
||||
Text(bitrate.name).tag(bitrate.value)
|
||||
|
|
|
@ -0,0 +1,121 @@
|
|||
//
|
||||
// NativePlayerViewController.swift
|
||||
// JellyfinVideoPlayerDev
|
||||
//
|
||||
// Created by Ethan Pippin on 11/20/21.
|
||||
//
|
||||
|
||||
import AVKit
|
||||
import Combine
|
||||
import JellyfinAPI
|
||||
import UIKit
|
||||
|
||||
class NativePlayerViewController: AVPlayerViewController {
|
||||
|
||||
let viewModel: VideoPlayerViewModel
|
||||
|
||||
var timeObserverToken: Any?
|
||||
|
||||
var lastProgressTicks: Int64 = 0
|
||||
|
||||
init(viewModel: VideoPlayerViewModel) {
|
||||
|
||||
self.viewModel = viewModel
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
let player = AVPlayer(url: viewModel.hlsURL)
|
||||
|
||||
player.appliesMediaSelectionCriteriaAutomatically = false
|
||||
player.currentItem?.externalMetadata = createMetadata()
|
||||
|
||||
let chevron = UIImage(systemName: "chevron.right.circle.fill")!
|
||||
let testAction = UIAction(title: "Next", image: chevron) { action in
|
||||
print("next item selected")
|
||||
}
|
||||
|
||||
// tvos
|
||||
// self.transportBarCustomMenuItems = [testAction]
|
||||
|
||||
let timeScale = CMTimeScale(NSEC_PER_SEC)
|
||||
let time = CMTime(seconds: 5, preferredTimescale: timeScale)
|
||||
|
||||
timeObserverToken = player.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] time in
|
||||
// print("Timer timed: \(time)")
|
||||
|
||||
if time.seconds != 0 {
|
||||
self?.sendProgressReport(seconds: time.seconds)
|
||||
}
|
||||
}
|
||||
|
||||
self.player = player
|
||||
|
||||
self.allowsPictureInPicturePlayback = true
|
||||
self.player?.allowsExternalPlayback = true
|
||||
}
|
||||
|
||||
private func createMetadata() -> [AVMetadataItem] {
|
||||
let allMetadata: [AVMetadataIdentifier: Any] = [
|
||||
.commonIdentifierTitle: viewModel.title,
|
||||
.iTunesMetadataTrackSubTitle: viewModel.subtitle ?? "",
|
||||
.commonIdentifierArtwork: UIImage(data: try! Data(contentsOf: viewModel.item.getBackdropImage(maxWidth: 200)))?.pngData() as Any,
|
||||
.commonIdentifierDescription: viewModel.item.overview ?? ""
|
||||
]
|
||||
|
||||
return allMetadata.compactMap { createMetadataItem(for:$0, value:$1) }
|
||||
}
|
||||
|
||||
private func createMetadataItem(for identifier: AVMetadataIdentifier,
|
||||
value: Any) -> AVMetadataItem {
|
||||
let item = AVMutableMetadataItem()
|
||||
item.identifier = identifier
|
||||
item.value = value as? NSCopying & NSObjectProtocol
|
||||
// Specify "und" to indicate an undefined language.
|
||||
item.extendedLanguageTag = "und"
|
||||
return item.copy() as! AVMetadataItem
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
stop()
|
||||
removePeriodicTimeObserver()
|
||||
}
|
||||
|
||||
func removePeriodicTimeObserver() {
|
||||
if let timeObserverToken = timeObserverToken {
|
||||
player?.removeTimeObserver(timeObserverToken)
|
||||
self.timeObserverToken = nil
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
player?.seek(to: CMTimeMake(value: viewModel.item.userData?.playbackPositionTicks ?? 0, timescale: 10_000_000), toleranceBefore: CMTimeMake(value: 5, timescale: 1), toleranceAfter: CMTimeMake(value: 5, timescale: 1), completionHandler: { _ in
|
||||
self.play()
|
||||
})
|
||||
}
|
||||
|
||||
private func play() {
|
||||
player?.play()
|
||||
|
||||
viewModel.sendPlayReport(startTimeTicks: viewModel.item.userData?.playbackPositionTicks ?? 0)
|
||||
}
|
||||
|
||||
private func sendProgressReport(seconds: Double) {
|
||||
viewModel.sendProgressReport(ticks: Int64(seconds) * 10_000_000)
|
||||
}
|
||||
|
||||
private func stop() {
|
||||
viewModel.sendStopReport(ticks: 10_000_000)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
//
|
||||
// PlaybackSpeed.swift
|
||||
// JellyfinVideoPlayerDev
|
||||
//
|
||||
// Created by Ethan Pippin on 12/27/21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum PlaybackSpeed: Double, CaseIterable {
|
||||
case quarter = 0.25
|
||||
case half = 0.5
|
||||
case threeQuarter = 0.75
|
||||
case one = 1.0
|
||||
case oneQuarter = 1.25
|
||||
case oneHalf = 1.5
|
||||
case oneThreeQuarter = 1.75
|
||||
case two = 2.0
|
||||
|
||||
var displayTitle: String {
|
||||
switch self {
|
||||
case .quarter:
|
||||
return "0.25x"
|
||||
case .half:
|
||||
return "0.5x"
|
||||
case .threeQuarter:
|
||||
return "0.75x"
|
||||
case .one:
|
||||
return "1x"
|
||||
case .oneQuarter:
|
||||
return "1.25x"
|
||||
case .oneHalf:
|
||||
return "1.5x"
|
||||
case .oneThreeQuarter:
|
||||
return "1.75x"
|
||||
case .two:
|
||||
return "2x"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
//
|
||||
// PlayerOverlayDelegate.swift
|
||||
// JellyfinVideoPlayerDev
|
||||
//
|
||||
// Created by Ethan Pippin on 12/27/21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol PlayerOverlayDelegate {
|
||||
|
||||
func didSelectClose()
|
||||
func didSelectGoogleCast()
|
||||
func didSelectAirplay()
|
||||
func didSelectCaptions()
|
||||
func didSelectMenu()
|
||||
func didDeselectMenu()
|
||||
|
||||
func didSelectBackward()
|
||||
func didSelectForward()
|
||||
func didSelectMain()
|
||||
|
||||
func didGenerallyTap()
|
||||
|
||||
func didBeginScrubbing()
|
||||
func didEndScrubbing(position: Double)
|
||||
|
||||
func didSelectAudioStream(index: Int)
|
||||
func didSelectSubtitleStream(index: Int)
|
||||
}
|
|
@ -0,0 +1,271 @@
|
|||
//
|
||||
// VLCPlayerCompactOverlayView.swift
|
||||
// JellyfinVideoPlayerDev
|
||||
//
|
||||
// Created by Ethan Pippin on 12/26/21.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import MobileVLCKit
|
||||
import SwiftUI
|
||||
import JellyfinAPI
|
||||
|
||||
struct VLCPlayerCompactOverlayView: View {
|
||||
|
||||
@ObservedObject var viewModel: VideoPlayerViewModel
|
||||
|
||||
@ViewBuilder
|
||||
private var mainButtonView: some View {
|
||||
switch viewModel.playerState {
|
||||
case .stopped, .paused:
|
||||
Image(systemName: "play.fill")
|
||||
.font(.system(size: 28, weight: .heavy, design: .default))
|
||||
case .playing:
|
||||
Image(systemName: "pause")
|
||||
.font(.system(size: 28, weight: .heavy, design: .default))
|
||||
default:
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var mainBody: some View {
|
||||
VStack {
|
||||
|
||||
VStack(alignment: .EpisodeSeriesAlignmentGuide) {
|
||||
|
||||
// MARK: Top Bar
|
||||
HStack(alignment: .top) {
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Button {
|
||||
viewModel.playerOverlayDelegate?.didSelectClose()
|
||||
} label: {
|
||||
Image(systemName: "chevron.left.circle.fill")
|
||||
.font(.system(size: 28, weight: .regular, design: .default))
|
||||
}
|
||||
|
||||
Text(viewModel.title)
|
||||
.font(.system(size: 28, weight: .regular, design: .default))
|
||||
.alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in
|
||||
context[.leading]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 20) {
|
||||
|
||||
if viewModel.shouldShowGoogleCast {
|
||||
Button {
|
||||
viewModel.playerOverlayDelegate?.didSelectGoogleCast()
|
||||
} label: {
|
||||
Image(systemName: "rectangle.badge.plus")
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.shouldShowAirplay {
|
||||
Button {
|
||||
viewModel.playerOverlayDelegate?.didSelectAirplay()
|
||||
} label: {
|
||||
Image(systemName: "airplayvideo")
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
viewModel.screenFilled = !viewModel.screenFilled
|
||||
} label: {
|
||||
if viewModel.screenFilled {
|
||||
Image(systemName: "rectangle.arrowtriangle.2.inward")
|
||||
.rotationEffect(Angle(degrees: 90))
|
||||
} else {
|
||||
Image(systemName: "rectangle.arrowtriangle.2.outward")
|
||||
.rotationEffect(Angle(degrees: 90))
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
viewModel.playerOverlayDelegate?.didSelectCaptions()
|
||||
} label: {
|
||||
if viewModel.captionsEnabled {
|
||||
Image(systemName: "captions.bubble.fill")
|
||||
} else {
|
||||
Image(systemName: "captions.bubble")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Settings Menu
|
||||
Menu {
|
||||
|
||||
Menu {
|
||||
ForEach(viewModel.audioStreams, id: \.self) { audioStream in
|
||||
Button {
|
||||
viewModel.selectedAudioStreamIndex = audioStream.index ?? -1
|
||||
} label: {
|
||||
if audioStream.index == viewModel.selectedAudioStreamIndex {
|
||||
Label.init(audioStream.displayTitle ?? "No Title", systemImage: "checkmark")
|
||||
} else {
|
||||
Text(audioStream.displayTitle ?? "No Title")
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "speaker.wave.3")
|
||||
Text("Audio")
|
||||
}
|
||||
}
|
||||
|
||||
Menu {
|
||||
ForEach(viewModel.subtitleStreams, id: \.self) { subtitleStream in
|
||||
Button {
|
||||
viewModel.selectedSubtitleStreamIndex = subtitleStream.index ?? -1
|
||||
} label: {
|
||||
if subtitleStream.index == viewModel.selectedSubtitleStreamIndex {
|
||||
Label.init(subtitleStream.displayTitle ?? "No Title", systemImage: "checkmark")
|
||||
} else {
|
||||
Text(subtitleStream.displayTitle ?? "No Title")
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "captions.bubble")
|
||||
Text("Subtitles")
|
||||
}
|
||||
}
|
||||
|
||||
Menu {
|
||||
ForEach(PlaybackSpeed.allCases, id: \.self) { speed in
|
||||
Button {
|
||||
viewModel.playbackSpeed = speed
|
||||
} label: {
|
||||
if speed == viewModel.playbackSpeed {
|
||||
Label(speed.displayTitle, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(speed.displayTitle)
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "speedometer")
|
||||
Text("Playback Speed")
|
||||
}
|
||||
}
|
||||
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.system(size: 24))
|
||||
|
||||
if let seriesTitle = viewModel.subtitle {
|
||||
Text(seriesTitle)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(Color.gray)
|
||||
.alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in
|
||||
context[.leading]
|
||||
}
|
||||
.offset(y: -10)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// MARK: Bottom Bar
|
||||
HStack {
|
||||
|
||||
HStack(spacing: 20) {
|
||||
Button {
|
||||
viewModel.playerOverlayDelegate?.didSelectBackward()
|
||||
} label: {
|
||||
Image(systemName: "gobackward.10")
|
||||
}
|
||||
|
||||
Button {
|
||||
viewModel.playerOverlayDelegate?.didSelectMain()
|
||||
} label: {
|
||||
mainButtonView
|
||||
}
|
||||
|
||||
Button {
|
||||
viewModel.playerOverlayDelegate?.didSelectForward()
|
||||
} label: {
|
||||
Image(systemName: "goforward.10")
|
||||
}
|
||||
}
|
||||
.font(.system(size: 24, weight: .semibold, design: .default))
|
||||
.padding(.trailing, 20)
|
||||
|
||||
Text(viewModel.leftLabelText)
|
||||
.font(.system(size: 18, weight: .semibold, design: .default))
|
||||
|
||||
Slider(value: $viewModel.sliderPercentage) { editing in
|
||||
viewModel.sliderIsScrubbing = editing
|
||||
}
|
||||
.foregroundColor(.purple)
|
||||
.tint(.purple)
|
||||
|
||||
// ValueSlider(value: $viewModel.sliderPercentage)
|
||||
// .valueSliderStyle(
|
||||
// HorizontalValueSliderStyle(thumb: Circle().foregroundColor(.purple),
|
||||
// thumbSize: CGSize(width: 32, height: 32),
|
||||
// thumbInteractiveSize: CGSize(width: 50, height: 50),
|
||||
// options: [.interactiveTrack])
|
||||
// )
|
||||
|
||||
Text(viewModel.rightLabelText)
|
||||
.font(.system(size: 18, weight: .semibold, design: .default))
|
||||
}
|
||||
.frame(height: 50)
|
||||
}
|
||||
.padding(.top)
|
||||
.padding(.horizontal)
|
||||
.ignoresSafeArea(edges: .top)
|
||||
.tint(Color.white)
|
||||
.foregroundColor(Color.white)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
mainBody
|
||||
.background {
|
||||
Color(uiColor: .black.withAlphaComponent(0.001))
|
||||
.ignoresSafeArea()
|
||||
.onTapGesture {
|
||||
viewModel.playerOverlayDelegate?.didGenerallyTap()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VLCPlayerCompactOverlayView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ZStack {
|
||||
Color.gray
|
||||
.ignoresSafeArea()
|
||||
|
||||
VLCPlayerCompactOverlayView(viewModel: VideoPlayerViewModel(item: BaseItemDto(runTimeTicks: 123 * 10_000_000),
|
||||
title: "Glorious Purpose",
|
||||
subtitle: "Loki - S1E1",
|
||||
streamURL: URL(string: "www.apple.com")!,
|
||||
hlsURL: URL(string: "www.apple.com")!,
|
||||
response: PlaybackInfoResponse(),
|
||||
audioStreams: [MediaStream(displayTitle: "English", index: -1)],
|
||||
subtitleStreams: [MediaStream(displayTitle: "None", index: -1)],
|
||||
defaultAudioStreamIndex: -1,
|
||||
defaultSubtitleStreamIndex: -1,
|
||||
playerState: .playing,
|
||||
shouldShowGoogleCast: false,
|
||||
shouldShowAirplay: false,
|
||||
subtitlesEnabled: true,
|
||||
sliderPercentage: 0.5,
|
||||
selectedAudioStreamIndex: -1,
|
||||
selectedSubtitleStreamIndex: -1))
|
||||
}
|
||||
.previewInterfaceOrientation(.landscapeLeft)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,258 @@
|
|||
//
|
||||
// VLCPlayerOverlayView.swift
|
||||
// JellyfinVideoPlayerDev
|
||||
//
|
||||
// Created by Ethan Pippin on 11/24/21.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import MobileVLCKit
|
||||
import SwiftUI
|
||||
import JellyfinAPI
|
||||
|
||||
|
||||
|
||||
struct VLCPlayerOverlayView: View {
|
||||
|
||||
@ObservedObject var viewModel: VideoPlayerViewModel
|
||||
|
||||
@ViewBuilder
|
||||
private var mainButtonView: some View {
|
||||
switch viewModel.playerState {
|
||||
case .stopped, .paused:
|
||||
Image(systemName: "play")
|
||||
.font(.system(size: 56))
|
||||
case .playing:
|
||||
Image(systemName: "pause")
|
||||
.font(.system(size: 56))
|
||||
default:
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var mainBody: some View {
|
||||
VStack {
|
||||
|
||||
VStack(alignment: .EpisodeSeriesAlignmentGuide) {
|
||||
|
||||
// MARK: Top Bar
|
||||
HStack(alignment: .top) {
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Button {
|
||||
viewModel.playerOverlayDelegate?.didSelectClose()
|
||||
} label: {
|
||||
Image(systemName: "chevron.backward")
|
||||
}
|
||||
|
||||
Text(viewModel.title)
|
||||
.font(.system(size: 28, weight: .regular, design: .default))
|
||||
.alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in
|
||||
context[.leading]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 20) {
|
||||
|
||||
if viewModel.shouldShowGoogleCast {
|
||||
Button {
|
||||
viewModel.playerOverlayDelegate?.didSelectGoogleCast()
|
||||
} label: {
|
||||
Image(systemName: "rectangle.badge.plus")
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.shouldShowAirplay {
|
||||
Button {
|
||||
viewModel.playerOverlayDelegate?.didSelectAirplay()
|
||||
} label: {
|
||||
Image(systemName: "airplayvideo")
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
viewModel.playerOverlayDelegate?.didSelectCaptions()
|
||||
} label: {
|
||||
if viewModel.captionsEnabled {
|
||||
Image(systemName: "captions.bubble.fill")
|
||||
} else {
|
||||
Image(systemName: "captions.bubble")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Settings Menu
|
||||
Menu {
|
||||
|
||||
Menu {
|
||||
ForEach(viewModel.audioStreams, id: \.self) { audioStream in
|
||||
Button {
|
||||
viewModel.selectedAudioStreamIndex = audioStream.index ?? -1
|
||||
} label: {
|
||||
if audioStream.index == viewModel.selectedAudioStreamIndex {
|
||||
Label.init(audioStream.displayTitle ?? "No Title", systemImage: "checkmark")
|
||||
} else {
|
||||
Text(audioStream.displayTitle ?? "No Title")
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "speaker.wave.3")
|
||||
Text("Audio")
|
||||
}
|
||||
}
|
||||
|
||||
Menu {
|
||||
ForEach(viewModel.subtitleStreams, id: \.self) { subtitleStream in
|
||||
Button {
|
||||
viewModel.selectedSubtitleStreamIndex = subtitleStream.index ?? -1
|
||||
} label: {
|
||||
if subtitleStream.index == viewModel.selectedSubtitleStreamIndex {
|
||||
Label.init(subtitleStream.displayTitle ?? "No Title", systemImage: "checkmark")
|
||||
} else {
|
||||
Text(subtitleStream.displayTitle ?? "No Title")
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "captions.bubble")
|
||||
Text("Subtitles")
|
||||
}
|
||||
}
|
||||
|
||||
Menu {
|
||||
Button {
|
||||
print("third pressed")
|
||||
} label: {
|
||||
Text("TODO")
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "speedometer")
|
||||
Text("Playback Speed")
|
||||
}
|
||||
}
|
||||
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.system(size: 24))
|
||||
|
||||
if let seriesTitle = viewModel.subtitle {
|
||||
Text(seriesTitle)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(Color.gray)
|
||||
.alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in
|
||||
context[.leading]
|
||||
}
|
||||
.offset(y: -10)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// MARK: Center Buttons
|
||||
HStack(spacing: 80) {
|
||||
Button {
|
||||
viewModel.playerOverlayDelegate?.didSelectBackward()
|
||||
} label: {
|
||||
Image(systemName: "gobackward.10")
|
||||
}
|
||||
|
||||
Button {
|
||||
viewModel.playerOverlayDelegate?.didSelectMain()
|
||||
} label: {
|
||||
mainButtonView
|
||||
}
|
||||
|
||||
Button {
|
||||
viewModel.playerOverlayDelegate?.didSelectForward()
|
||||
} label: {
|
||||
Image(systemName: "goforward.10")
|
||||
}
|
||||
}
|
||||
.font(.system(size: 48))
|
||||
|
||||
Spacer()
|
||||
|
||||
// MARK: Bottom Bar
|
||||
HStack {
|
||||
Text(viewModel.leftLabelText)
|
||||
.font(.system(size: 18, weight: .semibold, design: .default))
|
||||
|
||||
Slider(value: $viewModel.sliderPercentage) { editing in
|
||||
viewModel.sliderIsScrubbing = editing
|
||||
}
|
||||
.foregroundColor(.purple)
|
||||
.tint(.purple)
|
||||
|
||||
Text(viewModel.rightLabelText)
|
||||
.font(.system(size: 18, weight: .semibold, design: .default))
|
||||
}
|
||||
.frame(height: 50)
|
||||
}
|
||||
.padding(.top)
|
||||
.ignoresSafeArea(edges: .vertical)
|
||||
.tint(Color.white)
|
||||
.foregroundColor(Color.white)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
mainBody
|
||||
.background {
|
||||
Color(uiColor: .black.withAlphaComponent(0.2))
|
||||
.ignoresSafeArea()
|
||||
.onTapGesture {
|
||||
viewModel.playerOverlayDelegate?.didGenerallyTap()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VLCPlayerOverlayView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ZStack {
|
||||
Color.gray
|
||||
.ignoresSafeArea()
|
||||
|
||||
VLCPlayerOverlayView(viewModel: VideoPlayerViewModel(item: BaseItemDto(),
|
||||
title: "Glorious Purpose",
|
||||
subtitle: "Loki - S1E1",
|
||||
streamURL: URL(string: "www.apple.com")!,
|
||||
hlsURL: URL(string: "www.apple.com")!,
|
||||
response: PlaybackInfoResponse(),
|
||||
audioStreams: [MediaStream(displayTitle: "English", index: -1)],
|
||||
subtitleStreams: [MediaStream(displayTitle: "None", index: -1)],
|
||||
defaultAudioStreamIndex: -1,
|
||||
defaultSubtitleStreamIndex: -1,
|
||||
playerState: .playing,
|
||||
shouldShowGoogleCast: false,
|
||||
shouldShowAirplay: false,
|
||||
subtitlesEnabled: true,
|
||||
sliderPercentage: 0.0,
|
||||
selectedAudioStreamIndex: -1,
|
||||
selectedSubtitleStreamIndex: -1))
|
||||
}
|
||||
.previewInterfaceOrientation(.landscapeLeft)
|
||||
}
|
||||
}
|
||||
|
||||
extension HorizontalAlignment {
|
||||
|
||||
private struct EpisodeSeriesTitleAlignment: AlignmentID {
|
||||
static func defaultValue(in context: ViewDimensions) -> CGFloat {
|
||||
context[HorizontalAlignment.leading]
|
||||
}
|
||||
}
|
||||
|
||||
static let EpisodeSeriesAlignmentGuide = HorizontalAlignment(EpisodeSeriesTitleAlignment.self)
|
||||
|
||||
}
|
|
@ -0,0 +1,445 @@
|
|||
//
|
||||
// PlayerViewController.swift
|
||||
// JellyfinVideoPlayerDev
|
||||
//
|
||||
// Created by Ethan Pippin on 11/12/21.
|
||||
//
|
||||
|
||||
import AVKit
|
||||
import AVFoundation
|
||||
import Combine
|
||||
import JellyfinAPI
|
||||
import MediaPlayer
|
||||
import MobileVLCKit
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
class VLCPlayerViewController: UIViewController {
|
||||
|
||||
// MARK: variables
|
||||
|
||||
private let viewModel: VideoPlayerViewModel
|
||||
private var vlcMediaPlayer = VLCMediaPlayer()
|
||||
private var lastPlayerTicks: Int64
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var overlayDismissTimer: Timer?
|
||||
|
||||
private var currentPlayerTicks: Int64 {
|
||||
return Int64(vlcMediaPlayer.time.intValue) * 100_000
|
||||
}
|
||||
|
||||
private var displayingOverlay: Bool {
|
||||
return overlayHostingController.view.alpha > 0
|
||||
}
|
||||
|
||||
private lazy var videoContentView = makeVideoContentView()
|
||||
private lazy var tapGestureView = makeTapGestureView()
|
||||
private lazy var overlayHostingController = makeOverlayHostingController()
|
||||
|
||||
// MARK: init
|
||||
|
||||
init(viewModel: VideoPlayerViewModel) {
|
||||
|
||||
self.viewModel = viewModel
|
||||
|
||||
self.lastPlayerTicks = viewModel.item.userData?.playbackPositionTicks ?? 0
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
viewModel.playerOverlayDelegate = self
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func setupSubviews() {
|
||||
view.addSubview(videoContentView)
|
||||
view.addSubview(tapGestureView)
|
||||
|
||||
addChild(overlayHostingController)
|
||||
overlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
overlayHostingController.view.backgroundColor = UIColor.black.withAlphaComponent(0.2)
|
||||
view.addSubview(overlayHostingController.view)
|
||||
overlayHostingController.didMove(toParent: self)
|
||||
}
|
||||
|
||||
private func setupConstraints() {
|
||||
NSLayoutConstraint.activate([
|
||||
videoContentView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
videoContentView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
videoContentView.leftAnchor.constraint(equalTo: view.leftAnchor),
|
||||
videoContentView.rightAnchor.constraint(equalTo: view.rightAnchor)
|
||||
])
|
||||
NSLayoutConstraint.activate([
|
||||
tapGestureView.topAnchor.constraint(equalTo: videoContentView.topAnchor),
|
||||
tapGestureView.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor),
|
||||
tapGestureView.leftAnchor.constraint(equalTo: videoContentView.leftAnchor),
|
||||
tapGestureView.rightAnchor.constraint(equalTo: videoContentView.rightAnchor)
|
||||
])
|
||||
NSLayoutConstraint.activate([
|
||||
overlayHostingController.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
overlayHostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
overlayHostingController.view.leftAnchor.constraint(equalTo: view.leftAnchor),
|
||||
overlayHostingController.view.rightAnchor.constraint(equalTo: view.rightAnchor)
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: viewWillAppear
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
// AppUtility.lockOrientation(.all, andRotateTo: .landscapeLeft)
|
||||
}
|
||||
|
||||
// MARK: viewWillDisappear
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
// AppUtility.lockOrientation(.all)
|
||||
}
|
||||
|
||||
// MARK: viewDidLoad
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
setupSubviews()
|
||||
setupConstraints()
|
||||
|
||||
setupViewModelListeners()
|
||||
|
||||
view.backgroundColor = .black
|
||||
|
||||
setupMediaPlayer()
|
||||
}
|
||||
|
||||
// MARK: setupViewModelListeners
|
||||
|
||||
private func setupViewModelListeners() {
|
||||
viewModel.$playbackSpeed.sink { newSpeed in
|
||||
self.vlcMediaPlayer.rate = Float(newSpeed.rawValue)
|
||||
}.store(in: &cancellables)
|
||||
|
||||
viewModel.$screenFilled.sink { shouldFill in
|
||||
self.changeFill(to: shouldFill)
|
||||
}.store(in: &cancellables)
|
||||
|
||||
viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in
|
||||
if !sliderIsScrubbing {
|
||||
self.didEndScrubbing(position: self.viewModel.sliderPercentage)
|
||||
}
|
||||
}.store(in: &cancellables)
|
||||
|
||||
viewModel.$selectedAudioStreamIndex.sink { newAudioStreamIndex in
|
||||
self.didSelectAudioStream(index: newAudioStreamIndex)
|
||||
}.store(in: &cancellables)
|
||||
|
||||
viewModel.$selectedSubtitleStreamIndex.sink { newSubtitleStreamIndex in
|
||||
self.didSelectSubtitleStream(index: newSubtitleStreamIndex)
|
||||
}.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func changeFill(to shouldFill: Bool) {
|
||||
if shouldFill {
|
||||
// TODO: May not be possible with current VLCKit
|
||||
|
||||
// let drawableView = vlcMediaPlayer.drawable as! UIView
|
||||
// let drawableViewSize = drawableView.frame.size
|
||||
// let mediaSize = vlcMediaPlayer.videoSize
|
||||
|
||||
// Largest size from mediaSize is how it is currently filled
|
||||
// in the drawable view, find scaleFactor by filling entire
|
||||
// drawableView
|
||||
|
||||
vlcMediaPlayer.scaleFactor = 1.5
|
||||
} else {
|
||||
vlcMediaPlayer.scaleFactor = 0
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
startPlayback()
|
||||
restartOverlayDismissTimer()
|
||||
}
|
||||
|
||||
// MARK: subviews
|
||||
|
||||
private func makeVideoContentView() -> UIView {
|
||||
let view = UIView()
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.backgroundColor = .black
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
private func makeTapGestureView() -> UIView {
|
||||
let view = UIView()
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.backgroundColor = .clear
|
||||
|
||||
let singleTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap))
|
||||
let rightSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didRightSwipe))
|
||||
rightSwipeGesture.direction = .right
|
||||
let leftSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didLeftSwipe))
|
||||
leftSwipeGesture.direction = .left
|
||||
|
||||
view.addGestureRecognizer(singleTapGesture)
|
||||
view.addGestureRecognizer(rightSwipeGesture)
|
||||
view.addGestureRecognizer(leftSwipeGesture)
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
@objc private func didTap() {
|
||||
self.didGenerallyTap()
|
||||
}
|
||||
|
||||
@objc private func didRightSwipe() {
|
||||
self.didSelectForward()
|
||||
}
|
||||
|
||||
@objc private func didLeftSwipe() {
|
||||
self.didSelectBackward()
|
||||
}
|
||||
|
||||
private func makeOverlayHostingController() -> UIHostingController<VLCPlayerCompactOverlayView> {
|
||||
let overlayView = VLCPlayerCompactOverlayView(viewModel: viewModel)
|
||||
return UIHostingController(rootView: overlayView)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: setupMediaPlayer
|
||||
extension VLCPlayerViewController {
|
||||
|
||||
func setupMediaPlayer() {
|
||||
|
||||
vlcMediaPlayer.delegate = self
|
||||
vlcMediaPlayer.drawable = videoContentView
|
||||
vlcMediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 14)
|
||||
|
||||
let media = VLCMedia(url: viewModel.streamURL)
|
||||
media.addOption("--prefetch-buffer-size=1048576")
|
||||
media.addOption("--network-caching=5000")
|
||||
|
||||
vlcMediaPlayer.media = media
|
||||
}
|
||||
|
||||
func startPlayback() {
|
||||
vlcMediaPlayer.play()
|
||||
|
||||
viewModel.sendPlayReport(startTimeTicks: viewModel.item.userData?.playbackPositionTicks ?? 0)
|
||||
|
||||
// 1 second = 10,000,000 ticks
|
||||
let startTicks: Int64 = viewModel.item.userData?.playbackPositionTicks ?? 0
|
||||
|
||||
if startTicks != 0 {
|
||||
let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000)
|
||||
let secondsScrubbedTo = startTicks / 10_000_000
|
||||
let offset = secondsScrubbedTo - Int64(videoPosition)
|
||||
if offset > 0 {
|
||||
vlcMediaPlayer.jumpForward(Int32(offset))
|
||||
} else {
|
||||
vlcMediaPlayer.jumpBackward(Int32(abs(offset)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Show/Hide Overlay
|
||||
extension VLCPlayerViewController {
|
||||
|
||||
private func showOverlay() {
|
||||
guard overlayHostingController.view.alpha != 1 else { return }
|
||||
|
||||
UIView.animate(withDuration: 0.2) {
|
||||
self.overlayHostingController.view.alpha = 1
|
||||
}
|
||||
}
|
||||
|
||||
private func hideOverlay() {
|
||||
guard overlayHostingController.view.alpha != 0 else { return }
|
||||
|
||||
UIView.animate(withDuration: 0.2) {
|
||||
self.overlayHostingController.view.alpha = 0
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleOverlay() {
|
||||
if overlayHostingController.view.alpha < 1 {
|
||||
showOverlay()
|
||||
} else {
|
||||
hideOverlay()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: OverlayTimer
|
||||
extension VLCPlayerViewController {
|
||||
|
||||
private func restartOverlayDismissTimer(interval: Double = 2) {
|
||||
self.overlayDismissTimer?.invalidate()
|
||||
self.overlayDismissTimer = Timer.scheduledTimer(timeInterval: interval, target: self, selector: #selector(dismissTimerFired), userInfo: nil, repeats: false)
|
||||
}
|
||||
|
||||
@objc private func dismissTimerFired() {
|
||||
print("Dismiss timer fired")
|
||||
self.hideOverlay()
|
||||
}
|
||||
|
||||
private func stopOverlayDismissTimer() {
|
||||
self.overlayDismissTimer?.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: VLCMediaPlayerDelegate
|
||||
extension VLCPlayerViewController: VLCMediaPlayerDelegate {
|
||||
|
||||
func mediaPlayerStateChanged(_ aNotification: Notification!) {
|
||||
|
||||
self.viewModel.playerState = vlcMediaPlayer.state
|
||||
|
||||
print("Player state changed: \(viewModel.playerState.rawValue)")
|
||||
}
|
||||
|
||||
func mediaPlayerTimeChanged(_ aNotification: Notification!) {
|
||||
|
||||
guard !viewModel.sliderIsScrubbing else {
|
||||
lastPlayerTicks = currentPlayerTicks
|
||||
return
|
||||
}
|
||||
|
||||
viewModel.sliderPercentage = Double(vlcMediaPlayer.position)
|
||||
|
||||
if abs(currentPlayerTicks - lastPlayerTicks) >= 10000 {
|
||||
|
||||
viewModel.playerState = VLCMediaPlayerState.playing
|
||||
}
|
||||
|
||||
lastPlayerTicks = currentPlayerTicks
|
||||
|
||||
// if CACurrentMediaTime() - lastProgressReportTime > 5 {
|
||||
// mediaPlayer.currentVideoSubTitleIndex = selectedCaptionTrack
|
||||
// sendProgressReport(eventName: "timeupdate")
|
||||
// lastProgressReportTime = CACurrentMediaTime()
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: PlayerOverlayDelegate
|
||||
extension VLCPlayerViewController: PlayerOverlayDelegate {
|
||||
|
||||
func didSelectAudioStream(index: Int) {
|
||||
vlcMediaPlayer.currentAudioTrackIndex = Int32(index)
|
||||
print("New audio index: \(index)")
|
||||
}
|
||||
|
||||
func didSelectSubtitleStream(index: Int) {
|
||||
vlcMediaPlayer.currentVideoSubTitleIndex = Int32(index)
|
||||
|
||||
if index != -1 {
|
||||
// set in case weren't shown
|
||||
viewModel.captionsEnabled = true
|
||||
}
|
||||
print("New subtitle index: \(index)")
|
||||
}
|
||||
|
||||
func didSelectClose() {
|
||||
vlcMediaPlayer.stop()
|
||||
|
||||
viewModel.sendStopReport(ticks: currentPlayerTicks)
|
||||
|
||||
dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
func didSelectGoogleCast() {
|
||||
print("didSelectCast")
|
||||
}
|
||||
|
||||
func didSelectAirplay() {
|
||||
print("didSelectAirplay")
|
||||
}
|
||||
|
||||
func didSelectCaptions() {
|
||||
|
||||
viewModel.captionsEnabled = !viewModel.captionsEnabled
|
||||
|
||||
if viewModel.captionsEnabled {
|
||||
vlcMediaPlayer.currentVideoSubTitleIndex = vlcMediaPlayer.videoSubTitlesIndexes[1] as! Int32
|
||||
} else {
|
||||
vlcMediaPlayer.currentVideoSubTitleIndex = -1
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Implement properly in overlays
|
||||
func didSelectMenu() {
|
||||
stopOverlayDismissTimer()
|
||||
}
|
||||
|
||||
// TODO: Implement properly in overlays
|
||||
func didDeselectMenu() {
|
||||
restartOverlayDismissTimer()
|
||||
}
|
||||
|
||||
func didSelectBackward() {
|
||||
vlcMediaPlayer.jumpBackward(10)
|
||||
|
||||
restartOverlayDismissTimer()
|
||||
}
|
||||
|
||||
func didSelectForward() {
|
||||
vlcMediaPlayer.jumpForward(10)
|
||||
|
||||
restartOverlayDismissTimer()
|
||||
}
|
||||
|
||||
func didSelectMain() {
|
||||
|
||||
switch viewModel.playerState {
|
||||
case .stopped: ()
|
||||
case .opening: ()
|
||||
case .buffering:
|
||||
vlcMediaPlayer.play()
|
||||
restartOverlayDismissTimer()
|
||||
case .ended: ()
|
||||
case .error: ()
|
||||
case .playing:
|
||||
vlcMediaPlayer.pause()
|
||||
restartOverlayDismissTimer(interval: 5)
|
||||
case .paused:
|
||||
vlcMediaPlayer.play()
|
||||
case .esAdded: ()
|
||||
|
||||
default: ()
|
||||
}
|
||||
}
|
||||
|
||||
func didGenerallyTap() {
|
||||
toggleOverlay()
|
||||
|
||||
restartOverlayDismissTimer(interval: 5)
|
||||
}
|
||||
|
||||
func didBeginScrubbing() {
|
||||
|
||||
}
|
||||
|
||||
func didEndScrubbing(position: Double) {
|
||||
let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000)
|
||||
let videoDuration = Double(viewModel.item.runTimeTicks! / 10_000_000)
|
||||
let secondsScrubbedTo = round(viewModel.sliderPercentage * videoDuration)
|
||||
let newPositionOffset = secondsScrubbedTo - videoPosition
|
||||
|
||||
if newPositionOffset > 0 {
|
||||
vlcMediaPlayer.jumpForward(Int32(newPositionOffset))
|
||||
} else {
|
||||
vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset)))
|
||||
}
|
||||
|
||||
print("Scrubbed position: \(position)")
|
||||
}
|
||||
}
|
|
@ -1,252 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<device id="retina6_5" orientation="landscape" appearance="light"/>
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
|
||||
<capability name="Image references" minToolsVersion="12.0"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Player View Controller-->
|
||||
<scene sceneID="s0d-6b-0kx">
|
||||
<objects>
|
||||
<viewController storyboardIdentifier="VideoPlayer" id="Y6W-OH-hqX" customClass="PlayerViewController" customModule="JellyfinPlayer_iOS" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<view key="view" autoresizesSubviews="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="IQg-r0-AeH">
|
||||
<rect key="frame" x="0.0" y="0.0" width="896" height="414"/>
|
||||
<subviews>
|
||||
<view autoresizesSubviews="NO" tag="1" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Tsh-rC-BwO" userLabel="VideoContentView">
|
||||
<rect key="frame" x="31" y="0.0" width="834" height="414"/>
|
||||
<viewLayoutGuide key="safeArea" id="aVY-BC-PZU"/>
|
||||
<color key="backgroundColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<gestureRecognizers/>
|
||||
<connections>
|
||||
<outletCollection property="gestureRecognizers" destination="Tag-oM-Uha" appends="YES" id="AlY-fE-iBg"/>
|
||||
</connections>
|
||||
</view>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Qcb-Fb-qZl" userLabel="VideoControlsView">
|
||||
<rect key="frame" x="0.0" y="0.0" width="896" height="414"/>
|
||||
<subviews>
|
||||
<slider opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" minValue="0.0" maxValue="1" translatesAutoresizingMaskIntoConstraints="NO" id="e9f-8l-RdN" userLabel="SeekSlider">
|
||||
<rect key="frame" x="133" y="355" width="630" height="31"/>
|
||||
<color key="tintColor" red="0.66666666666666663" green="0.36078431372549019" blue="0.76470588235294112" alpha="1" colorSpace="calibratedRGB"/>
|
||||
<color key="thumbTintColor" red="0.66666666666666663" green="0.36078431372549019" blue="0.76470588235294112" alpha="1" colorSpace="calibratedRGB"/>
|
||||
<connections>
|
||||
<action selector="seekSliderEnd:" destination="Y6W-OH-hqX" eventType="touchUpInside" id="m4l-h6-V0d"/>
|
||||
<action selector="seekSliderStart:" destination="Y6W-OH-hqX" eventType="touchDown" id="it4-Bp-hPL"/>
|
||||
<action selector="seekSliderValueChanged:" destination="Y6W-OH-hqX" eventType="valueChanged" id="tfF-Zl-CdU"/>
|
||||
</connections>
|
||||
</slider>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="-:--:--" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="qft-iu-f1z" userLabel="Time Left Text">
|
||||
<rect key="frame" x="766" y="353" width="91" height="34"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="91" id="LbL-h0-EYA"/>
|
||||
<constraint firstAttribute="height" constant="34" id="OkD-Dr-Ina"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="18"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="-:--:--" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="knf-PP-UIS" userLabel="Time Text">
|
||||
<rect key="frame" x="39" y="353" width="91" height="34"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="91" id="FcP-Mk-OIL"/>
|
||||
<constraint firstAttribute="height" constant="34" id="yXx-PI-kXn"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="18"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="t2L-Oz-fe9" userLabel="MainActionButton">
|
||||
<rect key="frame" x="406.66666666666669" y="165.66666666666666" width="83" height="83"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="83" id="PdD-nW-y9r"/>
|
||||
<constraint firstAttribute="height" constant="83" id="e9j-PI-Ic4"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="18"/>
|
||||
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<state key="normal">
|
||||
<imageReference key="image" image="play.slash.fill" catalog="system" symbolScale="default"/>
|
||||
<preferredSymbolConfiguration key="preferredSymbolConfiguration" configurationType="pointSize" pointSize="55" scale="default"/>
|
||||
</state>
|
||||
<connections>
|
||||
<action selector="mainActionButtonPressed:" destination="Y6W-OH-hqX" eventType="touchUpInside" id="qBH-T0-6R4"/>
|
||||
</connections>
|
||||
</button>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="rLx-SN-RHr">
|
||||
<rect key="frame" x="30" y="22" width="60" height="60"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="60" id="jwh-l2-ARL"/>
|
||||
<constraint firstAttribute="width" constant="60" id="rcS-W1-m4V"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="22"/>
|
||||
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<state key="normal" image="chevron.backward" catalog="system">
|
||||
<color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<preferredSymbolConfiguration key="preferredSymbolConfiguration" configurationType="pointSize" pointSize="24"/>
|
||||
</state>
|
||||
<connections>
|
||||
<action selector="exitButtonPressed:" destination="Y6W-OH-hqX" eventType="touchUpInside" id="XHc-OR-kc8"/>
|
||||
</connections>
|
||||
</button>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="bYM-Xp-bZO">
|
||||
<rect key="frame" x="213" y="169" width="75" height="76"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="76" id="5lC-V1-lHH"/>
|
||||
<constraint firstAttribute="width" constant="75" id="IPn-pO-Rxo"/>
|
||||
</constraints>
|
||||
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<state key="normal" image="gobackward.15" catalog="system">
|
||||
<preferredSymbolConfiguration key="preferredSymbolConfiguration" configurationType="pointSize" pointSize="35"/>
|
||||
</state>
|
||||
<connections>
|
||||
<action selector="jumpBackTapped:" destination="Y6W-OH-hqX" eventType="touchUpInside" id="4vd-25-cCB"/>
|
||||
</connections>
|
||||
</button>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="An8-jF-FAY">
|
||||
<rect key="frame" x="608" y="169" width="75" height="76"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="76" id="huv-QZ-HSc"/>
|
||||
<constraint firstAttribute="width" constant="75" id="uPN-A8-EV1"/>
|
||||
</constraints>
|
||||
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<state key="normal" image="goforward.30" catalog="system">
|
||||
<preferredSymbolConfiguration key="preferredSymbolConfiguration" configurationType="pointSize" pointSize="35"/>
|
||||
</state>
|
||||
<connections>
|
||||
<action selector="jumpForwardTapped:" destination="Y6W-OH-hqX" eventType="touchUpInside" id="I6H-fd-Mn8"/>
|
||||
</connections>
|
||||
</button>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" id="riN-y1-ABZ">
|
||||
<rect key="frame" x="817" y="32" width="40" height="40"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMinX="YES" flexibleMaxY="YES"/>
|
||||
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<state key="normal" image="gear" catalog="system">
|
||||
<color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<preferredSymbolConfiguration key="preferredSymbolConfiguration" configurationType="pointSize" pointSize="23"/>
|
||||
</state>
|
||||
<connections>
|
||||
<action selector="settingsButtonTapped:" destination="Y6W-OH-hqX" eventType="touchUpInside" id="NeC-px-2TY"/>
|
||||
</connections>
|
||||
</button>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Loading" textAlignment="center" lineBreakMode="tailTruncation" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="o8N-R1-DhT">
|
||||
<rect key="frame" x="106" y="23.333333333333332" width="684" height="57.666666666666671"/>
|
||||
<accessibility key="accessibilityConfiguration">
|
||||
<accessibilityTraits key="traits" staticText="YES" header="YES"/>
|
||||
</accessibility>
|
||||
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="19"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<color key="highlightedColor" systemColor="labelColor"/>
|
||||
</label>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" id="XVc-27-uDe" userLabel="Cast Button">
|
||||
<rect key="frame" x="766" y="32" width="40" height="40"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMinX="YES" flexibleMaxY="YES"/>
|
||||
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<state key="normal">
|
||||
<color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<imageReference key="image" image="CastDisconnected"/>
|
||||
<preferredSymbolConfiguration key="preferredSymbolConfiguration" scale="large"/>
|
||||
</state>
|
||||
<connections>
|
||||
<action selector="castButtonPressed:" destination="Y6W-OH-hqX" eventType="touchUpInside" id="LwK-pi-uQ2"/>
|
||||
</connections>
|
||||
</button>
|
||||
</subviews>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.5954241071428571" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<gestureRecognizers/>
|
||||
<constraints>
|
||||
<constraint firstItem="t2L-Oz-fe9" firstAttribute="top" secondItem="o8N-R1-DhT" secondAttribute="bottom" constant="84.666666666666657" id="1y1-QQ-N95"/>
|
||||
<constraint firstAttribute="bottom" secondItem="e9f-8l-RdN" secondAttribute="bottom" constant="29" id="231-rB-qDs"/>
|
||||
<constraint firstAttribute="trailing" secondItem="qft-iu-f1z" secondAttribute="trailing" constant="39" id="2Ie-OW-sUL"/>
|
||||
<constraint firstItem="An8-jF-FAY" firstAttribute="leading" secondItem="t2L-Oz-fe9" secondAttribute="trailing" constant="118.5" id="2zE-ul-pOh"/>
|
||||
<constraint firstItem="An8-jF-FAY" firstAttribute="centerY" secondItem="t2L-Oz-fe9" secondAttribute="centerY" id="36i-Q2-D1K"/>
|
||||
<constraint firstItem="t2L-Oz-fe9" firstAttribute="centerX" secondItem="Qcb-Fb-qZl" secondAttribute="centerX" id="3Gw-QD-lQX"/>
|
||||
<constraint firstItem="o8N-R1-DhT" firstAttribute="centerY" secondItem="riN-y1-ABZ" secondAttribute="centerY" id="Hs5-Bc-iPB"/>
|
||||
<constraint firstAttribute="bottom" secondItem="qft-iu-f1z" secondAttribute="bottom" constant="27" id="NPi-py-0qd"/>
|
||||
<constraint firstItem="rLx-SN-RHr" firstAttribute="leading" secondItem="Qcb-Fb-qZl" secondAttribute="leading" constant="30" id="Oe7-LK-6Tl"/>
|
||||
<constraint firstItem="e9f-8l-RdN" firstAttribute="leading" secondItem="knf-PP-UIS" secondAttribute="trailing" constant="5" id="ShK-80-ij1"/>
|
||||
<constraint firstItem="t2L-Oz-fe9" firstAttribute="centerY" secondItem="Qcb-Fb-qZl" secondAttribute="centerY" id="TOk-sG-UXV"/>
|
||||
<constraint firstItem="knf-PP-UIS" firstAttribute="leading" secondItem="Qcb-Fb-qZl" secondAttribute="leading" constant="39" id="XNC-Q4-nE0"/>
|
||||
<constraint firstItem="o8N-R1-DhT" firstAttribute="centerX" secondItem="t2L-Oz-fe9" secondAttribute="centerX" id="a5g-8U-9S5"/>
|
||||
<constraint firstAttribute="bottom" secondItem="qft-iu-f1z" secondAttribute="bottom" constant="27" id="aOB-Uz-cbQ"/>
|
||||
<constraint firstItem="qft-iu-f1z" firstAttribute="leading" secondItem="e9f-8l-RdN" secondAttribute="trailing" constant="5" id="auL-Vv-ZMV"/>
|
||||
<constraint firstItem="bYM-Xp-bZO" firstAttribute="top" secondItem="An8-jF-FAY" secondAttribute="top" id="cVS-eI-vv2"/>
|
||||
<constraint firstItem="t2L-Oz-fe9" firstAttribute="leading" secondItem="bYM-Xp-bZO" secondAttribute="trailing" constant="118.5" id="fci-L5-1f6"/>
|
||||
<constraint firstItem="e9f-8l-RdN" firstAttribute="centerX" secondItem="Qcb-Fb-qZl" secondAttribute="centerX" id="jFy-Sb-aYi"/>
|
||||
<constraint firstAttribute="bottom" secondItem="knf-PP-UIS" secondAttribute="bottom" constant="27" id="nLN-ju-9qC"/>
|
||||
<constraint firstItem="o8N-R1-DhT" firstAttribute="leading" secondItem="rLx-SN-RHr" secondAttribute="trailing" constant="16" id="qnV-Qf-y9m"/>
|
||||
<constraint firstItem="rLx-SN-RHr" firstAttribute="top" secondItem="Qcb-Fb-qZl" secondAttribute="top" constant="22" id="v4G-B1-7y6"/>
|
||||
</constraints>
|
||||
<connections>
|
||||
<outletCollection property="gestureRecognizers" destination="iQW-fW-KWT" appends="YES" id="H09-88-nzQ"/>
|
||||
</connections>
|
||||
</view>
|
||||
<view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="CY9-gw-dv8" userLabel="UpNextView">
|
||||
<rect key="frame" x="672" y="254" width="224" height="160"/>
|
||||
<color key="backgroundColor" red="0.34509803921568627" green="0.33725490196078434" blue="0.83921568627450982" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="160" id="IyL-p4-Y54"/>
|
||||
<constraint firstAttribute="width" constant="224" id="rFU-Nq-Qmj"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="zud-b9-RyD"/>
|
||||
<color key="backgroundColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<gestureRecognizers/>
|
||||
<constraints>
|
||||
<constraint firstItem="Qcb-Fb-qZl" firstAttribute="top" secondItem="zud-b9-RyD" secondAttribute="top" id="0rU-S8-2ZG"/>
|
||||
<constraint firstItem="Tsh-rC-BwO" firstAttribute="bottom" secondItem="IQg-r0-AeH" secondAttribute="bottom" id="CLQ-xL-eMg"/>
|
||||
<constraint firstAttribute="trailing" secondItem="CY9-gw-dv8" secondAttribute="trailing" id="GAY-9O-TMP"/>
|
||||
<constraint firstItem="zud-b9-RyD" firstAttribute="trailing" secondItem="Tsh-rC-BwO" secondAttribute="trailing" constant="-13" id="MTY-zG-Jfx"/>
|
||||
<constraint firstAttribute="trailing" secondItem="Qcb-Fb-qZl" secondAttribute="trailing" id="N96-TI-UDZ"/>
|
||||
<constraint firstAttribute="trailing" secondItem="CY9-gw-dv8" secondAttribute="trailing" id="VY1-j7-qK2"/>
|
||||
<constraint firstAttribute="bottom" secondItem="CY9-gw-dv8" secondAttribute="bottom" id="Wtk-gJ-gF4"/>
|
||||
<constraint firstItem="Qcb-Fb-qZl" firstAttribute="leading" secondItem="IQg-r0-AeH" secondAttribute="leading" id="ctC-7w-DiS"/>
|
||||
<constraint firstItem="Tsh-rC-BwO" firstAttribute="leading" secondItem="zud-b9-RyD" secondAttribute="leading" constant="-13" id="cw7-9C-iua"/>
|
||||
<constraint firstItem="Tsh-rC-BwO" firstAttribute="top" secondItem="zud-b9-RyD" secondAttribute="top" id="d4Q-bp-K4m"/>
|
||||
<constraint firstAttribute="bottom" secondItem="Qcb-Fb-qZl" secondAttribute="bottom" id="gmY-zx-4Ed"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<connections>
|
||||
<outlet property="castButton" destination="XVc-27-uDe" id="FII-I9-nHf"/>
|
||||
<outlet property="jumpBackButton" destination="bYM-Xp-bZO" id="K2u-5Q-dkm"/>
|
||||
<outlet property="jumpForwardButton" destination="An8-jF-FAY" id="4hN-YB-yVd"/>
|
||||
<outlet property="mainActionButton" destination="t2L-Oz-fe9" id="nQR-2e-64l"/>
|
||||
<outlet property="playerSettingsButton" destination="riN-y1-ABZ" id="I6r-z9-Jy2"/>
|
||||
<outlet property="seekSlider" destination="e9f-8l-RdN" id="b3H-tn-TPG"/>
|
||||
<outlet property="timeLeftText" destination="qft-iu-f1z" id="cSg-fO-9nF"/>
|
||||
<outlet property="timeText" destination="knf-PP-UIS" id="KhK-BX-rqT"/>
|
||||
<outlet property="titleLabel" destination="o8N-R1-DhT" id="E7D-iU-bMi"/>
|
||||
<outlet property="upNextView" destination="CY9-gw-dv8" id="BP6-bc-6Vk"/>
|
||||
<outlet property="videoContentView" destination="Tsh-rC-BwO" id="5uR-No-wLy"/>
|
||||
<outlet property="videoControlsView" destination="Qcb-Fb-qZl" id="Z1U-Qr-8ND"/>
|
||||
</connections>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="Ief-a0-LHa" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
<tapGestureRecognizer id="Tag-oM-Uha">
|
||||
<connections>
|
||||
<action selector="contentViewTapped:" destination="Y6W-OH-hqX" id="uq5-EN-60x"/>
|
||||
</connections>
|
||||
</tapGestureRecognizer>
|
||||
<tapGestureRecognizer id="iQW-fW-KWT">
|
||||
<connections>
|
||||
<action selector="controlViewTapped:" destination="Y6W-OH-hqX" id="0lD-A7-3TP"/>
|
||||
</connections>
|
||||
</tapGestureRecognizer>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="129.24107142857142" y="71.014492753623188"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="CastDisconnected" width="24" height="24"/>
|
||||
<image name="chevron.backward" catalog="system" width="96" height="128"/>
|
||||
<image name="gear" catalog="system" width="128" height="119"/>
|
||||
<image name="gobackward.15" catalog="system" width="121" height="128"/>
|
||||
<image name="goforward.30" catalog="system" width="121" height="128"/>
|
||||
<image name="play.slash.fill" catalog="system" width="116" height="128"/>
|
||||
<systemColor name="labelColor">
|
||||
<color white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
</resources>
|
||||
</document>
|
File diff suppressed because it is too large
Load Diff
|
@ -1,93 +0,0 @@
|
|||
/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public
|
||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||
*/
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
class VideoPlayerCastDeviceSelectorView: UIViewController {
|
||||
private var contentView: UIHostingController<VideoPlayerCastDeviceSelector>!
|
||||
weak var delegate: PlayerViewController?
|
||||
|
||||
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
||||
.landscape
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
contentView = UIHostingController(rootView: VideoPlayerCastDeviceSelector(delegate: self.delegate ?? PlayerViewController()))
|
||||
self.view.addSubview(contentView.view)
|
||||
contentView.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
|
||||
contentView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
|
||||
contentView.view.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
|
||||
contentView.view.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
self.delegate?.castPopoverDismissed()
|
||||
}
|
||||
}
|
||||
|
||||
struct VideoPlayerCastDeviceSelector: View {
|
||||
weak var delegate: PlayerViewController!
|
||||
|
||||
init(delegate: PlayerViewController) {
|
||||
self.delegate = delegate
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Group {
|
||||
if !delegate.discoveredCastDevices.isEmpty {
|
||||
List(delegate.discoveredCastDevices, id: \.deviceID) { device in
|
||||
HStack {
|
||||
Text(device.friendlyName!)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
Spacer()
|
||||
Button {
|
||||
delegate.selectedCastDevice = device
|
||||
delegate?.castDeviceChanged()
|
||||
delegate?.castPopoverDismissed()
|
||||
} label: {
|
||||
HStack {
|
||||
L10n.connect.text
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
Image(systemName: "bonjour")
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
L10n.noCastdevicesfound.text
|
||||
.foregroundColor(.secondary)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationTitle(L10n.selectCastDestination)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .navigationBarLeading) {
|
||||
if UIDevice.current.userInterfaceIdiom == .phone {
|
||||
Button {
|
||||
delegate?.castPopoverDismissed()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "chevron.left")
|
||||
L10n.back.text.font(.callout)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.offset(y: UIDevice.current.userInterfaceIdiom == .pad ? 14 : 0)
|
||||
}
|
||||
}
|
|
@ -1,89 +0,0 @@
|
|||
/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public
|
||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||
*/
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
class VideoPlayerSettingsView: UINavigationController {
|
||||
private var contentView: UIHostingController<VideoPlayerSettings>!
|
||||
weak var playerDelegate: PlayerViewController?
|
||||
|
||||
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
||||
.landscape
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
self.viewControllers = [UIHostingController(rootView: VideoPlayerSettings(delegate: self.playerDelegate ?? PlayerViewController()))]
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
self.playerDelegate?.settingsPopoverDismissed()
|
||||
}
|
||||
}
|
||||
|
||||
struct VideoPlayerSettings: View {
|
||||
weak var delegate: PlayerViewController!
|
||||
@State var captionTrack: Int32 = -99
|
||||
@State var audioTrack: Int32 = -99
|
||||
@State var playbackSpeedSelection: Int = 3
|
||||
|
||||
init(delegate: PlayerViewController) {
|
||||
self.delegate = delegate
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Picker(L10n.closedCaptions, selection: $captionTrack) {
|
||||
ForEach(delegate.subtitleTrackArray, id: \.id) { caption in
|
||||
Text(caption.name).tag(caption.id)
|
||||
}
|
||||
}
|
||||
.onChange(of: captionTrack) { track in
|
||||
self.delegate.subtitleTrackChanged(newTrackID: track)
|
||||
}
|
||||
Picker(L10n.audioTrack, selection: $audioTrack) {
|
||||
ForEach(delegate.audioTrackArray, id: \.id) { caption in
|
||||
Text(caption.name).tag(caption.id).lineLimit(1)
|
||||
}
|
||||
}.onChange(of: audioTrack) { track in
|
||||
self.delegate.audioTrackChanged(newTrackID: track)
|
||||
}
|
||||
Picker(L10n.playbackSpeed, selection: $playbackSpeedSelection) {
|
||||
ForEach(delegate.playbackSpeeds.indices, id: \.self) { speedIndex in
|
||||
let speed = delegate.playbackSpeeds[speedIndex]
|
||||
Text("\(String(speed))x").tag(speedIndex)
|
||||
}
|
||||
}
|
||||
.onChange(of: playbackSpeedSelection, perform: { index in
|
||||
self.delegate.playbackSpeedChanged(index: index)
|
||||
})
|
||||
}.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationTitle(L10n.audioAndCaptions)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .navigationBarLeading) {
|
||||
if UIDevice.current.userInterfaceIdiom == .phone {
|
||||
Button {
|
||||
self.delegate.settingsPopoverDismissed()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "chevron.left")
|
||||
L10n.back.text.font(.callout)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.offset(y: UIDevice.current.userInterfaceIdiom == .pad ? 14 : 0)
|
||||
.onAppear(perform: {
|
||||
captionTrack = self.delegate.selectedCaptionTrack
|
||||
audioTrack = self.delegate.selectedAudioTrack
|
||||
playbackSpeedSelection = self.delegate.selectedPlaybackSpeedIndex
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
//
|
||||
// VideoPlayerView.swift
|
||||
// JellyfinVideoPlayerDev
|
||||
//
|
||||
// Created by Ethan Pippin on 11/12/21.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
struct NativePlayerView: UIViewControllerRepresentable {
|
||||
|
||||
let viewModel: VideoPlayerViewModel
|
||||
|
||||
typealias UIViewControllerType = NativePlayerViewController
|
||||
|
||||
func makeUIViewController(context: Context) -> NativePlayerViewController {
|
||||
|
||||
return NativePlayerViewController(viewModel: viewModel)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: NativePlayerViewController, context: Context) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
struct VLCPlayerView: UIViewControllerRepresentable {
|
||||
|
||||
let viewModel: VideoPlayerViewModel
|
||||
|
||||
typealias UIViewControllerType = VLCPlayerViewController
|
||||
|
||||
func makeUIViewController(context: Context) -> VLCPlayerViewController {
|
||||
|
||||
return VLCPlayerViewController(viewModel: viewModel)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: VLCPlayerViewController, context: Context) {
|
||||
|
||||
}
|
||||
}
|
|
@ -1,54 +0,0 @@
|
|||
//
|
||||
/*
|
||||
* SwiftFin is subject to the terms of the Mozilla Public
|
||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||
*/
|
||||
|
||||
import SwiftUI
|
||||
import JellyfinAPI
|
||||
|
||||
class UpNextViewModel: ObservableObject {
|
||||
@Published var largeView: Bool = false
|
||||
@Published var item: BaseItemDto?
|
||||
weak var delegate: PlayerViewController?
|
||||
|
||||
func nextUp() {
|
||||
if delegate != nil {
|
||||
delegate?.setPlayerToNextUp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VideoUpNextView: View {
|
||||
|
||||
@ObservedObject var viewModel: UpNextViewModel
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
viewModel.nextUp()
|
||||
} label: {
|
||||
HStack {
|
||||
VStack {
|
||||
L10n.playNext.text
|
||||
.foregroundColor(.white)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
Text(viewModel.item?.getEpisodeLocator() ?? "")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
Image(systemName: "play.fill")
|
||||
.foregroundColor(.white)
|
||||
.font(.subheadline)
|
||||
}
|
||||
.frame(width: 120, height: 35)
|
||||
.background(Color.jellyfinPurple)
|
||||
.cornerRadius(10)
|
||||
}.buttonStyle(PlainButtonStyle())
|
||||
.frame(width: 120, height: 35)
|
||||
.shadow(color: .black, radius: 20)
|
||||
}
|
||||
}
|
|
@ -35,8 +35,8 @@ final class ItemCoordinator: NavigationCoordinatable {
|
|||
ItemCoordinator(item: item)
|
||||
}
|
||||
|
||||
func makeVideoPlayer(item: BaseItemDto) -> NavigationViewCoordinator<VideoPlayerCoordinator> {
|
||||
NavigationViewCoordinator(VideoPlayerCoordinator(item: item))
|
||||
func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator<VideoPlayerCoordinator> {
|
||||
NavigationViewCoordinator(VideoPlayerCoordinator(viewModel: viewModel))
|
||||
}
|
||||
|
||||
@ViewBuilder func makeStart() -> some View {
|
||||
|
|
|
@ -1,30 +0,0 @@
|
|||
//
|
||||
/*
|
||||
* SwiftFin is subject to the terms of the Mozilla Public
|
||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||
*/
|
||||
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
|
||||
final class VideoPlayerCoordinator: NavigationCoordinatable {
|
||||
|
||||
let stack = NavigationStack(initial: \VideoPlayerCoordinator.start)
|
||||
|
||||
@Root var start = makeStart
|
||||
|
||||
let item: BaseItemDto
|
||||
|
||||
init(item: BaseItemDto) {
|
||||
self.item = item
|
||||
}
|
||||
|
||||
@ViewBuilder func makeStart() -> some View {
|
||||
VideoPlayerView(item: item)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
//
|
||||
/*
|
||||
* SwiftFin is subject to the terms of the Mozilla Public
|
||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||
*/
|
||||
|
||||
import Defaults
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
|
||||
final class VideoPlayerCoordinator: NavigationCoordinatable {
|
||||
|
||||
let stack = NavigationStack(initial: \VideoPlayerCoordinator.start)
|
||||
|
||||
@Root var start = makeStart
|
||||
|
||||
@Default(.nativeVideoPlayer) var nativeVideoPlayer
|
||||
let viewModel: VideoPlayerViewModel
|
||||
|
||||
init(viewModel: VideoPlayerViewModel) {
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
@ViewBuilder func makeStart() -> some View {
|
||||
if nativeVideoPlayer {
|
||||
NativePlayerView(viewModel: viewModel)
|
||||
.navigationBarHidden(true)
|
||||
.statusBar(hidden: true)
|
||||
.ignoresSafeArea()
|
||||
} else {
|
||||
VLCPlayerView(viewModel: viewModel)
|
||||
.navigationBarHidden(true)
|
||||
.statusBar(hidden: true)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
//
|
||||
/*
|
||||
* SwiftFin is subject to the terms of the Mozilla Public
|
||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||
*/
|
||||
|
||||
import Defaults
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
|
||||
final class VideoPlayerCoordinator: NavigationCoordinatable {
|
||||
|
||||
let stack = NavigationStack(initial: \VideoPlayerCoordinator.start)
|
||||
|
||||
@Root var start = makeStart
|
||||
|
||||
@Default(.nativeVideoPlayer) var nativeVideoPlayer
|
||||
let viewModel: VideoPlayerViewModel
|
||||
|
||||
init(viewModel: VideoPlayerViewModel) {
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
@ViewBuilder func makeStart() -> some View {
|
||||
NativePlayerView(viewModel: viewModel)
|
||||
.navigationBarHidden(true)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
//
|
||||
/*
|
||||
* SwiftFin is subject to the terms of the Mozilla Public
|
||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||
*/
|
||||
|
||||
import Foundation
|
||||
|
||||
extension URLComponents {
|
||||
|
||||
mutating func addQueryItem(name: String, value: String?) {
|
||||
if let _ = self.queryItems {
|
||||
self.queryItems?.append(URLQueryItem(name: name, value: value))
|
||||
} else {
|
||||
self.queryItems = []
|
||||
self.queryItems?.append(URLQueryItem(name: name, value: value))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -32,4 +32,5 @@ extension Defaults.Keys {
|
|||
static let appAppearance = Key<AppAppearance>("appAppearance", default: .system, suite: SwiftfinStore.Defaults.suite)
|
||||
static let videoPlayerJumpForward = Key<VideoPlayerJumpLength>("videoPlayerJumpForward", default: .thirty, suite: SwiftfinStore.Defaults.suite)
|
||||
static let videoPlayerJumpBackward = Key<VideoPlayerJumpLength>("videoPlayerJumpBackward", default: .thirty, suite: SwiftfinStore.Defaults.suite)
|
||||
static let nativeVideoPlayer = Key<Bool>("nativeVideoPlayer", default: false, suite: SwiftfinStore.Defaults.suite)
|
||||
}
|
||||
|
|
|
@ -102,7 +102,7 @@ final class HomeViewModel: ViewModel {
|
|||
.store(in: &cancellables)
|
||||
|
||||
ItemsAPI.getResumeItems(userId: SessionManager.main.currentLogin.user.id, limit: 12,
|
||||
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
|
||||
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters],
|
||||
mediaTypes: ["Video"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: [.primary, .backdrop, .thumb])
|
||||
|
@ -122,7 +122,7 @@ final class HomeViewModel: ViewModel {
|
|||
.store(in: &cancellables)
|
||||
|
||||
TvShowsAPI.getNextUp(userId: SessionManager.main.currentLogin.user.id, limit: 12,
|
||||
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people])
|
||||
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters])
|
||||
.trackActivity(loading)
|
||||
.sink(receiveCompletion: { completion in
|
||||
switch completion {
|
||||
|
|
|
@ -7,8 +7,10 @@
|
|||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||
*/
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import UIKit
|
||||
|
||||
class ItemViewModel: ViewModel {
|
||||
|
||||
|
@ -17,6 +19,7 @@ class ItemViewModel: ViewModel {
|
|||
@Published var similarItems: [BaseItemDto] = []
|
||||
@Published var isWatched = false
|
||||
@Published var isFavorited = false
|
||||
var itemVideoPlayerViewModel: VideoPlayerViewModel?
|
||||
|
||||
init(item: BaseItemDto) {
|
||||
self.item = item
|
||||
|
@ -32,6 +35,14 @@ class ItemViewModel: ViewModel {
|
|||
super.init()
|
||||
|
||||
getSimilarItems()
|
||||
|
||||
self.createVideoPlayerViewModel(item: item)
|
||||
.sink { completion in
|
||||
self.handleAPIRequestError(completion: completion)
|
||||
} receiveValue: { videoPlayerViewModel in
|
||||
self.itemVideoPlayerViewModel = videoPlayerViewModel
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func playButtonText() -> String {
|
||||
|
@ -100,4 +111,96 @@ class ItemViewModel: ViewModel {
|
|||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
|
||||
func createVideoPlayerViewModel(item: BaseItemDto) -> AnyPublisher<VideoPlayerViewModel, Error> {
|
||||
let builder = DeviceProfileBuilder()
|
||||
// TODO: fix bitrate settings
|
||||
builder.setMaxBitrate(bitrate: 60000000)
|
||||
let profile = builder.buildProfile()
|
||||
|
||||
let playbackInfo = PlaybackInfoDto(userId: SessionManager.main.currentLogin.user.id,
|
||||
maxStreamingBitrate: 60000000,
|
||||
startTimeTicks: item.userData?.playbackPositionTicks ?? 0,
|
||||
deviceProfile: profile,
|
||||
autoOpenLiveStream: true)
|
||||
|
||||
return MediaInfoAPI.getPostedPlaybackInfo(itemId: item.id!,
|
||||
userId: SessionManager.main.currentLogin.user.id,
|
||||
maxStreamingBitrate: 60000000,
|
||||
startTimeTicks: item.userData?.playbackPositionTicks ?? 0,
|
||||
autoOpenLiveStream: true,
|
||||
playbackInfoDto: playbackInfo)
|
||||
.map({ response -> VideoPlayerViewModel in
|
||||
let mediaSource = response.mediaSources!.first!
|
||||
|
||||
let audioStreams = mediaSource.mediaStreams?.filter({ $0.type == .audio }) ?? []
|
||||
let subtitleStreams = mediaSource.mediaStreams?.filter({ $0.type == .subtitle }) ?? []
|
||||
|
||||
let defaultAudioStream = audioStreams.first(where: { $0.index! == mediaSource.defaultAudioStreamIndex! })
|
||||
|
||||
let defaultSubtitleStream = subtitleStreams.first(where: { $0.index! == mediaSource.defaultSubtitleStreamIndex ?? -1 })
|
||||
|
||||
let videoStream = mediaSource.mediaStreams!.first(where: { $0.type! == MediaStreamType.video })
|
||||
|
||||
let audioCodecs = mediaSource.mediaStreams!.filter({ $0.type! == MediaStreamType.audio }).map({ $0.codec! })
|
||||
|
||||
// MARK: basic stream
|
||||
var streamURL = URLComponents(string: SessionManager.main.currentLogin.server.currentURI)!
|
||||
streamURL.path = "/Videos/\(item.id!)/stream"
|
||||
|
||||
streamURL.addQueryItem(name: "Static", value: "true")
|
||||
streamURL.addQueryItem(name: "MediaSourceId", value: item.id!)
|
||||
streamURL.addQueryItem(name: "Tag", value: item.etag)
|
||||
streamURL.addQueryItem(name: "MinSegments", value: "6")
|
||||
|
||||
// MARK: hls stream
|
||||
var hlsURL = URLComponents(string: SessionManager.main.currentLogin.server.currentURI)!
|
||||
hlsURL.path = "/videos/\(item.id!)/master.m3u8"
|
||||
|
||||
hlsURL.addQueryItem(name: "DeviceId", value: UIDevice.vendorUUIDString)
|
||||
hlsURL.addQueryItem(name: "MediaSourceId", value: item.id!)
|
||||
hlsURL.addQueryItem(name: "VideoCodec", value: videoStream?.codec!)
|
||||
hlsURL.addQueryItem(name: "AudioCodec", value: audioCodecs.joined(separator: ","))
|
||||
hlsURL.addQueryItem(name: "AudioStreamIndex", value: "\(defaultAudioStream!.index!)")
|
||||
hlsURL.addQueryItem(name: "VideoBitrate", value: "\(videoStream!.bitRate!)")
|
||||
hlsURL.addQueryItem(name: "AudioBitrate", value: "\(defaultAudioStream!.bitRate!)")
|
||||
hlsURL.addQueryItem(name: "PlaySessionId", value: response.playSessionId!)
|
||||
hlsURL.addQueryItem(name: "TranscodingMaxAudioChannels", value: "6")
|
||||
hlsURL.addQueryItem(name: "RequireAvc", value: "false")
|
||||
hlsURL.addQueryItem(name: "Tag", value: mediaSource.eTag!)
|
||||
hlsURL.addQueryItem(name: "SegmentContainer", value: "ts")
|
||||
hlsURL.addQueryItem(name: "MinSegments", value: "2")
|
||||
hlsURL.addQueryItem(name: "BreakOnNonKeyFrames", value: "true")
|
||||
hlsURL.addQueryItem(name: "TranscodeReasons", value: "VideoCodecNotSupported,AudioCodecNotSupported")
|
||||
hlsURL.addQueryItem(name: "api_key", value: SessionManager.main.currentLogin.user.accessToken)
|
||||
|
||||
if defaultSubtitleStream?.index != nil {
|
||||
hlsURL.addQueryItem(name: "SubtitleMethod", value: "Encode")
|
||||
hlsURL.addQueryItem(name: "SubtitleStreamIndex", value: "\(defaultSubtitleStream!.index!)")
|
||||
}
|
||||
|
||||
// startURL.queryItems?.append(URLQueryItem(name: "SubtitleCodec", value: "\(defaultSubtitleStream!.codec!)"))
|
||||
|
||||
let videoPlayerViewModel = VideoPlayerViewModel(item: item,
|
||||
title: item.name!,
|
||||
subtitle: item.seriesName,
|
||||
streamURL: streamURL.url!,
|
||||
hlsURL: hlsURL.url!,
|
||||
response: response,
|
||||
audioStreams: audioStreams,
|
||||
subtitleStreams: subtitleStreams,
|
||||
defaultAudioStreamIndex: defaultAudioStream?.index ?? -1,
|
||||
defaultSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1,
|
||||
playerState: .playing,
|
||||
shouldShowGoogleCast: false,
|
||||
shouldShowAirplay: false,
|
||||
subtitlesEnabled: defaultAudioStream?.index != nil,
|
||||
sliderPercentage: (item.userData?.playedPercentage ?? 0) / 100,
|
||||
selectedAudioStreamIndex: defaultAudioStream?.index ?? -1,
|
||||
selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1)
|
||||
|
||||
return videoPlayerViewModel
|
||||
})
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -83,7 +83,7 @@ final class LibraryViewModel: ViewModel {
|
|||
let shouldBeRecursive: Bool = filters.filters.contains(.isFavorite) || personIDs != [] || studioIDs != [] || genreIDs != []
|
||||
ItemsAPI.getItemsByUserId(userId: SessionManager.main.currentLogin.user.id, startIndex: currentPage * 100, limit: 100, recursive: shouldBeRecursive,
|
||||
searchTerm: nil, sortOrder: filters.sortOrder, parentId: parentID,
|
||||
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], includeItemTypes: filters.filters.contains(.isFavorite) ? ["Movie", "Series", "Season", "Episode"] : ["Movie", "Series"],
|
||||
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters], includeItemTypes: filters.filters.contains(.isFavorite) ? ["Movie", "Series", "Season", "Episode"] : ["Movie", "Series"],
|
||||
filters: filters.filters, sortBy: sortBy, tags: filters.tags,
|
||||
enableUserData: true, personIds: personIDs, studioIds: studioIDs, genreIds: genreIDs, enableImages: true)
|
||||
.trackActivity(loading)
|
||||
|
@ -115,7 +115,7 @@ final class LibraryViewModel: ViewModel {
|
|||
let shouldBeRecursive: Bool = filters.filters.contains(.isFavorite) || personIDs != [] || studioIDs != [] || genreIDs != []
|
||||
ItemsAPI.getItemsByUserId(userId: SessionManager.main.currentLogin.user.id, startIndex: currentPage * 100, limit: 100, recursive: shouldBeRecursive,
|
||||
searchTerm: nil, sortOrder: filters.sortOrder, parentId: parentID,
|
||||
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], includeItemTypes: filters.filters.contains(.isFavorite) ? ["Movie", "Series", "Season", "Episode"] : ["Movie", "Series"],
|
||||
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters], includeItemTypes: filters.filters.contains(.isFavorite) ? ["Movie", "Series", "Season", "Episode"] : ["Movie", "Series"],
|
||||
filters: filters.filters, sortBy: sortBy, tags: filters.tags,
|
||||
enableUserData: true, personIds: personIDs, studioIds: studioIDs, genreIds: genreIDs, enableImages: true)
|
||||
.sink(receiveCompletion: { [weak self] completion in
|
||||
|
|
|
@ -0,0 +1,218 @@
|
|||
//
|
||||
// VideoPlayerViewModel.swift
|
||||
// JellyfinVideoPlayerDev
|
||||
//
|
||||
// Created by Ethan Pippin on 11/12/21.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
#if os(tvOS)
|
||||
import TVVLCKit
|
||||
#else
|
||||
import MobileVLCKit
|
||||
#endif
|
||||
import Stinsen
|
||||
import UIKit
|
||||
|
||||
final class VideoPlayerViewModel: ObservableObject {
|
||||
|
||||
// Manually kept state because VLCKit doesn't properly set "played"
|
||||
// on the VLCMediaPlayer object
|
||||
@Published var playerState: VLCMediaPlayerState
|
||||
@Published var shouldShowGoogleCast: Bool
|
||||
@Published var shouldShowAirplay: Bool
|
||||
@Published var captionsEnabled: Bool
|
||||
@Published var leftLabelText: String = "--:--"
|
||||
@Published var rightLabelText: String = "--:--"
|
||||
@Published var screenFilled: Bool = false
|
||||
@Published var sliderPercentage: Double {
|
||||
willSet {
|
||||
sliderScrubbingSubject.send(self)
|
||||
sliderPercentageChanged(newValue: newValue)
|
||||
}
|
||||
}
|
||||
@Published var sliderIsScrubbing: Bool = false
|
||||
@Published var selectedAudioStreamIndex: Int
|
||||
@Published var selectedSubtitleStreamIndex: Int
|
||||
|
||||
let item: BaseItemDto
|
||||
let title: String
|
||||
let subtitle: String?
|
||||
let streamURL: URL
|
||||
let hlsURL: URL
|
||||
// Full response kept for convenience
|
||||
let response: PlaybackInfoResponse
|
||||
let audioStreams: [MediaStream]
|
||||
let subtitleStreams: [MediaStream]
|
||||
let defaultAudioStreamIndex: Int
|
||||
let defaultSubtitleStreamIndex: Int
|
||||
|
||||
var playerOverlayDelegate: PlayerOverlayDelegate?
|
||||
|
||||
// Ticks of the time the media has begun
|
||||
var startTimeTicks: Int64?
|
||||
|
||||
// Necessary PassthroughSubject to capture manual scrubbing from sliders
|
||||
let sliderScrubbingSubject = PassthroughSubject<VideoPlayerViewModel, Never>()
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(item: BaseItemDto,
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
streamURL: URL,
|
||||
hlsURL: URL,
|
||||
response: PlaybackInfoResponse,
|
||||
audioStreams: [MediaStream],
|
||||
subtitleStreams: [MediaStream],
|
||||
defaultAudioStreamIndex: Int,
|
||||
defaultSubtitleStreamIndex: Int,
|
||||
playerState: VLCMediaPlayerState,
|
||||
shouldShowGoogleCast: Bool,
|
||||
shouldShowAirplay: Bool,
|
||||
subtitlesEnabled: Bool,
|
||||
sliderPercentage: Double,
|
||||
selectedAudioStreamIndex: Int,
|
||||
selectedSubtitleStreamIndex: Int) {
|
||||
self.item = item
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.streamURL = streamURL
|
||||
self.hlsURL = hlsURL
|
||||
self.response = response
|
||||
self.audioStreams = audioStreams
|
||||
self.subtitleStreams = subtitleStreams
|
||||
self.defaultAudioStreamIndex = defaultAudioStreamIndex
|
||||
self.defaultSubtitleStreamIndex = defaultSubtitleStreamIndex
|
||||
self.playerState = playerState
|
||||
self.shouldShowGoogleCast = shouldShowGoogleCast
|
||||
self.shouldShowAirplay = shouldShowAirplay
|
||||
self.captionsEnabled = subtitlesEnabled
|
||||
self.sliderPercentage = sliderPercentage
|
||||
self.selectedAudioStreamIndex = selectedAudioStreamIndex
|
||||
self.selectedSubtitleStreamIndex = selectedSubtitleStreamIndex
|
||||
|
||||
self.sliderPercentageChanged(newValue: (item.userData?.playedPercentage ?? 0) / 100)
|
||||
}
|
||||
|
||||
private func sliderPercentageChanged(newValue: Double) {
|
||||
let videoDuration = Double(item.runTimeTicks! / 10_000_000)
|
||||
let secondsScrubbedTo = round(sliderPercentage * videoDuration)
|
||||
let secondsScrubbedRemaining = videoDuration - secondsScrubbedTo
|
||||
|
||||
leftLabelText = calculateTimeText(from: secondsScrubbedTo)
|
||||
rightLabelText = calculateTimeText(from: secondsScrubbedRemaining)
|
||||
}
|
||||
|
||||
private func calculateTimeText(from duration: Double) -> String {
|
||||
let hours = floor(duration / 3600)
|
||||
let minutes = duration.truncatingRemainder(dividingBy: 3600) / 60
|
||||
let seconds = duration.truncatingRemainder(dividingBy: 3600).truncatingRemainder(dividingBy: 60)
|
||||
|
||||
let timeText: String
|
||||
|
||||
if hours != 0 {
|
||||
timeText =
|
||||
"\(Int(hours)):\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))"
|
||||
} else {
|
||||
timeText =
|
||||
"\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))"
|
||||
}
|
||||
|
||||
return timeText
|
||||
}
|
||||
|
||||
func sendPlayReport(startTimeTicks: Int64) {
|
||||
|
||||
self.startTimeTicks = startTimeTicks
|
||||
|
||||
let startInfo = PlaybackStartInfo(canSeek: true,
|
||||
item: item,
|
||||
itemId: item.id,
|
||||
sessionId: response.playSessionId,
|
||||
mediaSourceId: item.id,
|
||||
audioStreamIndex: audioStreams.first(where: { $0.index! == response.mediaSources?.first?.defaultAudioStreamIndex! })?.index,
|
||||
subtitleStreamIndex: subtitleStreams.first(where: { $0.index! == response.mediaSources?.first?.defaultSubtitleStreamIndex ?? -1 })?.index,
|
||||
isPaused: false,
|
||||
isMuted: false,
|
||||
positionTicks: item.userData?.playbackPositionTicks,
|
||||
playbackStartTimeTicks: startTimeTicks,
|
||||
volumeLevel: 100,
|
||||
brightness: 100,
|
||||
aspectRatio: nil,
|
||||
playMethod: .directPlay,
|
||||
liveStreamId: nil,
|
||||
playSessionId: response.playSessionId,
|
||||
repeatMode: .repeatNone,
|
||||
nowPlayingQueue: nil,
|
||||
playlistItemId: "playlistItem0"
|
||||
)
|
||||
|
||||
PlaystateAPI.reportPlaybackStart(playbackStartInfo: startInfo)
|
||||
.sink { completion in
|
||||
print(completion)
|
||||
} receiveValue: { _ in
|
||||
print("Playback start report sent!")
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func sendProgressReport(ticks: Int64) {
|
||||
|
||||
print("Progress ticks: \(ticks)")
|
||||
|
||||
let progressInfo = PlaybackProgressInfo(canSeek: true,
|
||||
item: item,
|
||||
itemId: item.id,
|
||||
sessionId: response.playSessionId,
|
||||
mediaSourceId: item.id,
|
||||
audioStreamIndex: audioStreams.first(where: { $0.index! == response.mediaSources?.first?.defaultAudioStreamIndex! })?.index,
|
||||
subtitleStreamIndex: subtitleStreams.first(where: { $0.index! == response.mediaSources?.first?.defaultSubtitleStreamIndex ?? -1 })?.index,
|
||||
isPaused: false,
|
||||
isMuted: false,
|
||||
positionTicks: ticks,
|
||||
playbackStartTimeTicks: startTimeTicks,
|
||||
volumeLevel: nil,
|
||||
brightness: nil,
|
||||
aspectRatio: nil,
|
||||
playMethod: .directPlay,
|
||||
liveStreamId: nil,
|
||||
playSessionId: response.playSessionId,
|
||||
repeatMode: .repeatNone,
|
||||
nowPlayingQueue: nil,
|
||||
playlistItemId: "playlistItem0")
|
||||
|
||||
PlaystateAPI.reportPlaybackProgress(playbackProgressInfo: progressInfo)
|
||||
.sink { completion in
|
||||
print(completion)
|
||||
} receiveValue: { _ in
|
||||
print("Playback progress sent!")
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func sendStopReport(ticks: Int64) {
|
||||
|
||||
let stopInfo = PlaybackStopInfo(item: item,
|
||||
itemId: item.id,
|
||||
sessionId: response.playSessionId,
|
||||
mediaSourceId: item.id,
|
||||
positionTicks: ticks,
|
||||
liveStreamId: nil,
|
||||
playSessionId: response.playSessionId,
|
||||
failed: nil,
|
||||
nextMediaType: nil,
|
||||
playlistItemId: "playlistItem0",
|
||||
nowPlayingQueue: nil)
|
||||
|
||||
PlaystateAPI.reportPlaybackStopped(playbackStopInfo: stopInfo)
|
||||
.sink { completion in
|
||||
print(completion)
|
||||
} receiveValue: { _ in
|
||||
print("Playback stop report sent!")
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue