Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 16 additions & 9 deletions docs/UI_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 42 additions & 23 deletions ios-app/Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.")
}
}
}

Expand All @@ -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.")
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -406,14 +405,34 @@ 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")
}
Link(destination: URL(string: "https://github.com/MyNamesEMurray/LensLink")!) {
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)
}
}

Expand Down
63 changes: 63 additions & 0 deletions ios-app/Sources/DocumentationView.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
24 changes: 4 additions & 20 deletions ios-app/Sources/OptionsView.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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")
Expand Down
Loading