commit
b586ffccc7
|
@ -9,14 +9,13 @@
|
|||
/* Begin PBXBuildFile section */
|
||||
5302F82A2658791C00647A2E /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 5302F8292658791C00647A2E /* Sentry */; };
|
||||
53192D5D265AA78A008A4215 /* DeviceProfileBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */; };
|
||||
5335256E265E8D5A006CCA86 /* VideoPlayerViewRefactored.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5335256D265E8D5A006CCA86 /* VideoPlayerViewRefactored.swift */; };
|
||||
53313B90265EEA6D00947AA3 /* VideoPlayer.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 53313B8F265EEA6D00947AA3 /* VideoPlayer.storyboard */; };
|
||||
53352571265EA0A0006CCA86 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 53352570265EA0A0006CCA86 /* Introspect */; };
|
||||
5338F74E263B61370014BF09 /* ConnectToServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5338F74D263B61370014BF09 /* ConnectToServerView.swift */; };
|
||||
5338F754263B65E10014BF09 /* SwiftyRequest in Frameworks */ = {isa = PBXBuildFile; productRef = 5338F753263B65E10014BF09 /* SwiftyRequest */; };
|
||||
5338F757263B7E2E0014BF09 /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 5338F756263B7E2E0014BF09 /* KeychainSwift */; };
|
||||
535BAE9F2649E569005FA86D /* ItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535BAE9E2649E569005FA86D /* ItemView.swift */; };
|
||||
535BAEA5264A151C005FA86D /* VLCPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535BAEA4264A151C005FA86D /* VLCPlayer.swift */; };
|
||||
535BAEA7264A18AA005FA86D /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535BAEA6264A18AA005FA86D /* VideoPlayerView.swift */; };
|
||||
535BAEA5264A151C005FA86D /* VideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535BAEA4264A151C005FA86D /* VideoPlayer.swift */; };
|
||||
5377CBF5263B596A003A4E83 /* JellyfinPlayerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5377CBF4263B596A003A4E83 /* JellyfinPlayerApp.swift */; };
|
||||
5377CBF7263B596A003A4E83 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5377CBF6263B596A003A4E83 /* ContentView.swift */; };
|
||||
5377CBF9263B596B003A4E83 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5377CBF8263B596B003A4E83 /* Assets.xcassets */; };
|
||||
|
@ -72,11 +71,10 @@
|
|||
|
||||
/* Begin PBXFileReference section */
|
||||
53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceProfileBuilder.swift; sourceTree = "<group>"; };
|
||||
5335256D265E8D5A006CCA86 /* VideoPlayerViewRefactored.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewRefactored.swift; sourceTree = "<group>"; };
|
||||
53313B8F265EEA6D00947AA3 /* VideoPlayer.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = VideoPlayer.storyboard; sourceTree = "<group>"; };
|
||||
5338F74D263B61370014BF09 /* ConnectToServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectToServerView.swift; sourceTree = "<group>"; };
|
||||
535BAE9E2649E569005FA86D /* ItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemView.swift; sourceTree = "<group>"; };
|
||||
535BAEA4264A151C005FA86D /* VLCPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCPlayer.swift; sourceTree = "<group>"; };
|
||||
535BAEA6264A18AA005FA86D /* VideoPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = "<group>"; };
|
||||
535BAEA4264A151C005FA86D /* VideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = "<group>"; };
|
||||
5377CBF1263B596A003A4E83 /* JellyfinPlayer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = JellyfinPlayer.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
5377CBF4263B596A003A4E83 /* JellyfinPlayerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinPlayerApp.swift; sourceTree = "<group>"; };
|
||||
5377CBF6263B596A003A4E83 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
|
@ -169,14 +167,11 @@
|
|||
53E4E648263F725B00F67C6B /* MultiSelector.swift */,
|
||||
535BAE9E2649E569005FA86D /* ItemView.swift */,
|
||||
53A089CF264DA9DA00D57806 /* MovieItemView.swift */,
|
||||
535BAEA4264A151C005FA86D /* VLCPlayer.swift */,
|
||||
535BAEA6264A18AA005FA86D /* VideoPlayerView.swift */,
|
||||
53EE24E5265060780068F029 /* LibrarySearchView.swift */,
|
||||
53987CA326572C1300E7EA70 /* SeasonItemView.swift */,
|
||||
53987CA526572F0700E7EA70 /* SeriesItemView.swift */,
|
||||
53987CA72657424A00E7EA70 /* EpisodeItemView.swift */,
|
||||
53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */,
|
||||
5335256D265E8D5A006CCA86 /* VideoPlayerViewRefactored.swift */,
|
||||
);
|
||||
path = JellyfinPlayer;
|
||||
sourceTree = "<group>";
|
||||
|
@ -200,7 +195,9 @@
|
|||
AE8C3150265D5FE1008AA076 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
535BAEA4264A151C005FA86D /* VideoPlayer.swift */,
|
||||
539B2DA4263BA5B8007FF1A4 /* SettingsView.swift */,
|
||||
53313B8F265EEA6D00947AA3 /* VideoPlayer.storyboard */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
|
@ -308,6 +305,7 @@
|
|||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5377CBFC263B596B003A4E83 /* Preview Assets.xcassets in Resources */,
|
||||
53313B90265EEA6D00947AA3 /* VideoPlayer.storyboard in Resources */,
|
||||
AE8C3159265D6F90008AA076 /* bitrates.json in Resources */,
|
||||
5377CBF9263B596B003A4E83 /* Assets.xcassets in Resources */,
|
||||
);
|
||||
|
@ -328,19 +326,17 @@
|
|||
53192D5D265AA78A008A4215 /* DeviceProfileBuilder.swift in Sources */,
|
||||
AE8C3154265D60BF008AA076 /* SettingsModel.swift in Sources */,
|
||||
53892770263C25230035E14B /* NextUpView.swift in Sources */,
|
||||
535BAEA5264A151C005FA86D /* VLCPlayer.swift in Sources */,
|
||||
535BAEA5264A151C005FA86D /* VideoPlayer.swift in Sources */,
|
||||
5377CC01263B596B003A4E83 /* Model.xcdatamodeld in Sources */,
|
||||
53DF641E263D9C0600A7CD1A /* LibraryView.swift in Sources */,
|
||||
53A089D0264DA9DA00D57806 /* MovieItemView.swift in Sources */,
|
||||
53E4E649263F725B00F67C6B /* MultiSelector.swift in Sources */,
|
||||
535BAEA7264A18AA005FA86D /* VideoPlayerView.swift in Sources */,
|
||||
53E4E647263F6CF100F67C6B /* LibraryFilterView.swift in Sources */,
|
||||
53892777263CBB000035E14B /* JellyApiTypings.swift in Sources */,
|
||||
5377CBF7263B596A003A4E83 /* ContentView.swift in Sources */,
|
||||
53987CA82657424A00E7EA70 /* EpisodeItemView.swift in Sources */,
|
||||
5389277C263CC3DB0035E14B /* BlurHashDecode.swift in Sources */,
|
||||
53987CA626572F0700E7EA70 /* SeriesItemView.swift in Sources */,
|
||||
5335256E265E8D5A006CCA86 /* VideoPlayerViewRefactored.swift in Sources */,
|
||||
539B2DA5263BA5B8007FF1A4 /* SettingsView.swift in Sources */,
|
||||
AE8C3156265D616A008AA076 /* SettingsViewModel.swift in Sources */,
|
||||
5338F74E263B61370014BF09 /* ConnectToServerView.swift in Sources */,
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>SwiftFin</string>
|
||||
<string>Jellyfin SUI</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
|
|
|
@ -16,6 +16,7 @@ class ItemPlayback: ObservableObject {
|
|||
struct ItemView: View {
|
||||
var item: ResumeItem;
|
||||
@StateObject private var playback: ItemPlayback = ItemPlayback()
|
||||
@State private var shouldShowLoadingView: Bool = false;
|
||||
|
||||
init(item: ResumeItem) {
|
||||
self.item = item;
|
||||
|
@ -23,7 +24,17 @@ struct ItemView: View {
|
|||
|
||||
var body: some View {
|
||||
if(playback.shouldPlay) {
|
||||
VideoPlayerViewRefactored(itemPlayback: playback)
|
||||
LoadingView(isShowing: $shouldShowLoadingView) {
|
||||
VLCPlayerWithControls(item: playback.itemToPlay, loadBinding: $shouldShowLoadingView, pBinding: _playback.projectedValue.shouldPlay)
|
||||
.navigationBarHidden(true)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.statusBar(hidden: true)
|
||||
.prefersHomeIndicatorAutoHidden(true)
|
||||
.preferredColorScheme(.dark)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
.overrideViewPreference(.unspecified)
|
||||
.supportedOrientations(.landscape)
|
||||
}
|
||||
} else {
|
||||
Group {
|
||||
if(item.Type == "Movie") {
|
||||
|
|
|
@ -21,7 +21,7 @@ class GlobalData: ObservableObject {
|
|||
|
||||
extension UIDevice {
|
||||
var hasNotch: Bool {
|
||||
let bottom = UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? 0
|
||||
let bottom = UIApplication.shared.windows.filter {$0.isKeyWindow}.first?.safeAreaInsets.bottom ?? 0
|
||||
return bottom > 0
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,98 +0,0 @@
|
|||
//
|
||||
// VideoPlayerView.swift
|
||||
// JellyfinPlayer
|
||||
//
|
||||
// Created by Aiden Vigue on 5/10/21.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import MobileVLCKit
|
||||
|
||||
extension NSNotification {
|
||||
static let PlayerUpdate = NSNotification.Name.init("PlayerUpdate")
|
||||
}
|
||||
|
||||
enum VideoType {
|
||||
case hls;
|
||||
case direct;
|
||||
}
|
||||
|
||||
struct Subtitle {
|
||||
var name: String;
|
||||
var id: Int32;
|
||||
var url: URL;
|
||||
var delivery: String;
|
||||
var codec: String;
|
||||
}
|
||||
|
||||
class PlaybackItem: ObservableObject {
|
||||
@Published var videoType: VideoType = .hls;
|
||||
@Published var videoUrl: URL = URL(string: "https://example.com")!;
|
||||
@Published var subtitles: [Subtitle] = [];
|
||||
}
|
||||
|
||||
struct VLCPlayer: UIViewRepresentable{
|
||||
var url: Binding<PlaybackItem>;
|
||||
var player: Binding<VLCMediaPlayer>;
|
||||
var startTime: Int;
|
||||
|
||||
func updateUIView(_ uiView: PlayerUIView, context: UIViewRepresentableContext<VLCPlayer>) {
|
||||
uiView.url = self.url
|
||||
if(self.url.wrappedValue.videoUrl.absoluteString != "https://example.com") {
|
||||
uiView.videoSetup()
|
||||
}
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> PlayerUIView {
|
||||
return PlayerUIView(frame: .zero, url: url, player: self.player, startTime: self.startTime);
|
||||
}
|
||||
}
|
||||
|
||||
class PlayerUIView: UIView, VLCMediaPlayerDelegate {
|
||||
|
||||
private var mediaPlayer: Binding<VLCMediaPlayer>;
|
||||
var url:Binding<PlaybackItem>
|
||||
var lastUrl: PlaybackItem?
|
||||
var startTime: Int
|
||||
|
||||
init(frame: CGRect, url: Binding<PlaybackItem>, player: Binding<VLCMediaPlayer>, startTime: Int) {
|
||||
self.mediaPlayer = player;
|
||||
self.url = url;
|
||||
self.startTime = startTime;
|
||||
super.init(frame: frame)
|
||||
mediaPlayer.wrappedValue.delegate = self
|
||||
mediaPlayer.wrappedValue.drawable = self
|
||||
}
|
||||
|
||||
func videoSetup() {
|
||||
if(lastUrl == nil || lastUrl?.videoUrl != url.wrappedValue.videoUrl) {
|
||||
lastUrl = url.wrappedValue
|
||||
mediaPlayer.wrappedValue.stop()
|
||||
mediaPlayer.wrappedValue.media = VLCMedia(url: url.wrappedValue.videoUrl)
|
||||
self.url.wrappedValue.subtitles.forEach() { sub in
|
||||
if(sub.id != -1 && sub.delivery == "External" && sub.codec != "subrip") {
|
||||
mediaPlayer.wrappedValue.addPlaybackSlave(sub.url, type: .subtitle, enforce: false)
|
||||
}
|
||||
}
|
||||
|
||||
mediaPlayer.wrappedValue.perform(Selector(("setTextRendererFontSize:")), with: 14)
|
||||
//mediaPlayer.wrappedValue.perform(Selector(("setTextRendererFont:")), with: "Copperplate")
|
||||
|
||||
DispatchQueue.global(qos: .utility).async { [weak self] in
|
||||
self?.mediaPlayer.wrappedValue.play()
|
||||
if(self?.startTime != 0) {
|
||||
print(self?.startTime ?? "")
|
||||
self?.mediaPlayer.wrappedValue.jumpForward(Int32(self!.startTime/10000000))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
}
|
||||
}
|
|
@ -1,585 +0,0 @@
|
|||
//
|
||||
// VideoPlayerView.swift
|
||||
// JellyfinPlayer
|
||||
//
|
||||
// Created by Aiden Vigue on 5/10/21.
|
||||
//
|
||||
/*
|
||||
import SwiftUI
|
||||
import SwiftyJSON
|
||||
import SwiftyRequest
|
||||
import AVKit
|
||||
import MobileVLCKit
|
||||
import Foundation
|
||||
import NotificationCenter
|
||||
|
||||
struct Subtitle {
|
||||
var name: String;
|
||||
var id: Int32;
|
||||
var url: URL;
|
||||
var delivery: String;
|
||||
var codec: String;
|
||||
}
|
||||
|
||||
extension String {
|
||||
public func leftPad(toWidth width: Int, withString string: String?) -> String {
|
||||
let paddingString = string ?? " "
|
||||
|
||||
if self.count >= width {
|
||||
return self
|
||||
}
|
||||
|
||||
let remainingLength: Int = width - self.count
|
||||
var padString = String()
|
||||
for _ in 0 ..< remainingLength {
|
||||
padString += paddingString
|
||||
}
|
||||
|
||||
return "\(padString)\(self)"
|
||||
}
|
||||
}
|
||||
|
||||
struct VideoPlayerView: View {
|
||||
@EnvironmentObject var globalData: GlobalData
|
||||
@State private var pbitem: PlaybackItem = PlaybackItem(videoType: VideoType.direct, videoUrl: URL(string: "https://example.com")!, subtitles: []);
|
||||
@State private var streamLoading = false;
|
||||
@State private var vlcplayer: VLCMediaPlayer = VLCMediaPlayer();
|
||||
@State private var isPlaying = false;
|
||||
@State private var subtitles: [Subtitle] = [];
|
||||
@State private var audioTracks: [Subtitle] = [];
|
||||
@State private var inactivity: Bool = true;
|
||||
@State private var lastActivityTime: Double = 0;
|
||||
@State private var scrub: Double = 0;
|
||||
@State private var timeText: String = "-:--:--";
|
||||
@State private var playPauseButtonSystemName: String = "pause";
|
||||
@State private var playSessionId: String = "";
|
||||
@State private var lastPosition: Double = 0;
|
||||
@State private var iterations: Int = 0;
|
||||
@State private var startTime: Int = 0;
|
||||
@State private var hasSentPlayReport: Bool = false;
|
||||
@State private var selectedVideoQuality: Int = 0;
|
||||
@State private var captionConfiguration: Bool = false {
|
||||
didSet {
|
||||
if(captionConfiguration == false) {
|
||||
DispatchQueue.global(qos: .userInitiated).async { [self] in
|
||||
vlcplayer.pause()
|
||||
usleep(10000);
|
||||
vlcplayer.play()
|
||||
usleep(10000);
|
||||
vlcplayer.pause()
|
||||
usleep(10000);
|
||||
vlcplayer.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@State private var playbackSettings: Bool = false;
|
||||
@State private var selectedCaptionTrack: Int32 = -1;
|
||||
@State private var selectedAudioTrack: Int32 = -1;
|
||||
|
||||
var playing: Binding<Bool>;
|
||||
var item: DetailItem;
|
||||
|
||||
init(item: DetailItem, playing: Binding<Bool>) {
|
||||
self.item = item;
|
||||
self.playing = playing;
|
||||
}
|
||||
|
||||
@State var lastProgressReportSent: Double = CACurrentMediaTime()
|
||||
|
||||
func keepUpWithPlayerState() {
|
||||
if(!vlcplayer.isPlaying) {
|
||||
while(!vlcplayer.isPlaying) {}
|
||||
}
|
||||
|
||||
sendProgressReport(eventName: "unpause")
|
||||
|
||||
while(vlcplayer.state != VLCMediaPlayerState.stopped) {
|
||||
_streamLoading.wrappedValue = false;
|
||||
while(vlcplayer.isPlaying) {
|
||||
vlcplayer.currentVideoSubTitleIndex = _selectedCaptionTrack.wrappedValue;
|
||||
usleep(500000)
|
||||
if(CACurrentMediaTime() - lastProgressReportSent > 10) {
|
||||
sendProgressReport(eventName: "timeupdate")
|
||||
_lastProgressReportSent.wrappedValue = CACurrentMediaTime()
|
||||
}
|
||||
if(vlcplayer.time.intValue != 0) {
|
||||
_scrub.wrappedValue = Double(Double(vlcplayer.time.intValue) / Double(vlcplayer.time.intValue + abs(vlcplayer.remainingTime.intValue)));
|
||||
|
||||
//Turn remainingTime into text
|
||||
let remainingTime = abs(vlcplayer.remainingTime.intValue)/1000;
|
||||
let hours = remainingTime / 3600;
|
||||
let minutes = (remainingTime % 3600) / 60;
|
||||
let seconds = (remainingTime % 3600) % 60;
|
||||
if(hours != 0) {
|
||||
timeText = "\(Int(hours)):\(String(Int((minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int((seconds))).leftPad(toWidth: 2, withString: "0"))";
|
||||
} else {
|
||||
timeText = "\(String(Int((minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int((seconds))).leftPad(toWidth: 2, withString: "0"))";
|
||||
}
|
||||
}
|
||||
if(CACurrentMediaTime() - _lastActivityTime.wrappedValue > 5 && vlcplayer.state != VLCMediaPlayerState.paused) {
|
||||
_inactivity.wrappedValue = true
|
||||
}
|
||||
if((lastPosition == Double(vlcplayer.position) && vlcplayer.state != VLCMediaPlayerState.paused)) {
|
||||
if(iterations > 5) {
|
||||
_iterations.wrappedValue = 0;
|
||||
_streamLoading.wrappedValue = true;
|
||||
}
|
||||
_iterations.wrappedValue+=1;
|
||||
} else {
|
||||
_iterations.wrappedValue = 0;
|
||||
_streamLoading.wrappedValue = false;
|
||||
}
|
||||
if(vlcplayer.state == VLCMediaPlayerState.error) {
|
||||
playing.wrappedValue = false;
|
||||
}
|
||||
_lastPosition.wrappedValue = Double(vlcplayer.position)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendProgressReport(eventName: String) {
|
||||
var progressBody: String = "";
|
||||
if(pbitem.videoType == VideoType.direct) {
|
||||
progressBody = "{\"VolumeLevel\":100,\"IsMuted\":false,\"IsPaused\":\(vlcplayer.state == VLCMediaPlayerState.paused ? "true" : "false"),\"RepeatMode\":\"RepeatNone\",\"ShuffleMode\":\"Sorted\",\"MaxStreamingBitrate\":120000000,\"PositionTicks\":\(Int(vlcplayer.position * Float(item.RuntimeTicks))),\"PlaybackStartTimeTicks\":\(startTime),\"AudioStreamIndex\":\(selectedAudioTrack),\"BufferedRanges\":[{\"start\":0,\"end\":569735888.888889}],\"PlayMethod\":\"DirectStream\",\"PlaySessionId\":\"\(playSessionId)\",\"PlaylistItemId\":\"playlistItem0\",\"MediaSourceId\":\"\(item.Id)\",\"CanSeek\":true,\"ItemId\":\"\(item.Id)\",\"EventName\":\"\(eventName)\"}";
|
||||
} else {
|
||||
progressBody = "{\"VolumeLevel\":100,\"IsMuted\":false,\"IsPaused\":\(vlcplayer.state == VLCMediaPlayerState.paused ? "true" : "false"),\"RepeatMode\":\"RepeatNone\",\"ShuffleMode\":\"Sorted\",\"MaxStreamingBitrate\":120000000,\"PositionTicks\":\(Int(vlcplayer.position * Float(item.RuntimeTicks))),\"PlaybackStartTimeTicks\":\(startTime),\"AudioStreamIndex\":\(selectedAudioTrack),\"BufferedRanges\":[{\"start\":0,\"end\":569735888.888889}],\"PlayMethod\":\"Transcode\",\"PlaySessionId\":\"\(playSessionId)\",\"PlaylistItemId\":\"playlistItem0\",\"MediaSourceId\":\"\(item.Id)\",\"CanSeek\":true,\"ItemId\":\"\(item.Id)\",\"EventName\":\"\(eventName)\"}";
|
||||
}
|
||||
|
||||
print("");
|
||||
print("Sending progress report")
|
||||
print(progressBody)
|
||||
|
||||
let request = RestRequest(method: .post, url: (globalData.server?.baseURI ?? "") + "/Sessions/Playing/Progress")
|
||||
request.headerParameters["X-Emby-Authorization"] = globalData.authHeader
|
||||
request.contentType = "application/json"
|
||||
request.acceptType = "application/json"
|
||||
request.messageBody = progressBody.data(using: .ascii);
|
||||
request.responseData() { (result: Result<RestResponse<Data>, RestError>) in
|
||||
switch result {
|
||||
case .success(let resp):
|
||||
print(resp.body)
|
||||
break
|
||||
case .failure(let error):
|
||||
debugPrint(error)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendStopReport() {
|
||||
var progressBody: String = "";
|
||||
if(pbitem.videoType == VideoType.direct) {
|
||||
progressBody = "{\"VolumeLevel\":100,\"IsMuted\":false,\"IsPaused\":true,\"RepeatMode\":\"RepeatNone\",\"ShuffleMode\":\"Sorted\",\"MaxStreamingBitrate\":120000000,\"PositionTicks\":\(Int(vlcplayer.position * Float(item.RuntimeTicks))),\"PlaybackStartTimeTicks\":\(startTime),\"AudioStreamIndex\":\(selectedAudioTrack),\"BufferedRanges\":[],\"PlayMethod\":\"DirectStream\",\"PlaySessionId\":\"\(playSessionId)\",\"PlaylistItemId\":\"playlistItem0\",\"MediaSourceId\":\"\(item.Id)\",\"CanSeek\":true,\"ItemId\":\"\(item.Id)\",\"NowPlayingQueue\":[{\"Id\":\"\(item.Id)\",\"PlaylistItemId\":\"playlistItem0\"}]}";
|
||||
} else {
|
||||
progressBody = "{\"VolumeLevel\":100,\"IsMuted\":false,\"IsPaused\":true,\"RepeatMode\":\"RepeatNone\",\"ShuffleMode\":\"Sorted\",\"MaxStreamingBitrate\":120000000,\"PositionTicks\":\(Int(vlcplayer.position * Float(item.RuntimeTicks))),\"PlaybackStartTimeTicks\":\(startTime),\"AudioStreamIndex\":\(selectedAudioTrack),\"BufferedRanges\":[{\"start\":0,\"end\":100000}],\"PlayMethod\":\"Transcode\",\"PlaySessionId\":\"\(playSessionId)\",\"PlaylistItemId\":\"playlistItem0\",\"MediaSourceId\":\"\(item.Id)\",\"CanSeek\":true,\"ItemId\":\"\(item.Id)\",\"NowPlayingQueue\":[{\"Id\":\"\(item.Id)\",\"PlaylistItemId\":\"playlistItem0\"}]}";
|
||||
}
|
||||
|
||||
print("");
|
||||
print("Sending stop report")
|
||||
print(progressBody)
|
||||
|
||||
let request = RestRequest(method: .post, url: (globalData.server?.baseURI ?? "") + "/Sessions/Playing/Stopped")
|
||||
request.headerParameters["X-Emby-Authorization"] = globalData.authHeader
|
||||
request.contentType = "application/json"
|
||||
request.acceptType = "application/json"
|
||||
request.messageBody = progressBody.data(using: .ascii);
|
||||
request.responseData() { (result: Result<RestResponse<Data>, RestError>) in
|
||||
switch result {
|
||||
case .success(let resp):
|
||||
print(resp.body)
|
||||
break
|
||||
case .failure(let error):
|
||||
debugPrint(error)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendPlayReport() {
|
||||
var progressBody: String = "";
|
||||
_startTime.wrappedValue = Int(Date().timeIntervalSince1970) * 10000000
|
||||
if(pbitem.videoType == VideoType.hls) {
|
||||
progressBody = "{\"VolumeLevel\":100,\"IsMuted\":false,\"IsPaused\":false,\"RepeatMode\":\"RepeatNone\",\"ShuffleMode\":\"Sorted\",\"MaxStreamingBitrate\":120000000,\"PositionTicks\":\(Int(item.Progress)),\"PlaybackStartTimeTicks\":\(startTime),\"AudioStreamIndex\":\(selectedAudioTrack),\"BufferedRanges\":[],\"PlayMethod\":\"Transcode\",\"PlaySessionId\":\"\(playSessionId)\",\"PlaylistItemId\":\"playlistItem0\",\"MediaSourceId\":\"\(item.Id)\",\"CanSeek\":true,\"ItemId\":\"\(item.Id)\",\"NowPlayingQueue\":[{\"Id\":\"\(item.Id)\",\"PlaylistItemId\":\"playlistItem0\"}]}";
|
||||
} else {
|
||||
progressBody = "{\"VolumeLevel\":100,\"IsMuted\":false,\"IsPaused\":false,\"RepeatMode\":\"RepeatNone\",\"ShuffleMode\":\"Sorted\",\"MaxStreamingBitrate\":120000000,\"PositionTicks\":\(Int(item.Progress)),\"PlaybackStartTimeTicks\":\(startTime),\"AudioStreamIndex\":\(selectedAudioTrack),\"BufferedRanges\":[],\"PlayMethod\":\"DirectStream\",\"PlaySessionId\":\"\(playSessionId)\",\"PlaylistItemId\":\"playlistItem0\",\"MediaSourceId\":\"\(item.Id)\",\"CanSeek\":true,\"ItemId\":\"\(item.Id)\",\"NowPlayingQueue\":[{\"Id\":\"\(item.Id)\",\"PlaylistItemId\":\"playlistItem0\"}]}";
|
||||
}
|
||||
|
||||
print("");
|
||||
print("Sending play report")
|
||||
print(progressBody)
|
||||
|
||||
let request = RestRequest(method: .post, url: (globalData.server?.baseURI ?? "") + "/Sessions/Playing")
|
||||
request.headerParameters["X-Emby-Authorization"] = globalData.authHeader
|
||||
request.contentType = "application/json"
|
||||
request.acceptType = "application/json"
|
||||
request.messageBody = progressBody.data(using: .ascii);
|
||||
request.responseData() { (result: Result<RestResponse<Data>, RestError>) in
|
||||
switch result {
|
||||
case .success(let resp):
|
||||
print(resp.body)
|
||||
break
|
||||
case .failure(let error):
|
||||
debugPrint(error)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startStream() {
|
||||
|
||||
let builder = DeviceProfileBuilder()
|
||||
|
||||
let defaults = UserDefaults.standard;
|
||||
if(globalData.isInNetwork) {
|
||||
builder.setMaxBitrate(bitrate: defaults.integer(forKey: "InNetworkBandwidth"))
|
||||
} else {
|
||||
builder.setMaxBitrate(bitrate: defaults.integer(forKey: "OutOfNetworkBandwidth"))
|
||||
}
|
||||
print(builder.bitrate)
|
||||
_selectedVideoQuality.wrappedValue = builder.bitrate;
|
||||
|
||||
let DeviceProfile = builder.buildProfile()
|
||||
|
||||
let jsonEncoder = JSONEncoder()
|
||||
let jsonData = try! jsonEncoder.encode(DeviceProfile)
|
||||
let jsonString = String(data: jsonData, encoding: .ascii)!
|
||||
print(jsonString)
|
||||
|
||||
_streamLoading.wrappedValue = true;
|
||||
let url = (globalData.server?.baseURI ?? "") + "/Items/\(item.Id)/PlaybackInfo?UserId=\(globalData.user?.user_id ?? "")&StartTimeTicks=\(Int(item.Progress))&IsPlayback=true&AutoOpenLiveStream=true&MaxStreamingBitrate=\(DeviceProfile.DeviceProfile.MaxStreamingBitrate)";
|
||||
print(url)
|
||||
|
||||
let request = RestRequest(method: .post, url: url)
|
||||
|
||||
request.headerParameters["X-Emby-Authorization"] = globalData.authHeader
|
||||
request.contentType = "application/json"
|
||||
request.acceptType = "application/json"
|
||||
request.messageBody = jsonString.data(using: .ascii)
|
||||
|
||||
request.responseData() { (result: Result<RestResponse<Data>, RestError>) in
|
||||
switch result {
|
||||
case .success(let response):
|
||||
let body = response.body
|
||||
do {
|
||||
let json = try JSON(data: body)
|
||||
_playSessionId.wrappedValue = json["PlaySessionId"].string ?? "";
|
||||
if(json["MediaSources"][0]["TranscodingUrl"].string != nil) {
|
||||
print("Transcoding!")
|
||||
let streamURL: URL = URL(string: "\(globalData.server?.baseURI ?? "")\((json["MediaSources"][0]["TranscodingUrl"].string ?? ""))")!
|
||||
print(streamURL)
|
||||
let item = PlaybackItem(videoType: VideoType.hls, videoUrl: streamURL, subtitles: [])
|
||||
let disableSubtitleTrack = Subtitle(name: "Disabled", id: -1, url: URL(string: "https://example.com")!, delivery: "Embed", codec: "")
|
||||
_subtitles.wrappedValue.append(disableSubtitleTrack);
|
||||
for (_,stream):(String, JSON) in json["MediaSources"][0]["MediaStreams"] {
|
||||
if(stream["Type"].string == "Subtitle") { //ignore ripped subtitles - we don't want to extract subtitles
|
||||
let deliveryUrl = URL(string: "\(globalData.server?.baseURI ?? "")\(stream["DeliveryUrl"].string ?? "")")!
|
||||
let subtitle = Subtitle(name: stream["DisplayTitle"].string ?? "", id: Int32(stream["Index"].int ?? 0), url: deliveryUrl, delivery: stream["DeliveryMethod"].string ?? "", codec: stream["Codec"].string ?? "")
|
||||
_subtitles.wrappedValue.append(subtitle);
|
||||
}
|
||||
|
||||
if(stream["Type"].string == "Audio") {
|
||||
let deliveryUrl = URL(string: "https://example.com")!
|
||||
let subtitle = Subtitle(name: stream["DisplayTitle"].string ?? "", id: Int32(stream["Index"].int ?? 0), url: deliveryUrl, delivery: stream["IsExternal"].boolValue ? "External" : "Embed", codec: stream["Codec"].string ?? "")
|
||||
if(stream["IsDefault"].boolValue) {
|
||||
_selectedAudioTrack.wrappedValue = Int32(stream["Index"].int ?? 0);
|
||||
}
|
||||
_audioTracks.wrappedValue.append(subtitle);
|
||||
}
|
||||
}
|
||||
|
||||
if(_selectedAudioTrack.wrappedValue == -1) {
|
||||
if(_audioTracks.wrappedValue.count > 0) {
|
||||
_selectedAudioTrack.wrappedValue = _audioTracks.wrappedValue[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
let streamUrl = streamURL.absoluteString;
|
||||
let segmentUrl = URL(string: streamUrl.replacingOccurrences(of: "master.m3u8", with: "hls1/main/0.ts"))!
|
||||
var request2 = URLRequest(url: segmentUrl)
|
||||
|
||||
request2.httpMethod = "GET"
|
||||
let task = URLSession.shared.dataTask(with: request2) { (data, response2, error) in
|
||||
DispatchQueue.global(qos: .utility).async { [self] in
|
||||
self.sendPlayReport()
|
||||
pbitem = item;
|
||||
pbitem.subtitles = subtitles;
|
||||
_isPlaying.wrappedValue = true;
|
||||
}
|
||||
}
|
||||
task.resume()
|
||||
} else {
|
||||
print("Direct playing!");
|
||||
let streamURL: URL = URL(string: "\(globalData.server?.baseURI ?? "")/Videos/\(item.Id)/stream?Static=true&mediaSourceId=\(item.Id)&deviceId=\(globalData.user?.device_uuid ?? "")&api_key=\(globalData.authToken)&Tag=\(json["MediaSources"][0]["ETag"])")!;
|
||||
let item = PlaybackItem(videoType: VideoType.direct, videoUrl: streamURL, subtitles: [])
|
||||
let disableSubtitleTrack = Subtitle(name: "Disabled", id: -1, url: URL(string: "https://example.com")!, delivery: "Embed", codec: "")
|
||||
_subtitles.wrappedValue.append(disableSubtitleTrack);
|
||||
for (_,stream):(String, JSON) in json["MediaSources"][0]["MediaStreams"] {
|
||||
if(stream["Type"].string == "Subtitle") {
|
||||
let deliveryUrl = URL(string: "\(globalData.server?.baseURI ?? "")\(stream["DeliveryUrl"].string ?? "")")!
|
||||
let subtitle = Subtitle(name: stream["DisplayTitle"].string ?? "", id: Int32(stream["Index"].int ?? 0), url: deliveryUrl, delivery: stream["DeliveryMethod"].string ?? "", codec: stream["Codec"].string ?? "")
|
||||
_subtitles.wrappedValue.append(subtitle);
|
||||
}
|
||||
|
||||
if(stream["Type"].string == "Audio") {
|
||||
let deliveryUrl = URL(string: "https://example.com")!
|
||||
let subtitle = Subtitle(name: stream["DisplayTitle"].string ?? "", id: Int32(stream["Index"].int ?? 0), url: deliveryUrl, delivery: stream["IsExternal"].boolValue ? "External" : "Embed", codec: stream["Codec"].string ?? "")
|
||||
if(stream["IsDefault"].boolValue) {
|
||||
_selectedAudioTrack.wrappedValue = Int32(stream["Index"].int ?? 0);
|
||||
}
|
||||
_audioTracks.wrappedValue.append(subtitle);
|
||||
}
|
||||
}
|
||||
|
||||
if(_selectedAudioTrack.wrappedValue == -1) {
|
||||
_selectedAudioTrack.wrappedValue = _audioTracks.wrappedValue[0].id;
|
||||
}
|
||||
|
||||
sendPlayReport()
|
||||
pbitem = item;
|
||||
pbitem.subtitles = subtitles;
|
||||
_isPlaying.wrappedValue = true;
|
||||
}
|
||||
|
||||
DispatchQueue.global(qos: .utility).async { [self] in
|
||||
self.keepUpWithPlayerState()
|
||||
}
|
||||
} catch {
|
||||
|
||||
}
|
||||
break
|
||||
case .failure(let error):
|
||||
debugPrint(error)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func processScrubbingState() {
|
||||
let videoDuration = Double(vlcplayer.time.intValue + abs(vlcplayer.remainingTime.intValue))/1000
|
||||
while(vlcplayer.state != VLCMediaPlayerState.paused) {}
|
||||
while(vlcplayer.state == VLCMediaPlayerState.paused) {
|
||||
let secondsScrubbedTo = round(_scrub.wrappedValue * videoDuration);
|
||||
let scrubRemaining = videoDuration - secondsScrubbedTo;
|
||||
usleep(100000)
|
||||
let remainingTime = scrubRemaining;
|
||||
let hours = floor(remainingTime / 3600);
|
||||
let minutes = (remainingTime.truncatingRemainder(dividingBy: 3600)) / 60;
|
||||
let seconds = (remainingTime.truncatingRemainder(dividingBy: 3600)).truncatingRemainder(dividingBy: 60);
|
||||
if(hours != 0) {
|
||||
timeText = "\(Int(hours)):\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))";
|
||||
} else {
|
||||
timeText = "\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resetTimer() {
|
||||
print("resetTimer ran")
|
||||
if(_inactivity.wrappedValue == false) {
|
||||
_inactivity.wrappedValue = true;
|
||||
return;
|
||||
}
|
||||
_lastActivityTime.wrappedValue = CACurrentMediaTime()
|
||||
_inactivity.wrappedValue = false;
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
LoadingView(isShowing: ($streamLoading)) {
|
||||
VLCPlayer(url: $pbitem, player: $vlcplayer, startTime: Int(item.Progress)).onDisappear(perform: {
|
||||
_isPlaying.wrappedValue = false;
|
||||
vlcplayer.stop()
|
||||
}).padding(EdgeInsets(top: 0, leading: UIDevice.current.hasNotch ? 30 : 0, bottom: 0, trailing: UIDevice.current.hasNotch ? 30 : 0))
|
||||
}
|
||||
.overlay(
|
||||
VStack() {
|
||||
HStack() {
|
||||
HStack() {
|
||||
Button() {
|
||||
sendStopReport()
|
||||
self.playing.wrappedValue = false;
|
||||
} label: {
|
||||
HStack() {
|
||||
Image(systemName: "chevron.left").font(.system(size: 20)).foregroundColor(.white)
|
||||
}
|
||||
}.frame(width: 20)
|
||||
Spacer()
|
||||
Text(item.Name).font(.headline).fontWeight(.semibold).foregroundColor(.white).offset(x:20)
|
||||
Spacer()
|
||||
Button() {
|
||||
vlcplayer.pause()
|
||||
self.playbackSettings = true;
|
||||
} label: {
|
||||
HStack() {
|
||||
Image(systemName: "gear").font(.system(size: 20)).foregroundColor(.white)
|
||||
}
|
||||
}.frame(width: 20).padding(.trailing,15)
|
||||
Button() {
|
||||
vlcplayer.pause()
|
||||
self.captionConfiguration = true;
|
||||
} label: {
|
||||
HStack() {
|
||||
Image(systemName: "captions.bubble").font(.system(size: 20)).foregroundColor(.white)
|
||||
}
|
||||
}.frame(width: 20)
|
||||
}
|
||||
Spacer()
|
||||
}.padding(EdgeInsets(top: 55, leading: 40, bottom: 0, trailing: 40))
|
||||
Spacer()
|
||||
HStack() {
|
||||
Spacer()
|
||||
Button() {
|
||||
vlcplayer.jumpBackward(15)
|
||||
} label: {
|
||||
Image(systemName: "gobackward.15").font(.system(size: 40)).foregroundColor(.white)
|
||||
}.padding(20)
|
||||
Spacer()
|
||||
Button() {
|
||||
if(vlcplayer.state != VLCMediaPlayerState.paused) {
|
||||
vlcplayer.pause()
|
||||
playPauseButtonSystemName = "play"
|
||||
sendProgressReport(eventName: "pause")
|
||||
} else {
|
||||
vlcplayer.play()
|
||||
playPauseButtonSystemName = "pause"
|
||||
sendProgressReport(eventName: "unpause")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: playPauseButtonSystemName).font(.system(size: 55)).foregroundColor(.white)
|
||||
}.padding(20).frame(width: 60, height: 60)
|
||||
Spacer()
|
||||
Button() {
|
||||
vlcplayer.jumpForward(15)
|
||||
} label: {
|
||||
Image(systemName: "goforward.15").font(.system(size: 40)).foregroundColor(.white)
|
||||
}.padding(20)
|
||||
Spacer()
|
||||
}.padding(.leading, -20)
|
||||
Spacer()
|
||||
HStack() {
|
||||
Slider(value: $scrub, onEditingChanged: { bool in
|
||||
let videoPosition = Double(vlcplayer.time.intValue)
|
||||
let videoDuration = Double(vlcplayer.time.intValue + abs(vlcplayer.remainingTime.intValue))
|
||||
if(bool == true) {
|
||||
vlcplayer.pause()
|
||||
sendProgressReport(eventName: "pause")
|
||||
DispatchQueue.global(qos: .utility).async { [self] in
|
||||
self.processScrubbingState()
|
||||
}
|
||||
} else {
|
||||
//Scrub is value from 0..1 - find position in video and add / or remove.
|
||||
let secondsScrubbedTo = round(_scrub.wrappedValue * videoDuration);
|
||||
let offset = secondsScrubbedTo - videoPosition;
|
||||
sendProgressReport(eventName: "unpause")
|
||||
vlcplayer.play()
|
||||
if(offset > 0) {
|
||||
vlcplayer.jumpForward(Int32(offset)/1000);
|
||||
} else {
|
||||
vlcplayer.jumpBackward(Int32(abs(offset))/1000);
|
||||
}
|
||||
}
|
||||
})
|
||||
.accentColor(Color(red: 172/255, green: 92/255, blue: 195/255))
|
||||
Text(timeText).fontWeight(.semibold).frame(width: 80).foregroundColor(.white)
|
||||
}.padding(EdgeInsets(top: -20, leading: 44, bottom: 42, trailing: 40))
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(.black).opacity(0.4))
|
||||
, alignment: .topLeading)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
|
||||
.onAppear(perform: startStream)
|
||||
.navigationBarHidden(true)
|
||||
.overrideViewPreference(.dark)
|
||||
.preferredColorScheme(.dark)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
.withHostingWindow { window in
|
||||
if let vc = window?.rootViewController {
|
||||
let preferenceHost = vc as! PreferenceUIHostingController
|
||||
preferenceHost._viewPreference = .dark
|
||||
}
|
||||
}
|
||||
.statusBar(hidden: true)
|
||||
.onTapGesture(perform: resetTimer)
|
||||
.fullScreenCover(isPresented: self.$captionConfiguration) {
|
||||
NavigationView() {
|
||||
VStack() {
|
||||
Form() {
|
||||
Picker("Closed Captions", selection: $selectedCaptionTrack) {
|
||||
ForEach(subtitles, id: \.id) { caption in
|
||||
Text(caption.name).tag(caption.id)
|
||||
}
|
||||
}.onChange(of: selectedCaptionTrack) { track in
|
||||
vlcplayer.currentVideoSubTitleIndex = track;
|
||||
}
|
||||
Picker("Audio Track", selection: $selectedAudioTrack) {
|
||||
ForEach(audioTracks, id: \.id) { caption in
|
||||
Text(caption.name).tag(caption.id)
|
||||
}
|
||||
}.onChange(of: selectedAudioTrack) { track in
|
||||
vlcplayer.currentAudioTrackIndex = track;
|
||||
}
|
||||
}
|
||||
Text("Subtitles may take a few moments to appear once selected.")
|
||||
.font(.callout)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.navigationBarTitle("Audio & Captions", displayMode: .inline)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .navigationBarLeading) {
|
||||
Button {
|
||||
captionConfiguration = false;
|
||||
playPauseButtonSystemName = "pause";
|
||||
} label: {
|
||||
HStack() {
|
||||
Text("Back").font(.callout)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.edgesIgnoringSafeArea(.bottom)
|
||||
}
|
||||
EmptyView()
|
||||
.fullScreenCover(isPresented: $playbackSettings) {
|
||||
NavigationView() {
|
||||
Form() {
|
||||
Picker("Quality", selection: $selectedVideoQuality) {
|
||||
Group {
|
||||
Text("1080p - 60 Mbps").tag(60000000)
|
||||
Text("1080p - 40 Mbps").tag(40000000)
|
||||
Text("1080p - 20 Mbps").tag(20000000)
|
||||
Text("1080p - 15 Mbps").tag(15000000)
|
||||
Text("1080p - 10 Mbps").tag(10000000)
|
||||
}
|
||||
Group {
|
||||
Text("720p - 8 Mbps").tag(8000000)
|
||||
Text("720p - 6 Mbps").tag(6000000)
|
||||
Text("720p - 4 Mbps").tag(4000000)
|
||||
}
|
||||
Text("480p - 3 Mbps").tag(3000000)
|
||||
Text("480p - 1.5 Mbps").tag(2000000)
|
||||
Text("480p - 740 Kbps").tag(1000000)
|
||||
}.onChange(of: selectedVideoQuality) { quality in
|
||||
print(quality)
|
||||
}
|
||||
}
|
||||
.navigationBarTitle("Playback Settings", displayMode: .inline)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .navigationBarLeading) {
|
||||
Button {
|
||||
playbackSettings = false;
|
||||
playPauseButtonSystemName = "pause";
|
||||
} label: {
|
||||
HStack() {
|
||||
Text("Back").font(.callout)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.edgesIgnoringSafeArea(.bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
|
@ -1,358 +0,0 @@
|
|||
//
|
||||
// VideoPlayerViewRefactored.swift
|
||||
// JellyfinPlayer
|
||||
//
|
||||
// Created by Aiden Vigue on 5/26/21.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import MobileVLCKit
|
||||
import Introspect
|
||||
import SwiftyJSON
|
||||
import SwiftyRequest
|
||||
|
||||
struct VideoPlayerViewRefactored: View {
|
||||
@EnvironmentObject private var globalData: GlobalData;
|
||||
|
||||
@State private var shouldShowLoadingView: Bool = true;
|
||||
@State private var itemPlayback: ItemPlayback;
|
||||
|
||||
@State private var VLCPlayerObj = VLCMediaPlayer()
|
||||
|
||||
@State private var scrub: Double = 0; // storage value for scrubbing
|
||||
@State private var timeText: String = "-:--:--"; //shows time text on play overlay
|
||||
@State private var startTime: Int = 0; //ticks since 1970
|
||||
@State private var selectedAudioTrack: Int32 = 0;
|
||||
@State private var selectedCaptionTrack: Int32 = 0;
|
||||
@State private var playSessionId: String = "";
|
||||
@State private var shouldOverlayShow: Bool = false;
|
||||
|
||||
@State private var subtitles: [Subtitle] = [];
|
||||
@State private var audioTracks: [Subtitle] = []; // can reuse the same struct
|
||||
|
||||
@State private var VLCItem: PlaybackItem = PlaybackItem();
|
||||
|
||||
init(itemPlayback: ItemPlayback) {
|
||||
self.itemPlayback = itemPlayback
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
LoadingView(isShowing: $shouldShowLoadingView) {
|
||||
VLCPlayer(url: $VLCItem, player: $VLCPlayerObj, startTime: Int(itemPlayback.itemToPlay.Progress)).onDisappear(perform: {
|
||||
VLCPlayerObj.stop()
|
||||
})
|
||||
.padding(EdgeInsets(top: 0, leading: UIDevice.current.hasNotch ? 30 : 0, bottom: 0, trailing: UIDevice.current.hasNotch ? 30 : 0))
|
||||
}
|
||||
.overlay(
|
||||
Group {
|
||||
if(shouldOverlayShow) {
|
||||
VStack() {
|
||||
HStack() {
|
||||
HStack() {
|
||||
Button() {
|
||||
sendStopReport()
|
||||
self.itemPlayback.shouldPlay = false;
|
||||
} label: {
|
||||
HStack() {
|
||||
Image(systemName: "chevron.left").font(.system(size: 20)).foregroundColor(.white)
|
||||
}
|
||||
}.frame(width: 20)
|
||||
Spacer()
|
||||
Text(itemPlayback.itemToPlay.Name).font(.headline).fontWeight(.semibold).foregroundColor(.white).offset(x:20)
|
||||
Spacer()
|
||||
Button() {
|
||||
VLCPlayerObj.pause()
|
||||
} label: {
|
||||
HStack() {
|
||||
Image(systemName: "gear").font(.system(size: 20)).foregroundColor(.white)
|
||||
}
|
||||
}.frame(width: 20).padding(.trailing,15)
|
||||
Button() {
|
||||
VLCPlayerObj.pause()
|
||||
} label: {
|
||||
HStack() {
|
||||
Image(systemName: "captions.bubble").font(.system(size: 20)).foregroundColor(.white)
|
||||
}
|
||||
}.frame(width: 20)
|
||||
}
|
||||
Spacer()
|
||||
}.padding(EdgeInsets(top: 55, leading: 40, bottom: 0, trailing: 40))
|
||||
Spacer()
|
||||
HStack() {
|
||||
Spacer()
|
||||
Button() {
|
||||
VLCPlayerObj.jumpBackward(15)
|
||||
} label: {
|
||||
Image(systemName: "gobackward.15").font(.system(size: 40)).foregroundColor(.white)
|
||||
}.padding(20)
|
||||
Spacer()
|
||||
Button() {
|
||||
if(VLCPlayerObj.state != .paused) {
|
||||
VLCPlayerObj.pause()
|
||||
sendProgressReport(eventName: "pause")
|
||||
} else {
|
||||
VLCPlayerObj.play()
|
||||
sendProgressReport(eventName: "unpause")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: VLCPlayerObj.state == .paused ? "play" : "pause").font(.system(size: 55)).foregroundColor(.white)
|
||||
}.padding(20).frame(width: 60, height: 60)
|
||||
Spacer()
|
||||
Button() {
|
||||
VLCPlayerObj.jumpForward(15)
|
||||
} label: {
|
||||
Image(systemName: "goforward.15").font(.system(size: 40)).foregroundColor(.white)
|
||||
}.padding(20)
|
||||
Spacer()
|
||||
}.padding(.leading, -20)
|
||||
Spacer()
|
||||
HStack() {
|
||||
Slider(value: $scrub, onEditingChanged: { bool in
|
||||
let videoPosition = Double(VLCPlayerObj.time.intValue)
|
||||
let videoDuration = Double(VLCPlayerObj.time.intValue + abs(VLCPlayerObj.remainingTime.intValue))
|
||||
if(bool == true) {
|
||||
VLCPlayerObj.pause()
|
||||
sendProgressReport(eventName: "pause")
|
||||
} else {
|
||||
//Scrub is value from 0..1 - find position in video and add / or remove.
|
||||
let secondsScrubbedTo = round(_scrub.wrappedValue * videoDuration);
|
||||
let offset = secondsScrubbedTo - videoPosition;
|
||||
sendProgressReport(eventName: "unpause")
|
||||
VLCPlayerObj.play()
|
||||
if(offset > 0) {
|
||||
VLCPlayerObj.jumpForward(Int32(offset)/1000);
|
||||
} else {
|
||||
VLCPlayerObj.jumpBackward(Int32(abs(offset))/1000);
|
||||
}
|
||||
}
|
||||
})
|
||||
.accentColor(Color(red: 172/255, green: 92/255, blue: 195/255))
|
||||
Text(timeText).fontWeight(.semibold).frame(width: 80).foregroundColor(.white)
|
||||
}.padding(EdgeInsets(top: -20, leading: 44, bottom: 42, trailing: 40))
|
||||
}
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
|
||||
.background(Color(.black).opacity(0.4))
|
||||
}
|
||||
}
|
||||
, alignment: .topLeading)
|
||||
.introspectTabBarController { (UITabBarController) in
|
||||
UITabBarController.tabBar.isHidden = true
|
||||
}
|
||||
.onTapGesture(perform: resetTimer)
|
||||
.navigationBarHidden(true)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.statusBar(hidden: true)
|
||||
.prefersHomeIndicatorAutoHidden(true)
|
||||
.preferredColorScheme(.dark)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
|
||||
.overrideViewPreference(.unspecified)
|
||||
.supportedOrientations(.landscape)
|
||||
.onAppear(perform: onAppear)
|
||||
}
|
||||
|
||||
func onAppear() {
|
||||
shouldShowLoadingView = true;
|
||||
let builder = DeviceProfileBuilder()
|
||||
|
||||
let defaults = UserDefaults.standard;
|
||||
if(globalData.isInNetwork) {
|
||||
builder.setMaxBitrate(bitrate: defaults.integer(forKey: "InNetworkBandwidth"))
|
||||
} else {
|
||||
builder.setMaxBitrate(bitrate: defaults.integer(forKey: "OutOfNetworkBandwidth"))
|
||||
}
|
||||
|
||||
let DeviceProfile = builder.buildProfile()
|
||||
|
||||
let jsonEncoder = JSONEncoder()
|
||||
let jsonData = try! jsonEncoder.encode(DeviceProfile)
|
||||
|
||||
let url = (globalData.server?.baseURI ?? "") + "/Items/\(itemPlayback.itemToPlay.Id)/PlaybackInfo?UserId=\(globalData.user?.user_id ?? "")&StartTimeTicks=\(Int(itemPlayback.itemToPlay.Progress))&IsPlayback=true&AutoOpenLiveStream=true&MaxStreamingBitrate=\(DeviceProfile.DeviceProfile.MaxStreamingBitrate)";
|
||||
|
||||
let request = RestRequest(method: .post, url: url)
|
||||
|
||||
request.headerParameters["X-Emby-Authorization"] = globalData.authHeader
|
||||
request.contentType = "application/json"
|
||||
request.acceptType = "application/json"
|
||||
request.messageBody = jsonData
|
||||
|
||||
request.responseData() { (result: Result<RestResponse<Data>, RestError>) in
|
||||
switch result {
|
||||
case .success(let response):
|
||||
let body = response.body
|
||||
do {
|
||||
let json = try JSON(data: body)
|
||||
_playSessionId.wrappedValue = json["PlaySessionId"].string ?? "";
|
||||
if(json["MediaSources"][0]["TranscodingUrl"].string != nil) {
|
||||
let streamURL: URL = URL(string: "\(globalData.server?.baseURI ?? "")\((json["MediaSources"][0]["TranscodingUrl"].string ?? ""))")!
|
||||
let item = PlaybackItem()
|
||||
item.videoType = .hls
|
||||
item.videoUrl = streamURL
|
||||
let disableSubtitleTrack = Subtitle(name: "Disabled", id: -1, url: URL(string: "https://example.com")!, delivery: "Embed", codec: "")
|
||||
_subtitles.wrappedValue.append(disableSubtitleTrack);
|
||||
|
||||
for (_,stream):(String, JSON) in json["MediaSources"][0]["MediaStreams"] {
|
||||
if(stream["Type"].string == "Subtitle") { //ignore ripped subtitles - we don't want to extract subtitles
|
||||
let deliveryUrl = URL(string: "\(globalData.server?.baseURI ?? "")\(stream["DeliveryUrl"].string ?? "")")!
|
||||
let subtitle = Subtitle(name: stream["DisplayTitle"].string ?? "", id: Int32(stream["Index"].int ?? 0), url: deliveryUrl, delivery: stream["DeliveryMethod"].string ?? "", codec: stream["Codec"].string ?? "")
|
||||
_subtitles.wrappedValue.append(subtitle);
|
||||
}
|
||||
|
||||
if(stream["Type"].string == "Audio") {
|
||||
let deliveryUrl = URL(string: "https://example.com")!
|
||||
let subtitle = Subtitle(name: stream["DisplayTitle"].string ?? "", id: Int32(stream["Index"].int ?? 0), url: deliveryUrl, delivery: stream["IsExternal"].boolValue ? "External" : "Embed", codec: stream["Codec"].string ?? "")
|
||||
if(stream["IsDefault"].boolValue) {
|
||||
_selectedAudioTrack.wrappedValue = Int32(stream["Index"].int ?? 0);
|
||||
}
|
||||
_audioTracks.wrappedValue.append(subtitle);
|
||||
}
|
||||
}
|
||||
|
||||
if(_selectedAudioTrack.wrappedValue == -1) {
|
||||
if(_audioTracks.wrappedValue.count > 0) {
|
||||
_selectedAudioTrack.wrappedValue = _audioTracks.wrappedValue[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
self.sendPlayReport()
|
||||
VLCItem = item;
|
||||
VLCItem.subtitles = subtitles;
|
||||
} else {
|
||||
print("Direct playing!");
|
||||
let streamURL: URL = URL(string: "\(globalData.server?.baseURI ?? "")/Videos/\(itemPlayback.itemToPlay.Id)/stream?Static=true&mediaSourceId=\(itemPlayback.itemToPlay.Id)&deviceId=\(globalData.user?.device_uuid ?? "")&api_key=\(globalData.authToken)&Tag=\(json["MediaSources"][0]["ETag"])")!;
|
||||
let item = PlaybackItem()
|
||||
item.videoUrl = streamURL
|
||||
item.videoType = .direct
|
||||
let disableSubtitleTrack = Subtitle(name: "Disabled", id: -1, url: URL(string: "https://example.com")!, delivery: "Embed", codec: "")
|
||||
_subtitles.wrappedValue.append(disableSubtitleTrack);
|
||||
for (_,stream):(String, JSON) in json["MediaSources"][0]["MediaStreams"] {
|
||||
if(stream["Type"].string == "Subtitle") {
|
||||
let deliveryUrl = URL(string: "\(globalData.server?.baseURI ?? "")\(stream["DeliveryUrl"].string ?? "")")!
|
||||
let subtitle = Subtitle(name: stream["DisplayTitle"].string ?? "", id: Int32(stream["Index"].int ?? 0), url: deliveryUrl, delivery: stream["DeliveryMethod"].string ?? "", codec: stream["Codec"].string ?? "")
|
||||
_subtitles.wrappedValue.append(subtitle);
|
||||
}
|
||||
|
||||
if(stream["Type"].string == "Audio") {
|
||||
let deliveryUrl = URL(string: "https://example.com")!
|
||||
let subtitle = Subtitle(name: stream["DisplayTitle"].string ?? "", id: Int32(stream["Index"].int ?? 0), url: deliveryUrl, delivery: stream["IsExternal"].boolValue ? "External" : "Embed", codec: stream["Codec"].string ?? "")
|
||||
if(stream["IsDefault"].boolValue) {
|
||||
_selectedAudioTrack.wrappedValue = Int32(stream["Index"].int ?? 0);
|
||||
}
|
||||
_audioTracks.wrappedValue.append(subtitle);
|
||||
}
|
||||
}
|
||||
|
||||
if(_selectedAudioTrack.wrappedValue == -1) {
|
||||
_selectedAudioTrack.wrappedValue = _audioTracks.wrappedValue[0].id;
|
||||
}
|
||||
|
||||
sendPlayReport()
|
||||
_VLCItem.wrappedValue = item;
|
||||
_VLCItem.wrappedValue.subtitles = subtitles;
|
||||
}
|
||||
|
||||
shouldShowLoadingView = false;
|
||||
|
||||
/*
|
||||
DispatchQueue.global(qos: .utility).async { [self] in
|
||||
self.keepUpWithPlayerState()
|
||||
}
|
||||
*/
|
||||
} catch {
|
||||
|
||||
}
|
||||
break
|
||||
case .failure(let error):
|
||||
debugPrint(error)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendProgressReport(eventName: String) {
|
||||
var progressBody: String = "";
|
||||
progressBody = "{\"VolumeLevel\":100,\"IsMuted\":false,\"IsPaused\":\(VLCPlayerObj.state == .paused ? "true" : "false"),\"RepeatMode\":\"RepeatNone\",\"ShuffleMode\":\"Sorted\",\"MaxStreamingBitrate\":120000000,\"PositionTicks\":\(Int(VLCPlayerObj.position * Float(itemPlayback.itemToPlay.RuntimeTicks))),\"PlaybackStartTimeTicks\":\(startTime),\"AudioStreamIndex\":\(selectedAudioTrack),\"BufferedRanges\":[{\"start\":0,\"end\":569735888.888889}],\"PlayMethod\":\"\(VLCItem.videoType == .hls ? "Transcode" : "DirectStream")\",\"PlaySessionId\":\"\(playSessionId)\",\"PlaylistItemId\":\"playlistItem0\",\"MediaSourceId\":\"\(itemPlayback.itemToPlay.Id)\",\"CanSeek\":true,\"ItemId\":\"\(itemPlayback.itemToPlay.Id)\",\"EventName\":\"\(eventName)\"}";
|
||||
|
||||
print("");
|
||||
print("Sending progress report")
|
||||
print(progressBody)
|
||||
|
||||
let request = RestRequest(method: .post, url: (globalData.server?.baseURI ?? "") + "/Sessions/Playing/Progress")
|
||||
request.headerParameters["X-Emby-Authorization"] = globalData.authHeader
|
||||
request.contentType = "application/json"
|
||||
request.acceptType = "application/json"
|
||||
request.messageBody = progressBody.data(using: .ascii);
|
||||
request.responseData() { (result: Result<RestResponse<Data>, RestError>) in
|
||||
switch result {
|
||||
case .success(let resp):
|
||||
print(resp.body)
|
||||
break
|
||||
case .failure(let error):
|
||||
debugPrint(error)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resetTimer() {
|
||||
print("rt running")
|
||||
if(_shouldOverlayShow.wrappedValue == true) {
|
||||
_shouldOverlayShow.wrappedValue = false
|
||||
return;
|
||||
}
|
||||
_shouldOverlayShow.wrappedValue = true;
|
||||
}
|
||||
|
||||
func sendStopReport() {
|
||||
var progressBody: String = "";
|
||||
|
||||
progressBody = "{\"VolumeLevel\":100,\"IsMuted\":false,\"IsPaused\":true,\"RepeatMode\":\"RepeatNone\",\"ShuffleMode\":\"Sorted\",\"MaxStreamingBitrate\":120000000,\"PositionTicks\":\(Int(VLCPlayerObj.position * Float(itemPlayback.itemToPlay.RuntimeTicks))),\"PlaybackStartTimeTicks\":\(startTime),\"AudioStreamIndex\":\(selectedAudioTrack),\"BufferedRanges\":[{\"start\":0,\"end\":100000}],\"PlayMethod\":\"\(VLCItem.videoType == .hls ? "Transcode" : "DirectStream")\",\"PlaySessionId\":\"\(playSessionId)\",\"PlaylistItemId\":\"playlistItem0\",\"MediaSourceId\":\"\(itemPlayback.itemToPlay.Id)\",\"CanSeek\":true,\"ItemId\":\"\(itemPlayback.itemToPlay.Id)\",\"NowPlayingQueue\":[{\"Id\":\"\(itemPlayback.itemToPlay.Id)\",\"PlaylistItemId\":\"playlistItem0\"}]}";
|
||||
|
||||
print("");
|
||||
print("Sending stop report")
|
||||
print(progressBody)
|
||||
|
||||
let request = RestRequest(method: .post, url: (globalData.server?.baseURI ?? "") + "/Sessions/Playing/Stopped")
|
||||
request.headerParameters["X-Emby-Authorization"] = globalData.authHeader
|
||||
request.contentType = "application/json"
|
||||
request.acceptType = "application/json"
|
||||
request.messageBody = progressBody.data(using: .ascii);
|
||||
request.responseData() { (result: Result<RestResponse<Data>, RestError>) in
|
||||
switch result {
|
||||
case .success(let resp):
|
||||
print(resp.body)
|
||||
break
|
||||
case .failure(let error):
|
||||
debugPrint(error)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendPlayReport() {
|
||||
var progressBody: String = "";
|
||||
_startTime.wrappedValue = Int(Date().timeIntervalSince1970) * 10000000
|
||||
|
||||
progressBody = "{\"VolumeLevel\":100,\"IsMuted\":false,\"IsPaused\":false,\"RepeatMode\":\"RepeatNone\",\"ShuffleMode\":\"Sorted\",\"MaxStreamingBitrate\":120000000,\"PositionTicks\":\(Int(itemPlayback.itemToPlay.Progress)),\"PlaybackStartTimeTicks\":\(startTime),\"AudioStreamIndex\":\(selectedAudioTrack),\"BufferedRanges\":[],\"PlayMethod\":\"\(VLCItem.videoType == .hls ? "Transcode" : "DirectStream")\",\"PlaySessionId\":\"\(playSessionId)\",\"PlaylistItemId\":\"playlistItem0\",\"MediaSourceId\":\"\(itemPlayback.itemToPlay.Id)\",\"CanSeek\":true,\"ItemId\":\"\(itemPlayback.itemToPlay.Id)\",\"NowPlayingQueue\":[{\"Id\":\"\(itemPlayback.itemToPlay.Id)\",\"PlaylistItemId\":\"playlistItem0\"}]}";
|
||||
|
||||
print("");
|
||||
print("Sending play report")
|
||||
print(progressBody)
|
||||
|
||||
let request = RestRequest(method: .post, url: (globalData.server?.baseURI ?? "") + "/Sessions/Playing")
|
||||
request.headerParameters["X-Emby-Authorization"] = globalData.authHeader
|
||||
request.contentType = "application/json"
|
||||
request.acceptType = "application/json"
|
||||
request.messageBody = progressBody.data(using: .ascii);
|
||||
request.responseData() { (result: Result<RestResponse<Data>, RestError>) in
|
||||
switch result {
|
||||
case .success(let resp):
|
||||
print(resp.body)
|
||||
break
|
||||
case .failure(let error):
|
||||
debugPrint(error)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,190 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<device id="ipad10_2" orientation="landscape" layout="fullscreen" appearance="light"/>
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
|
||||
<capability name="Image references" minToolsVersion="12.0"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Player View Controller-->
|
||||
<scene sceneID="s0d-6b-0kx">
|
||||
<objects>
|
||||
<viewController storyboardIdentifier="VideoPlayer" id="Y6W-OH-hqX" customClass="PlayerViewController" customModule="JellyfinPlayer" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<view key="view" contentMode="scaleToFill" id="IQg-r0-AeH">
|
||||
<rect key="frame" x="0.0" y="0.0" width="1080" height="810"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<subviews>
|
||||
<view tag="1" contentMode="scaleToFill" semanticContentAttribute="playback" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="Tsh-rC-BwO" userLabel="VideoContentView">
|
||||
<rect key="frame" x="0.0" y="0.0" width="1080" height="810"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<gestureRecognizers/>
|
||||
<connections>
|
||||
<outletCollection property="gestureRecognizers" destination="Tag-oM-Uha" appends="YES" id="AlY-fE-iBg"/>
|
||||
</connections>
|
||||
</view>
|
||||
<view contentMode="scaleToFill" id="Qcb-Fb-qZl" userLabel="VideoControlsView">
|
||||
<rect key="frame" x="0.0" y="0.0" width="1080" height="810"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<slider opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" minValue="0.0" maxValue="1" translatesAutoresizingMaskIntoConstraints="NO" id="e9f-8l-RdN" userLabel="SeekSlider">
|
||||
<rect key="frame" x="50" y="751" width="873" height="31"/>
|
||||
<color key="tintColor" red="0.66666666666666663" green="0.36078431372549019" blue="0.76470588235294112" alpha="1" colorSpace="calibratedRGB"/>
|
||||
<color key="thumbTintColor" red="0.66666666666666663" green="0.36078431372549019" blue="0.76470588235294112" alpha="1" colorSpace="calibratedRGB"/>
|
||||
<connections>
|
||||
<action selector="seekSliderEnd:" destination="Y6W-OH-hqX" eventType="touchUpInside" id="m4l-h6-V0d"/>
|
||||
<action selector="seekSliderStart:" destination="Y6W-OH-hqX" eventType="touchDown" id="it4-Bp-hPL"/>
|
||||
<action selector="seekSliderValueChanged:" destination="Y6W-OH-hqX" eventType="valueChanged" id="tfF-Zl-CdU"/>
|
||||
</connections>
|
||||
</slider>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="-:--:--" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="qft-iu-f1z">
|
||||
<rect key="frame" x="950" y="749" width="91" height="34"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="91" id="LbL-h0-EYA"/>
|
||||
<constraint firstAttribute="height" constant="34" id="OkD-Dr-Ina"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="19"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="t2L-Oz-fe9" userLabel="MainActionButton">
|
||||
<rect key="frame" x="498.5" y="363.5" width="83" height="83"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="83" id="PdD-nW-y9r"/>
|
||||
<constraint firstAttribute="height" constant="83" id="e9j-PI-Ic4"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="18"/>
|
||||
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<state key="normal">
|
||||
<imageReference key="image" image="play.slash.fill" catalog="system" symbolScale="default"/>
|
||||
<preferredSymbolConfiguration key="preferredSymbolConfiguration" configurationType="pointSize" pointSize="55" scale="default"/>
|
||||
</state>
|
||||
<connections>
|
||||
<action selector="mainActionButtonPressed:" destination="Y6W-OH-hqX" eventType="touchUpInside" id="qBH-T0-6R4"/>
|
||||
</connections>
|
||||
</button>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="rLx-SN-RHr">
|
||||
<rect key="frame" x="30" y="22" width="60" height="60"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="60" id="jwh-l2-ARL"/>
|
||||
<constraint firstAttribute="width" constant="60" id="rcS-W1-m4V"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="22"/>
|
||||
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<state key="normal" image="chevron.backward" catalog="system">
|
||||
<color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<preferredSymbolConfiguration key="preferredSymbolConfiguration" configurationType="pointSize" pointSize="24"/>
|
||||
</state>
|
||||
<connections>
|
||||
<action selector="exitButtonPressed:" destination="Y6W-OH-hqX" eventType="touchUpInside" id="XHc-OR-kc8"/>
|
||||
</connections>
|
||||
</button>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Loading" textAlignment="center" lineBreakMode="tailTruncation" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="o8N-R1-DhT">
|
||||
<rect key="frame" x="141" y="34" width="798" height="36"/>
|
||||
<accessibility key="accessibilityConfiguration">
|
||||
<accessibilityTraits key="traits" staticText="YES" header="YES"/>
|
||||
</accessibility>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="798" id="l8g-la-DDC"/>
|
||||
<constraint firstAttribute="height" constant="36" id="xOv-x8-971"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="20"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<color key="highlightedColor" systemColor="labelColor"/>
|
||||
</label>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="bYM-Xp-bZO">
|
||||
<rect key="frame" x="305" y="367" width="75" height="76"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="76" id="5lC-V1-lHH"/>
|
||||
<constraint firstAttribute="width" constant="75" id="IPn-pO-Rxo"/>
|
||||
</constraints>
|
||||
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<state key="normal" image="gobackward.15" catalog="system">
|
||||
<preferredSymbolConfiguration key="preferredSymbolConfiguration" configurationType="pointSize" pointSize="35"/>
|
||||
</state>
|
||||
<connections>
|
||||
<action selector="jumpBackTapped:" destination="Y6W-OH-hqX" eventType="touchUpInside" id="4vd-25-cCB"/>
|
||||
</connections>
|
||||
</button>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="An8-jF-FAY">
|
||||
<rect key="frame" x="700" y="367" width="75" height="76"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="76" id="huv-QZ-HSc"/>
|
||||
<constraint firstAttribute="width" constant="75" id="uPN-A8-EV1"/>
|
||||
</constraints>
|
||||
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<state key="normal" image="goforward.15" catalog="system">
|
||||
<preferredSymbolConfiguration key="preferredSymbolConfiguration" configurationType="pointSize" pointSize="35"/>
|
||||
</state>
|
||||
<connections>
|
||||
<action selector="jumpForwardTapped:" destination="Y6W-OH-hqX" eventType="touchUpInside" id="I6H-fd-Mn8"/>
|
||||
</connections>
|
||||
</button>
|
||||
</subviews>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.5954241071428571" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<gestureRecognizers/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="bottom" secondItem="e9f-8l-RdN" secondAttribute="bottom" constant="29" id="231-rB-qDs"/>
|
||||
<constraint firstAttribute="trailing" secondItem="qft-iu-f1z" secondAttribute="trailing" constant="39" id="2Ie-OW-sUL"/>
|
||||
<constraint firstItem="An8-jF-FAY" firstAttribute="leading" secondItem="t2L-Oz-fe9" secondAttribute="trailing" constant="118.5" id="2zE-ul-pOh"/>
|
||||
<constraint firstItem="An8-jF-FAY" firstAttribute="centerY" secondItem="t2L-Oz-fe9" secondAttribute="centerY" id="36i-Q2-D1K"/>
|
||||
<constraint firstItem="t2L-Oz-fe9" firstAttribute="centerX" secondItem="Qcb-Fb-qZl" secondAttribute="centerX" id="3Gw-QD-lQX"/>
|
||||
<constraint firstAttribute="bottom" secondItem="qft-iu-f1z" secondAttribute="bottom" constant="27" id="NPi-py-0qd"/>
|
||||
<constraint firstItem="rLx-SN-RHr" firstAttribute="leading" secondItem="Qcb-Fb-qZl" secondAttribute="leading" constant="30" id="Oe7-LK-6Tl"/>
|
||||
<constraint firstItem="o8N-R1-DhT" firstAttribute="centerX" secondItem="t2L-Oz-fe9" secondAttribute="centerX" id="RUg-BG-nBq"/>
|
||||
<constraint firstItem="t2L-Oz-fe9" firstAttribute="centerY" secondItem="Qcb-Fb-qZl" secondAttribute="centerY" id="TOk-sG-UXV"/>
|
||||
<constraint firstAttribute="bottom" secondItem="qft-iu-f1z" secondAttribute="bottom" constant="27" id="aOB-Uz-cbQ"/>
|
||||
<constraint firstItem="qft-iu-f1z" firstAttribute="leading" secondItem="e9f-8l-RdN" secondAttribute="trailing" constant="29" id="auL-Vv-ZMV"/>
|
||||
<constraint firstItem="bYM-Xp-bZO" firstAttribute="top" secondItem="An8-jF-FAY" secondAttribute="top" id="cVS-eI-vv2"/>
|
||||
<constraint firstItem="e9f-8l-RdN" firstAttribute="leading" secondItem="Qcb-Fb-qZl" secondAttribute="leading" constant="52" id="ed3-xq-0Ug"/>
|
||||
<constraint firstItem="t2L-Oz-fe9" firstAttribute="leading" secondItem="bYM-Xp-bZO" secondAttribute="trailing" constant="118.5" id="fci-L5-1f6"/>
|
||||
<constraint firstItem="o8N-R1-DhT" firstAttribute="top" secondItem="Qcb-Fb-qZl" secondAttribute="top" constant="34" id="tJD-Ts-VuB"/>
|
||||
<constraint firstItem="rLx-SN-RHr" firstAttribute="top" secondItem="Qcb-Fb-qZl" secondAttribute="top" constant="22" id="v4G-B1-7y6"/>
|
||||
</constraints>
|
||||
<connections>
|
||||
<outletCollection property="gestureRecognizers" destination="iQW-fW-KWT" appends="YES" id="H09-88-nzQ"/>
|
||||
</connections>
|
||||
</view>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="zud-b9-RyD"/>
|
||||
<color key="backgroundColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</view>
|
||||
<connections>
|
||||
<outlet property="jumpBackButton" destination="bYM-Xp-bZO" id="K2u-5Q-dkm"/>
|
||||
<outlet property="jumpForwardButton" destination="An8-jF-FAY" id="4hN-YB-yVd"/>
|
||||
<outlet property="mainActionButton" destination="t2L-Oz-fe9" id="nQR-2e-64l"/>
|
||||
<outlet property="seekSlider" destination="e9f-8l-RdN" id="b3H-tn-TPG"/>
|
||||
<outlet property="timeText" destination="qft-iu-f1z" id="pAX-J3-I53"/>
|
||||
<outlet property="titleLabel" destination="o8N-R1-DhT" id="E7D-iU-bMi"/>
|
||||
<outlet property="videoContentView" destination="Tsh-rC-BwO" id="5uR-No-wLy"/>
|
||||
<outlet property="videoControlsView" destination="Qcb-Fb-qZl" id="Z1U-Qr-8ND"/>
|
||||
</connections>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="Ief-a0-LHa" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
<tapGestureRecognizer id="Tag-oM-Uha">
|
||||
<connections>
|
||||
<action selector="contentViewTapped:" destination="Y6W-OH-hqX" id="uq5-EN-60x"/>
|
||||
</connections>
|
||||
</tapGestureRecognizer>
|
||||
<tapGestureRecognizer id="iQW-fW-KWT">
|
||||
<connections>
|
||||
<action selector="controlViewTapped:" destination="Y6W-OH-hqX" id="0lD-A7-3TP"/>
|
||||
</connections>
|
||||
</tapGestureRecognizer>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="130.55555555555554" y="74.81481481481481"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="chevron.backward" catalog="system" width="96" height="128"/>
|
||||
<image name="gobackward.15" catalog="system" width="121" height="128"/>
|
||||
<image name="goforward.15" catalog="system" width="121" height="128"/>
|
||||
<image name="play.slash.fill" catalog="system" width="116" height="128"/>
|
||||
<systemColor name="labelColor">
|
||||
<color white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
</resources>
|
||||
</document>
|
|
@ -0,0 +1,499 @@
|
|||
//
|
||||
// VideoPlayer.swift
|
||||
// JellyfinPlayer
|
||||
//
|
||||
// Created by Aiden Vigue on 5/26/21.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import MobileVLCKit
|
||||
import SwiftyJSON
|
||||
import SwiftyRequest
|
||||
|
||||
enum VideoType {
|
||||
case hls;
|
||||
case direct;
|
||||
}
|
||||
|
||||
struct Subtitle {
|
||||
var name: String;
|
||||
var id: Int32;
|
||||
var url: URL;
|
||||
var delivery: String;
|
||||
var codec: String;
|
||||
}
|
||||
|
||||
struct AudioTrack {
|
||||
var name: String;
|
||||
var id: Int32;
|
||||
}
|
||||
|
||||
class PlaybackItem: ObservableObject {
|
||||
@Published var videoType: VideoType = .hls;
|
||||
@Published var videoUrl: URL = URL(string: "https://example.com")!;
|
||||
@Published var subtitles: [Subtitle] = [];
|
||||
}
|
||||
|
||||
protocol PlayerViewControllerDelegate: AnyObject {
|
||||
func hideLoadingView(_ viewController: PlayerViewController)
|
||||
func showLoadingView(_ viewController: PlayerViewController)
|
||||
func exitPlayer(_ viewController: PlayerViewController)
|
||||
}
|
||||
|
||||
class PlayerViewController: UIViewController, VLCMediaDelegate, VLCMediaPlayerDelegate {
|
||||
|
||||
weak var delegate: PlayerViewControllerDelegate?
|
||||
|
||||
var mediaPlayer = VLCMediaPlayer(options: ["--sub-margin=200"])!
|
||||
var globalData = GlobalData()
|
||||
|
||||
@IBOutlet weak var timeText: UILabel!
|
||||
@IBOutlet weak var videoContentView: UIView!
|
||||
@IBOutlet weak var videoControlsView: UIView!
|
||||
@IBOutlet weak var seekSlider: UISlider!
|
||||
@IBOutlet weak var titleLabel: UILabel!
|
||||
@IBOutlet weak var jumpBackButton: UIButton!
|
||||
@IBOutlet weak var jumpForwardButton: UIButton!
|
||||
|
||||
var shouldShowLoadingScreen: Bool = false;
|
||||
var ssTargetValueOffset: Int = 0;
|
||||
var ssStartValue: Int = 0;
|
||||
|
||||
var paused: Bool = true;
|
||||
var lastTime: Float = 0.0;
|
||||
var startTime: Int = 0;
|
||||
var controlsAppearTime: Double = 0;
|
||||
|
||||
var selectedAudioTrack: Int32 = -1;
|
||||
var selectedCaptionTrack: Int32 = -1;
|
||||
var playSessionId: String = "";
|
||||
var lastProgressReportTime: Double = 0;
|
||||
|
||||
var subtitleTrackArray: [Subtitle] = [];
|
||||
var audioTrackArray: [AudioTrack] = [];
|
||||
|
||||
var manifest: DetailItem = DetailItem();
|
||||
var playbackItem = PlaybackItem();
|
||||
|
||||
@IBAction func seekSliderStart(_ sender: Any) {
|
||||
sendProgressReport(eventName: "pause")
|
||||
mediaPlayer.pause()
|
||||
}
|
||||
@IBAction func seekSliderValueChanged(_ sender: Any) {
|
||||
let videoDuration = Double(mediaPlayer.time.intValue + abs(mediaPlayer.remainingTime.intValue))/1000
|
||||
let secondsScrubbedTo = round(Double(seekSlider.value) * videoDuration);
|
||||
let scrubRemaining = videoDuration - secondsScrubbedTo;
|
||||
let remainingTime = scrubRemaining;
|
||||
let hours = floor(remainingTime / 3600);
|
||||
let minutes = (remainingTime.truncatingRemainder(dividingBy: 3600)) / 60;
|
||||
let seconds = (remainingTime.truncatingRemainder(dividingBy: 3600)).truncatingRemainder(dividingBy: 60);
|
||||
if(hours != 0) {
|
||||
timeText.text = "\(Int(hours)):\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))";
|
||||
} else {
|
||||
timeText.text = "\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))";
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func seekSliderEnd(_ sender: Any) {
|
||||
print("ss end")
|
||||
let videoPosition = Double(mediaPlayer.time.intValue)
|
||||
let videoDuration = Double(mediaPlayer.time.intValue + abs(mediaPlayer.remainingTime.intValue))
|
||||
//Scrub is value from 0..1 - find position in video and add / or remove.
|
||||
let secondsScrubbedTo = round(Double(seekSlider.value) * videoDuration);
|
||||
let offset = secondsScrubbedTo - videoPosition;
|
||||
mediaPlayer.play()
|
||||
if(offset > 0) {
|
||||
mediaPlayer.jumpForward(Int32(offset)/1000);
|
||||
} else {
|
||||
mediaPlayer.jumpBackward(Int32(abs(offset))/1000);
|
||||
}
|
||||
sendProgressReport(eventName: "unpause")
|
||||
}
|
||||
|
||||
@IBAction func exitButtonPressed(_ sender: Any) {
|
||||
sendStopReport()
|
||||
delegate?.exitPlayer(self)
|
||||
}
|
||||
|
||||
@IBAction func controlViewTapped(_ sender: Any) {
|
||||
videoControlsView.isHidden = true
|
||||
}
|
||||
|
||||
@IBAction func contentViewTapped(_ sender: Any) {
|
||||
videoControlsView.isHidden = false
|
||||
controlsAppearTime = CACurrentMediaTime()
|
||||
}
|
||||
|
||||
@IBAction func jumpBackTapped(_ sender: Any) {
|
||||
if(paused == false) {
|
||||
mediaPlayer.jumpBackward(15)
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func jumpForwardTapped(_ sender: Any) {
|
||||
if(paused == false) {
|
||||
mediaPlayer.jumpForward(15)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@IBOutlet weak var mainActionButton: UIButton!
|
||||
@IBAction func mainActionButtonPressed(_ sender: Any) {
|
||||
print(mediaPlayer.state.rawValue)
|
||||
if(paused) {
|
||||
mediaPlayer.play()
|
||||
mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal)
|
||||
paused = false;
|
||||
} else {
|
||||
mediaPlayer.pause()
|
||||
mainActionButton.setImage(UIImage(systemName: "play"), for: .normal)
|
||||
paused = true;
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
//View has loaded.
|
||||
//Show loading screen
|
||||
usleep(10000);
|
||||
delegate?.showLoadingView(self)
|
||||
|
||||
mediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 14)
|
||||
//mediaPlayer.wrappedValue.perform(Selector(("setTextRendererFont:")), with: "Copperplate")
|
||||
|
||||
//Fetch max bitrate from UserDefaults depending on current connection mode
|
||||
let defaults = UserDefaults.standard
|
||||
let maxBitrate = globalData.isInNetwork ? defaults.integer(forKey: "InNetworkBandwidth") : defaults.integer(forKey: "OutOfNetworkBandwidth")
|
||||
|
||||
//Build a device profile
|
||||
let builder = DeviceProfileBuilder()
|
||||
builder.setMaxBitrate(bitrate: maxBitrate)
|
||||
let profile = builder.buildProfile()
|
||||
|
||||
let jsonEncoder = JSONEncoder()
|
||||
let jsonData = try! jsonEncoder.encode(profile)
|
||||
|
||||
let url = (globalData.server?.baseURI ?? "") + "/Items/\(manifest.Id)/PlaybackInfo?UserId=\(globalData.user?.user_id ?? "")&StartTimeTicks=\(Int(manifest.Progress))&IsPlayback=true&AutoOpenLiveStream=true&MaxStreamingBitrate=\(profile.DeviceProfile.MaxStreamingBitrate)";
|
||||
|
||||
let request = RestRequest(method: .post, url: url)
|
||||
|
||||
request.headerParameters["X-Emby-Authorization"] = globalData.authHeader
|
||||
request.contentType = "application/json"
|
||||
request.acceptType = "application/json"
|
||||
request.messageBody = jsonData
|
||||
|
||||
request.responseData() { [self] (result: Result<RestResponse<Data>, RestError>) in
|
||||
switch result {
|
||||
case .success(let response):
|
||||
let body = response.body
|
||||
do {
|
||||
let json = try JSON(data: body)
|
||||
playSessionId = json["PlaySessionId"].string ?? "";
|
||||
if(json["MediaSources"][0]["TranscodingUrl"].string != nil) {
|
||||
let streamURL: URL = URL(string: "\(globalData.server?.baseURI ?? "")\((json["MediaSources"][0]["TranscodingUrl"].string ?? ""))")!
|
||||
let item = PlaybackItem()
|
||||
item.videoType = .hls
|
||||
item.videoUrl = streamURL
|
||||
let disableSubtitleTrack = Subtitle(name: "Disabled", id: -1, url: URL(string: "https://example.com")!, delivery: "Embed", codec: "")
|
||||
subtitleTrackArray.append(disableSubtitleTrack);
|
||||
|
||||
for (_,stream):(String, JSON) in json["MediaSources"][0]["MediaStreams"] {
|
||||
if(stream["Type"].string == "Subtitle") { //ignore ripped subtitles - we don't want to extract subtitles
|
||||
let deliveryUrl = URL(string: "\(globalData.server?.baseURI ?? "")\(stream["DeliveryUrl"].string ?? "")")!
|
||||
let subtitle = Subtitle(name: stream["DisplayTitle"].string ?? "", id: Int32(stream["Index"].int ?? 0), url: deliveryUrl, delivery: stream["DeliveryMethod"].string ?? "", codec: stream["Codec"].string ?? "")
|
||||
subtitleTrackArray.append(subtitle);
|
||||
}
|
||||
|
||||
if(stream["Type"].string == "Audio") {
|
||||
let subtitle = AudioTrack(name: stream["DisplayTitle"].string ?? "", id: Int32(stream["Index"].int ?? 0))
|
||||
if(stream["IsDefault"].boolValue) {
|
||||
selectedAudioTrack = Int32(stream["Index"].int ?? 0);
|
||||
}
|
||||
audioTrackArray.append(subtitle);
|
||||
}
|
||||
}
|
||||
|
||||
if(selectedAudioTrack == -1) {
|
||||
if(audioTrackArray.count > 0) {
|
||||
selectedAudioTrack = audioTrackArray[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
self.sendPlayReport()
|
||||
item.subtitles = subtitleTrackArray
|
||||
playbackItem = item;
|
||||
} else {
|
||||
print("Direct playing!");
|
||||
let streamURL: URL = URL(string: "\(globalData.server?.baseURI ?? "")/Videos/\(manifest.Id)/stream?Static=true&mediaSourceId=\(manifest.Id)&deviceId=\(globalData.user?.device_uuid ?? "")&api_key=\(globalData.authToken)&Tag=\(json["MediaSources"][0]["ETag"])")!;
|
||||
let item = PlaybackItem()
|
||||
item.videoUrl = streamURL
|
||||
item.videoType = .direct
|
||||
|
||||
let disableSubtitleTrack = Subtitle(name: "Disabled", id: -1, url: URL(string: "https://example.com")!, delivery: "Embed", codec: "")
|
||||
subtitleTrackArray.append(disableSubtitleTrack);
|
||||
for (_,stream):(String, JSON) in json["MediaSources"][0]["MediaStreams"] {
|
||||
if(stream["Type"].string == "Subtitle") {
|
||||
let deliveryUrl = URL(string: "\(globalData.server?.baseURI ?? "")\(stream["DeliveryUrl"].string ?? "")")!
|
||||
let subtitle = Subtitle(name: stream["DisplayTitle"].string ?? "", id: Int32(stream["Index"].int ?? 0), url: deliveryUrl, delivery: stream["DeliveryMethod"].string ?? "", codec: stream["Codec"].string ?? "")
|
||||
subtitleTrackArray.append(subtitle);
|
||||
}
|
||||
|
||||
if(stream["Type"].string == "Audio") {
|
||||
let subtitle = AudioTrack(name: stream["DisplayTitle"].string ?? "", id: Int32(stream["Index"].int ?? 0))
|
||||
if(stream["IsDefault"].boolValue) {
|
||||
selectedAudioTrack = Int32(stream["Index"].int ?? 0);
|
||||
}
|
||||
audioTrackArray.append(subtitle);
|
||||
}
|
||||
}
|
||||
|
||||
if(selectedAudioTrack == -1) {
|
||||
if(audioTrackArray.count > 0) {
|
||||
selectedAudioTrack = audioTrackArray[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
sendPlayReport()
|
||||
item.subtitles = subtitleTrackArray
|
||||
playbackItem = item;
|
||||
}
|
||||
|
||||
mediaPlayer.media = VLCMedia(url: playbackItem.videoUrl)
|
||||
playbackItem.subtitles.forEach() { sub in
|
||||
if(sub.id != -1 && sub.delivery == "External" && sub.codec != "subrip") {
|
||||
mediaPlayer.addPlaybackSlave(sub.url, type: .subtitle, enforce: false)
|
||||
}
|
||||
}
|
||||
mediaPlayer.play()
|
||||
mediaPlayer.jumpForward(Int32(manifest.Progress/10000000))
|
||||
} catch {
|
||||
|
||||
}
|
||||
break
|
||||
case .failure(let error):
|
||||
debugPrint(error)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
mediaPlayer.delegate = self
|
||||
mediaPlayer.drawable = videoContentView
|
||||
|
||||
if(manifest.Type == "Episode") {
|
||||
titleLabel.text = "\(manifest.Name) - S\(String(manifest.ParentIndexNumber ?? 0)):E\(String(manifest.IndexNumber ?? 0)) - \(manifest.SeriesName ?? "")"
|
||||
} else {
|
||||
titleLabel.text = manifest.Name
|
||||
}
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
self.tabBarController?.tabBar.isHidden = true;
|
||||
}
|
||||
|
||||
|
||||
//MARK: VLCMediaPlayer Delegates
|
||||
func mediaPlayerStateChanged(_ aNotification: Notification!) {
|
||||
let currentState: VLCMediaPlayerState = mediaPlayer.state
|
||||
switch currentState {
|
||||
case .stopped :
|
||||
print("Video is done playing)")
|
||||
sendStopReport()
|
||||
case .ended :
|
||||
print("Video is done playing)")
|
||||
sendStopReport()
|
||||
case .playing :
|
||||
print("Video is playing")
|
||||
sendProgressReport(eventName: "unpause")
|
||||
delegate?.hideLoadingView(self)
|
||||
paused = false;
|
||||
|
||||
case .paused :
|
||||
print("Video is paused)")
|
||||
paused = true;
|
||||
|
||||
case .opening :
|
||||
print("Video is opening)")
|
||||
|
||||
case .buffering :
|
||||
print("Video is buffering)")
|
||||
sendProgressReport(eventName: "pause")
|
||||
delegate?.showLoadingView(self)
|
||||
mediaPlayer.pause()
|
||||
usleep(10000)
|
||||
mediaPlayer.play()
|
||||
|
||||
case .error :
|
||||
print("Video has error)")
|
||||
sendStopReport()
|
||||
case .esAdded:
|
||||
mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal)
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func mediaPlayerTimeChanged(_ aNotification: Notification!) {
|
||||
let time = mediaPlayer.position;
|
||||
if(time != lastTime) {
|
||||
paused = false;
|
||||
seekSlider.setValue(mediaPlayer.position, animated: true)
|
||||
delegate?.hideLoadingView(self)
|
||||
|
||||
let remainingTime = abs(mediaPlayer.remainingTime.intValue)/1000;
|
||||
let hours = remainingTime / 3600;
|
||||
let minutes = (remainingTime % 3600) / 60;
|
||||
let seconds = (remainingTime % 3600) % 60;
|
||||
var timeTextStr = "";
|
||||
if(hours != 0) {
|
||||
timeTextStr = "\(Int(hours)):\(String(Int((minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int((seconds))).leftPad(toWidth: 2, withString: "0"))";
|
||||
} else {
|
||||
timeTextStr = "\(String(Int((minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int((seconds))).leftPad(toWidth: 2, withString: "0"))";
|
||||
}
|
||||
timeText.text = timeTextStr
|
||||
|
||||
if(CACurrentMediaTime() - controlsAppearTime > 5) {
|
||||
videoControlsView.isHidden = true;
|
||||
controlsAppearTime = 10000000000000000000000;
|
||||
}
|
||||
} else {
|
||||
paused = true;
|
||||
}
|
||||
lastTime = time;
|
||||
|
||||
if(CACurrentMediaTime() - lastProgressReportTime > 5) {
|
||||
sendProgressReport(eventName: "timeupdate")
|
||||
lastProgressReportTime = CACurrentMediaTime()
|
||||
}
|
||||
}
|
||||
|
||||
//MARK: Jellyfin Playstate updates
|
||||
func sendProgressReport(eventName: String) {
|
||||
var progressBody: String = "";
|
||||
progressBody = "{\"VolumeLevel\":100,\"IsMuted\":false,\"IsPaused\":\(mediaPlayer.state == .paused ? "true" : "false"),\"RepeatMode\":\"RepeatNone\",\"ShuffleMode\":\"Sorted\",\"MaxStreamingBitrate\":120000000,\"PositionTicks\":\(Int(mediaPlayer.position * Float(manifest.RuntimeTicks))),\"PlaybackStartTimeTicks\":\(startTime),\"AudioStreamIndex\":\(selectedAudioTrack),\"BufferedRanges\":[{\"start\":0,\"end\":569735888.888889}],\"PlayMethod\":\"\(playbackItem.videoType == .hls ? "Transcode" : "DirectStream")\",\"PlaySessionId\":\"\(playSessionId)\",\"PlaylistItemId\":\"playlistItem0\",\"MediaSourceId\":\"\(manifest.Id)\",\"CanSeek\":true,\"ItemId\":\"\(manifest.Id)\",\"EventName\":\"\(eventName)\"}";
|
||||
|
||||
print("");
|
||||
print("Sending progress report")
|
||||
print(progressBody)
|
||||
|
||||
let request = RestRequest(method: .post, url: (globalData.server?.baseURI ?? "") + "/Sessions/Playing/Progress")
|
||||
request.headerParameters["X-Emby-Authorization"] = globalData.authHeader
|
||||
request.contentType = "application/json"
|
||||
request.acceptType = "application/json"
|
||||
request.messageBody = progressBody.data(using: .ascii);
|
||||
request.responseData() { (result: Result<RestResponse<Data>, RestError>) in
|
||||
switch result {
|
||||
case .success(let resp):
|
||||
print(resp.body)
|
||||
break
|
||||
case .failure(let error):
|
||||
debugPrint(error)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendStopReport() {
|
||||
var progressBody: String = "";
|
||||
|
||||
progressBody = "{\"VolumeLevel\":100,\"IsMuted\":false,\"IsPaused\":true,\"RepeatMode\":\"RepeatNone\",\"ShuffleMode\":\"Sorted\",\"MaxStreamingBitrate\":120000000,\"PositionTicks\":\(Int(mediaPlayer.position * Float(manifest.RuntimeTicks))),\"PlaybackStartTimeTicks\":\(startTime),\"AudioStreamIndex\":\(selectedAudioTrack),\"BufferedRanges\":[{\"start\":0,\"end\":100000}],\"PlayMethod\":\"\(playbackItem.videoType == .hls ? "Transcode" : "DirectStream")\",\"PlaySessionId\":\"\(playSessionId)\",\"PlaylistItemId\":\"playlistItem0\",\"MediaSourceId\":\"\(manifest.Id)\",\"CanSeek\":true,\"ItemId\":\"\(manifest.Id)\",\"NowPlayingQueue\":[{\"Id\":\"\(manifest.Id)\",\"PlaylistItemId\":\"playlistItem0\"}]}";
|
||||
|
||||
print("");
|
||||
print("Sending stop report")
|
||||
print(progressBody)
|
||||
|
||||
let request = RestRequest(method: .post, url: (globalData.server?.baseURI ?? "") + "/Sessions/Playing/Stopped")
|
||||
request.headerParameters["X-Emby-Authorization"] = globalData.authHeader
|
||||
request.contentType = "application/json"
|
||||
request.acceptType = "application/json"
|
||||
request.messageBody = progressBody.data(using: .ascii);
|
||||
request.responseData() { (result: Result<RestResponse<Data>, RestError>) in
|
||||
switch result {
|
||||
case .success(let resp):
|
||||
print(resp.body)
|
||||
break
|
||||
case .failure(let error):
|
||||
debugPrint(error)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendPlayReport() {
|
||||
var progressBody: String = "";
|
||||
startTime = Int(Date().timeIntervalSince1970) * 10000000
|
||||
|
||||
progressBody = "{\"VolumeLevel\":100,\"IsMuted\":false,\"IsPaused\":false,\"RepeatMode\":\"RepeatNone\",\"ShuffleMode\":\"Sorted\",\"MaxStreamingBitrate\":120000000,\"PositionTicks\":\(Int(manifest.Progress)),\"PlaybackStartTimeTicks\":\(startTime),\"AudioStreamIndex\":\(selectedAudioTrack),\"BufferedRanges\":[],\"PlayMethod\":\"\(playbackItem.videoType == .hls ? "Transcode" : "DirectStream")\",\"PlaySessionId\":\"\(playSessionId)\",\"PlaylistItemId\":\"playlistItem0\",\"MediaSourceId\":\"\(manifest.Id)\",\"CanSeek\":true,\"ItemId\":\"\(manifest.Id)\",\"NowPlayingQueue\":[{\"Id\":\"\(manifest.Id)\",\"PlaylistItemId\":\"playlistItem0\"}]}";
|
||||
|
||||
print("");
|
||||
print("Sending play report")
|
||||
print(progressBody)
|
||||
|
||||
let request = RestRequest(method: .post, url: (globalData.server?.baseURI ?? "") + "/Sessions/Playing")
|
||||
request.headerParameters["X-Emby-Authorization"] = globalData.authHeader
|
||||
request.contentType = "application/json"
|
||||
request.acceptType = "application/json"
|
||||
request.messageBody = progressBody.data(using: .ascii);
|
||||
request.responseData() { (result: Result<RestResponse<Data>, RestError>) in
|
||||
switch result {
|
||||
case .success(let resp):
|
||||
print(resp.body)
|
||||
break
|
||||
case .failure(let error):
|
||||
debugPrint(error)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VLCPlayerWithControls: UIViewControllerRepresentable {
|
||||
var item: DetailItem
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
@EnvironmentObject private var globalData: GlobalData;
|
||||
|
||||
var loadBinding: Binding<Bool>
|
||||
var pBinding: Binding<Bool>
|
||||
|
||||
class Coordinator: NSObject, PlayerViewControllerDelegate {
|
||||
let loadBinding: Binding<Bool>
|
||||
let pBinding: Binding<Bool>
|
||||
|
||||
init(loadBinding: Binding<Bool>, pBinding: Binding<Bool>) {
|
||||
self.loadBinding = loadBinding
|
||||
self.pBinding = pBinding
|
||||
}
|
||||
|
||||
func hideLoadingView(_ viewController: PlayerViewController) {
|
||||
self.loadBinding.wrappedValue = false;
|
||||
}
|
||||
|
||||
func showLoadingView(_ viewController: PlayerViewController) {
|
||||
self.loadBinding.wrappedValue = true;
|
||||
}
|
||||
|
||||
func exitPlayer(_ viewController: PlayerViewController) {
|
||||
self.pBinding.wrappedValue = false;
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(loadBinding: self.loadBinding, pBinding: self.pBinding)
|
||||
}
|
||||
|
||||
|
||||
typealias UIViewControllerType = PlayerViewController
|
||||
func makeUIViewController(context: UIViewControllerRepresentableContext<VLCPlayerWithControls>) -> VLCPlayerWithControls.UIViewControllerType {
|
||||
let storyboard = UIStoryboard(name: "VideoPlayer", bundle: nil)
|
||||
let customViewController = storyboard.instantiateViewController(withIdentifier: "VideoPlayer") as! PlayerViewController
|
||||
customViewController.manifest = item;
|
||||
customViewController.delegate = context.coordinator;
|
||||
customViewController.globalData = globalData;
|
||||
return customViewController
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: VLCPlayerWithControls.UIViewControllerType, context: UIViewControllerRepresentableContext<VLCPlayerWithControls>) {
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue