diff --git a/js/app/components/provider/provider.shared.tsx b/js/app/components/provider/provider.shared.tsx
index e8ad876d8..2b75446f5 100644
--- a/js/app/components/provider/provider.shared.tsx
+++ b/js/app/components/provider/provider.shared.tsx
@@ -5,6 +5,7 @@ import {
} from "@react-navigation/native";
import * as Sentry from "@sentry/react-native";
import {
+ BrandedThemeProvider,
I18nProvider,
ThemeProvider,
StreamplaceProvider as ZustandStreamplaceProvider,
@@ -85,19 +86,21 @@ function ProviderInner({
return (
-
-
-
-
-
-
- {children}
-
-
-
-
-
-
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+
);
}
diff --git a/js/app/components/sidebar/sidebar.tsx b/js/app/components/sidebar/sidebar.tsx
index 253c2150b..3e40d5a12 100644
--- a/js/app/components/sidebar/sidebar.tsx
+++ b/js/app/components/sidebar/sidebar.tsx
@@ -6,7 +6,7 @@ import {
ParamListBase,
useNavigation,
} from "@react-navigation/native";
-import { Text, zero } from "@streamplace/components";
+import { Text, useMainLogo, useSiteTitle, zero } from "@streamplace/components";
import { useAQLinkHref } from "components/aqlink";
import React from "react";
import { Image, Platform, Pressable, View } from "react-native";
@@ -46,6 +46,9 @@ export default function Sidebar({
externalItems = [],
}: SidebarProps) {
const navigation = useNavigation();
+ const siteTitle = useSiteTitle();
+ const mainLogo = useMainLogo();
+
const animatedSidebarStyle = useAnimatedStyle(() => {
return {
minWidth: widthAnim.value,
@@ -87,13 +90,22 @@ export default function Sidebar({
},
]}
>
-
- {!collapsed && Streamplace}
+ {mainLogo ? (
+
+ ) : (
+
+ )}
+ {!collapsed && {siteTitle}}
{state.routes.map((route) => {
diff --git a/js/components/src/hooks/index.ts b/js/components/src/hooks/index.ts
index 1f590b942..9d5fedc2f 100644
--- a/js/components/src/hooks/index.ts
+++ b/js/components/src/hooks/index.ts
@@ -1,6 +1,7 @@
// barrel file :)
export * from "./useAvatars";
export * from "./useCameraToggle";
+export * from "./useDocumentTitle";
export * from "./useKeyboard";
export * from "./useKeyboardSlide";
export * from "./useLivestreamInfo";
diff --git a/js/components/src/hooks/useDocumentTitle.tsx b/js/components/src/hooks/useDocumentTitle.tsx
new file mode 100644
index 000000000..9669517de
--- /dev/null
+++ b/js/components/src/hooks/useDocumentTitle.tsx
@@ -0,0 +1,17 @@
+import { useEffect } from "react";
+import { Platform } from "react-native";
+import { useSiteTitle } from "../streamplace-store";
+
+/**
+ * Hook to set the document title on web based on branding.
+ * No-op on native platforms.
+ */
+export function useDocumentTitle() {
+ const siteTitle = useSiteTitle();
+
+ useEffect(() => {
+ if (Platform.OS === "web" && typeof document !== "undefined") {
+ document.title = siteTitle;
+ }
+ }, [siteTitle]);
+}
diff --git a/js/components/src/lib/theme/branded-theme-provider.tsx b/js/components/src/lib/theme/branded-theme-provider.tsx
new file mode 100644
index 000000000..20cc9d110
--- /dev/null
+++ b/js/components/src/lib/theme/branded-theme-provider.tsx
@@ -0,0 +1,58 @@
+import { useMemo, type ReactNode } from "react";
+import {
+ useAccentColor,
+ usePrimaryColor,
+ useStreamplaceStore,
+} from "../../streamplace-store";
+import { ThemeProvider, type Theme } from "./theme";
+
+interface BrandedThemeProviderProps {
+ children: ReactNode;
+ defaultTheme?: "light" | "dark" | "system";
+ forcedTheme?: "light" | "dark";
+}
+
+/**
+ * ThemeProvider wrapper that automatically applies branding colors from the
+ * broadcaster's branding configuration.
+ */
+export function BrandedThemeProvider({
+ children,
+ defaultTheme,
+ forcedTheme,
+}: BrandedThemeProviderProps) {
+ const primaryColor = usePrimaryColor();
+ const accentColor = useAccentColor();
+ const brandingLoading = useStreamplaceStore((state) => state.brandingLoading);
+
+ // Build color theme overrides from branding
+ const colorTheme = useMemo>(() => {
+ // don't override until branding is loaded
+ if (brandingLoading) {
+ return {};
+ }
+
+ const overrides: Partial = {};
+
+ if (primaryColor) {
+ overrides.primary = primaryColor;
+ overrides.ring = primaryColor;
+ }
+
+ if (accentColor) {
+ overrides.accent = accentColor;
+ }
+
+ return overrides;
+ }, [primaryColor, accentColor, brandingLoading]);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/js/components/src/lib/theme/index.ts b/js/components/src/lib/theme/index.ts
index dd3c86ff9..c42abad4d 100644
--- a/js/components/src/lib/theme/index.ts
+++ b/js/components/src/lib/theme/index.ts
@@ -12,6 +12,9 @@ export {
type ThemeIcons,
} from "./theme";
+// Branded theme provider
+export { BrandedThemeProvider } from "./branded-theme-provider";
+
// Design tokens
export {
animations,
diff --git a/js/components/src/streamplace-provider/index.tsx b/js/components/src/streamplace-provider/index.tsx
index 40d9c34a2..671b425ad 100644
--- a/js/components/src/streamplace-provider/index.tsx
+++ b/js/components/src/streamplace-provider/index.tsx
@@ -1,5 +1,6 @@
import { SessionManager } from "@atproto/api/dist/session-manager";
import { useEffect, useRef } from "react";
+import { useDocumentTitle } from "../hooks";
import {
useBrandingAutoFetch,
useFetchBroadcasterDID,
@@ -43,6 +44,7 @@ export function StreamplaceProvider({
export function BrandingFetcher({ children }: { children: React.ReactNode }) {
const fetchBroadcasterDID = useFetchBroadcasterDID();
useBrandingAutoFetch();
+ useDocumentTitle();
useEffect(() => {
fetchBroadcasterDID();