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