diff --git a/README.md b/README.md index 64133aae..79c422b7 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,10 @@ If you want to edit the functions that have been deployed to Twilio Serverless, 1. Enter passcode from the [backend deploy](#deploy-the-app-to-twilio) and tap `Continue`. 1. Tap `Create Event` to host a new stream or `Join Event` to join a stream as a viewer or a speaker. +## Recordings + +The event host has the option to enable recording in the app UI when they create the event. A recording will be available shortly after the event ends. To view a list of all recordings, run `npm run recordings`. + ## Reference Backend The API for the reference backend used by the clients is specified [here](ReferenceBackendAPI.md). diff --git a/ReferenceBackendAPI.md b/ReferenceBackendAPI.md index ae7cefff..de2ac4d0 100644 --- a/ReferenceBackendAPI.md +++ b/ReferenceBackendAPI.md @@ -58,7 +58,8 @@ Request parameters: ```json { "user_identity": "Bob", - "stream_name": "demo" + "stream_name": "demo", + "record_stream": true } ``` diff --git a/apps/ios/LiveVideo/LiveVideo.xcodeproj/project.pbxproj b/apps/ios/LiveVideo/LiveVideo.xcodeproj/project.pbxproj index 68fae40f..bdd1b3d8 100644 --- a/apps/ios/LiveVideo/LiveVideo.xcodeproj/project.pbxproj +++ b/apps/ios/LiveVideo/LiveVideo.xcodeproj/project.pbxproj @@ -76,6 +76,8 @@ DCB1BE0B27581C11006CE9D1 /* Nimble in Frameworks */ = {isa = PBXBuildFile; productRef = DCB1BE0A27581C11006CE9D1 /* Nimble */; }; DCB4497426F5496400B52774 /* SpeakerSettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCB4497326F5496400B52774 /* SpeakerSettingsManager.swift */; }; DCB4497626F65A7000B52774 /* SpeakerVideoViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCB4497526F65A7000B52774 /* SpeakerVideoViewModel.swift */; }; + DCB8FA7B27EE17E00001EEB1 /* SyncStreamDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCB8FA7A27EE17DF0001EEB1 /* SyncStreamDocument.swift */; }; + DCB8FA8127EE51630001EEB1 /* RecordingBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCB8FA8027EE51630001EEB1 /* RecordingBadge.swift */; }; DCC1D62626D9396400892038 /* StreamManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC1D62526D9396400892038 /* StreamManager.swift */; }; DCC1D62826D9568700892038 /* StreamConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC1D62726D9568700892038 /* StreamConfig.swift */; }; DCC1D62A26D9643400892038 /* FormStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC1D62926D9643400892038 /* FormStack.swift */; }; @@ -184,6 +186,8 @@ DCB1BE042756D954006CE9D1 /* EnterPasscodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnterPasscodeView.swift; sourceTree = ""; }; DCB4497326F5496400B52774 /* SpeakerSettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeakerSettingsManager.swift; sourceTree = ""; }; DCB4497526F65A7000B52774 /* SpeakerVideoViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeakerVideoViewModel.swift; sourceTree = ""; }; + DCB8FA7A27EE17DF0001EEB1 /* SyncStreamDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncStreamDocument.swift; sourceTree = ""; }; + DCB8FA8027EE51630001EEB1 /* RecordingBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingBadge.swift; sourceTree = ""; }; DCC1D62526D9396400892038 /* StreamManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamManager.swift; sourceTree = ""; }; DCC1D62726D9568700892038 /* StreamConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamConfig.swift; sourceTree = ""; }; DCC1D62926D9643400892038 /* FormStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormStack.swift; sourceTree = ""; }; @@ -259,6 +263,7 @@ children = ( DC175F2C270E232F00D9D1FE /* SyncManager.swift */, DC175F3D2717530D00D9D1FE /* SyncObjectConnecting.swift */, + DCB8FA7A27EE17DF0001EEB1 /* SyncStreamDocument.swift */, DC175F31270E23F900D9D1FE /* SyncUserDocument.swift */, DC175F33270E3FD200D9D1FE /* SyncUsersMap.swift */, ); @@ -472,6 +477,7 @@ DCCA726E27B590F6006B0441 /* DisplayNameFactory.swift */, DC1ED0EF26DFCD3C00FFA769 /* LiveBadge.swift */, DC9E18382729DC740070F53F /* OffscreenSpeakersView.swift */, + DCB8FA8027EE51630001EEB1 /* RecordingBadge.swift */, DC1ED12F26E960C600FFA769 /* SpeakerGridView.swift */, DC504A7426F0F18600C37EC9 /* SpeakerGridViewModel.swift */, DC504A7626F1468E00C37EC9 /* SpeakerVideoView.swift */, @@ -720,11 +726,13 @@ DC1ED0FD26E14D0B00FFA769 /* StreamToolbar.swift in Sources */, DC1ED0F926E1440200FFA769 /* StreamStatusView.swift in Sources */, DC504A7726F1468E00C37EC9 /* SpeakerVideoView.swift in Sources */, + DCB8FA7B27EE17E00001EEB1 /* SyncStreamDocument.swift in Sources */, DC175F34270E3FD200D9D1FE /* SyncUsersMap.swift in Sources */, DCC48FA326D8322900EE49EF /* SwiftUIPlayerView.swift in Sources */, DC4F45E92723135500BE730B /* CardButtonLabel.swift in Sources */, DCADF33927FCA7780093A9FE /* TitleValueView.swift in Sources */, DC1ED0F726E12C7400FFA769 /* ProgressHUD.swift in Sources */, + DCB8FA8127EE51630001EEB1 /* RecordingBadge.swift in Sources */, DC175F32270E23F900D9D1FE /* SyncUserDocument.swift in Sources */, DCC1D62626D9396400892038 /* StreamManager.swift in Sources */, DCCA727127B5BFE8006B0441 /* PresentationStatusView.swift in Sources */, diff --git a/apps/ios/LiveVideo/LiveVideo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apps/ios/LiveVideo/LiveVideo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 242fd007..9e930af4 100644 --- a/apps/ios/LiveVideo/LiveVideo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/apps/ios/LiveVideo/LiveVideo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -60,8 +60,8 @@ "repositoryURL": "https://github.com/twilio/twilio-live-player-ios", "state": { "branch": null, - "revision": "7c4827e0bdb3b935aecdce9fa3c1b3c42ba60dd5", - "version": "1.0.1" + "revision": "97dff98041229cfe90c9d2e5b930c836279a9a48", + "version": "1.1.0" } }, { diff --git a/apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Colors/RecordingDotBright.colorset/Contents.json b/apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Colors/RecordingDotBright.colorset/Contents.json new file mode 100644 index 00000000..90d55e10 --- /dev/null +++ b/apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Colors/RecordingDotBright.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.000", + "green" : "0.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Colors/RecordingDotDark.colorset/Contents.json b/apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Colors/RecordingDotDark.colorset/Contents.json new file mode 100644 index 00000000..34bd757a --- /dev/null +++ b/apps/ios/LiveVideo/LiveVideo/Assets.xcassets/Colors/RecordingDotDark.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.000", + "green" : "0.000", + "red" : "0.663" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/LiveVideo/LiveVideo/Helpers/LiveVideoError.swift b/apps/ios/LiveVideo/LiveVideo/Helpers/LiveVideoError.swift index 959e904b..df0344d2 100644 --- a/apps/ios/LiveVideo/LiveVideo/Helpers/LiveVideoError.swift +++ b/apps/ios/LiveVideo/LiveVideo/Helpers/LiveVideoError.swift @@ -7,9 +7,11 @@ import Foundation enum LiveVideoError: Error { case backendError(message: String) case passcodeIncorrect + case recordError(message: String) case speakerMovedToViewersByHost case streamEndedByHost case syncClientConnectionFatalError + case syncObjectDecodeError case syncTokenExpired } @@ -18,9 +20,11 @@ extension LiveVideoError: LocalizedError { switch self { case let .backendError(message): return message case .passcodeIncorrect: return "Passcode incorrect." + case let .recordError(message): return message case .speakerMovedToViewersByHost: return "Speaker moved to viewers by host." case .streamEndedByHost: return "Event ended by host." case .syncClientConnectionFatalError: return "Sync client connection fatal error." + case .syncObjectDecodeError: return "Sync object decode error." case .syncTokenExpired: return "Sync token expired." } } diff --git a/apps/ios/LiveVideo/LiveVideo/Launch/LiveVideoApp.swift b/apps/ios/LiveVideo/LiveVideo/Launch/LiveVideoApp.swift index 92c8eb5a..e998b0a1 100644 --- a/apps/ios/LiveVideo/LiveVideo/Launch/LiveVideoApp.swift +++ b/apps/ios/LiveVideo/LiveVideo/Launch/LiveVideoApp.swift @@ -16,6 +16,7 @@ struct LiveVideoApp: App { @StateObject private var speakerGridViewModel = SpeakerGridViewModel() @StateObject private var presentationLayoutViewModel = PresentationLayoutViewModel() @StateObject private var api = API() + @StateObject private var streamDocument = SyncStreamDocument() @StateObject private var appSettingsManager = AppSettingsManager() var body: some Scene { @@ -30,6 +31,7 @@ struct LiveVideoApp: App { .environmentObject(speakerSettingsManager) .environmentObject(hostControlsManager) .environmentObject(api) + .environmentObject(streamDocument) .environmentObject(appSettingsManager) .onAppear { authManager.configure(api: api, appSettingsManager: appSettingsManager) @@ -37,15 +39,16 @@ struct LiveVideoApp: App { let roomManager = RoomManager() roomManager.configure(localParticipant: localParticipant) let userDocument = SyncUserDocument() - let speakersMap = SyncUsersMap() - let raisedHandsMap = SyncUsersMap() - let viewersMap = SyncUsersMap() + let speakersMap = SyncUsersMap(uniqueName: "speakers") + let raisedHandsMap = SyncUsersMap(uniqueName: "raised_hands") + let viewersMap = SyncUsersMap(uniqueName: "viewers") let speakerVideoViewModelFactory = SpeakerVideoViewModelFactory() let syncManager = SyncManager( speakersMap: speakersMap, viewersMap: viewersMap, raisedHandsMap: raisedHandsMap, userDocument: userDocument, + streamDocument: streamDocument, appSettingsManager: appSettingsManager ) streamManager.configure( @@ -59,7 +62,8 @@ struct LiveVideoApp: App { streamManager: streamManager, speakerSettingsManager: speakerSettingsManager, api: api, - userDocument: userDocument + userDocument: userDocument, + streamDocument: streamDocument ) participantsViewModel.configure( streamManager: streamManager, diff --git a/apps/ios/LiveVideo/LiveVideo/Managers/API/CreateOrJoinStreamRequest.swift b/apps/ios/LiveVideo/LiveVideo/Managers/API/CreateOrJoinStreamRequest.swift index 25547d77..e14c0ecb 100644 --- a/apps/ios/LiveVideo/LiveVideo/Managers/API/CreateOrJoinStreamRequest.swift +++ b/apps/ios/LiveVideo/LiveVideo/Managers/API/CreateOrJoinStreamRequest.swift @@ -8,26 +8,19 @@ struct CreateOrJoinStreamRequest: APIRequest { struct Parameters: Encodable { let userIdentity: String let streamName: String + let recordStream: Bool? } struct Response: Decodable { - struct SyncObjectNames: Decodable { - let speakersMap: String - let viewersMap: String - let raisedHandsMap: String - let userDocument: String? - } - let token: String - let syncObjectNames: SyncObjectNames } let path: String let parameters: Parameters let responseType = Response.self - init(userIdentity: String, streamName: String, role: StreamConfig.Role) { - parameters = Parameters(userIdentity: userIdentity, streamName: streamName) + init(userIdentity: String, streamName: String, role: StreamConfig.Role, recordStream: Bool?) { + parameters = Parameters(userIdentity: userIdentity, streamName: streamName, recordStream: recordStream) path = role.path } } diff --git a/apps/ios/LiveVideo/LiveVideo/Twilio/Room/LocalParticipantManager.swift b/apps/ios/LiveVideo/LiveVideo/Twilio/Room/LocalParticipantManager.swift index dd38ff26..a29dfa29 100644 --- a/apps/ios/LiveVideo/LiveVideo/Twilio/Room/LocalParticipantManager.swift +++ b/apps/ios/LiveVideo/LiveVideo/Twilio/Room/LocalParticipantManager.swift @@ -87,8 +87,8 @@ class LocalParticipantManager: NSObject { private(set) var micTrack: LocalAudioTrack? private(set) var cameraTrack: LocalVideoTrack? private let app = UIApplication.shared + private let authManager: AuthManager private var cameraSource: CameraSource? - private var authManager: AuthManager init(authManager: AuthManager) { self.authManager = authManager diff --git a/apps/ios/LiveVideo/LiveVideo/Twilio/Stream/StreamConfig.swift b/apps/ios/LiveVideo/LiveVideo/Twilio/Stream/StreamConfig.swift index f86bb209..223d9526 100644 --- a/apps/ios/LiveVideo/LiveVideo/Twilio/Stream/StreamConfig.swift +++ b/apps/ios/LiveVideo/LiveVideo/Twilio/Stream/StreamConfig.swift @@ -15,5 +15,15 @@ struct StreamConfig { let streamName: String let userIdentity: String + let shouldRecord: Bool? var role: Role + + var hasUserDocument: Bool { + switch role { + case .host: + return false + case .speaker, .viewer: + return true + } + } } diff --git a/apps/ios/LiveVideo/LiveVideo/Twilio/Stream/StreamManager.swift b/apps/ios/LiveVideo/LiveVideo/Twilio/Stream/StreamManager.swift index 29eb6ddc..347d2e99 100644 --- a/apps/ios/LiveVideo/LiveVideo/Twilio/Stream/StreamManager.swift +++ b/apps/ios/LiveVideo/LiveVideo/Twilio/Stream/StreamManager.swift @@ -109,33 +109,31 @@ class StreamManager: ObservableObject { let request = CreateOrJoinStreamRequest( userIdentity: config.userIdentity, streamName: config.streamName, - role: config.role + role: config.role, + recordStream: config.shouldRecord ) api.request(request) { [weak self] result in switch result { case let .success(response): - let objectNames = SyncManager.ObjectNames( - speakersMap: response.syncObjectNames.speakersMap, - viewersMap: response.syncObjectNames.viewersMap, - raisedHandsMap: response.syncObjectNames.raisedHandsMap, - userDocument: response.syncObjectNames.userDocument - ) - - self?.connectSync(accessToken: response.token, objectNames: objectNames) + self?.connectSync(accessToken: response.token) case let .failure(error): self?.handleError(error) } } } - private func connectSync(accessToken: String, objectNames: SyncManager.ObjectNames) { + private func connectSync(accessToken: String) { guard !syncManager.isConnected else { connectRoomOrPlayer(accessToken: accessToken) return } - syncManager.connect(token: accessToken, objectNames: objectNames) { [weak self] error in + syncManager.connect( + token: accessToken, + userIdentity: config.userIdentity, + hasUserDocument: config.hasUserDocument + ) { [weak self] error in if let error = error { self?.handleError(error) return diff --git a/apps/ios/LiveVideo/LiveVideo/Twilio/Stream/StreamViewModel.swift b/apps/ios/LiveVideo/LiveVideo/Twilio/Stream/StreamViewModel.swift index 6085010e..419577fc 100644 --- a/apps/ios/LiveVideo/LiveVideo/Twilio/Stream/StreamViewModel.swift +++ b/apps/ios/LiveVideo/LiveVideo/Twilio/Stream/StreamViewModel.swift @@ -7,8 +7,10 @@ import TwilioLivePlayer class StreamViewModel: ObservableObject { enum AlertIdentifier: String, Identifiable { - case error + case fatalError + case informativeError case receivedSpeakerInvite + case recordingIsInProgress case speakerMovedToViewersByHost case streamEndedByHost case streamWillEndIfHostLeaves @@ -45,6 +47,7 @@ class StreamViewModel: ObservableObject { private var streamManager: StreamManager! private var api: API! private var userDocument: SyncUserDocument! + private var streamDocument: SyncStreamDocument! private var speakerSettingsManager: SpeakerSettingsManager! private var subscriptions = Set() @@ -52,12 +55,14 @@ class StreamViewModel: ObservableObject { streamManager: StreamManager, speakerSettingsManager: SpeakerSettingsManager, api: API, - userDocument: SyncUserDocument + userDocument: SyncUserDocument, + streamDocument: SyncStreamDocument ) { self.streamManager = streamManager self.speakerSettingsManager = speakerSettingsManager self.api = api self.userDocument = userDocument + self.streamDocument = streamDocument streamManager.$state .sink { [weak self] state in @@ -89,6 +94,12 @@ class StreamViewModel: ObservableObject { case .host, .speaker: break } + + if let error = self.streamDocument.recordError { + self.handleError(error) /// Handle record error that was received while connecting + } else if self.streamDocument.isRecording { + self.handleRecordingEnabled() + } } } .store(in: &subscriptions) @@ -102,19 +113,58 @@ class StreamViewModel: ObservableObject { self?.alertIdentifier = .receivedSpeakerInvite } .store(in: &subscriptions) + + streamDocument.$isRecording + .filter { $0 } + .sink { [weak self] _ in + guard self?.streamManager.state == .connected else { + return /// Don't show recording message until after the stream is connected + } + + self?.handleRecordingEnabled() + } + .store(in: &subscriptions) + + streamDocument.$recordError + .compactMap { $0 } + .sink { [weak self] error in + guard self?.streamManager.state == .connected else { + return /// Record errors are not fatal so wait until after the stream is connected to show them + } + + self?.handleError(error) } + .store(in: &subscriptions) } private func handleError(_ error: Error) { - streamManager.disconnect() - - if error.isStreamEndedByHostError { - alertIdentifier = .streamEndedByHost - } else if error.isSpeakerMovedToViewersByHostError { - alertIdentifier = .speakerMovedToViewersByHost - streamManager.changeRole(to: .viewer) - } else { + if error.isRecordError { + guard streamManager.config.role == .host else { + return + } + self.error = error - alertIdentifier = .error + alertIdentifier = .informativeError + } else { + streamManager.disconnect() + + if error.isStreamEndedByHostError { + alertIdentifier = .streamEndedByHost + } else if error.isSpeakerMovedToViewersByHostError { + alertIdentifier = .speakerMovedToViewersByHost + streamManager.changeRole(to: .viewer) + } else { + self.error = error + alertIdentifier = .fatalError + } + } + } + + private func handleRecordingEnabled() { + switch streamManager.config.role { + case .host, .speaker: + self.alertIdentifier = .recordingIsInProgress + case .viewer: + break } } } @@ -135,4 +185,12 @@ private extension Error { return false } + + var isRecordError: Bool { + if case .recordError = self as? LiveVideoError { + return true + } + + return false + } } diff --git a/apps/ios/LiveVideo/LiveVideo/Twilio/Sync/SyncManager.swift b/apps/ios/LiveVideo/LiveVideo/Twilio/Sync/SyncManager.swift index f55b1865..c01a1b1b 100644 --- a/apps/ios/LiveVideo/LiveVideo/Twilio/Sync/SyncManager.swift +++ b/apps/ios/LiveVideo/LiveVideo/Twilio/Sync/SyncManager.swift @@ -7,13 +7,6 @@ import TwilioSyncClient /// Consolidates configuration and error handling for stores that are backed by [Twilio Sync](https://www.twilio.com/sync). class SyncManager: NSObject { - struct ObjectNames { - let speakersMap: String - let viewersMap: String - let raisedHandsMap: String - let userDocument: String? - } - let errorPublisher = PassthroughSubject() var isConnected: Bool { client != nil } private var client: TwilioSyncClient? @@ -21,6 +14,7 @@ class SyncManager: NSObject { private var raisedHandsMap: SyncUsersMap private var viewersMap: SyncUsersMap private var userDocument: SyncUserDocument + private var streamDocument: SyncStreamDocument private var objects: [SyncObjectConnecting] = [] private var appSettingsManager: AppSettingsManager @@ -29,29 +23,33 @@ class SyncManager: NSObject { viewersMap: SyncUsersMap, raisedHandsMap: SyncUsersMap, userDocument: SyncUserDocument, + streamDocument: SyncStreamDocument, appSettingsManager: AppSettingsManager ) { self.speakersMap = speakersMap self.viewersMap = viewersMap self.raisedHandsMap = raisedHandsMap self.userDocument = userDocument + self.streamDocument = streamDocument self.appSettingsManager = appSettingsManager } /// Connects all sync objects. /// /// - Parameter token: An access token with sync grant. - /// - Parameter objectNames: Unique names for the sync objects. + /// - Parameter userIdentity: Identity of the user. + /// - Parameter hasUserDocument: If there is a user document to open. /// - Parameter completion: Called when all configured objects are synchronnized or an error is encountered. - func connect(token: String, objectNames: ObjectNames, completion: @escaping (Error?) -> Void + func connect( + token: String, + userIdentity: String, + hasUserDocument: Bool, + completion: @escaping (Error?) -> Void ) { - speakersMap.uniqueName = objectNames.speakersMap - viewersMap.uniqueName = objectNames.viewersMap - raisedHandsMap.uniqueName = objectNames.raisedHandsMap - objects = [speakersMap, viewersMap, raisedHandsMap] - - if let userDocumentName = objectNames.userDocument { - userDocument.uniqueName = userDocumentName + objects = [speakersMap, viewersMap, raisedHandsMap, streamDocument] + + if hasUserDocument { + userDocument.uniqueName = "user-" + userIdentity objects.append(userDocument) } diff --git a/apps/ios/LiveVideo/LiveVideo/Twilio/Sync/SyncStreamDocument.swift b/apps/ios/LiveVideo/LiveVideo/Twilio/Sync/SyncStreamDocument.swift new file mode 100644 index 00000000..9e205c19 --- /dev/null +++ b/apps/ios/LiveVideo/LiveVideo/Twilio/Sync/SyncStreamDocument.swift @@ -0,0 +1,113 @@ +// +// Copyright (C) 2022 Twilio, Inc. +// + +import Combine +import TwilioSyncClient + +class SyncStreamDocument: NSObject, SyncObjectConnecting, ObservableObject { + struct DocumentData { + struct Recording { + let isRecording: Bool + let error: String? + } + + let recording: Recording + + init(data: [String: Any]) throws { + guard + let recording = data["recording"] as? [String: Any], + let isRecording = recording["is_recording"] as? Bool + else { + throw LiveVideoError.syncObjectDecodeError + } + + let error = recording["error"] as? String + + self.recording = Recording(isRecording: isRecording, error: error) + } + } + + @Published var isRecording = false + + /// Record errors are informative and do not end the stream, so provide a special path to handle them. It would be nice to have + /// a single path for all errors but that would require a significant refactor and probably make other code more complex. + @Published var recordError: Error? + + var errorHandler: ((Error) -> Void)? + private var document: TWSDocument? + + func connect(client: TwilioSyncClient, completion: @escaping (Error?) -> Void) { + guard let options = TWSOpenOptions.open(withSidOrUniqueName: "stream") else { return } + + client.openDocument(with: options, delegate: self) { [weak self] result, document in + guard let document = document else { + completion(result.error!) + return + } + + self?.document = document + + do { + try self?.update(data: document.data) + } catch { + completion(error) + } + + completion(nil) + } + } + + func disconnect() { + document = nil + isRecording = false + recordError = nil + } + + private func handleError(_ error: Error) { + disconnect() + errorHandler?(error) + } + + private func update(data: [String: Any], previousData: [String: Any]? = nil) throws { + let data = try DocumentData(data: data) + + isRecording = data.recording.isRecording + + if let error = data.recording.error { + var isNewError = false + + if let previousData = previousData { + if try DocumentData(data: previousData).recording.error == nil { + isNewError = true + } + } else { + isNewError = true + } + + if isNewError { + recordError = LiveVideoError.recordError(message: error) + } + } + } +} + +extension SyncStreamDocument: TWSDocumentDelegate { + func onDocument( + _ document: TWSDocument, + updated data: [String: Any], + previousData: [String: Any], + eventContext: TWSEventContext + ) { + do { + try update(data: data, previousData: previousData) + } catch { + handleError(error) + } + } + + func onDocument(_ document: TWSDocument, errorOccurred error: TWSError) { + handleError(error) + } +} + diff --git a/apps/ios/LiveVideo/LiveVideo/Twilio/Sync/SyncUserDocument.swift b/apps/ios/LiveVideo/LiveVideo/Twilio/Sync/SyncUserDocument.swift index eb7aee33..78550a3a 100644 --- a/apps/ios/LiveVideo/LiveVideo/Twilio/Sync/SyncUserDocument.swift +++ b/apps/ios/LiveVideo/LiveVideo/Twilio/Sync/SyncUserDocument.swift @@ -5,7 +5,7 @@ import Combine import TwilioSyncClient -class SyncUserDocument: NSObject, SyncObjectConnecting, ObservableObject { +class SyncUserDocument: NSObject, SyncObjectConnecting { let speakerInvitePublisher = PassthroughSubject() var uniqueName: String! var errorHandler: ((Error) -> Void)? diff --git a/apps/ios/LiveVideo/LiveVideo/Twilio/Sync/SyncUsersMap.swift b/apps/ios/LiveVideo/LiveVideo/Twilio/Sync/SyncUsersMap.swift index c36390bf..1ca25212 100644 --- a/apps/ios/LiveVideo/LiveVideo/Twilio/Sync/SyncUsersMap.swift +++ b/apps/ios/LiveVideo/LiveVideo/Twilio/Sync/SyncUsersMap.swift @@ -22,14 +22,18 @@ class SyncUsersMap: NSObject, SyncObjectConnecting { let userAddedPublisher = PassthroughSubject() let userRemovedPublisher = PassthroughSubject() - var uniqueName: String! var errorHandler: ((Error) -> Void)? var host: User? { users.first { $0.isHost } // This app only has one host and it is the user that created the stream } private(set) var users: [User] = [] + private let uniqueName: String private var map: TWSMap? + init(uniqueName: String) { + self.uniqueName = uniqueName + } + func connect(client: TwilioSyncClient, completion: @escaping (Error?) -> Void) { guard let openOptions = TWSOpenOptions.open(withSidOrUniqueName: uniqueName) else { return } diff --git a/apps/ios/LiveVideo/LiveVideo/Views/Stream/LiveBadge.swift b/apps/ios/LiveVideo/LiveVideo/Views/Stream/LiveBadge.swift index 33f92c7b..09c4219d 100644 --- a/apps/ios/LiveVideo/LiveVideo/Views/Stream/LiveBadge.swift +++ b/apps/ios/LiveVideo/LiveVideo/Views/Stream/LiveBadge.swift @@ -15,8 +15,8 @@ struct LiveBadge: View { .fixedSize() .font(.system(size: 13)) } - .padding(.vertical, 4) - .padding(.horizontal, 8) + .frame(height: 14) + .padding(6) .foregroundColor(.black) .background(Color.backgroundLiveBadge) .cornerRadius(2) diff --git a/apps/ios/LiveVideo/LiveVideo/Views/Stream/RecordingBadge.swift b/apps/ios/LiveVideo/LiveVideo/Views/Stream/RecordingBadge.swift new file mode 100644 index 00000000..421e66a3 --- /dev/null +++ b/apps/ios/LiveVideo/LiveVideo/Views/Stream/RecordingBadge.swift @@ -0,0 +1,40 @@ +// +// Copyright (C) 2022 Twilio, Inc. +// + +import SwiftUI + +struct RecordingBadge: View { + @State private var isBright = false + + var body: some View { + HStack(spacing: 5) { + Circle() + .frame(width: 12) + .foregroundColor(isBright ? .recordingDotBright : .recordingDotDark) + .onAppear { + withAnimation(.easeInOut(duration: 0.6).repeatForever(autoreverses: true)) { + isBright.toggle() + } + } + + Text("Recording") + .foregroundColor(.white) + .font(.system(size: 14)) + } + .frame(height: 14) + .padding(6) + .background(Color.white.opacity(0.25)) + .cornerRadius(3) + .fixedSize(horizontal: true, vertical: false) + } +} + +struct RecordingBadge_Previews: PreviewProvider { + static var previews: some View { + RecordingBadge() + .padding() + .background(Color.backgroundBrandStronger) + .previewLayout(.sizeThatFits) + } +} diff --git a/apps/ios/LiveVideo/LiveVideo/Views/Stream/StreamStatusView.swift b/apps/ios/LiveVideo/LiveVideo/Views/Stream/StreamStatusView.swift index e25babe6..d4acbbbe 100644 --- a/apps/ios/LiveVideo/LiveVideo/Views/Stream/StreamStatusView.swift +++ b/apps/ios/LiveVideo/LiveVideo/Views/Stream/StreamStatusView.swift @@ -5,6 +5,7 @@ import SwiftUI struct StreamStatusView: View { + @EnvironmentObject var streamDocument: SyncStreamDocument let streamName: String @Binding var streamState: StreamManager.State @@ -12,6 +13,10 @@ struct StreamStatusView: View { HStack { if streamState == .connected { LiveBadge() + + if streamDocument.isRecording { + RecordingBadge() + } } Spacer(minLength: 20) @@ -26,17 +31,27 @@ struct StreamStatusView: View { struct StreamStatusView_Previews: PreviewProvider { static var previews: some View { - Group { - StreamStatusView(streamName: "Room name", streamState: .constant(.connecting)) - .previewDisplayName("Loading") - StreamStatusView(streamName: "Short room name", streamState: .constant(.connected)) - .previewDisplayName("Short Room Name") - StreamStatusView( - streamName: "A very long room name that doesn't fit completely", - streamState: .constant(.connected) - ) - .previewDisplayName("Long Room Name") + let streamNames = ["Short room name", "Long room name that is truncated because it does not fit"] + let streamStates: [StreamManager.State] = [.connecting, .connected] + + ForEach([false, true], id: \.self) { isRecording in + ForEach(streamStates, id: \.self) { streamState in + ForEach(streamNames, id: \.self) { streamName in + StreamStatusView(streamName: streamName, streamState: .constant(streamState)) + .environmentObject(SyncStreamDocument.stub(isRecording: isRecording)) + .frame(width: 400) + .background(Color.backgroundStronger) + .previewLayout(.sizeThatFits) + } + } } - .previewLayout(.sizeThatFits) + } +} + +extension SyncStreamDocument { + static func stub(isRecording: Bool = false) -> SyncStreamDocument { + let streamDocument = SyncStreamDocument() + streamDocument.isRecording = isRecording + return streamDocument } } diff --git a/apps/ios/LiveVideo/LiveVideo/Views/Stream/StreamView.swift b/apps/ios/LiveVideo/LiveVideo/Views/Stream/StreamView.swift index 998374c5..6ef90194 100644 --- a/apps/ios/LiveVideo/LiveVideo/Views/Stream/StreamView.swift +++ b/apps/ios/LiveVideo/LiveVideo/Views/Stream/StreamView.swift @@ -142,10 +142,12 @@ struct StreamView: View { } .alert(item: $viewModel.alertIdentifier) { alertIdentifier in switch alertIdentifier { - case .error: + case .fatalError: return Alert(error: viewModel.error!) { presentationMode.wrappedValue.dismiss() } + case .informativeError: + return Alert(error: viewModel.error!) case .receivedSpeakerInvite: return Alert( title: Text("It’s your time to shine! ✨"), @@ -157,6 +159,11 @@ struct StreamView: View { viewModel.isHandRaised = false } ) + case .recordingIsInProgress: + return Alert( + title: Text("Recording Is in Progress"), + dismissButton: .default(Text("OK")) + ) case .speakerMovedToViewersByHost: return Alert( title: Text("Moved to viewers"), @@ -236,6 +243,7 @@ struct StreamView_Previews: PreviewProvider { .environmentObject(SpeakerSettingsManager()) .environmentObject(ParticipantsViewModel()) .environmentObject(StreamViewModel()) + .environmentObject(SyncStreamDocument.stub(isRecording: true)) } } @@ -248,7 +256,17 @@ extension StreamManager { } extension StreamConfig { - static func stub(streamName: String = "Demo", userIdentity: String = "Alice", role: Role = .host) -> Self { - StreamConfig(streamName: streamName, userIdentity: userIdentity, role: role) + static func stub( + streamName: String = "Demo", + userIdentity: String = "Alice", + shouldRecord: Bool? = nil, + role: Role = .host + ) -> Self { + StreamConfig( + streamName: streamName, + userIdentity: userIdentity, + shouldRecord: shouldRecord, + role: role + ) } } diff --git a/apps/ios/LiveVideo/LiveVideo/Views/StreamConfigFlow/EnterStreamNameView.swift b/apps/ios/LiveVideo/LiveVideo/Views/StreamConfigFlow/EnterStreamNameView.swift index 2dfe366d..70ae74e9 100644 --- a/apps/ios/LiveVideo/LiveVideo/Views/StreamConfigFlow/EnterStreamNameView.swift +++ b/apps/ios/LiveVideo/LiveVideo/Views/StreamConfigFlow/EnterStreamNameView.swift @@ -8,6 +8,7 @@ struct EnterStreamNameView: View { private struct ViewModel { let title: String let tip: String + let shouldShowRecordOption: Bool let shouldSelectRole: Bool } @@ -15,19 +16,22 @@ struct EnterStreamNameView: View { @EnvironmentObject var authManager: AuthManager @State private var streamName = "" @State private var isShowingSelectRole = false + @State private var shouldRecord = false private var viewModel: ViewModel { switch flowModel.parameters.role { case .host: return ViewModel( title: "Create new event", - tip: "Tip: give your event a name that’s related to the topic you’ll be talking about.", + tip: "Give your event a name that’s related to the topic you’ll be talking about.", + shouldShowRecordOption: true, shouldSelectRole: false ) case .none, .viewer, .speaker: return ViewModel( title: "Join event", tip: "Enter the event name.", + shouldShowRecordOption: false, shouldSelectRole: true ) } @@ -35,32 +39,44 @@ struct EnterStreamNameView: View { var body: some View { NavigationView { - FormStack { - Text(viewModel.tip) - .modifier(TipStyle()) - - TextField("Event name", text: $streamName) - .textFieldStyle(FormTextFieldStyle()) - .autocapitalization(.none) - .disableAutocorrection(true) - - VStack { - NavigationLink(destination: SelectRoleView(), isActive: $isShowingSelectRole) { - EmptyView() + Form { + Section(footer: Text(viewModel.tip) + ) { + TextField("Event name", text: $streamName) + .autocapitalization(.none) + .disableAutocorrection(true) + } + + if viewModel.shouldShowRecordOption { + Section(header: Text("Recording")) { + Toggle("Record event", isOn: $shouldRecord) } - - Button("Continue") { - flowModel.parameters.streamName = streamName - flowModel.parameters.userIdentity = authManager.userIdentity - - if viewModel.shouldSelectRole { - isShowingSelectRole = true - } else { - flowModel.isShowing = false + } + + Section( + footer: + VStack { + NavigationLink(destination: SelectRoleView(), isActive: $isShowingSelectRole) { + EmptyView() + } + + Button("Continue") { + flowModel.parameters.streamName = streamName + flowModel.parameters.userIdentity = authManager.userIdentity + flowModel.parameters.shouldRecord = shouldRecord + + if viewModel.shouldSelectRole { + isShowingSelectRole = true + } else { + flowModel.isShowing = false + } + } + .buttonStyle(PrimaryButtonStyle(isEnabled: !streamName.isEmpty)) + .disabled(streamName.isEmpty) + .padding(.horizontal, -20) } - } - .buttonStyle(PrimaryButtonStyle(isEnabled: !streamName.isEmpty)) - .disabled(streamName.isEmpty) + ) { + /// Using the footer to have more control over style } } .navigationBarTitle(viewModel.title, displayMode: .inline) diff --git a/apps/ios/LiveVideo/LiveVideo/Views/StreamConfigFlow/StreamConfigFlowModel.swift b/apps/ios/LiveVideo/LiveVideo/Views/StreamConfigFlow/StreamConfigFlowModel.swift index 74ceacab..c0225fed 100644 --- a/apps/ios/LiveVideo/LiveVideo/Views/StreamConfigFlow/StreamConfigFlowModel.swift +++ b/apps/ios/LiveVideo/LiveVideo/Views/StreamConfigFlow/StreamConfigFlowModel.swift @@ -9,6 +9,7 @@ class StreamConfigFlowModel: ObservableObject { var userIdentity: String? var streamName: String? var role: StreamConfig.Role? + var shouldRecord: Bool? } @Published var isShowing = false @@ -23,6 +24,11 @@ class StreamConfigFlowModel: ObservableObject { return nil } - return StreamConfig(streamName: streamName, userIdentity: userIdentity, role: role) + return StreamConfig( + streamName: streamName, + userIdentity: userIdentity, + shouldRecord: parameters.shouldRecord, + role: role + ) } } diff --git a/apps/ios/LiveVideo/LiveVideo/Views/Style/Color.swift b/apps/ios/LiveVideo/LiveVideo/Views/Style/Color.swift index b0fff914..2814bb13 100644 --- a/apps/ios/LiveVideo/LiveVideo/Views/Style/Color.swift +++ b/apps/ios/LiveVideo/LiveVideo/Views/Style/Color.swift @@ -23,6 +23,8 @@ extension Color { static var borderSuccessWeak: Color { Color("BorderSuccessWeak") } static var borderWeaker: Color { Color("BorderWeaker") } static var iconPurple: Color { Color("IconPurple") } + static var recordingDotBright: Color { Color("RecordingDotBright") } + static var recordingDotDark: Color { Color("RecordingDotDark") } static var shadowLow: Color { Color("ShadowLow") } static var textWeak: Color { Color("TextWeak") } } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index a3d0e232..03dc4833 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -6,7 +6,6 @@ import MobileTopMenuBar from './components/MobileTopMenuBar/MobileTopMenuBar'; import Player from './components/Player/Player'; import PreJoinScreens from './components/PreJoinScreens/PreJoinScreens'; import ReconnectingNotification from './components/ReconnectingNotification/ReconnectingNotification'; -import RecordingNotifications from './components/RecordingNotifications/RecordingNotifications'; import Room from './components/Room/Room'; import useHeight from './hooks/useHeight/useHeight'; @@ -47,7 +46,6 @@ export default function App() { {roomState !== 'disconnected' && (
- diff --git a/apps/web/src/components/Buttons/LeaveEventButton/LeaveEventButton.tsx b/apps/web/src/components/Buttons/LeaveEventButton/LeaveEventButton.tsx index 6b39b1dd..05c976b1 100644 --- a/apps/web/src/components/Buttons/LeaveEventButton/LeaveEventButton.tsx +++ b/apps/web/src/components/Buttons/LeaveEventButton/LeaveEventButton.tsx @@ -37,7 +37,7 @@ export default function LeaveEventButton(props: { buttonClassName?: string }) { const { data } = await joinStreamAsViewer(appState.participantName, appState.eventName); await playerConnect(data.token); await connectViewerToPlayer(appState.participantName, appState.eventName); - registerUserDocument(data.sync_object_names.user_document); + registerUserDocument(`user-${appState.participantName}`); room!.emit('setPreventAutomaticJoinStreamAsViewer'); room!.disconnect(); } diff --git a/apps/web/src/components/MainParticipantInfo/MainParticipantInfo.tsx b/apps/web/src/components/MainParticipantInfo/MainParticipantInfo.tsx index 2ee0657a..53adbfd0 100644 --- a/apps/web/src/components/MainParticipantInfo/MainParticipantInfo.tsx +++ b/apps/web/src/components/MainParticipantInfo/MainParticipantInfo.tsx @@ -141,7 +141,7 @@ export default function MainParticipantInfo({ participant, children }: MainParti const isVideoSwitchedOff = useIsTrackSwitchedOff(videoTrack as LocalVideoTrack | RemoteVideoTrack); const isParticipantReconnecting = useParticipantIsReconnecting(participant); - const isRecording = useIsRecording(); + const { isRecording } = useIsRecording(); return (
{isRecording && ( - +
diff --git a/apps/web/src/components/Player/Player.tsx b/apps/web/src/components/Player/Player.tsx index 680b61d5..2bf41fc4 100644 --- a/apps/web/src/components/Player/Player.tsx +++ b/apps/web/src/components/Player/Player.tsx @@ -8,6 +8,8 @@ import usePlayerContext from '../../hooks/usePlayerContext/usePlayerContext'; import { useAppState } from '../../state'; import { useEnqueueSnackbar } from '../../hooks/useSnackbar/useSnackbar'; import { usePlayerState } from '../../hooks/usePlayerState/usePlayerState'; +import useIsRecording from '../../hooks/useIsRecording/useIsRecording'; +import { Tooltip, Typography } from '@material-ui/core'; TwilioPlayer.telemetry.subscribe(data => { const method = data.name === 'error' ? 'error' : 'log'; @@ -29,6 +31,31 @@ const useStyles = makeStyles((theme: Theme) => width: '100%', height: '100%', }, + recordingIndicator: { + position: 'absolute', + bottom: theme.footerHeight + 'px', + display: 'flex', + alignItems: 'center', + background: 'rgba(0, 0, 0, 0.5)', + color: 'white', + padding: '0.1em 0.3em 0.1em 0', + fontSize: '1.2rem', + height: '28px', + zIndex: 10, + [theme.breakpoints.down('sm')]: { + bottom: 'auto', + right: 0, + top: 0, + }, + }, + circle: { + height: '12px', + width: '12px', + background: 'red', + borderRadius: '100%', + margin: '0 0.6em', + animation: `1.25s $pulsate ease-out infinite`, + }, }) ); @@ -40,6 +67,7 @@ function Player() { const { appState, appDispatch } = useAppState(); const enqueueSnackbar = useEnqueueSnackbar(); const [welcomeMessageDisplayed, setWelcomeMessageDisplayed] = useState(false); + const { isRecording } = useIsRecording(); useLayoutEffect(() => { if (player && state === 'ready') { @@ -69,6 +97,16 @@ function Player() { [classes.rightDrawerOpen]: appState.isParticipantWindowOpen, })} > + {isRecording && ( + +
+
+ + Recording + +
+
+ )}
diff --git a/apps/web/src/components/PreJoinScreens/CreateNewEventScreen/CreateNewEventScreen.tsx b/apps/web/src/components/PreJoinScreens/CreateNewEventScreen/CreateNewEventScreen.tsx index 4952e10d..5effffed 100644 --- a/apps/web/src/components/PreJoinScreens/CreateNewEventScreen/CreateNewEventScreen.tsx +++ b/apps/web/src/components/PreJoinScreens/CreateNewEventScreen/CreateNewEventScreen.tsx @@ -1,5 +1,15 @@ import React, { ChangeEvent, FormEvent } from 'react'; -import { Typography, makeStyles, TextField, Grid, Button, InputLabel, Theme } from '@material-ui/core'; +import { + Typography, + makeStyles, + TextField, + Grid, + Button, + InputLabel, + Theme, + FormControlLabel, + Checkbox, +} from '@material-ui/core'; import { appActionTypes, ActiveScreen, appStateTypes } from '../../../state/appState/appReducer'; const useStyles = makeStyles((theme: Theme) => ({ @@ -8,18 +18,18 @@ const useStyles = makeStyles((theme: Theme) => ({ fontWeight: 'bold', }, inputContainer: { - display: 'flex', justifyContent: 'space-between', - margin: '1.5em 0 3.5em', + margin: '1.5em 0 1.3em', '& div:not(:last-child)': { marginRight: '1em', }, [theme.breakpoints.down('sm')]: { - margin: '1.5em 0 2em', + margin: '1.5em 0 1.2em', }, }, textFieldContainer: { width: '100%', + marginBottom: '1em', }, continueButton: { [theme.breakpoints.down('sm')]: { @@ -40,6 +50,10 @@ export default function CreateNewEventScreen({ state, dispatch }: CreateNewEvent dispatch({ type: 'set-event-name', eventName: event.target.value }); }; + const handleRecordChange = (event: ChangeEvent) => { + dispatch({ type: 'set-record-stream', recordStream: event.target.checked }); + }; + const handleSubmit = (event: FormEvent) => { event.preventDefault(); dispatch({ type: 'set-active-screen', activeScreen: ActiveScreen.DeviceSelectionScreen }); @@ -69,6 +83,19 @@ export default function CreateNewEventScreen({ state, dispatch }: CreateNewEvent onChange={handleNameChange} /> +
+ + } + label="Record Stream" + /> +