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";