raven_rtc 0.2.2
raven_rtc: ^0.2.2 copied to clipboard
Livqeno SDK for Flutter — join a room, publish camera and microphone, render participants. Hides WebRTC, SDP, ICE, STUN, TURN and the media server behind a small typed API.
0.2.2 #
-
Fixed: every other participant in a room hit an unhandled
Bad state: Cannot add new events after calling closethe instant the host ended a broadcast — one exception per participant still connected, every time._scheduleReconnect()only checked whether the client had been closed at the moment_onCloseddecided to start reconnecting, not at any of its own suspension points. When the app tore the client down (dispose()) while a reconnect attempt was parked mid-await— refreshing the token, or waiting out the backoff —close()'s_reconnectTimer?.cancel()had nothing to cancel yet, so the chain resumed after dispose anyway and tried to add a lifecycle event to aStreamControllerthatdispose()had already closed._scheduleReconnectnow re-checks before every resumption: on entry, after the token-refreshawait, and inside the retry timer's callback before reopening a socket. Same bug on 0.1.5 through 0.2.1 — the reconnect logic is unchanged across those versions.Validated: a unit test reproduces the exact race —
dispose()arriving while a reconnect is parked awaiting a token refresh — against a fake socket, and confirmed it throws the reported exception when the fix is reverted. Not yet validated: the failure on a physical device against a real room teardown. This was diagnosed and fixed from the reported stack trace and a controlled reproduction, not by re-running the original broadcast-end scenario end-to-end.
0.2.1 #
-
Fixed: an Android client could allocate a TURN relay, never send a CreatePermission for the server's address, never send a relay connectivity check, and sit in
checkinguntil the SFU gave up on it around thirty seconds later. Because it depended on which message won a race it looked intermittent, and so it read like a network problem.It was not. Remote ICE candidates were being discarded: the engine returned early when no peer connection existed yet, and wrapped
addCandidatein a barecatch.addCandidateis illegal until a remote description has been applied, and the SFU trickles its candidates as soon as it has them — routinely before its offer has been processed. A client left holding no remote candidate has no peer to permit and nothing to check toward, which is exactly the shape the server-side investigation kept finding.Candidates that cannot be applied yet are now held — bounded, oldest dropped past 128 — and drained after every
setRemoteDescription, on both the offer and the answer path, so neither ordering loses what the other would have applied. Device logs show why it mattered: every run buffers three candidates. The race was happening on every connection; the old code simply got lucky about half the time. Measured on Android against the published 0.2.0, same device and network, alternating: five of ten host connections succeeded before, ten of ten after. -
Added: a bounded stall watchdog. A connection still short of
connectedafter twelve seconds — comfortably inside the SFU's own thirty-second deadline — gets exactly one ICE restart. Single-flight, one attempt per connection, and skipped outright if a negotiation is already in flight, so it cannot produce a duplicate publisher, a second peer connection, or a retry loop. It reuses the existing offer path, so published tracks stay on their transceivers. In practice it did not need to fire in any of the ten runs above; it is there for the connection that would otherwise wait for the server to end it. -
Changed: ICE diagnostics now go through
debugPrint, so they reachlogcaton a device. Candidates are logged by shape only —relay/udp,srflx/udp— never their address, because an ICE candidate line carries the connection's ufrag.
Not yet validated: repeated viewer-side sessions, physical devices,
network transitions, and the ICE restart path on a device. The host path
is well covered; the viewer path rests on a single clean end-to-end
session. See flutter_check/android_ice for the harness.
0.2.0 #
Simulcast now actually works. It never had.
-
Fixed (the big one):
RavenEngine.publish()calledpc.addTrack()and then tried to attach a three-layer ladder withsender.setParameters(). That cannot work — the WebRTC specification requires implementations to reject a change to a sender's number of encodings, and RIDs only reach the SDP when they were present before the offer was generated. The error was caught by a barecatch (_)and discarded, so every published camera sent a single full-resolution layer while the SDK reported success. Subscribers had no cheaper layer to drop to, which is what made a call past four or five participants cost every device the full bitrate of every other device.A camera is now published through
pc.addTransceiver()withsendEncodingsdeclared at creation, which is the only point a ladder can be declared. The generated offer carriesa=simulcast:send low;medium;highand threea=rid:lines, and the SFU answersa=simulcast:recv. -
Fixed: an explicit
room.requestLayer(...)was silently dropped wheneveradaptiveStreamwas false — the one combination that means "I will manage quality myself". It now always works;adaptiveStreamgates only automatic requests. -
Added:
RavenSimulcastStatusandRavenRoom.simulcastStatusFor(kind). Read back from the negotiated sender, never from what was requested. A platform that refuses the ladder reportsunsupportedand logs a warning rather than failing silently, and still publishes a single layer. -
Changed: an explicit
requestLayernow pins a track — adaptive streaming will not move it afterwards. Pass'auto'to release the pin. Previously a manual choice was undone by the next layout pass. The signature is unchanged; it still takes the layer as aString. -
Changed:
RavenVideoView's adaptive layer selection gained hysteresis and a 250 ms debounce. A grid that lands tiles on a threshold used to flip them between layers on every small nudge, and each flip costs a real layer switch — the SFU waits for a keyframe and the tile stutters. -
Changed: republishing a camera reuses its transceiver. Without that, every
disableCamera()/enableCamera()cycle added an m-section that never went away. -
Fixed: a tile stayed on its placeholder after the camera it shows was enabled, and would only correct itself when some unrelated event happened to rebuild the tree. Two things had to line up.
RavenRoomnotifies its listeners synchronously, before the microtask that delivers the new roster onparticipantChangesruns, soRavenVideoView's own listener re-resolved against awidget.participantthat was still the snapshot from before the publish — and found nothing. The rebuild that followed carried the right snapshot, butdidUpdateWidgetskipped re-resolving becauseRavenParticipantcompares by identity alone: two snapshots of the same person are==however different what they are publishing is.RavenVideoViewnow reads the participant back from the room by identity rather than trusting the snapshot it was handed, and re-resolves on every rebuild. The workaround that was circulating — keying the view onisCameraEnabled/isScreenSharing— is no longer needed, and harmless if you keep it. This affected remote tiles as well as local ones, and the layer request adaptive streaming makes on a tile's behalf, which was being dropped for the same reason. -
Added:
RavenRoom.mediaStateandRavenRoom.mediaStateChanges, reporting a newRavenMediaStateread from the peer connection itself.RavenConnectionStateis, and has always been, the signaling connection: it readsconnectedthe moment the server accepts the join, whatever the media transport is doing. A network that permits outbound TLS but drops UDP therefore produced a room that joined cleanly, listed its participants, relayed chat, and never carried a frame — while every observable the SDK offered saidconnected. There was no way to tell that apart from "nobody has published yet" without readingflutter_webrtc's logs.mediaStateanswers that question directly:idle,connecting,connected,interrupted,failed,closed.RavenConnectionState's own documentation now says plainly what it does and does not cover.
0.1.8 #
Fixes the local self-preview tile staying on its placeholder while the remote peer's tile rendered fine, found via the same external-developer re-test as 0.1.6/0.1.7.
- Fixed:
enableCamera()/enableMicrophone()/enableScreenShare()only told the UI about a newly published local track afterRavenEngine.publish()fully resolved — which waits on a complete SFU offer/answer round trip. The localMediaStreamis actually available and ready to render much earlier, right aftergetUserMedia()returns, but nothing notified listeners until negotiation finished. A remote track has no equivalent avoidable delay, so this asymmetry showed up exactly as reported: the local tile appearing stuck while the remote tile updated immediately.RavenEngine.publish()now notifies as soon as the local track is captured, independent of the negotiation round trip that follows.
0.1.7 #
Fixes a console warning logged on every single enableCamera()/
enableMicrophone() call on web, found via the same external-developer
re-test as 0.1.6.
- Fixed:
_cameraConstraintspassedfacingMode: 'user'unconditionally, including on web.flutter_webrtc's webgetUserMediashim only keepsfacingModefor a mobile-browser user agent — desktop Chrome, including headless Chromium, never matches, so the shim stripped it and logged[getUserMedia] failed to remove facingMode from mediaConstraintson every call.facingModeis now omitted on web; mobile camera switching is unaffected, since it goes throughRoom.switchCamera()rather than this constraint.
0.1.6 #
Fixes RavenRoom.participantChanges silently missing the current roster
for a listener attached after Raven.join() returns, found via an
external-developer re-test of the published package against a real
backend and SFU — specifically, a viewer joining a live stream already
in progress.
- Fixed:
participantChangeswas a plain broadcast stream fed by_emitParticipants(), whose first call happens synchronously whilejoin()itself is still resolving — applying the server's initial roster before any caller's code can possibly have attached a listener yet, since theRavenRoom(and so this getter) doesn't exist any earlier. A late listener on a broadcast stream simply never sees an event that already fired, so an app that builds its initial UI from this stream (rather than readingparticipants/remoteParticipantsdirectly first) saw an empty roster until the next change — which, for a viewer joining a room where someone was already publishing, could be never.participantsandremoteParticipantswere never affected; they read current state directly.participantChangesnow replays the current roster to each new listener the instant it subscribes, viaStream.multi. No public API changes — same getter, same event shape, now correct for every subscriber regardless of when it attaches. - Added regression coverage (
test/room_test.dart) proving a listener attached well after the initial join still receives the roster that was already there.
0.1.5 #
Fixes a data channel that could silently never open, found via a real Flutter-to-Flutter and Flutter-to-browser end-to-end sweep against a real backend and SFU. No public API changes.
- Fixed:
RavenEngine.ensureDataChannel()created the local data channel but never negotiated it — unlikepublish()/unpublish(), nothing else was ever going to prompt an offer for a participant who only wantsdata. A channel opened this way either rode along behind some unrelated publish's negotiation round, or, for a participant that never published anything, never opened at all:RavenRoom.data's listener callsensureDataChannel()exactly this way (spec §8), so a receive-only participant could not send or receive data.ensureDataChannel()now awaits the same join-time barrierpublish()does and negotiates through the same guarded path, so the channel it creates is always the one actually offered to the SFU. - Added regression coverage (
test/engine_test.dart) provingensureDataChannel()negotiates on its own, with no publish required.
0.1.4 #
Fixes a join-time negotiation race that could leave the SFU's very first offer permanently unanswered, found via a real end-to-end sweep against a real backend, SFU and browser. No public API changes.
- Fixed:
SignalingClient.send()refused to send anything butroom.joinuntil the server'sroom.joinedconfirmation arrived, gating on_joined. The SFU's join-time offer reaches the client over a path with no ordering relationship to that confirmation — it travels node-link → gateway → socket independently of the join handler's own response — and the SFU builds and sends that offer synchronously, with no I/O in between, so it frequently wins the race. When it did, the client answered it correctly butsend()silently dropped the answer, and the SFU waited out its full 15sanswerTimeoutfor an answer that was never coming.send()now gates on a new_joinSentflag that flips the instantroom.joinis dispatched, so an answer or ICE candidate produced while a join is still in flight is no longer discarded. - Added regression coverage (
test/signaling_client_test.dart) pinning this exact race: an SFU offer arriving and being answered beforeroom.joinedhas arrived. - Verified via a real end-to-end sweep — a real Flutter Web build joining
a real live stream through a real local SFU, with a real browser
viewer's raw
RTCPeerConnection.getStats()confirmingframesDecodedandbytesReceivedgenuinely increasing — at 20/20 with zero dropped answers and zero negotiation timeouts.
0.1.3 #
Fixes a negotiation-glare bug that could leave a locally "published" track never actually negotiated with the SFU, found via a real end-to-end run against a real backend, SFU and browser. No public API changes.
- Fixed: the SFU is the impolite peer in a glare (both sides
offering at once) by design: it refuses the client's offer with a
retryable
NEGOTIATION_GLAREand expects the client to answer its own offer, then retry.RavenEnginereceived that error and simply discarded it — no rollback, no retry — so a track added right as the SFU's join-time offer arrived could sit on the peer connection withtrack.publishdeclared but never actually negotiated. Every other participant then saw that source as not published (camera=false/microphone=false) even thoughenableCamera()/enableMicrophone()reported success._handleOffernow rolls the local offer back before accepting a colliding SFU offer, and a glared publish is requeued and retried once that round completes. - Fixed: the retry itself could stay queued forever. It decided
whether to negotiate by reading
RTCPeerConnection.signalingState, which flutter_webrtc only updates from an asynchronous platform event; read immediately after our own rollback-and-answer (exactly where the retry needs to fire), it could still report the pre-answer state on Flutter Web. It now asks the platform directly (getSignalingState()) instead of trusting that cache. - Every operation that can change the peer connection's signaling state (an incoming SFU offer, an incoming SFU answer, a local publish) is now serialized through one queue, so two of them can no longer interleave against a signaling state the other has since moved past.
- Added regression coverage (
test/engine_test.dart) for glare recovery specifically: a colliding SFU offer rolling ours back, a glared publish surviving instead of being dropped, a publish deferred behind our own offer retrying once that offer is answered (not only once the SFU offers again), and the full publish → glare → rollback → retry → accepted sequence end to end. - Verified via repeated real end-to-end runs — a real Flutter Web
build publishing camera and microphone to a real live stream through
a real local SFU, with a real browser subscriber's raw
RTCPeerConnection.getStats()confirmingframesDecodedandbytesReceivedactually increase — including runs where the glare condition fired live and recovered correctly. - Known issue, not fixed here: a separate, rarer race was found
during this verification, unrelated to glare: a viewer joining right
as a Flutter host's negotiation completes can occasionally time out
waiting to subscribe. It traces to the SFU only subscribing a new
participant to tracks it already considers published at join time,
combined with this SDK's
ready/publish-success signals meaning the local offer was sent, not that the SFU has accepted it yet. Fixing it would mean changing the SFU or the readiness contract; out of scope for this release.
0.1.2 #
Fixes Raven.join() rejecting rooms identified by name, found via a
real end-to-end check of raven_live against a real backend and SFU.
No public API changes.
- Fixed:
Raven.join(roomId)only ever validatedroomIdagainst the RTC token's internal room id (rid), and then sentroomId— whatever the caller passed — verbatim on the wire. A token also carries the room's human name (rnm), and a caller who legitimately knows the room only by that name (a live stream's public id, which is what its underlying room'snameis set to server-side) got rejected outright withRavenErrorCode.roomNotFound, or, once the local check was widened to accept either claim, still got rejected server-side: the signaling layer names a room byridalone, and this SDK was sending its ownroomIdargument on the wire instead of the token's.join()now accepts a match against either claim, and the wire value is always the token's ownrid— matching the web SDK'sassertTokenMatchesRoom/roomIdFromTokenexactly. A token naming no room at all now fails fast withRavenErrorCode.invalidTokeninstead of silently sending nothing meaningful. - Added regression coverage (
test/raven_test.dart) for the precheck accepting either claim, rejecting neither, and the no-room-claim failure mode. - Verified via a genuine end-to-end run: a real Flutter Web build
publishing camera and microphone to a real live stream through a real
local SFU, with a real browser subscriber observing
framesDecodedandbytesReceivedactually increase over a sustained window — and the reverse direction, a real browser host with a real Flutter Web viewer subscribing to it. Seeapps/api/test/flutter-live-streaming.e2e-spec.tsandflutter_check/live_host.
0.1.1 #
Fixes a signaling/negotiation lifecycle bug that could prevent remote media from ever arriving. No public API changes.
- Fixed:
RavenEngineandRavenRoomnow subscribe to signaling beforeSignalingClient.connect()is called, not after it resolves. Previously,RavenRoom.attach()andRavenEngine.start()ran only once the join had already completed, which left a window for the server's own messages — ansdp.offermost of all — to arrive on a broadcast stream with no subscriber yet and be silently dropped, with nosdp.answerever sent back. Subscribing first closes that window unconditionally, matching how the web SDK registers its signaling listeners before awaitingconnect(). - Fixed:
RavenEngine.applyJoinedState()— which restores announced tracks and reconciles the roster — was never called on the first join, only on reconnects.RavenRoom._handleJoined()now runs the same path both times. - Fixed:
sendData()could send into a data channel that had not finished opening. It now waits for the channel to open before sending; nothing sent while it's still coming up is dropped. - Fixed: a participant that only listens for
RavenRoom.dataand never publishes or callssendData()now still gets a local data channel opened (RavenEngine.ensureDataChannel()), so the SFU has somewhere to relay incoming data onto. Previously such a participant could not receive data either, only send it. - Added unit-level regression coverage for all of the above
(
test/engine_test.dart,test/room_test.dart) against a mockedflutter_webrtcplatform channel. Live end-to-end media validation against a real SFU and real devices should still be run before depending on this release for production calls — seedocs/sdk/flutter.md.
0.1.0 #
Initial release (Livqeno Phase 13).
Raven/RavenRoom— join, leave, camera, microphone, screen share, front/rear camera switch.RavenVideoView— renders a participant, follows track changes, and disposes its native texture with the widget.RavenPermissions— camera and microphone prompts without an extra plugin.- Typed errors (
RavenException,RavenPermissionException) sharing the web SDK's vocabulary. - Adaptive streaming and dynacast on by default, which the web SDK leaves off — a phone pays for resolution it isn't showing.