Initial implementation over

This commit is contained in:
Ethan Pippin 2021-12-28 07:21:44 -07:00
parent 25c2bcc3c8
commit 59465a3c4a
42 changed files with 1960 additions and 3057 deletions

View File

@ -11,15 +11,22 @@ import SwiftUI
struct MediaPlayButtonRowView: View {
@EnvironmentObject var itemRouter: ItemCoordinator.Router
@ObservedObject var viewModel: ItemViewModel
@State var wrappedScrollView: UIScrollView?
var body: some View {
HStack {
VStack {
NavigationLink(destination: VideoPlayerView(item: viewModel.item).ignoresSafeArea()) {
// NavigationLink(destination: VideoPlayerView(item: viewModel.item).ignoresSafeArea()) {
// MediaViewActionButton(icon: "play.fill", scrollView: $wrappedScrollView)
// }
Button {
itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!)
} label: {
MediaViewActionButton(icon: "play.fill", scrollView: $wrappedScrollView)
}
Text(viewModel.item.getItemProgressString() != "" ? "\(viewModel.item.getItemProgressString()) left" : L10n.play)
.font(.caption)
}

View File

@ -11,6 +11,8 @@ import SwiftUI
import JellyfinAPI
struct EpisodeItemView: View {
@EnvironmentObject var itemRouter: ItemCoordinator.Router
@ObservedObject var viewModel: EpisodeItemViewModel
@State var actors: [BaseItemPerson] = []
@ -130,7 +132,13 @@ struct EpisodeItemView: View {
.font(.caption)
}
VStack {
NavigationLink(destination: VideoPlayerView(item: viewModel.item).ignoresSafeArea()) {
// NavigationLink(destination: VideoPlayerView(item: viewModel.item).ignoresSafeArea()) {
// MediaViewActionButton(icon: "play.fill")
// }
Button {
itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!)
} label: {
// MediaViewActionButton(icon: "play.fill", scrollView: $wrappedScrollView)
MediaViewActionButton(icon: "play.fill")
}
Text(viewModel.item.getItemProgressString() != "" ? "\(viewModel.item.getItemProgressString()) left" : L10n.play)

View File

@ -27,7 +27,7 @@ struct LatestMediaView: View {
viewDidLoad = true
DispatchQueue.global(qos: .userInitiated).async {
UserLibraryAPI.getLatestMedia(userId: SessionManager.main.currentLogin.user.id, parentId: library_id, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], enableUserData: true, limit: 12)
UserLibraryAPI.getLatestMedia(userId: SessionManager.main.currentLogin.user.id, parentId: library_id, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters], enableUserData: true, limit: 12)
.sink(receiveCompletion: { completion in
print(completion)
}, receiveValue: { response in

View File

@ -1,66 +0,0 @@
//
/*
* SwiftFin is subject to the terms of the Mozilla Public
* License, v2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import SwiftUI
class AudioViewController: InfoTabViewController {
override func viewDidLoad() {
super.viewDidLoad()
tabBarItem.title = "Audio"
}
func prepareAudioView(audioTracks: [AudioTrack], selectedTrack: Int32, delegate: VideoPlayerSettingsDelegate) {
let contentView = UIHostingController(rootView: AudioView(selectedTrack: selectedTrack, audioTrackArray: audioTracks, delegate: delegate))
self.view.addSubview(contentView.view)
contentView.view.translatesAutoresizingMaskIntoConstraints = false
contentView.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
contentView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
contentView.view.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
contentView.view.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true
}
}
struct AudioView: View {
@State var selectedTrack: Int32 = -1
@State var audioTrackArray: [AudioTrack] = []
weak var delegate: VideoPlayerSettingsDelegate?
var body : some View {
NavigationView {
VStack {
List(audioTrackArray, id: \.id) { track in
Button(action: {
delegate?.selectNew(audioTrack: track.id)
selectedTrack = track.id
}, label: {
HStack(spacing: 10) {
if track.id == selectedTrack {
Image(systemName: "checkmark")
} else {
Image(systemName: "checkmark")
.hidden()
}
Text(track.name)
}
})
}
}
.frame(width: 400)
.frame(maxHeight: 400)
}
}
}

View File

@ -1,59 +0,0 @@
//
/*
* SwiftFin is subject to the terms of the Mozilla Public
* License, v2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import TVUIKit
import JellyfinAPI
class InfoTabViewController: UIViewController {
var height: CGFloat = 420
}
class InfoTabBarViewController: UITabBarController, UIGestureRecognizerDelegate {
var videoPlayer: VideoPlayerViewController?
var subtitleViewController: SubtitlesViewController?
var audioViewController: AudioViewController?
var mediaInfoController: MediaInfoViewController?
override func viewDidLoad() {
super.viewDidLoad()
mediaInfoController = MediaInfoViewController()
audioViewController = AudioViewController()
subtitleViewController = SubtitlesViewController()
viewControllers = [mediaInfoController!, audioViewController!, subtitleViewController!]
}
func setupInfoViews(mediaItem: BaseItemDto, subtitleTracks: [Subtitle], selectedSubtitleTrack: Int32, audioTracks: [AudioTrack], selectedAudioTrack: Int32, delegate: VideoPlayerSettingsDelegate) {
mediaInfoController?.setMedia(item: mediaItem)
audioViewController?.prepareAudioView(audioTracks: audioTracks, selectedTrack: selectedAudioTrack, delegate: delegate)
subtitleViewController?.prepareSubtitleView(subtitleTracks: subtitleTracks, selectedTrack: selectedSubtitleTrack, delegate: delegate)
}
override func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
if let index = tabBar.items?.firstIndex(of: item),
let tabViewController = viewControllers?[index] as? InfoTabViewController,
let width = videoPlayer?.infoPanelContainerView.frame.width {
let height = tabViewController.height + tabBar.frame.size.height
UIView.animate(withDuration: 0.6, delay: 0, options: .curveEaseOut) { [self] in
videoPlayer?.infoPanelContainerView.frame = CGRect(x: 88, y: 87, width: width, height: height)
}
}
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
}

View File

@ -1,121 +0,0 @@
//
/*
* SwiftFin is subject to the terms of the Mozilla Public
* License, v2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import SwiftUI
import JellyfinAPI
class MediaInfoViewController: InfoTabViewController {
private var contentView: UIHostingController<MediaInfoView>!
override func viewDidLoad() {
super.viewDidLoad()
tabBarItem.title = "Info"
}
func setMedia(item: BaseItemDto) {
contentView = UIHostingController(rootView: MediaInfoView(item: item))
self.view.addSubview(contentView.view)
contentView.view.translatesAutoresizingMaskIntoConstraints = false
contentView.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
contentView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
contentView.view.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
contentView.view.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true
height = self.view.frame.height
}
}
struct MediaInfoView: View {
@State var item: BaseItemDto?
var body: some View {
if let item = item {
HStack(spacing: 30) {
VStack {
ImageView(src: item.type == "Episode" ? item.getSeriesPrimaryImage(maxWidth: 200) : item.getPrimaryImage(maxWidth: 200), bh: item.type == "Episode" ? item.getSeriesPrimaryImageBlurHash() : item.getPrimaryImageBlurHash())
.frame(width: 200, height: 300)
.cornerRadius(10)
.ignoresSafeArea()
Spacer()
}
VStack(alignment: .leading, spacing: 10) {
if item.type == "Episode" {
Text(item.seriesName ?? "Series")
.fontWeight(.bold)
HStack {
Text(item.name ?? "Episode")
.foregroundColor(.secondary)
Text(item.getEpisodeLocator() ?? "")
if let date = item.premiereDate {
Text(formatDate(date: date))
}
}
} else {
Text(item.name ?? "Movie")
.fontWeight(.bold)
}
HStack(spacing: 10) {
if item.type != "Episode" {
if let year = item.productionYear {
Text(String(year))
}
}
if item.runTimeTicks != nil {
Text("")
Text(item.getItemRuntime())
}
if let rating = item.officialRating {
Text("")
Text("\(rating)").font(.subheadline)
.fontWeight(.semibold)
.padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4))
.overlay(RoundedRectangle(cornerRadius: 2)
.stroke(Color.secondary, lineWidth: 1))
}
}
.foregroundColor(.secondary)
if let overview = item.overview {
Text(overview)
.padding(.top)
.foregroundColor(.secondary)
}
Spacer()
}
Spacer()
}
.padding(.leading, 350)
.padding(.trailing, 125)
} else {
EmptyView()
}
}
func formatDate(date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "MMM d, yyyy"
return formatter.string(from: date)
}
}

View File

@ -0,0 +1,159 @@
//
// NativePlayerViewController.swift
// JellyfinVideoPlayerDev
//
// Created by Ethan Pippin on 11/20/21.
//
import AVKit
import Combine
import JellyfinAPI
import UIKit
class NativePlayerViewController: AVPlayerViewController {
let viewModel: VideoPlayerViewModel
var timeObserverToken: Any?
var lastProgressTicks: Int64 = 0
private var cancellables = Set<AnyCancellable>()
init(viewModel: VideoPlayerViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
let player = AVPlayer(url: viewModel.hlsURL)
player.appliesMediaSelectionCriteriaAutomatically = false
player.currentItem?.externalMetadata = createMetadata()
player.currentItem?.navigationMarkerGroups = createNavigationMarkerGroups()
// let chevron = UIImage(systemName: "chevron.right.circle.fill")!
// let testAction = UIAction(title: "Next", image: chevron) { action in
// SessionAPI.sendSystemCommand(sessionId: viewModel.response.playSessionId!, command: .setSubtitleStreamIndex)
// .sink { completion in
// print(completion)
// } receiveValue: { _ in
// print("idk but we're here")
// }
// .store(in: &self.cancellables)
// }
// self.transportBarCustomMenuItems = [testAction]
// self.infoViewActions.append(testAction)
let timeScale = CMTimeScale(NSEC_PER_SEC)
let time = CMTime(seconds: 5, preferredTimescale: timeScale)
timeObserverToken = player.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] time in
// print("Timer timed: \(time)")
if time.seconds != 0 {
self?.sendProgressReport(seconds: time.seconds)
}
}
self.player = player
self.allowsPictureInPicturePlayback = true
self.player?.allowsExternalPlayback = true
}
private func createMetadata() -> [AVMetadataItem] {
let allMetadata: [AVMetadataIdentifier: Any] = [
.commonIdentifierTitle: viewModel.title,
.iTunesMetadataTrackSubTitle: viewModel.subtitle ?? "",
.commonIdentifierArtwork: UIImage(data: try! Data(contentsOf: viewModel.item.getBackdropImage(maxWidth: 200)))?.pngData() as Any,
.commonIdentifierDescription: viewModel.item.overview ?? "",
.iTunesMetadataContentRating: viewModel.item.officialRating ?? "",
.quickTimeMetadataGenre: viewModel.item.genres?.first ?? ""
]
return allMetadata.compactMap { createMetadataItem(for:$0, value:$1) }
}
private func createMetadataItem(for identifier: AVMetadataIdentifier,
value: Any) -> AVMetadataItem {
let item = AVMutableMetadataItem()
item.identifier = identifier
item.value = value as? NSCopying & NSObjectProtocol
// Specify "und" to indicate an undefined language.
item.extendedLanguageTag = "und"
return item.copy() as! AVMetadataItem
}
private func createNavigationMarkerGroups() -> [AVNavigationMarkersGroup] {
guard let chapters = viewModel.item.chapters else { return [] }
var metadataGroups: [AVTimedMetadataGroup] = []
// TODO: Determine range between chapters
chapters.forEach { chapterInfo in
var chapterMetadata: [AVMetadataItem] = []
let titleItem = createMetadataItem(for: .commonIdentifierTitle, value: chapterInfo.name ?? "No Name")
chapterMetadata.append(titleItem)
let imageItem = createMetadataItem(for: .commonIdentifierArtwork, value: UIImage(data: try! Data(contentsOf: viewModel.item.getBackdropImage(maxWidth: 200)))?.pngData() as Any)
chapterMetadata.append(imageItem)
let startTime = CMTimeMake(value: chapterInfo.startPositionTicks ?? 0, timescale: 10_000_000)
let endTime = CMTimeMake(value: (chapterInfo.startPositionTicks ?? 0) + 50_000_000, timescale: 10_000_000)
let timeRange = CMTimeRangeFromTimeToTime(start: startTime, end: endTime)
metadataGroups.append(AVTimedMetadataGroup(items: chapterMetadata, timeRange: timeRange))
}
return [AVNavigationMarkersGroup(title: nil, timedNavigationMarkers: metadataGroups)]
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
stop()
removePeriodicTimeObserver()
}
func removePeriodicTimeObserver() {
if let timeObserverToken = timeObserverToken {
player?.removeTimeObserver(timeObserverToken)
self.timeObserverToken = nil
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
player?.seek(to: CMTimeMake(value: viewModel.item.userData?.playbackPositionTicks ?? 0, timescale: 10_000_000), toleranceBefore: CMTimeMake(value: 5, timescale: 1), toleranceAfter: CMTimeMake(value: 5, timescale: 1), completionHandler: { _ in
self.play()
})
}
private func play() {
player?.play()
viewModel.sendPlayReport(startTimeTicks: viewModel.item.userData?.playbackPositionTicks ?? 0)
}
private func sendProgressReport(seconds: Double) {
viewModel.sendProgressReport(ticks: Int64(seconds) * 10_000_000)
}
private func stop() {
self.player?.pause()
viewModel.sendStopReport(ticks: 10_000_000)
}
}

View File

@ -0,0 +1,30 @@
//
// PlayerOverlayDelegate.swift
// JellyfinVideoPlayerDev
//
// Created by Ethan Pippin on 12/27/21.
//
import Foundation
protocol PlayerOverlayDelegate {
func didSelectClose()
func didSelectGoogleCast()
func didSelectAirplay()
func didSelectCaptions()
func didSelectMenu()
func didDeselectMenu()
func didSelectBackward()
func didSelectForward()
func didSelectMain()
func didGenerallyTap()
func didBeginScrubbing()
func didEndScrubbing(position: Double)
func didSelectAudioStream(index: Int)
func didSelectSubtitleStream(index: Int)
}

View File

@ -1,67 +0,0 @@
//
/*
* SwiftFin is subject to the terms of the Mozilla Public
* License, v2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import SwiftUI
class SubtitlesViewController: InfoTabViewController {
override func viewDidLoad() {
super.viewDidLoad()
tabBarItem.title = "Subtitles"
}
func prepareSubtitleView(subtitleTracks: [Subtitle], selectedTrack: Int32, delegate: VideoPlayerSettingsDelegate) {
let contentView = UIHostingController(rootView: SubtitleView(selectedTrack: selectedTrack, subtitleTrackArray: subtitleTracks, delegate: delegate))
self.view.addSubview(contentView.view)
contentView.view.translatesAutoresizingMaskIntoConstraints = false
contentView.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
contentView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
contentView.view.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
contentView.view.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true
}
}
struct SubtitleView: View {
@State var selectedTrack: Int32 = -1
@State var subtitleTrackArray: [Subtitle] = []
weak var delegate: VideoPlayerSettingsDelegate?
var body : some View {
NavigationView {
VStack {
List(subtitleTrackArray, id: \.id) { track in
Button(action: {
delegate?.selectNew(subtitleTrack: track.id)
selectedTrack = track.id
}, label: {
HStack(spacing: 10) {
if track.id == selectedTrack {
Image(systemName: "checkmark")
} else {
Image(systemName: "checkmark")
.hidden()
}
Text(track.name)
}
})
}
}
.frame(width: 400)
.frame(maxHeight: 400)
}
}
}

View File

@ -1,126 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder.AppleTV.Storyboard" version="3.0" toolsVersion="18122" targetRuntime="AppleTV" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="appleTV" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Info Tab Bar View Controller-->
<scene sceneID="NE2-Ez-3qW">
<objects>
<tabBarController id="odZ-Ww-zvF" customClass="InfoTabBarViewController" customModule="JellyfinPlayer_tvOS" customModuleProvider="target" sceneMemberID="viewController">
<tabBar key="tabBar" opaque="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" translucent="NO" id="YVR-nj-bPt">
<rect key="frame" x="0.0" y="0.0" width="1920" height="68"/>
<autoresizingMask key="autoresizingMask"/>
<color key="tintColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<color key="barTintColor" red="0.101966925" green="0.1019589528" blue="0.1101123616" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</tabBar>
</tabBarController>
<placeholder placeholderIdentifier="IBFirstResponder" id="LdX-BO-e71" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-5664" y="320"/>
</scene>
<!--Video Player View Controller-->
<scene sceneID="9lE-WX-96o">
<objects>
<viewController storyboardIdentifier="VideoPlayer" id="Xgj-up-wSf" customClass="VideoPlayerViewController" customModule="JellyfinPlayer_tvOS" customModuleProvider="target" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="aTn-mJ-5lt"/>
<viewControllerLayoutGuide type="bottom" id="BrO-eL-FPV"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Oo6-ab-TCE">
<rect key="frame" x="0.0" y="0.0" width="1920" height="1080"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<view userInteractionEnabled="NO" contentMode="scaleToFill" id="Ibg-CP-gb2" userLabel="VideoPlayer">
<rect key="frame" x="0.0" y="0.0" width="1920" height="1080"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<viewLayoutGuide key="safeArea" id="jNU-Xf-Kyx"/>
<accessibility key="accessibilityConfiguration">
<accessibilityTraits key="traits" notEnabled="YES"/>
</accessibility>
</view>
<view hidden="YES" contentMode="scaleToFill" id="OG6-kk-N7Z" userLabel="Controls">
<rect key="frame" x="-1" y="0.0" width="1920" height="1080"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<view contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="eh8-uG-9Wz" userLabel="GradientView">
<rect key="frame" x="0.0" y="700" width="1925" height="391"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" fixedFrame="YES" text="-00:00:00" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" translatesAutoresizingMaskIntoConstraints="NO" id="NyZ-z0-56J" userLabel="RemainingTimeLabel">
<rect key="frame" x="1694" y="1007" width="139" height="36"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<fontDescription key="fontDescription" type="system" pointSize="30"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" fixedFrame="YES" text="00:00:00" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="CL5-ko-ceu" userLabel="CurrentTimeLabel">
<rect key="frame" x="88" y="1007" width="140" height="36"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<fontDescription key="fontDescription" type="system" pointSize="30"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<view contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="CQO-wl-bxv" userLabel="TransportBar">
<rect key="frame" x="88" y="981" width="1744" height="11"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<subviews>
<view contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="yrg-ru-QSH" userLabel="Scrubber">
<rect key="frame" x="0.0" y="0.0" width="2" height="10"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</view>
<label hidden="YES" opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" fixedFrame="YES" text="00:00" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="HfD-p0-JMA" userLabel="ScrubLabel">
<rect key="frame" x="50" y="-60" width="140" height="36"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<fontDescription key="fontDescription" type="system" pointSize="28"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<color key="backgroundColor" white="0.33333333333333331" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</view>
</subviews>
<viewLayoutGuide key="safeArea" id="IS7-IU-teh"/>
<accessibility key="accessibilityConfiguration">
<accessibilityTraits key="traits" allowsDirectInteraction="YES"/>
</accessibility>
</view>
<containerView opaque="NO" contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="lie-K8-LNT">
<rect key="frame" x="88" y="87" width="1744" height="635"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMinY="YES"/>
<connections>
<segue destination="odZ-Ww-zvF" kind="embed" identifier="infoView" id="i7y-hI-hVh"/>
</connections>
</containerView>
<activityIndicatorView opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" fixedFrame="YES" animating="YES" style="large" translatesAutoresizingMaskIntoConstraints="NO" id="WHW-kl-wkr">
<rect key="frame" x="928" y="508" width="64" height="64"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<color key="color" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</activityIndicatorView>
</subviews>
<viewLayoutGuide key="safeArea" id="Rbh-h3-eDf"/>
<color key="backgroundColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</view>
<connections>
<outlet property="activityIndicator" destination="WHW-kl-wkr" id="7aF-qL-t4E"/>
<outlet property="controlsView" destination="OG6-kk-N7Z" id="8Ed-du-EpL"/>
<outlet property="currentTimeLabel" destination="CL5-ko-ceu" id="gZB-h5-TGd"/>
<outlet property="gradientView" destination="eh8-uG-9Wz" id="fBa-EG-C6z"/>
<outlet property="infoPanelContainerView" destination="lie-K8-LNT" id="4io-B3-qE3"/>
<outlet property="remainingTimeLabel" destination="NyZ-z0-56J" id="Opj-7c-cIE"/>
<outlet property="scrubLabel" destination="HfD-p0-JMA" id="R28-Fa-v9d"/>
<outlet property="scrubberView" destination="yrg-ru-QSH" id="ylv-C7-RNl"/>
<outlet property="transportBarView" destination="CQO-wl-bxv" id="tQv-bp-jYq"/>
<outlet property="videoContentView" destination="Ibg-CP-gb2" id="vnQ-7F-8AU"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="7uX-ET-Cqw" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-5664" y="-1259"/>
</scene>
</scenes>
</document>

View File

@ -1,28 +0,0 @@
//
/*
* SwiftFin is subject to the terms of the Mozilla Public
* License, v2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import SwiftUI
import JellyfinAPI
struct VideoPlayerView: UIViewControllerRepresentable {
var item: BaseItemDto
func makeUIViewController(context: Context) -> some UIViewController {
let storyboard = UIStoryboard(name: "VideoPlayer", bundle: nil)
let viewController = storyboard.instantiateViewController(withIdentifier: "VideoPlayer") as! VideoPlayerViewController
viewController.manifest = item
return viewController
}
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
}
}

View File

@ -0,0 +1,25 @@
//
// VideoPlayerView.swift
// JellyfinVideoPlayerDev
//
// Created by Ethan Pippin on 11/12/21.
//
import UIKit
import SwiftUI
struct NativePlayerView: UIViewControllerRepresentable {
let viewModel: VideoPlayerViewModel
typealias UIViewControllerType = NativePlayerViewController
func makeUIViewController(context: Context) -> NativePlayerViewController {
return NativePlayerViewController(viewModel: viewModel)
}
func updateUIViewController(_ uiViewController: NativePlayerViewController, context: Context) {
}
}

View File

@ -1,772 +0,0 @@
//
/*
* SwiftFin is subject to the terms of the Mozilla Public
* License, v2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import TVUIKit
import TVVLCKit
import MediaPlayer
import JellyfinAPI
import Combine
import Defaults
protocol VideoPlayerSettingsDelegate: AnyObject {
func selectNew(audioTrack id: Int32)
func selectNew(subtitleTrack id: Int32)
}
class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate, VLCMediaPlayerDelegate, VLCMediaDelegate, UIGestureRecognizerDelegate {
@IBOutlet weak var videoContentView: UIView!
@IBOutlet weak var controlsView: UIView!
@IBOutlet weak var activityIndicator: UIActivityIndicatorView!
@IBOutlet weak var transportBarView: UIView!
@IBOutlet weak var scrubberView: UIView!
@IBOutlet weak var scrubLabel: UILabel!
@IBOutlet weak var gradientView: UIView!
@IBOutlet weak var currentTimeLabel: UILabel!
@IBOutlet weak var remainingTimeLabel: UILabel!
@IBOutlet weak var infoPanelContainerView: UIView!
var infoTabBarViewController: InfoTabBarViewController?
var focusedOnTabBar: Bool = false
var showingInfoPanel: Bool = false
var mediaPlayer = VLCMediaPlayer()
var lastProgressReportTime: Double = 0
var lastTime: Float = 0.0
var startTime: Int = 0
var selectedAudioTrack: Int32 = -1
var selectedCaptionTrack: Int32 = -1
var subtitleTrackArray: [Subtitle] = []
var audioTrackArray: [AudioTrack] = []
var playing: Bool = false
var seeking: Bool = false
var showingControls: Bool = false
var loading: Bool = true
var initialSeekPos: CGFloat = 0
var videoPos: Double = 0
var videoDuration: Double = 0
var controlsAppearTime: Double = 0
var manifest: BaseItemDto = BaseItemDto()
var playbackItem = PlaybackItem()
var playSessionId: String = ""
var cancellables = Set<AnyCancellable>()
override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) {
super.didUpdateFocus(in: context, with: coordinator)
// Check if focused on the tab bar, allows for swipe up to dismiss the info panel
if let nextFocused = context.nextFocusedView,
nextFocused.description.contains("UITabBarButton") {
// Set value after half a second so info panel is not dismissed instantly when swiping up from content
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.focusedOnTabBar = true
}
} else {
focusedOnTabBar = false
}
}
override func viewDidLoad() {
super.viewDidLoad()
activityIndicator.isHidden = false
activityIndicator.startAnimating()
mediaPlayer.delegate = self
mediaPlayer.drawable = videoContentView
if let runTimeTicks = manifest.runTimeTicks {
videoDuration = Double(runTimeTicks / 10_000_000)
}
// Black gradient behind transport bar
let gradientLayer: CAGradientLayer = CAGradientLayer()
gradientLayer.frame.size = self.gradientView.frame.size
gradientLayer.colors = [UIColor.black.withAlphaComponent(0.6).cgColor, UIColor.black.withAlphaComponent(0).cgColor]
gradientLayer.startPoint = CGPoint(x: 0.0, y: 1.0)
gradientLayer.endPoint = CGPoint(x: 0.0, y: 0.0)
self.gradientView.layer.addSublayer(gradientLayer)
infoPanelContainerView.center = CGPoint(x: infoPanelContainerView.center.x, y: -infoPanelContainerView.frame.height)
infoPanelContainerView.layer.cornerRadius = 40
let blurEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
blurEffectView.frame = infoPanelContainerView.bounds
blurEffectView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
blurEffectView.layer.cornerRadius = 40
blurEffectView.clipsToBounds = true
infoPanelContainerView.addSubview(blurEffectView)
infoPanelContainerView.sendSubviewToBack(blurEffectView)
transportBarView.layer.cornerRadius = CGFloat(5)
setupGestures()
fetchVideo()
setupNowPlayingCC()
// Adjust subtitle size
mediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 16)
}
func fetchVideo() {
// Fetch max bitrate from UserDefaults depending on current connection mode
let maxBitrate = Defaults[.inNetworkBandwidth]
// Build a device profile
let builder = DeviceProfileBuilder()
builder.setMaxBitrate(bitrate: maxBitrate)
let profile = builder.buildProfile()
let currentUser = SessionManager.main.currentLogin.user
let playbackInfo = PlaybackInfoDto(userId: currentUser.id, maxStreamingBitrate: Int(maxBitrate), startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, deviceProfile: profile, autoOpenLiveStream: true)
DispatchQueue.global(qos: .userInitiated).async { [self] in
MediaInfoAPI.getPostedPlaybackInfo(itemId: manifest.id!, userId: currentUser.id, maxStreamingBitrate: Int(maxBitrate), startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, autoOpenLiveStream: true, playbackInfoDto: playbackInfo)
.sink(receiveCompletion: { result in
print(result)
}, receiveValue: { [self] response in
videoContentView.setNeedsLayout()
videoContentView.setNeedsDisplay()
playSessionId = response.playSessionId ?? ""
guard let mediaSource = response.mediaSources?.first.self else {
return
}
let item = PlaybackItem()
let streamURL: URL
// Item is being transcoded by request of server
if let transcodiungUrl = mediaSource.transcodingUrl {
item.videoType = .transcode
streamURL = URL(string: "\(SessionManager.main.currentLogin.server.currentURI)\(transcodiungUrl)")!
}
// Item will be directly played by the client
else {
item.videoType = .directPlay
// streamURL = URL(string: "\(SessionManager.main.currentLogin.server.currentURI)/Videos/\(manifest.id!)/stream?Static=true&mediaSourceId=\(manifest.id!)&deviceId=\(SessionManager.current.deviceID)&api_key=\(SessionManager.current.accessToken)&Tag=\(mediaSource.eTag!)")!
streamURL = URL(string: "\(SessionManager.main.currentLogin.server.currentURI)/Videos/\(manifest.id!)/stream?Static=true&mediaSourceId=\(manifest.id!)&Tag=\(mediaSource.eTag ?? "")")!
}
item.videoUrl = streamURL
let disableSubtitleTrack = Subtitle(name: "None", id: -1, url: nil, delivery: .embed, codec: "", languageCode: "")
subtitleTrackArray.append(disableSubtitleTrack)
// Loop through media streams and add to array
for stream in mediaSource.mediaStreams! {
if stream.type == .subtitle {
var deliveryUrl: URL?
if stream.deliveryMethod == .external {
deliveryUrl = URL(string: "\(SessionManager.main.currentLogin.server.currentURI)\(stream.deliveryUrl!)")!
}
let subtitle = Subtitle(name: stream.displayTitle ?? "Unknown", id: Int32(stream.index!), url: deliveryUrl, delivery: stream.deliveryMethod!, codec: stream.codec ?? "webvtt", languageCode: stream.language ?? "")
if stream.isDefault == true {
selectedCaptionTrack = Int32(stream.index!)
}
if subtitle.delivery != .encode {
subtitleTrackArray.append(subtitle)
}
}
if stream.type == .audio {
let track = AudioTrack(name: stream.displayTitle!, languageCode: stream.language ?? "", id: Int32(stream.index!))
if stream.isDefault! == true {
selectedAudioTrack = Int32(stream.index!)
}
audioTrackArray.append(track)
}
}
// If no default audio tracks select the first one
if selectedAudioTrack == -1 && !audioTrackArray.isEmpty {
selectedAudioTrack = audioTrackArray.first!.id
}
self.sendPlayReport()
playbackItem = item
mediaPlayer.media = VLCMedia(url: playbackItem.videoUrl)
mediaPlayer.media.delegate = self
mediaPlayer.play()
// 1 second = 10,000,000 ticks
if let rawStartTicks = manifest.userData?.playbackPositionTicks {
mediaPlayer.jumpForward(Int32(rawStartTicks / 10_000_000))
}
subtitleTrackArray.forEach { sub in
if sub.id != -1 && sub.delivery == .external && sub.codec != "subrip" {
mediaPlayer.addPlaybackSlave(sub.url!, type: .subtitle, enforce: false)
}
}
playing = true
setupInfoPanel()
})
.store(in: &cancellables)
}
}
func setupNowPlayingCC() {
let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.playCommand.isEnabled = true
commandCenter.pauseCommand.isEnabled = true
commandCenter.skipBackwardCommand.isEnabled = true
commandCenter.skipBackwardCommand.preferredIntervals = [15]
commandCenter.skipForwardCommand.isEnabled = true
commandCenter.skipForwardCommand.preferredIntervals = [30]
commandCenter.changePlaybackPositionCommand.isEnabled = true
commandCenter.enableLanguageOptionCommand.isEnabled = true
// Add handler for Pause Command
commandCenter.pauseCommand.addTarget { _ in
self.pause()
self.showingControls = true
self.controlsView.isHidden = false
self.controlsAppearTime = CACurrentMediaTime()
return .success
}
// Add handler for Play command
commandCenter.playCommand.addTarget { _ in
self.play()
self.showingControls = false
self.controlsView.isHidden = true
return .success
}
// Add handler for FF command
commandCenter.skipForwardCommand.addTarget { _ in
self.mediaPlayer.jumpForward(30)
self.sendProgressReport(eventName: "timeupdate")
return .success
}
// Add handler for RW command
commandCenter.skipBackwardCommand.addTarget { _ in
self.mediaPlayer.jumpBackward(15)
self.sendProgressReport(eventName: "timeupdate")
return .success
}
// Scrubber
commandCenter.changePlaybackPositionCommand.addTarget { [weak self](remoteEvent) -> MPRemoteCommandHandlerStatus in
guard let self = self else {return .commandFailed}
if let event = remoteEvent as? MPChangePlaybackPositionCommandEvent {
let targetSeconds = event.positionTime
let videoPosition = Double(self.mediaPlayer.time.intValue / 1000)
let offset = targetSeconds - videoPosition
if offset > 0 {
self.mediaPlayer.jumpForward(Int32(offset))
} else {
self.mediaPlayer.jumpBackward(Int32(abs(offset)))
}
self.sendProgressReport(eventName: "unpause")
return .success
} else {
return .commandFailed
}
}
var runTicks = 0
var playbackTicks = 0
if let ticks = manifest.runTimeTicks {
runTicks = Int(ticks / 10_000_000)
}
if let ticks = manifest.userData?.playbackPositionTicks {
playbackTicks = Int(ticks / 10_000_000)
}
var nowPlayingInfo = [String: Any]()
nowPlayingInfo[MPMediaItemPropertyTitle] = manifest.name ?? "Jellyfin Video"
if manifest.type == "Episode" {
nowPlayingInfo[MPMediaItemPropertyArtist] = "\(manifest.seriesName ?? manifest.name ?? "")\(manifest.getEpisodeLocator())"
}
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 0.0
nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = AVMediaType.video
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = runTicks
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = playbackTicks
if let imageData = NSData(contentsOf: manifest.getPrimaryImage(maxWidth: 500)) {
if let artworkImage = UIImage(data: imageData as Data) {
let artwork = MPMediaItemArtwork.init(boundsSize: artworkImage.size, requestHandler: { (_) -> UIImage in
return artworkImage
})
nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork
}
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
UIApplication.shared.beginReceivingRemoteControlEvents()
}
func updateNowPlayingCenter(time: Double?, playing: Bool?) {
var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [String: Any]()
if let playing = playing {
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = playing ? 1.0 : 0.0
}
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = mediaPlayer.time.intValue / 1000
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
// Grabs a reference to the info panel view controller
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if segue.identifier == "infoView" {
infoTabBarViewController = segue.destination as? InfoTabBarViewController
infoTabBarViewController?.videoPlayer = self
}
}
// MARK: Player functions
// Animate the scrubber when playing state changes
func animateScrubber() {
let y: CGFloat = playing ? 0 : -20
let height: CGFloat = playing ? 10 : 30
UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseIn, animations: {
self.scrubberView.frame = CGRect(x: self.scrubberView.frame.minX, y: y, width: 2, height: height)
})
}
func pause() {
playing = false
mediaPlayer.pause()
self.sendProgressReport(eventName: "pause")
self.updateNowPlayingCenter(time: nil, playing: false)
animateScrubber()
self.scrubLabel.frame = CGRect(x: self.scrubberView.frame.minX - self.scrubLabel.frame.width/2, y: self.scrubLabel.frame.minY, width: self.scrubLabel.frame.width, height: self.scrubLabel.frame.height)
}
func play () {
playing = true
mediaPlayer.play()
self.updateNowPlayingCenter(time: nil, playing: true)
self.sendProgressReport(eventName: "unpause")
animateScrubber()
}
func toggleInfoContainer() {
showingInfoPanel.toggle()
infoTabBarViewController?.view.isUserInteractionEnabled = showingInfoPanel
if showingInfoPanel && seeking {
scrubLabel.isHidden = true
UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut, animations: {
self.scrubberView.frame = CGRect(x: self.initialSeekPos, y: self.scrubberView.frame.minY, width: 2, height: self.scrubberView.frame.height)
}) { _ in
self.scrubLabel.frame = CGRect(x: (self.initialSeekPos - self.scrubLabel.frame.width/2), y: self.scrubLabel.frame.minY, width: self.scrubLabel.frame.width, height: self.scrubLabel.frame.height)
self.scrubLabel.text = self.currentTimeLabel.text
}
seeking = false
}
UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut) { [self] in
let size = infoPanelContainerView.frame.size
let y: CGFloat = showingInfoPanel ? 87 : -size.height
infoPanelContainerView.frame = CGRect(x: 88, y: y, width: size.width, height: size.height)
}
}
// MARK: Gestures
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
for item in presses {
if item.type == .select {
selectButtonTapped()
}
}
}
func setupGestures() {
self.becomeFirstResponder()
// vlc crap
videoContentView.gestureRecognizers?.forEach { gr in
videoContentView.removeGestureRecognizer(gr)
}
videoContentView.subviews.forEach { sv in
sv.gestureRecognizers?.forEach { gr in
sv.removeGestureRecognizer(gr)
}
}
let playPauseGesture = UITapGestureRecognizer(target: self, action: #selector(self.selectButtonTapped))
let playPauseType = UIPress.PressType.playPause
playPauseGesture.allowedPressTypes = [NSNumber(value: playPauseType.rawValue)]
view.addGestureRecognizer(playPauseGesture)
let backTapGesture = UITapGestureRecognizer(target: self, action: #selector(self.backButtonPressed(tap:)))
let backPress = UIPress.PressType.menu
backTapGesture.allowedPressTypes = [NSNumber(value: backPress.rawValue)]
view.addGestureRecognizer(backTapGesture)
let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.userPanned(panGestureRecognizer:)))
view.addGestureRecognizer(panGestureRecognizer)
}
@objc func backButtonPressed(tap: UITapGestureRecognizer) {
// Dismiss info panel
if showingInfoPanel {
if focusedOnTabBar {
toggleInfoContainer()
}
return
}
// Cancel seek and move back to initial position
if seeking {
scrubLabel.isHidden = true
UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut, animations: {
self.scrubberView.frame = CGRect(x: self.initialSeekPos, y: 0, width: 2, height: 10)
})
play()
seeking = false
} else {
// Dismiss view
self.resignFirstResponder()
mediaPlayer.stop()
sendStopReport()
self.navigationController?.popViewController(animated: true)
}
}
@objc func userPanned(panGestureRecognizer: UIPanGestureRecognizer) {
if loading {
return
}
let translation = panGestureRecognizer.translation(in: view)
let velocity = panGestureRecognizer.velocity(in: view)
// Swiped up - Handle dismissing info panel
if translation.y < -200 && (focusedOnTabBar && showingInfoPanel) {
toggleInfoContainer()
return
}
if showingInfoPanel {
return
}
// Swiped down - Show the info panel
if translation.y > 200 {
toggleInfoContainer()
return
}
// Ignore seek if video is playing
if playing {
return
}
// Save current position if seek is cancelled and show the scrubLabel
if !seeking {
initialSeekPos = self.scrubberView.frame.minX
seeking = true
self.scrubLabel.isHidden = false
}
let newPos = (self.scrubberView.frame.minX + velocity.x/100).clamped(to: 0...transportBarView.frame.width)
UIView.animate(withDuration: 0.8, delay: 0, options: .curveEaseOut, animations: {
let time = (Double(self.scrubberView.frame.minX) * self.videoDuration) / Double(self.transportBarView.frame.width)
self.scrubberView.frame = CGRect(x: newPos, y: self.scrubberView.frame.minY, width: 2, height: 30)
self.scrubLabel.frame = CGRect(x: (newPos - self.scrubLabel.frame.width/2), y: self.scrubLabel.frame.minY, width: self.scrubLabel.frame.width, height: self.scrubLabel.frame.height)
self.scrubLabel.text = (self.formatSecondsToHMS(time))
})
}
/// Play/Pause or Select is pressed on the AppleTV remote
@objc func selectButtonTapped() {
print("select")
if loading {
return
}
showingControls = true
controlsView.isHidden = false
controlsAppearTime = CACurrentMediaTime()
// Move to seeked position
if seeking {
scrubLabel.isHidden = true
// Move current time to the scrubbed position
UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut, animations: { [self] in
self.currentTimeLabel.frame = CGRect(x: CGFloat(scrubLabel.frame.minX + transportBarView.frame.minX), y: currentTimeLabel.frame.minY, width: currentTimeLabel.frame.width, height: currentTimeLabel.frame.height)
})
let time = (Double(self.scrubberView.frame.minX) * self.videoDuration) / Double(self.transportBarView.frame.width)
self.currentTimeLabel.text = self.scrubLabel.text
self.remainingTimeLabel.text = "-" + formatSecondsToHMS(videoDuration - time)
mediaPlayer.position = Float(self.scrubberView.frame.minX) / Float(self.transportBarView.frame.width)
play()
seeking = false
return
}
playing ? pause() : play()
}
// MARK: Jellyfin Playstate updates
func sendProgressReport(eventName: String) {
updateNowPlayingCenter(time: nil, playing: mediaPlayer.state == .playing)
if (eventName == "timeupdate" && mediaPlayer.state == .playing) || eventName != "timeupdate" {
var ticks: Int64 = Int64(mediaPlayer.position * Float(manifest.runTimeTicks!))
if ticks == 0 {
ticks = manifest.userData?.playbackPositionTicks ?? 0
}
let progressInfo = PlaybackProgressInfo(canSeek: true, item: manifest, itemId: manifest.id, sessionId: playSessionId, mediaSourceId: manifest.id, audioStreamIndex: Int(selectedAudioTrack), subtitleStreamIndex: Int(selectedCaptionTrack), isPaused: (!playing), isMuted: false, positionTicks: ticks, playbackStartTimeTicks: Int64(startTime), volumeLevel: 100, brightness: 100, aspectRatio: nil, playMethod: playbackItem.videoType, liveStreamId: nil, playSessionId: playSessionId, repeatMode: .repeatNone, nowPlayingQueue: [], playlistItemId: "playlistItem0")
PlaystateAPI.reportPlaybackProgress(playbackProgressInfo: progressInfo)
.sink(receiveCompletion: { result in
print(result)
}, receiveValue: { _ in
print("Playback progress report sent!")
})
.store(in: &cancellables)
}
}
func sendStopReport() {
let stopInfo = PlaybackStopInfo(item: manifest, itemId: manifest.id, sessionId: playSessionId, mediaSourceId: manifest.id, positionTicks: Int64(mediaPlayer.position * Float(manifest.runTimeTicks!)), liveStreamId: nil, playSessionId: playSessionId, failed: nil, nextMediaType: nil, playlistItemId: "playlistItem0", nowPlayingQueue: [])
PlaystateAPI.reportPlaybackStopped(playbackStopInfo: stopInfo)
.sink(receiveCompletion: { result in
print(result)
}, receiveValue: { _ in
print("Playback stop report sent!")
})
.store(in: &cancellables)
}
func sendPlayReport() {
startTime = Int(Date().timeIntervalSince1970) * 10000000
print("sending play report!")
let startInfo = PlaybackStartInfo(canSeek: true, item: manifest, itemId: manifest.id, sessionId: playSessionId, mediaSourceId: manifest.id, audioStreamIndex: Int(selectedAudioTrack), subtitleStreamIndex: Int(selectedCaptionTrack), isPaused: false, isMuted: false, positionTicks: manifest.userData?.playbackPositionTicks, playbackStartTimeTicks: Int64(startTime), volumeLevel: 100, brightness: 100, aspectRatio: nil, playMethod: playbackItem.videoType, liveStreamId: nil, playSessionId: playSessionId, repeatMode: .repeatNone, nowPlayingQueue: [], playlistItemId: "playlistItem0")
PlaystateAPI.reportPlaybackStart(playbackStartInfo: startInfo)
.sink(receiveCompletion: { result in
print(result)
}, receiveValue: { _ in
print("Playback start report sent!")
})
.store(in: &cancellables)
}
// MARK: VLC Delegate
func mediaPlayerStateChanged(_ aNotification: Notification!) {
let currentState: VLCMediaPlayerState = mediaPlayer.state
switch currentState {
case .buffering:
print("Video is buffering")
loading = true
activityIndicator.isHidden = false
activityIndicator.startAnimating()
mediaPlayer.pause()
usleep(10000)
mediaPlayer.play()
break
case .stopped:
print("stopped")
break
case .ended:
print("ended")
break
case .opening:
print("opening")
break
case .paused:
print("paused")
break
case .playing:
print("Video is playing")
loading = false
sendProgressReport(eventName: "unpause")
DispatchQueue.main.async { [self] in
activityIndicator.isHidden = true
activityIndicator.stopAnimating()
}
playing = true
break
case .error:
print("error")
break
case .esAdded:
print("esAdded")
break
default:
print("default")
break
}
}
// Move time along transport bar
func mediaPlayerTimeChanged(_ aNotification: Notification!) {
if loading {
loading = false
DispatchQueue.main.async { [self] in
activityIndicator.isHidden = true
activityIndicator.stopAnimating()
}
updateNowPlayingCenter(time: nil, playing: true)
}
let time = mediaPlayer.position
if time != lastTime {
self.currentTimeLabel.text = formatSecondsToHMS(Double(mediaPlayer.time.intValue/1000))
self.remainingTimeLabel.text = "-" + formatSecondsToHMS(Double(abs(mediaPlayer.remainingTime.intValue/1000)))
self.videoPos = Double(mediaPlayer.position)
let newPos = videoPos * Double(self.transportBarView.frame.width)
if !newPos.isNaN && self.playing {
self.scrubberView.frame = CGRect(x: newPos, y: 0, width: 2, height: 10)
self.currentTimeLabel.frame = CGRect(x: CGFloat(newPos) + transportBarView.frame.minX - currentTimeLabel.frame.width/2, y: currentTimeLabel.frame.minY, width: currentTimeLabel.frame.width, height: currentTimeLabel.frame.height)
}
if showingControls {
if CACurrentMediaTime() - controlsAppearTime > 5 {
showingControls = false
UIView.animate(withDuration: 0.5, delay: 0, options: .curveEaseOut, animations: {
self.controlsView.alpha = 0.0
}, completion: { (_: Bool) in
self.controlsView.isHidden = true
self.controlsView.alpha = 1
})
controlsAppearTime = 999_999_999_999_999
}
}
}
lastTime = time
if CACurrentMediaTime() - lastProgressReportTime > 5 {
sendProgressReport(eventName: "timeupdate")
lastProgressReportTime = CACurrentMediaTime()
}
}
// MARK: Settings Delegate
func selectNew(audioTrack id: Int32) {
selectedAudioTrack = id
mediaPlayer.currentAudioTrackIndex = id
}
func selectNew(subtitleTrack id: Int32) {
selectedCaptionTrack = id
mediaPlayer.currentVideoSubTitleIndex = id
}
func setupInfoPanel() {
infoTabBarViewController?.setupInfoViews(mediaItem: manifest, subtitleTracks: subtitleTrackArray, selectedSubtitleTrack: selectedCaptionTrack, audioTracks: audioTrackArray, selectedAudioTrack: selectedAudioTrack, delegate: self)
}
func formatSecondsToHMS(_ seconds: Double) -> String {
let timeHMSFormatter: DateComponentsFormatter = {
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .positional
formatter.allowedUnits = seconds >= 3600 ?
[.hour, .minute, .second] :
[.minute, .second]
formatter.zeroFormattingBehavior = .pad
return formatter
}()
guard !seconds.isNaN,
let text = timeHMSFormatter.string(from: seconds) else {
return "00:00"
}
return text.hasPrefix("0") && text.count > 4 ?
.init(text.dropFirst()) : text
}
}
extension Comparable {
func clamped(to limits: ClosedRange<Self>) -> Self {
return min(max(self, limits.lowerBound), limits.upperBound)
}
}

View File

@ -14,15 +14,7 @@
09389CC526814E4500AE350E /* DeviceProfileBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */; };
09389CC726819B4600AE350E /* VideoPlayerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09389CC626819B4500AE350E /* VideoPlayerModel.swift */; };
09389CC826819B4600AE350E /* VideoPlayerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09389CC626819B4500AE350E /* VideoPlayerModel.swift */; };
0959A5FD2686D29800C7C9A9 /* VideoUpNextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0959A5FC2686D29800C7C9A9 /* VideoUpNextView.swift */; };
363CADF08820D3B2055CF1D8 /* Pods_JellyfinPlayer_tvOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4BE2D324B040DCA2629C110D /* Pods_JellyfinPlayer_tvOS.framework */; };
531069572684E7EE00CFFDBA /* InfoTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531069502684E7EE00CFFDBA /* InfoTabBarViewController.swift */; };
531069582684E7EE00CFFDBA /* MediaInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531069512684E7EE00CFFDBA /* MediaInfoView.swift */; };
531069592684E7EE00CFFDBA /* SubtitlesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531069522684E7EE00CFFDBA /* SubtitlesView.swift */; };
5310695A2684E7EE00CFFDBA /* VideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531069532684E7EE00CFFDBA /* VideoPlayer.swift */; };
5310695B2684E7EE00CFFDBA /* AudioView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531069542684E7EE00CFFDBA /* AudioView.swift */; };
5310695C2684E7EE00CFFDBA /* VideoPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531069552684E7EE00CFFDBA /* VideoPlayerViewController.swift */; };
5310695D2684E7EE00CFFDBA /* VideoPlayer.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 531069562684E7EE00CFFDBA /* VideoPlayer.storyboard */; };
53116A17268B919A003024C9 /* SeriesItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53116A16268B919A003024C9 /* SeriesItemView.swift */; };
53116A19268B947A003024C9 /* PlainLinkButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53116A18268B947A003024C9 /* PlainLinkButton.swift */; };
531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690E6267ABD79005D8AB9 /* HomeView.swift */; };
@ -39,8 +31,6 @@
53272532268BF09D0035FBF1 /* MediaViewActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53272531268BF09D0035FBF1 /* MediaViewActionButton.swift */; };
53272537268C1DBB0035FBF1 /* SeasonItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53272536268C1DBB0035FBF1 /* SeasonItemView.swift */; };
53272539268C20100035FBF1 /* EpisodeItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53272538268C20100035FBF1 /* EpisodeItemView.swift */; };
532E68CF267D9F6B007B9F13 /* VideoPlayerCastDeviceSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 532E68CE267D9F6B007B9F13 /* VideoPlayerCastDeviceSelector.swift */; };
53313B90265EEA6D00947AA3 /* VideoPlayer.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 53313B8F265EEA6D00947AA3 /* VideoPlayer.storyboard */; };
53352571265EA0A0006CCA86 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 53352570265EA0A0006CCA86 /* Introspect */; };
5338F74E263B61370014BF09 /* ConnectToServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5338F74D263B61370014BF09 /* ConnectToServerView.swift */; };
534D4FF026A7D7CC000A7A48 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 534D4FEE26A7D7CC000A7A48 /* Localizable.strings */; };
@ -64,7 +54,6 @@
535870AA2669D8AE00D05A09 /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5389277B263CC3DB0035E14B /* BlurHashDecode.swift */; };
535870AD2669D8DD00D05A09 /* Typings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535870AC2669D8DD00D05A09 /* Typings.swift */; };
535BAE9F2649E569005FA86D /* ItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535BAE9E2649E569005FA86D /* ItemView.swift */; };
535BAEA5264A151C005FA86D /* VideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535BAEA4264A151C005FA86D /* VideoPlayer.swift */; };
53649AAD269CFAEA00A2D8B7 /* Puppy in Frameworks */ = {isa = PBXBuildFile; productRef = 53649AAC269CFAEA00A2D8B7 /* Puppy */; };
53649AAF269CFAF600A2D8B7 /* Puppy in Frameworks */ = {isa = PBXBuildFile; productRef = 53649AAE269CFAF600A2D8B7 /* Puppy */; };
53649AB1269CFB1900A2D8B7 /* LogManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53649AB0269CFB1900A2D8B7 /* LogManager.swift */; };
@ -150,7 +139,6 @@
53DF641E263D9C0600A7CD1A /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53DF641D263D9C0600A7CD1A /* LibraryView.swift */; };
53E4E649263F725B00F67C6B /* MultiSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E4E648263F725B00F67C6B /* MultiSelectorView.swift */; };
53EE24E6265060780068F029 /* LibrarySearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53EE24E5265060780068F029 /* LibrarySearchView.swift */; };
53F8377D265FF67C00F456B3 /* VideoPlayerSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53F8377C265FF67C00F456B3 /* VideoPlayerSettingsView.swift */; };
53F866442687A45F00DCD1D7 /* PortraitItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53F866432687A45F00DCD1D7 /* PortraitItemView.swift */; };
53FF7F2A263CF3F500585C35 /* LatestMediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53FF7F29263CF3F500585C35 /* LatestMediaView.swift */; };
560CA59B3956A4CA13EDAC05 /* Pods_JellyfinPlayer_iOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 86BAC42C3764D232C8DF8F5E /* Pods_JellyfinPlayer_iOS.framework */; };
@ -165,7 +153,7 @@
6220D0B726D5EE1100B8E046 /* SearchCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0B626D5EE1100B8E046 /* SearchCoordinator.swift */; };
6220D0BA26D6092100B8E046 /* FilterCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0B926D6092100B8E046 /* FilterCoordinator.swift */; };
6220D0C026D61C5000B8E046 /* ItemCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0BF26D61C5000B8E046 /* ItemCoordinator.swift */; };
6220D0C626D62D8700B8E046 /* VideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0C526D62D8700B8E046 /* VideoPlayerCoordinator.swift */; };
6220D0C626D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0C526D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift */; };
6220D0C926D63F3700B8E046 /* Stinsen in Frameworks */ = {isa = PBXBuildFile; productRef = 6220D0C826D63F3700B8E046 /* Stinsen */; };
6220D0CC26D640C400B8E046 /* AppURLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0CB26D640C400B8E046 /* AppURLHandler.swift */; };
6225FCCB2663841E00E067F6 /* ParallaxHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6225FCCA2663841E00E067F6 /* ParallaxHeader.swift */; };
@ -317,7 +305,6 @@
E193D53B27193F9200900D82 /* SettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0B026D5EC9900B8E046 /* SettingsCoordinator.swift */; };
E193D53C27193F9500900D82 /* UserListCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD4012717EE79009D4DAF /* UserListCoordinator.swift */; };
E193D53D27193F9700900D82 /* UserSignInCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3F127179378009D4DAF /* UserSignInCoordinator.swift */; };
E193D53E27193F9A00900D82 /* VideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0C526D62D8700B8E046 /* VideoPlayerCoordinator.swift */; };
E193D5432719407E00900D82 /* tvOSMainCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E193D5422719407E00900D82 /* tvOSMainCoordinator.swift */; };
E193D547271941C500900D82 /* UserListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E193D546271941C500900D82 /* UserListView.swift */; };
E193D549271941CC00900D82 /* UserSignInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E193D548271941CC00900D82 /* UserSignInView.swift */; };
@ -340,6 +327,21 @@
E1AD106226D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD106126D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift */; };
E1B6DCE8271A23780015B715 /* CombineExt in Frameworks */ = {isa = PBXBuildFile; productRef = E1B6DCE7271A23780015B715 /* CombineExt */; };
E1B6DCEA271A23880015B715 /* SwiftyJSON in Frameworks */ = {isa = PBXBuildFile; productRef = E1B6DCE9271A23880015B715 /* SwiftyJSON */; };
E1C812BC277A8E5D00918266 /* PlaybackSpeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */; };
E1C812BD277A8E5D00918266 /* PlayerOverlayDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B5277A8E5D00918266 /* PlayerOverlayDelegate.swift */; };
E1C812BE277A8E5D00918266 /* VLCPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B6277A8E5D00918266 /* VLCPlayerViewController.swift */; };
E1C812BF277A8E5D00918266 /* VLCPlayerOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B7277A8E5D00918266 /* VLCPlayerOverlayView.swift */; };
E1C812C0277A8E5D00918266 /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B8277A8E5D00918266 /* VideoPlayerView.swift */; };
E1C812C1277A8E5D00918266 /* NativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B9277A8E5D00918266 /* NativePlayerViewController.swift */; };
E1C812C3277A8E5D00918266 /* VLCPlayerCompactOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812BB277A8E5D00918266 /* VLCPlayerCompactOverlayView.swift */; };
E1C812C5277A90B200918266 /* URLComponentsExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C4277A90B200918266 /* URLComponentsExtensions.swift */; };
E1C812CA277AE40900918266 /* PlayerOverlayDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C6277AE40900918266 /* PlayerOverlayDelegate.swift */; };
E1C812CB277AE40900918266 /* NativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C7277AE40900918266 /* NativePlayerViewController.swift */; };
E1C812CC277AE40A00918266 /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C8277AE40900918266 /* VideoPlayerView.swift */; };
E1C812CD277AE40A00918266 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C9277AE40900918266 /* VideoPlayerViewModel.swift */; };
E1C812CE277AE43100918266 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C9277AE40900918266 /* VideoPlayerViewModel.swift */; };
E1C812D1277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812D0277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift */; };
E1C812D2277AE50A00918266 /* URLComponentsExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C4277A90B200918266 /* URLComponentsExtensions.swift */; };
E1D4BF7C2719D05000A11E64 /* BasicAppSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF7B2719D05000A11E64 /* BasicAppSettingsView.swift */; };
E1D4BF7E2719D1DD00A11E64 /* BasicAppSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF7D2719D1DC00A11E64 /* BasicAppSettingsViewModel.swift */; };
E1D4BF7F2719D1DD00A11E64 /* BasicAppSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF7D2719D1DC00A11E64 /* BasicAppSettingsViewModel.swift */; };
@ -402,18 +404,10 @@
091B5A872683142E00D78B61 /* ServerDiscovery.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServerDiscovery.swift; sourceTree = "<group>"; };
091B5A882683142E00D78B61 /* UDPBroadCastConnection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UDPBroadCastConnection.swift; sourceTree = "<group>"; };
09389CC626819B4500AE350E /* VideoPlayerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerModel.swift; sourceTree = "<group>"; };
0959A5FC2686D29800C7C9A9 /* VideoUpNextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoUpNextView.swift; sourceTree = "<group>"; };
14E199C7BBA98782CAD2F0D4 /* Pods-JellyfinPlayer iOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JellyfinPlayer iOS.release.xcconfig"; path = "Target Support Files/Pods-JellyfinPlayer iOS/Pods-JellyfinPlayer iOS.release.xcconfig"; sourceTree = "<group>"; };
20CA36DDD247EED8D16438A5 /* Pods-JellyfinPlayer tvOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JellyfinPlayer tvOS.release.xcconfig"; path = "Target Support Files/Pods-JellyfinPlayer tvOS/Pods-JellyfinPlayer tvOS.release.xcconfig"; sourceTree = "<group>"; };
4BDCEE3B49CF70A9E9BA3CD8 /* Pods-WidgetExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetExtension.debug.xcconfig"; path = "Target Support Files/Pods-WidgetExtension/Pods-WidgetExtension.debug.xcconfig"; sourceTree = "<group>"; };
4BE2D324B040DCA2629C110D /* Pods_JellyfinPlayer_tvOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_JellyfinPlayer_tvOS.framework; sourceTree = BUILT_PRODUCTS_DIR; };
531069502684E7EE00CFFDBA /* InfoTabBarViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InfoTabBarViewController.swift; sourceTree = "<group>"; };
531069512684E7EE00CFFDBA /* MediaInfoView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaInfoView.swift; sourceTree = "<group>"; };
531069522684E7EE00CFFDBA /* SubtitlesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubtitlesView.swift; sourceTree = "<group>"; };
531069532684E7EE00CFFDBA /* VideoPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = "<group>"; };
531069542684E7EE00CFFDBA /* AudioView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AudioView.swift; sourceTree = "<group>"; };
531069552684E7EE00CFFDBA /* VideoPlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewController.swift; sourceTree = "<group>"; };
531069562684E7EE00CFFDBA /* VideoPlayer.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = VideoPlayer.storyboard; sourceTree = "<group>"; };
53116A16268B919A003024C9 /* SeriesItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeriesItemView.swift; sourceTree = "<group>"; };
53116A18268B947A003024C9 /* PlainLinkButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlainLinkButton.swift; sourceTree = "<group>"; };
531690E6267ABD79005D8AB9 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
@ -427,8 +421,6 @@
53272531268BF09D0035FBF1 /* MediaViewActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaViewActionButton.swift; sourceTree = "<group>"; };
53272536268C1DBB0035FBF1 /* SeasonItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeasonItemView.swift; sourceTree = "<group>"; };
53272538268C20100035FBF1 /* EpisodeItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeItemView.swift; sourceTree = "<group>"; };
532E68CE267D9F6B007B9F13 /* VideoPlayerCastDeviceSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerCastDeviceSelector.swift; sourceTree = "<group>"; };
53313B8F265EEA6D00947AA3 /* VideoPlayer.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = VideoPlayer.storyboard; sourceTree = "<group>"; };
5338F74D263B61370014BF09 /* ConnectToServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectToServerView.swift; sourceTree = "<group>"; };
534D4FE826A7D7CC000A7A48 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = Localizable.strings; sourceTree = "<group>"; };
534D4FEC26A7D7CC000A7A48 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = Localizable.strings; sourceTree = "<group>"; };
@ -440,7 +432,6 @@
535870702669D21700D05A09 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
535870AC2669D8DD00D05A09 /* Typings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Typings.swift; sourceTree = "<group>"; };
535BAE9E2649E569005FA86D /* ItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemView.swift; sourceTree = "<group>"; };
535BAEA4264A151C005FA86D /* VideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = "<group>"; };
5362E4A7267D4067000E2F71 /* GoogleCast.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = GoogleCast.framework; path = "../../Downloads/GoogleCastSDK-ios-4.6.0_dynamic/GoogleCast.framework"; sourceTree = "<group>"; };
5362E4AA267D40AD000E2F71 /* AVFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFoundation.framework; path = System/Library/Frameworks/AVFoundation.framework; sourceTree = SDKROOT; };
5362E4AC267D40B1000E2F71 /* Accelerate.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Accelerate.framework; path = System/Library/Frameworks/Accelerate.framework; sourceTree = SDKROOT; };
@ -501,7 +492,6 @@
53E4E646263F6CF100F67C6B /* LibraryFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryFilterView.swift; sourceTree = "<group>"; };
53E4E648263F725B00F67C6B /* MultiSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiSelectorView.swift; sourceTree = "<group>"; };
53EE24E5265060780068F029 /* LibrarySearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySearchView.swift; sourceTree = "<group>"; };
53F8377C265FF67C00F456B3 /* VideoPlayerSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerSettingsView.swift; sourceTree = "<group>"; };
53F866432687A45F00DCD1D7 /* PortraitItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitItemView.swift; sourceTree = "<group>"; };
53FF7F29263CF3F500585C35 /* LatestMediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestMediaView.swift; sourceTree = "<group>"; };
59AFF849629F3C787909A911 /* Pods_WidgetExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_WidgetExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@ -514,7 +504,7 @@
6220D0B626D5EE1100B8E046 /* SearchCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchCoordinator.swift; sourceTree = "<group>"; };
6220D0B926D6092100B8E046 /* FilterCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterCoordinator.swift; sourceTree = "<group>"; };
6220D0BF26D61C5000B8E046 /* ItemCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemCoordinator.swift; sourceTree = "<group>"; };
6220D0C526D62D8700B8E046 /* VideoPlayerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerCoordinator.swift; sourceTree = "<group>"; };
6220D0C526D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSVideoPlayerCoordinator.swift; sourceTree = "<group>"; };
6220D0CB26D640C400B8E046 /* AppURLHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppURLHandler.swift; sourceTree = "<group>"; };
6225FCCA2663841E00E067F6 /* ParallaxHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParallaxHeader.swift; sourceTree = "<group>"; };
624C21742685CF60007F1390 /* SearchablePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchablePickerView.swift; sourceTree = "<group>"; };
@ -610,6 +600,19 @@
E1AD105B26D9ABDD003E4A08 /* PillHStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillHStackView.swift; sourceTree = "<group>"; };
E1AD105E26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameGUIDPairExtensions.swift; sourceTree = "<group>"; };
E1AD106126D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemPortraitHeaderOverlayView.swift; sourceTree = "<group>"; };
E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlaybackSpeed.swift; sourceTree = "<group>"; };
E1C812B5277A8E5D00918266 /* PlayerOverlayDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerOverlayDelegate.swift; sourceTree = "<group>"; };
E1C812B6277A8E5D00918266 /* VLCPlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VLCPlayerViewController.swift; sourceTree = "<group>"; };
E1C812B7277A8E5D00918266 /* VLCPlayerOverlayView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VLCPlayerOverlayView.swift; sourceTree = "<group>"; };
E1C812B8277A8E5D00918266 /* VideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = "<group>"; };
E1C812B9277A8E5D00918266 /* NativePlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NativePlayerViewController.swift; sourceTree = "<group>"; };
E1C812BB277A8E5D00918266 /* VLCPlayerCompactOverlayView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VLCPlayerCompactOverlayView.swift; sourceTree = "<group>"; };
E1C812C4277A90B200918266 /* URLComponentsExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLComponentsExtensions.swift; sourceTree = "<group>"; };
E1C812C6277AE40900918266 /* PlayerOverlayDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerOverlayDelegate.swift; sourceTree = "<group>"; };
E1C812C7277AE40900918266 /* NativePlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NativePlayerViewController.swift; sourceTree = "<group>"; };
E1C812C8277AE40900918266 /* VideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = "<group>"; };
E1C812C9277AE40900918266 /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = "<group>"; };
E1C812D0277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvOSVideoPlayerCoordinator.swift; sourceTree = "<group>"; };
E1D4BF7B2719D05000A11E64 /* BasicAppSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAppSettingsView.swift; sourceTree = "<group>"; };
E1D4BF7D2719D1DC00A11E64 /* BasicAppSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAppSettingsViewModel.swift; sourceTree = "<group>"; };
E1D4BF802719D22800A11E64 /* AppAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAppearance.swift; sourceTree = "<group>"; };
@ -695,13 +698,9 @@
5310694F2684E7EE00CFFDBA /* VideoPlayer */ = {
isa = PBXGroup;
children = (
531069512684E7EE00CFFDBA /* MediaInfoView.swift */,
531069522684E7EE00CFFDBA /* SubtitlesView.swift */,
531069542684E7EE00CFFDBA /* AudioView.swift */,
531069502684E7EE00CFFDBA /* InfoTabBarViewController.swift */,
531069532684E7EE00CFFDBA /* VideoPlayer.swift */,
531069552684E7EE00CFFDBA /* VideoPlayerViewController.swift */,
531069562684E7EE00CFFDBA /* VideoPlayer.storyboard */,
E1C812C7277AE40900918266 /* NativePlayerViewController.swift */,
E1C812C6277AE40900918266 /* PlayerOverlayDelegate.swift */,
E1C812C8277AE40900918266 /* VideoPlayerView.swift */,
);
path = VideoPlayer;
sourceTree = "<group>";
@ -720,17 +719,18 @@
62E632DB267D2E130063E547 /* LibrarySearchViewModel.swift */,
62E632DF267D30CA0063E547 /* LibraryViewModel.swift */,
536D3D75267BA9BB0004248C /* MainTabViewModel.swift */,
C40CD924271F8D1E000FB198 /* MovieLibrariesViewModel.swift */,
C4BE0765271FC109003F4AD1 /* TVLibrariesViewModel.swift */,
62E632E2267D3BA60063E547 /* MovieItemViewModel.swift */,
C40CD924271F8D1E000FB198 /* MovieLibrariesViewModel.swift */,
62E632E8267D3FF50063E547 /* SeasonItemViewModel.swift */,
62E632EB267D410B0063E547 /* SeriesItemViewModel.swift */,
E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */,
E13DD3E027176BD3009D4DAF /* ServerListViewModel.swift */,
5321753A2671BCFC005491E6 /* SettingsViewModel.swift */,
C4BE0765271FC109003F4AD1 /* TVLibrariesViewModel.swift */,
E13DD3F82717E961009D4DAF /* UserListViewModel.swift */,
E13DD3EB27178A54009D4DAF /* UserSignInViewModel.swift */,
09389CC626819B4500AE350E /* VideoPlayerModel.swift */,
E1C812C9277AE40900918266 /* VideoPlayerViewModel.swift */,
625CB57B2678CE1000530A6E /* ViewModel.swift */,
);
path = ViewModels;
@ -1073,6 +1073,7 @@
E1AD105226D96D5F003E4A08 /* JellyfinAPIExtensions */,
621338922660107500A81A2A /* StringExtensions.swift */,
E13DD3C727164B1E009D4DAF /* UIDeviceExtensions.swift */,
E1C812C4277A90B200918266 /* URLComponentsExtensions.swift */,
6220D0AC26D5EABB00B8E046 /* ViewExtensions.swift */,
);
path = Extensions;
@ -1116,7 +1117,7 @@
C4BE0762271FC0BB003F4AD1 /* TVLibrariesCoordinator.swift */,
E13DD4012717EE79009D4DAF /* UserListCoordinator.swift */,
E13DD3F127179378009D4DAF /* UserSignInCoordinator.swift */,
6220D0C526D62D8700B8E046 /* VideoPlayerCoordinator.swift */,
E1C812CF277AE4C700918266 /* VideoPlayerCoordinator */,
);
path = Coordinators;
sourceTree = "<group>";
@ -1284,11 +1285,13 @@
E193D5452719418B00900D82 /* VideoPlayer */ = {
isa = PBXGroup;
children = (
53313B8F265EEA6D00947AA3 /* VideoPlayer.storyboard */,
535BAEA4264A151C005FA86D /* VideoPlayer.swift */,
532E68CE267D9F6B007B9F13 /* VideoPlayerCastDeviceSelector.swift */,
53F8377C265FF67C00F456B3 /* VideoPlayerSettingsView.swift */,
0959A5FC2686D29800C7C9A9 /* VideoUpNextView.swift */,
E1C812B9277A8E5D00918266 /* NativePlayerViewController.swift */,
E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */,
E1C812B5277A8E5D00918266 /* PlayerOverlayDelegate.swift */,
E1C812B8277A8E5D00918266 /* VideoPlayerView.swift */,
E1C812BB277A8E5D00918266 /* VLCPlayerCompactOverlayView.swift */,
E1C812B7277A8E5D00918266 /* VLCPlayerOverlayView.swift */,
E1C812B6277A8E5D00918266 /* VLCPlayerViewController.swift */,
);
path = VideoPlayer;
sourceTree = "<group>";
@ -1331,6 +1334,15 @@
path = Views;
sourceTree = "<group>";
};
E1C812CF277AE4C700918266 /* VideoPlayerCoordinator */ = {
isa = PBXGroup;
children = (
6220D0C526D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift */,
E1C812D0277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift */,
);
path = VideoPlayerCoordinator;
sourceTree = "<group>";
};
E1DD1127271E7D15005BE12F /* Objects */ = {
isa = PBXGroup;
children = (
@ -1531,7 +1543,6 @@
53913C0226D323FE00EB3286 /* Localizable.strings in Resources */,
53913C1426D323FE00EB3286 /* Localizable.strings in Resources */,
53913BF926D323FE00EB3286 /* Localizable.strings in Resources */,
5310695D2684E7EE00CFFDBA /* VideoPlayer.storyboard in Resources */,
534D4FF726A7D7CC000A7A48 /* Localizable.strings in Resources */,
53913BF326D323FE00EB3286 /* Localizable.strings in Resources */,
53913BF626D323FE00EB3286 /* Localizable.strings in Resources */,
@ -1562,7 +1573,6 @@
534D4FF626A7D7CC000A7A48 /* Localizable.strings in Resources */,
53913BF226D323FE00EB3286 /* Localizable.strings in Resources */,
53913BF526D323FE00EB3286 /* Localizable.strings in Resources */,
53313B90265EEA6D00947AA3 /* VideoPlayer.storyboard in Resources */,
53913C0426D323FE00EB3286 /* Localizable.strings in Resources */,
53913BFE26D323FE00EB3286 /* Localizable.strings in Resources */,
53913C0D26D323FE00EB3286 /* Localizable.strings in Resources */,
@ -1762,13 +1772,11 @@
buildActionMask = 2147483647;
files = (
E193D53627193F8500900D82 /* LibraryCoordinator.swift in Sources */,
531069572684E7EE00CFFDBA /* InfoTabBarViewController.swift in Sources */,
E18845F626DD631E00B0C5B7 /* BaseItemDto+Stackable.swift in Sources */,
E193D4DC27193CCA00900D82 /* PillStackable.swift in Sources */,
E193D53327193F7D00900D82 /* FilterCoordinator.swift in Sources */,
6267B3DC2671139500A7371D /* ImageExtensions.swift in Sources */,
C40CD929271F8DAB000FB198 /* MovieLibrariesView.swift in Sources */,
531069592684E7EE00CFFDBA /* SubtitlesView.swift in Sources */,
C4E5081D2703F8370045C9AB /* LibrarySearchView.swift in Sources */,
53ABFDE9267974EF00886593 /* HomeViewModel.swift in Sources */,
53116A17268B919A003024C9 /* SeriesItemView.swift in Sources */,
@ -1776,15 +1784,16 @@
531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */,
E11D224327378428003F9CB3 /* ServerDetailCoordinator.swift in Sources */,
E1D4BF8B2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */,
E1C812CB277AE40900918266 /* NativePlayerViewController.swift in Sources */,
E13DD3FA2717E961009D4DAF /* UserListViewModel.swift in Sources */,
C40CD926271F8D1E000FB198 /* MovieLibrariesViewModel.swift in Sources */,
62E632DE267D2E170063E547 /* LatestMediaViewModel.swift in Sources */,
E1FCD09726C47118007C8DCF /* ErrorMessage.swift in Sources */,
E193D53527193F8100900D82 /* ItemCoordinator.swift in Sources */,
53116A19268B947A003024C9 /* PlainLinkButton.swift in Sources */,
E193D53E27193F9A00900D82 /* VideoPlayerCoordinator.swift in Sources */,
536D3D88267C17350004248C /* PublicUserButton.swift in Sources */,
62E632EA267D3FF50063E547 /* SeasonItemViewModel.swift in Sources */,
E1C812CC277AE40A00918266 /* VideoPlayerView.swift in Sources */,
53CD2A40268A49C2002ABD4E /* ItemView.swift in Sources */,
53CD2A42268A4B38002ABD4E /* MovieItemView.swift in Sources */,
536D3D7F267BDF100004248C /* LatestMediaView.swift in Sources */,
@ -1804,7 +1813,6 @@
62E632ED267D410B0063E547 /* SeriesItemViewModel.swift in Sources */,
5398514526B64DA100101B49 /* SettingsView.swift in Sources */,
62E632F0267D43320063E547 /* LibraryFilterViewModel.swift in Sources */,
5310695A2684E7EE00CFFDBA /* VideoPlayer.swift in Sources */,
E193D54B271941D300900D82 /* ServerListView.swift in Sources */,
53ABFDE6267974EF00886593 /* SettingsViewModel.swift in Sources */,
62E632F4267D54030063E547 /* ItemViewModel.swift in Sources */,
@ -1823,13 +1831,13 @@
E193D53427193F7F00900D82 /* HomeCoordinator.swift in Sources */,
E193D5502719430400900D82 /* ServerDetailView.swift in Sources */,
E11B1B6D2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */,
E1C812D1277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift in Sources */,
E193D53D27193F9700900D82 /* UserSignInCoordinator.swift in Sources */,
535870A72669D8AE00D05A09 /* MultiSelectorView.swift in Sources */,
E1AD104E26D96CE3003E4A08 /* BaseItemDtoExtensions.swift in Sources */,
62E632DD267D2E130063E547 /* LibrarySearchViewModel.swift in Sources */,
536D3D81267BDFC60004248C /* PortraitItemElement.swift in Sources */,
62E1DCC4273CE19800C9AE76 /* URLExtensions.swift in Sources */,
5310695B2684E7EE00CFFDBA /* AudioView.swift in Sources */,
5398514726B64E4100101B49 /* SearchBarView.swift in Sources */,
091B5A8D268315D400D78B61 /* ServerDiscovery.swift in Sources */,
E1D4BF882719D27100A11E64 /* Bitrates.swift in Sources */,
@ -1844,24 +1852,25 @@
535870AA2669D8AE00D05A09 /* BlurHashDecode.swift in Sources */,
53ABFDE5267974EF00886593 /* ViewModel.swift in Sources */,
E19169CF272514760085832A /* HTTPScheme.swift in Sources */,
E1C812CD277AE40A00918266 /* VideoPlayerViewModel.swift in Sources */,
C45B29BB26FAC5B600CEF5E0 /* ColorExtension.swift in Sources */,
531069582684E7EE00CFFDBA /* MediaInfoView.swift in Sources */,
E1D4BF822719D22800A11E64 /* AppAppearance.swift in Sources */,
C40CD923271F8CD8000FB198 /* MoviesLibrariesCoordinator.swift in Sources */,
53272537268C1DBB0035FBF1 /* SeasonItemView.swift in Sources */,
09389CC526814E4500AE350E /* DeviceProfileBuilder.swift in Sources */,
E1C812D2277AE50A00918266 /* URLComponentsExtensions.swift in Sources */,
E193D53C27193F9500900D82 /* UserListCoordinator.swift in Sources */,
E13DD3C927164B1E009D4DAF /* UIDeviceExtensions.swift in Sources */,
535870A62669D8AE00D05A09 /* LazyView.swift in Sources */,
E193D53A27193F9000900D82 /* ServerListCoordinator.swift in Sources */,
6220D0AE26D5EABB00B8E046 /* ViewExtensions.swift in Sources */,
5321753E2671DE9C005491E6 /* Typings.swift in Sources */,
E1C812CA277AE40900918266 /* PlayerOverlayDelegate.swift in Sources */,
E1F0204F26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */,
53ABFDEB2679753200886593 /* ConnectToServerView.swift in Sources */,
6264E88D273850380081A12A /* Strings.swift in Sources */,
536D3D76267BA9BB0004248C /* MainTabViewModel.swift in Sources */,
E193D5512719432400900D82 /* ServerDetailViewModel.swift in Sources */,
5310695C2684E7EE00CFFDBA /* VideoPlayerViewController.swift in Sources */,
C4E5081B2703F82A0045C9AB /* LibraryListView.swift in Sources */,
E193D53B27193F9200900D82 /* SettingsCoordinator.swift in Sources */,
536D3D74267BA8170004248C /* BackgroundManager.swift in Sources */,
@ -1896,6 +1905,7 @@
E13DD3F227179378009D4DAF /* UserSignInCoordinator.swift in Sources */,
621338932660107500A81A2A /* StringExtensions.swift in Sources */,
53FF7F2A263CF3F500585C35 /* LatestMediaView.swift in Sources */,
E1C812C5277A90B200918266 /* URLComponentsExtensions.swift in Sources */,
62E632EC267D410B0063E547 /* SeriesItemViewModel.swift in Sources */,
625CB5732678C32A00530A6E /* HomeViewModel.swift in Sources */,
62C29EA826D103D500C1D2E7 /* LibraryListCoordinator.swift in Sources */,
@ -1906,6 +1916,7 @@
E1AD105626D981CE003E4A08 /* PortraitHStackView.swift in Sources */,
62C29EA126D102A500C1D2E7 /* iOSMainTabCoordinator.swift in Sources */,
C4BE076E2720FEA8003F4AD1 /* PortraitItemElement.swift in Sources */,
E1C812C0277A8E5D00918266 /* VideoPlayerView.swift in Sources */,
535BAE9F2649E569005FA86D /* ItemView.swift in Sources */,
C40CD925271F8D1E000FB198 /* MovieLibrariesViewModel.swift in Sources */,
6225FCCB2663841E00E067F6 /* ParallaxHeader.swift in Sources */,
@ -1918,36 +1929,37 @@
E18845F826DEA9C900B0C5B7 /* ItemViewBody.swift in Sources */,
E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */,
E188460426DEF04800B0C5B7 /* EpisodeCardVStackView.swift in Sources */,
53F8377D265FF67C00F456B3 /* VideoPlayerSettingsView.swift in Sources */,
E19169CE272514760085832A /* HTTPScheme.swift in Sources */,
53192D5D265AA78A008A4215 /* DeviceProfileBuilder.swift in Sources */,
62133890265F83A900A81A2A /* LibraryListView.swift in Sources */,
62C29EA326D1030F00C1D2E7 /* ConnectToServerCoodinator.swift in Sources */,
0959A5FD2686D29800C7C9A9 /* VideoUpNextView.swift in Sources */,
62E632DA267D2BC40063E547 /* LatestMediaViewModel.swift in Sources */,
E1AD105C26D9ABDD003E4A08 /* PillHStackView.swift in Sources */,
625CB56F2678C23300530A6E /* HomeView.swift in Sources */,
E1C812BC277A8E5D00918266 /* PlaybackSpeed.swift in Sources */,
E173DA5226D04AAF00CC4EB7 /* ColorExtension.swift in Sources */,
53892770263C25230035E14B /* NextUpView.swift in Sources */,
E11D224227378428003F9CB3 /* ServerDetailCoordinator.swift in Sources */,
6264E88C273850380081A12A /* Strings.swift in Sources */,
C4BE0766271FC109003F4AD1 /* TVLibrariesViewModel.swift in Sources */,
62ECA01826FA685A00E8EBB7 /* DeepLink.swift in Sources */,
535BAEA5264A151C005FA86D /* VideoPlayer.swift in Sources */,
62E632E6267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */,
E131691726C583BC0074BFEE /* LogConstructor.swift in Sources */,
5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */,
532E68CF267D9F6B007B9F13 /* VideoPlayerCastDeviceSelector.swift in Sources */,
E14F7D0926DB36F7007C3AE6 /* ItemLandscapeMainView.swift in Sources */,
532175402671EE4F005491E6 /* LibraryFilterView.swift in Sources */,
C4BE0763271FC0BB003F4AD1 /* TVLibrariesCoordinator.swift in Sources */,
53DF641E263D9C0600A7CD1A /* LibraryView.swift in Sources */,
E1C812BF277A8E5D00918266 /* VLCPlayerOverlayView.swift in Sources */,
E1C812CE277AE43100918266 /* VideoPlayerViewModel.swift in Sources */,
E1C812C3277A8E5D00918266 /* VLCPlayerCompactOverlayView.swift in Sources */,
E188460026DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift in Sources */,
091B5A8B2683142E00D78B61 /* UDPBroadCastConnection.swift in Sources */,
6267B3D626710B8900A7371D /* CollectionExtensions.swift in Sources */,
E13DD3F5271793BB009D4DAF /* UserSignInView.swift in Sources */,
E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */,
53649AB1269CFB1900A2D8B7 /* LogManager.swift in Sources */,
E1C812C1277A8E5D00918266 /* NativePlayerViewController.swift in Sources */,
E13DD3E127176BD3009D4DAF /* ServerListViewModel.swift in Sources */,
62E632E9267D3FF50063E547 /* SeasonItemViewModel.swift in Sources */,
62E632F3267D54030063E547 /* ItemViewModel.swift in Sources */,
@ -1960,14 +1972,16 @@
E14F7D0726DB36EF007C3AE6 /* ItemPortraitMainView.swift in Sources */,
E1AD106226D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift in Sources */,
53E4E649263F725B00F67C6B /* MultiSelectorView.swift in Sources */,
6220D0C626D62D8700B8E046 /* VideoPlayerCoordinator.swift in Sources */,
6220D0C626D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift in Sources */,
E11B1B6C2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */,
E1D4BF812719D22800A11E64 /* AppAppearance.swift in Sources */,
621338B32660A07800A81A2A /* LazyView.swift in Sources */,
6220D0B126D5EC9900B8E046 /* SettingsCoordinator.swift in Sources */,
E1C812BE277A8E5D00918266 /* VLCPlayerViewController.swift in Sources */,
62C29EA626D1036A00C1D2E7 /* HomeCoordinator.swift in Sources */,
531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */,
E13DD3E927177ED6009D4DAF /* ServerListCoordinator.swift in Sources */,
E1C812BD277A8E5D00918266 /* PlayerOverlayDelegate.swift in Sources */,
E13DD3BD27163C63009D4DAF /* EmailHelper.swift in Sources */,
E13DD3C227164941009D4DAF /* SwiftfinStore.swift in Sources */,
E1AD104A26D94822003E4A08 /* DetailItem.swift in Sources */,
@ -2379,7 +2393,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 66;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = "";
DEVELOPMENT_TEAM = TY84JMYEFE;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
EXCLUDED_ARCHS = "";
@ -2391,7 +2405,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.swiftfin;
PRODUCT_BUNDLE_IDENTIFIER = pips.swiftfin;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SUPPORTS_MACCATALYST = NO;
@ -2416,7 +2430,7 @@
CURRENT_PROJECT_VERSION = 66;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = "";
DEVELOPMENT_TEAM = TY84JMYEFE;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
EXCLUDED_ARCHS = "";
@ -2428,7 +2442,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.swiftfin;
PRODUCT_BUNDLE_IDENTIFIER = pips.swiftfin;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SUPPORTS_MACCATALYST = NO;
@ -2447,7 +2461,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 66;
DEVELOPMENT_TEAM = "";
DEVELOPMENT_TEAM = TY84JMYEFE;
INFOPLIST_FILE = WidgetExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
@ -2456,7 +2470,7 @@
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.swiftfin.widget;
PRODUCT_BUNDLE_IDENTIFIER = pips.swiftfin.widget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
@ -2474,7 +2488,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 66;
DEVELOPMENT_TEAM = "";
DEVELOPMENT_TEAM = TY84JMYEFE;
INFOPLIST_FILE = WidgetExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
@ -2483,7 +2497,7 @@
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.swiftfin.widget;
PRODUCT_BUNDLE_IDENTIFIER = pips.swiftfin.widget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;

View File

@ -7,6 +7,7 @@
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import AVFAudio
import SwiftUI
import UIKit
@ -17,6 +18,13 @@ class AppDelegate: NSObject, UIApplicationDelegate {
// Lazily initialize datastack
_ = SwiftfinStore.dataStack
let audioSession = AVAudioSession.sharedInstance()
do {
try audioSession.setCategory(.playback)
} catch {
print("setting category AVAudioSessionCategoryPlayback failed")
}
return true
}

View File

@ -9,11 +9,6 @@ import Introspect
import JellyfinAPI
import SwiftUI
class VideoPlayerItem: ObservableObject {
@Published var shouldShowPlayer: Bool = false
@Published var itemToPlay = BaseItemDto()
}
// Intermediary view for ItemView to set navigation bar settings
struct ItemNavigationView: View {
private let item: BaseItemDto
@ -31,10 +26,7 @@ struct ItemNavigationView: View {
private struct ItemView: View {
@EnvironmentObject var itemRouter: ItemCoordinator.Router
@State private var videoIsLoading: Bool = false // This variable is only changed by the underlying VLC view.
@State private var viewDidLoad: Bool = false
@State private var orientation: UIDeviceOrientation = .unknown
@StateObject private var videoPlayerItem = VideoPlayerItem()
@Environment(\.horizontalSizeClass) private var hSizeClass
@Environment(\.verticalSizeClass) private var vSizeClass
@ -91,19 +83,13 @@ private struct ItemView: View {
var body: some View {
Group {
if hSizeClass == .compact && vSizeClass == .regular {
ItemPortraitMainView(videoIsLoading: $videoIsLoading)
.environmentObject(videoPlayerItem)
ItemPortraitMainView()
.environmentObject(viewModel)
} else {
ItemLandscapeMainView(videoIsLoading: $videoIsLoading)
.environmentObject(videoPlayerItem)
ItemLandscapeMainView()
.environmentObject(viewModel)
}
}
.onReceive(videoPlayerItem.$shouldShowPlayer) { flag in
guard flag else { return }
self.itemRouter.route(to: \.videoPlayer, viewModel.item)
}
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
toolbarItemContent

View File

@ -12,13 +12,7 @@ import SwiftUI
struct ItemLandscapeMainView: View {
@EnvironmentObject var itemRouter: ItemCoordinator.Router
@Binding private var videoIsLoading: Bool
@EnvironmentObject private var viewModel: ItemViewModel
@EnvironmentObject private var videoPlayerItem: VideoPlayerItem
init(videoIsLoading: Binding<Bool>) {
self._videoIsLoading = videoIsLoading
}
// MARK: innerBody
@ -34,14 +28,10 @@ struct ItemLandscapeMainView: View {
Spacer().frame(height: 15)
// MARK: Play
Button {
if let playButtonItem = viewModel.playButtonItem {
self.videoPlayerItem.itemToPlay = playButtonItem
self.videoPlayerItem.shouldShowPlayer = true
}
self.itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!)
} label: {
// MARK: Play
HStack {
Image(systemName: "play.fill")
.foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white)

View File

@ -12,8 +12,8 @@ import JellyfinAPI
struct PortraitHeaderOverlayView: View {
@EnvironmentObject var itemRouter: ItemCoordinator.Router
@EnvironmentObject private var viewModel: ItemViewModel
@EnvironmentObject private var videoPlayerItem: VideoPlayerItem
var body: some View {
VStack(alignment: .leading) {
@ -75,10 +75,7 @@ struct PortraitHeaderOverlayView: View {
// MARK: Play
Button {
if let playButtonItem = viewModel.playButtonItem {
self.videoPlayerItem.itemToPlay = playButtonItem
self.videoPlayerItem.shouldShowPlayer = true
}
self.itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!)
} label: {
HStack {
Image(systemName: "play.fill")

View File

@ -11,14 +11,9 @@ import JellyfinAPI
import SwiftUI
struct ItemPortraitMainView: View {
@EnvironmentObject var itemRouter: ItemCoordinator.Router
@Binding private var videoIsLoading: Bool
@EnvironmentObject private var viewModel: ItemViewModel
@EnvironmentObject private var videoPlayerItem: VideoPlayerItem
init(videoIsLoading: Binding<Bool>) {
self._videoIsLoading = videoIsLoading
}
// MARK: portraitHeaderView

View File

@ -23,6 +23,7 @@ struct SettingsView: View {
@Default(.appAppearance) var appAppearance
@Default(.videoPlayerJumpForward) var jumpForwardLength
@Default(.videoPlayerJumpBackward) var jumpBackwardLength
@Default(.nativeVideoPlayer) var nativeVideoPlayer
var body: some View {
Form {
@ -83,6 +84,7 @@ struct SettingsView: View {
}
Section(header: Text("Playback")) {
Toggle("Native Player", isOn: $nativeVideoPlayer)
Picker("Default local quality", selection: $inNetworkStreamBitrate) {
ForEach(self.viewModel.bitrates, id: \.self) { bitrate in
Text(bitrate.name).tag(bitrate.value)

View File

@ -0,0 +1,121 @@
//
// NativePlayerViewController.swift
// JellyfinVideoPlayerDev
//
// Created by Ethan Pippin on 11/20/21.
//
import AVKit
import Combine
import JellyfinAPI
import UIKit
class NativePlayerViewController: AVPlayerViewController {
let viewModel: VideoPlayerViewModel
var timeObserverToken: Any?
var lastProgressTicks: Int64 = 0
init(viewModel: VideoPlayerViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
let player = AVPlayer(url: viewModel.hlsURL)
player.appliesMediaSelectionCriteriaAutomatically = false
player.currentItem?.externalMetadata = createMetadata()
let chevron = UIImage(systemName: "chevron.right.circle.fill")!
let testAction = UIAction(title: "Next", image: chevron) { action in
print("next item selected")
}
// tvos
// self.transportBarCustomMenuItems = [testAction]
let timeScale = CMTimeScale(NSEC_PER_SEC)
let time = CMTime(seconds: 5, preferredTimescale: timeScale)
timeObserverToken = player.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] time in
// print("Timer timed: \(time)")
if time.seconds != 0 {
self?.sendProgressReport(seconds: time.seconds)
}
}
self.player = player
self.allowsPictureInPicturePlayback = true
self.player?.allowsExternalPlayback = true
}
private func createMetadata() -> [AVMetadataItem] {
let allMetadata: [AVMetadataIdentifier: Any] = [
.commonIdentifierTitle: viewModel.title,
.iTunesMetadataTrackSubTitle: viewModel.subtitle ?? "",
.commonIdentifierArtwork: UIImage(data: try! Data(contentsOf: viewModel.item.getBackdropImage(maxWidth: 200)))?.pngData() as Any,
.commonIdentifierDescription: viewModel.item.overview ?? ""
]
return allMetadata.compactMap { createMetadataItem(for:$0, value:$1) }
}
private func createMetadataItem(for identifier: AVMetadataIdentifier,
value: Any) -> AVMetadataItem {
let item = AVMutableMetadataItem()
item.identifier = identifier
item.value = value as? NSCopying & NSObjectProtocol
// Specify "und" to indicate an undefined language.
item.extendedLanguageTag = "und"
return item.copy() as! AVMetadataItem
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
stop()
removePeriodicTimeObserver()
}
func removePeriodicTimeObserver() {
if let timeObserverToken = timeObserverToken {
player?.removeTimeObserver(timeObserverToken)
self.timeObserverToken = nil
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
player?.seek(to: CMTimeMake(value: viewModel.item.userData?.playbackPositionTicks ?? 0, timescale: 10_000_000), toleranceBefore: CMTimeMake(value: 5, timescale: 1), toleranceAfter: CMTimeMake(value: 5, timescale: 1), completionHandler: { _ in
self.play()
})
}
private func play() {
player?.play()
viewModel.sendPlayReport(startTimeTicks: viewModel.item.userData?.playbackPositionTicks ?? 0)
}
private func sendProgressReport(seconds: Double) {
viewModel.sendProgressReport(ticks: Int64(seconds) * 10_000_000)
}
private func stop() {
viewModel.sendStopReport(ticks: 10_000_000)
}
}

View File

@ -0,0 +1,40 @@
//
// PlaybackSpeed.swift
// JellyfinVideoPlayerDev
//
// Created by Ethan Pippin on 12/27/21.
//
import Foundation
enum PlaybackSpeed: Double, CaseIterable {
case quarter = 0.25
case half = 0.5
case threeQuarter = 0.75
case one = 1.0
case oneQuarter = 1.25
case oneHalf = 1.5
case oneThreeQuarter = 1.75
case two = 2.0
var displayTitle: String {
switch self {
case .quarter:
return "0.25x"
case .half:
return "0.5x"
case .threeQuarter:
return "0.75x"
case .one:
return "1x"
case .oneQuarter:
return "1.25x"
case .oneHalf:
return "1.5x"
case .oneThreeQuarter:
return "1.75x"
case .two:
return "2x"
}
}
}

View File

@ -0,0 +1,30 @@
//
// PlayerOverlayDelegate.swift
// JellyfinVideoPlayerDev
//
// Created by Ethan Pippin on 12/27/21.
//
import Foundation
protocol PlayerOverlayDelegate {
func didSelectClose()
func didSelectGoogleCast()
func didSelectAirplay()
func didSelectCaptions()
func didSelectMenu()
func didDeselectMenu()
func didSelectBackward()
func didSelectForward()
func didSelectMain()
func didGenerallyTap()
func didBeginScrubbing()
func didEndScrubbing(position: Double)
func didSelectAudioStream(index: Int)
func didSelectSubtitleStream(index: Int)
}

View File

@ -0,0 +1,271 @@
//
// VLCPlayerCompactOverlayView.swift
// JellyfinVideoPlayerDev
//
// Created by Ethan Pippin on 12/26/21.
//
import Combine
import MobileVLCKit
import SwiftUI
import JellyfinAPI
struct VLCPlayerCompactOverlayView: View {
@ObservedObject var viewModel: VideoPlayerViewModel
@ViewBuilder
private var mainButtonView: some View {
switch viewModel.playerState {
case .stopped, .paused:
Image(systemName: "play.fill")
.font(.system(size: 28, weight: .heavy, design: .default))
case .playing:
Image(systemName: "pause")
.font(.system(size: 28, weight: .heavy, design: .default))
default:
ProgressView()
}
}
@ViewBuilder
private var mainBody: some View {
VStack {
VStack(alignment: .EpisodeSeriesAlignmentGuide) {
// MARK: Top Bar
HStack(alignment: .top) {
VStack(alignment: .leading) {
HStack {
Button {
viewModel.playerOverlayDelegate?.didSelectClose()
} label: {
Image(systemName: "chevron.left.circle.fill")
.font(.system(size: 28, weight: .regular, design: .default))
}
Text(viewModel.title)
.font(.system(size: 28, weight: .regular, design: .default))
.alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in
context[.leading]
}
}
}
Spacer()
HStack(spacing: 20) {
if viewModel.shouldShowGoogleCast {
Button {
viewModel.playerOverlayDelegate?.didSelectGoogleCast()
} label: {
Image(systemName: "rectangle.badge.plus")
}
}
if viewModel.shouldShowAirplay {
Button {
viewModel.playerOverlayDelegate?.didSelectAirplay()
} label: {
Image(systemName: "airplayvideo")
}
}
Button {
viewModel.screenFilled = !viewModel.screenFilled
} label: {
if viewModel.screenFilled {
Image(systemName: "rectangle.arrowtriangle.2.inward")
.rotationEffect(Angle(degrees: 90))
} else {
Image(systemName: "rectangle.arrowtriangle.2.outward")
.rotationEffect(Angle(degrees: 90))
}
}
Button {
viewModel.playerOverlayDelegate?.didSelectCaptions()
} label: {
if viewModel.captionsEnabled {
Image(systemName: "captions.bubble.fill")
} else {
Image(systemName: "captions.bubble")
}
}
// MARK: Settings Menu
Menu {
Menu {
ForEach(viewModel.audioStreams, id: \.self) { audioStream in
Button {
viewModel.selectedAudioStreamIndex = audioStream.index ?? -1
} label: {
if audioStream.index == viewModel.selectedAudioStreamIndex {
Label.init(audioStream.displayTitle ?? "No Title", systemImage: "checkmark")
} else {
Text(audioStream.displayTitle ?? "No Title")
}
}
}
} label: {
HStack {
Image(systemName: "speaker.wave.3")
Text("Audio")
}
}
Menu {
ForEach(viewModel.subtitleStreams, id: \.self) { subtitleStream in
Button {
viewModel.selectedSubtitleStreamIndex = subtitleStream.index ?? -1
} label: {
if subtitleStream.index == viewModel.selectedSubtitleStreamIndex {
Label.init(subtitleStream.displayTitle ?? "No Title", systemImage: "checkmark")
} else {
Text(subtitleStream.displayTitle ?? "No Title")
}
}
}
} label: {
HStack {
Image(systemName: "captions.bubble")
Text("Subtitles")
}
}
Menu {
ForEach(PlaybackSpeed.allCases, id: \.self) { speed in
Button {
viewModel.playbackSpeed = speed
} label: {
if speed == viewModel.playbackSpeed {
Label(speed.displayTitle, systemImage: "checkmark")
} else {
Text(speed.displayTitle)
}
}
}
} label: {
HStack {
Image(systemName: "speedometer")
Text("Playback Speed")
}
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
.font(.system(size: 24))
if let seriesTitle = viewModel.subtitle {
Text(seriesTitle)
.font(.subheadline)
.foregroundColor(Color.gray)
.alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in
context[.leading]
}
.offset(y: -10)
}
}
Spacer()
// MARK: Bottom Bar
HStack {
HStack(spacing: 20) {
Button {
viewModel.playerOverlayDelegate?.didSelectBackward()
} label: {
Image(systemName: "gobackward.10")
}
Button {
viewModel.playerOverlayDelegate?.didSelectMain()
} label: {
mainButtonView
}
Button {
viewModel.playerOverlayDelegate?.didSelectForward()
} label: {
Image(systemName: "goforward.10")
}
}
.font(.system(size: 24, weight: .semibold, design: .default))
.padding(.trailing, 20)
Text(viewModel.leftLabelText)
.font(.system(size: 18, weight: .semibold, design: .default))
Slider(value: $viewModel.sliderPercentage) { editing in
viewModel.sliderIsScrubbing = editing
}
.foregroundColor(.purple)
.tint(.purple)
// ValueSlider(value: $viewModel.sliderPercentage)
// .valueSliderStyle(
// HorizontalValueSliderStyle(thumb: Circle().foregroundColor(.purple),
// thumbSize: CGSize(width: 32, height: 32),
// thumbInteractiveSize: CGSize(width: 50, height: 50),
// options: [.interactiveTrack])
// )
Text(viewModel.rightLabelText)
.font(.system(size: 18, weight: .semibold, design: .default))
}
.frame(height: 50)
}
.padding(.top)
.padding(.horizontal)
.ignoresSafeArea(edges: .top)
.tint(Color.white)
.foregroundColor(Color.white)
}
var body: some View {
mainBody
.background {
Color(uiColor: .black.withAlphaComponent(0.001))
.ignoresSafeArea()
.onTapGesture {
viewModel.playerOverlayDelegate?.didGenerallyTap()
}
}
}
}
struct VLCPlayerCompactOverlayView_Previews: PreviewProvider {
static var previews: some View {
ZStack {
Color.gray
.ignoresSafeArea()
VLCPlayerCompactOverlayView(viewModel: VideoPlayerViewModel(item: BaseItemDto(runTimeTicks: 123 * 10_000_000),
title: "Glorious Purpose",
subtitle: "Loki - S1E1",
streamURL: URL(string: "www.apple.com")!,
hlsURL: URL(string: "www.apple.com")!,
response: PlaybackInfoResponse(),
audioStreams: [MediaStream(displayTitle: "English", index: -1)],
subtitleStreams: [MediaStream(displayTitle: "None", index: -1)],
defaultAudioStreamIndex: -1,
defaultSubtitleStreamIndex: -1,
playerState: .playing,
shouldShowGoogleCast: false,
shouldShowAirplay: false,
subtitlesEnabled: true,
sliderPercentage: 0.5,
selectedAudioStreamIndex: -1,
selectedSubtitleStreamIndex: -1))
}
.previewInterfaceOrientation(.landscapeLeft)
}
}

View File

@ -0,0 +1,258 @@
//
// VLCPlayerOverlayView.swift
// JellyfinVideoPlayerDev
//
// Created by Ethan Pippin on 11/24/21.
//
import Combine
import MobileVLCKit
import SwiftUI
import JellyfinAPI
struct VLCPlayerOverlayView: View {
@ObservedObject var viewModel: VideoPlayerViewModel
@ViewBuilder
private var mainButtonView: some View {
switch viewModel.playerState {
case .stopped, .paused:
Image(systemName: "play")
.font(.system(size: 56))
case .playing:
Image(systemName: "pause")
.font(.system(size: 56))
default:
ProgressView()
}
}
@ViewBuilder
private var mainBody: some View {
VStack {
VStack(alignment: .EpisodeSeriesAlignmentGuide) {
// MARK: Top Bar
HStack(alignment: .top) {
VStack(alignment: .leading) {
HStack {
Button {
viewModel.playerOverlayDelegate?.didSelectClose()
} label: {
Image(systemName: "chevron.backward")
}
Text(viewModel.title)
.font(.system(size: 28, weight: .regular, design: .default))
.alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in
context[.leading]
}
}
}
Spacer()
HStack(spacing: 20) {
if viewModel.shouldShowGoogleCast {
Button {
viewModel.playerOverlayDelegate?.didSelectGoogleCast()
} label: {
Image(systemName: "rectangle.badge.plus")
}
}
if viewModel.shouldShowAirplay {
Button {
viewModel.playerOverlayDelegate?.didSelectAirplay()
} label: {
Image(systemName: "airplayvideo")
}
}
Button {
viewModel.playerOverlayDelegate?.didSelectCaptions()
} label: {
if viewModel.captionsEnabled {
Image(systemName: "captions.bubble.fill")
} else {
Image(systemName: "captions.bubble")
}
}
// MARK: Settings Menu
Menu {
Menu {
ForEach(viewModel.audioStreams, id: \.self) { audioStream in
Button {
viewModel.selectedAudioStreamIndex = audioStream.index ?? -1
} label: {
if audioStream.index == viewModel.selectedAudioStreamIndex {
Label.init(audioStream.displayTitle ?? "No Title", systemImage: "checkmark")
} else {
Text(audioStream.displayTitle ?? "No Title")
}
}
}
} label: {
HStack {
Image(systemName: "speaker.wave.3")
Text("Audio")
}
}
Menu {
ForEach(viewModel.subtitleStreams, id: \.self) { subtitleStream in
Button {
viewModel.selectedSubtitleStreamIndex = subtitleStream.index ?? -1
} label: {
if subtitleStream.index == viewModel.selectedSubtitleStreamIndex {
Label.init(subtitleStream.displayTitle ?? "No Title", systemImage: "checkmark")
} else {
Text(subtitleStream.displayTitle ?? "No Title")
}
}
}
} label: {
HStack {
Image(systemName: "captions.bubble")
Text("Subtitles")
}
}
Menu {
Button {
print("third pressed")
} label: {
Text("TODO")
}
} label: {
HStack {
Image(systemName: "speedometer")
Text("Playback Speed")
}
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
.font(.system(size: 24))
if let seriesTitle = viewModel.subtitle {
Text(seriesTitle)
.font(.subheadline)
.foregroundColor(Color.gray)
.alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in
context[.leading]
}
.offset(y: -10)
}
}
Spacer()
// MARK: Center Buttons
HStack(spacing: 80) {
Button {
viewModel.playerOverlayDelegate?.didSelectBackward()
} label: {
Image(systemName: "gobackward.10")
}
Button {
viewModel.playerOverlayDelegate?.didSelectMain()
} label: {
mainButtonView
}
Button {
viewModel.playerOverlayDelegate?.didSelectForward()
} label: {
Image(systemName: "goforward.10")
}
}
.font(.system(size: 48))
Spacer()
// MARK: Bottom Bar
HStack {
Text(viewModel.leftLabelText)
.font(.system(size: 18, weight: .semibold, design: .default))
Slider(value: $viewModel.sliderPercentage) { editing in
viewModel.sliderIsScrubbing = editing
}
.foregroundColor(.purple)
.tint(.purple)
Text(viewModel.rightLabelText)
.font(.system(size: 18, weight: .semibold, design: .default))
}
.frame(height: 50)
}
.padding(.top)
.ignoresSafeArea(edges: .vertical)
.tint(Color.white)
.foregroundColor(Color.white)
}
var body: some View {
mainBody
.background {
Color(uiColor: .black.withAlphaComponent(0.2))
.ignoresSafeArea()
.onTapGesture {
viewModel.playerOverlayDelegate?.didGenerallyTap()
}
}
}
}
struct VLCPlayerOverlayView_Previews: PreviewProvider {
static var previews: some View {
ZStack {
Color.gray
.ignoresSafeArea()
VLCPlayerOverlayView(viewModel: VideoPlayerViewModel(item: BaseItemDto(),
title: "Glorious Purpose",
subtitle: "Loki - S1E1",
streamURL: URL(string: "www.apple.com")!,
hlsURL: URL(string: "www.apple.com")!,
response: PlaybackInfoResponse(),
audioStreams: [MediaStream(displayTitle: "English", index: -1)],
subtitleStreams: [MediaStream(displayTitle: "None", index: -1)],
defaultAudioStreamIndex: -1,
defaultSubtitleStreamIndex: -1,
playerState: .playing,
shouldShowGoogleCast: false,
shouldShowAirplay: false,
subtitlesEnabled: true,
sliderPercentage: 0.0,
selectedAudioStreamIndex: -1,
selectedSubtitleStreamIndex: -1))
}
.previewInterfaceOrientation(.landscapeLeft)
}
}
extension HorizontalAlignment {
private struct EpisodeSeriesTitleAlignment: AlignmentID {
static func defaultValue(in context: ViewDimensions) -> CGFloat {
context[HorizontalAlignment.leading]
}
}
static let EpisodeSeriesAlignmentGuide = HorizontalAlignment(EpisodeSeriesTitleAlignment.self)
}

View File

@ -0,0 +1,445 @@
//
// PlayerViewController.swift
// JellyfinVideoPlayerDev
//
// Created by Ethan Pippin on 11/12/21.
//
import AVKit
import AVFoundation
import Combine
import JellyfinAPI
import MediaPlayer
import MobileVLCKit
import SwiftUI
import UIKit
class VLCPlayerViewController: UIViewController {
// MARK: variables
private let viewModel: VideoPlayerViewModel
private var vlcMediaPlayer = VLCMediaPlayer()
private var lastPlayerTicks: Int64
private var cancellables = Set<AnyCancellable>()
private var overlayDismissTimer: Timer?
private var currentPlayerTicks: Int64 {
return Int64(vlcMediaPlayer.time.intValue) * 100_000
}
private var displayingOverlay: Bool {
return overlayHostingController.view.alpha > 0
}
private lazy var videoContentView = makeVideoContentView()
private lazy var tapGestureView = makeTapGestureView()
private lazy var overlayHostingController = makeOverlayHostingController()
// MARK: init
init(viewModel: VideoPlayerViewModel) {
self.viewModel = viewModel
self.lastPlayerTicks = viewModel.item.userData?.playbackPositionTicks ?? 0
super.init(nibName: nil, bundle: nil)
viewModel.playerOverlayDelegate = self
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupSubviews() {
view.addSubview(videoContentView)
view.addSubview(tapGestureView)
addChild(overlayHostingController)
overlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false
overlayHostingController.view.backgroundColor = UIColor.black.withAlphaComponent(0.2)
view.addSubview(overlayHostingController.view)
overlayHostingController.didMove(toParent: self)
}
private func setupConstraints() {
NSLayoutConstraint.activate([
videoContentView.topAnchor.constraint(equalTo: view.topAnchor),
videoContentView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
videoContentView.leftAnchor.constraint(equalTo: view.leftAnchor),
videoContentView.rightAnchor.constraint(equalTo: view.rightAnchor)
])
NSLayoutConstraint.activate([
tapGestureView.topAnchor.constraint(equalTo: videoContentView.topAnchor),
tapGestureView.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor),
tapGestureView.leftAnchor.constraint(equalTo: videoContentView.leftAnchor),
tapGestureView.rightAnchor.constraint(equalTo: videoContentView.rightAnchor)
])
NSLayoutConstraint.activate([
overlayHostingController.view.topAnchor.constraint(equalTo: view.topAnchor),
overlayHostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
overlayHostingController.view.leftAnchor.constraint(equalTo: view.leftAnchor),
overlayHostingController.view.rightAnchor.constraint(equalTo: view.rightAnchor)
])
}
// MARK: viewWillAppear
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// AppUtility.lockOrientation(.all, andRotateTo: .landscapeLeft)
}
// MARK: viewWillDisappear
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
// AppUtility.lockOrientation(.all)
}
// MARK: viewDidLoad
override func viewDidLoad() {
super.viewDidLoad()
setupSubviews()
setupConstraints()
setupViewModelListeners()
view.backgroundColor = .black
setupMediaPlayer()
}
// MARK: setupViewModelListeners
private func setupViewModelListeners() {
viewModel.$playbackSpeed.sink { newSpeed in
self.vlcMediaPlayer.rate = Float(newSpeed.rawValue)
}.store(in: &cancellables)
viewModel.$screenFilled.sink { shouldFill in
self.changeFill(to: shouldFill)
}.store(in: &cancellables)
viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in
if !sliderIsScrubbing {
self.didEndScrubbing(position: self.viewModel.sliderPercentage)
}
}.store(in: &cancellables)
viewModel.$selectedAudioStreamIndex.sink { newAudioStreamIndex in
self.didSelectAudioStream(index: newAudioStreamIndex)
}.store(in: &cancellables)
viewModel.$selectedSubtitleStreamIndex.sink { newSubtitleStreamIndex in
self.didSelectSubtitleStream(index: newSubtitleStreamIndex)
}.store(in: &cancellables)
}
private func changeFill(to shouldFill: Bool) {
if shouldFill {
// TODO: May not be possible with current VLCKit
// let drawableView = vlcMediaPlayer.drawable as! UIView
// let drawableViewSize = drawableView.frame.size
// let mediaSize = vlcMediaPlayer.videoSize
// Largest size from mediaSize is how it is currently filled
// in the drawable view, find scaleFactor by filling entire
// drawableView
vlcMediaPlayer.scaleFactor = 1.5
} else {
vlcMediaPlayer.scaleFactor = 0
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
startPlayback()
restartOverlayDismissTimer()
}
// MARK: subviews
private func makeVideoContentView() -> UIView {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = .black
return view
}
private func makeTapGestureView() -> UIView {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = .clear
let singleTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap))
let rightSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didRightSwipe))
rightSwipeGesture.direction = .right
let leftSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didLeftSwipe))
leftSwipeGesture.direction = .left
view.addGestureRecognizer(singleTapGesture)
view.addGestureRecognizer(rightSwipeGesture)
view.addGestureRecognizer(leftSwipeGesture)
return view
}
@objc private func didTap() {
self.didGenerallyTap()
}
@objc private func didRightSwipe() {
self.didSelectForward()
}
@objc private func didLeftSwipe() {
self.didSelectBackward()
}
private func makeOverlayHostingController() -> UIHostingController<VLCPlayerCompactOverlayView> {
let overlayView = VLCPlayerCompactOverlayView(viewModel: viewModel)
return UIHostingController(rootView: overlayView)
}
}
// MARK: setupMediaPlayer
extension VLCPlayerViewController {
func setupMediaPlayer() {
vlcMediaPlayer.delegate = self
vlcMediaPlayer.drawable = videoContentView
vlcMediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 14)
let media = VLCMedia(url: viewModel.streamURL)
media.addOption("--prefetch-buffer-size=1048576")
media.addOption("--network-caching=5000")
vlcMediaPlayer.media = media
}
func startPlayback() {
vlcMediaPlayer.play()
viewModel.sendPlayReport(startTimeTicks: viewModel.item.userData?.playbackPositionTicks ?? 0)
// 1 second = 10,000,000 ticks
let startTicks: Int64 = viewModel.item.userData?.playbackPositionTicks ?? 0
if startTicks != 0 {
let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000)
let secondsScrubbedTo = startTicks / 10_000_000
let offset = secondsScrubbedTo - Int64(videoPosition)
if offset > 0 {
vlcMediaPlayer.jumpForward(Int32(offset))
} else {
vlcMediaPlayer.jumpBackward(Int32(abs(offset)))
}
}
}
}
// MARK: Show/Hide Overlay
extension VLCPlayerViewController {
private func showOverlay() {
guard overlayHostingController.view.alpha != 1 else { return }
UIView.animate(withDuration: 0.2) {
self.overlayHostingController.view.alpha = 1
}
}
private func hideOverlay() {
guard overlayHostingController.view.alpha != 0 else { return }
UIView.animate(withDuration: 0.2) {
self.overlayHostingController.view.alpha = 0
}
}
private func toggleOverlay() {
if overlayHostingController.view.alpha < 1 {
showOverlay()
} else {
hideOverlay()
}
}
}
// MARK: OverlayTimer
extension VLCPlayerViewController {
private func restartOverlayDismissTimer(interval: Double = 2) {
self.overlayDismissTimer?.invalidate()
self.overlayDismissTimer = Timer.scheduledTimer(timeInterval: interval, target: self, selector: #selector(dismissTimerFired), userInfo: nil, repeats: false)
}
@objc private func dismissTimerFired() {
print("Dismiss timer fired")
self.hideOverlay()
}
private func stopOverlayDismissTimer() {
self.overlayDismissTimer?.invalidate()
}
}
// MARK: VLCMediaPlayerDelegate
extension VLCPlayerViewController: VLCMediaPlayerDelegate {
func mediaPlayerStateChanged(_ aNotification: Notification!) {
self.viewModel.playerState = vlcMediaPlayer.state
print("Player state changed: \(viewModel.playerState.rawValue)")
}
func mediaPlayerTimeChanged(_ aNotification: Notification!) {
guard !viewModel.sliderIsScrubbing else {
lastPlayerTicks = currentPlayerTicks
return
}
viewModel.sliderPercentage = Double(vlcMediaPlayer.position)
if abs(currentPlayerTicks - lastPlayerTicks) >= 10000 {
viewModel.playerState = VLCMediaPlayerState.playing
}
lastPlayerTicks = currentPlayerTicks
// if CACurrentMediaTime() - lastProgressReportTime > 5 {
// mediaPlayer.currentVideoSubTitleIndex = selectedCaptionTrack
// sendProgressReport(eventName: "timeupdate")
// lastProgressReportTime = CACurrentMediaTime()
// }
}
}
// MARK: PlayerOverlayDelegate
extension VLCPlayerViewController: PlayerOverlayDelegate {
func didSelectAudioStream(index: Int) {
vlcMediaPlayer.currentAudioTrackIndex = Int32(index)
print("New audio index: \(index)")
}
func didSelectSubtitleStream(index: Int) {
vlcMediaPlayer.currentVideoSubTitleIndex = Int32(index)
if index != -1 {
// set in case weren't shown
viewModel.captionsEnabled = true
}
print("New subtitle index: \(index)")
}
func didSelectClose() {
vlcMediaPlayer.stop()
viewModel.sendStopReport(ticks: currentPlayerTicks)
dismiss(animated: true, completion: nil)
}
func didSelectGoogleCast() {
print("didSelectCast")
}
func didSelectAirplay() {
print("didSelectAirplay")
}
func didSelectCaptions() {
viewModel.captionsEnabled = !viewModel.captionsEnabled
if viewModel.captionsEnabled {
vlcMediaPlayer.currentVideoSubTitleIndex = vlcMediaPlayer.videoSubTitlesIndexes[1] as! Int32
} else {
vlcMediaPlayer.currentVideoSubTitleIndex = -1
}
}
// TODO: Implement properly in overlays
func didSelectMenu() {
stopOverlayDismissTimer()
}
// TODO: Implement properly in overlays
func didDeselectMenu() {
restartOverlayDismissTimer()
}
func didSelectBackward() {
vlcMediaPlayer.jumpBackward(10)
restartOverlayDismissTimer()
}
func didSelectForward() {
vlcMediaPlayer.jumpForward(10)
restartOverlayDismissTimer()
}
func didSelectMain() {
switch viewModel.playerState {
case .stopped: ()
case .opening: ()
case .buffering:
vlcMediaPlayer.play()
restartOverlayDismissTimer()
case .ended: ()
case .error: ()
case .playing:
vlcMediaPlayer.pause()
restartOverlayDismissTimer(interval: 5)
case .paused:
vlcMediaPlayer.play()
case .esAdded: ()
default: ()
}
}
func didGenerallyTap() {
toggleOverlay()
restartOverlayDismissTimer(interval: 5)
}
func didBeginScrubbing() {
}
func didEndScrubbing(position: Double) {
let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000)
let videoDuration = Double(viewModel.item.runTimeTicks! / 10_000_000)
let secondsScrubbedTo = round(viewModel.sliderPercentage * videoDuration)
let newPositionOffset = secondsScrubbedTo - videoPosition
if newPositionOffset > 0 {
vlcMediaPlayer.jumpForward(Int32(newPositionOffset))
} else {
vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset)))
}
print("Scrubbed position: \(position)")
}
}

View File

@ -1,252 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_5" orientation="landscape" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
<capability name="Image references" minToolsVersion="12.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Player View Controller-->
<scene sceneID="s0d-6b-0kx">
<objects>
<viewController storyboardIdentifier="VideoPlayer" id="Y6W-OH-hqX" customClass="PlayerViewController" customModule="JellyfinPlayer_iOS" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" autoresizesSubviews="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="IQg-r0-AeH">
<rect key="frame" x="0.0" y="0.0" width="896" height="414"/>
<subviews>
<view autoresizesSubviews="NO" tag="1" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Tsh-rC-BwO" userLabel="VideoContentView">
<rect key="frame" x="31" y="0.0" width="834" height="414"/>
<viewLayoutGuide key="safeArea" id="aVY-BC-PZU"/>
<color key="backgroundColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<gestureRecognizers/>
<connections>
<outletCollection property="gestureRecognizers" destination="Tag-oM-Uha" appends="YES" id="AlY-fE-iBg"/>
</connections>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Qcb-Fb-qZl" userLabel="VideoControlsView">
<rect key="frame" x="0.0" y="0.0" width="896" height="414"/>
<subviews>
<slider opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" minValue="0.0" maxValue="1" translatesAutoresizingMaskIntoConstraints="NO" id="e9f-8l-RdN" userLabel="SeekSlider">
<rect key="frame" x="133" y="355" width="630" height="31"/>
<color key="tintColor" red="0.66666666666666663" green="0.36078431372549019" blue="0.76470588235294112" alpha="1" colorSpace="calibratedRGB"/>
<color key="thumbTintColor" red="0.66666666666666663" green="0.36078431372549019" blue="0.76470588235294112" alpha="1" colorSpace="calibratedRGB"/>
<connections>
<action selector="seekSliderEnd:" destination="Y6W-OH-hqX" eventType="touchUpInside" id="m4l-h6-V0d"/>
<action selector="seekSliderStart:" destination="Y6W-OH-hqX" eventType="touchDown" id="it4-Bp-hPL"/>
<action selector="seekSliderValueChanged:" destination="Y6W-OH-hqX" eventType="valueChanged" id="tfF-Zl-CdU"/>
</connections>
</slider>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="-:--:--" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="qft-iu-f1z" userLabel="Time Left Text">
<rect key="frame" x="766" y="353" width="91" height="34"/>
<constraints>
<constraint firstAttribute="width" constant="91" id="LbL-h0-EYA"/>
<constraint firstAttribute="height" constant="34" id="OkD-Dr-Ina"/>
</constraints>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="18"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="-:--:--" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="knf-PP-UIS" userLabel="Time Text">
<rect key="frame" x="39" y="353" width="91" height="34"/>
<constraints>
<constraint firstAttribute="width" constant="91" id="FcP-Mk-OIL"/>
<constraint firstAttribute="height" constant="34" id="yXx-PI-kXn"/>
</constraints>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="18"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="t2L-Oz-fe9" userLabel="MainActionButton">
<rect key="frame" x="406.66666666666669" y="165.66666666666666" width="83" height="83"/>
<constraints>
<constraint firstAttribute="width" constant="83" id="PdD-nW-y9r"/>
<constraint firstAttribute="height" constant="83" id="e9j-PI-Ic4"/>
</constraints>
<fontDescription key="fontDescription" type="system" pointSize="18"/>
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<state key="normal">
<imageReference key="image" image="play.slash.fill" catalog="system" symbolScale="default"/>
<preferredSymbolConfiguration key="preferredSymbolConfiguration" configurationType="pointSize" pointSize="55" scale="default"/>
</state>
<connections>
<action selector="mainActionButtonPressed:" destination="Y6W-OH-hqX" eventType="touchUpInside" id="qBH-T0-6R4"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="rLx-SN-RHr">
<rect key="frame" x="30" y="22" width="60" height="60"/>
<constraints>
<constraint firstAttribute="height" constant="60" id="jwh-l2-ARL"/>
<constraint firstAttribute="width" constant="60" id="rcS-W1-m4V"/>
</constraints>
<fontDescription key="fontDescription" type="system" pointSize="22"/>
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<state key="normal" image="chevron.backward" catalog="system">
<color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<preferredSymbolConfiguration key="preferredSymbolConfiguration" configurationType="pointSize" pointSize="24"/>
</state>
<connections>
<action selector="exitButtonPressed:" destination="Y6W-OH-hqX" eventType="touchUpInside" id="XHc-OR-kc8"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="bYM-Xp-bZO">
<rect key="frame" x="213" y="169" width="75" height="76"/>
<constraints>
<constraint firstAttribute="height" constant="76" id="5lC-V1-lHH"/>
<constraint firstAttribute="width" constant="75" id="IPn-pO-Rxo"/>
</constraints>
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<state key="normal" image="gobackward.15" catalog="system">
<preferredSymbolConfiguration key="preferredSymbolConfiguration" configurationType="pointSize" pointSize="35"/>
</state>
<connections>
<action selector="jumpBackTapped:" destination="Y6W-OH-hqX" eventType="touchUpInside" id="4vd-25-cCB"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="An8-jF-FAY">
<rect key="frame" x="608" y="169" width="75" height="76"/>
<constraints>
<constraint firstAttribute="height" constant="76" id="huv-QZ-HSc"/>
<constraint firstAttribute="width" constant="75" id="uPN-A8-EV1"/>
</constraints>
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<state key="normal" image="goforward.30" catalog="system">
<preferredSymbolConfiguration key="preferredSymbolConfiguration" configurationType="pointSize" pointSize="35"/>
</state>
<connections>
<action selector="jumpForwardTapped:" destination="Y6W-OH-hqX" eventType="touchUpInside" id="I6H-fd-Mn8"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" id="riN-y1-ABZ">
<rect key="frame" x="817" y="32" width="40" height="40"/>
<autoresizingMask key="autoresizingMask" flexibleMinX="YES" flexibleMaxY="YES"/>
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<state key="normal" image="gear" catalog="system">
<color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<preferredSymbolConfiguration key="preferredSymbolConfiguration" configurationType="pointSize" pointSize="23"/>
</state>
<connections>
<action selector="settingsButtonTapped:" destination="Y6W-OH-hqX" eventType="touchUpInside" id="NeC-px-2TY"/>
</connections>
</button>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Loading" textAlignment="center" lineBreakMode="tailTruncation" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="o8N-R1-DhT">
<rect key="frame" x="106" y="23.333333333333332" width="684" height="57.666666666666671"/>
<accessibility key="accessibilityConfiguration">
<accessibilityTraits key="traits" staticText="YES" header="YES"/>
</accessibility>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="19"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<color key="highlightedColor" systemColor="labelColor"/>
</label>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" id="XVc-27-uDe" userLabel="Cast Button">
<rect key="frame" x="766" y="32" width="40" height="40"/>
<autoresizingMask key="autoresizingMask" flexibleMinX="YES" flexibleMaxY="YES"/>
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<state key="normal">
<color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<imageReference key="image" image="CastDisconnected"/>
<preferredSymbolConfiguration key="preferredSymbolConfiguration" scale="large"/>
</state>
<connections>
<action selector="castButtonPressed:" destination="Y6W-OH-hqX" eventType="touchUpInside" id="LwK-pi-uQ2"/>
</connections>
</button>
</subviews>
<color key="backgroundColor" white="0.0" alpha="0.5954241071428571" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<gestureRecognizers/>
<constraints>
<constraint firstItem="t2L-Oz-fe9" firstAttribute="top" secondItem="o8N-R1-DhT" secondAttribute="bottom" constant="84.666666666666657" id="1y1-QQ-N95"/>
<constraint firstAttribute="bottom" secondItem="e9f-8l-RdN" secondAttribute="bottom" constant="29" id="231-rB-qDs"/>
<constraint firstAttribute="trailing" secondItem="qft-iu-f1z" secondAttribute="trailing" constant="39" id="2Ie-OW-sUL"/>
<constraint firstItem="An8-jF-FAY" firstAttribute="leading" secondItem="t2L-Oz-fe9" secondAttribute="trailing" constant="118.5" id="2zE-ul-pOh"/>
<constraint firstItem="An8-jF-FAY" firstAttribute="centerY" secondItem="t2L-Oz-fe9" secondAttribute="centerY" id="36i-Q2-D1K"/>
<constraint firstItem="t2L-Oz-fe9" firstAttribute="centerX" secondItem="Qcb-Fb-qZl" secondAttribute="centerX" id="3Gw-QD-lQX"/>
<constraint firstItem="o8N-R1-DhT" firstAttribute="centerY" secondItem="riN-y1-ABZ" secondAttribute="centerY" id="Hs5-Bc-iPB"/>
<constraint firstAttribute="bottom" secondItem="qft-iu-f1z" secondAttribute="bottom" constant="27" id="NPi-py-0qd"/>
<constraint firstItem="rLx-SN-RHr" firstAttribute="leading" secondItem="Qcb-Fb-qZl" secondAttribute="leading" constant="30" id="Oe7-LK-6Tl"/>
<constraint firstItem="e9f-8l-RdN" firstAttribute="leading" secondItem="knf-PP-UIS" secondAttribute="trailing" constant="5" id="ShK-80-ij1"/>
<constraint firstItem="t2L-Oz-fe9" firstAttribute="centerY" secondItem="Qcb-Fb-qZl" secondAttribute="centerY" id="TOk-sG-UXV"/>
<constraint firstItem="knf-PP-UIS" firstAttribute="leading" secondItem="Qcb-Fb-qZl" secondAttribute="leading" constant="39" id="XNC-Q4-nE0"/>
<constraint firstItem="o8N-R1-DhT" firstAttribute="centerX" secondItem="t2L-Oz-fe9" secondAttribute="centerX" id="a5g-8U-9S5"/>
<constraint firstAttribute="bottom" secondItem="qft-iu-f1z" secondAttribute="bottom" constant="27" id="aOB-Uz-cbQ"/>
<constraint firstItem="qft-iu-f1z" firstAttribute="leading" secondItem="e9f-8l-RdN" secondAttribute="trailing" constant="5" id="auL-Vv-ZMV"/>
<constraint firstItem="bYM-Xp-bZO" firstAttribute="top" secondItem="An8-jF-FAY" secondAttribute="top" id="cVS-eI-vv2"/>
<constraint firstItem="t2L-Oz-fe9" firstAttribute="leading" secondItem="bYM-Xp-bZO" secondAttribute="trailing" constant="118.5" id="fci-L5-1f6"/>
<constraint firstItem="e9f-8l-RdN" firstAttribute="centerX" secondItem="Qcb-Fb-qZl" secondAttribute="centerX" id="jFy-Sb-aYi"/>
<constraint firstAttribute="bottom" secondItem="knf-PP-UIS" secondAttribute="bottom" constant="27" id="nLN-ju-9qC"/>
<constraint firstItem="o8N-R1-DhT" firstAttribute="leading" secondItem="rLx-SN-RHr" secondAttribute="trailing" constant="16" id="qnV-Qf-y9m"/>
<constraint firstItem="rLx-SN-RHr" firstAttribute="top" secondItem="Qcb-Fb-qZl" secondAttribute="top" constant="22" id="v4G-B1-7y6"/>
</constraints>
<connections>
<outletCollection property="gestureRecognizers" destination="iQW-fW-KWT" appends="YES" id="H09-88-nzQ"/>
</connections>
</view>
<view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="CY9-gw-dv8" userLabel="UpNextView">
<rect key="frame" x="672" y="254" width="224" height="160"/>
<color key="backgroundColor" red="0.34509803921568627" green="0.33725490196078434" blue="0.83921568627450982" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstAttribute="height" constant="160" id="IyL-p4-Y54"/>
<constraint firstAttribute="width" constant="224" id="rFU-Nq-Qmj"/>
</constraints>
</view>
</subviews>
<viewLayoutGuide key="safeArea" id="zud-b9-RyD"/>
<color key="backgroundColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<gestureRecognizers/>
<constraints>
<constraint firstItem="Qcb-Fb-qZl" firstAttribute="top" secondItem="zud-b9-RyD" secondAttribute="top" id="0rU-S8-2ZG"/>
<constraint firstItem="Tsh-rC-BwO" firstAttribute="bottom" secondItem="IQg-r0-AeH" secondAttribute="bottom" id="CLQ-xL-eMg"/>
<constraint firstAttribute="trailing" secondItem="CY9-gw-dv8" secondAttribute="trailing" id="GAY-9O-TMP"/>
<constraint firstItem="zud-b9-RyD" firstAttribute="trailing" secondItem="Tsh-rC-BwO" secondAttribute="trailing" constant="-13" id="MTY-zG-Jfx"/>
<constraint firstAttribute="trailing" secondItem="Qcb-Fb-qZl" secondAttribute="trailing" id="N96-TI-UDZ"/>
<constraint firstAttribute="trailing" secondItem="CY9-gw-dv8" secondAttribute="trailing" id="VY1-j7-qK2"/>
<constraint firstAttribute="bottom" secondItem="CY9-gw-dv8" secondAttribute="bottom" id="Wtk-gJ-gF4"/>
<constraint firstItem="Qcb-Fb-qZl" firstAttribute="leading" secondItem="IQg-r0-AeH" secondAttribute="leading" id="ctC-7w-DiS"/>
<constraint firstItem="Tsh-rC-BwO" firstAttribute="leading" secondItem="zud-b9-RyD" secondAttribute="leading" constant="-13" id="cw7-9C-iua"/>
<constraint firstItem="Tsh-rC-BwO" firstAttribute="top" secondItem="zud-b9-RyD" secondAttribute="top" id="d4Q-bp-K4m"/>
<constraint firstAttribute="bottom" secondItem="Qcb-Fb-qZl" secondAttribute="bottom" id="gmY-zx-4Ed"/>
</constraints>
</view>
<connections>
<outlet property="castButton" destination="XVc-27-uDe" id="FII-I9-nHf"/>
<outlet property="jumpBackButton" destination="bYM-Xp-bZO" id="K2u-5Q-dkm"/>
<outlet property="jumpForwardButton" destination="An8-jF-FAY" id="4hN-YB-yVd"/>
<outlet property="mainActionButton" destination="t2L-Oz-fe9" id="nQR-2e-64l"/>
<outlet property="playerSettingsButton" destination="riN-y1-ABZ" id="I6r-z9-Jy2"/>
<outlet property="seekSlider" destination="e9f-8l-RdN" id="b3H-tn-TPG"/>
<outlet property="timeLeftText" destination="qft-iu-f1z" id="cSg-fO-9nF"/>
<outlet property="timeText" destination="knf-PP-UIS" id="KhK-BX-rqT"/>
<outlet property="titleLabel" destination="o8N-R1-DhT" id="E7D-iU-bMi"/>
<outlet property="upNextView" destination="CY9-gw-dv8" id="BP6-bc-6Vk"/>
<outlet property="videoContentView" destination="Tsh-rC-BwO" id="5uR-No-wLy"/>
<outlet property="videoControlsView" destination="Qcb-Fb-qZl" id="Z1U-Qr-8ND"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="Ief-a0-LHa" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
<tapGestureRecognizer id="Tag-oM-Uha">
<connections>
<action selector="contentViewTapped:" destination="Y6W-OH-hqX" id="uq5-EN-60x"/>
</connections>
</tapGestureRecognizer>
<tapGestureRecognizer id="iQW-fW-KWT">
<connections>
<action selector="controlViewTapped:" destination="Y6W-OH-hqX" id="0lD-A7-3TP"/>
</connections>
</tapGestureRecognizer>
</objects>
<point key="canvasLocation" x="129.24107142857142" y="71.014492753623188"/>
</scene>
</scenes>
<resources>
<image name="CastDisconnected" width="24" height="24"/>
<image name="chevron.backward" catalog="system" width="96" height="128"/>
<image name="gear" catalog="system" width="128" height="119"/>
<image name="gobackward.15" catalog="system" width="121" height="128"/>
<image name="goforward.30" catalog="system" width="121" height="128"/>
<image name="play.slash.fill" catalog="system" width="116" height="128"/>
<systemColor name="labelColor">
<color white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>

File diff suppressed because it is too large Load Diff

View File

@ -1,93 +0,0 @@
/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public
* License, v2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import Foundation
import SwiftUI
class VideoPlayerCastDeviceSelectorView: UIViewController {
private var contentView: UIHostingController<VideoPlayerCastDeviceSelector>!
weak var delegate: PlayerViewController?
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
.landscape
}
override func viewDidLoad() {
super.viewDidLoad()
contentView = UIHostingController(rootView: VideoPlayerCastDeviceSelector(delegate: self.delegate ?? PlayerViewController()))
self.view.addSubview(contentView.view)
contentView.view.translatesAutoresizingMaskIntoConstraints = false
contentView.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
contentView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
contentView.view.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
contentView.view.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
self.delegate?.castPopoverDismissed()
}
}
struct VideoPlayerCastDeviceSelector: View {
weak var delegate: PlayerViewController!
init(delegate: PlayerViewController) {
self.delegate = delegate
}
var body: some View {
NavigationView {
Group {
if !delegate.discoveredCastDevices.isEmpty {
List(delegate.discoveredCastDevices, id: \.deviceID) { device in
HStack {
Text(device.friendlyName!)
.font(.subheadline)
.fontWeight(.medium)
Spacer()
Button {
delegate.selectedCastDevice = device
delegate?.castDeviceChanged()
delegate?.castPopoverDismissed()
} label: {
HStack {
L10n.connect.text
.font(.caption)
.fontWeight(.medium)
Image(systemName: "bonjour")
.font(.caption)
}
}
}
}
} else {
L10n.noCastdevicesfound.text
.foregroundColor(.secondary)
.font(.subheadline)
.fontWeight(.medium)
}
}
.navigationBarTitleDisplayMode(.inline)
.navigationTitle(L10n.selectCastDestination)
.toolbar {
ToolbarItemGroup(placement: .navigationBarLeading) {
if UIDevice.current.userInterfaceIdiom == .phone {
Button {
delegate?.castPopoverDismissed()
} label: {
HStack {
Image(systemName: "chevron.left")
L10n.back.text.font(.callout)
}
}
}
}
}
}.offset(y: UIDevice.current.userInterfaceIdiom == .pad ? 14 : 0)
}
}

View File

@ -1,89 +0,0 @@
/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public
* License, v2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import Foundation
import SwiftUI
class VideoPlayerSettingsView: UINavigationController {
private var contentView: UIHostingController<VideoPlayerSettings>!
weak var playerDelegate: PlayerViewController?
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
.landscape
}
override func viewDidLoad() {
super.viewDidLoad()
self.viewControllers = [UIHostingController(rootView: VideoPlayerSettings(delegate: self.playerDelegate ?? PlayerViewController()))]
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
self.playerDelegate?.settingsPopoverDismissed()
}
}
struct VideoPlayerSettings: View {
weak var delegate: PlayerViewController!
@State var captionTrack: Int32 = -99
@State var audioTrack: Int32 = -99
@State var playbackSpeedSelection: Int = 3
init(delegate: PlayerViewController) {
self.delegate = delegate
}
var body: some View {
Form {
Picker(L10n.closedCaptions, selection: $captionTrack) {
ForEach(delegate.subtitleTrackArray, id: \.id) { caption in
Text(caption.name).tag(caption.id)
}
}
.onChange(of: captionTrack) { track in
self.delegate.subtitleTrackChanged(newTrackID: track)
}
Picker(L10n.audioTrack, selection: $audioTrack) {
ForEach(delegate.audioTrackArray, id: \.id) { caption in
Text(caption.name).tag(caption.id).lineLimit(1)
}
}.onChange(of: audioTrack) { track in
self.delegate.audioTrackChanged(newTrackID: track)
}
Picker(L10n.playbackSpeed, selection: $playbackSpeedSelection) {
ForEach(delegate.playbackSpeeds.indices, id: \.self) { speedIndex in
let speed = delegate.playbackSpeeds[speedIndex]
Text("\(String(speed))x").tag(speedIndex)
}
}
.onChange(of: playbackSpeedSelection, perform: { index in
self.delegate.playbackSpeedChanged(index: index)
})
}.navigationBarTitleDisplayMode(.inline)
.navigationTitle(L10n.audioAndCaptions)
.toolbar {
ToolbarItemGroup(placement: .navigationBarLeading) {
if UIDevice.current.userInterfaceIdiom == .phone {
Button {
self.delegate.settingsPopoverDismissed()
} label: {
HStack {
Image(systemName: "chevron.left")
L10n.back.text.font(.callout)
}
}
}
}
}.offset(y: UIDevice.current.userInterfaceIdiom == .pad ? 14 : 0)
.onAppear(perform: {
captionTrack = self.delegate.selectedCaptionTrack
audioTrack = self.delegate.selectedAudioTrack
playbackSpeedSelection = self.delegate.selectedPlaybackSpeedIndex
})
}
}

View File

@ -0,0 +1,41 @@
//
// VideoPlayerView.swift
// JellyfinVideoPlayerDev
//
// Created by Ethan Pippin on 11/12/21.
//
import UIKit
import SwiftUI
struct NativePlayerView: UIViewControllerRepresentable {
let viewModel: VideoPlayerViewModel
typealias UIViewControllerType = NativePlayerViewController
func makeUIViewController(context: Context) -> NativePlayerViewController {
return NativePlayerViewController(viewModel: viewModel)
}
func updateUIViewController(_ uiViewController: NativePlayerViewController, context: Context) {
}
}
struct VLCPlayerView: UIViewControllerRepresentable {
let viewModel: VideoPlayerViewModel
typealias UIViewControllerType = VLCPlayerViewController
func makeUIViewController(context: Context) -> VLCPlayerViewController {
return VLCPlayerViewController(viewModel: viewModel)
}
func updateUIViewController(_ uiViewController: VLCPlayerViewController, context: Context) {
}
}

View File

@ -1,54 +0,0 @@
//
/*
* SwiftFin is subject to the terms of the Mozilla Public
* License, v2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import SwiftUI
import JellyfinAPI
class UpNextViewModel: ObservableObject {
@Published var largeView: Bool = false
@Published var item: BaseItemDto?
weak var delegate: PlayerViewController?
func nextUp() {
if delegate != nil {
delegate?.setPlayerToNextUp()
}
}
}
struct VideoUpNextView: View {
@ObservedObject var viewModel: UpNextViewModel
var body: some View {
Button {
viewModel.nextUp()
} label: {
HStack {
VStack {
L10n.playNext.text
.foregroundColor(.white)
.font(.subheadline)
.fontWeight(.semibold)
Text(viewModel.item?.getEpisodeLocator() ?? "")
.foregroundColor(.secondary)
.font(.caption)
}
Image(systemName: "play.fill")
.foregroundColor(.white)
.font(.subheadline)
}
.frame(width: 120, height: 35)
.background(Color.jellyfinPurple)
.cornerRadius(10)
}.buttonStyle(PlainButtonStyle())
.frame(width: 120, height: 35)
.shadow(color: .black, radius: 20)
}
}

View File

@ -35,8 +35,8 @@ final class ItemCoordinator: NavigationCoordinatable {
ItemCoordinator(item: item)
}
func makeVideoPlayer(item: BaseItemDto) -> NavigationViewCoordinator<VideoPlayerCoordinator> {
NavigationViewCoordinator(VideoPlayerCoordinator(item: item))
func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator<VideoPlayerCoordinator> {
NavigationViewCoordinator(VideoPlayerCoordinator(viewModel: viewModel))
}
@ViewBuilder func makeStart() -> some View {

View File

@ -1,30 +0,0 @@
//
/*
* SwiftFin is subject to the terms of the Mozilla Public
* License, v2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import Foundation
import JellyfinAPI
import Stinsen
import SwiftUI
final class VideoPlayerCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \VideoPlayerCoordinator.start)
@Root var start = makeStart
let item: BaseItemDto
init(item: BaseItemDto) {
self.item = item
}
@ViewBuilder func makeStart() -> some View {
VideoPlayerView(item: item)
}
}

View File

@ -0,0 +1,42 @@
//
/*
* SwiftFin is subject to the terms of the Mozilla Public
* License, v2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import Defaults
import Foundation
import JellyfinAPI
import Stinsen
import SwiftUI
final class VideoPlayerCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \VideoPlayerCoordinator.start)
@Root var start = makeStart
@Default(.nativeVideoPlayer) var nativeVideoPlayer
let viewModel: VideoPlayerViewModel
init(viewModel: VideoPlayerViewModel) {
self.viewModel = viewModel
}
@ViewBuilder func makeStart() -> some View {
if nativeVideoPlayer {
NativePlayerView(viewModel: viewModel)
.navigationBarHidden(true)
.statusBar(hidden: true)
.ignoresSafeArea()
} else {
VLCPlayerView(viewModel: viewModel)
.navigationBarHidden(true)
.statusBar(hidden: true)
.ignoresSafeArea()
}
}
}

View File

@ -0,0 +1,35 @@
//
/*
* SwiftFin is subject to the terms of the Mozilla Public
* License, v2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import Defaults
import Foundation
import JellyfinAPI
import Stinsen
import SwiftUI
final class VideoPlayerCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \VideoPlayerCoordinator.start)
@Root var start = makeStart
@Default(.nativeVideoPlayer) var nativeVideoPlayer
let viewModel: VideoPlayerViewModel
init(viewModel: VideoPlayerViewModel) {
self.viewModel = viewModel
}
@ViewBuilder func makeStart() -> some View {
NativePlayerView(viewModel: viewModel)
.navigationBarHidden(true)
.ignoresSafeArea()
}
}

View File

@ -0,0 +1,22 @@
//
/*
* SwiftFin is subject to the terms of the Mozilla Public
* License, v2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import Foundation
extension URLComponents {
mutating func addQueryItem(name: String, value: String?) {
if let _ = self.queryItems {
self.queryItems?.append(URLQueryItem(name: name, value: value))
} else {
self.queryItems = []
self.queryItems?.append(URLQueryItem(name: name, value: value))
}
}
}

View File

@ -32,4 +32,5 @@ extension Defaults.Keys {
static let appAppearance = Key<AppAppearance>("appAppearance", default: .system, suite: SwiftfinStore.Defaults.suite)
static let videoPlayerJumpForward = Key<VideoPlayerJumpLength>("videoPlayerJumpForward", default: .thirty, suite: SwiftfinStore.Defaults.suite)
static let videoPlayerJumpBackward = Key<VideoPlayerJumpLength>("videoPlayerJumpBackward", default: .thirty, suite: SwiftfinStore.Defaults.suite)
static let nativeVideoPlayer = Key<Bool>("nativeVideoPlayer", default: false, suite: SwiftfinStore.Defaults.suite)
}

View File

@ -102,7 +102,7 @@ final class HomeViewModel: ViewModel {
.store(in: &cancellables)
ItemsAPI.getResumeItems(userId: SessionManager.main.currentLogin.user.id, limit: 12,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters],
mediaTypes: ["Video"],
imageTypeLimit: 1,
enableImageTypes: [.primary, .backdrop, .thumb])
@ -122,7 +122,7 @@ final class HomeViewModel: ViewModel {
.store(in: &cancellables)
TvShowsAPI.getNextUp(userId: SessionManager.main.currentLogin.user.id, limit: 12,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people])
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters])
.trackActivity(loading)
.sink(receiveCompletion: { completion in
switch completion {

View File

@ -7,8 +7,10 @@
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import Combine
import Foundation
import JellyfinAPI
import UIKit
class ItemViewModel: ViewModel {
@ -17,6 +19,7 @@ class ItemViewModel: ViewModel {
@Published var similarItems: [BaseItemDto] = []
@Published var isWatched = false
@Published var isFavorited = false
var itemVideoPlayerViewModel: VideoPlayerViewModel?
init(item: BaseItemDto) {
self.item = item
@ -32,6 +35,14 @@ class ItemViewModel: ViewModel {
super.init()
getSimilarItems()
self.createVideoPlayerViewModel(item: item)
.sink { completion in
self.handleAPIRequestError(completion: completion)
} receiveValue: { videoPlayerViewModel in
self.itemVideoPlayerViewModel = videoPlayerViewModel
}
.store(in: &cancellables)
}
func playButtonText() -> String {
@ -100,4 +111,96 @@ class ItemViewModel: ViewModel {
.store(in: &cancellables)
}
}
func createVideoPlayerViewModel(item: BaseItemDto) -> AnyPublisher<VideoPlayerViewModel, Error> {
let builder = DeviceProfileBuilder()
// TODO: fix bitrate settings
builder.setMaxBitrate(bitrate: 60000000)
let profile = builder.buildProfile()
let playbackInfo = PlaybackInfoDto(userId: SessionManager.main.currentLogin.user.id,
maxStreamingBitrate: 60000000,
startTimeTicks: item.userData?.playbackPositionTicks ?? 0,
deviceProfile: profile,
autoOpenLiveStream: true)
return MediaInfoAPI.getPostedPlaybackInfo(itemId: item.id!,
userId: SessionManager.main.currentLogin.user.id,
maxStreamingBitrate: 60000000,
startTimeTicks: item.userData?.playbackPositionTicks ?? 0,
autoOpenLiveStream: true,
playbackInfoDto: playbackInfo)
.map({ response -> VideoPlayerViewModel in
let mediaSource = response.mediaSources!.first!
let audioStreams = mediaSource.mediaStreams?.filter({ $0.type == .audio }) ?? []
let subtitleStreams = mediaSource.mediaStreams?.filter({ $0.type == .subtitle }) ?? []
let defaultAudioStream = audioStreams.first(where: { $0.index! == mediaSource.defaultAudioStreamIndex! })
let defaultSubtitleStream = subtitleStreams.first(where: { $0.index! == mediaSource.defaultSubtitleStreamIndex ?? -1 })
let videoStream = mediaSource.mediaStreams!.first(where: { $0.type! == MediaStreamType.video })
let audioCodecs = mediaSource.mediaStreams!.filter({ $0.type! == MediaStreamType.audio }).map({ $0.codec! })
// MARK: basic stream
var streamURL = URLComponents(string: SessionManager.main.currentLogin.server.currentURI)!
streamURL.path = "/Videos/\(item.id!)/stream"
streamURL.addQueryItem(name: "Static", value: "true")
streamURL.addQueryItem(name: "MediaSourceId", value: item.id!)
streamURL.addQueryItem(name: "Tag", value: item.etag)
streamURL.addQueryItem(name: "MinSegments", value: "6")
// MARK: hls stream
var hlsURL = URLComponents(string: SessionManager.main.currentLogin.server.currentURI)!
hlsURL.path = "/videos/\(item.id!)/master.m3u8"
hlsURL.addQueryItem(name: "DeviceId", value: UIDevice.vendorUUIDString)
hlsURL.addQueryItem(name: "MediaSourceId", value: item.id!)
hlsURL.addQueryItem(name: "VideoCodec", value: videoStream?.codec!)
hlsURL.addQueryItem(name: "AudioCodec", value: audioCodecs.joined(separator: ","))
hlsURL.addQueryItem(name: "AudioStreamIndex", value: "\(defaultAudioStream!.index!)")
hlsURL.addQueryItem(name: "VideoBitrate", value: "\(videoStream!.bitRate!)")
hlsURL.addQueryItem(name: "AudioBitrate", value: "\(defaultAudioStream!.bitRate!)")
hlsURL.addQueryItem(name: "PlaySessionId", value: response.playSessionId!)
hlsURL.addQueryItem(name: "TranscodingMaxAudioChannels", value: "6")
hlsURL.addQueryItem(name: "RequireAvc", value: "false")
hlsURL.addQueryItem(name: "Tag", value: mediaSource.eTag!)
hlsURL.addQueryItem(name: "SegmentContainer", value: "ts")
hlsURL.addQueryItem(name: "MinSegments", value: "2")
hlsURL.addQueryItem(name: "BreakOnNonKeyFrames", value: "true")
hlsURL.addQueryItem(name: "TranscodeReasons", value: "VideoCodecNotSupported,AudioCodecNotSupported")
hlsURL.addQueryItem(name: "api_key", value: SessionManager.main.currentLogin.user.accessToken)
if defaultSubtitleStream?.index != nil {
hlsURL.addQueryItem(name: "SubtitleMethod", value: "Encode")
hlsURL.addQueryItem(name: "SubtitleStreamIndex", value: "\(defaultSubtitleStream!.index!)")
}
// startURL.queryItems?.append(URLQueryItem(name: "SubtitleCodec", value: "\(defaultSubtitleStream!.codec!)"))
let videoPlayerViewModel = VideoPlayerViewModel(item: item,
title: item.name!,
subtitle: item.seriesName,
streamURL: streamURL.url!,
hlsURL: hlsURL.url!,
response: response,
audioStreams: audioStreams,
subtitleStreams: subtitleStreams,
defaultAudioStreamIndex: defaultAudioStream?.index ?? -1,
defaultSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1,
playerState: .playing,
shouldShowGoogleCast: false,
shouldShowAirplay: false,
subtitlesEnabled: defaultAudioStream?.index != nil,
sliderPercentage: (item.userData?.playedPercentage ?? 0) / 100,
selectedAudioStreamIndex: defaultAudioStream?.index ?? -1,
selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1)
return videoPlayerViewModel
})
.eraseToAnyPublisher()
}
}

View File

@ -83,7 +83,7 @@ final class LibraryViewModel: ViewModel {
let shouldBeRecursive: Bool = filters.filters.contains(.isFavorite) || personIDs != [] || studioIDs != [] || genreIDs != []
ItemsAPI.getItemsByUserId(userId: SessionManager.main.currentLogin.user.id, startIndex: currentPage * 100, limit: 100, recursive: shouldBeRecursive,
searchTerm: nil, sortOrder: filters.sortOrder, parentId: parentID,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], includeItemTypes: filters.filters.contains(.isFavorite) ? ["Movie", "Series", "Season", "Episode"] : ["Movie", "Series"],
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters], includeItemTypes: filters.filters.contains(.isFavorite) ? ["Movie", "Series", "Season", "Episode"] : ["Movie", "Series"],
filters: filters.filters, sortBy: sortBy, tags: filters.tags,
enableUserData: true, personIds: personIDs, studioIds: studioIDs, genreIds: genreIDs, enableImages: true)
.trackActivity(loading)
@ -115,7 +115,7 @@ final class LibraryViewModel: ViewModel {
let shouldBeRecursive: Bool = filters.filters.contains(.isFavorite) || personIDs != [] || studioIDs != [] || genreIDs != []
ItemsAPI.getItemsByUserId(userId: SessionManager.main.currentLogin.user.id, startIndex: currentPage * 100, limit: 100, recursive: shouldBeRecursive,
searchTerm: nil, sortOrder: filters.sortOrder, parentId: parentID,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], includeItemTypes: filters.filters.contains(.isFavorite) ? ["Movie", "Series", "Season", "Episode"] : ["Movie", "Series"],
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters], includeItemTypes: filters.filters.contains(.isFavorite) ? ["Movie", "Series", "Season", "Episode"] : ["Movie", "Series"],
filters: filters.filters, sortBy: sortBy, tags: filters.tags,
enableUserData: true, personIds: personIDs, studioIds: studioIDs, genreIds: genreIDs, enableImages: true)
.sink(receiveCompletion: { [weak self] completion in

View File

@ -0,0 +1,218 @@
//
// VideoPlayerViewModel.swift
// JellyfinVideoPlayerDev
//
// Created by Ethan Pippin on 11/12/21.
//
import Combine
import Foundation
import JellyfinAPI
#if os(tvOS)
import TVVLCKit
#else
import MobileVLCKit
#endif
import Stinsen
import UIKit
final class VideoPlayerViewModel: ObservableObject {
// Manually kept state because VLCKit doesn't properly set "played"
// on the VLCMediaPlayer object
@Published var playerState: VLCMediaPlayerState
@Published var shouldShowGoogleCast: Bool
@Published var shouldShowAirplay: Bool
@Published var captionsEnabled: Bool
@Published var leftLabelText: String = "--:--"
@Published var rightLabelText: String = "--:--"
@Published var screenFilled: Bool = false
@Published var sliderPercentage: Double {
willSet {
sliderScrubbingSubject.send(self)
sliderPercentageChanged(newValue: newValue)
}
}
@Published var sliderIsScrubbing: Bool = false
@Published var selectedAudioStreamIndex: Int
@Published var selectedSubtitleStreamIndex: Int
let item: BaseItemDto
let title: String
let subtitle: String?
let streamURL: URL
let hlsURL: URL
// Full response kept for convenience
let response: PlaybackInfoResponse
let audioStreams: [MediaStream]
let subtitleStreams: [MediaStream]
let defaultAudioStreamIndex: Int
let defaultSubtitleStreamIndex: Int
var playerOverlayDelegate: PlayerOverlayDelegate?
// Ticks of the time the media has begun
var startTimeTicks: Int64?
// Necessary PassthroughSubject to capture manual scrubbing from sliders
let sliderScrubbingSubject = PassthroughSubject<VideoPlayerViewModel, Never>()
private var cancellables = Set<AnyCancellable>()
init(item: BaseItemDto,
title: String,
subtitle: String?,
streamURL: URL,
hlsURL: URL,
response: PlaybackInfoResponse,
audioStreams: [MediaStream],
subtitleStreams: [MediaStream],
defaultAudioStreamIndex: Int,
defaultSubtitleStreamIndex: Int,
playerState: VLCMediaPlayerState,
shouldShowGoogleCast: Bool,
shouldShowAirplay: Bool,
subtitlesEnabled: Bool,
sliderPercentage: Double,
selectedAudioStreamIndex: Int,
selectedSubtitleStreamIndex: Int) {
self.item = item
self.title = title
self.subtitle = subtitle
self.streamURL = streamURL
self.hlsURL = hlsURL
self.response = response
self.audioStreams = audioStreams
self.subtitleStreams = subtitleStreams
self.defaultAudioStreamIndex = defaultAudioStreamIndex
self.defaultSubtitleStreamIndex = defaultSubtitleStreamIndex
self.playerState = playerState
self.shouldShowGoogleCast = shouldShowGoogleCast
self.shouldShowAirplay = shouldShowAirplay
self.captionsEnabled = subtitlesEnabled
self.sliderPercentage = sliderPercentage
self.selectedAudioStreamIndex = selectedAudioStreamIndex
self.selectedSubtitleStreamIndex = selectedSubtitleStreamIndex
self.sliderPercentageChanged(newValue: (item.userData?.playedPercentage ?? 0) / 100)
}
private func sliderPercentageChanged(newValue: Double) {
let videoDuration = Double(item.runTimeTicks! / 10_000_000)
let secondsScrubbedTo = round(sliderPercentage * videoDuration)
let secondsScrubbedRemaining = videoDuration - secondsScrubbedTo
leftLabelText = calculateTimeText(from: secondsScrubbedTo)
rightLabelText = calculateTimeText(from: secondsScrubbedRemaining)
}
private func calculateTimeText(from duration: Double) -> String {
let hours = floor(duration / 3600)
let minutes = duration.truncatingRemainder(dividingBy: 3600) / 60
let seconds = duration.truncatingRemainder(dividingBy: 3600).truncatingRemainder(dividingBy: 60)
let timeText: String
if hours != 0 {
timeText =
"\(Int(hours)):\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))"
} else {
timeText =
"\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))"
}
return timeText
}
func sendPlayReport(startTimeTicks: Int64) {
self.startTimeTicks = startTimeTicks
let startInfo = PlaybackStartInfo(canSeek: true,
item: item,
itemId: item.id,
sessionId: response.playSessionId,
mediaSourceId: item.id,
audioStreamIndex: audioStreams.first(where: { $0.index! == response.mediaSources?.first?.defaultAudioStreamIndex! })?.index,
subtitleStreamIndex: subtitleStreams.first(where: { $0.index! == response.mediaSources?.first?.defaultSubtitleStreamIndex ?? -1 })?.index,
isPaused: false,
isMuted: false,
positionTicks: item.userData?.playbackPositionTicks,
playbackStartTimeTicks: startTimeTicks,
volumeLevel: 100,
brightness: 100,
aspectRatio: nil,
playMethod: .directPlay,
liveStreamId: nil,
playSessionId: response.playSessionId,
repeatMode: .repeatNone,
nowPlayingQueue: nil,
playlistItemId: "playlistItem0"
)
PlaystateAPI.reportPlaybackStart(playbackStartInfo: startInfo)
.sink { completion in
print(completion)
} receiveValue: { _ in
print("Playback start report sent!")
}
.store(in: &cancellables)
}
func sendProgressReport(ticks: Int64) {
print("Progress ticks: \(ticks)")
let progressInfo = PlaybackProgressInfo(canSeek: true,
item: item,
itemId: item.id,
sessionId: response.playSessionId,
mediaSourceId: item.id,
audioStreamIndex: audioStreams.first(where: { $0.index! == response.mediaSources?.first?.defaultAudioStreamIndex! })?.index,
subtitleStreamIndex: subtitleStreams.first(where: { $0.index! == response.mediaSources?.first?.defaultSubtitleStreamIndex ?? -1 })?.index,
isPaused: false,
isMuted: false,
positionTicks: ticks,
playbackStartTimeTicks: startTimeTicks,
volumeLevel: nil,
brightness: nil,
aspectRatio: nil,
playMethod: .directPlay,
liveStreamId: nil,
playSessionId: response.playSessionId,
repeatMode: .repeatNone,
nowPlayingQueue: nil,
playlistItemId: "playlistItem0")
PlaystateAPI.reportPlaybackProgress(playbackProgressInfo: progressInfo)
.sink { completion in
print(completion)
} receiveValue: { _ in
print("Playback progress sent!")
}
.store(in: &cancellables)
}
func sendStopReport(ticks: Int64) {
let stopInfo = PlaybackStopInfo(item: item,
itemId: item.id,
sessionId: response.playSessionId,
mediaSourceId: item.id,
positionTicks: ticks,
liveStreamId: nil,
playSessionId: response.playSessionId,
failed: nil,
nextMediaType: nil,
playlistItemId: "playlistItem0",
nowPlayingQueue: nil)
PlaystateAPI.reportPlaybackStopped(playbackStopInfo: stopInfo)
.sink { completion in
print(completion)
} receiveValue: { _ in
print("Playback stop report sent!")
}
.store(in: &cancellables)
}
}