@syra.fm/sdk 0.4.0 → 0.6.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 +33 -4
- package/lib/commonjs/client.js +268 -0
- package/lib/commonjs/client.js.map +1 -0
- package/lib/commonjs/errors.js +22 -0
- package/lib/commonjs/errors.js.map +1 -0
- package/lib/commonjs/index.js +63 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/index.native.js +28 -0
- package/lib/commonjs/index.native.js.map +1 -0
- package/lib/commonjs/live/assets/icons/spaces-icon.js +42 -0
- package/lib/commonjs/live/assets/icons/spaces-icon.js.map +1 -0
- package/lib/commonjs/live/assets/icons/syra-icon.js +43 -0
- package/lib/commonjs/live/assets/icons/syra-icon.js.map +1 -0
- package/lib/commonjs/live/colors.js +54 -0
- package/lib/commonjs/live/colors.js.map +1 -0
- package/lib/commonjs/live/components/AnimatedPulse.js +38 -0
- package/lib/commonjs/live/components/AnimatedPulse.js.map +1 -0
- package/lib/commonjs/live/components/AnimatedSpeakerRing.js +90 -0
- package/lib/commonjs/live/components/AnimatedSpeakerRing.js.map +1 -0
- package/lib/commonjs/live/components/CreateRoomSheet.js +688 -0
- package/lib/commonjs/live/components/CreateRoomSheet.js.map +1 -0
- package/lib/commonjs/live/components/InsightsPanel.js +333 -0
- package/lib/commonjs/live/components/InsightsPanel.js.map +1 -0
- package/lib/commonjs/live/components/LiveRoomSheet.js +1196 -0
- package/lib/commonjs/live/components/LiveRoomSheet.js.map +1 -0
- package/lib/commonjs/live/components/MiniRoomBar.js +162 -0
- package/lib/commonjs/live/components/MiniRoomBar.js.map +1 -0
- package/lib/commonjs/live/components/PanelHeader.js +57 -0
- package/lib/commonjs/live/components/PanelHeader.js.map +1 -0
- package/lib/commonjs/live/components/PodcastStreamPicker.js +1190 -0
- package/lib/commonjs/live/components/PodcastStreamPicker.js.map +1 -0
- package/lib/commonjs/live/components/RecordingCard.js +192 -0
- package/lib/commonjs/live/components/RecordingCard.js.map +1 -0
- package/lib/commonjs/live/components/RecordingsPanel.js +305 -0
- package/lib/commonjs/live/components/RecordingsPanel.js.map +1 -0
- package/lib/commonjs/live/components/RoomCard.js +349 -0
- package/lib/commonjs/live/components/RoomCard.js.map +1 -0
- package/lib/commonjs/live/components/StreamConfigModal.js +656 -0
- package/lib/commonjs/live/components/StreamConfigModal.js.map +1 -0
- package/lib/commonjs/live/components/StreamConfigPanel.js +822 -0
- package/lib/commonjs/live/components/StreamConfigPanel.js.map +1 -0
- package/lib/commonjs/live/context/LiveConfigContext.js +46 -0
- package/lib/commonjs/live/context/LiveConfigContext.js.map +1 -0
- package/lib/commonjs/live/context/LiveRoomContext.js +195 -0
- package/lib/commonjs/live/context/LiveRoomContext.js.map +1 -0
- package/lib/commonjs/live/hooks/useActiveSpeakers.js +35 -0
- package/lib/commonjs/live/hooks/useActiveSpeakers.js.map +1 -0
- package/lib/commonjs/live/hooks/useRoomAudio.js +129 -0
- package/lib/commonjs/live/hooks/useRoomAudio.js.map +1 -0
- package/lib/commonjs/live/hooks/useRoomConnection.js +177 -0
- package/lib/commonjs/live/hooks/useRoomConnection.js.map +1 -0
- package/lib/commonjs/live/hooks/useRoomManager.js +37 -0
- package/lib/commonjs/live/hooks/useRoomManager.js.map +1 -0
- package/lib/commonjs/live/hooks/useRoomUsers.js +52 -0
- package/lib/commonjs/live/hooks/useRoomUsers.js.map +1 -0
- package/lib/commonjs/live/index.js +341 -0
- package/lib/commonjs/live/index.js.map +1 -0
- package/lib/commonjs/live/services/livekitService.js +20 -0
- package/lib/commonjs/live/services/livekitService.js.map +1 -0
- package/lib/commonjs/live/services/spaceSocketService.js +181 -0
- package/lib/commonjs/live/services/spaceSocketService.js.map +1 -0
- package/lib/commonjs/live/services/spacesService.js +642 -0
- package/lib/commonjs/live/services/spacesService.js.map +1 -0
- package/lib/commonjs/live/types.js +6 -0
- package/lib/commonjs/live/types.js.map +1 -0
- package/lib/commonjs/live/validation.js +238 -0
- package/lib/commonjs/live/validation.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/{dist/cjs → lib/commonjs}/schema.js +55 -47
- package/lib/commonjs/schema.js.map +1 -0
- package/lib/module/client.js +264 -0
- package/lib/module/client.js.map +1 -0
- package/lib/module/errors.js +17 -0
- package/lib/module/errors.js.map +1 -0
- package/lib/module/index.js +6 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/index.native.js +5 -0
- package/lib/module/index.native.js.map +1 -0
- package/lib/module/live/assets/icons/spaces-icon.js +34 -0
- package/lib/module/live/assets/icons/spaces-icon.js.map +1 -0
- package/lib/module/live/assets/icons/syra-icon.js +36 -0
- package/lib/module/live/assets/icons/syra-icon.js.map +1 -0
- package/lib/module/live/colors.js +49 -0
- package/lib/module/live/colors.js.map +1 -0
- package/lib/module/live/components/AnimatedPulse.js +33 -0
- package/lib/module/live/components/AnimatedPulse.js.map +1 -0
- package/lib/module/live/components/AnimatedSpeakerRing.js +85 -0
- package/lib/module/live/components/AnimatedSpeakerRing.js.map +1 -0
- package/lib/module/live/components/CreateRoomSheet.js +682 -0
- package/lib/module/live/components/CreateRoomSheet.js.map +1 -0
- package/lib/module/live/components/InsightsPanel.js +327 -0
- package/lib/module/live/components/InsightsPanel.js.map +1 -0
- package/lib/module/live/components/LiveRoomSheet.js +1190 -0
- package/lib/module/live/components/LiveRoomSheet.js.map +1 -0
- package/lib/module/live/components/MiniRoomBar.js +155 -0
- package/lib/module/live/components/MiniRoomBar.js.map +1 -0
- package/lib/module/live/components/PanelHeader.js +52 -0
- package/lib/module/live/components/PanelHeader.js.map +1 -0
- package/lib/module/live/components/PodcastStreamPicker.js +1183 -0
- package/lib/module/live/components/PodcastStreamPicker.js.map +1 -0
- package/lib/module/live/components/RecordingCard.js +187 -0
- package/lib/module/live/components/RecordingCard.js.map +1 -0
- package/lib/module/live/components/RecordingsPanel.js +299 -0
- package/lib/module/live/components/RecordingsPanel.js.map +1 -0
- package/lib/module/live/components/RoomCard.js +343 -0
- package/lib/module/live/components/RoomCard.js.map +1 -0
- package/lib/module/live/components/StreamConfigModal.js +650 -0
- package/lib/module/live/components/StreamConfigModal.js.map +1 -0
- package/lib/module/live/components/StreamConfigPanel.js +816 -0
- package/lib/module/live/components/StreamConfigPanel.js.map +1 -0
- package/lib/module/live/context/LiveConfigContext.js +40 -0
- package/lib/module/live/context/LiveConfigContext.js.map +1 -0
- package/lib/module/live/context/LiveRoomContext.js +189 -0
- package/lib/module/live/context/LiveRoomContext.js.map +1 -0
- package/lib/module/live/hooks/useActiveSpeakers.js +31 -0
- package/lib/module/live/hooks/useActiveSpeakers.js.map +1 -0
- package/lib/module/live/hooks/useRoomAudio.js +125 -0
- package/lib/module/live/hooks/useRoomAudio.js.map +1 -0
- package/lib/module/live/hooks/useRoomConnection.js +173 -0
- package/lib/module/live/hooks/useRoomConnection.js.map +1 -0
- package/lib/module/live/hooks/useRoomManager.js +32 -0
- package/lib/module/live/hooks/useRoomManager.js.map +1 -0
- package/lib/module/live/hooks/useRoomUsers.js +46 -0
- package/lib/module/live/hooks/useRoomUsers.js.map +1 -0
- package/lib/module/live/index.js +42 -0
- package/lib/module/live/index.js.map +1 -0
- package/lib/module/live/services/livekitService.js +16 -0
- package/lib/module/live/services/livekitService.js.map +1 -0
- package/lib/module/live/services/spaceSocketService.js +176 -0
- package/lib/module/live/services/spaceSocketService.js.map +1 -0
- package/lib/module/live/services/spacesService.js +639 -0
- package/lib/module/live/services/spacesService.js.map +1 -0
- package/lib/module/live/types.js +4 -0
- package/lib/module/live/types.js.map +1 -0
- package/lib/module/live/validation.js +229 -0
- package/lib/module/live/validation.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/{dist/esm → lib/module}/schema.js +47 -39
- package/lib/module/schema.js.map +1 -0
- package/{dist/types → lib/typescript/commonjs}/client.d.ts +1 -0
- package/lib/typescript/commonjs/client.d.ts.map +1 -0
- package/{dist/types → lib/typescript/commonjs}/errors.d.ts +1 -0
- package/lib/typescript/commonjs/errors.d.ts.map +1 -0
- package/{dist/types → lib/typescript/commonjs}/index.d.ts +1 -0
- package/lib/typescript/commonjs/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/index.native.d.ts +3 -0
- package/lib/typescript/commonjs/index.native.d.ts.map +1 -0
- package/lib/typescript/commonjs/live/assets/icons/spaces-icon.d.ts +13 -0
- package/lib/typescript/commonjs/live/assets/icons/spaces-icon.d.ts.map +1 -0
- package/lib/typescript/commonjs/live/assets/icons/syra-icon.d.ts +13 -0
- package/lib/typescript/commonjs/live/assets/icons/syra-icon.d.ts.map +1 -0
- package/lib/typescript/commonjs/live/colors.d.ts +35 -0
- package/lib/typescript/commonjs/live/colors.d.ts.map +1 -0
- package/lib/typescript/commonjs/live/components/AnimatedPulse.d.ts +8 -0
- package/lib/typescript/commonjs/live/components/AnimatedPulse.d.ts.map +1 -0
- package/lib/typescript/commonjs/live/components/AnimatedSpeakerRing.d.ts +10 -0
- package/lib/typescript/commonjs/live/components/AnimatedSpeakerRing.d.ts.map +1 -0
- package/lib/typescript/commonjs/live/components/CreateRoomSheet.d.ts +26 -0
- package/lib/typescript/commonjs/live/components/CreateRoomSheet.d.ts.map +1 -0
- package/lib/typescript/commonjs/live/components/InsightsPanel.d.ts +11 -0
- package/lib/typescript/commonjs/live/components/InsightsPanel.d.ts.map +1 -0
- package/lib/typescript/commonjs/live/components/LiveRoomSheet.d.ts +11 -0
- package/lib/typescript/commonjs/live/components/LiveRoomSheet.d.ts.map +1 -0
- package/lib/typescript/commonjs/live/components/MiniRoomBar.d.ts +15 -0
- package/lib/typescript/commonjs/live/components/MiniRoomBar.d.ts.map +1 -0
- package/lib/typescript/commonjs/live/components/PanelHeader.d.ts +10 -0
- package/lib/typescript/commonjs/live/components/PanelHeader.d.ts.map +1 -0
- package/lib/typescript/commonjs/live/components/PodcastStreamPicker.d.ts +54 -0
- package/lib/typescript/commonjs/live/components/PodcastStreamPicker.d.ts.map +1 -0
- package/lib/typescript/commonjs/live/components/RecordingCard.d.ts +11 -0
- package/lib/typescript/commonjs/live/components/RecordingCard.d.ts.map +1 -0
- package/lib/typescript/commonjs/live/components/RecordingsPanel.d.ts +12 -0
- package/lib/typescript/commonjs/live/components/RecordingsPanel.d.ts.map +1 -0
- package/lib/typescript/commonjs/live/components/RoomCard.d.ts +38 -0
- package/lib/typescript/commonjs/live/components/RoomCard.d.ts.map +1 -0
- package/lib/typescript/commonjs/live/components/StreamConfigModal.d.ts +11 -0
- package/lib/typescript/commonjs/live/components/StreamConfigModal.d.ts.map +1 -0
- package/lib/typescript/commonjs/live/components/StreamConfigPanel.d.ts +13 -0
- package/lib/typescript/commonjs/live/components/StreamConfigPanel.d.ts.map +1 -0
- package/lib/typescript/commonjs/live/context/LiveConfigContext.d.ts +78 -0
- package/lib/typescript/commonjs/live/context/LiveConfigContext.d.ts.map +1 -0
- package/lib/typescript/commonjs/live/context/LiveRoomContext.d.ts +12 -0
- package/lib/typescript/commonjs/live/context/LiveRoomContext.d.ts.map +1 -0
- package/lib/typescript/commonjs/live/hooks/useActiveSpeakers.d.ts +3 -0
- package/lib/typescript/commonjs/live/hooks/useActiveSpeakers.d.ts.map +1 -0
- package/lib/typescript/commonjs/live/hooks/useRoomAudio.d.ts +16 -0
- package/lib/typescript/commonjs/live/hooks/useRoomAudio.d.ts.map +1 -0
- package/lib/typescript/commonjs/live/hooks/useRoomConnection.d.ts +29 -0
- package/lib/typescript/commonjs/live/hooks/useRoomConnection.d.ts.map +1 -0
- package/lib/typescript/commonjs/live/hooks/useRoomManager.d.ts +11 -0
- package/lib/typescript/commonjs/live/hooks/useRoomManager.d.ts.map +1 -0
- package/lib/typescript/commonjs/live/hooks/useRoomUsers.d.ts +5 -0
- package/lib/typescript/commonjs/live/hooks/useRoomUsers.d.ts.map +1 -0
- package/lib/typescript/commonjs/live/index.d.ts +25 -0
- package/lib/typescript/commonjs/live/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/live/services/livekitService.d.ts +7 -0
- package/lib/typescript/commonjs/live/services/livekitService.d.ts.map +1 -0
- package/lib/typescript/commonjs/live/services/spaceSocketService.d.ts +69 -0
- package/lib/typescript/commonjs/live/services/spaceSocketService.d.ts.map +1 -0
- package/lib/typescript/commonjs/live/services/spacesService.d.ts +181 -0
- package/lib/typescript/commonjs/live/services/spacesService.d.ts.map +1 -0
- package/lib/typescript/commonjs/live/types.d.ts +100 -0
- package/lib/typescript/commonjs/live/types.d.ts.map +1 -0
- package/lib/typescript/commonjs/live/validation.d.ts +658 -0
- package/lib/typescript/commonjs/live/validation.d.ts.map +1 -0
- package/lib/typescript/commonjs/package.json +1 -0
- package/{dist/types → lib/typescript/commonjs}/schema.d.ts +1 -0
- package/lib/typescript/commonjs/schema.d.ts.map +1 -0
- package/lib/typescript/module/client.d.ts +141 -0
- package/lib/typescript/module/client.d.ts.map +1 -0
- package/lib/typescript/module/errors.d.ts +9 -0
- package/lib/typescript/module/errors.d.ts.map +1 -0
- package/lib/typescript/module/index.d.ts +6 -0
- package/lib/typescript/module/index.d.ts.map +1 -0
- package/lib/typescript/module/index.native.d.ts +3 -0
- package/lib/typescript/module/index.native.d.ts.map +1 -0
- package/lib/typescript/module/live/assets/icons/spaces-icon.d.ts +13 -0
- package/lib/typescript/module/live/assets/icons/spaces-icon.d.ts.map +1 -0
- package/lib/typescript/module/live/assets/icons/syra-icon.d.ts +13 -0
- package/lib/typescript/module/live/assets/icons/syra-icon.d.ts.map +1 -0
- package/lib/typescript/module/live/colors.d.ts +35 -0
- package/lib/typescript/module/live/colors.d.ts.map +1 -0
- package/lib/typescript/module/live/components/AnimatedPulse.d.ts +8 -0
- package/lib/typescript/module/live/components/AnimatedPulse.d.ts.map +1 -0
- package/lib/typescript/module/live/components/AnimatedSpeakerRing.d.ts +10 -0
- package/lib/typescript/module/live/components/AnimatedSpeakerRing.d.ts.map +1 -0
- package/lib/typescript/module/live/components/CreateRoomSheet.d.ts +26 -0
- package/lib/typescript/module/live/components/CreateRoomSheet.d.ts.map +1 -0
- package/lib/typescript/module/live/components/InsightsPanel.d.ts +11 -0
- package/lib/typescript/module/live/components/InsightsPanel.d.ts.map +1 -0
- package/lib/typescript/module/live/components/LiveRoomSheet.d.ts +11 -0
- package/lib/typescript/module/live/components/LiveRoomSheet.d.ts.map +1 -0
- package/lib/typescript/module/live/components/MiniRoomBar.d.ts +15 -0
- package/lib/typescript/module/live/components/MiniRoomBar.d.ts.map +1 -0
- package/lib/typescript/module/live/components/PanelHeader.d.ts +10 -0
- package/lib/typescript/module/live/components/PanelHeader.d.ts.map +1 -0
- package/lib/typescript/module/live/components/PodcastStreamPicker.d.ts +54 -0
- package/lib/typescript/module/live/components/PodcastStreamPicker.d.ts.map +1 -0
- package/lib/typescript/module/live/components/RecordingCard.d.ts +11 -0
- package/lib/typescript/module/live/components/RecordingCard.d.ts.map +1 -0
- package/lib/typescript/module/live/components/RecordingsPanel.d.ts +12 -0
- package/lib/typescript/module/live/components/RecordingsPanel.d.ts.map +1 -0
- package/lib/typescript/module/live/components/RoomCard.d.ts +38 -0
- package/lib/typescript/module/live/components/RoomCard.d.ts.map +1 -0
- package/lib/typescript/module/live/components/StreamConfigModal.d.ts +11 -0
- package/lib/typescript/module/live/components/StreamConfigModal.d.ts.map +1 -0
- package/lib/typescript/module/live/components/StreamConfigPanel.d.ts +13 -0
- package/lib/typescript/module/live/components/StreamConfigPanel.d.ts.map +1 -0
- package/lib/typescript/module/live/context/LiveConfigContext.d.ts +78 -0
- package/lib/typescript/module/live/context/LiveConfigContext.d.ts.map +1 -0
- package/lib/typescript/module/live/context/LiveRoomContext.d.ts +12 -0
- package/lib/typescript/module/live/context/LiveRoomContext.d.ts.map +1 -0
- package/lib/typescript/module/live/hooks/useActiveSpeakers.d.ts +3 -0
- package/lib/typescript/module/live/hooks/useActiveSpeakers.d.ts.map +1 -0
- package/lib/typescript/module/live/hooks/useRoomAudio.d.ts +16 -0
- package/lib/typescript/module/live/hooks/useRoomAudio.d.ts.map +1 -0
- package/lib/typescript/module/live/hooks/useRoomConnection.d.ts +29 -0
- package/lib/typescript/module/live/hooks/useRoomConnection.d.ts.map +1 -0
- package/lib/typescript/module/live/hooks/useRoomManager.d.ts +11 -0
- package/lib/typescript/module/live/hooks/useRoomManager.d.ts.map +1 -0
- package/lib/typescript/module/live/hooks/useRoomUsers.d.ts +5 -0
- package/lib/typescript/module/live/hooks/useRoomUsers.d.ts.map +1 -0
- package/lib/typescript/module/live/index.d.ts +25 -0
- package/lib/typescript/module/live/index.d.ts.map +1 -0
- package/lib/typescript/module/live/services/livekitService.d.ts +7 -0
- package/lib/typescript/module/live/services/livekitService.d.ts.map +1 -0
- package/lib/typescript/module/live/services/spaceSocketService.d.ts +69 -0
- package/lib/typescript/module/live/services/spaceSocketService.d.ts.map +1 -0
- package/lib/typescript/module/live/services/spacesService.d.ts +181 -0
- package/lib/typescript/module/live/services/spacesService.d.ts.map +1 -0
- package/lib/typescript/module/live/types.d.ts +100 -0
- package/lib/typescript/module/live/types.d.ts.map +1 -0
- package/lib/typescript/module/live/validation.d.ts +658 -0
- package/lib/typescript/module/live/validation.d.ts.map +1 -0
- package/lib/typescript/module/package.json +1 -0
- package/lib/typescript/module/schema.d.ts +1072 -0
- package/lib/typescript/module/schema.d.ts.map +1 -0
- package/package.json +123 -21
- package/src/index.native.ts +2 -0
- package/src/live/assets/icons/spaces-icon.tsx +15 -0
- package/src/live/assets/icons/syra-icon.tsx +13 -0
- package/src/live/assets/sounds/intro.mp3 +0 -0
- package/src/live/colors.ts +57 -0
- package/src/live/components/AnimatedPulse.tsx +52 -0
- package/src/live/components/AnimatedSpeakerRing.tsx +101 -0
- package/src/live/components/CreateRoomSheet.tsx +654 -0
- package/src/live/components/InsightsPanel.tsx +202 -0
- package/src/live/components/LiveRoomSheet.tsx +970 -0
- package/src/live/components/MiniRoomBar.tsx +167 -0
- package/src/live/components/PanelHeader.tsx +36 -0
- package/src/live/components/PodcastStreamPicker.tsx +1155 -0
- package/src/live/components/RecordingCard.tsx +154 -0
- package/src/live/components/RecordingsPanel.tsx +252 -0
- package/src/live/components/RoomCard.tsx +343 -0
- package/src/live/components/StreamConfigModal.tsx +600 -0
- package/src/live/components/StreamConfigPanel.tsx +729 -0
- package/src/live/context/LiveConfigContext.tsx +96 -0
- package/src/live/context/LiveRoomContext.tsx +235 -0
- package/src/live/hooks/useActiveSpeakers.ts +39 -0
- package/src/live/hooks/useRoomAudio.ts +103 -0
- package/src/live/hooks/useRoomConnection.ts +162 -0
- package/src/live/hooks/useRoomManager.ts +17 -0
- package/src/live/hooks/useRoomUsers.ts +52 -0
- package/src/live/index.ts +104 -0
- package/src/live/services/livekitService.ts +11 -0
- package/src/live/services/spaceSocketService.ts +136 -0
- package/src/live/services/spacesService.ts +692 -0
- package/src/live/types.ts +119 -0
- package/src/live/validation.ts +279 -0
- package/dist/cjs/client.js +0 -244
- package/dist/cjs/errors.js +0 -16
- package/dist/cjs/index.js +0 -15
- package/dist/cjs/package.json +0 -3
- package/dist/esm/client.js +0 -240
- package/dist/esm/errors.js +0 -12
- package/dist/esm/index.js +0 -3
- package/dist/esm/package.json +0 -3
|
@@ -0,0 +1,1155 @@
|
|
|
1
|
+
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
Text,
|
|
5
|
+
StyleSheet,
|
|
6
|
+
TouchableOpacity,
|
|
7
|
+
TextInput,
|
|
8
|
+
FlatList,
|
|
9
|
+
ActivityIndicator,
|
|
10
|
+
Linking,
|
|
11
|
+
} from 'react-native';
|
|
12
|
+
import type { ViewStyle } from 'react-native';
|
|
13
|
+
import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons';
|
|
14
|
+
|
|
15
|
+
import { useLiveConfig, type PinnedPodcast } from '../context/LiveConfigContext';
|
|
16
|
+
import type { LiveTheme } from '../types';
|
|
17
|
+
import type { PodcastResult, EpisodeListItem, MusicTrackResult } from '../services/spacesService';
|
|
18
|
+
|
|
19
|
+
const SEARCH_DEBOUNCE_MS = 350;
|
|
20
|
+
|
|
21
|
+
/** Public Syra web base — the "Open in Syra" affordance links to `/podcasts/:id`. */
|
|
22
|
+
const SYRA_WEB_BASE_URL = 'https://syra.fm';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* English source copy for every user-facing string in the picker. Hosts with an
|
|
26
|
+
* i18n layer (the Mention frontend) inject `t` via `useLiveConfig()` and these
|
|
27
|
+
* are never read; hosts without one (the standalone live-rooms app) fall back to
|
|
28
|
+
* these. Keys mirror the `agora.podcastStream.*` entries in the app locale files.
|
|
29
|
+
*/
|
|
30
|
+
const DEFAULT_STRINGS: Record<string, string> = {
|
|
31
|
+
'agora.podcastStream.disclaimer':
|
|
32
|
+
"You're responsible for having the rights to stream this audio into your room.",
|
|
33
|
+
'agora.podcastStream.searchPlaceholder': 'Search podcasts',
|
|
34
|
+
'agora.podcastStream.searchHint': 'Search for a podcast to stream',
|
|
35
|
+
'agora.podcastStream.pinnedTitle': 'Stream my pinned podcast',
|
|
36
|
+
'agora.podcastStream.emptyShows': 'No podcasts found',
|
|
37
|
+
'agora.podcastStream.emptyEpisodes': 'No episodes found',
|
|
38
|
+
'agora.podcastStream.errorShows': "Couldn't load podcasts. Check your connection and try again.",
|
|
39
|
+
'agora.podcastStream.errorEpisodes': "Couldn't load episodes. Check your connection and try again.",
|
|
40
|
+
'agora.podcastStream.retry': 'Retry',
|
|
41
|
+
'agora.podcastStream.openInSyra': 'Open in Syra',
|
|
42
|
+
'agora.podcastStream.openFailed': "Couldn't open Syra",
|
|
43
|
+
'agora.podcastStream.addToQueue': 'Add to queue',
|
|
44
|
+
'agora.podcastStream.removeFromQueue': 'Remove from queue',
|
|
45
|
+
'agora.podcastStream.playQueue': 'Play episodes',
|
|
46
|
+
'agora.podcastStream.clearQueue': 'Clear',
|
|
47
|
+
// Music (listening party). Streaming a full track into a room is a broadcast —
|
|
48
|
+
// the disclaimer makes the host's rights responsibility explicit.
|
|
49
|
+
'agora.musicStream.disclaimer':
|
|
50
|
+
"You're responsible for having the rights to broadcast this music into your room.",
|
|
51
|
+
'agora.musicStream.searchPlaceholder': 'Search music',
|
|
52
|
+
'agora.musicStream.searchHint': 'Search for a track to play',
|
|
53
|
+
'agora.musicStream.empty': 'No tracks found',
|
|
54
|
+
'agora.musicStream.error': "Couldn't load music. Check your connection and try again.",
|
|
55
|
+
'agora.musicStream.retry': 'Retry',
|
|
56
|
+
'agora.musicStream.addToQueue': 'Add to queue',
|
|
57
|
+
'agora.musicStream.removeFromQueue': 'Remove from queue',
|
|
58
|
+
'agora.musicStream.playQueue': 'Play tracks',
|
|
59
|
+
'agora.musicStream.clearQueue': 'Clear',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
type TranslateFn = (key: string) => string;
|
|
63
|
+
|
|
64
|
+
type AvatarComponentType = React.ComponentType<{
|
|
65
|
+
size: number;
|
|
66
|
+
source?: string;
|
|
67
|
+
shape?: string;
|
|
68
|
+
style?: ViewStyle;
|
|
69
|
+
}>;
|
|
70
|
+
|
|
71
|
+
/** One episode selected for the up-next queue. The show id is always known here. */
|
|
72
|
+
export interface PodcastQueueSelection {
|
|
73
|
+
syraPodcastId: string;
|
|
74
|
+
episodeId: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface PodcastStreamPickerProps {
|
|
78
|
+
onSelectEpisode: (syraPodcastId: string, episodeId: string) => void;
|
|
79
|
+
/**
|
|
80
|
+
* Start a multi-episode session: the first item plays immediately, the rest
|
|
81
|
+
* become the room's up-next queue. Optional — hosts that omit it get the
|
|
82
|
+
* single-tap "play this now" flow only, and the queue affordances (per-row "+"
|
|
83
|
+
* and the footer) are hidden.
|
|
84
|
+
*/
|
|
85
|
+
onStartQueue?: (items: PodcastQueueSelection[]) => void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function formatDuration(seconds?: number): string {
|
|
89
|
+
if (!seconds || seconds <= 0) return '';
|
|
90
|
+
const total = Math.floor(seconds);
|
|
91
|
+
const hours = Math.floor(total / 3600);
|
|
92
|
+
const minutes = Math.floor((total % 3600) / 60);
|
|
93
|
+
const secs = total % 60;
|
|
94
|
+
const paddedSecs = String(secs).padStart(2, '0');
|
|
95
|
+
if (hours > 0) {
|
|
96
|
+
return `${hours}:${String(minutes).padStart(2, '0')}:${paddedSecs}`;
|
|
97
|
+
}
|
|
98
|
+
return `${minutes}:${paddedSecs}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function formatDate(iso?: string): string {
|
|
102
|
+
if (!iso) return '';
|
|
103
|
+
const date = new Date(iso);
|
|
104
|
+
if (Number.isNaN(date.getTime())) return '';
|
|
105
|
+
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const ShowRow = memo(function ShowRow({
|
|
109
|
+
show,
|
|
110
|
+
theme,
|
|
111
|
+
AvatarComponent,
|
|
112
|
+
onSelect,
|
|
113
|
+
}: {
|
|
114
|
+
show: PodcastResult;
|
|
115
|
+
theme: LiveTheme;
|
|
116
|
+
AvatarComponent: AvatarComponentType;
|
|
117
|
+
onSelect: (show: PodcastResult) => void;
|
|
118
|
+
}) {
|
|
119
|
+
return (
|
|
120
|
+
<TouchableOpacity style={styles.row} activeOpacity={0.7} onPress={() => onSelect(show)}>
|
|
121
|
+
<AvatarComponent size={44} source={show.artworkUrl} shape="squircle" />
|
|
122
|
+
<View style={styles.rowText}>
|
|
123
|
+
<Text style={[styles.rowTitle, { color: theme.colors.text }]} numberOfLines={1}>
|
|
124
|
+
{show.title}
|
|
125
|
+
</Text>
|
|
126
|
+
{!!show.author && (
|
|
127
|
+
<Text style={[styles.rowSubtitle, { color: theme.colors.textSecondary }]} numberOfLines={1}>
|
|
128
|
+
{show.author}
|
|
129
|
+
</Text>
|
|
130
|
+
)}
|
|
131
|
+
</View>
|
|
132
|
+
<MaterialCommunityIcons name="chevron-right" size={22} color={theme.colors.textSecondary} />
|
|
133
|
+
</TouchableOpacity>
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const EpisodeRow = memo(function EpisodeRow({
|
|
138
|
+
episode,
|
|
139
|
+
theme,
|
|
140
|
+
AvatarComponent,
|
|
141
|
+
onSelect,
|
|
142
|
+
queueEnabled,
|
|
143
|
+
isQueued,
|
|
144
|
+
onToggleQueue,
|
|
145
|
+
queueActionLabel,
|
|
146
|
+
}: {
|
|
147
|
+
episode: EpisodeListItem;
|
|
148
|
+
theme: LiveTheme;
|
|
149
|
+
AvatarComponent: AvatarComponentType;
|
|
150
|
+
onSelect: (episode: EpisodeListItem) => void;
|
|
151
|
+
queueEnabled: boolean;
|
|
152
|
+
isQueued: boolean;
|
|
153
|
+
onToggleQueue: (episode: EpisodeListItem) => void;
|
|
154
|
+
queueActionLabel: string;
|
|
155
|
+
}) {
|
|
156
|
+
const meta = [formatDuration(episode.durationSec), formatDate(episode.publishedAt)]
|
|
157
|
+
.filter(Boolean)
|
|
158
|
+
.join(' · ');
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<View style={styles.row}>
|
|
162
|
+
<TouchableOpacity style={styles.rowMain} activeOpacity={0.7} onPress={() => onSelect(episode)}>
|
|
163
|
+
<AvatarComponent size={44} source={episode.artworkUrl} shape="squircle" />
|
|
164
|
+
<View style={styles.rowText}>
|
|
165
|
+
<Text style={[styles.rowTitle, { color: theme.colors.text }]} numberOfLines={2}>
|
|
166
|
+
{episode.title}
|
|
167
|
+
</Text>
|
|
168
|
+
{!!meta && (
|
|
169
|
+
<Text style={[styles.rowSubtitle, { color: theme.colors.textSecondary }]} numberOfLines={1}>
|
|
170
|
+
{meta}
|
|
171
|
+
</Text>
|
|
172
|
+
)}
|
|
173
|
+
</View>
|
|
174
|
+
<MaterialCommunityIcons name="play-circle" size={26} color={theme.colors.primary} />
|
|
175
|
+
</TouchableOpacity>
|
|
176
|
+
{queueEnabled && (
|
|
177
|
+
<TouchableOpacity
|
|
178
|
+
style={styles.queueBtn}
|
|
179
|
+
activeOpacity={0.7}
|
|
180
|
+
hitSlop={8}
|
|
181
|
+
onPress={() => onToggleQueue(episode)}
|
|
182
|
+
accessibilityRole="button"
|
|
183
|
+
accessibilityLabel={queueActionLabel}
|
|
184
|
+
>
|
|
185
|
+
<MaterialCommunityIcons
|
|
186
|
+
name={isQueued ? 'check-circle' : 'plus-circle-outline'}
|
|
187
|
+
size={24}
|
|
188
|
+
color={isQueued ? theme.colors.primary : theme.colors.textSecondary}
|
|
189
|
+
/>
|
|
190
|
+
</TouchableOpacity>
|
|
191
|
+
)}
|
|
192
|
+
</View>
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
/** Failed-request state (distinct from empty) with a Retry affordance. */
|
|
197
|
+
const ErrorState = memo(function ErrorState({
|
|
198
|
+
theme,
|
|
199
|
+
message,
|
|
200
|
+
retryLabel,
|
|
201
|
+
onRetry,
|
|
202
|
+
}: {
|
|
203
|
+
theme: LiveTheme;
|
|
204
|
+
message: string;
|
|
205
|
+
retryLabel: string;
|
|
206
|
+
onRetry: () => void;
|
|
207
|
+
}) {
|
|
208
|
+
return (
|
|
209
|
+
<View style={styles.stateBox}>
|
|
210
|
+
<MaterialCommunityIcons name="wifi-off" size={32} color={theme.colors.textSecondary} />
|
|
211
|
+
<Text style={[styles.stateText, { color: theme.colors.textSecondary }]}>{message}</Text>
|
|
212
|
+
<TouchableOpacity
|
|
213
|
+
style={[styles.retryBtn, { borderColor: theme.colors.border }]}
|
|
214
|
+
activeOpacity={0.7}
|
|
215
|
+
onPress={onRetry}
|
|
216
|
+
>
|
|
217
|
+
<MaterialCommunityIcons name="refresh" size={16} color={theme.colors.primary} />
|
|
218
|
+
<Text style={[styles.retryText, { color: theme.colors.primary }]}>{retryLabel}</Text>
|
|
219
|
+
</TouchableOpacity>
|
|
220
|
+
</View>
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Self-contained two-level podcast picker for the live-room stream setup. Level 1
|
|
226
|
+
* is a debounced Syra show search; tapping a show drills into Level 2, its episode
|
|
227
|
+
* list. Tapping an episode reports `(syraPodcastId, episodeId)` to the parent — the
|
|
228
|
+
* picker owns NO room/stream logic. Ships inside @syra.fm/sdk, so it depends only
|
|
229
|
+
* on `useLiveConfig()` injection and never imports from a host app (`@/`).
|
|
230
|
+
*
|
|
231
|
+
* UX affordances (all backend-field-free): a host copyright disclaimer, an honest
|
|
232
|
+
* error+Retry state distinct from empty results, a one-tap "stream my pinned
|
|
233
|
+
* podcast" quick-start row (when the host exposes `getPinnedPodcast`), and an
|
|
234
|
+
* "Open in Syra" deep link on the episode-list header.
|
|
235
|
+
*/
|
|
236
|
+
export function PodcastStreamPicker({ onSelectEpisode, onStartQueue }: PodcastStreamPickerProps) {
|
|
237
|
+
const { useTheme, roomsService, AvatarComponent, toast, t, getPinnedPodcast } = useLiveConfig();
|
|
238
|
+
const theme = useTheme();
|
|
239
|
+
|
|
240
|
+
const tr = useCallback<TranslateFn>(
|
|
241
|
+
(key) => (t ? t(key) : DEFAULT_STRINGS[key] ?? key),
|
|
242
|
+
[t],
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
// Up-next queue: only offered when the host wired the multi-episode start.
|
|
246
|
+
const queueEnabled = !!onStartQueue;
|
|
247
|
+
const [queued, setQueued] = useState<PodcastQueueSelection[]>([]);
|
|
248
|
+
const queuedIds = useMemo(() => new Set(queued.map((q) => q.episodeId)), [queued]);
|
|
249
|
+
|
|
250
|
+
// Level 1 — show search
|
|
251
|
+
const [query, setQuery] = useState('');
|
|
252
|
+
const [shows, setShows] = useState<PodcastResult[]>([]);
|
|
253
|
+
const [showsNextOffset, setShowsNextOffset] = useState(0);
|
|
254
|
+
const [showsHasMore, setShowsHasMore] = useState(false);
|
|
255
|
+
const [showsLoading, setShowsLoading] = useState(false);
|
|
256
|
+
const [showsLoadingMore, setShowsLoadingMore] = useState(false);
|
|
257
|
+
const [showsError, setShowsError] = useState(false);
|
|
258
|
+
const showsSeqRef = useRef(0);
|
|
259
|
+
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
260
|
+
|
|
261
|
+
// Pinned-podcast quick-start (only when the host exposes profile media).
|
|
262
|
+
const [pinnedPodcast, setPinnedPodcast] = useState<PinnedPodcast | null>(null);
|
|
263
|
+
|
|
264
|
+
// Level 2 — episode list
|
|
265
|
+
const [selectedShow, setSelectedShow] = useState<PodcastResult | null>(null);
|
|
266
|
+
const [episodes, setEpisodes] = useState<EpisodeListItem[]>([]);
|
|
267
|
+
const [episodesNextOffset, setEpisodesNextOffset] = useState(0);
|
|
268
|
+
const [episodesHasMore, setEpisodesHasMore] = useState(false);
|
|
269
|
+
const [episodesLoading, setEpisodesLoading] = useState(false);
|
|
270
|
+
const [episodesLoadingMore, setEpisodesLoadingMore] = useState(false);
|
|
271
|
+
const [episodesError, setEpisodesError] = useState(false);
|
|
272
|
+
const episodesSeqRef = useRef(0);
|
|
273
|
+
|
|
274
|
+
const runSearch = useCallback(
|
|
275
|
+
async (rawQuery: string, offset: number) => {
|
|
276
|
+
const trimmed = rawQuery.trim();
|
|
277
|
+
if (!trimmed) {
|
|
278
|
+
showsSeqRef.current += 1;
|
|
279
|
+
setShows([]);
|
|
280
|
+
setShowsHasMore(false);
|
|
281
|
+
setShowsNextOffset(0);
|
|
282
|
+
setShowsLoading(false);
|
|
283
|
+
setShowsLoadingMore(false);
|
|
284
|
+
setShowsError(false);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const seq = offset === 0 ? (showsSeqRef.current += 1) : showsSeqRef.current;
|
|
288
|
+
if (offset === 0) {
|
|
289
|
+
setShowsLoading(true);
|
|
290
|
+
setShowsError(false);
|
|
291
|
+
} else {
|
|
292
|
+
setShowsLoadingMore(true);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const result = await roomsService.searchPodcasts(trimmed, offset);
|
|
296
|
+
if (seq !== showsSeqRef.current) return; // superseded by a newer query
|
|
297
|
+
|
|
298
|
+
if (!result.ok) {
|
|
299
|
+
// Only replace the list on a fresh search; a load-more failure keeps the
|
|
300
|
+
// rows the host already has and simply stops paginating.
|
|
301
|
+
if (offset === 0) {
|
|
302
|
+
setShows([]);
|
|
303
|
+
setShowsError(true);
|
|
304
|
+
}
|
|
305
|
+
setShowsHasMore(false);
|
|
306
|
+
setShowsLoading(false);
|
|
307
|
+
setShowsLoadingMore(false);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
setShows((prev) => (offset === 0 ? result.items : [...prev, ...result.items]));
|
|
312
|
+
setShowsHasMore(result.hasMore);
|
|
313
|
+
setShowsNextOffset(result.offset + result.items.length);
|
|
314
|
+
setShowsLoading(false);
|
|
315
|
+
setShowsLoadingMore(false);
|
|
316
|
+
},
|
|
317
|
+
[roomsService],
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
const handleQueryChange = useCallback(
|
|
321
|
+
(text: string) => {
|
|
322
|
+
setQuery(text);
|
|
323
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
324
|
+
// Show the spinner for the debounce window so a fresh query never briefly
|
|
325
|
+
// renders the "No podcasts found" empty state before the request fires.
|
|
326
|
+
if (text.trim()) {
|
|
327
|
+
setShowsLoading(true);
|
|
328
|
+
setShowsError(false);
|
|
329
|
+
}
|
|
330
|
+
debounceRef.current = setTimeout(() => {
|
|
331
|
+
runSearch(text, 0);
|
|
332
|
+
}, SEARCH_DEBOUNCE_MS);
|
|
333
|
+
},
|
|
334
|
+
[runSearch],
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
const handleClearQuery = useCallback(() => {
|
|
338
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
339
|
+
setQuery('');
|
|
340
|
+
runSearch('', 0);
|
|
341
|
+
}, [runSearch]);
|
|
342
|
+
|
|
343
|
+
const handleShowsEndReached = useCallback(() => {
|
|
344
|
+
if (showsLoading || showsLoadingMore || !showsHasMore) return;
|
|
345
|
+
runSearch(query, showsNextOffset);
|
|
346
|
+
}, [showsLoading, showsLoadingMore, showsHasMore, runSearch, query, showsNextOffset]);
|
|
347
|
+
|
|
348
|
+
const loadEpisodes = useCallback(
|
|
349
|
+
async (syraPodcastId: string, offset: number) => {
|
|
350
|
+
const seq = offset === 0 ? (episodesSeqRef.current += 1) : episodesSeqRef.current;
|
|
351
|
+
if (offset === 0) {
|
|
352
|
+
setEpisodesLoading(true);
|
|
353
|
+
setEpisodesError(false);
|
|
354
|
+
} else {
|
|
355
|
+
setEpisodesLoadingMore(true);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const result = await roomsService.getPodcastEpisodes(syraPodcastId, offset);
|
|
359
|
+
if (seq !== episodesSeqRef.current) return; // superseded by a newer show
|
|
360
|
+
|
|
361
|
+
if (!result.ok) {
|
|
362
|
+
if (offset === 0) {
|
|
363
|
+
setEpisodes([]);
|
|
364
|
+
setEpisodesError(true);
|
|
365
|
+
}
|
|
366
|
+
setEpisodesHasMore(false);
|
|
367
|
+
setEpisodesLoading(false);
|
|
368
|
+
setEpisodesLoadingMore(false);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
setEpisodes((prev) => (offset === 0 ? result.items : [...prev, ...result.items]));
|
|
373
|
+
setEpisodesHasMore(result.hasMore);
|
|
374
|
+
setEpisodesNextOffset(result.offset + result.items.length);
|
|
375
|
+
setEpisodesLoading(false);
|
|
376
|
+
setEpisodesLoadingMore(false);
|
|
377
|
+
},
|
|
378
|
+
[roomsService],
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
const handleSelectShow = useCallback(
|
|
382
|
+
(show: PodcastResult) => {
|
|
383
|
+
episodesSeqRef.current += 1;
|
|
384
|
+
setSelectedShow(show);
|
|
385
|
+
setEpisodes([]);
|
|
386
|
+
setEpisodesHasMore(false);
|
|
387
|
+
setEpisodesNextOffset(0);
|
|
388
|
+
setEpisodesError(false);
|
|
389
|
+
loadEpisodes(show.syraPodcastId, 0);
|
|
390
|
+
},
|
|
391
|
+
[loadEpisodes],
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
const handleSelectPinned = useCallback(() => {
|
|
395
|
+
if (!pinnedPodcast) return;
|
|
396
|
+
handleSelectShow({
|
|
397
|
+
syraPodcastId: pinnedPodcast.syraPodcastId,
|
|
398
|
+
title: pinnedPodcast.title ?? tr('agora.podcastStream.pinnedTitle'),
|
|
399
|
+
artworkUrl: pinnedPodcast.artworkUrl,
|
|
400
|
+
});
|
|
401
|
+
}, [pinnedPodcast, handleSelectShow, tr]);
|
|
402
|
+
|
|
403
|
+
const handleBack = useCallback(() => {
|
|
404
|
+
episodesSeqRef.current += 1;
|
|
405
|
+
setSelectedShow(null);
|
|
406
|
+
setEpisodes([]);
|
|
407
|
+
setEpisodesHasMore(false);
|
|
408
|
+
setEpisodesNextOffset(0);
|
|
409
|
+
setEpisodesLoading(false);
|
|
410
|
+
setEpisodesLoadingMore(false);
|
|
411
|
+
setEpisodesError(false);
|
|
412
|
+
}, []);
|
|
413
|
+
|
|
414
|
+
const handleEpisodesEndReached = useCallback(() => {
|
|
415
|
+
if (!selectedShow || episodesLoading || episodesLoadingMore || !episodesHasMore) return;
|
|
416
|
+
loadEpisodes(selectedShow.syraPodcastId, episodesNextOffset);
|
|
417
|
+
}, [selectedShow, episodesLoading, episodesLoadingMore, episodesHasMore, loadEpisodes, episodesNextOffset]);
|
|
418
|
+
|
|
419
|
+
const handleSelectEpisode = useCallback(
|
|
420
|
+
(episode: EpisodeListItem) => {
|
|
421
|
+
if (!selectedShow) return;
|
|
422
|
+
onSelectEpisode(selectedShow.syraPodcastId, episode.episodeId);
|
|
423
|
+
},
|
|
424
|
+
[selectedShow, onSelectEpisode],
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
// Toggle an episode in/out of the local up-next queue. Items keep their own
|
|
428
|
+
// show id, so a host can queue across multiple shows before launching.
|
|
429
|
+
const handleToggleQueue = useCallback(
|
|
430
|
+
(episode: EpisodeListItem) => {
|
|
431
|
+
if (!selectedShow) return;
|
|
432
|
+
const showId = selectedShow.syraPodcastId;
|
|
433
|
+
setQueued((prev) =>
|
|
434
|
+
prev.some((q) => q.episodeId === episode.episodeId)
|
|
435
|
+
? prev.filter((q) => q.episodeId !== episode.episodeId)
|
|
436
|
+
: [...prev, { syraPodcastId: showId, episodeId: episode.episodeId }],
|
|
437
|
+
);
|
|
438
|
+
},
|
|
439
|
+
[selectedShow],
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
const handleClearQueue = useCallback(() => setQueued([]), []);
|
|
443
|
+
|
|
444
|
+
const handleStartQueue = useCallback(() => {
|
|
445
|
+
if (queued.length === 0) return;
|
|
446
|
+
onStartQueue?.(queued);
|
|
447
|
+
setQueued([]);
|
|
448
|
+
}, [queued, onStartQueue]);
|
|
449
|
+
|
|
450
|
+
const handleOpenInSyra = useCallback(() => {
|
|
451
|
+
if (!selectedShow) return;
|
|
452
|
+
Linking.openURL(`${SYRA_WEB_BASE_URL}/podcasts/${selectedShow.syraPodcastId}`).catch(() =>
|
|
453
|
+
toast.error(tr('agora.podcastStream.openFailed')),
|
|
454
|
+
);
|
|
455
|
+
}, [selectedShow, toast, tr]);
|
|
456
|
+
|
|
457
|
+
// Resolve the viewer's pinned podcast once on mount (host-provided; optional).
|
|
458
|
+
useEffect(() => {
|
|
459
|
+
if (!getPinnedPodcast) return;
|
|
460
|
+
let cancelled = false;
|
|
461
|
+
getPinnedPodcast().then(
|
|
462
|
+
(podcast) => {
|
|
463
|
+
if (!cancelled) setPinnedPodcast(podcast);
|
|
464
|
+
},
|
|
465
|
+
() => {
|
|
466
|
+
if (!cancelled) setPinnedPodcast(null);
|
|
467
|
+
},
|
|
468
|
+
);
|
|
469
|
+
return () => {
|
|
470
|
+
cancelled = true;
|
|
471
|
+
};
|
|
472
|
+
}, [getPinnedPodcast]);
|
|
473
|
+
|
|
474
|
+
// Cancel any pending debounced search when the picker unmounts.
|
|
475
|
+
useEffect(() => {
|
|
476
|
+
return () => {
|
|
477
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
478
|
+
};
|
|
479
|
+
}, []);
|
|
480
|
+
|
|
481
|
+
const hasQuery = query.trim().length > 0;
|
|
482
|
+
const showQueueFooter = queueEnabled && queued.length > 0;
|
|
483
|
+
const listContentStyle = showQueueFooter
|
|
484
|
+
? [styles.listContent, styles.listContentWithFooter]
|
|
485
|
+
: styles.listContent;
|
|
486
|
+
|
|
487
|
+
// Floats over the bottom of either level; queued items persist across shows
|
|
488
|
+
// so the host can review/launch after adding from multiple shows.
|
|
489
|
+
const queueFooter = showQueueFooter ? (
|
|
490
|
+
<View style={[styles.queueFooter, { backgroundColor: theme.colors.background, borderTopColor: theme.colors.border }]}>
|
|
491
|
+
<TouchableOpacity
|
|
492
|
+
style={styles.queueClearBtn}
|
|
493
|
+
activeOpacity={0.7}
|
|
494
|
+
hitSlop={8}
|
|
495
|
+
onPress={handleClearQueue}
|
|
496
|
+
accessibilityRole="button"
|
|
497
|
+
>
|
|
498
|
+
<Text style={[styles.queueClearText, { color: theme.colors.textSecondary }]}>
|
|
499
|
+
{tr('agora.podcastStream.clearQueue')}
|
|
500
|
+
</Text>
|
|
501
|
+
</TouchableOpacity>
|
|
502
|
+
<TouchableOpacity
|
|
503
|
+
style={[styles.queuePlayBtn, { backgroundColor: theme.colors.primary }]}
|
|
504
|
+
activeOpacity={0.85}
|
|
505
|
+
onPress={handleStartQueue}
|
|
506
|
+
accessibilityRole="button"
|
|
507
|
+
>
|
|
508
|
+
<MaterialCommunityIcons name="play" size={18} color="#FFFFFF" />
|
|
509
|
+
<Text style={styles.queuePlayText}>
|
|
510
|
+
{tr('agora.podcastStream.playQueue')} ({queued.length})
|
|
511
|
+
</Text>
|
|
512
|
+
</TouchableOpacity>
|
|
513
|
+
</View>
|
|
514
|
+
) : null;
|
|
515
|
+
|
|
516
|
+
if (selectedShow) {
|
|
517
|
+
return (
|
|
518
|
+
<View style={styles.container}>
|
|
519
|
+
<View style={styles.backHeader}>
|
|
520
|
+
<TouchableOpacity
|
|
521
|
+
style={styles.backHeaderMain}
|
|
522
|
+
activeOpacity={0.7}
|
|
523
|
+
onPress={handleBack}
|
|
524
|
+
hitSlop={8}
|
|
525
|
+
>
|
|
526
|
+
<MaterialCommunityIcons name="chevron-left" size={24} color={theme.colors.text} />
|
|
527
|
+
<Text style={[styles.backTitle, { color: theme.colors.text }]} numberOfLines={1}>
|
|
528
|
+
{selectedShow.title}
|
|
529
|
+
</Text>
|
|
530
|
+
</TouchableOpacity>
|
|
531
|
+
<TouchableOpacity
|
|
532
|
+
style={styles.openBtn}
|
|
533
|
+
activeOpacity={0.7}
|
|
534
|
+
onPress={handleOpenInSyra}
|
|
535
|
+
hitSlop={8}
|
|
536
|
+
accessibilityRole="link"
|
|
537
|
+
accessibilityLabel={tr('agora.podcastStream.openInSyra')}
|
|
538
|
+
>
|
|
539
|
+
<MaterialCommunityIcons name="open-in-new" size={20} color={theme.colors.textSecondary} />
|
|
540
|
+
</TouchableOpacity>
|
|
541
|
+
</View>
|
|
542
|
+
|
|
543
|
+
{episodesLoading ? (
|
|
544
|
+
<View style={styles.stateBox}>
|
|
545
|
+
<ActivityIndicator size="small" color={theme.colors.primary} />
|
|
546
|
+
</View>
|
|
547
|
+
) : episodesError ? (
|
|
548
|
+
<ErrorState
|
|
549
|
+
theme={theme}
|
|
550
|
+
message={tr('agora.podcastStream.errorEpisodes')}
|
|
551
|
+
retryLabel={tr('agora.podcastStream.retry')}
|
|
552
|
+
onRetry={() => loadEpisodes(selectedShow.syraPodcastId, 0)}
|
|
553
|
+
/>
|
|
554
|
+
) : episodes.length === 0 ? (
|
|
555
|
+
<View style={styles.stateBox}>
|
|
556
|
+
<MaterialCommunityIcons name="playlist-remove" size={32} color={theme.colors.textSecondary} />
|
|
557
|
+
<Text style={[styles.stateText, { color: theme.colors.textSecondary }]}>
|
|
558
|
+
{tr('agora.podcastStream.emptyEpisodes')}
|
|
559
|
+
</Text>
|
|
560
|
+
</View>
|
|
561
|
+
) : (
|
|
562
|
+
<FlatList
|
|
563
|
+
data={episodes}
|
|
564
|
+
keyExtractor={(item) => item.episodeId}
|
|
565
|
+
renderItem={({ item }) => (
|
|
566
|
+
<EpisodeRow
|
|
567
|
+
episode={item}
|
|
568
|
+
theme={theme}
|
|
569
|
+
AvatarComponent={AvatarComponent}
|
|
570
|
+
onSelect={handleSelectEpisode}
|
|
571
|
+
queueEnabled={queueEnabled}
|
|
572
|
+
isQueued={queuedIds.has(item.episodeId)}
|
|
573
|
+
onToggleQueue={handleToggleQueue}
|
|
574
|
+
queueActionLabel={tr(
|
|
575
|
+
queuedIds.has(item.episodeId)
|
|
576
|
+
? 'agora.podcastStream.removeFromQueue'
|
|
577
|
+
: 'agora.podcastStream.addToQueue',
|
|
578
|
+
)}
|
|
579
|
+
/>
|
|
580
|
+
)}
|
|
581
|
+
showsVerticalScrollIndicator={false}
|
|
582
|
+
keyboardShouldPersistTaps="handled"
|
|
583
|
+
onEndReached={handleEpisodesEndReached}
|
|
584
|
+
onEndReachedThreshold={0.4}
|
|
585
|
+
contentContainerStyle={listContentStyle}
|
|
586
|
+
ListFooterComponent={
|
|
587
|
+
episodesLoadingMore ? (
|
|
588
|
+
<View style={styles.footerLoader}>
|
|
589
|
+
<ActivityIndicator size="small" color={theme.colors.primary} />
|
|
590
|
+
</View>
|
|
591
|
+
) : null
|
|
592
|
+
}
|
|
593
|
+
/>
|
|
594
|
+
)}
|
|
595
|
+
{queueFooter}
|
|
596
|
+
</View>
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return (
|
|
601
|
+
<View style={styles.container}>
|
|
602
|
+
<View style={styles.disclaimer}>
|
|
603
|
+
<MaterialCommunityIcons name="shield-alert-outline" size={15} color={theme.colors.textSecondary} />
|
|
604
|
+
<Text style={[styles.disclaimerText, { color: theme.colors.textSecondary }]}>
|
|
605
|
+
{tr('agora.podcastStream.disclaimer')}
|
|
606
|
+
</Text>
|
|
607
|
+
</View>
|
|
608
|
+
|
|
609
|
+
<View style={styles.searchWrap}>
|
|
610
|
+
<View style={[styles.searchRow, { backgroundColor: `${theme.colors.card}80`, borderColor: theme.colors.border }]}>
|
|
611
|
+
<MaterialCommunityIcons name="magnify" size={18} color={theme.colors.textSecondary} />
|
|
612
|
+
<TextInput
|
|
613
|
+
style={[styles.searchInput, { color: theme.colors.text }]}
|
|
614
|
+
placeholder={tr('agora.podcastStream.searchPlaceholder')}
|
|
615
|
+
placeholderTextColor={theme.colors.textSecondary}
|
|
616
|
+
value={query}
|
|
617
|
+
onChangeText={handleQueryChange}
|
|
618
|
+
autoCapitalize="none"
|
|
619
|
+
autoCorrect={false}
|
|
620
|
+
returnKeyType="search"
|
|
621
|
+
/>
|
|
622
|
+
{query.length > 0 && (
|
|
623
|
+
<TouchableOpacity onPress={handleClearQuery} hitSlop={8}>
|
|
624
|
+
<MaterialCommunityIcons name="close-circle" size={18} color={theme.colors.textSecondary} />
|
|
625
|
+
</TouchableOpacity>
|
|
626
|
+
)}
|
|
627
|
+
</View>
|
|
628
|
+
</View>
|
|
629
|
+
|
|
630
|
+
{!hasQuery ? (
|
|
631
|
+
<View>
|
|
632
|
+
{pinnedPodcast && (
|
|
633
|
+
<TouchableOpacity
|
|
634
|
+
style={[styles.pinnedRow, { backgroundColor: `${theme.colors.primary}14`, borderColor: theme.colors.border }]}
|
|
635
|
+
activeOpacity={0.7}
|
|
636
|
+
onPress={handleSelectPinned}
|
|
637
|
+
>
|
|
638
|
+
<View style={[styles.pinnedIcon, { backgroundColor: `${theme.colors.primary}22` }]}>
|
|
639
|
+
<MaterialCommunityIcons name="flash" size={20} color={theme.colors.primary} />
|
|
640
|
+
</View>
|
|
641
|
+
<View style={styles.rowText}>
|
|
642
|
+
<Text style={[styles.rowTitle, { color: theme.colors.text }]} numberOfLines={1}>
|
|
643
|
+
{tr('agora.podcastStream.pinnedTitle')}
|
|
644
|
+
</Text>
|
|
645
|
+
{!!pinnedPodcast.title && (
|
|
646
|
+
<Text style={[styles.rowSubtitle, { color: theme.colors.textSecondary }]} numberOfLines={1}>
|
|
647
|
+
{pinnedPodcast.title}
|
|
648
|
+
</Text>
|
|
649
|
+
)}
|
|
650
|
+
</View>
|
|
651
|
+
<MaterialCommunityIcons name="chevron-right" size={22} color={theme.colors.textSecondary} />
|
|
652
|
+
</TouchableOpacity>
|
|
653
|
+
)}
|
|
654
|
+
<View style={styles.stateBox}>
|
|
655
|
+
<MaterialCommunityIcons name="podcast" size={32} color={theme.colors.textSecondary} />
|
|
656
|
+
<Text style={[styles.stateText, { color: theme.colors.textSecondary }]}>
|
|
657
|
+
{tr('agora.podcastStream.searchHint')}
|
|
658
|
+
</Text>
|
|
659
|
+
</View>
|
|
660
|
+
</View>
|
|
661
|
+
) : showsError ? (
|
|
662
|
+
<ErrorState
|
|
663
|
+
theme={theme}
|
|
664
|
+
message={tr('agora.podcastStream.errorShows')}
|
|
665
|
+
retryLabel={tr('agora.podcastStream.retry')}
|
|
666
|
+
onRetry={() => runSearch(query, 0)}
|
|
667
|
+
/>
|
|
668
|
+
) : showsLoading ? (
|
|
669
|
+
<View style={styles.stateBox}>
|
|
670
|
+
<ActivityIndicator size="small" color={theme.colors.primary} />
|
|
671
|
+
</View>
|
|
672
|
+
) : shows.length === 0 ? (
|
|
673
|
+
<View style={styles.stateBox}>
|
|
674
|
+
<MaterialCommunityIcons name="magnify-close" size={32} color={theme.colors.textSecondary} />
|
|
675
|
+
<Text style={[styles.stateText, { color: theme.colors.textSecondary }]}>
|
|
676
|
+
{tr('agora.podcastStream.emptyShows')}
|
|
677
|
+
</Text>
|
|
678
|
+
</View>
|
|
679
|
+
) : (
|
|
680
|
+
<FlatList
|
|
681
|
+
data={shows}
|
|
682
|
+
keyExtractor={(item) => item.syraPodcastId}
|
|
683
|
+
renderItem={({ item }) => (
|
|
684
|
+
<ShowRow show={item} theme={theme} AvatarComponent={AvatarComponent} onSelect={handleSelectShow} />
|
|
685
|
+
)}
|
|
686
|
+
showsVerticalScrollIndicator={false}
|
|
687
|
+
keyboardShouldPersistTaps="handled"
|
|
688
|
+
onEndReached={handleShowsEndReached}
|
|
689
|
+
onEndReachedThreshold={0.4}
|
|
690
|
+
contentContainerStyle={listContentStyle}
|
|
691
|
+
ListFooterComponent={
|
|
692
|
+
showsLoadingMore ? (
|
|
693
|
+
<View style={styles.footerLoader}>
|
|
694
|
+
<ActivityIndicator size="small" color={theme.colors.primary} />
|
|
695
|
+
</View>
|
|
696
|
+
) : null
|
|
697
|
+
}
|
|
698
|
+
/>
|
|
699
|
+
)}
|
|
700
|
+
{queueFooter}
|
|
701
|
+
</View>
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const MusicTrackRow = memo(function MusicTrackRow({
|
|
706
|
+
track,
|
|
707
|
+
theme,
|
|
708
|
+
AvatarComponent,
|
|
709
|
+
onSelect,
|
|
710
|
+
isQueued,
|
|
711
|
+
onToggleQueue,
|
|
712
|
+
queueActionLabel,
|
|
713
|
+
}: {
|
|
714
|
+
track: MusicTrackResult;
|
|
715
|
+
theme: LiveTheme;
|
|
716
|
+
AvatarComponent: AvatarComponentType;
|
|
717
|
+
onSelect: (track: MusicTrackResult) => void;
|
|
718
|
+
isQueued: boolean;
|
|
719
|
+
onToggleQueue: (track: MusicTrackResult) => void;
|
|
720
|
+
queueActionLabel: string;
|
|
721
|
+
}) {
|
|
722
|
+
const meta = [track.artist, formatDuration(track.durationSec)].filter(Boolean).join(' · ');
|
|
723
|
+
|
|
724
|
+
return (
|
|
725
|
+
<View style={styles.row}>
|
|
726
|
+
<TouchableOpacity style={styles.rowMain} activeOpacity={0.7} onPress={() => onSelect(track)}>
|
|
727
|
+
<AvatarComponent size={44} source={track.artworkUrl} shape="squircle" />
|
|
728
|
+
<View style={styles.rowText}>
|
|
729
|
+
<Text style={[styles.rowTitle, { color: theme.colors.text }]} numberOfLines={2}>
|
|
730
|
+
{track.title}
|
|
731
|
+
</Text>
|
|
732
|
+
{!!meta && (
|
|
733
|
+
<Text style={[styles.rowSubtitle, { color: theme.colors.textSecondary }]} numberOfLines={1}>
|
|
734
|
+
{meta}
|
|
735
|
+
</Text>
|
|
736
|
+
)}
|
|
737
|
+
</View>
|
|
738
|
+
<MaterialCommunityIcons name="play-circle" size={26} color={theme.colors.primary} />
|
|
739
|
+
</TouchableOpacity>
|
|
740
|
+
<TouchableOpacity
|
|
741
|
+
style={styles.queueBtn}
|
|
742
|
+
activeOpacity={0.7}
|
|
743
|
+
hitSlop={8}
|
|
744
|
+
onPress={() => onToggleQueue(track)}
|
|
745
|
+
accessibilityRole="button"
|
|
746
|
+
accessibilityLabel={queueActionLabel}
|
|
747
|
+
>
|
|
748
|
+
<MaterialCommunityIcons
|
|
749
|
+
name={isQueued ? 'check-circle' : 'plus-circle-outline'}
|
|
750
|
+
size={24}
|
|
751
|
+
color={isQueued ? theme.colors.primary : theme.colors.textSecondary}
|
|
752
|
+
/>
|
|
753
|
+
</TouchableOpacity>
|
|
754
|
+
</View>
|
|
755
|
+
);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
interface MusicPickerProps {
|
|
759
|
+
/** Stream a single track immediately. */
|
|
760
|
+
onSelectTrack: (trackId: string) => void;
|
|
761
|
+
/**
|
|
762
|
+
* Start a multi-track session: the first track plays immediately, the rest
|
|
763
|
+
* become the room's up-next queue. Optional — hosts that omit it get the
|
|
764
|
+
* single-tap "play this now" flow only, and the queue affordances are hidden.
|
|
765
|
+
*/
|
|
766
|
+
onStartQueue?: (trackIds: string[]) => void;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Self-contained single-level music picker for the live-room "listening party".
|
|
771
|
+
* A debounced Syra catalog search; tapping a track reports its `trackId` to the
|
|
772
|
+
* parent (the picker owns NO room/stream logic and never sees an audio URL — the
|
|
773
|
+
* backend resolves the playable source server-side). Ships inside the shared
|
|
774
|
+
* package, so it depends only on `useLiveConfig()` injection and never imports
|
|
775
|
+
* from a host app (`@/`).
|
|
776
|
+
*
|
|
777
|
+
* Mirrors {@link PodcastStreamPicker}: a host copyright/broadcast disclaimer, an
|
|
778
|
+
* honest error+Retry state distinct from empty results, and an optional up-next
|
|
779
|
+
* queue (per-row "+" plus a floating "Play (N)" footer).
|
|
780
|
+
*/
|
|
781
|
+
export function MusicPicker({ onSelectTrack, onStartQueue }: MusicPickerProps) {
|
|
782
|
+
const { useTheme, roomsService, AvatarComponent, t } = useLiveConfig();
|
|
783
|
+
const theme = useTheme();
|
|
784
|
+
|
|
785
|
+
const tr = useCallback<TranslateFn>((key) => (t ? t(key) : DEFAULT_STRINGS[key] ?? key), [t]);
|
|
786
|
+
|
|
787
|
+
const queueEnabled = !!onStartQueue;
|
|
788
|
+
const [queued, setQueued] = useState<string[]>([]);
|
|
789
|
+
const queuedIds = useMemo(() => new Set(queued), [queued]);
|
|
790
|
+
|
|
791
|
+
const [query, setQuery] = useState('');
|
|
792
|
+
const [tracks, setTracks] = useState<MusicTrackResult[]>([]);
|
|
793
|
+
const [nextOffset, setNextOffset] = useState(0);
|
|
794
|
+
const [hasMore, setHasMore] = useState(false);
|
|
795
|
+
const [loading, setLoading] = useState(false);
|
|
796
|
+
const [loadingMore, setLoadingMore] = useState(false);
|
|
797
|
+
const [error, setError] = useState(false);
|
|
798
|
+
const seqRef = useRef(0);
|
|
799
|
+
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
800
|
+
|
|
801
|
+
const runSearch = useCallback(
|
|
802
|
+
async (rawQuery: string, offset: number) => {
|
|
803
|
+
const trimmed = rawQuery.trim();
|
|
804
|
+
if (!trimmed) {
|
|
805
|
+
seqRef.current += 1;
|
|
806
|
+
setTracks([]);
|
|
807
|
+
setHasMore(false);
|
|
808
|
+
setNextOffset(0);
|
|
809
|
+
setLoading(false);
|
|
810
|
+
setLoadingMore(false);
|
|
811
|
+
setError(false);
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
const seq = offset === 0 ? (seqRef.current += 1) : seqRef.current;
|
|
815
|
+
if (offset === 0) {
|
|
816
|
+
setLoading(true);
|
|
817
|
+
setError(false);
|
|
818
|
+
} else {
|
|
819
|
+
setLoadingMore(true);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const result = await roomsService.searchMusic(trimmed, offset);
|
|
823
|
+
if (seq !== seqRef.current) return; // superseded by a newer query
|
|
824
|
+
|
|
825
|
+
if (!result.ok) {
|
|
826
|
+
if (offset === 0) {
|
|
827
|
+
setTracks([]);
|
|
828
|
+
setError(true);
|
|
829
|
+
}
|
|
830
|
+
setHasMore(false);
|
|
831
|
+
setLoading(false);
|
|
832
|
+
setLoadingMore(false);
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
setTracks((prev) => (offset === 0 ? result.items : [...prev, ...result.items]));
|
|
837
|
+
setHasMore(result.hasMore);
|
|
838
|
+
setNextOffset(result.offset + result.items.length);
|
|
839
|
+
setLoading(false);
|
|
840
|
+
setLoadingMore(false);
|
|
841
|
+
},
|
|
842
|
+
[roomsService],
|
|
843
|
+
);
|
|
844
|
+
|
|
845
|
+
const handleQueryChange = useCallback(
|
|
846
|
+
(text: string) => {
|
|
847
|
+
setQuery(text);
|
|
848
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
849
|
+
if (text.trim()) {
|
|
850
|
+
setLoading(true);
|
|
851
|
+
setError(false);
|
|
852
|
+
}
|
|
853
|
+
debounceRef.current = setTimeout(() => {
|
|
854
|
+
runSearch(text, 0);
|
|
855
|
+
}, SEARCH_DEBOUNCE_MS);
|
|
856
|
+
},
|
|
857
|
+
[runSearch],
|
|
858
|
+
);
|
|
859
|
+
|
|
860
|
+
const handleClearQuery = useCallback(() => {
|
|
861
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
862
|
+
setQuery('');
|
|
863
|
+
runSearch('', 0);
|
|
864
|
+
}, [runSearch]);
|
|
865
|
+
|
|
866
|
+
const handleEndReached = useCallback(() => {
|
|
867
|
+
if (loading || loadingMore || !hasMore) return;
|
|
868
|
+
runSearch(query, nextOffset);
|
|
869
|
+
}, [loading, loadingMore, hasMore, runSearch, query, nextOffset]);
|
|
870
|
+
|
|
871
|
+
const handleSelectTrack = useCallback(
|
|
872
|
+
(track: MusicTrackResult) => onSelectTrack(track.trackId),
|
|
873
|
+
[onSelectTrack],
|
|
874
|
+
);
|
|
875
|
+
|
|
876
|
+
const handleToggleQueue = useCallback((track: MusicTrackResult) => {
|
|
877
|
+
setQueued((prev) =>
|
|
878
|
+
prev.includes(track.trackId)
|
|
879
|
+
? prev.filter((id) => id !== track.trackId)
|
|
880
|
+
: [...prev, track.trackId],
|
|
881
|
+
);
|
|
882
|
+
}, []);
|
|
883
|
+
|
|
884
|
+
const handleClearQueue = useCallback(() => setQueued([]), []);
|
|
885
|
+
|
|
886
|
+
const handleStartQueue = useCallback(() => {
|
|
887
|
+
if (queued.length === 0) return;
|
|
888
|
+
onStartQueue?.(queued);
|
|
889
|
+
setQueued([]);
|
|
890
|
+
}, [queued, onStartQueue]);
|
|
891
|
+
|
|
892
|
+
// Cancel any pending debounced search when the picker unmounts.
|
|
893
|
+
useEffect(() => {
|
|
894
|
+
return () => {
|
|
895
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
896
|
+
};
|
|
897
|
+
}, []);
|
|
898
|
+
|
|
899
|
+
const hasQuery = query.trim().length > 0;
|
|
900
|
+
const showQueueFooter = queueEnabled && queued.length > 0;
|
|
901
|
+
const listContentStyle = showQueueFooter
|
|
902
|
+
? [styles.listContent, styles.listContentWithFooter]
|
|
903
|
+
: styles.listContent;
|
|
904
|
+
|
|
905
|
+
const queueFooter = showQueueFooter ? (
|
|
906
|
+
<View style={[styles.queueFooter, { backgroundColor: theme.colors.background, borderTopColor: theme.colors.border }]}>
|
|
907
|
+
<TouchableOpacity
|
|
908
|
+
style={styles.queueClearBtn}
|
|
909
|
+
activeOpacity={0.7}
|
|
910
|
+
hitSlop={8}
|
|
911
|
+
onPress={handleClearQueue}
|
|
912
|
+
accessibilityRole="button"
|
|
913
|
+
>
|
|
914
|
+
<Text style={[styles.queueClearText, { color: theme.colors.textSecondary }]}>
|
|
915
|
+
{tr('agora.musicStream.clearQueue')}
|
|
916
|
+
</Text>
|
|
917
|
+
</TouchableOpacity>
|
|
918
|
+
<TouchableOpacity
|
|
919
|
+
style={[styles.queuePlayBtn, { backgroundColor: theme.colors.primary }]}
|
|
920
|
+
activeOpacity={0.85}
|
|
921
|
+
onPress={handleStartQueue}
|
|
922
|
+
accessibilityRole="button"
|
|
923
|
+
>
|
|
924
|
+
<MaterialCommunityIcons name="play" size={18} color="#FFFFFF" />
|
|
925
|
+
<Text style={styles.queuePlayText}>
|
|
926
|
+
{tr('agora.musicStream.playQueue')} ({queued.length})
|
|
927
|
+
</Text>
|
|
928
|
+
</TouchableOpacity>
|
|
929
|
+
</View>
|
|
930
|
+
) : null;
|
|
931
|
+
|
|
932
|
+
return (
|
|
933
|
+
<View style={styles.container}>
|
|
934
|
+
<View style={styles.disclaimer}>
|
|
935
|
+
<MaterialCommunityIcons name="shield-alert-outline" size={15} color={theme.colors.textSecondary} />
|
|
936
|
+
<Text style={[styles.disclaimerText, { color: theme.colors.textSecondary }]}>
|
|
937
|
+
{tr('agora.musicStream.disclaimer')}
|
|
938
|
+
</Text>
|
|
939
|
+
</View>
|
|
940
|
+
|
|
941
|
+
<View style={styles.searchWrap}>
|
|
942
|
+
<View style={[styles.searchRow, { backgroundColor: `${theme.colors.card}80`, borderColor: theme.colors.border }]}>
|
|
943
|
+
<MaterialCommunityIcons name="magnify" size={18} color={theme.colors.textSecondary} />
|
|
944
|
+
<TextInput
|
|
945
|
+
style={[styles.searchInput, { color: theme.colors.text }]}
|
|
946
|
+
placeholder={tr('agora.musicStream.searchPlaceholder')}
|
|
947
|
+
placeholderTextColor={theme.colors.textSecondary}
|
|
948
|
+
value={query}
|
|
949
|
+
onChangeText={handleQueryChange}
|
|
950
|
+
autoCapitalize="none"
|
|
951
|
+
autoCorrect={false}
|
|
952
|
+
returnKeyType="search"
|
|
953
|
+
/>
|
|
954
|
+
{query.length > 0 && (
|
|
955
|
+
<TouchableOpacity onPress={handleClearQuery} hitSlop={8}>
|
|
956
|
+
<MaterialCommunityIcons name="close-circle" size={18} color={theme.colors.textSecondary} />
|
|
957
|
+
</TouchableOpacity>
|
|
958
|
+
)}
|
|
959
|
+
</View>
|
|
960
|
+
</View>
|
|
961
|
+
|
|
962
|
+
{!hasQuery ? (
|
|
963
|
+
<View style={styles.stateBox}>
|
|
964
|
+
<MaterialCommunityIcons name="music-note" size={32} color={theme.colors.textSecondary} />
|
|
965
|
+
<Text style={[styles.stateText, { color: theme.colors.textSecondary }]}>
|
|
966
|
+
{tr('agora.musicStream.searchHint')}
|
|
967
|
+
</Text>
|
|
968
|
+
</View>
|
|
969
|
+
) : error ? (
|
|
970
|
+
<ErrorState
|
|
971
|
+
theme={theme}
|
|
972
|
+
message={tr('agora.musicStream.error')}
|
|
973
|
+
retryLabel={tr('agora.musicStream.retry')}
|
|
974
|
+
onRetry={() => runSearch(query, 0)}
|
|
975
|
+
/>
|
|
976
|
+
) : loading ? (
|
|
977
|
+
<View style={styles.stateBox}>
|
|
978
|
+
<ActivityIndicator size="small" color={theme.colors.primary} />
|
|
979
|
+
</View>
|
|
980
|
+
) : tracks.length === 0 ? (
|
|
981
|
+
<View style={styles.stateBox}>
|
|
982
|
+
<MaterialCommunityIcons name="magnify-close" size={32} color={theme.colors.textSecondary} />
|
|
983
|
+
<Text style={[styles.stateText, { color: theme.colors.textSecondary }]}>
|
|
984
|
+
{tr('agora.musicStream.empty')}
|
|
985
|
+
</Text>
|
|
986
|
+
</View>
|
|
987
|
+
) : (
|
|
988
|
+
<FlatList
|
|
989
|
+
data={tracks}
|
|
990
|
+
keyExtractor={(item) => item.trackId}
|
|
991
|
+
renderItem={({ item }) => (
|
|
992
|
+
<MusicTrackRow
|
|
993
|
+
track={item}
|
|
994
|
+
theme={theme}
|
|
995
|
+
AvatarComponent={AvatarComponent}
|
|
996
|
+
onSelect={handleSelectTrack}
|
|
997
|
+
isQueued={queuedIds.has(item.trackId)}
|
|
998
|
+
onToggleQueue={handleToggleQueue}
|
|
999
|
+
queueActionLabel={tr(
|
|
1000
|
+
queuedIds.has(item.trackId)
|
|
1001
|
+
? 'agora.musicStream.removeFromQueue'
|
|
1002
|
+
: 'agora.musicStream.addToQueue',
|
|
1003
|
+
)}
|
|
1004
|
+
/>
|
|
1005
|
+
)}
|
|
1006
|
+
showsVerticalScrollIndicator={false}
|
|
1007
|
+
keyboardShouldPersistTaps="handled"
|
|
1008
|
+
onEndReached={handleEndReached}
|
|
1009
|
+
onEndReachedThreshold={0.4}
|
|
1010
|
+
contentContainerStyle={listContentStyle}
|
|
1011
|
+
ListFooterComponent={
|
|
1012
|
+
loadingMore ? (
|
|
1013
|
+
<View style={styles.footerLoader}>
|
|
1014
|
+
<ActivityIndicator size="small" color={theme.colors.primary} />
|
|
1015
|
+
</View>
|
|
1016
|
+
) : null
|
|
1017
|
+
}
|
|
1018
|
+
/>
|
|
1019
|
+
)}
|
|
1020
|
+
{queueFooter}
|
|
1021
|
+
</View>
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
const styles = StyleSheet.create({
|
|
1026
|
+
container: { flex: 1 },
|
|
1027
|
+
disclaimer: {
|
|
1028
|
+
flexDirection: 'row',
|
|
1029
|
+
alignItems: 'flex-start',
|
|
1030
|
+
gap: 8,
|
|
1031
|
+
paddingHorizontal: 16,
|
|
1032
|
+
paddingTop: 12,
|
|
1033
|
+
},
|
|
1034
|
+
disclaimerText: { flex: 1, fontSize: 12, lineHeight: 16 },
|
|
1035
|
+
searchWrap: {
|
|
1036
|
+
paddingHorizontal: 16,
|
|
1037
|
+
paddingTop: 12,
|
|
1038
|
+
paddingBottom: 8,
|
|
1039
|
+
},
|
|
1040
|
+
searchRow: {
|
|
1041
|
+
flexDirection: 'row',
|
|
1042
|
+
alignItems: 'center',
|
|
1043
|
+
gap: 8,
|
|
1044
|
+
borderWidth: 1,
|
|
1045
|
+
borderRadius: 10,
|
|
1046
|
+
paddingHorizontal: 12,
|
|
1047
|
+
paddingVertical: 10,
|
|
1048
|
+
},
|
|
1049
|
+
searchInput: {
|
|
1050
|
+
flex: 1,
|
|
1051
|
+
fontSize: 15,
|
|
1052
|
+
padding: 0,
|
|
1053
|
+
},
|
|
1054
|
+
backHeader: {
|
|
1055
|
+
flexDirection: 'row',
|
|
1056
|
+
alignItems: 'center',
|
|
1057
|
+
paddingHorizontal: 12,
|
|
1058
|
+
paddingTop: 12,
|
|
1059
|
+
paddingBottom: 8,
|
|
1060
|
+
},
|
|
1061
|
+
backHeaderMain: {
|
|
1062
|
+
flex: 1,
|
|
1063
|
+
flexDirection: 'row',
|
|
1064
|
+
alignItems: 'center',
|
|
1065
|
+
gap: 2,
|
|
1066
|
+
},
|
|
1067
|
+
backTitle: {
|
|
1068
|
+
flex: 1,
|
|
1069
|
+
fontSize: 16,
|
|
1070
|
+
fontWeight: '600',
|
|
1071
|
+
},
|
|
1072
|
+
openBtn: { padding: 6, marginLeft: 4 },
|
|
1073
|
+
listContent: { paddingBottom: 24 },
|
|
1074
|
+
listContentWithFooter: { paddingBottom: 88 },
|
|
1075
|
+
row: {
|
|
1076
|
+
flexDirection: 'row',
|
|
1077
|
+
alignItems: 'center',
|
|
1078
|
+
gap: 12,
|
|
1079
|
+
paddingHorizontal: 16,
|
|
1080
|
+
paddingVertical: 10,
|
|
1081
|
+
},
|
|
1082
|
+
rowMain: {
|
|
1083
|
+
flex: 1,
|
|
1084
|
+
flexDirection: 'row',
|
|
1085
|
+
alignItems: 'center',
|
|
1086
|
+
gap: 12,
|
|
1087
|
+
},
|
|
1088
|
+
queueBtn: { padding: 4 },
|
|
1089
|
+
pinnedRow: {
|
|
1090
|
+
flexDirection: 'row',
|
|
1091
|
+
alignItems: 'center',
|
|
1092
|
+
gap: 12,
|
|
1093
|
+
marginHorizontal: 16,
|
|
1094
|
+
marginTop: 4,
|
|
1095
|
+
marginBottom: 8,
|
|
1096
|
+
paddingHorizontal: 12,
|
|
1097
|
+
paddingVertical: 10,
|
|
1098
|
+
borderWidth: 1,
|
|
1099
|
+
borderRadius: 12,
|
|
1100
|
+
},
|
|
1101
|
+
pinnedIcon: {
|
|
1102
|
+
width: 44,
|
|
1103
|
+
height: 44,
|
|
1104
|
+
borderRadius: 12,
|
|
1105
|
+
alignItems: 'center',
|
|
1106
|
+
justifyContent: 'center',
|
|
1107
|
+
},
|
|
1108
|
+
rowText: { flex: 1, gap: 2 },
|
|
1109
|
+
rowTitle: { fontSize: 15, fontWeight: '600' },
|
|
1110
|
+
rowSubtitle: { fontSize: 13 },
|
|
1111
|
+
stateBox: {
|
|
1112
|
+
alignItems: 'center',
|
|
1113
|
+
justifyContent: 'center',
|
|
1114
|
+
gap: 10,
|
|
1115
|
+
paddingVertical: 40,
|
|
1116
|
+
},
|
|
1117
|
+
stateText: { fontSize: 14, textAlign: 'center', paddingHorizontal: 32 },
|
|
1118
|
+
retryBtn: {
|
|
1119
|
+
flexDirection: 'row',
|
|
1120
|
+
alignItems: 'center',
|
|
1121
|
+
gap: 6,
|
|
1122
|
+
borderWidth: 1,
|
|
1123
|
+
borderRadius: 10,
|
|
1124
|
+
paddingHorizontal: 16,
|
|
1125
|
+
paddingVertical: 8,
|
|
1126
|
+
marginTop: 4,
|
|
1127
|
+
},
|
|
1128
|
+
retryText: { fontSize: 14, fontWeight: '600' },
|
|
1129
|
+
footerLoader: { paddingVertical: 16 },
|
|
1130
|
+
queueFooter: {
|
|
1131
|
+
position: 'absolute',
|
|
1132
|
+
left: 0,
|
|
1133
|
+
right: 0,
|
|
1134
|
+
bottom: 0,
|
|
1135
|
+
flexDirection: 'row',
|
|
1136
|
+
alignItems: 'center',
|
|
1137
|
+
gap: 12,
|
|
1138
|
+
paddingHorizontal: 16,
|
|
1139
|
+
paddingTop: 12,
|
|
1140
|
+
paddingBottom: 16,
|
|
1141
|
+
borderTopWidth: 1,
|
|
1142
|
+
},
|
|
1143
|
+
queueClearBtn: { paddingVertical: 8, paddingHorizontal: 4 },
|
|
1144
|
+
queueClearText: { fontSize: 14, fontWeight: '600' },
|
|
1145
|
+
queuePlayBtn: {
|
|
1146
|
+
flex: 1,
|
|
1147
|
+
flexDirection: 'row',
|
|
1148
|
+
alignItems: 'center',
|
|
1149
|
+
justifyContent: 'center',
|
|
1150
|
+
gap: 8,
|
|
1151
|
+
paddingVertical: 12,
|
|
1152
|
+
borderRadius: 12,
|
|
1153
|
+
},
|
|
1154
|
+
queuePlayText: { color: '#FFFFFF', fontSize: 15, fontWeight: '700' },
|
|
1155
|
+
});
|