├── .gitignore
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── Example
├── Boilerplate
│ ├── Assets.xcassets
│ │ ├── AccentColor.colorset
│ │ │ └── Contents.json
│ │ ├── AppIcon.appiconset
│ │ │ └── Contents.json
│ │ └── Contents.json
│ ├── Info.plist
│ ├── Preview Content
│ │ └── Preview Assets.xcassets
│ │ │ └── Contents.json
│ ├── Sample.entitlements
│ └── SampleApp.swift
├── Code
│ ├── ContentView.swift
│ ├── CustomPipifyView.swift
│ └── PipifyLoadingBarView.swift
└── Sample.xcodeproj
│ ├── project.pbxproj
│ └── project.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── LICENSE
├── Package.swift
├── README.md
└── Sources
├── LayerView.swift
├── PipifyController.swift
├── PipifyViewModifier.swift
├── View+Buffer.swift
├── View+Changes.swift
└── View+Pipify.swift
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | xcuserdata/
5 | DerivedData/
6 | .swiftpm/config/registries.json
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 | .netrc
9 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Example/Boilerplate/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 |
--------------------------------------------------------------------------------
/Example/Boilerplate/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "platform" : "ios",
6 | "size" : "1024x1024"
7 | },
8 | {
9 | "idiom" : "mac",
10 | "scale" : "1x",
11 | "size" : "16x16"
12 | },
13 | {
14 | "idiom" : "mac",
15 | "scale" : "2x",
16 | "size" : "16x16"
17 | },
18 | {
19 | "idiom" : "mac",
20 | "scale" : "1x",
21 | "size" : "32x32"
22 | },
23 | {
24 | "idiom" : "mac",
25 | "scale" : "2x",
26 | "size" : "32x32"
27 | },
28 | {
29 | "idiom" : "mac",
30 | "scale" : "1x",
31 | "size" : "128x128"
32 | },
33 | {
34 | "idiom" : "mac",
35 | "scale" : "2x",
36 | "size" : "128x128"
37 | },
38 | {
39 | "idiom" : "mac",
40 | "scale" : "1x",
41 | "size" : "256x256"
42 | },
43 | {
44 | "idiom" : "mac",
45 | "scale" : "2x",
46 | "size" : "256x256"
47 | },
48 | {
49 | "idiom" : "mac",
50 | "scale" : "1x",
51 | "size" : "512x512"
52 | },
53 | {
54 | "idiom" : "mac",
55 | "scale" : "2x",
56 | "size" : "512x512"
57 | }
58 | ],
59 | "info" : {
60 | "author" : "xcode",
61 | "version" : 1
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/Example/Boilerplate/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Example/Boilerplate/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | UIBackgroundModes
6 |
7 | audio
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/Example/Boilerplate/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Example/Boilerplate/Sample.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.app-sandbox
6 |
7 | com.apple.security.files.user-selected.read-only
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/Example/Boilerplate/SampleApp.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2022 • Sidetrack Tech Limited
3 | //
4 |
5 | import SwiftUI
6 |
7 | @main
8 | struct SampleApp: App {
9 | var body: some Scene {
10 | WindowGroup {
11 | ContentView()
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/Example/Code/ContentView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2022 • Sidetrack Tech Limited
3 | //
4 |
5 | import SwiftUI
6 | import Pipify
7 |
8 | struct ContentView: View {
9 | @State var isPresentedOne = false
10 | @State var isPresentedTwo = false
11 | @State var isPresentedThree = false
12 | @State var isPresentedFour = false
13 |
14 | var body: some View {
15 | VStack {
16 | Text("SwiftUI Pipify")
17 | .font(.title)
18 |
19 | Button("Launch Basic Example") {
20 | isPresentedTwo.toggle()
21 | }
22 |
23 | Text("Pipify View (Tap on me!)")
24 | .foregroundColor(.red)
25 | .fontWeight(.medium)
26 | .padding()
27 | .background(Color.gray.opacity(0.2))
28 | .cornerRadius(8)
29 | .pipify(isPresented: $isPresentedOne)
30 | .padding(.top)
31 | .onTapGesture {
32 | isPresentedOne.toggle()
33 | }
34 |
35 | Button("Basic Example") { isPresentedThree.toggle() }
36 | .pipify(isPresented: $isPresentedThree) {
37 | Text("Example Three")
38 | .foregroundColor(.red)
39 | .padding()
40 | .onPipSkip { _ in }
41 | .onPipPlayPause { _ in }
42 | }
43 |
44 | Button("Progress Bar") { isPresentedFour.toggle() }
45 | .pipify(isPresented: $isPresentedFour) { PipifyLoadingBarView() }
46 | }
47 | .frame(maxWidth: .infinity, maxHeight: .infinity)
48 | .pipify(isPresented: $isPresentedTwo, content: BasicExample.init)
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/Example/Code/CustomPipifyView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2022 • Sidetrack Tech Limited
3 | //
4 |
5 | import Pipify
6 | import SwiftUI
7 |
8 | struct BasicExample: View {
9 | @State var mode: Int = 0
10 | @State var counter: Int = 0
11 |
12 | @EnvironmentObject var controller: PipifyController
13 |
14 | var body: some View {
15 | Group {
16 | switch mode {
17 | case 0:
18 | VStack {
19 | Text("Width: \(Int(controller.renderSize.width))")
20 | Text("Height: \(Int(controller.renderSize.height))")
21 | }
22 | .foregroundColor(.green)
23 | case 1:
24 | Text("Counter: \(counter)")
25 | .foregroundColor(.blue)
26 | .frame(width: 300, height: 100)
27 | default:
28 | Color.red
29 | .overlay { Text("Counter: \(counter)").foregroundColor(.white) }
30 | .frame(width: 100, height: 300)
31 | }
32 | }
33 | .task {
34 | await updateMode()
35 | }
36 | .task {
37 | await updateCounter()
38 | }
39 | .onPipPlayPause { isPlaying in
40 | print("Playback \(isPlaying ? "is playing" : "is not playing")")
41 | }
42 | }
43 |
44 | private func updateCounter() async {
45 | counter += 1
46 | try? await Task.sleep(nanoseconds: 10_000_000) // 10 milliseconds
47 | await updateCounter()
48 | }
49 |
50 | private func updateMode() async {
51 | try? await Task.sleep(nanoseconds: 1_000_000_000 * 5) // 5 seconds
52 | mode += 1
53 |
54 | if mode == 3 {
55 | mode = 0
56 | }
57 |
58 | await updateMode()
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/Example/Code/PipifyLoadingBarView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2022 • Sidetrack Tech Limited
3 | //
4 |
5 | import SwiftUI
6 | import Pipify
7 |
8 | struct PipifyLoadingBarView: View {
9 | let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
10 | @State var progress: Double = 0
11 |
12 | var body: some View {
13 | Text("Loading...")
14 | .padding()
15 | .foregroundColor(.red)
16 | .pipBindProgress(progress: $progress)
17 | .onReceive(timer) { _ in
18 | let newProgress = progress + 0.05
19 |
20 | if newProgress > 1 {
21 | progress = 0
22 | } else {
23 | progress = newProgress
24 | }
25 | }
26 | }
27 |
28 | }
29 |
--------------------------------------------------------------------------------
/Example/Sample.xcodeproj/project.pbxproj:
--------------------------------------------------------------------------------
1 | // !$*UTF8*$!
2 | {
3 | archiveVersion = 1;
4 | classes = {
5 | };
6 | objectVersion = 56;
7 | objects = {
8 |
9 | /* Begin PBXBuildFile section */
10 | 162BEA2C2873B99500345084 /* PipifyLoadingBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 162BEA2B2873B99500345084 /* PipifyLoadingBarView.swift */; };
11 | 162BEA2F2874901D00345084 /* Pipify in Frameworks */ = {isa = PBXBuildFile; productRef = 162BEA2E2874901D00345084 /* Pipify */; };
12 | 1678264C286F37CD006AA4B6 /* SampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1678264B286F37CD006AA4B6 /* SampleApp.swift */; };
13 | 1678264E286F37CD006AA4B6 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1678264D286F37CD006AA4B6 /* ContentView.swift */; };
14 | 16782650286F37CE006AA4B6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1678264F286F37CE006AA4B6 /* Assets.xcassets */; };
15 | 16782654286F37CE006AA4B6 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 16782653286F37CE006AA4B6 /* Preview Assets.xcassets */; };
16 | 1678267E287351A9006AA4B6 /* CustomPipifyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1678267D287351A9006AA4B6 /* CustomPipifyView.swift */; };
17 | /* End PBXBuildFile section */
18 |
19 | /* Begin PBXFileReference section */
20 | 162BEA2B2873B99500345084 /* PipifyLoadingBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PipifyLoadingBarView.swift; sourceTree = ""; };
21 | 16782648286F37CD006AA4B6 /* Sample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sample.app; sourceTree = BUILT_PRODUCTS_DIR; };
22 | 1678264B286F37CD006AA4B6 /* SampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleApp.swift; sourceTree = ""; };
23 | 1678264D286F37CD006AA4B6 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
24 | 1678264F286F37CE006AA4B6 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
25 | 16782651286F37CE006AA4B6 /* Sample.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Sample.entitlements; sourceTree = ""; };
26 | 16782653286F37CE006AA4B6 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
27 | 1678265B286F37EC006AA4B6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
28 | 1678265F286F4617006AA4B6 /* Dockable */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Dockable; path = ..; sourceTree = ""; };
29 | 1678267D287351A9006AA4B6 /* CustomPipifyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomPipifyView.swift; sourceTree = ""; };
30 | /* End PBXFileReference section */
31 |
32 | /* Begin PBXFrameworksBuildPhase section */
33 | 16782645286F37CD006AA4B6 /* Frameworks */ = {
34 | isa = PBXFrameworksBuildPhase;
35 | buildActionMask = 2147483647;
36 | files = (
37 | 162BEA2F2874901D00345084 /* Pipify in Frameworks */,
38 | );
39 | runOnlyForDeploymentPostprocessing = 0;
40 | };
41 | /* End PBXFrameworksBuildPhase section */
42 |
43 | /* Begin PBXGroup section */
44 | 162BEA2A287352A500345084 /* Boilerplate */ = {
45 | isa = PBXGroup;
46 | children = (
47 | 1678265B286F37EC006AA4B6 /* Info.plist */,
48 | 1678264B286F37CD006AA4B6 /* SampleApp.swift */,
49 | 1678264F286F37CE006AA4B6 /* Assets.xcassets */,
50 | 16782651286F37CE006AA4B6 /* Sample.entitlements */,
51 | 16782652286F37CE006AA4B6 /* Preview Content */,
52 | );
53 | path = Boilerplate;
54 | sourceTree = "";
55 | };
56 | 1678263F286F37CD006AA4B6 = {
57 | isa = PBXGroup;
58 | children = (
59 | 1678265F286F4617006AA4B6 /* Dockable */,
60 | 162BEA2A287352A500345084 /* Boilerplate */,
61 | 1678264A286F37CD006AA4B6 /* Code */,
62 | 16782649286F37CD006AA4B6 /* Products */,
63 | );
64 | sourceTree = "";
65 | };
66 | 16782649286F37CD006AA4B6 /* Products */ = {
67 | isa = PBXGroup;
68 | children = (
69 | 16782648286F37CD006AA4B6 /* Sample.app */,
70 | );
71 | name = Products;
72 | sourceTree = "";
73 | };
74 | 1678264A286F37CD006AA4B6 /* Code */ = {
75 | isa = PBXGroup;
76 | children = (
77 | 1678264D286F37CD006AA4B6 /* ContentView.swift */,
78 | 1678267D287351A9006AA4B6 /* CustomPipifyView.swift */,
79 | 162BEA2B2873B99500345084 /* PipifyLoadingBarView.swift */,
80 | );
81 | path = Code;
82 | sourceTree = "";
83 | };
84 | 16782652286F37CE006AA4B6 /* Preview Content */ = {
85 | isa = PBXGroup;
86 | children = (
87 | 16782653286F37CE006AA4B6 /* Preview Assets.xcassets */,
88 | );
89 | path = "Preview Content";
90 | sourceTree = "";
91 | };
92 | /* End PBXGroup section */
93 |
94 | /* Begin PBXNativeTarget section */
95 | 16782647286F37CD006AA4B6 /* Sample */ = {
96 | isa = PBXNativeTarget;
97 | buildConfigurationList = 16782657286F37CE006AA4B6 /* Build configuration list for PBXNativeTarget "Sample" */;
98 | buildPhases = (
99 | 16782644286F37CD006AA4B6 /* Sources */,
100 | 16782645286F37CD006AA4B6 /* Frameworks */,
101 | 16782646286F37CD006AA4B6 /* Resources */,
102 | );
103 | buildRules = (
104 | );
105 | dependencies = (
106 | );
107 | name = Sample;
108 | packageProductDependencies = (
109 | 162BEA2E2874901D00345084 /* Pipify */,
110 | );
111 | productName = DockableSample;
112 | productReference = 16782648286F37CD006AA4B6 /* Sample.app */;
113 | productType = "com.apple.product-type.application";
114 | };
115 | /* End PBXNativeTarget section */
116 |
117 | /* Begin PBXProject section */
118 | 16782640286F37CD006AA4B6 /* Project object */ = {
119 | isa = PBXProject;
120 | attributes = {
121 | BuildIndependentTargetsInParallel = 1;
122 | LastSwiftUpdateCheck = 1400;
123 | LastUpgradeCheck = 1400;
124 | TargetAttributes = {
125 | 16782647286F37CD006AA4B6 = {
126 | CreatedOnToolsVersion = 14.0;
127 | };
128 | };
129 | };
130 | buildConfigurationList = 16782643286F37CD006AA4B6 /* Build configuration list for PBXProject "Sample" */;
131 | compatibilityVersion = "Xcode 14.0";
132 | developmentRegion = en;
133 | hasScannedForEncodings = 0;
134 | knownRegions = (
135 | en,
136 | Base,
137 | );
138 | mainGroup = 1678263F286F37CD006AA4B6;
139 | productRefGroup = 16782649286F37CD006AA4B6 /* Products */;
140 | projectDirPath = "";
141 | projectRoot = "";
142 | targets = (
143 | 16782647286F37CD006AA4B6 /* Sample */,
144 | );
145 | };
146 | /* End PBXProject section */
147 |
148 | /* Begin PBXResourcesBuildPhase section */
149 | 16782646286F37CD006AA4B6 /* Resources */ = {
150 | isa = PBXResourcesBuildPhase;
151 | buildActionMask = 2147483647;
152 | files = (
153 | 16782654286F37CE006AA4B6 /* Preview Assets.xcassets in Resources */,
154 | 16782650286F37CE006AA4B6 /* Assets.xcassets in Resources */,
155 | );
156 | runOnlyForDeploymentPostprocessing = 0;
157 | };
158 | /* End PBXResourcesBuildPhase section */
159 |
160 | /* Begin PBXSourcesBuildPhase section */
161 | 16782644286F37CD006AA4B6 /* Sources */ = {
162 | isa = PBXSourcesBuildPhase;
163 | buildActionMask = 2147483647;
164 | files = (
165 | 162BEA2C2873B99500345084 /* PipifyLoadingBarView.swift in Sources */,
166 | 1678264E286F37CD006AA4B6 /* ContentView.swift in Sources */,
167 | 1678267E287351A9006AA4B6 /* CustomPipifyView.swift in Sources */,
168 | 1678264C286F37CD006AA4B6 /* SampleApp.swift in Sources */,
169 | );
170 | runOnlyForDeploymentPostprocessing = 0;
171 | };
172 | /* End PBXSourcesBuildPhase section */
173 |
174 | /* Begin XCBuildConfiguration section */
175 | 16782655286F37CE006AA4B6 /* Debug */ = {
176 | isa = XCBuildConfiguration;
177 | buildSettings = {
178 | ALWAYS_SEARCH_USER_PATHS = NO;
179 | CLANG_ANALYZER_NONNULL = YES;
180 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
181 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
182 | CLANG_ENABLE_MODULES = YES;
183 | CLANG_ENABLE_OBJC_ARC = YES;
184 | CLANG_ENABLE_OBJC_WEAK = YES;
185 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
186 | CLANG_WARN_BOOL_CONVERSION = YES;
187 | CLANG_WARN_COMMA = YES;
188 | CLANG_WARN_CONSTANT_CONVERSION = YES;
189 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
190 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
191 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
192 | CLANG_WARN_EMPTY_BODY = YES;
193 | CLANG_WARN_ENUM_CONVERSION = YES;
194 | CLANG_WARN_INFINITE_RECURSION = YES;
195 | CLANG_WARN_INT_CONVERSION = YES;
196 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
197 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
198 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
199 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
200 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
201 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
202 | CLANG_WARN_STRICT_PROTOTYPES = YES;
203 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
204 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
205 | CLANG_WARN_UNREACHABLE_CODE = YES;
206 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
207 | COPY_PHASE_STRIP = NO;
208 | DEBUG_INFORMATION_FORMAT = dwarf;
209 | ENABLE_STRICT_OBJC_MSGSEND = YES;
210 | ENABLE_TESTABILITY = YES;
211 | GCC_C_LANGUAGE_STANDARD = gnu11;
212 | GCC_DYNAMIC_NO_PIC = NO;
213 | GCC_NO_COMMON_BLOCKS = YES;
214 | GCC_OPTIMIZATION_LEVEL = 0;
215 | GCC_PREPROCESSOR_DEFINITIONS = (
216 | "DEBUG=1",
217 | "$(inherited)",
218 | );
219 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
220 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
221 | GCC_WARN_UNDECLARED_SELECTOR = YES;
222 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
223 | GCC_WARN_UNUSED_FUNCTION = YES;
224 | GCC_WARN_UNUSED_VARIABLE = YES;
225 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
226 | MTL_FAST_MATH = YES;
227 | ONLY_ACTIVE_ARCH = YES;
228 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
229 | SWIFT_OPTIMIZATION_LEVEL = "-Onone";
230 | };
231 | name = Debug;
232 | };
233 | 16782656286F37CE006AA4B6 /* Release */ = {
234 | isa = XCBuildConfiguration;
235 | buildSettings = {
236 | ALWAYS_SEARCH_USER_PATHS = NO;
237 | CLANG_ANALYZER_NONNULL = YES;
238 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
239 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
240 | CLANG_ENABLE_MODULES = YES;
241 | CLANG_ENABLE_OBJC_ARC = YES;
242 | CLANG_ENABLE_OBJC_WEAK = YES;
243 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
244 | CLANG_WARN_BOOL_CONVERSION = YES;
245 | CLANG_WARN_COMMA = YES;
246 | CLANG_WARN_CONSTANT_CONVERSION = YES;
247 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
248 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
249 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
250 | CLANG_WARN_EMPTY_BODY = YES;
251 | CLANG_WARN_ENUM_CONVERSION = YES;
252 | CLANG_WARN_INFINITE_RECURSION = YES;
253 | CLANG_WARN_INT_CONVERSION = YES;
254 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
255 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
256 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
257 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
258 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
259 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
260 | CLANG_WARN_STRICT_PROTOTYPES = YES;
261 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
262 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
263 | CLANG_WARN_UNREACHABLE_CODE = YES;
264 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
265 | COPY_PHASE_STRIP = NO;
266 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
267 | ENABLE_NS_ASSERTIONS = NO;
268 | ENABLE_STRICT_OBJC_MSGSEND = YES;
269 | GCC_C_LANGUAGE_STANDARD = gnu11;
270 | GCC_NO_COMMON_BLOCKS = YES;
271 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
272 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
273 | GCC_WARN_UNDECLARED_SELECTOR = YES;
274 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
275 | GCC_WARN_UNUSED_FUNCTION = YES;
276 | GCC_WARN_UNUSED_VARIABLE = YES;
277 | MTL_ENABLE_DEBUG_INFO = NO;
278 | MTL_FAST_MATH = YES;
279 | SWIFT_COMPILATION_MODE = wholemodule;
280 | SWIFT_OPTIMIZATION_LEVEL = "-O";
281 | };
282 | name = Release;
283 | };
284 | 16782658286F37CE006AA4B6 /* Debug */ = {
285 | isa = XCBuildConfiguration;
286 | buildSettings = {
287 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
288 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
289 | CODE_SIGN_ENTITLEMENTS = Boilerplate/Sample.entitlements;
290 | CODE_SIGN_STYLE = Automatic;
291 | CURRENT_PROJECT_VERSION = 1;
292 | DEVELOPMENT_ASSET_PATHS = "\"Boilerplate/Preview Content\"";
293 | DEVELOPMENT_TEAM = LY9S5TJ4Z6;
294 | ENABLE_HARDENED_RUNTIME = YES;
295 | ENABLE_PREVIEWS = YES;
296 | GENERATE_INFOPLIST_FILE = YES;
297 | INFOPLIST_FILE = Boilerplate/Info.plist;
298 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
299 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
300 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
301 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
302 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
303 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
304 | MARKETING_VERSION = 1.0;
305 | PRODUCT_BUNDLE_IDENTIFIER = com.getsidetrack.sample.dockable;
306 | PRODUCT_NAME = "$(TARGET_NAME)";
307 | SDKROOT = auto;
308 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
309 | SWIFT_EMIT_LOC_STRINGS = YES;
310 | SWIFT_VERSION = 5.0;
311 | TARGETED_DEVICE_FAMILY = "1,2";
312 | };
313 | name = Debug;
314 | };
315 | 16782659286F37CE006AA4B6 /* Release */ = {
316 | isa = XCBuildConfiguration;
317 | buildSettings = {
318 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
319 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
320 | CODE_SIGN_ENTITLEMENTS = Boilerplate/Sample.entitlements;
321 | CODE_SIGN_STYLE = Automatic;
322 | CURRENT_PROJECT_VERSION = 1;
323 | DEVELOPMENT_ASSET_PATHS = "\"Boilerplate/Preview Content\"";
324 | DEVELOPMENT_TEAM = LY9S5TJ4Z6;
325 | ENABLE_HARDENED_RUNTIME = YES;
326 | ENABLE_PREVIEWS = YES;
327 | GENERATE_INFOPLIST_FILE = YES;
328 | INFOPLIST_FILE = Boilerplate/Info.plist;
329 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
330 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
331 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
332 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
333 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
334 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
335 | MARKETING_VERSION = 1.0;
336 | PRODUCT_BUNDLE_IDENTIFIER = com.getsidetrack.sample.dockable;
337 | PRODUCT_NAME = "$(TARGET_NAME)";
338 | SDKROOT = auto;
339 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
340 | SWIFT_EMIT_LOC_STRINGS = YES;
341 | SWIFT_VERSION = 5.0;
342 | TARGETED_DEVICE_FAMILY = "1,2";
343 | };
344 | name = Release;
345 | };
346 | /* End XCBuildConfiguration section */
347 |
348 | /* Begin XCConfigurationList section */
349 | 16782643286F37CD006AA4B6 /* Build configuration list for PBXProject "Sample" */ = {
350 | isa = XCConfigurationList;
351 | buildConfigurations = (
352 | 16782655286F37CE006AA4B6 /* Debug */,
353 | 16782656286F37CE006AA4B6 /* Release */,
354 | );
355 | defaultConfigurationIsVisible = 0;
356 | defaultConfigurationName = Release;
357 | };
358 | 16782657286F37CE006AA4B6 /* Build configuration list for PBXNativeTarget "Sample" */ = {
359 | isa = XCConfigurationList;
360 | buildConfigurations = (
361 | 16782658286F37CE006AA4B6 /* Debug */,
362 | 16782659286F37CE006AA4B6 /* Release */,
363 | );
364 | defaultConfigurationIsVisible = 0;
365 | defaultConfigurationName = Release;
366 | };
367 | /* End XCConfigurationList section */
368 |
369 | /* Begin XCSwiftPackageProductDependency section */
370 | 162BEA2E2874901D00345084 /* Pipify */ = {
371 | isa = XCSwiftPackageProductDependency;
372 | productName = Pipify;
373 | };
374 | /* End XCSwiftPackageProductDependency section */
375 | };
376 | rootObject = 16782640286F37CD006AA4B6 /* Project object */;
377 | }
378 |
--------------------------------------------------------------------------------
/Example/Sample.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Example/Sample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Sidetrack Tech Limited
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.6
2 |
3 | import PackageDescription
4 |
5 | let package = Package(
6 | name: "Pipify",
7 | platforms: [
8 | // Minimum OS version is currently limited by the ImageRenderer:
9 | // https://developer.apple.com/documentation/swiftui/imagerenderer
10 | // A GitHub issue is open to explore backporting to older versions:
11 | // https://github.com/getsidetrack/swiftui-pipify/issues/4
12 |
13 | // We use string initialisers for versions in order to reduce the swift tools version required
14 | // (5.6 instead of 5.7). This is for SwiftPackageIndex.
15 | .iOS("16.0.0"),
16 | .macOS("13.0.0"),
17 | .tvOS("16.0.0"),
18 | .macCatalyst("16.0.0"),
19 | ],
20 | products: [
21 | .library(name: "Pipify", targets: ["Pipify"]),
22 | ],
23 | targets: [
24 | .target(name: "Pipify", path: "Sources"),
25 | ]
26 | )
27 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Pipify for SwiftUI
2 |
3 | This library introduces a new SwiftUI modifier that enables a view to be shown within a Picture in Picture overlay. This overlay
4 | allows information to be displayed on the screen even when the application is in the background.
5 |
6 | This could be great for applications like navigation apps and stock market apps which rely on real-time information being shown
7 | to the user.
8 |
9 | > *Note*: This project currently relies on SwiftUI 4 and as such has a minimum deployment target of iOS 16, tvOS 16, or macOS 13 - all of which are currently in beta. This library does not work on watchOS.
10 |
11 | ## Getting Started
12 |
13 | ### Installation
14 |
15 | We currently support installation through [Swift Package Manager](https://www.swift.org/package-manager/).
16 |
17 | ```
18 | https://github.com/getsidetrack/swiftui-pipify.git
19 | ```
20 |
21 | ### Project Configuration
22 |
23 | You will need to enable "Background Modes" in your project entitlements window. Specifically, you need the
24 | `Audio, AirPlay and Picture in Picture` option to be enabled. Without this picture in picture mode cannot launch.
25 |
26 | ### Pipify View
27 |
28 | The "pipify view" is the view which is actually shown within the picture-in-picture window. This can either be the
29 | view which you add the pipify modifier to, or a completely different view.
30 |
31 | If you do not provide a custom pipify view, then we will use the view that the modifier was added to. By default this
32 | will use Apple's 'morph' transition which will animate the view into the picture-in-picture controller.
33 |
34 | When a custom pipify view is provided, we will render this offscreen which causes picture-in-picture to simply fade in.
35 |
36 | Your pipify view can be any SwiftUI view, but there are some important notes to be aware of:
37 |
38 | 1. This view will be rendered invisibly when created, so closures such as `task` and `onAppear` may be called when you don't expect it.
39 | 2. Most user interactions are not supported - so buttons, tap gestures, and more will not work.
40 | 3. Animations and transitions may result in unexpected behaviour and has not been fully tested.
41 |
42 | ### Usage
43 |
44 | Simply add the `pipify` modifier to your SwiftUI view. There are two key signatures based on whether you want to provide
45 | your own custom pipify view (see above).
46 |
47 | ```swift
48 | @State var isPresented = false
49 |
50 | var body: some View {
51 | yourView
52 | .pipify(isPresented: $isPresented) // presents `yourView` in PIP
53 |
54 | // or
55 |
56 | yourView
57 | .pipify(isPresented: $isPresented) {
58 | SomeOtherView() // presents `SomeOtherView` in PIP
59 | }
60 | }
61 | ```
62 |
63 | In the example above, you can replace `yourView` with whatever you'd like to show. This is your existing code. The state
64 | binding is what determines when to present the picture in picture window. This API is similar to Apple's own solutions for example with
65 | [sheet](https://www.hackingwithswift.com/quick-start/swiftui/how-to-present-a-new-view-using-sheets).
66 |
67 | If you provide a custom SwiftUI view as your pipify view, then you may choose to add our pipify controller as an environment
68 | object in that separate view ("SomeOtherView" in the example code above).
69 |
70 | Note that you cannot use the EnvironmentObject in the view which specifies the pipify modifier.
71 |
72 | ```swift
73 | @EnvironmentObject var controller: PipifyController
74 | ```
75 |
76 | This will give you access to certain variables such as the `renderSize` which returns the size of the picture-in-picture
77 | window.
78 |
79 | Alternatively, you can attach our custom closures to your view to get informed about key events.
80 |
81 | ```
82 | yourPipifyView
83 | .onPipRenderSizeChanged { size in
84 | // size has changed
85 | }
86 | ```
87 |
88 | > A basic example project is included in the repository. Want to share your own example? Raise a pull request with your examples below.
89 |
90 | ### Testing
91 |
92 | Pipify will not launch on unsupported devices and will return an error in the debug console stating that it could not launch.
93 | You can check whether a device is compatible by using `PipifyController.isSupported` which returns true or false. You may use
94 | this to show or hide the pip option in your application.
95 |
96 | **You must test this library on physical devices**. Due to issues in simulators outside of our control, you will see various
97 | inconsistencies, lack of support and other bugs when run on simulators.
98 |
99 | ## How does this work?
100 |
101 | This library utilises what many may refer to as a "hack" but is essentially a feature in Apple's picture-in-picture mode (PiP).
102 |
103 | Picture-in-Picture has been around for quite a while first launching with iOS 9 in 2015 and was later brought to macOS
104 | 10.15 in 2019 and tvOS 14 most recently in 2020. It provides users with the ability to view video content while using
105 | other applications, for example watching a YouTube video while reading tweets.
106 |
107 | Pipify expands on this feature by essentially creating a video stream from a SwiftUI view. We take a screenshot of your
108 | view anytime it updates and push this through a series of functions in Apple's AVKit. From these screenshots which we turn
109 | into a video stream, we can launch picture-in-picture mode.
110 |
111 | From a user's perspective, the view is moved in a window above the application. Video controls may be visible and can be
112 | hidden by tapping on them. Users can temporarily hide or close the picture-in-picture window at any time.
113 |
114 | ⚠️ There is no reason to believe that this functionality breaks Apple's App Store guidelines. There are examples of apps
115 | on the App Store which use this functionality ([Example](https://apps.apple.com/us/app/minispeech-live-transcribe/id1576069409)),
116 | but there has also been cases of apps being rejected for misuse of the API ([Example](https://twitter.com/palmin/status/1440719449468772361)).
117 |
118 | We recommend that developers proceed with caution and consider what the best experience is for their users. You may want to explore
119 | ways of implementing sound into your application.
120 |
121 | ## Thanks
122 |
123 | Credit goes to Akihiro Urushihara with [UIPiPView](https://github.com/uakihir0/UIPiPView) which was the inspiration for building
124 | this SwiftUI component.
125 |
--------------------------------------------------------------------------------
/Sources/LayerView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2022 • Sidetrack Tech Limited
3 | //
4 |
5 | import SwiftUI
6 |
7 | #if os(iOS) || os(tvOS) || targetEnvironment(macCatalyst)
8 | import UIKit
9 | struct LayerView: UIViewRepresentable {
10 | let layer: CALayer
11 | let size: CGSize?
12 |
13 | func makeUIView(context: Context) -> UIView {
14 | let view = UIView()
15 | view.layer.addSublayer(layer)
16 |
17 | if let size {
18 | layer.frame.size = size
19 | }
20 |
21 | return view
22 | }
23 |
24 | func updateUIView(_ uiView: UIView, context: Context) {}
25 | }
26 | #elseif os(macOS)
27 | import Cocoa
28 | struct LayerView: NSViewRepresentable {
29 | let layer: CALayer
30 | let size: CGSize?
31 |
32 | func makeNSView(context: Context) -> NSView {
33 | let view = NSView()
34 | view.layer?.addSublayer(layer)
35 | return view
36 | }
37 |
38 | func updateNSView(_ nsView: NSView, context: Context) {}
39 | }
40 | #endif
41 |
--------------------------------------------------------------------------------
/Sources/PipifyController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2022 • Sidetrack Tech Limited
3 | //
4 |
5 | import Foundation
6 | import SwiftUI
7 | import AVKit
8 | import Combine
9 | import os.log
10 |
11 | public final class PipifyController: NSObject, ObservableObject, AVPictureInPictureControllerDelegate,
12 | AVPictureInPictureSampleBufferPlaybackDelegate {
13 |
14 | public static var isSupported: Bool {
15 | AVPictureInPictureController.isPictureInPictureSupported()
16 | }
17 |
18 | @Published public var renderSize: CGSize = .zero
19 | @Published public var isPlaying: Bool = true
20 |
21 | @Published public var enabled: Bool = false
22 | internal var isPlayPauseEnabled = false
23 |
24 | internal var onSkip: ((Double) -> Void)? = nil {
25 | didSet {
26 | // the pip controller is setup by the time the skip modifier changes this value
27 | // as such we update the pip controller after the fact
28 | pipController?.requiresLinearPlayback = onSkip == nil
29 | pipController?.invalidatePlaybackState()
30 | }
31 | }
32 |
33 | internal var progress: Double = 1 {
34 | didSet {
35 | pipController?.invalidatePlaybackState()
36 | }
37 | }
38 |
39 | internal let bufferLayer = AVSampleBufferDisplayLayer()
40 | private var pipController: AVPictureInPictureController?
41 | private var rendererSubscriptions = Set()
42 | private var pipPossibleObservation: NSKeyValueObservation?
43 |
44 | /// Updates (if necessary) the iOS audio session.
45 | ///
46 | /// Even though we don't play (or yet even support playing) audio, an audio session must be active in order
47 | /// for picture-in-picture to operate.
48 | static func setupAudioSession() {
49 | // not needed on macOS
50 | #if !os(macOS)
51 | logger.info("configuring audio session")
52 | let session = AVAudioSession.sharedInstance()
53 |
54 | // only update if necessary
55 | if session.category == .soloAmbient || session.mode == .default {
56 | try? session.setCategory(.playback, mode: .moviePlayback, options: .mixWithOthers)
57 | }
58 | #endif
59 | }
60 |
61 | public override init() {
62 | super.init()
63 | Self.setupAudioSession()
64 | setupController()
65 | }
66 |
67 | private func setupController() {
68 | logger.info("creating pip controller")
69 |
70 | bufferLayer.frame.size = .init(width: 300, height: 100)
71 | bufferLayer.videoGravity = .resizeAspect
72 |
73 | pipController = AVPictureInPictureController(contentSource: .init(
74 | sampleBufferDisplayLayer: bufferLayer,
75 | playbackDelegate: self
76 | ))
77 |
78 | // Combined with a certain time range this makes it so the skip buttons are not visible / interactable.
79 | // if an `onSkip` closure is provied then we don't do this
80 | pipController?.requiresLinearPlayback = onSkip == nil
81 |
82 | pipController?.delegate = self
83 | }
84 |
85 | @MainActor func setView(_ view: some View, maximumUpdatesPerSecond: Double = 30) {
86 | let modifiedView = view.environmentObject(self)
87 | let renderer = ImageRenderer(content: modifiedView)
88 |
89 | renderer
90 | .objectWillChange
91 | // limit the number of times we redraw per second (performance)
92 | .throttle(for: .init(1.0 / maximumUpdatesPerSecond), scheduler: RunLoop.main, latest: true)
93 | .sink { [weak self] _ in
94 | self?.render(view: modifiedView, using: renderer)
95 | }
96 | .store(in: &rendererSubscriptions)
97 |
98 | // first draw
99 | render(view: modifiedView, using: renderer)
100 | }
101 |
102 | // MARK: - Rendering
103 |
104 | private func render(view: some View, using renderer: ImageRenderer) {
105 | Task {
106 | do {
107 | let buffer = try await view.makeBuffer(renderer: renderer)
108 | render(buffer: buffer)
109 | } catch {
110 | logger.error("failed to create buffer: \(error.localizedDescription)")
111 | }
112 | }
113 | }
114 |
115 | private func render(buffer: CMSampleBuffer) {
116 | if bufferLayer.status == .failed {
117 | bufferLayer.flush()
118 | }
119 |
120 | bufferLayer.enqueue(buffer)
121 | }
122 |
123 | // MARK: - Lifecycle
124 |
125 | internal func start() {
126 | guard let pipController else {
127 | logger.warning("could not start: no controller")
128 | return
129 | }
130 |
131 | guard pipController.isPictureInPictureActive == false else {
132 | logger.warning("could not start: already active")
133 | return
134 | }
135 |
136 | #if !os(macOS)
137 | logger.info("activating audio session")
138 | try? AVAudioSession.sharedInstance().setActive(true)
139 | #endif
140 |
141 | // force the timestamp to update
142 | pipController.invalidatePlaybackState()
143 |
144 | if pipController.isPictureInPicturePossible {
145 | logger.info("starting picture in picture")
146 | pipController.startPictureInPicture()
147 | } else {
148 | logger.info("waiting for pip to be possible")
149 |
150 | // not currently possible, so wait until it is.
151 | let keyPath = \AVPictureInPictureController.isPictureInPicturePossible
152 | pipPossibleObservation = pipController.observe(keyPath, options: [ .new ]) { [weak self] controller, change in
153 | if change.newValue ?? false {
154 | logger.info("starting picture in picture")
155 | controller.startPictureInPicture()
156 | self?.pipPossibleObservation = nil
157 | }
158 | }
159 | }
160 | }
161 |
162 | internal func stop() {
163 | guard let pipController else {
164 | logger.warning("could not stop: no controller")
165 | return
166 | }
167 |
168 | logger.info("stopping picture in picture")
169 | pipController.stopPictureInPicture()
170 |
171 | #if !os(macOS)
172 | logger.info("deactivating audio session")
173 | try? AVAudioSession.sharedInstance().setActive(false)
174 | #endif
175 | }
176 |
177 | // MARK: - AVPictureInPictureControllerDelegate
178 |
179 | public func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
180 | logger.info("didStart")
181 | }
182 |
183 | public func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
184 | logger.info("didStop")
185 | enabled = false
186 | }
187 |
188 | public func pictureInPictureControllerShouldProhibitBackgroundAudioPlayback(_ pictureInPictureController: AVPictureInPictureController) -> Bool {
189 | // We do not support audio through the pipify controller, as such we will allow other background audio to
190 | // continue playing
191 | return false
192 | }
193 |
194 | public func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error) {
195 | logger.error("failed to start: \(error.localizedDescription)")
196 | enabled = false
197 | }
198 |
199 | public func pictureInPictureController(
200 | _ pictureInPictureController: AVPictureInPictureController,
201 | restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void
202 | ) {
203 | logger.info("restore UI")
204 | enabled = false
205 | completionHandler(true)
206 | }
207 |
208 | // MARK: - AVPictureInPictureSampleBufferPlaybackDelegate
209 |
210 | public func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, setPlaying playing: Bool) {
211 | if isPlayPauseEnabled {
212 | DispatchQueue.main.async {
213 | logger.info("setPlaying: \(playing)")
214 | self.isPlaying = playing
215 | pictureInPictureController.invalidatePlaybackState()
216 | }
217 | }
218 | }
219 |
220 | public func pictureInPictureControllerIsPlaybackPaused(_ pictureInPictureController: AVPictureInPictureController) -> Bool {
221 | return isPlayPauseEnabled && isPlaying == false
222 | }
223 |
224 | public func pictureInPictureControllerTimeRangeForPlayback(_ pictureInPictureController: AVPictureInPictureController) -> CMTimeRange {
225 | if onSkip == nil && progress == 1 {
226 | // By returning a positive time range in conjunction with enabling `requiresLinearPlayback`
227 | // PIP will only show the play/pause button and hide the 'Live' label and skip buttons.
228 | return CMTimeRange(start: .init(value: 1, timescale: 1), end: .init(value: 2, timescale: 1))
229 | } else {
230 | let currentTime = CMTime(
231 | seconds: CACurrentMediaTime(),
232 | preferredTimescale: 120
233 | )
234 |
235 | // We use one week as the value needs to be large enough that a user would not feasibly see time pass.
236 | let oneWeek: Double = 86400 * 7
237 |
238 | let multipliers: (Double, Double)
239 | switch progress {
240 | case 0: // 0%
241 | multipliers = (0, 1)
242 | default:
243 | multipliers = (1, 1 / progress - 1)
244 | }
245 |
246 | let startScaler = CMTime(seconds: oneWeek * multipliers.0, preferredTimescale: 120)
247 |
248 | // the 20 here (can be pretty much any number) ensures that the skip forward button works
249 | // if we don't add this little extra then Apple believes we're at the end of the clip
250 | // and as such disables the skip forward button. we don't want that.
251 | // because our oneWeek number is so large, the 20 here isn't noticeable to users.
252 | let endScaler = CMTime(seconds: oneWeek * multipliers.1 + 20, preferredTimescale: 120)
253 |
254 | return CMTimeRange(start: currentTime - startScaler, end: currentTime + endScaler)
255 | }
256 | }
257 |
258 | public func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, didTransitionToRenderSize newRenderSize: CMVideoDimensions) {
259 | logger.trace("window resize: \(newRenderSize.width)x\(newRenderSize.height)")
260 | renderSize = .init(width: Int(newRenderSize.width), height: Int(newRenderSize.height))
261 | }
262 |
263 | public func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, skipByInterval skipInterval: CMTime) async {
264 | logger.info("skip by: \(skipInterval.seconds) seconds")
265 | onSkip?(skipInterval.seconds)
266 | }
267 | }
268 |
269 | let logger = Logger(subsystem: "com.getsidetrack.pipify", category: "Pipify")
270 |
--------------------------------------------------------------------------------
/Sources/PipifyViewModifier.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2022 • Sidetrack Tech Limited
3 | //
4 |
5 | import SwiftUI
6 |
7 | internal struct PipifyModifier: ViewModifier {
8 | @Binding var isPresented: Bool
9 | @StateObject var controller: PipifyController = PipifyController()
10 |
11 | let pipContent: () -> PipView
12 | let offscreenRendering: Bool
13 |
14 | init(
15 | isPresented: Binding,
16 | pipContent: @escaping () -> PipView,
17 | offscreenRendering: Bool
18 | ) {
19 | self._isPresented = isPresented
20 | self.pipContent = pipContent
21 | self.offscreenRendering = offscreenRendering
22 | }
23 |
24 | func body(content: Content) -> some View {
25 | content
26 | .overlay {
27 | if offscreenRendering == false {
28 | GeometryReader { proxy in
29 | generateLayerView(size: proxy.size)
30 | }
31 | } else {
32 | generateLayerView(size: nil)
33 | }
34 | }
35 | .onChange(of: isPresented) { newValue in
36 | logger.trace("isPresented changed to \(newValue)")
37 | if newValue {
38 | controller.start()
39 | } else {
40 | controller.stop()
41 | }
42 | }
43 | .onChange(of: controller.enabled) { newValue in
44 | isPresented = newValue
45 | }
46 | .task {
47 | logger.trace("setting view content")
48 | controller.setView(pipContent())
49 | }
50 | }
51 |
52 | @ViewBuilder
53 | func generateLayerView(size: CGSize?) -> some View {
54 | LayerView(layer: controller.bufferLayer, size: size)
55 | // layer needs to be in the hierarchy, doesn't actually need to be visible
56 | .opacity(0)
57 | .allowsHitTesting(false)
58 | // if we have a size, then we'll morph from the existing view. otherwise we'll fade from offscreen.
59 | .offset(size != nil ? .zero : .init(width: .max, height: .max))
60 | }
61 | }
62 |
63 |
--------------------------------------------------------------------------------
/Sources/View+Buffer.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2022 • Sidetrack Tech Limited
3 | //
4 |
5 | import SwiftUI
6 | import AVFoundation
7 |
8 | @available(macOS 13.0, *)
9 | extension View {
10 | /// Creates a `CMSampleBuffer` containing the rendered view.
11 | func makeBuffer(renderer: ImageRenderer) async throws -> CMSampleBuffer {
12 | // Pixel Buffer
13 | var buffer: CVPixelBuffer?
14 |
15 | #if canImport(UIKit)
16 | let scale = await UIScreen.main.scale * 2
17 | #else
18 | let scale: CGFloat = 1
19 | #endif
20 |
21 | await renderer.render { size, callback in
22 | let scaledSize = CGSize(width: size.width * scale, height: size.height * scale)
23 |
24 | let status = CVPixelBufferCreate(
25 | kCFAllocatorDefault,
26 | Int(scaledSize.width),
27 | Int(scaledSize.height),
28 | kCVPixelFormatType_32ARGB,
29 | [
30 | kCVPixelBufferCGImageCompatibilityKey: kCFBooleanTrue!,
31 | kCVPixelBufferCGBitmapContextCompatibilityKey: kCFBooleanTrue!,
32 | kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary,
33 | ] as CFDictionary,
34 | &buffer
35 | )
36 |
37 | guard let unwrappedBuffer = buffer, status == kCVReturnSuccess else {
38 | logger.error("buffer not created, status: \(status)")
39 | return
40 | }
41 |
42 | CVPixelBufferLockBaseAddress(unwrappedBuffer, [])
43 | defer { CVPixelBufferUnlockBaseAddress(unwrappedBuffer, []) }
44 |
45 | let context = CGContext(
46 | data: CVPixelBufferGetBaseAddress(unwrappedBuffer),
47 | width: Int(scaledSize.width),
48 | height: Int(scaledSize.height),
49 | bitsPerComponent: 8,
50 | bytesPerRow: CVPixelBufferGetBytesPerRow(unwrappedBuffer),
51 | space: CGColorSpaceCreateDeviceRGB(),
52 | bitmapInfo: CGImageAlphaInfo.noneSkipFirst.rawValue
53 | )
54 |
55 | guard let unwrappedContext = context else {
56 | logger.error("context not created")
57 | return
58 | }
59 |
60 | // Apply the scale to the context
61 | unwrappedContext.scaleBy(x: scale, y: scale)
62 |
63 | callback(unwrappedContext)
64 | }
65 |
66 | guard let unwrappedPixelBuffer = buffer else {
67 | logger.error("buffer not created")
68 | throw NSError(domain: "com.getsidetrack.pipify", code: 0)
69 | }
70 |
71 | // Format Description
72 | var formatDescription: CMFormatDescription?
73 | let status = CMVideoFormatDescriptionCreateForImageBuffer(
74 | allocator: kCFAllocatorDefault,
75 | imageBuffer: unwrappedPixelBuffer,
76 | formatDescriptionOut: &formatDescription
77 | )
78 |
79 | guard let unwrappedFormatDescription = formatDescription, status == kCVReturnSuccess else {
80 | logger.error("format description not created, status: \(status)")
81 | throw NSError(domain: "com.getsidetrack.pipify", code: 1)
82 | }
83 |
84 | // Timing Info
85 | let now = CMTime(
86 | seconds: CACurrentMediaTime(),
87 | preferredTimescale: 120
88 | )
89 |
90 | let timingInfo = CMSampleTimingInfo(
91 | duration: .init(seconds: 1, preferredTimescale: 60),
92 | presentationTimeStamp: now,
93 | decodeTimeStamp: now
94 | )
95 |
96 | return try CMSampleBuffer(
97 | imageBuffer: unwrappedPixelBuffer,
98 | formatDescription: unwrappedFormatDescription,
99 | sampleTiming: timingInfo
100 | )
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/Sources/View+Changes.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2022 • Sidetrack Tech Limited
3 | //
4 |
5 | import SwiftUI
6 |
7 | public extension View {
8 | /// When the user uses the play/pause button inside the picture-in-picture window, the provided closure is called.
9 | ///
10 | /// The `Bool` is true if playing, else paused.
11 | @warn_unqualified_access
12 | func onPipPlayPause(closure: @escaping (Bool) -> Void) -> some View {
13 | modifier(PipifyPlayPauseModifier(closure: closure))
14 | }
15 |
16 | /// When the user uses the skip forward/backward button inside the picture-in-picture window, the provided closure is called.
17 | ///
18 | /// The `Bool` is true if forward, else backwards.
19 | @warn_unqualified_access
20 | func onPipSkip(closure: @escaping (Bool) -> Void) -> some View {
21 | modifier(PipifySkipModifier(closure: closure))
22 | }
23 |
24 | /// When picture-in-picture is started, the provided closure is called.
25 | @warn_unqualified_access
26 | func onPipStart(closure: @escaping () -> Void) -> some View {
27 | modifier(PipifyStatusModifier(closure: { newValue in
28 | if newValue {
29 | closure()
30 | }
31 | }))
32 | }
33 |
34 | /// When picture-in-picture is stopped, the provided closure is called.
35 | @warn_unqualified_access
36 | func onPipStop(closure: @escaping () -> Void) -> some View {
37 | modifier(PipifyStatusModifier(closure: { newValue in
38 | if newValue == false {
39 | closure()
40 | }
41 | }))
42 | }
43 |
44 | /// When the render size of the picture-in-picture window is changed, the provided closure is called.
45 | @warn_unqualified_access
46 | func onPipRenderSizeChanged(closure: @escaping (CGSize) -> Void) -> some View {
47 | modifier(PipifyRenderSizeModifier(closure: closure))
48 | }
49 |
50 | /// When the application is moved to the foreground, and if picture-in-picture is active, stop it.
51 | @warn_unqualified_access
52 | func pipHideOnForeground() -> some View {
53 | modifier(PipifyForegroundModifier())
54 | }
55 |
56 | /// When the application is moved to the background, activate picture-in-picture.
57 | @warn_unqualified_access
58 | func pipShowOnBackground() -> some View {
59 | modifier(PipifyBackgroundModifier())
60 | }
61 |
62 | /// Provides a binding to a double whose value is used to update the progress bar in the picture-in-picture window.
63 | @warn_unqualified_access
64 | func pipBindProgress(progress: Binding) -> some View {
65 | modifier(PipifyProgressModifier(progress: progress))
66 | }
67 | }
68 |
69 | internal struct PipifyPlayPauseModifier: ViewModifier {
70 | @EnvironmentObject var controller: PipifyController
71 | let closure: (Bool) -> Void
72 |
73 | func body(content: Content) -> some View {
74 | content
75 | .task {
76 | controller.isPlayPauseEnabled = true
77 | }
78 | .onChange(of: controller.isPlaying) { newValue in
79 | closure(newValue)
80 | }
81 | }
82 | }
83 |
84 | internal struct PipifyRenderSizeModifier: ViewModifier {
85 | @EnvironmentObject var controller: PipifyController
86 | let closure: (CGSize) -> Void
87 |
88 | func body(content: Content) -> some View {
89 | content
90 | .onChange(of: controller.renderSize) { newValue in
91 | closure(newValue)
92 | }
93 | }
94 | }
95 |
96 | internal struct PipifyStatusModifier: ViewModifier {
97 | @EnvironmentObject var controller: PipifyController
98 | let closure: (Bool) -> Void
99 |
100 | func body(content: Content) -> some View {
101 | content
102 | .onChange(of: controller.enabled) { newValue in
103 | closure(newValue)
104 | }
105 | }
106 | }
107 |
108 | internal struct PipifySkipModifier: ViewModifier {
109 | @EnvironmentObject var controller: PipifyController
110 | let closure: (Bool) -> Void
111 |
112 | func body(content: Content) -> some View {
113 | content
114 | .task {
115 | controller.onSkip = { value in
116 | closure(value > 0) // isForward
117 | }
118 | }
119 | }
120 | }
121 |
122 | internal struct PipifyBackgroundModifier: ViewModifier {
123 | @EnvironmentObject var controller: PipifyController
124 | @Environment(\.scenePhase) var scenePhase
125 |
126 | func body(content: Content) -> some View {
127 | content
128 | .onChange(of: scenePhase) { newPhase in
129 | if newPhase == .background {
130 | controller.isPlaying = true
131 | }
132 | }
133 | }
134 | }
135 |
136 | internal struct PipifyForegroundModifier: ViewModifier {
137 | @EnvironmentObject var controller: PipifyController
138 | @Environment(\.scenePhase) var scenePhase
139 |
140 | func body(content: Content) -> some View {
141 | content
142 | .onChange(of: scenePhase) { newPhase in
143 | if newPhase == .active {
144 | controller.isPlaying = false
145 | }
146 | }
147 | }
148 | }
149 |
150 | internal struct PipifyProgressModifier: ViewModifier {
151 | @EnvironmentObject var controller: PipifyController
152 | @Binding var progress: Double
153 |
154 | func body(content: Content) -> some View {
155 | content
156 | .onChange(of: progress) { newProgress in
157 | assert(newProgress >= 0 && newProgress <= 1, "progress value must be between 0 and 1")
158 | controller.progress = newProgress.clamped(to: 0...1)
159 | }
160 | }
161 | }
162 |
163 | extension Comparable {
164 | func clamped(to limits: ClosedRange) -> Self {
165 | return min(max(self, limits.lowerBound), limits.upperBound)
166 | }
167 | }
168 |
--------------------------------------------------------------------------------
/Sources/View+Pipify.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Copyright 2022 • Sidetrack Tech Limited
3 | //
4 |
5 | import SwiftUI
6 |
7 | public extension View {
8 |
9 | /// Creates a Picture in Picture experience when given a SwiftUI view. This allows a view of your application to be presented over the top of the application
10 | /// window and even when your application is in the background.
11 | ///
12 | /// - Parameters:
13 | /// - isPresented: A binding to a boolean which determines when the Picture in Picture controller should be presented.
14 | /// - content: A closure which returns the view you wish to present in the Picture in Picture controller.
15 | @warn_unqualified_access
16 | func pipify(
17 | isPresented: Binding,
18 | content: @escaping () -> PipView
19 | ) -> some View {
20 | modifier(PipifyModifier(
21 | isPresented: isPresented,
22 | pipContent: content,
23 | offscreenRendering: true
24 | ))
25 | }
26 |
27 | /// Creates a Picture in Picture experience using the current view. This allows the view to be presented over the top of the application window and even when
28 | /// your application is in the background.
29 | ///
30 | /// - Parameters:
31 | /// - isPresented: A binding to a boolean which determines when the Picture in Picture controller should be presented.
32 | @warn_unqualified_access
33 | func pipify(
34 | isPresented: Binding
35 | ) -> some View {
36 | modifier(PipifyModifier(
37 | isPresented: isPresented,
38 | pipContent: { self },
39 | offscreenRendering: false
40 | ))
41 | }
42 |
43 | }
44 |
45 | /// Makes the Pipify view modifier available to Xcode's library allowing for improved auto-complete and discoverability.
46 | ///
47 | /// Reference: https://useyourloaf.com/blog/adding-views-and-modifiers-to-the-xcode-library/
48 | struct PipifyLibrary: LibraryContentProvider {
49 | @State var isPresented: Bool = false
50 |
51 | @LibraryContentBuilder
52 | func modifiers(base: any View) -> [LibraryItem] {
53 | LibraryItem(base.pipify(isPresented: $isPresented), title: "Pipify Embedded View")
54 | LibraryItem(base.pipify(isPresented: $isPresented) { Text("Hello, world!") }, title: "Pipify External View")
55 | }
56 | }
57 |
--------------------------------------------------------------------------------