├── .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 |
42 |
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 |
--------------------------------------------------------------------------------