diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 5ff456b..b091a5b 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -183,25 +183,32 @@ Native system font (SF on Apple, `system-ui` on web). | Label / caption | 13 regular | Secondary text, field labels | | Numeric readout | 13–15 **monospaced digits** | Zoom `2.0×`, exposure `+0.3`, latency `57 ms` — monospaced so values don't jitter while dragging | -### Microcopy (hints, footers, captions) +### Microcopy (explanations, captions, the Documentation screen) Explanatory text is a cost the reader pays on every visit. The rules: -- **A hint earns its place by adding a consequence or constraint the +- **Setting screens are pure controls — no section footers.** Every + explanation lives in the **Documentation** screen (main screen tail → + Documentation, `DocumentationView.swift`), grouped by feature. + Adding or changing a control means updating its Documentation entry + in the same change — the screen is the manual, and a control it + doesn't cover doesn't exist as far as the reader knows. Two + sanctioned exceptions: the version line under the tail (bug reports + need a build to cite) and failure text that must be seen in place + (a broken broadcast extension warns on the main screen). +- **An entry earns its place by adding a consequence or constraint the control's label can't carry** — a precondition ("while the app is open and idle"), a cost ("the phone stays awake"), a boundary ("iOS mutes - DRM audio"). A hint that restates the label, or describes what the - reader can see, gets deleted, not shortened. -- **At most two sentences** — three only when one footer covers two - controls (the Microphone section). Anything longer belongs in the - README, one tap away. + DRM audio"). An entry that restates the label gets deleted, not + shortened. - Sentence case; no exclamation marks; no "please". Em-dash asides over - nested parentheses. Numbers as numerals ("10 seconds"). + nested parentheses. Numbers as numerals ("10 seconds"). Bold the + control's name at the start of its entry so the list scans. - **American English** in user-facing copy ("color"), matching the platform's own language. (These docs may write "colour"; the UI must not.) - Name OBS-side things exactly as OBS shows them: a "LensLink Camera" - source, the Phone IP field. + source, the Phone IP field, an "Apply LUT" filter. - Overlay/status lines follow the pattern *state — action*: "Streaming — tap to wake", "Not visible by name in OBS — tap to allow…". - Diagnostics (probe results, extension checks) are exempt from the diff --git a/ios-app/Sources/ContentView.swift b/ios-app/Sources/ContentView.swift index d1c6e9a..2bfbaf7 100644 --- a/ios-app/Sources/ContentView.swift +++ b/ios-app/Sources/ContentView.swift @@ -15,8 +15,11 @@ struct ContentView: View { @AppStorage("showConnectionHelp") private var showConnectionHelp = true // The behaviour toggles live in a sheet (OptionsView) so the main - // screen stays short — see that file for why. + // screen stays short — see that file for why. The explanations live + // in a second sheet (DocumentationView) so the sections themselves + // are pure controls. @State private var showOptions = false + @State private var showDocs = false // Standby keeps the phone awake (see Streamer.updateIdleTimer) so // remote start stays reachable; this dim overlay is what makes that @@ -64,8 +67,21 @@ struct ContentView: View { OptionsView() .environmentObject(streamer) } - // Opening or closing the sheet is activity too. + .sheet(isPresented: $showDocs) { + NavigationView { + DocumentationView() + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { showDocs = false } + } + } + } + .navigationViewStyle(.stack) + .tint(Theme.accent) + } + // Opening or closing a sheet is activity too. .onChange(of: showOptions) { _ in lastInteraction = Date() } + .onChange(of: showDocs) { _ in lastInteraction = Date() } if dimmed { dimOverlay @@ -100,7 +116,7 @@ struct ContentView: View { // the sheet with its tap-to-wake unreachable, leaving the // screen dark with no visible way back. if streamer.standbyActive && streamer.dimWhileStreaming && - !showOptions && !dimmed && + !showOptions && !showDocs && !dimmed && Date().timeIntervalSince(lastInteraction) > Self.dimAfterSeconds { dim() } else if !streamer.standbyActive && dimmed { @@ -305,20 +321,9 @@ struct ContentView: View { .listRowInsets(EdgeInsets()) .listRowBackground(Color.clear) } header: { + // Sections are pure controls; every explanation lives in + // the Documentation sheet (tail row). Text("Camera & color") - } footer: { - // The colour hint rides the camera footer only while the - // picker is visible; without it the footer is the original - // one-liner. - if VideoEncoder.hdrSupported { - if CameraManager.appleLogCaptureAvailable { - Text("Streams to a \"LensLink Camera\" source in OBS. HDR streams 10-bit HLG color — OBS tone-maps it for SDR scenes; Apple Log streams a flat 10-bit image made for grading with a LUT in OBS. Both are HEVC-only and take effect when the camera next starts.") - } else { - Text("Streams to a \"LensLink Camera\" source in OBS. HDR streams 10-bit HLG color — OBS tone-maps it for SDR scenes; HEVC only, takes effect when the camera next starts.") - } - } else { - Text("Streams to a \"LensLink Camera\" source in OBS.") - } } } @@ -334,10 +339,6 @@ struct ContentView: View { isOn: $streamer.sendAudioReference) } header: { Text("Microphone") - } footer: { - // One capture, two jobs — Streamer enforces the - // exclusivity; this footer is where users learn it. - Text("**Send phone mic** makes this phone the camera's audio in OBS — a wireless mic. **Auto lip-sync** sends the mic only as a timing reference for aligning your real microphone; it's never heard. One mic, one role — turning one on turns the other off.") } } @@ -369,8 +370,6 @@ struct ContentView: View { .listRowBackground(Color.clear) } header: { Text("Screen mirror") - } footer: { - Text("Streams your whole screen, with app audio, to a \"LensLink Screen\" source in OBS. iOS mutes DRM audio (Apple Music, Netflix).") } .onAppear { // Whether the extension survived sideloading — the broadcast @@ -406,6 +405,24 @@ struct ContentView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + Button { + showDocs = true + } label: { + HStack { + Label { + Text("Documentation") + } icon: { + Image(systemName: "book") + .foregroundColor(Theme.accent) + } + Spacer() + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundColor(.secondary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) Link(destination: Self.reportProblemURL) { Label("Report a problem", systemImage: "ladybug") } @@ -413,7 +430,9 @@ struct ContentView: View { Label("LensLink on GitHub", systemImage: "link") } } footer: { - Text("OBS plugin downloads, guides, and bug reports. \(Self.versionLine)") + // The one surviving footer: bug reports need a build to cite, + // and the version has no control it could live beside. + Text(Self.versionLine) } } diff --git a/ios-app/Sources/DocumentationView.swift b/ios-app/Sources/DocumentationView.swift new file mode 100644 index 0000000..e111f9e --- /dev/null +++ b/ios-app/Sources/DocumentationView.swift @@ -0,0 +1,63 @@ +import SwiftUI + +/// The in-app manual: every explanation that used to sit under a +/// section as footer text lives here instead, grouped by feature, so +/// the Setup form and Options stay pure controls. One place to read, +/// one place to keep the wording in sync (docs/UI_DESIGN.md vocabulary +/// still applies). +struct DocumentationView: View { + var body: some View { + Form { + Section { + Text("The camera streams to a **LensLink Camera** source in OBS; the screen broadcast streams to a **LensLink Screen** source. Add the source in OBS, enter the phone's Wi-Fi address as its Phone IP — or plug in USB and set Connection to \"USB cable\" (Windows needs iTunes).") + } header: { + Text("Connecting") + } + + Section { + Text("**HDR (HLG)** streams 10-bit color. OBS tone-maps it for SDR scenes, and HDR canvases get the real thing.") + Text("**Apple Log** (Pro iPhones, iOS 17+) streams a flat 10-bit image made for grading — add an **Apply LUT** filter to the source in OBS and load an Apple Log LUT.") + Text("Both are HEVC-only and take effect when the camera next starts. The Color row only offers what this phone can capture.") + } header: { + Text("Color") + } + + Section { + Text("**Send phone mic** makes this phone the camera's audio in OBS — a wireless mic.") + Text("**Auto lip-sync** sends the mic only as a timing reference for aligning your real microphone; it's never heard.") + Text("One mic, one role — turning one on turns the other off.") + } header: { + Text("Microphone") + } + + Section { + Text("Streams your whole screen, with app audio, to a **LensLink Screen** source — great for mobile games or app demos. iOS mutes DRM audio (Apple Music, Netflix), and your microphone isn't sent — mic yourself in OBS as usual.") + } header: { + Text("Screen mirror") + } + + Section { + Text("**Remote start from OBS**: while the app is open and idle, OBS can start the camera for you. The phone stays awake while it waits — locking it or leaving the app ends remote start. Siri: \"Start streaming with LensLink.\"") + Text("**Dim screen to save battery**: dims 10 seconds into streaming, or a minute into remote-start standby — tap to wake. Never while you're using the app.") + Text("**Allow system video effects** is experimental: it lets iOS lower the frame rate on its own, which the Control Center video effects (Portrait, Studio Light) may require. Takes effect when the camera next starts.") + } header: { + Text("Options") + } + + Section { + Text("The colored border around the Live screen while streaming. Colors, priority order, and per-status off switches are customizable in Options → Tally light.") + } header: { + Text("Tally light") + } + + Section { + Text("**Camera diagnostics** lists the camera's formats — which resolutions support the Control Center video effects, and which are 10-bit HDR or Apple Log capable. Paste it into a bug report if something is missing.") + Text("**Check broadcast link** verifies the screen-mirror extension is alive on this phone, independent of OBS — run it while a screen broadcast is active.") + } header: { + Text("Diagnostics") + } + } + .navigationTitle("Documentation") + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/ios-app/Sources/OptionsView.swift b/ios-app/Sources/OptionsView.swift index 9361e36..69c168c 100644 --- a/ios-app/Sources/OptionsView.swift +++ b/ios-app/Sources/OptionsView.swift @@ -1,10 +1,10 @@ import SwiftUI /// The Options sheet: the set-and-forget behaviour toggles plus the -/// diagnostics, each with its own short explanation. They live off the -/// main screen so the Setup form stays focused on per-stream decisions -/// (camera, color, mic role) — a single wall-of-text footer under a -/// pile of toggles was unreadable, and pushed the form into scrolling. +/// diagnostics. They live off the main screen so the Setup form stays +/// focused on per-stream decisions (camera, color, mic role). No footer +/// text anywhere — every explanation lives in DocumentationView, so +/// both surfaces stay pure controls. struct OptionsView: View { @EnvironmentObject private var streamer: Streamer @Environment(\.dismiss) private var dismiss @@ -19,30 +19,16 @@ struct OptionsView: View { Section { Toggle("Remote start from OBS", isOn: $streamer.remoteStartEnabled) - } footer: { - Text("While the app is open and idle, OBS can start the camera for you. The phone stays awake while it waits — locking it or leaving the app ends remote start. Siri: \"Start streaming with LensLink.\"") - } - - Section { Toggle("Dim screen to save battery", isOn: $streamer.dimWhileStreaming) - } footer: { - Text("Dims 10 seconds into streaming, or a minute into remote-start standby — tap to wake. Never while you're using the app.") - } - - Section { Toggle("Allow system video effects", isOn: $streamer.allowVideoEffects) - } footer: { - Text("Experimental: lets iOS lower the frame rate on its own, which the Control Center video effects (Portrait, Studio Light) may require. Takes effect when the camera next starts.") } Section { NavigationLink(destination: TallyLightOptionsView()) { Text("Tally light") } - } footer: { - Text("The colored border around the Live screen while streaming.") } Section { @@ -83,8 +69,6 @@ struct OptionsView: View { } } header: { Text("Diagnostics") - } footer: { - Text("Camera diagnostics lists the camera's formats and capabilities — paste into a bug report if an effect or color mode is missing. Check broadcast link verifies the screen-mirror extension while a broadcast is running.") } } .navigationTitle("Options")