Adiciona layout mobile responsivo e ícones PWA

---

- Adicionado FABs flutuantes (Refresh e Add) no canto inferior direito para telas abaixo de 640px;
- Ocultados botões de Refresh e Add do header no mobile (hidden sm:flex);
- Cards passam a ocupar largura total no mobile (sm:max-w-[...] com prefixo responsivo em page.tsx e StreamCard.tsx);
- Adicionado overflow-x: hidden no body para eliminar scroll horizontal no mobile;
- Seletor de tamanho de card ocultado no popup de Settings no mobile (hidden sm:flex);
- Adicionados ícones apple-touch-icon.png e apple-touch-icon-dark.png em public/ para instalação como PWA;
- Registrados os ícones no metadata do layout com suporte a light/dark via media query;
- Corrigido estilo do toggle de GPU no StreamForm para transição mais consistente;
- Reduzido shm_size de 2gb para 1gb no exemplo do compose no CHANGELOG.md;
- Atualizada seção de screenshots no README para incluir dashboard-config.png em layout de 3 colunas;

---
This commit is contained in:
2026-04-28 00:48:42 -03:00
parent 4918fa091e
commit 0eb83c19d1
13 changed files with 62 additions and 31 deletions
+2 -1
View File
@@ -16,6 +16,7 @@ Turn any web page into an RTMP/HLS stream. Chromium renders the page in a virtua
- **Open in new tab** — global toggle in Settings to open any action button in a new tab; saved in the browser - **Open in new tab** — global toggle in Settings to open any action button in a new tab; saved in the browser
- **Optional UI authentication** — set `AUTH_USER` + `AUTH_PASS` to password-protect the entire UI - **Optional UI authentication** — set `AUTH_USER` + `AUTH_PASS` to password-protect the entire UI
- **Persistent desired state** — streams restore automatically on container restart - **Persistent desired state** — streams restore automatically on container restart
- **Mobile-friendly UI** — responsive layout for phones: floating Add/Refresh FABs, full-width cards, no horizontal scroll
### Quick start ### Quick start
@@ -24,7 +25,7 @@ services:
decap-stream: decap-stream:
image: ghcr.io/riguettodev/decap-stream:latest image: ghcr.io/riguettodev/decap-stream:latest
restart: unless-stopped restart: unless-stopped
shm_size: "2gb" shm_size: "1gb"
security_opt: security_opt:
- seccomp:unconfined - seccomp:unconfined
ports: ports:
+4 -2
View File
@@ -10,8 +10,9 @@ Turn any web page into an RTMP/HLS stream. Chromium renders the page, ffmpeg cap
![Dashboard](./screenshots/dashboard.png) ![Dashboard](./screenshots/dashboard.png)
<p align="center"> <p align="center">
<img src="./screenshots/stream-config.png" alt="Stream Config" width="49%" /> <img src="./screenshots/dashboard-config.png" alt="Dashboard Config" width="32%" />
<img src="./screenshots/login.png" alt="Login Page" width="49%" /> <img src="./screenshots/stream-config.png" alt="Stream Config" width="32%" />
<img src="./screenshots/login.png" alt="Login Page" width="32" />
</p> </p>
## How it works ## How it works
@@ -43,6 +44,7 @@ All processes are managed by Supervisord. The web UI is a Next.js app that contr
- **Open in new tab** — global toggle in Settings to open any button in a new tab instead of navigating in place; saved in the browser - **Open in new tab** — global toggle in Settings to open any button in a new tab instead of navigating in place; saved in the browser
- **Chromium auto-reload** — per-stream toggle to reload the Chromium page on a configurable interval via Chrome DevTools Protocol; configured from the card menu and persisted on the server - **Chromium auto-reload** — per-stream toggle to reload the Chromium page on a configurable interval via Chrome DevTools Protocol; configured from the card menu and persisted on the server
- **Player client-side auto-reload** — global toggle in Settings to reload the HLS player itself on a configurable interval (in minutes) - **Player client-side auto-reload** — global toggle in Settings to reload the HLS player itself on a configurable interval (in minutes)
- **Mobile-friendly UI** — responsive layout for phones (< 640 px): Add and Refresh become floating action buttons in the bottom-right corner, cards fill the screen width automatically, no horizontal scroll; installable as a PWA with separate light/dark home-screen icons
## Platform Support ## Platform Support
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

+1
View File
@@ -31,6 +31,7 @@ body {
background: var(--background); background: var(--background);
color: var(--foreground); color: var(--foreground);
margin: 0; margin: 0;
overflow-x: hidden;
} }
button { button {
+6
View File
@@ -8,6 +8,12 @@ const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"]
export const metadata: Metadata = { export const metadata: Metadata = {
title: "DecapStream", title: "DecapStream",
description: "Headless stream manager", description: "Headless stream manager",
icons: {
apple: [
{ url: "/apple-touch-icon.png", sizes: "180x180" },
{ url: "/apple-touch-icon-dark.png", sizes: "180x180", media: "(prefers-color-scheme: dark)" },
],
},
} }
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
+46 -17
View File
@@ -1,7 +1,8 @@
"use client" "use client"
import { useEffect, useState, useCallback } from "react" import { useEffect, useState, useCallback, useRef } from "react"
import { Plus, Download, RefreshCw, Settings, X, LogOut } from "lucide-react" import { Plus, Download, RefreshCw, Settings, X, LogOut } from "lucide-react"
import { cn } from "@/lib/utils"
import { StreamCard } from "@/components/StreamCard" import { StreamCard } from "@/components/StreamCard"
import { Toggle } from "@/components/Toggle" import { Toggle } from "@/components/Toggle"
import type { Stream } from "@/types/stream" import type { Stream } from "@/types/stream"
@@ -15,7 +16,7 @@ type GlobalPrefs = { pureMode: boolean; newTab: boolean; autoReload: boolean; re
const DEFAULT_GLOBAL_PREFS: GlobalPrefs = { pureMode: false, newTab: false, autoReload: false, reloadInterval: 2 } const DEFAULT_GLOBAL_PREFS: GlobalPrefs = { pureMode: false, newTab: false, autoReload: false, reloadInterval: 2 }
const CARD_WIDTHS: Record<CardSize, string> = { mini: "max-w-[200px]", sm: "max-w-[240px]", md: "max-w-[300px]", lg: "max-w-[380px]" } const CARD_WIDTHS: Record<CardSize, string> = { mini: "sm:max-w-[200px]", sm: "sm:max-w-[240px]", md: "sm:max-w-[300px]", lg: "sm:max-w-[380px]" }
function SortableStreamCard(props: { function SortableStreamCard(props: {
stream: Stream stream: Stream
@@ -41,7 +42,7 @@ function SortableStreamCard(props: {
} }
function SkeletonCard({ size = "sm" }: { size?: CardSize }) { function SkeletonCard({ size = "sm" }: { size?: CardSize }) {
const widths = { mini: "max-w-[200px]", sm: "max-w-[240px]", md: "max-w-[300px]", lg: "max-w-[380px]" } const widths = { mini: "sm:max-w-[200px]", sm: "sm:max-w-[240px]", md: "sm:max-w-[300px]", lg: "sm:max-w-[380px]" }
return ( return (
<div className={`rounded-lg border border-border bg-card p-3 flex flex-col gap-2.5 w-full ${widths[size]} animate-pulse`}> <div className={`rounded-lg border border-border bg-card p-3 flex flex-col gap-2.5 w-full ${widths[size]} animate-pulse`}>
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
@@ -82,7 +83,7 @@ function SettingsPopup({ cardSize, onCardSize, globalPrefs, onGlobalPrefs, authE
</button> </button>
</div> </div>
<div className="flex flex-col gap-2"> <div className="hidden sm:flex flex-col gap-2">
<p className="text-xs text-muted-foreground tracking-wider uppercase">Card size</p> <p className="text-xs text-muted-foreground tracking-wider uppercase">Card size</p>
<div className="flex gap-2"> <div className="flex gap-2">
{(["mini", "sm", "md", "lg"] as CardSize[]).map((s) => ( {(["mini", "sm", "md", "lg"] as CardSize[]).map((s) => (
@@ -154,6 +155,8 @@ export default function GalleryPage() {
const [localStatuses, setLocalStatuses] = useState<Record<string, string | null>>({}) const [localStatuses, setLocalStatuses] = useState<Record<string, string | null>>({})
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
const [spinningFab, setSpinningFab] = useState(false)
const spinTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [cardSize, setCardSize] = useState<CardSize>("md") const [cardSize, setCardSize] = useState<CardSize>("md")
const [settingsOpen, setSettingsOpen] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false)
const [authEnabled, setAuthEnabled] = useState(false) const [authEnabled, setAuthEnabled] = useState(false)
@@ -224,6 +227,15 @@ export default function GalleryPage() {
setLocalStatuses((prev) => ({ ...prev, [id]: s })) setLocalStatuses((prev) => ({ ...prev, [id]: s }))
}, []) }, [])
async function handleFabRefresh() {
if (spinTimerRef.current) clearTimeout(spinTimerRef.current)
setSpinningFab(true)
const t0 = Date.now()
await fetchStreams(true)
const remaining = Math.max(0, 1000 - (Date.now() - t0))
spinTimerRef.current = setTimeout(() => setSpinningFab(false), remaining)
}
function downloadPlaylist() { function downloadPlaylist() {
window.location.href = `/api/streams/playlist?host=${window.location.hostname}&port=8888` window.location.href = `/api/streams/playlist?host=${window.location.hostname}&port=8888`
} }
@@ -235,21 +247,21 @@ export default function GalleryPage() {
return ( return (
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
<header className="border-b border-border px-6 py-4 flex items-center justify-between"> <header className="border-b border-border px-4 sm:px-6 py-3 sm:py-4 flex items-center justify-between gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 min-w-0 overflow-hidden">
<img src="/web-app-manifest-192x192.png" alt="Decap Stream" className="w-6 h-6 rounded" /> <img src="/web-app-manifest-192x192.png" alt="Decap Stream" className="w-6 h-6 rounded shrink-0" />
<h1 className="text-lg font-semibold tracking-tight">Decap Stream</h1> <h1 className="text-lg font-semibold tracking-tight whitespace-nowrap">Decap Stream</h1>
<span className="text-muted-foreground/40 text-sm select-none">·</span> <span className="text-muted-foreground/40 text-sm select-none whitespace-nowrap">·</span>
<a href="https://riguetto.dev" target="_blank" rel="noopener noreferrer" className="text-xs text-[#888] hover:text-[#ededed] hover:underline transition-colors">riguetto.dev</a> <a href="https://riguetto.dev" target="_blank" rel="noopener noreferrer" className="text-xs text-[#888] hover:text-[#ededed] hover:underline transition-colors whitespace-nowrap">riguetto.dev</a>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 shrink-0">
<button onClick={() => fetchStreams(true)} className={btnBase} title="Refresh"> <button onClick={handleFabRefresh} className={cn(btnBase, "hidden sm:flex")} title="Refresh">
<RefreshCw className={`w-3.5 h-3.5 ${refreshing ? "animate-spin" : ""}`} /> <RefreshCw className={`w-3.5 h-3.5 ${spinningFab ? "animate-spin" : ""}`} />
</button> </button>
<button onClick={() => setSettingsOpen((v) => !v)} className={btnBase} title="Settings"> <button onClick={() => setSettingsOpen((v) => !v)} className={btnBase} title="Settings">
<Settings className="w-3.5 h-3.5" /> <Settings className="w-3.5 h-3.5" />
</button> </button>
<button onClick={() => window.location.href = "/streams/new"} className={btnPrimary} title="New stream"> <button onClick={() => window.location.href = "/streams/new"} className={cn(btnPrimary, "hidden sm:flex")} title="New stream">
<Plus className="w-3.5 h-3.5" /> <Plus className="w-3.5 h-3.5" />
</button> </button>
</div> </div>
@@ -268,9 +280,9 @@ export default function GalleryPage() {
/> />
)} )}
<main className="flex-1 p-6"> <main className="flex-1 px-3 pt-3 pb-40 sm:p-6">
{showSkeleton ? ( {showSkeleton ? (
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-3 sm:gap-4 justify-center sm:justify-start">
{[...Array(refreshing ? Math.max(streams.length, 1) : 4)].map((_, i) => ( {[...Array(refreshing ? Math.max(streams.length, 1) : 4)].map((_, i) => (
<SkeletonCard key={i} size={cardSize} /> <SkeletonCard key={i} size={cardSize} />
))} ))}
@@ -285,7 +297,7 @@ export default function GalleryPage() {
) : ( ) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}> <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={streams.map((s) => s.id)} strategy={rectSortingStrategy}> <SortableContext items={streams.map((s) => s.id)} strategy={rectSortingStrategy}>
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-3 sm:gap-4 justify-center sm:justify-start">
{streams.map((s) => ( {streams.map((s) => (
<SortableStreamCard <SortableStreamCard
key={s.id} key={s.id}
@@ -303,6 +315,23 @@ export default function GalleryPage() {
</DndContext> </DndContext>
)} )}
</main> </main>
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-center gap-3 sm:hidden">
<button
onClick={handleFabRefresh}
className="w-12 h-12 rounded-full border border-[#333] bg-[#1c1c1c] shadow-lg flex items-center justify-center active:bg-[#333] transition-colors"
title="Refresh"
>
<RefreshCw className={`w-4 h-4 ${spinningFab ? "animate-spin" : ""}`} />
</button>
<button
onClick={() => window.location.href = "/streams/new"}
className="w-14 h-14 rounded-full border border-[#333] bg-[#1c1c1c] shadow-lg flex items-center justify-center active:bg-[#333] transition-colors"
title="New stream"
>
<Plus className="w-6 h-6" />
</button>
</div>
</div> </div>
) )
} }
+1 -1
View File
@@ -59,7 +59,7 @@ function copyToClipboard(text: string) {
return Promise.resolve() return Promise.resolve()
} }
const CARD_WIDTHS = { mini: "max-w-[200px]", sm: "max-w-[240px]", md: "max-w-[300px]", lg: "max-w-[380px]" } const CARD_WIDTHS = { mini: "sm:max-w-[200px]", sm: "sm:max-w-[240px]", md: "sm:max-w-[300px]", lg: "sm:max-w-[380px]" }
const SCALE = { const SCALE = {
mini: { card: "p-2 gap-2", name: "text-xs", meta: "text-[10px]", btn: "text-[10px] px-2 py-1 gap-1.5", btnIcon: "w-2.5 h-2.5", menuIcon: "w-3.5 h-3.5", dot: "w-1.5 h-1.5" }, mini: { card: "p-2 gap-2", name: "text-xs", meta: "text-[10px]", btn: "text-[10px] px-2 py-1 gap-1.5", btnIcon: "w-2.5 h-2.5", menuIcon: "w-3.5 h-3.5", dot: "w-1.5 h-1.5" },
+2 -10
View File
@@ -281,18 +281,10 @@ export function StreamForm({ initial }: Props) {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
type="button" type="button"
role="switch"
aria-checked={form.gpu ?? false}
onClick={() => set("gpu", !form.gpu)} onClick={() => set("gpu", !form.gpu)}
className={cn( className={cn("relative w-9 h-5 rounded-full transition-colors shrink-0 overflow-hidden", form.gpu ? "bg-blue-600" : "bg-zinc-600")}
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none",
form.gpu ? "bg-primary" : "bg-zinc-600"
)}
> >
<span className={cn( <span className={cn("absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all", form.gpu ? "left-[18px]" : "left-0.5")} />
"pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-lg transform transition-transform",
form.gpu ? "translate-x-4" : "translate-x-0"
)} />
</button> </button>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-sm">GPU acceleration (Chromium)</span> <span className="text-sm">GPU acceleration (Chromium)</span>