@@ -126,7 +129,7 @@ export function StreamInfo({
{sessionState.status === "authenticated" &&
sessionState.session.did !== author?.did && (
)}
@@ -209,6 +212,7 @@ function CopyButton({
type: "vod" | "live";
rkey?: string;
}) {
+ const { t } = useTranslation("common");
const share = nodeBaseURL
? assembleShareLink(nodeBaseURL, type, false, rkey)
: undefined;
@@ -238,7 +242,7 @@ function CopyButton({
- Copy
+ {t("share-copy")} {
if (share) {
@@ -246,7 +250,7 @@ function CopyButton({
}
}}
>
- Copy Link
+ {t("share-copy-link")}
{
@@ -257,7 +261,7 @@ function CopyButton({
}
}}
>
- Copy Embed Code
+ {t("share-copy-embed-code")}
{
@@ -266,7 +270,7 @@ function CopyButton({
}
}}
>
- Copy Embed URL
+ {t("share-copy-embed-url")}
diff --git a/js/web/src/components/stream/stream-notifications.tsx b/js/web/src/components/stream/stream-notifications.tsx
index 4f89aeadd..21cdd60d3 100644
--- a/js/web/src/components/stream/stream-notifications.tsx
+++ b/js/web/src/components/stream/stream-notifications.tsx
@@ -4,6 +4,7 @@ import type { LivestreamStore } from "@streamplace/core";
import { segmentize, type Facet, type FacetFeature } from "@streamplace/core";
import { EyeOff, Pin, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
import type {
ChatMessageViewHydrated,
PinnedRecordViewHydrated,
@@ -47,6 +48,7 @@ function PinnedNotification({
store: LivestreamStore;
comment: PinnedRecordViewHydrated;
}) {
+ const { t } = useTranslation("common");
const { state: sessionState, pdsAgent, did } = useSession();
const streamerDid = useStore(store, (s) => s.livestream?.author.did);
const canUnpin = did && streamerDid && did === streamerDid;
@@ -142,7 +144,7 @@ function PinnedNotification({
type="button"
onClick={handleUnpin}
className="p-1 rounded hover:bg-white/10 text-neutral-400 hover:text-neutral-200 transition-colors"
- aria-label="Unpin message"
+ aria-label={t("chat-unpin-message")}
>
@@ -151,7 +153,7 @@ function PinnedNotification({
type="button"
onClick={handleDismiss}
className="p-1 rounded hover:bg-white/10 text-neutral-400 hover:text-neutral-200 transition-colors"
- aria-label="Dismiss pinned message"
+ aria-label={t("chat-dismiss-pinned")}
>
@@ -190,6 +192,7 @@ function PinnedRichText({
}
function TeleportNotification({ teleport }: { teleport: any }) {
+ const { t } = useTranslation("common");
const targetDid = teleport.target;
const startsAt = teleport.startsAt
? new Date(teleport.startsAt).getTime()
@@ -212,7 +215,7 @@ function TeleportNotification({ teleport }: { teleport: any }) {
return (
- Teleporting in{" "}
+ {t("teleporting-in")}{" "}
{display}
diff --git a/js/web/src/components/stream/user-offline.tsx b/js/web/src/components/stream/user-offline.tsx
index 2bcb9c73e..55a12b825 100644
--- a/js/web/src/components/stream/user-offline.tsx
+++ b/js/web/src/components/stream/user-offline.tsx
@@ -1,15 +1,20 @@
+import { useTranslation } from "react-i18next";
+
// Inline "stream offline" overlay rendered on top of the video element
// when the streamer is not currently live. Distinct from the
// page-level offline state in routes/$user.tsx (which replaces the
// whole player) — this is for the in-page "stream is offline, the
// page is still here" case.
export function UserOffline({ user }: { user: string }) {
+ const { t } = useTranslation("common");
return (
-
Stream offline
+
+ {t("stream-offline")}
+
- {user} is not currently streaming
+ {t("user-not-streaming", { user })}
diff --git a/js/web/src/components/stream/video-section.tsx b/js/web/src/components/stream/video-section.tsx
index 00f75a1ef..1fff01598 100644
--- a/js/web/src/components/stream/video-section.tsx
+++ b/js/web/src/components/stream/video-section.tsx
@@ -1,10 +1,12 @@
import type { LivestreamStore } from "@streamplace/core";
-import { useMemo } from "react";
+import { useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
import { useStore } from "zustand";
import { useShallow } from "zustand/react/shallow";
import { Player } from "../../components/player/player";
import type { Liveness } from "../../hooks/use-liveness-state";
import { getStreamplaceUrl } from "../../lib/streamplace-url";
+import { DanmuOverlay } from "./danmu-overlay";
import { UserOffline } from "./user-offline";
type Segment = { width: number; height: number } | null;
@@ -14,11 +16,15 @@ export function VideoSection({
store,
user,
liveness,
+ devMode = false,
}: {
store: LivestreamStore;
user: string;
liveness: Liveness;
+ devMode?: boolean;
}) {
+ const [showDanmu, setShowDanmu] = useState(false);
+
const state = useStore(
store,
useShallow((s) => ({
@@ -45,6 +51,10 @@ export function VideoSection({
problems={state.problems}
playlistUrl={playlistUrl}
thumbnailUrl={thumbnailUrl}
+ store={store}
+ showDanmu={showDanmu}
+ onShowDanmuChange={setShowDanmu}
+ devMode={devMode}
/>
);
}
@@ -61,6 +71,10 @@ export function VideoSectionInner({
playlistUrl,
thumbnailUrl,
mode = "live",
+ store,
+ showDanmu = false,
+ onShowDanmuChange,
+ devMode = false,
}: {
user: string;
liveness: Liveness;
@@ -69,7 +83,12 @@ export function VideoSectionInner({
playlistUrl: string;
thumbnailUrl: string;
mode?: "live" | "vod";
+ store?: LivestreamStore;
+ showDanmu?: boolean;
+ onShowDanmuChange?: (show: boolean) => void;
+ devMode?: boolean;
}) {
+ const { t } = useTranslation("common");
const neverLive = liveness === "never-live";
// calculate seg ratio for poster aspect ratio correction
@@ -97,14 +116,27 @@ export function VideoSectionInner({
}}
/>
) : (
-
+
+ )}
+
+ {/* Danmu overlay sits on top of the video */}
+ {store && (
+
)}
{liveness === "live" && (
- Live
+ {t("live-badge")}
)}
@@ -113,9 +145,9 @@ export function VideoSectionInner({
-
Reconnecting…
+
{t("reconnecting")}
- The stream may resume shortly.
+ {t("stream-may-resume")}
diff --git a/js/web/src/components/video/video-card.tsx b/js/web/src/components/video/video-card.tsx
index 17e24ac68..c423fe281 100644
--- a/js/web/src/components/video/video-card.tsx
+++ b/js/web/src/components/video/video-card.tsx
@@ -4,6 +4,7 @@
// /$user/vod/:tid.
import { Link } from "@tanstack/react-router";
+import { useTranslation } from "react-i18next";
import type { PlaceStreamVideo } from "streamplace";
import type { VideoView } from "../../hooks/use-video-list";
import {
@@ -12,16 +13,6 @@ import {
getVideoThumbnailUrl,
} from "../../lib/video";
-function formatCount(count: number): string {
- if (count >= 1_000_000) {
- return `${(count / 1_000_000).toFixed(count >= 10_000_000 ? 0 : 1)}M`;
- }
- if (count >= 1_000) {
- return `${(count / 1_000).toFixed(count >= 10_000 ? 0 : 1)}K`;
- }
- return `${count}`;
-}
-
export function VideoCard({
video,
avatarUrl,
@@ -29,11 +20,12 @@ export function VideoCard({
video: VideoView;
avatarUrl?: string;
}) {
+ const { t } = useTranslation("common");
const record = video.record as PlaceStreamVideo.Record;
const author = video.author;
const user = author.handle || author.did;
const tid = getTidFromAtUri(video.uri);
- const title = record.title || "Untitled";
+ const title = record.title || t("untitled");
const thumbnailUrl = getVideoThumbnailUrl(record, author.did);
const duration = formatDuration(record.durationMs);
const viewCount = video.viewCounts?.count ?? 0;
@@ -103,8 +95,8 @@ export function VideoCard({
@{user}