diff --git a/Sources/LiveKit/Participant/LocalParticipant.swift b/Sources/LiveKit/Participant/LocalParticipant.swift index 146fda51b..5c96b9311 100644 --- a/Sources/LiveKit/Participant/LocalParticipant.swift +++ b/Sources/LiveKit/Participant/LocalParticipant.swift @@ -96,6 +96,13 @@ public class LocalParticipant: Participant, @unchecked Sendable { try await publisher.remove(track: sender) // Mark re-negotiation required... try await room.publisherShouldNegotiate() + + // Reset hasPublished when no more tracks are published, so that + // a publisher transport disconnect doesn't trigger an unnecessary reconnect. + let hasPublishedTracks = _state.trackPublications.values.contains { $0.track != nil } + if !hasPublishedTracks { + room._state.mutate { $0.hasPublished = false } + } } // Wait for track to stop (if required) diff --git a/Tests/LiveKitCoreTests/PeerConnectionSignalingTests.swift b/Tests/LiveKitCoreTests/PeerConnectionSignalingTests.swift index 5156e748c..9e6aa96fb 100644 --- a/Tests/LiveKitCoreTests/PeerConnectionSignalingTests.swift +++ b/Tests/LiveKitCoreTests/PeerConnectionSignalingTests.swift @@ -372,6 +372,63 @@ struct PeerConnectionSignalingTests { } } + @Test(.bug("https://github.com/livekit/client-sdk-swift/issues/958"), + arguments: SignalingMode.allCases) + func republishAfterUnpublishAll(mode: SignalingMode) async throws { + try await TestEnvironment.withRooms([ + roomTestingOptions(mode: mode, canPublish: true), + roomTestingOptions(mode: mode, canSubscribe: true), + ]) { rooms in + let room1 = rooms[0] + let room2 = rooms[1] + + let publisherIdentity = try #require(room1.localParticipant.identity) + let remoteParticipant = try #require(room2.remoteParticipants[publisherIdentity]) + + // Phase 1: Publish audio + simulcast video (720x1280 triggers 3 layers) + let audioTrack1 = TestAudioTrack(name: "audio-1") + let videoTrack1 = LocalVideoTrack.createBufferTrack(name: "video-1") + if let capturer = videoTrack1.capturer as? BufferCapturer { + var pb: CVPixelBuffer? + CVPixelBufferCreate(kCFAllocatorDefault, 720, 1280, kCVPixelFormatType_32BGRA, nil, &pb) + if let pb { capturer.capture(pb) } + } + + try await room1.localParticipant.publish(audioTrack: audioTrack1) + try await room1.localParticipant.publish(videoTrack: videoTrack1) + + try await waitForPublish(on: remoteParticipant) { + $0.trackPublications.count >= 2 + } + + // Phase 2: Unpublish all + await room1.localParticipant.unpublishAll() + + try await waitForPublish(on: remoteParticipant) { + $0.trackPublications.isEmpty + } + + try await Task.sleep(nanoseconds: 2_000_000_000) + + // Phase 3: Republish new tracks + let audioTrack2 = TestAudioTrack(name: "audio-2") + let videoTrack2 = LocalVideoTrack.createBufferTrack(name: "video-2") + if let capturer = videoTrack2.capturer as? BufferCapturer { + var pb: CVPixelBuffer? + CVPixelBufferCreate(kCFAllocatorDefault, 720, 1280, kCVPixelFormatType_32BGRA, nil, &pb) + if let pb { capturer.capture(pb) } + } + + try await room1.localParticipant.publish(audioTrack: audioTrack2) + try await room1.localParticipant.publish(videoTrack: videoTrack2) + + try await waitForPublish(on: remoteParticipant) { + $0.trackPublications.count >= 2 + } + #expect(remoteParticipant.trackPublications.count >= 2, "Remote should see republished tracks") + } + } + @Test(arguments: SignalingMode.allCases) func doubleReconnect(mode: SignalingMode) async throws { let reconnectWatcher = ReconnectWatcher()