This commit is contained in:
Aiden Vigue 2021-05-26 21:24:01 -04:00
parent 1ee7905e4c
commit 208bec783a
No known key found for this signature in database
GPG Key ID: E7570472648F4544
7 changed files with 799 additions and 215 deletions

View File

@ -9,6 +9,7 @@
/* Begin PBXBuildFile section */
5302F82A2658791C00647A2E /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 5302F8292658791C00647A2E /* Sentry */; };
53192D5D265AA78A008A4215 /* DeviceProfileBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */; };
53313B90265EEA6D00947AA3 /* VideoPlayer.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 53313B8F265EEA6D00947AA3 /* VideoPlayer.storyboard */; };
5335256E265E8D5A006CCA86 /* VideoPlayerViewRefactored.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5335256D265E8D5A006CCA86 /* VideoPlayerViewRefactored.swift */; };
53352571265EA0A0006CCA86 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 53352570265EA0A0006CCA86 /* Introspect */; };
5338F74E263B61370014BF09 /* ConnectToServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5338F74D263B61370014BF09 /* ConnectToServerView.swift */; };
@ -72,6 +73,7 @@
/* Begin PBXFileReference section */
53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceProfileBuilder.swift; sourceTree = "<group>"; };
53313B8F265EEA6D00947AA3 /* VideoPlayer.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = VideoPlayer.storyboard; sourceTree = "<group>"; };
5335256D265E8D5A006CCA86 /* VideoPlayerViewRefactored.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewRefactored.swift; 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>"; };
@ -169,7 +171,6 @@
53E4E648263F725B00F67C6B /* MultiSelector.swift */,
535BAE9E2649E569005FA86D /* ItemView.swift */,
53A089CF264DA9DA00D57806 /* MovieItemView.swift */,
535BAEA4264A151C005FA86D /* VLCPlayer.swift */,
535BAEA6264A18AA005FA86D /* VideoPlayerView.swift */,
53EE24E5265060780068F029 /* LibrarySearchView.swift */,
53987CA326572C1300E7EA70 /* SeasonItemView.swift */,
@ -200,7 +201,9 @@
AE8C3150265D5FE1008AA076 /* Views */ = {
isa = PBXGroup;
children = (
535BAEA4264A151C005FA86D /* VLCPlayer.swift */,
539B2DA4263BA5B8007FF1A4 /* SettingsView.swift */,
53313B8F265EEA6D00947AA3 /* VideoPlayer.storyboard */,
);
path = Views;
sourceTree = "<group>";
@ -308,6 +311,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 */,
);

View File

@ -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>

View File

@ -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") {

View File

@ -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()
}
}

View File

@ -25,7 +25,8 @@ struct VideoPlayerViewRefactored: View {
@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 shouldOverlayShow: Bool = true;
@State private var show: Bool = true;
@State private var subtitles: [Subtitle] = [];
@State private var audioTracks: [Subtitle] = []; // can reuse the same struct
@ -37,118 +38,129 @@ struct VideoPlayerViewRefactored: View {
}
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))
}
if(show) {
LoadingView(isShowing: $shouldShowLoadingView) {
EmptyView()
.padding(EdgeInsets(top: 0, leading: UIDevice.current.hasNotch ? 30 : 0, bottom: 0, trailing: UIDevice.current.hasNotch ? 30 : 0))
}
, alignment: .topLeading)
.introspectTabBarController { (UITabBarController) in
UITabBarController.tabBar.isHidden = true
.overlay(
Group {
if(shouldOverlayShow) {
VStack() {
HStack() {
HStack() {
Button() {
sendStopReport()
VLCPlayerObj.stop()
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: {
if(VLCPlayerObj.state == .paused) {
Image(systemName: "play").font(.system(size: 55)).foregroundColor(.white)
} else {
Image(systemName: "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)
} else {
Text("test").onAppear(perform: {
print("ev appear")
usleep(10000);
_show.wrappedValue = 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() {
@ -296,11 +308,12 @@ struct VideoPlayerViewRefactored: View {
func resetTimer() {
print("rt running")
if(_shouldOverlayShow.wrappedValue == true) {
_shouldOverlayShow.wrappedValue = false
show = false;
if(shouldOverlayShow == true) {
shouldOverlayShow = false
return;
}
_shouldOverlayShow.wrappedValue = true;
shouldOverlayShow = true;
}
func sendStopReport() {

View File

@ -0,0 +1,498 @@
//
// VLCPlayer.swift
// JellyfinPlayer
//
// Created by Aiden Vigue on 5/10/21.
//
//me realizing i shouldve just written the whole app in the mvvm system bc it makes so much more sense
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()
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!
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 selectedAudioTrack: Int32 = 0;
var selectedCaptionTrack: Int32 = 0;
var playSessionId: String = "";
var subtitleTrackArray: [Subtitle] = [];
var audioTrackArray: [AudioTrack] = [];
var manifest: DetailItem = DetailItem();
var playbackItem = PlaybackItem();
@IBAction func seekSliderStart(_ sender: Any) {
print("ss start")
mediaPlayer.pause()
}
@IBAction func seekSliderValueChanged(_ sender: Any) {
print("ss mv " + String(seekSlider.value))
}
@IBAction func seekSliderEnd(_ sender: Any) {
print("ss end")
mediaPlayer.play()
}
@IBAction func exitButtonPressed(_ sender: Any) {
print("exit tap")
delegate?.exitPlayer(self)
}
@IBAction func controlViewTapped(_ sender: Any) {
print("control view tap")
videoControlsView.isHidden = !videoControlsView.isHidden
}
@IBAction func contentViewTapped(_ sender: Any) {
print("content view tap")
videoControlsView.isHidden = !videoControlsView.isHidden
}
@IBOutlet weak var mainActionButton: UIButton!
@IBAction func mainActionButtonPressed(_ sender: Any) {
print("mab press")
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
delegate?.showLoadingView(self)
//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 deliveryUrl = URL(string: "https://example.com")!
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 deliveryUrl = URL(string: "https://example.com")!
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;
}
} catch {
}
break
case .failure(let error):
debugPrint(error)
break
}
}
mediaPlayer.media = media
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
}
mediaPlayer.play()
}
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)")
case .ended :
print("Video is done playing)")
case .playing :
print("Video is playing")
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)")
delegate?.showLoadingView(self)
mediaPlayer.pause()
usleep(10000)
mediaPlayer.play()
case .error :
print("Video has error)")
case .esAdded:
print("Es Added")
mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal)
@unknown default:
break
}
}
func mediaPlayerTimeChanged(_ aNotification: Notification!) {
let time = mediaPlayer.position;
if(time != lastTime) {
paused = false;
delegate?.hideLoadingView(self)
} else {
paused = true;
}
lastTime = time;
}
//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>) {
}
}
/*
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()
}
}
*/

View File

@ -0,0 +1,156 @@
<?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" value="1" 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" systemColor="systemPurpleColor"/>
<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="TimeText" 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" style="UICTFontTextStyleHeadline"/>
<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="52" y="40" width="18" height="24"/>
<constraints>
<constraint firstAttribute="width" constant="18" id="4Zj-z3-4nR"/>
</constraints>
<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="EXAMPLE LONG MOVIE TITLE NAME ABCDEFG1234567890ABCDEFG1234567890 " textAlignment="center" lineBreakMode="tailTruncation" adjustsFontForContentSizeCategory="YES" 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" style="UICTFontTextStyleTitle2"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<color key="highlightedColor" systemColor="labelColor"/>
</label>
</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="t2L-Oz-fe9" firstAttribute="centerX" secondItem="Qcb-Fb-qZl" secondAttribute="centerX" id="3Gw-QD-lQX"/>
<constraint firstItem="o8N-R1-DhT" firstAttribute="centerY" secondItem="rLx-SN-RHr" secondAttribute="centerY" id="8LT-n3-gFu"/>
<constraint firstItem="rLx-SN-RHr" firstAttribute="leading" secondItem="Qcb-Fb-qZl" secondAttribute="leading" constant="52" id="J0Q-GN-NaA"/>
<constraint firstAttribute="bottom" secondItem="qft-iu-f1z" secondAttribute="bottom" constant="27" id="NPi-py-0qd"/>
<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="e9f-8l-RdN" firstAttribute="leading" secondItem="Qcb-Fb-qZl" secondAttribute="leading" constant="52" id="ed3-xq-0Ug"/>
<constraint firstItem="o8N-R1-DhT" firstAttribute="leading" secondItem="rLx-SN-RHr" secondAttribute="trailing" constant="71" id="sYe-SO-vtX"/>
<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="40" id="vZc-Kd-fE5"/>
</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="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="play.slash.fill" catalog="system" width="116" height="128"/>
<systemColor name="labelColor">
<color white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
<systemColor name="systemPurpleColor">
<color red="0.68627450980392157" green="0.32156862745098042" blue="0.87058823529411766" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
</resources>
</document>