vg-x07df 1.6.3 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -81,6 +81,159 @@ import { LiveKitProvider, useTrack } from 'vg-x07df/livekit';
81
81
  const track = useTrack();
82
82
  ```
83
83
 
84
+ ## Video Call Features
85
+
86
+ ### Video Initialization
87
+
88
+ The SDK automatically initializes camera capability but keeps it disabled by default for privacy:
89
+
90
+ - Camera permissions are requested when the room connects
91
+ - Video starts disabled (privacy-first approach)
92
+ - Users must explicitly enable video via UI controls
93
+ - Room is optimized with adaptive streaming and dynacast for performance
94
+
95
+ ### Using Video Controls
96
+
97
+ ```tsx
98
+ import { useTrackToggle, Track } from "vg-x07df/livekit";
99
+
100
+ function VideoControls() {
101
+ // Toggle video on/off
102
+ const { toggle: toggleVideo, enabled: isVideoEnabled } = useTrackToggle({
103
+ source: Track.Source.Camera
104
+ });
105
+
106
+ return (
107
+ <button onClick={toggleVideo}>
108
+ {isVideoEnabled ? "Turn Off Video" : "Turn On Video"}
109
+ </button>
110
+ );
111
+ }
112
+ ```
113
+
114
+ ### Displaying Video
115
+
116
+ ```tsx
117
+ import { VideoTrack, useParticipantTracks, Track } from "vg-x07df/livekit";
118
+ import { hasVideoTrack } from "vg-x07df/utils";
119
+
120
+ function ParticipantVideo({ participant }) {
121
+ const tracks = useParticipantTracks(participant, Track.Source.Camera);
122
+
123
+ return (
124
+ <div className="participant-container">
125
+ {hasVideoTrack(participant) ? (
126
+ <VideoTrack
127
+ participant={participant}
128
+ source={Track.Source.Camera}
129
+ className="video-element"
130
+ />
131
+ ) : (
132
+ <div className="avatar-fallback">
133
+ {participant.identity}
134
+ </div>
135
+ )}
136
+ </div>
137
+ );
138
+ }
139
+ ```
140
+
141
+ ### Video Utilities
142
+
143
+ The SDK provides utility functions for common video operations:
144
+
145
+ ```tsx
146
+ import { hasVideoTrack, getVideoTrack, hasVideoCapability } from "vg-x07df/utils";
147
+
148
+ // Check if participant has active video (enabled and published)
149
+ const hasActiveVideo = hasVideoTrack(participant);
150
+
151
+ // Get video track publication
152
+ const videoTrack = getVideoTrack(participant);
153
+
154
+ // Check if video capability exists (track exists, may be muted)
155
+ const canHaveVideo = hasVideoCapability(participant);
156
+ ```
157
+
158
+ ### Complete Video Implementation Example
159
+
160
+ ```tsx
161
+ import {
162
+ VideoTrack,
163
+ useTrackToggle,
164
+ useParticipantTracks,
165
+ Track,
166
+ useParticipants
167
+ } from "vg-x07df/livekit";
168
+ import { hasVideoTrack } from "vg-x07df/utils";
169
+
170
+ function VideoCallInterface() {
171
+ const participants = useParticipants();
172
+ const { toggle: toggleVideo, enabled: isVideoEnabled } = useTrackToggle({
173
+ source: Track.Source.Camera
174
+ });
175
+
176
+ return (
177
+ <div className="video-call-container">
178
+ {/* Video Controls */}
179
+ <div className="controls">
180
+ <button onClick={toggleVideo}>
181
+ {isVideoEnabled ? "Turn Off Video" : "Turn On Video"}
182
+ </button>
183
+ </div>
184
+
185
+ {/* Participant Videos */}
186
+ <div className="participants-grid">
187
+ {participants.map((participant) => (
188
+ <div key={participant.identity} className="participant-tile">
189
+ {hasVideoTrack(participant) ? (
190
+ <VideoTrack
191
+ participant={participant}
192
+ source={Track.Source.Camera}
193
+ className="video-element"
194
+ />
195
+ ) : (
196
+ <div className="avatar-placeholder">
197
+ {participant.identity.charAt(0).toUpperCase()}
198
+ </div>
199
+ )}
200
+ <span className="participant-name">
201
+ {participant.identity}
202
+ </span>
203
+ </div>
204
+ ))}
205
+ </div>
206
+ </div>
207
+ );
208
+ }
209
+ ```
210
+
211
+ ### Video Troubleshooting
212
+
213
+ **Camera permissions denied:**
214
+ - The SDK handles permissions gracefully
215
+ - Users will see a browser permission prompt on first video toggle
216
+ - If permissions are denied, the call continues as audio-only
217
+ - Check browser settings if video toggle doesn't work
218
+
219
+ **Video not appearing:**
220
+ - Verify `hasVideoTrack(participant)` returns `true`
221
+ - Check that `useTrackToggle` shows `enabled: true`
222
+ - Ensure the `VideoTrack` component has the correct `participant` prop
223
+ - Confirm the participant has published their video track
224
+
225
+ **Performance issues with multiple videos:**
226
+ - The SDK uses optimized settings (720p, adaptive streaming, dynacast)
227
+ - Video quality automatically adjusts based on available bandwidth
228
+ - Multiple video streams are handled efficiently with simulcast
229
+ - Consider reducing video quality for low-bandwidth scenarios
230
+
231
+ **Audio works but video doesn't:**
232
+ - Check if camera is being used by another application
233
+ - Verify camera permissions in browser settings
234
+ - Try refreshing the page to reinitialize camera access
235
+ - Check browser console for camera-related errors
236
+
84
237
  ## Requirements
85
238
 
86
239
  - React ≥18.0.0
@@ -460,8 +460,10 @@ var ChatService = class {
460
460
  this.room.registerTextStreamHandler(
461
461
  "chat:v1",
462
462
  async (reader, participantInfo) => {
463
+ console.log("Got here: chat:v1", participantInfo.identity);
463
464
  try {
464
465
  const text = await reader.readAll();
466
+ console.log("Got here: chat:v1", text);
465
467
  this.handleIncomingMessage(text);
466
468
  } catch (error) {
467
469
  logger.error("Error reading text stream", error);
@@ -828,6 +830,262 @@ function useChat() {
828
830
  unreact
829
831
  };
830
832
  }
833
+ var defaultState2 = {
834
+ reactions: /* @__PURE__ */ new Map(),
835
+ participantCache: {},
836
+ ttlMs: 4e3
837
+ };
838
+ var useReactionsStore = zustand.create()(
839
+ immer.immer((set) => ({
840
+ ...defaultState2,
841
+ setReaction: (participantId, reaction) => set((state) => {
842
+ state.reactions.set(participantId, reaction);
843
+ }),
844
+ clearReaction: (participantId) => set((state) => {
845
+ state.reactions.delete(participantId);
846
+ }),
847
+ upsertParticipantInfo: (id, info) => set((state) => {
848
+ state.participantCache[id] = info;
849
+ }),
850
+ pruneExpired: (now = Date.now()) => set((state) => {
851
+ const entries = Array.from(state.reactions.entries());
852
+ for (const [participantId, reaction] of entries) {
853
+ if (reaction.ts + state.ttlMs < now) {
854
+ state.reactions.delete(participantId);
855
+ }
856
+ }
857
+ }),
858
+ clear: () => set(() => ({
859
+ reactions: /* @__PURE__ */ new Map(),
860
+ participantCache: {},
861
+ ttlMs: defaultState2.ttlMs
862
+ }))
863
+ }))
864
+ );
865
+ function applyIncomingReaction(envelope) {
866
+ const { upsertParticipantInfo, setReaction } = useReactionsStore.getState();
867
+ if (envelope.sender.info) {
868
+ upsertParticipantInfo(envelope.sender.id, envelope.sender.info);
869
+ }
870
+ setReaction(envelope.sender.id, {
871
+ emoji: envelope.payload.emoji,
872
+ ts: Date.now(),
873
+ nonce: envelope.payload.nonce
874
+ });
875
+ }
876
+
877
+ // src/channel/reactions/utils.ts
878
+ createLogger("reactions");
879
+ function validateEmoji(emoji) {
880
+ if (!emoji || typeof emoji !== "string") {
881
+ return { valid: false, error: "empty" };
882
+ }
883
+ if (emoji.length > 10) {
884
+ return { valid: false, error: "too long" };
885
+ }
886
+ return { valid: true };
887
+ }
888
+ function generateNonce() {
889
+ return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
890
+ }
891
+
892
+ // src/channel/reactions/service.ts
893
+ var logger3 = createLogger("reactions");
894
+ var ReactionsService = class {
895
+ constructor(room) {
896
+ this.isSubscribed = false;
897
+ this.lastSendAt = 0;
898
+ this.minIntervalMs = 200;
899
+ this.lastRemoteTs = /* @__PURE__ */ new Map();
900
+ this.room = room;
901
+ }
902
+ isRoomReady() {
903
+ if (!this.room) {
904
+ logger3.warn("Room not initialized");
905
+ return false;
906
+ }
907
+ if (this.room.state !== livekitClient.ConnectionState.Connected) {
908
+ logger3.warn("Room not connected", { state: this.room.state });
909
+ return false;
910
+ }
911
+ if (!this.room.localParticipant) {
912
+ logger3.warn("Local participant not available");
913
+ return false;
914
+ }
915
+ return true;
916
+ }
917
+ canSendNow() {
918
+ const now = Date.now();
919
+ if (now - this.lastSendAt < this.minIntervalMs) return false;
920
+ this.lastSendAt = now;
921
+ return true;
922
+ }
923
+ subscribe() {
924
+ if (this.isSubscribed) return;
925
+ this.room.registerTextStreamHandler("reactions:v1", async (reader) => {
926
+ try {
927
+ const text = await reader.readAll();
928
+ this.handleIncoming(text);
929
+ } catch (err) {
930
+ logger3.error("Error reading reactions stream", err);
931
+ }
932
+ });
933
+ this.pruneInterval = setInterval(() => {
934
+ useReactionsStore.getState().pruneExpired();
935
+ }, 1e3);
936
+ this.isSubscribed = true;
937
+ logger3.info("ReactionsService subscribed");
938
+ }
939
+ unsubscribe() {
940
+ this.isSubscribed = false;
941
+ if (this.pruneInterval) {
942
+ clearInterval(this.pruneInterval);
943
+ this.pruneInterval = void 0;
944
+ }
945
+ logger3.info("ReactionsService unsubscribed");
946
+ }
947
+ getLocalParticipantId() {
948
+ return this.room.localParticipant.identity;
949
+ }
950
+ async sendReaction(emoji) {
951
+ if (!this.isRoomReady()) {
952
+ useRtcStore.getState().addError({
953
+ code: "REACTIONS_ROOM_NOT_READY",
954
+ message: "Cannot send reaction: room not connected",
955
+ timestamp: Date.now()
956
+ });
957
+ return;
958
+ }
959
+ const validation = validateEmoji(emoji);
960
+ if (!validation.valid) {
961
+ useRtcStore.getState().addError({
962
+ code: "REACTIONS_INVALID_EMOJI",
963
+ message: validation.error || "Invalid emoji",
964
+ timestamp: Date.now()
965
+ });
966
+ return;
967
+ }
968
+ if (!this.canSendNow()) {
969
+ logger3.debug("Rate limited, skipping send");
970
+ return;
971
+ }
972
+ const senderInfo = this.getSenderInfo();
973
+ const ts = Date.now();
974
+ const nonce = generateNonce();
975
+ useReactionsStore.getState().setReaction(senderInfo.id, {
976
+ emoji,
977
+ ts,
978
+ nonce
979
+ });
980
+ try {
981
+ const envelope = {
982
+ v: 1,
983
+ kind: "reaction",
984
+ roomId: this.room.name,
985
+ ts,
986
+ sender: senderInfo,
987
+ payload: {
988
+ emoji,
989
+ nonce
990
+ }
991
+ };
992
+ await this.room.localParticipant.sendText(JSON.stringify(envelope), {
993
+ topic: "reactions:v1"
994
+ });
995
+ logger3.debug("Reaction sent", { emoji });
996
+ } catch (error) {
997
+ logger3.error("Failed to send reaction", error);
998
+ useRtcStore.getState().addError({
999
+ code: "REACTIONS_SEND_FAILED",
1000
+ message: error instanceof Error ? error.message : "Failed to send reaction",
1001
+ timestamp: Date.now(),
1002
+ context: { emoji }
1003
+ });
1004
+ }
1005
+ }
1006
+ handleIncoming(text) {
1007
+ try {
1008
+ const parsed = JSON.parse(text);
1009
+ if (!this.isValidEnvelope(parsed)) {
1010
+ logger3.warn("Invalid reaction envelope received", parsed);
1011
+ return;
1012
+ }
1013
+ if (parsed.sender.id === this.getLocalParticipantId()) {
1014
+ logger3.debug("Ignoring self-echo reaction");
1015
+ return;
1016
+ }
1017
+ const lastTs = this.lastRemoteTs.get(parsed.sender.id) ?? Number.NEGATIVE_INFINITY;
1018
+ if (parsed.ts < lastTs) {
1019
+ logger3.debug("Ignoring out-of-order reaction", {
1020
+ sender: parsed.sender.id,
1021
+ ts: parsed.ts,
1022
+ lastTs
1023
+ });
1024
+ return;
1025
+ }
1026
+ this.lastRemoteTs.set(parsed.sender.id, parsed.ts);
1027
+ applyIncomingReaction(parsed);
1028
+ logger3.debug("Reaction received", { emoji: parsed.payload.emoji });
1029
+ } catch (error) {
1030
+ logger3.error("Error parsing incoming reaction", error);
1031
+ }
1032
+ }
1033
+ isValidEnvelope(e) {
1034
+ return e && e.v === 1 && e.kind === "reaction" && typeof e.roomId === "string" && e.roomId === this.room.name && typeof e.ts === "number" && e.ts > 0 && typeof e.sender?.id === "string" && typeof e.payload?.emoji === "string" && typeof e.payload?.nonce === "string";
1035
+ }
1036
+ getSenderInfo() {
1037
+ const localParticipant = this.room.localParticipant;
1038
+ const sender = {
1039
+ id: localParticipant.identity
1040
+ };
1041
+ if (localParticipant.metadata) {
1042
+ try {
1043
+ sender.info = JSON.parse(
1044
+ localParticipant.metadata
1045
+ );
1046
+ } catch {
1047
+ }
1048
+ }
1049
+ return sender;
1050
+ }
1051
+ };
1052
+ var logger4 = createLogger("reactions:hook");
1053
+ function useReactions() {
1054
+ const service = useFeatureService("reactions");
1055
+ const reactions = useReactionsStore((state) => state.reactions);
1056
+ const participantCache = useReactionsStore((state) => state.participantCache);
1057
+ const getReactionFor = react.useCallback(
1058
+ (participantId) => {
1059
+ const reaction = reactions.get(participantId);
1060
+ return reaction?.emoji ?? null;
1061
+ },
1062
+ [reactions]
1063
+ );
1064
+ const getParticipantInfo = react.useCallback(
1065
+ (id) => participantCache[id] || null,
1066
+ [participantCache]
1067
+ );
1068
+ const sendReaction = react.useCallback(
1069
+ async (emoji) => {
1070
+ if (!service) {
1071
+ logger4.error("Cannot send reaction: service not ready");
1072
+ return;
1073
+ }
1074
+ return service.sendReaction(emoji);
1075
+ },
1076
+ [service]
1077
+ );
1078
+ const clearReaction = react.useCallback((participantId) => {
1079
+ useReactionsStore.getState().clearReaction(participantId);
1080
+ }, []);
1081
+ return {
1082
+ sendReaction,
1083
+ getReactionFor,
1084
+ clearReaction,
1085
+ getParticipantInfo,
1086
+ isReady: !!service
1087
+ };
1088
+ }
831
1089
 
832
1090
  // src/channel/registry.ts
833
1091
  var FEATURES = {
@@ -835,10 +1093,15 @@ var FEATURES = {
835
1093
  name: "chat",
836
1094
  createService: (room) => new ChatService(room),
837
1095
  cleanupStore: () => useChatStore.getState().clearChat()
1096
+ },
1097
+ reactions: {
1098
+ name: "reactions",
1099
+ createService: (room) => new ReactionsService(room),
1100
+ cleanupStore: () => useReactionsStore.getState().clear()
838
1101
  }
839
1102
  };
840
- var DEFAULT_FEATURES = ["chat"];
841
- var logger2 = createLogger("channels:provider");
1103
+ var DEFAULT_FEATURES = ["chat", "reactions"];
1104
+ var logger5 = createLogger("channels:provider");
842
1105
  function DataChannelProvider({
843
1106
  room,
844
1107
  features = DEFAULT_FEATURES,
@@ -848,52 +1111,52 @@ function DataChannelProvider({
848
1111
  const [isReady, setIsReady] = react.useState(false);
849
1112
  react.useEffect(() => {
850
1113
  if (!room) {
851
- logger2.warn("DataChannelProvider mounted without room");
1114
+ logger5.warn("DataChannelProvider mounted without room");
852
1115
  return;
853
1116
  }
854
- logger2.debug("Initializing features", { features });
1117
+ logger5.debug("Initializing features", { features });
855
1118
  for (const featureName of features) {
856
1119
  const feature = FEATURES[featureName];
857
1120
  if (!feature) {
858
- logger2.warn(`Feature "${featureName}" not found in registry`);
1121
+ logger5.warn(`Feature "${featureName}" not found in registry`);
859
1122
  continue;
860
1123
  }
861
1124
  try {
862
- logger2.debug(`Initializing feature: ${featureName}`);
1125
+ logger5.debug(`Initializing feature: ${featureName}`);
863
1126
  const service = feature.createService(room);
864
1127
  service.subscribe();
865
1128
  services.current.set(featureName, service);
866
- logger2.info(`Feature "${featureName}" initialized`);
1129
+ logger5.info(`Feature "${featureName}" initialized`);
867
1130
  } catch (error) {
868
- logger2.error(`Failed to initialize feature "${featureName}"`, error);
1131
+ logger5.error(`Failed to initialize feature "${featureName}"`, error);
869
1132
  }
870
1133
  }
871
1134
  setIsReady(true);
872
- logger2.info("All features initialized");
1135
+ logger5.info("All features initialized");
873
1136
  return () => {
874
- logger2.debug("Cleaning up features");
1137
+ logger5.debug("Cleaning up features");
875
1138
  services.current.forEach((service, name) => {
876
1139
  try {
877
- logger2.debug(`Unsubscribing feature: ${name}`);
1140
+ logger5.debug(`Unsubscribing feature: ${name}`);
878
1141
  service.unsubscribe();
879
1142
  } catch (error) {
880
- logger2.error(`Failed to unsubscribe feature "${name}"`, error);
1143
+ logger5.error(`Failed to unsubscribe feature "${name}"`, error);
881
1144
  }
882
1145
  });
883
1146
  for (const featureName of features) {
884
1147
  const feature = FEATURES[featureName];
885
1148
  if (feature?.cleanupStore) {
886
1149
  try {
887
- logger2.debug(`Cleaning store for feature: ${featureName}`);
1150
+ logger5.debug(`Cleaning store for feature: ${featureName}`);
888
1151
  feature.cleanupStore();
889
1152
  } catch (error) {
890
- logger2.error(`Failed to cleanup store for "${featureName}"`, error);
1153
+ logger5.error(`Failed to cleanup store for "${featureName}"`, error);
891
1154
  }
892
1155
  }
893
1156
  }
894
1157
  services.current.clear();
895
1158
  setIsReady(false);
896
- logger2.info("Features cleanup complete");
1159
+ logger5.info("Features cleanup complete");
897
1160
  };
898
1161
  }, [room, features]);
899
1162
  const contextValue = react.useMemo(
@@ -908,14 +1171,20 @@ function DataChannelProvider({
908
1171
 
909
1172
  exports.ChatService = ChatService;
910
1173
  exports.DataChannelProvider = DataChannelProvider;
1174
+ exports.ReactionsService = ReactionsService;
1175
+ exports.applyIncomingReaction = applyIncomingReaction;
911
1176
  exports.compareEntries = compareEntries;
912
1177
  exports.generateEntryId = generateEntryId;
1178
+ exports.generateNonce = generateNonce;
913
1179
  exports.getCurrentTimestamp = getCurrentTimestamp;
914
1180
  exports.isValidEnvelope = isValidEnvelope;
915
1181
  exports.useChat = useChat;
916
1182
  exports.useChatStore = useChatStore;
917
1183
  exports.useDataChannelContext = useDataChannelContext;
918
1184
  exports.useFeatureService = useFeatureService;
1185
+ exports.useReactions = useReactions;
1186
+ exports.useReactionsStore = useReactionsStore;
919
1187
  exports.validateContent = validateContent;
1188
+ exports.validateEmoji = validateEmoji;
920
1189
  //# sourceMappingURL=index.cjs.map
921
1190
  //# sourceMappingURL=index.cjs.map