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
|
- **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:
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ Turn any web page into an RTMP/HLS stream. Chromium renders the page, ffmpeg cap
|
|||||||

|

|
||||||
|
|
||||||
<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="33%" />
|
||||||
<img src="./screenshots/login.png" alt="Login Page" width="49%" />
|
<img src="./screenshots/stream-config.png" alt="Stream Config" width="33%" />
|
||||||
|
<img src="./screenshots/login.png" alt="Login Page" width="33%" />
|
||||||
</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
|
||||||
|
|
||||||
|
|||||||
|
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);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||