diff --git a/JellyfinPlayer tvOS/ContinueWatchingView.swift b/JellyfinPlayer tvOS/ContinueWatchingView.swift index 0503c59b..f73167de 100644 --- a/JellyfinPlayer tvOS/ContinueWatchingView.swift +++ b/JellyfinPlayer tvOS/ContinueWatchingView.swift @@ -25,7 +25,7 @@ struct ContinueWatchingView: View { LazyHStack { Spacer().frame(width: 45) ForEach(items, id: \.id) { item in - NavigationLink(destination: Text("itemv")) { + NavigationLink(destination: VideoPlayerView(item: item)) { LandscapeItemElement(item: item) } .buttonStyle(PlainNavigationLinkButtonStyle()) diff --git a/JellyfinPlayer tvOS/NextUpView.swift b/JellyfinPlayer tvOS/NextUpView.swift index 5304ee0c..8b12f5cc 100644 --- a/JellyfinPlayer tvOS/NextUpView.swift +++ b/JellyfinPlayer tvOS/NextUpView.swift @@ -24,7 +24,7 @@ struct NextUpView: View { LazyHStack { Spacer().frame(width: 45) ForEach(items, id: \.id) { item in - NavigationLink(destination: Text("itemv")) { + NavigationLink(destination: VideoPlayerView(item: item)) { LandscapeItemElement(item: item) }.buttonStyle(PlainNavigationLinkButtonStyle()) } diff --git a/JellyfinPlayer tvOS/VideoPlayer/AudioView.swift b/JellyfinPlayer tvOS/VideoPlayer/AudioView.swift new file mode 100644 index 00000000..4a52baab --- /dev/null +++ b/JellyfinPlayer tvOS/VideoPlayer/AudioView.swift @@ -0,0 +1,78 @@ +// + /* + * SwiftFin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ + +import SwiftUI + +class AudioViewController: UIViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + tabBarItem.title = "Audio" + + } + + func prepareAudioView(audioTracks: [AudioTrack], selectedTrack: Int32, delegate: VideoPlayerSettingsDelegate) + { + let contentView = UIHostingController(rootView: AudioView(selectedTrack: selectedTrack, audioTrackArray: audioTracks, delegate: delegate)) + self.view.addSubview(contentView.view) + contentView.view.translatesAutoresizingMaskIntoConstraints = false + contentView.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true + contentView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true + contentView.view.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true + contentView.view.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true + + } + + /* + // MARK: - Navigation + + // In a storyboard-based application, you will often want to do a little preparation before navigation + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + // Get the new view controller using segue.destination. + // Pass the selected object to the new view controller. + } + */ + +} + +struct AudioView: View { + + @State var selectedTrack : Int32 = -1 + @State var audioTrackArray: [AudioTrack] = [] + + weak var delegate: VideoPlayerSettingsDelegate? + + var body : some View { + NavigationView { + VStack() { + List(audioTrackArray, id: \.id) { track in + Button(action: { + delegate?.selectNew(audioTrack: track.id) + selectedTrack = track.id + }, label: { + HStack(spacing: 10){ + if track.id == selectedTrack { + Image(systemName: "checkmark") + } + else { + Image(systemName: "checkmark") + .hidden() + } + Text(track.name) + } + }) + + } + } + .frame(width: 400) + + } + } +} diff --git a/JellyfinPlayer tvOS/VideoPlayer/InfoTabBarViewController.swift b/JellyfinPlayer tvOS/VideoPlayer/InfoTabBarViewController.swift new file mode 100644 index 00000000..cf96178e --- /dev/null +++ b/JellyfinPlayer tvOS/VideoPlayer/InfoTabBarViewController.swift @@ -0,0 +1,54 @@ +// +// InfoTabBarViewController.swift +// CustomPlayer +// +// Created by Stephen Byatt on 15/6/21. +// + +import TVUIKit +import JellyfinAPI + +class InfoTabBarViewController: UITabBarController, UIGestureRecognizerDelegate { + + var videoPlayer : VideoPlayerViewController? = nil + var subtitleViewController : SubtitlesViewController? = nil + var audioViewController : AudioViewController? = nil + var mediaInfoController : MediaInfoViewController? = nil + + + override func viewDidLoad() { + super.viewDidLoad() + mediaInfoController = MediaInfoViewController() + audioViewController = AudioViewController() + subtitleViewController = SubtitlesViewController() + + viewControllers = [mediaInfoController!, audioViewController!, subtitleViewController!] + + } + + func setupInfoViews(mediaItem: BaseItemDto, subtitleTracks: [Subtitle], selectedSubtitleTrack : Int32, audioTracks: [AudioTrack], selectedAudioTrack: Int32, delegate: VideoPlayerSettingsDelegate) { + + mediaInfoController?.setMedia(item: mediaItem) + + audioViewController?.prepareAudioView(audioTracks: audioTracks, selectedTrack: selectedAudioTrack, delegate: delegate) + + subtitleViewController?.prepareSubtitleView(subtitleTracks: subtitleTracks, selectedTrack: selectedSubtitleTrack, delegate: delegate) + + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + return true + } + + + + // MARK: - Navigation + +// // In a storyboard-based application, you will often want to do a little preparation before navigation +// override func prepare(for segue: UIStoryboardSegue, sender: Any?) { +// // Get the new view controller using segue.destination. +// // Pass the selected object to the new view controller. +// } +// + +} diff --git a/JellyfinPlayer tvOS/VideoPlayer/MediaInfoView.swift b/JellyfinPlayer tvOS/VideoPlayer/MediaInfoView.swift new file mode 100644 index 00000000..453e43e4 --- /dev/null +++ b/JellyfinPlayer tvOS/VideoPlayer/MediaInfoView.swift @@ -0,0 +1,117 @@ +// +/* + * SwiftFin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ + +import SwiftUI +import JellyfinAPI + +class MediaInfoViewController: UIViewController { + private var contentView: UIHostingController! + + + override func viewDidLoad() { + super.viewDidLoad() + + tabBarItem.title = "Info" + } + + func setMedia(item: BaseItemDto) + { + contentView = UIHostingController(rootView: MediaInfoView(item: item)) + self.view.addSubview(contentView.view) + contentView.view.translatesAutoresizingMaskIntoConstraints = false + contentView.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true + contentView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true + contentView.view.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true + contentView.view.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true + + } +} + +struct MediaInfoView: View { + @State var item : BaseItemDto? = nil + + var body: some View { + if let item = item { + HStack { + VStack { + ImageView(src: item.type == "Episode" ? item.getSeriesPrimaryImage(maxWidth: 200) : item.getPrimaryImage(maxWidth: 200), bh: item.type == "Episode" ? item.getSeriesPrimaryImageBlurHash() : item.getPrimaryImageBlurHash()) .frame(width: 200, height: 300) + .cornerRadius(10) + Spacer() + } + .padding(.leading, 200) + + VStack(alignment: .leading) { + if item.type == "Episode" { + Text(item.seriesName!) + .font(.title3) + + Text("S\(item.parentIndexNumber ?? 0):E\(item.indexNumber ?? 0) • \(item.name!)") + .font(.headline) + .foregroundColor(.secondary) + } + else + { + Text(item.name!) + .font(.title3) + } + + HStack(spacing: 10) { + Text(String(item.productionYear!)) + Text("•") + + Text(formatRunningtime()) + + if item.officialRating != nil { + Text("•") + + Text("\(item.officialRating!)").font(.subheadline) + .fontWeight(.semibold) + .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) + .overlay(RoundedRectangle(cornerRadius: 2) + .stroke(Color.secondary, lineWidth: 1)) + + } + } + .padding(.top) + .foregroundColor(.secondary) + + + Text(item.overview!) + .padding([.top, .trailing]) + .foregroundColor(.secondary) + + Spacer() + } + + + Spacer() + + + } + .frame(maxWidth: .infinity) + } + else { + EmptyView() + } + + } + + func formatRunningtime() -> String { + let timeHMSFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.unitsStyle = .brief + formatter.allowedUnits = [.hour, .minute] + return formatter + }() + + let text = timeHMSFormatter.string(from: Double(item!.runTimeTicks! / 10_000_000)) ?? "" + + return text + } +} diff --git a/JellyfinPlayer tvOS/VideoPlayer/SubtitlesView.swift b/JellyfinPlayer tvOS/VideoPlayer/SubtitlesView.swift new file mode 100644 index 00000000..ec99c1d0 --- /dev/null +++ b/JellyfinPlayer tvOS/VideoPlayer/SubtitlesView.swift @@ -0,0 +1,150 @@ +// +/* + * SwiftFin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ + +import SwiftUI + +class SubtitlesViewController: UIViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + tabBarItem.title = "Subtitles" + + } + + + func prepareSubtitleView(subtitleTracks: [Subtitle], selectedTrack: Int32, delegate: VideoPlayerSettingsDelegate) + { + let contentView = UIHostingController(rootView: SubtitleView(selectedTrack: selectedTrack, subtitleTrackArray: subtitleTracks, delegate: delegate)) + self.view.addSubview(contentView.view) + contentView.view.translatesAutoresizingMaskIntoConstraints = false + contentView.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true + contentView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true + contentView.view.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true + contentView.view.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true + + } + + // + // + // func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + // return subtitleTrackArray.count + // } + // + // func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + // let cell = UITableViewCell() + // let subtitle = subtitleTrackArray[indexPath.row] + // cell.textLabel?.text = subtitle.name + // + // let image = UIImage(systemName: "checkmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: (27), weight: .bold))?.withRenderingMode(.alwaysOriginal).withTintColor(.white) + // cell.imageView?.image = image + // + // if selectedTrack != subtitle.id { + // cell.imageView?.isHidden = true + // } + // else { + // selectedTrackCellRow = indexPath.row + // } + // + // return cell + // } + // + // func tableView(_ tableView: UITableView, didUpdateFocusIn context: UITableViewFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) { + // + // if let path = context.nextFocusedIndexPath { + // if path.row == selectedTrackCellRow { + // let cell : UITableViewCell = tableView.cellForRow(at: path)! + // cell.imageView?.image = cell.imageView?.image?.withTintColor(.black) + // } + // } + // + // if let path = context.previouslyFocusedIndexPath { + // if path.row == selectedTrackCellRow { + // let cell : UITableViewCell = tableView.cellForRow(at: path)! + // cell.imageView?.image = cell.imageView?.image?.withTintColor(.white) + // } + // } + // + // } + // + // + // func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + // let oldPath = IndexPath(row: selectedTrackCellRow, section: 0) + // if let oldCell : UITableViewCell = tableView.cellForRow(at: oldPath) { + // oldCell.imageView?.isHidden = true + // } + // + // let cell : UITableViewCell = tableView.cellForRow(at: indexPath)! + // cell.imageView?.isHidden = false + // cell.imageView?.image = cell.imageView?.image?.withTintColor(.black) + // + // selectedTrack = Int32(subtitleTrackArray[indexPath.row].id) + // selectedTrackCellRow = indexPath.row + //// infoTabBar?.videoPlayer?.subtitleTrackChanged(newTrackID: selectedTrack) + // print("setting new subtitle") + // tableView.deselectRow(at: indexPath, animated: false) + // + // } + // + // func numberOfSections(in tableView: UITableView) -> Int { + // return 1 + // } + // + // + + + + /* + // MARK: - Navigation + + // In a storyboard-based application, you will often want to do a little preparation before navigation + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + // Get the new view controller using segue.destination. + // Pass the selected object to the new view controller. + } + */ + +} + +struct SubtitleView: View { + + @State var selectedTrack : Int32 = -1 + @State var subtitleTrackArray: [Subtitle] = [] + + weak var delegate: VideoPlayerSettingsDelegate? + + + var body : some View { + NavigationView { + VStack() { + List(subtitleTrackArray, id: \.id) { track in + Button(action: { + delegate?.selectNew(subtitleTrack: track.id) + selectedTrack = track.id + }, label: { + HStack(spacing: 10){ + if track.id == selectedTrack { + Image(systemName: "checkmark") + } + else { + Image(systemName: "checkmark") + .hidden() + } + Text(track.name) + } + }) + + } + } + .frame(width: 400) + + } + } + +} diff --git a/JellyfinPlayer tvOS/VideoPlayer/VideoPlayer.swift b/JellyfinPlayer tvOS/VideoPlayer/VideoPlayer.swift new file mode 100644 index 00000000..0f4c5e39 --- /dev/null +++ b/JellyfinPlayer tvOS/VideoPlayer/VideoPlayer.swift @@ -0,0 +1,26 @@ +// +// VideoPlayer.swift +// CustomPlayer +// +// Created by Stephen Byatt on 25/5/21. +// + +import SwiftUI +import JellyfinAPI + +struct VideoPlayerView: UIViewControllerRepresentable { + var item: BaseItemDto + + func makeUIViewController(context: Context) -> some UIViewController { + + let storyboard = UIStoryboard(name: "VideoPlayerStoryboard", bundle: nil) + let viewController = storyboard.instantiateViewController(withIdentifier: "VideoPlayer") as! VideoPlayerViewController + viewController.manifest = item + + return viewController + } + + func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { + + } +} diff --git a/JellyfinPlayer tvOS/VideoPlayer/VideoPlayerStoryboard.storyboard b/JellyfinPlayer tvOS/VideoPlayer/VideoPlayerStoryboard.storyboard new file mode 100644 index 00000000..1f7535cb --- /dev/null +++ b/JellyfinPlayer tvOS/VideoPlayer/VideoPlayerStoryboard.storyboard @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/JellyfinPlayer tvOS/VideoPlayer/VideoPlayerViewController.swift b/JellyfinPlayer tvOS/VideoPlayer/VideoPlayerViewController.swift new file mode 100644 index 00000000..173f1f97 --- /dev/null +++ b/JellyfinPlayer tvOS/VideoPlayer/VideoPlayerViewController.swift @@ -0,0 +1,785 @@ +// +// VideoPlayerViewController.swift +// CustomPlayer +// +// Created by Stephen Byatt on 15/6/21. +// + +import TVUIKit +import TVVLCKit +import MediaPlayer +import JellyfinAPI +import Combine + +struct Subtitle { + var name: String + var id: Int32 + var url: URL? + var delivery: SubtitleDeliveryMethod + var codec: String +} + +struct AudioTrack { + var name: String + var id: Int32 +} + +class PlaybackItem: ObservableObject { + @Published var videoType: PlayMethod = .directPlay + @Published var videoUrl: URL = URL(string: "https://example.com")! +} + +protocol VideoPlayerSettingsDelegate: AnyObject { + func selectNew(audioTrack id: Int32) + func selectNew(subtitleTrack id: Int32) +} + +class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate, VLCMediaPlayerDelegate, VLCMediaDelegate, UIGestureRecognizerDelegate { + + + @IBOutlet weak var videoContentView: UIView! + @IBOutlet weak var controlsView: UIView! + + @IBOutlet weak var activityIndicator: UIActivityIndicatorView! + + @IBOutlet weak var transportBarView: UIView! + @IBOutlet weak var scrubberView: UIView! + @IBOutlet weak var scrubLabel: UILabel! + @IBOutlet weak var gradientView: UIView! + + @IBOutlet weak var currentTimeLabel: UILabel! + @IBOutlet weak var remainingTimeLabel: UILabel! + + @IBOutlet weak var infoViewContainer: UIView! + + var infoPanelDisplayPoint : CGPoint = .zero + var infoPanelHiddenPoint : CGPoint = .zero + + var containerViewController: InfoTabBarViewController? + var focusedOnTabBar : Bool = false + var showingInfoPanel : Bool = false + + var mediaPlayer = VLCMediaPlayer() + + var lastProgressReportTime: Double = 0 + var lastTime: Float = 0.0 + var startTime: Int = 0 + + var selectedAudioTrack: Int32 = -1 { + didSet { + print(selectedAudioTrack) + } + } + var selectedCaptionTrack: Int32 = -1 { + didSet { + print(selectedCaptionTrack) + } + } + + var subtitleTrackArray: [Subtitle] = [] + var audioTrackArray: [AudioTrack] = [] + + var playing: Bool = false + var seeking: Bool = false + var showingControls: Bool = false + var loading: Bool = true + + var initialSeekPos : CGFloat = 0 + var videoPos: Double = 0 + var videoDuration: Double = 0 + var controlsAppearTime: Double = 0 + + + var manifest: BaseItemDto = BaseItemDto() + var playbackItem = PlaybackItem() + var playSessionId: String = "" + + var cancellables = Set() + + + override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) { + + super.didUpdateFocus(in: context, with: coordinator) + + // Check if focused on the tab bar, allows for swipe up to dismiss the info panel + if context.nextFocusedView!.description.contains("UITabBarButton") + { + // Set value after half a second so info panel is not dismissed instantly when swiping up from content + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.focusedOnTabBar = true + } + } + else + { + focusedOnTabBar = false + } + + } + + override func viewDidLoad() { + super.viewDidLoad() + + activityIndicator.isHidden = false + activityIndicator.startAnimating() + + mediaPlayer.delegate = self + mediaPlayer.drawable = videoContentView + + if let runTimeTicks = manifest.runTimeTicks { + videoDuration = Double(runTimeTicks / 10_000_000) + } + + // Black gradient behind transport bar + let gradientLayer:CAGradientLayer = CAGradientLayer() + gradientLayer.frame.size = self.gradientView.frame.size + gradientLayer.colors = [UIColor.black.withAlphaComponent(0.6).cgColor, UIColor.black.withAlphaComponent(0).cgColor] + gradientLayer.startPoint = CGPoint(x: 0.0, y: 1.0) + gradientLayer.endPoint = CGPoint(x: 0.0, y: 0.0) + self.gradientView.layer.addSublayer(gradientLayer) + + infoPanelDisplayPoint = infoViewContainer.center + infoPanelHiddenPoint = CGPoint(x: infoPanelDisplayPoint.x, y: -infoViewContainer.frame.height) + infoViewContainer.center = infoPanelHiddenPoint + infoViewContainer.layer.cornerRadius = 40 + + transportBarView.layer.cornerRadius = CGFloat(5) + + setupGestures() + + fetchVideo() + + setupNowPlayingCC() + + mediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 16) + + } + + func fetchVideo() { + // Fetch max bitrate from UserDefaults depending on current connection mode + let defaults = UserDefaults.standard + let maxBitrate = defaults.integer(forKey: "InNetworkBandwidth") + + // Build a device profile + let builder = DeviceProfileBuilder() + builder.setMaxBitrate(bitrate: maxBitrate) + let profile = builder.buildProfile() + + let playbackInfo = PlaybackInfoDto(userId: SessionManager.current.user.user_id!, maxStreamingBitrate: Int(maxBitrate), startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, deviceProfile: profile, autoOpenLiveStream: true) + + DispatchQueue.global(qos: .userInitiated).async { [self] in + // delegate?.showLoadingView(self) + MediaInfoAPI.getPostedPlaybackInfo(itemId: manifest.id!, userId: SessionManager.current.user.user_id!, maxStreamingBitrate: Int(maxBitrate), startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, autoOpenLiveStream: true, playbackInfoDto: playbackInfo) + .sink(receiveCompletion: { result in + print(result) + }, receiveValue: { [self] response in + + videoContentView.setNeedsLayout() + videoContentView.setNeedsDisplay() + + playSessionId = response.playSessionId ?? "" + + let mediaSource = response.mediaSources!.first.self! + + let item = PlaybackItem() + let streamURL : URL? + + // Item is being transcoded by request of server + if mediaSource.transcodingUrl != nil + { + item.videoType = .transcode + streamURL = URL(string: "\(ServerEnvironment.current.server.baseURI!)\(mediaSource.transcodingUrl!)") + } + // Item will be directly played by the client + else + { + item.videoType = .directPlay + streamURL = URL(string: "\(ServerEnvironment.current.server.baseURI!)/Videos/\(manifest.id!)/stream?Static=true&mediaSourceId=\(manifest.id!)&deviceId=\(SessionManager.current.deviceID)&api_key=\(SessionManager.current.accessToken)&Tag=\(mediaSource.eTag!)")! + } + + item.videoUrl = streamURL! + + let disableSubtitleTrack = Subtitle(name: "None", id: -1, url: nil, delivery: .embed, codec: "") + subtitleTrackArray.append(disableSubtitleTrack) + + // Loop through media streams and add to array + for stream in mediaSource.mediaStreams! { + + if stream.type == .subtitle { + var deliveryUrl: URL? = nil + + if stream.deliveryMethod == .external { + deliveryUrl = URL(string: "\(ServerEnvironment.current.server.baseURI!)\(stream.deliveryUrl!)")! + } + + let subtitle = Subtitle(name: stream.displayTitle ?? "Unknown", id: Int32(stream.index!), url: deliveryUrl, delivery: stream.deliveryMethod!, codec: stream.codec ?? "webvtt") + + if stream.isDefault == true{ + selectedCaptionTrack = Int32(stream.index!) + } + + if subtitle.delivery != .encode { + subtitleTrackArray.append(subtitle) + } + } + + if stream.type == .audio { + let track = AudioTrack(name: stream.displayTitle!, id: Int32(stream.index!)) + + if stream.isDefault! == true { + selectedAudioTrack = Int32(stream.index!) + } + + audioTrackArray.append(track) + } + } + + // If no default audio tracks select the first one + if selectedAudioTrack == -1 && !audioTrackArray.isEmpty{ + selectedAudioTrack = audioTrackArray.first!.id + } + + + self.sendPlayReport() + playbackItem = item + + mediaPlayer.media = VLCMedia(url: playbackItem.videoUrl) + mediaPlayer.media.delegate = self + mediaPlayer.play() + + // 1 second = 10,000,000 ticks + + if let rawStartTicks = manifest.userData?.playbackPositionTicks { + mediaPlayer.jumpForward(Int32(rawStartTicks / 10_000_000)) + } + + // Pause and load captions into memory. + mediaPlayer.pause() + + var shouldHaveSubtitleTracks = 0 + subtitleTrackArray.forEach { sub in + if sub.id != -1 && sub.delivery == .external && sub.codec != "subrip" { + shouldHaveSubtitleTracks = shouldHaveSubtitleTracks + 1 + mediaPlayer.addPlaybackSlave(sub.url!, type: .subtitle, enforce: false) + } + } + + // Wait for captions to load + while mediaPlayer.numberOfSubtitlesTracks != shouldHaveSubtitleTracks {} + + // Select default track & resume playback + mediaPlayer.currentVideoSubTitleIndex = selectedCaptionTrack + mediaPlayer.pause() + mediaPlayer.play() + playing = true + + setupInfoPanel() + + activityIndicator.isHidden = true + loading = false + + }) + .store(in: &cancellables) + + + } + } + + func setupNowPlayingCC() { + let commandCenter = MPRemoteCommandCenter.shared() + commandCenter.playCommand.isEnabled = true + commandCenter.pauseCommand.isEnabled = true + commandCenter.seekForwardCommand.isEnabled = true + commandCenter.seekBackwardCommand.isEnabled = true + commandCenter.changePlaybackPositionCommand.isEnabled = true + + // Add handler for Pause Command + commandCenter.pauseCommand.addTarget { _ in + self.pause() + return .success + } + + // Add handler for Play command + commandCenter.playCommand.addTarget { _ in + self.play() + return .success + } + + // Add handler for FF command + commandCenter.seekForwardCommand.addTarget { _ in + self.mediaPlayer.jumpForward(30) + self.sendProgressReport(eventName: "timeupdate") + return .success + } + + // Add handler for RW command + commandCenter.seekBackwardCommand.addTarget { _ in + self.mediaPlayer.jumpBackward(15) + self.sendProgressReport(eventName: "timeupdate") + return .success + } + + // Scrubber + commandCenter.changePlaybackPositionCommand.addTarget { [weak self](remoteEvent) -> MPRemoteCommandHandlerStatus in + guard let self = self else {return .commandFailed} + + if let event = remoteEvent as? MPChangePlaybackPositionCommandEvent { + let targetSeconds = event.positionTime + + let videoPosition = Double(self.mediaPlayer.time.intValue) + let offset = targetSeconds - videoPosition + if offset > 0 { + self.mediaPlayer.jumpForward(Int32(offset)/1000) + } else { + self.mediaPlayer.jumpBackward(Int32(abs(offset))/1000) + } + self.sendProgressReport(eventName: "unpause") + + return .success + } else { + return .commandFailed + } + } + + + MPNowPlayingInfoCenter.default().nowPlayingInfo = [ + MPMediaItemPropertyTitle: "TestVideo", + MPNowPlayingInfoPropertyPlaybackRate : 1.0, + MPNowPlayingInfoPropertyMediaType : AVMediaType.video, + MPMediaItemPropertyPlaybackDuration : manifest.runTimeTicks ?? 0 / 10_000_000, + MPNowPlayingInfoPropertyElapsedPlaybackTime : mediaPlayer.time.intValue/1000 + ] + + UIApplication.shared.beginReceivingRemoteControlEvents() + } + + + + // Grabs a refference to the info panel view controller + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "infoView" { + containerViewController = segue.destination as? InfoTabBarViewController + containerViewController?.videoPlayer = self + + } + } + + // MARK: Player functions + // Animate the scrubber when playing state changes + func animateScrubber() { + let y : CGFloat = playing ? 0 : -20 + let height: CGFloat = playing ? 10 : 30 + + UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseIn, animations: { + self.scrubberView.frame = CGRect(x: self.scrubberView.frame.minX, y: y, width: 2, height: height) + }) + } + + + func pause() { + playing = false + mediaPlayer.pause() + + self.sendProgressReport(eventName: "pause") + + animateScrubber() + + self.scrubLabel.frame = CGRect(x: self.scrubberView.frame.minX - self.scrubLabel.frame.width/2, y:self.scrubLabel.frame.minY, width: self.scrubLabel.frame.width, height: self.scrubLabel.frame.height) + } + + func play () { + playing = true + mediaPlayer.play() + + self.sendProgressReport(eventName: "unpause") + + animateScrubber() + } + + + func toggleInfoContainer() { + showingInfoPanel.toggle() + + containerViewController?.view.isUserInteractionEnabled = showingInfoPanel + + if showingInfoPanel && seeking { + scrubLabel.isHidden = true + UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut, animations: { + self.scrubberView.frame = CGRect(x: self.initialSeekPos, y: self.scrubberView.frame.minY, width: 2, height: self.scrubberView.frame.height) + }) + seeking = false + + } + + UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut) { [self] in + infoViewContainer.center = showingInfoPanel ? infoPanelDisplayPoint : infoPanelHiddenPoint + } + + } + + // MARK: Gestures + func setupGestures() { + + let playPauseGesture = UITapGestureRecognizer(target: self, action: #selector(self.selectButtonTapped)) + let playPauseType = UIPress.PressType.playPause + playPauseGesture.allowedPressTypes = [NSNumber(value: playPauseType.rawValue)]; + view.addGestureRecognizer(playPauseGesture) + + let selectGesture = UITapGestureRecognizer(target: self, action: #selector(self.selectButtonTapped)) + let selectType = UIPress.PressType.select + selectGesture.allowedPressTypes = [NSNumber(value: selectType.rawValue)]; + view.addGestureRecognizer(selectGesture) + + let backTapGesture = UITapGestureRecognizer(target: self, action: #selector(self.backButtonPressed(tap:))) + let backPress = UIPress.PressType.menu + backTapGesture.allowedPressTypes = [NSNumber(value: backPress.rawValue)]; + view.addGestureRecognizer(backTapGesture) + + let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.userPanned(panGestureRecognizer:))) + view.addGestureRecognizer(panGestureRecognizer) + + + let swipeRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(self.swipe(swipe:))) + swipeRecognizer.direction = .right + view.addGestureRecognizer(swipeRecognizer) + + let swipeRecognizerl = UISwipeGestureRecognizer(target: self, action: #selector(self.swipe(swipe:))) + swipeRecognizerl.direction = .left + view.addGestureRecognizer(swipeRecognizerl) + + } + + @objc func backButtonPressed(tap : UITapGestureRecognizer) { + + // Dismiss info panel + if showingInfoPanel { + if focusedOnTabBar { + toggleInfoContainer() + } + return + } + + // Cancel seek and move back to initial position + if(seeking) { + scrubLabel.isHidden = true + UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut, animations: { + self.scrubberView.frame = CGRect(x: self.initialSeekPos, y: 0, width: 2, height: 10) + }) + play() + seeking = false + } + else + { + // Dismiss view + mediaPlayer.stop() + sendStopReport() + self.navigationController?.popViewController(animated: true) + } + } + + @objc func userPanned(panGestureRecognizer : UIPanGestureRecognizer) { + if loading { + return + } + + let translation = panGestureRecognizer.translation(in: view) + let velocity = panGestureRecognizer.velocity(in: view) + + // Swiped up - Handle dismissing info panel + if translation.y < -700 && (focusedOnTabBar && showingInfoPanel) { + toggleInfoContainer() + return + } + + if showingInfoPanel { + return + } + + // Swiped down - Show the info panel + if translation.y > 700 { + toggleInfoContainer() + return + } + + // Ignore seek if video is playing + if playing { + return + } + + // Save current position if seek is cancelled and show the scrubLabel + if(!seeking) { + initialSeekPos = self.scrubberView.frame.minX + seeking = true + self.scrubLabel.isHidden = false + } + + let newPos = (self.scrubberView.frame.minX + velocity.x/100).clamped(to: 0...transportBarView.frame.width) + + UIView.animate(withDuration: 0.8, delay: 0, options: .curveEaseOut, animations: { + let time = (Double(self.scrubberView.frame.minX) * self.videoDuration) / Double(self.transportBarView.frame.width) + + self.scrubberView.frame = CGRect(x: newPos, y: self.scrubberView.frame.minY, width: 2, height: 30) + self.scrubLabel.frame = CGRect(x: (newPos - self.scrubLabel.frame.width/2), y: self.scrubLabel.frame.minY, width: self.scrubLabel.frame.width, height: self.scrubLabel.frame.height) + self.scrubLabel.text = (self.formatSecondsToHMS(time)) + + }) + + + } + + // Not currently used + @objc func swipe(swipe: UISwipeGestureRecognizer!) { + print("swiped") + switch swipe.direction { + case .left: + print("swiped left") + mediaPlayer.pause() + // player.seek(to: CMTime(value: Int64(self.currentSeconds) + 10, timescale: 1)) + mediaPlayer.play() + case .right: + print("swiped right") + mediaPlayer.pause() + // player.seek(to: CMTime(value: Int64(self.currentSeconds) + 10, timescale: 1)) + mediaPlayer.play() + case .up: + break + case .down: + break + default: + break + } + + } + + /// Play/Pause or Select is pressed on the AppleTV remote + @objc func selectButtonTapped() { + if loading { + return + } + + showingControls = true + controlsView.isHidden = false + controlsAppearTime = CACurrentMediaTime() + + + // Move to seeked position + if(seeking) { + scrubLabel.isHidden = true + + // Move current time to the scrubbed position + UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut, animations: { [self] in + + self.currentTimeLabel.frame = CGRect(x: CGFloat(scrubLabel.frame.minX + transportBarView.frame.minX), y: currentTimeLabel.frame.minY, width: currentTimeLabel.frame.width, height: currentTimeLabel.frame.height) + + }) + + let time = (Double(self.scrubberView.frame.minX) * self.videoDuration) / Double(self.transportBarView.frame.width) + + self.currentTimeLabel.text = self.scrubLabel.text + self.remainingTimeLabel.text = "-" + formatSecondsToHMS(videoDuration - time) + + mediaPlayer.position = Float(self.scrubberView.frame.minX) / Float(self.transportBarView.frame.width) + + play() + + seeking = false + return + } + + playing ? pause() : play() + } + + + // MARK: Jellyfin Playstate updates + func sendProgressReport(eventName: String) { + if (eventName == "timeupdate" && mediaPlayer.state == .playing) || eventName != "timeupdate" { + let progressInfo = PlaybackProgressInfo(canSeek: true, item: manifest, itemId: manifest.id, sessionId: playSessionId, mediaSourceId: manifest.id, audioStreamIndex: Int(selectedAudioTrack), subtitleStreamIndex: Int(selectedCaptionTrack), isPaused: (!playing), isMuted: false, positionTicks: Int64(mediaPlayer.position * Float(manifest.runTimeTicks!)), playbackStartTimeTicks: Int64(startTime), volumeLevel: 100, brightness: 100, aspectRatio: nil, playMethod: playbackItem.videoType, liveStreamId: nil, playSessionId: playSessionId, repeatMode: .repeatNone, nowPlayingQueue: [], playlistItemId: "playlistItem0") + + PlaystateAPI.reportPlaybackProgress(playbackProgressInfo: progressInfo) + .sink(receiveCompletion: { result in + print(result) + }, receiveValue: { _ in + print("Playback progress report sent!") + }) + .store(in: &cancellables) + } + } + + func sendStopReport() { + let stopInfo = PlaybackStopInfo(item: manifest, itemId: manifest.id, sessionId: playSessionId, mediaSourceId: manifest.id, positionTicks: Int64(mediaPlayer.position * Float(manifest.runTimeTicks!)), liveStreamId: nil, playSessionId: playSessionId, failed: nil, nextMediaType: nil, playlistItemId: "playlistItem0", nowPlayingQueue: []) + + PlaystateAPI.reportPlaybackStopped(playbackStopInfo: stopInfo) + .sink(receiveCompletion: { result in + print(result) + }, receiveValue: { _ in + print("Playback stop report sent!") + }) + .store(in: &cancellables) + } + + func sendPlayReport() { + startTime = Int(Date().timeIntervalSince1970) * 10000000 + + print("sending play report!") + + let startInfo = PlaybackStartInfo(canSeek: true, item: manifest, itemId: manifest.id, sessionId: playSessionId, mediaSourceId: manifest.id, audioStreamIndex: Int(selectedAudioTrack), subtitleStreamIndex: Int(selectedCaptionTrack), isPaused: false, isMuted: false, positionTicks: manifest.userData?.playbackPositionTicks, playbackStartTimeTicks: Int64(startTime), volumeLevel: 100, brightness: 100, aspectRatio: nil, playMethod: playbackItem.videoType, liveStreamId: nil, playSessionId: playSessionId, repeatMode: .repeatNone, nowPlayingQueue: [], playlistItemId: "playlistItem0") + + PlaystateAPI.reportPlaybackStart(playbackStartInfo: startInfo) + .sink(receiveCompletion: { result in + print(result) + }, receiveValue: { _ in + print("Playback start report sent!") + }) + .store(in: &cancellables) + } + + + // MARK: VLC Delegate + + func mediaPlayerStateChanged(_ aNotification: Notification!) { + let currentState: VLCMediaPlayerState = mediaPlayer.state + switch currentState { + case .buffering: + print("Video is buffering") + loading = true + activityIndicator.isHidden = false + activityIndicator.startAnimating() + mediaPlayer.pause() + usleep(10000) + mediaPlayer.play() + break + case .stopped: + print("stopped") + + break + case .ended: + print("ended") + + break + case .opening: + print("opening") + + break + case .paused: + print("paused") + + break + case .playing: + print("Video is playing") + loading = false + sendProgressReport(eventName: "unpause") + DispatchQueue.main.async { [self] in + activityIndicator.isHidden = true + activityIndicator.stopAnimating() + } + playing = true + break + case .error: + print("error") + break + case .esAdded: + print("esAdded") + break + default: + print("default") + break + + } + + } + + // Move time along transport bar + func mediaPlayerTimeChanged(_ aNotification: Notification!) { + + if loading { + loading = false + DispatchQueue.main.async { [self] in + activityIndicator.isHidden = true + activityIndicator.stopAnimating() + } + } + + let time = mediaPlayer.position + if time != lastTime { + self.currentTimeLabel.text = formatSecondsToHMS(Double(mediaPlayer.time.intValue/1000)) + self.remainingTimeLabel.text = "-" + formatSecondsToHMS(Double(abs(mediaPlayer.remainingTime.intValue/1000))) + + self.videoPos = Double(mediaPlayer.position) + + let newPos = videoPos * Double(self.transportBarView.frame.width) + if !newPos.isNaN && self.playing { + self.scrubberView.frame = CGRect(x: newPos, y: 0, width: 2, height: 10) + self.currentTimeLabel.frame = CGRect(x: CGFloat(newPos) + transportBarView.frame.minX - currentTimeLabel.frame.width/2, y: currentTimeLabel.frame.minY, width: currentTimeLabel.frame.width, height: currentTimeLabel.frame.height) + } + + if showingControls { + if CACurrentMediaTime() - controlsAppearTime > 5 { + showingControls = false + UIView.animate(withDuration: 0.5, delay: 0, options: .curveEaseOut, animations: { + self.controlsView.alpha = 0.0 + }, completion: { (_: Bool) in + self.controlsView.isHidden = true + self.controlsView.alpha = 1 + }) + controlsAppearTime = 999_999_999_999_999 + } + } + + } + + lastTime = time + + if CACurrentMediaTime() - lastProgressReportTime > 5 { + sendProgressReport(eventName: "timeupdate") + lastProgressReportTime = CACurrentMediaTime() + } + } + + + // MARK: Settings Delegate + func selectNew(audioTrack id: Int32) { + selectedAudioTrack = id + mediaPlayer.currentAudioTrackIndex = id + } + + func selectNew(subtitleTrack id: Int32) { + selectedCaptionTrack = id + mediaPlayer.currentVideoSubTitleIndex = id + } + + func setupInfoPanel() { + containerViewController?.setupInfoViews(mediaItem: manifest, subtitleTracks: subtitleTrackArray, selectedSubtitleTrack: selectedCaptionTrack, audioTracks: audioTrackArray, selectedAudioTrack: selectedAudioTrack, delegate: self) + } + + + func formatSecondsToHMS(_ seconds: Double) -> String { + let timeHMSFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.unitsStyle = .positional + formatter.allowedUnits = seconds >= 3600 ? + [.hour, .minute, .second] : + [.minute, .second] + formatter.zeroFormattingBehavior = .pad + return formatter + }() + + guard !seconds.isNaN, + let text = timeHMSFormatter.string(from: seconds) else { + return "00:00" + } + + return text.hasPrefix("0") && text.count > 4 ? + .init(text.dropFirst()) : text + } + + // When VLC video starts playing a real device can no longer receive gesture recognisers, adding this in hopes to fix the issue but no luck + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + print("recognisesimultaneousvideoplayer") + return true + } +} + +extension Comparable { + func clamped(to limits: ClosedRange) -> Self { + return min(max(self, limits.lowerBound), limits.upperBound) + } +} diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index 5d0f0316..86b882cb 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -7,6 +7,14 @@ objects = { /* Begin PBXBuildFile section */ + 09389CBE26814DF600AE350E /* VideoPlayerStoryboard.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 09389CB726814DF500AE350E /* VideoPlayerStoryboard.storyboard */; }; + 09389CBF26814DF600AE350E /* MediaInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09389CB826814DF600AE350E /* MediaInfoView.swift */; }; + 09389CC026814DF600AE350E /* SubtitlesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09389CB926814DF600AE350E /* SubtitlesView.swift */; }; + 09389CC126814DF600AE350E /* InfoTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09389CBA26814DF600AE350E /* InfoTabBarViewController.swift */; }; + 09389CC226814DF600AE350E /* VideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09389CBB26814DF600AE350E /* VideoPlayer.swift */; }; + 09389CC326814DF600AE350E /* VideoPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09389CBC26814DF600AE350E /* VideoPlayerViewController.swift */; }; + 09389CC426814DF600AE350E /* AudioView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09389CBD26814DF600AE350E /* AudioView.swift */; }; + 09389CC526814E4500AE350E /* DeviceProfileBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */; }; 531690E5267ABD5C005D8AB9 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690E4267ABD5C005D8AB9 /* MainTabView.swift */; }; 531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690E6267ABD79005D8AB9 /* HomeView.swift */; }; 531690ED267ABF46005D8AB9 /* ContinueWatchingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690EB267ABF46005D8AB9 /* ContinueWatchingView.swift */; }; @@ -181,6 +189,13 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 09389CB726814DF500AE350E /* VideoPlayerStoryboard.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = VideoPlayerStoryboard.storyboard; sourceTree = ""; }; + 09389CB826814DF600AE350E /* MediaInfoView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaInfoView.swift; sourceTree = ""; }; + 09389CB926814DF600AE350E /* SubtitlesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubtitlesView.swift; sourceTree = ""; }; + 09389CBA26814DF600AE350E /* InfoTabBarViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InfoTabBarViewController.swift; sourceTree = ""; }; + 09389CBB26814DF600AE350E /* VideoPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = ""; }; + 09389CBC26814DF600AE350E /* VideoPlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewController.swift; sourceTree = ""; }; + 09389CBD26814DF600AE350E /* AudioView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AudioView.swift; sourceTree = ""; }; 3773C07648173CE7FEC083D5 /* Pods-JellyfinPlayer iOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JellyfinPlayer iOS.debug.xcconfig"; path = "Target Support Files/Pods-JellyfinPlayer iOS/Pods-JellyfinPlayer iOS.debug.xcconfig"; sourceTree = ""; }; 3F905C1D3D3A0C9E13E7A0BC /* Pods_JellyfinPlayer_iOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_JellyfinPlayer_iOS.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 531690E4267ABD5C005D8AB9 /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = ""; }; @@ -345,6 +360,20 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 09389CB626814DD700AE350E /* VideoPlayer */ = { + isa = PBXGroup; + children = ( + 09389CBD26814DF600AE350E /* AudioView.swift */, + 09389CBA26814DF600AE350E /* InfoTabBarViewController.swift */, + 09389CB826814DF600AE350E /* MediaInfoView.swift */, + 09389CB926814DF600AE350E /* SubtitlesView.swift */, + 09389CBB26814DF600AE350E /* VideoPlayer.swift */, + 09389CB726814DF500AE350E /* VideoPlayerStoryboard.storyboard */, + 09389CBC26814DF600AE350E /* VideoPlayerViewController.swift */, + ); + path = VideoPlayer; + sourceTree = ""; + }; 532175392671BCED005491E6 /* ViewModels */ = { isa = PBXGroup; children = ( @@ -371,6 +400,7 @@ 535870612669D21600D05A09 /* JellyfinPlayer tvOS */ = { isa = PBXGroup; children = ( + 09389CB626814DD700AE350E /* VideoPlayer */, 536D3D77267BB9650004248C /* Components */, 53ABFDDA267972BF00886593 /* JellyfinPlayer tvOS.entitlements */, 531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */, @@ -459,7 +489,6 @@ 5377CBF8263B596B003A4E83 /* Assets.xcassets */, 5338F74D263B61370014BF09 /* ConnectToServerView.swift */, 5389276D263C25100035E14B /* ContinueWatchingView.swift */, - 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */, 53987CA72657424A00E7EA70 /* EpisodeItemView.swift */, 5377CC02263B596B003A4E83 /* Info.plist */, 535BAE9E2649E569005FA86D /* ItemView.swift */, @@ -560,6 +589,7 @@ 62EC352A26766657000E9F2D /* Singleton */ = { isa = PBXGroup; children = ( + 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */, 62EC352B26766675000E9F2D /* ServerEnvironment.swift */, 62EC352E267666A5000E9F2D /* SessionManager.swift */, 536D3D73267BA8170004248C /* BackgroundManager.swift */, @@ -728,6 +758,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 09389CBE26814DF600AE350E /* VideoPlayerStoryboard.storyboard in Resources */, 5358706A2669D21700D05A09 /* Preview Assets.xcassets in Resources */, 535870672669D21700D05A09 /* Assets.xcassets in Resources */, 5358707E2669D64F00D05A09 /* bitrates.json in Resources */, @@ -862,6 +893,7 @@ 53ABFDE9267974EF00886593 /* HomeViewModel.swift in Sources */, 62EC352D26766675000E9F2D /* ServerEnvironment.swift in Sources */, 531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */, + 09389CC126814DF600AE350E /* InfoTabBarViewController.swift in Sources */, 53ABFDDE267974E300886593 /* SplashView.swift in Sources */, 53ABFDE8267974EF00886593 /* SplashViewModel.swift in Sources */, 62E632DE267D2E170063E547 /* LatestMediaViewModel.swift in Sources */, @@ -879,6 +911,7 @@ 62E632F4267D54030063E547 /* DetailItemViewModel.swift in Sources */, 6267B3D826710B9800A7371D /* CollectionExtensions.swift in Sources */, 62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, + 09389CBF26814DF600AE350E /* MediaInfoView.swift in Sources */, 535870A52669D8AE00D05A09 /* ParallaxHeader.swift in Sources */, 531690F0267ABF72005D8AB9 /* NextUpView.swift in Sources */, 535870A72669D8AE00D05A09 /* MultiSelectorView.swift in Sources */, @@ -891,16 +924,21 @@ 5358706C2669D21700D05A09 /* PersistenceController.swift in Sources */, 535870AA2669D8AE00D05A09 /* BlurHashDecode.swift in Sources */, 53ABFDE5267974EF00886593 /* ViewModel.swift in Sources */, + 09389CC526814E4500AE350E /* DeviceProfileBuilder.swift in Sources */, 535870A62669D8AE00D05A09 /* LazyView.swift in Sources */, 5321753E2671DE9C005491E6 /* Typings.swift in Sources */, 53ABFDEB2679753200886593 /* ConnectToServerView.swift in Sources */, + 09389CC026814DF600AE350E /* SubtitlesView.swift in Sources */, 536D3D76267BA9BB0004248C /* MainTabViewModel.swift in Sources */, 536D3D74267BA8170004248C /* BackgroundManager.swift in Sources */, + 09389CC226814DF600AE350E /* VideoPlayer.swift in Sources */, 535870632669D21600D05A09 /* JellyfinPlayer_tvOSApp.swift in Sources */, 53ABFDE4267974EF00886593 /* LibraryListViewModel.swift in Sources */, 5364F456266CA0DC0026ECBA /* APIExtensions.swift in Sources */, + 09389CC326814DF600AE350E /* VideoPlayerViewController.swift in Sources */, 531690FA267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift in Sources */, 535870A32669D89F00D05A09 /* Model.xcdatamodeld in Sources */, + 09389CC426814DF600AE350E /* AudioView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/JellyfinPlayer/DeviceProfileBuilder.swift b/Shared/Singleton/DeviceProfileBuilder.swift similarity index 100% rename from JellyfinPlayer/DeviceProfileBuilder.swift rename to Shared/Singleton/DeviceProfileBuilder.swift