@syra.fm/sdk 0.6.0 → 0.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.
Files changed (63) hide show
  1. package/lib/commonjs/index.js.map +1 -1
  2. package/lib/commonjs/index.native.js.map +1 -1
  3. package/lib/commonjs/live/components/RoomCard.js +241 -158
  4. package/lib/commonjs/live/components/RoomCard.js.map +1 -1
  5. package/lib/commonjs/live/hooks/useRoomAudio.js +6 -2
  6. package/lib/commonjs/live/hooks/useRoomAudio.js.map +1 -1
  7. package/lib/commonjs/live/hooks/useRoomConnection.js +3 -1
  8. package/lib/commonjs/live/hooks/useRoomConnection.js.map +1 -1
  9. package/lib/commonjs/live/services/spacesService.js +2 -2
  10. package/lib/commonjs/live/services/spacesService.js.map +1 -1
  11. package/lib/commonjs/live/validation.js +20 -2
  12. package/lib/commonjs/live/validation.js.map +1 -1
  13. package/lib/module/index.js +5 -0
  14. package/lib/module/index.js.map +1 -1
  15. package/lib/module/index.native.js +6 -0
  16. package/lib/module/index.native.js.map +1 -1
  17. package/lib/module/live/components/RoomCard.js +243 -161
  18. package/lib/module/live/components/RoomCard.js.map +1 -1
  19. package/lib/module/live/hooks/useRoomAudio.js +6 -2
  20. package/lib/module/live/hooks/useRoomAudio.js.map +1 -1
  21. package/lib/module/live/hooks/useRoomConnection.js +3 -1
  22. package/lib/module/live/hooks/useRoomConnection.js.map +1 -1
  23. package/lib/module/live/services/spacesService.js +2 -2
  24. package/lib/module/live/services/spacesService.js.map +1 -1
  25. package/lib/module/live/validation.js +19 -1
  26. package/lib/module/live/validation.js.map +1 -1
  27. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  28. package/lib/typescript/commonjs/index.native.d.ts.map +1 -1
  29. package/lib/typescript/commonjs/live/components/RoomCard.d.ts +18 -0
  30. package/lib/typescript/commonjs/live/components/RoomCard.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/live/hooks/useRoomAudio.d.ts.map +1 -1
  32. package/lib/typescript/commonjs/live/hooks/useRoomConnection.d.ts.map +1 -1
  33. package/lib/typescript/commonjs/live/services/spacesService.d.ts +4 -4
  34. package/lib/typescript/commonjs/live/services/spacesService.d.ts.map +1 -1
  35. package/lib/typescript/commonjs/live/types.d.ts +1 -1
  36. package/lib/typescript/commonjs/live/types.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/live/validation.d.ts +157 -510
  38. package/lib/typescript/commonjs/live/validation.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/schema.d.ts +32 -864
  40. package/lib/typescript/commonjs/schema.d.ts.map +1 -1
  41. package/lib/typescript/module/index.d.ts.map +1 -1
  42. package/lib/typescript/module/index.native.d.ts.map +1 -1
  43. package/lib/typescript/module/live/components/RoomCard.d.ts +18 -0
  44. package/lib/typescript/module/live/components/RoomCard.d.ts.map +1 -1
  45. package/lib/typescript/module/live/hooks/useRoomAudio.d.ts.map +1 -1
  46. package/lib/typescript/module/live/hooks/useRoomConnection.d.ts.map +1 -1
  47. package/lib/typescript/module/live/services/spacesService.d.ts +4 -4
  48. package/lib/typescript/module/live/services/spacesService.d.ts.map +1 -1
  49. package/lib/typescript/module/live/types.d.ts +1 -1
  50. package/lib/typescript/module/live/types.d.ts.map +1 -1
  51. package/lib/typescript/module/live/validation.d.ts +157 -510
  52. package/lib/typescript/module/live/validation.d.ts.map +1 -1
  53. package/lib/typescript/module/schema.d.ts +32 -864
  54. package/lib/typescript/module/schema.d.ts.map +1 -1
  55. package/package.json +5 -4
  56. package/src/index.native.ts +6 -0
  57. package/src/index.ts +5 -0
  58. package/src/live/components/RoomCard.tsx +202 -129
  59. package/src/live/hooks/useRoomAudio.ts +8 -2
  60. package/src/live/hooks/useRoomConnection.ts +6 -1
  61. package/src/live/services/spacesService.ts +4 -4
  62. package/src/live/types.ts +1 -0
  63. package/src/live/validation.ts +18 -1
@@ -1 +1 @@
1
- {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../../src/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;;;;GAMG;AAEH,mFAAmF;AACnF,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;;EAKhC,CAAC;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAC;AAEpE,4CAA4C;AAC5C,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAO9B,CAAC;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEhE,yBAAyB;AACzB,MAAM,MAAM,WAAW,GAAG,MAAM,aAAa,CAAC;AAE9C;;;GAGG;AACH,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAW7B,CAAC;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAE9D;;;;;;;;;GASG;AACH,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAQ/B,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAElE;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAa/B,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC"}
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../../src/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;;;;GAMG;AAEH,mFAAmF;AACnF,eAAO,MAAM,qBAAqB;;;;;iBAKhC,CAAC;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAC;AAEpE,4CAA4C;AAC5C,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAO9B,CAAC;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEhE,yBAAyB;AACzB,MAAM,MAAM,WAAW,GAAG,MAAM,aAAa,CAAC;AAE9C;;;GAGG;AACH,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAW7B,CAAC;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAE9D;;;;;;;;;GASG;AACH,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAQ/B,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAElE;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAa/B,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syra.fm/sdk",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Syra SDK — headless, isomorphic catalog client (Node/React-free, public reads + 30s previews) that also ships the Syra live-rooms engine (audio rooms over LiveKit) on React Native and web.",
5
5
  "source": "src/index.ts",
6
6
  "main": "lib/commonjs/index.js",
@@ -51,6 +51,7 @@
51
51
  },
52
52
  "scripts": {
53
53
  "build": "bob build",
54
+ "prepare": "bun run build",
54
55
  "clean": "rm -rf lib",
55
56
  "typecheck": "tsc --noEmit -p tsconfig.build.json",
56
57
  "test": "bun test src",
@@ -82,14 +83,14 @@
82
83
  ]
83
84
  },
84
85
  "dependencies": {
85
- "zod": "^3.25.76"
86
+ "zod": "^4.4.3"
86
87
  },
87
88
  "peerDependencies": {
88
89
  "react": "^19.2.3",
89
90
  "react-native": "^0.85.3",
90
91
  "react-native-reanimated": "^4.3.1",
91
92
  "react-native-safe-area-context": "^5.7.0",
92
- "@oxyhq/services": "^15.0.0",
93
+ "@oxyhq/services": "^22.1.0",
93
94
  "livekit-client": "^2.20.0",
94
95
  "@livekit/react-native": "^2.11.1",
95
96
  "expo-audio": "~56.0.12",
@@ -150,7 +151,7 @@
150
151
  "react-native": "^0.85.3",
151
152
  "react-native-reanimated": "^4.3.1",
152
153
  "react-native-safe-area-context": "^5.7.0",
153
- "@oxyhq/services": "^15.0.0",
154
+ "@oxyhq/services": "^22.1.0",
154
155
  "livekit-client": "^2.20.0",
155
156
  "@livekit/react-native": "^2.11.1",
156
157
  "expo-audio": "~56.0.12",
@@ -1,2 +1,8 @@
1
+ // Native/web-app entrypoint. The package `exports` map routes the `react-native`
2
+ // and `browser` conditions HERE, so Expo/RN consumers get the live-rooms engine on
3
+ // top of the base SDK. Plain Node resolution (`import`/`require`/`default`) lands on
4
+ // `./index`, which deliberately omits `./live` — the engine needs react-native peers
5
+ // that cannot load off-device. Exposure of live is therefore a property of the
6
+ // CONSUMER's resolution conditions, not of `@syra.fm/sdk`.
1
7
  export * from './index';
2
8
  export * from './live';
package/src/index.ts CHANGED
@@ -1,3 +1,8 @@
1
+ // Base entrypoint: the platform-agnostic API client + schemas. The live-rooms
2
+ // engine is intentionally NOT re-exported here — it needs react-native peers, so it
3
+ // ships from `./index.native`, which the `exports` map serves to the `react-native`
4
+ // and `browser` conditions. A Node consumer resolving `@syra.fm/sdk` gets THIS file
5
+ // and will not find the live exports; that is by design, not a missing export.
1
6
  export {
2
7
  createSyraClient,
3
8
  DEFAULT_SYRA_BASE_URL,
@@ -6,7 +6,8 @@ import { useAuth } from '@oxyhq/services';
6
6
  import { useLiveConfig } from '../context/LiveConfigContext';
7
7
  import { AnimatedPulse } from './AnimatedPulse';
8
8
  import { useRoomUsers, getAvatarUrl } from '../hooks/useRoomUsers';
9
- import { LIVE_COLOR, LIVE_FOREGROUND_COLOR, getRoomTypeMeta, type RoomTypeMeta } from '../colors';
9
+ import { LIVE_COLOR, LIVE_FOREGROUND_COLOR } from '../colors';
10
+ import type { LiveTheme } from '../types';
10
11
 
11
12
  // --- Utility helpers ---
12
13
 
@@ -25,33 +26,55 @@ function formatDuration(startedAt: string, endedAt: string): string {
25
26
  return m > 0 ? `${h}h ${m}m` : `${h}h`;
26
27
  }
27
28
 
28
- const MONTH_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
29
+ const DAY_MS = 86_400_000;
29
30
 
30
- function formatDate(dateStr: string): string {
31
- const d = new Date(dateStr);
32
- return `${d.getDate()} ${MONTH_SHORT[d.getMonth()]} ${d.getFullYear()}`;
31
+ function startOfDay(date: Date): number {
32
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
33
33
  }
34
34
 
35
- function getTimeLabel(room: RoomCardProps['room']): string {
36
- if (room.status === 'ended' && room.startedAt && room.endedAt) {
37
- return `${formatDuration(room.startedAt, room.endedAt)} · ${formatDate(room.endedAt)}`;
38
- }
39
- if (room.status === 'live' && room.startedAt) {
40
- return `Live · Started ${formatDate(room.startedAt)}`;
41
- }
35
+ /** `Today` / `Tomorrow` / `Yesterday`, else a locale short date (year only when it differs). */
36
+ function formatDay(date: Date, now: Date): string {
37
+ const dayDelta = Math.round((startOfDay(date) - startOfDay(now)) / DAY_MS);
38
+ if (dayDelta === 0) return 'Today';
39
+ if (dayDelta === 1) return 'Tomorrow';
40
+ if (dayDelta === -1) return 'Yesterday';
41
+
42
+ const options: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short' };
43
+ if (date.getFullYear() !== now.getFullYear()) options.year = 'numeric';
44
+ return date.toLocaleDateString(undefined, options);
45
+ }
46
+
47
+ function formatTime(date: Date): string {
48
+ return date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
49
+ }
50
+
51
+ /**
52
+ * The when-label for a room that is NOT live: the start time for a scheduled room
53
+ * (`Tomorrow 18:00`), the day + duration for an ended one (`Yesterday · 45 min`).
54
+ * A live room never uses this — its lead metric is the listener count.
55
+ */
56
+ function getScheduleLabel(room: RoomCardProps['room'], now: Date): string {
42
57
  if (room.status === 'scheduled' && room.scheduledStart) {
43
- return formatDate(room.scheduledStart);
58
+ const start = new Date(room.scheduledStart);
59
+ return `${formatDay(start, now)} ${formatTime(start)}`;
60
+ }
61
+ if (room.status === 'ended' && room.endedAt) {
62
+ const day = formatDay(new Date(room.endedAt), now);
63
+ return room.startedAt ? `${day} · ${formatDuration(room.startedAt, room.endedAt)}` : day;
44
64
  }
45
65
  if (room.createdAt) {
46
- return formatDate(room.createdAt);
66
+ return formatDay(new Date(room.createdAt), now);
47
67
  }
48
68
  return '';
49
69
  }
50
70
 
51
71
  // --- Constants ---
52
72
 
53
- const MAX_SPEAKER_AVATARS = 4;
73
+ const MAX_SPEAKER_AVATARS = 3;
54
74
  const SPEAKER_AVATAR_SIZE = 44;
75
+ const ROW_AVATAR_SIZE = 40;
76
+ const BYLINE_AVATAR_SIZE = 20;
77
+ const ACTION_HIT_SLOP = { top: 8, bottom: 8, left: 8, right: 8 };
55
78
 
56
79
  // --- Types ---
57
80
 
@@ -85,6 +108,24 @@ interface RoomCardProps {
85
108
 
86
109
  // --- Component ---
87
110
 
111
+ /**
112
+ * A room in a list. The `default` variant renders ONE of two presentations,
113
+ * selected by `room.status`, because a live room and a listing of one are
114
+ * different objects:
115
+ *
116
+ * - **live** → a featured card: the LIVE chip + the listener count (the only
117
+ * number that matters while a room is on air), the speaker faces, the host,
118
+ * and an explicit Join CTA.
119
+ * - **scheduled / ended** → a flush full-width row: host avatar, title + when,
120
+ * byline, and one subtle right-side action. No card chrome, no CTA — there is
121
+ * nothing to join yet (or ever again).
122
+ *
123
+ * At most ONE status chip is ever shown (a live room is not a scheduled one, and
124
+ * the date already says "scheduled"); the room type carries no badge at all.
125
+ *
126
+ * The `compact` variant is the small fixed-width card used by post attachments
127
+ * and the composer preview, and follows the same rules in miniature.
128
+ */
88
129
  export const RoomCard: React.FC<RoomCardProps> = ({
89
130
  room,
90
131
  onPress,
@@ -105,6 +146,7 @@ export const RoomCard: React.FC<RoomCardProps> = ({
105
146
  const isLive = room.status === 'live';
106
147
  const isScheduled = room.status === 'scheduled';
107
148
  const isCompact = variant === 'compact';
149
+ const isFeatured = isLive && !isCompact;
108
150
 
109
151
  // Resolve host display: an explicit prop wins, then the @handle, then the
110
152
  // canonical API-owned `name.displayName` from the Oxy user DTO (never a
@@ -118,18 +160,19 @@ export const RoomCard: React.FC<RoomCardProps> = ({
118
160
  || 'Unknown');
119
161
  const hostAvatarUri = hostAvatarUriProp ?? getAvatarUrl(hostProfile, oxyServices, getCachedFileDownloadUrlSync);
120
162
 
121
- // Resolve speaker avatars (default variant only)
163
+ // Speaker faces only earn their space on the featured (live) card.
122
164
  const speakerIds = useMemo(() => {
123
- if (isCompact) return [];
165
+ if (!isFeatured) return [];
124
166
  const ids = room.speakers?.length ? room.speakers : [room.host];
125
167
  return ids.slice(0, MAX_SPEAKER_AVATARS);
126
- }, [room.speakers, room.host, isCompact]);
168
+ }, [isFeatured, room.speakers, room.host]);
169
+ const hiddenSpeakerCount = Math.max(0, (room.speakers?.length ?? 0) - speakerIds.length);
127
170
 
128
171
  useRoomUsers(speakerIds);
129
172
 
130
- const typeMeta = getRoomTypeMeta(room.type);
131
173
  const listenerCount = room.participants?.length || room.stats?.totalJoined || 0;
132
- const timeLabel = getTimeLabel(room);
174
+ const scheduleLabel = isLive ? '' : getScheduleLabel(room, new Date());
175
+ const byline = house ? `by ${hostName} · ${house.name}` : `by ${hostName}`;
133
176
 
134
177
  // --- Compact variant (post attachments, composer preview) ---
135
178
  // It lives inside HORIZONTAL carousels, where a percentage width has no parent
@@ -146,121 +189,117 @@ export const RoomCard: React.FC<RoomCardProps> = ({
146
189
  disabled={!onPress}
147
190
  >
148
191
  <View className="flex-1">
149
- {house && (
150
- <Text
151
- className="mb-1 text-[9px] font-bold tracking-[0.5px] text-muted-foreground"
152
- numberOfLines={1}
153
- >
154
- {house.name.toUpperCase()}
155
- </Text>
192
+ {isLive && (
193
+ <View className="mb-1.5 flex-row">
194
+ <LiveBadge />
195
+ </View>
156
196
  )}
157
197
  <Text className="text-sm font-semibold leading-[18px] text-foreground" numberOfLines={2}>
158
198
  {room.title}
159
199
  </Text>
160
- {(isLive || isScheduled || typeMeta) && (
161
- <View className="mt-1 flex-row flex-wrap gap-1">
162
- {isLive && <LiveBadge />}
163
- {isScheduled && <ScheduledBadge iconSize={10} iconColor={theme.colors.textSecondary} />}
164
- {typeMeta && <TypeBadge meta={typeMeta} />}
165
- </View>
166
- )}
167
200
  </View>
168
- <View className="mt-2 flex-row items-center gap-1">
169
- <MaterialCommunityIcons name="account-group" size={14} color={theme.colors.textSecondary} />
170
- <Text className="text-[11px] text-muted-foreground">{listenerCount} listening</Text>
171
- <Text className="text-[10px] text-muted-foreground">•</Text>
172
- {hostAvatarUri && (
173
- <AvatarComponent size={14} source={hostAvatarUri} shape="squircle" style={{ marginRight: 2 }} />
174
- )}
175
- <Text className="flex-1 text-[11px] text-muted-foreground" numberOfLines={1}>
176
- {hostName}
201
+ <View className="mt-2 gap-1">
202
+ <View className="flex-row items-center gap-1">
203
+ <AvatarComponent size={14} source={hostAvatarUri} shape="squircle" />
204
+ <Text className="flex-1 text-[11px] text-muted-foreground" numberOfLines={1}>
205
+ {byline}
206
+ </Text>
207
+ </View>
208
+ <Text className="text-[11px] text-muted-foreground" numberOfLines={1}>
209
+ {isLive ? `${formatCompact(listenerCount)} listening` : scheduleLabel}
177
210
  </Text>
178
211
  </View>
179
212
  </TouchableOpacity>
180
213
  );
181
214
  }
182
215
 
183
- // --- Default variant: a full-width, flush feed row (hairline divider, no card chrome) ---
184
- return (
185
- <TouchableOpacity
186
- className="w-full gap-2 border-b border-border px-3 py-3"
187
- style={style}
188
- onPress={onPress}
189
- activeOpacity={onPress ? 0.7 : 1}
190
- disabled={!onPress}
191
- >
192
- {/* Section 1: House header + menu */}
193
- {(house || onMenuPress) && (
216
+ // --- Featured card: a LIVE room, with presence and an explicit CTA ---
217
+ if (isFeatured) {
218
+ return (
219
+ <TouchableOpacity
220
+ className="mb-3 w-full gap-2.5 rounded-2xl border border-border bg-surface p-3"
221
+ style={style}
222
+ onPress={onPress}
223
+ activeOpacity={onPress ? 0.7 : 1}
224
+ disabled={!onPress}
225
+ >
226
+ {/* Status + the only number that matters on air, then icon-only actions */}
194
227
  <View className="flex-row items-center gap-1.5">
195
- {house && (
228
+ <LiveBadge />
229
+ {listenerCount > 0 && (
196
230
  <>
197
- <MaterialCommunityIcons name="home" size={14} color={theme.colors.primary} />
198
- <Text
199
- className="text-[11px] font-bold tracking-[0.5px] text-muted-foreground"
200
- numberOfLines={1}
201
- >
202
- {house.name.toUpperCase()}
231
+ <Text className="text-[13px] text-muted-foreground">·</Text>
232
+ <Text className="text-[13px] font-semibold text-muted-foreground" numberOfLines={1}>
233
+ {formatCompact(listenerCount)} listening
203
234
  </Text>
204
235
  </>
205
236
  )}
206
237
  <View className="flex-1" />
207
- {onMenuPress && (
208
- <TouchableOpacity onPress={onMenuPress} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
209
- <MaterialCommunityIcons name="dots-horizontal" size={20} color={theme.colors.textSecondary} />
210
- </TouchableOpacity>
238
+ {onSave && (
239
+ <SaveAction onSave={onSave} isSaved={isSaved} isScheduled={false} theme={theme} />
211
240
  )}
241
+ {onMenuPress && <MenuAction onMenuPress={onMenuPress} theme={theme} />}
212
242
  </View>
213
- )}
214
243
 
215
- {/* Section 2: Title + status badges */}
216
- <View>
217
244
  <Text className="text-[17px] font-bold leading-[22px] text-foreground" numberOfLines={2}>
218
245
  {room.title}
219
246
  </Text>
220
- {(isLive || isScheduled || typeMeta) && (
221
- <View className="mt-1.5 flex-row flex-wrap items-center gap-1.5">
222
- {isLive && <LiveBadge />}
223
- {isScheduled && <ScheduledBadge iconColor={theme.colors.textSecondary} />}
224
- {typeMeta && <TypeBadge meta={typeMeta} />}
225
- </View>
226
- )}
227
- </View>
228
247
 
229
- {/* Section 3: Speaker avatars + listener count */}
230
- <SpeakerRow speakerIds={speakerIds} listenerCount={listenerCount} />
248
+ <SpeakerStack speakerIds={speakerIds} hiddenCount={hiddenSpeakerCount} />
231
249
 
232
- {/* Section 4: Metadata + save */}
233
- {(timeLabel || onSave) && (
234
- <View className="flex-row items-center justify-between">
235
- {timeLabel ? (
236
- <Text className="text-[13px] text-muted-foreground">{timeLabel}</Text>
237
- ) : (
238
- <View />
239
- )}
240
- {onSave && (
250
+ {/* Host — a room's primary identity — and the join affordance */}
251
+ <View className="flex-row items-center gap-2">
252
+ <AvatarComponent size={BYLINE_AVATAR_SIZE} source={hostAvatarUri} shape="squircle" />
253
+ <Text className="flex-1 text-[13px] text-muted-foreground" numberOfLines={1}>
254
+ {byline}
255
+ </Text>
256
+ {onPress && (
241
257
  <TouchableOpacity
242
- className="flex-row items-center gap-1"
243
- onPress={onSave}
244
- hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
258
+ className="rounded-full px-4 py-1.5"
259
+ style={{ backgroundColor: LIVE_COLOR }}
260
+ onPress={onPress}
261
+ accessibilityRole="button"
262
+ accessibilityLabel="Join room"
245
263
  >
246
- <MaterialCommunityIcons
247
- name={isSaved ? 'bookmark' : 'bookmark-outline'}
248
- size={18}
249
- color={isSaved ? theme.colors.primary : theme.colors.textSecondary}
250
- />
251
- <Text
252
- className={
253
- isSaved
254
- ? 'text-[13px] font-medium text-primary'
255
- : 'text-[13px] font-medium text-muted-foreground'
256
- }
257
- >
258
- Save
264
+ <Text className="text-[13px] font-bold" style={{ color: LIVE_FOREGROUND_COLOR }}>
265
+ Join
259
266
  </Text>
260
267
  </TouchableOpacity>
261
268
  )}
262
269
  </View>
263
- )}
270
+ </TouchableOpacity>
271
+ );
272
+ }
273
+
274
+ // --- Compact row: a SCHEDULED or ENDED room, flush in the feed ---
275
+ return (
276
+ <TouchableOpacity
277
+ className="w-full flex-row items-center gap-3 border-b border-border px-3 py-3"
278
+ style={style}
279
+ onPress={onPress}
280
+ activeOpacity={onPress ? 0.7 : 1}
281
+ disabled={!onPress}
282
+ >
283
+ <AvatarComponent size={ROW_AVATAR_SIZE} source={hostAvatarUri} shape="squircle" />
284
+
285
+ <View className="flex-1 gap-0.5">
286
+ <View className="flex-row items-baseline gap-1.5">
287
+ <Text className="shrink text-[15px] font-semibold text-foreground" numberOfLines={1}>
288
+ {room.title}
289
+ </Text>
290
+ {scheduleLabel !== '' && (
291
+ <Text className="shrink-0 text-[13px] text-muted-foreground" numberOfLines={1}>
292
+ · {scheduleLabel}
293
+ </Text>
294
+ )}
295
+ </View>
296
+ <Text className="text-[13px] text-muted-foreground" numberOfLines={1}>
297
+ {byline}
298
+ </Text>
299
+ </View>
300
+
301
+ {onSave && <SaveAction onSave={onSave} isSaved={isSaved} isScheduled={isScheduled} theme={theme} />}
302
+ {onMenuPress && <MenuAction onMenuPress={onMenuPress} theme={theme} />}
264
303
  </TouchableOpacity>
265
304
  );
266
305
  };
@@ -282,44 +321,77 @@ function LiveBadge() {
282
321
  );
283
322
  }
284
323
 
285
- function ScheduledBadge({ iconSize = 12, iconColor }: { iconSize?: number; iconColor: string }) {
324
+ // --- Actions (icon-only, like the surrounding feed) ---
325
+
326
+ /**
327
+ * The saved-state toggle. On a SCHEDULED room, saving it is how you ask to be
328
+ * pulled back when it starts, so the icon reads as a reminder bell; anywhere
329
+ * else it is a bookmark.
330
+ */
331
+ function SaveAction({
332
+ onSave,
333
+ isSaved,
334
+ isScheduled,
335
+ theme,
336
+ }: {
337
+ onSave: () => void;
338
+ isSaved?: boolean;
339
+ isScheduled: boolean;
340
+ theme: LiveTheme;
341
+ }) {
342
+ const icon = isScheduled
343
+ ? (isSaved ? 'bell' : 'bell-outline')
344
+ : (isSaved ? 'bookmark' : 'bookmark-outline');
345
+
286
346
  return (
287
- <View className="flex-row items-center gap-1 rounded-[4px] bg-muted px-2 py-1">
288
- <MaterialCommunityIcons name="calendar" size={iconSize} color={iconColor} />
289
- <Text className="text-[10px] font-bold text-muted-foreground">SCHEDULED</Text>
290
- </View>
347
+ <TouchableOpacity
348
+ onPress={onSave}
349
+ hitSlop={ACTION_HIT_SLOP}
350
+ accessibilityRole="button"
351
+ accessibilityLabel={isScheduled ? 'Remind me about this room' : 'Save room'}
352
+ accessibilityState={{ selected: Boolean(isSaved) }}
353
+ >
354
+ <MaterialCommunityIcons
355
+ name={icon}
356
+ size={20}
357
+ color={isSaved ? theme.colors.primary : theme.colors.textSecondary}
358
+ />
359
+ </TouchableOpacity>
291
360
  );
292
361
  }
293
362
 
294
- function TypeBadge({ meta }: { meta: RoomTypeMeta }) {
363
+ function MenuAction({ onMenuPress, theme }: { onMenuPress: () => void; theme: LiveTheme }) {
295
364
  return (
296
- <View
297
- className="flex-row items-center gap-0.5 rounded-[4px] px-1.5 py-0.5"
298
- style={{ backgroundColor: meta.tintColor }}
365
+ <TouchableOpacity
366
+ onPress={onMenuPress}
367
+ hitSlop={ACTION_HIT_SLOP}
368
+ accessibilityRole="button"
369
+ accessibilityLabel="Room options"
299
370
  >
300
- <MaterialCommunityIcons name={meta.icon} size={10} color={meta.color} />
301
- <Text className="text-[9px] font-bold" style={{ color: meta.color }}>
302
- {meta.label}
303
- </Text>
304
- </View>
371
+ <MaterialCommunityIcons name="dots-horizontal" size={20} color={theme.colors.textSecondary} />
372
+ </TouchableOpacity>
305
373
  );
306
374
  }
307
375
 
308
- // --- Speaker row sub-component ---
376
+ // --- Speaker stack (featured card only) ---
377
+
378
+ function SpeakerStack({ speakerIds, hiddenCount }: { speakerIds: string[]; hiddenCount: number }) {
379
+ if (speakerIds.length === 0) return null;
309
380
 
310
- function SpeakerRow({ speakerIds, listenerCount }: { speakerIds: string[]; listenerCount: number }) {
311
381
  return (
312
- <View className="flex-row items-center gap-2">
313
- {speakerIds.map((id) => (
314
- <SpeakerAvatar key={id} userId={id} />
382
+ <View className="flex-row items-center">
383
+ {speakerIds.map((id, index) => (
384
+ <View key={id} className={index === 0 ? '' : '-ml-3'}>
385
+ <SpeakerAvatar userId={id} />
386
+ </View>
315
387
  ))}
316
- {listenerCount > 0 && (
388
+ {hiddenCount > 0 && (
317
389
  <View
318
- className="items-center justify-center rounded-[14px] border-2 border-border bg-muted"
319
- style={{ width: SPEAKER_AVATAR_SIZE + 4, height: SPEAKER_AVATAR_SIZE + 4 }}
390
+ className="-ml-3 items-center justify-center rounded-full border-2 border-surface bg-muted"
391
+ style={{ width: SPEAKER_AVATAR_SIZE, height: SPEAKER_AVATAR_SIZE }}
320
392
  >
321
- <Text className="text-sm font-bold text-muted-foreground">
322
- +{formatCompact(listenerCount)}
393
+ <Text className="text-[13px] font-bold text-muted-foreground">
394
+ +{formatCompact(hiddenCount)}
323
395
  </Text>
324
396
  </View>
325
397
  )}
@@ -333,8 +405,9 @@ function SpeakerAvatar({ userId }: { userId: string }) {
333
405
  const profile = useUserById(userId);
334
406
  const avatarUri = getAvatarUrl(profile, oxyServices, getCachedFileDownloadUrlSync);
335
407
 
408
+ // The ring is the card's own surface color, so overlapping faces stay separable.
336
409
  return (
337
- <View className="rounded-[14px] border-2 border-border p-0.5">
410
+ <View className="rounded-full border-2 border-surface">
338
411
  <AvatarComponent size={SPEAKER_AVATAR_SIZE} source={avatarUri} shape="squircle" />
339
412
  </View>
340
413
  );
@@ -6,7 +6,11 @@ import { useLiveConfig } from '../context/LiveConfigContext';
6
6
 
7
7
  let AudioSession: { startAudioSession: () => void; stopAudioSession: () => void } | null = null;
8
8
  if (Platform.OS !== 'web') {
9
- try { AudioSession = require('@livekit/react-native').AudioSession; } catch {}
9
+ try {
10
+ AudioSession = require('@livekit/react-native').AudioSession;
11
+ } catch (err) {
12
+ console.warn('[RoomAudio] @livekit/react-native AudioSession unavailable; continuing without explicit audio session management:', err);
13
+ }
10
14
  }
11
15
 
12
16
  interface UseRoomAudioOptions {
@@ -37,7 +41,9 @@ export function useRoomAudio({ roomId, isSpeaker, isMuted, isConnected }: UseRoo
37
41
  (async () => {
38
42
  try {
39
43
  await setAudioModeAsync({ shouldPlayInBackground: true, playsInSilentMode: true, interruptionMode: 'duckOthers' });
40
- } catch {}
44
+ } catch (err) {
45
+ console.warn('[RoomAudio] Failed to set audio mode:', err);
46
+ }
41
47
  if (AudioSession) AudioSession.startAudioSession();
42
48
  })();
43
49
  return () => { if (AudioSession) AudioSession.stopAudioSession(); };
@@ -112,7 +112,12 @@ export function useRoomConnection({
112
112
  setParticipants(res.participants);
113
113
  hasJoined.current = true;
114
114
  if (introSound) {
115
- try { const player = createAudioPlayer(introSound); player.play(); } catch {}
115
+ try {
116
+ const player = createAudioPlayer(introSound);
117
+ player.play();
118
+ } catch (err) {
119
+ console.warn('[RoomConnection] Failed to play intro sound:', err);
120
+ }
116
121
  }
117
122
  }
118
123
  });
@@ -1,4 +1,4 @@
1
- import type { Room, Recording, House, HttpClient } from '../types';
1
+ import type { Room, Recording, House, HouseVisibility, HttpClient } from '../types';
2
2
  import { validateRooms, validateRoom, validateRecordings, validateRecording, validateHouse, ZStartStreamResponse, ZGenerateStreamKeyResponse } from '../validation';
3
3
 
4
4
  export interface CreateRoomData {
@@ -45,8 +45,8 @@ export interface EpisodeListItem {
45
45
  /**
46
46
  * A single Syra music track row for the listening-party picker. Like the podcast
47
47
  * rows it carries NO audio URL — the playable source (presigned original /
48
- * tokenized HLS / Audius passthrough) stays server-owned and is resolved at
49
- * stream-start by the backend from the opaque `trackId` alone.
48
+ * tokenized HLS) stays server-owned and is resolved at stream-start by the
49
+ * backend from the opaque `trackId` alone.
50
50
  */
51
51
  export interface MusicTrackResult {
52
52
  trackId: string;
@@ -630,7 +630,7 @@ export function createRoomsService(httpClient: HttpClient) {
630
630
 
631
631
  // --- Houses ---
632
632
 
633
- async createHouse(data: { name: string; description?: string; tags?: string[]; isPublic?: boolean }): Promise<House | null> {
633
+ async createHouse(data: { name: string; description?: string; tags?: string[]; visibility?: Partial<HouseVisibility> }): Promise<House | null> {
634
634
  try {
635
635
  const res = await httpClient.post("/houses", data);
636
636
  const raw = res.house || res.data || res || null;
package/src/live/types.ts CHANGED
@@ -6,6 +6,7 @@ export type {
6
6
  Recording,
7
7
  House,
8
8
  HouseMember,
9
+ HouseVisibility,
9
10
  Series,
10
11
  SeriesEpisode,
11
12
  Recurrence,
@@ -103,6 +103,20 @@ export const ZHouseMember = z.object({
103
103
 
104
104
  export type HouseMember = z.infer<typeof ZHouseMember>;
105
105
 
106
+ /**
107
+ * House visibility as three orthogonal axes — mirrors the backend `House`
108
+ * model. `discovery` (who can find it), `rooms` (who can see/enter its rooms),
109
+ * `join` (how one becomes a member). See the backend model for the full
110
+ * contract.
111
+ */
112
+ export const ZHouseVisibility = z.object({
113
+ discovery: z.enum(['listed', 'unlisted', 'hidden']).default('listed'),
114
+ rooms: z.enum(['anyone', 'members']).default('anyone'),
115
+ join: z.enum(['anyone', 'invite']).default('invite'),
116
+ }).passthrough();
117
+
118
+ export type HouseVisibility = z.infer<typeof ZHouseVisibility>;
119
+
106
120
  export const ZHouse = z.object({
107
121
  _id: z.string(),
108
122
  id: z.string().optional(),
@@ -110,9 +124,12 @@ export const ZHouse = z.object({
110
124
  description: z.string().optional(),
111
125
  avatar: z.string().optional().nullable(),
112
126
  coverImage: z.string().optional().nullable(),
127
+ // Withheld from non-members of a `rooms: members` house; `memberCount` is sent
128
+ // in its place. Both survive `.passthrough()`.
113
129
  members: z.array(ZHouseMember).default([]),
130
+ memberCount: z.number().optional(),
114
131
  createdBy: z.string(),
115
- isPublic: z.boolean().default(true),
132
+ visibility: ZHouseVisibility.default({ discovery: 'listed', rooms: 'anyone', join: 'invite' }),
116
133
  tags: z.array(z.string()).optional(),
117
134
  createdAt: z.string(),
118
135
  }).passthrough();