From 12646c1e2cfae480d9fd53506fb34aed20783fb5 Mon Sep 17 00:00:00 2001 From: Alfred Gutierrez Date: Sat, 12 Sep 2026 01:16:12 +0900 Subject: [PATCH 1/3] Show how to install ffmpegd when it isn't connected - Options: a line under the ffmpegd toggle says what ffmpegd is and links to its install guide. - When ffmpegd is enabled but unreachable, Options and the Queue show the address being tried and the setup steps: brew install, run it in the folder with your videos, and that the page connects on its own. - The steps wait 1.5s before appearing so a normal connect doesn't flash them. --- src/App.tsx | 1 + src/components/FfmpegdSetup.tsx | 67 +++++++++++++++++++++ src/components/Queue.tsx | 3 + src/components/sections/OptionsSection.tsx | 24 +++++++- src/lib/__tests__/ffmpegd.spec.tsx | 70 +++++++++++++++++++++- 5 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 src/components/FfmpegdSetup.tsx diff --git a/src/App.tsx b/src/App.tsx index b32bb37..ad9a559 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -85,6 +85,7 @@ export default function App() { value={form.options} onChange={updateOptions} ffmpegdEnabled={ffmpegd.enabled} + ffmpegdConnected={ffmpegd.connected} onFfmpegdChange={ffmpegd.setEnabled} ffmpegdAddress={ffmpegd.address} onFfmpegdAddressChange={ffmpegd.setAddress} diff --git a/src/components/FfmpegdSetup.tsx b/src/components/FfmpegdSetup.tsx new file mode 100644 index 0000000..8e0462b --- /dev/null +++ b/src/components/FfmpegdSetup.tsx @@ -0,0 +1,67 @@ +import { useEffect, useState } from 'react' +import { host } from '@/lib/ffmpegd' + +export const FFMPEGD_INSTALL_URL = 'https://github.com/alfg/ffmpegd#install' + +// The socket takes a moment to open on page load, so wait before telling +// someone whose daemon is running fine how to install it. +export const SETUP_DELAY_MS = 1500 + +const link = 'text-accent underline underline-offset-2 hover:text-accent-hover' +// nowrap keeps a command in one piece rather than breaking it mid-line on a phone. +const code = 'rounded bg-sunken px-1 font-mono text-xs whitespace-nowrap' + +// host:port without the scheme, for display. Falls back to the raw address. +function displayAddress(): string { + try { + return new URL(host()).host + } catch { + return host() + } +} + +/** + * How to get ffmpegd running, shown wherever ffmpegd is enabled but not + * connected. Renders nothing for the first SETUP_DELAY_MS so a normal connect + * does not flash it; the parent unmounts it once the socket opens. + * + * `explain` adds a sentence on what ffmpegd is, for places that don't already + * say so (the Options tab does, right above it). + */ +export default function FfmpegdSetup({ explain = true }: { explain?: boolean }) { + const [visible, setVisible] = useState(false) + + useEffect(() => { + const id = window.setTimeout(() => setVisible(true), SETUP_DELAY_MS) + return () => window.clearTimeout(id) + }, []) + + if (!visible) return null + + return ( +
+

+ Can't reach ffmpegd at {displayAddress()} +

+

+ {explain ? 'ffmpegd is a small companion app that runs these commands on your computer. ' : ''} + To set it up: +

+
    +
  1. + Install it with Homebrew: brew install alfg/tap/ffmpegd. + For Windows, Go or Docker, see{' '} + + the install guide + + . An older copy may need updating to connect. +
  2. +
  3. + Run ffmpegd in the folder with your videos. Input and output + paths are relative to that folder. +
  4. +
  5. This page connects on its own once ffmpegd is running.
  6. +
+
+ ) +} diff --git a/src/components/Queue.tsx b/src/components/Queue.tsx index 129b1f9..66216f2 100644 --- a/src/components/Queue.tsx +++ b/src/components/Queue.tsx @@ -1,3 +1,4 @@ +import FfmpegdSetup from '@/components/FfmpegdSetup' import { Status, type Job, type Progress } from '@/lib/ffmpegd' interface Props { @@ -65,6 +66,8 @@ export default function Queue({ + {!connected ? : null} + {ordered.length === 0 ? (

Nothing queued. Build a command and press Encode. diff --git a/src/components/sections/OptionsSection.tsx b/src/components/sections/OptionsSection.tsx index e3ab969..a46c9c7 100644 --- a/src/components/sections/OptionsSection.tsx +++ b/src/components/sections/OptionsSection.tsx @@ -3,6 +3,7 @@ import Field from '@/components/ui/Field' import Input from '@/components/ui/Input' import Select from '@/components/ui/Select' import Toggle from '@/components/ui/Toggle' +import FfmpegdSetup, { FFMPEGD_INSTALL_URL } from '@/components/FfmpegdSetup' import { defaultHost, parseAddress } from '@/lib/ffmpegd' import form from '@/lib/form' import type { IFFMpegOptionsForm } from '@/lib/types' @@ -11,6 +12,7 @@ interface Props { value: IFFMpegOptionsForm['options'] onChange: (patch: Partial) => void ffmpegdEnabled: boolean + ffmpegdConnected: boolean onFfmpegdChange: (enabled: boolean) => void /** Saved daemon origin, or '' when the default is in use. */ ffmpegdAddress: string @@ -70,6 +72,7 @@ export default function OptionsSection({ value, onChange, ffmpegdEnabled, + ffmpegdConnected, onFfmpegdChange, ffmpegdAddress, onFfmpegdAddressChange, @@ -110,8 +113,27 @@ export default function OptionsSection({ label="Send encode jobs to a local ffmpegd daemon (experimental)." onChange={onFfmpegdChange} /> +

+ ffmpegd is a small companion app that runs these commands on your computer and reports + progress here.{' '} + + Install ffmpegd + +

{ffmpegdEnabled ? ( - + <> + + {!ffmpegdConnected ? ( +
+ +
+ ) : null} + ) : null} diff --git a/src/lib/__tests__/ffmpegd.spec.tsx b/src/lib/__tests__/ffmpegd.spec.tsx index 65999f4..5cdac6b 100644 --- a/src/lib/__tests__/ffmpegd.spec.tsx +++ b/src/lib/__tests__/ffmpegd.spec.tsx @@ -1,9 +1,10 @@ import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest' -import { render, screen, cleanup, act, waitFor } from '@testing-library/react' +import { render, screen, cleanup, act, waitFor, fireEvent, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import App from '@/App' import storage from '@/lib/storage' import { FFMPEGD_KEY, POLL_MS, QUEUE_KEY, Status } from '@/lib/ffmpegd' +import { FFMPEGD_INSTALL_URL, SETUP_DELAY_MS } from '@/components/FfmpegdSetup' const commandText = () => screen.getByTestId('command').textContent?.replace(/\s+/g, ' ').trim() ?? '' @@ -293,3 +294,70 @@ describe('ffmpegd', () => { expect(FakeSocket.instances).toHaveLength(1) }) }) + +describe('ffmpegd setup hint', () => { + const setup = () => screen.queryByRole('status') + const wait = (ms: number) => act(async () => { vi.advanceTimersByTime(ms) }) + + it('links to the install guide from Options, even while ffmpegd is off', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('tab', { name: 'Options' })) + + const link = screen.getByRole('link', { name: 'Install ffmpegd' }) + expect(link.getAttribute('href')).toBe(FFMPEGD_INSTALL_URL) + expect(setup()).toBeNull() + }) + + it('shows setup steps in Options when enabled but not connected, after a grace period', async () => { + vi.useFakeTimers() + enableFfmpegd() + render() + fireEvent.click(screen.getByRole('tab', { name: 'Options' })) + + // A normal connect takes a moment; don't flash install steps at it. + await wait(SETUP_DELAY_MS - 100) + expect(setup()).toBeNull() + + await wait(200) + const hint = setup()! + expect(hint.textContent).toContain("Can't reach ffmpegd at localhost:3000") + expect(hint.textContent).toContain('brew install alfg/tap/ffmpegd') + expect(within(hint).getByRole('link', { name: 'the install guide' }).getAttribute('href')) + .toBe(FFMPEGD_INSTALL_URL) + }) + + it('goes away once the daemon connects', async () => { + vi.useFakeTimers() + enableFfmpegd() + render() + fireEvent.click(screen.getByRole('tab', { name: 'Options' })) + await wait(SETUP_DELAY_MS) + expect(setup()).not.toBeNull() + + await act(async () => socket().open()) + expect(setup()).toBeNull() + }) + + it('names a saved daemon address', async () => { + vi.useFakeTimers() + enableFfmpegd() + localStorage.setItem('host', 'http://mybox:9000') + render() + fireEvent.click(screen.getByRole('tab', { name: 'Options' })) + await wait(SETUP_DELAY_MS) + + expect(setup()!.textContent).toContain("Can't reach ffmpegd at mybox:9000") + }) + + it('shows the same steps on the Queue while offline', async () => { + vi.useFakeTimers() + enableFfmpegd() + render() + fireEvent.click(screen.getByRole('tab', { name: 'Queue' })) + await wait(SETUP_DELAY_MS) + + expect(screen.getByText('● ffmpegd offline')).toBeTruthy() + expect(setup()!.textContent).toContain('Run ffmpegd in the folder with your videos') + }) +}) From 51a92eccfb655595f733c8510f9ba96d7022f903 Mon Sep 17 00:00:00 2001 From: Alfred Gutierrez Date: Sat, 12 Sep 2026 01:22:03 +0900 Subject: [PATCH 2/3] Show ffmpegd's connection status in Options, and name the old versions - Options shows "Connecting to ffmpegd at ..." while the socket opens, then "Connected to ffmpegd at ..." once it does, or the setup steps if nothing answers. Before, connecting showed nothing in Options. - Replace "An older copy may need updating" with what it meant: ffmpegd 0.1.0 and earlier don't allow ffmpeg-commander.com and can't connect. --- src/components/FfmpegdSetup.tsx | 17 ++++++++++---- src/components/sections/OptionsSection.tsx | 15 ++++++++---- src/lib/__tests__/ffmpegd.spec.tsx | 27 +++++++++++++--------- 3 files changed, 38 insertions(+), 21 deletions(-) diff --git a/src/components/FfmpegdSetup.tsx b/src/components/FfmpegdSetup.tsx index 8e0462b..843ddea 100644 --- a/src/components/FfmpegdSetup.tsx +++ b/src/components/FfmpegdSetup.tsx @@ -12,7 +12,7 @@ const link = 'text-accent underline underline-offset-2 hover:text-accent-hover' const code = 'rounded bg-sunken px-1 font-mono text-xs whitespace-nowrap' // host:port without the scheme, for display. Falls back to the raw address. -function displayAddress(): string { +export function displayAddress(): string { try { return new URL(host()).host } catch { @@ -22,8 +22,9 @@ function displayAddress(): string { /** * How to get ffmpegd running, shown wherever ffmpegd is enabled but not - * connected. Renders nothing for the first SETUP_DELAY_MS so a normal connect - * does not flash it; the parent unmounts it once the socket opens. + * connected. For the first SETUP_DELAY_MS it only says it is connecting, so a + * normal connect does not flash install steps; the parent unmounts it once the + * socket opens. * * `explain` adds a sentence on what ffmpegd is, for places that don't already * say so (the Options tab does, right above it). @@ -36,7 +37,13 @@ export default function FfmpegdSetup({ explain = true }: { explain?: boolean }) return () => window.clearTimeout(id) }, []) - if (!visible) return null + if (!visible) { + return ( +

+ Connecting to ffmpegd at {displayAddress()}… +

+ ) + } return (
@@ -54,7 +61,7 @@ export default function FfmpegdSetup({ explain = true }: { explain?: boolean }) the install guide - . An older copy may need updating to connect. + . ffmpegd 0.1.0 and earlier can't connect to this site, so update an older copy.
  • Run ffmpegd in the folder with your videos. Input and output diff --git a/src/components/sections/OptionsSection.tsx b/src/components/sections/OptionsSection.tsx index a46c9c7..6af03db 100644 --- a/src/components/sections/OptionsSection.tsx +++ b/src/components/sections/OptionsSection.tsx @@ -3,7 +3,7 @@ import Field from '@/components/ui/Field' import Input from '@/components/ui/Input' import Select from '@/components/ui/Select' import Toggle from '@/components/ui/Toggle' -import FfmpegdSetup, { FFMPEGD_INSTALL_URL } from '@/components/FfmpegdSetup' +import FfmpegdSetup, { displayAddress, FFMPEGD_INSTALL_URL } from '@/components/FfmpegdSetup' import { defaultHost, parseAddress } from '@/lib/ffmpegd' import form from '@/lib/form' import type { IFFMpegOptionsForm } from '@/lib/types' @@ -128,11 +128,16 @@ export default function OptionsSection({ {ffmpegdEnabled ? ( <> - {!ffmpegdConnected ? ( -
    +
    + {ffmpegdConnected ? ( +

    + ● Connected to ffmpegd at{' '} + {displayAddress()} +

    + ) : ( -
    - ) : null} + )} +
    ) : null}
  • diff --git a/src/lib/__tests__/ffmpegd.spec.tsx b/src/lib/__tests__/ffmpegd.spec.tsx index 5cdac6b..c886399 100644 --- a/src/lib/__tests__/ffmpegd.spec.tsx +++ b/src/lib/__tests__/ffmpegd.spec.tsx @@ -295,8 +295,8 @@ describe('ffmpegd', () => { }) }) -describe('ffmpegd setup hint', () => { - const setup = () => screen.queryByRole('status') +describe('ffmpegd setup hint and status', () => { + const text = () => document.body.textContent ?? '' const wait = (ms: number) => act(async () => { vi.advanceTimersByTime(ms) }) it('links to the install guide from Options, even while ffmpegd is off', async () => { @@ -306,10 +306,11 @@ describe('ffmpegd setup hint', () => { const link = screen.getByRole('link', { name: 'Install ffmpegd' }) expect(link.getAttribute('href')).toBe(FFMPEGD_INSTALL_URL) - expect(setup()).toBeNull() + expect(text()).not.toContain('Connecting to ffmpegd') + expect(text()).not.toContain("Can't reach ffmpegd") }) - it('shows setup steps in Options when enabled but not connected, after a grace period', async () => { + it('says it is connecting, then shows setup steps if nothing answers', async () => { vi.useFakeTimers() enableFfmpegd() render() @@ -317,26 +318,30 @@ describe('ffmpegd setup hint', () => { // A normal connect takes a moment; don't flash install steps at it. await wait(SETUP_DELAY_MS - 100) - expect(setup()).toBeNull() + expect(text()).toContain('Connecting to ffmpegd at localhost:3000') + expect(text()).not.toContain("Can't reach ffmpegd") await wait(200) - const hint = setup()! + const hint = screen.getByRole('status') expect(hint.textContent).toContain("Can't reach ffmpegd at localhost:3000") expect(hint.textContent).toContain('brew install alfg/tap/ffmpegd') + expect(hint.textContent).toContain('ffmpegd 0.1.0 and earlier can\'t connect to this site') expect(within(hint).getByRole('link', { name: 'the install guide' }).getAttribute('href')) .toBe(FFMPEGD_INSTALL_URL) }) - it('goes away once the daemon connects', async () => { + it('shows that it connected, in place of the steps', async () => { vi.useFakeTimers() enableFfmpegd() render() fireEvent.click(screen.getByRole('tab', { name: 'Options' })) await wait(SETUP_DELAY_MS) - expect(setup()).not.toBeNull() + expect(text()).toContain("Can't reach ffmpegd") await act(async () => socket().open()) - expect(setup()).toBeNull() + expect(screen.getByRole('status').textContent).toContain('Connected to ffmpegd at localhost:3000') + expect(text()).not.toContain("Can't reach ffmpegd") + expect(text()).not.toContain('Connecting to ffmpegd') }) it('names a saved daemon address', async () => { @@ -347,7 +352,7 @@ describe('ffmpegd setup hint', () => { fireEvent.click(screen.getByRole('tab', { name: 'Options' })) await wait(SETUP_DELAY_MS) - expect(setup()!.textContent).toContain("Can't reach ffmpegd at mybox:9000") + expect(screen.getByRole('status').textContent).toContain("Can't reach ffmpegd at mybox:9000") }) it('shows the same steps on the Queue while offline', async () => { @@ -358,6 +363,6 @@ describe('ffmpegd setup hint', () => { await wait(SETUP_DELAY_MS) expect(screen.getByText('● ffmpegd offline')).toBeTruthy() - expect(setup()!.textContent).toContain('Run ffmpegd in the folder with your videos') + expect(screen.getByRole('status').textContent).toContain('Run ffmpegd in the folder with your videos') }) }) From 8857bb327a9eb8caba70e22ab973e07446953b02 Mon Sep 17 00:00:00 2001 From: Alfred Gutierrez Date: Sat, 12 Sep 2026 01:25:04 +0900 Subject: [PATCH 3/3] Word the ffmpegd update note simply, and fix a lint warning - The setup steps now say to make sure an existing install is up to date, rather than naming the versions that can't connect. - Move displayHost into lib/ffmpegd so FfmpegdSetup.tsx only exports components and constants (react/only-export-components). --- src/components/FfmpegdSetup.tsx | 17 ++++------------- src/components/sections/OptionsSection.tsx | 6 +++--- src/lib/__tests__/ffmpegd.spec.tsx | 2 +- src/lib/ffmpegd.ts | 9 +++++++++ 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/components/FfmpegdSetup.tsx b/src/components/FfmpegdSetup.tsx index 843ddea..55bcd21 100644 --- a/src/components/FfmpegdSetup.tsx +++ b/src/components/FfmpegdSetup.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { host } from '@/lib/ffmpegd' +import { displayHost } from '@/lib/ffmpegd' export const FFMPEGD_INSTALL_URL = 'https://github.com/alfg/ffmpegd#install' @@ -11,15 +11,6 @@ const link = 'text-accent underline underline-offset-2 hover:text-accent-hover' // nowrap keeps a command in one piece rather than breaking it mid-line on a phone. const code = 'rounded bg-sunken px-1 font-mono text-xs whitespace-nowrap' -// host:port without the scheme, for display. Falls back to the raw address. -export function displayAddress(): string { - try { - return new URL(host()).host - } catch { - return host() - } -} - /** * How to get ffmpegd running, shown wherever ffmpegd is enabled but not * connected. For the first SETUP_DELAY_MS it only says it is connecting, so a @@ -40,7 +31,7 @@ export default function FfmpegdSetup({ explain = true }: { explain?: boolean }) if (!visible) { return (

    - Connecting to ffmpegd at {displayAddress()}… + Connecting to ffmpegd at {displayHost()}…

    ) } @@ -48,7 +39,7 @@ export default function FfmpegdSetup({ explain = true }: { explain?: boolean }) return (

    - Can't reach ffmpegd at {displayAddress()} + Can't reach ffmpegd at {displayHost()}

    {explain ? 'ffmpegd is a small companion app that runs these commands on your computer. ' : ''} @@ -61,7 +52,7 @@ export default function FfmpegdSetup({ explain = true }: { explain?: boolean }) the install guide - . ffmpegd 0.1.0 and earlier can't connect to this site, so update an older copy. + . If it's already installed, make sure it's up to date.

  • Run ffmpegd in the folder with your videos. Input and output diff --git a/src/components/sections/OptionsSection.tsx b/src/components/sections/OptionsSection.tsx index 6af03db..bd7df15 100644 --- a/src/components/sections/OptionsSection.tsx +++ b/src/components/sections/OptionsSection.tsx @@ -3,8 +3,8 @@ import Field from '@/components/ui/Field' import Input from '@/components/ui/Input' import Select from '@/components/ui/Select' import Toggle from '@/components/ui/Toggle' -import FfmpegdSetup, { displayAddress, FFMPEGD_INSTALL_URL } from '@/components/FfmpegdSetup' -import { defaultHost, parseAddress } from '@/lib/ffmpegd' +import FfmpegdSetup, { FFMPEGD_INSTALL_URL } from '@/components/FfmpegdSetup' +import { defaultHost, displayHost, parseAddress } from '@/lib/ffmpegd' import form from '@/lib/form' import type { IFFMpegOptionsForm } from '@/lib/types' @@ -132,7 +132,7 @@ export default function OptionsSection({ {ffmpegdConnected ? (

    ● Connected to ffmpegd at{' '} - {displayAddress()} + {displayHost()}

    ) : ( diff --git a/src/lib/__tests__/ffmpegd.spec.tsx b/src/lib/__tests__/ffmpegd.spec.tsx index c886399..6f22dc8 100644 --- a/src/lib/__tests__/ffmpegd.spec.tsx +++ b/src/lib/__tests__/ffmpegd.spec.tsx @@ -325,7 +325,7 @@ describe('ffmpegd setup hint and status', () => { const hint = screen.getByRole('status') expect(hint.textContent).toContain("Can't reach ffmpegd at localhost:3000") expect(hint.textContent).toContain('brew install alfg/tap/ffmpegd') - expect(hint.textContent).toContain('ffmpegd 0.1.0 and earlier can\'t connect to this site') + expect(hint.textContent).toContain("If it's already installed, make sure it's up to date.") expect(within(hint).getByRole('link', { name: 'the install guide' }).getAttribute('href')) .toBe(FFMPEGD_INSTALL_URL) }) diff --git a/src/lib/ffmpegd.ts b/src/lib/ffmpegd.ts index 88ecbde..2db4047 100644 --- a/src/lib/ffmpegd.ts +++ b/src/lib/ffmpegd.ts @@ -114,6 +114,15 @@ export const wsUri = () => { export const host = () => read(HOST_KEY) || defaultHost() +/** host() as host:port without the scheme, for display. */ +export function displayHost(): string { + try { + return new URL(host()).host + } catch { + return host() + } +} + export async function listFiles(prefix = ''): Promise { const res = await fetch(`${host()}/files?prefix=${encodeURIComponent(prefix)}`) if (!res.ok) throw new Error(`ffmpegd responded ${res.status}`)