├── .gitignore ├── Assets.xcassets ├── AccentColor.colorset │ └── Contents.json ├── AppIcon.appiconset │ ├── Contents.json │ ├── iOS App Icon_1024pt@1x.png │ ├── iOS App Icon_20pt.png │ ├── iOS App Icon_20pt@2x 1.png │ ├── iOS App Icon_20pt@2x.png │ ├── iOS App Icon_20pt@3x.png │ ├── iOS App Icon_29pt.png │ ├── iOS App Icon_29pt@2x 1.png │ ├── iOS App Icon_29pt@2x.png │ ├── iOS App Icon_29pt@3x.png │ ├── iOS App Icon_40pt.png │ ├── iOS App Icon_40pt@2x 1.png │ ├── iOS App Icon_40pt@2x.png │ ├── iOS App Icon_40pt@3x.png │ ├── iOS App Icon_60pt@2x.png │ ├── iOS App Icon_60pt@3x.png │ ├── iOS App Icon_76pt.png │ ├── iOS App Icon_76pt@2x.png │ └── iOS App Icon_83.5@2x.png ├── Background.colorset │ └── Contents.json ├── BackgroundButton.colorset │ └── Contents.json ├── BackgroundGray.colorset │ └── Contents.json ├── BackgroundLight.colorset │ └── Contents.json ├── BackgroundList.colorset │ └── Contents.json ├── BackgroundSecondary.colorset │ └── Contents.json ├── ButtonRed.colorset │ └── Contents.json ├── Contents.json ├── GradientGray.colorset │ └── Contents.json ├── Green.colorset │ └── Contents.json ├── Red.colorset │ └── Contents.json ├── TextGray1.colorset │ └── Contents.json ├── TextGray2.colorset │ └── Contents.json ├── Yellow.colorset │ └── Contents.json ├── icon_arrow_down.imageset │ ├── Contents.json │ └── icon_arrow_down.png ├── icon_cached.imageset │ ├── Contents.json │ └── icon_cached.png ├── icon_chat_off.imageset │ ├── Contents.json │ └── icon_chat_off.png ├── icon_chat_on.imageset │ ├── Contents.json │ └── icon_chat_on.png ├── icon_do_not_disturb.imageset │ ├── Contents.json │ └── icon_do_not_disturb.png ├── icon_group.imageset │ ├── Contents.json │ └── groups_2_FILL1_wght400_GRAD0_opsz24 1.png ├── icon_info_dark.imageset │ ├── Contents.json │ └── icon_info_dark.png ├── icon_info_light.imageset │ ├── Contents.json │ └── icon_info_light.png ├── icon_invite.imageset │ ├── Contents.json │ └── Vector.png ├── icon_logout.imageset │ ├── Contents.json │ └── icon_logout.png ├── icon_mic_off.imageset │ ├── Contents.json │ └── icon_mic_off.png ├── icon_mic_off_red.imageset │ ├── Contents.json │ └── icon_mic_off_red.png ├── icon_mic_on.imageset │ ├── Contents.json │ └── icon_mic_on.png ├── icon_start_broadcast.imageset │ ├── Contents.json │ └── icon_start_broadcast.png ├── icon_swap_camera.imageset │ ├── Contents.json │ └── icon_swap_camera.png ├── icon_video_off.imageset │ ├── Contents.json │ └── icon_video_off.png ├── icon_video_off_red.imageset │ ├── Contents.json │ └── icon_video_off_red.png ├── icon_video_on.imageset │ ├── Contents.json │ └── icon_video_on.png ├── icon_warning.imageset │ ├── Contents.json │ └── icon_warning.png └── welcomeImage.imageset │ ├── Contents.json │ └── welcomeImage.png ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── MultiHost-demo.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ └── xcschemes │ └── Stages-demo.xcscheme ├── MultiHost-demo.xcworkspace ├── contents.xcworkspacedata └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── MultiHostDemo ├── BroadcastDelegate.swift ├── Constants.swift ├── Models │ ├── ChatModel.swift │ ├── ChatTokenRequest.swift │ ├── Managers │ │ ├── PermissionsManager.swift │ │ ├── Server.swift │ │ └── ServicesManager.swift │ ├── Notification.swift │ ├── ParticipantData.swift │ ├── ServerResponses.swift │ ├── StageViewModel+Extensions.swift │ ├── StageViewModel.swift │ └── User.swift ├── Utilities │ ├── ActivityIndicator.swift │ ├── IVSImagePreviewViewWrapper.swift │ ├── Modifiers.swift │ ├── Oservable.swift │ ├── StageLayoutCalculator.swift │ └── View.swift └── Views │ ├── Components │ ├── BottomSheetView.swift │ ├── CameraView.swift │ ├── ChatMessagesView.swift │ ├── ChatView.swift │ ├── ControlButtonsDrawer.swift │ ├── CustomTextField.swift │ ├── JoinPreviewView.swift │ ├── ManageParticipantsView.swift │ ├── NotificationsView.swift │ ├── ParticipantView.swift │ ├── ParticipantsGridView.swift │ └── RemoteImageView.swift │ ├── MultihostApp.swift │ ├── SetupView.swift │ ├── StageListView.swift │ ├── StageView.swift │ └── WelcomeView.swift ├── Podfile ├── Podfile.lock ├── README.md ├── THIRD-PARTY-LICENSES.txt └── app-screenshot.png /.gitignore: -------------------------------------------------------------------------------- 1 | ## Build generated 2 | build/ 3 | DerivedData/ 4 | 5 | ## Various settings 6 | *.pbxuser 7 | !default.pbxuser 8 | *.mode1v3 9 | !default.mode1v3 10 | *.mode2v3 11 | !default.mode2v3 12 | *.perspectivev3 13 | !default.perspectivev3 14 | xcuserdata/ 15 | 16 | ## Other 17 | *.moved-aside 18 | *.xccheckout 19 | *.xcscmblueprint 20 | 21 | ## Obj-C/Swift specific 22 | *.hmap 23 | *.ipa 24 | *.dSYM.zip 25 | *.dSYM 26 | 27 | ## Playgrounds 28 | timeline.xctimeline 29 | playground.xcworkspace 30 | 31 | # Swift Package Manager 32 | # 33 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 34 | # Packages/ 35 | # Package.pins 36 | # Package.resolved 37 | .build/ 38 | 39 | # CocoaPods 40 | # 41 | # We recommend against adding the Pods directory to your .gitignore. However 42 | # you should judge for yourself, the pros and cons are mentioned at: 43 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 44 | # 45 | Pods/ 46 | 47 | # Carthage 48 | # 49 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 50 | # Carthage/Checkouts 51 | 52 | Carthage/Build 53 | 54 | # fastlane 55 | # 56 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 57 | # screenshots whenever they are needed. 58 | # For more information about the recommended setup visit: 59 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 60 | 61 | fastlane/report.xml 62 | fastlane/Preview.html 63 | fastlane/screenshots/**/*.png 64 | fastlane/test_output 65 | 66 | *.DS_Store -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "iOS App Icon_20pt@2x 1.png", 5 | "idiom" : "iphone", 6 | "scale" : "2x", 7 | "size" : "20x20" 8 | }, 9 | { 10 | "filename" : "iOS App Icon_20pt@3x.png", 11 | "idiom" : "iphone", 12 | "scale" : "3x", 13 | "size" : "20x20" 14 | }, 15 | { 16 | "filename" : "iOS App Icon_29pt@2x 1.png", 17 | "idiom" : "iphone", 18 | "scale" : "2x", 19 | "size" : "29x29" 20 | }, 21 | { 22 | "filename" : "iOS App Icon_29pt@3x.png", 23 | "idiom" : "iphone", 24 | "scale" : "3x", 25 | "size" : "29x29" 26 | }, 27 | { 28 | "filename" : "iOS App Icon_40pt@2x 1.png", 29 | "idiom" : "iphone", 30 | "scale" : "2x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "filename" : "iOS App Icon_40pt@3x.png", 35 | "idiom" : "iphone", 36 | "scale" : "3x", 37 | "size" : "40x40" 38 | }, 39 | { 40 | "filename" : "iOS App Icon_60pt@2x.png", 41 | "idiom" : "iphone", 42 | "scale" : "2x", 43 | "size" : "60x60" 44 | }, 45 | { 46 | "filename" : "iOS App Icon_60pt@3x.png", 47 | "idiom" : "iphone", 48 | "scale" : "3x", 49 | "size" : "60x60" 50 | }, 51 | { 52 | "filename" : "iOS App Icon_20pt.png", 53 | "idiom" : "ipad", 54 | "scale" : "1x", 55 | "size" : "20x20" 56 | }, 57 | { 58 | "filename" : "iOS App Icon_20pt@2x.png", 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "20x20" 62 | }, 63 | { 64 | "filename" : "iOS App Icon_29pt.png", 65 | "idiom" : "ipad", 66 | "scale" : "1x", 67 | "size" : "29x29" 68 | }, 69 | { 70 | "filename" : "iOS App Icon_29pt@2x.png", 71 | "idiom" : "ipad", 72 | "scale" : "2x", 73 | "size" : "29x29" 74 | }, 75 | { 76 | "filename" : "iOS App Icon_40pt.png", 77 | "idiom" : "ipad", 78 | "scale" : "1x", 79 | "size" : "40x40" 80 | }, 81 | { 82 | "filename" : "iOS App Icon_40pt@2x.png", 83 | "idiom" : "ipad", 84 | "scale" : "2x", 85 | "size" : "40x40" 86 | }, 87 | { 88 | "filename" : "iOS App Icon_76pt.png", 89 | "idiom" : "ipad", 90 | "scale" : "1x", 91 | "size" : "76x76" 92 | }, 93 | { 94 | "filename" : "iOS App Icon_76pt@2x.png", 95 | "idiom" : "ipad", 96 | "scale" : "2x", 97 | "size" : "76x76" 98 | }, 99 | { 100 | "filename" : "iOS App Icon_83.5@2x.png", 101 | "idiom" : "ipad", 102 | "scale" : "2x", 103 | "size" : "83.5x83.5" 104 | }, 105 | { 106 | "filename" : "iOS App Icon_1024pt@1x.png", 107 | "idiom" : "ios-marketing", 108 | "scale" : "1x", 109 | "size" : "1024x1024" 110 | } 111 | ], 112 | "info" : { 113 | "author" : "xcode", 114 | "version" : 1 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /Assets.xcassets/AppIcon.appiconset/iOS App Icon_1024pt@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/AppIcon.appiconset/iOS App Icon_1024pt@1x.png -------------------------------------------------------------------------------- /Assets.xcassets/AppIcon.appiconset/iOS App Icon_20pt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/AppIcon.appiconset/iOS App Icon_20pt.png -------------------------------------------------------------------------------- /Assets.xcassets/AppIcon.appiconset/iOS App Icon_20pt@2x 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/AppIcon.appiconset/iOS App Icon_20pt@2x 1.png -------------------------------------------------------------------------------- /Assets.xcassets/AppIcon.appiconset/iOS App Icon_20pt@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/AppIcon.appiconset/iOS App Icon_20pt@2x.png -------------------------------------------------------------------------------- /Assets.xcassets/AppIcon.appiconset/iOS App Icon_20pt@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/AppIcon.appiconset/iOS App Icon_20pt@3x.png -------------------------------------------------------------------------------- /Assets.xcassets/AppIcon.appiconset/iOS App Icon_29pt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/AppIcon.appiconset/iOS App Icon_29pt.png -------------------------------------------------------------------------------- /Assets.xcassets/AppIcon.appiconset/iOS App Icon_29pt@2x 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/AppIcon.appiconset/iOS App Icon_29pt@2x 1.png -------------------------------------------------------------------------------- /Assets.xcassets/AppIcon.appiconset/iOS App Icon_29pt@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/AppIcon.appiconset/iOS App Icon_29pt@2x.png -------------------------------------------------------------------------------- /Assets.xcassets/AppIcon.appiconset/iOS App Icon_29pt@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/AppIcon.appiconset/iOS App Icon_29pt@3x.png -------------------------------------------------------------------------------- /Assets.xcassets/AppIcon.appiconset/iOS App Icon_40pt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/AppIcon.appiconset/iOS App Icon_40pt.png -------------------------------------------------------------------------------- /Assets.xcassets/AppIcon.appiconset/iOS App Icon_40pt@2x 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/AppIcon.appiconset/iOS App Icon_40pt@2x 1.png -------------------------------------------------------------------------------- /Assets.xcassets/AppIcon.appiconset/iOS App Icon_40pt@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/AppIcon.appiconset/iOS App Icon_40pt@2x.png -------------------------------------------------------------------------------- /Assets.xcassets/AppIcon.appiconset/iOS App Icon_40pt@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/AppIcon.appiconset/iOS App Icon_40pt@3x.png -------------------------------------------------------------------------------- /Assets.xcassets/AppIcon.appiconset/iOS App Icon_60pt@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/AppIcon.appiconset/iOS App Icon_60pt@2x.png -------------------------------------------------------------------------------- /Assets.xcassets/AppIcon.appiconset/iOS App Icon_60pt@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/AppIcon.appiconset/iOS App Icon_60pt@3x.png -------------------------------------------------------------------------------- /Assets.xcassets/AppIcon.appiconset/iOS App Icon_76pt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/AppIcon.appiconset/iOS App Icon_76pt.png -------------------------------------------------------------------------------- /Assets.xcassets/AppIcon.appiconset/iOS App Icon_76pt@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/AppIcon.appiconset/iOS App Icon_76pt@2x.png -------------------------------------------------------------------------------- /Assets.xcassets/AppIcon.appiconset/iOS App Icon_83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/AppIcon.appiconset/iOS App Icon_83.5@2x.png -------------------------------------------------------------------------------- /Assets.xcassets/Background.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.000", 9 | "green" : "0.000", 10 | "red" : "0.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.000", 27 | "green" : "0.000", 28 | "red" : "0.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Assets.xcassets/BackgroundButton.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "0.450", 8 | "blue" : "0.463", 9 | "green" : "0.463", 10 | "red" : "0.463" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "0.450", 26 | "blue" : "0.463", 27 | "green" : "0.463", 28 | "red" : "0.463" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Assets.xcassets/BackgroundGray.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.306", 9 | "green" : "0.306", 10 | "red" : "0.306" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.306", 27 | "green" : "0.306", 28 | "red" : "0.306" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Assets.xcassets/BackgroundLight.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.122", 9 | "green" : "0.122", 10 | "red" : "0.122" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.122", 27 | "green" : "0.122", 28 | "red" : "0.122" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Assets.xcassets/BackgroundList.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.180", 9 | "green" : "0.180", 10 | "red" : "0.180" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.180", 27 | "green" : "0.180", 28 | "red" : "0.180" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Assets.xcassets/BackgroundSecondary.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.169", 9 | "green" : "0.169", 10 | "red" : "0.169" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.169", 27 | "green" : "0.169", 28 | "red" : "0.169" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Assets.xcassets/ButtonRed.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.341", 9 | "green" : "0.341", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.341", 27 | "green" : "0.341", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Assets.xcassets/GradientGray.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.051", 9 | "green" : "0.051", 10 | "red" : "0.051" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.051", 27 | "green" : "0.051", 28 | "red" : "0.051" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Assets.xcassets/Green.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.263", 9 | "green" : "0.553", 10 | "red" : "0.204" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.263", 27 | "green" : "0.553", 28 | "red" : "0.204" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Assets.xcassets/Red.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.184", 9 | "green" : "0.259", 10 | "red" : "0.800" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.184", 27 | "green" : "0.259", 28 | "red" : "0.800" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Assets.xcassets/TextGray1.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.729", 9 | "green" : "0.729", 10 | "red" : "0.729" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.729", 27 | "green" : "0.729", 28 | "red" : "0.729" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Assets.xcassets/TextGray2.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.553", 9 | "green" : "0.553", 10 | "red" : "0.553" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.553", 27 | "green" : "0.553", 28 | "red" : "0.553" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Assets.xcassets/Yellow.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.337", 9 | "green" : "0.816", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.337", 27 | "green" : "0.816", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Assets.xcassets/icon_arrow_down.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_arrow_down.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Assets.xcassets/icon_arrow_down.imageset/icon_arrow_down.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/icon_arrow_down.imageset/icon_arrow_down.png -------------------------------------------------------------------------------- /Assets.xcassets/icon_cached.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_cached.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Assets.xcassets/icon_cached.imageset/icon_cached.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/icon_cached.imageset/icon_cached.png -------------------------------------------------------------------------------- /Assets.xcassets/icon_chat_off.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_chat_off.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Assets.xcassets/icon_chat_off.imageset/icon_chat_off.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/icon_chat_off.imageset/icon_chat_off.png -------------------------------------------------------------------------------- /Assets.xcassets/icon_chat_on.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_chat_on.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Assets.xcassets/icon_chat_on.imageset/icon_chat_on.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/icon_chat_on.imageset/icon_chat_on.png -------------------------------------------------------------------------------- /Assets.xcassets/icon_do_not_disturb.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_do_not_disturb.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Assets.xcassets/icon_do_not_disturb.imageset/icon_do_not_disturb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/icon_do_not_disturb.imageset/icon_do_not_disturb.png -------------------------------------------------------------------------------- /Assets.xcassets/icon_group.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "groups_2_FILL1_wght400_GRAD0_opsz24 1.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Assets.xcassets/icon_group.imageset/groups_2_FILL1_wght400_GRAD0_opsz24 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/icon_group.imageset/groups_2_FILL1_wght400_GRAD0_opsz24 1.png -------------------------------------------------------------------------------- /Assets.xcassets/icon_info_dark.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_info_dark.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Assets.xcassets/icon_info_dark.imageset/icon_info_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/icon_info_dark.imageset/icon_info_dark.png -------------------------------------------------------------------------------- /Assets.xcassets/icon_info_light.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_info_light.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Assets.xcassets/icon_info_light.imageset/icon_info_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/icon_info_light.imageset/icon_info_light.png -------------------------------------------------------------------------------- /Assets.xcassets/icon_invite.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Vector.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Assets.xcassets/icon_invite.imageset/Vector.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/icon_invite.imageset/Vector.png -------------------------------------------------------------------------------- /Assets.xcassets/icon_logout.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_logout.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Assets.xcassets/icon_logout.imageset/icon_logout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/icon_logout.imageset/icon_logout.png -------------------------------------------------------------------------------- /Assets.xcassets/icon_mic_off.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_mic_off.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Assets.xcassets/icon_mic_off.imageset/icon_mic_off.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/icon_mic_off.imageset/icon_mic_off.png -------------------------------------------------------------------------------- /Assets.xcassets/icon_mic_off_red.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_mic_off_red.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Assets.xcassets/icon_mic_off_red.imageset/icon_mic_off_red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/icon_mic_off_red.imageset/icon_mic_off_red.png -------------------------------------------------------------------------------- /Assets.xcassets/icon_mic_on.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_mic_on.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Assets.xcassets/icon_mic_on.imageset/icon_mic_on.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/icon_mic_on.imageset/icon_mic_on.png -------------------------------------------------------------------------------- /Assets.xcassets/icon_start_broadcast.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_start_broadcast.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Assets.xcassets/icon_start_broadcast.imageset/icon_start_broadcast.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/icon_start_broadcast.imageset/icon_start_broadcast.png -------------------------------------------------------------------------------- /Assets.xcassets/icon_swap_camera.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_swap_camera.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Assets.xcassets/icon_swap_camera.imageset/icon_swap_camera.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/icon_swap_camera.imageset/icon_swap_camera.png -------------------------------------------------------------------------------- /Assets.xcassets/icon_video_off.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_video_off.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Assets.xcassets/icon_video_off.imageset/icon_video_off.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/icon_video_off.imageset/icon_video_off.png -------------------------------------------------------------------------------- /Assets.xcassets/icon_video_off_red.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_video_off_red.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Assets.xcassets/icon_video_off_red.imageset/icon_video_off_red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/icon_video_off_red.imageset/icon_video_off_red.png -------------------------------------------------------------------------------- /Assets.xcassets/icon_video_on.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_video_on.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Assets.xcassets/icon_video_on.imageset/icon_video_on.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/icon_video_on.imageset/icon_video_on.png -------------------------------------------------------------------------------- /Assets.xcassets/icon_warning.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_warning.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Assets.xcassets/icon_warning.imageset/icon_warning.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/icon_warning.imageset/icon_warning.png -------------------------------------------------------------------------------- /Assets.xcassets/welcomeImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "welcomeImage.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Assets.xcassets/welcomeImage.imageset/welcomeImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/Assets.xcassets/welcomeImage.imageset/welcomeImage.png -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT No Attribution 2 | 3 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 13 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 15 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 16 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | 18 | -------------------------------------------------------------------------------- /MultiHost-demo.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /MultiHost-demo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /MultiHost-demo.xcodeproj/xcshareddata/xcschemes/Stages-demo.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /MultiHost-demo.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /MultiHost-demo.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /MultiHostDemo/BroadcastDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BroadcastDelegateViewController.swift 3 | // Multihost 4 | // 5 | // Created by Uldis Zingis on 09/06/2022. 6 | // 7 | 8 | import AmazonIVSBroadcast 9 | 10 | class BroadcastDelegate: UIViewController, IVSBroadcastSession.Delegate { 11 | var viewModel: StageViewModel? 12 | 13 | func broadcastSession(_ session: IVSBroadcastSession, didChange state: IVSBroadcastSession.State) { 14 | print("ℹ IVSBroadcastSession state did change to \(state.text)") 15 | DispatchQueue.main.async { [weak self] in 16 | switch state { 17 | case .invalid, .disconnected, .error: 18 | self?.viewModel?.isBroadcasting = false 19 | self?.viewModel?.broadcastSession = nil 20 | case .connecting, .connected: 21 | self?.viewModel?.isBroadcasting = true 22 | @unknown default: 23 | print("ℹ ❌ IVSBroadcastSession did emit unknown state") 24 | fatalError() 25 | } 26 | } 27 | } 28 | 29 | func broadcastSession(_ session: IVSBroadcastSession, didEmitError error: Error) { 30 | print("ℹ ❌ IVSBroadcastSession did emit error \(error)") 31 | DispatchQueue.main.async { [weak self] in 32 | self?.viewModel?.appendErrorNotification(error.localizedDescription) 33 | } 34 | } 35 | } 36 | 37 | extension IVSBroadcastSession.State { 38 | var text: String { 39 | switch self { 40 | case .disconnected: return "Disconnected" 41 | case .connecting: return "Connecting" 42 | case .connected: return "Connected" 43 | case .invalid: return "Invalid" 44 | case .error: return "Error" 45 | @unknown default: return "Unknown broadcast session state" 46 | } 47 | } 48 | } 49 | 50 | -------------------------------------------------------------------------------- /MultiHostDemo/Constants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Constants.swift 3 | // Multihost 4 | // 5 | // Created by Uldis Zingis on 09/06/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct Constants { 11 | static let API_URL = "" 12 | 13 | static let sourceCodeUrl = "https://github.com/aws-samples/amazon-ivs-multi-host-for-ios-demo" 14 | 15 | // Avatar urls for users 16 | static let userAvatarUrls: [String] = [ 17 | "https://d39ii5l128t5ul.cloudfront.net/assets/animals_square/bear.png", 18 | "https://d39ii5l128t5ul.cloudfront.net/assets/animals_square/bird.png", 19 | "https://d39ii5l128t5ul.cloudfront.net/assets/animals_square/bird2.png", 20 | "https://d39ii5l128t5ul.cloudfront.net/assets/animals_square/giraffe.png", 21 | "https://d39ii5l128t5ul.cloudfront.net/assets/animals_square/hedgehog.png", 22 | "https://d39ii5l128t5ul.cloudfront.net/assets/animals_square/hippo.png" 23 | ] 24 | 25 | // App fonts 26 | static let fAppRegular = Font.system(size: 15) 27 | static let fAppRegularBold = Font.system(size: 15, weight: .semibold) 28 | static let fAppSmall = Font.system(size: 13) 29 | static let fAppSmallMedium = Font.system(size: 13, weight: .medium) 30 | static let fAppTitleSmall = Font.system(size: 13, weight: .semibold) 31 | static let fAppTitle = Font.system(size: 22, weight: .bold) 32 | static let fAppPrimary = Font.system(size: 17, weight: .bold) 33 | static let fAppPrimaryRegular = Font.system(size: 17, weight: .regular) 34 | static let fAppPrimarySmall = Font.system(size: 13, weight: .bold) 35 | static let fAppSecondary = Font.system(size: 17) 36 | static let fAppSecondaryMedium = Font.system(size: 17, weight: .medium) 37 | 38 | // Keys 39 | static let kActiveFrontCamera = "frontCameraIsActive" 40 | } 41 | -------------------------------------------------------------------------------- /MultiHostDemo/Models/ChatModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatModel.swift 3 | // Stages-demo 4 | // 5 | // Created by Uldis Zingis on 08/09/2022. 6 | // 7 | 8 | import Foundation 9 | import AmazonIVSChatMessaging 10 | 11 | class ChatModel: ObservableObject { 12 | enum MessageType: String { 13 | case message = "MESSAGE" 14 | } 15 | 16 | var tokenRequest: ChatTokenRequest? 17 | var room: ChatRoom? 18 | 19 | @Published var messages: [ChatMessage] = [] 20 | 21 | func connectChatRoom(_ chatTokenRequest: ChatTokenRequest, onError: @escaping (String?) -> Void) { 22 | print("ℹ Connecting to chat room \(chatTokenRequest.chatRoomId)") 23 | tokenRequest = chatTokenRequest 24 | room = nil 25 | room = ChatRoom(awsRegion: chatTokenRequest.awsRegion) { 26 | return ChatToken(token: chatTokenRequest.chatRoomToken.token) 27 | } 28 | room?.delegate = self 29 | 30 | Task(priority: .background) { 31 | room?.connect({ _, error in 32 | if let error = error { 33 | print("❌ Could not connect to chat room: \(error)") 34 | onError(error.localizedDescription) 35 | } 36 | }) 37 | } 38 | } 39 | 40 | func disconnect() { 41 | room?.disconnect() 42 | DispatchQueue.main.async { 43 | self.messages = [] 44 | } 45 | } 46 | 47 | func sendMessage(_ message: String, user: User, onComplete: @escaping (String?) -> Void) { 48 | let sendRequest = SendMessageRequest(content: message, 49 | attributes: [ 50 | "type": MessageType.message.rawValue, 51 | "username": user.username ?? "", 52 | "avatarUrl": user.avatarUrl ?? "" 53 | ]) 54 | room?.sendMessage(with: sendRequest, 55 | onSuccess: { responseType in 56 | onComplete(nil) 57 | }, 58 | onFailure: { chatError in 59 | print("❌ Error sending message: \(chatError)") 60 | onComplete(chatError.localizedDescription) 61 | }) 62 | } 63 | 64 | private func sendChatRequest(_ type: MessageType, connectionId: String, onComplete: @escaping (String?) -> Void) { 65 | let request = SendMessageRequest(content: type.rawValue, 66 | attributes: ["type": type.rawValue, 67 | "connectionId": "\(connectionId)"]) 68 | room?.sendMessage(with: request, 69 | onSuccess: { responseType in 70 | onComplete(nil) 71 | }, 72 | onFailure: { chatError in 73 | print("❌ Error sending request to join stage: \(chatError)") 74 | onComplete(chatError.localizedDescription) 75 | }) 76 | } 77 | } 78 | 79 | extension ChatModel: ChatRoomDelegate { 80 | func roomDidConnect(_ room: ChatRoom) { 81 | print("ℹ Did connect to chat room \(room)") 82 | } 83 | 84 | func roomDidDisconnect(_ room: ChatRoom) { 85 | print("ℹ Did disconnect from chat room \(room)") 86 | } 87 | 88 | func room(_ room: ChatRoom, didReceive message: ChatMessage) { 89 | print("ℹ Chat did receive message: \(message.content), attributes: \(message.attributes ?? [:])") 90 | guard let type = message.attributes?["type"] else { 91 | print("❌ No message 'type' in message attributes: \(message.attributes ?? [:])") 92 | return 93 | } 94 | let messageType = MessageType(rawValue: type) 95 | 96 | DispatchQueue.main.async { 97 | switch messageType { 98 | case .message: 99 | self.messages.append(message) 100 | // Store only last 50 messages 101 | if self.messages.count > 50 { 102 | self.messages.remove(at: 0) 103 | } 104 | case .none: 105 | print("❌ None message type received") 106 | } 107 | } 108 | } 109 | 110 | func room(_ room: ChatRoom, didReceive event: ChatEvent) { 111 | print("ℹ Chat did receive event: \(event)") 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /MultiHostDemo/Models/ChatTokenRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatTokenRequest.swift 3 | // Stages-demo 4 | // 5 | // Created by Uldis Zingis on 08/09/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | struct ChatAuthToken: Decodable { 11 | let token: String 12 | let sessionExpirationTime: String 13 | let tokenExpirationTime: String 14 | } 15 | 16 | struct ChatTokenRequest: Codable { 17 | enum UserCapability: String, Codable { 18 | case deleteMessage = "DELETE_MESSAGE" 19 | case disconnectUser = "DISCONNECT_USER" 20 | case sendMessage = "SEND_MESSAGE" 21 | } 22 | 23 | enum TokenRequestError: Error { 24 | case serverNotSet 25 | } 26 | 27 | let user: User 28 | let chatRoomId: String 29 | let chatRoomToken: ChatTokenData 30 | 31 | var awsRegion: String { 32 | chatRoomId.components(separatedBy: ":")[3] 33 | } 34 | 35 | func fetchResponse() async throws -> Data { 36 | print("ℹ Requesting new chat auth token") 37 | guard let url = URL(string: "\(Constants.API_URL)/chat/auth") else { 38 | print("❌ Server url not set in Constats.swift") 39 | throw TokenRequestError.serverNotSet 40 | } 41 | let authSession = URLSession(configuration: .default) 42 | var authRequest = URLRequest(url: url) 43 | authRequest.httpMethod = "POST" 44 | authRequest.addValue("application/json", forHTTPHeaderField: "Content-Type") 45 | authRequest.httpBody = """ 46 | { 47 | "roomIdentifier": "\(chatRoomId)", 48 | "userId": "\(user.userId)", 49 | "attributes": { 50 | "username": "\(user.username ?? "")", 51 | "avatar": "\(user.avatarUrl ?? "")" 52 | }, 53 | "capabilities": ["\(UserCapability.sendMessage.rawValue)"], 54 | "durationInMinutes": 55 55 | } 56 | """.data(using: .utf8) 57 | authRequest.timeoutInterval = 10 58 | 59 | return try await authSession.data(for: authRequest).0 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /MultiHostDemo/Models/Managers/PermissionsManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Permissions.swift 3 | // Stages-demo 4 | // 5 | // Created by Uldis Zingis on 30/08/2022. 6 | // 7 | 8 | import AVFoundation 9 | 10 | func checkAVPermissions(_ result: @escaping (Bool) -> Void) { 11 | checkOrGetPermission(for: .video) { granted in 12 | guard granted else { 13 | result(false) 14 | return 15 | } 16 | checkOrGetPermission(for: .audio) { granted in 17 | guard granted else { 18 | result(false) 19 | return 20 | } 21 | result(true) 22 | } 23 | } 24 | } 25 | 26 | func checkOrGetPermission(for mediaType: AVMediaType, _ result: @escaping (Bool) -> Void) { 27 | func mainThreadResult(_ success: Bool) { 28 | DispatchQueue.main.async { result(success) } 29 | } 30 | switch AVCaptureDevice.authorizationStatus(for: mediaType) { 31 | case .authorized: mainThreadResult(true) 32 | case .notDetermined: AVCaptureDevice.requestAccess(for: mediaType) { mainThreadResult($0) } 33 | case .denied, .restricted: mainThreadResult(false) 34 | @unknown default: mainThreadResult(false) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /MultiHostDemo/Models/Managers/Server.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WebsocketModel.swift 3 | // Multihost 4 | // 5 | // Created by Uldis Zingis on 01/08/2022. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | protocol ServerDelegate { 12 | func didEmitError(error: String) 13 | } 14 | 15 | class Server: ObservableObject { 16 | var delegate: ServerDelegate? 17 | var decoder = JSONDecoder() 18 | 19 | @Published var stageDetails: StageDetails? 20 | @Published var stageJoinDetails: StageJoinDetails? 21 | @Published var stageHostDetails: StageHostDetails? 22 | 23 | var joinedGroupId: String = "" 24 | var joinedStagePlaybackUrl: String = "" 25 | 26 | func getAllStages(_ onComplete: @escaping (Bool, [StageDetails]?, String?) -> Void) { 27 | send("POST", endpoint: "list", body: nil, onComplete: { success, data, errorMessage in 28 | 29 | guard let data = data else { 30 | onComplete(false, nil, "No data in response") 31 | return 32 | } 33 | 34 | do { 35 | let stages = try self.decoder.decode([StageDetails].self, from: data) 36 | onComplete(success, stages, errorMessage) 37 | } catch { 38 | print("❌ \(error)") 39 | self.delegate?.didEmitError(error: "Could not decode get all stages response: \(error.localizedDescription)") 40 | onComplete(false, nil, "No data in response") 41 | return 42 | } 43 | }) 44 | } 45 | 46 | func createStage(user: User, onComplete: @escaping (Bool, String?) -> Void) { 47 | print("ℹ Creating new stage for user \(user.userId)") 48 | 49 | let body = """ 50 | { 51 | "userId": "\(user.userId)", 52 | "attributes": { 53 | "username": "\(user.username ?? "")", 54 | "avatarUrl": "\(user.avatarUrl ?? "")" 55 | }, 56 | "id": "" 57 | } 58 | """ 59 | 60 | send("POST", endpoint: "create", body: body, onComplete: { [weak self] success, data, errorMessage in 61 | if let error = errorMessage { 62 | onComplete(false, error) 63 | } 64 | 65 | guard let data = data else { 66 | onComplete(false, "No data in response") 67 | return 68 | } 69 | 70 | do { 71 | self?.stageHostDetails = try self?.decoder.decode(StageHostDetails.self, from: data) 72 | print("ℹ got host stage details: \(String(describing: self?.stageHostDetails))") 73 | } catch { 74 | print("❌ \(error)") 75 | self?.delegate?.didEmitError(error: "Could not decode stage create response: \(error)") 76 | onComplete(false, "Could not decode stage create response") 77 | return 78 | } 79 | 80 | onComplete(success, errorMessage) 81 | }) 82 | } 83 | 84 | func joinStage(user: User, groupId: String, onComplete: @escaping (StageJoinDetails?, String?) -> Void) { 85 | let body = """ 86 | { 87 | "groupId": "\(groupId)", 88 | "userId": "\(user.userId)", 89 | "attributes": { 90 | "avatarUrl": "\(user.avatarUrl ?? "")", 91 | "username": "\(user.username ?? "")" 92 | } 93 | } 94 | """ 95 | send("POST", endpoint: "join", body: body, onComplete: { [weak self] success, data, errorMessage in 96 | if let error = errorMessage { 97 | print("❌ Error on stage join: \(error)") 98 | } 99 | 100 | guard let data = data else { 101 | print("❌ No data in join stage response") 102 | onComplete(nil, "No data in response. \(errorMessage ?? "")") 103 | return 104 | } 105 | 106 | do { 107 | self?.stageJoinDetails = try self?.decoder.decode(StageJoinDetails.self, from: data) 108 | print("ℹ got stage join response: \(String(describing: self?.stageJoinDetails))") 109 | self?.joinedGroupId = groupId 110 | onComplete(self?.stageJoinDetails, nil) 111 | } catch { 112 | print("❌ \(error)") 113 | self?.delegate?.didEmitError(error: "Could not decode stage join response: \(error.localizedDescription)") 114 | onComplete(nil, error.localizedDescription) 115 | return 116 | } 117 | }) 118 | } 119 | 120 | func deleteStage(onComplete: @escaping () -> Void) { 121 | guard let stage = stageHostDetails else { 122 | print("❌ Can't delete stage - not a host") 123 | return 124 | } 125 | 126 | print("ℹ Deleting created stage...") 127 | 128 | let body = """ 129 | { 130 | "groupId": "\(stage.groupId)" 131 | } 132 | """ 133 | 134 | send("DELETE", endpoint: "delete", body: body, onComplete: { _, _, errorMessage in 135 | if let error = errorMessage { 136 | print("❌ Error from delete stage response: \(error)") 137 | } 138 | onComplete() 139 | }) 140 | } 141 | 142 | func disconnect(_ participantId: String, from groupId: String, userId: String) { 143 | let body = """ 144 | { 145 | "groupId": "\(groupId)", 146 | "participantId": "\(participantId)", 147 | "reason": "Kicked by another user", 148 | "userId": "\(userId)" 149 | } 150 | """ 151 | send("POST", endpoint: "disconnect", body: body) { [weak self] success, data, error in 152 | if let error = error { 153 | print("❌ Error disconnecting participant \(participantId): \(error)") 154 | } 155 | 156 | self?.joinedGroupId = "" 157 | } 158 | } 159 | 160 | private func send(_ method: String, endpoint: String, body: String?, onComplete: @escaping (Bool, Data?, String?) -> Void) { 161 | guard let url = URL(string: "\(Constants.API_URL)/\(endpoint)") else { 162 | delegate?.didEmitError(error: "Server url not set in Constats.swift") 163 | return 164 | } 165 | 166 | let session = URLSession(configuration: .default) 167 | var request = URLRequest(url: url) 168 | request.timeoutInterval = 10 169 | request.httpMethod = method 170 | request.addValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") 171 | 172 | if let body = body { 173 | request.httpBody = body.data(using: .utf8) 174 | } 175 | 176 | print("ℹ 🔗 sending \(method) '\(endpoint)' \(body != nil ? "with body: \(body!)" : "")") 177 | 178 | session.dataTask(with: request) { data, response, error in 179 | if let httpResponse = response as? HTTPURLResponse { 180 | if ![200, 204].contains(httpResponse.statusCode) { 181 | print("ℹ 🔗 Got status code \(httpResponse.statusCode) when sending \(request)") 182 | self.delegate?.didEmitError(error: "Got status code \(httpResponse.statusCode) when sending \(request)") 183 | if let data = data, let response = String(data: data, encoding: .utf8) { 184 | print(response) 185 | onComplete(false, nil, "\(httpResponse.statusCode) \(response)") 186 | } 187 | return 188 | } 189 | 190 | if let error = error { 191 | print("ℹ 🔗 ❌ Failed to send '\(method)' to '\(endpoint)': \(error)") 192 | self.delegate?.didEmitError(error: "Failed to send '\(method)' to '\(endpoint)': \(error)") 193 | onComplete(false, nil, error.localizedDescription) 194 | return 195 | } 196 | 197 | print("ℹ 🔗 sent \(method) to '\(endpoint)' successfully") 198 | onComplete(true, data, nil) 199 | } 200 | }.resume() 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /MultiHostDemo/Models/Managers/ServicesManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ServicesManager.swift 3 | // Stages-demo 4 | // 5 | // Created by Uldis Zingis on 31/08/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | class ServicesManager: ObservableObject { 11 | @ObservedObject var server: Server 12 | @ObservedObject var user: User 13 | @ObservedObject var chatModel = ChatModel() 14 | 15 | @Published var viewModel: StageViewModel? 16 | 17 | init() { 18 | self.server = Server() 19 | self.user = User(username: UserDefaults.standard.string(forKey: "username") ?? "", 20 | avatarUrl: UserDefaults.standard.string(forKey: "avatar") ?? Constants.userAvatarUrls.first ?? "") 21 | self.viewModel = StageViewModel(services: self) 22 | server.delegate = viewModel 23 | } 24 | 25 | func disconnectFromStage(_ onComplete: @escaping () -> Void) { 26 | chatModel.disconnect() 27 | 28 | if user.isHost { 29 | viewModel?.endSession() 30 | viewModel?.deleteStage(onComplete: { [weak self] in 31 | self?.user.isHost = false 32 | onComplete() 33 | }) 34 | } else { 35 | viewModel?.endSession() 36 | onComplete() 37 | } 38 | } 39 | 40 | func connect(to chat: Chat) { 41 | print("ℹ Connecting to chat room \(chat.id)") 42 | let tokenRequest = ChatTokenRequest(user: user, chatRoomId: chat.id, chatRoomToken: chat.token) 43 | chatModel.connectChatRoom(tokenRequest) { error in 44 | if let error = error { 45 | self.viewModel?.appendErrorNotification(error) 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /MultiHostDemo/Models/Notification.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Notification.swift 3 | // Multihost 4 | // 5 | // Created by Uldis Zingis on 27/07/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | enum NotificationType: String { 11 | case success, error, warning, blank 12 | } 13 | 14 | struct Notification: Hashable { 15 | let id: String 16 | let type: NotificationType 17 | let message: String 18 | let title: String 19 | let iconName: String 20 | 21 | init(type: NotificationType, message: String) { 22 | self.id = UUID().uuidString 23 | self.type = type 24 | self.message = message 25 | self.title = type.rawValue.uppercased() 26 | self.iconName = Notification.iconNameFor(type) 27 | } 28 | 29 | private static func iconNameFor(_ type: NotificationType) -> String { 30 | switch type { 31 | case .success: 32 | return "icon_info_light" 33 | case .error: 34 | return "icon_warning" 35 | case .warning: 36 | return "icon_info_dark" 37 | case .blank: 38 | return "icon_info_dark" 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /MultiHostDemo/Models/ParticipantData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ParticipantData.swift 3 | // Stages-demo 4 | // 5 | // Created by Uldis Zingis on 26/01/2023. 6 | // 7 | 8 | import Foundation 9 | import AmazonIVSBroadcast 10 | 11 | class ParticipantData: Identifiable, ObservableObject { 12 | let id: String 13 | let isLocal: Bool 14 | var participantId: String? 15 | var username: String = "" 16 | var avatarUrl: String = "" 17 | var isHost: Bool = false 18 | 19 | @Published var info: IVSParticipantInfo? 20 | @Published var publishState: IVSParticipantPublishState = .notPublished 21 | @Published var streams: [IVSStageStream] = [] { 22 | didSet { 23 | videoMuted = streams.first(where: { $0.device is IVSImageDevice })?.isMuted ?? false 24 | audioMuted = streams.first(where: { $0.device is IVSAudioDevice })?.isMuted ?? false 25 | } 26 | } 27 | 28 | // The host-app has explicitly requested audio only 29 | @Published var wantsAudioOnly = false 30 | // The host-app is in the background and requires audio only 31 | @Published var requiresAudioOnly = false 32 | // The actual audio only state to be used for subscriptions 33 | var isAudioOnly: Bool { 34 | return wantsAudioOnly || requiresAudioOnly 35 | } 36 | 37 | @Published var wantsSubscribed = true 38 | @Published var wantsBroadcast = true 39 | @Published var videoMuted = false 40 | @Published var audioMuted = false 41 | 42 | var broadcastSlotName: String { 43 | if isLocal { 44 | return "localUser" 45 | } else { 46 | guard let participantId = participantId else { 47 | fatalError("non-local participants must have a participantId") 48 | } 49 | return "participant-\(participantId)" 50 | } 51 | } 52 | 53 | private var imageDevice: IVSImageDevice? { 54 | return streams.lazy.compactMap { $0.device as? IVSImageDevice }.first 55 | } 56 | 57 | var previewView: ParticipantView { 58 | var preview: IVSImagePreviewView? 59 | do { 60 | preview = try imageDevice?.previewView(with: .fill) 61 | } catch { 62 | print("ℹ ❌ got error when trying to get participant preview view from IVSImageDevice: \(error)") 63 | } 64 | let view = ParticipantView(preview: preview, participant: self) 65 | return view 66 | } 67 | 68 | init(isLocal: Bool, info: IVSParticipantInfo?, participantId: String?) { 69 | self.id = UUID().uuidString 70 | self.isLocal = isLocal 71 | self.participantId = participantId 72 | self.info = info 73 | if !isLocal { 74 | self.username = info?.attributes["username"] as? String ?? "" 75 | self.avatarUrl = info?.attributes["avatarUrl"] as? String ?? "" 76 | self.isHost = Bool(info?.attributes["isHost"] as? String ?? "false") ?? false 77 | } 78 | } 79 | 80 | func toggleAudioMute() { 81 | audioMuted = !audioMuted 82 | streams 83 | .compactMap({ $0.device as? IVSAudioDevice }) 84 | .first? 85 | .setGain(audioMuted ? 0 : 1) 86 | } 87 | 88 | func toggleVideoMute() { 89 | videoMuted = !videoMuted 90 | wantsBroadcast = !videoMuted 91 | } 92 | 93 | func mutatingStreams(_ stream: IVSStageStream?, modifier: (inout IVSStageStream) -> Void) { 94 | guard let index = streams.firstIndex(where: { $0.device.descriptor().urn == stream?.device.descriptor().urn }) else { 95 | fatalError("Something is out of sync, investigate") 96 | } 97 | 98 | var stream = streams[index] 99 | modifier(&stream) 100 | streams[index] = stream 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /MultiHostDemo/Models/ServerResponses.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Stage.swift 3 | // Multihost 4 | // 5 | // Created by Uldis Zingis on 29/07/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | struct StageHostDetails: Decodable { 11 | let groupId: String 12 | let channel: Channel 13 | let stage: Stage 14 | let chat: Chat 15 | } 16 | 17 | struct StageJoinDetails: Decodable { 18 | let stage: Stage 19 | let chat: Chat 20 | } 21 | 22 | struct Stage: Decodable { 23 | let id: String 24 | let token: StageTokenData 25 | } 26 | 27 | struct Chat: Decodable { 28 | let id: String 29 | let token: ChatTokenData 30 | } 31 | 32 | struct StageTokenData: Decodable { 33 | let token: String 34 | let participantId: String 35 | let expirationTime: String 36 | } 37 | 38 | struct ChatTokenData: Codable { 39 | let token: String 40 | let sessionExpirationTime: String? 41 | let tokenExpirationTime: String? 42 | } 43 | 44 | struct StageDetails: Decodable { 45 | let roomId: String 46 | let channelId: String 47 | let userAttributes: UserAttributes 48 | let groupId: String 49 | let stageId: String 50 | 51 | enum CodingKeys: String, CodingKey { 52 | case roomId 53 | case channelId 54 | case stageId 55 | case groupId 56 | case userAttributes = "stageAttributes" 57 | } 58 | 59 | static let empty = StageDetails( 60 | roomId: "", 61 | channelId: "", 62 | userAttributes: UserAttributes(username: "", avatarUrl: ""), 63 | groupId: "", 64 | stageId: "") 65 | } 66 | 67 | struct UserAttributes: Decodable { 68 | let username: String 69 | let avatarUrl: String 70 | } 71 | 72 | struct Channel: Decodable { 73 | let id: String 74 | let playbackUrl: String 75 | let ingestEndpoint: String 76 | let streamKey: String 77 | } 78 | 79 | struct StreamKey: Decodable { 80 | let arn: String 81 | let channelArn: String 82 | let value: String 83 | } 84 | -------------------------------------------------------------------------------- /MultiHostDemo/Models/StageViewModel+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StageViewModel+Extensions.swift 3 | // Stages-demo 4 | // 5 | // Created by Uldis Zingis on 26/01/2023. 6 | // 7 | 8 | import AmazonIVSBroadcast 9 | 10 | extension StageViewModel: ServerDelegate { 11 | func didEmitError(error: String) { 12 | print("ℹ ❌ \(error)") 13 | appendErrorNotification(error) 14 | } 15 | } 16 | 17 | extension StageViewModel: IVSMicrophoneDelegate { 18 | func underlyingInputSourceChanged(for microphone: IVSMicrophone, toInputSource inputSource: IVSDeviceDescriptor?) { 19 | guard localStreams.contains(where: { $0.device === microphone }) else { return } 20 | selectedMicrophone = inputSource 21 | } 22 | } 23 | 24 | extension StageViewModel: IVSErrorDelegate { 25 | func source(_ source: IVSErrorSource, didEmitError error: Error) { 26 | print("ℹ ❌ IVSError \(error)") 27 | appendErrorNotification(error.localizedDescription) 28 | } 29 | } 30 | 31 | extension StageViewModel: IVSStageStrategy { 32 | func stage(_ stage: IVSStage, shouldSubscribeToParticipant participant: IVSParticipantInfo) -> IVSStageSubscribeType { 33 | guard let data = dataForParticipant(participant.participantId) else { 34 | return .none 35 | } 36 | let subType: IVSStageSubscribeType 37 | if data.wantsSubscribed { 38 | subType = data.isAudioOnly ? .audioOnly : .audioVideo 39 | } else { 40 | subType = .none 41 | } 42 | 43 | return subType 44 | } 45 | 46 | func stage(_ stage: IVSStage, shouldPublishParticipant participant: IVSParticipantInfo) -> Bool { 47 | return localUserWantsPublish 48 | } 49 | 50 | func stage(_ stage: IVSStage, streamsToPublishForParticipant participant: IVSParticipantInfo) -> [IVSLocalStageStream] { 51 | guard participantsData[0].participantId == participant.participantId else { 52 | return [] 53 | } 54 | return localStreams 55 | } 56 | } 57 | 58 | extension StageViewModel: IVSStageRenderer { 59 | func stage(_ stage: IVSStage, participantDidJoin participant: IVSParticipantInfo) { 60 | print("ℹ participant \(participant.participantId) did join") 61 | if participant.isLocal { 62 | participantsData[0].participantId = participant.participantId 63 | } else { 64 | participantsData.append(ParticipantData(isLocal: false, info: participant, participantId: participant.participantId)) 65 | } 66 | } 67 | 68 | func stage(_ stage: IVSStage, participantDidLeave participant: IVSParticipantInfo) { 69 | print("ℹ participant \(participant.participantId) did leave") 70 | if participant.isLocal { 71 | participantsData[0].participantId = nil 72 | } else { 73 | if let index = participantsData.firstIndex(where: { $0.participantId == participant.participantId }) { 74 | participantsData.remove(at: index) 75 | } 76 | } 77 | } 78 | 79 | func stage(_ stage: IVSStage, participant: IVSParticipantInfo, didChange publishState: IVSParticipantPublishState) { 80 | print("ℹ participant \(participant.participantId) didChangePublishState to '\(publishState.text)'") 81 | mutatingParticipant(participant.participantId) { data in 82 | data.publishState = publishState 83 | } 84 | } 85 | 86 | func stage(_ stage: IVSStage, participant: IVSParticipantInfo, didChange subscribeState: IVSParticipantSubscribeState) { 87 | print("ℹ participant \(participant.participantId) didChangeSubscribeState to '\(subscribeState.text)'") 88 | 89 | } 90 | 91 | func stage(_ stage: IVSStage, participant: IVSParticipantInfo, didAdd streams: [IVSStageStream]) { 92 | print("ℹ participant \(participant.participantId) didAdd \(streams.count) streams") 93 | if participant.isLocal { return } 94 | 95 | mutatingParticipant(participant.participantId) { data in 96 | data.streams.append(contentsOf: streams) 97 | } 98 | } 99 | 100 | func stage(_ stage: IVSStage, participant: IVSParticipantInfo, didRemove streams: [IVSStageStream]) { 101 | print("ℹ participant \(participant.participantId) didRemove \(streams.count) streams") 102 | if participant.isLocal { return } 103 | 104 | mutatingParticipant(participant.participantId) { data in 105 | let oldUrns = streams.map { $0.device.descriptor().urn } 106 | data.streams.removeAll(where: { stream in 107 | return oldUrns.contains(stream.device.descriptor().urn) 108 | }) 109 | } 110 | } 111 | 112 | func stage(_ stage: IVSStage, participant: IVSParticipantInfo, didChangeMutedStreams streams: [IVSStageStream]) { 113 | print("ℹ participant \(participant.participantId) didChangeMutedStreams") 114 | if participant.isLocal { return } 115 | 116 | for stream in streams { 117 | print("ℹ is muted: \(stream.isMuted)") 118 | mutatingParticipant(participant.participantId) { data in 119 | if let index = data.streams.firstIndex(of: stream) { 120 | data.streams[index] = stream 121 | } 122 | } 123 | } 124 | } 125 | 126 | func stage(_ stage: IVSStage, didChange connectionState: IVSStageConnectionState, withError error: Error?) { 127 | print("ℹ didChangeConnectionStateWithError state '\(connectionState.text)', error: \(String(describing: error))") 128 | stageConnectionState = connectionState; 129 | } 130 | } 131 | 132 | extension IVSStageConnectionState { 133 | var text: String { 134 | switch self { 135 | case .disconnected: return "Disconnected" 136 | case .connecting: return "Connecting" 137 | case .connected: return "Connected" 138 | @unknown default: return "Unknown connection state" 139 | } 140 | } 141 | } 142 | 143 | extension IVSParticipantPublishState { 144 | var text: String { 145 | switch self { 146 | case .notPublished: return "Not Published" 147 | case .attemptingPublish: return "Attempting to Publish" 148 | case .published: return "Published" 149 | @unknown default: return "Unknown publish state" 150 | } 151 | } 152 | } 153 | 154 | extension IVSParticipantSubscribeState { 155 | var text: String { 156 | switch self { 157 | case .subscribed: return "Subscribed" 158 | case .notSubscribed: return "Not Subscribed" 159 | case .attemptingSubscribe: return "Attempting Subscribe" 160 | @unknown default: return "Unknown subscribe state" 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /MultiHostDemo/Models/StageViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StageViewModel.swift 3 | // Multihost 4 | // 5 | // Created by Uldis Zingis on 09/06/2022. 6 | // 7 | 8 | import Foundation 9 | import AmazonIVSBroadcast 10 | import SwiftUI 11 | 12 | class StageViewModel: NSObject, ObservableObject { 13 | let services: ServicesManager 14 | 15 | @Published var primaryCameraName = "None" 16 | @Published var primaryMicrophoneName = "None" 17 | @Published var allStages: [StageDetails] = [] 18 | @Published private(set) var notifications: [Notification] = [] { 19 | didSet { 20 | // Hide success notifications after 5 seconds 21 | if let newNotification = notifications.last, newNotification.type == .success { 22 | DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: { 23 | if let index = self.notifications.firstIndex(of: newNotification) { 24 | self.notifications.remove(at: index) 25 | } 26 | }) 27 | } 28 | } 29 | } 30 | 31 | @Published var sessionRunning: Bool = false 32 | @Published var isBroadcasting: Bool = false 33 | @Published var stageConnectionState: IVSStageConnectionState = .disconnected 34 | @Published var localUserAudioMuted: Bool = false 35 | @Published var localUserVideoMuted: Bool = false 36 | @Published var localUserWantsPublish: Bool = true 37 | 38 | @Published var participantsData: [ParticipantData] = [] { 39 | didSet { 40 | updateBroadcastSlots() 41 | } 42 | } 43 | 44 | var participantCount: Int { 45 | return participantsData.count 46 | } 47 | 48 | private(set) var videoConfig = IVSLocalStageStreamVideoConfiguration() 49 | private let broadcastConfig = IVSPresets.configurations().standardPortrait() 50 | 51 | var selectedCamera: IVSDeviceDescriptor? { 52 | didSet { 53 | primaryCameraName = selectedCamera?.friendlyName ?? "None" 54 | } 55 | } 56 | 57 | var selectedMicrophone: IVSDeviceDescriptor? { 58 | didSet { 59 | primaryMicrophoneName = selectedMicrophone?.friendlyName ?? "None" 60 | } 61 | } 62 | 63 | private var shouldRepublishWhenEnteringForeground = false 64 | private var stage: IVSStage? 65 | var localStreams: [IVSLocalStageStream] = [] { 66 | didSet { updateBroadcastBindings() } 67 | } 68 | var broadcastSession: IVSBroadcastSession? 69 | private var broadcastSlots: [IVSMixerSlotConfiguration] = [] { 70 | didSet { 71 | guard let broadcastSession = broadcastSession else { return } 72 | let oldSlots = broadcastSession.mixer.slots() 73 | // We're going to remove old slots, then add new slots, and update existing slots. 74 | 75 | // Removing old slots 76 | oldSlots.forEach { oldSlot in 77 | if !broadcastSlots.contains(where: { $0.name == oldSlot.name }) { 78 | broadcastSession.mixer.removeSlot(withName: oldSlot.name) 79 | } 80 | } 81 | 82 | // Adding new slots 83 | broadcastSlots.forEach { newSlot in 84 | if !oldSlots.contains(where: { $0.name == newSlot.name }) { 85 | broadcastSession.mixer.addSlot(newSlot) 86 | } 87 | } 88 | 89 | // Update existing slots 90 | broadcastSlots.forEach { newSlot in 91 | if oldSlots.contains(where: { $0.name == newSlot.name }) { 92 | broadcastSession.mixer.transitionSlot(withName: newSlot.name, toState: newSlot, duration: 0.3) 93 | } 94 | } 95 | } 96 | } 97 | 98 | let deviceDiscovery = IVSDeviceDiscovery() 99 | let deviceSlotName = UUID().uuidString 100 | var broadcastDelegate: BroadcastDelegate? 101 | var currentJoinToken: String = "" 102 | 103 | init(services: ServicesManager) { 104 | self.services = services 105 | super.init() 106 | self.setupLocalUser() 107 | 108 | NotificationCenter.default.addObserver(self, 109 | selector: #selector(applicationDidEnterBackground), 110 | name: UIApplication.didEnterBackgroundNotification, 111 | object: nil) 112 | NotificationCenter.default.addObserver(self, 113 | selector: #selector(applicationWillEnterForeground), 114 | name: UIApplication.willEnterForegroundNotification, 115 | object: nil) 116 | NotificationCenter.default.addObserver(self, 117 | selector: #selector(mediaServicesLost), 118 | name: AVAudioSession.mediaServicesWereLostNotification, 119 | object: nil) 120 | NotificationCenter.default.addObserver(self, 121 | selector: #selector(mediaServicesReset), 122 | name: AVAudioSession.mediaServicesWereResetNotification, 123 | object: nil) 124 | } 125 | 126 | deinit { 127 | NotificationCenter.default.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil) 128 | NotificationCenter.default.removeObserver(self, name: UIApplication.willEnterForegroundNotification, object: nil) 129 | NotificationCenter.default.removeObserver(self, name: AVAudioSession.mediaServicesWereLostNotification, object: nil) 130 | NotificationCenter.default.removeObserver(self, name: AVAudioSession.mediaServicesWereResetNotification, object: nil) 131 | } 132 | 133 | private func setupLocalUser() { 134 | setLocalCamera(to: .front) 135 | 136 | #if targetEnvironment(simulator) 137 | let devices: [Any] = [] 138 | #else 139 | let devices = deviceDiscovery.listLocalDevices() 140 | #endif 141 | 142 | if let microphone = devices 143 | .compactMap({ $0 as? IVSMicrophone }) 144 | .first 145 | { 146 | microphone.delegate = self 147 | microphone.isEchoCancellationEnabled = true 148 | self.localStreams.append(IVSLocalStageStream(device: microphone)) 149 | } 150 | 151 | let localParticipant = ParticipantData(isLocal: true, info: nil, participantId: nil) 152 | localParticipant.username = services.user.username ?? "" 153 | localParticipant.avatarUrl = services.user.avatarUrl ?? "" 154 | self.participantsData.append(localParticipant) 155 | self.participantsData[0].streams = self.localStreams 156 | } 157 | 158 | @objc private func applicationDidEnterBackground() { 159 | let connectingOrConnected = (stageConnectionState == .connecting) || (stageConnectionState == .connected) 160 | 161 | if connectingOrConnected { 162 | shouldRepublishWhenEnteringForeground = localUserWantsPublish 163 | localUserWantsPublish = false 164 | participantsData 165 | .compactMap { $0.participantId } 166 | .forEach { 167 | mutatingParticipant($0) { data in 168 | data.requiresAudioOnly = true 169 | } 170 | } 171 | stage?.refreshStrategy() 172 | } 173 | } 174 | 175 | @objc private func applicationWillEnterForeground() { 176 | if shouldRepublishWhenEnteringForeground { 177 | localUserWantsPublish = true 178 | shouldRepublishWhenEnteringForeground = false 179 | } 180 | if !participantsData.isEmpty { 181 | participantsData 182 | .compactMap { $0.participantId } 183 | .forEach { 184 | mutatingParticipant($0) { data in 185 | data.requiresAudioOnly = false 186 | } 187 | } 188 | stage?.refreshStrategy() 189 | } 190 | } 191 | 192 | @objc private func mediaServicesLost() { 193 | // once media services are lost, errors will start to fire. Kill the session ASAP and wait for the reset 194 | // notification to stream again. 195 | destroyBroadcastSession() 196 | print("ℹ ❌ media services were lost") 197 | appendErrorNotification("The Media Services on this device have been lost, no video or audio work can be done for a couple seconds. Please wait…") 198 | } 199 | 200 | @objc private func mediaServicesReset() { 201 | print("ℹ media services were reset") 202 | appendSuccessNotification("Media services restored - OK to start broadcast again") 203 | } 204 | 205 | func initializeStage(onComplete: @escaping () -> Void) { 206 | IVSSession.applicationAudioSessionStrategy = .playAndRecordDefaultToSpeaker 207 | IVSBroadcastSession.applicationAudioSessionStrategy = .playAndRecordDefaultToSpeaker 208 | DispatchQueue.main.async { 209 | self.broadcastDelegate = BroadcastDelegate() 210 | self.broadcastDelegate?.viewModel = self 211 | } 212 | 213 | onComplete() 214 | } 215 | 216 | func clearNotifications() { 217 | DispatchQueue.main.async { 218 | self.notifications = [] 219 | } 220 | } 221 | 222 | func createStage(user: User, onComplete: @escaping (Bool) -> Void) { 223 | services.server.createStage(user: user) { [weak self] success, error in 224 | if success { 225 | print("ℹ New stage created for user \(user.userId)") 226 | onComplete(true) 227 | } else { 228 | print("ℹ ❌ Could not create stage: \(error ?? "")") 229 | self?.appendErrorNotification(error ?? "") 230 | onComplete(false) 231 | } 232 | } 233 | } 234 | 235 | func deleteStage(onComplete: @escaping () -> Void) { 236 | services.server.deleteStage() { 237 | print("ℹ Stage deleted") 238 | onComplete() 239 | } 240 | } 241 | 242 | func getToken(for stage: StageDetails, onComplete: @escaping (StageJoinDetails?, String?) -> Void) { 243 | services.server.joinStage(user: services.user, groupId: stage.groupId) { [weak self] stageJoinResponse, error in 244 | let token = stageJoinResponse?.stage 245 | if token == nil { 246 | self?.appendErrorNotification("Can't join stage - missing stage token") 247 | } 248 | self?.services.user.participantId = stageJoinResponse?.stage.token.participantId 249 | onComplete(stageJoinResponse, nil) 250 | } 251 | } 252 | 253 | func joinAsParticipant(_ token: String, onSuccess: () -> Void) { 254 | joinStage(token, onSuccess: onSuccess) 255 | 256 | if let chat = services.server.stageJoinDetails?.chat { 257 | services.connect(to: chat) 258 | } 259 | } 260 | 261 | func joinAsHost(onComplete: @escaping (Bool) -> Void) { 262 | print("ℹ Joining stage as host...") 263 | guard let hostToken = services.server.stageHostDetails?.stage.token else { 264 | print("❌ Can't join - no auth token in host stage details") 265 | self.appendErrorNotification("Can't join created stage - missing host stage details") 266 | onComplete(false) 267 | return 268 | } 269 | 270 | if let chat = services.server.stageHostDetails?.chat { 271 | services.connect(to: chat) 272 | } 273 | 274 | joinStage(hostToken.token) { 275 | print("ℹ Stage joined as host") 276 | onComplete(true) 277 | } 278 | } 279 | 280 | private func joinStage(_ token: String, onSuccess: () -> Void) { 281 | do { 282 | self.stage = nil 283 | let stage = try IVSStage(token: token, strategy: self) 284 | stage.addRenderer(self) 285 | stage.errorDelegate = self 286 | try stage.join() 287 | self.stage = stage 288 | appendSuccessNotification(self.services.user.isHost ? "Stage Created" : "Stage Joined") 289 | print("ℹ stage joined") 290 | currentJoinToken = token 291 | DispatchQueue.main.async { 292 | self.sessionRunning = true 293 | } 294 | onSuccess() 295 | 296 | } catch { 297 | print("ℹ ❌ Error joining stage: \(error)") 298 | } 299 | } 300 | 301 | func leaveStage() { 302 | print("ℹ Leaving stage") 303 | stage?.leave() 304 | while participantsData.count > 1 { 305 | participantsData.remove(at: participantsData.count - 1) 306 | } 307 | } 308 | 309 | func getAllStages(initial: Bool = false, _ onComplete: @escaping ([StageDetails]) -> Void) { 310 | if initial { 311 | allStages = [] 312 | } 313 | services.server.getAllStages { [weak self] success, stages, error in 314 | DispatchQueue.main.async { 315 | if success { 316 | self?.allStages = stages ?? [] 317 | print("ℹ got \(self?.allStages.count ?? 0) stages") 318 | } 319 | 320 | if self?.allStages.count == 0 { 321 | // Add empty stage to suppport List view refreshable when there are no stages 322 | self?.allStages = [StageDetails.empty] 323 | } 324 | onComplete(self?.allStages ?? []) 325 | } 326 | } 327 | } 328 | 329 | 330 | func endSession() { 331 | print("ℹ Ending session...") 332 | sessionRunning = false 333 | leaveStage() 334 | destroyBroadcastSession() 335 | stage = nil 336 | } 337 | 338 | func toggleLocalAudioMute() { 339 | localStreams 340 | .filter { $0.device is IVSAudioDevice } 341 | .forEach { 342 | $0.setMuted(!$0.isMuted) 343 | localUserAudioMuted = $0.isMuted 344 | if let audioDevice = $0.device as? IVSAudioDevice { 345 | audioDevice.setGain(localUserAudioMuted ? 0 : 1) 346 | } 347 | } 348 | services.user.audioOn = !localUserAudioMuted 349 | print("ℹ Toggled audio, is muted: \(localUserAudioMuted)") 350 | } 351 | 352 | func toggleLocalVideoMute() { 353 | localStreams 354 | .filter { $0.device is IVSImageDevice } 355 | .forEach { 356 | $0.setMuted(!$0.isMuted) 357 | localUserVideoMuted = $0.isMuted 358 | if isBroadcasting { 359 | if $0.isMuted { 360 | broadcastSession?.detach($0.device.descriptor()) 361 | } else { 362 | broadcastSession?.attach($0.device, toSlotWithName: participantsData[0].broadcastSlotName) 363 | } 364 | } 365 | } 366 | services.user.videoOn = !localUserVideoMuted 367 | print("ℹ Toggled video, is muted: \(localUserVideoMuted)") 368 | } 369 | 370 | func toggleRemoteAudioMute(for participantId: String?) { 371 | mutatingParticipant(participantId) { data in 372 | data.toggleAudioMute() 373 | } 374 | } 375 | 376 | func toggleRemoteVideoMute(for participantId: String?) { 377 | mutatingParticipant(participantId) { data in 378 | data.toggleVideoMute() 379 | } 380 | } 381 | 382 | func toggleBroadcasting() { 383 | guard setupBroadcastSessionIfNeeded() else { return } 384 | if isBroadcasting { 385 | print("ℹ Stopping broadcast") 386 | broadcastSession?.stop() 387 | isBroadcasting = false 388 | } else { 389 | do { 390 | guard let stageChannel = services.server.stageHostDetails?.channel else { 391 | print("ℹ ❌ Can't start broadcasting - hostStageDetails not set") 392 | appendWarningNotification("Can't start - missing host stage details") 393 | return 394 | } 395 | print("ℹ Starting broadcast") 396 | try broadcastSession?.start(with: URL(string: "rtmps://\(stageChannel.ingestEndpoint)")!, 397 | streamKey: stageChannel.streamKey) 398 | isBroadcasting = true 399 | } catch { 400 | print("ℹ ❌ error starting broadcast: \(error)") 401 | appendErrorNotification(error.localizedDescription) 402 | isBroadcasting = false 403 | broadcastSession = nil 404 | } 405 | } 406 | } 407 | 408 | func swapCamera() { 409 | print("ℹ swapping camera to \(selectedCamera?.position == .front ? "back" : "front")") 410 | setLocalCamera(to: selectedCamera?.position == .front ? .back : .front) 411 | } 412 | 413 | func appendSuccessNotification(_ message: String) { 414 | DispatchQueue.main.async { 415 | self.notifications.removeAll(where: { $0.type == .success }) 416 | self.notifications.append(Notification(type: .success, message: message)) 417 | } 418 | } 419 | 420 | func appendWarningNotification(_ message: String) { 421 | DispatchQueue.main.async { 422 | self.notifications.removeAll(where: { $0.type == .warning }) 423 | self.notifications.append(Notification(type: .warning, message: message)) 424 | } 425 | } 426 | 427 | func appendErrorNotification(_ message: String) { 428 | DispatchQueue.main.async { 429 | self.notifications.removeAll(where: { $0.type == .error }) 430 | self.notifications.append(Notification(type: .error, message: message)) 431 | } 432 | } 433 | 434 | func removeNotification(_ notification: Notification) { 435 | if let index = notifications.firstIndex(of: notification) { 436 | DispatchQueue.main.async { 437 | self.notifications.remove(at: index) 438 | } 439 | } 440 | } 441 | 442 | private func setLocalCamera(to position: IVSDevicePosition) { 443 | #if targetEnvironment(simulator) 444 | let devices: [Any] = [] 445 | #else 446 | let devices = deviceDiscovery.listLocalDevices() 447 | #endif 448 | 449 | if let camera = devices.compactMap({ $0 as? IVSCamera }).first { 450 | if let cameraSource = camera.listAvailableInputSources().first(where: { $0.position == position }) { 451 | print("ℹ local camera source: \(cameraSource)") 452 | camera.setPreferredInputSource(cameraSource) { [weak self] in 453 | if let error = $0 { 454 | print("ℹ ❌ Error on setting preferred input source: \(error)") 455 | self?.appendErrorNotification(error.localizedDescription) 456 | } else { 457 | self?.selectedCamera = cameraSource 458 | } 459 | print("ℹ localy selected camera: \(String(describing: self?.selectedCamera))") 460 | } 461 | } 462 | self.localStreams.append(IVSLocalStageStream(device: camera, configuration: self.videoConfig)) 463 | } 464 | } 465 | 466 | func updateLocalVideoStreamConfiguration(_ config: IVSLocalStageStreamVideoConfiguration) { 467 | videoConfig = config 468 | localStreams 469 | .filter { $0.device is IVSImageDevice } 470 | .forEach { 471 | print("Updating VideoConfig for \($0.device.descriptor().friendlyName)") 472 | $0.setConfiguration(videoConfig) 473 | } 474 | } 475 | 476 | private func updateBroadcastSlots() { 477 | do { 478 | let participantsToBroadcast = participantsData.filter { $0.wantsBroadcast } 479 | broadcastSlots = try StageLayoutCalculator().calculateFrames(participantCount: participantsToBroadcast.count, 480 | width: broadcastConfig.video.size.width, 481 | height: broadcastConfig.video.size.height, 482 | padding: 10) 483 | .enumerated() 484 | .map { (index, frame) in 485 | let slot = IVSMixerSlotConfiguration() 486 | try slot.setName(participantsToBroadcast[index].broadcastSlotName) 487 | slot.position = frame.origin 488 | slot.size = frame.size 489 | slot.aspect = .fill 490 | slot.zIndex = Int32(index) 491 | return slot 492 | } 493 | updateBroadcastBindings() 494 | } catch { 495 | print("ℹ ❌ error updating broadcast slots: \(error)") 496 | appendErrorNotification(error.localizedDescription) 497 | } 498 | } 499 | 500 | private func updateBroadcastBindings() { 501 | guard let broadcastSession = broadcastSession else { return } 502 | 503 | broadcastSession.awaitDeviceChanges { [weak self] in 504 | var attachedDevices = broadcastSession.listAttachedDevices() 505 | self?.participantsData 506 | .filter { $0.wantsBroadcast } 507 | .forEach { participant in 508 | participant.streams.forEach { stream in 509 | let slotName = participant.broadcastSlotName 510 | 511 | if stream.isMuted { 512 | broadcastSession.detach(stream.device) 513 | } else { 514 | if attachedDevices.contains(where: { $0 === stream.device }) { 515 | if broadcastSession.mixer.binding(for: stream.device) != slotName { 516 | broadcastSession.mixer.bindDevice(stream.device, toSlotWithName: slotName) 517 | } 518 | } else { 519 | broadcastSession.attach(stream.device, toSlotWithName: slotName) 520 | } 521 | } 522 | 523 | attachedDevices.removeAll(where: { $0 === stream.device }) 524 | } 525 | } 526 | // Anything still in the attached devices list at the end shouldn't be attached anymore 527 | attachedDevices.forEach { 528 | broadcastSession.detach($0) 529 | } 530 | } 531 | } 532 | 533 | private func destroyBroadcastSession() { 534 | if isBroadcasting { 535 | print("ℹ Destroying broadcast session") 536 | broadcastSession?.stop() 537 | broadcastSession = nil 538 | isBroadcasting = false 539 | } 540 | } 541 | 542 | @discardableResult 543 | private func setupBroadcastSessionIfNeeded() -> Bool { 544 | guard broadcastSession == nil else { 545 | print("ℹ Session not created, it already existed") 546 | return true 547 | } 548 | do { 549 | broadcastSession = try IVSBroadcastSession(configuration: broadcastConfig, 550 | descriptors: nil, 551 | delegate: broadcastDelegate) 552 | updateBroadcastSlots() 553 | return true 554 | } catch { 555 | print("ℹ ❌ error setting up BroadcastSession: \(error)") 556 | appendErrorNotification(error.localizedDescription) 557 | return false 558 | } 559 | } 560 | 561 | // MARK: - SessionConfigurable 562 | 563 | func listAvailableDevices() -> [IVSDeviceDescriptor] { 564 | #if targetEnvironment(simulator) 565 | let devices: [Any] = [] 566 | #else 567 | let devices = deviceDiscovery.listLocalDevices() 568 | #endif 569 | 570 | return devices.flatMap { device -> [IVSDeviceDescriptor] in 571 | if let camera = device as? IVSCamera { 572 | return camera.listAvailableInputSources() 573 | } else if let microphone = device as? IVSMicrophone { 574 | return microphone.listAvailableInputSources() 575 | } 576 | return [] 577 | } 578 | } 579 | 580 | func setCamera(_ device: IVSDeviceDescriptor?) { 581 | setDevice(device, outDevice: \Self.selectedCamera, type: IVSCamera.self, logSource: "setCamera") 582 | } 583 | 584 | func setMicrophone(_ device: IVSDeviceDescriptor?) { 585 | setDevice(device, outDevice: \Self.selectedMicrophone, type: IVSMicrophone.self, logSource: "setMicrophone") 586 | } 587 | 588 | private func setDevice(_ inDevice: IVSDeviceDescriptor?, 589 | outDevice: ReferenceWritableKeyPath, 590 | type: DeviceType.Type, 591 | logSource: String) { 592 | 593 | #if targetEnvironment(simulator) 594 | let devices: [Any] = [] 595 | #else 596 | let devices = deviceDiscovery.listLocalDevices() 597 | #endif 598 | 599 | guard let localDevice = devices.compactMap({ $0 as? DeviceType }).first else { return } 600 | 601 | if let inputSource = inDevice { 602 | localDevice.setPreferredInputSource(inputSource) { [weak self] in 603 | if let error = $0 { 604 | print("ℹ ❌ error setting device: \(error)") 605 | self?.appendErrorNotification(error.localizedDescription) 606 | } else { 607 | self?[keyPath: outDevice] = inputSource 608 | } 609 | } 610 | } 611 | 612 | var localStreamsDidChange = false 613 | let index = localStreams.firstIndex(where: { $0.device === localDevice }) 614 | if let index = index, inDevice == nil { 615 | localStreams.remove(at: index) 616 | localStreamsDidChange = true 617 | } else if index == nil, inDevice != nil { 618 | localStreams.append(IVSLocalStageStream(device: localDevice, configuration: videoConfig)) 619 | localStreamsDidChange = true 620 | } 621 | 622 | if localStreamsDidChange { 623 | self[keyPath: outDevice] = inDevice 624 | stage?.refreshStrategy() 625 | participantsData[0].streams = localStreams 626 | } 627 | } 628 | 629 | func kick(_ participantId: String) { 630 | guard let stage = services.server.stageHostDetails?.stage else { 631 | print("ℹ ❌ Can't disconnect users without host stage details") 632 | return 633 | } 634 | services.server.disconnect(participantId, from: stage.id, userId: services.user.userId) 635 | } 636 | 637 | func toggleSubscribed(forParticipant participantId: String) { 638 | mutatingParticipant(participantId) { $0.wantsSubscribed.toggle() } 639 | stage?.refreshStrategy() 640 | } 641 | 642 | func toggleAudioOnlySubscribe(forParticipant participantId: String) { 643 | var shouldRefresh = false 644 | mutatingParticipant(participantId) { 645 | shouldRefresh = $0.wantsSubscribed 646 | $0.wantsAudioOnly.toggle() 647 | } 648 | if shouldRefresh { 649 | stage?.refreshStrategy() 650 | } 651 | } 652 | 653 | func toggleBroadcasting(forParticipant participantId: String?) { 654 | mutatingParticipant(participantId) { $0.wantsBroadcast.toggle() } 655 | } 656 | 657 | func dataForParticipant(_ participantId: String) -> ParticipantData? { 658 | guard let participant = participantsData.first(where: { $0.participantId == participantId }) else { 659 | print("ℹ ❌ Could not find data for participant with id \(participantId)") 660 | return nil 661 | } 662 | return participant 663 | } 664 | 665 | func mutatingParticipant(_ participantId: String?, modifier: (inout ParticipantData) -> Void) { 666 | guard let index = participantsData.firstIndex(where: { $0.participantId == participantId }) else { 667 | fatalError("Something is out of sync, investigate") 668 | } 669 | 670 | var participant = participantsData[index] 671 | modifier(&participant) 672 | participantsData[index] = participant 673 | } 674 | } 675 | -------------------------------------------------------------------------------- /MultiHostDemo/Models/User.swift: -------------------------------------------------------------------------------- 1 | // 2 | // User.swift 3 | // Multihost 4 | // 5 | // Created by Uldis Zingis on 25/07/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | class User: ObservableObject, Codable, Hashable { 11 | var userId: String 12 | var username: String? 13 | var avatarUrl: String? 14 | var participantId: String? 15 | var isHost: Bool = false 16 | @Published var videoOn: Bool = true 17 | @Published var audioOn: Bool = true 18 | 19 | init(username: String, avatarUrl: String, isHost: Bool = false) { 20 | self.userId = UUID().uuidString 21 | self.username = username 22 | self.avatarUrl = avatarUrl 23 | self.isHost = isHost 24 | } 25 | 26 | static func == (lhs: User, rhs: User) -> Bool { 27 | return lhs.userId == rhs.userId && lhs.username == rhs.username 28 | } 29 | 30 | func hash(into hasher: inout Hasher) { 31 | hasher.combine(username) 32 | hasher.combine(avatarUrl) 33 | hasher.combine(isHost) 34 | hasher.combine(userId) 35 | } 36 | 37 | required init(from decoder: Decoder) throws { 38 | self.userId = UUID().uuidString 39 | let container = try decoder.container(keyedBy: CodingKeys.self) 40 | videoOn = try container.decode(Bool.self, forKey: .videoOn) 41 | audioOn = try container.decode(Bool.self, forKey: .audioOn) 42 | username = try container.decode(String.self, forKey: .username) 43 | avatarUrl = try container.decode(String.self, forKey: .avatarUrl) 44 | participantId = try container.decode(String.self, forKey: .participantId) 45 | isHost = try container.decode(Bool.self, forKey: .isHost) 46 | } 47 | 48 | func encode(to encoder: Encoder) throws { 49 | var container = encoder.container(keyedBy: CodingKeys.self) 50 | try container.encode(videoOn, forKey: .videoOn) 51 | try container.encode(audioOn, forKey: .audioOn) 52 | } 53 | 54 | enum CodingKeys: CodingKey { 55 | case isHost, participantId, avatarUrl, username, audioOn, videoOn 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /MultiHostDemo/Utilities/ActivityIndicator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActivityIndicator.swift 3 | // Multihost 4 | // 5 | // Created by Uldis Zingis on 01/08/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ActivityIndicator: View { 11 | var body: some View { 12 | GeometryReader { geometry in 13 | ZStack { 14 | Text("") 15 | .frame(width: UIScreen.main.bounds.width, 16 | height: UIScreen.main.bounds.height, 17 | alignment: .center) 18 | .background(Color.black.opacity(0.5)) 19 | 20 | ProgressView() { 21 | Text("Please wait") 22 | .modifier(Description()) 23 | } 24 | .progressViewStyle(CircularProgressViewStyle()) 25 | .foregroundColor(.white) 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /MultiHostDemo/Utilities/IVSImagePreviewViewWrapper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IVSImagePreviewViewWrapper.swift 3 | // Stages-demo 4 | // 5 | // Created by Uldis Zingis on 02/09/2022. 6 | // 7 | 8 | import SwiftUI 9 | import AmazonIVSBroadcast 10 | 11 | struct IVSImagePreviewViewWrapper: UIViewRepresentable { 12 | let previewView: IVSImagePreviewView? 13 | 14 | func makeUIView(context: Context) -> IVSImagePreviewView { 15 | guard let view = previewView else { 16 | fatalError("No actual IVSImagePreviewView passed to wrapper") 17 | } 18 | return view 19 | } 20 | 21 | func updateUIView(_ uiView: IVSImagePreviewView, context: Context) {} 22 | } 23 | -------------------------------------------------------------------------------- /MultiHostDemo/Utilities/Modifiers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Modifiers.swift 3 | // Multihost 4 | // 5 | // Created by Uldis Zingis on 22/07/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct PrimaryButton: ViewModifier { 11 | @Environment(\.isEnabled) var isEnabled 12 | var color: Color = Color("Yellow") 13 | var textColor: Color = .black 14 | 15 | public func body(content: Content) -> some View { 16 | content 17 | .frame(maxWidth: .infinity) 18 | .padding(.vertical, 15) 19 | .foregroundColor(textColor) 20 | .font(Constants.fAppPrimary) 21 | .background(isEnabled ? color : Color.gray) 22 | .cornerRadius(8) 23 | } 24 | } 25 | 26 | struct SecondaryButton: ViewModifier { 27 | @Environment(\.isEnabled) var isEnabled 28 | 29 | public func body(content: Content) -> some View { 30 | content 31 | .frame(maxWidth: .infinity) 32 | .padding(.vertical, 15) 33 | .foregroundColor(isEnabled ? Color("Yellow") : Color.gray) 34 | .font(Constants.fAppSecondary) 35 | } 36 | } 37 | 38 | struct ActionButton: ViewModifier { 39 | @Environment(\.isEnabled) var isEnabled 40 | var color: Color = .white 41 | var background: Color = Color("BackgroundList") 42 | 43 | public func body(content: Content) -> some View { 44 | content 45 | .frame(maxWidth: .infinity) 46 | .frame(height: 44) 47 | .foregroundColor(isEnabled ? color : .gray) 48 | .font(Constants.fAppSecondaryMedium) 49 | .background(background) 50 | .cornerRadius(8) 51 | } 52 | } 53 | 54 | struct Title: ViewModifier { 55 | public func body(content: Content) -> some View { 56 | content 57 | .frame(maxWidth: .infinity) 58 | .padding(.vertical, 12) 59 | .multilineTextAlignment(.center) 60 | .font(Constants.fAppTitle) 61 | .foregroundColor(.white) 62 | } 63 | } 64 | 65 | struct TitleLeading: ViewModifier { 66 | public func body(content: Content) -> some View { 67 | content 68 | .padding(.vertical, 12) 69 | .padding(.horizontal, 16) 70 | .font(Constants.fAppTitle) 71 | .foregroundColor(.white) 72 | } 73 | } 74 | 75 | struct TitleRegular: ViewModifier { 76 | public func body(content: Content) -> some View { 77 | content 78 | .padding(.vertical, 12) 79 | .padding(.horizontal, 16) 80 | .multilineTextAlignment(.center) 81 | .font(Constants.fAppPrimaryRegular) 82 | .foregroundColor(.white) 83 | } 84 | } 85 | 86 | struct Subtitle: ViewModifier { 87 | public func body(content: Content) -> some View { 88 | content 89 | .frame(maxWidth: .infinity) 90 | .multilineTextAlignment(.center) 91 | .font(Constants.fAppSmallMedium) 92 | .foregroundColor(Color("TextGray2")) 93 | } 94 | } 95 | 96 | struct Description: ViewModifier { 97 | public func body(content: Content) -> some View { 98 | content 99 | .frame(maxWidth: .infinity) 100 | .multilineTextAlignment(.center) 101 | .font(Constants.fAppRegular) 102 | .foregroundColor(.white) 103 | } 104 | } 105 | 106 | struct InputTitle: ViewModifier { 107 | public func body(content: Content) -> some View { 108 | content 109 | .padding(.vertical, 6) 110 | .font(Constants.fAppPrimary) 111 | .foregroundColor(.white) 112 | } 113 | } 114 | 115 | struct InputTitleSmall: ViewModifier { 116 | public func body(content: Content) -> some View { 117 | content 118 | .padding(.vertical, 6) 119 | .font(Constants.fAppPrimarySmall) 120 | .foregroundColor(.white) 121 | } 122 | } 123 | 124 | struct TableHeader: ViewModifier { 125 | public func body(content: Content) -> some View { 126 | content 127 | .font(Constants.fAppTitleSmall) 128 | .foregroundColor(Color("TextGray1")) 129 | .padding(.horizontal, 16) 130 | .padding(.vertical, 8) 131 | } 132 | } 133 | 134 | struct TableFooter: ViewModifier { 135 | public func body(content: Content) -> some View { 136 | content 137 | .font(Constants.fAppSmall) 138 | .foregroundColor(Color("TextGray2")) 139 | .padding(.horizontal, 16) 140 | .padding(.vertical, 8) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /MultiHostDemo/Utilities/Oservable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Oservable.swift 3 | // Multihost 4 | // 5 | // Created by Uldis Zingis on 22/07/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | class Observable { 11 | typealias Observer = (T) -> () 12 | private var observer: Observer? 13 | 14 | var value: T { 15 | didSet { 16 | observer?(value) 17 | } 18 | } 19 | 20 | init(_ v: T) { 21 | value = v 22 | } 23 | 24 | func add(_ observer: Observer?) { 25 | self.observer = observer 26 | } 27 | 28 | func addAndNotify(_ observer: Observer?) { 29 | self.observer = observer 30 | observer?(value) 31 | } 32 | 33 | func notify() { 34 | observer?(value) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /MultiHostDemo/Utilities/StageLayoutCalculator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StageLayoutCalculator.swift 3 | // Stages-demo 4 | // 5 | // Created by Uldis Zingis on 26/01/2023. 6 | // 7 | 8 | import UIKit 9 | 10 | class StageLayoutCalculator { 11 | private let layouts: [[Int]] = [ 12 | // 1 participant 13 | [ 1 ], // 1 row, full width 14 | // 2 participants 15 | [ 1, 1 ], // 2 rows, full width 16 | // 3 participants 17 | [ 1, 2 ], // 2 rows, full width then 1/2 width 18 | // 4 participants 19 | [ 2, 2 ], // 2 rows, 1/2 width for both 20 | ] 21 | 22 | func calculateFrames(participantCount: Int, width: CGFloat, height: CGFloat, padding: CGFloat) -> [CGRect] { 23 | if participantCount > 4 { 24 | fatalError("Only 4 participants are supported in this demo") 25 | } 26 | if participantCount == 0 { 27 | return [] 28 | } 29 | var currentIndex = 0 30 | var lastFrame: CGRect = .zero 31 | 32 | let isVertical = height > width 33 | 34 | let halfPadding = padding / 2.0 35 | 36 | let layout = layouts[participantCount - 1] // 1 participant is in index 0, so `-1`. 37 | let rowHeight = (isVertical ? height : width) / CGFloat(layout.count) 38 | 39 | var frames = [CGRect]() 40 | for row in 0 ..< layout.count { 41 | // layout[row] is the number of columns in a layout 42 | let itemWidth = (isVertical ? width : height) / CGFloat(layout[row]) 43 | let segmentFrame = CGRect(x: (isVertical ? 0 : lastFrame.maxX) + halfPadding, 44 | y: (isVertical ? lastFrame.maxY : 0) + halfPadding, 45 | width: (isVertical ? itemWidth : rowHeight) - padding, 46 | height: (isVertical ? rowHeight : itemWidth) - padding) 47 | 48 | for column in 0 ..< layout[row] { 49 | var frame = segmentFrame 50 | if isVertical { 51 | frame.origin.x = (itemWidth * CGFloat(column)) + halfPadding 52 | } else { 53 | frame.origin.y = (itemWidth * CGFloat(column)) + halfPadding 54 | } 55 | frames.append(frame) 56 | currentIndex += 1 57 | } 58 | 59 | lastFrame = segmentFrame 60 | lastFrame.origin.x += halfPadding 61 | lastFrame.origin.y += halfPadding 62 | } 63 | return frames 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /MultiHostDemo/Utilities/View.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View.swift 3 | // Multihost 4 | // 5 | // Created by Uldis Zingis on 25/07/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension View { 11 | func placeholder( 12 | when shouldShow: Bool, 13 | alignment: Alignment = .leading, 14 | @ViewBuilder placeholder: () -> Content) -> some View { 15 | 16 | ZStack(alignment: alignment) { 17 | placeholder().opacity(shouldShow ? 1 : 0) 18 | .frame(alignment: alignment) 19 | .padding(.leading, 10) 20 | self 21 | } 22 | } 23 | 24 | func onFirstAppear(_ action: @escaping () -> ()) -> some View { 25 | modifier(FirstAppear(action: action)) 26 | } 27 | } 28 | 29 | private struct FirstAppear: ViewModifier { 30 | let action: () -> () 31 | 32 | @State private var hasAppeared = false 33 | 34 | func body(content: Content) -> some View { 35 | content.onAppear { 36 | guard !hasAppeared else { return } 37 | hasAppeared = true 38 | action() 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /MultiHostDemo/Views/Components/BottomSheetView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BottomSheetView.swift 3 | // Stages-demo 4 | // 5 | // Created by Uldis Zingis on 12/10/2022. 6 | // 7 | 8 | import Foundation 9 | 10 | import SwiftUI 11 | 12 | struct BottomSheetView: View { 13 | @Binding var isPresent: Bool 14 | var title: String 15 | var description: String 16 | var imageUrls: [String] = [] 17 | var onSubmit: () -> Void = {} 18 | 19 | var body: some View { 20 | ZStack(alignment: .bottom) { 21 | Color("Background") 22 | .edgesIgnoringSafeArea(.all) 23 | .opacity(0.6) 24 | 25 | VStack { 26 | HStack(spacing: -15) { 27 | ForEach(imageUrls, id: \.self) { url in 28 | RemoteImageView(imageURL: url.isEmpty ? Constants.userAvatarUrls.randomElement()! : url) 29 | .frame(width: 72, height: 72) 30 | .clipShape(Circle()) 31 | .overlay(Circle().stroke(Color("BackgroundLight"), lineWidth: 5)) 32 | } 33 | } 34 | .padding(.top, 20) 35 | .padding(.bottom, 12) 36 | 37 | Text(title) 38 | .modifier(Description()) 39 | 40 | Text(description) 41 | .modifier(TableFooter()) 42 | .multilineTextAlignment(.center) 43 | 44 | HStack { 45 | Button(action: { 46 | isPresent.toggle() 47 | }) { 48 | Text("Ingore") 49 | .modifier(ActionButton()) 50 | } 51 | 52 | Button(action: { 53 | onSubmit() 54 | isPresent.toggle() 55 | }) { 56 | Text("Continue") 57 | .modifier(ActionButton(color: Color("Yellow"))) 58 | } 59 | } 60 | .padding(.top, 16) 61 | .padding(.horizontal, 16) 62 | } 63 | .padding(.bottom, 50) 64 | .background(Color("BackgroundLight")) 65 | .cornerRadius(20) 66 | } 67 | .ignoresSafeArea(edges: .bottom) 68 | } 69 | } 70 | 71 | struct ConfirmationBottomSheetView: View { 72 | @Binding var isPresent: Bool 73 | var title: String 74 | var description: String 75 | var confirmTitle: String 76 | var confirmColor: Color = Color("Red") 77 | var declineTitle: String = "Cancel" 78 | var onConfirm: () -> Void = {} 79 | var onDecline: () -> Void = {} 80 | 81 | var body: some View { 82 | ZStack(alignment: .bottom) { 83 | Color("Background") 84 | .edgesIgnoringSafeArea(.all) 85 | .opacity(0.6) 86 | 87 | VStack { 88 | Text(title) 89 | .modifier(Title()) 90 | 91 | Text(description) 92 | .modifier(Description()) 93 | .multilineTextAlignment(.center) 94 | .padding(.horizontal, 16) 95 | 96 | HStack { 97 | Button(action: { 98 | onConfirm() 99 | isPresent.toggle() 100 | }) { 101 | Text(confirmTitle) 102 | .modifier(ActionButton(color: confirmColor)) 103 | } 104 | 105 | Button(action: { 106 | onDecline() 107 | isPresent.toggle() 108 | }) { 109 | Text(declineTitle) 110 | .modifier(ActionButton()) 111 | } 112 | } 113 | .padding(.top, 16) 114 | .padding(.horizontal, 16) 115 | } 116 | .padding(.bottom, 50) 117 | .background(Color("BackgroundLight")) 118 | .cornerRadius(20) 119 | } 120 | .ignoresSafeArea(edges: .bottom) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /MultiHostDemo/Views/Components/CameraView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CameraModel.swift 3 | // Stages-demo 4 | // 5 | // Created by Uldis Zingis on 14/09/2022. 6 | // 7 | 8 | import AVFoundation 9 | import UIKit 10 | import SwiftUI 11 | 12 | struct CameraView: UIViewControllerRepresentable { 13 | typealias UIViewType = CameraPreviewController 14 | 15 | @Binding var isPreviewActive: Bool 16 | @Binding var isFrontCameraActive: Bool 17 | 18 | func makeUIViewController(context: Context) -> CameraPreviewController { 19 | return CameraPreviewController() 20 | } 21 | 22 | func updateUIViewController(_ uiViewController: CameraPreviewController, context: Context) { 23 | if isPreviewActive { 24 | uiViewController.startCaptureSession() 25 | } else { 26 | uiViewController.stopCaptureSession() 27 | } 28 | 29 | if (uiViewController.activeCamera?.position == .front && !isFrontCameraActive) || 30 | (uiViewController.activeCamera?.position == .back && isFrontCameraActive) { 31 | uiViewController.swapCamera() 32 | } 33 | } 34 | } 35 | 36 | class CameraPreviewController: UIViewController { 37 | var captureSession = AVCaptureSession() 38 | var cameraPreviewLayer: AVCaptureVideoPreviewLayer? 39 | 40 | var frontCamera: AVCaptureDevice? 41 | var backCamera: AVCaptureDevice? 42 | var activeCamera: AVCaptureDevice? 43 | var captureDeviceInput: AVCaptureDeviceInput? 44 | 45 | private var configurationInProgress: Bool = false 46 | 47 | override func viewDidLoad() { 48 | super.viewDidLoad() 49 | setupPreviewLayer() 50 | setupCameras() 51 | } 52 | 53 | func setupPreviewLayer() { 54 | captureSession.sessionPreset = AVCaptureSession.Preset.hd1280x720 55 | 56 | cameraPreviewLayer = AVCaptureVideoPreviewLayer(session: captureSession) 57 | cameraPreviewLayer?.session = captureSession 58 | cameraPreviewLayer?.frame = CGRect(x: view.frame.width / 5, 59 | y: 0, 60 | width: 225, 61 | height: 400) 62 | cameraPreviewLayer?.videoGravity = AVLayerVideoGravity.resizeAspectFill 63 | cameraPreviewLayer?.connection?.videoOrientation = AVCaptureVideoOrientation.portrait 64 | cameraPreviewLayer?.backgroundColor = UIColor.black.cgColor 65 | view.layer.insertSublayer(cameraPreviewLayer!, at: 0) 66 | } 67 | 68 | func setupCameras() { 69 | let deviceDiscoverySession = AVCaptureDevice.DiscoverySession( 70 | deviceTypes: [ 71 | .builtInDualCamera, 72 | .builtInTrueDepthCamera, 73 | .builtInWideAngleCamera, 74 | .builtInDualWideCamera, 75 | .builtInTripleCamera 76 | ], 77 | mediaType: AVMediaType.video, 78 | position: .unspecified) 79 | frontCamera = deviceDiscoverySession.devices.first(where: { $0.position == .front }) 80 | backCamera = deviceDiscoverySession.devices.last(where: { $0.position == .back }) 81 | let isFrontCameraSelected = (UserDefaults.standard.value(forKey: Constants.kActiveFrontCamera) as? Bool) ?? true 82 | activeCamera = isFrontCameraSelected ? frontCamera : backCamera 83 | } 84 | 85 | func setupInput(_ camera: AVCaptureDevice) { 86 | if configurationInProgress { 87 | print("ℹ cameras configuration already in progress") 88 | return 89 | } 90 | 91 | do { 92 | captureDeviceInput = try AVCaptureDeviceInput(device: camera) 93 | 94 | captureSession.beginConfiguration() 95 | configurationInProgress = true 96 | 97 | for input in captureSession.inputs { 98 | captureSession.removeInput(input) 99 | } 100 | 101 | guard captureSession.canAddInput(captureDeviceInput!) else { 102 | print("ℹ ❌ AVCaptureSession can't add input \(captureDeviceInput!)") 103 | return 104 | } 105 | 106 | captureSession.addInput(captureDeviceInput!) 107 | activeCamera = camera 108 | print("ℹ camera preview input set to \(camera)") 109 | } catch { 110 | print("ℹ ❌ Error creating AVCaptureDeviceInput with camera: \(error)") 111 | } 112 | captureSession.commitConfiguration() 113 | configurationInProgress = false 114 | } 115 | 116 | func swapCamera() { 117 | let isFrontCameraSelected = (UserDefaults.standard.value(forKey: Constants.kActiveFrontCamera) as? Bool) ?? true 118 | if isFrontCameraSelected { 119 | guard let backCamera = backCamera else { 120 | print("ℹ ❌ no camera available for preview") 121 | return 122 | } 123 | setupInput(backCamera) 124 | } else { 125 | guard let frontCamera = frontCamera else { 126 | print("ℹ ❌ no camera available for preview") 127 | return 128 | } 129 | setupInput(frontCamera) 130 | } 131 | UserDefaults.standard.set(!isFrontCameraSelected, forKey: Constants.kActiveFrontCamera) 132 | } 133 | 134 | public func startCaptureSession() { 135 | if captureSession.isRunning { 136 | print("ℹ camera preview capture session already running") 137 | return 138 | } 139 | 140 | if let activeCamera = activeCamera { 141 | setupInput(activeCamera) 142 | } 143 | DispatchQueue.global().async { 144 | self.captureSession.startRunning() 145 | } 146 | } 147 | 148 | public func stopCaptureSession() { 149 | captureSession.stopRunning() 150 | if let input = captureDeviceInput { 151 | captureSession.removeInput(input) 152 | captureDeviceInput = nil 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /MultiHostDemo/Views/Components/ChatMessagesView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatMessagesView.swift 3 | // Stages-demo 4 | // 5 | // Created by Uldis Zingis on 09/09/2022. 6 | // 7 | 8 | import SwiftUI 9 | import AmazonIVSChatMessaging 10 | 11 | struct ChatMessagesView: View { 12 | @ObservedObject var chatModel: ChatModel 13 | 14 | var body: some View { 15 | ZStack { 16 | ScrollViewReader { proxy in 17 | ScrollView(.vertical, showsIndicators: false) { 18 | LazyVStack(alignment: .leading) { 19 | ForEach(chatModel.messages, id: \.id) { message in 20 | MessageView(message: message) 21 | } 22 | } 23 | .rotationEffect(.radians(.pi)) 24 | .scaleEffect(x: -1, y: 1, anchor: .center) 25 | .animation(.easeInOut(duration: 0.25)) 26 | } 27 | .rotationEffect(.radians(.pi)) 28 | .scaleEffect(x: -1, y: 1, anchor: .center) 29 | .onChange(of: chatModel.messages, perform: { _ in 30 | guard let lastMessage = chatModel.messages.last else { return } 31 | withAnimation { 32 | proxy.scrollTo(lastMessage.id, anchor: .bottom) 33 | } 34 | }) 35 | } 36 | } 37 | } 38 | } 39 | 40 | struct MessageView: View { 41 | @State var message: ChatMessage 42 | @State private var offsetY: CGFloat = 50 43 | @State private var opacity: Double = 0 44 | 45 | var body: some View { 46 | VStack(alignment: .leading, spacing: 8) { 47 | MessagePreviewView(message: message) 48 | } 49 | .offset(y: offsetY) 50 | .opacity(opacity) 51 | .onAppear { 52 | withAnimation { 53 | offsetY = 0 54 | opacity = 1 55 | } 56 | } 57 | } 58 | } 59 | 60 | struct MessagePreviewView: View { 61 | @State var message: ChatMessage 62 | 63 | var body: some View { 64 | HStack(alignment: .top) { 65 | if let avatarUrl = message.sender.attributes?["avatarUrl"] { 66 | RemoteImageView(imageURL: avatarUrl) 67 | .frame(width: 40, height: 40) 68 | .cornerRadius(42) 69 | .padding(.leading, 8) 70 | } 71 | 72 | VStack(alignment: .leading, spacing: 4) { 73 | Text(message.sender.attributes?["username"] ?? "") 74 | .font(Constants.fAppRegularBold) 75 | .foregroundColor(.white) 76 | Text(message.content) 77 | .font(Constants.fAppRegular) 78 | .foregroundColor(.white) 79 | } 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /MultiHostDemo/Views/Components/ChatView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatView.swift 3 | // Stages-demo 4 | // 5 | // Created by Uldis Zingis on 05/09/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ChatView: View { 11 | @EnvironmentObject var services: ServicesManager 12 | @ObservedObject var chatModel: ChatModel 13 | @Binding var isPresent: Bool 14 | @State var message: String = "" 15 | 16 | var body: some View { 17 | ZStack { 18 | if isPresent { 19 | VStack(alignment: .center, spacing: 0) { 20 | ChatMessagesView(chatModel: chatModel) 21 | .frame(maxHeight: 150) 22 | 23 | HStack { 24 | RemoteImageView(imageURL: services.user.avatarUrl ?? "") 25 | .frame(width: 40, height: 40) 26 | .clipShape(Circle()) 27 | .padding(.leading, 8) 28 | 29 | CustomTextField(text: $message) { 30 | if message.isEmpty { 31 | return 32 | } 33 | chatModel.sendMessage(message, user: services.user, onComplete: { error in 34 | if let error = error { 35 | services.viewModel?.appendErrorNotification(error) 36 | } else { 37 | message = "" 38 | } 39 | }) 40 | } 41 | .padding(.horizontal, 8) 42 | .placeholder(when: message.isEmpty, alignment: .leading) { 43 | Text("Say something...") 44 | .font(Constants.fAppRegular) 45 | .foregroundColor(.white) 46 | .padding(.leading, 10) 47 | } 48 | .frame(maxWidth: .infinity) 49 | .frame(height: 40) 50 | .background(Color("BackgroundSecondary")) 51 | .cornerRadius(20) 52 | .padding(.horizontal, 8) 53 | } 54 | .padding(.top, 8) 55 | .padding(.bottom, 16) 56 | } 57 | .background(LinearGradient(gradient: Gradient(colors: [.clear, Color("GradientGray")]), 58 | startPoint: .top, 59 | endPoint: .bottom)) 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /MultiHostDemo/Views/Components/ControlButtonsDrawer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ControlButtonsDrawer.swift 3 | // Multihost 4 | // 5 | // Created by Uldis Zingis on 25/07/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ControlButtonsDrawer: View { 11 | @EnvironmentObject var services: ServicesManager 12 | @ObservedObject var viewModel: StageViewModel 13 | @Binding var isExpanded: Bool 14 | @Binding var isChatPresent: Bool 15 | 16 | var body: some View { 17 | VStack { 18 | VStack(alignment: .center) { 19 | Capsule(style: RoundedCornerStyle.circular) 20 | .foregroundColor(Color.gray) 21 | .frame(width: 40, height: 4) 22 | .padding(.vertical, 8) 23 | .opacity(services.user.isHost ? 1 : 0) 24 | 25 | HStack(alignment: .top) { 26 | ControlButton(image: Image(isChatPresent ? "icon_chat_on" : "icon_chat_off"), 27 | backgroundColor: isChatPresent ? Color("BackgroundButton") : .white) { 28 | isChatPresent.toggle() 29 | } 30 | 31 | ControlButton(image: Image(viewModel.localUserAudioMuted ? "icon_mic_off" : "icon_mic_on"), 32 | backgroundColor: viewModel.localUserAudioMuted ? .white : Color("BackgroundButton")) { 33 | viewModel.toggleLocalAudioMute() 34 | } 35 | 36 | ControlButton(image: Image(viewModel.localUserVideoMuted ? "icon_video_off" : "icon_video_on"), 37 | backgroundColor: viewModel.localUserVideoMuted ? .white : Color("BackgroundButton")) { 38 | viewModel.toggleLocalVideoMute() 39 | } 40 | 41 | ControlButton(image: Image("icon_swap_camera")) { 42 | viewModel.swapCamera() 43 | } 44 | } 45 | .frame(height: 50) 46 | .padding(.bottom, services.user.isHost ? 0 : 50) 47 | 48 | if services.user.isHost { 49 | VStack { 50 | Button(action: { 51 | let pasteboard = UIPasteboard.general 52 | pasteboard.string = "https://debug.ivsdemos.com/?p=ivs&url=\(services.server.stageHostDetails?.channel.playbackUrl ?? services.server.joinedStagePlaybackUrl)" 53 | }) { 54 | Text("Copy playback URL") 55 | .modifier(PrimaryButton(color: Color("BackgroundButton"), textColor: .white)) 56 | } 57 | .padding(.top, 30) 58 | 59 | Button(action: { 60 | viewModel.toggleBroadcasting() 61 | }) { 62 | Text(viewModel.isBroadcasting ? "Stop Streaming" : "Start Streaming") 63 | .modifier(PrimaryButton(color: viewModel.isBroadcasting ? Color("ButtonRed") : Color("Yellow"))) 64 | } 65 | } 66 | .padding(.bottom, 50) 67 | .padding(.horizontal, 16) 68 | } 69 | } 70 | .frame(width: UIScreen.main.bounds.width) 71 | .background(Color("BackgroundLight")) 72 | .cornerRadius(40) 73 | .padding(.bottom, -50) 74 | } 75 | .gesture( 76 | DragGesture() 77 | .onEnded({ gesture in 78 | if abs(gesture.translation.height) > 60 { 79 | withAnimation { 80 | if gesture.translation.height > 0 { 81 | isExpanded = false 82 | } else if gesture.translation.height < 0 { 83 | isExpanded = true 84 | } 85 | } 86 | } 87 | }) 88 | ) 89 | } 90 | } 91 | 92 | struct ControlButton: View { 93 | let image: Image 94 | var color: Color = Color.white 95 | var backgroundColor = Color("BackgroundButton") 96 | var size: CGFloat = 48 97 | let action: () -> Void 98 | 99 | var body: some View { 100 | Button(action: { 101 | action() 102 | }) { 103 | VStack { 104 | image 105 | .resizable() 106 | .padding(12) 107 | .background(backgroundColor) 108 | .foregroundColor(color) 109 | .clipShape(Circle()) 110 | .frame(width: size, height: size) 111 | } 112 | } 113 | .frame(maxWidth: .infinity) 114 | .padding(.vertical, 16) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /MultiHostDemo/Views/Components/CustomTextField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomTextField.swift 3 | // Multihost 4 | // 5 | // Created by Uldis Zingis on 22/07/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CustomTextField: UIViewRepresentable { 11 | @Binding public var text: String 12 | let onCommit: () -> Void 13 | 14 | public init(text: Binding, onCommit: @escaping () -> Void) { 15 | self.onCommit = onCommit 16 | self._text = text 17 | } 18 | 19 | public func makeUIView(context: Context) -> UITextField { 20 | let view = TextField() 21 | view.returnKeyType = .send 22 | view.textColor = .white 23 | view.font = UIFont.systemFont(ofSize: 15) 24 | view.addTarget(context.coordinator, action: #selector(Coordinator.textViewDidChange), for: .editingChanged) 25 | view.delegate = context.coordinator 26 | view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) 27 | view.textAlignment = .left 28 | return view 29 | } 30 | 31 | public func updateUIView(_ uiView: UITextField, context: Context) { 32 | uiView.text = text 33 | } 34 | 35 | public func makeCoordinator() -> Coordinator { 36 | Coordinator($text, onCommit: onCommit) 37 | } 38 | 39 | public class Coordinator: NSObject, UITextFieldDelegate { 40 | var text: Binding 41 | var onCommit: () -> Void 42 | 43 | init(_ text: Binding, onCommit: @escaping () -> Void) { 44 | self.text = text 45 | self.onCommit = onCommit 46 | } 47 | 48 | public func textFieldShouldReturn(_ textField: UITextField) -> Bool { 49 | onCommit() 50 | return false 51 | } 52 | 53 | @objc public func textViewDidChange(_ textField: UITextField) { 54 | self.text.wrappedValue = textField.text ?? "" 55 | } 56 | } 57 | } 58 | 59 | class TextField: UITextField { 60 | let padding = UIEdgeInsets(top: 12, left: 10, bottom: 12, right: 10) 61 | 62 | override open func textRect(forBounds bounds: CGRect) -> CGRect { 63 | return bounds.inset(by: padding) 64 | } 65 | 66 | override open func placeholderRect(forBounds bounds: CGRect) -> CGRect { 67 | return bounds.inset(by: padding) 68 | } 69 | 70 | override open func editingRect(forBounds bounds: CGRect) -> CGRect { 71 | return bounds.inset(by: padding) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /MultiHostDemo/Views/Components/JoinPreviewView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JoinPreviewView.swift 3 | // Multihost 4 | // 5 | // Created by Uldis Zingis on 08/08/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct JoinPreviewView: View { 11 | @EnvironmentObject var services: ServicesManager 12 | @ObservedObject var viewModel: StageViewModel 13 | @Binding var isPresent: Bool 14 | @Binding var isLoading: Bool 15 | let onJoin: () -> Void 16 | 17 | @State var isPreviewActive: Bool = true 18 | @State var isFrontCameraActive: Bool = true 19 | 20 | var body: some View { 21 | GeometryReader { geometry in 22 | ZStack(alignment: .bottom) { 23 | Color("Background") 24 | .edgesIgnoringSafeArea(.all) 25 | .opacity(0.6) 26 | 27 | VStack(spacing: 8) { 28 | Text("This is how you'll look and sound") 29 | .modifier(Description()) 30 | .padding(16) 31 | 32 | CameraView(isPreviewActive: $isPreviewActive, isFrontCameraActive: $isFrontCameraActive) 33 | .frame(width: geometry.size.width - 16, height: 400) 34 | .scaledToFit() 35 | .overlay { 36 | ZStack { 37 | Color("BackgroundGray") 38 | .cornerRadius(50) 39 | 40 | VStack(spacing: 0) { 41 | RemoteImageView(imageURL: services.user.avatarUrl ?? "") 42 | .frame(width: 84, height: 84) 43 | .clipShape(Circle()) 44 | Text(services.user.username ?? "") 45 | .modifier(TitleRegular()) 46 | } 47 | } 48 | .opacity(viewModel.localUserVideoMuted ? 1 : 0) 49 | .transition(.opacity.animation(.easeInOut)) 50 | } 51 | .background(Color.black) 52 | .cornerRadius(50) 53 | .padding(.horizontal, 8) 54 | .onAppear { 55 | isFrontCameraActive = viewModel.selectedCamera?.position == .front 56 | } 57 | 58 | HStack(spacing: 24) { 59 | Spacer() 60 | ControlButton(image: Image(viewModel.localUserAudioMuted ? "icon_mic_off" : "icon_mic_on"), 61 | backgroundColor: viewModel.localUserAudioMuted ? .white : Color("BackgroundButton")) { 62 | viewModel.toggleLocalAudioMute() 63 | } 64 | .frame(maxWidth: 48) 65 | 66 | ControlButton(image: Image(viewModel.localUserVideoMuted ? "icon_video_off" : "icon_video_on"), 67 | backgroundColor: viewModel.localUserVideoMuted ? .white : Color("BackgroundButton")) { 68 | withAnimation { 69 | viewModel.toggleLocalVideoMute() 70 | } 71 | } 72 | .frame(maxWidth: 48) 73 | 74 | ControlButton(image: Image("icon_swap_camera")) { 75 | viewModel.swapCamera() 76 | isFrontCameraActive = !isFrontCameraActive 77 | } 78 | .frame(maxWidth: 48) 79 | Spacer() 80 | } 81 | .background(Color("BackgroundList")) 82 | .cornerRadius(25) 83 | .padding(8) 84 | 85 | HStack { 86 | Button(action: { 87 | isPresent.toggle() 88 | }) { 89 | Text("Cancel") 90 | .modifier(ActionButton()) 91 | } 92 | 93 | Button(action: { 94 | isLoading = true 95 | isPreviewActive = false 96 | 97 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 98 | self.onJoin() 99 | self.isPresent.toggle() 100 | } 101 | }) { 102 | Text("Join") 103 | .modifier(ActionButton(color: .black, background: Color("Yellow"))) 104 | } 105 | } 106 | .padding(.horizontal, 16) 107 | } 108 | .padding(.bottom, 50) 109 | .background(Color("BackgroundLight")) 110 | .cornerRadius(20) 111 | } 112 | .ignoresSafeArea(edges: .bottom) 113 | } 114 | .onAppear { 115 | isPreviewActive = true 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /MultiHostDemo/Views/Components/ManageParticipantsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ManageParticipantsView.swift 3 | // Multihost 4 | // 5 | // Created by Uldis Zingis on 10/08/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ManageParticipantsView: View { 11 | @EnvironmentObject var services: ServicesManager 12 | @Binding var isPresent: Bool 13 | @State var confirmationPresent: Bool = false 14 | 15 | @State var confirmationTitle: String = "" 16 | @State var confirmationDescription: String = "" 17 | @State var confirmationConfirmTitle: String = "" 18 | @State var confirmAction: () -> Void = {} 19 | 20 | private func presentConfirmation(title: String, description: String , confirmTitle: String, action: @escaping () -> Void) { 21 | confirmationTitle = title 22 | confirmationDescription = description 23 | confirmationConfirmTitle = confirmTitle 24 | confirmAction = action 25 | confirmationPresent = true 26 | } 27 | 28 | var body: some View { 29 | ZStack { 30 | if (confirmationPresent) { 31 | ConfirmationBottomSheetView( 32 | isPresent: $confirmationPresent, 33 | title: confirmationTitle, 34 | description: confirmationDescription, 35 | confirmTitle: confirmationConfirmTitle, 36 | onConfirm: confirmAction) 37 | } else { 38 | GeometryReader { (proxy: GeometryProxy) in 39 | ZStack(alignment: .bottom) { 40 | Color.black 41 | .edgesIgnoringSafeArea(.all) 42 | .opacity(0.4) 43 | .onTapGesture { 44 | isPresent.toggle() 45 | } 46 | 47 | VStack { 48 | HStack { 49 | Button { 50 | isPresent.toggle() 51 | } label: { 52 | Image(systemName: "xmark") 53 | .resizable() 54 | .frame(width: 12, height: 12) 55 | .foregroundColor(Color.white) 56 | } 57 | Spacer() 58 | Text("Stage Participants") 59 | .modifier(InputTitle()) 60 | Spacer() 61 | } 62 | .padding(16) 63 | 64 | ManageParticipantListView( 65 | viewModel: services.viewModel!, 66 | onRemoveAction: { participant in 67 | presentConfirmation( 68 | title: "Confirm removal", 69 | description: "Continue removing \(participant.username) from the stage?", 70 | confirmTitle: "Yes, remove") { 71 | services.viewModel?.kick(participant.participantId ?? participant.id) 72 | isPresent.toggle() 73 | } 74 | } 75 | ) 76 | 77 | if services.viewModel?.participantCount == 0 { 78 | Text("No participants") 79 | .modifier(TableFooter()) 80 | .padding(.bottom, 50) 81 | } 82 | 83 | Spacer() 84 | } 85 | .frame(width: proxy.size.width) 86 | .frame(maxHeight: 400) 87 | .background(Color("BackgroundLight")) 88 | .cornerRadius(20) 89 | .padding(.bottom, -25) 90 | } 91 | } 92 | } 93 | } 94 | } 95 | } 96 | 97 | struct ManageParticipantListView: View { 98 | @EnvironmentObject var services: ServicesManager 99 | @ObservedObject var viewModel: StageViewModel 100 | var onRemoveAction: (ParticipantData) -> Void 101 | 102 | var body: some View { 103 | VStack(spacing: 0) { 104 | ForEach(viewModel.participantsData, id: \.id) { participant in 105 | Divider() 106 | .background(Color("TextGray1")) 107 | .opacity(0.5) 108 | HStack(spacing: 4) { 109 | RemoteImageView(imageURL: participant.avatarUrl) 110 | .frame(width: 40, height: 40) 111 | .clipShape(Circle()) 112 | .padding(.leading, 16) 113 | 114 | Text(participant.username + "\(participant.isLocal ? " (You)" : "")") 115 | .frame(maxWidth: .infinity, alignment: .leading) 116 | .foregroundColor(Color.white) 117 | .font(Constants.fAppSmall) 118 | .background(Color("BackgroundList")) 119 | .padding(.horizontal, 16) 120 | 121 | Spacer() 122 | 123 | HStack(spacing: 20) { 124 | Button { 125 | if participant.isLocal { 126 | viewModel.toggleLocalAudioMute() 127 | } else { 128 | viewModel.toggleRemoteAudioMute(for: participant.participantId) 129 | } 130 | } label: { 131 | Image((participant.isLocal && viewModel.localUserAudioMuted) || participant.audioMuted ? 132 | "icon_mic_off_red" : "icon_mic_on") 133 | .resizable() 134 | .frame(width: 24, height: 24) 135 | .foregroundColor(Color.white) 136 | } 137 | 138 | Button { 139 | if participant.isLocal { 140 | viewModel.toggleLocalVideoMute() 141 | } else { 142 | viewModel.toggleRemoteVideoMute(for: participant.participantId) 143 | } 144 | } label: { 145 | Image((participant.isLocal && viewModel.localUserVideoMuted) || participant.videoMuted ? "icon_video_off_red" : "icon_video_on") 146 | .resizable() 147 | .frame(width: 24, height: 24) 148 | .foregroundColor(Color.white) 149 | } 150 | 151 | if services.user.isHost && !participant.isLocal { 152 | Button { 153 | onRemoveAction(participant) 154 | } label: { 155 | Image(systemName: "minus.circle") 156 | .foregroundColor(Color("Red")) 157 | } 158 | } 159 | } 160 | .padding(.trailing, 16) 161 | } 162 | .frame(height: 55) 163 | } 164 | .background(Color("BackgroundList")) 165 | } 166 | .padding(.bottom, 50) 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /MultiHostDemo/Views/Components/NotificationsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotificationsView.swift 3 | // Multihost 4 | // 5 | // Created by Uldis Zingis on 26/07/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct NotificationsView: View { 11 | @EnvironmentObject var services: ServicesManager 12 | @ObservedObject var viewModel: StageViewModel 13 | @State private var scrollViewContentSize: CGFloat = 0 14 | 15 | var body: some View { 16 | ScrollViewReader { proxy in 17 | ScrollView(.vertical, showsIndicators: false) { 18 | ForEach(viewModel.notifications, id: \.id, content: { notification in 19 | VStack(alignment: .leading) { 20 | HStack { 21 | Image(notification.iconName) 22 | .resizable() 23 | .frame(width: 15, height: 15) 24 | Text(notification.title) 25 | .opacity(0.6) 26 | .font(Constants.fAppSmall) 27 | } 28 | .padding(.top, 10) 29 | 30 | Text(notification.message) 31 | .font(Constants.fAppRegular) 32 | .padding(.bottom, 10) 33 | } 34 | .padding(.horizontal, 16) 35 | .frame(maxWidth: .infinity, alignment: .leading) 36 | .foregroundColor([.success, .error].contains(notification.type) ? Color.white : Color.black) 37 | .background(colorFor(notification.type)) 38 | .cornerRadius(8) 39 | .onTapGesture { 40 | viewModel.removeNotification(notification) 41 | } 42 | }) 43 | .background( 44 | GeometryReader { geo -> Color in 45 | DispatchQueue.main.async { 46 | scrollViewContentSize = geo.size.height 47 | } 48 | return Color.clear 49 | } 50 | ) 51 | } 52 | .frame(maxHeight: scrollViewContentSize) 53 | .onChange(of: viewModel.notifications) { _ in 54 | proxy.scrollTo(viewModel.notifications.last?.id) 55 | } 56 | } 57 | } 58 | 59 | func colorFor(_ type: NotificationType) -> Color { 60 | switch type { 61 | case .success: 62 | return Color("Green") 63 | case .error: 64 | return Color("Red") 65 | case .warning: 66 | return Color("Yellow") 67 | case .blank: 68 | return Color.white 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /MultiHostDemo/Views/Components/ParticipantView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ParticipantView.swift 3 | // Multihost 4 | // 5 | // Created by Uldis Zingis on 22/07/2022. 6 | // 7 | 8 | import SwiftUI 9 | import AVKit 10 | import AmazonIVSBroadcast 11 | 12 | struct ParticipantView: View { 13 | @EnvironmentObject var services: ServicesManager 14 | var preview: IVSImagePreviewView? 15 | weak var audioDevice: IVSAudioDevice? 16 | @ObservedObject var participant: ParticipantData 17 | 18 | var body: some View { 19 | ZStack(alignment: .bottom) { 20 | if let preview = preview { 21 | IVSImagePreviewViewWrapper(previewView: preview) 22 | } 23 | 24 | if participant.videoMuted || (participant.isLocal && services.viewModel?.localUserVideoMuted ?? false) { 25 | ZStack { 26 | Color("BackgroundGray") 27 | .cornerRadius(40) 28 | 29 | VStack(spacing: 4) { 30 | RemoteImageView(imageURL: participant.avatarUrl) 31 | .frame(width: 60, height: 60) 32 | .clipShape(Circle()) 33 | 34 | Text(participant.username) 35 | .modifier(TitleRegular()) 36 | .foregroundColor(.white) 37 | } 38 | } 39 | } 40 | } 41 | .background(Color("BackgroundList")) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /MultiHostDemo/Views/Components/ParticipantsGridView.swift: -------------------------------------------------------------------------------- 1 | 2 | // ParticipantsGridView.swift 3 | // Multihost 4 | // 5 | // Created by Uldis Zingis on 09/06/2022. 6 | // 7 | 8 | import SwiftUI 9 | import AmazonIVSBroadcast 10 | 11 | struct ParticipantsGridView: View { 12 | @ObservedObject var viewModel: StageViewModel 13 | 14 | var body: some View { 15 | if viewModel.sessionRunning { 16 | switch viewModel.participantCount { 17 | case 0: 18 | EmptyView() 19 | case 1: 20 | viewModel.participantsData[0].previewView 21 | case 2: 22 | VStack { 23 | viewModel.participantsData[0].previewView.cornerRadius(40) 24 | viewModel.participantsData[1].previewView.cornerRadius(40) 25 | } 26 | case 3: 27 | VStack { 28 | viewModel.participantsData[0].previewView.cornerRadius(40) 29 | HStack { 30 | viewModel.participantsData[1].previewView.cornerRadius(40) 31 | viewModel.participantsData[2].previewView.cornerRadius(40) 32 | } 33 | } 34 | default: 35 | VStack { 36 | HStack { 37 | viewModel.participantsData[0].previewView.cornerRadius(40) 38 | viewModel.participantsData[1].previewView.cornerRadius(40) 39 | } 40 | HStack { 41 | viewModel.participantsData[2].previewView.cornerRadius(40) 42 | viewModel.participantsData[3].previewView.cornerRadius(40) 43 | } 44 | } 45 | } 46 | } else { 47 | Spacer() 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /MultiHostDemo/Views/Components/RemoteImageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteImageView.swift 3 | // Multihost 4 | // 5 | // Created by Uldis Zingis on 25/07/2022. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | 11 | struct RemoteImageView: View { 12 | @ObservedObject var imageLoader: ImageLoader 13 | @State var image: UIImage = UIImage() 14 | 15 | init(imageURL url: String) { 16 | imageLoader = ImageLoader(urlString: url) 17 | } 18 | 19 | var body: some View { 20 | Image(uiImage: image) 21 | .resizable() 22 | .aspectRatio(contentMode: .fill) 23 | .onReceive(imageLoader.didChange) { data in 24 | self.image = UIImage(data: data) ?? UIImage() 25 | } 26 | } 27 | } 28 | 29 | class ImageLoader: ObservableObject { 30 | var didChange = PassthroughSubject() 31 | var data = Data() { 32 | didSet { didChange.send(data) } 33 | } 34 | 35 | init(urlString: String) { 36 | guard let url = URL(string: urlString) else { return } 37 | let task = URLSession.shared.dataTask(with: url) { data, _, _ in 38 | guard let data = data else { return } 39 | DispatchQueue.main.async { 40 | self.data = data 41 | } 42 | } 43 | task.resume() 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /MultiHostDemo/Views/MultihostApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MultihostApp.swift 3 | // Multihost 4 | // 5 | // Created by Uldis Zingis on 09/06/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct MultihostApp: App { 12 | @ObservedObject var services = ServicesManager() 13 | @State var isWelcomePresent: Bool = true 14 | @State var isSetupPresent: Bool = false 15 | @State var isStageListPresent: Bool = false 16 | @State var isStagePresent: Bool = false 17 | @State var isLoading: Bool = false 18 | 19 | var body: some Scene { 20 | WindowGroup { 21 | ZStack(alignment: .top) { 22 | Color("Background") 23 | .edgesIgnoringSafeArea(.all) 24 | 25 | if isStagePresent, let viewModel = services.viewModel { 26 | StageView(viewModel: viewModel, 27 | chatModel: services.chatModel, 28 | isPresent: $isStagePresent, 29 | isLoading: $isLoading, 30 | backAction: backAction) 31 | .transition(.slide) 32 | } 33 | 34 | if isSetupPresent { 35 | SetupView(isPresent: $isSetupPresent, 36 | isLoading: $isLoading, 37 | isStageListPresent: $isStageListPresent, 38 | onComplete: { (user, token) in 39 | services.viewModel?.clearNotifications() 40 | services.user = user 41 | 42 | if user.isHost { 43 | services.viewModel?.createStage(user: user) { success in 44 | if success { 45 | services.viewModel?.initializeStage(onComplete: { 46 | services.viewModel?.joinAsHost() { success in 47 | if success { 48 | presentStage() 49 | } 50 | isLoading = false 51 | } 52 | }) 53 | } else { 54 | isLoading = false 55 | } 56 | } 57 | } else if let token = token { 58 | services.viewModel?.initializeStage(onComplete: { 59 | services.viewModel?.joinAsParticipant(token) { 60 | presentStage() 61 | isLoading = false 62 | } 63 | }) 64 | } 65 | }) 66 | } 67 | 68 | if isWelcomePresent { 69 | WelcomeView(isPresent: $isWelcomePresent, 70 | isSetupPresent: $isSetupPresent) 71 | } 72 | 73 | if let viewModel = services.viewModel { 74 | NotificationsView(viewModel: viewModel) 75 | } 76 | 77 | if isLoading { 78 | ActivityIndicator() 79 | } 80 | } 81 | .environmentObject(services) 82 | } 83 | } 84 | 85 | private func presentStage() { 86 | withAnimation { 87 | isStagePresent = true 88 | } 89 | isSetupPresent = !isStagePresent 90 | isWelcomePresent = !isStagePresent 91 | } 92 | 93 | private func backAction() { 94 | isSetupPresent = true 95 | isStageListPresent = true 96 | withAnimation { 97 | isStagePresent = false 98 | } 99 | isLoading = false 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /MultiHostDemo/Views/SetupView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SetupView.swift 3 | // Multihost 4 | // 5 | // Created by Uldis Zingis on 22/07/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SetupView: View { 11 | @EnvironmentObject var services: ServicesManager 12 | @Binding var isPresent: Bool 13 | @Binding var isLoading: Bool 14 | @Binding var isStageListPresent: Bool 15 | var onComplete: (User, String?) -> Void 16 | @State var username: String = "" 17 | @State var avatarUrl: String = "" 18 | @State var joinToken: String = "" 19 | 20 | var body: some View { 21 | ZStack(alignment: .bottom) { 22 | Color("Background") 23 | .edgesIgnoringSafeArea(.all) 24 | VStack(alignment: .leading) { 25 | 26 | Spacer() 27 | 28 | Text("Introduce yourself") 29 | .modifier(InputTitle()) 30 | .frame(maxHeight: 50) 31 | CustomTextField(text: $username) {} 32 | .placeholder(when: username.isEmpty) { 33 | Text("Your name") 34 | .font(Constants.fAppRegular) 35 | .foregroundColor(Color.gray) 36 | } 37 | .frame(maxHeight: 20) 38 | Divider() 39 | .background(Color.gray) 40 | 41 | Text("Select avatar") 42 | .modifier(InputTitleSmall()) 43 | .padding(.top, 30) 44 | ScrollView(.horizontal, showsIndicators: false) { 45 | LazyHStack() { 46 | ForEach(Constants.userAvatarUrls, id: \.self) { url in 47 | RemoteImageView(imageURL: url) 48 | .frame(width: 48, height: 48) 49 | .clipShape(Circle()) 50 | .overlay(avatarUrl == url ? Circle().stroke(Color.black, lineWidth: 4) : nil) 51 | .overlay(avatarUrl == url ? Circle().stroke(Color("Yellow"), lineWidth: 2) : nil) 52 | .padding(.horizontal, 4) 53 | .onTapGesture { 54 | avatarUrl = url 55 | } 56 | } 57 | } 58 | .frame(maxHeight: 52) 59 | .padding(.bottom, 20) 60 | } 61 | 62 | Button(action: { 63 | updateUser() 64 | withAnimation { 65 | isStageListPresent.toggle() 66 | } 67 | }) { 68 | Text("Sign in") 69 | .modifier(PrimaryButton()) 70 | } 71 | .disabled(username.isEmpty) 72 | .padding(.vertical, 30) 73 | } 74 | .blur(radius: isLoading ? 3 : 0) 75 | .padding(.horizontal, 8) 76 | 77 | if isStageListPresent { 78 | StageList(isPresent: $isStageListPresent, 79 | isLoading: $isLoading, 80 | onSelect: joinStage, 81 | onCreate: createStage) 82 | .blur(radius: isLoading ? 3 : 0) 83 | } 84 | } 85 | .onTapGesture { 86 | UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) 87 | } 88 | .onDisappear { 89 | UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) 90 | } 91 | .onFirstAppear { 92 | username = services.user.username ?? "" 93 | avatarUrl = services.user.avatarUrl ?? "" 94 | 95 | checkAVPermissions { granted in 96 | if !granted { 97 | services.viewModel?.appendErrorNotification("No camera/microphone permission granted") 98 | } 99 | } 100 | } 101 | } 102 | 103 | private func createStage() { 104 | isLoading = true 105 | onComplete(updateUser(true), nil) 106 | } 107 | 108 | private func joinStage(_ stage: StageDetails) { 109 | isLoading = true 110 | print("ℹ joining stage: \(stage.stageId)") 111 | 112 | services.viewModel?.getToken(for: stage) { stageJoinResponse, error in 113 | if let token = stageJoinResponse?.stage.token { 114 | print("ℹ stage auth successful - got token: \(token)") 115 | services.server.stageDetails = stage 116 | services.server.joinedStagePlaybackUrl = services.server.stageHostDetails?.channel.playbackUrl ?? "" 117 | onComplete(updateUser(), stageJoinResponse?.stage.token.token) 118 | } else { 119 | print("❌ Could not join stage - missing stage join token: \(error ?? "\(String(describing: stageJoinResponse))")") 120 | } 121 | isLoading = false 122 | } 123 | } 124 | 125 | @discardableResult 126 | private func updateUser(_ asHost: Bool = false) -> User { 127 | services.user.username = username 128 | services.user.avatarUrl = avatarUrl 129 | services.user.isHost = asHost 130 | UserDefaults.standard.set(services.user.username, forKey: "username") 131 | UserDefaults.standard.set(services.user.avatarUrl, forKey: "avatar") 132 | return services.user 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /MultiHostDemo/Views/StageListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StageListView.swift 3 | // Stages-demo 4 | // 5 | // Created by Uldis Zingis on 10/11/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct StageList: View { 11 | @EnvironmentObject var services: ServicesManager 12 | @Binding var isPresent: Bool 13 | @Binding var isLoading: Bool 14 | @State var stages: [StageDetails] = [] 15 | @State var isCameraPreviewPresent: Bool = false 16 | @State var selectedStage: StageDetails? 17 | var onSelect: (StageDetails) -> Void 18 | var onCreate: () -> Void 19 | 20 | var body: some View { 21 | UIRefreshControl.appearance().tintColor = UIColor.white 22 | let attributes = [NSAttributedString.Key.foregroundColor: UIColor.white] 23 | UIRefreshControl.appearance().attributedTitle = NSAttributedString(string: "Loading", attributes: attributes) 24 | UIScrollView.appearance().backgroundColor = UIColor.clear 25 | UITableView.appearance().backgroundColor = .clear 26 | UITableViewCell.appearance().backgroundColor = .clear 27 | 28 | let list = List(stages, id: \.stageId) { stage in 29 | if stages.count == 1 && stage.stageId.isEmpty { 30 | EmptyView() 31 | .background(Color.clear) 32 | } else { 33 | VStack(spacing: 0) { 34 | Divider() 35 | .background(Color("TextGray1")) 36 | .opacity(0.5) 37 | HStack { 38 | RemoteImageView(imageURL: stage.userAttributes.avatarUrl) 39 | .frame(width: 40, height: 40) 40 | .clipShape(Circle()) 41 | 42 | Text("\(stage.userAttributes.username)'s Stage") 43 | .frame(maxWidth: .infinity, alignment: .leading) 44 | .foregroundColor(Color.white) 45 | .font(Constants.fAppRegular) 46 | .padding(.horizontal, 16) 47 | 48 | Spacer() 49 | } 50 | .frame(height: 55) 51 | .padding(.horizontal, 16) 52 | .onTapGesture { 53 | selectedStage = stage 54 | isCameraPreviewPresent = true 55 | } 56 | } 57 | .listRowBackground(Color("BackgroundList")) 58 | .listRowSeparator(.hidden) 59 | .listRowInsets(EdgeInsets(top: 0, 60 | leading: 0, 61 | bottom: 0, 62 | trailing: 0)) 63 | } 64 | } 65 | .overlay(alignment: .center) { 66 | if stages.first?.stageId.isEmpty ?? true { 67 | Text("No stages are available\n Create a new stage to get started.") 68 | .modifier(Description()) 69 | } 70 | } 71 | .background(Color.clear) 72 | .listStyle(.plain) 73 | .refreshable(action: { 74 | services.viewModel?.getAllStages { allStages in 75 | stages = allStages 76 | } 77 | }) 78 | .preferredColorScheme(.dark) 79 | 80 | return ZStack(alignment: .top) { 81 | Color("Background") 82 | .edgesIgnoringSafeArea(.all) 83 | 84 | VStack(alignment: .leading, spacing: 0) { 85 | Text("Stages") 86 | .modifier(TitleLeading()) 87 | 88 | if !(stages.first?.stageId.isEmpty ?? false) { 89 | Text("All stages") 90 | .modifier(TableHeader()) 91 | } 92 | 93 | if #available(iOS 16.0, *) { 94 | list 95 | .scrollContentBackground(.hidden) 96 | } else { 97 | list 98 | } 99 | 100 | Spacer() 101 | 102 | Button(action: { 103 | onCreate() 104 | }) { 105 | Text("Create new stage") 106 | .modifier(PrimaryButton()) 107 | } 108 | .padding(.top, 30) 109 | } 110 | 111 | if isCameraPreviewPresent, let viewModel = services.viewModel { 112 | JoinPreviewView(viewModel: viewModel, isPresent: $isCameraPreviewPresent, isLoading: $isLoading) { 113 | guard let stage = selectedStage else { 114 | print("❌ Can't join - no stage selected") 115 | return 116 | } 117 | isPresent = false 118 | onSelect(stage) 119 | } 120 | } 121 | } 122 | .onAppear { 123 | isLoading = true 124 | DispatchQueue.main.asyncAfter(deadline: .now() + 2) { 125 | services.viewModel?.getAllStages(initial: true) { allStages in 126 | stages = allStages 127 | isLoading = false 128 | } 129 | } 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /MultiHostDemo/Views/StageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BroadcastView.swift 3 | // Multihost 4 | // 5 | // Created by Uldis Zingis on 09/06/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct StageView: View { 11 | @EnvironmentObject var services: ServicesManager 12 | @ObservedObject var viewModel: StageViewModel 13 | @ObservedObject var chatModel: ChatModel 14 | @Binding var isPresent: Bool 15 | @Binding var isLoading: Bool 16 | var backAction: () -> Void 17 | 18 | @State var isControlsExpanded: Bool = false 19 | @State var isManageParticipantsPresent: Bool = false 20 | @State var isChatPresent: Bool = false 21 | @State var isParticipantRequestToJoinPresent: Bool = false 22 | 23 | var body: some View { 24 | ZStack(alignment: .top) { 25 | Color("Background") 26 | .edgesIgnoringSafeArea(.all) 27 | VStack(alignment: .center, spacing: 0) { 28 | HeaderView(isLoading: $isLoading, 29 | isManageParticipantsPresent: $isManageParticipantsPresent, 30 | backAction: backAction) 31 | 32 | ZStack(alignment: .bottom) { 33 | if let viewModel = services.viewModel { 34 | ParticipantsGridView(viewModel: viewModel) 35 | .onTapGesture { 36 | UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), 37 | to: nil, from: nil, for: nil) 38 | withAnimation { 39 | isControlsExpanded = false 40 | } 41 | } 42 | .cornerRadius(40) 43 | .padding(.bottom, 80) 44 | } 45 | 46 | ChatView(chatModel: services.chatModel, isPresent: $isChatPresent) 47 | .padding(.bottom, 80) 48 | 49 | ControlButtonsDrawer(viewModel: services.viewModel!, 50 | isExpanded: $isControlsExpanded, 51 | isChatPresent: $isChatPresent) 52 | .padding(.bottom, !isControlsExpanded && services.user.isHost ? -145 : 0) 53 | .onTapGesture { 54 | guard !isControlsExpanded else { return } 55 | withAnimation { 56 | isControlsExpanded = true 57 | } 58 | } 59 | } 60 | 61 | } 62 | .frame(alignment: .bottom) 63 | .navigationBarBackButtonHidden(true) 64 | 65 | if isManageParticipantsPresent { 66 | ManageParticipantsView(isPresent: $isManageParticipantsPresent) 67 | } 68 | } 69 | .onAppear { 70 | isChatPresent = !services.user.isHost 71 | } 72 | .onTapGesture { 73 | UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) 74 | } 75 | .onChange(of: viewModel.stageConnectionState) { state in 76 | if state == .disconnected { 77 | backAction() 78 | } 79 | } 80 | } 81 | } 82 | 83 | struct HeaderView: View { 84 | @EnvironmentObject var services: ServicesManager 85 | @Binding var isLoading: Bool 86 | @Binding var isManageParticipantsPresent: Bool 87 | var backAction: () -> Void 88 | 89 | var body: some View { 90 | HStack { 91 | Button { 92 | isLoading = true 93 | services.disconnectFromStage() { 94 | backAction() 95 | } 96 | } label: { 97 | Image(systemName: "xmark") 98 | .resizable() 99 | .frame(width: 12, height: 12) 100 | } 101 | .foregroundColor(Color.white) 102 | .padding() 103 | .frame(width: 50) 104 | 105 | Spacer() 106 | 107 | Text(services.user.isHost ? 108 | "Your Stage" : 109 | "\(services.server.stageDetails?.userAttributes.username ?? "")'s Stage") 110 | .modifier(TitleRegular()) 111 | 112 | Spacer() 113 | 114 | ControlButton(image: Image("icon_group"), 115 | backgroundColor: Color.clear) { 116 | isManageParticipantsPresent.toggle() 117 | } 118 | .frame(width: 50) 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /MultiHostDemo/Views/WelcomeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WelcomeView.swift 3 | // Multihost 4 | // 5 | // Created by Uldis Zingis on 22/07/2022. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct WelcomeView: View { 11 | @EnvironmentObject var services: ServicesManager 12 | @Binding var isPresent: Bool 13 | @Binding var isSetupPresent: Bool 14 | 15 | var body: some View { 16 | ZStack(alignment: .top) { 17 | Color("Background") 18 | .edgesIgnoringSafeArea(.all) 19 | VStack(alignment: .center) { 20 | 21 | Spacer() 22 | 23 | Image("welcomeImage") 24 | .resizable() 25 | .frame(width: 82, height: 152) 26 | 27 | Spacer() 28 | 29 | Text("Amazon IVS Stages Demo") 30 | .modifier(Title()) 31 | 32 | Text("This demo app demonstrates how to use Amazon IVS Stages to broadcast a video call.") 33 | .modifier(Description()) 34 | 35 | Spacer() 36 | 37 | Button(action: { 38 | isPresent.toggle() 39 | isSetupPresent.toggle() 40 | }) { 41 | Text("Get Started") 42 | .modifier(PrimaryButton()) 43 | } 44 | .padding(.horizontal, 8) 45 | 46 | Link("View Source Code", destination: URL(string: Constants.sourceCodeUrl)!) 47 | .modifier(SecondaryButton()) 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Podfile: -------------------------------------------------------------------------------- 1 | platform :ios, '14.0' 2 | 3 | target 'MultiHost-demo' do 4 | pod 'AmazonIVSChat', '~> 1.0.0' 5 | pod 'AmazonIVSBroadcast/Stages', '~> 1.28.1' 6 | end -------------------------------------------------------------------------------- /Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - AmazonIVSBroadcast/Stages (1.28.1) 3 | - AmazonIVSChat (1.0.0): 4 | - AmazonIVSChat/Messaging (= 1.0.0) 5 | - AmazonIVSChat/Messaging (1.0.0) 6 | 7 | DEPENDENCIES: 8 | - AmazonIVSBroadcast/Stages (~> 1.28.1) 9 | - AmazonIVSChat (~> 1.0.0) 10 | 11 | SPEC REPOS: 12 | trunk: 13 | - AmazonIVSBroadcast 14 | - AmazonIVSChat 15 | 16 | SPEC CHECKSUMS: 17 | AmazonIVSBroadcast: 7bb8bbc080cb78088aba900eaecc2b37feb7d39d 18 | AmazonIVSChat: 62d4e654c4b16b7e62d43048a0e548efd145f183 19 | 20 | PODFILE CHECKSUM: a19006831ff0aa77ca229d76de9eeee450cdb710 21 | 22 | COCOAPODS: 1.16.2 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Amazon IVS Multi-host for iOS Demo 2 | 3 | A demo SwiftUI iPhone application intended as an educational tool to demonstrate how you can build a real-time collaborative live streaming experience with [Amazon IVS](https://www.ivs.rocks/). 4 | 5 | A screenshot of the demo application running on an iPhone. 6 | 7 | **This project is intended for education purposes only and not for production usage.** 8 | 9 | ## Prerequisites 10 | 11 | You must have the `ApiUrl` from the [Amazon IVS Multi-host Serverless Demo](https://www.github.com/aws-samples/amazon-ivs-multi-host-serverless-demo). 12 | 13 | ## Setup 14 | 15 | 1. Clone the repository to your local machine. 16 | 2. Install the SDK dependency using CocoaPods: `pod install` 17 | 3. Open `MultiHost-demo.xcworkspace`. 18 | 4. Set the `API_URL` constant in the `Constants.swift` file to equal the `ApiUrl` from your deployed [Amazon IVS Multi-host Serverless Demo](https://www.github.com/aws-samples/amazon-ivs-multi-host-serverless-demo). 19 | 5. Since iPhone simulators don't currently support the use of cameras or ReplayKit in this app, there are a couple changes you need to make before building and running the app on a physical device. 20 | 1. Have an active Apple Developer account in order to build to physical devices. 21 | 2. Modify the Bundle Identifier for the `MultiHost-demo` target. 22 | 3. Choose a Team for the target. 23 | 6. You can now build and run the project on a device. 24 | 25 | **IMPORTANT NOTE:** Joining a stage and streaming in the app will create and consume AWS resources, which will cost money. 26 | 27 | ## Known Issues 28 | 29 | - This app has only been tested on devices running iOS 14 or later. While this app may work on devices running older versions of iOS, it has not been tested on them. 30 | - A list of known issues for the Amazon IVS Broadcast SDK is available on the following page: [Amazon IVS Broadcast SDK: iOS Known Issues](https://docs.aws.amazon.com/ivs/latest/userguide/broadcast-ios-issues.html) 31 | 32 | ## More Documentation 33 | 34 | - [Amazon IVS iOS Broadcast SDK Guide](https://docs.aws.amazon.com/ivs/latest/userguide/broadcast-ios.html) 35 | - [Amazon IVS iOS Broadcast SDK Sample code](https://github.com/aws-samples/amazon-ivs-broadcast-ios-sample) 36 | - [More code samples and demos](https://www.ivs.rocks/examples) 37 | 38 | ## License 39 | 40 | This project is licensed under the MIT-0 License. See the LICENSE file. 41 | -------------------------------------------------------------------------------- /THIRD-PARTY-LICENSES.txt: -------------------------------------------------------------------------------- 1 | The Amazon IVS Multi-host for iOS Demo includes the following third-party software/licensing: 2 | 3 | * Assets.xcassets/icon_chat_off.imageset/icon_chat_off.png 4 | * Assets.xcassets/icon_cached.imageset/icon_cached.png 5 | * Assets.xcassets/icon_chat_on.imageset/icon_chat_on.png 6 | * Assets.xcassets/icon_do_not_disturb.imageset/icon_do_not_disturb.png 7 | * Assets.xcassets/icon_arrow_down.imageset/icon_arrow_down.png 8 | * Assets.xcassets/icon_info_dark.imageset/icon_info_dark.png 9 | * Assets.xcassets/icon_info_light.imageset/icon_info_light.png 10 | * Assets.xcassets/icon_logout.imageset/icon_logout.png 11 | * Assets.xcassets/icon_mic_off_red.imageset/icon_mic_off_red.png 12 | * Assets.xcassets/icon_mic_off.imageset/icon_mic_off.png 13 | * Assets.xcassets/icon_mic_on.imageset/icon_mic_on.png 14 | * Assets.xcassets/icon_start_broadcast.imageset/icon_start_broadcast.png 15 | * Assets.xcassets/icon_swap_camera.imageset/icon_swap_camera.png 16 | * Assets.xcassets/icon_video_off.imageset/icon_video_off.png 17 | * Assets.xcassets/icon_video_off_red.imageset/icon_video_off_red.png 18 | * Assets.xcassets/icon_video_on.imageset/icon_video_on.png 19 | * Assets.xcassets/icon_warning.imageset/icon_warning.png 20 | 21 | Material-design-icons - https://github.com/google/material-design-icons 22 | 23 | Apache License 24 | Version 2.0, January 2004 25 | http://www.apache.org/licenses/ 26 | 27 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 28 | 29 | 1. Definitions. 30 | 31 | "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. 32 | 33 | "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. 34 | 35 | "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. 36 | 37 | "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. 38 | 39 | "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. 40 | 41 | "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. 42 | 43 | "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). 44 | 45 | "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. 46 | 47 | "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." 48 | 49 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 50 | 51 | 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 52 | 53 | 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 54 | 55 | 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: 56 | 57 | You must give any other recipients of the Work or Derivative Works a copy of this License; and 58 | You must cause any modified files to carry prominent notices stating that You changed the files; and 59 | You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and 60 | If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. 61 | 62 | You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 63 | 64 | 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 65 | 66 | 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 67 | 68 | 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 69 | 70 | 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. 71 | 72 | END OF TERMS AND CONDITIONS 73 | -------------------------------------------------------------------------------- /app-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-multi-host-for-ios-demo/c2fb454d59d3c99925b800dc71412ac463bdae67/app-screenshot.png --------------------------------------------------------------------------------