From 7e6ce80e2f5252633e470ff2e4f740bac25fd9dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Braz=CC=87ewicz?= Date: Mon, 27 Apr 2026 17:20:34 +0200 Subject: [PATCH] skills and agents integration --- .../stream-video-flutter-livestream/SKILL.md | 526 +++++++++++++++ .../stream-video-flutter-ringing/SKILL.md | 478 ++++++++++++++ .claude/skills/stream-video-flutter/SKILL.md | 598 ++++++++++++++++++ AGENTS.md | 116 ++++ CLAUDE.md | 6 + llms.txt | 30 + 6 files changed, 1754 insertions(+) create mode 100644 .claude/skills/stream-video-flutter-livestream/SKILL.md create mode 100644 .claude/skills/stream-video-flutter-ringing/SKILL.md create mode 100644 .claude/skills/stream-video-flutter/SKILL.md create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 llms.txt diff --git a/.claude/skills/stream-video-flutter-livestream/SKILL.md b/.claude/skills/stream-video-flutter-livestream/SKILL.md new file mode 100644 index 000000000..1610750f2 --- /dev/null +++ b/.claude/skills/stream-video-flutter-livestream/SKILL.md @@ -0,0 +1,526 @@ +--- +name: stream-video-flutter-livestream +description: | + Use this skill whenever the user wants to add livestreaming, audio rooms, + HLS broadcasting, or a TikTok-style vertical livestream feed to a Flutter + app using the Stream Video SDK. Covers the host (publisher) side, the + viewer side with `LivestreamPlayer`, backstage / go-live transitions, + participant filtering for the broadcast view, HLS playback URLs, and the + call-switching `CallManager` pattern for swipeable feeds. TRIGGER on + prompts like: "add livestreaming to my app", "build a livestream with + Stream", "create an audio room / Twitter Spaces clone", "build a TikTok- + style live feed", "go live / publish a livestream from Flutter", "watch a + livestream in Flutter", "implement HLS playback", "host vs viewer + experience", "swipe between live channels". Assumes the user has already + set up the SDK — for installation, initialization, permissions, and core + call concepts, defer to `stream-video-flutter`. For ringing / + CallKit / push, use `stream-video-flutter-ringing`. +--- + +# Stream Video Flutter SDK — Livestreaming & Audio Rooms + +You are helping implement a livestream, audio room, or live feed using the +**Stream Video Flutter SDK**. This skill assumes core SDK setup is already +done — see `stream-video-flutter` for installation, permissions, and +initialization. + +> **Authoritative documentation:** +> - Livestreaming: +> - Audio rooms: +> - General Flutter docs: +> +> Consult these for anything not covered by this skill (advanced HLS +> configuration, recording layouts, viewer-count APIs, custom backstage UI, +> moderation tooling, dashboard call-type configuration). + +--- + +## Pick the right shape first + +| What the user wants | Call type | Viewer widget | Host widget | +|---|---|---|---| +| 1-to-many video stream | `liveStream()` | `LivestreamPlayer` | `StreamCallContent` (filtered) | +| Twitter Spaces / Clubhouse | `audioRoom()` | `StreamCallContent` audio-only | `StreamCallContent` audio-only | +| TikTok-style swipe feed | `liveStream()` + `CallManager` | `LivestreamPlayer` | `StreamCallContent` (filtered) | +| 1:1 / group calling | `defaultType()` | (use `stream-video-flutter`) | (use `stream-video-flutter`) | + +Both `liveStream` and `audio_room` call types **start in backstage mode**. +The host must call `call.goLive()` for viewers / listeners to see or hear +anything. + +--- + +## Audio rooms + +```dart +final call = StreamVideo.instance.makeCall( + callType: StreamCallType.audioRoom(), + id: 'room-42', +); + +await call.getOrCreate( + members: [ + MemberRequest( + userId: StreamVideo.instance.currentUser.id, + role: 'host', + ), + ], +); + +await call.join( + connectOptions: CallConnectOptions( + microphone: TrackOption.enabled(), + camera: TrackOption.disabled(), + ), +); + +await call.goLive(); // Allow listeners to join +``` + +Key points: + +- Listeners join with mic / camera disabled. +- Listeners need `call.requestPermissions([CallPermission.sendAudio])` to + speak; the host grants with `call.grantPermissions(userId:, permissions:)`. +- Use `call.stopLive()` to return to backstage (paused, not ended). + +--- + +## Livestream — host (publisher) + +```dart +final call = StreamVideo.instance.makeCall( + callType: StreamCallType.liveStream(), + id: 'stream-abc', +); + +await call.getOrCreate( + members: [ + MemberRequest( + userId: StreamVideo.instance.currentUser.id, + role: 'host', + ), + ], +); + +await call.join( + connectOptions: CallConnectOptions( + camera: TrackOption.enabled(), + microphone: TrackOption.enabled(), + ), +); + +await call.goLive(); // Backstage → live +``` + +### Host broadcast UI — filter by `isVideoEnabled` + +If you render `StreamCallContent` as-is, viewer tiles (with disabled +cameras) appear in the host's layout. Filter participants so only those +with active video are shown: + +```dart +PartialCallStateBuilder( + call: call, + selector: (state) => state.callParticipants + .where((p) => p.isVideoEnabled) + .toList(), + builder: (context, hosts) { + if (hosts.isEmpty) { + return const Center(child: Text('Host video is not available')); + } + return StreamCallContent( + call: call, + callParticipantsWidgetBuilder: (context, call) => + StreamCallParticipants(call: call, participants: hosts), + callControlsWidgetBuilder: (_, __) => const SizedBox.shrink(), + callAppBarWidgetBuilder: (_, __) => const SizedBox.shrink(), + ); + }, +) +``` + +> **Why `isVideoEnabled` and not `roles.contains('host')`?** Filtering by +> role works only when roles are perfectly assigned. `isVideoEnabled` +> correctly excludes any participant joining with a disabled camera — +> matching the visual contract of "only show people who are broadcasting". + +### Host controls + +```dart +Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ToggleMicrophoneOption(call: call), + ToggleCameraOption(call: call), + FlipCameraOption(call: call), + // Custom "End Live" button → await call.stopLive(); await call.leave(); + ], +) +``` + +### Ending the broadcast + +```dart +await call.stopLive(); // Return to backstage (stream paused, not ended) +await call.leave(); // Disconnect from SFU +// or: +await call.end(); // Terminate for everyone +``` + +--- + +## Livestream — viewer (subscriber) + +**Always use `LivestreamPlayer`** for the viewer side. It is the SDK's +purpose-built widget — it joins the call, renders the host full-screen, +and handles backstage / ended / no-host states. **Do not** use +`StreamCallContent` or `StreamCallContainer` for viewers. + +### Simple viewer (player auto-joins) + +```dart +final call = StreamVideo.instance.makeCall( + callType: StreamCallType.liveStream(), + id: 'stream-abc', +); +await call.getOrCreate(); + +LivestreamPlayer( + call: call, + onCallDisconnected: (_) => Navigator.pop(context), +) +``` + +### Manual join (e.g. inside a feed manager) + +```dart +await call.join( + connectOptions: CallConnectOptions( + camera: TrackOption.disabled(), + microphone: TrackOption.disabled(), + ), +); + +// Then in the widget tree: +LivestreamPlayer(call: call, onCallDisconnected: (_) {}); +``` + +### Detect "no host yet" before showing the player + +```dart +PartialCallStateBuilder( + call: call, + selector: (state) => + state.otherParticipants.where((p) => p.isVideoEnabled).isNotEmpty, + builder: (context, hasHost) { + if (!hasHost) return const _OfflineOverlay(); + return LivestreamPlayer(call: call, onCallDisconnected: (_) {}); + }, +) +``` + +Always check `otherParticipants.where((p) => p.isVideoEnabled).isNotEmpty`, +not just `otherParticipants.isNotEmpty` — viewers also count as +participants. + +--- + +## Backstage & live state + +```dart +await call.goLive(); // backstage → live +await call.stopLive(); // live → backstage (paused) +await call.leave(); // host leaves; stream continues if co-hosts remain +await call.end(); // terminate for everyone +``` + +Reactive view: + +```dart +PartialCallStateBuilder( + call: call, + selector: (state) => state.isBackstage, + builder: (context, isBackstage) => + isBackstage ? const Text('Backstage') : const Text('Live'), +) +``` + +--- + +## HLS broadcasting + +Use HLS when you need to deliver to non-WebRTC players (web embeds, native +video players, very large audiences). + +```dart +final result = await call.startHLS(); +if (result.isSuccess) { + final url = call.state.value.egress.hlsPlaylistUrl; +} + +await call.stopHLS(); +``` + +Note: HLS introduces ~10-30s latency vs WebRTC. For low-latency viewers, +keep `LivestreamPlayer` (WebRTC). + +--- + +## TikTok-style livestream feed + +The hard part of a swipeable live feed is switching calls fast: leave the +current SFU connection, join the next, and survive rapid swipes where the +user blows past intermediate channels. + +### Architecture + +``` +FeedScreen (PageView vertical) + └─ CallManager (ChangeNotifier) + ├─ currentCall: Call? + ├─ state: idle | connecting | connected | failure + ├─ _version: int (incremented on every switch) + └─ switchToCall(callId) + +Each page (_LivestreamPage) + └─ ListenableBuilder(callManager) + → connected: LivestreamPlayer(call: callManager.currentCall!) + → connecting: _ConnectingOverlay() + → other: _OfflineOverlay() +``` + +### Design principles + +1. **Single active connection.** Only one SFU connection at a time. Leave + the old call (fire-and-forget) before starting the new one. +2. **Version-based cancellation.** Each `switchToCall` increments + `_version`. After every `await` in `_connectCall`, check + `_isStale(version, call)` — if a newer switch happened, abandon the + in-flight connection and `leave()` the call. +3. **No queue.** Rapid swipes simply make the latest `switchToCall` win; + earlier in-flight connections detect they are stale and clean up. + +### Reference implementation + +```dart +class CallManager extends ChangeNotifier { + CallManagerState _state = CallManagerState.idle; + Call? _currentCall; + String? _currentCallId; + int _version = 0; + bool _disposed = false; + + CallManagerState get state => _state; + Call? get currentCall => _currentCall; + String? get currentCallId => _currentCallId; + + void switchToCall(String callId) { + if (_currentCallId == callId && _state != CallManagerState.failure) return; + + final version = ++_version; + + final oldCall = _currentCall; + _currentCall = null; + _currentCallId = callId; + _state = CallManagerState.connecting; + _safeNotify(); + + oldCall?.leave(); + + _connectCall(callId, version); + } + + Future _connectCall(String callId, int version) async { + final call = StreamVideo.instance.makeCall( + callType: StreamCallType.liveStream(), + id: callId, + ); + + _currentCall = call; + + try { + final getResult = await call.getOrCreate(); + if (_isStale(version, call)) return; + if (getResult.isFailure) { _fail(version); return; } + + final joinResult = await call.join( + connectOptions: CallConnectOptions( + camera: TrackOption.disabled(), + microphone: TrackOption.disabled(), + ), + ); + if (_isStale(version, call)) return; + if (joinResult.isFailure) { _fail(version); return; } + } catch (_) { + if (_isStale(version, call)) return; + _fail(version); + return; + } + + _state = CallManagerState.connected; + _safeNotify(); + } + + bool _isStale(int version, Call call) { + if (version != _version || _disposed) { + call.leave(); + return true; + } + return false; + } + + void _fail(int version) { + if (version != _version || _disposed) return; + _state = CallManagerState.failure; + _safeNotify(); + } + + void _safeNotify() { + if (_disposed) return; + notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + _currentCall?.leave(); + super.dispose(); + } +} + +enum CallManagerState { idle, connecting, connected, failure } +``` + +### Wiring the `PageView` + +```dart +PageView.builder( + controller: _pageController, + scrollDirection: Axis.vertical, + itemCount: AppConfig.channels.length, + onPageChanged: (index) => + _callManager.switchToCall(AppConfig.channels[index].id), + itemBuilder: (context, index) => _LivestreamPage( + channel: AppConfig.channels[index], + callManager: _callManager, + ), +) +``` + +In `initState`, prime the first channel: + +```dart +@override +void initState() { + super.initState(); + _pageController = PageController(); + _callManager.switchToCall(AppConfig.channels[0].id); +} +``` + +### Per-page rendering + +```dart +ListenableBuilder( + listenable: callManager, + builder: (context, _) { + final isActive = callManager.currentCallId == channel.id; + final isConnected = isActive && callManager.state == CallManagerState.connected; + final isConnecting = isActive && callManager.state == CallManagerState.connecting; + + if (isConnected && callManager.currentCall != null) { + return _ActiveVideo(call: callManager.currentCall!); + } + if (isConnecting) return const _ConnectingOverlay(); + return const _OfflineOverlay(); + }, +) +``` + +A complete reference app lives at +[`packages/video_livestream_feed/`](https://github.com/GetStream/flutter-video-samples/tree/main/packages/video_livestream_feed) +in the [flutter-video-samples](https://github.com/GetStream/flutter-video-samples) +repo. + +--- + +## Critical rules + +1. **Viewers join with camera & mic DISABLED.** +2. **Viewers use `LivestreamPlayer`** — never `StreamCallContent` or + `StreamCallContainer`. +3. **Hosts filter participants by `isVideoEnabled`** so viewer tiles don't + show in the broadcast layout. +4. **Host must call `goLive()`** — `liveStream` and `audio_room` start + backstage. +5. **Always `leave()` the old call before joining a new one.** Only one + SFU connection at a time. +6. **Use a version counter for rapid switching**, not a debounced queue — + in-flight connections check staleness after each `await`. +7. **Always check `.isFailure` on `getOrCreate()` and `join()`** results + instead of assuming success. +8. **Specify `role: 'host'`** in `MemberRequest` for the broadcaster, or + they may not have permission to go live. + +--- + +## Common pitfalls + +| Mistake | Fix | +|---|---| +| Using `StreamCallContent` for the viewer | Use `LivestreamPlayer` — purpose-built for livestream consumption. | +| Using `StreamCallContainer` in a feed | Use `LivestreamPlayer`; manage join/leave via `CallManager`. | +| Showing all participants in host view | Filter via `callParticipantsWidgetBuilder` + `isVideoEnabled`. | +| Filtering by `roles.contains('host')` | Filter by `isVideoEnabled` instead — robust regardless of role assignment. | +| Joining without leaving the previous call | Always `leave()` first. | +| Checking `otherParticipants.isNotEmpty` for "live" | Check `otherParticipants.where((p) => p.isVideoEnabled).isNotEmpty`. | +| Debounced queue for switching | Use a version counter + staleness checks after each `await`. | +| Skipping `.isFailure` checks | `getOrCreate()` and `join()` return result objects — handle failures. | +| Re-connecting on every swipe | Skip if `_currentCallId == callId` and not in failure state. | +| Forgetting `goLive()` | Viewers see nothing until the host goes live. | +| Missing `role: 'host'` | Host may lack permission to go live. | +| `notifyListeners()` after dispose | Guard with a `_disposed` flag and a `_safeNotify()` helper. | + +--- + +## Key APIs + +| API | Purpose | +|---|---| +| `StreamCallType.liveStream()` / `.audioRoom()` | Call type factories | +| `MemberRequest(userId:, role: 'host')` | Assign host role | +| `call.goLive()` / `.stopLive()` | Backstage transitions | +| `call.startHLS()` / `.stopHLS()` | HLS broadcasting | +| `call.state.value.egress.hlsPlaylistUrl` | HLS playback URL | +| `LivestreamPlayer(call:, onCallDisconnected:)` | Pre-built viewer | +| `StreamCallContent` + `callParticipantsWidgetBuilder` | Host broadcast UI | +| `StreamCallParticipants(call:, participants:)` | Render filtered tile list | +| `PartialCallStateBuilder` | Reactive state slice | +| `CallState.callParticipants` / `.otherParticipants` / `.isBackstage` | State | +| `CallParticipantState.isVideoEnabled` | Filter for active broadcasters | +| `ToggleMicrophoneOption` / `ToggleCameraOption` / `FlipCameraOption` | Controls | + +--- + +## When to consult docs + +If a request goes beyond this skill — recording layouts, transcription, +viewer-count APIs, RTMP ingest, dashboard call-type configuration, custom +backstage screens — fetch the relevant page from + or the broader + documentation tree before +guessing. + +## When to defer + +- **Core SDK setup, install, init, permissions, generic calls** → + `stream-video-flutter`. +- **Ringing, push, CallKit** → `stream-video-flutter-ringing`. + +## Tone & style + +Architectural and code-first. Lead with the right widget choice +(`LivestreamPlayer` vs `StreamCallContent`) — it determines almost +everything else. When the user's design conflicts with the SDK's +opinionated viewer / host split, call it out directly. diff --git a/.claude/skills/stream-video-flutter-ringing/SKILL.md b/.claude/skills/stream-video-flutter-ringing/SKILL.md new file mode 100644 index 000000000..f566cba74 --- /dev/null +++ b/.claude/skills/stream-video-flutter-ringing/SKILL.md @@ -0,0 +1,478 @@ +--- +name: stream-video-flutter-ringing +description: | + Use this skill whenever the user wants to add ringing calls, push + notifications, CallKit, or missed-call handling to a Flutter app using the + Stream Video SDK. Covers `stream_video_push_notification` setup, Firebase + (Android FCM) and APNs / VoIP (iOS PushKit) configuration, the + `ringing: true` call flow, foreground / background notification handling, + iOS entitlements, AppDelegate setup (regular APNs + VoIP + foreground + display), and the iOS `registerApnDeviceToken` requirement for missed-call + pushes. TRIGGER on prompts like: "add ringing to my Flutter app", + "incoming / outgoing call screen with Stream", "push notifications for + Stream Video", "set up CallKit / VoIP push", "FCM background handler for + ringing", "missed call notification", "why are call.missed pushes not + arriving on iOS", "configure APNs / Firebase for Stream Video". Assumes + the user has already integrated the core SDK — for installation, + initialization, and call basics, defer to `stream-video-flutter`. For + livestreaming, use `stream-video-flutter-livestream`. +--- + +# Stream Video Flutter SDK — Ringing & Push Notifications + +You are helping wire up ringing calls and push notifications using +`stream_video_push_notification`. This skill assumes the core SDK is set up +— see `stream-video-flutter` for installation and initialization. + +> **Authoritative documentation:** +> - Push notifications: +> - CallKit: +> - General Flutter docs: +> +> Consult these for anything not covered here (provider name registration in +> the Stream Dashboard, custom notification UI, alternate FCM clients, +> deeplinks, Firebase project setup details). + +--- + +## Before you write any code — gather these + +Push setup has many moving parts. Establish all of these first: + +1. **Firebase project & `google-services.json`** for Android (FCM). +2. **APNs key + certificate**, **APNs auth key** uploaded to Stream + Dashboard for iOS, with a registered provider name (the string you'll + pass as `iosPushProvider.name`). +3. **Provider names** registered in the Stream Dashboard for both + `apn` and `firebase`. +4. **Apple Developer account** with an App ID that has Push Notifications + capability enabled (and ideally Voice over IP for VoIP push). +5. **Where stored credentials live** — for the background handler you need + to reload `apiKey`, `userId`, `userToken` from secure storage without a + live app session. + +--- + +## Packages + +```yaml +dependencies: + stream_video_flutter: ^1.2.4 + stream_video_push_notification: ^1.2.4 + # plus your Firebase plumbing on Android: + firebase_core: ^... + firebase_messaging: ^... +``` + +```dart +import 'package:stream_video_flutter/stream_video_flutter.dart'; +import 'package:stream_video_push_notification/stream_video_push_notification.dart'; +``` + +--- + +## Initialize the client with push manager + +```dart +StreamVideo( + 'API_KEY', + user: user, + userToken: token, + options: const StreamVideoOptions( + keepConnectionsAliveWhenInBackground: true, + ), + pushNotificationManagerProvider: StreamVideoPushNotificationManager.create( + iosPushProvider: const StreamVideoPushProvider.apn( + name: 'your-apn-provider-name', + ), + androidPushProvider: const StreamVideoPushProvider.firebase( + name: 'your-firebase-provider-name', + ), + registerApnDeviceToken: true, // critical on iOS — see "Missed call" section + ), +)..connect(); +``` + +`keepConnectionsAliveWhenInBackground: true` is what lets the SDK stay +reachable for ringing while the app is suspended. + +> **iOS-only critical:** if you support `call.missed` notifications, you +> **must** pass `registerApnDeviceToken: true` AND request the +> `Permission.notification` permission **before** constructing +> `StreamVideo(...)` — the SDK does a one-shot APNs token fetch during +> `connect()`. See [Missed-call setup](#missed-call-notifications) below. + +--- + +## Make a ringing call + +```dart +final call = StreamVideo.instance.makeCall( + callType: StreamCallType.defaultType(), + id: const Uuid().v4(), +); + +await call.getOrCreate( + memberIds: ['recipient-user-id'], + ringing: true, + video: true, // false for audio-only +); +``` + +The `ringing: true` flag triggers the push to all members — the SDK +delivers an incoming-call screen (via CallKit on iOS) on the recipient's +device. + +## Observing ringing events + +```dart +StreamVideo.instance.observeCoreRingingEvents( + onCallAccepted: (call) { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => CallScreen(call: call)), + ); + }, +); +``` + +Use this in your home / shell route to navigate to the call screen when the +recipient accepts. + +--- + +## Android — background handler + +FCM data messages arrive via the Firebase background handler. Initialize a +**separate** `StreamVideo` via `StreamVideo.create(...)` (not the singleton +constructor) and dispose it after handling: + +```dart +@pragma('vm:entry-point') +Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + ); + + final sv = StreamVideo.create( + 'API_KEY', + user: storedUser, + userToken: storedToken, + options: const StreamVideoOptions( + keepConnectionsAliveWhenInBackground: true, + ), + pushNotificationManagerProvider: StreamVideoPushNotificationManager.create( + iosPushProvider: const StreamVideoPushProvider.apn(name: '...'), + androidPushProvider: const StreamVideoPushProvider.firebase(name: '...'), + registerApnDeviceToken: true, // keep parity with foreground init + ), + )..connect(); + + sv.disposeAfterResolvingRinging(); + await sv.handleRingingFlowNotifications(message.data); +} +``` + +Register it in `main()`: + +```dart +FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler); +``` + +For foreground FCM messages, also call +`handleRingingFlowNotifications(message.data)` from your +`FirebaseMessaging.onMessage` listener (using the singleton instance). + +`handleRingingFlowNotifications()` knows the difference between `call.ring` +and `call.missed` types and dispatches accordingly when +`handleMissedCall: true` (the default). + +--- + +## iOS — `AppDelegate.swift` + +You must register for **both** VoIP push (PushKit) **and** regular APNs. +You must also explicitly tell iOS to display regular pushes in the +foreground, otherwise missed-call alerts are silently dropped while the app +is active. + +```swift +import UIKit +import Flutter +import stream_video_push_notification + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + + // VoIP push (PushKit) — for incoming-call ringing + StreamVideoPKDelegateManager.shared.registerForPushNotifications() + + // Regular APNs — required so the SDK can obtain an APNs device + // token for `call.missed` and other non-VoIP pushes. Without this + // call, FirebaseMessaging.instance.getAPNSToken() returns nil and + // the backend never learns about this device. + application.registerForRemoteNotifications() + + // Show regular APNs notifications while the app is in the foreground + UNUserNotificationCenter.current().delegate = self + + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + override func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + let streamDict = notification.request.content.userInfo["stream"] as? [String: Any] + if streamDict?["sender"] as? String != "stream.video" { + return completionHandler([]) + } + if #available(iOS 14.0, *) { + completionHandler([.list, .banner, .sound]) + } else { + completionHandler([.alert]) + } + } +} +``` + +--- + +## iOS — entitlements + +New Flutter projects do **not** create entitlements files. Without the +`aps-environment` entitlement, iOS silently refuses to deliver VoIP and +regular pushes. + +Either: + +**Recommended — let Xcode do it:** open `ios/Runner.xcworkspace` → Runner +target → **Signing & Capabilities** → **+ Capability** → **Push +Notifications**. Xcode generates the entitlements file and updates +`project.pbxproj`. + +**Manual fallback:** + +1. `ios/Runner/Runner.entitlements` (Release & Profile): + +```xml + + + + + aps-environment + development + + +``` + +2. `ios/Runner/RunnerDebug.entitlements` (Debug) — same content. +3. In each of the three Runner build configurations in `project.pbxproj`, + add inside `buildSettings`: + +``` +// Debug +CODE_SIGN_ENTITLEMENTS = Runner/RunnerDebug.entitlements; +// Release & Profile +CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; +``` + +`UIBackgroundModes` in `Info.plist` must include both `voip` and +`remote-notification` (the core skill `stream-video-flutter` already +covers the full list). + +--- + +## Android — extras + +```xml + + +``` + +On Android 13+ you must also request `Permission.notification` at +runtime before / during init. + +The default `AndroidPushConfiguration` already enables missed-call +notifications with sensible defaults. To customize: + +```dart +StreamVideoPushNotificationManager.create( + iosPushProvider: ..., + androidPushProvider: ..., + pushConfiguration: const StreamVideoPushConfiguration( + android: AndroidPushConfiguration( + missedCallNotification: MissedCallNotificationParams( + showNotification: true, + subtitle: 'You missed a call', + callbackText: 'Call back', + showCallbackButton: true, + ), + ), + ), +) +``` + +--- + +## Missed-call notifications + +When a ringing call goes unanswered, Stream sends `call.missed` — a +**regular push notification**, not VoIP. iOS treats this very differently +from VoIP push, which is why the iOS setup has extra requirements. + +### How it works + +1. User A calls User B with `ringing: true`. +2. User B doesn't answer (or User A cancels). +3. Stream backend sends `call.missed` via regular APNs (iOS) or FCM data + message (Android). +4. The SDK's `handleRingingFlowNotifications()` detects + `type == 'call.missed'` and calls `showMissedCall()`. + +### Android + +`call.missed` is a normal FCM data message — same channel as `call.ring`. +`handleRingingFlowNotifications()` handles it automatically when +`handleMissedCall: true` (the default). No extra Android-side setup beyond +the standard ringing config. + +### iOS — three things must all be true + +1. **`registerApnDeviceToken: true`** in + `StreamVideoPushNotificationManager.create()`. By default the SDK only + registers the **VoIP** token; without this flag the backend has no APNs + token and cannot deliver `call.missed`. + +2. **`application.registerForRemoteNotifications()` in AppDelegate.** + `StreamVideoPKDelegateManager.shared.registerForPushNotifications()` + only handles VoIP via PushKit. The regular APNs token comes from + `registerForRemoteNotifications()`. Without it, + `FirebaseMessaging.instance.getAPNSToken()` returns `nil` and the SDK + cannot send the token to the Stream backend. + +3. **Permission requested BEFORE `StreamVideo(...)` is constructed.** + `registerDevice()` runs once during `connect()` and does a **one-shot** + `getAPNSToken()` — there is no retry or listener. If notification + permissions are requested after init, the token is `nil` at registration + time and the device is never registered for regular pushes. + +```dart +// CORRECT — permissions first +await [ + Permission.camera, + Permission.microphone, + Permission.notification, // triggers registerForRemoteNotifications on iOS + if (CurrentPlatform.isAndroid) Permission.phone, +].request(); + +final streamVideo = StreamVideo( + apiKey, + user: user, + userToken: token, + pushNotificationManagerProvider: StreamVideoPushNotificationManager.create( + iosPushProvider: const StreamVideoPushProvider.apn(name: '...'), + androidPushProvider: const StreamVideoPushProvider.firebase(name: '...'), + registerApnDeviceToken: true, + ), +); + +// WRONG — token is nil at registration time +final streamVideo = StreamVideo(apiKey, user: user, userToken: token, ...); +await Permission.notification.request(); // too late +``` + +The `application.registerForRemoteNotifications()` call in `AppDelegate` +acts as a safety net (it can produce a token without the user permission +prompt), but the Flutter-side permission request should still come first. + +**Apply the same `registerApnDeviceToken: true`** in the background +handler's `StreamVideo.create(...)`. + +--- + +## Summary checklist + +| Requirement | Android | iOS | +|---|---|---| +| `keepConnectionsAliveWhenInBackground: true` | yes | yes | +| `handleRingingFlowNotifications(message.data)` in foreground FCM listener | yes | n/a (APNs) | +| Same call in background FCM handler | yes | n/a | +| `StreamVideo.create(...)` (not singleton) in background handler | yes | n/a | +| `registerApnDeviceToken: true` | n/a | **yes — critical** | +| `application.registerForRemoteNotifications()` in `AppDelegate` | n/a | **yes — critical** | +| Request `Permission.notification` **before** `StreamVideo(...)` | n/a | **yes — critical** | +| `UNUserNotificationCenter.current().delegate = self` | n/a | yes (foreground display) | +| `willPresent` delegate | n/a | yes (foreground display) | +| Push entitlements (`aps-environment`) | n/a | yes | +| `POST_NOTIFICATIONS` runtime permission | yes (Android 13+) | n/a | +| `UIBackgroundModes`: `voip`, `remote-notification` | n/a | yes | +| Provider names registered in Stream Dashboard | yes | yes | + +--- + +## Common pitfalls + +| Mistake | Fix | +|---|---| +| Calling `StreamVideo(...)` (singleton) in the background handler | Use `StreamVideo.create(...)`; it does not replace the foreground singleton. | +| Hardcoding tokens in the background handler | Reload `apiKey`, `userId`, `userToken` from secure storage. | +| `call.missed` not arriving on iOS | Set `registerApnDeviceToken: true`, call `registerForRemoteNotifications()`, request notification permission **before** init. | +| Missed-call pushes silent in foreground on iOS | Set `UNUserNotificationCenter.current().delegate = self` and implement `willPresent`. | +| `getAPNSToken()` returns nil | Permission requested after `StreamVideo(...)` was created — always permissions first. | +| Forgetting `aps-environment` entitlement | Add via Xcode capability or manually in `Runner.entitlements` + `project.pbxproj`. | +| iOS build error about iOS 13.0 vs `stream_video_push_notification` 14.0 | Update all three build configurations in `project.pbxproj` to `IPHONEOS_DEPLOYMENT_TARGET = 14.0`. | +| Skipping `keepConnectionsAliveWhenInBackground: true` | Calls drop while suspended — required for ringing. | +| Not calling `disposeAfterResolvingRinging()` in the background handler | Connection leaks across pushes. | +| Custom FCM channel without `handleRingingFlowNotifications` | The SDK won't process the payload; ringing screens never appear. | + +--- + +## Key APIs + +| API | Purpose | +|---|---| +| `StreamVideoPushNotificationManager.create(...)` | Builds the push manager (provider names + flags). | +| `StreamVideoPushProvider.apn(name:)` / `.firebase(name:)` | Provider configs (names match Stream Dashboard). | +| `registerApnDeviceToken: true` | Enables regular APNs token registration on iOS (required for `call.missed`). | +| `StreamVideoOptions(keepConnectionsAliveWhenInBackground: true)` | Keeps the connection alive for ringing. | +| `call.getOrCreate(memberIds:, ringing: true, video:)` | Creates and rings a call. | +| `StreamVideo.instance.observeCoreRingingEvents(onCallAccepted:)` | Hook into accept events. | +| `handleRingingFlowNotifications(message.data)` | Process FCM data payloads (foreground & background). | +| `disposeAfterResolvingRinging()` | Auto-dispose background-handler client. | +| `StreamVideo.create(...)` | Auxiliary client for background isolates. | +| `StreamVideoPKDelegateManager.shared.registerForPushNotifications()` | iOS VoIP / PushKit registration. | +| `application.registerForRemoteNotifications()` | iOS regular APNs registration (for `call.missed`). | +| `UNUserNotificationCenter.willPresent` | Show regular pushes in foreground. | +| `MissedCallNotificationParams` / `AndroidPushConfiguration` | Customize Android missed-call UX. | + +--- + +## When to consult docs + +For provider name registration in the Stream Dashboard, RTMP push, +deeplinking from the notification into a specific screen, custom +notification content, or alternate push backends — fetch the relevant page +from +and +before guessing. + +## When to defer + +- **Core SDK setup, install, init, generic calling** → `stream-video-flutter`. +- **Livestreaming, audio rooms, HLS, feed UI** → `stream-video-flutter-livestream`. + +## Tone & style + +Diagnostic and checklist-driven. Push setup fails silently when one piece +is missing, so when the user reports "ringing doesn't work" or "missed call +isn't showing on iOS", walk the [Summary checklist](#summary-checklist) row +by row before suggesting code changes. Always name **which** of the three +iOS missed-call requirements is most likely missing rather than dumping all +of them again. diff --git a/.claude/skills/stream-video-flutter/SKILL.md b/.claude/skills/stream-video-flutter/SKILL.md new file mode 100644 index 000000000..09a0aac20 --- /dev/null +++ b/.claude/skills/stream-video-flutter/SKILL.md @@ -0,0 +1,598 @@ +--- +name: stream-video-flutter +description: | + Use this skill whenever the user wants to build, scaffold, or extend a Flutter + app that uses the Stream Video SDK — including 1:1 / group video calling, + audio-only calls, joining or creating calls, rendering call UI, and + customizing call controls or theming. Covers initial setup (pubspec, iOS / + Android permissions, deployment targets), client initialization, call + lifecycle (`makeCall` → `getOrCreate` → `join` → `leave` / `end`), the + pre-built widgets (`StreamCallContainer`, `StreamCallContent`, + `StreamCallParticipants`, `PartialCallStateBuilder`), and common pitfalls. + TRIGGER on prompts like: "add video calling to my Flutter app", "build a + Flutter app with Stream Video", "integrate Stream Video SDK", "add audio / + video to an existing Flutter project", "create a call screen with Stream", + "set up `stream_video_flutter`", "show participants / mute mic / flip + camera", "customize Stream call UI / theme", "join a call by ID". For + livestreaming or audio rooms, defer to `stream-video-flutter-livestream`. + For ringing / push notifications / CallKit, defer to + `stream-video-flutter-ringing`. +--- + +# Stream Video Flutter SDK — Setup & Core Calling + +You are helping integrate the **Stream Video Flutter SDK** into a Flutter +application. This skill covers everything needed to ship a working 1:1, group, +or audio-only call. For livestreaming, audio rooms, or ringing flows, defer +to the dedicated sibling skills. + +> **Versions assumed:** `stream_video_flutter ^1.2.x` · Dart ≥ 3.8 · +> Flutter ≥ 3.32 · iOS ≥ 14 · Android `minSdk 24`. Always check the latest +> version on [pub.dev](https://pub.dev/packages/stream_video_flutter). + +> **Authoritative documentation:** +> — consult this before guessing +> any API surface that is not in this skill (UI cookbook, advanced APIs, +> dashboard configuration, server-side token issuance, etc.). + +--- + +## Before you write any code + +1. **Confirm the call type**. The SDK supports `default` (video / audio + calls), `livestream`, `audio_room`, `development`, and custom types. If the + user wants a livestream or audio room, switch to + `stream-video-flutter-livestream`. If they want incoming-call ringing or + CallKit, switch to `stream-video-flutter-ringing`. +2. **Confirm credentials**. The SDK needs an `apiKey`, a `userId`, and a + `userToken` (JWT). Ask the user where the token comes from — for production + it must come from their backend via `tokenLoader`, never hardcoded. +3. **Confirm target platforms**. iOS needs deployment target 14.0 and the + permission keys in `Info.plist`. Android needs `minSdk 24` and the + manifest permissions. + +--- + +## Packages + +| Package | Purpose | +|---|---| +| `stream_video_flutter` | **Primary.** Pre-built UI widgets + re-exports `stream_video`. Use this for almost every app. | +| `stream_video` | Low-level client only. Use directly only when building entirely custom UI. | +| `stream_video_push_notification` | Push / CallKit / ringing. See `stream-video-flutter-ringing`. | +| `stream_video_noise_cancellation` | Optional AI noise cancellation. | + +**Import rule:** only import `stream_video_flutter` — it re-exports everything +from `stream_video`, so importing both is a mistake. + +```dart +import 'package:stream_video_flutter/stream_video_flutter.dart'; +``` + +### `pubspec.yaml` + +```yaml +dependencies: + flutter: + sdk: flutter + stream_video_flutter: ^1.2.4 +``` + +--- + +## Platform setup + +### iOS — `ios/Podfile` + +```ruby +platform :ios, '14.0' +``` + +New Flutter projects default `IPHONEOS_DEPLOYMENT_TARGET` to `13.0` in +`ios/Runner.xcodeproj/project.pbxproj`. Update **all three** build +configurations (Debug, Release, Profile) to `14.0`, otherwise the build +fails with: *"Compiling for iOS 13.0, but module +'stream_video_push_notification' has a minimum deployment target of iOS 14"*. + +### iOS — `ios/Runner/Info.plist` + +```xml +NSCameraUsageDescription +$(PRODUCT_NAME) Camera Usage +NSMicrophoneUsageDescription +$(PRODUCT_NAME) Microphone Usage +UIBackgroundModes + + audio + fetch + processing + remote-notification + voip + +``` + +### Android — `android/app/src/main/AndroidManifest.xml` + +```xml + + + + + + + + + + +``` + +Set `minSdkVersion 24` in `android/app/build.gradle` (already the Flutter +default in recent versions). + +--- + +## Initialization + +```dart +import 'package:flutter/material.dart'; +import 'package:stream_video_flutter/stream_video_flutter.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // Creates the singleton AND auto-connects to Stream's backend. + StreamVideo( + 'YOUR_API_KEY', + user: User.regular(userId: 'user-id', name: 'Jane Doe'), + userToken: 'USER_JWT_TOKEN', + ); + + runApp(const MyApp()); +} +``` + +### Canonical initialization rules + +1. **`WidgetsFlutterBinding.ensureInitialized()`** must run before + `StreamVideo(...)` in `main()`. +2. **`StreamVideo(...)` is a singleton.** Access it anywhere with + `StreamVideo.instance`. Do **not** wrap it in a provider / GetIt / Riverpod + for storage — just use the singleton. +3. **Auto-connect is on by default.** Disable with + `options: StreamVideoOptions(autoConnect: false)` and call + `StreamVideo.instance.connect()` later. +4. **Provide either `userToken` (JWT string) or `tokenLoader` (async + callback).** Never both. Use `tokenLoader` in production so tokens can be + refreshed. +5. **One singleton at a time.** Calling `StreamVideo(...)` while one exists + throws. To switch users: `await StreamVideo.reset(disconnect: true)` first, + or pass `failIfSingletonExists: false`. +6. **For background isolates** (e.g. push handlers) use `StreamVideo.create(...)` + — it does not replace the main singleton. + +### `StreamVideoOptions` — notable fields + +```dart +StreamVideoOptions( + autoConnect: true, + keepConnectionsAliveWhenInBackground: false, // set true for ringing apps + muteVideoWhenInBackground: false, + muteAudioWhenInBackground: false, + logPriority: Priority.none, // Priority.debug for dev +) +``` + +--- + +## Smallest working call (copy-pasteable) + +```dart +import 'package:flutter/material.dart'; +import 'package:stream_video_flutter/stream_video_flutter.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + StreamVideo( + 'YOUR_API_KEY', + user: User.regular(userId: 'john', name: 'John Doe'), + userToken: 'USER_JWT', + ); + + runApp(const MaterialApp(home: HomeScreen())); +} + +class HomeScreen extends StatelessWidget { + const HomeScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: ElevatedButton( + child: const Text('Join Call'), + onPressed: () async { + final call = StreamVideo.instance.makeCall( + callType: StreamCallType.defaultType(), + id: 'my-call-123', + ); + await call.getOrCreate(); + + if (context.mounted) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => Scaffold( + body: StreamCallContainer(call: call), + ), + ), + ); + } + }, + ), + ), + ); + } +} +``` + +`StreamCallContainer` handles the full lifecycle: lobby → outgoing → incoming +→ active → disconnected. It calls `join()` for you. + +--- + +## The Call lifecycle + +``` +makeCall() → getOrCreate() → join() → [active call] → leave() / end() +``` + +| Step | What it does | +|---|---| +| `StreamVideo.instance.makeCall(callType:, id:)` | Creates a local `Call` object — no network. | +| `call.getOrCreate(...)` | Creates the call on Stream servers (or returns existing). | +| `call.join(...)` | Connects WebRTC, publishes/subscribes to tracks. | +| `call.leave()` | Disconnects this user; call continues for others. | +| `call.end()` | Terminates the call for everyone. | + +**Critical rules:** + +- If you use `StreamCallContainer`, do **not** call `call.join()` yourself — + it joins automatically. Only call `getOrCreate()` before navigating. +- If you build fully custom UI without `StreamCallContainer`, you must call + both `getOrCreate()` and `join()`. +- Always call `call.leave()` when the user leaves the call screen. + +### Call types + +| Factory | String | Use case | +|---|---|---| +| `StreamCallType.defaultType()` | `default` | Video / audio calls (1:1 or group) | +| `StreamCallType.liveStream()` | `livestream` | Livestreaming (see livestream skill) | +| `StreamCallType.audioRoom()` | `audio_room` | Twitter Spaces / Clubhouse-style | +| `StreamCallType.development()` | `development` | Testing | +| `StreamCallType.custom('my_type')` | custom | Custom dashboard type | + +Configure call-type permissions and features in the [Stream Dashboard](https://dashboard.getstream.io/). + +--- + +## Creating & joining calls + +### Standard call + +```dart +final call = StreamVideo.instance.makeCall( + callType: StreamCallType.defaultType(), + id: 'unique-call-id', +); +await call.getOrCreate(); +// Navigate to a screen with StreamCallContainer(call: call) +``` + +### With pre-invited members + +```dart +await call.getOrCreate(memberIds: ['alice', 'bob']); +``` + +### Custom track options + +```dart +await call.join( + connectOptions: CallConnectOptions( + camera: TrackOption.enabled(), + microphone: TrackOption.disabled(), + ), +); +``` + +Or pass them to `StreamCallContainer`: + +```dart +StreamCallContainer( + call: call, + callConnectOptions: CallConnectOptions( + camera: TrackOption.enabled(), + microphone: TrackOption.disabled(), + ), +) +``` + +### `TrackOption` values + +| Value | Meaning | +|---|---| +| `TrackOption.enabled()` | Start the track on join | +| `TrackOption.disabled()` | Keep the track off on join | +| `TrackOption.provided(localTrack)` | Use a pre-created local track | + +--- + +## Pre-built widgets + +### `StreamCallContainer` — all-in-one + +Manages lobby → outgoing → incoming → active → disconnected. + +```dart +Scaffold( + body: StreamCallContainer( + call: call, + callConnectOptions: CallConnectOptions( + camera: TrackOption.enabled(), + microphone: TrackOption.enabled(), + ), + onCallDisconnected: (_) => Navigator.pop(context), + ), +) +``` + +### `StreamCallContent` — active call only + +For finer control. Renders app bar + participants grid + controls. + +```dart +StreamCallContent( + call: call, + layoutMode: ParticipantLayoutMode.grid, // or .spotlight + callAppBarWidgetBuilder: (context, call) => CallAppBar(call: call), + callParticipantsWidgetBuilder: (context, call) => + StreamCallParticipants(call: call), + callControlsWidgetBuilder: (context, call) => + StreamCallControls.withDefaultOptions(call: call), +) +``` + +### `StreamCallParticipants` / `StreamCallParticipant` + +Render participant tiles (grid or spotlight) or a single tile. + +--- + +## Reactive state — `PartialCallStateBuilder` + +`call.state` is a `StateEmitter`. **Always prefer +`PartialCallStateBuilder`** over raw `StreamBuilder` on +`call.state.valueStream` — it rebuilds only when the selected slice changes. + +```dart +PartialCallStateBuilder( + call: call, + selector: (state) => state.callParticipants.length, + builder: (context, count) => Text('$count participants'), +) +``` + +### Useful `CallState` properties + +| Property | Type | Description | +|---|---|---| +| `callParticipants` | `List` | All participants | +| `localParticipant` | `CallParticipantState?` | Local user | +| `otherParticipants` | `List` | Remote | +| `activeSpeakers` | `List` | Currently speaking | +| `isBackstage` | `bool` | Backstage mode active | +| `isRecording` | `bool` | Recording in progress | +| `isBroadcasting` | `bool` | HLS broadcasting active | +| `status` | `CallStatus` | Connection status | +| `createdByMe` | `bool` | Current user created the call | +| `callId` | `String` | The call ID | +| `startsAt` / `endedAt` | `DateTime?` | Schedule / end time | + +--- + +## Call controls + +Pre-built option widgets — drop them into `StreamCallControls.options`: + +| Widget | Purpose | +|---|---| +| `ToggleMicrophoneOption` | Mute / unmute microphone | +| `ToggleCameraOption` | Enable / disable camera | +| `FlipCameraOption` | Switch front / rear camera | +| `ToggleScreenShareOption` | Start / stop screen sharing | +| `ToggleSpeakerphoneOption` | Toggle speakerphone | +| `LeaveCallOption` | Leave the call | +| `AddReactionOption` | Send emoji reactions | +| `ToggleRecordingOption` | Start / stop recording | +| `ToggleLayoutOption` | Switch grid / spotlight | +| `ToggleClosedCaptionsOption` | Toggle closed captions | +| `CallControlOption` | Generic custom button | + +Custom set: + +```dart +StreamCallControls( + options: [ + ToggleMicrophoneOption(call: call), + ToggleCameraOption(call: call), + FlipCameraOption(call: call), + LeaveCallOption( + call: call, + onLeaveCallTap: () => call.leave(), + ), + ], +) +``` + +### Programmatic control + +```dart +await call.setMicrophoneEnabled(enabled: false); +await call.setCameraEnabled(enabled: true); +await call.setScreenShareEnabled(enabled: true); +``` + +--- + +## Screen sharing, recording, broadcasting + +```dart +ToggleScreenShareOption( + call: call, + screenShareConstraints: const ScreenShareConstraints( + useiOSBroadcastExtension: true, + captureScreenAudio: true, + ), +) + +await call.startRecording(); +await call.stopRecording(); +final recordings = await call.listRecordings(); + +await call.startHLS(); +final hlsUrl = call.state.value.egress.hlsPlaylistUrl; +``` + +For HLS-driven livestream viewers, see `stream-video-flutter-livestream`. + +--- + +## Permissions & moderation + +```dart +call.hasPermission(CallPermission.sendAudio); + +await call.requestPermissions([CallPermission.sendAudio]); // listener +await call.grantPermissions( // host + userId: 'user-id', + permissions: [CallPermission.sendAudio], +); + +call.onPermissionRequest = (request) { /* request.user, request.permissions */ }; + +await call.muteUsers(userIds: ['user-id'], track: TrackType.audio); +await call.muteAllUsers(); +await call.blockUser('user-id'); +await call.unblockUser('user-id'); +``` + +--- + +## Theming + +```dart +MaterialApp( + builder: (context, child) { + return StreamVideoTheme( + data: StreamVideoThemeData.fromColorScheme( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), + ), + child: child!, + ); + }, + home: const HomeScreen(), +) +``` + +Component-level theming is documented in the [UI Cookbook](https://getstream.io/video/docs/flutter/ui-cookbook/overview/). + +--- + +## Cleanup & logout + +```dart +await call.leave(); // Leave current call +await StreamVideo.instance.disconnect(); // Disconnect from backend +await StreamVideo.reset(); // Destroy singleton (required before re-init for a new user) +``` + +--- + +## Common pitfalls + +| Mistake | Fix | +|---|---| +| Calling `call.join()` AND using `StreamCallContainer` | The container joins automatically — only call `getOrCreate()` first. | +| Creating multiple `StreamVideo(...)` singletons | `await StreamVideo.reset(disconnect: true)` first, or `failIfSingletonExists: false`. | +| Importing both `stream_video` and `stream_video_flutter` | Only import `stream_video_flutter`. | +| Forgetting `WidgetsFlutterBinding.ensureInitialized()` | Must run before `StreamVideo(...)`. | +| Using `StreamBuilder` on `call.state.valueStream` | Use `PartialCallStateBuilder` with a `selector`. | +| Not calling `call.getOrCreate()` before `join()` | Call must exist on the server first. | +| Audio rooms invisible to listeners | Call `call.goLive()` after joining (`audio_room` and `livestream` start in backstage). | +| Using `StreamCallContent` for a livestream viewer | Use `LivestreamPlayer` — see `stream-video-flutter-livestream`. | +| Hardcoded tokens in production | Use `tokenLoader` (async callback that fetches a fresh JWT). | +| Not disposing calls | Always call `call.leave()` when leaving the call screen. | + +--- + +## Quick reference — key classes + +| Class / Function | Package | Purpose | +|---|---|---| +| `StreamVideo(apiKey, user:, userToken:)` | `stream_video` | Singleton constructor | +| `StreamVideo.instance` | `stream_video` | Access singleton | +| `StreamVideo.reset()` | `stream_video` | Destroy singleton | +| `StreamVideo.instance.makeCall(callType:, id:)` | `stream_video` | Create local Call | +| `Call.getOrCreate(...)` | `stream_video` | Create / fetch call | +| `Call.join(connectOptions:)` | `stream_video` | Connect WebRTC | +| `Call.leave()` / `.end()` / `.goLive()` / `.stopLive()` | `stream_video` | Lifecycle | +| `Call.state` | `stream_video` | Reactive `CallState` | +| `CallConnectOptions` | `stream_video` | Camera / mic / screen config | +| `TrackOption.enabled()` / `.disabled()` / `.provided(...)` | `stream_video` | Track config | +| `StreamCallType.defaultType()` | `stream_video` | Call type factory | +| `User.regular(userId:, name:)` | `stream_video` | Authenticated user | +| `StreamCallContainer` | `stream_video_flutter` | All-in-one call UI | +| `StreamCallContent` | `stream_video_flutter` | Active call content | +| `StreamCallParticipants` / `StreamCallParticipant` | `stream_video_flutter` | Participant tiles | +| `StreamCallControls` | `stream_video_flutter` | Controls bar | +| `PartialCallStateBuilder` | `stream_video_flutter` | Reactive state builder | +| `StreamVideoTheme` | `stream_video_flutter` | Theme provider | +| `LivestreamPlayer` | `stream_video_flutter` | Livestream viewer (see livestream skill) | +| `MemberRequest` / `CallPermission` | `stream_video` | Members & permissions | + +--- + +## When to defer to other skills + +- **Livestreaming, audio rooms, HLS viewer, TikTok-style feed** → + `stream-video-flutter-livestream`. +- **Ringing, push notifications, CallKit, missed-call handling** → + `stream-video-flutter-ringing`. +- **AI voice agents** → see [AI Voice Agents docs](https://getstream.io/video/docs/api/voice-agents/). + +## When to consult the docs site + +If a question is **not covered by this skill** — e.g. SFU configuration, +custom UI cookbook recipes, advanced theming, dashboard setup, server-side +SDKs, dynascale / simulcast tuning, recording layouts, transcription, custom +events — fetch the relevant page from + instead of guessing. Useful entry +points: + +- Tutorials: +- UI Cookbook: +- Core concepts: +- API reference: +- pub.dev: +- GitHub: +- Dashboard: + +## Tone & style + +Concise, code-first answers. Prefer pre-built widgets over custom builds +unless the user explicitly asks for full customization. Always cite the +exact widget / method name. When the user's setup is incomplete, point at +the **specific** missing step (Podfile target, manifest permission, token +source) rather than dumping the full setup again. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..74da8572b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,116 @@ +# AGENTS.md — Stream Video Flutter SDK + +This file is the entry point for AI coding agents (Claude Code, Cursor, +Codex, Aider, Copilot, Continue, Cody, Windsurf, …). It follows the +[agents.md](https://agents.md) convention. + +There are two distinct audiences: + +1. **Integrators** — agents working in a *user's* Flutter app who need + to add Stream Video. Skip to [Integrating the SDK](#integrating-the-sdk). +2. **Contributors** — agents working *inside this repository* on the SDK + itself. Skip to [Contributing to the SDK](#contributing-to-the-sdk). + +--- + +## Integrating the SDK + +The canonical guidance lives as **agent skills** (Anthropic Agent Skill +format — markdown with YAML frontmatter triggers). They are routed by +trigger phrase; load only the one matching the user's primary intent. + +| Track | Skill | Trigger summary | +|---|---|---| +| A — Core SDK | [`.claude/skills/stream-video-flutter/SKILL.md`](.claude/skills/stream-video-flutter/SKILL.md) | "add video / audio calling", "integrate Stream Video", "build a call screen", any setup / init / generic call work. | +| B — Livestreaming | [`.claude/skills/stream-video-flutter-livestream/SKILL.md`](.claude/skills/stream-video-flutter-livestream/SKILL.md) | "add livestreaming", "go live", "watch a stream", "TikTok-style live feed", "audio room / Twitter Spaces". | +| C — Ringing & Push | [`.claude/skills/stream-video-flutter-ringing/SKILL.md`](.claude/skills/stream-video-flutter-ringing/SKILL.md) | "add ringing", "incoming / outgoing call", "CallKit / VoIP push", "FCM background handler", "call.missed not arriving". | + +### Routing rules + +1. **One skill at a time.** Pick the skill matching the *primary* intent. + Track A is foundational: if SDK setup isn't done yet, run it first, + then switch to B or C — installation, permissions, and `StreamVideo` + initialization only live in track A. +2. **Defer when out of scope.** Each skill states explicitly when to + defer to a sibling. Honor those pointers. +3. **Docs fallback.** For anything not in a skill, fetch the relevant + page from rather than + guessing. + +### If your tool doesn't auto-load `SKILL.md` + +The same content is published as a standalone pack at +. Install it +globally so it's available in every project: + +```bash +git clone https://github.com/GetStream/flutter-video-agent-skills.git \ + ~/.claude/skills/flutter-video +``` + +Or paste the contents of the relevant `SKILL.md` into your tool's +system prompt / rules file. + +### Versions assumed + +- `stream_video_flutter ^1.2.x` · Dart ≥ 3.8 · Flutter ≥ 3.32 +- iOS ≥ 14 · Android `minSdk 24` + +Always check the latest version on +[pub.dev](https://pub.dev/packages/stream_video_flutter). + +--- + +## Contributing to the SDK + +You are inside the `stream-video-flutter` monorepo. It's a +[Melos](https://melos.invertase.dev) workspace. + +### Layout + +- `packages/` — published Dart / Flutter packages (`stream_video`, + `stream_video_flutter`, `stream_video_push_notification`, + `stream_video_noise_cancellation`, …). +- `dogfooding/` — full sample app exercising every feature. +- `docusaurus/` — public documentation site source. +- `examples/` — smaller targeted samples. +- `melos.yaml`, `analysis_options.yaml`, `all_lint_rules.yaml` — workspace + config. + +### Common commands + +```bash +# Bootstrap the workspace after clone or large refactor +melos postclean + +# Run static analysis across all packages +melos analyze + +# Run tests +melos test +``` + +See [`development.md`](development.md) for fuller contributor docs. + +### Conventions + +- **Conventional Commits** (`feat:`, `fix:`, `chore:`, `feat(llc):`, …) — + see recent `git log` for scopes. +- Public API changes in `packages/stream_video/` and + `packages/stream_video_flutter/` need a changelog entry. +- Don't hand-edit generated protobuf / OpenAPI code under + `packages/stream_video/lib/protobuf/` or + `packages/stream_video/lib/open_api/` — regenerate via the relevant + Melos script. + +--- + +## Companion resources + +- **Docs:** +- **pub.dev:** +- **Sample apps:** +- **Tutorials:** +- **Skill pack (standalone):** +- **Broader Stream agent pack** (Chat, Feeds, Moderation, CLI): + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..d7a0cce62 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,6 @@ +# CLAUDE.md + +See [AGENTS.md](AGENTS.md) for routing, integration skills, and +contributor conventions. Project-local skills live under +[`.claude/skills/`](.claude/skills/) and auto-load on matching trigger +phrases. diff --git a/llms.txt b/llms.txt new file mode 100644 index 000000000..2184f643f --- /dev/null +++ b/llms.txt @@ -0,0 +1,30 @@ +# Stream Video Flutter SDK + +> Official Flutter / Dart client for [Stream Video](https://getstream.io/video/sdk/flutter/) — 1:1 and group video & audio calling, livestreaming, audio rooms, ringing with CallKit / VoIP push, and AI voice agents. This file follows the [llms.txt](https://llmstxt.org) convention so LLMs can find the canonical entry points without crawling the whole repo. + +## Agent entry points + +- [AGENTS.md](AGENTS.md): Cross-tool agent guide — routing table for integration skills and contributor conventions. +- [.claude/skills/stream-video-flutter/SKILL.md](.claude/skills/stream-video-flutter/SKILL.md): Core SDK setup, 1:1 / group video & audio calling, call lifecycle, pre-built UI widgets. +- [.claude/skills/stream-video-flutter-livestream/SKILL.md](.claude/skills/stream-video-flutter-livestream/SKILL.md): Livestreaming, audio rooms, HLS, TikTok-style live feeds. +- [.claude/skills/stream-video-flutter-ringing/SKILL.md](.claude/skills/stream-video-flutter-ringing/SKILL.md): Ringing, push notifications, CallKit, VoIP push, missed-call handling. + +## Documentation + +- [Flutter Video docs](https://getstream.io/video/docs/flutter/): Authoritative documentation — UI cookbook, advanced APIs, dashboard, server-side tokens. +- [development.md](development.md): Contributor setup for this monorepo. +- [README.md](README.md): Repository overview and Melos workspace structure. + +## Packages on pub.dev + +- [stream_video_flutter](https://pub.dev/packages/stream_video_flutter): Primary package — pre-built UI widgets and re-exports `stream_video`. +- [stream_video](https://pub.dev/packages/stream_video): Low-level client. Use directly only when building custom UI from scratch. +- [stream_video_push_notification](https://pub.dev/packages/stream_video_push_notification): Push notifications and CallKit integration for ringing. +- [stream_video_noise_cancellation](https://pub.dev/packages/stream_video_noise_cancellation): Optional AI noise cancellation add-on. + +## Related repositories + +- [flutter-video-agent-skills](https://github.com/GetStream/flutter-video-agent-skills): Standalone, installable copy of the agent skills used in this repo. +- [flutter-video-samples](https://github.com/GetStream/flutter-video-samples): Full sample apps (calling, livestream, audio room, ringing). +- [flutter-video-tutorials](https://github.com/GetStream/flutter-video-tutorials): Step-by-step tutorial repos. +- [agent-skills](https://github.com/GetStream/agent-skills): Broader Stream agent pack (Chat, Feeds, Moderation, CLI).