Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### 🐞 Fixed
- During a reconnection/migration the current user will not be appearing twice any more. [#731](https://github.com/GetStream/stream-video-swift/pull/731)
- ParticipantsCount and AnonymousParticipantsCount weren't updating correctly. [#736](https://github.com/GetStream/stream-video-swift/pull/736)

# [1.19.2](https://github.com/GetStream/stream-video-swift/releases/tag/1.19.2)
_March 27, 2025_
Expand Down
3 changes: 3 additions & 0 deletions Sources/StreamVideo/WebSockets/Client/WebSocketClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,9 @@ extension WebSocketClient: WebSocketEngineDelegate {
onConnected?()
}

// We send the healthcheck to the eventSubject so that observers
// (e.g. SFUEventAdapter) get updated.
eventSubject.send(healthcheck)
eventNotificationCenter.process(healthcheck, postNotification: false) { [weak self] in
self?.engineQueue.async { [weak self] in
self?.pingController.pongReceived()
Expand Down
190 changes: 106 additions & 84 deletions Sources/StreamVideoSwiftUI/Livestreaming/LivestreamPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,94 +81,15 @@ public struct LivestreamPlayer<Factory: ViewFactory>: View {
public var body: some View {
ZStack {
if viewModel.errorShown {
Text(L10n.Call.Livestream.error)
errorView
} else if viewModel.loading {
ProgressView()
loadingView
} else if state.backstage {
Text(L10n.Call.Livestream.notStarted)
notStartedView
} else {
ZStack {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No changes here, just moving things around, right?

Copy link
Contributor Author

@ipavlidakis ipavlidakis Mar 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It adds also some background when the state is loading,error or backstage because currently the text just overlays the CallScreen. it also shows the controls show that in any state the user can leave the call.

GeometryReader { reader in
if let participant = state.participants.first(where: { $0.track != nil }) {
VideoCallParticipantView(
viewFactory: viewFactory,
participant: participant,
availableFrame: reader.frame(in: .global),
contentMode: .scaleAspectFit,
customData: [:],
call: viewModel.call
)
.onTapGesture {
viewModel.update(controlsShown: true)
}
.overlay(
viewModel.controlsShown ? LivestreamPlayPauseButton(
viewModel: viewModel
) {
participant.track?.isEnabled =
!viewModel.streamPaused
if !viewModel.streamPaused {
viewModel.update(controlsShown: false)
}
} : nil
)
}
}

if viewModel.controlsShown || !viewModel.fullScreen {
VStack {
Spacer()
HStack(spacing: 8) {
LiveIndicator()
if viewModel.showParticipantCount {
LivestreamParticipantsView(
participantsCount:
Int(
viewModel.call.state
.participantCount
)
)
}
Spacer()
LivestreamButton(
imageName: !viewModel.muted
? "speaker.wave.2.fill"
: "speaker.slash.fill"
) {
viewModel.toggleAudioOutput()
}
LivestreamButton(imageName: "viewfinder") {
viewModel.update(
fullScreen:
!viewModel.fullScreen
)
}
if showsLeaveCallButton {
LivestreamButton(
imageName: "phone.down.fill"
) {
viewModel.leaveLivestream()
}
}
}
.padding()
.background(
colors.livestreamBackground
.edgesIgnoringSafeArea(.all)
)
.foregroundColor(colors.livestreamCallControlsColor)
.overlay(
LivestreamDurationView(
duration: viewModel.duration(from: state)
)
)
}
}
}
.onChange(of: viewModel.fullScreen) { newValue in
onFullScreenStateChange?(newValue)
}
videoRenderer
}
livestreamControls
}
.onChange(of: state.participants, perform: { newValue in
if viewModel.muted && newValue.first?.track != nil {
Expand All @@ -192,6 +113,107 @@ public struct LivestreamPlayer<Factory: ViewFactory>: View {
}
}
}

// MARK: - Private

@ViewBuilder
private var errorView: some View {
Color(colors.callBackground).ignoresSafeArea()
Text(L10n.Call.Livestream.error)
}

@ViewBuilder
private var loadingView: some View {
Color(colors.callBackground).ignoresSafeArea()
ProgressView()
}

@ViewBuilder
private var notStartedView: some View {
Color(colors.callBackground).ignoresSafeArea()
Text(L10n.Call.Livestream.notStarted)
}

@ViewBuilder
private var videoRenderer: some View {
GeometryReader { reader in
if let participant = state.participants.first(where: { $0.track != nil }) {
VideoCallParticipantView(
viewFactory: viewFactory,
participant: participant,
availableFrame: reader.frame(in: .global),
contentMode: .scaleAspectFit,
customData: [:],
call: viewModel.call
)
.onTapGesture {
viewModel.update(controlsShown: true)
}
.overlay(
viewModel.controlsShown ? LivestreamPlayPauseButton(
viewModel: viewModel
) {
participant.track?.isEnabled =
!viewModel.streamPaused
if !viewModel.streamPaused {
viewModel.update(controlsShown: false)
}
} : nil
)
}
}
.onChange(of: viewModel.fullScreen) { onFullScreenStateChange?($0) }
}

@ViewBuilder
private var livestreamControls: some View {
if viewModel.controlsShown || !viewModel.fullScreen {
VStack {
Spacer()
HStack(spacing: 8) {
LiveIndicator()
if viewModel.showParticipantCount {
LivestreamParticipantsView(
participantsCount:
Int(state.participantCount)
)
}
Spacer()
LivestreamButton(
imageName: !viewModel.muted
? "speaker.wave.2.fill"
: "speaker.slash.fill"
) {
viewModel.toggleAudioOutput()
}
LivestreamButton(imageName: "viewfinder") {
viewModel.update(
fullScreen:
!viewModel.fullScreen
)
}
if showsLeaveCallButton {
LivestreamButton(
imageName: "phone.down.fill"
) {
viewModel.leaveLivestream()
}
}
}
.padding()
.background(
colors.livestreamBackground
.edgesIgnoringSafeArea(.all)
)
.foregroundColor(colors.livestreamCallControlsColor)
.overlay(
LivestreamDurationView(
duration: viewModel.duration(from: state)
)
)
}
}
}
}

struct LiveIndicator: View {
Expand Down
29 changes: 28 additions & 1 deletion StreamVideoTests/WebSocketClient/WebSocketClient_Tests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ final class WebSocketClient_Tests: XCTestCase, @unchecked Sendable {

// MARK: - Event handling tests

func test_whenHealthCheckEventComes_itGetProcessedSilentlyWithoutBatching() throws {
func test_whenCoordinatorHealthCheckEventComes_itGetProcessedSilentlyWithoutBatching() throws {
// Connect the web-socket client
webSocketClient.connect()

Expand Down Expand Up @@ -312,6 +312,33 @@ final class WebSocketClient_Tests: XCTestCase, @unchecked Sendable {
XCTAssertFalse(postNotification)
}

func test_whenSFUHealthCheckEventComes_itGetProcessedSilentlyWithoutBatching() {
let receiptExpectation = expectation(description: "HealthCheck event received")
let cancellable = webSocketClient
.eventSubject
.filter { $0.name.contains("healthCheck") }
.sink { _ in receiptExpectation.fulfill() }

// Connect the web-socket client
webSocketClient.connect()

// Wait for engine to be called
AssertAsync.willBeEqual(engine!.connect_calledCount, 1)

// Simulate engine established connection
engine!.simulateConnectionSuccess()

// Wait for the connection state to be propagated to web-socket client
AssertAsync.willBeEqual(webSocketClient.connectionState, .authenticating)

// Simulate received health check event
decoder.decodedEvent = .success(.sfuEvent(.healthCheckResponse(.init())))
engine!.simulateMessageReceived()

wait(for: [receiptExpectation], timeout: defaultTimeout)
cancellable.cancel()
}

func test_whenNonHealthCheckEventComes_getsBatchedAndPostedAfterProcessing() throws {
// Simulate connection
test_connectionFlow()
Expand Down