tvOS player
This commit is contained in:
parent
a12429ba46
commit
5fe8c3b7cc
|
@ -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())
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
// }
|
||||
//
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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) {
|
||||
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue