diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index d0e47bb1..e77af4b3 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -40,6 +40,35 @@ 535870AD2669D8DD00D05A09 /* Typings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535870AC2669D8DD00D05A09 /* Typings.swift */; }; 535BAE9F2649E569005FA86D /* ItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535BAE9E2649E569005FA86D /* ItemView.swift */; }; 535BAEA5264A151C005FA86D /* VideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535BAEA4264A151C005FA86D /* VideoPlayer.swift */; }; + 5362E4D3267D461F000E2F71 /* SwiftProtobuf in Frameworks */ = {isa = PBXBuildFile; productRef = 5362E4D2267D461F000E2F71 /* SwiftProtobuf */; }; + 5362E4D6267D4671000E2F71 /* Result in Frameworks */ = {isa = PBXBuildFile; productRef = 5362E4D5267D4671000E2F71 /* Result */; }; + 5362E4D9267D4695000E2F71 /* SwiftyJSON in Frameworks */ = {isa = PBXBuildFile; productRef = 5362E4D8267D4695000E2F71 /* SwiftyJSON */; }; + 5362E500267D4707000E2F71 /* CastClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5362E4DE267D4707000E2F71 /* CastClient.swift */; }; + 5362E501267D4707000E2F71 /* CastDeviceScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5362E4DF267D4707000E2F71 /* CastDeviceScanner.swift */; }; + 5362E502267D4707000E2F71 /* ReceiverControlChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5362E4E1267D4707000E2F71 /* ReceiverControlChannel.swift */; }; + 5362E503267D4707000E2F71 /* Channelable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5362E4E2267D4707000E2F71 /* Channelable.swift */; }; + 5362E504267D4707000E2F71 /* CastChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5362E4E3267D4707000E2F71 /* CastChannel.swift */; }; + 5362E505267D4707000E2F71 /* DeviceAuthChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5362E4E4267D4707000E2F71 /* DeviceAuthChannel.swift */; }; + 5362E506267D4707000E2F71 /* MediaControlChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5362E4E5267D4707000E2F71 /* MediaControlChannel.swift */; }; + 5362E507267D4707000E2F71 /* HeartbeatChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5362E4E6267D4707000E2F71 /* HeartbeatChannel.swift */; }; + 5362E508267D4707000E2F71 /* DeviceSetupChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5362E4E7267D4707000E2F71 /* DeviceSetupChannel.swift */; }; + 5362E509267D4707000E2F71 /* DeviceConnectionChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5362E4E8267D4707000E2F71 /* DeviceConnectionChannel.swift */; }; + 5362E50A267D4707000E2F71 /* RequestSink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5362E4E9267D4707000E2F71 /* RequestSink.swift */; }; + 5362E50B267D4707000E2F71 /* MultizoneControlChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5362E4EA267D4707000E2F71 /* MultizoneControlChannel.swift */; }; + 5362E50C267D4707000E2F71 /* DeviceDiscoveryChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5362E4EB267D4707000E2F71 /* DeviceDiscoveryChannel.swift */; }; + 5362E50D267D4707000E2F71 /* CastDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5362E4ED267D4707000E2F71 /* CastDevice.swift */; }; + 5362E50E267D4707000E2F71 /* CastStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5362E4EE267D4707000E2F71 /* CastStatus.swift */; }; + 5362E50F267D4707000E2F71 /* CastApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5362E4EF267D4707000E2F71 /* CastApp.swift */; }; + 5362E510267D4707000E2F71 /* CastMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5362E4F0267D4707000E2F71 /* CastMessage.swift */; }; + 5362E511267D4707000E2F71 /* CastMediaStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5362E4F1267D4707000E2F71 /* CastMediaStatus.swift */; }; + 5362E512267D4707000E2F71 /* CastMedia.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5362E4F2267D4707000E2F71 /* CastMedia.swift */; }; + 5362E513267D4707000E2F71 /* CastMultizoneDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5362E4F3267D4707000E2F71 /* CastMultizoneDevice.swift */; }; + 5362E514267D4707000E2F71 /* CastMultizoneStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5362E4F4267D4707000E2F71 /* CastMultizoneStatus.swift */; }; + 5362E515267D4707000E2F71 /* AppAvailability.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5362E4F5267D4707000E2F71 /* AppAvailability.swift */; }; + 5362E517267D4707000E2F71 /* CASTV2Protocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5362E4FA267D4707000E2F71 /* CASTV2Protocol.swift */; }; + 5362E518267D4707000E2F71 /* cast_channel.proto in Sources */ = {isa = PBXBuildFile; fileRef = 5362E4FD267D4707000E2F71 /* cast_channel.proto */; }; + 5362E519267D4707000E2F71 /* cast_channel.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5362E4FE267D4707000E2F71 /* cast_channel.pb.swift */; }; + 5362E51A267D4707000E2F71 /* CastV2PlatformReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5362E4FF267D4707000E2F71 /* CastV2PlatformReader.swift */; }; 5364F455266CA0DC0026ECBA /* APIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* APIExtensions.swift */; }; 5364F456266CA0DC0026ECBA /* APIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* APIExtensions.swift */; }; 536D3D74267BA8170004248C /* BackgroundManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536D3D73267BA8170004248C /* BackgroundManager.swift */; }; @@ -224,6 +253,50 @@ 535870AC2669D8DD00D05A09 /* Typings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Typings.swift; sourceTree = ""; }; 535BAE9E2649E569005FA86D /* ItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemView.swift; sourceTree = ""; }; 535BAEA4264A151C005FA86D /* VideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = ""; }; + 5362E4A7267D4067000E2F71 /* GoogleCast.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = GoogleCast.framework; path = "../../Downloads/GoogleCastSDK-ios-4.6.0_dynamic/GoogleCast.framework"; sourceTree = ""; }; + 5362E4AA267D40AD000E2F71 /* AVFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFoundation.framework; path = System/Library/Frameworks/AVFoundation.framework; sourceTree = SDKROOT; }; + 5362E4AC267D40B1000E2F71 /* Accelerate.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Accelerate.framework; path = System/Library/Frameworks/Accelerate.framework; sourceTree = SDKROOT; }; + 5362E4AE267D40B5000E2F71 /* AudioToolbox.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AudioToolbox.framework; path = System/Library/Frameworks/AudioToolbox.framework; sourceTree = SDKROOT; }; + 5362E4B0267D40B9000E2F71 /* CFNetwork.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CFNetwork.framework; path = System/Library/Frameworks/CFNetwork.framework; sourceTree = SDKROOT; }; + 5362E4B2267D40BE000E2F71 /* CoreBluetooth.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreBluetooth.framework; path = System/Library/Frameworks/CoreBluetooth.framework; sourceTree = SDKROOT; }; + 5362E4B4267D40C5000E2F71 /* CoreData.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreData.framework; path = System/Library/Frameworks/CoreData.framework; sourceTree = SDKROOT; }; + 5362E4B6267D40CA000E2F71 /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; }; + 5362E4B8267D40CE000E2F71 /* CoreMedia.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreMedia.framework; path = System/Library/Frameworks/CoreMedia.framework; sourceTree = SDKROOT; }; + 5362E4BA267D40D2000E2F71 /* CoreText.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreText.framework; path = System/Library/Frameworks/CoreText.framework; sourceTree = SDKROOT; }; + 5362E4BC267D40D8000E2F71 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; }; + 5362E4BE267D40E4000E2F71 /* MediaAccessibility.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaAccessibility.framework; path = System/Library/Frameworks/MediaAccessibility.framework; sourceTree = SDKROOT; }; + 5362E4C0267D40E8000E2F71 /* MediaPlayer.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaPlayer.framework; path = System/Library/Frameworks/MediaPlayer.framework; sourceTree = SDKROOT; }; + 5362E4C2267D40EC000E2F71 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; }; + 5362E4C4267D40F0000E2F71 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; + 5362E4C6267D40F4000E2F71 /* SystemConfiguration.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SystemConfiguration.framework; path = System/Library/Frameworks/SystemConfiguration.framework; sourceTree = SDKROOT; }; + 5362E4C8267D40F7000E2F71 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; + 5362E4DE267D4707000E2F71 /* CastClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CastClient.swift; sourceTree = ""; }; + 5362E4DF267D4707000E2F71 /* CastDeviceScanner.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CastDeviceScanner.swift; sourceTree = ""; }; + 5362E4E1267D4707000E2F71 /* ReceiverControlChannel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReceiverControlChannel.swift; sourceTree = ""; }; + 5362E4E2267D4707000E2F71 /* Channelable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Channelable.swift; sourceTree = ""; }; + 5362E4E3267D4707000E2F71 /* CastChannel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CastChannel.swift; sourceTree = ""; }; + 5362E4E4267D4707000E2F71 /* DeviceAuthChannel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceAuthChannel.swift; sourceTree = ""; }; + 5362E4E5267D4707000E2F71 /* MediaControlChannel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaControlChannel.swift; sourceTree = ""; }; + 5362E4E6267D4707000E2F71 /* HeartbeatChannel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HeartbeatChannel.swift; sourceTree = ""; }; + 5362E4E7267D4707000E2F71 /* DeviceSetupChannel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceSetupChannel.swift; sourceTree = ""; }; + 5362E4E8267D4707000E2F71 /* DeviceConnectionChannel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceConnectionChannel.swift; sourceTree = ""; }; + 5362E4E9267D4707000E2F71 /* RequestSink.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RequestSink.swift; sourceTree = ""; }; + 5362E4EA267D4707000E2F71 /* MultizoneControlChannel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MultizoneControlChannel.swift; sourceTree = ""; }; + 5362E4EB267D4707000E2F71 /* DeviceDiscoveryChannel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceDiscoveryChannel.swift; sourceTree = ""; }; + 5362E4ED267D4707000E2F71 /* CastDevice.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CastDevice.swift; sourceTree = ""; }; + 5362E4EE267D4707000E2F71 /* CastStatus.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CastStatus.swift; sourceTree = ""; }; + 5362E4EF267D4707000E2F71 /* CastApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CastApp.swift; sourceTree = ""; }; + 5362E4F0267D4707000E2F71 /* CastMessage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CastMessage.swift; sourceTree = ""; }; + 5362E4F1267D4707000E2F71 /* CastMediaStatus.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CastMediaStatus.swift; sourceTree = ""; }; + 5362E4F2267D4707000E2F71 /* CastMedia.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CastMedia.swift; sourceTree = ""; }; + 5362E4F3267D4707000E2F71 /* CastMultizoneDevice.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CastMultizoneDevice.swift; sourceTree = ""; }; + 5362E4F4267D4707000E2F71 /* CastMultizoneStatus.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CastMultizoneStatus.swift; sourceTree = ""; }; + 5362E4F5267D4707000E2F71 /* AppAvailability.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppAvailability.swift; sourceTree = ""; }; + 5362E4F7267D4707000E2F71 /* ChromeCastCore.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ChromeCastCore.h; sourceTree = ""; }; + 5362E4FA267D4707000E2F71 /* CASTV2Protocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CASTV2Protocol.swift; sourceTree = ""; }; + 5362E4FD267D4707000E2F71 /* cast_channel.proto */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.protobuf; path = cast_channel.proto; sourceTree = ""; }; + 5362E4FE267D4707000E2F71 /* cast_channel.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = cast_channel.pb.swift; sourceTree = ""; }; + 5362E4FF267D4707000E2F71 /* CastV2PlatformReader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CastV2PlatformReader.swift; sourceTree = ""; }; 5364F454266CA0DC0026ECBA /* APIExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIExtensions.swift; sourceTree = ""; }; 536D3D73267BA8170004248C /* BackgroundManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundManager.swift; sourceTree = ""; }; 536D3D75267BA9BB0004248C /* MainTabViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabViewModel.swift; sourceTree = ""; }; @@ -318,11 +391,14 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 5362E4D9267D4695000E2F71 /* SwiftyJSON in Frameworks */, 5338F757263B7E2E0014BF09 /* KeychainSwift in Frameworks */, 53352571265EA0A0006CCA86 /* Introspect in Frameworks */, 621C638026672A30004216EA /* NukeUI in Frameworks */, 625CB57A2678C4A400530A6E /* ActivityIndicator in Frameworks */, 53A431BD266B0FF20016769F /* JellyfinAPI in Frameworks */, + 5362E4D6267D4671000E2F71 /* Result in Frameworks */, + 5362E4D3267D461F000E2F71 /* SwiftProtobuf in Frameworks */, 62EC3527267665D8000E9F2D /* MobileVLCKit.xcframework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -418,6 +494,96 @@ path = Typings; sourceTree = ""; }; + 5362E4DC267D4707000E2F71 /* OpenCastSwift */ = { + isa = PBXGroup; + children = ( + 5362E4DD267D4707000E2F71 /* Networking */, + 5362E4EC267D4707000E2F71 /* Models */, + 5362E4F6267D4707000E2F71 /* Supporting Files */, + 5362E4F9267D4707000E2F71 /* Definitions */, + 5362E4FB267D4707000E2F71 /* Helpers */, + ); + path = OpenCastSwift; + sourceTree = ""; + }; + 5362E4DD267D4707000E2F71 /* Networking */ = { + isa = PBXGroup; + children = ( + 5362E4DE267D4707000E2F71 /* CastClient.swift */, + 5362E4DF267D4707000E2F71 /* CastDeviceScanner.swift */, + 5362E4E0267D4707000E2F71 /* Channels */, + ); + path = Networking; + sourceTree = ""; + }; + 5362E4E0267D4707000E2F71 /* Channels */ = { + isa = PBXGroup; + children = ( + 5362E4E1267D4707000E2F71 /* ReceiverControlChannel.swift */, + 5362E4E2267D4707000E2F71 /* Channelable.swift */, + 5362E4E3267D4707000E2F71 /* CastChannel.swift */, + 5362E4E4267D4707000E2F71 /* DeviceAuthChannel.swift */, + 5362E4E5267D4707000E2F71 /* MediaControlChannel.swift */, + 5362E4E6267D4707000E2F71 /* HeartbeatChannel.swift */, + 5362E4E7267D4707000E2F71 /* DeviceSetupChannel.swift */, + 5362E4E8267D4707000E2F71 /* DeviceConnectionChannel.swift */, + 5362E4E9267D4707000E2F71 /* RequestSink.swift */, + 5362E4EA267D4707000E2F71 /* MultizoneControlChannel.swift */, + 5362E4EB267D4707000E2F71 /* DeviceDiscoveryChannel.swift */, + ); + path = Channels; + sourceTree = ""; + }; + 5362E4EC267D4707000E2F71 /* Models */ = { + isa = PBXGroup; + children = ( + 5362E4ED267D4707000E2F71 /* CastDevice.swift */, + 5362E4EE267D4707000E2F71 /* CastStatus.swift */, + 5362E4EF267D4707000E2F71 /* CastApp.swift */, + 5362E4F0267D4707000E2F71 /* CastMessage.swift */, + 5362E4F1267D4707000E2F71 /* CastMediaStatus.swift */, + 5362E4F2267D4707000E2F71 /* CastMedia.swift */, + 5362E4F3267D4707000E2F71 /* CastMultizoneDevice.swift */, + 5362E4F4267D4707000E2F71 /* CastMultizoneStatus.swift */, + 5362E4F5267D4707000E2F71 /* AppAvailability.swift */, + ); + path = Models; + sourceTree = ""; + }; + 5362E4F6267D4707000E2F71 /* Supporting Files */ = { + isa = PBXGroup; + children = ( + 5362E4F7267D4707000E2F71 /* ChromeCastCore.h */, + ); + path = "Supporting Files"; + sourceTree = ""; + }; + 5362E4F9267D4707000E2F71 /* Definitions */ = { + isa = PBXGroup; + children = ( + 5362E4FA267D4707000E2F71 /* CASTV2Protocol.swift */, + ); + path = Definitions; + sourceTree = ""; + }; + 5362E4FB267D4707000E2F71 /* Helpers */ = { + isa = PBXGroup; + children = ( + 5362E4FC267D4707000E2F71 /* Proto */, + 5362E4FF267D4707000E2F71 /* CastV2PlatformReader.swift */, + ); + path = Helpers; + sourceTree = ""; + }; + 5362E4FC267D4707000E2F71 /* Proto */ = { + isa = PBXGroup; + children = ( + 5362E4FD267D4707000E2F71 /* cast_channel.proto */, + 5362E4FE267D4707000E2F71 /* cast_channel.pb.swift */, + ); + path = Proto; + sourceTree = ""; + }; 536D3D77267BB9650004248C /* Components */ = { isa = PBXGroup; children = ( @@ -454,6 +620,7 @@ 5377CBF3263B596A003A4E83 /* JellyfinPlayer */ = { isa = PBXGroup; children = ( + 5362E4DC267D4707000E2F71 /* OpenCastSwift */, 53AD124C2670278D0094A276 /* JellyfinPlayer.entitlements */, 5377CBF8263B596B003A4E83 /* Assets.xcassets */, 5338F74D263B61370014BF09 /* ConnectToServerView.swift */, @@ -498,6 +665,23 @@ 53D5E3DB264B47EE00BADDC8 /* Frameworks */ = { isa = PBXGroup; children = ( + 5362E4C8267D40F7000E2F71 /* UIKit.framework */, + 5362E4C6267D40F4000E2F71 /* SystemConfiguration.framework */, + 5362E4C4267D40F0000E2F71 /* Security.framework */, + 5362E4C2267D40EC000E2F71 /* QuartzCore.framework */, + 5362E4C0267D40E8000E2F71 /* MediaPlayer.framework */, + 5362E4BE267D40E4000E2F71 /* MediaAccessibility.framework */, + 5362E4BC267D40D8000E2F71 /* Foundation.framework */, + 5362E4BA267D40D2000E2F71 /* CoreText.framework */, + 5362E4B8267D40CE000E2F71 /* CoreMedia.framework */, + 5362E4B6267D40CA000E2F71 /* CoreGraphics.framework */, + 5362E4B4267D40C5000E2F71 /* CoreData.framework */, + 5362E4B2267D40BE000E2F71 /* CoreBluetooth.framework */, + 5362E4B0267D40B9000E2F71 /* CFNetwork.framework */, + 5362E4AE267D40B5000E2F71 /* AudioToolbox.framework */, + 5362E4AC267D40B1000E2F71 /* Accelerate.framework */, + 5362E4AA267D40AD000E2F71 /* AVFoundation.framework */, + 5362E4A7267D4067000E2F71 /* GoogleCast.framework */, 53ABFDDB267972BF00886593 /* TVServices.framework */, 625CB57D2678E81E00530A6E /* TVVLCKit.xcframework */, 53D5E3DC264B47EE00BADDC8 /* MobileVLCKit.xcframework */, @@ -614,6 +798,9 @@ 621C637F26672A30004216EA /* NukeUI */, 53A431BC266B0FF20016769F /* JellyfinAPI */, 625CB5792678C4A400530A6E /* ActivityIndicator */, + 5362E4D2267D461F000E2F71 /* SwiftProtobuf */, + 5362E4D5267D4671000E2F71 /* Result */, + 5362E4D8267D4695000E2F71 /* SwiftyJSON */, ); productName = JellyfinPlayer; productReference = 5377CBF1263B596A003A4E83 /* JellyfinPlayer iOS.app */; @@ -681,6 +868,9 @@ 53A431BB266B0FF20016769F /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */, 625CB5782678C4A400530A6E /* XCRemoteSwiftPackageReference "ActivityIndicator" */, 536D3D82267BEA550004248C /* XCRemoteSwiftPackageReference "ParallaxView" */, + 5362E4D1267D461F000E2F71 /* XCRemoteSwiftPackageReference "swift-protobuf" */, + 5362E4D4267D4671000E2F71 /* XCRemoteSwiftPackageReference "Result" */, + 5362E4D7267D4695000E2F71 /* XCRemoteSwiftPackageReference "SwiftyJSON" */, ); productRefGroup = 5377CBF2263B596A003A4E83 /* Products */; projectDirPath = ""; @@ -782,53 +972,79 @@ files = ( 5364F455266CA0DC0026ECBA /* APIExtensions.swift in Sources */, 621338932660107500A81A2A /* StringExtensions.swift in Sources */, + 5362E507267D4707000E2F71 /* HeartbeatChannel.swift in Sources */, 53FF7F2A263CF3F500585C35 /* LatestMediaView.swift in Sources */, 62E632EC267D410B0063E547 /* SeriesItemViewModel.swift in Sources */, + 5362E506267D4707000E2F71 /* MediaControlChannel.swift in Sources */, 625CB5732678C32A00530A6E /* HomeViewModel.swift in Sources */, 62E632DC267D2E130063E547 /* LibrarySearchViewModel.swift in Sources */, 5377CBFE263B596B003A4E83 /* PersistenceController.swift in Sources */, + 5362E500267D4707000E2F71 /* CastClient.swift in Sources */, 5389276E263C25100035E14B /* ContinueWatchingView.swift in Sources */, 53AD124E26702B8A0094A276 /* SeasonItemView.swift in Sources */, 535BAE9F2649E569005FA86D /* ItemView.swift in Sources */, 6225FCCB2663841E00E067F6 /* ParallaxHeader.swift in Sources */, 625CB5772678C34300530A6E /* ConnectToServerViewModel.swift in Sources */, 536D3D78267BD5C30004248C /* ViewModel.swift in Sources */, + 5362E508267D4707000E2F71 /* DeviceSetupChannel.swift in Sources */, 53DE4BD02670961400739748 /* EpisodeItemView.swift in Sources */, 53F8377D265FF67C00F456B3 /* VideoPlayerSettingsView.swift in Sources */, 53192D5D265AA78A008A4215 /* DeviceProfileBuilder.swift in Sources */, + 5362E50E267D4707000E2F71 /* CastStatus.swift in Sources */, 62133890265F83A900A81A2A /* LibraryListView.swift in Sources */, 62E632DA267D2BC40063E547 /* LatestMediaViewModel.swift in Sources */, 625CB56F2678C23300530A6E /* HomeView.swift in Sources */, 53892770263C25230035E14B /* NextUpView.swift in Sources */, + 5362E513267D4707000E2F71 /* CastMultizoneDevice.swift in Sources */, 625CB5682678B6FB00530A6E /* SplashView.swift in Sources */, 535BAEA5264A151C005FA86D /* VideoPlayer.swift in Sources */, 62E632E6267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */, 532175402671EE4F005491E6 /* LibraryFilterView.swift in Sources */, + 5362E50A267D4707000E2F71 /* RequestSink.swift in Sources */, 5377CC01263B596B003A4E83 /* Model.xcdatamodeld in Sources */, 53DF641E263D9C0600A7CD1A /* LibraryView.swift in Sources */, + 5362E514267D4707000E2F71 /* CastMultizoneStatus.swift in Sources */, 6267B3D626710B8900A7371D /* CollectionExtensions.swift in Sources */, + 5362E503267D4707000E2F71 /* Channelable.swift in Sources */, 53A089D0264DA9DA00D57806 /* MovieItemView.swift in Sources */, 62E632E9267D3FF50063E547 /* SeasonItemViewModel.swift in Sources */, 625CB56A2678B71200530A6E /* SplashViewModel.swift in Sources */, 62E632F3267D54030063E547 /* DetailItemViewModel.swift in Sources */, + 5362E50C267D4707000E2F71 /* DeviceDiscoveryChannel.swift in Sources */, 53DE4BD2267098F300739748 /* SearchBarView.swift in Sources */, + 5362E50D267D4707000E2F71 /* CastDevice.swift in Sources */, 53E4E649263F725B00F67C6B /* MultiSelectorView.swift in Sources */, + 5362E509267D4707000E2F71 /* DeviceConnectionChannel.swift in Sources */, 621338B32660A07800A81A2A /* LazyView.swift in Sources */, 531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */, 62E632E0267D30CA0063E547 /* LibraryViewModel.swift in Sources */, 62EC352F267666A5000E9F2D /* SessionManager.swift in Sources */, 62E632E3267D3BA60063E547 /* MovieItemViewModel.swift in Sources */, 62E632EF267D43320063E547 /* LibraryFilterViewModel.swift in Sources */, + 5362E512267D4707000E2F71 /* CastMedia.swift in Sources */, + 5362E519267D4707000E2F71 /* cast_channel.pb.swift in Sources */, 535870AD2669D8DD00D05A09 /* Typings.swift in Sources */, 62EC352C26766675000E9F2D /* ServerEnvironment.swift in Sources */, 6267B3DA2671138200A7371D /* ImageExtensions.swift in Sources */, + 5362E50B267D4707000E2F71 /* MultizoneControlChannel.swift in Sources */, + 5362E517267D4707000E2F71 /* CASTV2Protocol.swift in Sources */, 62EC353426766B03000E9F2D /* DeviceRotationViewModifier.swift in Sources */, 5389277C263CC3DB0035E14B /* BlurHashDecode.swift in Sources */, + 5362E518267D4707000E2F71 /* cast_channel.proto in Sources */, 625CB56C2678C0FD00530A6E /* MainTabView.swift in Sources */, 539B2DA5263BA5B8007FF1A4 /* SettingsView.swift in Sources */, + 5362E511267D4707000E2F71 /* CastMediaStatus.swift in Sources */, 5338F74E263B61370014BF09 /* ConnectToServerView.swift in Sources */, + 5362E505267D4707000E2F71 /* DeviceAuthChannel.swift in Sources */, 53AD124D267029D60094A276 /* SeriesItemView.swift in Sources */, + 5362E50F267D4707000E2F71 /* CastApp.swift in Sources */, + 5362E501267D4707000E2F71 /* CastDeviceScanner.swift in Sources */, + 5362E502267D4707000E2F71 /* ReceiverControlChannel.swift in Sources */, + 5362E504267D4707000E2F71 /* CastChannel.swift in Sources */, + 5362E515267D4707000E2F71 /* AppAvailability.swift in Sources */, + 5362E51A267D4707000E2F71 /* CastV2PlatformReader.swift in Sources */, + 5362E510267D4707000E2F71 /* CastMessage.swift in Sources */, 5377CBF5263B596A003A4E83 /* JellyfinPlayerApp.swift in Sources */, 53EE24E6265060780068F029 /* LibrarySearchView.swift in Sources */, 53892772263C8C6F0035E14B /* LoadingView.swift in Sources */, @@ -1225,6 +1441,30 @@ minimumVersion = 19.0.0; }; }; + 5362E4D1267D461F000E2F71 /* XCRemoteSwiftPackageReference "swift-protobuf" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-protobuf.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; + }; + }; + 5362E4D4267D4671000E2F71 /* XCRemoteSwiftPackageReference "Result" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/antitypical/Result"; + requirement = { + branch = master; + kind = branch; + }; + }; + 5362E4D7267D4695000E2F71 /* XCRemoteSwiftPackageReference "SwiftyJSON" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SwiftyJSON/SwiftyJSON"; + requirement = { + branch = master; + kind = branch; + }; + }; 536D3D82267BEA550004248C /* XCRemoteSwiftPackageReference "ParallaxView" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/PGSSoft/ParallaxView"; @@ -1285,6 +1525,21 @@ package = 621C637E26672A30004216EA /* XCRemoteSwiftPackageReference "NukeUI" */; productName = NukeUI; }; + 5362E4D2267D461F000E2F71 /* SwiftProtobuf */ = { + isa = XCSwiftPackageProductDependency; + package = 5362E4D1267D461F000E2F71 /* XCRemoteSwiftPackageReference "swift-protobuf" */; + productName = SwiftProtobuf; + }; + 5362E4D5267D4671000E2F71 /* Result */ = { + isa = XCSwiftPackageProductDependency; + package = 5362E4D4267D4671000E2F71 /* XCRemoteSwiftPackageReference "Result" */; + productName = Result; + }; + 5362E4D8267D4695000E2F71 /* SwiftyJSON */ = { + isa = XCSwiftPackageProductDependency; + package = 5362E4D7267D4695000E2F71 /* XCRemoteSwiftPackageReference "SwiftyJSON" */; + productName = SwiftyJSON; + }; 536D3D7C267BD5F90004248C /* ActivityIndicator */ = { isa = XCSwiftPackageProductDependency; package = 625CB5782678C4A400530A6E /* XCRemoteSwiftPackageReference "ActivityIndicator" */; diff --git a/JellyfinPlayer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/JellyfinPlayer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index ef329286..498af284 100644 --- a/JellyfinPlayer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/JellyfinPlayer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -73,6 +73,24 @@ "version": "3.1.2" } }, + { + "package": "Result", + "repositoryURL": "https://github.com/antitypical/Result", + "state": { + "branch": "master", + "revision": "c30700bfcab7f555bf1d386fdd609407a94f369c", + "version": null + } + }, + { + "package": "swift-protobuf", + "repositoryURL": "https://github.com/apple/swift-protobuf.git", + "state": { + "branch": null, + "revision": "1f62db409f2c9b0223a3f68567b4a01333aae778", + "version": "1.17.0" + } + }, { "package": "Introspect", "repositoryURL": "https://github.com/siteline/SwiftUI-Introspect", @@ -81,6 +99,15 @@ "revision": "2e09be8af614401bc9f87d40093ec19ce56ccaf2", "version": "0.1.3" } + }, + { + "package": "SwiftyJSON", + "repositoryURL": "https://github.com/SwiftyJSON/SwiftyJSON", + "state": { + "branch": "master", + "revision": "b3dcd7dbd0d488e1a7077cb33b00f2083e382f07", + "version": null + } } ] }, diff --git a/JellyfinPlayer/JellyfinPlayerApp.swift b/JellyfinPlayer/JellyfinPlayerApp.swift index 1afbf8a2..c5103ef3 100644 --- a/JellyfinPlayer/JellyfinPlayerApp.swift +++ b/JellyfinPlayer/JellyfinPlayerApp.swift @@ -142,13 +142,23 @@ extension View { struct JellyfinPlayerApp: App { let persistenceController = PersistenceController.shared + func test_cast() { + let scanner = CastDeviceScanner() + + NotificationCenter.default.addObserver(forName: CastDeviceScanner.deviceListDidChange, object: scanner, queue: nil) { _ in + dump(scanner.devices) + } + + scanner.startScanning() + } + var body: some Scene { WindowGroup { SplashView() .environment(\.managedObjectContext, persistenceController.container.viewContext) .withHostingWindow { window in window?.rootViewController = PreferenceUIHostingController(wrappedView: SplashView().environment(\.managedObjectContext, persistenceController.container.viewContext)) - } + }.onAppear(perform: test_cast) } } } diff --git a/JellyfinPlayer/OpenCastSwift/Definitions/CASTV2Protocol.swift b/JellyfinPlayer/OpenCastSwift/Definitions/CASTV2Protocol.swift new file mode 100644 index 00000000..3f629cb1 --- /dev/null +++ b/JellyfinPlayer/OpenCastSwift/Definitions/CASTV2Protocol.swift @@ -0,0 +1,96 @@ +// +// CASTV2Protocol.swift +// OpenCastSwift +// +// Created by Miles Hollingsworth on 4/22/18 +// Copyright © 2018 Miles Hollingsworth. All rights reserved. +// + +import Foundation + +struct CastNamespace { + static let auth = "urn:x-cast:com.google.cast.tp.deviceauth" + static let connection = "urn:x-cast:com.google.cast.tp.connection" + static let heartbeat = "urn:x-cast:com.google.cast.tp.heartbeat" + static let receiver = "urn:x-cast:com.google.cast.receiver" + static let media = "urn:x-cast:com.google.cast.media" + static let discovery = "urn:x-cast:com.google.cast.receiver.discovery" + static let setup = "urn:x-cast:com.google.cast.setup" + static let multizone = "urn:x-cast:com.google.cast.multizone" +} + +enum CastMessageType: String { + case ping = "PING" + case pong = "PONG" + case connect = "CONNECT" + case close = "CLOSE" + case status = "RECEIVER_STATUS" + case launch = "LAUNCH" + case stop = "STOP" + case load = "LOAD" + case pause = "PAUSE" + case play = "PLAY" + case seek = "SEEK" + case setVolume = "SET_VOLUME" + case setDeviceVolume = "SET_DEVICE_VOLUME" + case statusRequest = "GET_STATUS" + case availableApps = "GET_APP_AVAILABILITY" + case mediaStatus = "MEDIA_STATUS" + case getDeviceInfo = "GET_DEVICE_INFO" + case deviceInfo = "DEVICE_INFO" + case getDeviceConfig = "eureka_info" + case setDeviceConfig = "set_eureka_info" + case getAppDeviceId = "get_app_device_id" + case multizoneStatus = "MULTIZONE_STATUS" + case deviceAdded = "DEVICE_ADDED" + case deviceUpdated = "DEVICE_UPDATED" + case deviceRemoved = "DEVICE_REMOVED" + case invalidRequest = "INVALID_REQUEST" + case mdxSessionStatus = "mdxSessionStatus" +} + +struct CastJSONPayloadKeys { + static let type = "type" + static let requestId = "requestId" + static let status = "status" + static let applications = "applications" + static let appId = "appId" + static let displayName = "displayName" + static let sessionId = "sessionId" + static let transportId = "transportId" + static let statusText = "statusText" + static let isIdleScreen = "isIdleScreen" + static let namespaces = "namespaces" + static let volume = "volume" + static let controlType = "controlType" + static let level = "level" + static let muted = "muted" + static let mediaSessionId = "mediaSessionId" + static let availability = "availability" + static let name = "name" + static let currentTime = "currentTime" + static let media = "media" + static let repeatMode = "repeatMode" + static let autoplay = "autoplay" + static let contentId = "contentId" + static let contentType = "contentType" + static let streamType = "streamType" + static let metadata = "metadata" + static let metadataType = "metadataType" + static let title = "title" + static let images = "images" + static let url = "url" + static let activeTrackIds = "activeTrackIds" + static let playbackRate = "playbackRate" + static let playerState = "playerState" + static let deviceId = "deviceId" + static let device = "device" + static let devices = "devices" + static let capabilities = "capabilities" +} + +struct CastConstants { + static let sender = "sender-0" + static let receiver = "receiver-0" + static let transport = "transport-0" +} diff --git a/JellyfinPlayer/OpenCastSwift/Helpers/CastV2PlatformReader.swift b/JellyfinPlayer/OpenCastSwift/Helpers/CastV2PlatformReader.swift new file mode 100644 index 00000000..a96bb4a1 --- /dev/null +++ b/JellyfinPlayer/OpenCastSwift/Helpers/CastV2PlatformReader.swift @@ -0,0 +1,83 @@ +// +// CastV2PlatformReader.swift +// OpenCastSwift Mac +// +// Created by Miles Hollingsworth on 4/22/18 +// Copyright © 2018 Miles Hollingsworth. All rights reserved. +// + +let maxBufferLength = 8192 + +import Foundation + +class CastV2PlatformReader { + let stream: InputStream + var readPosition = 0 + var buffer = Data(capacity: maxBufferLength) + + init(stream: InputStream) { + self.stream = stream + } + + func readStream() { + objc_sync_enter(self) + defer { objc_sync_exit(self) } + + var totalBytesRead = 0 + let bufferSize = 32 + + while stream.hasBytesAvailable { + var bytes = [UInt8](repeating: 0, count: bufferSize) + + let bytesRead = stream.read(&bytes, maxLength: bufferSize) + + if bytesRead < 0 { continue } + + buffer.append(Data(bytes: &bytes, count: bytesRead)) + + totalBytesRead += bytesRead + } + } + + func nextMessage() -> Data? { + objc_sync_enter(self) + defer { objc_sync_exit(self) } + + let headerSize = MemoryLayout.size + guard buffer.count - readPosition >= headerSize else { return nil } + let header = buffer.withUnsafeBytes({ (pointer: UnsafePointer) -> UInt32 in + return pointer.advanced(by: self.readPosition).withMemoryRebound(to: UInt32.self, capacity: 1, { $0.pointee }) + }) + + let payloadSize = Int(CFSwapInt32BigToHost(header)) + + readPosition += headerSize + + guard buffer.count >= readPosition + payloadSize, buffer.count - readPosition >= payloadSize, payloadSize >= 0 else { + // Message hasn't arrived + readPosition -= headerSize + return nil + } + + let payload = buffer.withUnsafeBytes({ (pointer: UnsafePointer) -> Data in + return Data(bytes: pointer.advanced(by: self.readPosition), count: payloadSize) + }) + readPosition += payloadSize + + resetBufferIfNeeded() + + return payload + } + + private func resetBufferIfNeeded() { + guard buffer.count >= maxBufferLength else { return } + + if readPosition == buffer.count { + buffer = Data(capacity: maxBufferLength) + } else { + buffer = buffer.advanced(by: readPosition) + } + + readPosition = 0 + } +} diff --git a/JellyfinPlayer/OpenCastSwift/Helpers/Proto/cast_channel.pb.swift b/JellyfinPlayer/OpenCastSwift/Helpers/Proto/cast_channel.pb.swift new file mode 100644 index 00000000..25a61633 --- /dev/null +++ b/JellyfinPlayer/OpenCastSwift/Helpers/Proto/cast_channel.pb.swift @@ -0,0 +1,618 @@ +// DO NOT EDIT. +// +// Generated by the Swift generator plugin for the protocol buffer compiler. +// Source: cast_channel.proto +// +// For information on using the generated types, please see the documenation: +// https://github.com/apple/swift-protobuf/ + +// Copyright 2013 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import Foundation +import SwiftProtobuf + +// If the compiler emits an error on this type, it is because this file +// was generated by a version of the `protoc` Swift plug-in that is +// incompatible with the version of SwiftProtobuf to which you are linking. +// Please ensure that your are building against the same version of the API +// that was used to generate this file. +private struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAPIVersionCheck { + struct _2: SwiftProtobuf.ProtobufAPIVersion_2 {} + typealias Version = _2 +} + +struct Extensions_Api_CastChannel_CastMessage: SwiftProtobuf.Message { + static let protoMessageName: String = _protobuf_package + ".CastMessage" + + var protocolVersion: Extensions_Api_CastChannel_CastMessage.ProtocolVersion { + get {return _protocolVersion ?? .castv210} + set {_protocolVersion = newValue} + } + /// Returns true if `protocolVersion` has been explicitly set. + var hasProtocolVersion: Bool {return self._protocolVersion != nil} + /// Clears the value of `protocolVersion`. Subsequent reads from it will return its default value. + mutating func clearProtocolVersion() {self._protocolVersion = nil} + + /// source and destination ids identify the origin and destination of the + /// message. They are used to route messages between endpoints that share a + /// device-to-device channel. + /// + /// For messages between applications: + /// - The sender application id is a unique identifier generated on behalf of + /// the sender application. + /// - The receiver id is always the the session id for the application. + /// + /// For messages to or from the sender or receiver platform, the special ids + /// 'sender-0' and 'receiver-0' can be used. + /// + /// For messages intended for all endpoints using a given channel, the + /// wildcard destination_id '*' can be used. + var sourceID: String { + get {return _sourceID ?? String()} + set {_sourceID = newValue} + } + /// Returns true if `sourceID` has been explicitly set. + var hasSourceID: Bool {return self._sourceID != nil} + /// Clears the value of `sourceID`. Subsequent reads from it will return its default value. + mutating func clearSourceID() {self._sourceID = nil} + + var destinationID: String { + get {return _destinationID ?? String()} + set {_destinationID = newValue} + } + /// Returns true if `destinationID` has been explicitly set. + var hasDestinationID: Bool {return self._destinationID != nil} + /// Clears the value of `destinationID`. Subsequent reads from it will return its default value. + mutating func clearDestinationID() {self._destinationID = nil} + + /// This is the core multiplexing key. All messages are sent on a namespace + /// and endpoints sharing a channel listen on one or more namespaces. The + /// namespace defines the protocol and semantics of the message. + var namespace: String { + get {return _namespace ?? String()} + set {_namespace = newValue} + } + /// Returns true if `namespace` has been explicitly set. + var hasNamespace: Bool {return self._namespace != nil} + /// Clears the value of `namespace`. Subsequent reads from it will return its default value. + mutating func clearNamespace() {self._namespace = nil} + + var payloadType: Extensions_Api_CastChannel_CastMessage.PayloadType { + get {return _payloadType ?? .string} + set {_payloadType = newValue} + } + /// Returns true if `payloadType` has been explicitly set. + var hasPayloadType: Bool {return self._payloadType != nil} + /// Clears the value of `payloadType`. Subsequent reads from it will return its default value. + mutating func clearPayloadType() {self._payloadType = nil} + + /// Depending on payload_type, exactly one of the following optional fields + /// will always be set. + var payloadUtf8: String { + get {return _payloadUtf8 ?? String()} + set {_payloadUtf8 = newValue} + } + /// Returns true if `payloadUtf8` has been explicitly set. + var hasPayloadUtf8: Bool {return self._payloadUtf8 != nil} + /// Clears the value of `payloadUtf8`. Subsequent reads from it will return its default value. + mutating func clearPayloadUtf8() {self._payloadUtf8 = nil} + + var payloadBinary: Data { + get {return _payloadBinary ?? SwiftProtobuf.Internal.emptyData} + set {_payloadBinary = newValue} + } + /// Returns true if `payloadBinary` has been explicitly set. + var hasPayloadBinary: Bool {return self._payloadBinary != nil} + /// Clears the value of `payloadBinary`. Subsequent reads from it will return its default value. + mutating func clearPayloadBinary() {self._payloadBinary = nil} + + var unknownFields = SwiftProtobuf.UnknownStorage() + + /// Always pass a version of the protocol for future compatibility + /// requirements. + enum ProtocolVersion: SwiftProtobuf.Enum { + typealias RawValue = Int + case castv210 // = 0 + + init() { + self = .castv210 + } + + init?(rawValue: Int) { + switch rawValue { + case 0: self = .castv210 + default: return nil + } + } + + var rawValue: Int { + switch self { + case .castv210: return 0 + } + } + + } + + /// What type of data do we have in this message. + enum PayloadType: SwiftProtobuf.Enum { + typealias RawValue = Int + case string // = 0 + case binary // = 1 + + init() { + self = .string + } + + init?(rawValue: Int) { + switch rawValue { + case 0: self = .string + case 1: self = .binary + default: return nil + } + } + + var rawValue: Int { + switch self { + case .string: return 0 + case .binary: return 1 + } + } + + } + + init() {} + + public var isInitialized: Bool { + if self._protocolVersion == nil {return false} + if self._sourceID == nil {return false} + if self._destinationID == nil {return false} + if self._namespace == nil {return false} + if self._payloadType == nil {return false} + return true + } + + /// Used by the decoding initializers in the SwiftProtobuf library, not generally + /// used directly. `init(serializedData:)`, `init(jsonUTF8Data:)`, and other decoding + /// initializers are defined in the SwiftProtobuf library. See the Message and + /// Message+*Additions` files. + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + switch fieldNumber { + case 1: try decoder.decodeSingularEnumField(value: &self._protocolVersion) + case 2: try decoder.decodeSingularStringField(value: &self._sourceID) + case 3: try decoder.decodeSingularStringField(value: &self._destinationID) + case 4: try decoder.decodeSingularStringField(value: &self._namespace) + case 5: try decoder.decodeSingularEnumField(value: &self._payloadType) + case 6: try decoder.decodeSingularStringField(value: &self._payloadUtf8) + case 7: try decoder.decodeSingularBytesField(value: &self._payloadBinary) + default: break + } + } + } + + /// Used by the encoding methods of the SwiftProtobuf library, not generally + /// used directly. `Message.serializedData()`, `Message.jsonUTF8Data()`, and + /// other serializer methods are defined in the SwiftProtobuf library. See the + /// `Message` and `Message+*Additions` files. + func traverse(visitor: inout V) throws { + if let v = self._protocolVersion { + try visitor.visitSingularEnumField(value: v, fieldNumber: 1) + } + if let v = self._sourceID { + try visitor.visitSingularStringField(value: v, fieldNumber: 2) + } + if let v = self._destinationID { + try visitor.visitSingularStringField(value: v, fieldNumber: 3) + } + if let v = self._namespace { + try visitor.visitSingularStringField(value: v, fieldNumber: 4) + } + if let v = self._payloadType { + try visitor.visitSingularEnumField(value: v, fieldNumber: 5) + } + if let v = self._payloadUtf8 { + try visitor.visitSingularStringField(value: v, fieldNumber: 6) + } + if let v = self._payloadBinary { + try visitor.visitSingularBytesField(value: v, fieldNumber: 7) + } + try unknownFields.traverse(visitor: &visitor) + } + + fileprivate var _protocolVersion: Extensions_Api_CastChannel_CastMessage.ProtocolVersion? + fileprivate var _sourceID: String? + fileprivate var _destinationID: String? + fileprivate var _namespace: String? + fileprivate var _payloadType: Extensions_Api_CastChannel_CastMessage.PayloadType? + fileprivate var _payloadUtf8: String? + fileprivate var _payloadBinary: Data? +} + +/// Messages for authentication protocol between a sender and a receiver. +struct Extensions_Api_CastChannel_AuthChallenge: SwiftProtobuf.Message { + static let protoMessageName: String = _protobuf_package + ".AuthChallenge" + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} + + /// Used by the decoding initializers in the SwiftProtobuf library, not generally + /// used directly. `init(serializedData:)`, `init(jsonUTF8Data:)`, and other decoding + /// initializers are defined in the SwiftProtobuf library. See the Message and + /// Message+*Additions` files. + mutating func decodeMessage(decoder: inout D) throws { + while let _ = try decoder.nextFieldNumber() { + } + } + + /// Used by the encoding methods of the SwiftProtobuf library, not generally + /// used directly. `Message.serializedData()`, `Message.jsonUTF8Data()`, and + /// other serializer methods are defined in the SwiftProtobuf library. See the + /// `Message` and `Message+*Additions` files. + func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } +} + +struct Extensions_Api_CastChannel_AuthResponse: SwiftProtobuf.Message { + static let protoMessageName: String = _protobuf_package + ".AuthResponse" + + var signature: Data { + get {return _signature ?? SwiftProtobuf.Internal.emptyData} + set {_signature = newValue} + } + /// Returns true if `signature` has been explicitly set. + var hasSignature: Bool {return self._signature != nil} + /// Clears the value of `signature`. Subsequent reads from it will return its default value. + mutating func clearSignature() {self._signature = nil} + + var clientAuthCertificate: Data { + get {return _clientAuthCertificate ?? SwiftProtobuf.Internal.emptyData} + set {_clientAuthCertificate = newValue} + } + /// Returns true if `clientAuthCertificate` has been explicitly set. + var hasClientAuthCertificate: Bool {return self._clientAuthCertificate != nil} + /// Clears the value of `clientAuthCertificate`. Subsequent reads from it will return its default value. + mutating func clearClientAuthCertificate() {self._clientAuthCertificate = nil} + + var clientCa: [Data] = [] + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} + + public var isInitialized: Bool { + if self._signature == nil {return false} + if self._clientAuthCertificate == nil {return false} + return true + } + + /// Used by the decoding initializers in the SwiftProtobuf library, not generally + /// used directly. `init(serializedData:)`, `init(jsonUTF8Data:)`, and other decoding + /// initializers are defined in the SwiftProtobuf library. See the Message and + /// Message+*Additions` files. + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + switch fieldNumber { + case 1: try decoder.decodeSingularBytesField(value: &self._signature) + case 2: try decoder.decodeSingularBytesField(value: &self._clientAuthCertificate) + case 3: try decoder.decodeRepeatedBytesField(value: &self.clientCa) + default: break + } + } + } + + /// Used by the encoding methods of the SwiftProtobuf library, not generally + /// used directly. `Message.serializedData()`, `Message.jsonUTF8Data()`, and + /// other serializer methods are defined in the SwiftProtobuf library. See the + /// `Message` and `Message+*Additions` files. + func traverse(visitor: inout V) throws { + if let v = self._signature { + try visitor.visitSingularBytesField(value: v, fieldNumber: 1) + } + if let v = self._clientAuthCertificate { + try visitor.visitSingularBytesField(value: v, fieldNumber: 2) + } + if !self.clientCa.isEmpty { + try visitor.visitRepeatedBytesField(value: self.clientCa, fieldNumber: 3) + } + try unknownFields.traverse(visitor: &visitor) + } + + fileprivate var _signature: Data? + fileprivate var _clientAuthCertificate: Data? +} + +struct Extensions_Api_CastChannel_AuthError: SwiftProtobuf.Message { + static let protoMessageName: String = _protobuf_package + ".AuthError" + + var errorType: Extensions_Api_CastChannel_AuthError.ErrorType { + get {return _errorType ?? .internalError} + set {_errorType = newValue} + } + /// Returns true if `errorType` has been explicitly set. + var hasErrorType: Bool {return self._errorType != nil} + /// Clears the value of `errorType`. Subsequent reads from it will return its default value. + mutating func clearErrorType() {self._errorType = nil} + + var unknownFields = SwiftProtobuf.UnknownStorage() + + enum ErrorType: SwiftProtobuf.Enum { + typealias RawValue = Int + case internalError // = 0 + + /// The underlying connection is not TLS + case noTls // = 1 + + init() { + self = .internalError + } + + init?(rawValue: Int) { + switch rawValue { + case 0: self = .internalError + case 1: self = .noTls + default: return nil + } + } + + var rawValue: Int { + switch self { + case .internalError: return 0 + case .noTls: return 1 + } + } + + } + + init() {} + + public var isInitialized: Bool { + if self._errorType == nil {return false} + return true + } + + /// Used by the decoding initializers in the SwiftProtobuf library, not generally + /// used directly. `init(serializedData:)`, `init(jsonUTF8Data:)`, and other decoding + /// initializers are defined in the SwiftProtobuf library. See the Message and + /// Message+*Additions` files. + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + switch fieldNumber { + case 1: try decoder.decodeSingularEnumField(value: &self._errorType) + default: break + } + } + } + + /// Used by the encoding methods of the SwiftProtobuf library, not generally + /// used directly. `Message.serializedData()`, `Message.jsonUTF8Data()`, and + /// other serializer methods are defined in the SwiftProtobuf library. See the + /// `Message` and `Message+*Additions` files. + func traverse(visitor: inout V) throws { + if let v = self._errorType { + try visitor.visitSingularEnumField(value: v, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + fileprivate var _errorType: Extensions_Api_CastChannel_AuthError.ErrorType? +} + +struct Extensions_Api_CastChannel_DeviceAuthMessage: SwiftProtobuf.Message { + static let protoMessageName: String = _protobuf_package + ".DeviceAuthMessage" + + /// Request fields + var challenge: Extensions_Api_CastChannel_AuthChallenge { + get {return _storage._challenge ?? Extensions_Api_CastChannel_AuthChallenge()} + set {_uniqueStorage()._challenge = newValue} + } + /// Returns true if `challenge` has been explicitly set. + var hasChallenge: Bool {return _storage._challenge != nil} + /// Clears the value of `challenge`. Subsequent reads from it will return its default value. + mutating func clearChallenge() {_storage._challenge = nil} + + /// Response fields + var response: Extensions_Api_CastChannel_AuthResponse { + get {return _storage._response ?? Extensions_Api_CastChannel_AuthResponse()} + set {_uniqueStorage()._response = newValue} + } + /// Returns true if `response` has been explicitly set. + var hasResponse: Bool {return _storage._response != nil} + /// Clears the value of `response`. Subsequent reads from it will return its default value. + mutating func clearResponse() {_storage._response = nil} + + var error: Extensions_Api_CastChannel_AuthError { + get {return _storage._error ?? Extensions_Api_CastChannel_AuthError()} + set {_uniqueStorage()._error = newValue} + } + /// Returns true if `error` has been explicitly set. + var hasError: Bool {return _storage._error != nil} + /// Clears the value of `error`. Subsequent reads from it will return its default value. + mutating func clearError() {_storage._error = nil} + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} + + public var isInitialized: Bool { + return withExtendedLifetime(_storage) { (_storage: _StorageClass) in + if let v = _storage._response, !v.isInitialized {return false} + if let v = _storage._error, !v.isInitialized {return false} + return true + } + } + + /// Used by the decoding initializers in the SwiftProtobuf library, not generally + /// used directly. `init(serializedData:)`, `init(jsonUTF8Data:)`, and other decoding + /// initializers are defined in the SwiftProtobuf library. See the Message and + /// Message+*Additions` files. + mutating func decodeMessage(decoder: inout D) throws { + _ = _uniqueStorage() + try withExtendedLifetime(_storage) { (_storage: _StorageClass) in + while let fieldNumber = try decoder.nextFieldNumber() { + switch fieldNumber { + case 1: try decoder.decodeSingularMessageField(value: &_storage._challenge) + case 2: try decoder.decodeSingularMessageField(value: &_storage._response) + case 3: try decoder.decodeSingularMessageField(value: &_storage._error) + default: break + } + } + } + } + + /// Used by the encoding methods of the SwiftProtobuf library, not generally + /// used directly. `Message.serializedData()`, `Message.jsonUTF8Data()`, and + /// other serializer methods are defined in the SwiftProtobuf library. See the + /// `Message` and `Message+*Additions` files. + func traverse(visitor: inout V) throws { + try withExtendedLifetime(_storage) { (_storage: _StorageClass) in + if let v = _storage._challenge { + try visitor.visitSingularMessageField(value: v, fieldNumber: 1) + } + if let v = _storage._response { + try visitor.visitSingularMessageField(value: v, fieldNumber: 2) + } + if let v = _storage._error { + try visitor.visitSingularMessageField(value: v, fieldNumber: 3) + } + } + try unknownFields.traverse(visitor: &visitor) + } + + fileprivate var _storage = _StorageClass.defaultInstance +} + +// MARK: - Code below here is support for the SwiftProtobuf runtime. + +private let _protobuf_package = "extensions.api.cast_channel" + +extension Extensions_Api_CastChannel_CastMessage: SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .standard(proto: "protocol_version"), + 2: .standard(proto: "source_id"), + 3: .standard(proto: "destination_id"), + 4: .same(proto: "namespace"), + 5: .standard(proto: "payload_type"), + 6: .standard(proto: "payload_utf8"), + 7: .standard(proto: "payload_binary") + ] + + func _protobuf_generated_isEqualTo(other: Extensions_Api_CastChannel_CastMessage) -> Bool { + if self._protocolVersion != other._protocolVersion {return false} + if self._sourceID != other._sourceID {return false} + if self._destinationID != other._destinationID {return false} + if self._namespace != other._namespace {return false} + if self._payloadType != other._payloadType {return false} + if self._payloadUtf8 != other._payloadUtf8 {return false} + if self._payloadBinary != other._payloadBinary {return false} + if unknownFields != other.unknownFields {return false} + return true + } +} + +extension Extensions_Api_CastChannel_CastMessage.ProtocolVersion: SwiftProtobuf._ProtoNameProviding { + static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 0: .same(proto: "CASTV2_1_0") + ] +} + +extension Extensions_Api_CastChannel_CastMessage.PayloadType: SwiftProtobuf._ProtoNameProviding { + static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 0: .same(proto: "STRING"), + 1: .same(proto: "BINARY") + ] +} + +extension Extensions_Api_CastChannel_AuthChallenge: SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + func _protobuf_generated_isEqualTo(other: Extensions_Api_CastChannel_AuthChallenge) -> Bool { + if unknownFields != other.unknownFields {return false} + return true + } +} + +extension Extensions_Api_CastChannel_AuthResponse: SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "signature"), + 2: .standard(proto: "client_auth_certificate"), + 3: .standard(proto: "client_ca") + ] + + func _protobuf_generated_isEqualTo(other: Extensions_Api_CastChannel_AuthResponse) -> Bool { + if self._signature != other._signature {return false} + if self._clientAuthCertificate != other._clientAuthCertificate {return false} + if self.clientCa != other.clientCa {return false} + if unknownFields != other.unknownFields {return false} + return true + } +} + +extension Extensions_Api_CastChannel_AuthError: SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .standard(proto: "error_type") + ] + + func _protobuf_generated_isEqualTo(other: Extensions_Api_CastChannel_AuthError) -> Bool { + if self._errorType != other._errorType {return false} + if unknownFields != other.unknownFields {return false} + return true + } +} + +extension Extensions_Api_CastChannel_AuthError.ErrorType: SwiftProtobuf._ProtoNameProviding { + static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 0: .same(proto: "INTERNAL_ERROR"), + 1: .same(proto: "NO_TLS") + ] +} + +extension Extensions_Api_CastChannel_DeviceAuthMessage: SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "challenge"), + 2: .same(proto: "response"), + 3: .same(proto: "error") + ] + + fileprivate class _StorageClass { + var _challenge: Extensions_Api_CastChannel_AuthChallenge? + var _response: Extensions_Api_CastChannel_AuthResponse? + var _error: Extensions_Api_CastChannel_AuthError? + + static let defaultInstance = _StorageClass() + + private init() {} + + init(copying source: _StorageClass) { + _challenge = source._challenge + _response = source._response + _error = source._error + } + } + + fileprivate mutating func _uniqueStorage() -> _StorageClass { + if !isKnownUniquelyReferenced(&_storage) { + _storage = _StorageClass(copying: _storage) + } + return _storage + } + + func _protobuf_generated_isEqualTo(other: Extensions_Api_CastChannel_DeviceAuthMessage) -> Bool { + if _storage !== other._storage { + let storagesAreEqual: Bool = withExtendedLifetime((_storage, other._storage)) { (_args: (_StorageClass, _StorageClass)) in + let _storage = _args.0 + let other_storage = _args.1 + if _storage._challenge != other_storage._challenge {return false} + if _storage._response != other_storage._response {return false} + if _storage._error != other_storage._error {return false} + return true + } + if !storagesAreEqual {return false} + } + if unknownFields != other.unknownFields {return false} + return true + } +} diff --git a/JellyfinPlayer/OpenCastSwift/Helpers/Proto/cast_channel.proto b/JellyfinPlayer/OpenCastSwift/Helpers/Proto/cast_channel.proto new file mode 100644 index 00000000..47d63940 --- /dev/null +++ b/JellyfinPlayer/OpenCastSwift/Helpers/Proto/cast_channel.proto @@ -0,0 +1,80 @@ +// Copyright 2013 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +syntax = "proto2"; + +option optimize_for = LITE_RUNTIME; + +package extensions.api.cast_channel; + +message CastMessage { + // Always pass a version of the protocol for future compatibility + // requirements. + enum ProtocolVersion { + CASTV2_1_0 = 0; + } + required ProtocolVersion protocol_version = 1; + + // source and destination ids identify the origin and destination of the + // message. They are used to route messages between endpoints that share a + // device-to-device channel. + // + // For messages between applications: + // - The sender application id is a unique identifier generated on behalf of + // the sender application. + // - The receiver id is always the the session id for the application. + // + // For messages to or from the sender or receiver platform, the special ids + // 'sender-0' and 'receiver-0' can be used. + // + // For messages intended for all endpoints using a given channel, the + // wildcard destination_id '*' can be used. + required string source_id = 2; + required string destination_id = 3; + + // This is the core multiplexing key. All messages are sent on a namespace + // and endpoints sharing a channel listen on one or more namespaces. The + // namespace defines the protocol and semantics of the message. + required string namespace = 4; + + // Encoding and payload info follows. + + // What type of data do we have in this message. + enum PayloadType { + STRING = 0; + BINARY = 1; + } + required PayloadType payload_type = 5; + + // Depending on payload_type, exactly one of the following optional fields + // will always be set. + optional string payload_utf8 = 6; + optional bytes payload_binary = 7; +} + +// Messages for authentication protocol between a sender and a receiver. +message AuthChallenge { +} + +message AuthResponse { + required bytes signature = 1; + required bytes client_auth_certificate = 2; + repeated bytes client_ca = 3; +} + +message AuthError { + enum ErrorType { + INTERNAL_ERROR = 0; + NO_TLS = 1; // The underlying connection is not TLS + } + required ErrorType error_type = 1; +} + +message DeviceAuthMessage { + // Request fields + optional AuthChallenge challenge = 1; + // Response fields + optional AuthResponse response = 2; + optional AuthError error = 3; +} diff --git a/JellyfinPlayer/OpenCastSwift/Models/AppAvailability.swift b/JellyfinPlayer/OpenCastSwift/Models/AppAvailability.swift new file mode 100644 index 00000000..1ad12496 --- /dev/null +++ b/JellyfinPlayer/OpenCastSwift/Models/AppAvailability.swift @@ -0,0 +1,24 @@ +// +// AppAvailability.swift +// OpenCastSwift +// +// Created by Miles Hollingsworth on 4/22/18 +// Copyright © 2018 Miles Hollingsworth. All rights reserved. +// + +import SwiftyJSON +import Foundation + +public class AppAvailability: NSObject { + public var availability = [String: Bool]() +} + +extension AppAvailability { + convenience init(json: JSON) { + self.init() + + if let availability = json[CastJSONPayloadKeys.availability].dictionaryObject as? [String: String] { + self.availability = availability.mapValues { $0 == "APP_AVAILABLE" } + } + } +} diff --git a/JellyfinPlayer/OpenCastSwift/Models/CastApp.swift b/JellyfinPlayer/OpenCastSwift/Models/CastApp.swift new file mode 100644 index 00000000..8ac4f0f0 --- /dev/null +++ b/JellyfinPlayer/OpenCastSwift/Models/CastApp.swift @@ -0,0 +1,62 @@ +// +// CastApp.swift +// OpenCastSwift +// +// Created by Miles Hollingsworth on 4/22/18 +// Copyright © 2018 Miles Hollingsworth. All rights reserved. +// + +import Foundation +import SwiftyJSON + +public struct CastAppIdentifier { + public static let defaultMediaPlayer = "CC1AD845" + public static let youTube = "YouTube" + public static let googleAssistant = "97216CB6" +} + +public final class CastApp: NSObject { + public var id: String = "" + public var displayName: String = "" + public var isIdleScreen: Bool = false + public var sessionId: String = "" + public var statusText: String = "" + public var transportId: String = "" + public var namespaces = [String]() + + convenience init(json: JSON) { + self.init() + + if let id = json[CastJSONPayloadKeys.appId].string { + self.id = id + } + + if let displayName = json[CastJSONPayloadKeys.displayName].string { + self.displayName = displayName + } + + if let isIdleScreen = json[CastJSONPayloadKeys.isIdleScreen].bool { + self.isIdleScreen = isIdleScreen + } + + if let sessionId = json[CastJSONPayloadKeys.sessionId].string { + self.sessionId = sessionId + } + + if let statusText = json[CastJSONPayloadKeys.statusText].string { + self.statusText = statusText + } + + if let transportId = json[CastJSONPayloadKeys.transportId].string { + self.transportId = transportId + } + + if let namespaces = json[CastJSONPayloadKeys.namespaces].array { + self.namespaces = namespaces.compactMap { $0[CastJSONPayloadKeys.name].string } + } + } + + public override var description: String { + return "CastApp(id: \(id), displayName: \(displayName), isIdleScreen: \(isIdleScreen), sessionId: \(sessionId), statusText: \(statusText), transportId: \(transportId), namespaces: \(namespaces)" + } +} diff --git a/JellyfinPlayer/OpenCastSwift/Models/CastDevice.swift b/JellyfinPlayer/OpenCastSwift/Models/CastDevice.swift new file mode 100644 index 00000000..b155a20b --- /dev/null +++ b/JellyfinPlayer/OpenCastSwift/Models/CastDevice.swift @@ -0,0 +1,66 @@ +// +// CastDevice.swift +// OpenCastSwift +// +// Created by Miles Hollingsworth on 4/22/18 +// Copyright © 2018 Miles Hollingsworth. All rights reserved. +// + +import Foundation + +public struct DeviceCapabilities: OptionSet { + public let rawValue: Int + public init(rawValue: Int) { self.rawValue = rawValue } + + public static let none = DeviceCapabilities([]) + public static let videoOut = DeviceCapabilities(rawValue: 1 << 0) + public static let videoIn = DeviceCapabilities(rawValue: 1 << 1) + public static let audioOut = DeviceCapabilities(rawValue: 1 << 2) + public static let audioIn = DeviceCapabilities(rawValue: 1 << 3) + public static let multizoneGroup = DeviceCapabilities(rawValue: 1 << 5) + public static let masterVolume = DeviceCapabilities(rawValue: 1 << 11) + public static let attenuationVolume = DeviceCapabilities(rawValue: 1 << 12) +} + +public final class CastDevice: NSObject, NSCopying { + + public private(set) var id: String + public private(set) var name: String + public private(set) var modelName: String + public private(set) var hostName: String + public private(set) var ipAddress: String + public private(set) var port: Int + public private(set) var capabilities: DeviceCapabilities + public private(set) var status: String + public private(set) var iconPath: String + + init(id: String, name: String, modelName: String, hostName: String, ipAddress: String, port: Int, capabilitiesMask: Int, status: String, iconPath: String) { + self.id = id + self.name = name + self.modelName = modelName + self.hostName = hostName + self.ipAddress = ipAddress + self.port = port + capabilities = DeviceCapabilities(rawValue: capabilitiesMask) + self.status = status + self.iconPath = iconPath + + super.init() + } + + public func copy(with zone: NSZone? = nil) -> Any { + return CastDevice(id: self.id, + name: self.name, + modelName: self.modelName, + hostName: self.hostName, + ipAddress: self.ipAddress, + port: self.port, + capabilitiesMask: capabilities.rawValue, + status: self.status, + iconPath: iconPath) + } + + public override var description: String { + return "CastDevice(id: \(id), name: \(name), hostName:\(hostName), ipAddress:\(ipAddress), port:\(port))" + } +} diff --git a/JellyfinPlayer/OpenCastSwift/Models/CastMedia.swift b/JellyfinPlayer/OpenCastSwift/Models/CastMedia.swift new file mode 100644 index 00000000..cee1db85 --- /dev/null +++ b/JellyfinPlayer/OpenCastSwift/Models/CastMedia.swift @@ -0,0 +1,92 @@ +// +// CastMedia.swift +// OpenCastSwift +// +// Created by Miles Hollingsworth on 4/22/18 +// Copyright © 2018 Miles Hollingsworth. All rights reserved. +// + +import Foundation + +public let CastMediaStreamTypeBuffered = "BUFFERED" +public let CastMediaStreamTypeLive = "LIVE" + +public enum CastMediaStreamType: String { + case buffered = "BUFFERED" + case live = "LIVE" +} + +public final class CastMedia: NSObject { + public let title: String + public let url: URL + public let poster: URL? + + public let autoplay: Bool + public let currentTime: Double + + public let contentType: String + public let streamType: CastMediaStreamType + + public init(title: String, url: URL, poster: URL? = nil, contentType: String, streamType: CastMediaStreamType = .buffered, autoplay: Bool = true, currentTime: Double = 0) { + self.title = title + self.url = url + self.poster = poster + self.contentType = contentType + self.streamType = streamType + self.autoplay = autoplay + self.currentTime = currentTime + } + +// public convenience init(title: String, url: URL, poster: URL, contentType: String, streamType: String, autoplay: Bool, currentTime: Double) { +// guard let type = CastMediaStreamType(rawValue: streamType) else { +// fatalError("Invalid media stream type \(streamType)") +// } +// +// self.init(title: title, url: url, poster: poster, contentType: contentType, streamType: type, autoplay: autoplay, currentTime: currentTime) +// } +} + +extension CastMedia { + + var dict: [String: Any] { + if let poster = poster { + return [ + CastJSONPayloadKeys.autoplay: autoplay, + CastJSONPayloadKeys.activeTrackIds: [], + CastJSONPayloadKeys.repeatMode: "REPEAT_OFF", + CastJSONPayloadKeys.currentTime: currentTime, + CastJSONPayloadKeys.media: [ + CastJSONPayloadKeys.contentId: url.absoluteString, + CastJSONPayloadKeys.contentType: contentType, + CastJSONPayloadKeys.streamType: streamType.rawValue, + CastJSONPayloadKeys.metadata: [ + CastJSONPayloadKeys.type: 0, + CastJSONPayloadKeys.metadataType: 0, + CastJSONPayloadKeys.title: title, + CastJSONPayloadKeys.images: [ + [CastJSONPayloadKeys.url: poster.absoluteString] + ] + ] + ] + ] + } else { + return [ + CastJSONPayloadKeys.autoplay: autoplay, + CastJSONPayloadKeys.activeTrackIds: [], + CastJSONPayloadKeys.repeatMode: "REPEAT_OFF", + CastJSONPayloadKeys.currentTime: currentTime, + CastJSONPayloadKeys.media: [ + CastJSONPayloadKeys.contentId: url.absoluteString, + CastJSONPayloadKeys.contentType: contentType, + CastJSONPayloadKeys.streamType: streamType.rawValue, + CastJSONPayloadKeys.metadata: [ + CastJSONPayloadKeys.type: 0, + CastJSONPayloadKeys.metadataType: 0, + CastJSONPayloadKeys.title: title + ] + ] + ] + } + } + +} diff --git a/JellyfinPlayer/OpenCastSwift/Models/CastMediaStatus.swift b/JellyfinPlayer/OpenCastSwift/Models/CastMediaStatus.swift new file mode 100644 index 00000000..ccd18a93 --- /dev/null +++ b/JellyfinPlayer/OpenCastSwift/Models/CastMediaStatus.swift @@ -0,0 +1,60 @@ +// +// CastMediaStatus.swift +// OpenCastSwift +// +// Created by Miles Hollingsworth on 4/22/18 +// Copyright © 2018 Miles Hollingsworth. All rights reserved. +// + +import Foundation +import SwiftyJSON + +public enum CastMediaPlayerState: String { + case buffering = "BUFFERING" + case playing = "PLAYING" + case paused = "PAUSED" + case stopped = "STOPPED" +} + +public final class CastMediaStatus: NSObject { + + public let mediaSessionId: Int + public let playbackRate: Int + public let playerState: CastMediaPlayerState + public let currentTime: Double + public let metadata: JSON? + public let contentID: String? + private let createdDate = Date() + + public var adjustedCurrentTime: Double { + return currentTime - Double(playbackRate)*createdDate.timeIntervalSinceNow + } + + public var state: String { + return playerState.rawValue + } + + public override var description: String { + return "MediaStatus(mediaSessionId: \(mediaSessionId), playbackRate: \(playbackRate), playerState: \(playerState.rawValue), currentTime: \(currentTime))" + } + + init(json: JSON) { + mediaSessionId = json[CastJSONPayloadKeys.mediaSessionId].int ?? 0 + + playbackRate = json[CastJSONPayloadKeys.playbackRate].int ?? 1 + + playerState = json[CastJSONPayloadKeys.playerState].string.flatMap(CastMediaPlayerState.init) ?? .buffering + + currentTime = json[CastJSONPayloadKeys.currentTime].double ?? 0 + + metadata = json[CastJSONPayloadKeys.media][CastJSONPayloadKeys.metadata] + + if let contentID = json[CastJSONPayloadKeys.media][CastJSONPayloadKeys.contentId].string, let data = contentID.data(using: .utf8) { + self.contentID = (try? JSON(data: data))?[CastJSONPayloadKeys.contentId].string ?? contentID + } else { + contentID = nil + } + + super.init() + } +} diff --git a/JellyfinPlayer/OpenCastSwift/Models/CastMessage.swift b/JellyfinPlayer/OpenCastSwift/Models/CastMessage.swift new file mode 100644 index 00000000..4957d737 --- /dev/null +++ b/JellyfinPlayer/OpenCastSwift/Models/CastMessage.swift @@ -0,0 +1,36 @@ +// +// CastMessage.swift +// OpenCastSwift +// +// Created by Miles Hollingsworth on 4/22/18 +// Copyright © 2018 Miles Hollingsworth. All rights reserved. +// + +import Foundation + +extension CastMessage { + static func encodedMessage(payload: CastPayload, namespace: String, sourceId: String, destinationId: String) throws -> Data { + var message = CastMessage() + message.protocolVersion = .castv210 + message.sourceID = sourceId + message.destinationID = destinationId + message.namespace = namespace + + switch payload { + case .json(let payload): + let json = try JSONSerialization.data(withJSONObject: payload, options: []) + + guard let jsonString = String(data: json, encoding: .utf8) else { + fatalError("error forming json string") + } + + message.payloadType = .string + message.payloadUtf8 = jsonString + case .data(let payload): + message.payloadType = .binary + message.payloadBinary = payload + } + + return try message.serializedData() + } +} diff --git a/JellyfinPlayer/OpenCastSwift/Models/CastMultizoneDevice.swift b/JellyfinPlayer/OpenCastSwift/Models/CastMultizoneDevice.swift new file mode 100644 index 00000000..eb79b919 --- /dev/null +++ b/JellyfinPlayer/OpenCastSwift/Models/CastMultizoneDevice.swift @@ -0,0 +1,42 @@ +// +// CastMultizoneDevice.swift +// OpenCastSwift Mac +// +// Created by Miles Hollingsworth on 4/22/18 +// Copyright © 2018 Miles Hollingsworth. All rights reserved. +// + +import Foundation +import SwiftyJSON + +public class CastMultizoneDevice { + public let name: String + public let volume: Float + public let isMuted: Bool + public let capabilities: DeviceCapabilities + public let id: String + + public init(name: String, volume: Float, isMuted: Bool, capabilitiesMask: Int, id: String) { + self.name = name + self.volume = volume + self.isMuted = isMuted + capabilities = DeviceCapabilities(rawValue: capabilitiesMask) + self.id = id + } +} + +extension CastMultizoneDevice { + convenience init(json: JSON) { + let name = json[CastJSONPayloadKeys.name].stringValue + + let volumeValues = json[CastJSONPayloadKeys.volume] + + let volume = volumeValues[CastJSONPayloadKeys.level].floatValue + let isMuted = volumeValues[CastJSONPayloadKeys.muted].boolValue + let capabilitiesMask = json[CastJSONPayloadKeys.capabilities].intValue + let deviceId = json[CastJSONPayloadKeys.deviceId].stringValue + + self.init(name: name, volume: volume, isMuted: isMuted, capabilitiesMask: capabilitiesMask, id: deviceId) + } + +} diff --git a/JellyfinPlayer/OpenCastSwift/Models/CastMultizoneStatus.swift b/JellyfinPlayer/OpenCastSwift/Models/CastMultizoneStatus.swift new file mode 100644 index 00000000..d2584b59 --- /dev/null +++ b/JellyfinPlayer/OpenCastSwift/Models/CastMultizoneStatus.swift @@ -0,0 +1,29 @@ +// +// CastMultizoneStatus.swift +// OpenCastSwift Mac +// +// Created by Miles Hollingsworth on 4/22/18 +// Copyright © 2018 Miles Hollingsworth. All rights reserved. +// + +import Foundation +import SwiftyJSON + +public class CastMultizoneStatus { + public let devices: [CastMultizoneDevice] + + public init(devices: [CastMultizoneDevice]) { + self.devices = devices + } +} + +extension CastMultizoneStatus { + + convenience init(json: JSON) { + let status = json[CastJSONPayloadKeys.status] + let devices = status[CastJSONPayloadKeys.devices].array?.map(CastMultizoneDevice.init) ?? [] + + self.init(devices: devices) + } + +} diff --git a/JellyfinPlayer/OpenCastSwift/Models/CastStatus.swift b/JellyfinPlayer/OpenCastSwift/Models/CastStatus.swift new file mode 100644 index 00000000..1d7dabda --- /dev/null +++ b/JellyfinPlayer/OpenCastSwift/Models/CastStatus.swift @@ -0,0 +1,44 @@ +// +// CastStatus.swift +// OpenCastSwift +// +// Created by Miles Hollingsworth on 4/22/18 +// Copyright © 2018 Miles Hollingsworth. All rights reserved. +// + +import Foundation +import SwiftyJSON + +public final class CastStatus: NSObject { + + public var volume: Double = 1 + public var muted: Bool = false + public var apps: [CastApp] = [] + + public override var description: String { + return "CastStatus(volume: \(volume), muted: \(muted), apps: \(apps))" + } + +} + +extension CastStatus { + + convenience init(json: JSON) { + self.init() +// print(json) + let status = json[CastJSONPayloadKeys.status] + let volume = status[CastJSONPayloadKeys.volume] + + if let volume = volume[CastJSONPayloadKeys.level].double { + self.volume = volume + } + if let muted = volume[CastJSONPayloadKeys.muted].bool { + self.muted = muted + } + + if let apps = status[CastJSONPayloadKeys.applications].array { + self.apps = apps.compactMap(CastApp.init) + } + } + +} diff --git a/JellyfinPlayer/OpenCastSwift/Networking/CastClient.swift b/JellyfinPlayer/OpenCastSwift/Networking/CastClient.swift new file mode 100644 index 00000000..0213d80d --- /dev/null +++ b/JellyfinPlayer/OpenCastSwift/Networking/CastClient.swift @@ -0,0 +1,628 @@ +// +// CastClient.swift +// OpenCastSwift +// +// Created by Miles Hollingsworth on 4/22/18 +// Copyright © 2018 Miles Hollingsworth. All rights reserved. +// + +import Foundation +import SwiftProtobuf +import SwiftyJSON +import Result + +public enum CastPayload { + case json([String: Any]) + case data(Data) + + init(_ json: [String: Any]) { + self = .json(json) + } + + init(_ data: Data) { + self = .data(data) + } +} + +typealias CastMessage = Extensions_Api_CastChannel_CastMessage +public typealias CastResponseHandler = (Result) -> Void + +public enum CastError: Error { + case connection(String) + case write(String) + case session(String) + case request(String) + case launch(String) + case load(String) +} + +public class CastRequest: NSObject { + var id: Int + var namespace: String + var destinationId: String + var payload: CastPayload + + init(id: Int, namespace: String, destinationId: String, payload: [String: Any]) { + self.id = id + self.namespace = namespace + self.destinationId = destinationId + self.payload = CastPayload(payload) + } + + init(id: Int, namespace: String, destinationId: String, payload: Data) { + self.id = id + self.namespace = namespace + self.destinationId = destinationId + self.payload = CastPayload(payload) + } +} + +@objc public protocol CastClientDelegate: AnyObject { + + @objc optional func castClient(_ client: CastClient, willConnectTo device: CastDevice) + @objc optional func castClient(_ client: CastClient, didConnectTo device: CastDevice) + @objc optional func castClient(_ client: CastClient, didDisconnectFrom device: CastDevice) + @objc optional func castClient(_ client: CastClient, connectionTo device: CastDevice, didFailWith error: Error?) + + @objc optional func castClient(_ client: CastClient, deviceStatusDidChange status: CastStatus) + @objc optional func castClient(_ client: CastClient, mediaStatusDidChange status: CastMediaStatus) + +} + +public final class CastClient: NSObject, RequestDispatchable, Channelable { + + public let device: CastDevice + public weak var delegate: CastClientDelegate? + public var connectedApp: CastApp? + + public private(set) var currentStatus: CastStatus? { + didSet { + guard let status = currentStatus else { return } + + if oldValue != status { + DispatchQueue.main.async { + self.delegate?.castClient?(self, deviceStatusDidChange: status) + self.statusDidChange?(status) + } + } + } + } + + public private(set) var currentMediaStatus: CastMediaStatus? { + didSet { + guard let status = currentMediaStatus else { return } + + if oldValue != status { + DispatchQueue.main.async { + self.delegate?.castClient?(self, mediaStatusDidChange: status) + self.mediaStatusDidChange?(status) + } + } + } + } + + public private(set) var currentMultizoneStatus: CastMultizoneStatus? + + public var statusDidChange: ((CastStatus) -> Void)? + public var mediaStatusDidChange: ((CastMediaStatus) -> Void)? + + public init(device: CastDevice) { + self.device = device + + super.init() + } + + deinit { + disconnect() + } + + // MARK: - Socket Setup + + public var isConnected = false { + didSet { + if oldValue != isConnected { + if isConnected { + DispatchQueue.main.async { self.delegate?.castClient?(self, didConnectTo: self.device) } + } else { + DispatchQueue.main.async { self.delegate?.castClient?(self, didDisconnectFrom: self.device) } + } + } + } + } + + private var inputStream: InputStream! { + didSet { + if let inputStream = inputStream { + reader = CastV2PlatformReader(stream: inputStream) + } else { + reader = nil + } + } + } + + private var outputStream: OutputStream! + + fileprivate lazy var socketQueue = DispatchQueue.global(qos: .userInitiated) + + public func connect() { + socketQueue.async { + do { + var readStream: Unmanaged? + var writeStream: Unmanaged? + + let settings: [String: Any] = [ + kCFStreamSSLValidatesCertificateChain as String: false, + kCFStreamSSLLevel as String: kCFStreamSocketSecurityLevelTLSv1, + kCFStreamPropertyShouldCloseNativeSocket as String: true + ] + + CFStreamCreatePairWithSocketToHost(nil, self.device.hostName as CFString, UInt32(self.device.port), &readStream, &writeStream) + + guard let readStreamRetained = readStream?.takeRetainedValue() else { + throw CastError.connection("Unable to create input stream") + } + + guard let writeStreamRetained = writeStream?.takeRetainedValue() else { + throw CastError.connection("Unable to create output stream") + } + + DispatchQueue.main.async { self.delegate?.castClient?(self, willConnectTo: self.device) } + + CFReadStreamSetProperty(readStreamRetained, CFStreamPropertyKey(kCFStreamPropertySSLSettings), settings as CFTypeRef?) + CFWriteStreamSetProperty(writeStreamRetained, CFStreamPropertyKey(kCFStreamPropertySSLSettings), settings as CFTypeRef?) + + self.inputStream = readStreamRetained + self.outputStream = writeStreamRetained + + self.inputStream.delegate = self + + self.inputStream.schedule(in: .current, forMode: RunLoop.Mode.default) + self.outputStream.schedule(in: .current, forMode: RunLoop.Mode.default) + + self.inputStream.open() + self.outputStream.open() + + RunLoop.current.run() + } catch { + DispatchQueue.main.async { self.delegate?.castClient?(self, connectionTo: self.device, didFailWith: error as NSError) } + } + } + } + + public func disconnect() { + if isConnected { + isConnected = false + } + + channels.values.forEach(remove) + + socketQueue.async { + if self.inputStream != nil { + self.inputStream.close() + self.inputStream.remove(from: RunLoop.current, forMode: RunLoop.Mode.default) + self.inputStream = nil + } + + if self.outputStream != nil { + self.outputStream.close() + self.outputStream.remove(from: RunLoop.current, forMode: RunLoop.Mode.default) + self.outputStream = nil + } + } + } + + // MARK: - Socket Lifecycle + + private func write(data: Data) throws { + var payloadSize = UInt32(data.count).bigEndian + let packet = NSMutableData(bytes: &payloadSize, length: MemoryLayout.size) + packet.append(data) + + let streamBytes = packet.bytes.bindMemory(to: UInt8.self, capacity: data.count) + + if outputStream.write(streamBytes, maxLength: packet.length) < 0 { + if let error = outputStream.streamError { + throw CastError.write("Error writing \(packet.length) byte(s) to stream: \(error)") + } else { + throw CastError.write("Unknown error writing \(packet.length) byte(s) to stream") + } + } + } + + fileprivate func sendConnectMessage() throws { + guard outputStream != nil else { return } + + _ = connectionChannel + + DispatchQueue.main.async { + _ = self.receiverControlChannel + _ = self.mediaControlChannel + _ = self.heartbeatChannel + + if self.device.capabilities.contains(.multizoneGroup) { + _ = self.multizoneControlChannel + } + } + } + + private var reader: CastV2PlatformReader? + + fileprivate func readStream() { + do { + reader?.readStream() + + while let payload = reader?.nextMessage() { + let message = try CastMessage(serializedData: payload) + + guard let channel = channels[message.namespace] else { + print("No channel attached for namespace \(message.namespace)") + return + } + + switch message.payloadType { + case .string: + if let messageData = message.payloadUtf8.data(using: .utf8) { + let json = JSON(messageData) + + channel.handleResponse(json, + sourceId: message.sourceID) + + if let requestId = json[CastJSONPayloadKeys.requestId].int { + callResponseHandler(for: requestId, with: Result(value: json)) + } + } else { + NSLog("Unable to get UTF8 JSON data from message") + } + case .binary: + channel.handleResponse(message.payloadBinary, + sourceId: message.sourceID) + } + } + } catch { + NSLog("Error reading: \(error)") + } + } + + // MARK: - Channelable + + var channels = [String: CastChannel]() + + private lazy var heartbeatChannel: HeartbeatChannel = { + let channel = HeartbeatChannel() + self.add(channel: channel) + + return channel + }() + + private lazy var connectionChannel: DeviceConnectionChannel = { + let channel = DeviceConnectionChannel() + self.add(channel: channel) + + return channel + }() + + private lazy var receiverControlChannel: ReceiverControlChannel = { + let channel = ReceiverControlChannel() + self.add(channel: channel) + + return channel + }() + + private lazy var mediaControlChannel: MediaControlChannel = { + let channel = MediaControlChannel() + self.add(channel: channel) + + return channel + }() + + private lazy var multizoneControlChannel: MultizoneControlChannel = { + let channel = MultizoneControlChannel() + self.add(channel: channel) + + return channel + }() + + // MARK: - Request response + + private lazy var currentRequestId = Int(arc4random_uniform(800)) + + func nextRequestId() -> Int { + currentRequestId += 1 + + return currentRequestId + } + + private let senderName: String = "sender-\(UUID().uuidString)" + + private var responseHandlers = [Int: CastResponseHandler]() + + func send(_ request: CastRequest, response: CastResponseHandler?) { + if let response = response { + responseHandlers[request.id] = response + } + + do { + let messageData = try CastMessage.encodedMessage(payload: request.payload, + namespace: request.namespace, + sourceId: senderName, + destinationId: request.destinationId) + + try write(data: messageData) + } catch { + callResponseHandler(for: request.id, with: Result(error: .request(error.localizedDescription))) + } + } + + private func callResponseHandler(for requestId: Int, with result: Result) { + DispatchQueue.main.async { + if let handler = self.responseHandlers.removeValue(forKey: requestId) { + handler(result) + } + } + } + + // MARK: - Public messages + + public func getAppAvailability(apps: [CastApp], completion: @escaping (Result) -> Void) { + guard outputStream != nil else { return } + + receiverControlChannel.getAppAvailability(apps: apps, completion: completion) + } + + public func join(app: CastApp? = nil, completion: @escaping (Result) -> Void) { + guard outputStream != nil, + let target = app ?? currentStatus?.apps.first else { + completion(Result(error: CastError.session("No Apps Running"))) + return + } + + if target == connectedApp { + completion(Result(value: target)) + } else if let existing = currentStatus?.apps.first(where: { $0.id == target.id }) { + connect(to: existing) + completion(Result(value: existing)) + } else { + receiverControlChannel.requestStatus { [weak self] result in + switch result { + case .success(let status): + guard let app = status.apps.first else { + completion(Result(error: CastError.launch("Unable to get launched app instance"))) + return + } + + self?.connect(to: app) + completion(Result(value: app)) + + case .failure(let error): + completion(Result(error: error)) + } + } + } + } + + public func launch(appId: String, completion: @escaping (Result) -> Void) { + guard outputStream != nil else { return } + + receiverControlChannel.launch(appId: appId) { [weak self] result in + switch result { + case .success(let app): + self?.connect(to: app) + fallthrough + + default: + completion(result) + } + } + } + + public func stopCurrentApp() { + guard outputStream != nil, let app = currentStatus?.apps.first else { return } + + receiverControlChannel.stop(app: app) + } + + public func leave(_ app: CastApp) { + guard outputStream != nil else { return } + + connectionChannel.leave(app) + connectedApp = nil + } + + public func load(media: CastMedia, with app: CastApp, completion: @escaping (Result) -> Void) { + guard outputStream != nil else { return } + + mediaControlChannel.load(media: media, with: app, completion: completion) + } + + public func requestMediaStatus(for app: CastApp, completion: ((Result) -> Void)? = nil) { + guard outputStream != nil else { return } + + mediaControlChannel.requestMediaStatus(for: app) + } + + private func connect(to app: CastApp) { + guard outputStream != nil else { return } + + connectionChannel.connect(to: app) + connectedApp = app + } + + public func pause() { + guard outputStream != nil, let app = connectedApp else { return } + + if let mediaStatus = currentMediaStatus { + mediaControlChannel.sendPause(for: app, mediaSessionId: mediaStatus.mediaSessionId) + } else { + mediaControlChannel.requestMediaStatus(for: app) { result in + switch result { + case .success(let mediaStatus): + self.mediaControlChannel.sendPause(for: app, mediaSessionId: mediaStatus.mediaSessionId) + + case .failure(let error): + print(error) + } + } + } + } + + public func play() { + guard outputStream != nil, let app = connectedApp else { return } + + if let mediaStatus = currentMediaStatus { + mediaControlChannel.sendPlay(for: app, mediaSessionId: mediaStatus.mediaSessionId) + } else { + mediaControlChannel.requestMediaStatus(for: app) { result in + switch result { + case .success(let mediaStatus): + self.mediaControlChannel.sendPlay(for: app, mediaSessionId: mediaStatus.mediaSessionId) + + case .failure(let error): + print(error) + } + } + } + } + + public func stop() { + guard outputStream != nil, let app = connectedApp else { return } + + if let mediaStatus = currentMediaStatus { + mediaControlChannel.sendStop(for: app, mediaSessionId: mediaStatus.mediaSessionId) + } else { + mediaControlChannel.requestMediaStatus(for: app) { result in + switch result { + case .success(let mediaStatus): + self.mediaControlChannel.sendStop(for: app, mediaSessionId: mediaStatus.mediaSessionId) + + case .failure(let error): + print(error) + } + } + } + } + + public func seek(to currentTime: Float) { + guard outputStream != nil, let app = connectedApp else { return } + + if let mediaStatus = currentMediaStatus { + mediaControlChannel.sendSeek(to: currentTime, for: app, mediaSessionId: mediaStatus.mediaSessionId) + } else { + mediaControlChannel.requestMediaStatus(for: app) { result in + switch result { + case .success(let mediaStatus): + self.mediaControlChannel.sendSeek(to: currentTime, for: app, mediaSessionId: mediaStatus.mediaSessionId) + + case .failure(let error): + print(error) + } + } + } + } + + public func setVolume(_ volume: Float) { + guard outputStream != nil else { return } + + receiverControlChannel.setVolume(volume) + } + + public func setMuted(_ muted: Bool) { + guard outputStream != nil else { return } + + receiverControlChannel.setMuted(muted) + } + + public func setVolume(_ volume: Float, for device: CastMultizoneDevice) { + guard device.capabilities.contains(.multizoneGroup) else { + print("Attempted to set zone volume on non-multizone device") + return + } + + multizoneControlChannel.setVolume(volume, for: device) + } + + public func setMuted(_ isMuted: Bool, for device: CastMultizoneDevice) { + guard device.capabilities.contains(.multizoneGroup) else { + print("Attempted to mute zone on non-multizone device") + return + } + + multizoneControlChannel.setMuted(isMuted, for: device) + } +} + +extension CastClient: StreamDelegate { + public func stream(_ aStream: Stream, handle eventCode: Stream.Event) { + switch eventCode { + case Stream.Event.openCompleted: + guard !isConnected else { return } + socketQueue.async { + do { + try self.sendConnectMessage() + + } catch { + NSLog("Error sending connect message: \(error)") + } + } + case Stream.Event.errorOccurred: + NSLog("Stream error occurred: \(aStream.streamError.debugDescription)") + + DispatchQueue.main.async { + self.delegate?.castClient?(self, connectionTo: self.device, didFailWith: aStream.streamError) + } + case Stream.Event.hasBytesAvailable: + socketQueue.async { + self.readStream() + } + case Stream.Event.endEncountered: + NSLog("Input stream ended") + disconnect() + + default: break + } + } +} + +extension CastClient: ReceiverControlChannelDelegate { + func channel(_ channel: ReceiverControlChannel, didReceive status: CastStatus) { + currentStatus = status + } +} + +extension CastClient: MediaControlChannelDelegate { + func channel(_ channel: MediaControlChannel, didReceive mediaStatus: CastMediaStatus) { + currentMediaStatus = mediaStatus + } +} + +extension CastClient: HeartbeatChannelDelegate { + func channelDidConnect(_ channel: HeartbeatChannel) { + if !isConnected { + isConnected = true + } + } + + func channelDidTimeout(_ channel: HeartbeatChannel) { + disconnect() + currentStatus = nil + currentMediaStatus = nil + connectedApp = nil + } +} + +extension CastClient: MultizoneControlChannelDelegate { + func channel(_ channel: MultizoneControlChannel, added device: CastMultizoneDevice) { + + } + + func channel(_ channel: MultizoneControlChannel, updated device: CastMultizoneDevice) { + + } + + func channel(_ channel: MultizoneControlChannel, removed deviceId: String) { + + } + + func channel(_ channel: MultizoneControlChannel, didReceive status: CastMultizoneStatus) { + currentMultizoneStatus = status + } +} diff --git a/JellyfinPlayer/OpenCastSwift/Networking/CastDeviceScanner.swift b/JellyfinPlayer/OpenCastSwift/Networking/CastDeviceScanner.swift new file mode 100644 index 00000000..38d45bb6 --- /dev/null +++ b/JellyfinPlayer/OpenCastSwift/Networking/CastDeviceScanner.swift @@ -0,0 +1,219 @@ +// +// CastDeviceScanner.swift +// OpenCastSwift +// +// Created by Miles Hollingsworth on 4/22/18 +// Copyright © 2018 Miles Hollingsworth. All rights reserved. +// + +import Foundation + +extension CastDevice { + convenience init(service: NetService, info: [String: String]) { + var ipAddress: String? + + if let address = service.addresses?.first { + ipAddress = address.withUnsafeBytes { (pointer: UnsafePointer) -> String? in + var hostName = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + + return getnameinfo(pointer, socklen_t(address.count), &hostName, socklen_t(NI_MAXHOST), nil, 0, NI_NUMERICHOST) == 0 ? String.init(cString: hostName) : nil + } + } + + self.init(id: info["id"] ?? "", + name: info["fn"] ?? service.name, + modelName: info["md"] ?? "Google Cast", + hostName: service.hostName!, + ipAddress: ipAddress ?? "", + port: service.port, + capabilitiesMask: info["ca"].flatMap(Int.init) ?? 0 , + status: info["rs"] ?? "", + iconPath: info["ic"] ?? "") + } + +} + +public final class CastDeviceScanner: NSObject { + public weak var delegate: CastDeviceScannerDelegate? + + public static let deviceListDidChange = Notification.Name(rawValue: "DeviceScannerDeviceListDidChangeNotification") + + private lazy var browser: NetServiceBrowser = configureBrowser() + + public var isScanning = false + + fileprivate var services = [NetService]() + + public fileprivate(set) var devices = [CastDevice]() { + didSet { + NotificationCenter.default.post(name: CastDeviceScanner.deviceListDidChange, object: self) + } + } + + private func configureBrowser() -> NetServiceBrowser { + let b = NetServiceBrowser() + + b.includesPeerToPeer = true + b.delegate = self + + return b + } + + public func startScanning() { + guard !isScanning else { return } + + browser.stop() + browser.searchForServices(ofType: "_googlecast._tcp", inDomain: "local") + + #if DEBUG + NSLog("Started scanning") + #endif + } + + public func stopScanning() { + guard isScanning else { return } + + browser.stop() + + #if DEBUG + NSLog("Stopped scanning") + #endif + } + + public func reset() { + stopScanning() + devices.removeAll() + } + + deinit { + stopScanning() + } + +} + +extension CastDeviceScanner: NetServiceBrowserDelegate { + + public func netServiceBrowserWillSearch(_ browser: NetServiceBrowser) { + isScanning = true + } + + public func netServiceBrowserDidStopSearch(_ browser: NetServiceBrowser) { + isScanning = false + } + + public func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool) { + removeService(service) + + service.delegate = self + service.resolve(withTimeout: 30.0) + services.append(service) + + #if DEBUG + NSLog("Did find service: \(service) more: \(moreComing)") + #endif + } + + public func netServiceBrowser(_ browser: NetServiceBrowser, didRemove service: NetService, moreComing: Bool) { + guard let service = removeService(service) else { return } + + #if DEBUG + NSLog("Did remove service: \(service)") + #endif + + guard let deviceId = service.id, + let index = devices.firstIndex(where: { $0.id == deviceId }) else { + #if DEBUG + NSLog("No device") + #endif + + return + } + + #if DEBUG + NSLog("Removing device: \(devices[index])") + #endif + let device = devices.remove(at: index) + delegate?.deviceDidGoOffline(device) + } + + @discardableResult func removeService(_ service: NetService) -> NetService? { + if let index = services.firstIndex(of: service) { + return services.remove(at: index) + } + + return nil + } + + func addDevice(_ device: CastDevice) { + if let index = devices.firstIndex(where: { $0.id == device.id }) { + let existing = devices[index] + + guard existing.name != device.name || existing.hostName != device.hostName else { return } + + devices.remove(at: index) + devices.insert(device, at: index) + + delegate?.deviceDidChange(device) + } else { + devices.append(device) + delegate?.deviceDidComeOnline(device) + } + } +} + +extension CastDeviceScanner: NetServiceDelegate { + + public func netServiceDidResolveAddress(_ sender: NetService) { + guard let infoDict = sender.infoDict else { + #if DEBUG + NSLog("No TXT record for \(sender), skipping") + #endif + return + } + + #if DEBUG + NSLog("Did resolve service: \(sender)") + NSLog("\(infoDict)") + #endif + + guard infoDict["id"] != nil else { + #if DEBUG + NSLog("No id for device \(sender), skipping") + #endif + return + } + + addDevice(CastDevice(service: sender, info: infoDict)) + } + + public func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) { + removeService(sender) + + #if DEBUG + NSLog("!! Failed to resolve service: \(sender) - \(errorDict) !!") + #endif + } +} + +extension NetService { + var infoDict: [String: String]? { + guard let data = txtRecordData() else { + return nil + } + + var dict = [String: String]() + NetService.dictionary(fromTXTRecord: data).forEach({ dict[$0.key] = String(data: $0.value, encoding: .utf8)! }) + + return dict + } + + var id: String? { + return infoDict?["id"] + } +} + +public protocol CastDeviceScannerDelegate: AnyObject { + func deviceDidComeOnline(_ device: CastDevice) + func deviceDidChange(_ device: CastDevice) + func deviceDidGoOffline(_ device: CastDevice) +} diff --git a/JellyfinPlayer/OpenCastSwift/Networking/Channels/CastChannel.swift b/JellyfinPlayer/OpenCastSwift/Networking/Channels/CastChannel.swift new file mode 100644 index 00000000..fce246bb --- /dev/null +++ b/JellyfinPlayer/OpenCastSwift/Networking/Channels/CastChannel.swift @@ -0,0 +1,31 @@ +// +// CastChannel.swift +// OpenCastSwift +// +// Created by Miles Hollingsworth on 4/22/18 +// Copyright © 2018 Miles Hollingsworth. All rights reserved. +// + +import Foundation +import SwiftyJSON + +open class CastChannel { + let namespace: String + weak var requestDispatcher: RequestDispatchable! + + init(namespace: String) { + self.namespace = namespace + } + + open func handleResponse(_ json: JSON, sourceId: String) { +// print(json) + } + + open func handleResponse(_ data: Data, sourceId: String) { + print("\n--Binary response--\n") + } + + public func send(_ request: CastRequest, response: CastResponseHandler? = nil) { + requestDispatcher.send(request, response: response) + } +} diff --git a/JellyfinPlayer/OpenCastSwift/Networking/Channels/Channelable.swift b/JellyfinPlayer/OpenCastSwift/Networking/Channels/Channelable.swift new file mode 100644 index 00000000..549be0b7 --- /dev/null +++ b/JellyfinPlayer/OpenCastSwift/Networking/Channels/Channelable.swift @@ -0,0 +1,39 @@ +// +// Channelable.swift +// OpenCastSwift Mac +// +// Created by Miles Hollingsworth on 4/22/18 +// Copyright © 2018 Miles Hollingsworth. All rights reserved. +// + +import Foundation + +protocol Channelable: RequestDispatchable { + var channels: [String: CastChannel] { get set } + + func add(channel: CastChannel) + func remove(channel: CastChannel) +} + +extension Channelable { + public func add(channel: CastChannel) { + let namespace = channel.namespace + guard channels[namespace] == nil else { + print("Channel already attached for \(namespace)") + return + } + + channels[namespace] = channel + channel.requestDispatcher = self + } + + public func remove(channel: CastChannel) { + let namespace = channel.namespace + guard let channel = channels.removeValue(forKey: namespace) else { + print("No channel attached for \(namespace)") + return + } + + channel.requestDispatcher = nil + } +} diff --git a/JellyfinPlayer/OpenCastSwift/Networking/Channels/DeviceAuthChannel.swift b/JellyfinPlayer/OpenCastSwift/Networking/Channels/DeviceAuthChannel.swift new file mode 100644 index 00000000..af7656dc --- /dev/null +++ b/JellyfinPlayer/OpenCastSwift/Networking/Channels/DeviceAuthChannel.swift @@ -0,0 +1,30 @@ +// +// DeviceAuthChannel.swift +// OpenCastSwift Mac +// +// Created by Miles Hollingsworth on 4/22/18 +// Copyright © 2018 Miles Hollingsworth. All rights reserved. +// + +import Foundation + +class DeviceAuthChannel: CastChannel { + typealias CastAuthChallenge = Extensions_Api_CastChannel_AuthChallenge + typealias CastAuthMessage = Extensions_Api_CastChannel_DeviceAuthMessage + + init() { + super.init(namespace: CastNamespace.auth) + } + + public func sendAuthChallenge() throws { + let message = CastAuthMessage.with { + $0.challenge = CastAuthChallenge() + } + + let request = requestDispatcher.request(withNamespace: namespace, + destinationId: CastConstants.receiver, + payload: try message.serializedData()) + + send(request) + } +} diff --git a/JellyfinPlayer/OpenCastSwift/Networking/Channels/DeviceConnectionChannel.swift b/JellyfinPlayer/OpenCastSwift/Networking/Channels/DeviceConnectionChannel.swift new file mode 100644 index 00000000..936ceb1e --- /dev/null +++ b/JellyfinPlayer/OpenCastSwift/Networking/Channels/DeviceConnectionChannel.swift @@ -0,0 +1,47 @@ +// +// DeviceConnectionChannel.swift +// OpenCastSwift Mac +// +// Created by Miles Hollingsworth on 4/22/18 +// Copyright © 2018 Miles Hollingsworth. All rights reserved. +// + +import Foundation + +class DeviceConnectionChannel: CastChannel { + override weak var requestDispatcher: RequestDispatchable! { + didSet { + if let _ = requestDispatcher { + connect() + } + } + } + + init() { + super.init(namespace: CastNamespace.connection) + } + + func connect() { + let request = requestDispatcher.request(withNamespace: namespace, + destinationId: CastConstants.receiver, + payload: [CastJSONPayloadKeys.type: CastMessageType.connect.rawValue]) + + send(request) + } + + func connect(to app: CastApp) { + let request = requestDispatcher.request(withNamespace: namespace, + destinationId: app.transportId, + payload: [CastJSONPayloadKeys.type: CastMessageType.connect.rawValue]) + + send(request) + } + + public func leave(_ app: CastApp) { + let request = requestDispatcher.request(withNamespace: namespace, + destinationId: app.transportId, + payload: [CastJSONPayloadKeys.type: CastMessageType.close.rawValue]) + + send(request) + } +} diff --git a/JellyfinPlayer/OpenCastSwift/Networking/Channels/DeviceDiscoveryChannel.swift b/JellyfinPlayer/OpenCastSwift/Networking/Channels/DeviceDiscoveryChannel.swift new file mode 100644 index 00000000..6b044631 --- /dev/null +++ b/JellyfinPlayer/OpenCastSwift/Networking/Channels/DeviceDiscoveryChannel.swift @@ -0,0 +1,31 @@ +// +// DeviceDiscoveryChannel.swift +// OpenCastSwift +// +// Created by Miles Hollingsworth on 4/22/18 +// Copyright © 2018 Miles Hollingsworth. All rights reserved. +// + +import Foundation + +class DeviceDiscoveryChannel: CastChannel { + init() { + super.init(namespace: CastNamespace.discovery) + } + + func requestDeviceInfo() { + let request = requestDispatcher.request(withNamespace: namespace, + destinationId: CastConstants.receiver, + payload: [CastJSONPayloadKeys.type: CastMessageType.getDeviceInfo.rawValue]) + + send(request) { result in + switch result { + case .success(let json): + print(json) + + case .failure(let error): + print(error) + } + } + } +} diff --git a/JellyfinPlayer/OpenCastSwift/Networking/Channels/DeviceSetupChannel.swift b/JellyfinPlayer/OpenCastSwift/Networking/Channels/DeviceSetupChannel.swift new file mode 100644 index 00000000..078674f0 --- /dev/null +++ b/JellyfinPlayer/OpenCastSwift/Networking/Channels/DeviceSetupChannel.swift @@ -0,0 +1,97 @@ +// +// DeviceSetupChannel.swift +// OpenCastSwift +// +// Created by Miles Hollingsworth on 4/22/18 +// Copyright © 2018 Miles Hollingsworth. All rights reserved. +// + +import Foundation + +class DeviceSetupChannel: CastChannel { + init() { + super.init(namespace: CastNamespace.setup) + } + + public func requestDeviceConfig() { + let params = [ + "version", + "name", + "build_info.cast_build_revision", + "net.ip_address", + "net.online", + "net.ssid", + "wifi.signal_level", + "wifi.noise_level" + ] + + let payload: [String: Any] = [ + CastJSONPayloadKeys.type: CastMessageType.getDeviceConfig.rawValue, + "params": params, + "data": [:] + ] + + let request = requestDispatcher.request(withNamespace: namespace, + destinationId: CastConstants.receiver, + payload: payload) + + send(request) { result in + switch result { + case .success(let json): + print(json) + + case .failure(let error): + print(error) + } + } + } + + public func requestSetDeviceConfig() { +// let data: [String: Any] = [ +// "name": "JUNK", +// "settings": [ +// +// ] +// ] + + let payload: [String: Any] = [ + CastJSONPayloadKeys.type: CastMessageType.getDeviceConfig.rawValue, + "data": [:] + ] + + let request = requestDispatcher.request(withNamespace: namespace, + destinationId: CastConstants.receiver, + payload: payload) + + send(request) { result in + switch result { + case .success(let json): + print(json) + + case .failure(let error): + print(error) + } + } + } + + public func requestAppDeviceId(app: CastApp) { + let payload: [String: Any] = [ + CastJSONPayloadKeys.type: CastMessageType.getAppDeviceId.rawValue, + "data": ["app_id": app.id] + ] + + let request = requestDispatcher.request(withNamespace: namespace, + destinationId: CastConstants.receiver, + payload: payload) + + send(request) { result in + switch result { + case .success(let json): + print(json) + + case .failure(let error): + print(error) + } + } + } +} diff --git a/JellyfinPlayer/OpenCastSwift/Networking/Channels/HeartbeatChannel.swift b/JellyfinPlayer/OpenCastSwift/Networking/Channels/HeartbeatChannel.swift new file mode 100644 index 00000000..38bf5a5f --- /dev/null +++ b/JellyfinPlayer/OpenCastSwift/Networking/Channels/HeartbeatChannel.swift @@ -0,0 +1,97 @@ +// +// CastHeartbeatChannel.swift +// OpenCastSwift +// +// Created by Miles Hollingsworth on 4/22/18 +// Copyright © 2018 Miles Hollingsworth. All rights reserved. +// + +import Foundation +import SwiftyJSON + +class HeartbeatChannel: CastChannel { + private lazy var timer = Timer.scheduledTimer(timeInterval: 5, target: self, selector: #selector(sendPing), userInfo: nil, repeats: true) + + private let disconnectTimeout: TimeInterval = 10 + private var disconnectTimer: Timer? { + willSet { + disconnectTimer?.invalidate() + } + didSet { + guard let timer = disconnectTimer else { return } + + RunLoop.main.add(timer, forMode: RunLoop.Mode.common) + } + } + + override weak var requestDispatcher: RequestDispatchable! { + didSet { + if let _ = requestDispatcher { + startBeating() + } else { + timer.invalidate() + } + } + } + + private var delegate: HeartbeatChannelDelegate? { + return requestDispatcher as? HeartbeatChannelDelegate + } + + init() { + super.init(namespace: CastNamespace.heartbeat) + } + + override func handleResponse(_ json: JSON, sourceId: String) { + delegate?.channelDidConnect(self) + + guard let rawType = json["type"].string else { return } + + guard let type = CastMessageType(rawValue: rawType) else { + print("Unknown type: \(rawType)") + print(json) + return + } + + if type == .ping { + sendPong(to: sourceId) + print("PING from \(sourceId)") + } + + disconnectTimer = Timer(timeInterval: disconnectTimeout, + target: self, + selector: #selector(handleTimeout), + userInfo: nil, + repeats: false) + } + + private func startBeating() { + _ = timer + sendPing() + } + + @objc private func sendPing() { + let request = requestDispatcher.request(withNamespace: namespace, + destinationId: CastConstants.transport, + payload: [CastJSONPayloadKeys.type: CastMessageType.ping.rawValue]) + + send(request) + } + + private func sendPong(to destinationId: String) { + let request = requestDispatcher.request(withNamespace: namespace, + destinationId: destinationId, + payload: [CastJSONPayloadKeys.type: CastMessageType.pong.rawValue]) + + send(request) + } + + @objc private func handleTimeout() { + delegate?.channelDidTimeout(self) + } +} + +protocol HeartbeatChannelDelegate: AnyObject { + func channelDidConnect(_ channel: HeartbeatChannel) + func channelDidTimeout(_ channel: HeartbeatChannel) +} diff --git a/JellyfinPlayer/OpenCastSwift/Networking/Channels/MediaControlChannel.swift b/JellyfinPlayer/OpenCastSwift/Networking/Channels/MediaControlChannel.swift new file mode 100644 index 00000000..0d0884d8 --- /dev/null +++ b/JellyfinPlayer/OpenCastSwift/Networking/Channels/MediaControlChannel.swift @@ -0,0 +1,132 @@ +// +// MediaControlChannel.swift +// OpenCastSwift +// +// Created by Miles Hollingsworth on 4/22/18 +// Copyright © 2018 Miles Hollingsworth. All rights reserved. +// + +import Foundation +import Result +import SwiftyJSON + +class MediaControlChannel: CastChannel { + private var delegate: MediaControlChannelDelegate? { + return requestDispatcher as? MediaControlChannelDelegate + } + + init() { + super.init(namespace: CastNamespace.media) + } + + override func handleResponse(_ json: JSON, sourceId: String) { + guard let rawType = json["type"].string else { return } + + guard let type = CastMessageType(rawValue: rawType) else { + print("Unknown type: \(rawType)") + print(json) + return + } + + switch type { + case .mediaStatus: + guard let status = json["status"].array?.first else { return } + + delegate?.channel(self, didReceive: CastMediaStatus(json: status)) + + default: + print(rawType) + } + } + + public func requestMediaStatus(for app: CastApp, completion: ((Result) -> Void)? = nil) { + let payload: [String: Any] = [ + CastJSONPayloadKeys.type: CastMessageType.statusRequest.rawValue, + CastJSONPayloadKeys.sessionId: app.sessionId + ] + + let request = requestDispatcher.request(withNamespace: namespace, + destinationId: app.transportId, + payload: payload) + + if let completion = completion { + send(request) { result in + switch result { + case .success(let json): + completion(Result(value: CastMediaStatus(json: json))) + + case .failure(let error): + completion(Result(error: error)) + } + } + } else { + send(request) + } + } + + public func sendPause(for app: CastApp, mediaSessionId: Int) { + send(.pause, for: app, mediaSessionId: mediaSessionId) + } + + public func sendPlay(for app: CastApp, mediaSessionId: Int) { + send(.play, for: app, mediaSessionId: mediaSessionId) + } + + public func sendStop(for app: CastApp, mediaSessionId: Int) { + send(.stop, for: app, mediaSessionId: mediaSessionId) + } + + public func sendSeek(to currentTime: Float, for app: CastApp, mediaSessionId: Int) { + let payload: [String: Any] = [ + CastJSONPayloadKeys.type: CastMessageType.seek.rawValue, + CastJSONPayloadKeys.sessionId: app.sessionId, + CastJSONPayloadKeys.currentTime: currentTime, + CastJSONPayloadKeys.mediaSessionId: mediaSessionId + ] + + let request = requestDispatcher.request(withNamespace: namespace, + destinationId: app.transportId, + payload: payload) + + send(request) + } + + private func send(_ message: CastMessageType, for app: CastApp, mediaSessionId: Int) { + let payload: [String: Any] = [ + CastJSONPayloadKeys.type: message.rawValue, + CastJSONPayloadKeys.mediaSessionId: mediaSessionId + ] + + let request = requestDispatcher.request(withNamespace: namespace, + destinationId: app.transportId, + payload: payload) + + send(request) + } + + public func load(media: CastMedia, with app: CastApp, completion: @escaping (Result) -> Void) { + var payload = media.dict + payload[CastJSONPayloadKeys.type] = CastMessageType.load.rawValue + payload[CastJSONPayloadKeys.sessionId] = app.sessionId + + let request = requestDispatcher.request(withNamespace: namespace, + destinationId: app.transportId, + payload: payload) + + send(request) { result in + switch result { + case .success(let json): + guard let status = json["status"].array?.first else { return } + + completion(Result(value: CastMediaStatus(json: status))) + + case .failure(let error): + completion(Result(error: CastError.load(error.localizedDescription))) + } + } + } +} + +protocol MediaControlChannelDelegate: AnyObject { + func channel(_ channel: MediaControlChannel, didReceive mediaStatus: CastMediaStatus) +} diff --git a/JellyfinPlayer/OpenCastSwift/Networking/Channels/MultizoneControlChannel.swift b/JellyfinPlayer/OpenCastSwift/Networking/Channels/MultizoneControlChannel.swift new file mode 100644 index 00000000..010259f5 --- /dev/null +++ b/JellyfinPlayer/OpenCastSwift/Networking/Channels/MultizoneControlChannel.swift @@ -0,0 +1,115 @@ +// +// MultizoneControlChannel.swift +// OpenCastSwift Mac +// +// Created by Miles Hollingsworth on 4/22/18 +// Copyright © 2018 Miles Hollingsworth. All rights reserved. +// + +import Foundation +import Result +import SwiftyJSON + +class MultizoneControlChannel: CastChannel { + override weak var requestDispatcher: RequestDispatchable! { + didSet { + if let _ = requestDispatcher { + requestStatus() + } + } + } + + private var delegate: MultizoneControlChannelDelegate? { + return requestDispatcher as? MultizoneControlChannelDelegate + } + + init() { + super.init(namespace: CastNamespace.multizone) + } + + override func handleResponse(_ json: JSON, sourceId: String) { + guard let rawType = json["type"].string else { return } + + guard let type = CastMessageType(rawValue: rawType) else { + print("Unknown type: \(rawType)") + print(json) + return + } + + switch type { + case .multizoneStatus: + delegate?.channel(self, didReceive: CastMultizoneStatus(json: json)) + + case .deviceAdded: + let device = CastMultizoneDevice(json: json[CastJSONPayloadKeys.device]) + delegate?.channel(self, added: device) + + case .deviceUpdated: + let device = CastMultizoneDevice(json: json[CastJSONPayloadKeys.device]) + delegate?.channel(self, updated: device) + + case .deviceRemoved: + guard let deviceId = json[CastJSONPayloadKeys.deviceId].string else { return } + delegate?.channel(self, removed: deviceId) + + default: + print(rawType) + print(json) + } + } + + public func requestStatus(completion: ((Result) -> Void)? = nil) { + let request = requestDispatcher.request(withNamespace: namespace, + destinationId: CastConstants.receiver, + payload: [CastJSONPayloadKeys.type: CastMessageType.statusRequest.rawValue]) + + if let completion = completion { + send(request) { result in + switch result { + case .success(let json): + completion(Result(value: CastStatus(json: json))) + + case .failure(let error): + completion(Result(error: error)) + } + } + } else { + send(request) + } + } + + public func setVolume(_ volume: Float, for device: CastMultizoneDevice) { + let payload: [String: Any] = [ + CastJSONPayloadKeys.type: CastMessageType.setDeviceVolume.rawValue, + CastJSONPayloadKeys.volume: [CastJSONPayloadKeys.level: volume], + CastJSONPayloadKeys.deviceId: device.id + ] + + let request = requestDispatcher.request(withNamespace: namespace, + destinationId: CastConstants.receiver, + payload: payload) + + send(request) + } + + public func setMuted(_ isMuted: Bool, for device: CastMultizoneDevice) { + let payload: [String: Any] = [ + CastJSONPayloadKeys.type: CastMessageType.setVolume.rawValue, + CastJSONPayloadKeys.volume: [CastJSONPayloadKeys.muted: isMuted], + CastJSONPayloadKeys.deviceId: device.id + ] + + let request = requestDispatcher.request(withNamespace: namespace, + destinationId: CastConstants.receiver, + payload: payload) + + send(request) + } +} + +protocol MultizoneControlChannelDelegate: AnyObject { + func channel(_ channel: MultizoneControlChannel, didReceive status: CastMultizoneStatus) + func channel(_ channel: MultizoneControlChannel, added device: CastMultizoneDevice) + func channel(_ channel: MultizoneControlChannel, updated device: CastMultizoneDevice) + func channel(_ channel: MultizoneControlChannel, removed deviceId: String) +} diff --git a/JellyfinPlayer/OpenCastSwift/Networking/Channels/ReceiverControlChannel.swift b/JellyfinPlayer/OpenCastSwift/Networking/Channels/ReceiverControlChannel.swift new file mode 100644 index 00000000..85a738a1 --- /dev/null +++ b/JellyfinPlayer/OpenCastSwift/Networking/Channels/ReceiverControlChannel.swift @@ -0,0 +1,157 @@ +// +// ReceiverControlChannel.swift +// OpenCastSwift +// +// Created by Miles Hollingsworth on 4/22/18 +// Copyright © 2018 Miles Hollingsworth. All rights reserved. +// + +import Foundation +import Result +import SwiftyJSON + +class ReceiverControlChannel: CastChannel { + override weak var requestDispatcher: RequestDispatchable! { + didSet { + if let _ = requestDispatcher { + requestStatus() + } + } + } + + private var delegate: ReceiverControlChannelDelegate? { + return requestDispatcher as? ReceiverControlChannelDelegate + } + + init() { + super.init(namespace: CastNamespace.receiver) + } + + override func handleResponse(_ json: JSON, sourceId: String) { + guard let rawType = json["type"].string else { return } + + guard let type = CastMessageType(rawValue: rawType) else { + print("Unknown type: \(rawType)") + print(json) + return + } + + switch type { + case .status: + delegate?.channel(self, didReceive: CastStatus(json: json)) + + default: + print(rawType) + } + } + + public func getAppAvailability(apps: [CastApp], completion: @escaping (Result) -> Void) { + let payload: [String: Any] = [ + CastJSONPayloadKeys.type: CastMessageType.availableApps.rawValue, + CastJSONPayloadKeys.appId: apps.map { $0.id } + ] + + let request = requestDispatcher.request(withNamespace: namespace, + destinationId: CastConstants.receiver, + payload: payload) + + send(request) { result in + switch result { + case .success(let json): + completion(Result(value: AppAvailability(json: json))) + case .failure(let error): + completion(Result(error: CastError.launch(error.localizedDescription))) + } + } + } + + public func requestStatus(completion: ((Result) -> Void)? = nil) { + let request = requestDispatcher.request(withNamespace: namespace, + destinationId: CastConstants.receiver, + payload: [CastJSONPayloadKeys.type: CastMessageType.statusRequest.rawValue]) + + if let completion = completion { + send(request) { result in + switch result { + case .success(let json): + completion(Result(value: CastStatus(json: json))) + + case .failure(let error): + completion(Result(error: error)) + } + } + } else { + send(request) + } + } + + func launch(appId: String, completion: @escaping (Result) -> Void) { + let payload: [String: Any] = [ + CastJSONPayloadKeys.type: CastMessageType.launch.rawValue, + CastJSONPayloadKeys.appId: appId + ] + + let request = requestDispatcher.request(withNamespace: namespace, + destinationId: CastConstants.receiver, + payload: payload) + + send(request) { result in + switch result { + case .success(let json): + guard let app = CastStatus(json: json).apps.first else { + completion(Result(error: CastError.launch("Unable to get launched app instance"))) + return + } + + completion(Result(value: app)) + + case .failure(let error): + completion(Result(error: error)) + } + + } + } + + public func stop(app: CastApp) { + let payload: [String: Any] = [ + CastJSONPayloadKeys.type: CastMessageType.stop.rawValue, + CastJSONPayloadKeys.sessionId: app.sessionId + ] + + let request = requestDispatcher.request(withNamespace: namespace, + destinationId: CastConstants.receiver, + payload: payload) + + send(request) + } + + public func setVolume(_ volume: Float) { + let payload: [String: Any] = [ + CastJSONPayloadKeys.type: CastMessageType.setVolume.rawValue, + CastJSONPayloadKeys.volume: [CastJSONPayloadKeys.level: volume] + ] + + let request = requestDispatcher.request(withNamespace: namespace, + destinationId: CastConstants.receiver, + payload: payload) + + send(request) + } + + public func setMuted(_ isMuted: Bool) { + let payload: [String: Any] = [ + CastJSONPayloadKeys.type: CastMessageType.setVolume.rawValue, + CastJSONPayloadKeys.volume: [CastJSONPayloadKeys.muted: isMuted] + ] + + let request = requestDispatcher.request(withNamespace: namespace, + destinationId: CastConstants.receiver, + payload: payload) + + send(request) + } +} + +protocol ReceiverControlChannelDelegate: RequestDispatchable { + func channel(_ channel: ReceiverControlChannel, didReceive status: CastStatus) +} diff --git a/JellyfinPlayer/OpenCastSwift/Networking/Channels/RequestSink.swift b/JellyfinPlayer/OpenCastSwift/Networking/Channels/RequestSink.swift new file mode 100644 index 00000000..25492c84 --- /dev/null +++ b/JellyfinPlayer/OpenCastSwift/Networking/Channels/RequestSink.swift @@ -0,0 +1,38 @@ +// +// RequestDispatchable.swift +// OpenCastSwift Mac +// +// Created by Miles Hollingsworth on 4/22/18 +// Copyright © 2018 Miles Hollingsworth. All rights reserved. +// + +import Foundation + +protocol RequestDispatchable: AnyObject { + func nextRequestId() -> Int + + func request(withNamespace namespace: String, destinationId: String, payload: [String: Any]) -> CastRequest + func request(withNamespace namespace: String, destinationId: String, payload: Data) -> CastRequest + + func send(_ request: CastRequest, response: CastResponseHandler?) +} + +extension RequestDispatchable { + func request(withNamespace namespace: String, destinationId: String, payload: [String: Any]) -> CastRequest { + var payload = payload + let requestId = nextRequestId() + payload[CastJSONPayloadKeys.requestId] = requestId + + return CastRequest(id: requestId, + namespace: namespace, + destinationId: destinationId, + payload: payload) + } + + func request(withNamespace namespace: String, destinationId: String, payload: Data) -> CastRequest { + return CastRequest(id: nextRequestId(), + namespace: namespace, + destinationId: destinationId, + payload: payload) + } +} diff --git a/JellyfinPlayer/OpenCastSwift/Supporting Files/ChromeCastCore.h b/JellyfinPlayer/OpenCastSwift/Supporting Files/ChromeCastCore.h new file mode 100644 index 00000000..aa85e2c7 --- /dev/null +++ b/JellyfinPlayer/OpenCastSwift/Supporting Files/ChromeCastCore.h @@ -0,0 +1,12 @@ +// +// OpenCastSwift.h +// OpenCastSwift +// +// Created by Miles Hollingsworth on 4/22/18 +// Copyright © 2018 Miles Hollingsworth. All rights reserved. +// + +@import Foundation; + +FOUNDATION_EXPORT double ChromeCastCoreVersionNumber; +FOUNDATION_EXPORT const unsigned char ChromeCastCoreVersionString[]; diff --git a/JellyfinPlayer/VideoPlayer.swift b/JellyfinPlayer/VideoPlayer.swift index c1b6f689..2f9760a5 100644 --- a/JellyfinPlayer/VideoPlayer.swift +++ b/JellyfinPlayer/VideoPlayer.swift @@ -258,8 +258,6 @@ class PlayerViewController: UIViewController, VLCMediaDelegate, VLCMediaPlayerDe override func remoteControlReceived(with event: UIEvent?) { dump(event) } - - // MARK: viewDidLoad override func viewDidLoad() { @@ -268,14 +266,14 @@ class PlayerViewController: UIViewController, VLCMediaDelegate, VLCMediaPlayerDe } else { titleLabel.text = "S\(String(manifest.parentIndexNumber ?? 0)):E\(String(manifest.indexNumber ?? 0)) “\(manifest.name ?? "")”" } - + super.viewDidLoad() - if(!UIDevice.current.orientation.isLandscape) { + if !UIDevice.current.orientation.isLandscape { let value = UIInterfaceOrientation.landscapeLeft.rawValue UIDevice.current.setValue(value, forKey: "orientation") } } - + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) overrideUserInterfaceStyle = .dark @@ -393,9 +391,9 @@ class PlayerViewController: UIViewController, VLCMediaDelegate, VLCMediaPlayerDe selectedAudioTrack = audioTrackArray[0].id } } - + print("gotToEnd") - + self.sendPlayReport() playbackItem = item } @@ -414,8 +412,8 @@ class PlayerViewController: UIViewController, VLCMediaDelegate, VLCMediaPlayerDe // Pause and load captions into memory. mediaPlayer.pause() - - var shouldHaveSubtitleTracks = 0; + + var shouldHaveSubtitleTracks = 0 subtitleTrackArray.forEach { sub in if sub.id != -1 && sub.delivery == .external && sub.codec != "subrip" { shouldHaveSubtitleTracks = shouldHaveSubtitleTracks + 1 @@ -556,9 +554,9 @@ class PlayerViewController: UIViewController, VLCMediaDelegate, VLCMediaPlayerDe func sendPlayReport() { startTime = Int(Date().timeIntervalSince1970) * 10000000 - + print("sending play report!") - + let startInfo = PlaybackStartInfo(canSeek: true, item: manifest, itemId: manifest.id, sessionId: playSessionId, mediaSourceId: manifest.id, audioStreamIndex: Int(selectedAudioTrack), subtitleStreamIndex: Int(selectedCaptionTrack), isPaused: false, isMuted: false, positionTicks: manifest.userData?.playbackPositionTicks, playbackStartTimeTicks: Int64(startTime), volumeLevel: 100, brightness: 100, aspectRatio: nil, playMethod: playbackItem.videoType, liveStreamId: nil, playSessionId: playSessionId, repeatMode: .repeatNone, nowPlayingQueue: [], playlistItemId: "playlistItem0") PlaystateAPI.reportPlaybackStart(playbackStartInfo: startInfo)