{videos.map((video, index) => {
const thumb = video.posterUrl ?? video.thumbnailUrl;
- const previewVideoUrl = video.url.includes("#")
- ? video.url
- : `${video.url}#t=0.1`;
+ const previewVideoUrl = videoMetadataPreviewSource(
+ videoPreviewSource(video, useMobilePreview),
+ );
const duration = formatDuration(video.durationSec);
return (
{
+ if (typeof window === "undefined" || !window.matchMedia) return;
+
+ const media = window.matchMedia(mobilePreviewMediaQuery);
+ const update = () => setUseMobilePreview(media.matches);
+ update();
+
+ media.addEventListener("change", update);
+ return () => media.removeEventListener("change", update);
+ }, []);
+
+ return useMobilePreview;
+}
+
+export function useVideoPreviewSource(attachment: Attachment): string {
+ return videoPreviewSource(attachment, useShouldUseMobilePreview());
+}
diff --git a/src/components/messageStream/utils/videoPreviewSource.test.ts b/src/components/messageStream/utils/videoPreviewSource.test.ts
new file mode 100644
index 0000000..544d4c9
--- /dev/null
+++ b/src/components/messageStream/utils/videoPreviewSource.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+import type { Attachment } from "../../../types/post";
+import {
+ videoMetadataPreviewSource,
+ videoPreviewSource,
+} from "./videoPreviewSource";
+
+const attachment: Attachment = {
+ id: "att-1",
+ kind: "video",
+ url: "/uploads/desktop-preview.mp4",
+ mobilePreviewUrl: "/uploads/mobile-540p-preview.mp4",
+ mime: "video/mp4",
+ filename: "original.mp4",
+ sizeBytes: 1024,
+};
+
+describe("videoPreviewSource", () => {
+ it("uses the desktop preview by default", () => {
+ expect(videoPreviewSource(attachment, false)).toBe(
+ "/uploads/desktop-preview.mp4",
+ );
+ });
+
+ it("uses mobilePreviewUrl only when mobile preview is active", () => {
+ expect(videoPreviewSource(attachment, true)).toBe(
+ "/uploads/mobile-540p-preview.mp4",
+ );
+ });
+
+ it("falls back to the desktop preview when mobilePreviewUrl is absent", () => {
+ expect(
+ videoPreviewSource({ ...attachment, mobilePreviewUrl: undefined }, true),
+ ).toBe("/uploads/desktop-preview.mp4");
+ });
+
+ it("adds a metadata seek fragment only when the URL has no fragment", () => {
+ expect(videoMetadataPreviewSource("/uploads/video.mp4")).toBe(
+ "/uploads/video.mp4#t=0.1",
+ );
+ expect(videoMetadataPreviewSource("/uploads/video.mp4#t=2")).toBe(
+ "/uploads/video.mp4#t=2",
+ );
+ });
+});
diff --git a/src/components/messageStream/utils/videoPreviewSource.ts b/src/components/messageStream/utils/videoPreviewSource.ts
new file mode 100644
index 0000000..503c1c9
--- /dev/null
+++ b/src/components/messageStream/utils/videoPreviewSource.ts
@@ -0,0 +1,17 @@
+import type { Attachment } from "../../../types/post";
+
+export const mobilePreviewMediaQuery = "(max-width: 760px)";
+
+export function videoPreviewSource(
+ attachment: Attachment,
+ useMobilePreview: boolean,
+): string {
+ if (useMobilePreview && attachment.mobilePreviewUrl) {
+ return attachment.mobilePreviewUrl;
+ }
+ return attachment.url;
+}
+
+export function videoMetadataPreviewSource(url: string): string {
+ return url.includes("#") ? url : `${url}#t=0.1`;
+}
diff --git a/src/languageRoutes.ts b/src/languageRoutes.ts
index b990a7b..106f730 100644
--- a/src/languageRoutes.ts
+++ b/src/languageRoutes.ts
@@ -33,3 +33,55 @@ export function homePathForLang(lang: Lang): string {
if (lang === "en") return "/";
return localizedHomeRoutes.find((route) => route.lang === lang)?.path ?? "/";
}
+
+/** Returns the URL prefix for a language (e.g. "/malay"), or "" for English. */
+export function langPathPrefix(lang: Lang): string {
+ if (lang === "en") return "";
+ return localizedHomeRoutes.find((route) => route.lang === lang)?.path ?? "";
+}
+
+/** Detects which language a URL belongs to by inspecting the path prefix. */
+export function languageFromPathname(pathname: string): Lang {
+ const normalized = normalizePathname(pathname);
+ for (const route of localizedHomeRoutes) {
+ if (normalized === route.path || normalized.startsWith(route.path + "/")) {
+ return route.lang;
+ }
+ }
+ return "en";
+}
+
+/**
+ * Prepends a language prefix to a path. Path may include `?query` or `#hash`;
+ * the prefix is inserted before the pathname only.
+ *
+ * localizePath("/browse", "ms") -> "/malay/browse"
+ * localizePath("/", "ms") -> "/malay"
+ * localizePath("/browse", "en") -> "/browse"
+ */
+export function localizePath(path: string, lang: Lang): string {
+ const prefix = langPathPrefix(lang);
+ if (!prefix) return path;
+ if (!path.startsWith("/")) path = "/" + path;
+ if (path === "/") return prefix;
+ return prefix + path;
+}
+
+/**
+ * Removes any known language prefix from a pathname. Useful when comparing
+ * the current route against canonical (unprefixed) paths.
+ *
+ * stripLangPrefix("/malay/browse") -> "/browse"
+ * stripLangPrefix("/malay") -> "/"
+ * stripLangPrefix("/browse") -> "/browse"
+ */
+export function stripLangPrefix(pathname: string): string {
+ const normalized = normalizePathname(pathname);
+ for (const route of localizedHomeRoutes) {
+ if (normalized === route.path) return "/";
+ if (normalized.startsWith(route.path + "/")) {
+ return normalized.slice(route.path.length);
+ }
+ }
+ return normalized;
+}
diff --git a/src/layouts/PublicLayout.tsx b/src/layouts/PublicLayout.tsx
index 618f8a1..b454568 100644
--- a/src/layouts/PublicLayout.tsx
+++ b/src/layouts/PublicLayout.tsx
@@ -11,7 +11,13 @@ import { DocumentMeta } from "../components/DocumentMeta";
import { SearchPanel } from "../components/SearchPanel";
import { useI18n, type Lang } from "../i18n";
import { LANG_OPTIONS } from "../i18nLanguages";
-import { homePathForLang, isHomePathname } from "../languageRoutes";
+import {
+ homePathForLang,
+ isHomePathname,
+ languageFromPathname,
+ stripLangPrefix,
+} from "../languageRoutes";
+import { useLocalizedPath } from "../useLocalizedPath";
type PublicNavWhich =
| "home"
@@ -29,25 +35,26 @@ function navIsActive(
which: PublicNavWhich,
): boolean {
const sp = new URLSearchParams(search);
+ const stripped = stripLangPrefix(pathname);
switch (which) {
case "home":
return isHomePathname(pathname);
case "browseAll":
- return pathname === "/browse" && !sp.has("sort");
+ return stripped === "/browse" && !sp.has("sort");
case "categories":
return (
- pathname === "/categories" ||
+ stripped === "/categories" ||
(isHomePathname(pathname) && hash === "#categories")
);
case "browseLatest":
- return pathname === "/browse" && sp.get("sort") === "latest";
+ return stripped === "/browse" && sp.get("sort") === "latest";
case "browseRecommended":
- return pathname === "/official-recommendations";
+ return stripped === "/official-recommendations";
case "browsePopular":
- return pathname === "/browse" && sp.get("sort") === "popular";
+ return stripped === "/browse" && sp.get("sort") === "popular";
case "favorites":
return (
- pathname === "/favorites" ||
+ stripped === "/favorites" ||
(isHomePathname(pathname) && hash === "#favorites")
);
default:
@@ -295,6 +302,15 @@ export function PublicLayout() {
const desktopSearchRef = useRef(null);
const desktopSearchPanelRef = useRef(null);
const nav = useNavigate();
+ const lp = useLocalizedPath();
+
+ // Keep i18n state in sync with URL so deep links (`/malay/browse`) flip the
+ // UI language even if the user navigated via address bar or shared link.
+ useEffect(() => {
+ const urlLang = languageFromPathname(pathname);
+ if (urlLang !== lang) setLang(urlLang);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [pathname]);
const na = (which: PublicNavWhich) =>
navIsActive(pathname, search, hash, which);
@@ -304,7 +320,7 @@ export function PublicLayout() {
setLang(nextLang);
if (isHome) nav(homePathForLang(nextLang), { replace: true });
};
- const footerInContentFlow = pathname === "/browse";
+ const footerInContentFlow = stripLangPrefix(pathname) === "/browse";
// Current page name shown in the header brand slot (falls back to the brand).
const pageTitle = usePageTitle();
@@ -344,12 +360,12 @@ export function PublicLayout() {
if (idleId) ric.cancelIdleCallback?.(idleId);
};
}, [lang]);
- const popularHref = "/browse?sort=popular";
+ const popularHref = lp("/browse?sort=popular");
const goSearch = () => {
const s = q.trim();
if (!s) return;
- nav(`/browse?q=${encodeURIComponent(s)}`);
+ nav(lp(`/browse?q=${encodeURIComponent(s)}`));
setOpen(false);
setMobileSearchOpen(false);
setDesktopSearchOpen(false);
@@ -574,35 +590,35 @@ export function PublicLayout() {
aria-label={t("mainNav")}
>
{t("all")}
{t("categories")}
{t("official")}
{t("latest")}
@@ -661,7 +677,7 @@ export function PublicLayout() {
className={`${headerMenuAnimationClass} fixed inset-x-0 top-[64px] z-50 grid gap-2 bg-[#08070c] px-4 py-3 shadow-2xl shadow-black/50 min-[440px]:px-5 sm:px-6 md:top-[70px] md:px-9 min-[1000px]:hidden`}
>
setOpen(false)}
@@ -669,7 +685,7 @@ export function PublicLayout() {
{t("all")}
setOpen(false)}
@@ -677,7 +693,7 @@ export function PublicLayout() {
{t("categories")}
setOpen(false)}
@@ -685,7 +701,7 @@ export function PublicLayout() {
{t("official")}
setOpen(false)}
@@ -693,7 +709,7 @@ export function PublicLayout() {
{t("latest")}
setOpen(false)}
@@ -767,15 +783,16 @@ export function PublicLayout() {
active={isHome}
/>
- {pathname === "/browse" ? : null}
+ {stripLangPrefix(pathname) === "/browse" ? : null}
);
}
diff --git a/src/pages/Categories/index.tsx b/src/pages/Categories/index.tsx
index 16264e3..2edbe83 100644
--- a/src/pages/Categories/index.tsx
+++ b/src/pages/Categories/index.tsx
@@ -5,6 +5,7 @@ import { CategoryIcon } from "../../components/CategoryIcon";
import { useSetPageTitle } from "../../components/PageTitleContext";
import { Skeleton } from "../../components/Skeleton";
import { langQuery, useI18n } from "../../i18n";
+import { useLocalizedPath } from "../../useLocalizedPath";
import { cleanCategoryDisplayName } from "../../utils/categoryDisplay";
import { Reveal } from "../../motion";
@@ -32,6 +33,7 @@ function figmaCategoryRank(category: Category): number {
export function CategoriesPage() {
const { t, lang } = useI18n();
+ const lp = useLocalizedPath();
useSetPageTitle(t("categories"));
const [cats, setCats] = useState
([]);
const [err, setErr] = useState(null);
@@ -89,7 +91,7 @@ export function CategoriesPage() {
className="h-[88px]"
>
([]);
const [rec, setRec] = useState([]);
@@ -298,7 +300,7 @@ export function Home() {
{page.map((c) => (
(
localized path` function bound to the current
+ * UI language. Use this anywhere a `` or `navigate()` target needs to
+ * preserve the active language prefix (e.g. `/malay/browse`).
+ */
+export function useLocalizedPath() {
+ const { lang } = useI18n();
+ return useCallback((path: string) => localizePath(path, lang), [lang]);
+}