Merge pull request #90 from jellyfin/create-pull-request/patch
[ci] SwiftLint
This commit is contained in:
commit
3a2328fbee
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,37 +19,36 @@ protocol VideoPlayerSettingsDelegate: AnyObject {
|
||||||
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)
|
||||||
|
@ -60,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]
|
||||||
|
@ -134,124 +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 maxBitrate = Defaults[.inNetworkBandwidth]
|
let maxBitrate = Defaults[.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: "", languageCode: "")
|
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", languageCode: stream.language ?? "")
|
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!, languageCode: stream.language ?? "", 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")
|
||||||
|
|
|
@ -122,7 +122,7 @@ struct ConnectToServerView: View {
|
||||||
}
|
}
|
||||||
.disabled(viewModel.isLoading || uri.isEmpty)
|
.disabled(viewModel.isLoading || uri.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section(header: Text("Discovered Servers")) {
|
Section(header: Text("Discovered Servers")) {
|
||||||
if self.viewModel.searching {
|
if self.viewModel.searching {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
|
@ -142,7 +142,7 @@ struct ConnectToServerView: View {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
@ -56,7 +56,7 @@ struct ContinueWatchingView: View {
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
if(item.type == "Episode") {
|
if item.type == "Episode" {
|
||||||
Text("• S\(String(item.parentIndexNumber ?? 0)):E\(String(item.indexNumber ?? 0)) - \(item.name ?? "")")
|
Text("• S\(String(item.parentIndexNumber ?? 0)):E\(String(item.indexNumber ?? 0)) - \(item.name ?? "")")
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
|
|
|
@ -13,10 +13,10 @@ 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 {
|
||||||
|
@ -53,7 +53,7 @@ struct HomeView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
innerBody
|
innerBody
|
||||||
.navigationTitle(MainTabView.Tab.home.localized)
|
.navigationTitle(MainTabView.Tab.home.localized)
|
||||||
|
|
|
@ -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 {
|
||||||
|
@ -23,15 +23,14 @@ struct LatestMediaView: View {
|
||||||
.shadow(radius: 4)
|
.shadow(radius: 4)
|
||||||
.overlay(
|
.overlay(
|
||||||
ZStack {
|
ZStack {
|
||||||
if(item.userData!.played ?? false) {
|
if item.userData!.played ?? false {
|
||||||
Image(systemName: "circle.fill")
|
Image(systemName: "circle.fill")
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.foregroundColor(Color(.systemBlue))
|
.foregroundColor(Color(.systemBlue))
|
||||||
}
|
}
|
||||||
}.padding(2)
|
}.padding(2)
|
||||||
.opacity(1)
|
.opacity(1), alignment: .topTrailing).opacity(1)
|
||||||
, alignment: .topTrailing).opacity(1)
|
|
||||||
Text(item.seriesName ?? item.name ?? "")
|
Text(item.seriesName ?? item.name ?? "")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
@ -37,15 +37,14 @@ struct LibrarySearchView: View {
|
||||||
.cornerRadius(10)
|
.cornerRadius(10)
|
||||||
.overlay(
|
.overlay(
|
||||||
ZStack {
|
ZStack {
|
||||||
if(item.userData!.played ?? false) {
|
if item.userData!.played ?? false {
|
||||||
Image(systemName: "circle.fill")
|
Image(systemName: "circle.fill")
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.foregroundColor(Color(.systemBlue))
|
.foregroundColor(Color(.systemBlue))
|
||||||
}
|
}
|
||||||
}.padding(2)
|
}.padding(2)
|
||||||
.opacity(1)
|
.opacity(1), alignment: .topTrailing).opacity(1)
|
||||||
, alignment: .topTrailing).opacity(1)
|
|
||||||
Text(item.name ?? "")
|
Text(item.name ?? "")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
|
|
|
@ -41,15 +41,14 @@ struct LibraryView: View {
|
||||||
.cornerRadius(10)
|
.cornerRadius(10)
|
||||||
.overlay(
|
.overlay(
|
||||||
ZStack {
|
ZStack {
|
||||||
if(item.userData!.played ?? false) {
|
if item.userData!.played ?? false {
|
||||||
Image(systemName: "circle.fill")
|
Image(systemName: "circle.fill")
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.foregroundColor(Color(.systemBlue))
|
.foregroundColor(Color(.systemBlue))
|
||||||
}
|
}
|
||||||
}.padding(2)
|
}.padding(2)
|
||||||
.opacity(1)
|
.opacity(1), alignment: .topTrailing).opacity(1)
|
||||||
, alignment: .topTrailing).opacity(1)
|
|
||||||
Text(item.name ?? "")
|
Text(item.name ?? "")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -78,15 +78,14 @@ struct SeasonItemView: View {
|
||||||
)
|
)
|
||||||
.overlay(
|
.overlay(
|
||||||
ZStack {
|
ZStack {
|
||||||
if(episode.userData!.played ?? false) {
|
if episode.userData!.played ?? false {
|
||||||
Image(systemName: "circle.fill")
|
Image(systemName: "circle.fill")
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.foregroundColor(Color(.systemBlue))
|
.foregroundColor(Color(.systemBlue))
|
||||||
}
|
}
|
||||||
}.padding(2)
|
}.padding(2)
|
||||||
.opacity(1)
|
.opacity(1), alignment: .topTrailing).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)
|
||||||
|
|
|
@ -11,9 +11,9 @@ import Defaults
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@Environment(\.managedObjectContext) private var viewContext
|
@Environment(\.managedObjectContext) private var viewContext
|
||||||
|
|
||||||
@ObservedObject var viewModel: SettingsViewModel
|
@ObservedObject var viewModel: SettingsViewModel
|
||||||
|
|
||||||
@Binding var close: Bool
|
@Binding var close: Bool
|
||||||
@Default(.inNetworkBandwidth) var inNetworkStreamBitrate
|
@Default(.inNetworkBandwidth) var inNetworkStreamBitrate
|
||||||
@Default(.outOfNetworkBandwidth) var outOfNetworkStreamBitrate
|
@Default(.outOfNetworkBandwidth) var outOfNetworkStreamBitrate
|
||||||
|
@ -21,11 +21,11 @@ struct SettingsView: View {
|
||||||
@Default(.autoSelectSubtitlesLangCode) var autoSelectSubtitlesLangcode
|
@Default(.autoSelectSubtitlesLangCode) var autoSelectSubtitlesLangcode
|
||||||
@Default(.autoSelectAudioLangCode) var autoSelectAudioLangcode
|
@Default(.autoSelectAudioLangCode) var autoSelectAudioLangcode
|
||||||
@State private var username: String = ""
|
@State private var username: String = ""
|
||||||
|
|
||||||
func onAppear() {
|
func onAppear() {
|
||||||
username = SessionManager.current.user.username ?? ""
|
username = SessionManager.current.user.username ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
Form {
|
Form {
|
||||||
|
@ -35,20 +35,20 @@ struct SettingsView: View {
|
||||||
Text(bitrate.name).tag(bitrate.value)
|
Text(bitrate.name).tag(bitrate.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section(header: Text("Accessibility")) {
|
Section(header: Text("Accessibility")) {
|
||||||
Toggle("Automatically show subtitles", isOn: $isAutoSelectSubtitles)
|
Toggle("Automatically show subtitles", isOn: $isAutoSelectSubtitles)
|
||||||
SearchablePicker(label: "Preferred subtitle language",
|
SearchablePicker(label: "Preferred subtitle language",
|
||||||
options: viewModel.langs,
|
options: viewModel.langs,
|
||||||
optionToString: { $0.name },
|
optionToString: { $0.name },
|
||||||
selected:Binding<TrackLanguage>(
|
selected: Binding<TrackLanguage>(
|
||||||
get: { viewModel.langs.first(where: { $0.isoCode == autoSelectSubtitlesLangcode }) ?? .auto },
|
get: { viewModel.langs.first(where: { $0.isoCode == autoSelectSubtitlesLangcode }) ?? .auto },
|
||||||
set: {autoSelectSubtitlesLangcode = $0.isoCode}
|
set: {autoSelectSubtitlesLangcode = $0.isoCode}
|
||||||
)
|
)
|
||||||
|
@ -62,7 +62,7 @@ struct SettingsView: View {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Signed in as \(username)").foregroundColor(.primary)
|
Text("Signed in as \(username)").foregroundColor(.primary)
|
||||||
|
@ -70,8 +70,7 @@ struct SettingsView: View {
|
||||||
Button {
|
Button {
|
||||||
let nc = NotificationCenter.default
|
let nc = NotificationCenter.default
|
||||||
nc.post(name: Notification.Name("didSignOut"), object: nil)
|
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)
|
||||||
|
|
|
@ -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
|
||||||
|
@ -356,8 +355,8 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
|
||||||
} else {
|
} else {
|
||||||
titleLabel.text = "S\(String(manifest.parentIndexNumber ?? 0)):E\(String(manifest.indexNumber ?? 0)) “\(manifest.name ?? "")”"
|
titleLabel.text = "S\(String(manifest.parentIndexNumber ?? 0)):E\(String(manifest.indexNumber ?? 0)) “\(manifest.name ?? "")”"
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
||||||
|
@ -366,7 +365,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)
|
||||||
|
@ -374,11 +373,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)
|
||||||
|
@ -395,15 +394,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
|
||||||
|
@ -532,7 +531,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
|
||||||
selectedAudioTrack = audioTrackArray[0].id
|
selectedAudioTrack = audioTrackArray[0].id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.sendPlayReport()
|
self.sendPlayReport()
|
||||||
playbackItem = item
|
playbackItem = item
|
||||||
}
|
}
|
||||||
|
@ -542,7 +541,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupTracksForPreferredDefaults() {
|
func setupTracksForPreferredDefaults() {
|
||||||
subtitleTrackArray.forEach { subtitle in
|
subtitleTrackArray.forEach { subtitle in
|
||||||
if Defaults[.isAutoSelectSubtitles] {
|
if Defaults[.isAutoSelectSubtitles] {
|
||||||
|
@ -556,7 +555,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
audioTrackArray.forEach { audio in
|
audioTrackArray.forEach { audio in
|
||||||
if audio.languageCode.contains(Defaults[.autoSelectAudioLangCode]) {
|
if audio.languageCode.contains(Defaults[.autoSelectAudioLangCode]) {
|
||||||
selectedAudioTrack = audio.id
|
selectedAudioTrack = audio.id
|
||||||
|
@ -564,7 +563,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
@ -572,17 +571,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)")
|
||||||
|
@ -592,8 +591,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()
|
||||||
|
@ -603,21 +602,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()
|
setupTracksForPreferredDefaults()
|
||||||
|
|
||||||
print("Local engine started.")
|
print("Local engine started.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -633,15 +632,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
|
||||||
|
@ -653,36 +652,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,
|
||||||
|
@ -698,12 +697,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)
|
||||||
|
@ -717,25 +716,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!,
|
||||||
|
@ -748,44 +747,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
|
||||||
|
@ -820,7 +818,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 {
|
||||||
|
@ -851,18 +849,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
|
||||||
|
@ -909,7 +896,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" {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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()))
|
||||||
|
|
|
@ -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)"
|
||||||
|
|
|
@ -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,16 +63,16 @@ 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) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func errorHandler(error: UDPBroadcastConnection.ConnectionError) {
|
func errorHandler(error: UDPBroadcastConnection.ConnectionError) {
|
||||||
}
|
}
|
||||||
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 {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 ?? "")")
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -39,12 +39,12 @@ final class LibraryFilterViewModel: ViewModel {
|
||||||
var selectedSortOrder: APISortOrder = .descending
|
var selectedSortOrder: APISortOrder = .descending
|
||||||
@Published
|
@Published
|
||||||
var selectedSortBy: SortBy = .name
|
var selectedSortBy: SortBy = .name
|
||||||
|
|
||||||
func updateModifiedFilter() {
|
func updateModifiedFilter() {
|
||||||
modifiedFilters.sortOrder = [selectedSortOrder]
|
modifiedFilters.sortOrder = [selectedSortOrder]
|
||||||
modifiedFilters.sortBy = [selectedSortBy]
|
modifiedFilters.sortBy = [selectedSortBy]
|
||||||
}
|
}
|
||||||
|
|
||||||
func resetFilters() {
|
func resetFilters() {
|
||||||
modifiedFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], tags: [], sortBy: [.name])
|
modifiedFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], tags: [], sortBy: [.name])
|
||||||
}
|
}
|
||||||
|
@ -54,7 +54,7 @@ final class LibraryFilterViewModel: ViewModel {
|
||||||
self.enabledFilterType = enabledFilterType
|
self.enabledFilterType = enabledFilterType
|
||||||
self.selectedSortBy = filters!.sortBy.first!
|
self.selectedSortBy = filters!.sortBy.first!
|
||||||
self.selectedSortOrder = filters!.sortOrder.first!
|
self.selectedSortOrder = filters!.sortOrder.first!
|
||||||
|
|
||||||
super.init()
|
super.init()
|
||||||
if let filters = filters {
|
if let filters = filters {
|
||||||
self.modifiedFilters = filters
|
self.modifiedFilters = filters
|
||||||
|
|
|
@ -26,7 +26,7 @@ struct Bitrates: Codable, Hashable {
|
||||||
struct TrackLanguage: Hashable {
|
struct TrackLanguage: Hashable {
|
||||||
var name: String
|
var name: String
|
||||||
var isoCode: String
|
var isoCode: String
|
||||||
|
|
||||||
static let auto = TrackLanguage(name: "Auto", isoCode: "Auto")
|
static let auto = TrackLanguage(name: "Auto", isoCode: "Auto")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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],
|
||||||
|
|
Loading…
Reference in New Issue