├── .DS_Store ├── Icon.draft1.afdesign ├── README.md ├── ScreencapExtension ├── HLS │ ├── FMP4Configuration.swift │ ├── HLSServer.swift │ ├── HLSVideoSegmenter.swift │ ├── HtmlComponents.swift │ ├── HtmlLibraryComponents.swift │ ├── M3u8Collector.swift │ └── Segment.swift ├── Info.plist ├── SampleHandler.swift ├── ScreencapContext.swift ├── ScreencapExtension-Bridging-Header.h ├── ScreencapExtension.entitlements └── Webserver.swift ├── UserStreamConfiguration.swift ├── Wingspan.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ ├── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ │ └── Package.resolved │ └── xcuserdata │ │ └── jdkula.xcuserdatad │ │ └── UserInterfaceState.xcuserstate └── xcuserdata │ └── jdkula.xcuserdatad │ └── xcschemes │ └── xcschememanagement.plist └── Wingspan ├── .DS_Store ├── Assets.xcassets ├── .DS_Store ├── AccentColor.colorset │ └── Contents.json ├── AppIcon.appiconset │ ├── Contents.json │ └── lil icon.png └── Contents.json ├── BroadcastSetupViewController.swift ├── ContentUploadUILaunch.swift ├── Info.plist ├── Preview Content └── Preview Assets.xcassets │ └── Contents.json ├── Util.swift ├── Views ├── ContentView.swift ├── InfoView.swift └── SettingsView.swift ├── Wingspan.entitlements └── WingspanApp.swift /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdkula/HLSStreamer/d7804fec74b5f0492de11e26bf5c1aa3527b5f15/.DS_Store -------------------------------------------------------------------------------- /Icon.draft1.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdkula/HLSStreamer/d7804fec74b5f0492de11e26bf5c1aa3527b5f15/Icon.draft1.afdesign -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HLSStreamer 2 | iOS app that allows you to stream your screen over HLS, no external server or desktop-side applications necessary. 3 | 4 | ## Building 5 | 6 | Clone the repo and open `HLSStreamer.xcodeproj` in Xcode. It will run on iOS devices running iOS/iPadOS 15 or later. 7 | 8 | ## Usage 9 | 10 | While streaming to the app, your iDevice will host a server by default at port `8888`, providing the following endpoints: 11 | 12 | - `/`: A simple webpage with a Video.js player that will play back the livestream. This is suitable for use in OBS. 13 | - `/orientation`: Will return the string `up`, `down`, `left`, or `right` depending on the orientation of your iDevice. 14 | If your iDevice is right-side-up, it will be `up`. 15 | - `/index.m3u8`: The HLS stream playlist. You can open this in VLC or embed it elsewhere and it will play correctly. 16 | - `/video/:file`: The video segments themselves 17 | 18 | The UI provides a couple options: 19 | - `Port`: You can adjust what port the server runs on. 20 | - `Segment Duration`: The higher this is, the more reliable the stream, but the longer the delay. 21 | - `Video Bitrate (Mbps)`: The higher this is, the higher quality the streams but the higher the bandwidth 22 | they use. In my testing, adjusting this hasn't seemed to do much. 23 | 24 | Files are stored in your iDevice's temporary storage and deleted when they leave the HLS sliding window (see below). 25 | They are also cleared when you stop streaming. 26 | 27 | ## Details 28 | 29 | Generates an HLS stream in fMP4 fragments (`header.mp4` and `\(sequenceNumber).m4s`) that are served from the 30 | iDevice in a sliding window lasting approx. 60 seconds. System audio and video are captured, but the user 31 | microphone is not (even if that option is selected while attempting to broadcast to `HLSStreamer`. 32 | 33 | ## Demo 34 | 35 | Recorded on Chrome, streaming from iPad (stream delay of approx. 5 seconds) 36 | 37 | 38 | https://user-images.githubusercontent.com/4166625/199675929-ec517b69-efc2-45b9-89e0-a85d95f286e7.mp4 39 | 40 | 41 | Interface screenshot 42 | Interface screenshot while streaming 43 | 44 | 45 | ## Attributions 46 | 47 | The HTTP server is provided by [Swifter](https://github.com/httpswift/swifter) 48 | -------------------------------------------------------------------------------- /ScreencapExtension/HLS/FMP4Configuration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Configuration.swift 3 | // HLSStreamerContentUploadExtension 4 | // 5 | // https://developer.apple.com/videos/play/wwdc2020/10011/ was heavily referenced 6 | // in the creation of this file. 7 | // 8 | // Created by @jdkula on on 11/1/22. 9 | // 10 | 11 | import Foundation 12 | import AVFoundation 13 | import VideoToolbox 14 | import UIKit 15 | 16 | /// Conveniently colocates and generates compression settings used elsewhere. 17 | struct FMP4Configuration { 18 | var segmentDuration: Double 19 | var segmentFileNamePrefix = "seq" 20 | 21 | var audioCompressionSettings: [String: Any] = [ 22 | AVFormatIDKey: kAudioFormatMPEG4AAC, 23 | // For simplicity, hard-code a common sample rate. 24 | // For a production use case, modify this as necessary to get the desired results given the source content. 25 | AVSampleRateKey: 44_100, 26 | AVNumberOfChannelsKey: 2, 27 | AVEncoderBitRateKey: 160_000 28 | ] 29 | var videoCompressionSettings: [String: Any] 30 | var minimumAllowableSourceFrameDuration: CMTime 31 | 32 | 33 | init(segmentDuration: Double = 1, videoBitrateMbps: Double = 6, fps: Int = 60, width: Int = Int(UIScreen.main.bounds.size.height), height: Int = Int(UIScreen.main.bounds.size.width)) { 34 | self.segmentDuration = segmentDuration 35 | self.videoCompressionSettings = [ 36 | AVVideoCodecKey: AVVideoCodecType.h264, 37 | // For simplicity, assume 16:9 aspect ratio. 38 | // For a production use case, modify this as necessary to match the source content. 39 | AVVideoWidthKey: width, 40 | AVVideoHeightKey: height, 41 | ] 42 | if (videoBitrateMbps != UserStreamConfiguration.kLossless) { 43 | self.videoCompressionSettings[AVVideoCompressionPropertiesKey] = [ 44 | kVTCompressionPropertyKey_AverageBitRate: videoBitrateMbps * 1_000_000, 45 | kVTCompressionPropertyKey_ProfileLevel: kVTProfileLevel_H264_Main_5_2, 46 | kVTCompressionPropertyKey_Quality: 1, 47 | ] 48 | } 49 | self.minimumAllowableSourceFrameDuration = CMTime(value: 1, timescale: CMTimeScale(fps)) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /ScreencapExtension/HLS/HLSServer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HLSServer.swift 3 | // HLSStreamer 4 | // 5 | // Provides a simple HTTP server that dynamically generates 6 | // an m3u8 playlist from an M3u8Collector, and serves mp4 7 | // and m4s files from the specified directory. 8 | // 9 | // It also provides the current device orientation, which can 10 | // be used to rotate the video on the client/desktop side. 11 | // 12 | // Created by @jdkula on 10/31/22. 13 | // 14 | 15 | import Foundation 16 | import Swifter 17 | import AVKit 18 | 19 | class HLSServer : WebserverConfigurator { 20 | private let m3u8_: M3u8Collector 21 | private let dir_: URL? 22 | 23 | init(dir: URL?, m3u8: M3u8Collector) { 24 | self.m3u8_ = m3u8 25 | self.dir_ = dir; 26 | } 27 | 28 | func prepareWebserver(webserver: Swifter.HttpServer) { 29 | webserver["/"] = { request in 30 | return HttpResponse.ok(.html(kIndexHtml)) 31 | } 32 | 33 | webserver["/index.m3u8"] = { request in 34 | return HttpResponse.ok(.text(self.m3u8_.getM3u8())) 35 | } 36 | 37 | if dir_ != nil { 38 | webserver["/video/:path"] = shareFilesFromDirectory(dir_!.path()) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /ScreencapExtension/HLS/HLSVideoSegmenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VideoSegment.swift 3 | // HLSStreamerContentUploadExtension 4 | // 5 | // https://developer.apple.com/videos/play/wwdc2020/10011/ was heavily referenced 6 | // in the creation of this file. 7 | // 8 | // Created by @jdkula on 10/31/22. 9 | // 10 | 11 | import Foundation 12 | import AVKit 13 | import Combine 14 | 15 | /** 16 | * Provides an interface to automatically segment an input stream into 17 | * fMP4 segments, which are later passed to the callback defined by ``VideoSegmenter.setOnSegment`` 18 | */ 19 | class HLSVideoSegmenter: NSObject, AVAssetWriterDelegate, ScreencapDataReceiver, Subject { 20 | private let subject_ : PassthroughSubject 21 | func send(_ value: Segment) { 22 | subject_.send(value) 23 | } 24 | func receive(subscriber: S) where S : Subscriber, Error == S.Failure, Segment == S.Input { 25 | subject_.receive(subscriber: subscriber) 26 | } 27 | func send(completion: Subscribers.Completion) { 28 | subject_.send(completion: completion) 29 | } 30 | 31 | func send(subscription: Subscription) { 32 | subject_.send(subscription: subscription) 33 | } 34 | typealias Output = Segment 35 | typealias Failure = Error 36 | 37 | private var subscription_: Subscription? 38 | func receive(subscription: Subscription) { 39 | subscription_ = subscription 40 | subscription.request(.unlimited) 41 | } 42 | 43 | func receive(_ input: ScreencapSampleBuffer) -> Subscribers.Demand { 44 | switch (input) { 45 | case .video(let buf): 46 | processVideo(chunk: buf) 47 | break 48 | case .deviceAudio(let buf): 49 | processAudio(chunk: buf) 50 | break 51 | default: 52 | break 53 | } 54 | return .unlimited 55 | } 56 | 57 | func receive(completion: Subscribers.Completion) { 58 | // Do nothing 59 | } 60 | 61 | private var config_: UserStreamConfiguration 62 | 63 | private var outputWriter_: AVAssetWriter? 64 | private var videoIn_: AVAssetWriterInput? 65 | private var audioIn_: AVAssetWriterInput? 66 | private var finished_: Bool 67 | 68 | private let outputDir_: URL 69 | 70 | private var curSeq_ = 0 71 | 72 | private var sessionStarted_: Bool 73 | 74 | init(outputDir: URL, config: UserStreamConfiguration) { 75 | outputDir_ = outputDir 76 | sessionStarted_ = false 77 | finished_ = false 78 | config_ = config 79 | subject_ = PassthroughSubject() 80 | 81 | super.init() 82 | } 83 | 84 | // Called each time outputWriter_ produces a segment (set by ouputWriter_.delegate = self) 85 | @objc func assetWriter(_ writer: AVAssetWriter, 86 | didOutputSegmentData segmentData: Data, 87 | segmentType: AVAssetSegmentType, 88 | segmentReport: AVAssetSegmentReport?) { 89 | let isInitializationSegment: Bool 90 | 91 | switch segmentType { 92 | case .initialization: 93 | isInitializationSegment = true 94 | case .separable: 95 | isInitializationSegment = false 96 | @unknown default: 97 | Swift.print("Skipping segment with unrecognized type \(segmentType)") 98 | return 99 | } 100 | 101 | let url = outputDir_.appending(component: isInitializationSegment ? "header.mp4" : "\(curSeq_).m4s") 102 | try! segmentData.write(to: url) 103 | 104 | send(Segment( 105 | url: url, 106 | index: curSeq_, 107 | isInitializationSegment: isInitializationSegment, 108 | report: segmentReport, 109 | trackReports: segmentReport?.trackReports)) 110 | 111 | curSeq_ += 1 112 | } 113 | 114 | private func initAVWriters_(chunk: CMSampleBuffer, config: FMP4Configuration) { 115 | outputWriter_ = AVAssetWriter(contentType: UTType(AVFileType.mp4.rawValue)!) 116 | 117 | videoIn_ = AVAssetWriterInput(mediaType: AVMediaType.video, outputSettings: config.videoCompressionSettings) 118 | audioIn_ = AVAssetWriterInput(mediaType: AVMediaType.audio, outputSettings: config.audioCompressionSettings) 119 | 120 | outputWriter_!.outputFileTypeProfile = .mpeg4AppleHLS 121 | outputWriter_!.initialSegmentStartTime = CMSampleBufferGetPresentationTimeStamp(chunk) 122 | outputWriter_!.preferredOutputSegmentInterval = CMTime(seconds: Double(config.segmentDuration), preferredTimescale: 1) 123 | outputWriter_!.delegate = self 124 | 125 | videoIn_!.expectsMediaDataInRealTime = true 126 | audioIn_!.expectsMediaDataInRealTime = true 127 | 128 | outputWriter_!.add(videoIn_!) 129 | outputWriter_!.add(audioIn_!) 130 | } 131 | 132 | private func maybeStartSession_(chunk: CMSampleBuffer) { 133 | if !sessionStarted_ { 134 | if let formatDescription = CMSampleBufferGetFormatDescription(chunk) { 135 | let config = FMP4Configuration( 136 | segmentDuration: config_.segmentDuration, 137 | videoBitrateMbps: config_.videoBitrateMbps, 138 | width: Int(formatDescription.dimensions.width), 139 | height: Int(formatDescription.dimensions.height)) 140 | initAVWriters_(chunk: chunk, config: config) 141 | if !self.outputWriter_!.startWriting() { 142 | fatalError("Failed to begin writing to the output file") 143 | } 144 | self.outputWriter_!.startSession(atSourceTime: CMSampleBufferGetPresentationTimeStamp(chunk)) 145 | sessionStarted_ = true 146 | } 147 | } 148 | } 149 | 150 | private func processVideo(chunk: CMSampleBuffer) { 151 | if finished_ { 152 | return 153 | } 154 | 155 | maybeStartSession_(chunk: chunk) 156 | 157 | if videoIn_ != nil && videoIn_!.isReadyForMoreMediaData { 158 | videoIn_!.append(chunk) 159 | } 160 | } 161 | 162 | private func processAudio(chunk: CMSampleBuffer) { 163 | if finished_ { 164 | return 165 | } 166 | 167 | maybeStartSession_(chunk: chunk) 168 | 169 | if audioIn_ != nil && audioIn_!.isReadyForMoreMediaData { 170 | audioIn_!.append(chunk) 171 | } 172 | } 173 | 174 | func finish() { 175 | if finished_ { 176 | return 177 | } 178 | 179 | finished_ = true 180 | outputWriter_?.finishWriting {} 181 | } 182 | } 183 | 184 | protocol SegmentDataReceiver : Subscriber { 185 | 186 | } 187 | -------------------------------------------------------------------------------- /ScreencapExtension/HLS/HtmlComponents.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HtmlLibraryComponents.swift 3 | // HLSStreamerContentUploadExtension 4 | // 5 | // Provides HTML/etc that is used by the internal HTTP server. 6 | // 7 | // Created by @jdkula on 11/2/22. 8 | // 9 | 10 | let kIndexHtml = """ 11 | 12 | 13 | 14 | Playback 15 | 18 | 21 | 22 | 23 | 26 | 27 | 30 | 34 | 64 | 65 | 66 | """ 67 | -------------------------------------------------------------------------------- /ScreencapExtension/HLS/M3u8Collector.swift: -------------------------------------------------------------------------------- 1 | // 2 | // M3u8Collector.swift 3 | // HLSStreamerContentUploadExtension 4 | // 5 | // 6 | // Created by @jdkula on 11/1/22. 7 | // 8 | 9 | import Foundation 10 | import AVKit 11 | import Combine 12 | 13 | /** 14 | * Provides the ongoing generation of a single M3U8 playlist, 15 | * accepting Segments to record details of. 16 | * 17 | * Once a segment is given to ``M3u8Collector``, it owns that segment, 18 | * and will delete it when it is no longer needed. The segments are assumed to 19 | * exist already by the time they get here. 20 | */ 21 | class M3u8Collector : SegmentDataReceiver { 22 | private var subscription_: Subscription? 23 | func receive(subscription: Subscription) { 24 | subscription_ = subscription 25 | subscription.request(.unlimited) 26 | } 27 | 28 | func receive(_ input: Segment) -> Subscribers.Demand { 29 | if input.isInitializationSegment { 30 | initM3u8(segment: input) 31 | } else { 32 | addSegment(segment: input) 33 | } 34 | return .unlimited 35 | } 36 | 37 | func receive(completion: Subscribers.Completion) { 38 | subscription_ = nil 39 | } 40 | 41 | private var headerSegment_: Segment? = nil 42 | private var segments_: [Segment] = [] 43 | private var segmentDuration_: Double = 0.0 44 | 45 | private var seqNo_: Int = 1; 46 | private var segmentsToKeep_: Int = 0 47 | 48 | private let folderPrefix_: String 49 | 50 | init(urlPrefix: String) { 51 | folderPrefix_ = urlPrefix; 52 | } 53 | 54 | private func getHeader_() -> String { 55 | return "#EXTM3U\n" 56 | + "#EXT-X-TARGETDURATION:\(segmentDuration_)\n" 57 | + "#EXT-X-VERSION:7\n" 58 | + "#EXT-X-MEDIA-SEQUENCE:\(seqNo_)\n" 59 | + "#EXT-X-MAP:URI=\"\(folderPrefix_)/header.mp4\"\n" 60 | } 61 | 62 | private func getContent_() -> String { 63 | var m3u8 = "" 64 | 65 | for segment in segments_ { 66 | if let segmentDuration = segment.trackReports?.max(by: {a, b in a.duration.seconds < b.duration.seconds })?.duration.seconds { 67 | // Sometimes we can get length-zero durations 68 | if segmentDuration > 0 { 69 | m3u8 += "#EXTINF:\(String(format: "%1.5f", segmentDuration)),\t\n\(folderPrefix_)/\(segment.index).m4s\n" 70 | } 71 | } 72 | } 73 | 74 | return m3u8 75 | } 76 | 77 | private func maybePruneSegments_() { 78 | while segments_.count > segmentsToKeep_ { 79 | let seg = segments_.remove(at: 0) 80 | seqNo_ += 1; 81 | 82 | // Asynchronously delete this segment. 83 | DispatchQueue.global(qos: .background).async { 84 | do { 85 | try FileManager.default.removeItem(at: seg.url) 86 | } catch { 87 | print("Got error removing item at", seg.url) 88 | } 89 | } 90 | } 91 | } 92 | 93 | /// Initializes a new M3u8 playlist with the given fMP4 configuration and header segment. 94 | func initM3u8(segment: Segment) { 95 | assert(segment.isInitializationSegment) 96 | let config = ScreencapContext.instance().getUserConfig() 97 | 98 | // 60 seconds worth of segments, or a minimum of 10. This is pretty arbitrary, could be configurable later. 99 | segmentsToKeep_ = max(10, Int(60 / config.segmentDuration)) 100 | seqNo_ = 1; 101 | segments_ = []; 102 | headerSegment_ = segment; 103 | segmentDuration_ = config.segmentDuration 104 | } 105 | 106 | /// Adds a segment to the end of this playlist, pruning if necessary 107 | func addSegment(segment: Segment) { 108 | segments_.append(segment); 109 | maybePruneSegments_(); 110 | } 111 | 112 | /// Generates and returns the current M3U8 playlist as a string. 113 | func getM3u8() -> String { 114 | return getHeader_() + getContent_() 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /ScreencapExtension/HLS/Segment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Segment.swift 3 | // HLSStreamerContentUploadExtension 4 | // 5 | // https://developer.apple.com/videos/play/wwdc2020/10011/ was heavily referenced 6 | // in the creation of this file. 7 | // 8 | // Created by @jdkula on 11/1/22. 9 | // 10 | 11 | import Foundation 12 | import AVKit 13 | 14 | /** 15 | * Packages together helpful information about a single fMP4 (m4s) segment. 16 | */ 17 | struct Segment { 18 | /// The URL the segment is located at; used to later remove that segment. 19 | let url: URL 20 | 21 | /// The index of this segment in sequence. 22 | let index: Int 23 | 24 | /// Whether or not this segment is the initialization segment, which provides additional metadata for the entire stream. 25 | let isInitializationSegment: Bool 26 | 27 | /// The raw ``AVAssetSegmentReport`` that gives information about this segment. 28 | let report: AVAssetSegmentReport? 29 | 30 | /// If this segment encodes video information, we also keep track of the track report (this allows us to figure out timing details later). 31 | var trackReports: [AVAssetSegmentTrackReport]? 32 | } 33 | -------------------------------------------------------------------------------- /ScreencapExtension/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSExtension 6 | 7 | NSExtensionPointIdentifier 8 | com.apple.broadcast-services-upload 9 | NSExtensionPrincipalClass 10 | $(PRODUCT_MODULE_NAME).SampleHandler 11 | RPBroadcastProcessMode 12 | RPBroadcastProcessModeSampleBuffer 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /ScreencapExtension/SampleHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SampleHandler.swift 3 | // HLSStreamerContentUploadExtension 4 | // 5 | // https://developer.apple.com/videos/play/wwdc2018/601/ was heavily referenced 6 | // in the creation of this file. 7 | // 8 | // Created by @jdkula on 10/31/22. 9 | // 10 | 11 | import ReplayKit 12 | import VideoToolbox 13 | 14 | /// Entrypoint of the extension. This is invoked/created/used when a system broadcast starts. 15 | class SampleHandler: RPBroadcastSampleHandler { 16 | override init() { 17 | super.init() 18 | // Try to load stream configuration options from disk 19 | let config = (try? UserStreamConfiguration.loadSync()) ?? UserStreamConfiguration(); 20 | let dir = ScreencapContext.getTemporaryDirectory() 21 | 22 | let segmenter = HLSVideoSegmenter(outputDir: dir, config: config) 23 | let m3u8collector = M3u8Collector(urlPrefix: "video") 24 | let server = HLSServer(dir: dir, m3u8: m3u8collector) 25 | 26 | segmenter.receive(subscriber: m3u8collector) 27 | 28 | ScreencapContext.initialize(userConfig: config, withServer: server).getFrameStream().receive(subscriber: segmenter) 29 | 30 | ScreencapContext.clearTemporaryDirectory() 31 | } 32 | 33 | override func broadcastStarted(withSetupInfo setupInfo: [String : NSObject]?) { 34 | // User has requested to start the broadcast. Setup info from the UI extension can be supplied but optional. 35 | } 36 | 37 | override func broadcastPaused() { 38 | // User has requested to pause the broadcast. Samples will stop being delivered. 39 | } 40 | 41 | override func broadcastResumed() { 42 | // User has requested to resume the broadcast. Samples delivery will resume. 43 | } 44 | 45 | override func broadcastFinished() { 46 | // User has requested to finish the broadcast. 47 | ScreencapContext.clearTemporaryDirectory() 48 | } 49 | 50 | override func processSampleBuffer(_ sampleBuffer: CMSampleBuffer, with sampleBufferType: RPSampleBufferType) { 51 | ScreencapContext.instance().getFrameStream().send(sampleBuffer.attachType(sampleBufferType)) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /ScreencapExtension/ScreencapContext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileUtil.swift 3 | // ScreencapExtension 4 | // 5 | // Created by Jonathan Kula on 11/4/22. 6 | // 7 | 8 | import Foundation 9 | import AVKit 10 | import Combine 11 | import ReplayKit 12 | 13 | class ScreencapContext { 14 | private static var instance_: ScreencapContext? 15 | private static var temporaryDirectory_: URL { 16 | guard let tempDir = try? FileManager.default.url( 17 | for: FileManager.SearchPathDirectory.cachesDirectory, 18 | in: FileManager.SearchPathDomainMask.allDomainsMask, 19 | appropriateFor: nil, 20 | create: true) else { 21 | fatalError("Could not acquire temporary directory") 22 | } 23 | return tempDir 24 | } 25 | 26 | private let userConfig_: UserStreamConfiguration; 27 | private let frameStream_: PassthroughSubject 28 | private var webserver_: Webserver? 29 | 30 | 31 | private init(userConfig: UserStreamConfiguration) { 32 | userConfig_ = userConfig; 33 | frameStream_ = PassthroughSubject() 34 | } 35 | 36 | private func prepareServer(_ configurator: WebserverConfigurator) { 37 | webserver_ = Webserver(); 38 | webserver_!.configure(configurator); 39 | do { 40 | try webserver_!.start() 41 | } catch { 42 | fatalError("Failed to start web server"); 43 | } 44 | } 45 | 46 | static func getTemporaryDirectory() -> URL { 47 | return ScreencapContext.temporaryDirectory_ 48 | } 49 | 50 | func getUserConfig() -> UserStreamConfiguration { 51 | return userConfig_; 52 | } 53 | 54 | func getFrameStream() -> PassthroughSubject { 55 | return frameStream_; 56 | } 57 | 58 | 59 | static func clearTemporaryDirectory() { 60 | DispatchQueue.global(qos: .background).async { 61 | do { 62 | try FileManager.default.contentsOfDirectory(at: getTemporaryDirectory(), includingPropertiesForKeys: nil).forEach { url in 63 | do { 64 | try FileManager.default.removeItem(at: url) 65 | } catch { 66 | print("Failed to delete file at", url) 67 | } 68 | } 69 | } catch { 70 | print("Failed to list directory...") 71 | } 72 | } 73 | } 74 | 75 | 76 | 77 | static func initialize(userConfig: UserStreamConfiguration, withServer configurator: WebserverConfigurator) -> ScreencapContext { 78 | if instance_ != nil { 79 | fatalError("Tried to initialize multiple contexts. There should only be one per invocation.") 80 | } 81 | 82 | instance_ = ScreencapContext(userConfig: userConfig) 83 | instance_!.prepareServer(configurator) 84 | return instance_! 85 | } 86 | 87 | static func instance() -> ScreencapContext { 88 | if instance_ == nil { 89 | fatalError("Tried to get instance of ScreencapContext before it was initialized") 90 | } 91 | 92 | return instance_! 93 | } 94 | } 95 | 96 | enum ScreencapSampleBuffer { 97 | case video(CMSampleBuffer) 98 | case deviceAudio(CMSampleBuffer) 99 | case micAudio(CMSampleBuffer) 100 | } 101 | 102 | extension CMSampleBuffer { 103 | func attachType(_ type: RPSampleBufferType) -> ScreencapSampleBuffer { 104 | switch (type) { 105 | case .video: 106 | return ScreencapSampleBuffer.video(self) 107 | case .audioApp: 108 | return ScreencapSampleBuffer.deviceAudio(self) 109 | case .audioMic: 110 | return ScreencapSampleBuffer.micAudio(self) 111 | @unknown default: 112 | fatalError("Got unknown sample type when creating ScreencapSampleBuffer") 113 | } 114 | } 115 | } 116 | 117 | protocol ScreencapDataReceiver : Subscriber { 118 | 119 | } 120 | -------------------------------------------------------------------------------- /ScreencapExtension/ScreencapExtension-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | // 2 | // Use this file to import your target's public headers that you would like to expose to Swift. 3 | // 4 | 5 | -------------------------------------------------------------------------------- /ScreencapExtension/ScreencapExtension.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.application-groups 6 | 7 | group.dev.jdkula.Wingspan.config 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /ScreencapExtension/Webserver.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Webserver.swift 3 | // ScreencapExtension 4 | // 5 | // Created by Jonathan Kula on 11/4/22. 6 | // 7 | 8 | import Foundation 9 | import Swifter 10 | import AVKit 11 | import ReplayKit 12 | 13 | class Webserver { 14 | private let server_: HttpServer 15 | 16 | private var orientation_: String = "up" 17 | 18 | init() { 19 | self.server_ = HttpServer() 20 | 21 | server_["/orientation"] = { request in 22 | return HttpResponse.ok(.text(self.orientation_)) 23 | } 24 | 25 | let _ = ScreencapContext.instance().getFrameStream().sink { err in 26 | // Do nothing 27 | } receiveValue: { buf in 28 | if case ScreencapSampleBuffer.video(let videoBuffer) = buf { 29 | self.updateOrientation_(from: videoBuffer) 30 | } 31 | } 32 | } 33 | 34 | deinit { 35 | server_.stop() 36 | } 37 | 38 | func start() throws { 39 | try server_.start(in_port_t(ScreencapContext.instance().getUserConfig().port)!) 40 | } 41 | 42 | private func updateOrientation_(from chunk: CMSampleBuffer) { 43 | let userRotation = ScreencapContext.instance().getUserConfig().rotation 44 | if userRotation != "auto" { 45 | orientation_ = userRotation 46 | return 47 | } 48 | 49 | if let orientationAttachment = CMGetAttachment(chunk, key: RPVideoSampleOrientationKey as CFString, attachmentModeOut: nil) as? NSNumber 50 | { 51 | let orientation = CGImagePropertyOrientation(rawValue: orientationAttachment.uint32Value) 52 | switch (orientation) { 53 | case .down: 54 | orientation_ = "down" 55 | break 56 | case .up: 57 | orientation_ = "up" 58 | break 59 | case .left: 60 | orientation_ = "left" 61 | break 62 | case .right: 63 | orientation_ = "right" 64 | break 65 | default: 66 | orientation_ = "unknown" 67 | break 68 | } 69 | } 70 | } 71 | 72 | func configure(_ configurator: WebserverConfigurator) { 73 | configurator.prepareWebserver(webserver: server_) 74 | } 75 | } 76 | 77 | protocol WebserverConfigurator { 78 | func prepareWebserver(webserver: HttpServer) 79 | } 80 | -------------------------------------------------------------------------------- /UserStreamConfiguration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HLSConfiguration.swift 3 | // HLSStreamer 4 | // 5 | // Provides global configuration that's shared between 6 | // the UI and the recording extension 7 | // 8 | // Heavily referenced https://developer.apple.com/tutorials/app-dev-training/persisting-data 9 | // 10 | // Created by @jdkula on 11/1/22. 11 | // 12 | 13 | import Foundation 14 | 15 | /// Struct representing the user-configurable aspects of the application. 16 | struct UserStreamConfiguration : Codable { 17 | static let kLossless = 10.5 18 | static let kRealtime = 0.0 19 | 20 | var port: String 21 | var segmentDuration: Double 22 | var videoBitrateMbps: Double 23 | var rotation: String 24 | 25 | init() { 26 | self.port = "8888" 27 | self.segmentDuration = UserStreamConfiguration.kRealtime 28 | self.videoBitrateMbps = UserStreamConfiguration.kLossless 29 | self.rotation = "auto" 30 | } 31 | 32 | init(port: String, segmentDuration: Double, videoBitrateMbps: Double, rotation: String) { 33 | self.port = port 34 | self.segmentDuration = segmentDuration 35 | self.videoBitrateMbps = videoBitrateMbps 36 | self.rotation = rotation 37 | } 38 | 39 | func withPort(_ port: String) -> UserStreamConfiguration { 40 | return UserStreamConfiguration(port: port, segmentDuration: self.segmentDuration, videoBitrateMbps: self.videoBitrateMbps, rotation: self.rotation) 41 | } 42 | 43 | func withSegmentDuration(_ segmentDuration: Double) -> UserStreamConfiguration { 44 | return UserStreamConfiguration(port: self.port, segmentDuration: segmentDuration, videoBitrateMbps: self.videoBitrateMbps, rotation: self.rotation) 45 | } 46 | 47 | func withVideoBitrateMbps(_ videoBitrateMbps: Double) -> UserStreamConfiguration { 48 | return UserStreamConfiguration(port: self.port, segmentDuration: self.segmentDuration, videoBitrateMbps: videoBitrateMbps, rotation: self.rotation) 49 | } 50 | } 51 | 52 | /// Observable wrapper around UserHLSConfiguration that auto-saves every time it is set. 53 | class UserStreamConfigObserver : ObservableObject { 54 | @Published var config: UserStreamConfiguration = UserStreamConfiguration() { 55 | didSet { 56 | UserStreamConfiguration.save(config: config) 57 | } 58 | } 59 | } 60 | 61 | /** 62 | * Provides functions to retrieve and persist the user-facing configuration 63 | * (this facilitates communication between the app and the extension). 64 | * 65 | * Note that the failure mode of these functions are to return a default configuration, 66 | * not throw errors. 67 | */ 68 | extension UserStreamConfiguration { 69 | private static func fileURL_() throws -> URL? { 70 | FileManager.default.containerURL( 71 | forSecurityApplicationGroupIdentifier: "group.dev.jdkula.Wingspan.config" 72 | )?.appendingPathComponent("HLSStreamer.config") 73 | } 74 | 75 | static func loadSync() throws -> UserStreamConfiguration { 76 | guard let fileURL = try fileURL_() else { 77 | return UserStreamConfiguration() 78 | } 79 | guard let file = try? FileHandle(forReadingFrom: fileURL) else { 80 | return UserStreamConfiguration() 81 | } 82 | let info = try JSONDecoder().decode(UserStreamConfiguration.self, from: file.availableData) 83 | return info 84 | } 85 | 86 | static func load(onComplete: @escaping ((Result) -> Void)) { 87 | DispatchQueue.global(qos: .background).async { 88 | do { 89 | guard let fileURL = try fileURL_() else { 90 | DispatchQueue.main.async { 91 | onComplete(.success(UserStreamConfiguration())) 92 | } 93 | return 94 | } 95 | guard let file = try? FileHandle(forReadingFrom: fileURL) else { 96 | DispatchQueue.main.async { 97 | onComplete(.success(UserStreamConfiguration())) 98 | } 99 | return 100 | } 101 | let info = try JSONDecoder().decode(UserStreamConfiguration.self, from: file.availableData) 102 | DispatchQueue.main.async { 103 | onComplete(.success(info)) 104 | } 105 | } catch { 106 | onComplete(.failure(error)) 107 | } 108 | } 109 | } 110 | 111 | static func save(config: UserStreamConfiguration) { 112 | do { 113 | let data = try JSONEncoder().encode(config) 114 | guard let fileURL = try fileURL_() else { 115 | return 116 | } 117 | try data.write(to: fileURL) 118 | } catch { 119 | // Ignore 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /Wingspan.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | FB8BE13629150FD0002534C7 /* WingspanApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB8BE13529150FD0002534C7 /* WingspanApp.swift */; }; 11 | FB8BE13A29150FD1002534C7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FB8BE13929150FD1002534C7 /* Assets.xcassets */; }; 12 | FB8BE13D29150FD1002534C7 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FB8BE13C29150FD1002534C7 /* Preview Assets.xcassets */; }; 13 | FB8BE14A29151002002534C7 /* BroadcastSetupViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB8BE14429151002002534C7 /* BroadcastSetupViewController.swift */; }; 14 | FB8BE14B29151002002534C7 /* Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB8BE14629151002002534C7 /* Util.swift */; }; 15 | FB8BE14D29151002002534C7 /* ContentUploadUILaunch.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB8BE14829151002002534C7 /* ContentUploadUILaunch.swift */; }; 16 | FB8BE1552915102D002534C7 /* ReplayKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FB8BE1542915102D002534C7 /* ReplayKit.framework */; }; 17 | FB8BE15C2915102D002534C7 /* ScreencapExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FB8BE1522915102D002534C7 /* ScreencapExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 18 | FB8BE16C2915104D002534C7 /* HtmlComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB8BE1622915104D002534C7 /* HtmlComponents.swift */; }; 19 | FB8BE16E2915104D002534C7 /* HtmlLibraryComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB8BE1642915104D002534C7 /* HtmlLibraryComponents.swift */; }; 20 | FB8BE16F2915104D002534C7 /* SampleHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB8BE1652915104D002534C7 /* SampleHandler.swift */; }; 21 | FB8BE1702915104D002534C7 /* Segment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB8BE1662915104D002534C7 /* Segment.swift */; }; 22 | FB8BE1712915104D002534C7 /* HLSServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB8BE1672915104D002534C7 /* HLSServer.swift */; }; 23 | FB8BE1722915104D002534C7 /* M3u8Collector.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB8BE1682915104D002534C7 /* M3u8Collector.swift */; }; 24 | FB8BE1732915104D002534C7 /* HLSVideoSegmenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB8BE1692915104D002534C7 /* HLSVideoSegmenter.swift */; }; 25 | FB8BE1742915104D002534C7 /* FMP4Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB8BE16A2915104D002534C7 /* FMP4Configuration.swift */; }; 26 | FB8BE17729151064002534C7 /* Swifter in Frameworks */ = {isa = PBXBuildFile; productRef = FB8BE17629151064002534C7 /* Swifter */; }; 27 | FB8BE17A2915107A002534C7 /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = FB8BE1792915107A002534C7 /* WebRTC */; }; 28 | FB8BE17C29151084002534C7 /* UserStreamConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB8BE17B29151084002534C7 /* UserStreamConfiguration.swift */; }; 29 | FB8BE17D29151084002534C7 /* UserStreamConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB8BE17B29151084002534C7 /* UserStreamConfiguration.swift */; }; 30 | FB8BE1822915110A002534C7 /* Swifter in Frameworks */ = {isa = PBXBuildFile; productRef = FB8BE1812915110A002534C7 /* Swifter */; }; 31 | FB8BE1892915118C002534C7 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB8BE1862915118C002534C7 /* ContentView.swift */; }; 32 | FB8BE18A2915118C002534C7 /* InfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB8BE1872915118C002534C7 /* InfoView.swift */; }; 33 | FB8BE18B2915118C002534C7 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB8BE1882915118C002534C7 /* SettingsView.swift */; }; 34 | FB8BE192291519AA002534C7 /* ScreencapContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB8BE191291519AA002534C7 /* ScreencapContext.swift */; }; 35 | FB8BE19429152051002534C7 /* Webserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB8BE19329152051002534C7 /* Webserver.swift */; }; 36 | /* End PBXBuildFile section */ 37 | 38 | /* Begin PBXContainerItemProxy section */ 39 | FB8BE15A2915102D002534C7 /* PBXContainerItemProxy */ = { 40 | isa = PBXContainerItemProxy; 41 | containerPortal = FB8BE12A29150FD0002534C7 /* Project object */; 42 | proxyType = 1; 43 | remoteGlobalIDString = FB8BE1512915102D002534C7; 44 | remoteInfo = ScreencapExtension; 45 | }; 46 | /* End PBXContainerItemProxy section */ 47 | 48 | /* Begin PBXCopyFilesBuildPhase section */ 49 | FB8BE1602915102D002534C7 /* Embed Foundation Extensions */ = { 50 | isa = PBXCopyFilesBuildPhase; 51 | buildActionMask = 2147483647; 52 | dstPath = ""; 53 | dstSubfolderSpec = 13; 54 | files = ( 55 | FB8BE15C2915102D002534C7 /* ScreencapExtension.appex in Embed Foundation Extensions */, 56 | ); 57 | name = "Embed Foundation Extensions"; 58 | runOnlyForDeploymentPostprocessing = 0; 59 | }; 60 | /* End PBXCopyFilesBuildPhase section */ 61 | 62 | /* Begin PBXFileReference section */ 63 | FB8BE13229150FD0002534C7 /* Wingspan.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Wingspan.app; sourceTree = BUILT_PRODUCTS_DIR; }; 64 | FB8BE13529150FD0002534C7 /* WingspanApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WingspanApp.swift; sourceTree = ""; }; 65 | FB8BE13929150FD1002534C7 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 66 | FB8BE13C29150FD1002534C7 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 67 | FB8BE14329151002002534C7 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 68 | FB8BE14429151002002534C7 /* BroadcastSetupViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BroadcastSetupViewController.swift; sourceTree = ""; }; 69 | FB8BE14629151002002534C7 /* Util.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Util.swift; sourceTree = ""; }; 70 | FB8BE14829151002002534C7 /* ContentUploadUILaunch.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentUploadUILaunch.swift; sourceTree = ""; }; 71 | FB8BE1522915102D002534C7 /* ScreencapExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ScreencapExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 72 | FB8BE1542915102D002534C7 /* ReplayKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ReplayKit.framework; path = System/Library/Frameworks/ReplayKit.framework; sourceTree = SDKROOT; }; 73 | FB8BE1592915102D002534C7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 74 | FB8BE1612915104D002534C7 /* ScreencapExtension-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "ScreencapExtension-Bridging-Header.h"; sourceTree = ""; }; 75 | FB8BE1622915104D002534C7 /* HtmlComponents.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HtmlComponents.swift; sourceTree = ""; }; 76 | FB8BE1642915104D002534C7 /* HtmlLibraryComponents.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HtmlLibraryComponents.swift; sourceTree = ""; }; 77 | FB8BE1652915104D002534C7 /* SampleHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SampleHandler.swift; sourceTree = ""; }; 78 | FB8BE1662915104D002534C7 /* Segment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Segment.swift; sourceTree = ""; }; 79 | FB8BE1672915104D002534C7 /* HLSServer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HLSServer.swift; sourceTree = ""; }; 80 | FB8BE1682915104D002534C7 /* M3u8Collector.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = M3u8Collector.swift; sourceTree = ""; }; 81 | FB8BE1692915104D002534C7 /* HLSVideoSegmenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HLSVideoSegmenter.swift; sourceTree = ""; }; 82 | FB8BE16A2915104D002534C7 /* FMP4Configuration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FMP4Configuration.swift; sourceTree = ""; }; 83 | FB8BE17B29151084002534C7 /* UserStreamConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserStreamConfiguration.swift; sourceTree = ""; }; 84 | FB8BE17E291510A3002534C7 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 85 | FB8BE17F291510A3002534C7 /* Icon.draft1.afdesign */ = {isa = PBXFileReference; lastKnownFileType = file; path = Icon.draft1.afdesign; sourceTree = ""; }; 86 | FB8BE1862915118C002534C7 /* ContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 87 | FB8BE1872915118C002534C7 /* InfoView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InfoView.swift; sourceTree = ""; }; 88 | FB8BE1882915118C002534C7 /* SettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 89 | FB8BE18D2915122C002534C7 /* Wingspan.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Wingspan.entitlements; sourceTree = ""; }; 90 | FB8BE18E29151232002534C7 /* ScreencapExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ScreencapExtension.entitlements; sourceTree = ""; }; 91 | FB8BE191291519AA002534C7 /* ScreencapContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreencapContext.swift; sourceTree = ""; }; 92 | FB8BE19329152051002534C7 /* Webserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Webserver.swift; sourceTree = ""; }; 93 | /* End PBXFileReference section */ 94 | 95 | /* Begin PBXFrameworksBuildPhase section */ 96 | FB8BE12F29150FD0002534C7 /* Frameworks */ = { 97 | isa = PBXFrameworksBuildPhase; 98 | buildActionMask = 2147483647; 99 | files = ( 100 | FB8BE17729151064002534C7 /* Swifter in Frameworks */, 101 | FB8BE17A2915107A002534C7 /* WebRTC in Frameworks */, 102 | ); 103 | runOnlyForDeploymentPostprocessing = 0; 104 | }; 105 | FB8BE14F2915102D002534C7 /* Frameworks */ = { 106 | isa = PBXFrameworksBuildPhase; 107 | buildActionMask = 2147483647; 108 | files = ( 109 | FB8BE1552915102D002534C7 /* ReplayKit.framework in Frameworks */, 110 | FB8BE1822915110A002534C7 /* Swifter in Frameworks */, 111 | ); 112 | runOnlyForDeploymentPostprocessing = 0; 113 | }; 114 | /* End PBXFrameworksBuildPhase section */ 115 | 116 | /* Begin PBXGroup section */ 117 | FB8BE12929150FD0002534C7 = { 118 | isa = PBXGroup; 119 | children = ( 120 | FB8BE17B29151084002534C7 /* UserStreamConfiguration.swift */, 121 | FB8BE17F291510A3002534C7 /* Icon.draft1.afdesign */, 122 | FB8BE17E291510A3002534C7 /* README.md */, 123 | FB8BE13429150FD0002534C7 /* Wingspan */, 124 | FB8BE1562915102D002534C7 /* ScreencapExtension */, 125 | FB8BE1532915102D002534C7 /* Frameworks */, 126 | FB8BE13329150FD0002534C7 /* Products */, 127 | ); 128 | sourceTree = ""; 129 | }; 130 | FB8BE13329150FD0002534C7 /* Products */ = { 131 | isa = PBXGroup; 132 | children = ( 133 | FB8BE13229150FD0002534C7 /* Wingspan.app */, 134 | FB8BE1522915102D002534C7 /* ScreencapExtension.appex */, 135 | ); 136 | name = Products; 137 | sourceTree = ""; 138 | }; 139 | FB8BE13429150FD0002534C7 /* Wingspan */ = { 140 | isa = PBXGroup; 141 | children = ( 142 | FB8BE18D2915122C002534C7 /* Wingspan.entitlements */, 143 | FB8BE18529151182002534C7 /* Views */, 144 | FB8BE14429151002002534C7 /* BroadcastSetupViewController.swift */, 145 | FB8BE14829151002002534C7 /* ContentUploadUILaunch.swift */, 146 | FB8BE14329151002002534C7 /* Info.plist */, 147 | FB8BE14629151002002534C7 /* Util.swift */, 148 | FB8BE13529150FD0002534C7 /* WingspanApp.swift */, 149 | FB8BE13929150FD1002534C7 /* Assets.xcassets */, 150 | FB8BE13B29150FD1002534C7 /* Preview Content */, 151 | ); 152 | path = Wingspan; 153 | sourceTree = ""; 154 | }; 155 | FB8BE13B29150FD1002534C7 /* Preview Content */ = { 156 | isa = PBXGroup; 157 | children = ( 158 | FB8BE13C29150FD1002534C7 /* Preview Assets.xcassets */, 159 | ); 160 | path = "Preview Content"; 161 | sourceTree = ""; 162 | }; 163 | FB8BE1532915102D002534C7 /* Frameworks */ = { 164 | isa = PBXGroup; 165 | children = ( 166 | FB8BE1542915102D002534C7 /* ReplayKit.framework */, 167 | ); 168 | name = Frameworks; 169 | sourceTree = ""; 170 | }; 171 | FB8BE1562915102D002534C7 /* ScreencapExtension */ = { 172 | isa = PBXGroup; 173 | children = ( 174 | FB8BE190291518FC002534C7 /* WebRTC */, 175 | FB8BE18F291518F5002534C7 /* HLS */, 176 | FB8BE18E29151232002534C7 /* ScreencapExtension.entitlements */, 177 | FB8BE1652915104D002534C7 /* SampleHandler.swift */, 178 | FB8BE1592915102D002534C7 /* Info.plist */, 179 | FB8BE1612915104D002534C7 /* ScreencapExtension-Bridging-Header.h */, 180 | FB8BE191291519AA002534C7 /* ScreencapContext.swift */, 181 | FB8BE19329152051002534C7 /* Webserver.swift */, 182 | ); 183 | path = ScreencapExtension; 184 | sourceTree = ""; 185 | }; 186 | FB8BE18529151182002534C7 /* Views */ = { 187 | isa = PBXGroup; 188 | children = ( 189 | FB8BE1862915118C002534C7 /* ContentView.swift */, 190 | FB8BE1872915118C002534C7 /* InfoView.swift */, 191 | FB8BE1882915118C002534C7 /* SettingsView.swift */, 192 | ); 193 | path = Views; 194 | sourceTree = ""; 195 | }; 196 | FB8BE18F291518F5002534C7 /* HLS */ = { 197 | isa = PBXGroup; 198 | children = ( 199 | FB8BE16A2915104D002534C7 /* FMP4Configuration.swift */, 200 | FB8BE1672915104D002534C7 /* HLSServer.swift */, 201 | FB8BE1622915104D002534C7 /* HtmlComponents.swift */, 202 | FB8BE1642915104D002534C7 /* HtmlLibraryComponents.swift */, 203 | FB8BE1682915104D002534C7 /* M3u8Collector.swift */, 204 | FB8BE1662915104D002534C7 /* Segment.swift */, 205 | FB8BE1692915104D002534C7 /* HLSVideoSegmenter.swift */, 206 | ); 207 | path = HLS; 208 | sourceTree = ""; 209 | }; 210 | FB8BE190291518FC002534C7 /* WebRTC */ = { 211 | isa = PBXGroup; 212 | children = ( 213 | ); 214 | path = WebRTC; 215 | sourceTree = ""; 216 | }; 217 | /* End PBXGroup section */ 218 | 219 | /* Begin PBXNativeTarget section */ 220 | FB8BE13129150FD0002534C7 /* Wingspan */ = { 221 | isa = PBXNativeTarget; 222 | buildConfigurationList = FB8BE14029150FD1002534C7 /* Build configuration list for PBXNativeTarget "Wingspan" */; 223 | buildPhases = ( 224 | FB8BE12E29150FD0002534C7 /* Sources */, 225 | FB8BE12F29150FD0002534C7 /* Frameworks */, 226 | FB8BE13029150FD0002534C7 /* Resources */, 227 | FB8BE1602915102D002534C7 /* Embed Foundation Extensions */, 228 | ); 229 | buildRules = ( 230 | ); 231 | dependencies = ( 232 | FB8BE15B2915102D002534C7 /* PBXTargetDependency */, 233 | ); 234 | name = Wingspan; 235 | packageProductDependencies = ( 236 | FB8BE17629151064002534C7 /* Swifter */, 237 | FB8BE1792915107A002534C7 /* WebRTC */, 238 | ); 239 | productName = Wingspan; 240 | productReference = FB8BE13229150FD0002534C7 /* Wingspan.app */; 241 | productType = "com.apple.product-type.application"; 242 | }; 243 | FB8BE1512915102D002534C7 /* ScreencapExtension */ = { 244 | isa = PBXNativeTarget; 245 | buildConfigurationList = FB8BE15D2915102D002534C7 /* Build configuration list for PBXNativeTarget "ScreencapExtension" */; 246 | buildPhases = ( 247 | FB8BE14E2915102D002534C7 /* Sources */, 248 | FB8BE14F2915102D002534C7 /* Frameworks */, 249 | FB8BE1502915102D002534C7 /* Resources */, 250 | ); 251 | buildRules = ( 252 | ); 253 | dependencies = ( 254 | ); 255 | name = ScreencapExtension; 256 | packageProductDependencies = ( 257 | FB8BE1812915110A002534C7 /* Swifter */, 258 | ); 259 | productName = ScreencapExtension; 260 | productReference = FB8BE1522915102D002534C7 /* ScreencapExtension.appex */; 261 | productType = "com.apple.product-type.app-extension"; 262 | }; 263 | /* End PBXNativeTarget section */ 264 | 265 | /* Begin PBXProject section */ 266 | FB8BE12A29150FD0002534C7 /* Project object */ = { 267 | isa = PBXProject; 268 | attributes = { 269 | BuildIndependentTargetsInParallel = 1; 270 | LastSwiftUpdateCheck = 1410; 271 | LastUpgradeCheck = 1410; 272 | TargetAttributes = { 273 | FB8BE13129150FD0002534C7 = { 274 | CreatedOnToolsVersion = 14.1; 275 | }; 276 | FB8BE1512915102D002534C7 = { 277 | CreatedOnToolsVersion = 14.1; 278 | LastSwiftMigration = 1410; 279 | }; 280 | }; 281 | }; 282 | buildConfigurationList = FB8BE12D29150FD0002534C7 /* Build configuration list for PBXProject "Wingspan" */; 283 | compatibilityVersion = "Xcode 14.0"; 284 | developmentRegion = en; 285 | hasScannedForEncodings = 0; 286 | knownRegions = ( 287 | en, 288 | Base, 289 | ); 290 | mainGroup = FB8BE12929150FD0002534C7; 291 | packageReferences = ( 292 | FB8BE17529151064002534C7 /* XCRemoteSwiftPackageReference "swifter" */, 293 | FB8BE1782915107A002534C7 /* XCRemoteSwiftPackageReference "WebRTC" */, 294 | ); 295 | productRefGroup = FB8BE13329150FD0002534C7 /* Products */; 296 | projectDirPath = ""; 297 | projectRoot = ""; 298 | targets = ( 299 | FB8BE13129150FD0002534C7 /* Wingspan */, 300 | FB8BE1512915102D002534C7 /* ScreencapExtension */, 301 | ); 302 | }; 303 | /* End PBXProject section */ 304 | 305 | /* Begin PBXResourcesBuildPhase section */ 306 | FB8BE13029150FD0002534C7 /* Resources */ = { 307 | isa = PBXResourcesBuildPhase; 308 | buildActionMask = 2147483647; 309 | files = ( 310 | FB8BE13D29150FD1002534C7 /* Preview Assets.xcassets in Resources */, 311 | FB8BE13A29150FD1002534C7 /* Assets.xcassets in Resources */, 312 | ); 313 | runOnlyForDeploymentPostprocessing = 0; 314 | }; 315 | FB8BE1502915102D002534C7 /* Resources */ = { 316 | isa = PBXResourcesBuildPhase; 317 | buildActionMask = 2147483647; 318 | files = ( 319 | ); 320 | runOnlyForDeploymentPostprocessing = 0; 321 | }; 322 | /* End PBXResourcesBuildPhase section */ 323 | 324 | /* Begin PBXSourcesBuildPhase section */ 325 | FB8BE12E29150FD0002534C7 /* Sources */ = { 326 | isa = PBXSourcesBuildPhase; 327 | buildActionMask = 2147483647; 328 | files = ( 329 | FB8BE18A2915118C002534C7 /* InfoView.swift in Sources */, 330 | FB8BE14A29151002002534C7 /* BroadcastSetupViewController.swift in Sources */, 331 | FB8BE14B29151002002534C7 /* Util.swift in Sources */, 332 | FB8BE18B2915118C002534C7 /* SettingsView.swift in Sources */, 333 | FB8BE1892915118C002534C7 /* ContentView.swift in Sources */, 334 | FB8BE17C29151084002534C7 /* UserStreamConfiguration.swift in Sources */, 335 | FB8BE13629150FD0002534C7 /* WingspanApp.swift in Sources */, 336 | FB8BE14D29151002002534C7 /* ContentUploadUILaunch.swift in Sources */, 337 | ); 338 | runOnlyForDeploymentPostprocessing = 0; 339 | }; 340 | FB8BE14E2915102D002534C7 /* Sources */ = { 341 | isa = PBXSourcesBuildPhase; 342 | buildActionMask = 2147483647; 343 | files = ( 344 | FB8BE16E2915104D002534C7 /* HtmlLibraryComponents.swift in Sources */, 345 | FB8BE1742915104D002534C7 /* FMP4Configuration.swift in Sources */, 346 | FB8BE16C2915104D002534C7 /* HtmlComponents.swift in Sources */, 347 | FB8BE16F2915104D002534C7 /* SampleHandler.swift in Sources */, 348 | FB8BE17D29151084002534C7 /* UserStreamConfiguration.swift in Sources */, 349 | FB8BE1712915104D002534C7 /* HLSServer.swift in Sources */, 350 | FB8BE1722915104D002534C7 /* M3u8Collector.swift in Sources */, 351 | FB8BE1702915104D002534C7 /* Segment.swift in Sources */, 352 | FB8BE1732915104D002534C7 /* HLSVideoSegmenter.swift in Sources */, 353 | FB8BE192291519AA002534C7 /* ScreencapContext.swift in Sources */, 354 | FB8BE19429152051002534C7 /* Webserver.swift in Sources */, 355 | ); 356 | runOnlyForDeploymentPostprocessing = 0; 357 | }; 358 | /* End PBXSourcesBuildPhase section */ 359 | 360 | /* Begin PBXTargetDependency section */ 361 | FB8BE15B2915102D002534C7 /* PBXTargetDependency */ = { 362 | isa = PBXTargetDependency; 363 | target = FB8BE1512915102D002534C7 /* ScreencapExtension */; 364 | targetProxy = FB8BE15A2915102D002534C7 /* PBXContainerItemProxy */; 365 | }; 366 | /* End PBXTargetDependency section */ 367 | 368 | /* Begin XCBuildConfiguration section */ 369 | FB8BE13E29150FD1002534C7 /* Debug */ = { 370 | isa = XCBuildConfiguration; 371 | buildSettings = { 372 | ALWAYS_SEARCH_USER_PATHS = NO; 373 | CLANG_ANALYZER_NONNULL = YES; 374 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 375 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 376 | CLANG_ENABLE_MODULES = YES; 377 | CLANG_ENABLE_OBJC_ARC = YES; 378 | CLANG_ENABLE_OBJC_WEAK = YES; 379 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 380 | CLANG_WARN_BOOL_CONVERSION = YES; 381 | CLANG_WARN_COMMA = YES; 382 | CLANG_WARN_CONSTANT_CONVERSION = YES; 383 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 384 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 385 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 386 | CLANG_WARN_EMPTY_BODY = YES; 387 | CLANG_WARN_ENUM_CONVERSION = YES; 388 | CLANG_WARN_INFINITE_RECURSION = YES; 389 | CLANG_WARN_INT_CONVERSION = YES; 390 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 391 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 392 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 393 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 394 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 395 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 396 | CLANG_WARN_STRICT_PROTOTYPES = YES; 397 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 398 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 399 | CLANG_WARN_UNREACHABLE_CODE = YES; 400 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 401 | COPY_PHASE_STRIP = NO; 402 | DEBUG_INFORMATION_FORMAT = dwarf; 403 | ENABLE_STRICT_OBJC_MSGSEND = YES; 404 | ENABLE_TESTABILITY = YES; 405 | GCC_C_LANGUAGE_STANDARD = gnu11; 406 | GCC_DYNAMIC_NO_PIC = NO; 407 | GCC_NO_COMMON_BLOCKS = YES; 408 | GCC_OPTIMIZATION_LEVEL = 0; 409 | GCC_PREPROCESSOR_DEFINITIONS = ( 410 | "DEBUG=1", 411 | "$(inherited)", 412 | ); 413 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 414 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 415 | GCC_WARN_UNDECLARED_SELECTOR = YES; 416 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 417 | GCC_WARN_UNUSED_FUNCTION = YES; 418 | GCC_WARN_UNUSED_VARIABLE = YES; 419 | IPHONEOS_DEPLOYMENT_TARGET = 16.1; 420 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 421 | MTL_FAST_MATH = YES; 422 | ONLY_ACTIVE_ARCH = YES; 423 | SDKROOT = iphoneos; 424 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 425 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 426 | }; 427 | name = Debug; 428 | }; 429 | FB8BE13F29150FD1002534C7 /* Release */ = { 430 | isa = XCBuildConfiguration; 431 | buildSettings = { 432 | ALWAYS_SEARCH_USER_PATHS = NO; 433 | CLANG_ANALYZER_NONNULL = YES; 434 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 435 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 436 | CLANG_ENABLE_MODULES = YES; 437 | CLANG_ENABLE_OBJC_ARC = YES; 438 | CLANG_ENABLE_OBJC_WEAK = YES; 439 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 440 | CLANG_WARN_BOOL_CONVERSION = YES; 441 | CLANG_WARN_COMMA = YES; 442 | CLANG_WARN_CONSTANT_CONVERSION = YES; 443 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 444 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 445 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 446 | CLANG_WARN_EMPTY_BODY = YES; 447 | CLANG_WARN_ENUM_CONVERSION = YES; 448 | CLANG_WARN_INFINITE_RECURSION = YES; 449 | CLANG_WARN_INT_CONVERSION = YES; 450 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 451 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 452 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 453 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 454 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 455 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 456 | CLANG_WARN_STRICT_PROTOTYPES = YES; 457 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 458 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 459 | CLANG_WARN_UNREACHABLE_CODE = YES; 460 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 461 | COPY_PHASE_STRIP = NO; 462 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 463 | ENABLE_NS_ASSERTIONS = NO; 464 | ENABLE_STRICT_OBJC_MSGSEND = YES; 465 | GCC_C_LANGUAGE_STANDARD = gnu11; 466 | GCC_NO_COMMON_BLOCKS = YES; 467 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 468 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 469 | GCC_WARN_UNDECLARED_SELECTOR = YES; 470 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 471 | GCC_WARN_UNUSED_FUNCTION = YES; 472 | GCC_WARN_UNUSED_VARIABLE = YES; 473 | IPHONEOS_DEPLOYMENT_TARGET = 16.1; 474 | MTL_ENABLE_DEBUG_INFO = NO; 475 | MTL_FAST_MATH = YES; 476 | SDKROOT = iphoneos; 477 | SWIFT_COMPILATION_MODE = wholemodule; 478 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 479 | VALIDATE_PRODUCT = YES; 480 | }; 481 | name = Release; 482 | }; 483 | FB8BE14129150FD1002534C7 /* Debug */ = { 484 | isa = XCBuildConfiguration; 485 | buildSettings = { 486 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 487 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 488 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 489 | CODE_SIGN_ENTITLEMENTS = Wingspan/Wingspan.entitlements; 490 | CODE_SIGN_STYLE = Automatic; 491 | CURRENT_PROJECT_VERSION = 1; 492 | DEVELOPMENT_ASSET_PATHS = "\"Wingspan/Preview Content\""; 493 | DEVELOPMENT_TEAM = F865XNG7F7; 494 | ENABLE_PREVIEWS = YES; 495 | GENERATE_INFOPLIST_FILE = YES; 496 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 497 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 498 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 499 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 500 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 501 | LD_RUNPATH_SEARCH_PATHS = ( 502 | "$(inherited)", 503 | "@executable_path/Frameworks", 504 | ); 505 | MARKETING_VERSION = 1.0; 506 | PRODUCT_BUNDLE_IDENTIFIER = dev.jdkula.Wingspan; 507 | PRODUCT_NAME = "$(TARGET_NAME)"; 508 | SWIFT_EMIT_LOC_STRINGS = YES; 509 | SWIFT_VERSION = 5.0; 510 | TARGETED_DEVICE_FAMILY = "1,2"; 511 | }; 512 | name = Debug; 513 | }; 514 | FB8BE14229150FD1002534C7 /* Release */ = { 515 | isa = XCBuildConfiguration; 516 | buildSettings = { 517 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 518 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 519 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 520 | CODE_SIGN_ENTITLEMENTS = Wingspan/Wingspan.entitlements; 521 | CODE_SIGN_STYLE = Automatic; 522 | CURRENT_PROJECT_VERSION = 1; 523 | DEVELOPMENT_ASSET_PATHS = "\"Wingspan/Preview Content\""; 524 | DEVELOPMENT_TEAM = F865XNG7F7; 525 | ENABLE_PREVIEWS = YES; 526 | GENERATE_INFOPLIST_FILE = YES; 527 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 528 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 529 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 530 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 531 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 532 | LD_RUNPATH_SEARCH_PATHS = ( 533 | "$(inherited)", 534 | "@executable_path/Frameworks", 535 | ); 536 | MARKETING_VERSION = 1.0; 537 | PRODUCT_BUNDLE_IDENTIFIER = dev.jdkula.Wingspan; 538 | PRODUCT_NAME = "$(TARGET_NAME)"; 539 | SWIFT_EMIT_LOC_STRINGS = YES; 540 | SWIFT_VERSION = 5.0; 541 | TARGETED_DEVICE_FAMILY = "1,2"; 542 | }; 543 | name = Release; 544 | }; 545 | FB8BE15E2915102D002534C7 /* Debug */ = { 546 | isa = XCBuildConfiguration; 547 | buildSettings = { 548 | CLANG_ENABLE_MODULES = YES; 549 | CODE_SIGN_ENTITLEMENTS = ScreencapExtension/ScreencapExtension.entitlements; 550 | CODE_SIGN_STYLE = Automatic; 551 | CURRENT_PROJECT_VERSION = 1; 552 | DEVELOPMENT_TEAM = F865XNG7F7; 553 | GENERATE_INFOPLIST_FILE = YES; 554 | INFOPLIST_FILE = ScreencapExtension/Info.plist; 555 | INFOPLIST_KEY_CFBundleDisplayName = ScreencapExtension; 556 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 557 | LD_RUNPATH_SEARCH_PATHS = ( 558 | "$(inherited)", 559 | "@executable_path/Frameworks", 560 | "@executable_path/../../Frameworks", 561 | ); 562 | MARKETING_VERSION = 1.0; 563 | PRODUCT_BUNDLE_IDENTIFIER = dev.jdkula.Wingspan.ScreencapExtension; 564 | PRODUCT_NAME = "$(TARGET_NAME)"; 565 | SKIP_INSTALL = YES; 566 | SWIFT_EMIT_LOC_STRINGS = YES; 567 | SWIFT_OBJC_BRIDGING_HEADER = "ScreencapExtension/ScreencapExtension-Bridging-Header.h"; 568 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 569 | SWIFT_VERSION = 5.0; 570 | TARGETED_DEVICE_FAMILY = "1,2"; 571 | }; 572 | name = Debug; 573 | }; 574 | FB8BE15F2915102D002534C7 /* Release */ = { 575 | isa = XCBuildConfiguration; 576 | buildSettings = { 577 | CLANG_ENABLE_MODULES = YES; 578 | CODE_SIGN_ENTITLEMENTS = ScreencapExtension/ScreencapExtension.entitlements; 579 | CODE_SIGN_STYLE = Automatic; 580 | CURRENT_PROJECT_VERSION = 1; 581 | DEVELOPMENT_TEAM = F865XNG7F7; 582 | GENERATE_INFOPLIST_FILE = YES; 583 | INFOPLIST_FILE = ScreencapExtension/Info.plist; 584 | INFOPLIST_KEY_CFBundleDisplayName = ScreencapExtension; 585 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 586 | LD_RUNPATH_SEARCH_PATHS = ( 587 | "$(inherited)", 588 | "@executable_path/Frameworks", 589 | "@executable_path/../../Frameworks", 590 | ); 591 | MARKETING_VERSION = 1.0; 592 | PRODUCT_BUNDLE_IDENTIFIER = dev.jdkula.Wingspan.ScreencapExtension; 593 | PRODUCT_NAME = "$(TARGET_NAME)"; 594 | SKIP_INSTALL = YES; 595 | SWIFT_EMIT_LOC_STRINGS = YES; 596 | SWIFT_OBJC_BRIDGING_HEADER = "ScreencapExtension/ScreencapExtension-Bridging-Header.h"; 597 | SWIFT_VERSION = 5.0; 598 | TARGETED_DEVICE_FAMILY = "1,2"; 599 | }; 600 | name = Release; 601 | }; 602 | /* End XCBuildConfiguration section */ 603 | 604 | /* Begin XCConfigurationList section */ 605 | FB8BE12D29150FD0002534C7 /* Build configuration list for PBXProject "Wingspan" */ = { 606 | isa = XCConfigurationList; 607 | buildConfigurations = ( 608 | FB8BE13E29150FD1002534C7 /* Debug */, 609 | FB8BE13F29150FD1002534C7 /* Release */, 610 | ); 611 | defaultConfigurationIsVisible = 0; 612 | defaultConfigurationName = Release; 613 | }; 614 | FB8BE14029150FD1002534C7 /* Build configuration list for PBXNativeTarget "Wingspan" */ = { 615 | isa = XCConfigurationList; 616 | buildConfigurations = ( 617 | FB8BE14129150FD1002534C7 /* Debug */, 618 | FB8BE14229150FD1002534C7 /* Release */, 619 | ); 620 | defaultConfigurationIsVisible = 0; 621 | defaultConfigurationName = Release; 622 | }; 623 | FB8BE15D2915102D002534C7 /* Build configuration list for PBXNativeTarget "ScreencapExtension" */ = { 624 | isa = XCConfigurationList; 625 | buildConfigurations = ( 626 | FB8BE15E2915102D002534C7 /* Debug */, 627 | FB8BE15F2915102D002534C7 /* Release */, 628 | ); 629 | defaultConfigurationIsVisible = 0; 630 | defaultConfigurationName = Release; 631 | }; 632 | /* End XCConfigurationList section */ 633 | 634 | /* Begin XCRemoteSwiftPackageReference section */ 635 | FB8BE17529151064002534C7 /* XCRemoteSwiftPackageReference "swifter" */ = { 636 | isa = XCRemoteSwiftPackageReference; 637 | repositoryURL = "https://github.com/httpswift/swifter.git"; 638 | requirement = { 639 | branch = stable; 640 | kind = branch; 641 | }; 642 | }; 643 | FB8BE1782915107A002534C7 /* XCRemoteSwiftPackageReference "WebRTC" */ = { 644 | isa = XCRemoteSwiftPackageReference; 645 | repositoryURL = "https://github.com/stasel/WebRTC.git"; 646 | requirement = { 647 | kind = upToNextMajorVersion; 648 | minimumVersion = 107.0.0; 649 | }; 650 | }; 651 | /* End XCRemoteSwiftPackageReference section */ 652 | 653 | /* Begin XCSwiftPackageProductDependency section */ 654 | FB8BE17629151064002534C7 /* Swifter */ = { 655 | isa = XCSwiftPackageProductDependency; 656 | package = FB8BE17529151064002534C7 /* XCRemoteSwiftPackageReference "swifter" */; 657 | productName = Swifter; 658 | }; 659 | FB8BE1792915107A002534C7 /* WebRTC */ = { 660 | isa = XCSwiftPackageProductDependency; 661 | package = FB8BE1782915107A002534C7 /* XCRemoteSwiftPackageReference "WebRTC" */; 662 | productName = WebRTC; 663 | }; 664 | FB8BE1812915110A002534C7 /* Swifter */ = { 665 | isa = XCSwiftPackageProductDependency; 666 | package = FB8BE17529151064002534C7 /* XCRemoteSwiftPackageReference "swifter" */; 667 | productName = Swifter; 668 | }; 669 | /* End XCSwiftPackageProductDependency section */ 670 | }; 671 | rootObject = FB8BE12A29150FD0002534C7 /* Project object */; 672 | } 673 | -------------------------------------------------------------------------------- /Wingspan.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Wingspan.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Wingspan.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swifter", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/httpswift/swifter.git", 7 | "state" : { 8 | "branch" : "stable", 9 | "revision" : "1e4f51c92d7ca486242d8bf0722b99de2c3531aa" 10 | } 11 | }, 12 | { 13 | "identity" : "webrtc", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/stasel/WebRTC.git", 16 | "state" : { 17 | "revision" : "fd78eb70fb7da306adbdfc6e44386086364c3cf8", 18 | "version" : "107.0.0" 19 | } 20 | } 21 | ], 22 | "version" : 2 23 | } 24 | -------------------------------------------------------------------------------- /Wingspan.xcodeproj/project.xcworkspace/xcuserdata/jdkula.xcuserdatad/UserInterfaceState.xcuserstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdkula/HLSStreamer/d7804fec74b5f0492de11e26bf5c1aa3527b5f15/Wingspan.xcodeproj/project.xcworkspace/xcuserdata/jdkula.xcuserdatad/UserInterfaceState.xcuserstate -------------------------------------------------------------------------------- /Wingspan.xcodeproj/xcuserdata/jdkula.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | ScreencapExtension.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 1 11 | 12 | Wingspan.xcscheme_^#shared#^_ 13 | 14 | orderHint 15 | 0 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /Wingspan/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdkula/HLSStreamer/d7804fec74b5f0492de11e26bf5c1aa3527b5f15/Wingspan/.DS_Store -------------------------------------------------------------------------------- /Wingspan/Assets.xcassets/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdkula/HLSStreamer/d7804fec74b5f0492de11e26bf5c1aa3527b5f15/Wingspan/Assets.xcassets/.DS_Store -------------------------------------------------------------------------------- /Wingspan/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Wingspan/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "lil icon.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Wingspan/Assets.xcassets/AppIcon.appiconset/lil icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdkula/HLSStreamer/d7804fec74b5f0492de11e26bf5c1aa3527b5f15/Wingspan/Assets.xcassets/AppIcon.appiconset/lil icon.png -------------------------------------------------------------------------------- /Wingspan/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Wingspan/BroadcastSetupViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BroadcastSetupViewController.swift 3 | // HLSStreamer 4 | // 5 | // Provides the button that starts the system 6 | // broadcast from the UI 7 | // 8 | // Created by @jdkula on 10/31/22. 9 | // 10 | import UIKit 11 | import ReplayKit 12 | 13 | @available(iOS 12.0, *) 14 | class BroadcastSetupViewController: UIViewController { 15 | var broadcastPicker: RPSystemBroadcastPickerView? 16 | 17 | override func viewDidLoad() { 18 | super.viewDidLoad() 19 | 20 | // Create broadcast picker, and customize it as much as possiuble 21 | let broadcastPicker = RPSystemBroadcastPickerView(frame: CGRect(x: 0, y: 0, width: 400, height: 100)) 22 | broadcastPicker.preferredExtension = "dev.jdkula.Wingspan.ScreencapExtension" 23 | broadcastPicker.showsMicrophoneButton = false; 24 | 25 | // Adjust tint and move the button up a little bit to make room for the label 26 | if let buttonImg = broadcastPicker.subviews.first as? UIButton { 27 | buttonImg.imageView?.tintColor = UIColor.label 28 | buttonImg.center.y = 40; 29 | } 30 | 31 | // Centered label 32 | let label = UILabel(frame: CGRect(x: 400 / 2 - 100, y: 50, width: 200, height: 50)) 33 | label.text = "Start Stream" 34 | label.textAlignment = .center 35 | broadcastPicker.addSubview(label) 36 | 37 | // Button-esque look behind it 38 | broadcastPicker.backgroundColor = UIColor.tertiarySystemBackground 39 | broadcastPicker.layer.cornerRadius = 8 40 | broadcastPicker.layer.masksToBounds = true 41 | 42 | view.addSubview(broadcastPicker) 43 | 44 | self.broadcastPicker = broadcastPicker; 45 | } 46 | 47 | /// Updates the view according to if we're currently recording or not 48 | func setStreaming(_ isStreaming: Bool) { 49 | guard let picker = broadcastPicker else { 50 | return 51 | } 52 | 53 | if let buttonImg = picker.subviews.first as? UIButton { 54 | buttonImg.imageView?.tintColor = isStreaming ? UIColor.red : UIColor.label 55 | } 56 | if let label = picker.subviews[1] as? UILabel { 57 | label.text = isStreaming ? "Streaming in progress..." : "Start Stream" 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Wingspan/ContentUploadUILaunch.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentUploadUILaunch.swift 3 | // HLSStreamer 4 | // 5 | // Created by @jdkula on 11/1/22. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | /// Allows ``BroadcastSetupViewController`` to be used in SwiftUI 12 | struct BroadcastSetupView: UIViewControllerRepresentable { 13 | @Binding var isStreaming: Bool 14 | 15 | func makeUIViewController(context: Context) -> BroadcastSetupViewController { 16 | let vc = BroadcastSetupViewController() 17 | vc.setStreaming(isStreaming); 18 | return vc; 19 | } 20 | 21 | func updateUIViewController(_ uiViewController: BroadcastSetupViewController, context: Context) { 22 | uiViewController.setStreaming(isStreaming); 23 | } 24 | 25 | typealias UIViewControllerType = BroadcastSetupViewController 26 | 27 | } 28 | -------------------------------------------------------------------------------- /Wingspan/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Wingspan/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Wingspan/Util.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Util.swift 3 | // HLSStreamer 4 | // 5 | // Created by @jdkula on 11/1/22. 6 | 7 | 8 | import Foundation 9 | 10 | /** 11 | * Gets a list of IP addresses associated with the current device. 12 | * Heavily inspired by https://stackoverflow.com/questions/30748480/swift-get-devices-wifi-ip-address 13 | */ 14 | func getIPAddresses() -> [String] { 15 | var addresses: [String] = [] 16 | var ifaddr: UnsafeMutablePointer? = nil 17 | if getifaddrs(&ifaddr) == 0 { 18 | var ptr = ifaddr 19 | while ptr != nil { 20 | defer { ptr = ptr?.pointee.ifa_next } 21 | 22 | guard let interface = ptr?.pointee else { return [] } 23 | let addrFamily = interface.ifa_addr.pointee.sa_family 24 | if addrFamily == UInt8(AF_INET) || addrFamily == UInt8(AF_INET6) { 25 | 26 | // wifi = ["en0"] 27 | // wired = ["en2", "en3", "en4"] 28 | // cellular = ["pdp_ip0","pdp_ip1","pdp_ip2","pdp_ip3"] 29 | 30 | let name: String = String(cString: (interface.ifa_name)) 31 | if name == "en0" || name == "en2" || name == "en3" || name == "en4" || name == "pdp_ip0" || name == "pdp_ip1" || name == "pdp_ip2" || name == "pdp_ip3" { 32 | var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST)) 33 | getnameinfo(interface.ifa_addr, socklen_t((interface.ifa_addr.pointee.sa_len)), &hostname, socklen_t(hostname.count), nil, socklen_t(0), NI_NUMERICHOST) 34 | addresses.append(String(cString: hostname)) 35 | } 36 | } 37 | } 38 | freeifaddrs(ifaddr) 39 | } 40 | return addresses 41 | } 42 | 43 | func getIPInformation_(isStreaming: Bool) -> [String]? { 44 | if !isStreaming { 45 | return nil 46 | } 47 | 48 | let addresses = getIPAddresses() 49 | let ipv4 = addresses.filter {s in 50 | s.contains(".") && !s.starts(with: "169.") 51 | } 52 | 53 | let ipv6 = getIPAddresses().filter {s in 54 | s.contains(":") && !s.starts(with: "fe80::") 55 | } 56 | 57 | let fallback = "" 58 | 59 | return ipv4.isEmpty ? (ipv6.isEmpty ? [fallback] : ipv6) : ipv4 60 | } 61 | -------------------------------------------------------------------------------- /Wingspan/Views/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // HLSStreamer 4 | // 5 | // The main view for the app 6 | // 7 | // Created by @jdkula on 10/31/22. 8 | // 9 | 10 | import SwiftUI 11 | import ReplayKit 12 | import Combine 13 | 14 | struct ContentView: View { 15 | @Binding var config: UserStreamConfiguration 16 | @Binding var isStreaming: Bool 17 | @Binding var isRealtime: Bool 18 | 19 | var body: some View { 20 | ScrollView { 21 | VStack(alignment: .leading, content: { 22 | 23 | // <== Title ==> 24 | Group { 25 | HStack {}.padding() 26 | 27 | Text("HLSStreamer").font(.system(size: 60, weight: .bold)).padding(EdgeInsets(top: 20, leading: 10, bottom: 0, trailing: 0)) 28 | } 29 | 30 | // <== Info Section ==> 31 | InfoView(isStreaming: $isStreaming, config: $config, isRealtime: $isRealtime) 32 | 33 | // <== Start Recording Button ==> 34 | if #available(iOS 15.0, *) { 35 | Group { 36 | HStack {}.padding() 37 | 38 | VStack { 39 | BroadcastSetupView(isStreaming: $isStreaming).frame(width: 400, height: 105, alignment: .center) 40 | }.frame(maxWidth: .infinity, alignment: .center) 41 | } 42 | } 43 | 44 | // <== Settings ==> 45 | SettingsView(isStreaming: $isStreaming, config: $config, isRealtime: $isRealtime) 46 | }).padding() 47 | 48 | Spacer() 49 | } 50 | } 51 | } 52 | 53 | struct ContentView_Previews: PreviewProvider { 54 | static var previews: some View { 55 | ContentView(config: .constant(UserStreamConfiguration()), isStreaming: .constant(false), isRealtime: .constant(false)) 56 | } 57 | } 58 | 59 | // https://stackoverflow.com/questions/65736518/how-do-i-create-a-slider-in-swiftui-for-an-int-type-property 60 | struct IntDoubleBinding { 61 | let intValue : Binding 62 | 63 | let doubleValue : Binding 64 | 65 | init(_ intValue : Binding) { 66 | self.intValue = intValue 67 | 68 | self.doubleValue = Binding(get: { 69 | return Double(intValue.wrappedValue) 70 | }, set: { 71 | intValue.wrappedValue = Int($0) 72 | }) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Wingspan/Views/InfoView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InfoView.swift 3 | // HLSStreamer 4 | // 5 | // Created by @jdkula on 11/2/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// The portion of the home page that displays where the user can access the stream 11 | struct InfoView: View { 12 | @Binding var isStreaming: Bool 13 | @Binding var config: UserStreamConfiguration 14 | @Binding var isRealtime: Bool 15 | 16 | var body: some View { 17 | HStack { 18 | Label("IP Addresses", systemImage: "wifi") 19 | Spacer(minLength: 40) 20 | VStack { 21 | Text(getIPAddresses().joined(separator: "\n")) 22 | .foregroundColor(Color.gray) 23 | .multilineTextAlignment(.trailing) 24 | } 25 | }.padding().background(Color(UIColor.secondarySystemBackground)).cornerRadius(8) 26 | 27 | HStack { 28 | VStack(alignment: .leading) { 29 | Label("Player Webpage Address", systemImage: "globe") 30 | Text("(You can open this URL in your web browser to view the stream)") 31 | .foregroundColor(Color.gray) 32 | .font(.system(size: 14)) 33 | } 34 | Spacer(minLength: 40) 35 | 36 | Text(getIPInformation_(isStreaming: isStreaming)?.map { s in "http://\(s):\(config.port)/" } 37 | .joined(separator: "\n") ?? "(server off)") 38 | .multilineTextAlignment(.trailing) 39 | .foregroundColor(Color.gray) 40 | }.padding().background(Color(UIColor.secondarySystemBackground)).cornerRadius(8) 41 | 42 | if !isRealtime { 43 | HStack { 44 | VStack(alignment: .leading) { 45 | Label("Stream Address", systemImage: "play.square.stack") 46 | Text("(You can open this URL in VLC or other media players supporting HLS streams)") 47 | .foregroundColor(Color.gray) 48 | .font(.system(size: 14)) 49 | } 50 | Spacer(minLength: 40) 51 | 52 | Text(getIPInformation_(isStreaming: isStreaming)?.map { s in "http://\(s):\(config.port)/index.m3u8" }.joined(separator: "\n") ?? "(server off)") 53 | .multilineTextAlignment(.trailing) 54 | .foregroundColor(Color.gray) 55 | }.padding().background(Color(UIColor.secondarySystemBackground)).cornerRadius(8) 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Wingspan/Views/SettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsView.swift 3 | // HLSStreamer 4 | // 5 | // Created by @jdkula on 11/2/22. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | 11 | /// The portion of the home page that allows the user to configure the settings used by the stream 12 | struct SettingsView: View { 13 | @Binding var isStreaming: Bool 14 | @Binding var config: UserStreamConfiguration 15 | @Binding var isRealtime: Bool 16 | 17 | var body: some View { 18 | Text("Settings").font(.system(size: 45, weight: .thin)) 19 | 20 | HStack { 21 | VStack(alignment: .leading) { 22 | Label("Port Number", systemImage: "number.circle") 23 | Text("(Configures which port the HTTP server listens on)") 24 | .foregroundColor(Color.gray) 25 | .font(.system(size: 14)) 26 | } 27 | Spacer(minLength: 40) 28 | 29 | TextField("", text: $config.port) 30 | .textFieldStyle(.roundedBorder) 31 | .keyboardType(.numberPad) 32 | .frame(maxWidth: 120) 33 | .multilineTextAlignment(.trailing) 34 | .onReceive(Just(config.port)) { newValue in 35 | let n = Int(newValue.filter { $0.isNumber }) 36 | 37 | if (newValue == config.port) { 38 | return 39 | } else if (n == nil) { 40 | config = config.withPort("") 41 | } else if n! > 65_535 { 42 | config = config.withPort("65535") 43 | } else { 44 | config = config.withPort("\(n!)") 45 | } 46 | } 47 | }.padding().background(Color(UIColor.secondarySystemBackground)).cornerRadius(8) 48 | 49 | HStack { 50 | VStack(alignment: .leading) { 51 | Label("Segment Duration", systemImage: "timelapse") 52 | Text("(Configures the length of each HLS segment; higher values make streams more stable, but give them a longer delay, and vice versa)") 53 | .foregroundColor(Color.gray) 54 | .font(.system(size: 14)) 55 | } 56 | Spacer(minLength: 40) 57 | 58 | Slider(value: $config.segmentDuration, in: 0...10, step: 1).frame(maxWidth: 250).onChange(of: config.segmentDuration) { newValue in 59 | withAnimation { 60 | isRealtime = newValue == UserStreamConfiguration.kRealtime 61 | } 62 | } 63 | 64 | Text(config.segmentDuration == UserStreamConfiguration.kRealtime ? "Realtime" : String(format: "%.0f", config.segmentDuration) + " s") 65 | .frame(width: 80, alignment: .trailing) 66 | }.padding().background(Color(UIColor.secondarySystemBackground)).cornerRadius(8) 67 | 68 | if !isRealtime { 69 | HStack { 70 | VStack(alignment: .leading) { 71 | Label("Video Bitrate (Mbps)", systemImage: "calendar.day.timeline.left") 72 | Text("(Configures the average bitrate to request of the output video. \"Lossless\" turns off video compression.)") 73 | .foregroundColor(Color.gray) 74 | .font(.system(size: 14)) 75 | } 76 | 77 | Spacer(minLength: 40) 78 | Slider(value: $config.videoBitrateMbps, in: 0.5...10.5, step: 0.5).frame(maxWidth: 250) 79 | Text(config.videoBitrateMbps == UserStreamConfiguration.kLossless ? "Lossless" : (String(format: "%.1f", config.videoBitrateMbps) + " Mbps")) 80 | .frame(width: 80, alignment: .trailing) 81 | 82 | }.padding().background(Color(UIColor.secondarySystemBackground)).cornerRadius(8) 83 | } 84 | 85 | HStack { 86 | VStack(alignment: .leading) { 87 | Label("Video Rotation", systemImage: "lock.rotation") 88 | Text("(Restart the stream for changes to take effect.)") 89 | .foregroundColor(Color.gray) 90 | .font(.system(size: 14)) 91 | } 92 | 93 | Spacer(minLength: 40) 94 | Picker("", selection: $config.rotation) { 95 | Text("Auto").tag("auto") 96 | Text("0°").tag("up") 97 | Text("90°").tag("left") 98 | Text("180°").tag("down") 99 | Text("270°").tag("right") 100 | }.pickerStyle(.segmented).frame(maxWidth: 400) 101 | 102 | }.padding().background(Color(UIColor.secondarySystemBackground)).cornerRadius(8) 103 | 104 | 105 | HStack { 106 | Label("Reset Values", systemImage: "arrow.clockwise") 107 | Spacer() 108 | Button("Tap to Reset") { 109 | config = UserStreamConfiguration() 110 | } 111 | }.padding().background(Color(UIColor.secondarySystemBackground)).cornerRadius(8) 112 | 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /Wingspan/Wingspan.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.application-groups 6 | 7 | group.dev.jdkula.Wingspan.config 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Wingspan/WingspanApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HLSStreamerApp.swift 3 | // HLSStreamer 4 | // 5 | // Main App entrypoint 6 | // 7 | // Created by @jdkula on 10/31/22. 8 | // 9 | 10 | import SwiftUI 11 | 12 | @main 13 | struct HLSStreamerApp: App { 14 | @StateObject private var config: UserStreamConfigObserver = UserStreamConfigObserver() 15 | 16 | @State private var isStreaming = UIScreen.main.isCaptured 17 | 18 | @State private var isRealtime = false 19 | 20 | var body: some Scene { 21 | WindowGroup { 22 | ContentView(config: $config.config, isStreaming: $isStreaming, isRealtime: $isRealtime).onAppear { 23 | // Load config values 24 | UserStreamConfiguration.load { result in 25 | switch result { 26 | case .failure: 27 | config.config = UserStreamConfiguration() 28 | case .success(let cfg): 29 | config.config = cfg; 30 | } 31 | withAnimation { 32 | isRealtime = config.config.segmentDuration == UserStreamConfiguration.kRealtime 33 | } 34 | } 35 | 36 | // Update isStreaming when we start/stop streaming 37 | NotificationCenter.default.addObserver(forName: UIScreen.capturedDidChangeNotification, object: nil, queue: nil) { _ in 38 | isStreaming = UIScreen.main.isCaptured 39 | } 40 | } 41 | } 42 | } 43 | } 44 | --------------------------------------------------------------------------------