Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 | 2x 2x 50x 50x 2x 48x 2x 50x 50x 50x 50x 19x 50x 2x 43x 172x 172x 172x 2x 7x 28x 28x 2x 2x 8x 12x | // SPDX-License-Identifier: GPL-3.0-only
// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7.
import { Outlet, NavLink, useLocation } from "react-router";
import { usePlatform } from "@/hooks/usePlatform";
import { cn } from "@/lib/utils";
import { useEffect } from "react";
import { mpvSetVisible } from "@/lib/tauri";
import { useFullscreen } from "@/lib/fullscreen-context";
import { Tv, List, FolderOpen, Settings as SettingsIcon } from "lucide-react";
const navItems = [
{ to: "/", label: "Channels", icon: Tv },
{ to: "/player", label: "Player", icon: List },
{ to: "/playlists", label: "Playlists", icon: FolderOpen },
{ to: "/settings", label: "Settings", icon: SettingsIcon },
];
export const AppLayout = () => {
const { layoutMode } = usePlatform();
if (layoutMode === "tv") {
return <TvLayout />;
}
// iPads in portrait use the sidebar layout for now.
return <MainLayout compact={layoutMode === "mobile"} />;
};
/**
* The sidebar layout, or the phone layout with a tab bar when `compact` is
* set. The page sits at the same place in the tree in both, so an iPad that
* rotates or resizes across a breakpoint keeps it mounted. Separate layout
* components remounted the player, which restarted the stream.
*/
const MainLayout = ({ compact }: { compact: boolean }) => {
const { pathname } = useLocation();
const isPlayer = pathname === "/player";
const { isFullscreen } = useFullscreen();
// Hide the native video view when not on the player route so it doesn't
// bleed through transparent areas on other pages.
useEffect(() => {
mpvSetVisible(isPlayer).catch(() => {});
}, [isPlayer]);
// On a phone the player fills the screen and brings its own back button,
// so the tab bar and the insets go away there.
return (
<div
className={cn(
"flex h-screen overflow-hidden",
compact ? "flex-col" : "pt-safe pb-safe pr-safe",
!compact && isPlayer && "safe-insets-black"
)}
>
{/* Only the player goes full screen, so any other page keeps the
sidebar even if full screen was left on. */}
{!compact && <Sidebar hidden={isFullscreen && isPlayer} />}
<main
className={cn(
"flex-1 overflow-hidden",
compact && "min-h-0",
compact && !isPlayer && "pt-safe pl-safe pr-safe"
)}
>
<Outlet />
</main>
{compact && <TabBar hidden={isPlayer} />}
</div>
);
};
const Sidebar = ({ hidden }: { hidden: boolean }) => (
<aside
className={cn(
"w-16 flex flex-col items-center py-3 gap-0.5 border-r border-border bg-card shrink-0 box-content pl-safe",
hidden && "hidden"
)}
>
{navItems.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
cn(
"relative flex flex-col items-center justify-center w-full py-3 gap-1 text-muted-foreground transition-colors",
isActive ? "text-primary" : "hover:text-foreground"
)
}
title={label}
>
{({ isActive }) => (
<>
{isActive && (
<span className="absolute left-0 top-2 bottom-2 w-0.5 rounded-r bg-primary" />
)}
<Icon className="h-5 w-5" />
<span className="text-[9px] font-medium leading-none">{label}</span>
</>
)}
</NavLink>
))}
</aside>
);
const TabBar = ({ hidden }: { hidden: boolean }) => (
<nav
className={cn(
"flex items-center justify-around border-t border-border bg-card/80 backdrop-blur-xs pb-safe pl-safe pr-safe shrink-0",
hidden && "hidden"
)}
>
{navItems.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
cn(
"flex flex-col items-center justify-center min-h-[49px] min-w-[64px] py-1.5 px-3 text-muted-foreground transition-colors",
isActive ? "text-primary" : ""
)
}
>
<Icon className="h-5 w-5" />
<span className="text-[10px] mt-0.5">{label}</span>
</NavLink>
))}
</nav>
);
const TvLayout = () => {
return (
<div className="flex h-screen overflow-hidden">
<aside className="w-20 flex flex-col items-center py-6 gap-2 border-r border-border bg-card/50">
{navItems.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
cn(
"flex flex-col items-center justify-center w-16 h-16 rounded-xl text-muted-foreground transition-colors focus:outline-hidden focus:ring-2 focus:ring-primary",
isActive
? "bg-primary/10 text-primary"
: "hover:bg-accent hover:text-accent-foreground"
)
}
tabIndex={0}
>
<Icon className="h-6 w-6" />
<span className="text-xs mt-1">{label}</span>
</NavLink>
))}
</aside>
<main className="flex-1 overflow-hidden">
<Outlet />
</main>
</div>
);
};
|