Skip to content

Commit f25da4c

Browse files
daytime-emrefactornatorandrewjl-mux
authored
doc: Example App v1 (#15)
* Add FakeBackend skeleton * Add the fake backend rest objects * Do the upload backend * Some widgets and colors * Styles or views * Some comments * Add prototype to nav * Ok * Minor indentation fixes * Minor formatting fix * I have mastered your puny buttons * Figuring it out * Ok now we're getting somewhere hypothetically * one last update * Don't need an environment object * Start with the pick flow * I guess we don't need the width * And a little tweaking * Upload CTA can be smarter than this * now for a view model * Move over permission request * Permission Request Success * No crash probs * up * Now to extract a thumbnail * Thumbnail extraction test * Ready to add other states * Error View * Do Processing View * now to hook it up * This thumbnail sucks * Ok now the thumbnail is in at least * Try to post an upload * Working authorization * Add some more headers for the fake backend * Add credentials but the data is off * Fake backend now works * Still works * TODO done * Time to start uploading * Fix bug in forceRestart * come back to that * Progress updates look broken * Fix the upload POST body * Cool regression * Debug * Fix the thumbnail rendering * Now what * Add listener for Uploads Updated * Auto acknowledge uploads * not time for nav * Rearrange the Upload CTA stuff * Updating more stuff * now we're navigating * Figuring it out * Time to start on the upload list * Added more listy stuff * list container * Now it works * Empty list * Hashable * Notify delegates but something doesn't seem to be working * Now working it out * Making it work * Thumbnail in the list items maybe * OK I got it * Figured out thumbnails * Work on it * Work * So far so good * ugh * Making progress * Progress View * Ok * Now we're talking * Ok now we have a status line * Now we are good * Appropriately scope the create-upload viewmodel * Now we are getting to the end * Save old uploads for a while * ont an error * Remove old example UI * cleanup * project * Remove another old UI component * Yknow this isn't really mvvm to begin with * Label cleanup * oh dang forgot the app icon * oh wow that didn't build * Add a Mux Asset catalog * Add Mux assets to a catalog * CTA vis * The list needs to actually scroll * Rename *Screen to *View * Use task instead of onAppear * Don't commit those * Fix permission prompt * AnyObject * Brackets * Update Sources/MuxUploadSDK/Public API/UploadManager.swift Co-authored-by: AJ Lauer Barinov <[email protected]> * Generics --------- Co-authored-by: Liam Lindner <[email protected]> Co-authored-by: AJ Lauer Barinov <[email protected]>
1 parent edfb544 commit f25da4c

File tree

39 files changed

+1880
-394
lines changed

39 files changed

+1880
-394
lines changed

Sources/MuxUploadSDK/Internal Utilities/ChunkedFile.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ class ChunkedFile {
2828

2929
/// Reads the next chunk from the file, advancing the file for the next read
3030
/// This method does synchronous I/O, so call it in the background
31-
public func readNextChunk() -> Result<FileChunk, Error> {
31+
func readNextChunk() -> Result<FileChunk, Error> {
3232
MuxUploadSDK.logger?.info("--readNextChunk(): called")
3333
do {
3434
guard fileHandle != nil else {

Sources/MuxUploadSDK/Public API/MuxUpload.swift

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ import Foundation
1212

1313
TODO: usage here
1414
*/
15-
public final class MuxUpload {
16-
15+
public final class MuxUpload : Hashable, Equatable {
16+
1717
private let uploadInfo: UploadInfo
1818
private let manageBySDK: Bool
1919
private let id: Int
@@ -26,7 +26,7 @@ public final class MuxUpload {
2626
/**
2727
Represents the state of an upload in progress.
2828
*/
29-
public struct Status : Sendable {
29+
public struct Status : Sendable, Hashable {
3030
public let progress: Progress?
3131
public let updatedTime: TimeInterval
3232
public let startTime: TimeInterval
@@ -53,7 +53,7 @@ public final class MuxUpload {
5353
public convenience init(
5454
uploadURL: URL,
5555
videoFileURL: URL,
56-
chunkSize: Int = 8 * 1024 * 1024, // Google recommends *at least* 8M,
56+
chunkSize: Int = 8 * 1024 * 1024, // Google recommends at least 8M
5757
retriesPerChunk: Int = 3,
5858
optOutOfEventTracking: Bool = false
5959
) {
@@ -82,7 +82,7 @@ public final class MuxUpload {
8282
*/
8383
public var progressHandler: StateHandler?
8484

85-
public struct Success : Sendable {
85+
public struct Success : Sendable, Hashable {
8686
public let finalState: Status
8787
}
8888

@@ -105,7 +105,12 @@ public final class MuxUpload {
105105
/**
106106
True if this upload is currently in progress and not paused
107107
*/
108-
var inProgress: Bool { get { fileWorker != nil } }
108+
public var inProgress: Bool { get { fileWorker != nil && !complete } }
109+
110+
/**
111+
True if this upload was completed
112+
*/
113+
public var complete: Bool { get { lastSeenStatus.progress?.completedUnitCount ?? 0 > 0 && lastSeenStatus.progress?.fractionCompleted ?? 0 >= 1.0 } }
109114

110115
/**
111116
URL to the file that will be uploaded
@@ -126,12 +131,12 @@ public final class MuxUpload {
126131
// See if there's anything in progress already
127132
fileWorker = uploadManager.findUploaderFor(videoFile: videoFile)
128133
}
129-
guard !forceRestart && fileWorker == nil else {
134+
if fileWorker != nil && !forceRestart {
130135
MuxUploadSDK.logger?.warning("start() called but upload is in progress")
131136
return
132137
}
133138
if forceRestart {
134-
fileWorker?.cancel()
139+
cancel()
135140
}
136141
let completedUnitCount = UInt64({ self.lastSeenStatus.progress?.completedUnitCount ?? 0 }())
137142
let fileWorker = ChunkedFileUploader(uploadInfo: uploadInfo, startingAtByte: completedUnitCount)
@@ -140,6 +145,7 @@ public final class MuxUpload {
140145
InternalUploaderDelegate { [weak self] state in self?.handleStateUpdate(state) }
141146
)
142147
fileWorker.start()
148+
uploadManager.registerUploader(fileWorker, withId: id)
143149
self.fileWorker = fileWorker
144150
}
145151

@@ -154,7 +160,7 @@ public final class MuxUpload {
154160
}
155161

156162
/**
157-
Cancels an ongoing download. Temp files will be deleted asynchronously. State and Delegates will be cleared. Your delegates will recieve no further calls
163+
Cancels an ongoing download. State and Delegates will be cleared. Your delegates will recieve no further calls
158164
*/
159165
public func cancel() {
160166
fileWorker?.cancel()
@@ -207,6 +213,17 @@ public final class MuxUpload {
207213
}
208214
}
209215

216+
public static func == (lhs: MuxUpload, rhs: MuxUpload) -> Bool {
217+
lhs.videoFile == rhs.videoFile
218+
&& lhs.uploadURL == rhs.uploadURL
219+
}
220+
221+
public func hash(into hasher: inout Hasher) {
222+
hasher.combine(videoFile)
223+
hasher.combine(uploadURL)
224+
}
225+
226+
210227
private init (uploadInfo: UploadInfo, manage: Bool = true, uploadManager: UploadManager) {
211228
self.uploadInfo = uploadInfo
212229
self.manageBySDK = manage
@@ -220,7 +237,7 @@ public final class MuxUpload {
220237

221238
handleStateUpdate(uploader.currentState)
222239
uploader.addDelegate(
223-
withToken: id,
240+
withToken: self.id,
224241
InternalUploaderDelegate { [weak self] state in self?.handleStateUpdate(state) }
225242
)
226243
}

Sources/MuxUploadSDK/Public API/UploadManager.swift

Lines changed: 48 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@ import Foundation
2222
public final class UploadManager {
2323

2424
private var uploadersByURL: [URL : ChunkedFileUploader] = [:]
25+
private var uploadsUpdateDelegatesByToken: [ObjectIdentifier : any UploadsUpdatedDelegate] = [:]
2526
private let uploadActor = UploadCacheActor()
26-
private lazy var uploaderDelegate: FileUploaderDelegate = FileUploaderDelegate(onBehalfOf: self)
27+
private lazy var uploaderDelegate: FileUploaderDelegate = FileUploaderDelegate(manager: self)
2728

2829
/// Finds an upload already in-progress and returns a new ``MuxUpload`` that can be observed
2930
/// to track and control its state
@@ -43,19 +44,6 @@ public final class UploadManager {
4344
return uploadersByURL.compactMap { (key, value) in MuxUpload(wrapping: value, uploadManager: self) }
4445
}
4546

46-
/// Call to remove a finished upload from the manger (so it will no longer be returned by other methods in this class).
47-
/// If it was in-progress, it will be canceled
48-
public func acknowledgeUpload(ofFile url: URL) {
49-
if let uploader = uploadersByURL[url] {
50-
uploader.cancel()
51-
}
52-
uploadersByURL.removeValue(forKey: url)
53-
Task.detached {
54-
await self.uploadActor.remove(uploadAt:url)
55-
}
56-
}
57-
58-
5947
/// Attempts to resume an upload that was previously paused or interrupted by process death
6048
/// If no upload was found in the cache, this method returns null without taking any action
6149
public func resumeUpload(ofFile: URL) async -> MuxUpload? {
@@ -89,18 +77,51 @@ public final class UploadManager {
8977
}
9078
}
9179

80+
/// Adds an ``UploadsUpdatedDelegate`` You can add as many of these as you like
81+
public func addUploadsUpdatedDelegate<Delegate: UploadsUpdatedDelegate>(_ delegate: Delegate) {
82+
uploadsUpdateDelegatesByToken[ObjectIdentifier(delegate)] = delegate
83+
}
84+
85+
/// Removes an ``UploadsUpdatedDelegate``
86+
public func removeUploadsUpdatedDelegate<Delegate: UploadsUpdatedDelegate>(_ delegate: Delegate) {
87+
uploadsUpdateDelegatesByToken.removeValue(forKey: ObjectIdentifier(delegate))
88+
}
89+
90+
internal func acknowledgeUpload(ofFile url: URL) {
91+
if let uploader = uploadersByURL[url] {
92+
uploader.cancel()
93+
}
94+
uploadersByURL.removeValue(forKey: url)
95+
Task.detached {
96+
await self.uploadActor.remove(uploadAt:url)
97+
self.notifyDelegates()
98+
}
99+
}
100+
92101
internal func findUploaderFor(videoFile url: URL) -> ChunkedFileUploader? {
93102
return uploadersByURL[url]
94103
}
95104

96105
internal func registerUploader(_ fileWorker: ChunkedFileUploader, withId id: Int) {
97106
uploadersByURL.updateValue(fileWorker, forKey: fileWorker.uploadInfo.videoFile)
98-
fileWorker.addDelegate(withToken: id, uploaderDelegate)
107+
fileWorker.addDelegate(withToken: id + 1, uploaderDelegate)
99108
Task.detached {
100109
await self.uploadActor.updateUpload(
101110
fileWorker.uploadInfo,
102111
withUpdate: fileWorker.currentState
103112
)
113+
self.notifyDelegates()
114+
}
115+
}
116+
117+
private func notifyDelegates() {
118+
Task.detached {
119+
await MainActor.run {
120+
self.uploadsUpdateDelegatesByToken
121+
.map { it in it.value }
122+
.forEach { it in it.uploadListUpdated(with: self.allManagedUploads()) }
123+
124+
}
104125
}
105126
}
106127

@@ -109,21 +130,27 @@ public final class UploadManager {
109130
private init() { }
110131

111132
private struct FileUploaderDelegate : ChunkedFileUploaderDelegate {
112-
let onBehalfOf: UploadManager
133+
let manager: UploadManager
113134

114135
func chunkedFileUploader(_ uploader: ChunkedFileUploader, stateUpdated state: ChunkedFileUploader.InternalUploadState) {
136+
Task.detached {
137+
await manager.uploadActor.updateUpload(uploader.uploadInfo, withUpdate: state)
138+
manager.notifyDelegates()
139+
}
115140
switch state {
116-
case .canceled: onBehalfOf.acknowledgeUpload(ofFile: uploader.uploadInfo.videoFile)
141+
case .success(_), .canceled: manager.acknowledgeUpload(ofFile: uploader.uploadInfo.videoFile)
117142
default: do { }
118143
}
119-
120-
Task.detached {
121-
await onBehalfOf.uploadActor.updateUpload(uploader.uploadInfo, withUpdate: state)
122-
}
123144
}
124145
}
125146
}
126147

148+
/// A delegate that handles changes to the list of active uploads
149+
public protocol UploadsUpdatedDelegate: AnyObject {
150+
func uploadListUpdated(with list: [MuxUpload])
151+
}
152+
153+
127154
/// Isolates/synchronizes multithreaded access to the upload cache.
128155
internal actor UploadCacheActor {
129156
private let persistence: UploadPersistence

apps/Test App/Test App/Assets.xcassets/AppIcon.appiconset/Contents.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"images" : [
33
{
4+
"filename" : "Upload-App-Icon.png",
45
"idiom" : "universal",
56
"platform" : "ios",
67
"size" : "1024x1024"
16.9 KB
Loading

apps/Test App/Test App/ContentView.swift

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,35 @@ import PhotosUI
99
import MuxUploadSDK
1010

1111
struct ContentView: View {
12+
@EnvironmentObject var uploadListModel: UploadListModel
13+
1214
var body: some View {
13-
UploadScreen()
15+
NavigationView {
16+
ZStack(alignment: .bottomTrailing) {
17+
UploadListScreen()
18+
NavigationLink {
19+
CreateUploadView()
20+
.navigationBarHidden(true)
21+
} label : {
22+
if !uploadListModel.lastKnownUploads.isEmpty {
23+
ZStack {
24+
Image("Mux-y Add")
25+
.padding()
26+
.background(Green50.clipShape(Circle()))
27+
}
28+
.padding(24.0)
29+
}
30+
}
31+
}
32+
}
33+
.preferredColorScheme(.dark)
1434
}
1535
}
1636

1737
struct ContentView_Previews: PreviewProvider {
1838
static var previews: some View {
1939
ContentView()
40+
.environmentObject(UploadCreationModel())
41+
.environmentObject(UploadListModel())
2042
}
2143
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
//
2+
// FakeBackend.swift
3+
// Test App
4+
//
5+
// Created by Emily Dixon on 5/8/23.
6+
//
7+
8+
import Foundation
9+
10+
/// This class "fakes" the server backend necessary to compvare an upload workflow.
11+
/// In your production use case, a backend server should take care of creating upload URLs
12+
///
13+
/// **You should never build Mux server API ceredentials into a real app**. We do it in this example for brevity only
14+
class FakeBackend {
15+
16+
func createDirectUpload() async throws -> URL {
17+
let request = try {
18+
var req = try URLRequest(url:fullURL(forEndpoint: "uploads"))
19+
req.httpBody = try jsonEncoder.encode(CreateUploadPost())
20+
req.httpMethod = "POST"
21+
req.addValue("application/json", forHTTPHeaderField: "Content-Type")
22+
req.addValue("application/json", forHTTPHeaderField: "accept")
23+
24+
let basicAuthCredential = "\(MUX_ACCESS_TOKEN_ID):\(MUX_ACCESS_SECRET_KEY)".data(using: .utf8)!.base64EncodedString()
25+
req.addValue("Basic \(basicAuthCredential)", forHTTPHeaderField: "Authorization")
26+
27+
return req
28+
}()
29+
30+
let (data, response) = try await urlSession.data(for: request)
31+
let httpResponse = response as! HTTPURLResponse
32+
if (200...299).contains(httpResponse.statusCode) {
33+
let responseData = try jsonDecoder.decode(CreateUploadResponseContainer.self, from: data).data
34+
guard let uploadURL = URL(string:responseData.url) else {
35+
throw CreateUploadError(message: "invalid upload url")
36+
}
37+
return uploadURL
38+
} else {
39+
self.logger.error("Upload POST failed: HTTP \(httpResponse.statusCode):\n\(String(decoding: data, as: UTF8.self))")
40+
throw CreateUploadError(message: "Upload POST failed: HTTP \(httpResponse.statusCode):\n\(String(decoding: data, as: UTF8.self))")
41+
}
42+
}
43+
44+
/// Generates a full URL for a given endpoint in the Mux Video public API
45+
private func fullURL(forEndpoint: String) throws -> URL {
46+
guard let url = URL(string: "https://api.mux.com/video/v1/\(forEndpoint)") else {
47+
throw CreateUploadError(message: "bad endpoint")
48+
}
49+
return url
50+
}
51+
52+
private let logger = Test_AppApp.logger
53+
54+
let urlSession: URLSession
55+
let jsonEncoder: JSONEncoder
56+
let jsonDecoder: JSONDecoder
57+
58+
let MUX_ACCESS_TOKEN_ID = "YOUR ACCESS TOKEN ID HERE"
59+
let MUX_ACCESS_SECRET_KEY = "YOUR SECRET KEY HERE"
60+
61+
init(urlSession: URLSession) {
62+
self.urlSession = urlSession
63+
self.jsonEncoder = JSONEncoder()
64+
self.jsonEncoder.keyEncodingStrategy = JSONEncoder.KeyEncodingStrategy.convertToSnakeCase
65+
self.jsonDecoder = JSONDecoder()
66+
self.jsonDecoder.keyDecodingStrategy = JSONDecoder.KeyDecodingStrategy.convertFromSnakeCase
67+
}
68+
69+
convenience init() {
70+
self.init(urlSession: URLSession(configuration: URLSessionConfiguration.default))
71+
}
72+
}
73+
74+
struct CreateUploadError : Error {
75+
let message: String
76+
}
77+
78+
79+
fileprivate struct CreateUploadPost: Codable {
80+
var newAssetSettings: NewAssetSettings = NewAssetSettings()
81+
var corsOrigin: String = "*"
82+
}
83+
84+
fileprivate struct NewAssetSettings: Codable {
85+
var playbackPolicy: [String] = ["public"]
86+
var passthrough: String = "Extra video data. This can be any data and it's for your use"
87+
var mp4Support: String = "standard"
88+
var normalizeAudio: Bool = true
89+
var test: Bool = false
90+
}
91+
92+
fileprivate struct CreateUploadResponse: Decodable {
93+
var url: String
94+
var id: String
95+
var timeout: Int64
96+
var status: String
97+
}
98+
99+
fileprivate struct CreateUploadResponseContainer: Decodable {
100+
var data: CreateUploadResponse
101+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"info" : {
3+
"author" : "xcode",
4+
"version" : 1
5+
}
6+
}

0 commit comments

Comments
 (0)