diff --git a/js/app/components/keep-awake.tsx b/js/app/components/keep-awake.tsx
deleted file mode 100644
index 41133f6ab..000000000
--- a/js/app/components/keep-awake.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-export default function KeepAwake() {
- return <>>;
-}
diff --git a/js/app/components/mobile/bottom-metadata.tsx b/js/app/components/mobile/bottom-metadata.tsx
new file mode 100644
index 000000000..2367934a2
--- /dev/null
+++ b/js/app/components/mobile/bottom-metadata.tsx
@@ -0,0 +1,95 @@
+import {
+ Button,
+ layout,
+ PlayerUI,
+ ShareSheet,
+ Text,
+ useAvatars,
+ useLivestreamInfo,
+ useLivestreamStore,
+ zero,
+} from "@streamplace/components";
+import { ChevronLeft } from "@tamagui/lucide-icons";
+import { ChevronRight } from "lucide-react-native";
+import { Image, View } from "react-native";
+const { gap, px, py, colors } = zero;
+
+export function BottomMetadata({
+ setShowChat,
+ showChat,
+}: {
+ setShowChat: (show: boolean) => void;
+ showChat: boolean;
+}) {
+ const { profile } = useLivestreamInfo();
+ const avatars = useAvatars(profile?.did ? [profile?.did] : []);
+ const ls = useLivestreamStore((x) => x.livestream);
+
+ return (
+
+
+ {/* Left side - Profile info */}
+
+ {profile?.did && avatars[profile?.did]?.avatar && (
+
+ )}
+ {!(profile?.did && avatars[profile?.did]?.avatar) && (
+
+ )}
+
+
+ @{profile?.handle || "user"}
+
+
+ {ls?.record.title || "Stream Title"}
+
+
+
+
+ {/* Right side - Viewer count and collapse chat */}
+
+
+
+
+
+
+
+ );
+}
diff --git a/js/app/components/mobile/chat.tsx b/js/app/components/mobile/chat.tsx
index b469725c8..7b2a79e85 100644
--- a/js/app/components/mobile/chat.tsx
+++ b/js/app/components/mobile/chat.tsx
@@ -1,8 +1,108 @@
-import { atoms, Chat, ChatBox, View } from "@streamplace/components";
-import { flex, px, py } from "@streamplace/components/src/lib/theme/atoms";
-const { borderRadius, gap, layout } = atoms;
+import {
+ Chat,
+ ChatBox,
+ Resizable,
+ Text,
+ useHandle,
+ useLivestreamInfo,
+ View,
+ zero,
+} from "@streamplace/components";
+import { useKeyboard } from "hooks/useKeyboard";
+import { useEffect } from "react";
+import { Keyboard, Pressable, TouchableWithoutFeedback } from "react-native";
+import Animated, {
+ useAnimatedStyle,
+ useSharedValue,
+ withSpring,
+} from "react-native-reanimated";
+import { useResponsiveLayout } from "./useResponsiveLayout";
-export function ChatPanel() {
+import { useNavigation } from "@react-navigation/native";
+import { ArrowRight } from "@tamagui/lucide-icons";
+import emojiData from "assets/emoji-data.json";
+const { borderRadius, gap, layout, flex, px, py, r, position, bottom } = zero;
+
+export function DesktopChatPanel({
+ chatVisible,
+ chatPanelWidth,
+ safeAreaInsets,
+}) {
+ const sidebarOffset = useSharedValue(chatVisible ? 0 : chatPanelWidth);
+
+ const kb = useKeyboard();
+
+ useEffect(() => {
+ console.log(
+ "Setting sidebar offset x to",
+ chatVisible ? 0 : chatPanelWidth,
+ );
+ sidebarOffset.value = withSpring(chatVisible ? 0 : chatPanelWidth, {
+ damping: 100,
+ stiffness: 1000,
+ });
+ }, [chatVisible, chatPanelWidth, sidebarOffset]);
+
+ const animatedSidebarStyle = useAnimatedStyle(() => ({
+ transform: [
+ { translateX: sidebarOffset.value },
+ { translateY: -kb.keyboardHeight },
+ ],
+ }));
+
+ return (
+
+
+
+
+
+ );
+}
+
+// MobileChatPanel.tsx
+export function MobileChatPanel({ isPlayerRatioGreater }) {
+ return (
+
+
+
+
+
+ );
+}
+
+function ChatPanel() {
+ const { shouldShowChatSidePanel, safeAreaInsets } = useResponsiveLayout();
+ const { profile } = useLivestreamInfo();
+ const handle = useHandle();
+ const navigation = useNavigation();
+ let canModerate = profile?.handle === handle;
return (
-
-
-
+
+
+
+
+ {handle ? (
+
+ ) : (
+ navigation.navigate("Login")}
+ style={[
+ layout.flex.row,
+ layout.flex.center,
+ gap.all[2],
+ {
+ padding: 16,
+ borderRadius: borderRadius.xl,
+ backgroundColor: "rgba(255, 255, 255, 0.1)",
+ },
+ ]}
+ >
+ Log in or sign up to chat
+
+
+ )}
);
diff --git a/js/app/components/mobile/desktop-ui.tsx b/js/app/components/mobile/desktop-ui.tsx
new file mode 100644
index 000000000..23a526cc3
--- /dev/null
+++ b/js/app/components/mobile/desktop-ui.tsx
@@ -0,0 +1,304 @@
+import {
+ PlayerUI,
+ Toast,
+ useLivestreamInfo,
+ usePlayerDimensions,
+ usePlayerStore,
+ useSegment,
+ View,
+ zero,
+} from "@streamplace/components";
+import React, { useCallback, useEffect, useRef, useState } from "react";
+import { Platform } from "react-native";
+import { Gesture, GestureDetector } from "react-native-gesture-handler";
+import Animated, {
+ runOnJS,
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from "react-native-reanimated";
+import {
+ BottomControlBar,
+ MuteOverlay,
+ TopControlBar,
+} from "./desktop-ui/index";
+import { useResponsiveLayout } from "./useResponsiveLayout";
+
+const { h, layout, position, w, px, py, r, p } = zero;
+
+function isRefObject(
+ ref: any,
+): ref is
+ | React.RefObject
+ | React.MutableRefObject {
+ return ref && typeof ref === "object" && "current" in ref;
+}
+
+export function DesktopUi() {
+ const {
+ ingest,
+ title,
+ setTitle,
+ showCountdown,
+ setShowCountdown,
+ recordSubmitted,
+ setRecordSubmitted,
+ ingestStarting,
+ setIngestStarting,
+ toggleGoLive,
+ } = useLivestreamInfo();
+ const { width, height } = usePlayerDimensions();
+ const { safeAreaInsets, shouldShowFloatingMetrics } = useResponsiveLayout();
+
+ const offline = usePlayerStore((state) => state.offline);
+ const showMetrics = usePlayerStore((state) => state.showDebugInfo);
+ const pipAction = usePlayerStore((state) => state.pipAction);
+ const videoRef = usePlayerStore((state) => state.videoRef);
+
+ const segment = useSegment();
+
+ const [isControlsVisible, setIsControlsVisible] = useState(true);
+ const [isChatOpen, setIsChatOpen] = useState(false);
+ const [pipSupported, setPipSupported] = useState(false);
+ const [pipActive, setPipActive] = useState(false);
+ const fadeOpacity = useSharedValue(1);
+ const fadeTimeout = useRef(null);
+ const FADE_OUT_DELAY = 500;
+
+ const isSelfAndNotLive = ingest === "new";
+ const isActivelyLive = ingest !== null && ingest !== "new";
+
+ const resetFadeTimer = useCallback(() => {
+ fadeOpacity.value = withTiming(1, { duration: 200 });
+ if (fadeTimeout.current) clearTimeout(fadeTimeout.current);
+ setIsControlsVisible(true);
+
+ fadeTimeout.current = setTimeout(() => {
+ fadeOpacity.value = withTiming(0, { duration: 400 });
+ setIsControlsVisible(false);
+ }, FADE_OUT_DELAY);
+ }, [fadeOpacity]);
+
+ const onPlayerHover = useCallback(() => {
+ resetFadeTimer();
+ }, [resetFadeTimer]);
+
+ const toggleChat = useCallback(() => {
+ setIsChatOpen((prev) => !prev);
+ }, []);
+
+ useEffect(() => {
+ resetFadeTimer();
+
+ return () => {
+ if (fadeTimeout.current) clearTimeout(fadeTimeout.current);
+ if (ingestStarting) {
+ setIngestStarting(false);
+ }
+ };
+ }, [ingestStarting, setIngestStarting, resetFadeTimer]);
+
+ const animatedFadeStyle = useAnimatedStyle(() => ({
+ opacity: shouldShowFloatingMetrics ? 1 : fadeOpacity.value,
+ }));
+
+ // Picture-in-Picture support detection
+ useEffect(() => {
+ if (Platform.OS === "web") {
+ setPipSupported(
+ !!document.pictureInPictureEnabled && pipAction !== undefined,
+ );
+ }
+ }, [pipAction]);
+
+ // Picture-in-Picture event listeners
+ useEffect(() => {
+ if (Platform.OS !== "web") return;
+
+ let video: HTMLVideoElement | null = null;
+ if (isRefObject(videoRef)) {
+ video = videoRef.current;
+ }
+ if (!video) return;
+
+ function onEnter() {
+ setPipActive(true);
+ }
+ function onLeave() {
+ setPipActive(false);
+ }
+
+ video.addEventListener("enterpictureinpicture", onEnter);
+ video.addEventListener("leavepictureinpicture", onLeave);
+
+ return () => {
+ if (video) {
+ video.removeEventListener("enterpictureinpicture", onEnter);
+ video.removeEventListener("leavepictureinpicture", onLeave);
+ }
+ };
+ }, [videoRef]);
+
+ const handlePip = useCallback(() => {
+ if (pipAction) pipAction();
+ }, [pipAction]);
+
+ // Live timer for offline overlay
+ const [timeSinceLastSeen, setTimeSinceLastSeen] = useState("Unknown");
+
+ useEffect(() => {
+ if (!offline || !segment?.startTime) {
+ setTimeSinceLastSeen("Unknown");
+ return;
+ }
+
+ const updateTimer = () => {
+ const now = new Date();
+ const lastSeen = new Date(segment.startTime);
+ const diffMs = now.getTime() - lastSeen.getTime();
+ const diffMinutes = Math.floor(diffMs / 60000);
+ const diffSeconds = Math.floor((diffMs % 60000) / 1000);
+
+ if (diffMinutes > 0) {
+ setTimeSinceLastSeen(`${diffMinutes}m ${diffSeconds}s ago`);
+ } else {
+ setTimeSinceLastSeen(`${diffSeconds}s ago`);
+ }
+ };
+
+ // Update immediately
+ updateTimer();
+
+ // Update every second while offline
+ const interval = setInterval(updateTimer, 1000);
+
+ return () => clearInterval(interval);
+ }, [offline, segment?.startTime]);
+
+ const hover = Gesture.Hover().onChange((_) => runOnJS(onPlayerHover)());
+
+ return (
+
+ <>
+
+
+
+
+
+
+
+ {isActivelyLive && isControlsVisible && (
+
+
+
+
+
+ )}
+
+
+
+
+
+ {isSelfAndNotLive && (
+
+ )}
+
+ {
+ setShowCountdown(false);
+ }}
+ />
+
+
+
+ {showMetrics && (
+
+
+
+ )}
+ >
+
+ );
+}
diff --git a/js/app/components/mobile/desktop-ui/bottom-controls.tsx b/js/app/components/mobile/desktop-ui/bottom-controls.tsx
new file mode 100644
index 000000000..e4613f09e
--- /dev/null
+++ b/js/app/components/mobile/desktop-ui/bottom-controls.tsx
@@ -0,0 +1,58 @@
+import { PlayerUI, View, usePlayerStore, zero } from "@streamplace/components";
+import { Fullscreen, Minimize, PictureInPicture2 } from "lucide-react-native";
+import { Platform, Pressable } from "react-native";
+import { VolumeSlider } from "./volume-slider";
+
+const { gap, layout, p, r } = zero;
+
+interface BottomControlBarProps {
+ ingest: string | null;
+ pipSupported: boolean;
+ pipActive: boolean;
+ onHandlePip: () => void;
+}
+
+export function BottomControlBar({
+ ingest,
+ pipSupported,
+ pipActive,
+ onHandlePip,
+}: BottomControlBarProps) {
+ const fullscreen = usePlayerStore((state) => state.fullscreen);
+ const setFullscreen = usePlayerStore((state) => state.setFullscreen);
+
+ return (
+
+
+
+
+
+
+ {Platform.OS === "web" && pipSupported && (
+
+
+
+
+
+ )}
+ {Platform.OS === "web" && (
+ {
+ setFullscreen(!fullscreen);
+ }}
+ style={[p[2], r[1]]}
+ >
+ {fullscreen ? : }
+
+ )}
+ {ingest === null && }
+
+
+ );
+}
diff --git a/js/app/components/mobile/desktop-ui/index.ts b/js/app/components/mobile/desktop-ui/index.ts
new file mode 100644
index 000000000..c156af421
--- /dev/null
+++ b/js/app/components/mobile/desktop-ui/index.ts
@@ -0,0 +1,5 @@
+export { BottomControlBar } from "./bottom-controls";
+export { LiveBubble } from "./live-bubble";
+export { MuteOverlay } from "./mute-overlay";
+export { TopControlBar } from "./top-controls";
+export { VolumeSlider } from "./volume-slider";
diff --git a/js/app/components/mobile/desktop-ui/live-bubble.tsx b/js/app/components/mobile/desktop-ui/live-bubble.tsx
new file mode 100644
index 000000000..dec9b4d4f
--- /dev/null
+++ b/js/app/components/mobile/desktop-ui/live-bubble.tsx
@@ -0,0 +1,35 @@
+import { Code, View, zero } from "@streamplace/components";
+
+const { borders, gap, h, w, px, bg, text } = zero;
+
+export function LiveBubble() {
+ return (
+
+
+
+
+ LIVE
+
+
+
+ );
+}
diff --git a/js/app/components/mobile/desktop-ui/mute-overlay.tsx b/js/app/components/mobile/desktop-ui/mute-overlay.tsx
new file mode 100644
index 000000000..7f3a1f5e0
--- /dev/null
+++ b/js/app/components/mobile/desktop-ui/mute-overlay.tsx
@@ -0,0 +1,74 @@
+import { Text, View, usePlayerStore, zero } from "@streamplace/components";
+import { VolumeX } from "lucide-react-native";
+import { Pressable } from "react-native";
+
+const { layout, h, w, p, px } = zero;
+
+export function MuteOverlay() {
+ const muteWasForced = usePlayerStore((state) => state.muteWasForced);
+ const setMuted = usePlayerStore((state) => state.setMuted);
+ const setMuteWasForced = usePlayerStore((state) => state.setMuteWasForced);
+
+ if (!muteWasForced) return null;
+
+ return (
+
+ {
+ if (muteWasForced) {
+ setMuted(false);
+ setMuteWasForced(false);
+ }
+ }}
+ style={[
+ {
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 8,
+ },
+ ]}
+ >
+
+
+
+
+
+ Press to unmute
+
+
+
+
+ );
+}
diff --git a/js/app/components/mobile/desktop-ui/top-controls.tsx b/js/app/components/mobile/desktop-ui/top-controls.tsx
new file mode 100644
index 000000000..d62c79ad9
--- /dev/null
+++ b/js/app/components/mobile/desktop-ui/top-controls.tsx
@@ -0,0 +1,107 @@
+import { useNavigation } from "@react-navigation/native";
+import {
+ PlayerUI,
+ Text,
+ View,
+ useAvatars,
+ useCameraToggle,
+ useLivestreamInfo,
+ zero,
+} from "@streamplace/components";
+import { ChevronLeft, MessageSquare, SwitchCamera } from "lucide-react-native";
+import { Image, Platform, Pressable } from "react-native";
+import { LiveBubble } from "./live-bubble";
+
+const { borders, colors, gap, layout, p, r, text } = zero;
+
+interface TopControlBarProps {
+ offline: boolean;
+ isActivelyLive: boolean;
+ ingest: string | null;
+ isChatOpen: boolean;
+ onToggleChat: () => void;
+ safeAreaInsets: { top: number };
+}
+
+export function TopControlBar({
+ offline,
+ isActivelyLive,
+ ingest,
+ isChatOpen,
+ onToggleChat,
+ safeAreaInsets,
+}: TopControlBarProps) {
+ const navigation = useNavigation();
+ const { profile } = useLivestreamInfo();
+ const { doSetIngestCamera } = useCameraToggle();
+ const avatars = useAvatars(profile?.did ? [profile?.did] : []);
+
+ return (
+
+
+ {Platform.OS !== "web" && (
+ {
+ navigation.canGoBack()
+ ? navigation.goBack()
+ : navigation.navigate("Home", { screen: "StreamList" });
+ }}
+ style={[p[2], r[1]]}
+ >
+
+
+ )}
+
+
+
+
+ {profile?.handle}
+
+ {!offline && }
+
+
+
+
+ {isActivelyLive && (
+ <>
+
+
+
+
+
+ >
+ )}
+ {ingest !== null && (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/js/app/components/mobile/desktop-ui/volume-slider.tsx b/js/app/components/mobile/desktop-ui/volume-slider.tsx
new file mode 100644
index 000000000..23b72e971
--- /dev/null
+++ b/js/app/components/mobile/desktop-ui/volume-slider.tsx
@@ -0,0 +1,109 @@
+import { Slider, View, usePlayerStore, zero } from "@streamplace/components";
+import { Volume2, VolumeX } from "lucide-react-native";
+import { useCallback } from "react";
+import { Pressable } from "react-native";
+import Animated, {
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from "react-native-reanimated";
+
+const { layout, p, r } = zero;
+
+export function VolumeSlider() {
+ const muted = usePlayerStore((state) => state.muted);
+ const setMuted = usePlayerStore((state) => state.setMuted);
+ const volume = usePlayerStore((state) => state.volume);
+ const setVolume = usePlayerStore((state) => state.setVolume);
+
+ const fadeAnim = useSharedValue(0);
+ const widthAnim = useSharedValue(0);
+
+ const onVolumeHover = useCallback(() => {
+ fadeAnim.value = withTiming(1, { duration: 200 });
+ widthAnim.value = withTiming(200, { duration: 200 });
+ }, [fadeAnim, widthAnim]);
+
+ // Toggle mute state
+ const handleMuteToggle = useCallback(() => {
+ setMuted(!muted);
+ }, [muted, setMuted]);
+
+ const VolumeIcon = muted ? VolumeX : Volume2;
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ opacity: fadeAnim.value,
+ width: widthAnim.value,
+ }));
+
+ // Convert volume (0-1) to percentage (0-100) for slider
+ const sliderValue = (muted ? 0 : volume) * 100;
+ return (
+
+
+
+
+
+
+ {
+ const newVolume = vals[0] / 100; // Convert back to 0-1 range
+ setVolume(newVolume);
+ if (newVolume === 0) {
+ setMuted(true);
+ } else {
+ setMuted(false);
+ }
+ }}
+ asChild
+ >
+
+
+
+
+
+
+
+ );
+}
diff --git a/js/app/components/mobile/offline-counter/index.tsx b/js/app/components/mobile/offline-counter/index.tsx
new file mode 100644
index 000000000..2ff6d42d0
--- /dev/null
+++ b/js/app/components/mobile/offline-counter/index.tsx
@@ -0,0 +1,114 @@
+import {
+ Text,
+ usePlayerStore,
+ useSegment,
+ View,
+ zero,
+} from "@streamplace/components";
+import { useEffect, useState } from "react";
+
+const { gap, h, layout, position, w } = zero;
+
+interface OfflineCounterProps {
+ isMobile?: boolean;
+}
+
+export function OfflineCounter({ isMobile = false }: OfflineCounterProps) {
+ const offline = usePlayerStore((state) => state.offline);
+ const segment = useSegment();
+
+ // Live timer for offline overlay
+ const [timeSinceLastSeen, setTimeSinceLastSeen] = useState("Unknown");
+
+ useEffect(() => {
+ if (!offline || !segment?.startTime) {
+ setTimeSinceLastSeen("Unknown");
+ return;
+ }
+
+ const updateTimer = () => {
+ const now = new Date();
+ const lastSeen = new Date(segment.startTime);
+ const diffMs = now.getTime() - lastSeen.getTime();
+ const diffMinutes = Math.floor(diffMs / 60000);
+ const diffSeconds = Math.floor((diffMs % 60000) / 1000);
+
+ if (diffMinutes > 0) {
+ setTimeSinceLastSeen(`${diffMinutes}m ${diffSeconds}s ago`);
+ } else {
+ setTimeSinceLastSeen(`${diffSeconds}s ago`);
+ }
+ };
+
+ // Update immediately
+ updateTimer();
+
+ // Update every second while offline
+ const interval = setInterval(updateTimer, 1000);
+
+ return () => clearInterval(interval);
+ }, [offline, segment?.startTime]);
+
+ if (!offline) return null;
+
+ const titleFontSize = isMobile ? 24 : 32;
+ const subtitleFontSize = isMobile ? 16 : 18;
+ const descriptionFontSize = isMobile ? 14 : 16;
+ const descriptionMarginTop = isMobile ? 8 : 12;
+ const maxWidth = isMobile ? undefined : 400;
+
+ return (
+
+
+
+ Stream Offline
+
+
+ Last seen: {timeSinceLastSeen}
+
+
+ The stream will resume automatically when the broadcaster returns
+
+
+
+ );
+}
diff --git a/js/app/components/mobile/player.tsx b/js/app/components/mobile/player.tsx
index 0661fcc04..9da00f142 100644
--- a/js/app/components/mobile/player.tsx
+++ b/js/app/components/mobile/player.tsx
@@ -3,25 +3,39 @@ import {
Button,
layout,
LivestreamProvider,
- Player as PlayerInner,
+ Player as PlayerInnerInner,
PlayerProps,
PlayerProvider,
Text,
+ usePlayerDimensions,
+ usePlayerStore,
View,
} from "@streamplace/components";
import { gap, h, pt, w } from "@streamplace/components/src/lib/theme/atoms";
import { ArrowLeft, ArrowRight } from "@tamagui/lucide-icons";
import { selectUserProfile } from "features/bluesky/blueskySlice";
import { useLiveUser } from "hooks/useLiveUser";
+import { useSidebarControl } from "hooks/useSidebarControl";
import { useEffect, useState } from "react";
+import { Animated, ScrollView } from "react-native";
import { useAppSelector } from "store/hooks";
+import { BottomMetadata } from "./bottom-metadata";
+import { DesktopChatPanel } from "./chat";
+import { DesktopUi } from "./desktop-ui";
+import { OfflineCounter } from "./offline-counter";
import { MobileUi } from "./ui";
+import { useResponsiveLayout } from "./useResponsiveLayout";
export function Player(
props: Partial & {
setFullscreen?: (fullscreen: boolean) => void;
},
) {
+ const [showChat, setShowChat] = useState(true);
+ const { shouldShowChatSidePanel, chatPanelWidth, safeAreaInsets } =
+ useResponsiveLayout();
+ const chatVisible = shouldShowChatSidePanel && showChat;
+
const [isStreamingElsewhere, setIsStreamingElsewhere] = useState<
boolean | null
>(null);
@@ -78,7 +92,10 @@ export function Player(
-
-
-
-
- {ingest === null ? (
-
- ) : (
-
-
-
+
+ {isSelfAndNotLive && (
+
)}
-
-
- {isLive && (
+
+ {
+ if (!recordSubmitted && title != "") {
+ setShowLoading(true);
+ }
+ setShowCountdown(false);
+ }}
+ />
+
+
+
+
+
+
+
+ {!isSelfAndNotLive && (
+
+ )}
+ {muteWasForced && (
-
-
- )}
- {isSelfAndNotLive ? (
-
- ) : (
-
- {
+ if (muteWasForced) {
+ setMuted(false);
+ setMuteWasForced(false);
+ }
+ }}
+ style={[
+ {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 8,
+ },
+ ]}
>
-
-
+
+ Tap to unmute
+
+
+
+
+
)}
-
- {
- if (!recordSubmitted && title != "") {
- setShowLoading(true);
- }
- setShowCountdown(false);
- }}
- />
-
-
-
-
>
);
}
diff --git a/js/app/components/mobile/useResponsiveLayout.ts b/js/app/components/mobile/useResponsiveLayout.ts
new file mode 100644
index 000000000..5d1b311c6
--- /dev/null
+++ b/js/app/components/mobile/useResponsiveLayout.ts
@@ -0,0 +1,77 @@
+import { responsiveValue } from "@streamplace/components/src/lib/utils";
+import { useMemo } from "react";
+import { useWindowDimensions } from "react-native";
+import { SharedValue } from "react-native-reanimated";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+export interface ResponsiveLayoutConfig {
+ shouldShowChatSidePanel: boolean;
+ shouldShowFloatingMetrics: boolean;
+ chatPanelWidth: number;
+ safeAreaInsets: {
+ top: number;
+ bottom: number;
+ left: number;
+ right: number;
+ };
+ screenWidth: number;
+ availableHeight: number;
+}
+
+export function useResponsiveLayout({
+ sidebarWidth = 0,
+ sidebarHidden = true,
+ showChatSidePanelOnLandscape = true,
+}: {
+ sidebarWidth?: number | SharedValue;
+ sidebarHidden?: boolean;
+ showChatSidePanelOnLandscape?: boolean;
+} = {}): ResponsiveLayoutConfig & {
+ contentWidth: number;
+} {
+ const { width: screenWidth, height: screenHeight } = useWindowDimensions();
+ const safeAreaInsets = useSafeAreaInsets();
+
+ const sidebarWidthValue = useMemo(() => {
+ if (typeof sidebarWidth === "object" && "value" in sidebarWidth) {
+ return sidebarWidth.value;
+ }
+ return sidebarWidth;
+ }, [sidebarWidth]);
+
+ const isLandscape = screenWidth > screenHeight;
+ const shouldShowChatSidePanel =
+ isLandscape && screenWidth >= 768 && showChatSidePanelOnLandscape;
+
+ const shouldShowFloatingMetrics = screenWidth < 768;
+ const availableHeight =
+ screenHeight - safeAreaInsets.top - safeAreaInsets.bottom;
+
+ const chatPanelWidth = responsiveValue(
+ {
+ md: 320,
+ lg: 400,
+ xl: 480,
+ default: 300,
+ },
+ screenWidth,
+ );
+
+ const availableWidth =
+ screenWidth - safeAreaInsets.left - safeAreaInsets.right / 2;
+
+ const contentWidth =
+ !sidebarHidden && sidebarWidthValue > 0
+ ? availableWidth - sidebarWidthValue
+ : availableWidth;
+
+ return {
+ shouldShowChatSidePanel,
+ shouldShowFloatingMetrics,
+ chatPanelWidth,
+ safeAreaInsets,
+ contentWidth,
+ screenWidth,
+ availableHeight,
+ };
+}
diff --git a/js/app/components/player/player-loading.tsx b/js/app/components/player/player-loading.tsx
index 35200190c..5aebe5632 100644
--- a/js/app/components/player/player-loading.tsx
+++ b/js/app/components/player/player-loading.tsx
@@ -1,6 +1,9 @@
-import { PlayerStatus, usePlayerStore } from "@streamplace/components";
+import {
+ KeepAwake,
+ PlayerStatus,
+ usePlayerStore,
+} from "@streamplace/components";
import { Play } from "@tamagui/lucide-icons";
-import KeepAwake from "components/keep-awake";
import { Spinner } from "components/loading/loading";
import { useTheme, View } from "tamagui";
diff --git a/js/app/src/router.tsx b/js/app/src/router.tsx
index ea5f23977..b942f659a 100644
--- a/js/app/src/router.tsx
+++ b/js/app/src/router.tsx
@@ -121,7 +121,7 @@ type RootStackParamList = {
Download: undefined;
PopoutChat: { user: string };
Embed: { user: string };
- MobileStream: { user: string };
+ LegacyStream: { user: string };
MobileGoLive: undefined;
};
@@ -157,7 +157,7 @@ const linking: LinkingOptions = {
Download: "download",
PopoutChat: "chat-popout/:user",
Embed: "embed/:user",
- MobileStream: "mobile/:user",
+ LegacyStream: "legacy/:user",
MobileGoLive: "mobile-golive",
},
},
@@ -556,8 +556,8 @@ export function StreamplaceDrawer() {
}}
/>
{
/>
;
+ return (
+
+
+
+
+
+
+
+
+
+ );
}
diff --git a/js/app/src/screens/mobile-go-live.tsx b/js/app/src/screens/mobile-go-live.tsx
index fdb9f9912..6dcfcd849 100644
--- a/js/app/src/screens/mobile-go-live.tsx
+++ b/js/app/src/screens/mobile-go-live.tsx
@@ -1,6 +1,5 @@
-import { theme } from "@streamplace/components";
+import { KeepAwake, ThemeProvider } from "@streamplace/components";
import { Redirect } from "components/aqlink";
-import KeepAwake from "components/keep-awake";
import { Player } from "components/mobile/player";
import { FullscreenProvider } from "contexts/FullscreenContext";
import { selectUserProfile } from "features/bluesky/blueskySlice";
@@ -14,11 +13,11 @@ export default function MobileGoLive() {
}
// get player
return (
-
+
-
+
);
}
diff --git a/js/app/src/screens/mobile-stream.tsx b/js/app/src/screens/mobile-stream.tsx
index 01588d48a..9bcd25f62 100644
--- a/js/app/src/screens/mobile-stream.tsx
+++ b/js/app/src/screens/mobile-stream.tsx
@@ -1,5 +1,4 @@
-import { theme } from "@streamplace/components";
-import KeepAwake from "components/keep-awake";
+import { KeepAwake, ThemeProvider } from "@streamplace/components";
import { Player } from "components/mobile/player";
import { PlayerProps } from "components/player/props";
import { FullscreenProvider } from "contexts/FullscreenContext";
@@ -21,11 +20,11 @@ export default function MobileStream({ route }) {
useTitle(user);
return (
-
+
-
+
);
}
diff --git a/js/app/src/screens/multi.tsx b/js/app/src/screens/multi.tsx
index cdad2d736..3e4ab4390 100644
--- a/js/app/src/screens/multi.tsx
+++ b/js/app/src/screens/multi.tsx
@@ -1,7 +1,30 @@
-import { Player } from "components";
-import { PlayerProps } from "components/player/props";
+import {
+ KeepAwake,
+ LivestreamProvider,
+ Player,
+ PlayerProps,
+ PlayerProvider,
+ ThemeProvider,
+ usePlayerStore,
+ zero,
+} from "@streamplace/components";
+import { DesktopUi } from "components/mobile/desktop-ui";
+import { FullscreenProvider } from "contexts/FullscreenContext";
import { useEffect, useState } from "react";
-import { Text, View, XStack, YStack } from "tamagui";
+import { Text, View } from "react-native";
+
+const { layout, flex } = zero;
+
+function IdViewer({ reqid }) {
+ const id = usePlayerStore((p) => p.id);
+ return (
+
+
+ {reqid} {id}
+
+
+ );
+}
export default function MultiScreen({ route }) {
const config = route.params?.config;
@@ -11,6 +34,7 @@ export default function MultiScreen({ route }) {
const [rows, setRows] = useState[][]>([]);
const [error, setError] = useState(null);
+
useEffect(() => {
try {
let nearestSquareExpo = 1;
@@ -39,20 +63,38 @@ export default function MultiScreen({ route }) {
setError(e.message);
}
}, [config]);
+
if (error) {
return {error};
}
+
return (
-
- {rows.map((players, i) => (
-
- {players.map((props, j) => (
-
- {props === null ? : }
+
+
+
+
+ {rows.map((players, i) => (
+
+ {players.map((props, j) => (
+
+ {props === null ? (
+
+ ) : (
+
+
+
+
+
+
+
+
+ )}
+
+ ))}
))}
-
- ))}
-
+
+
+
);
}
diff --git a/js/components/package.json b/js/components/package.json
index e998fdcf5..73d2ee767 100644
--- a/js/components/package.json
+++ b/js/components/package.json
@@ -28,11 +28,14 @@
"dependencies": {
"@atproto/api": "^0.15.7",
"@atproto/crypto": "^0.4.4",
+ "@emoji-mart/react": "^1.1.1",
"@gorhom/bottom-sheet": "^5.1.6",
"@react-navigation/native": "^6.1.18",
"@rn-primitives/dropdown-menu": "^1.2.0",
"@rn-primitives/portal": "^1.3.0",
+ "@rn-primitives/slider": "^1.2.0",
"class-variance-authority": "^0.6.1",
+ "expo-keep-awake": "~14.1.4",
"expo-video": "~2.2.1",
"hls.js": "^1.5.17",
"lucide-react-native": "^0.514.0",
@@ -41,6 +44,7 @@
"react-native-gesture-handler": "~2.26.0",
"react-native-reanimated": "~3.18.0",
"react-native-safe-area-context": "5.4.1",
+ "react-native-svg": "15.12.0",
"react-native-webrtc": "git+https://github.com/streamplace/react-native-webrtc.git#6b8472a771ac47f89217d327058a8a4124a6ae56",
"react-use-websocket": "^4.13.0",
"streamplace": "workspace:*",
diff --git a/js/components/src/components/chat/chat-box.tsx b/js/components/src/components/chat/chat-box.tsx
index 534dc40e0..eeab40ba9 100644
--- a/js/components/src/components/chat/chat-box.tsx
+++ b/js/components/src/components/chat/chat-box.tsx
@@ -1,6 +1,7 @@
-import { X } from "lucide-react-native";
+import Picker from "@emoji-mart/react";
+import { AtSignIcon, X } from "lucide-react-native";
import { useEffect, useMemo, useRef, useState } from "react";
-import { Pressable, TextInput } from "react-native";
+import { Platform, Pressable, TextInput } from "react-native";
import { ChatMessageViewHydrated } from "streamplace";
import {
Button,
@@ -12,26 +13,51 @@ import {
useSetReplyToMessage,
View,
} from "../../";
-import { bg, flex, gap, h, layout, mb, pl, pr, w } from "../../lib/theme/atoms";
+import {
+ bg,
+ flex,
+ gap,
+ h,
+ layout,
+ mb,
+ mr,
+ pl,
+ pr,
+ py,
+ w,
+} from "../../lib/theme/atoms";
import { usePDSAgent } from "../../streamplace-store/xrpc";
import { Textarea } from "../ui/textarea";
import { RenderChatMessage } from "./chat-message";
+import { EmojiData, EmojiSuggestions } from "./emoji-suggestions";
import { MentionSuggestions } from "./mention-suggestions";
+const COOL_EMOJI_LIST = [
+ ..."ππ₯Έππππ₯Έππ₯Έππ₯Έππ
π₯Έππ€«π±π₯Έπ€£πππ₯Έππ€π²π―π°π₯Έπ₯π₯Έπ£π₯Έπππ₯Έπ©π©π₯Έπ€π₯±",
+];
+
export function ChatBox({
isPopout,
chatBoxStyle,
+ emojiData,
}: {
isPopout?: boolean;
chatBoxStyle?: any;
+ emojiData: EmojiData;
}) {
const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState("");
const [showSuggestions, setShowSuggestions] = useState(false);
+ const [showEmojiSuggestions, setShowEmojiSuggestions] = useState(false);
+ const [showEmojiSelector, setShowEmojiSelector] = useState(false);
+ const [emojiIconIndex, setEmojiIconIndex] = useState(
+ Math.floor(Math.random() * COOL_EMOJI_LIST.length),
+ );
const [highlightedIndex, setHighlightedIndex] = useState(0);
const [filteredAuthors, setFilteredAuthors] = useState