Merge branch 'jellyfin:main' into nextUp

This commit is contained in:
Stephen Byatt 2021-06-26 10:33:37 +10:00 committed by GitHub
commit d1d44898e8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 869 additions and 704 deletions

View File

@ -105,7 +105,7 @@ struct ConnectToServerView: View {
} }
} else { } else {
if !viewModel.isLoading { if !viewModel.isLoading {
Form { Form {
Section(header: Text("Server Information")) { Section(header: Text("Server Information")) {
TextField("Jellyfin Server URL", text: $uri) TextField("Jellyfin Server URL", text: $uri)
@ -144,15 +144,14 @@ struct ConnectToServerView: View {
Image(systemName: "chevron.forward") Image(systemName: "chevron.forward")
.padding() .padding()
} }
}) })
.disabled(viewModel.isLoading) .disabled(viewModel.isLoading)
} }
} }
.onAppear(perform: self.viewModel.discoverServers) .onAppear(perform: self.viewModel.discoverServers)
} }
} } else {
else {
ProgressView() ProgressView()
} }
} }

View File

@ -10,19 +10,17 @@
import SwiftUI import SwiftUI
class AudioViewController: UIViewController { class AudioViewController: UIViewController {
var height : CGFloat = 420
var height: CGFloat = 420
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
tabBarItem.title = "Audio" tabBarItem.title = "Audio"
} }
func prepareAudioView(audioTracks: [AudioTrack], selectedTrack: Int32, delegate: VideoPlayerSettingsDelegate) func prepareAudioView(audioTracks: [AudioTrack], selectedTrack: Int32, delegate: VideoPlayerSettingsDelegate) {
{
let contentView = UIHostingController(rootView: AudioView(selectedTrack: selectedTrack, audioTrackArray: audioTracks, delegate: delegate)) let contentView = UIHostingController(rootView: AudioView(selectedTrack: selectedTrack, audioTrackArray: audioTracks, delegate: delegate))
self.view.addSubview(contentView.view) self.view.addSubview(contentView.view)
contentView.view.translatesAutoresizingMaskIntoConstraints = false contentView.view.translatesAutoresizingMaskIntoConstraints = false
@ -30,38 +28,37 @@ class AudioViewController: UIViewController {
contentView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true contentView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
contentView.view.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true contentView.view.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
contentView.view.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true contentView.view.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true
} }
} }
struct AudioView: View { struct AudioView: View {
@State var selectedTrack : Int32 = -1 @State var selectedTrack: Int32 = -1
@State var audioTrackArray: [AudioTrack] = [] @State var audioTrackArray: [AudioTrack] = []
weak var delegate: VideoPlayerSettingsDelegate? weak var delegate: VideoPlayerSettingsDelegate?
var body : some View { var body : some View {
NavigationView { NavigationView {
VStack() { VStack {
List(audioTrackArray, id: \.id) { track in List(audioTrackArray, id: \.id) { track in
Button(action: { Button(action: {
delegate?.selectNew(audioTrack: track.id) delegate?.selectNew(audioTrack: track.id)
selectedTrack = track.id selectedTrack = track.id
}, label: { }, label: {
HStack(spacing: 10){ HStack(spacing: 10) {
if track.id == selectedTrack { if track.id == selectedTrack {
Image(systemName: "checkmark") Image(systemName: "checkmark")
} } else {
else {
Image(systemName: "checkmark") Image(systemName: "checkmark")
.hidden() .hidden()
} }
Text(track.name) Text(track.name)
} }
}) })
} }
} }
.frame(width: 400) .frame(width: 400)

View File

@ -11,15 +11,14 @@ import TVUIKit
import JellyfinAPI import JellyfinAPI
class InfoTabBarViewController: UITabBarController, UIGestureRecognizerDelegate { class InfoTabBarViewController: UITabBarController, UIGestureRecognizerDelegate {
var videoPlayer : VideoPlayerViewController? = nil
var subtitleViewController : SubtitlesViewController? = nil
var audioViewController : AudioViewController? = nil
var mediaInfoController : MediaInfoViewController? = nil
var infoContainerPos : CGRect? = nil
var tabBarHeight : CGFloat = 0
var videoPlayer: VideoPlayerViewController?
var subtitleViewController: SubtitlesViewController?
var audioViewController: AudioViewController?
var mediaInfoController: MediaInfoViewController?
var infoContainerPos: CGRect?
var tabBarHeight: CGFloat = 0
// override func viewWillAppear(_ animated: Bool) { // override func viewWillAppear(_ animated: Bool) {
// tabBar.standardAppearance.backgroundColor = .clear // tabBar.standardAppearance.backgroundColor = .clear
// tabBar.standardAppearance.backgroundImage = UIImage() // tabBar.standardAppearance.backgroundImage = UIImage()
@ -40,40 +39,38 @@ class InfoTabBarViewController: UITabBarController, UIGestureRecognizerDelegate
mediaInfoController = MediaInfoViewController() mediaInfoController = MediaInfoViewController()
audioViewController = AudioViewController() audioViewController = AudioViewController()
subtitleViewController = SubtitlesViewController() subtitleViewController = SubtitlesViewController()
viewControllers = [mediaInfoController!, audioViewController!, subtitleViewController!] viewControllers = [mediaInfoController!, audioViewController!, subtitleViewController!]
tabBarHeight = tabBar.frame.size.height tabBarHeight = tabBar.frame.size.height
tabBar.standardAppearance.backgroundColor = .clear tabBar.standardAppearance.backgroundColor = .clear
tabBar.standardAppearance.backgroundImage = UIImage() tabBar.standardAppearance.backgroundImage = UIImage()
tabBar.standardAppearance.backgroundEffect = .none tabBar.standardAppearance.backgroundEffect = .none
tabBar.barTintColor = .clear tabBar.barTintColor = .clear
} }
func setupInfoViews(mediaItem: BaseItemDto, subtitleTracks: [Subtitle], selectedSubtitleTrack : Int32, audioTracks: [AudioTrack], selectedAudioTrack: Int32, delegate: VideoPlayerSettingsDelegate) { func setupInfoViews(mediaItem: BaseItemDto, subtitleTracks: [Subtitle], selectedSubtitleTrack: Int32, audioTracks: [AudioTrack], selectedAudioTrack: Int32, delegate: VideoPlayerSettingsDelegate) {
mediaInfoController?.setMedia(item: mediaItem) mediaInfoController?.setMedia(item: mediaItem)
audioViewController?.prepareAudioView(audioTracks: audioTracks, selectedTrack: selectedAudioTrack, delegate: delegate) audioViewController?.prepareAudioView(audioTracks: audioTracks, selectedTrack: selectedAudioTrack, delegate: delegate)
subtitleViewController?.prepareSubtitleView(subtitleTracks: subtitleTracks, selectedTrack: selectedSubtitleTrack, delegate: delegate) subtitleViewController?.prepareSubtitleView(subtitleTracks: subtitleTracks, selectedTrack: selectedSubtitleTrack, delegate: delegate)
if let videoPlayer = videoPlayer { if let videoPlayer = videoPlayer {
infoContainerPos = CGRect(x: 88, y: 87, width: videoPlayer.infoViewContainer.frame.width, height: videoPlayer.infoViewContainer.frame.height) infoContainerPos = CGRect(x: 88, y: 87, width: videoPlayer.infoViewContainer.frame.width, height: videoPlayer.infoViewContainer.frame.height)
} }
} }
override func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) { override func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
guard let pos = infoContainerPos else { guard let pos = infoContainerPos else {
return return
} }
switch item.title { switch item.title {
case "Audio": case "Audio":
if var height = audioViewController?.height { if var height = audioViewController?.height {
@ -83,7 +80,6 @@ class InfoTabBarViewController: UITabBarController, UIGestureRecognizerDelegate
} }
} }
break break
case "Info": case "Info":
@ -97,7 +93,7 @@ class InfoTabBarViewController: UITabBarController, UIGestureRecognizerDelegate
} }
break break
case "Subtitles": case "Subtitles":
if var height = subtitleViewController?.height{ if var height = subtitleViewController?.height {
height += tabBarHeight height += tabBarHeight
UIView.animate(withDuration: 0.6, delay: 0, options: .curveEaseOut) { [self] in UIView.animate(withDuration: 0.6, delay: 0, options: .curveEaseOut) { [self] in
videoPlayer?.infoViewContainer.frame = CGRect(x: pos.minX, y: pos.minY, width: pos.width, height: height) videoPlayer?.infoViewContainer.frame = CGRect(x: pos.minX, y: pos.minY, width: pos.width, height: height)
@ -110,13 +106,11 @@ class InfoTabBarViewController: UITabBarController, UIGestureRecognizerDelegate
break break
} }
} }
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true return true
} }
// MARK: - Navigation // MARK: - Navigation
// // In a storyboard-based application, you will often want to do a little preparation before navigation // // In a storyboard-based application, you will often want to do a little preparation before navigation

View File

@ -12,18 +12,16 @@ import JellyfinAPI
class MediaInfoViewController: UIViewController { class MediaInfoViewController: UIViewController {
private var contentView: UIHostingController<MediaInfoView>! private var contentView: UIHostingController<MediaInfoView>!
var height : CGFloat = 0 var height: CGFloat = 0
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
tabBarItem.title = "Info" tabBarItem.title = "Info"
} }
func setMedia(item: BaseItemDto) func setMedia(item: BaseItemDto) {
{
contentView = UIHostingController(rootView: MediaInfoView(item: item)) contentView = UIHostingController(rootView: MediaInfoView(item: item))
self.view.addSubview(contentView.view) self.view.addSubview(contentView.view)
contentView.view.translatesAutoresizingMaskIntoConstraints = false contentView.view.translatesAutoresizingMaskIntoConstraints = false
@ -31,40 +29,38 @@ class MediaInfoViewController: UIViewController {
contentView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true contentView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
contentView.view.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true contentView.view.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
contentView.view.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true contentView.view.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true
height = self.view.frame.height height = self.view.frame.height
} }
} }
struct MediaInfoView: View { struct MediaInfoView: View {
@State var item : BaseItemDto? = nil @State var item: BaseItemDto?
var body: some View { var body: some View {
if let item = item { if let item = item {
HStack(spacing: 30) { HStack(spacing: 30) {
VStack { VStack {
ImageView(src: item.type == "Episode" ? item.getSeriesPrimaryImage(maxWidth: 200) : item.getPrimaryImage(maxWidth: 200), bh: item.type == "Episode" ? item.getSeriesPrimaryImageBlurHash() : item.getPrimaryImageBlurHash()) 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) .frame(width: 200, height: 300)
.cornerRadius(10) .cornerRadius(10)
Spacer() Spacer()
} }
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
if item.type == "Episode" { if item.type == "Episode" {
Text(item.seriesName ?? "Series") Text(item.seriesName ?? "Series")
.fontWeight(.bold) .fontWeight(.bold)
Text(item.name ?? "Episode") Text(item.name ?? "Episode")
.foregroundColor(.secondary) .foregroundColor(.secondary)
} } else {
else
{
Text(item.name ?? "Movie") Text(item.name ?? "Movie")
.fontWeight(.bold) .fontWeight(.bold)
} }
HStack(spacing: 10) { HStack(spacing: 10) {
if item.type == "Episode" { if item.type == "Episode" {
Text("S\(item.parentIndexNumber ?? 0) • E\(item.indexNumber ?? 0)") Text("S\(item.parentIndexNumber ?? 0) • E\(item.indexNumber ?? 0)")
@ -73,56 +69,53 @@ struct MediaInfoView: View {
Text("") Text("")
Text(formatDate(date: date)) Text(formatDate(date: date))
} }
} else if let year = item.productionYear { } else if let year = item.productionYear {
Text(String(year)) Text(String(year))
} }
if item.runTimeTicks != nil { if item.runTimeTicks != nil {
Text("") Text("")
Text(item.getItemRuntime()) Text(item.getItemRuntime())
} }
if let rating = item.officialRating { if let rating = item.officialRating {
Text("") Text("")
Text("\(rating)").font(.subheadline) Text("\(rating)").font(.subheadline)
.fontWeight(.semibold) .fontWeight(.semibold)
.padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4))
.overlay(RoundedRectangle(cornerRadius: 2) .overlay(RoundedRectangle(cornerRadius: 2)
.stroke(Color.secondary, lineWidth: 1)) .stroke(Color.secondary, lineWidth: 1))
} }
} }
.foregroundColor(.secondary) .foregroundColor(.secondary)
if let overview = item.overview { if let overview = item.overview {
Text(overview) Text(overview)
.padding(.top) .padding(.top)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
Spacer() Spacer()
} }
Spacer() Spacer()
} }
.padding(.leading, 350) .padding(.leading, 350)
.padding(.trailing, 125) .padding(.trailing, 125)
} } else {
else {
EmptyView() EmptyView()
} }
} }
func formatDate(date: Date) -> String {
func formatDate(date : Date) -> String{
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.dateFormat = "d MMM yyyy" formatter.dateFormat = "d MMM yyyy"
return formatter.string(from: date) return formatter.string(from: date)
} }
} }

View File

@ -10,19 +10,17 @@
import SwiftUI import SwiftUI
class SubtitlesViewController: UIViewController { class SubtitlesViewController: UIViewController {
var height : CGFloat = 420
var height: CGFloat = 420
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
tabBarItem.title = "Subtitles" tabBarItem.title = "Subtitles"
} }
func prepareSubtitleView(subtitleTracks: [Subtitle], selectedTrack: Int32, delegate: VideoPlayerSettingsDelegate) func prepareSubtitleView(subtitleTracks: [Subtitle], selectedTrack: Int32, delegate: VideoPlayerSettingsDelegate) {
{
let contentView = UIHostingController(rootView: SubtitleView(selectedTrack: selectedTrack, subtitleTrackArray: subtitleTracks, delegate: delegate)) let contentView = UIHostingController(rootView: SubtitleView(selectedTrack: selectedTrack, subtitleTrackArray: subtitleTracks, delegate: delegate))
self.view.addSubview(contentView.view) self.view.addSubview(contentView.view)
contentView.view.translatesAutoresizingMaskIntoConstraints = false contentView.view.translatesAutoresizingMaskIntoConstraints = false
@ -30,44 +28,42 @@ class SubtitlesViewController: UIViewController {
contentView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true contentView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
contentView.view.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true contentView.view.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
contentView.view.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true contentView.view.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true
} }
} }
struct SubtitleView: View { struct SubtitleView: View {
@State var selectedTrack : Int32 = -1 @State var selectedTrack: Int32 = -1
@State var subtitleTrackArray: [Subtitle] = [] @State var subtitleTrackArray: [Subtitle] = []
weak var delegate: VideoPlayerSettingsDelegate? weak var delegate: VideoPlayerSettingsDelegate?
var body : some View { var body : some View {
NavigationView { NavigationView {
VStack() { VStack {
List(subtitleTrackArray, id: \.id) { track in List(subtitleTrackArray, id: \.id) { track in
Button(action: { Button(action: {
delegate?.selectNew(subtitleTrack: track.id) delegate?.selectNew(subtitleTrack: track.id)
selectedTrack = track.id selectedTrack = track.id
}, label: { }, label: {
HStack(spacing: 10){ HStack(spacing: 10) {
if track.id == selectedTrack { if track.id == selectedTrack {
Image(systemName: "checkmark") Image(systemName: "checkmark")
} } else {
else {
Image(systemName: "checkmark") Image(systemName: "checkmark")
.hidden() .hidden()
} }
Text(track.name) Text(track.name)
} }
}) })
} }
} }
.frame(width: 400) .frame(width: 400)
.frame(maxHeight: 400) .frame(maxHeight: 400)
} }
} }
} }

View File

@ -12,17 +12,17 @@ import JellyfinAPI
struct VideoPlayerView: UIViewControllerRepresentable { struct VideoPlayerView: UIViewControllerRepresentable {
var item: BaseItemDto var item: BaseItemDto
func makeUIViewController(context: Context) -> some UIViewController { func makeUIViewController(context: Context) -> some UIViewController {
let storyboard = UIStoryboard(name: "VideoPlayerStoryboard", bundle: nil) let storyboard = UIStoryboard(name: "VideoPlayerStoryboard", bundle: nil)
let viewController = storyboard.instantiateViewController(withIdentifier: "VideoPlayer") as! VideoPlayerViewController let viewController = storyboard.instantiateViewController(withIdentifier: "VideoPlayer") as! VideoPlayerViewController
viewController.manifest = item viewController.manifest = item
return viewController return viewController
} }
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
} }
} }

View File

@ -12,43 +12,43 @@ import TVVLCKit
import MediaPlayer import MediaPlayer
import JellyfinAPI import JellyfinAPI
import Combine import Combine
import Defaults
protocol VideoPlayerSettingsDelegate: AnyObject { protocol VideoPlayerSettingsDelegate: AnyObject {
func selectNew(audioTrack id: Int32) func selectNew(audioTrack id: Int32)
func selectNew(subtitleTrack id: Int32) func selectNew(subtitleTrack id: Int32)
} }
class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate, VLCMediaPlayerDelegate, VLCMediaDelegate, UIGestureRecognizerDelegate { class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate, VLCMediaPlayerDelegate, VLCMediaDelegate, UIGestureRecognizerDelegate {
@IBOutlet weak var videoContentView: UIView! @IBOutlet weak var videoContentView: UIView!
@IBOutlet weak var controlsView: UIView! @IBOutlet weak var controlsView: UIView!
@IBOutlet weak var activityIndicator: UIActivityIndicatorView! @IBOutlet weak var activityIndicator: UIActivityIndicatorView!
@IBOutlet weak var transportBarView: UIView! @IBOutlet weak var transportBarView: UIView!
@IBOutlet weak var scrubberView: UIView! @IBOutlet weak var scrubberView: UIView!
@IBOutlet weak var scrubLabel: UILabel! @IBOutlet weak var scrubLabel: UILabel!
@IBOutlet weak var gradientView: UIView! @IBOutlet weak var gradientView: UIView!
@IBOutlet weak var currentTimeLabel: UILabel! @IBOutlet weak var currentTimeLabel: UILabel!
@IBOutlet weak var remainingTimeLabel: UILabel! @IBOutlet weak var remainingTimeLabel: UILabel!
@IBOutlet weak var infoViewContainer: UIView! @IBOutlet weak var infoViewContainer: UIView!
var infoPanelDisplayPoint : CGPoint = .zero var infoPanelDisplayPoint: CGPoint = .zero
var infoPanelHiddenPoint : CGPoint = .zero var infoPanelHiddenPoint: CGPoint = .zero
var containerViewController: InfoTabBarViewController? var containerViewController: InfoTabBarViewController?
var focusedOnTabBar : Bool = false var focusedOnTabBar: Bool = false
var showingInfoPanel : Bool = false var showingInfoPanel: Bool = false
var mediaPlayer = VLCMediaPlayer() var mediaPlayer = VLCMediaPlayer()
var lastProgressReportTime: Double = 0 var lastProgressReportTime: Double = 0
var lastTime: Float = 0.0 var lastTime: Float = 0.0
var startTime: Int = 0 var startTime: Int = 0
var selectedAudioTrack: Int32 = -1 { var selectedAudioTrack: Int32 = -1 {
didSet { didSet {
print(selectedAudioTrack) print(selectedAudioTrack)
@ -59,73 +59,68 @@ class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate,
print(selectedCaptionTrack) print(selectedCaptionTrack)
} }
} }
var subtitleTrackArray: [Subtitle] = [] var subtitleTrackArray: [Subtitle] = []
var audioTrackArray: [AudioTrack] = [] var audioTrackArray: [AudioTrack] = []
var playing: Bool = false var playing: Bool = false
var seeking: Bool = false var seeking: Bool = false
var showingControls: Bool = false var showingControls: Bool = false
var loading: Bool = true var loading: Bool = true
var initialSeekPos : CGFloat = 0 var initialSeekPos: CGFloat = 0
var videoPos: Double = 0 var videoPos: Double = 0
var videoDuration: Double = 0 var videoDuration: Double = 0
var controlsAppearTime: Double = 0 var controlsAppearTime: Double = 0
var manifest: BaseItemDto = BaseItemDto() var manifest: BaseItemDto = BaseItemDto()
var playbackItem = PlaybackItem() var playbackItem = PlaybackItem()
var playSessionId: String = "" var playSessionId: String = ""
var cancellables = Set<AnyCancellable>() var cancellables = Set<AnyCancellable>()
override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) { override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) {
super.didUpdateFocus(in: context, with: coordinator) super.didUpdateFocus(in: context, with: coordinator)
// Check if focused on the tab bar, allows for swipe up to dismiss the info panel // Check if focused on the tab bar, allows for swipe up to dismiss the info panel
if context.nextFocusedView!.description.contains("UITabBarButton") if context.nextFocusedView!.description.contains("UITabBarButton") {
{
// Set value after half a second so info panel is not dismissed instantly when swiping up from content // 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) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.focusedOnTabBar = true self.focusedOnTabBar = true
} }
} } else {
else
{
focusedOnTabBar = false focusedOnTabBar = false
} }
} }
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
activityIndicator.isHidden = false activityIndicator.isHidden = false
activityIndicator.startAnimating() activityIndicator.startAnimating()
mediaPlayer.delegate = self mediaPlayer.delegate = self
mediaPlayer.drawable = videoContentView mediaPlayer.drawable = videoContentView
if let runTimeTicks = manifest.runTimeTicks { if let runTimeTicks = manifest.runTimeTicks {
videoDuration = Double(runTimeTicks / 10_000_000) videoDuration = Double(runTimeTicks / 10_000_000)
} }
// Black gradient behind transport bar // Black gradient behind transport bar
let gradientLayer:CAGradientLayer = CAGradientLayer() let gradientLayer: CAGradientLayer = CAGradientLayer()
gradientLayer.frame.size = self.gradientView.frame.size gradientLayer.frame.size = self.gradientView.frame.size
gradientLayer.colors = [UIColor.black.withAlphaComponent(0.6).cgColor, UIColor.black.withAlphaComponent(0).cgColor] gradientLayer.colors = [UIColor.black.withAlphaComponent(0.6).cgColor, UIColor.black.withAlphaComponent(0).cgColor]
gradientLayer.startPoint = CGPoint(x: 0.0, y: 1.0) gradientLayer.startPoint = CGPoint(x: 0.0, y: 1.0)
gradientLayer.endPoint = CGPoint(x: 0.0, y: 0.0) gradientLayer.endPoint = CGPoint(x: 0.0, y: 0.0)
self.gradientView.layer.addSublayer(gradientLayer) self.gradientView.layer.addSublayer(gradientLayer)
infoPanelDisplayPoint = infoViewContainer.center infoPanelDisplayPoint = infoViewContainer.center
infoPanelHiddenPoint = CGPoint(x: infoPanelDisplayPoint.x, y: -infoViewContainer.frame.height) infoPanelHiddenPoint = CGPoint(x: infoPanelDisplayPoint.x, y: -infoViewContainer.frame.height)
infoViewContainer.center = infoPanelHiddenPoint infoViewContainer.center = infoPanelHiddenPoint
infoViewContainer.layer.cornerRadius = 40 infoViewContainer.layer.cornerRadius = 40
let blurEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) let blurEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
blurEffectView.frame = infoViewContainer.bounds blurEffectView.frame = infoViewContainer.bounds
blurEffectView.autoresizingMask = [.flexibleWidth, .flexibleHeight] blurEffectView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
@ -133,125 +128,122 @@ class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate,
blurEffectView.clipsToBounds = true blurEffectView.clipsToBounds = true
infoViewContainer.addSubview(blurEffectView) infoViewContainer.addSubview(blurEffectView)
infoViewContainer.sendSubviewToBack(blurEffectView) infoViewContainer.sendSubviewToBack(blurEffectView)
transportBarView.layer.cornerRadius = CGFloat(5) transportBarView.layer.cornerRadius = CGFloat(5)
setupGestures() setupGestures()
fetchVideo() fetchVideo()
setupNowPlayingCC() setupNowPlayingCC()
// Adjust subtitle size // Adjust subtitle size
mediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 16) mediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 16)
} }
func fetchVideo() { func fetchVideo() {
// Fetch max bitrate from UserDefaults depending on current connection mode // Fetch max bitrate from UserDefaults depending on current connection mode
let defaults = UserDefaults.standard let maxBitrate = Defaults[.inNetworkBandwidth]
let maxBitrate = defaults.integer(forKey: "InNetworkBandwidth")
// Build a device profile // Build a device profile
let builder = DeviceProfileBuilder() let builder = DeviceProfileBuilder()
builder.setMaxBitrate(bitrate: maxBitrate) builder.setMaxBitrate(bitrate: maxBitrate)
let profile = builder.buildProfile() let profile = builder.buildProfile()
guard let currentUser = SessionManager.current.user else { guard let currentUser = SessionManager.current.user else {
return return
} }
let playbackInfo = PlaybackInfoDto(userId: currentUser.user_id ?? "", maxStreamingBitrate: Int(maxBitrate), startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, deviceProfile: profile, autoOpenLiveStream: true) let playbackInfo = PlaybackInfoDto(userId: currentUser.user_id ?? "", maxStreamingBitrate: Int(maxBitrate), startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, deviceProfile: profile, autoOpenLiveStream: true)
DispatchQueue.global(qos: .userInitiated).async { [self] in DispatchQueue.global(qos: .userInitiated).async { [self] in
MediaInfoAPI.getPostedPlaybackInfo(itemId: manifest.id!, userId: currentUser.user_id ?? "", maxStreamingBitrate: Int(maxBitrate), startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, autoOpenLiveStream: true, playbackInfoDto: playbackInfo) MediaInfoAPI.getPostedPlaybackInfo(itemId: manifest.id!, userId: currentUser.user_id ?? "", maxStreamingBitrate: Int(maxBitrate), startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, autoOpenLiveStream: true, playbackInfoDto: playbackInfo)
.sink(receiveCompletion: { result in .sink(receiveCompletion: { result in
print(result) print(result)
}, receiveValue: { [self] response in }, receiveValue: { [self] response in
videoContentView.setNeedsLayout() videoContentView.setNeedsLayout()
videoContentView.setNeedsDisplay() videoContentView.setNeedsDisplay()
playSessionId = response.playSessionId ?? "" playSessionId = response.playSessionId ?? ""
guard let mediaSource = response.mediaSources?.first.self else { guard let mediaSource = response.mediaSources?.first.self else {
return return
} }
let item = PlaybackItem() let item = PlaybackItem()
let streamURL : URL let streamURL: URL
// Item is being transcoded by request of server // Item is being transcoded by request of server
if let transcodiungUrl = mediaSource.transcodingUrl { if let transcodiungUrl = mediaSource.transcodingUrl {
item.videoType = .transcode item.videoType = .transcode
streamURL = URL(string: "\(ServerEnvironment.current.server.baseURI!)\(transcodiungUrl)")! streamURL = URL(string: "\(ServerEnvironment.current.server.baseURI!)\(transcodiungUrl)")!
} }
// Item will be directly played by the client // Item will be directly played by the client
else else {
{
item.videoType = .directPlay item.videoType = .directPlay
streamURL = URL(string: "\(ServerEnvironment.current.server.baseURI!)/Videos/\(manifest.id!)/stream?Static=true&mediaSourceId=\(manifest.id!)&deviceId=\(SessionManager.current.deviceID)&api_key=\(SessionManager.current.accessToken)&Tag=\(mediaSource.eTag!)")! streamURL = URL(string: "\(ServerEnvironment.current.server.baseURI!)/Videos/\(manifest.id!)/stream?Static=true&mediaSourceId=\(manifest.id!)&deviceId=\(SessionManager.current.deviceID)&api_key=\(SessionManager.current.accessToken)&Tag=\(mediaSource.eTag!)")!
} }
item.videoUrl = streamURL item.videoUrl = streamURL
let disableSubtitleTrack = Subtitle(name: "None", id: -1, url: nil, delivery: .embed, codec: "") let disableSubtitleTrack = Subtitle(name: "None", id: -1, url: nil, delivery: .embed, codec: "", languageCode: "")
subtitleTrackArray.append(disableSubtitleTrack) subtitleTrackArray.append(disableSubtitleTrack)
// Loop through media streams and add to array // Loop through media streams and add to array
for stream in mediaSource.mediaStreams! { for stream in mediaSource.mediaStreams! {
if stream.type == .subtitle { if stream.type == .subtitle {
var deliveryUrl: URL? = nil var deliveryUrl: URL?
if stream.deliveryMethod == .external { if stream.deliveryMethod == .external {
deliveryUrl = URL(string: "\(ServerEnvironment.current.server.baseURI!)\(stream.deliveryUrl!)")! deliveryUrl = URL(string: "\(ServerEnvironment.current.server.baseURI!)\(stream.deliveryUrl!)")!
} }
let subtitle = Subtitle(name: stream.displayTitle ?? "Unknown", id: Int32(stream.index!), url: deliveryUrl, delivery: stream.deliveryMethod!, codec: stream.codec ?? "webvtt") 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{ if stream.isDefault == true {
selectedCaptionTrack = Int32(stream.index!) selectedCaptionTrack = Int32(stream.index!)
} }
if subtitle.delivery != .encode { if subtitle.delivery != .encode {
subtitleTrackArray.append(subtitle) subtitleTrackArray.append(subtitle)
} }
} }
if stream.type == .audio { if stream.type == .audio {
let track = AudioTrack(name: stream.displayTitle!, id: Int32(stream.index!)) let track = AudioTrack(name: stream.displayTitle!, languageCode: stream.language ?? "", id: Int32(stream.index!))
if stream.isDefault! == true { if stream.isDefault! == true {
selectedAudioTrack = Int32(stream.index!) selectedAudioTrack = Int32(stream.index!)
} }
audioTrackArray.append(track) audioTrackArray.append(track)
} }
} }
// If no default audio tracks select the first one // If no default audio tracks select the first one
if selectedAudioTrack == -1 && !audioTrackArray.isEmpty { if selectedAudioTrack == -1 && !audioTrackArray.isEmpty {
selectedAudioTrack = audioTrackArray.first!.id selectedAudioTrack = audioTrackArray.first!.id
} }
self.sendPlayReport() self.sendPlayReport()
playbackItem = item playbackItem = item
mediaPlayer.media = VLCMedia(url: playbackItem.videoUrl) mediaPlayer.media = VLCMedia(url: playbackItem.videoUrl)
mediaPlayer.media.delegate = self mediaPlayer.media.delegate = self
mediaPlayer.play() mediaPlayer.play()
// 1 second = 10,000,000 ticks // 1 second = 10,000,000 ticks
if let rawStartTicks = manifest.userData?.playbackPositionTicks { if let rawStartTicks = manifest.userData?.playbackPositionTicks {
mediaPlayer.jumpForward(Int32(rawStartTicks / 10_000_000)) mediaPlayer.jumpForward(Int32(rawStartTicks / 10_000_000))
} }
// Pause and load captions into memory. // Pause and load captions into memory.
mediaPlayer.pause() mediaPlayer.pause()
var shouldHaveSubtitleTracks = 0 var shouldHaveSubtitleTracks = 0
subtitleTrackArray.forEach { sub in subtitleTrackArray.forEach { sub in
if sub.id != -1 && sub.delivery == .external && sub.codec != "subrip" { if sub.id != -1 && sub.delivery == .external && sub.codec != "subrip" {
@ -259,25 +251,24 @@ class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate,
mediaPlayer.addPlaybackSlave(sub.url!, type: .subtitle, enforce: false) mediaPlayer.addPlaybackSlave(sub.url!, type: .subtitle, enforce: false)
} }
} }
// Wait for captions to load // Wait for captions to load
while mediaPlayer.numberOfSubtitlesTracks != shouldHaveSubtitleTracks {} while mediaPlayer.numberOfSubtitlesTracks != shouldHaveSubtitleTracks {}
// Select default track & resume playback // Select default track & resume playback
mediaPlayer.currentVideoSubTitleIndex = selectedCaptionTrack mediaPlayer.currentVideoSubTitleIndex = selectedCaptionTrack
mediaPlayer.pause() mediaPlayer.pause()
mediaPlayer.play() mediaPlayer.play()
playing = true playing = true
setupInfoPanel() setupInfoPanel()
}) })
.store(in: &cancellables) .store(in: &cancellables)
} }
} }
func setupNowPlayingCC() { func setupNowPlayingCC() {
let commandCenter = MPRemoteCommandCenter.shared() let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.playCommand.isEnabled = true commandCenter.playCommand.isEnabled = true
@ -286,40 +277,40 @@ class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate,
commandCenter.seekBackwardCommand.isEnabled = true commandCenter.seekBackwardCommand.isEnabled = true
commandCenter.changePlaybackPositionCommand.isEnabled = true commandCenter.changePlaybackPositionCommand.isEnabled = true
commandCenter.enableLanguageOptionCommand.isEnabled = true commandCenter.enableLanguageOptionCommand.isEnabled = true
// Add handler for Pause Command // Add handler for Pause Command
commandCenter.pauseCommand.addTarget { _ in commandCenter.pauseCommand.addTarget { _ in
self.pause() self.pause()
return .success return .success
} }
// Add handler for Play command // Add handler for Play command
commandCenter.playCommand.addTarget { _ in commandCenter.playCommand.addTarget { _ in
self.play() self.play()
return .success return .success
} }
// Add handler for FF command // Add handler for FF command
commandCenter.seekForwardCommand.addTarget { _ in commandCenter.seekForwardCommand.addTarget { _ in
self.mediaPlayer.jumpForward(30) self.mediaPlayer.jumpForward(30)
self.sendProgressReport(eventName: "timeupdate") self.sendProgressReport(eventName: "timeupdate")
return .success return .success
} }
// Add handler for RW command // Add handler for RW command
commandCenter.seekBackwardCommand.addTarget { _ in commandCenter.seekBackwardCommand.addTarget { _ in
self.mediaPlayer.jumpBackward(15) self.mediaPlayer.jumpBackward(15)
self.sendProgressReport(eventName: "timeupdate") self.sendProgressReport(eventName: "timeupdate")
return .success return .success
} }
// Scrubber // Scrubber
commandCenter.changePlaybackPositionCommand.addTarget { [weak self](remoteEvent) -> MPRemoteCommandHandlerStatus in commandCenter.changePlaybackPositionCommand.addTarget { [weak self](remoteEvent) -> MPRemoteCommandHandlerStatus in
guard let self = self else {return .commandFailed} guard let self = self else {return .commandFailed}
if let event = remoteEvent as? MPChangePlaybackPositionCommandEvent { if let event = remoteEvent as? MPChangePlaybackPositionCommandEvent {
let targetSeconds = event.positionTime let targetSeconds = event.positionTime
let videoPosition = Double(self.mediaPlayer.time.intValue) let videoPosition = Double(self.mediaPlayer.time.intValue)
let offset = targetSeconds - videoPosition let offset = targetSeconds - videoPosition
if offset > 0 { if offset > 0 {
@ -328,56 +319,55 @@ class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate,
self.mediaPlayer.jumpBackward(Int32(abs(offset))/1000) self.mediaPlayer.jumpBackward(Int32(abs(offset))/1000)
} }
self.sendProgressReport(eventName: "unpause") self.sendProgressReport(eventName: "unpause")
return .success return .success
} else { } else {
return .commandFailed return .commandFailed
} }
} }
// commandCenter.enableLanguageOptionCommand.addTarget { [weak self](remoteEvent) in // commandCenter.enableLanguageOptionCommand.addTarget { [weak self](remoteEvent) in
// guard let self = self else {return .commandFailed} // guard let self = self else {return .commandFailed}
// //
// //
// //
// } // }
var runTicks = 0 var runTicks = 0
var playbackTicks = 0 var playbackTicks = 0
if let ticks = manifest.runTimeTicks { if let ticks = manifest.runTimeTicks {
runTicks = Int(ticks / 10_000_000) runTicks = Int(ticks / 10_000_000)
} }
if let ticks = manifest.userData?.playbackPositionTicks { if let ticks = manifest.userData?.playbackPositionTicks {
playbackTicks = Int(ticks / 10_000_000) playbackTicks = Int(ticks / 10_000_000)
} }
var nowPlayingInfo = [String: Any]() var nowPlayingInfo = [String: Any]()
nowPlayingInfo[MPMediaItemPropertyTitle] = manifest.name ?? "Jellyfin Video" nowPlayingInfo[MPMediaItemPropertyTitle] = manifest.name ?? "Jellyfin Video"
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 0.0 nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 0.0
nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = AVMediaType.video nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = AVMediaType.video
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = runTicks nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = runTicks
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = playbackTicks nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = playbackTicks
if let imageData = NSData(contentsOf: manifest.getPrimaryImage(maxWidth: 200)) { if let imageData = NSData(contentsOf: manifest.getPrimaryImage(maxWidth: 200)) {
if let artworkImage = UIImage(data: imageData as Data) { if let artworkImage = UIImage(data: imageData as Data) {
let artwork = MPMediaItemArtwork.init(boundsSize: artworkImage.size, requestHandler: { (size) -> UIImage in let artwork = MPMediaItemArtwork.init(boundsSize: artworkImage.size, requestHandler: { (_) -> UIImage in
return artworkImage return artworkImage
}) })
nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork
} }
} }
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
UIApplication.shared.beginReceivingRemoteControlEvents() UIApplication.shared.beginReceivingRemoteControlEvents()
} }
func updateNowPlayingCenter(time : Double?, playing : Bool?) { func updateNowPlayingCenter(time: Double?, playing: Bool?) {
var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [String: Any]() var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [String: Any]()
if let playing = playing { if let playing = playing {
@ -386,64 +376,60 @@ class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate,
if let time = time { if let time = time {
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = time nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = time
} }
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
} }
// Grabs a refference to the info panel view controller // Grabs a refference to the info panel view controller
override func prepare(for segue: UIStoryboardSegue, sender: Any?) { override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if segue.identifier == "infoView" { if segue.identifier == "infoView" {
containerViewController = segue.destination as? InfoTabBarViewController containerViewController = segue.destination as? InfoTabBarViewController
containerViewController?.videoPlayer = self containerViewController?.videoPlayer = self
} }
} }
// MARK: Player functions // MARK: Player functions
// Animate the scrubber when playing state changes // Animate the scrubber when playing state changes
func animateScrubber() { func animateScrubber() {
let y : CGFloat = playing ? 0 : -20 let y: CGFloat = playing ? 0 : -20
let height: CGFloat = playing ? 10 : 30 let height: CGFloat = playing ? 10 : 30
UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseIn, animations: { 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) self.scrubberView.frame = CGRect(x: self.scrubberView.frame.minX, y: y, width: 2, height: height)
}) })
} }
func pause() { func pause() {
playing = false playing = false
mediaPlayer.pause() mediaPlayer.pause()
self.sendProgressReport(eventName: "pause") self.sendProgressReport(eventName: "pause")
self.updateNowPlayingCenter(time: nil, playing: false) self.updateNowPlayingCenter(time: nil, playing: false)
animateScrubber() 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) 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 () { func play () {
playing = true playing = true
mediaPlayer.play() mediaPlayer.play()
self.updateNowPlayingCenter(time: nil, playing: true) self.updateNowPlayingCenter(time: nil, playing: true)
self.sendProgressReport(eventName: "unpause") self.sendProgressReport(eventName: "unpause")
animateScrubber() animateScrubber()
} }
func toggleInfoContainer() { func toggleInfoContainer() {
showingInfoPanel.toggle() showingInfoPanel.toggle()
containerViewController?.view.isUserInteractionEnabled = showingInfoPanel containerViewController?.view.isUserInteractionEnabled = showingInfoPanel
if showingInfoPanel && seeking { if showingInfoPanel && seeking {
scrubLabel.isHidden = true scrubLabel.isHidden = true
UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut, animations: { UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut, animations: {
@ -453,49 +439,48 @@ class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate,
self.scrubLabel.text = self.currentTimeLabel.text self.scrubLabel.text = self.currentTimeLabel.text
} }
seeking = false seeking = false
} }
UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut) { [self] in UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut) { [self] in
infoViewContainer.center = showingInfoPanel ? infoPanelDisplayPoint : infoPanelHiddenPoint infoViewContainer.center = showingInfoPanel ? infoPanelDisplayPoint : infoPanelHiddenPoint
} }
} }
// MARK: Gestures // MARK: Gestures
func setupGestures() { func setupGestures() {
let playPauseGesture = UITapGestureRecognizer(target: self, action: #selector(self.selectButtonTapped)) let playPauseGesture = UITapGestureRecognizer(target: self, action: #selector(self.selectButtonTapped))
let playPauseType = UIPress.PressType.playPause let playPauseType = UIPress.PressType.playPause
playPauseGesture.allowedPressTypes = [NSNumber(value: playPauseType.rawValue)]; playPauseGesture.allowedPressTypes = [NSNumber(value: playPauseType.rawValue)]
view.addGestureRecognizer(playPauseGesture) view.addGestureRecognizer(playPauseGesture)
let selectGesture = UITapGestureRecognizer(target: self, action: #selector(self.selectButtonTapped)) let selectGesture = UITapGestureRecognizer(target: self, action: #selector(self.selectButtonTapped))
let selectType = UIPress.PressType.select let selectType = UIPress.PressType.select
selectGesture.allowedPressTypes = [NSNumber(value: selectType.rawValue)]; selectGesture.allowedPressTypes = [NSNumber(value: selectType.rawValue)]
view.addGestureRecognizer(selectGesture) view.addGestureRecognizer(selectGesture)
let backTapGesture = UITapGestureRecognizer(target: self, action: #selector(self.backButtonPressed(tap:))) let backTapGesture = UITapGestureRecognizer(target: self, action: #selector(self.backButtonPressed(tap:)))
let backPress = UIPress.PressType.menu let backPress = UIPress.PressType.menu
backTapGesture.allowedPressTypes = [NSNumber(value: backPress.rawValue)]; backTapGesture.allowedPressTypes = [NSNumber(value: backPress.rawValue)]
view.addGestureRecognizer(backTapGesture) view.addGestureRecognizer(backTapGesture)
let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.userPanned(panGestureRecognizer:))) let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.userPanned(panGestureRecognizer:)))
view.addGestureRecognizer(panGestureRecognizer) view.addGestureRecognizer(panGestureRecognizer)
let swipeRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(self.swipe(swipe:))) let swipeRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(self.swipe(swipe:)))
swipeRecognizer.direction = .right swipeRecognizer.direction = .right
view.addGestureRecognizer(swipeRecognizer) view.addGestureRecognizer(swipeRecognizer)
let swipeRecognizerl = UISwipeGestureRecognizer(target: self, action: #selector(self.swipe(swipe:))) let swipeRecognizerl = UISwipeGestureRecognizer(target: self, action: #selector(self.swipe(swipe:)))
swipeRecognizerl.direction = .left swipeRecognizerl.direction = .left
view.addGestureRecognizer(swipeRecognizerl) view.addGestureRecognizer(swipeRecognizerl)
} }
@objc func backButtonPressed(tap : UITapGestureRecognizer) { @objc func backButtonPressed(tap: UITapGestureRecognizer) {
// Dismiss info panel // Dismiss info panel
if showingInfoPanel { if showingInfoPanel {
if focusedOnTabBar { if focusedOnTabBar {
@ -503,75 +488,72 @@ class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate,
} }
return return
} }
// Cancel seek and move back to initial position // Cancel seek and move back to initial position
if(seeking) { if seeking {
scrubLabel.isHidden = true scrubLabel.isHidden = true
UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut, animations: { UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut, animations: {
self.scrubberView.frame = CGRect(x: self.initialSeekPos, y: 0, width: 2, height: 10) self.scrubberView.frame = CGRect(x: self.initialSeekPos, y: 0, width: 2, height: 10)
}) })
play() play()
seeking = false seeking = false
} } else {
else
{
// Dismiss view // Dismiss view
mediaPlayer.stop() mediaPlayer.stop()
sendStopReport() sendStopReport()
self.navigationController?.popViewController(animated: true) self.navigationController?.popViewController(animated: true)
} }
} }
@objc func userPanned(panGestureRecognizer : UIPanGestureRecognizer) { @objc func userPanned(panGestureRecognizer: UIPanGestureRecognizer) {
if loading { if loading {
return return
} }
let translation = panGestureRecognizer.translation(in: view) let translation = panGestureRecognizer.translation(in: view)
let velocity = panGestureRecognizer.velocity(in: view) let velocity = panGestureRecognizer.velocity(in: view)
// Swiped up - Handle dismissing info panel // Swiped up - Handle dismissing info panel
if translation.y < -700 && (focusedOnTabBar && showingInfoPanel) { if translation.y < -700 && (focusedOnTabBar && showingInfoPanel) {
toggleInfoContainer() toggleInfoContainer()
return return
} }
if showingInfoPanel { if showingInfoPanel {
return return
} }
// Swiped down - Show the info panel // Swiped down - Show the info panel
if translation.y > 700 { if translation.y > 700 {
toggleInfoContainer() toggleInfoContainer()
return return
} }
// Ignore seek if video is playing // Ignore seek if video is playing
if playing { if playing {
return return
} }
// Save current position if seek is cancelled and show the scrubLabel // Save current position if seek is cancelled and show the scrubLabel
if(!seeking) { if !seeking {
initialSeekPos = self.scrubberView.frame.minX initialSeekPos = self.scrubberView.frame.minX
seeking = true seeking = true
self.scrubLabel.isHidden = false self.scrubLabel.isHidden = false
} }
let newPos = (self.scrubberView.frame.minX + velocity.x/100).clamped(to: 0...transportBarView.frame.width) 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: { UIView.animate(withDuration: 0.8, delay: 0, options: .curveEaseOut, animations: {
let time = (Double(self.scrubberView.frame.minX) * self.videoDuration) / Double(self.transportBarView.frame.width) 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.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.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)) self.scrubLabel.text = (self.formatSecondsToHMS(time))
}) })
} }
// Not currently used // Not currently used
@objc func swipe(swipe: UISwipeGestureRecognizer!) { @objc func swipe(swipe: UISwipeGestureRecognizer!) {
print("swiped") print("swiped")
@ -593,53 +575,51 @@ class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate,
default: default:
break break
} }
} }
/// Play/Pause or Select is pressed on the AppleTV remote /// Play/Pause or Select is pressed on the AppleTV remote
@objc func selectButtonTapped() { @objc func selectButtonTapped() {
if loading { if loading {
return return
} }
showingControls = true showingControls = true
controlsView.isHidden = false controlsView.isHidden = false
controlsAppearTime = CACurrentMediaTime() controlsAppearTime = CACurrentMediaTime()
// Move to seeked position // Move to seeked position
if(seeking) { if seeking {
scrubLabel.isHidden = true scrubLabel.isHidden = true
// Move current time to the scrubbed position // Move current time to the scrubbed position
UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut, animations: { [self] in 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) 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) let time = (Double(self.scrubberView.frame.minX) * self.videoDuration) / Double(self.transportBarView.frame.width)
self.currentTimeLabel.text = self.scrubLabel.text self.currentTimeLabel.text = self.scrubLabel.text
self.remainingTimeLabel.text = "-" + formatSecondsToHMS(videoDuration - time) self.remainingTimeLabel.text = "-" + formatSecondsToHMS(videoDuration - time)
mediaPlayer.position = Float(self.scrubberView.frame.minX) / Float(self.transportBarView.frame.width) mediaPlayer.position = Float(self.scrubberView.frame.minX) / Float(self.transportBarView.frame.width)
play() play()
seeking = false seeking = false
return return
} }
playing ? pause() : play() playing ? pause() : play()
} }
// MARK: Jellyfin Playstate updates // MARK: Jellyfin Playstate updates
func sendProgressReport(eventName: String) { func sendProgressReport(eventName: String) {
if (eventName == "timeupdate" && mediaPlayer.state == .playing) || eventName != "timeupdate" { if (eventName == "timeupdate" && mediaPlayer.state == .playing) || eventName != "timeupdate" {
let progressInfo = PlaybackProgressInfo(canSeek: true, item: manifest, itemId: manifest.id, sessionId: playSessionId, mediaSourceId: manifest.id, audioStreamIndex: Int(selectedAudioTrack), subtitleStreamIndex: Int(selectedCaptionTrack), isPaused: (!playing), isMuted: false, positionTicks: Int64(mediaPlayer.position * Float(manifest.runTimeTicks!)), playbackStartTimeTicks: Int64(startTime), volumeLevel: 100, brightness: 100, aspectRatio: nil, playMethod: playbackItem.videoType, liveStreamId: nil, playSessionId: playSessionId, repeatMode: .repeatNone, nowPlayingQueue: [], playlistItemId: "playlistItem0") let progressInfo = PlaybackProgressInfo(canSeek: true, item: manifest, itemId: manifest.id, sessionId: playSessionId, mediaSourceId: manifest.id, audioStreamIndex: Int(selectedAudioTrack), subtitleStreamIndex: Int(selectedCaptionTrack), isPaused: (!playing), isMuted: false, positionTicks: Int64(mediaPlayer.position * Float(manifest.runTimeTicks!)), playbackStartTimeTicks: Int64(startTime), volumeLevel: 100, brightness: 100, aspectRatio: nil, playMethod: playbackItem.videoType, liveStreamId: nil, playSessionId: playSessionId, repeatMode: .repeatNone, nowPlayingQueue: [], playlistItemId: "playlistItem0")
PlaystateAPI.reportPlaybackProgress(playbackProgressInfo: progressInfo) PlaystateAPI.reportPlaybackProgress(playbackProgressInfo: progressInfo)
.sink(receiveCompletion: { result in .sink(receiveCompletion: { result in
print(result) print(result)
@ -649,10 +629,10 @@ class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate,
.store(in: &cancellables) .store(in: &cancellables)
} }
} }
func sendStopReport() { 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: []) 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) PlaystateAPI.reportPlaybackStopped(playbackStopInfo: stopInfo)
.sink(receiveCompletion: { result in .sink(receiveCompletion: { result in
print(result) print(result)
@ -661,14 +641,14 @@ class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate,
}) })
.store(in: &cancellables) .store(in: &cancellables)
} }
func sendPlayReport() { func sendPlayReport() {
startTime = Int(Date().timeIntervalSince1970) * 10000000 startTime = Int(Date().timeIntervalSince1970) * 10000000
print("sending play report!") 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") 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) PlaystateAPI.reportPlaybackStart(playbackStartInfo: startInfo)
.sink(receiveCompletion: { result in .sink(receiveCompletion: { result in
print(result) print(result)
@ -677,10 +657,9 @@ class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate,
}) })
.store(in: &cancellables) .store(in: &cancellables)
} }
// MARK: VLC Delegate // MARK: VLC Delegate
func mediaPlayerStateChanged(_ aNotification: Notification!) { func mediaPlayerStateChanged(_ aNotification: Notification!) {
let currentState: VLCMediaPlayerState = mediaPlayer.state let currentState: VLCMediaPlayerState = mediaPlayer.state
switch currentState { switch currentState {
@ -695,19 +674,19 @@ class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate,
break break
case .stopped: case .stopped:
print("stopped") print("stopped")
break break
case .ended: case .ended:
print("ended") print("ended")
break break
case .opening: case .opening:
print("opening") print("opening")
break break
case .paused: case .paused:
print("paused") print("paused")
break break
case .playing: case .playing:
print("Video is playing") print("Video is playing")
@ -728,14 +707,14 @@ class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate,
default: default:
print("default") print("default")
break break
} }
} }
// Move time along transport bar // Move time along transport bar
func mediaPlayerTimeChanged(_ aNotification: Notification!) { func mediaPlayerTimeChanged(_ aNotification: Notification!) {
if loading { if loading {
loading = false loading = false
DispatchQueue.main.async { [self] in DispatchQueue.main.async { [self] in
@ -744,20 +723,20 @@ class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate,
} }
updateNowPlayingCenter(time: nil, playing: true) updateNowPlayingCenter(time: nil, playing: true)
} }
let time = mediaPlayer.position let time = mediaPlayer.position
if time != lastTime { if time != lastTime {
self.currentTimeLabel.text = formatSecondsToHMS(Double(mediaPlayer.time.intValue/1000)) self.currentTimeLabel.text = formatSecondsToHMS(Double(mediaPlayer.time.intValue/1000))
self.remainingTimeLabel.text = "-" + formatSecondsToHMS(Double(abs(mediaPlayer.remainingTime.intValue/1000))) self.remainingTimeLabel.text = "-" + formatSecondsToHMS(Double(abs(mediaPlayer.remainingTime.intValue/1000)))
self.videoPos = Double(mediaPlayer.position) self.videoPos = Double(mediaPlayer.position)
let newPos = videoPos * Double(self.transportBarView.frame.width) let newPos = videoPos * Double(self.transportBarView.frame.width)
if !newPos.isNaN && self.playing { if !newPos.isNaN && self.playing {
self.scrubberView.frame = CGRect(x: newPos, y: 0, width: 2, height: 10) 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) 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 showingControls {
if CACurrentMediaTime() - controlsAppearTime > 5 { if CACurrentMediaTime() - controlsAppearTime > 5 {
showingControls = false showingControls = false
@ -770,34 +749,32 @@ class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate,
controlsAppearTime = 999_999_999_999_999 controlsAppearTime = 999_999_999_999_999
} }
} }
} }
lastTime = time lastTime = time
if CACurrentMediaTime() - lastProgressReportTime > 5 { if CACurrentMediaTime() - lastProgressReportTime > 5 {
sendProgressReport(eventName: "timeupdate") sendProgressReport(eventName: "timeupdate")
lastProgressReportTime = CACurrentMediaTime() lastProgressReportTime = CACurrentMediaTime()
} }
} }
// MARK: Settings Delegate // MARK: Settings Delegate
func selectNew(audioTrack id: Int32) { func selectNew(audioTrack id: Int32) {
selectedAudioTrack = id selectedAudioTrack = id
mediaPlayer.currentAudioTrackIndex = id mediaPlayer.currentAudioTrackIndex = id
} }
func selectNew(subtitleTrack id: Int32) { func selectNew(subtitleTrack id: Int32) {
selectedCaptionTrack = id selectedCaptionTrack = id
mediaPlayer.currentVideoSubTitleIndex = id mediaPlayer.currentVideoSubTitleIndex = id
} }
func setupInfoPanel() { func setupInfoPanel() {
containerViewController?.setupInfoViews(mediaItem: manifest, subtitleTracks: subtitleTrackArray, selectedSubtitleTrack: selectedCaptionTrack, audioTracks: audioTrackArray, selectedAudioTrack: selectedAudioTrack, delegate: self) containerViewController?.setupInfoViews(mediaItem: manifest, subtitleTracks: subtitleTrackArray, selectedSubtitleTrack: selectedCaptionTrack, audioTracks: audioTrackArray, selectedAudioTrack: selectedAudioTrack, delegate: self)
} }
func formatSecondsToHMS(_ seconds: Double) -> String { func formatSecondsToHMS(_ seconds: Double) -> String {
let timeHMSFormatter: DateComponentsFormatter = { let timeHMSFormatter: DateComponentsFormatter = {
let formatter = DateComponentsFormatter() let formatter = DateComponentsFormatter()
@ -808,16 +785,16 @@ class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate,
formatter.zeroFormattingBehavior = .pad formatter.zeroFormattingBehavior = .pad
return formatter return formatter
}() }()
guard !seconds.isNaN, guard !seconds.isNaN,
let text = timeHMSFormatter.string(from: seconds) else { let text = timeHMSFormatter.string(from: seconds) else {
return "00:00" return "00:00"
} }
return text.hasPrefix("0") && text.count > 4 ? return text.hasPrefix("0") && text.count > 4 ?
.init(text.dropFirst()) : text .init(text.dropFirst()) : text
} }
// When VLC video starts playing a real device can no longer receive gesture recognisers, adding this in hopes to fix the issue but no luck // When VLC video starts playing a real device can no longer receive gesture recognisers, adding this in hopes to fix the issue but no luck
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
print("recognisesimultaneousvideoplayer") print("recognisesimultaneousvideoplayer")

View File

@ -108,6 +108,7 @@
621C638026672A30004216EA /* NukeUI in Frameworks */ = {isa = PBXBuildFile; productRef = 621C637F26672A30004216EA /* NukeUI */; }; 621C638026672A30004216EA /* NukeUI in Frameworks */ = {isa = PBXBuildFile; productRef = 621C637F26672A30004216EA /* NukeUI */; };
6225FCCB2663841E00E067F6 /* ParallaxHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6225FCCA2663841E00E067F6 /* ParallaxHeader.swift */; }; 6225FCCB2663841E00E067F6 /* ParallaxHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6225FCCA2663841E00E067F6 /* ParallaxHeader.swift */; };
6228B1C22670EB010067FD35 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5377CBFD263B596B003A4E83 /* PersistenceController.swift */; }; 6228B1C22670EB010067FD35 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5377CBFD263B596B003A4E83 /* PersistenceController.swift */; };
624C21752685CF60007F1390 /* SearchablePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 624C21742685CF60007F1390 /* SearchablePickerView.swift */; };
625CB5682678B6FB00530A6E /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5672678B6FB00530A6E /* SplashView.swift */; }; 625CB5682678B6FB00530A6E /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5672678B6FB00530A6E /* SplashView.swift */; };
625CB56A2678B71200530A6E /* SplashViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5692678B71200530A6E /* SplashViewModel.swift */; }; 625CB56A2678B71200530A6E /* SplashViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5692678B71200530A6E /* SplashViewModel.swift */; };
625CB56C2678C0FD00530A6E /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB56B2678C0FD00530A6E /* MainTabView.swift */; }; 625CB56C2678C0FD00530A6E /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB56B2678C0FD00530A6E /* MainTabView.swift */; };
@ -133,6 +134,10 @@
628B95382670CDAB0091AF3B /* Model.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 5377CBFF263B596B003A4E83 /* Model.xcdatamodeld */; }; 628B95382670CDAB0091AF3B /* Model.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 5377CBFF263B596B003A4E83 /* Model.xcdatamodeld */; };
628B953A2670CE250091AF3B /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 628B95392670CE250091AF3B /* KeychainSwift */; }; 628B953A2670CE250091AF3B /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 628B95392670CE250091AF3B /* KeychainSwift */; };
628B953C2670D2430091AF3B /* StringExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621338922660107500A81A2A /* StringExtensions.swift */; }; 628B953C2670D2430091AF3B /* StringExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621338922660107500A81A2A /* StringExtensions.swift */; };
62CB3F462685BAF7003D0A6F /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = 62CB3F452685BAF7003D0A6F /* Defaults */; };
62CB3F482685BB3B003D0A6F /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = 62CB3F472685BB3B003D0A6F /* Defaults */; };
62CB3F4B2685BB77003D0A6F /* DefaultsExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62CB3F4A2685BB77003D0A6F /* DefaultsExtension.swift */; };
62CB3F4C2685BB77003D0A6F /* DefaultsExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62CB3F4A2685BB77003D0A6F /* DefaultsExtension.swift */; };
62E632DA267D2BC40063E547 /* LatestMediaViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632D9267D2BC40063E547 /* LatestMediaViewModel.swift */; }; 62E632DA267D2BC40063E547 /* LatestMediaViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632D9267D2BC40063E547 /* LatestMediaViewModel.swift */; };
62E632DC267D2E130063E547 /* LibrarySearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632DB267D2E130063E547 /* LibrarySearchViewModel.swift */; }; 62E632DC267D2E130063E547 /* LibrarySearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632DB267D2E130063E547 /* LibrarySearchViewModel.swift */; };
62E632DD267D2E130063E547 /* LibrarySearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632DB267D2E130063E547 /* LibrarySearchViewModel.swift */; }; 62E632DD267D2E130063E547 /* LibrarySearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632DB267D2E130063E547 /* LibrarySearchViewModel.swift */; };
@ -285,6 +290,7 @@
621338922660107500A81A2A /* StringExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringExtensions.swift; sourceTree = "<group>"; }; 621338922660107500A81A2A /* StringExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringExtensions.swift; sourceTree = "<group>"; };
621338B22660A07800A81A2A /* LazyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyView.swift; sourceTree = "<group>"; }; 621338B22660A07800A81A2A /* LazyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyView.swift; sourceTree = "<group>"; };
6225FCCA2663841E00E067F6 /* ParallaxHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParallaxHeader.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>"; };
625CB5672678B6FB00530A6E /* SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashView.swift; sourceTree = "<group>"; }; 625CB5672678B6FB00530A6E /* SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashView.swift; sourceTree = "<group>"; };
625CB5692678B71200530A6E /* SplashViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashViewModel.swift; sourceTree = "<group>"; }; 625CB5692678B71200530A6E /* SplashViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashViewModel.swift; sourceTree = "<group>"; };
625CB56B2678C0FD00530A6E /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; }; 625CB56B2678C0FD00530A6E /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
@ -304,6 +310,7 @@
628B952A2670CABE0091AF3B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 628B952A2670CABE0091AF3B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
628B95362670CB800091AF3B /* JellyfinWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinWidget.swift; sourceTree = "<group>"; }; 628B95362670CB800091AF3B /* JellyfinWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinWidget.swift; sourceTree = "<group>"; };
628B953B2670D1FC0091AF3B /* WidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WidgetExtension.entitlements; sourceTree = "<group>"; }; 628B953B2670D1FC0091AF3B /* WidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WidgetExtension.entitlements; sourceTree = "<group>"; };
62CB3F4A2685BB77003D0A6F /* DefaultsExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultsExtension.swift; sourceTree = "<group>"; };
62E632D9267D2BC40063E547 /* LatestMediaViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestMediaViewModel.swift; sourceTree = "<group>"; }; 62E632D9267D2BC40063E547 /* LatestMediaViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestMediaViewModel.swift; sourceTree = "<group>"; };
62E632DB267D2E130063E547 /* LibrarySearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySearchViewModel.swift; sourceTree = "<group>"; }; 62E632DB267D2E130063E547 /* LibrarySearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySearchViewModel.swift; sourceTree = "<group>"; };
62E632DF267D30CA0063E547 /* LibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewModel.swift; sourceTree = "<group>"; }; 62E632DF267D30CA0063E547 /* LibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewModel.swift; sourceTree = "<group>"; };
@ -331,6 +338,7 @@
53EC6E1E267E80AC006DD26A /* Pods_JellyfinPlayer_tvOS.framework in Frameworks */, 53EC6E1E267E80AC006DD26A /* Pods_JellyfinPlayer_tvOS.framework in Frameworks */,
53A431BF266B0FFE0016769F /* JellyfinAPI in Frameworks */, 53A431BF266B0FFE0016769F /* JellyfinAPI in Frameworks */,
535870912669D7A800D05A09 /* Introspect in Frameworks */, 535870912669D7A800D05A09 /* Introspect in Frameworks */,
62CB3F482685BB3B003D0A6F /* Defaults in Frameworks */,
5358708D2669D7A800D05A09 /* KeychainSwift in Frameworks */, 5358708D2669D7A800D05A09 /* KeychainSwift in Frameworks */,
536D3D84267BEA550004248C /* ParallaxView in Frameworks */, 536D3D84267BEA550004248C /* ParallaxView in Frameworks */,
53ABFDDC267972BF00886593 /* TVServices.framework in Frameworks */, 53ABFDDC267972BF00886593 /* TVServices.framework in Frameworks */,
@ -343,6 +351,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
62CB3F462685BAF7003D0A6F /* Defaults in Frameworks */,
5338F757263B7E2E0014BF09 /* KeychainSwift in Frameworks */, 5338F757263B7E2E0014BF09 /* KeychainSwift in Frameworks */,
53EC6E25267EB10F006DD26A /* SwiftyJSON in Frameworks */, 53EC6E25267EB10F006DD26A /* SwiftyJSON in Frameworks */,
53EC6E21267E80B1006DD26A /* Pods_JellyfinPlayer_iOS.framework in Frameworks */, 53EC6E21267E80B1006DD26A /* Pods_JellyfinPlayer_iOS.framework in Frameworks */,
@ -591,6 +600,8 @@
6267B3D526710B8900A7371D /* CollectionExtensions.swift */, 6267B3D526710B8900A7371D /* CollectionExtensions.swift */,
6267B3D92671138200A7371D /* ImageExtensions.swift */, 6267B3D92671138200A7371D /* ImageExtensions.swift */,
62EC353326766B03000E9F2D /* DeviceRotationViewModifier.swift */, 62EC353326766B03000E9F2D /* DeviceRotationViewModifier.swift */,
62CB3F4A2685BB77003D0A6F /* DefaultsExtension.swift */,
624C21742685CF60007F1390 /* SearchablePickerView.swift */,
); );
path = Extensions; path = Extensions;
sourceTree = "<group>"; sourceTree = "<group>";
@ -662,6 +673,7 @@
53A431BE266B0FFE0016769F /* JellyfinAPI */, 53A431BE266B0FFE0016769F /* JellyfinAPI */,
53ABFDEC26799D7700886593 /* ActivityIndicator */, 53ABFDEC26799D7700886593 /* ActivityIndicator */,
536D3D83267BEA550004248C /* ParallaxView */, 536D3D83267BEA550004248C /* ParallaxView */,
62CB3F472685BB3B003D0A6F /* Defaults */,
); );
productName = "JellyfinPlayer tvOS"; productName = "JellyfinPlayer tvOS";
productReference = 535870602669D21600D05A09 /* JellyfinPlayer tvOS.app */; productReference = 535870602669D21600D05A09 /* JellyfinPlayer tvOS.app */;
@ -693,6 +705,7 @@
53A431BC266B0FF20016769F /* JellyfinAPI */, 53A431BC266B0FF20016769F /* JellyfinAPI */,
625CB5792678C4A400530A6E /* ActivityIndicator */, 625CB5792678C4A400530A6E /* ActivityIndicator */,
53EC6E24267EB10F006DD26A /* SwiftyJSON */, 53EC6E24267EB10F006DD26A /* SwiftyJSON */,
62CB3F452685BAF7003D0A6F /* Defaults */,
); );
productName = JellyfinPlayer; productName = JellyfinPlayer;
productReference = 5377CBF1263B596A003A4E83 /* JellyfinPlayer iOS.app */; productReference = 5377CBF1263B596A003A4E83 /* JellyfinPlayer iOS.app */;
@ -761,6 +774,7 @@
625CB5782678C4A400530A6E /* XCRemoteSwiftPackageReference "ActivityIndicator" */, 625CB5782678C4A400530A6E /* XCRemoteSwiftPackageReference "ActivityIndicator" */,
536D3D82267BEA550004248C /* XCRemoteSwiftPackageReference "ParallaxView" */, 536D3D82267BEA550004248C /* XCRemoteSwiftPackageReference "ParallaxView" */,
53EC6E23267EB10F006DD26A /* XCRemoteSwiftPackageReference "SwiftyJSON" */, 53EC6E23267EB10F006DD26A /* XCRemoteSwiftPackageReference "SwiftyJSON" */,
62CB3F442685BAF7003D0A6F /* XCRemoteSwiftPackageReference "Defaults" */,
); );
productRefGroup = 5377CBF2263B596A003A4E83 /* Products */; productRefGroup = 5377CBF2263B596A003A4E83 /* Products */;
projectDirPath = ""; projectDirPath = "";
@ -944,6 +958,7 @@
091B5A8D268315D400D78B61 /* ServerDiscovery.swift in Sources */, 091B5A8D268315D400D78B61 /* ServerDiscovery.swift in Sources */,
53ABFDE7267974EF00886593 /* ConnectToServerViewModel.swift in Sources */, 53ABFDE7267974EF00886593 /* ConnectToServerViewModel.swift in Sources */,
53ABFDEE26799DCD00886593 /* ImageView.swift in Sources */, 53ABFDEE26799DCD00886593 /* ImageView.swift in Sources */,
62CB3F4C2685BB77003D0A6F /* DefaultsExtension.swift in Sources */,
62E632E4267D3BA60063E547 /* MovieItemViewModel.swift in Sources */, 62E632E4267D3BA60063E547 /* MovieItemViewModel.swift in Sources */,
5358706C2669D21700D05A09 /* PersistenceController.swift in Sources */, 5358706C2669D21700D05A09 /* PersistenceController.swift in Sources */,
535870AA2669D8AE00D05A09 /* BlurHashDecode.swift in Sources */, 535870AA2669D8AE00D05A09 /* BlurHashDecode.swift in Sources */,
@ -982,6 +997,7 @@
6225FCCB2663841E00E067F6 /* ParallaxHeader.swift in Sources */, 6225FCCB2663841E00E067F6 /* ParallaxHeader.swift in Sources */,
625CB5772678C34300530A6E /* ConnectToServerViewModel.swift in Sources */, 625CB5772678C34300530A6E /* ConnectToServerViewModel.swift in Sources */,
536D3D78267BD5C30004248C /* ViewModel.swift in Sources */, 536D3D78267BD5C30004248C /* ViewModel.swift in Sources */,
62CB3F4B2685BB77003D0A6F /* DefaultsExtension.swift in Sources */,
53DE4BD02670961400739748 /* EpisodeItemView.swift in Sources */, 53DE4BD02670961400739748 /* EpisodeItemView.swift in Sources */,
53F8377D265FF67C00F456B3 /* VideoPlayerSettingsView.swift in Sources */, 53F8377D265FF67C00F456B3 /* VideoPlayerSettingsView.swift in Sources */,
53192D5D265AA78A008A4215 /* DeviceProfileBuilder.swift in Sources */, 53192D5D265AA78A008A4215 /* DeviceProfileBuilder.swift in Sources */,
@ -1008,6 +1024,7 @@
621338B32660A07800A81A2A /* LazyView.swift in Sources */, 621338B32660A07800A81A2A /* LazyView.swift in Sources */,
531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */, 531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */,
62E632E0267D30CA0063E547 /* LibraryViewModel.swift in Sources */, 62E632E0267D30CA0063E547 /* LibraryViewModel.swift in Sources */,
624C21752685CF60007F1390 /* SearchablePickerView.swift in Sources */,
62EC352F267666A5000E9F2D /* SessionManager.swift in Sources */, 62EC352F267666A5000E9F2D /* SessionManager.swift in Sources */,
62E632E3267D3BA60063E547 /* MovieItemViewModel.swift in Sources */, 62E632E3267D3BA60063E547 /* MovieItemViewModel.swift in Sources */,
091B5A8A2683142E00D78B61 /* ServerDiscovery.swift in Sources */, 091B5A8A2683142E00D78B61 /* ServerDiscovery.swift in Sources */,
@ -1066,7 +1083,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "JellyfinPlayer tvOS/JellyfinPlayer tvOS.entitlements"; CODE_SIGN_ENTITLEMENTS = "JellyfinPlayer tvOS/JellyfinPlayer tvOS.entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 53; CURRENT_PROJECT_VERSION = 54;
DEVELOPMENT_ASSET_PATHS = "\"JellyfinPlayer tvOS/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"JellyfinPlayer tvOS/Preview Content\"";
DEVELOPMENT_TEAM = 9R8RREG67J; DEVELOPMENT_TEAM = 9R8RREG67J;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@ -1094,7 +1111,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "JellyfinPlayer tvOS/JellyfinPlayer tvOS.entitlements"; CODE_SIGN_ENTITLEMENTS = "JellyfinPlayer tvOS/JellyfinPlayer tvOS.entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 53; CURRENT_PROJECT_VERSION = 54;
DEVELOPMENT_ASSET_PATHS = "\"JellyfinPlayer tvOS/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"JellyfinPlayer tvOS/Preview Content\"";
DEVELOPMENT_TEAM = 9R8RREG67J; DEVELOPMENT_TEAM = 9R8RREG67J;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@ -1243,7 +1260,7 @@
CODE_SIGN_ENTITLEMENTS = JellyfinPlayer/JellyfinPlayer.entitlements; CODE_SIGN_ENTITLEMENTS = JellyfinPlayer/JellyfinPlayer.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 53; CURRENT_PROJECT_VERSION = 54;
DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = 9R8RREG67J; DEVELOPMENT_TEAM = 9R8RREG67J;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@ -1277,7 +1294,7 @@
CODE_SIGN_ENTITLEMENTS = JellyfinPlayer/JellyfinPlayer.entitlements; CODE_SIGN_ENTITLEMENTS = JellyfinPlayer/JellyfinPlayer.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 53; CURRENT_PROJECT_VERSION = 54;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = 9R8RREG67J; DEVELOPMENT_TEAM = 9R8RREG67J;
@ -1309,7 +1326,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements; CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 53; CURRENT_PROJECT_VERSION = 54;
DEVELOPMENT_TEAM = 9R8RREG67J; DEVELOPMENT_TEAM = 9R8RREG67J;
INFOPLIST_FILE = WidgetExtension/Info.plist; INFOPLIST_FILE = WidgetExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -1334,7 +1351,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements; CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 53; CURRENT_PROJECT_VERSION = 54;
DEVELOPMENT_TEAM = 9R8RREG67J; DEVELOPMENT_TEAM = 9R8RREG67J;
INFOPLIST_FILE = WidgetExtension/Info.plist; INFOPLIST_FILE = WidgetExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -1438,8 +1455,8 @@
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/kean/NukeUI"; repositoryURL = "https://github.com/kean/NukeUI";
requirement = { requirement = {
kind = exactVersion; kind = upToNextMajorVersion;
version = 0.3.0; minimumVersion = 0.3.0;
}; };
}; };
625CB5782678C4A400530A6E /* XCRemoteSwiftPackageReference "ActivityIndicator" */ = { 625CB5782678C4A400530A6E /* XCRemoteSwiftPackageReference "ActivityIndicator" */ = {
@ -1450,6 +1467,14 @@
minimumVersion = 1.1.0; minimumVersion = 1.1.0;
}; };
}; };
62CB3F442685BAF7003D0A6F /* XCRemoteSwiftPackageReference "Defaults" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/sindresorhus/Defaults";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 5.0.0;
};
};
/* End XCRemoteSwiftPackageReference section */ /* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
@ -1533,6 +1558,16 @@
package = 5338F755263B7E2E0014BF09 /* XCRemoteSwiftPackageReference "keychain-swift" */; package = 5338F755263B7E2E0014BF09 /* XCRemoteSwiftPackageReference "keychain-swift" */;
productName = KeychainSwift; productName = KeychainSwift;
}; };
62CB3F452685BAF7003D0A6F /* Defaults */ = {
isa = XCSwiftPackageProductDependency;
package = 62CB3F442685BAF7003D0A6F /* XCRemoteSwiftPackageReference "Defaults" */;
productName = Defaults;
};
62CB3F472685BB3B003D0A6F /* Defaults */ = {
isa = XCSwiftPackageProductDependency;
package = 62CB3F442685BAF7003D0A6F /* XCRemoteSwiftPackageReference "Defaults" */;
productName = Defaults;
};
/* End XCSwiftPackageProductDependency section */ /* End XCSwiftPackageProductDependency section */
/* Begin XCVersionGroup section */ /* Begin XCVersionGroup section */

View File

@ -19,6 +19,15 @@
"version": "0.6.0" "version": "0.6.0"
} }
}, },
{
"package": "Defaults",
"repositoryURL": "https://github.com/sindresorhus/Defaults",
"state": {
"branch": null,
"revision": "63d93f97ad545c8bceb125a8a36175ea705f7cf5",
"version": "5.0.0"
}
},
{ {
"package": "Gifu", "package": "Gifu",
"repositoryURL": "https://github.com/kaishin/Gifu", "repositoryURL": "https://github.com/kaishin/Gifu",
@ -60,8 +69,8 @@
"repositoryURL": "https://github.com/kean/NukeUI", "repositoryURL": "https://github.com/kean/NukeUI",
"state": { "state": {
"branch": null, "branch": null,
"revision": "d2580b8d22b29c6244418d8e4b568f3162191460", "revision": "4516371912149ac024dec361827931b46a69c217",
"version": "0.3.0" "version": "0.6.2"
} }
}, },
{ {

View File

@ -105,7 +105,7 @@ struct ConnectToServerView: View {
} }
} }
} else { } else {
Section(header: Text("Server Information")) { Section(header: Text("Manual Connection")) {
TextField("Jellyfin Server URL", text: $uri) TextField("Jellyfin Server URL", text: $uri)
.disableAutocorrection(true) .disableAutocorrection(true)
.autocapitalization(.none) .autocapitalization(.none)
@ -122,30 +122,27 @@ struct ConnectToServerView: View {
} }
.disabled(viewModel.isLoading || uri.isEmpty) .disabled(viewModel.isLoading || uri.isEmpty)
} }
Section(header: Text("Local Servers")) { Section(header: Text("Discovered Servers")) {
if self.viewModel.searching { if self.viewModel.searching {
ProgressView() ProgressView()
} }
ForEach(self.viewModel.servers, id: \.id) { server in ForEach(self.viewModel.servers, id: \.id) { server in
Button(action: { Button(action: {
print(server.url)
viewModel.connectToServer(at: server.url) viewModel.connectToServer(at: server.url)
}, label: { }, label: {
HStack { HStack {
VStack {
Text(server.name) Text(server.name)
.font(.headline) .font(.headline)
Text(server.host) Text("\(server.host)")
.font(.subheadline) .font(.subheadline)
.foregroundColor(.secondary)
}
Spacer() Spacer()
if viewModel.isLoading { if viewModel.isLoading {
ProgressView() ProgressView()
} }
} }
}) })
} }
} }

View File

@ -12,27 +12,27 @@ import JellyfinAPI
struct ProgressBar: Shape { struct ProgressBar: Shape {
func path(in rect: CGRect) -> Path { func path(in rect: CGRect) -> Path {
var path = Path() var path = Path()
let tl = CGPoint(x: rect.minX, y: rect.minY) let tl = CGPoint(x: rect.minX, y: rect.minY)
let tr = CGPoint(x: rect.maxX, y: rect.minY) let tr = CGPoint(x: rect.maxX, y: rect.minY)
let br = CGPoint(x: rect.maxX, y: rect.maxY) let br = CGPoint(x: rect.maxX, y: rect.maxY)
let bls = CGPoint(x: rect.minX + 10, y: rect.maxY) let bls = CGPoint(x: rect.minX + 10, y: rect.maxY)
let blc = CGPoint(x: rect.minX + 10, y: rect.maxY - 10) let blc = CGPoint(x: rect.minX + 10, y: rect.maxY - 10)
path.move(to: tl) path.move(to: tl)
path.addLine(to: tr) path.addLine(to: tr)
path.addLine(to: br) path.addLine(to: br)
path.addLine(to: bls) path.addLine(to: bls)
path.addRelativeArc(center: blc, radius: 10, path.addRelativeArc(center: blc, radius: 10,
startAngle: Angle.degrees(90), delta: Angle.degrees(90)) startAngle: Angle.degrees(90), delta: Angle.degrees(90))
return path return path
} }
} }
struct ContinueWatchingView: View { struct ContinueWatchingView: View {
var items: [BaseItemDto] var items: [BaseItemDto]
var body: some View { var body: some View {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
LazyHStack { LazyHStack {
@ -43,19 +43,6 @@ struct ContinueWatchingView: View {
.frame(width: 320, height: 180) .frame(width: 320, height: 180)
.cornerRadius(10) .cornerRadius(10)
.shadow(radius: 4) .shadow(radius: 4)
.overlay(
Group {
if item.type == "Episode" {
Text("\(item.name ?? "")")
.font(.caption)
.padding(6)
.foregroundColor(.white)
}
}.background(Color.black)
.opacity(0.8)
.cornerRadius(10.0)
.padding(6), alignment: .topTrailing
)
.overlay( .overlay(
Rectangle() Rectangle()
.fill(Color(red: 172/255, green: 92/255, blue: 195/255)) .fill(Color(red: 172/255, green: 92/255, blue: 195/255))
@ -63,12 +50,22 @@ struct ContinueWatchingView: View {
.frame(width: CGFloat((item.userData?.playedPercentage ?? 0) * 3.2), height: 7) .frame(width: CGFloat((item.userData?.playedPercentage ?? 0) * 3.2), height: 7)
.padding(0), alignment: .bottomLeading .padding(0), alignment: .bottomLeading
) )
Text(item.seriesName ?? item.name ?? "") HStack {
.font(.callout) Text("\(item.seriesName ?? item.name ?? "")")
.fontWeight(.semibold) .font(.callout)
.foregroundColor(.primary) .fontWeight(.semibold)
.lineLimit(1) .foregroundColor(.primary)
.frame(width: 320, alignment: .leading) .lineLimit(1)
if item.type == "Episode" {
Text("• S\(String(item.parentIndexNumber ?? 0)):E\(String(item.indexNumber ?? 0)) - \(item.name ?? "")")
.font(.callout)
.fontWeight(.semibold)
.foregroundColor(.secondary)
.lineLimit(1)
.offset(x: -1.4)
}
Spacer()
}.frame(width: 320, alignment: .leading)
}.padding(.top, 10) }.padding(.top, 10)
.padding(.bottom, 5) .padding(.bottom, 5)
} }

View File

@ -13,14 +13,14 @@ import SwiftUI
struct HomeView: View { struct HomeView: View {
@StateObject var viewModel = HomeViewModel() @StateObject var viewModel = HomeViewModel()
@State var showingSettings = false @State var showingSettings = false
@ViewBuilder @ViewBuilder
var innerBody: some View { var innerBody: some View {
if(viewModel.isLoading) { if viewModel.isLoading {
ProgressView() ProgressView()
} else { } else {
ScrollView { ScrollView {
LazyVStack(alignment: .leading) { VStack(alignment: .leading) {
if !viewModel.resumeItems.isEmpty { if !viewModel.resumeItems.isEmpty {
ContinueWatchingView(items: viewModel.resumeItems) ContinueWatchingView(items: viewModel.resumeItems)
} }
@ -53,11 +53,10 @@ struct HomeView: View {
} }
} }
} }
var body: some View { var body: some View {
innerBody innerBody
.navigationTitle(MainTabView.Tab.home.localized) .navigationTitle(MainTabView.Tab.home.localized)
/*
.toolbar { .toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) { ToolbarItemGroup(placement: .navigationBarTrailing) {
Button { Button {
@ -70,6 +69,5 @@ struct HomeView: View {
.fullScreenCover(isPresented: $showingSettings) { .fullScreenCover(isPresented: $showingSettings) {
SettingsView(viewModel: SettingsViewModel(), close: $showingSettings) SettingsView(viewModel: SettingsViewModel(), close: $showingSettings)
} }
*/
} }
} }

View File

@ -9,7 +9,7 @@ import SwiftUI
struct LatestMediaView: View { struct LatestMediaView: View {
@StateObject var viewModel: LatestMediaViewModel @StateObject var viewModel: LatestMediaViewModel
var body: some View { var body: some View {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
LazyHStack { LazyHStack {
@ -21,6 +21,16 @@ struct LatestMediaView: View {
.frame(width: 100, height: 150) .frame(width: 100, height: 150)
.cornerRadius(10) .cornerRadius(10)
.shadow(radius: 4) .shadow(radius: 4)
.overlay(
ZStack {
if item.userData!.played ?? false {
Image(systemName: "circle.fill")
.foregroundColor(.white)
Image(systemName: "checkmark.circle.fill")
.foregroundColor(Color(.systemBlue))
}
}.padding(2)
.opacity(1), alignment: .topTrailing).opacity(1)
Text(item.seriesName ?? item.name ?? "") Text(item.seriesName ?? item.name ?? "")
.font(.caption) .font(.caption)
.fontWeight(.semibold) .fontWeight(.semibold)

View File

@ -21,45 +21,54 @@ struct LibraryFilterView: View {
var body: some View { var body: some View {
NavigationView { NavigationView {
ZStack { VStack {
Form { if viewModel.isLoading {
if viewModel.enabledFilterType.contains(.genre) { ProgressView()
MultiSelector(label: "Genres", } else {
options: viewModel.possibleGenres, Form {
optionToString: { $0.name ?? "" }, if viewModel.enabledFilterType.contains(.genre) {
selected: $viewModel.modifiedFilters.withGenres) MultiSelector(label: "Genres",
} options: viewModel.possibleGenres,
if viewModel.enabledFilterType.contains(.filter) { optionToString: { $0.name ?? "" },
MultiSelector(label: "Filters", selected: $viewModel.modifiedFilters.withGenres)
options: viewModel.possibleItemFilters, }
optionToString: { $0.localized }, if viewModel.enabledFilterType.contains(.filter) {
selected: $viewModel.modifiedFilters.filters) MultiSelector(label: "Filters",
} options: viewModel.possibleItemFilters,
if viewModel.enabledFilterType.contains(.tag) { optionToString: { $0.localized },
MultiSelector(label: "Tags", selected: $viewModel.modifiedFilters.filters)
options: viewModel.possibleTags, }
optionToString: { $0 }, if viewModel.enabledFilterType.contains(.tag) {
selected: $viewModel.modifiedFilters.tags) MultiSelector(label: "Tags",
} options: viewModel.possibleTags,
if viewModel.enabledFilterType.contains(.sortBy) { optionToString: { $0 },
MultiSelector(label: "Sort by", selected: $viewModel.modifiedFilters.tags)
options: viewModel.possibleSortBys, }
optionToString: { $0.localized }, if viewModel.enabledFilterType.contains(.sortBy) {
selected: $viewModel.modifiedFilters.sortBy) Picker(selection: $viewModel.selectedSortBy, label: Text("Sort by")) {
} ForEach(viewModel.possibleSortBys, id: \.self) { so in
if viewModel.enabledFilterType.contains(.sortOrder) { Text(so.localized).tag(so)
Picker(selection: $viewModel.modifiedFilters.sortOrder, label: Text("Order")) { }
ForEach(viewModel.possibleSortOrders, id: \.self) { so in }
Text("\(so.rawValue)").tag(so.rawValue) }
if viewModel.enabledFilterType.contains(.sortOrder) {
Picker(selection: $viewModel.selectedSortOrder, label: Text("Display order")) {
ForEach(viewModel.possibleSortOrders, id: \.self) { so in
Text(so.rawValue).tag(so)
}
} }
} }
} }
} Button {
if viewModel.isLoading { viewModel.resetFilters()
ProgressView() self.filters = viewModel.modifiedFilters
presentationMode.wrappedValue.dismiss()
} label: {
Text("Reset")
}
} }
} }
.navigationBarTitle("Filters", displayMode: .inline) .navigationBarTitle("Filter Results", displayMode: .inline)
.toolbar { .toolbar {
ToolbarItemGroup(placement: .navigationBarLeading) { ToolbarItemGroup(placement: .navigationBarLeading) {
Button { Button {
@ -70,6 +79,7 @@ struct LibraryFilterView: View {
} }
ToolbarItemGroup(placement: .navigationBarTrailing) { ToolbarItemGroup(placement: .navigationBarTrailing) {
Button { Button {
viewModel.updateModifiedFilter()
self.filters = viewModel.modifiedFilters self.filters = viewModel.modifiedFilters
presentationMode.wrappedValue.dismiss() presentationMode.wrappedValue.dismiss()
} label: { } label: {

View File

@ -13,12 +13,12 @@ struct LibraryListView: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
LazyVStack() { LazyVStack {
NavigationLink(destination: LazyView { NavigationLink(destination: LazyView {
LibraryView(viewModel: .init(filters: viewModel.withFavorites), title: "Favorites") LibraryView(viewModel: .init(filters: viewModel.withFavorites), title: "Favorites")
}) { }) {
ZStack() { ZStack {
HStack() { HStack {
Spacer() Spacer()
Text("Your Favorites") Text("Your Favorites")
.foregroundColor(.black) .foregroundColor(.black)
@ -34,12 +34,12 @@ struct LibraryListView: View {
.cornerRadius(10) .cornerRadius(10)
.shadow(radius: 5) .shadow(radius: 5)
.padding(.bottom, 5) .padding(.bottom, 5)
NavigationLink(destination: LazyView { NavigationLink(destination: LazyView {
Text("WIP") Text("WIP")
}) { }) {
ZStack() { ZStack {
HStack() { HStack {
Spacer() Spacer()
Text("All Genres") Text("All Genres")
.foregroundColor(.black) .foregroundColor(.black)
@ -55,16 +55,16 @@ struct LibraryListView: View {
.cornerRadius(10) .cornerRadius(10)
.shadow(radius: 5) .shadow(radius: 5)
.padding(.bottom, 15) .padding(.bottom, 15)
ForEach(viewModel.libraries, id: \.id) { library in ForEach(viewModel.libraries, id: \.id) { library in
if(library.collectionType ?? "" == "movies" || library.collectionType ?? "" == "tvshows") { if library.collectionType ?? "" == "movies" || library.collectionType ?? "" == "tvshows" {
NavigationLink(destination: LazyView { NavigationLink(destination: LazyView {
LibraryView(viewModel: .init(parentID: library.id), title: library.name ?? "") LibraryView(viewModel: .init(parentID: library.id), title: library.name ?? "")
}) { }) {
ZStack() { ZStack {
ImageView(src: library.getPrimaryImage(maxWidth: 500), bh: library.getPrimaryImageBlurHash()) ImageView(src: library.getPrimaryImage(maxWidth: 500), bh: library.getPrimaryImageBlurHash())
.opacity(0.4) .opacity(0.4)
HStack() { HStack {
Spacer() Spacer()
Text(library.name ?? "") Text(library.name ?? "")
.foregroundColor(.white) .foregroundColor(.white)
@ -74,6 +74,7 @@ struct LibraryListView: View {
}.padding(32) }.padding(32)
}.background(Color.black) }.background(Color.black)
.frame(minWidth: 100, maxWidth: .infinity) .frame(minWidth: 100, maxWidth: .infinity)
.frame(height: 72)
} }
.cornerRadius(10) .cornerRadius(10)
.shadow(radius: 5) .shadow(radius: 5)

View File

@ -24,7 +24,7 @@ struct LibrarySearchView: View {
Spacer().frame(height: 6) Spacer().frame(height: 6)
SearchBar(text: $searchQuery) SearchBar(text: $searchQuery)
ZStack { ZStack {
if(!viewModel.isLoading) { if !viewModel.isLoading {
ScrollView(.vertical) { ScrollView(.vertical) {
if !viewModel.items.isEmpty { if !viewModel.items.isEmpty {
Spacer().frame(height: 16) Spacer().frame(height: 16)
@ -35,6 +35,16 @@ struct LibrarySearchView: View {
ImageView(src: item.getPrimaryImage(maxWidth: 100), bh: item.getPrimaryImageBlurHash()) ImageView(src: item.getPrimaryImage(maxWidth: 100), bh: item.getPrimaryImageBlurHash())
.frame(width: 100, height: 150) .frame(width: 100, height: 150)
.cornerRadius(10) .cornerRadius(10)
.overlay(
ZStack {
if item.userData!.played ?? false {
Image(systemName: "circle.fill")
.foregroundColor(.white)
Image(systemName: "checkmark.circle.fill")
.foregroundColor(Color(.systemBlue))
}
}.padding(2)
.opacity(1), alignment: .topTrailing).opacity(1)
Text(item.name ?? "") Text(item.name ?? "")
.font(.caption) .font(.caption)
.fontWeight(.semibold) .fontWeight(.semibold)

View File

@ -13,6 +13,7 @@ struct LibraryView: View {
var title: String var title: String
// MARK: tracks for grid // MARK: tracks for grid
var defaultFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], tags: [], sortBy: [.name])
@State var isShowingSearchView = false @State var isShowingSearchView = false
@State var isShowingFilterView = false @State var isShowingFilterView = false
@ -38,6 +39,16 @@ struct LibraryView: View {
ImageView(src: item.getPrimaryImage(maxWidth: 100), bh: item.getPrimaryImageBlurHash()) ImageView(src: item.getPrimaryImage(maxWidth: 100), bh: item.getPrimaryImageBlurHash())
.frame(width: 100, height: 150) .frame(width: 100, height: 150)
.cornerRadius(10) .cornerRadius(10)
.overlay(
ZStack {
if item.userData!.played ?? false {
Image(systemName: "circle.fill")
.foregroundColor(.white)
Image(systemName: "checkmark.circle.fill")
.foregroundColor(Color(.systemBlue))
}
}.padding(2)
.opacity(1), alignment: .topTrailing).opacity(1)
Text(item.name ?? "") Text(item.name ?? "")
.font(.caption) .font(.caption)
.fontWeight(.semibold) .fontWeight(.semibold)
@ -107,14 +118,14 @@ struct LibraryView: View {
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
} }
} }
Button(action: { Label("Icon One", systemImage: "line.horizontal.3.decrease.circle")
.foregroundColor(viewModel.filters == defaultFilters ? .accentColor : Color(UIColor.systemOrange))
.onTapGesture {
isShowingFilterView = true isShowingFilterView = true
}) {
Image(systemName: "line.horizontal.3.decrease.circle")
} }
Button(action: { Button {
isShowingSearchView = true isShowingSearchView = true
}) { } label: {
Image(systemName: "magnifyingglass") Image(systemName: "magnifyingglass")
} }
} }

View File

@ -10,9 +10,9 @@ import Combine
import JellyfinAPI import JellyfinAPI
struct NextUpView: View { struct NextUpView: View {
var items: [BaseItemDto] var items: [BaseItemDto]
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text("Next Up") Text("Next Up")

View File

@ -76,6 +76,16 @@ struct SeasonItemView: View {
.frame(width: CGFloat(episode.userData!.playedPercentage ?? 0 * 1.5), height: 7) .frame(width: CGFloat(episode.userData!.playedPercentage ?? 0 * 1.5), height: 7)
.padding(0), alignment: .bottomLeading .padding(0), alignment: .bottomLeading
) )
.overlay(
ZStack {
if episode.userData!.played ?? false {
Image(systemName: "circle.fill")
.foregroundColor(.white)
Image(systemName: "checkmark.circle.fill")
.foregroundColor(Color(.systemBlue))
}
}.padding(2)
.opacity(1), alignment: .topTrailing).opacity(1)
VStack(alignment: .leading) { VStack(alignment: .leading) {
HStack { HStack {
Text("S\(String(episode.parentIndexNumber ?? 0)):E\(String(episode.indexNumber ?? 0))").font(.subheadline) Text("S\(String(episode.parentIndexNumber ?? 0)):E\(String(episode.indexNumber ?? 0))").font(.subheadline)

View File

@ -7,6 +7,7 @@
import CoreData import CoreData
import SwiftUI import SwiftUI
import Defaults
struct SettingsView: View { struct SettingsView: View {
@Environment(\.managedObjectContext) private var viewContext @Environment(\.managedObjectContext) private var viewContext
@ -14,19 +15,15 @@ struct SettingsView: View {
@ObservedObject var viewModel: SettingsViewModel @ObservedObject var viewModel: SettingsViewModel
@Binding var close: Bool @Binding var close: Bool
@State private var inNetworkStreamBitrate: Int = 40_000_000 @Default(.inNetworkBandwidth) var inNetworkStreamBitrate
@State private var outOfNetworkStreamBitrate: Int = 40_000_000 @Default(.outOfNetworkBandwidth) var outOfNetworkStreamBitrate
@State private var autoSelectSubtitles: Bool = false @Default(.isAutoSelectSubtitles) var isAutoSelectSubtitles
@State private var autoSelectSubtitlesLangcode: String = "none" @Default(.autoSelectSubtitlesLangCode) var autoSelectSubtitlesLangcode
@Default(.autoSelectAudioLangCode) var autoSelectAudioLangcode
@State private var username: String = "" @State private var username: String = ""
func onAppear() { func onAppear() {
let defaults = UserDefaults.standard
username = SessionManager.current.user.username ?? "" username = SessionManager.current.user.username ?? ""
inNetworkStreamBitrate = defaults.integer(forKey: "InNetworkBandwidth")
outOfNetworkStreamBitrate = defaults.integer(forKey: "OutOfNetworkBandwidth")
autoSelectSubtitles = defaults.bool(forKey: "AutoSelectSubtitles")
autoSelectSubtitlesLangcode = defaults.string(forKey: "AutoSelectSubtitlesLangcode") ?? ""
} }
var body: some View { var body: some View {
@ -37,27 +34,33 @@ struct SettingsView: View {
ForEach(self.viewModel.bitrates, id: \.self) { bitrate in ForEach(self.viewModel.bitrates, id: \.self) { bitrate in
Text(bitrate.name).tag(bitrate.value) Text(bitrate.name).tag(bitrate.value)
} }
}.onChange(of: inNetworkStreamBitrate) { _ in
let defaults = UserDefaults.standard
defaults.setValue(_inNetworkStreamBitrate.wrappedValue, forKey: "InNetworkBandwidth")
} }
Picker("Default remote quality", selection: $outOfNetworkStreamBitrate) { Picker("Default remote quality", selection: $outOfNetworkStreamBitrate) {
ForEach(self.viewModel.bitrates, id: \.self) { bitrate in ForEach(self.viewModel.bitrates, id: \.self) { bitrate in
Text(bitrate.name).tag(bitrate.value) Text(bitrate.name).tag(bitrate.value)
} }
}.onChange(of: outOfNetworkStreamBitrate) { _ in
let defaults = UserDefaults.standard
defaults.setValue(_outOfNetworkStreamBitrate.wrappedValue, forKey: "OutOfNetworkBandwidth")
} }
} }
Section(header: Text("Accessibility")) { Section(header: Text("Accessibility")) {
Toggle("Automatically show subtitles", isOn: $autoSelectSubtitles).onChange(of: autoSelectSubtitles, perform: { _ in Toggle("Automatically show subtitles", isOn: $isAutoSelectSubtitles)
let defaults = UserDefaults.standard SearchablePicker(label: "Preferred subtitle language",
defaults.setValue(autoSelectSubtitles, forKey: "AutoSelectSubtitles") options: viewModel.langs,
}) optionToString: { $0.name },
Picker("Language preferences", selection: $autoSelectSubtitlesLangcode) {} selected: Binding<TrackLanguage>(
get: { viewModel.langs.first(where: { $0.isoCode == autoSelectSubtitlesLangcode }) ?? .auto },
set: {autoSelectSubtitlesLangcode = $0.isoCode}
)
)
SearchablePicker(label: "Preferred audio language",
options: viewModel.langs,
optionToString: { $0.name },
selected: Binding<TrackLanguage>(
get: { viewModel.langs.first(where: { $0.isoCode == autoSelectAudioLangcode }) ?? .auto },
set: { autoSelectAudioLangcode = $0.isoCode}
)
)
} }
Section { Section {
@ -65,6 +68,9 @@ struct SettingsView: View {
Text("Signed in as \(username)").foregroundColor(.primary) Text("Signed in as \(username)").foregroundColor(.primary)
Spacer() Spacer()
Button { Button {
let nc = NotificationCenter.default
nc.post(name: Notification.Name("didSignOut"), object: nil)
SessionManager.current.logout() SessionManager.current.logout()
} label: { } label: {
Text("Log out").font(.callout) Text("Log out").font(.callout)
@ -79,7 +85,7 @@ struct SettingsView: View {
Button { Button {
close = false close = false
} label: { } label: {
Text("Back").font(.callout) Image(systemName: "xmark")
} }
} }
} }

View File

@ -12,7 +12,7 @@ import MediaPlayer
import Combine import Combine
import GoogleCast import GoogleCast
import SwiftyJSON import SwiftyJSON
import Defaults
enum PlayerDestination { enum PlayerDestination {
case remote case remote
@ -53,10 +53,10 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
var startTime: Int = 0 var startTime: Int = 0
var controlsAppearTime: Double = 0 var controlsAppearTime: Double = 0
var isSeeking: Bool = false var isSeeking: Bool = false
var playerDestination: PlayerDestination = .local; var playerDestination: PlayerDestination = .local
var discoveredCastDevices: [GCKDevice] = []; var discoveredCastDevices: [GCKDevice] = []
var selectedCastDevice: GCKDevice?; var selectedCastDevice: GCKDevice?
var jellyfinCastChannel: GCKGenericChannel? var jellyfinCastChannel: GCKGenericChannel?
var remotePositionTicks: Int = 0 var remotePositionTicks: Int = 0
private var castDiscoveryManager: GCKDiscoveryManager { private var castDiscoveryManager: GCKDiscoveryManager {
@ -65,7 +65,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
private var castSessionManager: GCKSessionManager { private var castSessionManager: GCKSessionManager {
return GCKCastContext.sharedInstance().sessionManager return GCKCastContext.sharedInstance().sessionManager
} }
var hasSentRemoteSeek: Bool = false; var hasSentRemoteSeek: Bool = false
var selectedAudioTrack: Int32 = -1 var selectedAudioTrack: Int32 = -1
var selectedCaptionTrack: Int32 = -1 var selectedCaptionTrack: Int32 = -1
@ -77,11 +77,10 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
var manifest: BaseItemDto = BaseItemDto() var manifest: BaseItemDto = BaseItemDto()
var playbackItem = PlaybackItem() var playbackItem = PlaybackItem()
var remoteTimeUpdateTimer: Timer? var remoteTimeUpdateTimer: Timer?
// MARK: IBActions // MARK: IBActions
@IBAction func seekSliderStart(_ sender: Any) { @IBAction func seekSliderStart(_ sender: Any) {
if(playerDestination == .local) { if playerDestination == .local {
sendProgressReport(eventName: "pause") sendProgressReport(eventName: "pause")
mediaPlayer.pause() mediaPlayer.pause()
} else { } else {
@ -111,8 +110,8 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
// Scrub is value from 0..1 - find position in video and add / or remove. // Scrub is value from 0..1 - find position in video and add / or remove.
let secondsScrubbedTo = round(Double(seekSlider.value) * videoDuration) let secondsScrubbedTo = round(Double(seekSlider.value) * videoDuration)
let offset = secondsScrubbedTo - videoPosition let offset = secondsScrubbedTo - videoPosition
if(playerDestination == .local) { if playerDestination == .local {
if offset > 0 { if offset > 0 {
mediaPlayer.jumpForward(Int32(offset)) mediaPlayer.jumpForward(Int32(offset))
} else { } else {
@ -130,22 +129,22 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
@IBAction func exitButtonPressed(_ sender: Any) { @IBAction func exitButtonPressed(_ sender: Any) {
sendStopReport() sendStopReport()
mediaPlayer.stop() mediaPlayer.stop()
if(castSessionManager.hasConnectedCastSession()) { if castSessionManager.hasConnectedCastSession() {
castSessionManager.endSessionAndStopCasting(true) castSessionManager.endSessionAndStopCasting(true)
} }
delegate?.exitPlayer(self) delegate?.exitPlayer(self)
} }
@IBAction func controlViewTapped(_ sender: Any) { @IBAction func controlViewTapped(_ sender: Any) {
if(playerDestination == .local) { if playerDestination == .local {
videoControlsView.isHidden = true videoControlsView.isHidden = true
} }
} }
@IBAction func contentViewTapped(_ sender: Any) { @IBAction func contentViewTapped(_ sender: Any) {
if(playerDestination == .local) { if playerDestination == .local {
videoControlsView.isHidden = false videoControlsView.isHidden = false
controlsAppearTime = CACurrentMediaTime() controlsAppearTime = CACurrentMediaTime()
} }
@ -153,7 +152,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
@IBAction func jumpBackTapped(_ sender: Any) { @IBAction func jumpBackTapped(_ sender: Any) {
if paused == false { if paused == false {
if(playerDestination == .local) { if playerDestination == .local {
mediaPlayer.jumpBackward(15) mediaPlayer.jumpBackward(15)
} else { } else {
self.sendJellyfinCommand(command: "Seek", options: ["position": (remotePositionTicks/10_000_000)-15]) self.sendJellyfinCommand(command: "Seek", options: ["position": (remotePositionTicks/10_000_000)-15])
@ -163,7 +162,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
@IBAction func jumpForwardTapped(_ sender: Any) { @IBAction func jumpForwardTapped(_ sender: Any) {
if paused == false { if paused == false {
if(playerDestination == .local) { if playerDestination == .local {
mediaPlayer.jumpForward(30) mediaPlayer.jumpForward(30)
} else { } else {
self.sendJellyfinCommand(command: "Seek", options: ["position": (remotePositionTicks/10_000_000)+30]) self.sendJellyfinCommand(command: "Seek", options: ["position": (remotePositionTicks/10_000_000)+30])
@ -174,7 +173,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
@IBOutlet weak var mainActionButton: UIButton! @IBOutlet weak var mainActionButton: UIButton!
@IBAction func mainActionButtonPressed(_ sender: Any) { @IBAction func mainActionButtonPressed(_ sender: Any) {
if paused { if paused {
if(playerDestination == .local) { if playerDestination == .local {
mediaPlayer.play() mediaPlayer.play()
mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal) mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal)
paused = false paused = false
@ -184,7 +183,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
paused = false paused = false
} }
} else { } else {
if(playerDestination == .local) { if playerDestination == .local {
mediaPlayer.pause() mediaPlayer.pause()
mainActionButton.setImage(UIImage(systemName: "play"), for: .normal) mainActionButton.setImage(UIImage(systemName: "play"), for: .normal)
paused = true paused = true
@ -210,10 +209,10 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
self.mainActionButton.setImage(UIImage(systemName: "play"), for: .normal) self.mainActionButton.setImage(UIImage(systemName: "play"), for: .normal)
} }
} }
//MARK: Cast methods // MARK: Cast methods
@IBAction func castButtonPressed(_ sender: Any) { @IBAction func castButtonPressed(_ sender: Any) {
if(selectedCastDevice == nil) { if selectedCastDevice == nil {
castDeviceVC = VideoPlayerCastDeviceSelectorView() castDeviceVC = VideoPlayerCastDeviceSelectorView()
castDeviceVC?.delegate = self castDeviceVC?.delegate = self
@ -228,33 +227,33 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
} }
} else { } else {
castSessionManager.endSessionAndStopCasting(true) castSessionManager.endSessionAndStopCasting(true)
selectedCastDevice = nil; selectedCastDevice = nil
self.castButton.isEnabled = true self.castButton.isEnabled = true
self.castButton.setImage(UIImage(named: "CastDisconnected"), for: .normal) self.castButton.setImage(UIImage(named: "CastDisconnected"), for: .normal)
playerDestination = .local playerDestination = .local
} }
} }
func castPopoverDismissed() { func castPopoverDismissed() {
castDeviceVC?.dismiss(animated: true, completion: nil) castDeviceVC?.dismiss(animated: true, completion: nil)
if(playerDestination == .local) { if playerDestination == .local {
self.mediaPlayer.play() self.mediaPlayer.play()
} }
self.mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal) self.mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal)
} }
func castDeviceChanged() { func castDeviceChanged() {
if(selectedCastDevice != nil) { if selectedCastDevice != nil {
playerDestination = .remote playerDestination = .remote
castSessionManager.add(self) castSessionManager.add(self)
castSessionManager.startSession(with: selectedCastDevice!) castSessionManager.startSession(with: selectedCastDevice!)
} }
} }
//MARK: Cast End // MARK: Cast End
func settingsPopoverDismissed() { func settingsPopoverDismissed() {
optionsVC?.dismiss(animated: true, completion: nil) optionsVC?.dismiss(animated: true, completion: nil)
if(playerDestination == .local) { if playerDestination == .local {
self.mediaPlayer.play() self.mediaPlayer.play()
self.mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal) self.mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal)
} }
@ -270,7 +269,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
// Add handler for Pause Command // Add handler for Pause Command
commandCenter.pauseCommand.addTarget { _ in commandCenter.pauseCommand.addTarget { _ in
if(self.playerDestination == .local) { if self.playerDestination == .local {
self.mediaPlayer.pause() self.mediaPlayer.pause()
self.sendProgressReport(eventName: "pause") self.sendProgressReport(eventName: "pause")
} else { } else {
@ -282,7 +281,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
// Add handler for Play command // Add handler for Play command
commandCenter.playCommand.addTarget { _ in commandCenter.playCommand.addTarget { _ in
if(self.playerDestination == .local) { if self.playerDestination == .local {
self.mediaPlayer.play() self.mediaPlayer.play()
self.sendProgressReport(eventName: "unpause") self.sendProgressReport(eventName: "unpause")
} else { } else {
@ -294,7 +293,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
// Add handler for FF command // Add handler for FF command
commandCenter.seekForwardCommand.addTarget { _ in commandCenter.seekForwardCommand.addTarget { _ in
if(self.playerDestination == .local) { if self.playerDestination == .local {
self.mediaPlayer.jumpForward(30) self.mediaPlayer.jumpForward(30)
self.sendProgressReport(eventName: "timeupdate") self.sendProgressReport(eventName: "timeupdate")
} else { } else {
@ -305,7 +304,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
// Add handler for RW command // Add handler for RW command
commandCenter.seekBackwardCommand.addTarget { _ in commandCenter.seekBackwardCommand.addTarget { _ in
if(self.playerDestination == .local) { if self.playerDestination == .local {
self.mediaPlayer.jumpBackward(15) self.mediaPlayer.jumpBackward(15)
self.sendProgressReport(eventName: "timeupdate") self.sendProgressReport(eventName: "timeupdate")
} else { } else {
@ -320,11 +319,11 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
if let event = remoteEvent as? MPChangePlaybackPositionCommandEvent { if let event = remoteEvent as? MPChangePlaybackPositionCommandEvent {
let targetSeconds = event.positionTime let targetSeconds = event.positionTime
let videoPosition = Double(self.mediaPlayer.time.intValue) let videoPosition = Double(self.mediaPlayer.time.intValue)
let offset = targetSeconds - videoPosition let offset = targetSeconds - videoPosition
if(self.playerDestination == .local) { if self.playerDestination == .local {
if offset > 0 { if offset > 0 {
self.mediaPlayer.jumpForward(Int32(offset)/1000) self.mediaPlayer.jumpForward(Int32(offset)/1000)
} else { } else {
@ -332,7 +331,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
} }
self.sendProgressReport(eventName: "unpause") self.sendProgressReport(eventName: "unpause")
} else { } else {
} }
return .success return .success
@ -413,8 +412,8 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
// //
} }
if(!UIDevice.current.orientation.isLandscape || UIDevice.current.orientation.isFlat) { if !UIDevice.current.orientation.isLandscape || UIDevice.current.orientation.isFlat {
let value = UIInterfaceOrientation.landscapeRight.rawValue let value = UIInterfaceOrientation.landscapeRight.rawValue
UIDevice.current.setValue(value, forKey: "orientation") UIDevice.current.setValue(value, forKey: "orientation")
UIViewController.attemptRotationToDeviceOrientation() UIViewController.attemptRotationToDeviceOrientation()
@ -423,7 +422,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
} }
func mediaHasStartedPlaying() { func mediaHasStartedPlaying() {
castButton.isHidden = true; castButton.isHidden = true
let discoveryCriteria = GCKDiscoveryCriteria(applicationID: "F007D354") let discoveryCriteria = GCKDiscoveryCriteria(applicationID: "F007D354")
let gckCastOptions = GCKCastOptions(discoveryCriteria: discoveryCriteria) let gckCastOptions = GCKCastOptions(discoveryCriteria: discoveryCriteria)
GCKCastContext.setSharedInstanceWith(gckCastOptions) GCKCastContext.setSharedInstanceWith(gckCastOptions)
@ -431,11 +430,11 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
castDiscoveryManager.add(self) castDiscoveryManager.add(self)
castDiscoveryManager.startDiscovery() castDiscoveryManager.startDiscovery()
} }
func didUpdateDeviceList() { func didUpdateDeviceList() {
let totalDevices = castDiscoveryManager.deviceCount; let totalDevices = castDiscoveryManager.deviceCount
discoveredCastDevices = [] discoveredCastDevices = []
if(totalDevices > 0) { if totalDevices > 0 {
for i in 0...totalDevices-1 { for i in 0...totalDevices-1 {
let device = castDiscoveryManager.device(at: i) let device = castDiscoveryManager.device(at: i)
discoveredCastDevices.append(device) discoveredCastDevices.append(device)
@ -452,15 +451,15 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
castButton.setImage(nil, for: .normal) castButton.setImage(nil, for: .normal)
} }
} }
override func viewWillDisappear(_ animated: Bool) { override func viewWillDisappear(_ animated: Bool) {
self.tabBarController?.tabBar.isHidden = false self.tabBarController?.tabBar.isHidden = false
self.navigationController?.isNavigationBarHidden = false self.navigationController?.isNavigationBarHidden = false
overrideUserInterfaceStyle = .unspecified overrideUserInterfaceStyle = .unspecified
super.viewWillDisappear(animated) super.viewWillDisappear(animated)
} }
//MARK: viewDidAppear // MARK: viewDidAppear
override func viewDidAppear(_ animated: Bool) { override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated) super.viewDidAppear(animated)
overrideUserInterfaceStyle = .dark overrideUserInterfaceStyle = .dark
@ -474,8 +473,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
mediaPlayer.drawable = videoContentView mediaPlayer.drawable = videoContentView
// Fetch max bitrate from UserDefaults depending on current connection mode // Fetch max bitrate from UserDefaults depending on current connection mode
let defaults = UserDefaults.standard let maxBitrate = Defaults[.inNetworkBandwidth]
let maxBitrate = defaults.integer(forKey: "InNetworkBandwidth")
// Build a device profile // Build a device profile
let builder = DeviceProfileBuilder() let builder = DeviceProfileBuilder()
@ -513,7 +511,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
item.videoType = .transcode item.videoType = .transcode
item.videoUrl = streamURL! item.videoUrl = streamURL!
let disableSubtitleTrack = Subtitle(name: "Disabled", id: -1, url: nil, delivery: .embed, codec: "") let disableSubtitleTrack = Subtitle(name: "Disabled", id: -1, url: nil, delivery: .embed, codec: "", languageCode: "")
subtitleTrackArray.append(disableSubtitleTrack) subtitleTrackArray.append(disableSubtitleTrack)
// Loop through media streams and add to array // Loop through media streams and add to array
@ -525,7 +523,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
} else { } else {
deliveryUrl = nil deliveryUrl = nil
} }
let subtitle = Subtitle(name: stream.displayTitle ?? "Unknown", id: Int32(stream.index!), url: deliveryUrl, delivery: stream.deliveryMethod!, codec: stream.codec ?? "webvtt") let subtitle = Subtitle(name: stream.displayTitle ?? "Unknown", id: Int32(stream.index!), url: deliveryUrl, delivery: stream.deliveryMethod!, codec: stream.codec ?? "webvtt", languageCode: stream.language ?? "")
if subtitle.delivery != .encode { if subtitle.delivery != .encode {
subtitleTrackArray.append(subtitle) subtitleTrackArray.append(subtitle)
@ -533,7 +531,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
} }
if stream.type == .audio { if stream.type == .audio {
let subtitle = AudioTrack(name: stream.displayTitle!, id: Int32(stream.index!)) let subtitle = AudioTrack(name: stream.displayTitle!, languageCode: stream.language ?? "", id: Int32(stream.index!))
if stream.isDefault! == true { if stream.isDefault! == true {
selectedAudioTrack = Int32(stream.index!) selectedAudioTrack = Int32(stream.index!)
} }
@ -557,7 +555,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
item.videoUrl = streamURL item.videoUrl = streamURL
item.videoType = .directPlay item.videoType = .directPlay
let disableSubtitleTrack = Subtitle(name: "Disabled", id: -1, url: nil, delivery: .embed, codec: "") let disableSubtitleTrack = Subtitle(name: "Disabled", id: -1, url: nil, delivery: .embed, codec: "", languageCode: "")
subtitleTrackArray.append(disableSubtitleTrack) subtitleTrackArray.append(disableSubtitleTrack)
// Loop through media streams and add to array // Loop through media streams and add to array
@ -569,7 +567,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
} else { } else {
deliveryUrl = nil deliveryUrl = nil
} }
let subtitle = Subtitle(name: stream.displayTitle ?? "Unknown", id: Int32(stream.index!), url: deliveryUrl, delivery: stream.deliveryMethod!, codec: stream.codec!) let subtitle = Subtitle(name: stream.displayTitle ?? "Unknown", id: Int32(stream.index!), url: deliveryUrl, delivery: stream.deliveryMethod!, codec: stream.codec!, languageCode: stream.language ?? "")
if subtitle.delivery != .encode { if subtitle.delivery != .encode {
subtitleTrackArray.append(subtitle) subtitleTrackArray.append(subtitle)
@ -577,7 +575,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
} }
if stream.type == .audio { if stream.type == .audio {
let subtitle = AudioTrack(name: stream.displayTitle!, id: Int32(stream.index!)) let subtitle = AudioTrack(name: stream.displayTitle!, languageCode: stream.language ?? "", id: Int32(stream.index!))
if stream.isDefault! == true { if stream.isDefault! == true {
selectedAudioTrack = Int32(stream.index!) selectedAudioTrack = Int32(stream.index!)
} }
@ -590,7 +588,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
selectedAudioTrack = audioTrackArray[0].id selectedAudioTrack = audioTrackArray[0].id
} }
} }
self.sendPlayReport() self.sendPlayReport()
playbackItem = item playbackItem = item
@ -604,6 +602,28 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
} }
} }
func setupTracksForPreferredDefaults() {
subtitleTrackArray.forEach { subtitle in
if Defaults[.isAutoSelectSubtitles] {
if Defaults[.autoSelectSubtitlesLangCode] == "Auto",
subtitle.languageCode.contains(Locale.current.languageCode ?? "") {
selectedCaptionTrack = subtitle.id
mediaPlayer.currentVideoSubTitleIndex = subtitle.id
} else if subtitle.languageCode.contains(Defaults[.autoSelectSubtitlesLangCode]) {
selectedCaptionTrack = subtitle.id
mediaPlayer.currentVideoSubTitleIndex = subtitle.id
}
}
}
audioTrackArray.forEach { audio in
if audio.languageCode.contains(Defaults[.autoSelectAudioLangCode]) {
selectedAudioTrack = audio.id
mediaPlayer.currentAudioTrackIndex = audio.id
}
}
}
func startLocalPlaybackEngine(_ fetchCaptions: Bool) { func startLocalPlaybackEngine(_ fetchCaptions: Bool) {
print("Local playback engine starting.") print("Local playback engine starting.")
mediaPlayer.media = VLCMedia(url: playbackItem.videoUrl) mediaPlayer.media = VLCMedia(url: playbackItem.videoUrl)
@ -611,17 +631,17 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
sendPlayReport() sendPlayReport()
// 1 second = 10,000,000 ticks // 1 second = 10,000,000 ticks
var startTicks: Int64 = 0; var startTicks: Int64 = 0
if(remotePositionTicks == 0) { if remotePositionTicks == 0 {
print("Using server-reported start time") print("Using server-reported start time")
startTicks = manifest.userData?.playbackPositionTicks ?? 0 startTicks = manifest.userData?.playbackPositionTicks ?? 0
} else { } else {
print("Using remote-reported start time") print("Using remote-reported start time")
startTicks = Int64(remotePositionTicks) startTicks = Int64(remotePositionTicks)
} }
if startTicks != 0 { if startTicks != 0 {
let videoPosition = Double(mediaPlayer.time.intValue / 1000); let videoPosition = Double(mediaPlayer.time.intValue / 1000)
let secondsScrubbedTo = startTicks / 10_000_000 let secondsScrubbedTo = startTicks / 10_000_000
let offset = secondsScrubbedTo - Int64(videoPosition) let offset = secondsScrubbedTo - Int64(videoPosition)
print("Seeking to position: \(secondsScrubbedTo)") print("Seeking to position: \(secondsScrubbedTo)")
@ -631,8 +651,8 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
mediaPlayer.jumpBackward(Int32(abs(offset))) mediaPlayer.jumpBackward(Int32(abs(offset)))
} }
} }
if(fetchCaptions) { if fetchCaptions {
print("Fetching captions.") print("Fetching captions.")
// Pause and load captions into memory. // Pause and load captions into memory.
mediaPlayer.pause() mediaPlayer.pause()
@ -642,20 +662,21 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
} }
} }
} }
self.mediaHasStartedPlaying() self.mediaHasStartedPlaying()
delegate?.hideLoadingView(self) delegate?.hideLoadingView(self)
videoContentView.setNeedsLayout() videoContentView.setNeedsLayout()
videoContentView.setNeedsDisplay() videoContentView.setNeedsDisplay()
self.view.setNeedsLayout() self.view.setNeedsLayout()
self.view.setNeedsDisplay() self.view.setNeedsDisplay()
self.videoControlsView.setNeedsLayout() self.videoControlsView.setNeedsLayout()
self.videoControlsView.setNeedsDisplay() self.videoControlsView.setNeedsDisplay()
mediaPlayer.pause() mediaPlayer.pause()
mediaPlayer.play() mediaPlayer.play()
setupTracksForPreferredDefaults()
print("Local engine started.") print("Local engine started.")
} }
@ -671,15 +692,15 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
} }
} }
//MARK: - GCKGenericChannelDelegate // MARK: - GCKGenericChannelDelegate
extension PlayerViewController: GCKGenericChannelDelegate { extension PlayerViewController: GCKGenericChannelDelegate {
@objc func updateRemoteTime() { @objc func updateRemoteTime() {
castButton.setImage(UIImage(named: "CastConnected"), for: .normal) castButton.setImage(UIImage(named: "CastConnected"), for: .normal)
if(!paused) { if !paused {
remotePositionTicks = remotePositionTicks + 2_000_000; //add 0.2 secs every timer evt. remotePositionTicks = remotePositionTicks + 2_000_000; // add 0.2 secs every timer evt.
} }
if(isSeeking == false) { if isSeeking == false {
let remainingTime = (manifest.runTimeTicks! - Int64(remotePositionTicks))/10_000_000 let remainingTime = (manifest.runTimeTicks! - Int64(remotePositionTicks))/10_000_000
let hours = remainingTime / 3600 let hours = remainingTime / 3600
let minutes = (remainingTime % 3600) / 60 let minutes = (remainingTime % 3600) / 60
@ -691,36 +712,36 @@ extension PlayerViewController: GCKGenericChannelDelegate {
timeTextStr = "\(String(Int((minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int((seconds))).leftPad(toWidth: 2, withString: "0"))" timeTextStr = "\(String(Int((minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int((seconds))).leftPad(toWidth: 2, withString: "0"))"
} }
timeText.text = timeTextStr timeText.text = timeTextStr
let playbackProgress = Float(remotePositionTicks) / Float(manifest.runTimeTicks!) let playbackProgress = Float(remotePositionTicks) / Float(manifest.runTimeTicks!)
seekSlider.setValue(playbackProgress, animated: true) seekSlider.setValue(playbackProgress, animated: true)
} }
} }
func cast(_ channel: GCKGenericChannel, didReceiveTextMessage message: String, withNamespace protocolNamespace: String) { func cast(_ channel: GCKGenericChannel, didReceiveTextMessage message: String, withNamespace protocolNamespace: String) {
if let data = message.data(using: .utf8) { if let data = message.data(using: .utf8) {
if let json = try? JSON(data: data) { if let json = try? JSON(data: data) {
let messageType = json["type"].string ?? "" let messageType = json["type"].string ?? ""
if(messageType == "playbackprogress") { if messageType == "playbackprogress" {
dump(json) dump(json)
if(remotePositionTicks > 100) { if remotePositionTicks > 100 {
if(hasSentRemoteSeek == false) { if hasSentRemoteSeek == false {
hasSentRemoteSeek = true; hasSentRemoteSeek = true
sendJellyfinCommand(command: "Seek", options: [ sendJellyfinCommand(command: "Seek", options: [
"position": Int(Float(manifest.runTimeTicks! / 10_000_000) * mediaPlayer.position) "position": Int(Float(manifest.runTimeTicks! / 10_000_000) * mediaPlayer.position)
]) ])
} }
} }
paused = json["data"]["PlayState"]["IsPaused"].boolValue paused = json["data"]["PlayState"]["IsPaused"].boolValue
self.remotePositionTicks = json["data"]["PlayState"]["PositionTicks"].int ?? 0; self.remotePositionTicks = json["data"]["PlayState"]["PositionTicks"].int ?? 0
if(remoteTimeUpdateTimer == nil) { if remoteTimeUpdateTimer == nil {
remoteTimeUpdateTimer = Timer.scheduledTimer(timeInterval: 0.2, target: self, selector: #selector(updateRemoteTime), userInfo: nil, repeats: true) remoteTimeUpdateTimer = Timer.scheduledTimer(timeInterval: 0.2, target: self, selector: #selector(updateRemoteTime), userInfo: nil, repeats: true)
} }
} }
} }
} }
} }
func sendJellyfinCommand(command: String, options: [String: Any]) { func sendJellyfinCommand(command: String, options: [String: Any]) {
let payload: [String: Any] = [ let payload: [String: Any] = [
"options": options, "options": options,
@ -736,12 +757,12 @@ extension PlayerViewController: GCKGenericChannelDelegate {
] ]
print(payload) print(payload)
let jsonData = JSON(payload) let jsonData = JSON(payload)
jellyfinCastChannel?.sendTextMessage(jsonData.rawString()!, error: nil) jellyfinCastChannel?.sendTextMessage(jsonData.rawString()!, error: nil)
if(command == "Seek") { if command == "Seek" {
remotePositionTicks = remotePositionTicks + ((options["position"] as! Int) * 10_000_000) remotePositionTicks = remotePositionTicks + ((options["position"] as! Int) * 10_000_000)
//Send playback report as Jellyfin Chromecast isn't smarter than a rock. // Send playback report as Jellyfin Chromecast isn't smarter than a rock.
let progressInfo = PlaybackProgressInfo(canSeek: true, item: manifest, itemId: manifest.id, sessionId: playSessionId, mediaSourceId: manifest.id, audioStreamIndex: Int(selectedAudioTrack), subtitleStreamIndex: Int(selectedCaptionTrack), isPaused: paused, isMuted: false, positionTicks: Int64(remotePositionTicks), playbackStartTimeTicks: Int64(startTime), volumeLevel: 100, brightness: 100, aspectRatio: nil, playMethod: playbackItem.videoType, liveStreamId: nil, playSessionId: playSessionId, repeatMode: .repeatNone, nowPlayingQueue: [], playlistItemId: "playlistItem0") let progressInfo = PlaybackProgressInfo(canSeek: true, item: manifest, itemId: manifest.id, sessionId: playSessionId, mediaSourceId: manifest.id, audioStreamIndex: Int(selectedAudioTrack), subtitleStreamIndex: Int(selectedCaptionTrack), isPaused: paused, isMuted: false, positionTicks: Int64(remotePositionTicks), 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) PlaystateAPI.reportPlaybackProgress(playbackProgressInfo: progressInfo)
@ -755,25 +776,25 @@ extension PlayerViewController: GCKGenericChannelDelegate {
} }
} }
//MARK: - GCKSessionManagerListener // MARK: - GCKSessionManagerListener
extension PlayerViewController: GCKSessionManagerListener { extension PlayerViewController: GCKSessionManagerListener {
func sessionDidStart(manager: GCKSessionManager, didStart session: GCKCastSession) { func sessionDidStart(manager: GCKSessionManager, didStart session: GCKCastSession) {
self.sendStopReport() self.sendStopReport()
mediaPlayer.stop() mediaPlayer.stop()
playerDestination = .remote playerDestination = .remote
videoContentView.isHidden = true; videoContentView.isHidden = true
videoControlsView.isHidden = false; videoControlsView.isHidden = false
castButton.setImage(UIImage(named: "CastConnected"), for: .normal) castButton.setImage(UIImage(named: "CastConnected"), for: .normal)
manager.currentCastSession?.start() manager.currentCastSession?.start()
jellyfinCastChannel!.delegate = self jellyfinCastChannel!.delegate = self
session.add(jellyfinCastChannel!) session.add(jellyfinCastChannel!)
if let client = session.remoteMediaClient { if let client = session.remoteMediaClient {
client.add(self) client.add(self)
} }
let playNowOptions: [String: Any] = [ let playNowOptions: [String: Any] = [
"items": [[ "items": [[
"Id": self.manifest.id!, "Id": self.manifest.id!,
@ -786,44 +807,43 @@ extension PlayerViewController: GCKSessionManagerListener {
] ]
sendJellyfinCommand(command: "PlayNow", options: playNowOptions) sendJellyfinCommand(command: "PlayNow", options: playNowOptions)
} }
func sessionManager(_ sessionManager: GCKSessionManager, didStart session: GCKCastSession) { func sessionManager(_ sessionManager: GCKSessionManager, didStart session: GCKCastSession) {
print("starting session") print("starting session")
self.jellyfinCastChannel = GCKGenericChannel(namespace: "urn:x-cast:com.connectsdk") self.jellyfinCastChannel = GCKGenericChannel(namespace: "urn:x-cast:com.connectsdk")
self.sessionDidStart(manager: sessionManager, didStart: session) self.sessionDidStart(manager: sessionManager, didStart: session)
} }
func sessionManager(_ sessionManager: GCKSessionManager, didResumeCastSession session: GCKCastSession) { func sessionManager(_ sessionManager: GCKSessionManager, didResumeCastSession session: GCKCastSession) {
self.jellyfinCastChannel = GCKGenericChannel(namespace: "urn:x-cast:com.connectsdk") self.jellyfinCastChannel = GCKGenericChannel(namespace: "urn:x-cast:com.connectsdk")
print("resuming session") print("resuming session")
self.sessionDidStart(manager: sessionManager, didStart: session) self.sessionDidStart(manager: sessionManager, didStart: session)
} }
func sessionManager(_ sessionManager: GCKSessionManager, didFailToStart session: GCKCastSession, withError error: Error) { func sessionManager(_ sessionManager: GCKSessionManager, didFailToStart session: GCKCastSession, withError error: Error) {
dump(error) dump(error)
} }
func sessionManager(_ sessionManager: GCKSessionManager, didEnd session: GCKCastSession, withError error: Error?) { func sessionManager(_ sessionManager: GCKSessionManager, didEnd session: GCKCastSession, withError error: Error?) {
print("didEnd") print("didEnd")
playerDestination = .local; playerDestination = .local
videoContentView.isHidden = false; videoContentView.isHidden = false
remoteTimeUpdateTimer?.invalidate() remoteTimeUpdateTimer?.invalidate()
castButton.setImage(UIImage(named: "CastDisconnected"), for: .normal) castButton.setImage(UIImage(named: "CastDisconnected"), for: .normal)
startLocalPlaybackEngine(false) startLocalPlaybackEngine(false)
} }
func sessionManager(_ sessionManager: GCKSessionManager, didSuspend session: GCKCastSession, with reason: GCKConnectionSuspendReason) { func sessionManager(_ sessionManager: GCKSessionManager, didSuspend session: GCKCastSession, with reason: GCKConnectionSuspendReason) {
print("didSuspend") print("didSuspend")
playerDestination = .local; playerDestination = .local
videoContentView.isHidden = false; videoContentView.isHidden = false
remoteTimeUpdateTimer?.invalidate() remoteTimeUpdateTimer?.invalidate()
castButton.setImage(UIImage(named: "CastDisconnected"), for: .normal) castButton.setImage(UIImage(named: "CastDisconnected"), for: .normal)
startLocalPlaybackEngine(false) startLocalPlaybackEngine(false)
} }
} }
//MARK: - VLCMediaPlayer Delegates // MARK: - VLCMediaPlayer Delegates
extension PlayerViewController: VLCMediaPlayerDelegate { extension PlayerViewController: VLCMediaPlayerDelegate {
func mediaPlayerStateChanged(_ aNotification: Notification!) { func mediaPlayerStateChanged(_ aNotification: Notification!) {
let currentState: VLCMediaPlayerState = mediaPlayer.state let currentState: VLCMediaPlayerState = mediaPlayer.state
@ -857,7 +877,7 @@ extension PlayerViewController: VLCMediaPlayerDelegate {
break break
} }
} }
func mediaPlayerTimeChanged(_ aNotification: Notification!) { func mediaPlayerTimeChanged(_ aNotification: Notification!) {
let time = mediaPlayer.position let time = mediaPlayer.position
if abs(time-lastTime) > 0.00005 { if abs(time-lastTime) > 0.00005 {
@ -888,18 +908,7 @@ extension PlayerViewController: VLCMediaPlayerDelegate {
} }
} }
// MARK: End VideoPlayerVC
//MARK: End VideoPlayerVC
struct VLCPlayerWithControls: UIViewControllerRepresentable { struct VLCPlayerWithControls: UIViewControllerRepresentable {
var item: BaseItemDto var item: BaseItemDto
@Environment(\.presentationMode) var presentationMode @Environment(\.presentationMode) var presentationMode
@ -946,7 +955,7 @@ struct VLCPlayerWithControls: UIViewControllerRepresentable {
} }
} }
//MARK: - Play State Update Methods // MARK: - Play State Update Methods
extension PlayerViewController { extension PlayerViewController {
func sendProgressReport(eventName: String) { func sendProgressReport(eventName: String) {
if (eventName == "timeupdate" && mediaPlayer.state == .playing) || eventName != "timeupdate" { if (eventName == "timeupdate" && mediaPlayer.state == .playing) || eventName != "timeupdate" {

View File

@ -15,7 +15,7 @@ class VideoPlayerCastDeviceSelectorView: UIViewController {
override var supportedInterfaceOrientations: UIInterfaceOrientationMask { override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
.landscape .landscape
} }
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
contentView = UIHostingController(rootView: VideoPlayerCastDeviceSelector(delegate: self.delegate ?? PlayerViewController())) contentView = UIHostingController(rootView: VideoPlayerCastDeviceSelector(delegate: self.delegate ?? PlayerViewController()))
@ -43,9 +43,9 @@ struct VideoPlayerCastDeviceSelector: View {
var body: some View { var body: some View {
NavigationView { NavigationView {
Group { Group {
if(!delegate.discoveredCastDevices.isEmpty) { if !delegate.discoveredCastDevices.isEmpty {
List(delegate.discoveredCastDevices, id: \.deviceID) { device in List(delegate.discoveredCastDevices, id: \.deviceID) { device in
HStack() { HStack {
Text(device.friendlyName!) Text(device.friendlyName!)
.font(.subheadline) .font(.subheadline)
.fontWeight(.medium) .fontWeight(.medium)
@ -55,7 +55,7 @@ struct VideoPlayerCastDeviceSelector: View {
delegate?.castDeviceChanged() delegate?.castDeviceChanged()
delegate?.castPopoverDismissed() delegate?.castPopoverDismissed()
} label: { } label: {
HStack() { HStack {
Text("Connect") Text("Connect")
.font(.caption) .font(.caption)
.fontWeight(.medium) .fontWeight(.medium)
@ -91,4 +91,3 @@ struct VideoPlayerCastDeviceSelector: View {
}.offset(y: UIDevice.current.userInterfaceIdiom == .pad ? 14 : 0) }.offset(y: UIDevice.current.userInterfaceIdiom == .pad ? 14 : 0)
} }
} }

View File

@ -15,7 +15,7 @@ class VideoPlayerSettingsView: UIViewController {
override var supportedInterfaceOrientations: UIInterfaceOrientationMask { override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
.landscape .landscape
} }
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
contentView = UIHostingController(rootView: VideoPlayerSettings(delegate: self.delegate ?? PlayerViewController())) contentView = UIHostingController(rootView: VideoPlayerSettings(delegate: self.delegate ?? PlayerViewController()))

View File

@ -95,12 +95,12 @@ extension BaseItemDto {
let imageType = "Primary" let imageType = "Primary"
var imageTag = self.imageTags?["Primary"] ?? "" var imageTag = self.imageTags?["Primary"] ?? ""
var imageItemId = self.id ?? "" var imageItemId = self.id ?? ""
if imageTag == "" || imageItemId == "" { if imageTag == "" || imageItemId == "" {
imageTag = self.seriesPrimaryImageTag ?? "" imageTag = self.seriesPrimaryImageTag ?? ""
imageItemId = self.seriesId ?? "" imageItemId = self.seriesId ?? ""
} }
let x = UIScreen.main.nativeScale * CGFloat(maxWidth) let x = UIScreen.main.nativeScale * CGFloat(maxWidth)
let urlString = "\(ServerEnvironment.current.server.baseURI!)/Items/\(imageItemId)/Images/\(imageType)?maxWidth=\(String(Int(x)))&quality=60&tag=\(imageTag)" let urlString = "\(ServerEnvironment.current.server.baseURI!)/Items/\(imageItemId)/Images/\(imageType)?maxWidth=\(String(Int(x)))&quality=60&tag=\(imageTag)"

View File

@ -0,0 +1,19 @@
//
/*
* 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 Defaults
extension Defaults.Keys {
static let inNetworkBandwidth = Key<Int>("InNetworkBandwidth", default: 40_000_000)
static let outOfNetworkBandwidth = Key<Int>("OutOfNetworkBandwidth", default: 40_000_000)
static let isAutoSelectSubtitles = Key<Bool>("isAutoSelectSubtitles", default: false)
static let autoSelectSubtitlesLangCode = Key<String>("AutoSelectSubtitlesLangCode", default: "Auto")
static let autoSelectAudioLangCode = Key<String>("AutoSelectAudioLangCode", default: "Auto")
}

View File

@ -24,14 +24,16 @@ struct ImageView: View {
} }
var body: some View { var body: some View {
LazyImage(source: source) LazyImage(source: source) { state in
.placeholder { if let image = state.image {
image
} else if state.error != nil {
Rectangle()
.fill(Color.gray)
} else {
Image(uiImage: UIImage(blurHash: blurhash, size: CGSize(width: 16, height: 16))!) Image(uiImage: UIImage(blurHash: blurhash, size: CGSize(width: 16, height: 16))!)
.resizable() .resizable()
} }
.failure { }
Rectangle()
.fill(Color.gray)
}
} }
} }

View File

@ -0,0 +1,72 @@
//
/*
* 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
private struct SearchablePickerView<Selectable: Hashable>: View {
@Environment(\.presentationMode) var presentationMode
let options: [Selectable]
let optionToString: (Selectable) -> String
let label: String
@State var text = ""
@Binding var selected: Selectable
var body: some View {
VStack {
SearchBar(text: $text)
List(options.filter {
guard !text.isEmpty else { return true }
return optionToString($0).lowercased().contains(text.lowercased())
}, id: \.self) { selectable in
Button(action: {
selected = selectable
presentationMode.wrappedValue.dismiss()
}) {
HStack {
Text(optionToString(selectable)).foregroundColor(Color.primary)
Spacer()
if selected == selectable {
Image(systemName: "checkmark").foregroundColor(.accentColor)
}
}
}
}.listStyle(GroupedListStyle())
}
}
}
struct SearchablePicker<Selectable: Hashable>: View {
let label: String
let options: [Selectable]
let optionToString: (Selectable) -> String
@Binding var selected: Selectable
var body: some View {
NavigationLink(destination: searchablePickerView()) {
HStack {
Text(label)
Spacer()
Text(optionToString(selected))
.foregroundColor(.gray)
.multilineTextAlignment(.trailing)
}
}
}
private func searchablePickerView() -> some View {
SearchablePickerView(options: options,
optionToString: optionToString,
label: label,
selected: $selected)
}
}

View File

@ -17,7 +17,7 @@ public class ServerDiscovery {
public let username: String public let username: String
public let password: String public let password: String
public let deviceId: String public let deviceId: String
public init(_ host: String, _ port: Int, _ username: String, _ password: String, _ deviceId: String = UUID().uuidString) { public init(_ host: String, _ port: Int, _ username: String, _ password: String, _ deviceId: String = UUID().uuidString) {
self.host = host self.host = host
self.port = port self.port = port
@ -26,17 +26,17 @@ public class ServerDiscovery {
self.deviceId = deviceId self.deviceId = deviceId
} }
} }
public struct ServerLookupResponse: Codable, Hashable, Identifiable { public struct ServerLookupResponse: Codable, Hashable, Identifiable {
public func hash(into hasher: inout Hasher) { public func hash(into hasher: inout Hasher) {
return hasher.combine(id) return hasher.combine(id)
} }
private let address: String private let address: String
public let id: String public let id: String
public let name: String public let name: String
public var url: URL { public var url: URL {
URL(string: self.address)! URL(string: self.address)!
} }
@ -47,7 +47,7 @@ public class ServerDiscovery {
} }
return self.address return self.address
} }
public var port: Int { public var port: Int {
let components = URLComponents(string: self.address) let components = URLComponents(string: self.address)
if let port = components?.port { if let port = components?.port {
@ -55,7 +55,7 @@ public class ServerDiscovery {
} }
return 8096 return 8096
} }
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case address = "Address" case address = "Address"
case id = "Id" case id = "Id"
@ -63,25 +63,22 @@ public class ServerDiscovery {
} }
} }
private let broadcastConn: UDPBroadcastConnection private let broadcastConn: UDPBroadcastConnection
public init() { public init() {
func receiveHandler(_ ipAddress: String, _ port: Int, _ response: Data) { func receiveHandler(_ ipAddress: String, _ port: Int, _ response: Data) {
print("RECIEVED \(ipAddress):\(String(port)) \(response)")
} }
func errorHandler(error: UDPBroadcastConnection.ConnectionError) { func errorHandler(error: UDPBroadcastConnection.ConnectionError) {
print(error)
} }
self.broadcastConn = try! UDPBroadcastConnection(port: 7359, handler: receiveHandler, errorHandler: errorHandler) self.broadcastConn = try! UDPBroadcastConnection(port: 7359, handler: receiveHandler, errorHandler: errorHandler)
} }
public func locateServer(completion: @escaping (ServerLookupResponse?) -> Void) { public func locateServer(completion: @escaping (ServerLookupResponse?) -> Void) {
func receiveHandler(_ ipAddress: String, _ port: Int, _ data: Data) { func receiveHandler(_ ipAddress: String, _ port: Int, _ data: Data) {
do { do {
let response = try JSONDecoder().decode(ServerLookupResponse.self, from: data) let response = try JSONDecoder().decode(ServerLookupResponse.self, from: data)
completion(response) completion(response)
} catch { } catch {
print(error)
completion(nil) completion(nil)
} }
} }

View File

@ -16,38 +16,37 @@ import Darwin
let INADDR_ANY = in_addr(s_addr: 0) let INADDR_ANY = in_addr(s_addr: 0)
let INADDR_BROADCAST = in_addr(s_addr: 0xffffffff) let INADDR_BROADCAST = in_addr(s_addr: 0xffffffff)
/// An object representing the UDP broadcast connection. Uses a dispatch source to handle the incoming traffic on the UDP socket. /// An object representing the UDP broadcast connection. Uses a dispatch source to handle the incoming traffic on the UDP socket.
open class UDPBroadcastConnection { open class UDPBroadcastConnection {
// MARK: Properties // MARK: Properties
/// The address of the UDP socket. /// The address of the UDP socket.
var address: sockaddr_in var address: sockaddr_in
/// Type of a closure that handles incoming UDP packets. /// Type of a closure that handles incoming UDP packets.
public typealias ReceiveHandler = (_ ipAddress: String, _ port: Int, _ response: Data) -> Void public typealias ReceiveHandler = (_ ipAddress: String, _ port: Int, _ response: Data) -> Void
/// Closure that handles incoming UDP packets. /// Closure that handles incoming UDP packets.
var handler: ReceiveHandler? var handler: ReceiveHandler?
/// Type of a closure that handles errors that were encountered during receiving UDP packets. /// Type of a closure that handles errors that were encountered during receiving UDP packets.
public typealias ErrorHandler = (_ error: ConnectionError) -> Void public typealias ErrorHandler = (_ error: ConnectionError) -> Void
/// Closure that handles errors that were encountered during receiving UDP packets. /// Closure that handles errors that were encountered during receiving UDP packets.
var errorHandler: ErrorHandler? var errorHandler: ErrorHandler?
/// A dispatch source for reading data from the UDP socket. /// A dispatch source for reading data from the UDP socket.
var responseSource: DispatchSourceRead? var responseSource: DispatchSourceRead?
/// The dispatch queue to run responseSource & reconnection on /// The dispatch queue to run responseSource & reconnection on
var dispatchQueue: DispatchQueue = DispatchQueue.main var dispatchQueue: DispatchQueue = DispatchQueue.main
/// Bind to port to start listening without first sending a message /// Bind to port to start listening without first sending a message
var shouldBeBound: Bool = false var shouldBeBound: Bool = false
// MARK: Initializers // MARK: Initializers
/// Initializes the UDP connection with the correct port address. /// Initializes the UDP connection with the correct port address.
/// - Note: This doesn't open a socket! The socket is opened transparently as needed when sending broadcast messages. If you want to open a socket immediately, use the `bindIt` parameter. This will also try to reopen the socket if it gets closed. /// - Note: This doesn't open a socket! The socket is opened transparently as needed when sending broadcast messages. If you want to open a socket immediately, use the `bindIt` parameter. This will also try to reopen the socket if it gets closed.
/// ///
/// - Parameters: /// - Parameters:
@ -58,13 +57,13 @@ open class UDPBroadcastConnection {
/// - Throws: Throws a `ConnectionError` if an error occurs. /// - Throws: Throws a `ConnectionError` if an error occurs.
public init(port: UInt16, bindIt: Bool = false, handler: ReceiveHandler?, errorHandler: ErrorHandler?) throws { public init(port: UInt16, bindIt: Bool = false, handler: ReceiveHandler?, errorHandler: ErrorHandler?) throws {
self.address = sockaddr_in( self.address = sockaddr_in(
sin_len: __uint8_t(MemoryLayout<sockaddr_in>.size), sin_len: __uint8_t(MemoryLayout<sockaddr_in>.size),
sin_family: sa_family_t(AF_INET), sin_family: sa_family_t(AF_INET),
sin_port: UDPBroadcastConnection.htonsPort(port: port), sin_port: UDPBroadcastConnection.htonsPort(port: port),
sin_addr: INADDR_BROADCAST, sin_addr: INADDR_BROADCAST,
sin_zero: ( 0, 0, 0, 0, 0, 0, 0, 0 ) sin_zero: ( 0, 0, 0, 0, 0, 0, 0, 0 )
) )
self.handler = handler self.handler = handler
self.errorHandler = errorHandler self.errorHandler = errorHandler
self.shouldBeBound = bindIt self.shouldBeBound = bindIt
@ -72,34 +71,33 @@ open class UDPBroadcastConnection {
try createSocket() try createSocket()
} }
} }
deinit { deinit {
if responseSource != nil { if responseSource != nil {
responseSource!.cancel() responseSource!.cancel()
} }
} }
// MARK: Interface // MARK: Interface
/// Create a UDP socket for broadcasting and set up cancel and event handlers /// Create a UDP socket for broadcasting and set up cancel and event handlers
/// ///
/// - Throws: Throws a `ConnectionError` if an error occurs. /// - Throws: Throws a `ConnectionError` if an error occurs.
fileprivate func createSocket() throws { fileprivate func createSocket() throws {
// Create new socket // Create new socket
let newSocket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP) let newSocket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP)
guard newSocket > 0 else { throw ConnectionError.createSocketFailed } guard newSocket > 0 else { throw ConnectionError.createSocketFailed }
// Enable broadcast on socket // Enable broadcast on socket
var broadcastEnable = Int32(1); var broadcastEnable = Int32(1)
let ret = setsockopt(newSocket, SOL_SOCKET, SO_BROADCAST, &broadcastEnable, socklen_t(MemoryLayout<UInt32>.size)); let ret = setsockopt(newSocket, SOL_SOCKET, SO_BROADCAST, &broadcastEnable, socklen_t(MemoryLayout<UInt32>.size))
if ret == -1 { if ret == -1 {
debugPrint("Couldn't enable broadcast on socket") debugPrint("Couldn't enable broadcast on socket")
close(newSocket) close(newSocket)
throw ConnectionError.enableBroadcastFailed throw ConnectionError.enableBroadcastFailed
} }
// Bind socket if needed // Bind socket if needed
if shouldBeBound { if shouldBeBound {
var saddr = sockaddr(sa_len: 0, sa_family: 0, var saddr = sockaddr(sa_len: 0, sa_family: 0,
@ -114,34 +112,34 @@ open class UDPBroadcastConnection {
throw ConnectionError.bindSocketFailed throw ConnectionError.bindSocketFailed
} }
} }
// Disable global SIGPIPE handler so that the app doesn't crash // Disable global SIGPIPE handler so that the app doesn't crash
setNoSigPipe(socket: newSocket) setNoSigPipe(socket: newSocket)
// Set up a dispatch source // Set up a dispatch source
let newResponseSource = DispatchSource.makeReadSource(fileDescriptor: newSocket, queue: dispatchQueue) let newResponseSource = DispatchSource.makeReadSource(fileDescriptor: newSocket, queue: dispatchQueue)
// Set up cancel handler // Set up cancel handler
newResponseSource.setCancelHandler { newResponseSource.setCancelHandler {
debugPrint("Closing UDP socket") // debugPrint("Closing UDP socket")
let UDPSocket = Int32(newResponseSource.handle) let UDPSocket = Int32(newResponseSource.handle)
shutdown(UDPSocket, SHUT_RDWR) shutdown(UDPSocket, SHUT_RDWR)
close(UDPSocket) close(UDPSocket)
} }
// Set up event handler (gets called when data arrives at the UDP socket) // Set up event handler (gets called when data arrives at the UDP socket)
newResponseSource.setEventHandler { [unowned self] in newResponseSource.setEventHandler { [unowned self] in
guard let source = self.responseSource else { return } guard let source = self.responseSource else { return }
var socketAddress = sockaddr_storage() var socketAddress = sockaddr_storage()
var socketAddressLength = socklen_t(MemoryLayout<sockaddr_storage>.size) var socketAddressLength = socklen_t(MemoryLayout<sockaddr_storage>.size)
let response = [UInt8](repeating: 0, count: 4096) let response = [UInt8](repeating: 0, count: 4096)
let UDPSocket = Int32(source.handle) let UDPSocket = Int32(source.handle)
let bytesRead = withUnsafeMutablePointer(to: &socketAddress) { let bytesRead = withUnsafeMutablePointer(to: &socketAddress) {
recvfrom(UDPSocket, UnsafeMutableRawPointer(mutating: response), response.count, 0, UnsafeMutableRawPointer($0).bindMemory(to: sockaddr.self, capacity: 1), &socketAddressLength) recvfrom(UDPSocket, UnsafeMutableRawPointer(mutating: response), response.count, 0, UnsafeMutableRawPointer($0).bindMemory(to: sockaddr.self, capacity: 1), &socketAddressLength)
} }
do { do {
guard bytesRead > 0 else { guard bytesRead > 0 else {
self.closeConnection() self.closeConnection()
@ -155,18 +153,18 @@ open class UDPBroadcastConnection {
throw ConnectionError.receiveFailed(code: errno) throw ConnectionError.receiveFailed(code: errno)
} }
} }
guard let endpoint = withUnsafePointer(to: &socketAddress, { self.getEndpointFromSocketAddress(socketAddressPointer: UnsafeRawPointer($0).bindMemory(to: sockaddr.self, capacity: 1)) }) guard let endpoint = withUnsafePointer(to: &socketAddress, { self.getEndpointFromSocketAddress(socketAddressPointer: UnsafeRawPointer($0).bindMemory(to: sockaddr.self, capacity: 1)) })
else { else {
debugPrint("Failed to get the address and port from the socket address received from recvfrom") // debugPrint("Failed to get the address and port from the socket address received from recvfrom")
self.closeConnection() self.closeConnection()
return return
} }
debugPrint("UDP connection received \(bytesRead) bytes from \(endpoint.host):\(endpoint.port)") // debugPrint("UDP connection received \(bytesRead) bytes from \(endpoint.host):\(endpoint.port)")
let responseBytes = Data(response[0..<bytesRead]) let responseBytes = Data(response[0..<bytesRead])
// Handle response // Handle response
self.handler?(endpoint.host, endpoint.port, responseBytes) self.handler?(endpoint.host, endpoint.port, responseBytes)
} catch { } catch {
@ -176,13 +174,13 @@ open class UDPBroadcastConnection {
self.errorHandler?(ConnectionError.underlying(error: error)) self.errorHandler?(ConnectionError.underlying(error: error))
} }
} }
} }
newResponseSource.resume() newResponseSource.resume()
responseSource = newResponseSource responseSource = newResponseSource
} }
/// Send broadcast message. /// Send broadcast message.
/// ///
/// - Parameter message: Message to send via broadcast. /// - Parameter message: Message to send via broadcast.
@ -191,7 +189,7 @@ open class UDPBroadcastConnection {
guard let data = message.data(using: .utf8) else { throw ConnectionError.messageEncodingFailed } guard let data = message.data(using: .utf8) else { throw ConnectionError.messageEncodingFailed }
try sendBroadcast(data) try sendBroadcast(data)
} }
/// Send broadcast data. /// Send broadcast data.
/// ///
/// - Parameter data: Data to send via broadcast. /// - Parameter data: Data to send via broadcast.
@ -200,7 +198,7 @@ open class UDPBroadcastConnection {
if responseSource == nil { if responseSource == nil {
try createSocket() try createSocket()
} }
guard let source = responseSource else { return } guard let source = responseSource else { return }
let UDPSocket = Int32(source.handle) let UDPSocket = Int32(source.handle)
let socketLength = socklen_t(address.sin_len) let socketLength = socklen_t(address.sin_len)
@ -210,22 +208,22 @@ open class UDPBroadcastConnection {
let memory = UnsafeRawPointer(pointer).bindMemory(to: sockaddr.self, capacity: 1) let memory = UnsafeRawPointer(pointer).bindMemory(to: sockaddr.self, capacity: 1)
return sendto(UDPSocket, broadcastMessage.baseAddress, broadcastMessageLength, 0, memory, socketLength) return sendto(UDPSocket, broadcastMessage.baseAddress, broadcastMessageLength, 0, memory, socketLength)
} }
guard sent > 0 else { guard sent > 0 else {
if let errorString = String(validatingUTF8: strerror(errno)) { if let errorString = String(validatingUTF8: strerror(errno)) {
debugPrint("UDP connection failed to send data: \(errorString)") // debugPrint("UDP connection failed to send data: \(errorString)")
} }
closeConnection() closeConnection()
throw ConnectionError.sendingMessageFailed(code: errno) throw ConnectionError.sendingMessageFailed(code: errno)
} }
if sent == broadcastMessageLength { if sent == broadcastMessageLength {
// Success // Success
debugPrint("UDP connection sent \(broadcastMessageLength) bytes") // debugPrint("UDP connection sent \(broadcastMessageLength) bytes")
} }
} }
} }
/// Close the connection. /// Close the connection.
/// ///
/// - Parameter reopen: Automatically reopens the connection if true. Defaults to true. /// - Parameter reopen: Automatically reopens the connection if true. Defaults to true.
@ -244,16 +242,16 @@ open class UDPBroadcastConnection {
} }
} }
} }
// MARK: - Helper // MARK: - Helper
/// Convert a sockaddr structure into an IP address string and port. /// Convert a sockaddr structure into an IP address string and port.
/// ///
/// - Parameter socketAddressPointer: socketAddressPointer: Pointer to a socket address. /// - Parameter socketAddressPointer: socketAddressPointer: Pointer to a socket address.
/// - Returns: Returns a tuple of the host IP address and the port in the socket address given. /// - Returns: Returns a tuple of the host IP address and the port in the socket address given.
func getEndpointFromSocketAddress(socketAddressPointer: UnsafePointer<sockaddr>) -> (host: String, port: Int)? { func getEndpointFromSocketAddress(socketAddressPointer: UnsafePointer<sockaddr>) -> (host: String, port: Int)? {
let socketAddress = UnsafePointer<sockaddr>(socketAddressPointer).pointee let socketAddress = UnsafePointer<sockaddr>(socketAddressPointer).pointee
switch Int32(socketAddress.sa_family) { switch Int32(socketAddress.sa_family) {
case AF_INET: case AF_INET:
var socketAddressInet = UnsafeRawPointer(socketAddressPointer).load(as: sockaddr_in.self) var socketAddressInet = UnsafeRawPointer(socketAddressPointer).load(as: sockaddr_in.self)
@ -262,7 +260,7 @@ open class UDPBroadcastConnection {
let hostCString = inet_ntop(AF_INET, &socketAddressInet.sin_addr, &buffer, socklen_t(length)) let hostCString = inet_ntop(AF_INET, &socketAddressInet.sin_addr, &buffer, socklen_t(length))
let port = Int(UInt16(socketAddressInet.sin_port).byteSwapped) let port = Int(UInt16(socketAddressInet.sin_port).byteSwapped)
return (String(cString: hostCString!), port) return (String(cString: hostCString!), port)
case AF_INET6: case AF_INET6:
var socketAddressInet6 = UnsafeRawPointer(socketAddressPointer).load(as: sockaddr_in6.self) var socketAddressInet6 = UnsafeRawPointer(socketAddressPointer).load(as: sockaddr_in6.self)
let length = Int(INET6_ADDRSTRLEN) + 2 let length = Int(INET6_ADDRSTRLEN) + 2
@ -270,60 +268,57 @@ open class UDPBroadcastConnection {
let hostCString = inet_ntop(AF_INET6, &socketAddressInet6.sin6_addr, &buffer, socklen_t(length)) let hostCString = inet_ntop(AF_INET6, &socketAddressInet6.sin6_addr, &buffer, socklen_t(length))
let port = Int(UInt16(socketAddressInet6.sin6_port).byteSwapped) let port = Int(UInt16(socketAddressInet6.sin6_port).byteSwapped)
return (String(cString: hostCString!), port) return (String(cString: hostCString!), port)
default: default:
return nil return nil
} }
} }
// MARK: - Private // MARK: - Private
/// Prevents crashes when blocking calls are pending and the app is paused (via Home button). /// Prevents crashes when blocking calls are pending and the app is paused (via Home button).
/// ///
/// - Parameter socket: The socket for which the signal should be disabled. /// - Parameter socket: The socket for which the signal should be disabled.
fileprivate func setNoSigPipe(socket: CInt) { fileprivate func setNoSigPipe(socket: CInt) {
var no_sig_pipe: Int32 = 1; var no_sig_pipe: Int32 = 1
setsockopt(socket, SOL_SOCKET, SO_NOSIGPIPE, &no_sig_pipe, socklen_t(MemoryLayout<Int32>.size)); setsockopt(socket, SOL_SOCKET, SO_NOSIGPIPE, &no_sig_pipe, socklen_t(MemoryLayout<Int32>.size))
} }
fileprivate class func htonsPort(port: in_port_t) -> in_port_t { fileprivate class func htonsPort(port: in_port_t) -> in_port_t {
let isLittleEndian = Int(OSHostByteOrder()) == OSLittleEndian let isLittleEndian = Int(OSHostByteOrder()) == OSLittleEndian
return isLittleEndian ? _OSSwapInt16(port) : port return isLittleEndian ? _OSSwapInt16(port) : port
} }
fileprivate class func ntohs(value: CUnsignedShort) -> CUnsignedShort { fileprivate class func ntohs(value: CUnsignedShort) -> CUnsignedShort {
return (value << 8) + (value >> 8) return (value << 8) + (value >> 8)
} }
} }
// Created by Gunter Hager on 25.03.19. // Created by Gunter Hager on 25.03.19.
// Copyright © 2019 Gunter Hager. All rights reserved. // Copyright © 2019 Gunter Hager. All rights reserved.
// //
public extension UDPBroadcastConnection { public extension UDPBroadcastConnection {
enum ConnectionError: Error { enum ConnectionError: Error {
// Creating socket // Creating socket
case createSocketFailed case createSocketFailed
case enableBroadcastFailed case enableBroadcastFailed
case bindSocketFailed case bindSocketFailed
// Sending message // Sending message
case messageEncodingFailed case messageEncodingFailed
case sendingMessageFailed(code: Int32) case sendingMessageFailed(code: Int32)
// Receiving data // Receiving data
case receivedEndOfFile case receivedEndOfFile
case receiveFailed(code: Int32) case receiveFailed(code: Int32)
// Closing socket // Closing socket
case reopeningSocketFailed(error: Error) case reopeningSocketFailed(error: Error)
// Underlying // Underlying
case underlying(error: Error) case underlying(error: Error)
} }
} }

View File

@ -149,7 +149,7 @@ final class SessionManager {
func logout() { func logout() {
let nc = NotificationCenter.default let nc = NotificationCenter.default
nc.post(name: Notification.Name("didSignOut"), object: nil) nc.post(name: Notification.Name("didSignOut"), object: nil)
let keychain = KeychainSwift() let keychain = KeychainSwift()
keychain.accessGroup = "9R8RREG67J.me.vigue.jellyfin.sharedKeychain" keychain.accessGroup = "9R8RREG67J.me.vigue.jellyfin.sharedKeychain"
keychain.delete("AccessToken_\(user?.user_id ?? "")") keychain.delete("AccessToken_\(user?.user_id ?? "")")

View File

@ -18,7 +18,6 @@ struct LibraryFilters: Codable, Hashable {
} }
public enum SortBy: String, Codable, CaseIterable { public enum SortBy: String, Codable, CaseIterable {
case productionYear = "ProductionYear"
case premiereDate = "PremiereDate" case premiereDate = "PremiereDate"
case name = "SortName" case name = "SortName"
case dateAdded = "DateCreated" case dateAdded = "DateCreated"
@ -27,14 +26,12 @@ public enum SortBy: String, Codable, CaseIterable {
extension SortBy { extension SortBy {
var localized: String { var localized: String {
switch self { switch self {
case .productionYear:
return "Release Year"
case .premiereDate: case .premiereDate:
return "Premiere date" return "Premiere date"
case .name: case .name:
return "Title" return "Name"
case .dateAdded: case .dateAdded:
return "Date Added" return "Date added"
} }
} }
} }

View File

@ -14,7 +14,7 @@ import JellyfinAPI
final class ConnectToServerViewModel: ViewModel { final class ConnectToServerViewModel: ViewModel {
@Published @Published
var isConnectedServer = false var isConnectedServer = false
var uriSubject = CurrentValueSubject<String, Never>("") var uriSubject = CurrentValueSubject<String, Never>("")
var usernameSubject = CurrentValueSubject<String, Never>("") var usernameSubject = CurrentValueSubject<String, Never>("")
var passwordSubject = CurrentValueSubject<String, Never>("") var passwordSubject = CurrentValueSubject<String, Never>("")
@ -25,11 +25,11 @@ final class ConnectToServerViewModel: ViewModel {
var publicUsers = [UserDto]() var publicUsers = [UserDto]()
@Published @Published
var selectedPublicUser = UserDto() var selectedPublicUser = UserDto()
private let discovery: ServerDiscovery = ServerDiscovery() private let discovery: ServerDiscovery = ServerDiscovery()
@Published var servers: [ServerDiscovery.ServerLookupResponse] = [] @Published var servers: [ServerDiscovery.ServerLookupResponse] = []
@Published var searching = false @Published var searching = false
override init() { override init() {
super.init() super.init()
getPublicUsers() getPublicUsers()
@ -74,8 +74,8 @@ final class ConnectToServerViewModel: ViewModel {
}) })
.store(in: &cancellables) .store(in: &cancellables)
} }
func connectToServer(at url : URL) { func connectToServer(at url: URL) {
ServerEnvironment.current.create(with: url.absoluteString) ServerEnvironment.current.create(with: url.absoluteString)
.trackActivity(loading) .trackActivity(loading)
.sink(receiveCompletion: { result in .sink(receiveCompletion: { result in
@ -90,15 +90,15 @@ final class ConnectToServerViewModel: ViewModel {
}) })
.store(in: &cancellables) .store(in: &cancellables)
} }
func discoverServers() { func discoverServers() {
searching = true searching = true
// Timeout after 5 seconds // Timeout after 5 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 5) { DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
self.searching = false self.searching = false
} }
discovery.locateServer { [self] (server) in discovery.locateServer { [self] (server) in
if let server = server, !servers.contains(server) { if let server = server, !servers.contains(server) {
servers.append(server) servers.append(server)

View File

@ -35,10 +35,26 @@ final class LibraryFilterViewModel: ViewModel {
var possibleItemFilters = ItemFilter.supportedTypes var possibleItemFilters = ItemFilter.supportedTypes
@Published @Published
var enabledFilterType: [FilterType] var enabledFilterType: [FilterType]
@Published
var selectedSortOrder: APISortOrder = .descending
@Published
var selectedSortBy: SortBy = .name
func updateModifiedFilter() {
modifiedFilters.sortOrder = [selectedSortOrder]
modifiedFilters.sortBy = [selectedSortBy]
}
func resetFilters() {
modifiedFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], tags: [], sortBy: [.name])
}
init(filters: LibraryFilters? = nil, init(filters: LibraryFilters? = nil,
enabledFilterType: [FilterType] = [.tag, .genre, .sortBy, .sortOrder, .filter]) { enabledFilterType: [FilterType] = [.tag, .genre, .sortBy, .sortOrder, .filter]) {
self.enabledFilterType = enabledFilterType self.enabledFilterType = enabledFilterType
self.selectedSortBy = filters!.sortBy.first!
self.selectedSortOrder = filters!.sortOrder.first!
super.init() super.init()
if let filters = filters { if let filters = filters {
self.modifiedFilters = filters self.modifiedFilters = filters

View File

@ -1,11 +1,11 @@
// //
/* /*
* SwiftFin is subject to the terms of the Mozilla Public * SwiftFin is subject to the terms of the Mozilla Public
* License, v2.0. If a copy of the MPL was not distributed with this * 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/. * file, you can obtain one at https://mozilla.org/MPL/2.0/.
* *
* Copyright 2021 Aiden Vigue & Jellyfin Contributors * Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/ */
import Foundation import Foundation
@ -23,8 +23,17 @@ struct Bitrates: Codable, Hashable {
public var value: Int public var value: Int
} }
struct TrackLanguage: Hashable {
var name: String
var isoCode: String
static let auto = TrackLanguage(name: "Auto", isoCode: "Auto")
}
final class SettingsViewModel: ObservableObject { final class SettingsViewModel: ObservableObject {
let currentLocale = Locale.current
var bitrates: [Bitrates] = [] var bitrates: [Bitrates] = []
var langs = [TrackLanguage]()
init() { init() {
let url = Bundle.main.url(forResource: "bitrates", withExtension: "json")! let url = Bundle.main.url(forResource: "bitrates", withExtension: "json")!
@ -39,5 +48,11 @@ final class SettingsViewModel: ObservableObject {
} catch { } catch {
print(error) print(error)
} }
self.langs = Locale.isoLanguageCodes.compactMap {
guard let name = currentLocale.localizedString(forLanguageCode: $0) else { return nil }
return TrackLanguage(name: name, isoCode: $0)
}.sorted(by: { $0.name < $1.name })
self.langs.insert(.auto, at: 0)
} }
} }

View File

@ -30,14 +30,6 @@ final class SplashViewModel: ViewModel {
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()
#endif #endif
let defaults = UserDefaults.standard
if defaults.integer(forKey: "InNetworkBandwidth") == 0 {
defaults.setValue(40_000_000, forKey: "InNetworkBandwidth")
}
if defaults.integer(forKey: "OutOfNetworkBandwidth") == 0 {
defaults.setValue(40_000_000, forKey: "OutOfNetworkBandwidth")
}
let nc = NotificationCenter.default let nc = NotificationCenter.default
nc.addObserver(self, selector: #selector(didLogIn), name: Notification.Name("didSignIn"), object: nil) nc.addObserver(self, selector: #selector(didLogIn), name: Notification.Name("didSignIn"), object: nil)
nc.addObserver(self, selector: #selector(didLogOut), name: Notification.Name("didSignOut"), object: nil) nc.addObserver(self, selector: #selector(didLogOut), name: Notification.Name("didSignOut"), object: nil)

View File

@ -16,10 +16,12 @@ struct Subtitle {
var url: URL? var url: URL?
var delivery: SubtitleDeliveryMethod var delivery: SubtitleDeliveryMethod
var codec: String var codec: String
var languageCode: String
} }
struct AudioTrack { struct AudioTrack {
var name: String var name: String
var languageCode: String
var id: Int32 var id: Int32
} }

View File

@ -28,8 +28,8 @@ struct NextUpWidgetProvider: TimelineProvider {
let server = ServerEnvironment.current.server let server = ServerEnvironment.current.server
let savedUser = SessionManager.current.user let savedUser = SessionManager.current.user
var tempCancellables = Set<AnyCancellable>() var tempCancellables = Set<AnyCancellable>()
if(server != nil && savedUser != nil) { if server != nil && savedUser != nil {
JellyfinAPI.basePath = server!.baseURI ?? "" JellyfinAPI.basePath = server!.baseURI ?? ""
TvShowsAPI.getNextUp(userId: savedUser!.user_id, limit: 3, TvShowsAPI.getNextUp(userId: savedUser!.user_id, limit: 3,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
@ -73,8 +73,8 @@ struct NextUpWidgetProvider: TimelineProvider {
let savedUser = SessionManager.current.user let savedUser = SessionManager.current.user
var tempCancellables = Set<AnyCancellable>() var tempCancellables = Set<AnyCancellable>()
if(server != nil && savedUser != nil) { if server != nil && savedUser != nil {
JellyfinAPI.basePath = server!.baseURI ?? "" JellyfinAPI.basePath = server!.baseURI ?? ""
TvShowsAPI.getNextUp(userId: savedUser!.user_id, limit: 3, TvShowsAPI.getNextUp(userId: savedUser!.user_id, limit: 3,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],