From 33a98d34ce72772eb18d50eb2385a3afa830f403 Mon Sep 17 00:00:00 2001 From: Natalie Bridgers Date: Wed, 29 Oct 2025 17:07:16 -0500 Subject: [PATCH] Add settings to 'settings' --- js/app/components/settings/settings.tsx | 411 +++++++++++++++++- .../src/components/danmu/danmu-settings.tsx | 180 -------- .../mobile-player/fullscreen.native.tsx | 17 +- .../components/mobile-player/fullscreen.tsx | 21 +- js/components/src/index.tsx | 1 - .../src/player-store/player-state.tsx | 10 - .../src/player-store/player-store.tsx | 11 - .../streamplace-store/streamplace-store.tsx | 160 ++++++- 8 files changed, 598 insertions(+), 213 deletions(-) delete mode 100644 js/components/src/components/danmu/danmu-settings.tsx diff --git a/js/app/components/settings/settings.tsx b/js/app/components/settings/settings.tsx index e244e5ca4..c6c4d9212 100644 --- a/js/app/components/settings/settings.tsx +++ b/js/app/components/settings/settings.tsx @@ -1,4 +1,14 @@ -import { Button, Input, Text, View, zero } from "@streamplace/components"; +import { useNavigation } from "@react-navigation/native"; +import { + Button, + Input, + Slider, + useToast, + Text, + View, + zero, +} from "@streamplace/components"; +import { useDanmuSettings } from "@streamplace/components/src/streamplace-store"; import AQLink from "components/aqlink"; import { createServerSettingsRecord, @@ -73,7 +83,7 @@ export function Settings() { { alignItems: "stretch" }, zero.layout.flex.justify.start, zero.w.percent[100], - zero.gap.all[4], + zero.gap.all[6], ]} > → + + @@ -259,3 +271,398 @@ const DebugRecording = () => { ); }; + +const DanmuSettings = () => { + const { + danmuEnabled, + danmuOpacity, + danmuSpeed, + danmuLaneCount, + danmuMaxMessages, + setDanmuEnabled, + setDanmuOpacity, + setDanmuSpeed, + setDanmuLaneCount, + setDanmuMaxMessages, + } = useDanmuSettings(); + + return ( + + + + Enable Danmaku + + Show "bullet comments" flying across the video. + + + + + + {danmuEnabled && ( + <> + + + + Opacity: + { + const val = parseInt(text) || 0; + setDanmuOpacity(Math.min(100, Math.max(0, val))); + }} + containerStyle={{ width: 60 }} + keyboardType="number-pad" + /> + % + + + {[0, 25, 50, 75, 100].map((value) => ( + + ))} + + + setDanmuOpacity(vals[0])} + style={{ width: "100%", height: 40 }} + > + + + + + + + + + + + Speed: + { + const val = parseFloat(text) || 0; + setDanmuSpeed(Math.min(3, Math.max(0.1, val))); + }} + containerStyle={{ width: 60 }} + keyboardType="numeric" + /> + × + + + {[ + { label: "0.5×", value: 0.5 }, + { label: "1×", value: 1 }, + { label: "1.5×", value: 1.5 }, + { label: "2×", value: 2 }, + ].map(({ label, value }) => ( + + ))} + + + setDanmuSpeed(vals[0])} + style={{ width: "100%", height: 40 }} + > + + + + + + + + + + + Lanes: + { + const val = parseInt(text) || 0; + setDanmuLaneCount(Math.min(20, Math.max(4, val))); + }} + containerStyle={{ width: 60 }} + keyboardType="number-pad" + /> + + + {[6, 8, 10, 12, 15].map((value) => ( + + ))} + + + setDanmuLaneCount(vals[0])} + style={{ width: "100%", height: 40 }} + > + + + + + + + + + + + Max Messages: + { + const val = parseInt(text) || 0; + setDanmuMaxMessages(Math.min(200, Math.max(5, val))); + }} + containerStyle={{ width: 60 }} + keyboardType="number-pad" + /> + + + {[10, 25, 50, 100].map((value) => ( + + ))} + + + setDanmuMaxMessages(vals[0])} + style={{ width: "100%", height: 40 }} + > + + + + + + + + )} + + ); +}; + +const styles = StyleSheet.create({ + buttonRow: { + flexDirection: "row", + gap: 8, + flexWrap: "wrap", + justifyContent: "flex-end", + }, + optionButton: { + paddingHorizontal: 16, + paddingVertical: 8, + backgroundColor: "#374151", + borderRadius: 6, + minWidth: 50, + alignItems: "center", + }, + optionButtonActive: { + backgroundColor: "#3b82f6", + }, + optionButtonText: { + color: "#9ca3af", + }, + optionButtonTextActive: { + color: "white", + }, +}); diff --git a/js/components/src/components/danmu/danmu-settings.tsx b/js/components/src/components/danmu/danmu-settings.tsx deleted file mode 100644 index cd572a0b4..000000000 --- a/js/components/src/components/danmu/danmu-settings.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { Settings } from "lucide-react-native"; -import { useState } from "react"; -import { Modal, Pressable, StyleSheet, Switch } from "react-native"; -import { usePlayerStore } from "../../player-store"; -import { Text, View } from "../ui"; - -export function DanmuSettings() { - const [isOpen, setIsOpen] = useState(false); - - const danmuEnabled = usePlayerStore((x) => x.danmuEnabled); - const setDanmuEnabled = usePlayerStore((x) => x.setDanmuEnabled); - - const danmuOpacity = usePlayerStore((x) => x.danmuOpacity); - const setDanmuOpacity = usePlayerStore((x) => x.setDanmuOpacity); - - const danmuSpeed = usePlayerStore((x) => x.danmuSpeed); - const setDanmuSpeed = usePlayerStore((x) => x.setDanmuSpeed); - - return ( - <> - setIsOpen(true)} style={styles.triggerButton}> - - - - setIsOpen(false)} - > - setIsOpen(false)}> - e.stopPropagation()}> - Danmu Settings - - - Enable Danmu - - - - - Opacity: {danmuOpacity}% - - {[0, 25, 50, 75, 100].map((value) => ( - setDanmuOpacity(value)} - style={[ - styles.sliderButton, - danmuOpacity === value && styles.sliderButtonActive, - ]} - > - - {value} - - - ))} - - - - - Speed - - {[ - { label: "0.5×", value: 0.5 }, - { label: "1×", value: 1 }, - { label: "1.5×", value: 1.5 }, - { label: "2×", value: 2 }, - ].map(({ label, value }) => ( - setDanmuSpeed(value)} - style={[ - styles.sliderButton, - danmuSpeed === value && styles.sliderButtonActive, - ]} - > - - {label} - - - ))} - - - - setIsOpen(false)} - style={styles.closeButton} - > - Close - - - - - - ); -} - -const styles = StyleSheet.create({ - triggerButton: { - padding: 8, - backgroundColor: "rgba(0, 0, 0, 0.5)", - borderRadius: 4, - }, - overlay: { - flex: 1, - backgroundColor: "rgba(0, 0, 0, 0.7)", - justifyContent: "center", - alignItems: "center", - }, - modal: { - backgroundColor: "#1f2937", - borderRadius: 12, - padding: 24, - width: "90%", - maxWidth: 400, - gap: 16, - }, - title: { - fontSize: 20, - fontWeight: "bold", - color: "white", - marginBottom: 8, - }, - setting: { - gap: 8, - }, - label: { - fontSize: 14, - color: "#d1d5db", - fontWeight: "600", - }, - sliderContainer: { - flexDirection: "row", - gap: 8, - flexWrap: "wrap", - }, - sliderButton: { - paddingHorizontal: 16, - paddingVertical: 8, - backgroundColor: "#374151", - borderRadius: 6, - minWidth: 50, - alignItems: "center", - }, - sliderButtonActive: { - backgroundColor: "#3b82f6", - }, - sliderButtonText: { - color: "#9ca3af", - fontSize: 14, - fontWeight: "600", - }, - sliderButtonTextActive: { - color: "white", - }, - closeButton: { - marginTop: 8, - padding: 12, - backgroundColor: "#374151", - borderRadius: 8, - alignItems: "center", - }, - closeButtonText: { - color: "white", - fontSize: 16, - fontWeight: "600", - }, -}); diff --git a/js/components/src/components/mobile-player/fullscreen.native.tsx b/js/components/src/components/mobile-player/fullscreen.native.tsx index e1e9ac617..a997c4c35 100644 --- a/js/components/src/components/mobile-player/fullscreen.native.tsx +++ b/js/components/src/components/mobile-player/fullscreen.native.tsx @@ -6,6 +6,11 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { DanmuOverlay, PlayerProtocol, + useDanmuEnabled, + useDanmuLaneCount, + useDanmuMaxMessages, + useDanmuOpacity, + useDanmuSpeed, useLivestreamStore, usePlayerStore, VideoRetry, @@ -31,9 +36,11 @@ export function Fullscreen(props: { const setFullscreen = usePlayerStore((x) => x.setFullscreen); const handle = useLivestreamStore((x) => x.profile?.handle); - const danmuEnabled = usePlayerStore((x) => x.danmuEnabled); - const danmuOpacity = usePlayerStore((x) => x.danmuOpacity); - const danmuSpeed = usePlayerStore((x) => x.danmuSpeed); + const danmuEnabled = useDanmuEnabled(); + const danmuOpacity = useDanmuOpacity(); + const danmuSpeed = useDanmuSpeed(); + const danmuLaneCount = useDanmuLaneCount(); + const danmuMaxMessages = useDanmuMaxMessages(); const setSrc = usePlayerStore((x) => x.setSrc); @@ -163,6 +170,8 @@ export function Fullscreen(props: { enabled={danmuEnabled} opacity={danmuOpacity} speed={danmuSpeed} + laneCount={danmuLaneCount} + maxMessages={danmuMaxMessages} /> {props.children} @@ -183,6 +192,8 @@ export function Fullscreen(props: { enabled={danmuEnabled} opacity={danmuOpacity} speed={danmuSpeed} + laneCount={danmuLaneCount} + maxMessages={danmuMaxMessages} /> {props.children} diff --git a/js/components/src/components/mobile-player/fullscreen.tsx b/js/components/src/components/mobile-player/fullscreen.tsx index f6a17d76e..4aa3c2849 100644 --- a/js/components/src/components/mobile-player/fullscreen.tsx +++ b/js/components/src/components/mobile-player/fullscreen.tsx @@ -1,6 +1,15 @@ import { useEffect, useRef } from "react"; import { View as RNView } from "react-native"; -import { DanmuOverlay, getFirstPlayerID, usePlayerStore } from "../.."; +import { + DanmuOverlay, + getFirstPlayerID, + useDanmuEnabled, + useDanmuLaneCount, + useDanmuMaxMessages, + useDanmuOpacity, + useDanmuSpeed, + usePlayerStore, +} from "../.."; import { View } from "../../components/ui"; import Video from "./video"; import VideoRetry from "./video-retry"; @@ -18,9 +27,11 @@ export function Fullscreen(props: { const setSrc = usePlayerStore((x) => x.setSrc); const setAutoplayFailed = usePlayerStore((x) => x.setAutoplayFailed); - const danmuEnabled = usePlayerStore((x) => x.danmuEnabled); - const danmuOpacity = usePlayerStore((x) => x.danmuOpacity); - const danmuSpeed = usePlayerStore((x) => x.danmuSpeed); + const danmuEnabled = useDanmuEnabled(); + const danmuOpacity = useDanmuOpacity(); + const danmuSpeed = useDanmuSpeed(); + const danmuLaneCount = useDanmuLaneCount(); + const danmuMaxMessages = useDanmuMaxMessages(); const divRef = useRef(null); const videoRef = useRef(null); @@ -98,6 +109,8 @@ export function Fullscreen(props: { enabled={danmuEnabled} opacity={danmuOpacity} speed={danmuSpeed} + laneCount={danmuLaneCount} + maxMessages={danmuMaxMessages} /> {props.children} diff --git a/js/components/src/index.tsx b/js/components/src/index.tsx index 382fe6e0b..f26efd6f0 100644 --- a/js/components/src/index.tsx +++ b/js/components/src/index.tsx @@ -38,7 +38,6 @@ export { default as VideoRetry } from "./components/mobile-player/video-retry"; export * from "./lib/system-messages"; export { DanmuOverlay } from "./components/danmu/danmu-overlay"; -export { DanmuSettings } from "./components/danmu/danmu-settings"; // Rotation lock system exports export { diff --git a/js/components/src/player-store/player-state.tsx b/js/components/src/player-store/player-state.tsx index 47d3de00b..7a9bd98cf 100644 --- a/js/components/src/player-store/player-state.tsx +++ b/js/components/src/player-store/player-state.tsx @@ -191,16 +191,6 @@ export interface PlayerState { setReportSubject: ( subject: ComAtprotoModerationCreateReport.InputSchema["subject"] | null, ) => void; - - /** Danmu (bullet comments) settings */ - danmuEnabled: boolean; - setDanmuEnabled: (enabled: boolean) => void; - - danmuOpacity: number; - setDanmuOpacity: (opacity: number) => void; - - danmuSpeed: number; - setDanmuSpeed: (speed: number) => void; } export type PlayerEvent = { diff --git a/js/components/src/player-store/player-store.tsx b/js/components/src/player-store/player-store.tsx index 7b7051c96..b62b4d3c4 100644 --- a/js/components/src/player-store/player-store.tsx +++ b/js/components/src/player-store/player-store.tsx @@ -178,17 +178,6 @@ export const makePlayerStore = (id?: string): StoreApi => { setReportSubject: ( subject: ComAtprotoModerationCreateReport.InputSchema["subject"] | null, ) => set(() => ({ reportSubject: subject })), - - danmuEnabled: false, - setDanmuEnabled: (enabled: boolean) => - set(() => ({ danmuEnabled: enabled })), - - danmuOpacity: 80, - setDanmuOpacity: (opacity: number) => - set(() => ({ danmuOpacity: opacity })), - - danmuSpeed: 1, - setDanmuSpeed: (speed: number) => set(() => ({ danmuSpeed: speed })), })); }; diff --git a/js/components/src/streamplace-store/streamplace-store.tsx b/js/components/src/streamplace-store/streamplace-store.tsx index 83b490a3b..e946b3216 100644 --- a/js/components/src/streamplace-store/streamplace-store.tsx +++ b/js/components/src/streamplace-store/streamplace-store.tsx @@ -54,6 +54,18 @@ export interface StreamplaceState { muted: boolean; setVolume: (volume: number) => void; setMuted: (muted: boolean) => void; + + // Danmu settings + danmuEnabled: boolean; + danmuOpacity: number; + danmuSpeed: number; + danmuLaneCount: number; + danmuMaxMessages: number; + setDanmuEnabled: (enabled: boolean) => void; + setDanmuOpacity: (opacity: number) => void; + setDanmuSpeed: (speed: number) => void; + setDanmuLaneCount: (laneCount: number) => void; + setDanmuMaxMessages: (maxMessages: number) => void; } export type StreamplaceStore = StoreApi; @@ -65,6 +77,11 @@ export const makeStreamplaceStore = ({ }): StoreApi => { const VOLUME_STORAGE_KEY = "globalVolume"; const MUTED_STORAGE_KEY = "globalMuted"; + const DANMU_ENABLED_KEY = "danmuEnabled"; + const DANMU_OPACITY_KEY = "danmuOpacity"; + const DANMU_SPEED_KEY = "danmuSpeed"; + const DANMU_LANE_COUNT_KEY = "danmuLaneCount"; + const DANMU_MAX_MESSAGES_KEY = "danmuMaxMessages"; const store = createStore()((set) => ({ url, @@ -125,16 +142,72 @@ export const makeStreamplaceStore = ({ set({ muted }); storage.setItem(MUTED_STORAGE_KEY, muted.toString()).catch(console.error); }, + + // Danmu settings - start with defaults + danmuEnabled: false, + danmuOpacity: 80, + danmuSpeed: 1, + danmuLaneCount: 12, + danmuMaxMessages: 50, + + setDanmuEnabled: (enabled: boolean) => { + set({ danmuEnabled: enabled }); + storage + .setItem(DANMU_ENABLED_KEY, enabled.toString()) + .catch(console.error); + }, + + setDanmuOpacity: (opacity: number) => { + const clamped = Math.max(0, Math.min(100, opacity)); + set({ danmuOpacity: clamped }); + storage + .setItem(DANMU_OPACITY_KEY, clamped.toString()) + .catch(console.error); + }, + + setDanmuSpeed: (speed: number) => { + const clamped = Math.max(0.1, Math.min(3, speed)); + set({ danmuSpeed: clamped }); + storage.setItem(DANMU_SPEED_KEY, clamped.toString()).catch(console.error); + }, + + setDanmuLaneCount: (laneCount: number) => { + const clamped = Math.max(4, Math.min(20, laneCount)); + set({ danmuLaneCount: clamped }); + storage + .setItem(DANMU_LANE_COUNT_KEY, clamped.toString()) + .catch(console.error); + }, + + setDanmuMaxMessages: (maxMessages: number) => { + const clamped = Math.max(5, Math.min(200, maxMessages)); + set({ danmuMaxMessages: clamped }); + storage + .setItem(DANMU_MAX_MESSAGES_KEY, clamped.toString()) + .catch(console.error); + }, })); - // Load initial volume state from storage asynchronously + // Load initial volume and danmu state from storage asynchronously (async () => { try { const storedVolume = await storage.getItem(VOLUME_STORAGE_KEY); const storedMuted = await storage.getItem(MUTED_STORAGE_KEY); + const storedDanmuEnabled = await storage.getItem(DANMU_ENABLED_KEY); + const storedDanmuOpacity = await storage.getItem(DANMU_OPACITY_KEY); + const storedDanmuSpeed = await storage.getItem(DANMU_SPEED_KEY); + const storedDanmuLaneCount = await storage.getItem(DANMU_LANE_COUNT_KEY); + const storedDanmuMaxMessages = await storage.getItem( + DANMU_MAX_MESSAGES_KEY, + ); let initialVolume = 1.0; let initialMuted = false; + let initialDanmuEnabled = false; + let initialDanmuOpacity = 80; + let initialDanmuSpeed = 1; + let initialDanmuLaneCount = 12; + let initialDanmuMaxMessages = 50; if (storedVolume) { const parsedVolume = parseFloat(storedVolume); @@ -151,12 +224,49 @@ export const makeStreamplaceStore = ({ initialMuted = storedMuted === "true"; } + if (storedDanmuEnabled) { + initialDanmuEnabled = storedDanmuEnabled === "true"; + } + + if (storedDanmuOpacity) { + const parsed = parseInt(storedDanmuOpacity); + if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 100) { + initialDanmuOpacity = parsed; + } + } + + if (storedDanmuSpeed) { + const parsed = parseFloat(storedDanmuSpeed); + if (Number.isFinite(parsed) && parsed >= 0.1 && parsed <= 3) { + initialDanmuSpeed = parsed; + } + } + + if (storedDanmuLaneCount) { + const parsed = parseInt(storedDanmuLaneCount); + if (Number.isFinite(parsed) && parsed >= 4 && parsed <= 20) { + initialDanmuLaneCount = parsed; + } + } + + if (storedDanmuMaxMessages) { + const parsed = parseInt(storedDanmuMaxMessages); + if (Number.isFinite(parsed) && parsed >= 5 && parsed <= 200) { + initialDanmuMaxMessages = parsed; + } + } + store.setState({ volume: initialVolume, muted: initialMuted, + danmuEnabled: initialDanmuEnabled, + danmuOpacity: initialDanmuOpacity, + danmuSpeed: initialDanmuSpeed, + danmuLaneCount: initialDanmuLaneCount, + danmuMaxMessages: initialDanmuMaxMessages, }); } catch (error) { - console.error("Failed to load volume state from storage:", error); + console.error("Failed to load state from storage:", error); } })(); @@ -213,4 +323,50 @@ export const useEffectiveVolume = () => return Number.isFinite(effectiveVolume) ? effectiveVolume : 1.0; }); +// Danmu convenience hooks +export const useDanmuEnabled = () => useStreamplaceStore((x) => x.danmuEnabled); +export const useDanmuOpacity = () => useStreamplaceStore((x) => x.danmuOpacity); +export const useDanmuSpeed = () => useStreamplaceStore((x) => x.danmuSpeed); +export const useDanmuLaneCount = () => + useStreamplaceStore((x) => x.danmuLaneCount); +export const useDanmuMaxMessages = () => + useStreamplaceStore((x) => x.danmuMaxMessages); +export const useSetDanmuEnabled = () => + useStreamplaceStore((x) => x.setDanmuEnabled); +export const useSetDanmuOpacity = () => + useStreamplaceStore((x) => x.setDanmuOpacity); +export const useSetDanmuSpeed = () => + useStreamplaceStore((x) => x.setDanmuSpeed); +export const useSetDanmuLaneCount = () => + useStreamplaceStore((x) => x.setDanmuLaneCount); +export const useSetDanmuMaxMessages = () => + useStreamplaceStore((x) => x.setDanmuMaxMessages); + +// Composite hook that calls all individual hooks +export const useDanmuSettings = () => { + const danmuEnabled = useDanmuEnabled(); + const danmuOpacity = useDanmuOpacity(); + const danmuSpeed = useDanmuSpeed(); + const danmuLaneCount = useDanmuLaneCount(); + const danmuMaxMessages = useDanmuMaxMessages(); + const setDanmuEnabled = useSetDanmuEnabled(); + const setDanmuOpacity = useSetDanmuOpacity(); + const setDanmuSpeed = useSetDanmuSpeed(); + const setDanmuLaneCount = useSetDanmuLaneCount(); + const setDanmuMaxMessages = useSetDanmuMaxMessages(); + + return { + danmuEnabled, + danmuOpacity, + danmuSpeed, + danmuLaneCount, + danmuMaxMessages, + setDanmuEnabled, + setDanmuOpacity, + setDanmuSpeed, + setDanmuLaneCount, + setDanmuMaxMessages, + }; +}; + export { useCreateStreamRecord, useUpdateStreamRecord } from "./stream"; -- 2.54.0