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..55bcd21 --- /dev/null +++ b/src/components/FfmpegdSetup.tsx @@ -0,0 +1,65 @@ +import { useEffect, useState } from 'react' +import { displayHost } 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' + +/** + * 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 + * 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). + */ +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 ( +

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

+ ) + } + + return ( +
+

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

+

+ {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 + + . If it's already installed, make sure it's up to date. +
  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..bd7df15 100644 --- a/src/components/sections/OptionsSection.tsx +++ b/src/components/sections/OptionsSection.tsx @@ -3,7 +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 { 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' @@ -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,32 @@ 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 ? ( +

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

+ ) : ( + + )} +
+ ) : null} diff --git a/src/lib/__tests__/ffmpegd.spec.tsx b/src/lib/__tests__/ffmpegd.spec.tsx index 65999f4..6f22dc8 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,75 @@ describe('ffmpegd', () => { expect(FakeSocket.instances).toHaveLength(1) }) }) + +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 () => { + 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(text()).not.toContain('Connecting to ffmpegd') + expect(text()).not.toContain("Can't reach ffmpegd") + }) + + it('says it is connecting, then shows setup steps if nothing answers', 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(text()).toContain('Connecting to ffmpegd at localhost:3000') + expect(text()).not.toContain("Can't reach ffmpegd") + + await wait(200) + 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("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) + }) + + 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(text()).toContain("Can't reach ffmpegd") + + await act(async () => socket().open()) + 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 () => { + vi.useFakeTimers() + enableFfmpegd() + localStorage.setItem('host', 'http://mybox:9000') + render() + fireEvent.click(screen.getByRole('tab', { name: 'Options' })) + await wait(SETUP_DELAY_MS) + + expect(screen.getByRole('status').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(screen.getByRole('status').textContent).toContain('Run ffmpegd in the folder with your videos') + }) +}) 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}`)