Live video on the AT Protocol

Merge pull request #542 from streamplace/natb/global-volume-store

player: store global volume state

authored by

Eli Mallon and committed by
GitHub
000a1d88 03029807

+167 -70
+8 -2
js/app/components/mobile/desktop-ui/mute-overlay.tsx
··· 1 - import { Text, View, usePlayerStore, zero } from "@streamplace/components"; 1 + import { 2 + Text, 3 + View, 4 + usePlayerStore, 5 + useSetMuted, 6 + zero, 7 + } from "@streamplace/components"; 2 8 import { VolumeX } from "lucide-react-native"; 3 9 import { Pressable } from "react-native"; 4 10 ··· 6 12 7 13 export function MuteOverlay() { 8 14 const muteWasForced = usePlayerStore((state) => state.muteWasForced); 9 - const setMuted = usePlayerStore((state) => state.setMuted); 15 + const setMuted = useSetMuted(); 10 16 const setMuteWasForced = usePlayerStore((state) => state.setMuteWasForced); 11 17 12 18 if (!muteWasForced) return null;
+13 -5
js/app/components/mobile/desktop-ui/volume-slider.tsx
··· 1 - import { Slider, View, usePlayerStore, zero } from "@streamplace/components"; 1 + import { 2 + Slider, 3 + useMuted, 4 + useSetMuted, 5 + useSetVolume, 6 + useVolume, 7 + View, 8 + zero, 9 + } from "@streamplace/components"; 2 10 import { Volume2, VolumeX } from "lucide-react-native"; 3 11 import { useCallback } from "react"; 4 12 import { Pressable } from "react-native"; ··· 11 19 const { layout, p, r } = zero; 12 20 13 21 export function VolumeSlider() { 14 - const muted = usePlayerStore((state) => state.muted); 15 - const setMuted = usePlayerStore((state) => state.setMuted); 16 - const volume = usePlayerStore((state) => state.volume); 17 - const setVolume = usePlayerStore((state) => state.setVolume); 22 + const muted = useMuted(); 23 + const setMuted = useSetMuted(); 24 + const volume = useVolume(); 25 + const setVolume = useSetVolume(); 18 26 19 27 const fadeAnim = useSharedValue(0); 20 28 const widthAnim = useSharedValue(0);
+2 -1
js/app/components/mobile/ui.tsx
··· 9 9 usePlayerDimensions, 10 10 usePlayerStore, 11 11 useSegmentDimensions, 12 + useSetMuted, 12 13 View, 13 14 zero, 14 15 } from "@streamplace/components"; ··· 48 49 49 50 const muteWasForced = usePlayerStore((state) => state.muteWasForced); 50 51 const setMuteWasForced = usePlayerStore((state) => state.setMuteWasForced); 51 - const setMuted = usePlayerStore((state) => state.setMuted); 52 + const setMuted = useSetMuted(); 52 53 53 54 const { shouldShowFloatingMetrics, safeAreaInsets } = useResponsiveLayout(); 54 55 const [showLoading, setShowLoading] = useState(false);
+2 -2
js/app/features/base/baseSlice.tsx
··· 1 + import { storage } from "@streamplace/components"; 1 2 import { createAppSlice } from "../../hooks/createSlice"; 2 - import Storage from "../../storage"; 3 3 export const STORED_KEY_KEY = "storedKey"; 4 4 export const DID_KEY = "did"; 5 5 ··· 26 26 let storedKey: StreamKey | null = null; 27 27 // Async operation would go here 28 28 try { 29 - const storedKeyStr = await Storage.getItem(STORED_KEY_KEY); 29 + const storedKeyStr = await storage.getItem(STORED_KEY_KEY); 30 30 if (storedKeyStr) { 31 31 storedKey = JSON.parse(storedKeyStr); 32 32 }
+1 -3
js/app/features/base/sidebarSlice.tsx
··· 1 + import { storage } from "@streamplace/components"; 1 2 import { createAppSlice } from "../../hooks/createSlice"; 2 - import WebStorage from "../../storage/storage"; 3 - 4 - const storage = new WebStorage(); 5 3 export const SIDEBAR_STORAGE_KEY = "sidebarState"; 6 4 7 5 export interface SidebarState {
+12 -12
js/app/features/bluesky/blueskySlice.tsx
··· 9 9 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 10 10 import { bytesToMultibase, Secp256k1Keypair } from "@atproto/crypto"; 11 11 import { OAuthSession } from "@streamplace/atproto-oauth-client-react-native"; 12 + import { storage } from "@streamplace/components"; 12 13 import { DID_KEY, hydrate, STORED_KEY_KEY } from "features/base/baseSlice"; 13 14 import { openLoginLink } from "features/platform/platformSlice"; 14 15 import { ··· 16 17 StreamplaceState, 17 18 } from "features/streamplace/streamplaceSlice"; 18 19 import { Platform } from "react-native"; 19 - import Storage from "storage"; 20 20 import { 21 21 LivestreamViewHydrated, 22 22 PlaceStreamChatProfile, ··· 146 146 typeof err?.message === "string" && 147 147 err.message.includes("oauth session revoked") 148 148 ) { 149 - Storage.removeItem("did").catch((e) => { 149 + storage.removeItem("did").catch((e) => { 150 150 console.error("Error removing did", e); 151 151 }); 152 - Storage.removeItem(STORED_KEY_KEY).catch((e) => { 152 + storage.removeItem(STORED_KEY_KEY).catch((e) => { 153 153 console.error("Error removing stored key", e); 154 154 }); 155 155 const u = new URL(document.location.href); ··· 170 170 const client = await createOAuthClient(streamplace.url); 171 171 const anonPDSAgent = new StreamplaceAgent(streamplace.url); 172 172 const maybeDIDs = await Promise.all([ 173 - Storage.getItem(DID_KEY), 174 - Storage.getItem("@@atproto/oauth-client-browser(sub)"), 175 - Storage.getItem("@@atproto/oauth-client-react-native:did:(sub)"), 173 + storage.getItem(DID_KEY), 174 + storage.getItem("@@atproto/oauth-client-browser(sub)"), 175 + storage.getItem("@@atproto/oauth-client-react-native:did:(sub)"), 176 176 ]); 177 177 const did = maybeDIDs.find((d) => d !== null) || null; 178 178 let session: OAuthSession | null = null; ··· 197 197 const { client, session, anonPDSAgent } = action.payload; 198 198 console.log("loadOAuthClient fulfilled", action.payload); 199 199 if (session) { 200 - Storage.setItem(DID_KEY, session.did).catch((e) => { 200 + storage.setItem(DID_KEY, session.did).catch((e) => { 201 201 console.error("Error setting did", e); 202 202 }); 203 203 return { ··· 294 294 295 295 logout: create.asyncThunk( 296 296 async (_, thunkAPI) => { 297 - await Storage.removeItem("did"); 298 - await Storage.removeItem(STORED_KEY_KEY); 297 + await storage.removeItem("did"); 298 + await storage.removeItem(STORED_KEY_KEY); 299 299 const { bluesky } = thunkAPI.getState() as { 300 300 bluesky: BlueskyState; 301 301 }; ··· 425 425 const client = await createOAuthClient(streamplace.url); 426 426 try { 427 427 const ret = await client.callback(params); 428 - await Storage.setItem(DID_KEY, ret.session.did); 428 + await storage.setItem(DID_KEY, ret.session.did); 429 429 return { session: ret.session as any, client }; 430 430 } catch (e) { 431 431 let message = e.message; ··· 653 653 record, 654 654 }); 655 655 if (store) { 656 - await Storage.setItem(STORED_KEY_KEY, JSON.stringify(newKey)); 656 + await storage.setItem(STORED_KEY_KEY, JSON.stringify(newKey)); 657 657 } 658 658 return newKey; 659 659 }, ··· 808 808 809 809 setPDS: create.asyncThunk( 810 810 async (pds: string, thunkAPI) => { 811 - await Storage.setItem("pdsURL", pds); 811 + await storage.setItem("pdsURL", pds); 812 812 return pds; 813 813 }, 814 814 {
+13 -13
js/app/features/streamplace/streamplaceSlice.tsx
··· 1 + import { storage } from "@streamplace/components"; 1 2 import { BlueskyState } from "features/bluesky/blueskyTypes"; 2 3 import { PlaceStreamLivestream, PlaceStreamSegment } from "streamplace"; 3 4 import { isWeb } from "tamagui"; 4 5 import { createAppSlice } from "../../hooks/createSlice"; 5 - import Storage from "../../storage"; 6 6 7 7 let DEFAULT_URL = process.env.EXPO_PUBLIC_STREAMPLACE_URL as string; 8 8 if (isWeb && process.env.EXPO_PUBLIC_WEB_TRY_LOCAL === "true") { ··· 78 78 initialize: create.asyncThunk( 79 79 async (_, { getState }) => { 80 80 let [url, userMutedStr, chatWarningStr] = await Promise.all([ 81 - Storage.getItem(URL_KEY), 82 - Storage.getItem(USER_MUTED_KEY), 83 - Storage.getItem(CHAT_WARNING_KEY), 81 + storage.getItem(URL_KEY), 82 + storage.getItem(USER_MUTED_KEY), 83 + storage.getItem(CHAT_WARNING_KEY), 84 84 ]); 85 85 if (!url) { 86 86 url = DEFAULT_URL; ··· 120 120 121 121 setURL: create.reducer((state, action: { payload: string }) => { 122 122 console.log("setURL", action); 123 - Storage.setItem(URL_KEY, action.payload).catch((err) => { 123 + storage.setItem(URL_KEY, action.payload).catch((err) => { 124 124 console.error("setURL error", err); 125 125 }); 126 126 return { ··· 130 130 }), 131 131 132 132 userMute: create.reducer((state, action: { payload: boolean }) => { 133 - Storage.setItem(USER_MUTED_KEY, JSON.stringify(action.payload)).catch( 134 - (err) => { 133 + storage 134 + .setItem(USER_MUTED_KEY, JSON.stringify(action.payload)) 135 + .catch((err) => { 135 136 console.error("userMute error", err); 136 - }, 137 - ); 137 + }); 138 138 return { 139 139 ...state, 140 140 userMuted: action.payload, ··· 142 142 }), 143 143 144 144 chatWarn: create.reducer((state, action: { payload: boolean }) => { 145 - Storage.setItem(CHAT_WARNING_KEY, JSON.stringify(action.payload)).catch( 146 - (err) => { 145 + storage 146 + .setItem(CHAT_WARNING_KEY, JSON.stringify(action.payload)) 147 + .catch((err) => { 147 148 console.error("chatWarn error", err); 148 - }, 149 - ); 149 + }); 150 150 return { 151 151 ...state, 152 152 chatWarned: action.payload,
js/app/storage/index.tsx js/components/src/storage/index.tsx
js/app/storage/lock.tsx js/components/src/storage/lock.tsx
js/app/storage/storage.native.tsx js/components/src/storage/storage.native.tsx
js/app/storage/storage.shared.tsx js/components/src/storage/storage.shared.tsx
js/app/storage/storage.tsx js/components/src/storage/storage.tsx
+1 -1
js/app/store/listener.ts
··· 1 1 import { createListenerMiddleware, isAnyOf } from "@reduxjs/toolkit"; 2 + import { storage } from "@streamplace/components"; 2 3 import { SIDEBAR_STORAGE_KEY, sidebarSlice } from "features/base/sidebarSlice"; 3 - import storage from "storage"; 4 4 import { RootState } from "./store"; 5 5 6 6 export const listenerMiddleware = createListenerMiddleware();
+1
js/components/package.json
··· 20 20 "tsup": "^8.5.0" 21 21 }, 22 22 "dependencies": { 23 + "expo-sqlite": "~15.2.12", 23 24 "@atproto/api": "^0.16.7", 24 25 "@atproto/crypto": "^0.4.4", 25 26 "@emoji-mart/react": "^1.1.1",
+2 -2
js/components/src/components/mobile-player/ui/autoplay-button.tsx
··· 1 1 import { Play } from "lucide-react-native"; 2 2 import { Pressable } from "react-native"; 3 - import { View, layout, usePlayerStore } from "../../.."; 3 + import { View, layout, usePlayerStore, useSetMuted } from "../../.."; 4 4 import { h, p, w } from "../../../ui"; 5 5 6 6 export function AutoplayButton() { 7 7 const autoplayFailed = usePlayerStore((x) => x.autoplayFailed); 8 8 const setAutoplayFailed = usePlayerStore((x) => x.setAutoplayFailed); 9 - const setMuted = usePlayerStore((x) => x.setMuted); 9 + const setMuted = useSetMuted(); 10 10 const setMuteWasForced = usePlayerStore((x) => x.setMuteWasForced); 11 11 const setUserInteraction = usePlayerStore((x) => x.setUserInteraction); 12 12 const videoRef = usePlayerStore((x) => x.videoRef);
+6 -4
js/components/src/components/mobile-player/video-async.native.tsx
··· 14 14 PlayerProtocol, 15 15 PlayerStatus, 16 16 Text, 17 + useEffectiveVolume, 17 18 usePlayerStore as useIngestPlayerStore, 19 + useMuted, 18 20 usePlayerStore, 19 21 useStreamplaceStore, 20 22 View, ··· 71 73 const src = usePlayerStore((x) => x.src); 72 74 const { url } = srcToUrl({ src: src, selectedRendition }, protocol); 73 75 const setStatus = usePlayerStore((x) => x.setStatus); 74 - const muted = usePlayerStore((x) => x.muted); 75 - const volume = usePlayerStore((x) => x.volume); 76 + const muted = useMuted(); 77 + const volume = useEffectiveVolume(); 76 78 const setFullscreen = usePlayerStore((x) => x.setFullscreen); 77 79 const fullscreen = usePlayerStore((x) => x.fullscreen); 78 80 const playerEvent = usePlayerStore((x) => x.playerEvent); ··· 211 213 }, []); 212 214 213 215 const setStatus = usePlayerStore((x) => x.setStatus); 214 - const muted = usePlayerStore((x) => x.muted); 215 - const volume = usePlayerStore((x) => x.volume); 216 + const muted = useMuted(); 217 + const volume = useEffectiveVolume(); 216 218 217 219 useEffect(() => { 218 220 if (stuck && status === PlayerStatus.PLAYING) {
+6 -3
js/components/src/components/mobile-player/video.tsx
··· 4 4 IngestMediaSource, 5 5 PlayerProtocol, 6 6 PlayerStatus, 7 + useEffectiveVolume, 8 + useMuted, 7 9 usePlayerStore, 10 + useSetMuted, 8 11 useStreamplaceStore, 9 12 } from "../.."; 10 13 import { borderRadius, colors, mt } from "../../lib/theme/atoms"; ··· 135 138 const x = usePlayerStore((x) => x); 136 139 const url = useStreamplaceStore((x) => x.url); 137 140 const playerEvent = usePlayerStore((x) => x.playerEvent); 138 - const setMuted = usePlayerStore((x) => x.setMuted); 139 141 const setMuteWasForced = usePlayerStore((x) => x.setMuteWasForced); 140 - const muted = usePlayerStore((x) => x.muted); 141 142 const ingest = usePlayerStore((x) => x.ingestConnectionState !== null); 142 - const volume = usePlayerStore((x) => x.volume); 143 + const volume = useEffectiveVolume(); 144 + const muted = useMuted(); 145 + const setMuted = useSetMuted(); 143 146 const setStatus = usePlayerStore((x) => x.setStatus); 144 147 const setUserInteraction = usePlayerStore((x) => x.setUserInteraction); 145 148 const setVideoRef = usePlayerStore((x) => x.setVideoRef);
+4
js/components/src/index.tsx
··· 37 37 38 38 // Dashboard components 39 39 export * as Dashboard from "./components/dashboard"; 40 + 41 + // Storage exports 42 + export { default as storage } from "./storage"; 43 + export type { AQStorage } from "./storage/storage.shared";
-12
js/components/src/player-store/player-state.tsx
··· 69 69 /** Function to set the ingestStarted timestamp */ 70 70 setIngestStarted: (timestamp: number | null) => void; 71 71 72 - /** Player muted state */ 73 - muted: boolean; 74 - 75 - /** Function to set the muted state */ 76 - setMuted: (isMuted: boolean) => void; 77 - 78 - /** Player volume level (0.0 to 1.0) */ 79 - volume: number; 80 - 81 - /** Function to set the volume level */ 82 - setVolume: (volume: number) => void; 83 - 84 72 /** Player fullscreen state */ 85 73 fullscreen: boolean; 86 74
-8
js/components/src/player-store/player-store.tsx
··· 52 52 setIngestStarted: (timestamp: number | null) => 53 53 set(() => ({ ingestStarted: timestamp })), 54 54 55 - muted: false, 56 - setMuted: (isMuted: boolean) => 57 - set(() => ({ muted: isMuted, muteWasForced: false })), 58 - 59 - volume: 1.0, 60 - setVolume: (volume: number) => 61 - set(() => ({ volume, muteWasForced: false })), 62 - 63 55 fullscreen: false, 64 56 setFullscreen: (isFullscreen: boolean) => 65 57 set(() => ({ fullscreen: isFullscreen })),
+91 -1
js/components/src/streamplace-store/streamplace-store.tsx
··· 2 2 import { useContext } from "react"; 3 3 import { PlaceStreamChatProfile, PlaceStreamLivestream } from "streamplace"; 4 4 import { createStore, StoreApi, useStore } from "zustand"; 5 + import storage from "../storage"; 5 6 import { StreamplaceContext } from "../streamplace-provider/context"; 6 7 7 8 // there are three categories of XRPC that we need to handle: ··· 31 32 oauthSession: SessionManager | null | undefined; 32 33 handle: string | null; 33 34 chatProfile: PlaceStreamChatProfile.Record | null; 35 + 36 + // Volume state 37 + volume: number; 38 + muted: boolean; 39 + setVolume: (volume: number) => void; 40 + setMuted: (muted: boolean) => void; 34 41 } 35 42 36 43 export type StreamplaceStore = StoreApi<StreamplaceState>; ··· 40 47 }: { 41 48 url: string; 42 49 }): StoreApi<StreamplaceState> => { 43 - return createStore<StreamplaceState>()((set) => ({ 50 + const VOLUME_STORAGE_KEY = "globalVolume"; 51 + const MUTED_STORAGE_KEY = "globalMuted"; 52 + 53 + const store = createStore<StreamplaceState>()((set) => ({ 44 54 url, 45 55 liveUsers: null, 46 56 setLiveUsers: (opts: { ··· 59 69 oauthSession: null, 60 70 handle: null, 61 71 chatProfile: null, 72 + 73 + // Volume state - start with defaults 74 + volume: 1.0, 75 + muted: false, 76 + 77 + setVolume: (volume: number) => { 78 + // Ensure the value is finite and within bounds 79 + if (!Number.isFinite(volume)) { 80 + console.warn("Invalid volume value:", volume, "- using 1.0"); 81 + volume = 1.0; 82 + } 83 + const clampedVolume = Math.max(0, Math.min(1, volume)); 84 + 85 + set({ volume: clampedVolume }); 86 + 87 + // Auto-unmute if volume > 0 88 + if (clampedVolume > 0) { 89 + set({ muted: false }); 90 + storage.setItem(MUTED_STORAGE_KEY, "false").catch(console.error); 91 + } 92 + 93 + storage 94 + .setItem(VOLUME_STORAGE_KEY, clampedVolume.toString()) 95 + .catch(console.error); 96 + }, 97 + 98 + setMuted: (muted: boolean) => { 99 + set({ muted }); 100 + storage.setItem(MUTED_STORAGE_KEY, muted.toString()).catch(console.error); 101 + }, 62 102 })); 103 + 104 + // Load initial volume state from storage asynchronously 105 + (async () => { 106 + try { 107 + const storedVolume = await storage.getItem(VOLUME_STORAGE_KEY); 108 + const storedMuted = await storage.getItem(MUTED_STORAGE_KEY); 109 + 110 + let initialVolume = 1.0; 111 + let initialMuted = false; 112 + 113 + if (storedVolume) { 114 + const parsedVolume = parseFloat(storedVolume); 115 + if ( 116 + Number.isFinite(parsedVolume) && 117 + parsedVolume >= 0 && 118 + parsedVolume <= 1 119 + ) { 120 + initialVolume = parsedVolume; 121 + } 122 + } 123 + 124 + if (storedMuted) { 125 + initialMuted = storedMuted === "true"; 126 + } 127 + 128 + // Update the store with loaded values 129 + store.setState({ 130 + volume: initialVolume, 131 + muted: initialMuted, 132 + }); 133 + } catch (e) { 134 + console.warn("Failed to load volume settings from storage:", e); 135 + } 136 + })(); 137 + 138 + return store; 63 139 }; 64 140 65 141 export function getStreamplaceStoreFromContext(): StreamplaceStore { ··· 87 163 const store = getStreamplaceStoreFromContext(); 88 164 return (handle: string) => store.setState({ handle }); 89 165 }; 166 + 167 + // Volume convenience hooks 168 + export const useVolume = () => useStreamplaceStore((x) => x.volume); 169 + export const useMuted = () => useStreamplaceStore((x) => x.muted); 170 + export const useSetVolume = () => useStreamplaceStore((x) => x.setVolume); 171 + export const useSetMuted = () => useStreamplaceStore((x) => x.setMuted); 172 + 173 + // Composite hook for effective volume (0 if muted) - used by video components 174 + export const useEffectiveVolume = () => 175 + useStreamplaceStore((state) => { 176 + const effectiveVolume = state.muted ? 0 : state.volume; 177 + // Ensure we always return a finite number for HTMLMediaElement.volume 178 + return Number.isFinite(effectiveVolume) ? effectiveVolume : 1.0; 179 + });
+2 -1
js/components/tsconfig.json
··· 4 4 "rootDir": "./src", 5 5 "outDir": "./dist", 6 6 "jsx": "react-jsx", 7 - "module": "commonjs" 7 + "module": "commonjs", 8 + "skipLibCheck": true 8 9 }, 9 10 "include": ["./src"] 10 11 }
+3
pnpm-lock.yaml
··· 410 410 expo-keep-awake: 411 411 specifier: ^14.0.0 412 412 version: 14.1.4(expo@53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 413 + expo-sqlite: 414 + specifier: ~15.2.12 415 + version: 15.2.12(expo@53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 413 416 expo-video: 414 417 specifier: ^2.0.0 415 418 version: 2.2.1(expo@53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10))(react-native@0.79.3(@babel/core@7.26.0)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)