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; ---
@@ -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
|
||||
- **Optional UI authentication** — set `AUTH_USER` + `AUTH_PASS` to password-protect the entire UI
|
||||
- **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
|
||||
|
||||
@@ -24,7 +25,7 @@ services:
|
||||
decap-stream:
|
||||
image: ghcr.io/riguettodev/decap-stream:latest
|
||||
restart: unless-stopped
|
||||
shm_size: "2gb"
|
||||
shm_size: "1gb"
|
||||
security_opt:
|
||||
- seccomp:unconfined
|
||||
ports:
|
||||
|
||||
@@ -10,8 +10,9 @@ Turn any web page into an RTMP/HLS stream. Chromium renders the page, ffmpeg cap
|
||||

|
||||
|
||||
<p align="center">
|
||||
<img src="./screenshots/stream-config.png" alt="Stream Config" width="49%" />
|
||||
<img src="./screenshots/login.png" alt="Login Page" width="49%" />
|
||||
<img src="./screenshots/dashboard-config.png" alt="Dashboard Config" width="33%" />
|
||||
<img src="./screenshots/stream-config.png" alt="Stream Config" width="33%" />
|
||||
<img src="./screenshots/login.png" alt="Login Page" width="33%" />
|
||||
</p>
|
||||
|
||||
## 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
|
||||
- **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)
|
||||
- **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
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 5.9 KiB |
@@ -31,6 +31,7 @@ body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
button {
|
||||
|
||||
@@ -8,6 +8,12 @@ const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"]
|
||||
export const metadata: Metadata = {
|
||||
title: "DecapStream",
|
||||
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 }) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"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 { cn } from "@/lib/utils"
|
||||
import { StreamCard } from "@/components/StreamCard"
|
||||
import { Toggle } from "@/components/Toggle"
|
||||
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 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: {
|
||||
stream: Stream
|
||||
@@ -41,7 +42,7 @@ function SortableStreamCard(props: {
|
||||
}
|
||||
|
||||
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 (
|
||||
<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">
|
||||
@@ -82,7 +83,7 @@ function SettingsPopup({ cardSize, onCardSize, globalPrefs, onGlobalPrefs, authE
|
||||
</button>
|
||||
</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>
|
||||
<div className="flex gap-2">
|
||||
{(["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 [loading, setLoading] = useState(true)
|
||||
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 [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [authEnabled, setAuthEnabled] = useState(false)
|
||||
@@ -224,6 +227,15 @@ export default function GalleryPage() {
|
||||
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() {
|
||||
window.location.href = `/api/streams/playlist?host=${window.location.hostname}&port=8888`
|
||||
}
|
||||
@@ -235,21 +247,21 @@ export default function GalleryPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<header className="border-b border-border px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<img src="/web-app-manifest-192x192.png" alt="Decap Stream" className="w-6 h-6 rounded" />
|
||||
<h1 className="text-lg font-semibold tracking-tight">Decap Stream</h1>
|
||||
<span className="text-muted-foreground/40 text-sm select-none">·</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>
|
||||
<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 min-w-0 overflow-hidden">
|
||||
<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 whitespace-nowrap">Decap Stream</h1>
|
||||
<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 whitespace-nowrap">riguetto.dev</a>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => fetchStreams(true)} className={btnBase} title="Refresh">
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${refreshing ? "animate-spin" : ""}`} />
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button onClick={handleFabRefresh} className={cn(btnBase, "hidden sm:flex")} title="Refresh">
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${spinningFab ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
<button onClick={() => setSettingsOpen((v) => !v)} className={btnBase} title="Settings">
|
||||
<Settings className="w-3.5 h-3.5" />
|
||||
</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" />
|
||||
</button>
|
||||
</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 ? (
|
||||
<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) => (
|
||||
<SkeletonCard key={i} size={cardSize} />
|
||||
))}
|
||||
@@ -285,7 +297,7 @@ export default function GalleryPage() {
|
||||
) : (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<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) => (
|
||||
<SortableStreamCard
|
||||
key={s.id}
|
||||
@@ -303,6 +315,23 @@ export default function GalleryPage() {
|
||||
</DndContext>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ function copyToClipboard(text: string) {
|
||||
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 = {
|
||||
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" },
|
||||
|
||||
@@ -281,18 +281,10 @@ export function StreamForm({ initial }: Props) {
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={form.gpu ?? false}
|
||||
onClick={() => set("gpu", !form.gpu)}
|
||||
className={cn(
|
||||
"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"
|
||||
)}
|
||||
className={cn("relative w-9 h-5 rounded-full transition-colors shrink-0 overflow-hidden", form.gpu ? "bg-blue-600" : "bg-zinc-600")}
|
||||
>
|
||||
<span className={cn(
|
||||
"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"
|
||||
)} />
|
||||
<span className={cn("absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all", form.gpu ? "left-[18px]" : "left-0.5")} />
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm">GPU acceleration (Chromium)</span>
|
||||
|
||||