diff --git a/CHANGELOG.md b/CHANGELOG.md index 235ac95..a3d7e15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: diff --git a/README.md b/README.md index 841f781..6badbd7 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,9 @@ Turn any web page into an RTMP/HLS stream. Chromium renders the page, ffmpeg cap ![Dashboard](./screenshots/dashboard.png)

- Stream Config - Login Page + Dashboard Config + Stream Config + Login Page

## 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 diff --git a/public/apple-touch-icon-dark.png b/public/apple-touch-icon-dark.png new file mode 100644 index 0000000..86b639f Binary files /dev/null and b/public/apple-touch-icon-dark.png differ diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..57183cf Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/screenshots/dashboard-config.png b/screenshots/dashboard-config.png new file mode 100644 index 0000000..7bc66b9 Binary files /dev/null and b/screenshots/dashboard-config.png differ diff --git a/screenshots/dashboard-mobile.png b/screenshots/dashboard-mobile.png new file mode 100644 index 0000000..3c2627b Binary files /dev/null and b/screenshots/dashboard-mobile.png differ diff --git a/screenshots/dashboard.png b/screenshots/dashboard.png index f575d3e..8d61b46 100644 Binary files a/screenshots/dashboard.png and b/screenshots/dashboard.png differ diff --git a/src/app/apple-touch-icon.png b/src/app/apple-touch-icon.png deleted file mode 100644 index e098833..0000000 Binary files a/src/app/apple-touch-icon.png and /dev/null differ diff --git a/src/app/globals.css b/src/app/globals.css index 6dc8c2f..7a23795 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -31,6 +31,7 @@ body { background: var(--background); color: var(--foreground); margin: 0; + overflow-x: hidden; } button { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 3c3921e..634bd23 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 }) { diff --git a/src/app/page.tsx b/src/app/page.tsx index 90e6b39..27f6a5c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 = { mini: "max-w-[200px]", sm: "max-w-[240px]", md: "max-w-[300px]", lg: "max-w-[380px]" } +const CARD_WIDTHS: Record = { 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 (
@@ -82,7 +83,7 @@ function SettingsPopup({ cardSize, onCardSize, globalPrefs, onGlobalPrefs, authE
-
+

Card size

{(["mini", "sm", "md", "lg"] as CardSize[]).map((s) => ( @@ -154,6 +155,8 @@ export default function GalleryPage() { const [localStatuses, setLocalStatuses] = useState>({}) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) + const [spinningFab, setSpinningFab] = useState(false) + const spinTimerRef = useRef | null>(null) const [cardSize, setCardSize] = useState("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 (
-
-
- Decap Stream -

Decap Stream

- · - riguetto.dev +
+
+ Decap Stream +

Decap Stream

+ · + riguetto.dev
-
- -
@@ -268,9 +280,9 @@ export default function GalleryPage() { /> )} -
+
{showSkeleton ? ( -
+
{[...Array(refreshing ? Math.max(streams.length, 1) : 4)].map((_, i) => ( ))} @@ -285,7 +297,7 @@ export default function GalleryPage() { ) : ( s.id)} strategy={rectSortingStrategy}> -
+
{streams.map((s) => ( )}
+ +
+ + +
) } diff --git a/src/components/StreamCard.tsx b/src/components/StreamCard.tsx index 5ce1b61..f18de0c 100644 --- a/src/components/StreamCard.tsx +++ b/src/components/StreamCard.tsx @@ -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" }, diff --git a/src/components/StreamForm.tsx b/src/components/StreamForm.tsx index 3102e07..cb306aa 100644 --- a/src/components/StreamForm.tsx +++ b/src/components/StreamForm.tsx @@ -281,18 +281,10 @@ export function StreamForm({ initial }: Props) {
GPU acceleration (Chromium)