tvOS player

This commit is contained in:
Stephen Byatt 2021-06-22 09:21:53 +10:00
parent a12429ba46
commit 5fe8c3b7cc
11 changed files with 1372 additions and 3 deletions

View File

@ -25,7 +25,7 @@ struct ContinueWatchingView: View {
LazyHStack {
Spacer().frame(width: 45)
ForEach(items, id: \.id) { item in
NavigationLink(destination: Text("itemv")) {
NavigationLink(destination: VideoPlayerView(item: item)) {
LandscapeItemElement(item: item)
}
.buttonStyle(PlainNavigationLinkButtonStyle())

View File

@ -24,7 +24,7 @@ struct NextUpView: View {
LazyHStack {
Spacer().frame(width: 45)
ForEach(items, id: \.id) { item in
NavigationLink(destination: Text("itemv")) {
NavigationLink(destination: VideoPlayerView(item: item)) {
LandscapeItemElement(item: item)
}.buttonStyle(PlainNavigationLinkButtonStyle())
}

View File

@ -0,0 +1,78 @@
//
/*
* 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: UIViewController {
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
}
/*
// MARK: - Navigation
// In a storyboard-based application, you will often want to do a little preparation before navigation
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
// Get the new view controller using segue.destination.
// Pass the selected object to the new view controller.
}
*/
}
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)
}
}
}

View File

@ -0,0 +1,54 @@
//
// InfoTabBarViewController.swift
// CustomPlayer
//
// Created by Stephen Byatt on 15/6/21.
//
import TVUIKit
import JellyfinAPI
class InfoTabBarViewController: UITabBarController, UIGestureRecognizerDelegate {
var videoPlayer : VideoPlayerViewController? = nil
var subtitleViewController : SubtitlesViewController? = nil
var audioViewController : AudioViewController? = nil
var mediaInfoController : MediaInfoViewController? = nil
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)
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
// MARK: - Navigation
// // In a storyboard-based application, you will often want to do a little preparation before navigation
// override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
// // Get the new view controller using segue.destination.
// // Pass the selected object to the new view controller.
// }
//
}

View File

@ -0,0 +1,117 @@
//
/*
* 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: UIViewController {
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
}
}
struct MediaInfoView: View {
@State var item : BaseItemDto? = nil
var body: some View {
if let item = item {
HStack {
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)
Spacer()
}
.padding(.leading, 200)
VStack(alignment: .leading) {
if item.type == "Episode" {
Text(item.seriesName!)
.font(.title3)
Text("S\(item.parentIndexNumber ?? 0):E\(item.indexNumber ?? 0)\(item.name!)")
.font(.headline)
.foregroundColor(.secondary)
}
else
{
Text(item.name!)
.font(.title3)
}
HStack(spacing: 10) {
Text(String(item.productionYear!))
Text("")
Text(formatRunningtime())
if item.officialRating != nil {
Text("")
Text("\(item.officialRating!)").font(.subheadline)
.fontWeight(.semibold)
.padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4))
.overlay(RoundedRectangle(cornerRadius: 2)
.stroke(Color.secondary, lineWidth: 1))
}
}
.padding(.top)
.foregroundColor(.secondary)
Text(item.overview!)
.padding([.top, .trailing])
.foregroundColor(.secondary)
Spacer()
}
Spacer()
}
.frame(maxWidth: .infinity)
}
else {
EmptyView()
}
}
func formatRunningtime() -> String {
let timeHMSFormatter: DateComponentsFormatter = {
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .brief
formatter.allowedUnits = [.hour, .minute]
return formatter
}()
let text = timeHMSFormatter.string(from: Double(item!.runTimeTicks! / 10_000_000)) ?? ""
return text
}
}

View File

@ -0,0 +1,150 @@
//
/*
* 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: UIViewController {
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
}
//
//
// func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
// return subtitleTrackArray.count
// }
//
// func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
// let cell = UITableViewCell()
// let subtitle = subtitleTrackArray[indexPath.row]
// cell.textLabel?.text = subtitle.name
//
// let image = UIImage(systemName: "checkmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: (27), weight: .bold))?.withRenderingMode(.alwaysOriginal).withTintColor(.white)
// cell.imageView?.image = image
//
// if selectedTrack != subtitle.id {
// cell.imageView?.isHidden = true
// }
// else {
// selectedTrackCellRow = indexPath.row
// }
//
// return cell
// }
//
// func tableView(_ tableView: UITableView, didUpdateFocusIn context: UITableViewFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) {
//
// if let path = context.nextFocusedIndexPath {
// if path.row == selectedTrackCellRow {
// let cell : UITableViewCell = tableView.cellForRow(at: path)!
// cell.imageView?.image = cell.imageView?.image?.withTintColor(.black)
// }
// }
//
// if let path = context.previouslyFocusedIndexPath {
// if path.row == selectedTrackCellRow {
// let cell : UITableViewCell = tableView.cellForRow(at: path)!
// cell.imageView?.image = cell.imageView?.image?.withTintColor(.white)
// }
// }
//
// }
//
//
// func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
// let oldPath = IndexPath(row: selectedTrackCellRow, section: 0)
// if let oldCell : UITableViewCell = tableView.cellForRow(at: oldPath) {
// oldCell.imageView?.isHidden = true
// }
//
// let cell : UITableViewCell = tableView.cellForRow(at: indexPath)!
// cell.imageView?.isHidden = false
// cell.imageView?.image = cell.imageView?.image?.withTintColor(.black)
//
// selectedTrack = Int32(subtitleTrackArray[indexPath.row].id)
// selectedTrackCellRow = indexPath.row
//// infoTabBar?.videoPlayer?.subtitleTrackChanged(newTrackID: selectedTrack)
// print("setting new subtitle")
// tableView.deselectRow(at: indexPath, animated: false)
//
// }
//
// func numberOfSections(in tableView: UITableView) -> Int {
// return 1
// }
//
//
/*
// MARK: - Navigation
// In a storyboard-based application, you will often want to do a little preparation before navigation
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
// Get the new view controller using segue.destination.
// Pass the selected object to the new view controller.
}
*/
}
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)
}
}
}

View File

@ -0,0 +1,26 @@
//
// VideoPlayer.swift
// CustomPlayer
//
// Created by Stephen Byatt on 25/5/21.
//
import SwiftUI
import JellyfinAPI
struct VideoPlayerView: UIViewControllerRepresentable {
var item: BaseItemDto
func makeUIViewController(context: Context) -> some UIViewController {
let storyboard = UIStoryboard(name: "VideoPlayerStoryboard", bundle: nil)
let viewController = storyboard.instantiateViewController(withIdentifier: "VideoPlayer") as! VideoPlayerViewController
viewController.manifest = item
return viewController
}
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
}
}

View File

@ -0,0 +1,121 @@
<?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"/>
</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"/>
</view>
<containerView opaque="NO" contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="lie-K8-LNT">
<rect key="frame" x="152" y="68" width="1615" height="635"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMinY="YES"/>
<color key="backgroundColor" red="0.10195661340000001" green="0.1019650772" blue="0.1060298011" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<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="infoViewContainer" 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>

View File

@ -0,0 +1,785 @@
//
// VideoPlayerViewController.swift
// CustomPlayer
//
// Created by Stephen Byatt on 15/6/21.
//
import TVUIKit
import TVVLCKit
import MediaPlayer
import JellyfinAPI
import Combine
struct Subtitle {
var name: String
var id: Int32
var url: URL?
var delivery: SubtitleDeliveryMethod
var codec: String
}
struct AudioTrack {
var name: String
var id: Int32
}
class PlaybackItem: ObservableObject {
@Published var videoType: PlayMethod = .directPlay
@Published var videoUrl: URL = URL(string: "https://example.com")!
}
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 infoViewContainer: UIView!
var infoPanelDisplayPoint : CGPoint = .zero
var infoPanelHiddenPoint : CGPoint = .zero
var containerViewController: 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 {
didSet {
print(selectedAudioTrack)
}
}
var selectedCaptionTrack: Int32 = -1 {
didSet {
print(selectedCaptionTrack)
}
}
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 context.nextFocusedView!.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)
infoPanelDisplayPoint = infoViewContainer.center
infoPanelHiddenPoint = CGPoint(x: infoPanelDisplayPoint.x, y: -infoViewContainer.frame.height)
infoViewContainer.center = infoPanelHiddenPoint
infoViewContainer.layer.cornerRadius = 40
transportBarView.layer.cornerRadius = CGFloat(5)
setupGestures()
fetchVideo()
setupNowPlayingCC()
mediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 16)
}
func fetchVideo() {
// Fetch max bitrate from UserDefaults depending on current connection mode
let defaults = UserDefaults.standard
let maxBitrate = defaults.integer(forKey: "InNetworkBandwidth")
// Build a device profile
let builder = DeviceProfileBuilder()
builder.setMaxBitrate(bitrate: maxBitrate)
let profile = builder.buildProfile()
let playbackInfo = PlaybackInfoDto(userId: SessionManager.current.user.user_id!, maxStreamingBitrate: Int(maxBitrate), startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, deviceProfile: profile, autoOpenLiveStream: true)
DispatchQueue.global(qos: .userInitiated).async { [self] in
// delegate?.showLoadingView(self)
MediaInfoAPI.getPostedPlaybackInfo(itemId: manifest.id!, userId: SessionManager.current.user.user_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 ?? ""
let mediaSource = response.mediaSources!.first.self!
let item = PlaybackItem()
let streamURL : URL?
// Item is being transcoded by request of server
if mediaSource.transcodingUrl != nil
{
item.videoType = .transcode
streamURL = URL(string: "\(ServerEnvironment.current.server.baseURI!)\(mediaSource.transcodingUrl!)")
}
// Item will be directly played by the client
else
{
item.videoType = .directPlay
streamURL = URL(string: "\(ServerEnvironment.current.server.baseURI!)/Videos/\(manifest.id!)/stream?Static=true&mediaSourceId=\(manifest.id!)&deviceId=\(SessionManager.current.deviceID)&api_key=\(SessionManager.current.accessToken)&Tag=\(mediaSource.eTag!)")!
}
item.videoUrl = streamURL!
let disableSubtitleTrack = Subtitle(name: "None", id: -1, url: nil, delivery: .embed, codec: "")
subtitleTrackArray.append(disableSubtitleTrack)
// Loop through media streams and add to array
for stream in mediaSource.mediaStreams! {
if stream.type == .subtitle {
var deliveryUrl: URL? = nil
if stream.deliveryMethod == .external {
deliveryUrl = URL(string: "\(ServerEnvironment.current.server.baseURI!)\(stream.deliveryUrl!)")!
}
let subtitle = Subtitle(name: stream.displayTitle ?? "Unknown", id: Int32(stream.index!), url: deliveryUrl, delivery: stream.deliveryMethod!, codec: stream.codec ?? "webvtt")
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!, 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))
}
// Pause and load captions into memory.
mediaPlayer.pause()
var shouldHaveSubtitleTracks = 0
subtitleTrackArray.forEach { sub in
if sub.id != -1 && sub.delivery == .external && sub.codec != "subrip" {
shouldHaveSubtitleTracks = shouldHaveSubtitleTracks + 1
mediaPlayer.addPlaybackSlave(sub.url!, type: .subtitle, enforce: false)
}
}
// Wait for captions to load
while mediaPlayer.numberOfSubtitlesTracks != shouldHaveSubtitleTracks {}
// Select default track & resume playback
mediaPlayer.currentVideoSubTitleIndex = selectedCaptionTrack
mediaPlayer.pause()
mediaPlayer.play()
playing = true
setupInfoPanel()
activityIndicator.isHidden = true
loading = false
})
.store(in: &cancellables)
}
}
func setupNowPlayingCC() {
let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.playCommand.isEnabled = true
commandCenter.pauseCommand.isEnabled = true
commandCenter.seekForwardCommand.isEnabled = true
commandCenter.seekBackwardCommand.isEnabled = true
commandCenter.changePlaybackPositionCommand.isEnabled = true
// Add handler for Pause Command
commandCenter.pauseCommand.addTarget { _ in
self.pause()
return .success
}
// Add handler for Play command
commandCenter.playCommand.addTarget { _ in
self.play()
return .success
}
// Add handler for FF command
commandCenter.seekForwardCommand.addTarget { _ in
self.mediaPlayer.jumpForward(30)
self.sendProgressReport(eventName: "timeupdate")
return .success
}
// Add handler for RW command
commandCenter.seekBackwardCommand.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)
let offset = targetSeconds - videoPosition
if offset > 0 {
self.mediaPlayer.jumpForward(Int32(offset)/1000)
} else {
self.mediaPlayer.jumpBackward(Int32(abs(offset))/1000)
}
self.sendProgressReport(eventName: "unpause")
return .success
} else {
return .commandFailed
}
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = [
MPMediaItemPropertyTitle: "TestVideo",
MPNowPlayingInfoPropertyPlaybackRate : 1.0,
MPNowPlayingInfoPropertyMediaType : AVMediaType.video,
MPMediaItemPropertyPlaybackDuration : manifest.runTimeTicks ?? 0 / 10_000_000,
MPNowPlayingInfoPropertyElapsedPlaybackTime : mediaPlayer.time.intValue/1000
]
UIApplication.shared.beginReceivingRemoteControlEvents()
}
// Grabs a refference to the info panel view controller
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if segue.identifier == "infoView" {
containerViewController = segue.destination as? InfoTabBarViewController
containerViewController?.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")
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.sendProgressReport(eventName: "unpause")
animateScrubber()
}
func toggleInfoContainer() {
showingInfoPanel.toggle()
containerViewController?.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)
})
seeking = false
}
UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut) { [self] in
infoViewContainer.center = showingInfoPanel ? infoPanelDisplayPoint : infoPanelHiddenPoint
}
}
// MARK: Gestures
func setupGestures() {
let playPauseGesture = UITapGestureRecognizer(target: self, action: #selector(self.selectButtonTapped))
let playPauseType = UIPress.PressType.playPause
playPauseGesture.allowedPressTypes = [NSNumber(value: playPauseType.rawValue)];
view.addGestureRecognizer(playPauseGesture)
let selectGesture = UITapGestureRecognizer(target: self, action: #selector(self.selectButtonTapped))
let selectType = UIPress.PressType.select
selectGesture.allowedPressTypes = [NSNumber(value: selectType.rawValue)];
view.addGestureRecognizer(selectGesture)
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)
let swipeRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(self.swipe(swipe:)))
swipeRecognizer.direction = .right
view.addGestureRecognizer(swipeRecognizer)
let swipeRecognizerl = UISwipeGestureRecognizer(target: self, action: #selector(self.swipe(swipe:)))
swipeRecognizerl.direction = .left
view.addGestureRecognizer(swipeRecognizerl)
}
@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
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 < -700 && (focusedOnTabBar && showingInfoPanel) {
toggleInfoContainer()
return
}
if showingInfoPanel {
return
}
// Swiped down - Show the info panel
if translation.y > 700 {
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))
})
}
// Not currently used
@objc func swipe(swipe: UISwipeGestureRecognizer!) {
print("swiped")
switch swipe.direction {
case .left:
print("swiped left")
mediaPlayer.pause()
// player.seek(to: CMTime(value: Int64(self.currentSeconds) + 10, timescale: 1))
mediaPlayer.play()
case .right:
print("swiped right")
mediaPlayer.pause()
// player.seek(to: CMTime(value: Int64(self.currentSeconds) + 10, timescale: 1))
mediaPlayer.play()
case .up:
break
case .down:
break
default:
break
}
}
/// Play/Pause or Select is pressed on the AppleTV remote
@objc func selectButtonTapped() {
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) {
if (eventName == "timeupdate" && mediaPlayer.state == .playing) || eventName != "timeupdate" {
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: Int64(mediaPlayer.position * Float(manifest.runTimeTicks!)), 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()
}
}
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() {
containerViewController?.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
}
// When VLC video starts playing a real device can no longer receive gesture recognisers, adding this in hopes to fix the issue but no luck
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
print("recognisesimultaneousvideoplayer")
return true
}
}
extension Comparable {
func clamped(to limits: ClosedRange<Self>) -> Self {
return min(max(self, limits.lowerBound), limits.upperBound)
}
}

View File

@ -7,6 +7,14 @@
objects = {
/* Begin PBXBuildFile section */
09389CBE26814DF600AE350E /* VideoPlayerStoryboard.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 09389CB726814DF500AE350E /* VideoPlayerStoryboard.storyboard */; };
09389CBF26814DF600AE350E /* MediaInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09389CB826814DF600AE350E /* MediaInfoView.swift */; };
09389CC026814DF600AE350E /* SubtitlesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09389CB926814DF600AE350E /* SubtitlesView.swift */; };
09389CC126814DF600AE350E /* InfoTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09389CBA26814DF600AE350E /* InfoTabBarViewController.swift */; };
09389CC226814DF600AE350E /* VideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09389CBB26814DF600AE350E /* VideoPlayer.swift */; };
09389CC326814DF600AE350E /* VideoPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09389CBC26814DF600AE350E /* VideoPlayerViewController.swift */; };
09389CC426814DF600AE350E /* AudioView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09389CBD26814DF600AE350E /* AudioView.swift */; };
09389CC526814E4500AE350E /* DeviceProfileBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */; };
531690E5267ABD5C005D8AB9 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690E4267ABD5C005D8AB9 /* MainTabView.swift */; };
531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690E6267ABD79005D8AB9 /* HomeView.swift */; };
531690ED267ABF46005D8AB9 /* ContinueWatchingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690EB267ABF46005D8AB9 /* ContinueWatchingView.swift */; };
@ -181,6 +189,13 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
09389CB726814DF500AE350E /* VideoPlayerStoryboard.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = VideoPlayerStoryboard.storyboard; sourceTree = "<group>"; };
09389CB826814DF600AE350E /* MediaInfoView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaInfoView.swift; sourceTree = "<group>"; };
09389CB926814DF600AE350E /* SubtitlesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubtitlesView.swift; sourceTree = "<group>"; };
09389CBA26814DF600AE350E /* InfoTabBarViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InfoTabBarViewController.swift; sourceTree = "<group>"; };
09389CBB26814DF600AE350E /* VideoPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = "<group>"; };
09389CBC26814DF600AE350E /* VideoPlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewController.swift; sourceTree = "<group>"; };
09389CBD26814DF600AE350E /* AudioView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AudioView.swift; sourceTree = "<group>"; };
3773C07648173CE7FEC083D5 /* Pods-JellyfinPlayer iOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JellyfinPlayer iOS.debug.xcconfig"; path = "Target Support Files/Pods-JellyfinPlayer iOS/Pods-JellyfinPlayer iOS.debug.xcconfig"; sourceTree = "<group>"; };
3F905C1D3D3A0C9E13E7A0BC /* Pods_JellyfinPlayer_iOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_JellyfinPlayer_iOS.framework; sourceTree = BUILT_PRODUCTS_DIR; };
531690E4267ABD5C005D8AB9 /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
@ -345,6 +360,20 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
09389CB626814DD700AE350E /* VideoPlayer */ = {
isa = PBXGroup;
children = (
09389CBD26814DF600AE350E /* AudioView.swift */,
09389CBA26814DF600AE350E /* InfoTabBarViewController.swift */,
09389CB826814DF600AE350E /* MediaInfoView.swift */,
09389CB926814DF600AE350E /* SubtitlesView.swift */,
09389CBB26814DF600AE350E /* VideoPlayer.swift */,
09389CB726814DF500AE350E /* VideoPlayerStoryboard.storyboard */,
09389CBC26814DF600AE350E /* VideoPlayerViewController.swift */,
);
path = VideoPlayer;
sourceTree = "<group>";
};
532175392671BCED005491E6 /* ViewModels */ = {
isa = PBXGroup;
children = (
@ -371,6 +400,7 @@
535870612669D21600D05A09 /* JellyfinPlayer tvOS */ = {
isa = PBXGroup;
children = (
09389CB626814DD700AE350E /* VideoPlayer */,
536D3D77267BB9650004248C /* Components */,
53ABFDDA267972BF00886593 /* JellyfinPlayer tvOS.entitlements */,
531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */,
@ -459,7 +489,6 @@
5377CBF8263B596B003A4E83 /* Assets.xcassets */,
5338F74D263B61370014BF09 /* ConnectToServerView.swift */,
5389276D263C25100035E14B /* ContinueWatchingView.swift */,
53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */,
53987CA72657424A00E7EA70 /* EpisodeItemView.swift */,
5377CC02263B596B003A4E83 /* Info.plist */,
535BAE9E2649E569005FA86D /* ItemView.swift */,
@ -560,6 +589,7 @@
62EC352A26766657000E9F2D /* Singleton */ = {
isa = PBXGroup;
children = (
53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */,
62EC352B26766675000E9F2D /* ServerEnvironment.swift */,
62EC352E267666A5000E9F2D /* SessionManager.swift */,
536D3D73267BA8170004248C /* BackgroundManager.swift */,
@ -728,6 +758,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
09389CBE26814DF600AE350E /* VideoPlayerStoryboard.storyboard in Resources */,
5358706A2669D21700D05A09 /* Preview Assets.xcassets in Resources */,
535870672669D21700D05A09 /* Assets.xcassets in Resources */,
5358707E2669D64F00D05A09 /* bitrates.json in Resources */,
@ -862,6 +893,7 @@
53ABFDE9267974EF00886593 /* HomeViewModel.swift in Sources */,
62EC352D26766675000E9F2D /* ServerEnvironment.swift in Sources */,
531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */,
09389CC126814DF600AE350E /* InfoTabBarViewController.swift in Sources */,
53ABFDDE267974E300886593 /* SplashView.swift in Sources */,
53ABFDE8267974EF00886593 /* SplashViewModel.swift in Sources */,
62E632DE267D2E170063E547 /* LatestMediaViewModel.swift in Sources */,
@ -879,6 +911,7 @@
62E632F4267D54030063E547 /* DetailItemViewModel.swift in Sources */,
6267B3D826710B9800A7371D /* CollectionExtensions.swift in Sources */,
62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */,
09389CBF26814DF600AE350E /* MediaInfoView.swift in Sources */,
535870A52669D8AE00D05A09 /* ParallaxHeader.swift in Sources */,
531690F0267ABF72005D8AB9 /* NextUpView.swift in Sources */,
535870A72669D8AE00D05A09 /* MultiSelectorView.swift in Sources */,
@ -891,16 +924,21 @@
5358706C2669D21700D05A09 /* PersistenceController.swift in Sources */,
535870AA2669D8AE00D05A09 /* BlurHashDecode.swift in Sources */,
53ABFDE5267974EF00886593 /* ViewModel.swift in Sources */,
09389CC526814E4500AE350E /* DeviceProfileBuilder.swift in Sources */,
535870A62669D8AE00D05A09 /* LazyView.swift in Sources */,
5321753E2671DE9C005491E6 /* Typings.swift in Sources */,
53ABFDEB2679753200886593 /* ConnectToServerView.swift in Sources */,
09389CC026814DF600AE350E /* SubtitlesView.swift in Sources */,
536D3D76267BA9BB0004248C /* MainTabViewModel.swift in Sources */,
536D3D74267BA8170004248C /* BackgroundManager.swift in Sources */,
09389CC226814DF600AE350E /* VideoPlayer.swift in Sources */,
535870632669D21600D05A09 /* JellyfinPlayer_tvOSApp.swift in Sources */,
53ABFDE4267974EF00886593 /* LibraryListViewModel.swift in Sources */,
5364F456266CA0DC0026ECBA /* APIExtensions.swift in Sources */,
09389CC326814DF600AE350E /* VideoPlayerViewController.swift in Sources */,
531690FA267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift in Sources */,
535870A32669D89F00D05A09 /* Model.xcdatamodeld in Sources */,
09389CC426814DF600AE350E /* AudioView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};