Antenna channels come from the tuner's lineup. The banner, guide and a rebuilt Reception view show what the tuner measured: a meter from the latest readings' quality, lock or no lock, errors a second, strength and when, with "Not measured yet" before any reading. Reception lists each RF channel the scan found, then the rest, with its transmitter facts, history and a Measure now button that shows its progress; OK on a row watches it, and it works from a remote. The NextGen filter shows only for a tuner that takes in ATSC 3.0. Antenna presets, "unlikely channels", ZIP previews and terrain profiles leave the app and the API (otascan keeps them).
- commit
- c21939bb04a07a3ab5d0f506631959110a465293
- parent
- 57ef46f4cb891c4fa74f033d3d096beb3813c69d
- author
- FelineStateMachine <226808055+FelineStateMachine@users.noreply.github.com>
- kind
- feat risk score 2.8 of 3, Jev's estimate
| M | README.md | 21 | +- |
| M | app.go | 19 | +- |
| M | frontend/ | 890 | ++++++++++++++++-------------- |
| M | frontend/ | 25 | +- |
| M | frontend/ | 125 | ++-- |
| M | frontend/ | 18 | +- |
| M | internal/ | 18 | - |
| M | internal/ | 20 | +- |
| M | internal/ | 32 | - |
| M | internal/ | 4 | +- |
| M | internal/ | 10 | +- |
| M | internal/ | 4 | +- |
| M | internal/ | 37 | - |
| M | internal/ | 26 | +- |
| M | internal/ | 6 | +- |
| M | internal/ | 12 | +- |
@@ -4,8 +4,11 @@ A desktop TV and program guide for free over-the-air broadcasts, built with Go a
44 [Wails v2](https://wails.io). It answers three questions for a US ZIP code:55 66 1. **What is on the air here?** Every licensed transmitter within a radius, from the FCC.7-2. **What can I actually receive?** A terrain-aware signal estimate per transmitter for8- indoor, attic and rooftop antennas, with a radar map, aiming advice and a path profile.7+2. **What do I actually receive?** The channels the tuner's scan found, and what it8+ measured on each RF channel (lock, strength, quality, errors), over time and on demand,9+ beside each transmitter's records and a radar map. Planning an antenna before there's10+ a tuner is `otascan`'s job: terrain-aware estimates for indoor, attic and rooftop11+ antennas.912 3. **What is on now?** A Gracenote-backed grid guide, live TV, and recordings.1013 1114 There is one setup: an HDHomeRun on the network as the tuner, a home server running@@ -27,7 +30,8 @@ open build/bin/Airwaves.app
2730 wails dev # live-reload development2831 ```2932 30-There is also a terminal report that needs only Go:33+There is also a terminal report that needs only Go, for planning an antenna: the34+reception it estimates from the terrain (see How reception is estimated):3135 3236 ```sh3337 go run ./cmd/otascan -zip 80302 # rooftop antenna, receivable channels@@ -136,16 +140,16 @@ had.
136140 | C | Closed captions on or off |137141 | V | Next audio track (second language, described video) |138142 | f | Favorite this channel |139-| I | Info banner; again for reception details, again to hide |143+| I | Info banner; again for the measured signal, again to hide |140144 | M / Shift F | Mute / fullscreen |141145 142146 | Key | Elsewhere |143147 | --- | --- |144148 | G | Guide: arrows move, Up from the top row to the filters (Favorites, Sports...; also `[` `]`). Enter watches what's on, or shows a program to come's actions; hold Enter, or I, for any program's: watch, record, series, new episodes only, favorite, hide. Home jumps to now, R / Shift R / N record, f favorite, H hide (U undoes) |145149 | D | Recordings: Left (or `[` `]`) for Library, Coming up, Series. Enter shows a recording's actions: resume, from the start, watched, delete (press twice); Shift Enter starts over, W marks watched, Delete twice removes; on a series K sets how many to keep, N new episodes only |146-| A | Reception: arrows pick a transmitter; Left for the antenna presets (or `1` `2` `3`) and to preview any ZIP code, then copy a shareable summary |150+| A | Reception: what the tuners measured, by RF channel (the ones the scan found first), with the transmitters licensed on each. Up and Down pick one, Enter watches its first channel; Left for the tuner and Measure now, which reads every RF channel on a free tuner and shows its progress |147151 | W | Weather: current conditions, alerts, the next 24 hours, 7-day, radar loop and satellite; Up and Down move through them, Left to watch the weather channel |148-| `,` or S | Settings: server, antenna, guide hours, deleting watched recordings, hidden channels |152+| `,` or S | Settings: server, guide hours, deleting watched recordings, hidden channels, the tuner |149153 | Esc | Back to TV; from a recording, back to live |150154 151155 With only a remote's arrows, OK and Back (an Android TV remote), every view is@@ -172,7 +176,8 @@ is the supported source if this grows beyond that.
172176 173177 ## How reception is estimated174178 175-`internal/reception` computes free-space loss plus Deygout multiple knife-edge diffraction179+For `otascan` only: the app shows what the tuner measured instead (see The lineup is the180+tuner's). `internal/reception` computes free-space loss plus Deygout multiple knife-edge diffraction176181 over a terrain profile on a 4/3 effective earth, then compares the received power against177182 the ATSC 1.0 threshold (15.2 dB C/N over a 6 MHz noise floor, with extra man-made noise178183 on VHF). Each antenna preset adds its height, gain by band and wall or cable loss.@@ -596,7 +601,7 @@ internal/dvr recording rules and reconciliation with Tvheadend
596601 internal/tvh Tvheadend API: channels, DVR entries, network setup, input status; tvhtest fakes it597602 internal/fcc FCC TV Query client and parser598603 internal/terrain Terrarium elevation tiles and path profiles599-internal/reception propagation model and antenna presets604+internal/reception propagation model and antenna presets (otascan)600605 internal/atsc3 RabbitEars ATSC 3.0 list parser601606 internal/guide Gracenote lineup and listings602607 internal/lineup merges all of the above into a Report, and describes the tuner's channels from it@@ -12,7 +12,6 @@ import (
1212 1313 "airwaves/internal/api"1414 "airwaves/internal/dvr"15- "airwaves/internal/reception"1615 "airwaves/internal/service"1716 "airwaves/internal/store"1817 "airwaves/internal/stream"@@ -79,11 +78,10 @@ func (a *App) server() (*api.Client, store.Settings, error) {
7978 8079 // Boot is the frontend's first call.8180 type Boot struct {82- Settings store.Settings `json:"settings"`83- Presets []reception.Preset `json:"presets"`84- Info service.Info `json:"info"`85- Config service.Config `json:"config"`86- Error string `json:"error,omitempty"` // server unset or unreachable81+ Settings store.Settings `json:"settings"`82+ Info service.Info `json:"info"`83+ Config service.Config `json:"config"`84+ Error string `json:"error,omitempty"` // server unset or unreachable8785 // ServerURL is the server's base URL, for images and the WX channel.8886 ServerURL string `json:"serverUrl,omitempty"`8987 View string `json:"view"` // initial view override@@ -103,7 +101,7 @@ type Boot struct {
103101 // specific view at launch (tv, info, guide, antenna, recordings, settings).104102 func (a *App) Boot() Boot {105103 c, s, err := a.server()106- out := Boot{Settings: s, Presets: reception.Presets, View: os.Getenv("AIRWAVES_VIEW"), Lite: os.Getenv("AIRWAVES_LITE"),104+ out := Boot{Settings: s, View: os.Getenv("AIRWAVES_VIEW"), Lite: os.Getenv("AIRWAVES_LITE"),107105 AutoScale: autoScale(), NativeZoom: nativeZoom, Version: version}108106 if err != nil {109107 out.Error = err.Error()@@ -208,13 +206,6 @@ func (a *App) Measure() (*service.SweepStatus, error) {
208206 return c.Measure(a.ctx)209207 }210208 211-// Profile and Preview answered with reception estimates, which the app212-// no longer shows; they stay until its interface stops calling them.213-func (a *App) Profile(int, int, string) (any, error) { return nil, api.ErrEstimates }214-215-// Preview: see Profile.216-func (a *App) Preview(string) (any, error) { return nil, api.ErrEstimates }217-218209 // Weather returns the local weather report from the server.219210 func (a *App) Weather() (*weather.Report, error) {220211 c, _, err := a.server()@@ -15,24 +15,94 @@ const clock = (t) => clockFormat.format(new Date(t));
1515 const COMPASS = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];1616 const compass = (deg) => COMPASS[Math.round(deg / 22.5) % 16];1717 18-const TIERS = ['unknown', 'unlikely', 'weak', 'fair', 'good', 'strong'];19-const TIER_LABEL = { strong: 'Strong', good: 'Good', fair: 'Fair', weak: 'Weak', unlikely: 'Unlikely', unknown: 'Unknown' };20-const TIER_COLOR = { strong: 'var(--t-strong)', good: 'var(--t-good)', fair: 'var(--t-fair)', weak: 'var(--t-weak)', unlikely: 'var(--t-unlikely)', unknown: 'var(--t-unlikely)' };21-const rankOf = (t) => Math.max(0, TIERS.indexOf(t));22-const receivable = (t) => t === 'strong' || t === 'good' || t === 'fair';23-const EARTH_M = 8_495_000; // 4/3 effective earth radius24-2518 function log(level, msg) {2619 try { api().Log(level, String(msg)); } catch { /* not in Wails */ }2720 }2821 29-function meter(tier, big) {30- const lit = rankOf(tier);22+// ---------- signal ----------23+// Everything shown of reception was measured by the server's tuners (its24+// /api/signal): per RF channel, the latest reading and the latest stretch25+// of readings (recent: the last Measure now, or the last while something26+// watched it), with lows and means. The stretch is what's shown: a single27+// reading of a marginal signal jumps around. An HDHomeRun reports strength28+// and quality (its signal-to-noise quality) as percentages; other tuners29+// may report SNR in dB.30+const SIG_COLOR = { lock: 'var(--amber)', part: 'var(--amber-2)', off: 'var(--red)', none: 'var(--faint)' };31+32+// sigOf sums up a measurement ({ signal, recent }, of a channel or an RF33+// channel): its state ('lock' throughout, 'part' some of the time, 'off'34+// none, 'none' never measured), bars to show (0 to 5, from the measured35+// quality), a label and the details.36+function sigOf(m) {37+ const r = m && m.recent;38+ const sig = m && m.signal;39+ if (!r && !sig) return { state: 'none', bars: 0, label: 'Not measured yet', color: SIG_COLOR.none, quality: '', strength: '', errors: null, at: 0, source: '', lockedPct: 0 };40+ const lockedPct = r ? r.lockedPct : sig.lock ? 100 : 0;41+ const state = lockedPct >= 100 ? 'lock' : lockedPct > 0 ? 'part' : 'off';42+ // From the stretch, its means; else the one reading.43+ const val = (key) => (r ? (r[key] ? r[key].avg : null) : sig[key]);44+ const src = r || sig;45+ const q = val('qualityPct');46+ const snr = val('snrDb');47+ const strengthPct = val('strengthPct');48+ const strengthDbm = val('strengthDbm');49+ let bars = 0;50+ if (state !== 'off') {51+ if (q != null) bars = q >= 70 ? 5 : q >= 55 ? 4 : q >= 40 ? 3 : q >= 25 ? 2 : q > 0 ? 1 : 0;52+ else if (snr != null) bars = snr >= 30 ? 5 : snr >= 25 ? 4 : snr >= 20 ? 3 : snr >= 17 ? 2 : snr > 0 ? 1 : 0;53+ else bars = 3;54+ }55+ const quality = q != null ? `${Math.round(q)}%` : snr != null ? `${snr.toFixed(1)} dB` : '';56+ const strength = strengthPct != null ? `${Math.round(strengthPct)}%` : strengthDbm != null ? `${strengthDbm.toFixed(1)} dBm` : '';57+ const errors = sig && sig.errorsPerSec != null ? sig.errorsPerSec : null;58+ const label = state === 'off' ? 'No lock'59+ : state === 'part' ? `Locked ${Math.round(lockedPct)}% of the time`60+ : quality ? `Quality ${quality}` : 'Locked';61+ return { state, bars, label, color: SIG_COLOR[state], quality, strength, errors, at: Date.parse(src.to || src.at), source: src.source, lockedPct };62+}63+64+// meter draws a measurement's bars.65+function meter(s, big) {3166 let html = '';32- for (let i = 1; i <= 5; i++) html += `<i class="${i <= lit ? 'on' : ''}"></i>`;33- return `<span class="meter${big ? ' big' : ''}" style="--c:${TIER_COLOR[tier] || TIER_COLOR.unknown}">${html}</span>`;67+ for (let i = 1; i <= 5; i++) html += `<i class="${i <= s.bars ? 'on' : ''}"></i>`;68+ return `<span class="meter${big ? ' big' : ''} m-${s.state}" style="--c:${s.color}">${html}</span>`;69+}70+71+// ago says how long ago a time was, roughly.72+function ago(t) {73+ const m = Math.round((Date.now() - t) / MIN);74+ if (m < 1) return 'just now';75+ if (m < 60) return `${m} min ago`;76+ const h = Math.round(m / 60);77+ if (h < 24) return `${h} h ago`;78+ return new Date(t).toLocaleDateString([], { month: 'short', day: 'numeric' });79+}80+81+// agoShort is ago for a table: "10 min", "3 h", "Oct 3".82+function agoShort(t) {83+ const m = Math.round((Date.now() - t) / MIN);84+ if (m < 1) return 'now';85+ if (m < 60) return `${m} min`;86+ if (m < 24 * 60) return `${Math.round(m / 60)} h`;87+ return new Date(t).toLocaleDateString([], { month: 'short', day: 'numeric' });88+}89+90+// sigDetail is a measurement's details in words: "strength 82%, 1891+// errors a second, 5 min ago".92+function sigDetail(s) {93+ if (s.state === 'none') return 'Measure now in Reception reads it';94+ const parts = [];95+ if (s.state !== 'lock' && s.quality && s.state !== 'off') parts.push(`quality ${s.quality}`);96+ if (s.strength) parts.push(`strength ${s.strength}`);97+ if (s.errors != null && s.state !== 'off') parts.push(s.errors >= 1 ? `${Math.round(s.errors)} errors a second` : 'no errors');98+ parts.push(`${ago(s.at)}${s.source === 'active' ? ', while tuned' : ''}`);99+ return parts.join(', ');34100 }35101 102+// trouble is a measurement that explains a bad picture: no lock, a lock103+// that comes and goes, or a stream breaking up (5 errors a second or more).104+const trouble = (s) => s.state === 'off' || s.state === 'part' || (s.errors != null && s.errors >= 5);105+36106 let toastTimer = 0;37107 function toast(msg, ms = 3500, html = false) {38108 const t = $('#toast');@@ -64,11 +134,14 @@ document.body.classList.toggle('android-tv', androidTV);
64134 const state = {65135 boot: null,66136 settings: null,67- presets: [],68137 report: null,69138 guide: null,70- byFacility: new Map(), // facilityId -> best station139+ byFacility: new Map(), // facilityId -> nearest station71140 bySite: new Map(), // "facilityId:rf" -> station141+ antenna: null, // the tuner and its RF channels, as last measured (/api/signal)142+ antennaChans: [], // the tuner's channels, hidden ones too143+ byRF: new Map(), // RF channel -> what's known of it (antenna.muxes)144+ sigStamp: 0, // counts signal updates, for redraws72145 lineup: [],73146 custom: [], // the server's own channels: the weather channel, folder, Jellyfin and YouTube channels74147 current: null,@@ -85,8 +158,7 @@ const state = {
85158 gSpan: 150,86159 gRow: 0,87160 gTime: 0,88- aSel: null,89- tuners: [],161+ aRF: 0, // the RF channel picked in Reception90162 info: null, // backend: local engine or a server91163 config: null, // location the lineup is built for92164 dvr: null, // recording state from the server@@ -106,6 +178,13 @@ async function scan(refresh) {
106178 const snap = await api().Scan(refresh);107179 state.report = snap.report;108180 state.guide = snap.guide || { programs: {} };181+ // The antenna channels are the tuner's: those its scan found, described182+ // from the FCC's records and the listings. Their keys (for favorites and183+ // hidden channels) carry the listings' call sign where there is one184+ // ("2.1|KWGNDT"), as keys always have, else the station's.185+ state.antennaChans = (snap.antenna ? snap.antenna.channels : snap.report.channels || [])186+ .map((c) => ({ ...c, key: `${c.number}|${c.guideCallSign || c.callSign}` }));187+ if (snap.antenna) applySignal(snap.antenna);109188 // The server's own channels, with the details set on its admin page. The110189 // weather channel plays here, from the WeatherStar display; the others'111190 // schedules join the guide like a station's, under their own guide id,@@ -130,11 +209,11 @@ async function scan(refresh) {
130209 function indexData() {131210 state.byFacility.clear();132211 state.bySite.clear();133- for (const s of state.report.stations) {212+ const stations = [...state.report.stations].sort((a, b) => a.distanceKm - b.distanceKm);213+ for (const s of stations) {134214 if (!state.byFacility.has(s.facilityId)) state.byFacility.set(s.facilityId, s);135215 state.bySite.set(`${s.facilityId}:${s.rfChannel}`, s);136216 }137- for (const c of state.report.channels) c.key = `${c.number}|${c.callSign}`;138217 const progs = (state.guide && state.guide.programs) || {};139218 for (const list of Object.values(progs)) {140219 for (const p of list) { p._s = Date.parse(p.start); p._e = Date.parse(p.end); }@@ -146,7 +225,6 @@ function indexData() {
146225 // number or name: the weather channel has one key, and a custom channel's147226 // carries its name as well as its number (migrateKeys follows either).148227 const WEATHER_KEY = 'weather';149-const OWN_TIER = { indoor: 'strong', attic: 'strong', rooftop: 'strong' };150228 151229 // A custom channel's category counts as these genres for its programs, so152230 // the guide's filters find it; categories with no filter of their own get@@ -169,7 +247,7 @@ function ownChannel(c) {
169247 call: c.callSign || '', callSign: c.callSign || '', baseCall: c.callSign || '',170248 kind: c.kind || 'folder', category: c.category || 'Other', description: c.description || '',171249 logo: c.logo ? state.boot.serverUrl + c.logo : '',172- own: true, weather, custom: !weather, guideId: weather ? '' : `custom:${c.number}`, tier: OWN_TIER,250+ own: true, weather, custom: !weather, guideId: weather ? '' : `custom:${c.number}`,173251 };174252 }175253 @@ -177,7 +255,7 @@ function ownChannel(c) {
177255 const OLD_WX = {178256 key: WEATHER_KEY, number: 'WX', major: 0, minor: 0, name: 'Airwaves Weather', network: 'Local Forecast',179257 call: 'WX', callSign: 'WX', baseCall: 'WX', kind: 'weather', category: 'Weather', description: '', logo: '',180- own: true, weather: true, custom: false, guideId: '', tier: OWN_TIER,258+ own: true, weather: true, custom: false, guideId: '',181259 };182260 183261 const weatherChannel = () => state.custom.find((c) => c.weather) || null;@@ -185,19 +263,23 @@ const weatherChannel = () => state.custom.find((c) => c.weather) || null;
185263 // migrateKeys moves saved favorites and hidden channels to where the186264 // server's own channels are now: the weather channel's from the old WX key,187265 // and a custom channel's to its current number and name (found by name, or188-// else by number). Keys of channels not listed now are kept for when they189-// are. A saved last channel of WX becomes the weather channel's number, and190-// one no longer in use the new number of the custom channel a saved key191-// names with it.266+// else by number). An antenna channel's whose call sign changed moves to267+// the one channel with its number. Keys of channels not listed now are kept268+// for when they are. A saved last channel of WX becomes the weather269+// channel's number, and one no longer in use the new number of the custom270+// channel a saved key names with it.192271 async function migrateKeys() {193272 const s = state.settings;194273 const custom = state.custom.filter((c) => !c.weather);195- const known = new Set([...state.custom, ...state.report.channels].map((c) => c.key));274+ const known = new Set([...state.custom, ...state.antennaChans].map((c) => c.key));196275 const fix = (k) => {197276 if (k === 'WX|WX') return WEATHER_KEY;198277 if (known.has(k)) return k;199278 const [num, kind, ...rest] = String(k).split('|');200- if (kind !== 'custom') return k;279+ if (kind !== 'custom') {280+ const same = state.antennaChans.filter((c) => c.number === num);281+ return same.length === 1 && kind ? same[0].key : k;282+ }201283 const name = rest.join('|').toLowerCase();202284 const named = name ? custom.filter((c) => c.name.toLowerCase() === name) : [];203285 if (named.length === 1) return named[0].key;@@ -208,7 +290,7 @@ async function migrateKeys() {
208290 const hidden = [...new Set((s.hidden || []).map(fix))];209291 const wx = weatherChannel();210292 let lastChannel = s.lastChannel;211- const numbers = new Set([...state.custom, ...state.report.channels].map((c) => c.number));293+ const numbers = new Set([...state.custom, ...state.antennaChans].map((c) => c.number));212294 if (lastChannel === 'WX' && wx) lastChannel = wx.number;213295 else if (lastChannel && !numbers.has(lastChannel)) {214296 const old = [...(s.favorites || []), ...(s.hidden || [])].find((k) => k.startsWith(`${lastChannel}|custom|`));@@ -237,13 +319,13 @@ function numberKey(c) {
237319 const byNumber = (a, b) => (a.major - b.major) || (a.minor - b.minor);238320 239321 // buildLineup is the channels to show, by number: the server's own and the240-// antenna channels expected at this antenna, less those hidden. A custom241-// channel takes the place of an antenna channel with its number, as it does242-// on the server, which plays it for that number.322+// tuner's, less those hidden. A tuner's channel stays when its signal is323+// weak now; tuning it then says so. A custom channel takes the place of an324+// antenna channel with its number, as it does on the server, which plays it325+// for that number.243326 function buildLineup() {244- const preset = state.settings.antenna;245327 const taken = new Set(state.custom.map(numberKey));246- const antenna = state.report.channels.filter((c) => !taken.has(numberKey(c)) && !isHidden(c) && (state.settings.showAll || receivable(c.tier && c.tier[preset])));328+ const antenna = state.antennaChans.filter((c) => !taken.has(numberKey(c)) && !isHidden(c));247329 state.lineup = [...state.custom.filter((c) => !isHidden(c)), ...antenna].sort(byNumber);248330 if (state.current) state.current = state.lineup.find((c) => c.key === state.current.key) || state.current;249331 }@@ -251,7 +333,14 @@ function buildLineup() {
251333 const programsFor = (ch) => (ch && ch.weather ? wxPrograms() : (ch && ch.guideId && state.guide && state.guide.programs[ch.guideId]) || []);252334 const airingAt = (ch, t) => programsFor(ch).find((p) => p._s <= t && t < p._e);253335 const nextAfter = (ch, t) => programsFor(ch).find((p) => p._s > t);254-const stationFor = (ch) => ch && state.byFacility.get(ch.facilityId);336+// stationFor is the licensed transmitter a channel comes from, on its RF337+// channel.338+const stationFor = (ch) => ch && (state.bySite.get(`${ch.facilityId}:${ch.rf}`) || state.byFacility.get(ch.facilityId));339+// sigFor is what was measured of a channel's RF channel.340+const sigFor = (ch) => (ch && !ch.own ? { signal: ch.signal, recent: ch.recent } : null);341+// nextGen reports whether the tuners take in ATSC 3.0: through Tvheadend342+// they never do, so NextGen viewing (its guide filter) stays out of sight.343+const nextGen = () => !!(state.info && state.info.tuner && state.info.tuner.atsc3);255344 // displayCall is a channel's short label: a station's call sign, or an own256345 // channel's call sign, "Airwaves" when it has none.257346 const displayCall = (ch) => (ch && ch.own ? ch.call || 'Airwaves' : (ch && (ch.baseCall || ch.callSign)) || '');@@ -261,7 +350,7 @@ function findChannel(number) {
261350 const n = number;262351 return state.lineup.find((c) => c.number === n)263352 || state.lineup.find((c) => c.major === Number(n) && (c.minor === 1 || c.minor === 0))264- || state.report.channels.find((c) => c.number === n)353+ || state.antennaChans.find((c) => c.number === n)265354 || state.custom.find((c) => c.number === n);266355 }267356 @@ -309,7 +398,7 @@ async function init() {
309398 }310399 f($('#boot'), 'sub').textContent = state.info.name;311400 for (const w of state.report.warnings || []) bootLog(w, true);312- bootLog(`Ready: ${state.lineup.length} channels you can likely receive`);401+ bootLog(`Ready: ${state.lineup.length} channels`);313402 setTimeout(() => document.body.classList.remove('booting'), 700);314403 // Gone once faded out, so its title stops animating.315404 setTimeout(() => { $('#boot').hidden = true; }, 700 + 1200);@@ -329,6 +418,7 @@ async function init() {
329418 setInterval(() => scan(false).then(renderAll).catch((e) => log('warn', e)), 30 * MIN);330419 setInterval(() => { if (state.view === 'guide' || state.view === 'recordings') loadDVR(); }, MIN);331420 setInterval(loadWeather, 2 * MIN);421+ setInterval(signalTick, 2000);332422 loadDVR();333423 loadWeather();334424 }@@ -362,7 +452,6 @@ function showConnect(err) {
362452 function applyBoot(b) {363453 state.boot = b;364454 state.settings = b.settings;365- state.presets = b.presets;366455 state.info = b.info || { name: '', tuner: {}, dvr: false };367456 state.config = b.config || {};368457 document.body.classList.toggle('has-dvr', !!state.info.dvr);@@ -509,6 +598,8 @@ async function tune(ch, { quiet = false } = {}) {
509598 if (token !== state.tuneToken) return;510599 log('warn', `tune ${ch.number}: ${e}`);511600 showNoSignal(ch, String(e && e.message ? e.message : e));601+ // The server read the tuner while it tried.602+ if (!ch.own) loadSignal();512603 } finally {513604 if (token === state.tuneToken) setTimeout(() => stage.classList.remove('tuning'), 200);514605 }@@ -640,10 +731,10 @@ function renderBanner() {
640731 // What's on is being recorded, or will be.641732 const rec = p && state.dvrKeys.get(airingKey(ch, p));642733 f(b, 'chips').innerHTML = (rec ? `<span class="chip live">${rec.status === 'recording' ? 'Recording' : 'Will record'}</span>` : '') + chipsFor(p, ch);643- f(b, 'title').textContent = p ? p.title : ch.atsc3Only ? 'ATSC 3.0 service' : 'No listings';734+ f(b, 'title').textContent = p ? p.title : 'No listings';644735 f(b, 'ep').textContent = episodeLine(p);645736 f(b, 'progress').style.width = p ? `${Math.min(100, ((now - p._s) / (p._e - p._s)) * 100)}%` : '0';646- f(b, 'desc').textContent = p ? p.description || '' : ch.atsc3Only ? 'Broadcast only in ATSC 3.0, which needs a NextGen TV tuner. No Gracenote listings for this service.' : ch.description || '';737+ f(b, 'desc').textContent = p ? p.description || '' : ch.description || '';647738 f(b, 'next').textContent = nx ? `${clock(nx._s)} ${nx.title}` : '';648739 649740 f(b, 'note').textContent = state.note || '';@@ -660,23 +751,27 @@ function renderBanner() {
660751 return;661752 }662753 663- // Reception is shown only when asked for (I twice) or when it is weak664- // enough to explain a bad picture.665- const preset = state.settings.antenna;666- const tier = (ch.tier && ch.tier[preset]) || 'unknown';667- const weak = tier === 'weak' || tier === 'unlikely';668- b.classList.toggle('warn', weak);754+ // The signal is shown when asked for (I twice, or the controls' I) or755+ // when what was measured explains a bad picture.756+ const sig = sigOf(sigFor(ch));757+ b.classList.toggle('warn', trouble(sig));758+ f(b, 'meter').outerHTML = meter(sig, true).replace('class="meter big', 'data-f="meter" class="meter big');759+ f(b, 'meter').hidden = false;760+ f(b, 'tier').textContent = sig.label;761+ f(b, 'tier').style.color = sig.state === 'none' ? '' : sig.color;669762 const st = stationFor(ch);670- f(b, 'meter').outerHTML = meter(tier, true).replace('class="meter big"', 'class="meter big" data-f="meter"');671- f(b, 'tier').textContent = weak ? 'Weak signal' : `${TIER_LABEL[tier]} signal`;672- f(b, 'tier').style.color = TIER_COLOR[tier];763+ const mux = state.byRF.get(ch.rf);764+ const where = ch.rf ? `RF ${ch.rf} ${(mux && mux.band) || (st && st.band) || ''}`.trim() : '';765+ const lines = [];673766 if (st) {674- const sig = st.signal[preset];675- f(b, 'tx').innerHTML = `${ch.via ? `On ${esc(st.callSign)}'s transmitter, ` : `${esc(st.callSign)} `}RF ${st.rfChannel} ${esc(st.band)}<br>${st.distanceKm.toFixed(0)} km ${compass(st.bearingDeg)}, ${sig && sig.tier !== 'unknown' ? `margin ${sig.noiseMarginDb > 0 ? '+' : ''}${sig.noiseMarginDb.toFixed(0)} dB, ${preset} antenna` : 'margin unknown'}`;676- } else {677- f(b, 'tx').textContent = 'No licensed transmitter matched';767+ lines.push(`${ch.via ? `On ${esc(st.callSign)}'s transmitter, ` : `${esc(st.callSign)} `}${where}`);768+ lines.push(`${st.distanceKm.toFixed(0)} km ${compass(st.bearingDeg)}, ${st.erpKw >= 10 ? st.erpKw.toFixed(0) : st.erpKw.toFixed(1)} kW`);769+ } else if (where) {770+ lines.push(`${where}, no licensed transmitter on record`);678771 }679- f(b, 'atsc3').textContent = ch.atsc3 ? `Also in ATSC 3.0 on ${ch.atsc3.hostCall} RF ${ch.atsc3.rf}` : '';772+ lines.push(esc(sigDetail(sig)));773+ f(b, 'tx').innerHTML = lines.join('<br>');774+ f(b, 'atsc3').textContent = ch.atsc3 ? `Also in ATSC 3.0 on ${ch.atsc3.hostCall} RF ${ch.atsc3.rf}${nextGen() ? '' : ', which needs a NextGen TV tuner'}` : '';680775 }681776 682777 // What the server's own channels play, for the banner's details.@@ -697,7 +792,7 @@ function showChannelLogo(b, ch) {
697792 }698793 699794 // The banner shows briefly on a channel change and on request. Pressing I700-// cycles: banner, banner with reception details, hidden. With the795+// cycles: banner, banner with the measured signal, hidden. With the701796 // on-screen controls up it stays as long as they do.702797 function showBanner(detail = false) {703798 renderBanner();@@ -1044,7 +1139,6 @@ function seekTimeline(frac) {
10441139 osdTouch();10451140 }10461141 1047-const presetLabel = (name) => (state.presets.find((p) => p.name === name) || { label: name }).label;10481142 10491143 // ---------- number entry ----------10501144 function entryKey(k) {@@ -1651,9 +1745,10 @@ const FILTERS = [
16511745 // guideFilters adds a filter for each category of the lineup's own channels16521746 // that none of FILTERS covers ("Kids", "Gaming"), in the order categories16531747 // are listed on the admin page. Not Weather: the weather channel leads the1654-// guide anyway.1748+// guide anyway. NextGen only with a tuner that takes in ATSC 3.0.16551749 function guideFilters() {1656- const keys = new Set([...FILTERS.map(([k]) => k), 'weather']);1750+ const base = FILTERS.filter(([k]) => k !== 'nextgen' || nextGen());1751+ const keys = new Set([...base.map(([k]) => k), 'weather']);16571752 const extra = [];16581753 for (const category of Object.keys(CATEGORY_GENRES)) {16591754 const k = categoryGenres(category)[0];@@ -1661,7 +1756,7 @@ function guideFilters() {
16611756 keys.add(k);16621757 extra.push([k, category]);16631758 }1664- return [...FILTERS, ...extra];1759+ return [...base, ...extra];16651760 }16661761 16671762 function guideChannels() {@@ -1910,7 +2005,7 @@ function renderGuide() {
19102005 const rows = $('.g-rows');19112006 const lo = state.gStart;19122007 const now = Date.now();1913- const shown = [state.guideFilter, lo, rows.clientWidth, Math.floor(now / MIN), state.lineup, state.guide, state.dvrKeys, state.settings, state.current && state.current.key, state.wx];2008+ const shown = [state.guideFilter, lo, rows.clientWidth, Math.floor(now / MIN), state.lineup, state.guide, state.dvrKeys, state.settings, state.current && state.current.key, state.wx, state.sigStamp];19142009 if (shown.every((v, i) => v === guideShown[i])) return moveGuideSelection(list, lo);19152010 guideShown = shown;19162011 const chw = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--chw')) || 260;@@ -1946,16 +2041,18 @@ function renderGuide() {
19462041 const sel = selectedProgram(list);19472042 let html = '';19482043 list.forEach((ch, r) => {1949- const tier = (ch.tier && ch.tier[state.settings.antenna]) || 'unknown';2044+ // A channel's measured signal shows only when it explains a bad picture.2045+ const sig = ch.own ? null : sigOf(sigFor(ch));2046+ const bad = sig && trouble(sig);19502047 const cls = ['g-row', r === state.gRow ? 'sel' : '', state.current && ch.key === state.current.key ? 'cur' : ''].join(' ');19512048 html += `<div class="${cls}"><div class="g-ch" data-row="${r}">19522049 <div class="num${ch.number.length > 4 ? ' long' : ''}">${esc(ch.number)}</div>1953- <div class="who"><div class="net">${isFav(ch) ? '<span class="fav">★</span>' : ''}${esc(ch.network || displayCall(ch))}</div><div class="call">${tier === 'weak' || tier === 'unlikely' ? meter(tier) : ''}<span>${esc(displayCall(ch))}</span></div></div>2050+ <div class="who"><div class="net">${isFav(ch) ? '<span class="fav">★</span>' : ''}${esc(ch.network || displayCall(ch))}</div><div class="call"${bad ? ` title="${esc(`${sig.label}: ${sigDetail(sig)}`)}"` : ''}>${bad ? meter(sig) : ''}<span>${esc(displayCall(ch))}</span></div></div>19542051 ${ch.logo ? `<div class="logo${ch.own ? ' own' : ''}" style="background-image:url('${esc(ch.logo)}')"></div>` : '<div></div>'}19552052 </div><div class="g-progs">`;19562053 const progs = programsFor(ch).filter((p) => p._e > lo && p._s < hi);19572054 if (!progs.length) {1958- const label = ch.weather ? 'Local forecast, around the clock' : ch.custom ? ch.description || 'Nothing scheduled yet' : ch.atsc3Only ? 'ATSC 3.0 only, no listings' : 'No listings';2055+ const label = ch.weather ? 'Local forecast, around the clock' : ch.custom ? ch.description || 'Nothing scheduled yet' : 'No listings';19592056 html += `<div class="g-cell empty${r === state.gRow ? ' sel' : ''}" data-row="${r}" style="left:2px;width:${width - 4}px"><div class="t">${label}</div></div>`;19602057 }19612058 for (const p of progs) {@@ -2103,7 +2200,7 @@ function renderGuideDetail(ch, p) {
21032200 }21042201 const now = Date.now();21052202 f(g, 'kicker').innerHTML = `<span>${esc(ch.number)} ${esc(ch.own ? ch.call : displayCall(ch))}</span><span style="color:var(--muted)">${p ? `${clock(p._s)} to ${clock(p._e)}` : ''}</span>`;2106- f(g, 'title').textContent = p ? p.title : ch.atsc3Only ? `${ch.network} (ATSC 3.0)` : ch.own ? ch.name : 'No listings';2203+ f(g, 'title').textContent = p ? p.title : ch.own ? ch.name : 'No listings';21072204 f(g, 'ep').textContent = episodeLine(p);21082205 // An own channel's category is among its programs' genres; with nothing21092206 // listed it shows on its own, with the channel's description.@@ -2122,176 +2219,326 @@ function renderGuideDetail(ch, p) {
21222219 }21232220 21242221 // ---------- reception ----------2222+// Reception is what the tuners measured, by RF channel, with what the FCC2223+// licenses on each; nothing in it is estimated. The rail has the tuner and2224+// Measure now, which has the server read every RF channel worth reading2225+// on a tuner nobody is using, the ones its scan found first. The table2226+// lists the RF channels, those found first; the radar places their2227+// transmitters, and the detail under it is the picked RF channel's2228+// readings over time and the stations on it.2229+2230+// applySignal takes in what the server measured: a snapshot's antenna2231+// section, or /api/signal.2232+function applySignal(sig) {2233+ const was = state.antenna && state.antenna.sweep;2234+ state.antenna = { tuner: sig.tuner || {}, muxes: sig.muxes || [], sweep: sig.sweep || {} };2235+ state.byRF = new Map(state.antenna.muxes.map((m) => [m.rf, m]));2236+ const by = new Map((sig.channels || []).map((c) => [c.number, c]));2237+ for (const ch of state.antennaChans) {2238+ const c = by.get(ch.number);2239+ if (c) [ch.signal, ch.recent] = [c.signal, c.recent];2240+ }2241+ state.sigStamp++;2242+ const sw = state.antenna.sweep;2243+ if (was && was.running && !sw.running && state.view === 'antenna') {2244+ toast(sw.note ? `Measure now ${sw.note}` : `Measured ${sw.done} RF channels`, 4000);2245+ }2246+}2247+2248+let signalAt = 0;2249+let signalBusy = false;2250+2251+// loadSignal fetches what was measured and redraws what shows it.2252+async function loadSignal() {2253+ if (!state.info || state.info.antenna === false || signalBusy || !api().Signal) return;2254+ signalBusy = true;2255+ try {2256+ applySignal(await api().Signal());2257+ } catch (e) {2258+ log('warn', `signal: ${e}`);2259+ return;2260+ } finally {2261+ signalBusy = false;2262+ signalAt = Date.now();2263+ }2264+ if (state.view === 'antenna') renderAntenna();2265+ if (state.view === 'guide') renderGuide();2266+ if (state.view === 'tv' && $('#banner').classList.contains('show')) renderBanner();2267+}2268+2269+// signalTick reads the signal again while it's on screen: every 2 s while2270+// Measure now runs, every 10 s in Reception or with an antenna channel's2271+// banner up, every 30 s in the guide.2272+function signalTick() {2273+ if (!state.info || state.info.antenna === false || document.hidden) return;2274+ const running = !!(state.antenna && state.antenna.sweep.running);2275+ const banner = state.view === 'tv' && state.current && !state.current.own && $('#banner').classList.contains('show');2276+ const every = running ? 2000 : state.view === 'antenna' || banner ? 10_000 : state.view === 'guide' ? 30_000 : 0;2277+ if (every && Date.now() - signalAt >= every - 100) loadSignal();2278+}2279+2280+// measureNow starts Measure now on the server.2281+async function measureNow() {2282+ const sw = state.antenna && state.antenna.sweep;2283+ if (sw && sw.running) return toast(`Measuring: ${sw.done} of ${sw.total} RF channels`, 2000);2284+ try {2285+ const st = await api().Measure();2286+ if (state.antenna) state.antenna.sweep = st;2287+ signalAt = 0;2288+ renderMeasure();2289+ } catch (e) {2290+ toast(String(e && e.message ? e.message : e), 6000);2291+ }2292+ return undefined;2293+}2294+21252295 function wireAntenna() {21262296 const a = $('#antenna');2127- f(a, 'preview').addEventListener('submit', (e) => {2128- e.preventDefault();2129- const zip = e.target.elements.zip;2130- zip.blur();2131- antZone('rail', 'zip');2132- // Once previewing, the focus goes to the preview's first button.2133- antUI.key = 'copy';2134- startPreview(zip.value.trim());2135- });2136- $('.rail', a).addEventListener('focusin', (e) => {2137- const el = e.target.closest('[data-p], [data-act], .rail-preview label');2138- if (el) antUI.key = el.dataset.p || el.dataset.act || 'zip';2139- });2140- f(a, 'previewing').addEventListener('click', (e) => {2141- const b = e.target.closest('[data-act]');2142- if (!b) return;2143- if (b.dataset.act === 'back') endPreview();2144- if (b.dataset.act === 'copy') api().CopyText(receptionSummary()).then(() => toast('Summary copied', 1800)).catch((err) => toast(String(err), 6000));2145- });2297+ f(a, 'measure').addEventListener('click', () => measureNow());21462298 $('.a-table tbody').addEventListener('click', (e) => {2147- const tr = e.target.closest('tr[data-k]');2148- if (tr) selectStation(tr.dataset.k);2299+ const tr = e.target.closest('tr[data-rf]');2300+ if (tr) pickRF(Number(tr.dataset.rf));21492301 });21502302 $('.radar').addEventListener('click', (e) => {2151- const dot = e.target.closest('[data-k]');2152- if (dot) selectStation(dot.dataset.k);2303+ const dot = e.target.closest('[data-rf]');2304+ if (dot) pickRF(Number(dot.dataset.rf));21532305 });21542306 }21552307 2156-// Reception's parts take the focus in turn (data-zone): the transmitters'2157-// table, and the rail beside it, Left from the table: while previewing,2158-// Copy summary and Back to my setup; the antenna presets (OK picks one);2159-// and Preview a ZIP code, where OK brings up the keyboard.2160-const antUI = { zone: 'table', key: '' };2308+// Reception's parts take the focus in turn (data-zone): the RF channels'2309+// table, and the rail beside it, Left from the table: the tuner's card and2310+// Measure now. OK on an RF channel with channels watches the first.2311+const antUI = { zone: 'table', key: 'measure' };21612312 21622313 function openAntenna() {21632314 renderAntenna();2164- antZone(stationOrder().length ? 'table' : 'rail');2315+ signalAt = 0;2316+ antZone(antRows().length ? 'table' : 'rail');21652317 }21662318 2167-function antRail() {2168- const a = $('#antenna');2169- return [...$$('[data-f="previewing"] .fx', a), ...$$('[data-f="presets"] .fx', a), $('.rail-preview label', a)].filter(isShown);2170-}2319+const antRail = () => $$('#antenna .rail .fx').filter(isShown);21712320 21722321 function antZone(zone, key = '') {21732322 antUI.zone = zone;21742323 $('#antenna').dataset.zone = zone;21752324 if (key) antUI.key = key;21762325 if (state.dock >= 0 || state.view !== 'antenna') return;2177- if (zone === 'table') return blurIn($('#antenna'));2326+ renderRFDetail(); // its OK hint is the table's2327+ if (zone === 'table') {2328+ blurIn($('#antenna'));2329+ scrollRF();2330+ return;2331+ }21782332 const items = antRail();2179- const keyOf = (el) => el.dataset.p || el.dataset.act || 'zip';2180- focusEl(items.find((el) => keyOf(el) === antUI.key) || items.find((el) => el.classList.contains('on')) || items[0]);2333+ focusEl(items.find((el) => el.dataset.act === antUI.key) || items[items.length - 1]);21812334 }21822335 21832336 function antRefocus() {21842337 if (state.view !== 'antenna' || state.dock >= 0 || antUI.zone !== 'rail') return;21852338 const el = document.activeElement;2186- if (el && el.matches(TEXT_INPUT)) return;21872339 if (!$('#antenna').contains(el) || !isShown(el)) antZone('rail');21882340 }21892341 21902342 function antennaKey(e) {2191- if (e.key === '1' || e.key === '2' || e.key === '3') return setPreset(state.presets[Number(e.key) - 1].name);21922343 if (antUI.zone === 'rail') {21932344 const items = antRail();21942345 const el = document.activeElement;21952346 const i = items.indexOf(el);21962347 switch (e.key) {21972348 case 'ArrowUp': if (i > 0) focusEl(items[i - 1]); else openDock(); break;2198- case 'ArrowDown': focusEl(items[Math.min(items.length - 1, i + 1)]); break;2199- case 'ArrowRight': if (stationOrder().length) antZone('table'); break;2349+ case 'ArrowDown': if (i < 0) antZone('rail'); else if (i < items.length - 1) focusEl(items[i + 1]); break;2350+ case 'ArrowRight': if (antRows().length) antZone('table'); break;22002351 case 'ArrowLeft': openDock(); break;22012352 case 'Enter':2202- if (el && el.matches('.rail-preview label')) {2203- const input = $('input', el);2204- input.focus();2205- input.select();2206- } else if (i >= 0) el.click();2207- else antZone('rail');2353+ if (el && el.dataset.act === 'measure') measureNow();2354+ else if (i < 0) antZone('rail');22082355 break;22092356 default: return;22102357 }2358+ // Where the focus comes back to, from the dock.2359+ const now = document.activeElement;2360+ if (now && now.dataset.act && $('#antenna').contains(now)) antUI.key = now.dataset.act;22112361 e.preventDefault();22122362 return;22132363 }2214- const keys = stationOrder().map((s) => `${s.facilityId}:${s.rfChannel}`);2215- let i = keys.indexOf(state.aSel);2216- if (e.key === 'ArrowUp' && i <= 0) { e.preventDefault(); return openDock(); }2217- if (e.key === 'ArrowLeft') { e.preventDefault(); return antZone('rail'); }2218- if (e.key === 'ArrowDown') i = Math.min(keys.length - 1, i + 1);2219- else if (e.key === 'ArrowUp') i = Math.max(0, i - 1);2220- else return;2364+ const rows = antRows();2365+ let i = rows.indexOf(state.aRF);2366+ switch (e.key) {2367+ case 'ArrowUp':2368+ if (i <= 0) { e.preventDefault(); openDock(); return; }2369+ i--;2370+ break;2371+ case 'ArrowDown': i = Math.min(rows.length - 1, i + 1); break;2372+ case 'PageDown': i = Math.min(rows.length - 1, i + 8); break;2373+ case 'PageUp': i = Math.max(0, i - 8); break;2374+ case 'ArrowLeft': e.preventDefault(); antZone('rail'); return;2375+ case 'Enter': {2376+ e.preventDefault();2377+ const m = state.byRF.get(state.aRF);2378+ const ch = m && m.channels.length && findChannel(m.channels[0]);2379+ if (ch) watchChannel(ch);2380+ return;2381+ }2382+ default: return;2383+ }22212384 e.preventDefault();2222- selectStation(keys[i]);2223- const tr = $(`.a-table tr[data-k="${keys[i]}"]`);2224- if (tr) tr.scrollIntoView({ block: 'nearest' });2385+ pickRF(rows[Math.max(0, i)]);22252386 }22262387 2227-// rx is what the reception view shows: the server's own location, or a2228-// previewed ZIP code.2229-const rx = () => state.preview || { report: state.report, bySite: state.bySite, byFacility: state.byFacility, zip: '' };2230-2231-function stationOrder() {2232- const p = state.settings.antenna;2233- return [...rx().report.stations].sort((a, b) => (b.signal[p]?.noiseMarginDb ?? -999) - (a.signal[p]?.noiseMarginDb ?? -999));2388+// antMuxes is the RF channels to list: those the scan found (by RF), then2389+// those a licensed station nearby uses; others hold nothing to see.2390+function antMuxes() {2391+ const all = state.antenna ? state.antenna.muxes : [];2392+ const found = all.filter(muxFound);2393+ const rest = all.filter((m) => !muxFound(m) && m.stations.length);2394+ return [...found, ...rest];22342395 }2396+const muxFound = (m) => m.channels.length > 0 || !!(m.scan && m.scan.lock);2397+const antRows = () => antMuxes().map((m) => m.rf);2398+const muxSig = (m) => sigOf(m && { signal: m.signal, recent: m.history && m.history.windows.length ? m.history.windows[m.history.windows.length - 1] : null });22352399 2236-async function setPreset(name) {2237- await saveAppSettings({ ...state.settings, antenna: name });2238- buildLineup();2239- renderAntenna();2240- renderBanner();2241- toast(`Antenna: ${presetLabel(name)}. ${state.lineup.length} channels in your lineup.`);2400+// tunerName names the tuner from its model as Tvheadend reports it.2401+function tunerName(t) {2402+ if (!t || !t.model) return 'Tuner';2403+ if (/^hdhomerun/i.test(t.model)) return 'HDHomeRun';2404+ return t.model;22422405 }22432406 22442407 function renderAntenna() {2245- if (!state.report || state.info?.antenna === false) return;2408+ if (!state.report || !state.info || state.info.antenna === false) return;22462409 const a = $('#antenna');2247- const rep = rx().report;2248- renderPreviewRail(rep);2249- const preset = state.settings.antenna;2250- const stations = stationOrder();2251- const good = stations.filter((s) => receivable(s.signal[preset]?.tier));2252- const chans = rep.channels.filter((c) => receivable(c.tier && c.tier[preset]));2253-2254- const where = { indoor: 'Inside, by a window', attic: 'Under the roof', rooftop: 'Outside, 30 ft up' };2255- f(a, 'presets').innerHTML = state.presets.map((p, i) => {2256- const n = rep.channels.filter((c) => receivable(c.tier && c.tier[p.name])).length;2257- return `<button type="button" role="radio" aria-checked="${p.name === preset}" class="rm-item fx ${p.name === preset ? 'on' : ''}" data-p="${p.name}" title="Key ${i + 1}">2258- <span class="rm-label">${esc(p.name[0].toUpperCase() + p.name.slice(1))}</span><span class="rm-count">${n}</span>2259- <small>${where[p.name] || ''}</small></button>`;2260- }).join('');2261- $$('button', f(a, 'presets')).forEach((b) => b.addEventListener('click', () => setPreset(b.dataset.p)));2262- const aim = aimOf(stations, preset);2410+ const muxes = antMuxes();2411+ if (!muxes.some((m) => m.rf === state.aRF)) state.aRF = muxes.length ? muxes[0].rf : 0;2412+ renderTunerCard();2413+ renderMeasure();2414+ const found = muxes.filter(muxFound);2415+ const locked = found.filter((m) => muxSig(m).state === 'lock').length;2416+ const chans = state.lineup.filter((c) => !c.own).length;22632417 f(a, 'stats').innerHTML = `2264- <div><dt>Channels</dt><dd>${chans.length}<small>from ${good.length} of ${stations.length} transmitters</small></dd></div>2265- ${aim ? `<div><dt>Aim</dt><dd>${aim.deg.toFixed(0)}° ${compass(aim.deg)}<small>${aim.within} of ${aim.total} channels within ${aim.spread}°</small></dd></div>` : ''}`;2266-2267- renderRadar(stations, preset);2268- renderTable(stations, preset);2269- renderNextGen(preset);2270- if (!state.aSel && good[0]) state.aSel = `${good[0].facilityId}:${good[0].rfChannel}`;2271- if (state.aSel) selectStation(state.aSel, true);2418+ <div><dt>Channels</dt><dd>${chans}<small>on ${found.length} RF channels the scan found</small></dd></div>2419+ ${found.length ? `<div><dt>Locked</dt><dd>${locked}<small>of those ${found.length} at their last reading</small></dd></div>` : ''}`;2420+ renderRFTable(muxes);2421+ renderRadar(muxes);2422+ renderRFDetail();2423+ renderNextGen();22722424 antRefocus();22732425 }22742426 2275-function renderTable(stations, preset) {2276- const rows = stations.map((s) => {2277- const k = `${s.facilityId}:${s.rfChannel}`;2278- const t = s.signal[preset]?.tier || 'unknown';2279- const nm = (name) => {2280- const e = s.signal[name];2281- if (!e || e.tier === 'unknown') return '<span class="nm dark" style="--c:var(--ink-3)">?</span>';2282- return `<span class="nm ${rankOf(e.tier) <= 2 ? 'dark' : ''}" style="--c:${TIER_COLOR[e.tier]}">${e.noiseMarginDb > 0 ? '+' : ''}${e.noiseMarginDb.toFixed(0)}</span>`;2283- };2284- const carries = (s.carries || []).length ? compactCarries(s.carries) : '';2285- return `<tr data-k="${k}" class="${receivable(t) ? '' : 'dim'} ${k === state.aSel ? 'sel' : ''}">2286- <td><div class="call">${esc(s.callSign)}${s.atsc3 ? ' <span class="chip ng">3.0</span>' : ''}</div><div class="sub">${esc(s.city)}, ${esc(s.service)}</div></td>2287- <td class="mono">${s.rfChannel} <span class="sub">${esc(s.band)}</span></td>2288- <td class="carries">${esc(carries)}</td>2289- <td class="r mono">${s.distanceKm.toFixed(0)} km ${compass(s.bearingDeg)}</td>2290- <td class="r mono">${s.erpKw >= 10 ? s.erpKw.toFixed(0) : s.erpKw.toFixed(1)} kW</td>2291- <td class="c">${nm('indoor')}</td><td class="c">${nm('attic')}</td><td class="c">${nm('rooftop')}</td>2292- </tr>`;2293- });2294- $('.a-table tbody').innerHTML = rows.join('');2427+function renderTunerCard() {2428+ const t = (state.antenna && state.antenna.tuner) || state.info.tuner || {};2429+ const n = t.tuners || 0;2430+ const use = t.inUse ? `${t.inUse} of ${n} in use` : n ? `${n > 1 ? 'Both' : 'It'} free` : '';2431+ f($('#antenna'), 'tuner').innerHTML = `2432+ <div class="at-label">Tuner</div>2433+ <div class="at-name">${esc(tunerName(t))}</div>2434+ <div class="at-line">${n ? `${n} tuner${n > 1 ? 's' : ''}, ${esc((t.standards || []).join(', ') || 'ATSC')}` : 'No tuner'}</div>2435+ ${use ? `<div class="at-line">${esc(use)}</div>` : ''}2436+ ${t.scanning ? '<div class="at-line at-warn">Scanning for channels</div>' : ''}2437+ ${t.reason ? `<div class="at-line at-warn">${esc(t.reason)}</div>` : ''}2438+ ${n && !t.atsc3 ? '<div class="at-note">NextGen TV (ATSC 3.0) needs another tuner</div>' : ''}2439+ ${t.model ? `<div class="at-model">${esc(t.model)}</div>` : ''}`;2440+}2441+2442+// renderMeasure shows Measure now's progress on its button: the RF2443+// channel being read, how many are done, the found ones first.2444+function renderMeasure() {2445+ const a = $('#antenna');2446+ const sw = (state.antenna && state.antenna.sweep) || {};2447+ const btn = f(a, 'measure');2448+ btn.classList.toggle('busy', !!sw.running);2449+ f(a, 'mlabel').textContent = sw.running ? (sw.rf ? `Measuring RF ${sw.rf}` : 'Measuring') : 'Measure now';2450+ f(a, 'mbar').style.width = sw.running && sw.total ? `${Math.round((sw.done / sw.total) * 100)}%` : '0';2451+ f(a, 'mprog').hidden = !sw.running;2452+ let note = 'Reads every RF channel on a free tuner, a few seconds each, the ones the scan found first';2453+ if (sw.running) {2454+ const found = sw.found || 0;2455+ note = sw.done < found2456+ ? `${sw.done} of the ${found} found by the scan, then ${sw.total - found} more nearby stations use`2457+ : `All ${found} found, now ${sw.done - found} of ${sw.total - found} more nearby stations use`;2458+ } else if (sw.finishedAt) {2459+ note = `Last measured ${ago(Date.parse(sw.finishedAt))}, ${sw.done} of ${sw.total} RF channels${sw.note ? `: ${sw.note}` : ''}`;2460+ }2461+ f(a, 'mnote').textContent = note;2462+}2463+2464+function renderRFTable(muxes) {2465+ const rows = [];2466+ let shownRest = false;2467+ for (const m of muxes) {2468+ const found = muxFound(m);2469+ if (!found && !shownRest) {2470+ shownRest = true;2471+ rows.push('<tr class="a-sect"><td colspan="7">Not found by the scan: RF channels nearby stations use</td></tr>');2472+ }2473+ const s = muxSig(m);2474+ const st = muxStation(m);2475+ const more = m.stations.length > 1 ? `, +${m.stations.length - 1} more` : '';2476+ rows.push(`<tr data-rf="${m.rf}" class="${m.rf === state.aRF ? 'sel' : ''}${found ? '' : ' nf'}">2477+ <td class="rf"><b>${m.rf}</b><div class="sub">${m.frequencyMhz} MHz ${esc(m.band)}</div></td>2478+ <td>${st ? `<div class="call">${esc(st.callSign)}${st.atsc3 ? ' <span class="chip ng">3.0</span>' : ''}</div><div class="sub">${esc(st.city)}, ${st.distanceKm.toFixed(0)} km ${compass(st.bearingDeg)}${more}</div>` : '<div class="sub">No licensed station nearby</div>'}</td>2479+ <td class="carries">${compactCarries(m.channels).split(' ').filter(Boolean).map((g) => `<span>${esc(g)}</span>`).join(' ')}</td>2480+ <td class="c">${meter(s)}</td>2481+ <td class="r mono" style="${s.state === 'off' ? 'color:var(--red)' : ''}">${s.state === 'none' ? '' : s.state === 'off' ? 'No lock' : s.quality || 'Lock'}<div class="sub">${s.strength ? `strength ${s.strength}` : ''}</div></td>2482+ <td class="r mono">${s.state === 'none' || s.state === 'off' || s.errors == null ? '' : s.errors >= 1 ? `<span class="errs">${Math.round(s.errors)}/s</span>` : '0'}</td>2483+ <td class="r mono">${s.state === 'none' ? '' : esc(agoShort(s.at))}</td>2484+ </tr>`);2485+ }2486+ $('.a-table tbody').innerHTML = rows.join('') || `<tr class="a-sect"><td colspan="7">${esc((state.antenna && state.antenna.tuner.reason) || 'No RF channels yet')}</td></tr>`;2487+}2488+2489+// muxStation is the transmitter an RF channel's channels come from, else2490+// the nearest station licensed on it.2491+function muxStation(m) {2492+ const tx = state.antennaChans.find((c) => c.rf === m.rf && c.transmitter);2493+ return (tx && m.stations.find((x) => x.callSign === tx.transmitter)) || m.stations[0] || null;2494+}2495+2496+// pickRF picks an RF channel in the table.2497+function pickRF(rf) {2498+ state.aRF = rf;2499+ $$('.a-table tr[data-rf]').forEach((tr) => tr.classList.toggle('sel', Number(tr.dataset.rf) === rf));2500+ scrollRF();2501+ renderRFDetail();2502+ markRadar();2503+}2504+2505+function scrollRF() {2506+ const tr = $(`.a-table tr[data-rf="${state.aRF}"]`);2507+ if (tr) tr.scrollIntoView({ block: 'nearest' });2508+}2509+2510+// renderRFDetail is the picked RF channel: its last reading, its readings2511+// over time, its channels and the stations licensed on it.2512+function renderRFDetail() {2513+ const box = f($('#antenna'), 'detail');2514+ const m = state.byRF.get(state.aRF);2515+ if (!m) {2516+ box.innerHTML = '';2517+ return;2518+ }2519+ const s = muxSig(m);2520+ const scan = m.scan ? `${m.scan.lock ? 'Found by the scan' : 'Not found by the scan'}, ${new Date(m.scan.at).toLocaleDateString([], { month: 'short', day: 'numeric' })}` : 'Not scanned yet';2521+ const wins = (m.history && m.history.windows) || [];2522+ const bars = wins.map((w) => {2523+ const q = w.qualityPct ? w.qualityPct.avg : w.snrDb ? Math.min(100, w.snrDb.avg * 3) : 0;2524+ const lo = w.qualityPct ? w.qualityPct.min : 0;2525+ const st = w.lockedPct >= 100 ? 'lock' : w.lockedPct > 0 ? 'part' : 'off';2526+ const tip = `${new Date(w.from).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}: ${w.samples} reading${w.samples > 1 ? 's' : ''}${w.source === 'active' ? ' while watched' : ''}, locked ${Math.round(w.lockedPct)}%${w.qualityPct ? `, quality ${w.qualityPct.min} to ${w.qualityPct.max}%` : ''}`;2527+ return `<i class="h-${st}" style="--h:${Math.max(4, q)}%;--lo:${lo}%" title="${esc(tip)}"></i>`;2528+ }).join('');2529+ const chans = m.channels.map((n) => {2530+ const c = state.antennaChans.find((x) => x.number === n);2531+ return `<span><b>${esc(n)}</b> ${esc(c ? displayCall(c) : '')}${c && c.network ? ` ${esc(c.network)}` : ''}</span>`;2532+ }).join('');2533+ const stations = m.stations.map((x) => `<li><b>${esc(x.callSign)}</b>${x.virtualChannel ? ` (${x.virtualChannel})` : ''} ${esc(x.city)}, ${x.distanceKm.toFixed(0)} km ${compass(x.bearingDeg)}, ${x.erpKw >= 10 ? x.erpKw.toFixed(0) : x.erpKw.toFixed(1)} kW${x.haatM ? `, ${x.haatM.toFixed(0)} m up` : ''}${x.atsc3 ? ' <span class="chip ng">ATSC 3.0</span>' : ''}</li>`).join('');2534+ const first = m.channels.length ? m.channels[0] : '';2535+ box.innerHTML = `2536+ <div class="ad-head"><b>RF ${m.rf}</b><span>${m.frequencyMhz} MHz ${esc(m.band)}</span><span class="ad-scan">${esc(scan)}</span></div>2537+ <div class="ad-sig">${meter(s, true)}<div><div class="ad-label" style="color:${s.state === 'none' ? 'var(--muted)' : s.color}">${esc(s.label)}</div><div class="ad-sub">${esc(sigDetail(s))}</div></div></div>2538+ ${bars ? `<div class="ad-hist" aria-label="Readings over time">${bars}</div><div class="ad-axis"><span>${esc(ago(Date.parse(wins[0].from)))}</span><span>${esc(ago(Date.parse(wins[wins.length - 1].to)))}</span></div>` : ''}2539+ ${chans ? `<div class="ad-chans">${chans}</div>` : ''}2540+ ${stations ? `<ul class="ad-stations">${stations}</ul>` : ''}2541+ ${first && antUI.zone === 'table' ? `<div class="ad-hint">${hints([['Enter', 'Enter', `watches ${first}`]])}</div>` : ''}`;22952542 }22962543 22972544 // compactCarries turns ["4.1","4.2","2.1 (3.0)"] into "4.1-2 3.0: 2.1".@@ -2317,46 +2564,18 @@ const svgEl = (tag, attrs, text) => {
23172564 return el;23182565 };23192566 2320-// aimOf is the circular mean bearing of receivable transmitters, weighted by2321-// the ATSC 1.0 channels each carries, and how many channels lie near it.2322-function aimOf(stations, preset) {2323- const spread = 22;2324- const v1 = (s) => (s.carries || []).filter((c) => !c.endsWith('(3.0)')).length;2325- const good = stations.filter((s) => receivable(s.signal[preset]?.tier) && v1(s));2326- if (!good.length) return null;2327- let sx = 0; let sy = 0;2328- for (const s of good) {2329- sx += v1(s) * Math.sin(s.bearingDeg * Math.PI / 180);2330- sy += v1(s) * Math.cos(s.bearingDeg * Math.PI / 180);2331- }2332- const deg = (Math.atan2(sx, sy) * 180 / Math.PI + 360) % 360;2333- const near = good.filter((s) => Math.abs(((s.bearingDeg - deg + 540) % 360) - 180) <= spread);2334- return { deg, spread, within: near.reduce((n, s) => n + v1(s), 0), total: good.reduce((n, s) => n + v1(s), 0) };2335-}2336-2337-function renderRadar(stations, preset) {2567+// renderRadar places the listed RF channels' transmitters around home, by2568+// tower site, colored by what was measured on their RF channels.2569+function renderRadar(muxes) {23382570 const svg = $('.radar');23392571 svg.innerHTML = '';23402572 const R = 200;2341- const maxKm = rx().report.radiusKm;2573+ const maxKm = state.report.radiusKm || 160;23422574 const rad = (km) => R * Math.sqrt(Math.min(km, maxKm) / maxKm);23432575 const pos = (km, deg) => {23442576 const a = (deg - 90) * Math.PI / 180;23452577 return [rad(km) * Math.cos(a), rad(km) * Math.sin(a)];23462578 };2347-2348- const defs = svgEl('defs');2349- defs.innerHTML = `<radialGradient id="beam" cx="0" cy="0" r="200" gradientUnits="userSpaceOnUse">2350- <stop offset="0" stop-color="#ffb224" stop-opacity=".38"/><stop offset="1" stop-color="#ffb224" stop-opacity="0"/></radialGradient>`;2351- svg.append(defs);2352-2353- const aim = aimOf(stations, preset);2354- if (aim) {2355- const [x1, y1] = pos(maxKm, aim.deg - aim.spread);2356- const [x2, y2] = pos(maxKm, aim.deg + aim.spread);2357- svg.append(svgEl('path', { class: 'beam', d: `M0 0 L${x1} ${y1} A${R} ${R} 0 0 1 ${x2} ${y2} Z` }));2358- }2359-23602579 for (const km of [25, 50, 100, maxKm]) {23612580 svg.append(svgEl('circle', { class: `ring${km === maxKm ? ' edge' : ''}`, r: rad(km) }));23622581 svg.append(svgEl('text', { x: 3, y: -rad(km) - 3 }, `${km} km`));@@ -2366,140 +2585,88 @@ function renderRadar(stations, preset) {
23662585 for (const [t, x, y] of [['N', 0, -R - 8], ['E', R + 10, 4], ['S', 0, R + 16], ['W', -R - 10, 4]]) {23672586 svg.append(svgEl('text', { class: 'card', x, y, 'text-anchor': 'middle' }, t));23682587 }2369-2370- // group transmitters by tower site2588+ // Tower sites: the stations on the listed RF channels, by place.2589+ const rank = { lock: 3, part: 2, off: 1, none: 0 };23712590 const sites = new Map();2372- for (const s of stations) {2373- const key = `${s.point.lat.toFixed(3)},${s.point.lon.toFixed(3)}`;2374- if (!sites.has(key)) sites.set(key, []);2375- sites.get(key).push(s);2591+ for (const m of muxes) {2592+ const sig = muxSig(m);2593+ for (const x of m.stations) {2594+ const full = state.bySite.get(`${x.facilityId}:${m.rf}`);2595+ if (!full || !full.point) continue;2596+ const k = `${full.point.lat.toFixed(3)},${full.point.lon.toFixed(3)}`;2597+ const site = sites.get(k) || { x, rfs: [], calls: [], state: 'none', found: 0 };2598+ site.rfs.push(m.rf);2599+ site.calls.push(x.callSign);2600+ if (rank[sig.state] > rank[site.state]) site.state = sig.state;2601+ if (muxFound(m)) site.found++;2602+ sites.set(k, site);2603+ }23762604 }2377- const ordered = [...sites.values()].sort((a, b) => rankOf(a[0].signal[preset]?.tier) - rankOf(b[0].signal[preset]?.tier));2378- // Label the largest receivable site per city to keep the map readable.23792605 const labelled = new Map();2380- for (const group of sites.values()) {2381- const live = group.filter((s) => receivable(s.signal[preset]?.tier));2382- if (live.length < 2) continue;2383- const city = group[0].city;2384- if (!labelled.has(city) || labelled.get(city).length < live.length) labelled.set(city, group);2606+ for (const site of sites.values()) {2607+ if (site.found < 2) continue;2608+ const city = site.x.city;2609+ if (!labelled.has(city) || labelled.get(city).found < site.found) labelled.set(city, site);23852610 }2386- const labelSet = new Set([...labelled.values()]);2611+ const ordered = [...sites.values()].sort((a, b) => rank[a.state] - rank[b.state]);23872612 const labels = [];2388- for (const group of ordered) {2389- const best = group.reduce((m, s) => ((s.signal[preset]?.noiseMarginDb ?? -999) > (m.signal[preset]?.noiseMarginDb ?? -999) ? s : m));2390- const tier = best.signal[preset]?.tier || 'unknown';2391- const [x, y] = pos(best.distanceKm, best.bearingDeg);2392- const r = 3 + Math.min(7, Math.sqrt(group.length) * 2);2393- const ng = group.some((s) => s.atsc3);2394- const k = `${best.facilityId}:${best.rfChannel}`;2395- const dot = svgEl('circle', { class: `site-dot${ng ? ' ng' : ''}`, cx: x, cy: y, r, fill: TIER_COLOR[tier], 'data-k': k });2396- dot.dataset.site = group.map((s) => `${s.facilityId}:${s.rfChannel}`).join(' ');2397- dot.append(svgEl('title', {}, `${group.map((s) => s.callSign).join(', ')}\n${best.city}, ${best.distanceKm.toFixed(0)} km ${compass(best.bearingDeg)}`));2613+ for (const site of ordered) {2614+ const [x, y] = pos(site.x.distanceKm, site.x.bearingDeg);2615+ const r = 3 + Math.min(7, Math.sqrt(site.rfs.length) * 2);2616+ const dot = svgEl('circle', { class: `site-dot s-${site.state}`, cx: x, cy: y, r, 'data-rf': site.rfs[0] });2617+ dot.dataset.rfs = site.rfs.join(' ');2618+ dot.append(svgEl('title', {}, `${site.calls.join(', ')}\n${site.x.city}, ${site.x.distanceKm.toFixed(0)} km ${compass(site.x.bearingDeg)}, RF ${site.rfs.join(', ')}`));23982619 svg.append(dot);2399- if (labelSet.has(group)) labels.push({ x, y, r, text: `${best.city} ${group.length}` });2620+ if ([...labelled.values()].includes(site)) labels.push({ x, y, r, text: `${site.x.city} ${site.found}` });24002621 }2401- // Place labels outward from the center, skipping any that would collide.24022622 const placed = [];24032623 for (const l of labels.sort((a, b) => a.text.length - b.text.length)) {24042624 const left = l.x < 0;24052625 const w = l.text.length * 7.8;24062626 const x0 = left ? l.x - l.r - 5 - w : l.x + l.r + 5;24072627 const box = { x0, x1: x0 + w, y0: l.y - 9, y1: l.y + 6 };2408- if (placed.some((b) => box.x0 < b.x1 && b.x0 < box.x1 && box.y0 < b.y1 && b.y0 < box.y1)) continue;2628+ if (box.x0 < -224 || box.x1 > 224 || placed.some((b) => box.x0 < b.x1 && b.x0 < box.x1 && box.y0 < b.y1 && b.y0 < box.y1)) continue;24092629 placed.push(box);24102630 svg.append(svgEl('text', { class: 'site', x: left ? l.x - l.r - 5 : l.x + l.r + 5, y: l.y + 4, 'text-anchor': left ? 'end' : 'start' }, l.text));24112631 }24122632 svg.append(svgEl('circle', { class: 'home', r: 4 }));24132633 svg.append(svgEl('line', { class: 'ray', id: 'ray', x1: 0, y1: 0, x2: 0, y2: 0 }));2634+ markRadar();24142635 }24152636 2416-async function selectStation(k, quiet) {2417- state.aSel = k;2418- $$('.a-table tr').forEach((tr) => tr.classList.toggle('sel', tr.dataset.k === k));2419- $$('.radar .site-dot').forEach((d) => d.classList.toggle('sel', (d.dataset.site || '').split(' ').includes(k)));2420- const s = rx().bySite.get(k);2421- if (!s) return;2637+// markRadar marks the picked RF channel's transmitter, with a ray to it.2638+function markRadar() {2639+ $$('.radar .site-dot').forEach((d) => d.classList.toggle('sel', (d.dataset.rfs || '').split(' ').includes(String(state.aRF))));24222640 const ray = $('#ray');2423- if (ray) {2424- const R = 200;2425- const rr = R * Math.sqrt(Math.min(s.distanceKm, rx().report.radiusKm) / rx().report.radiusKm);2426- const a = (s.bearingDeg - 90) * Math.PI / 180;2427- ray.setAttribute('x2', rr * Math.cos(a));2428- ray.setAttribute('y2', rr * Math.sin(a));2429- }2430- const a = $('#antenna');2431- f(a, 'phead').innerHTML = `<b>${esc(s.callSign)}</b><span>RF ${s.rfChannel} ${esc(s.band)} | ${s.erpKw} kW | ${s.distanceKm.toFixed(1)} km at ${s.bearingDeg.toFixed(0)}° ${compass(s.bearingDeg)}</span>`;2432- try {2433- const prof = await api().Profile(s.facilityId, s.rfChannel, rx().zip);2434- if (state.aSel !== k) return;2435- drawProfile(prof, s);2436- } catch (e) {2437- if (!quiet) toast(String(e));2438- }2439-}2440-2441-function drawProfile(prof, s) {2442- const svg = $('.ap-svg');2443- const preset = state.presets.find((p) => p.name === state.settings.antenna) || state.presets[0];2444- const W = 600; const H = 180;2445- const D = prof.distanceKm * 1000;2446- const n = prof.elevations.length;2447- const xs = prof.elevations.map((_, i) => (D * i) / (n - 1));2448- const z = prof.elevations.map((e, i) => e + (xs[i] * (D - xs[i])) / (2 * EARTH_M));2449- const rxH = prof.rxGroundM + preset.heightM;2450- const txH = prof.txHeightM;2451- const lo = Math.min(...z) - 40;2452- const hi = Math.max(...z, rxH, txH) + 60;2453- const X = (x) => (x / D) * W;2454- const Y = (h) => H - ((h - lo) / (hi - lo)) * H;2455-2456- // obstruction: highest terrain above the line of sight2457- let worst = -Infinity; let wi = -1;2458- for (let i = 1; i < n - 1; i++) {2459- const los = rxH + ((txH - rxH) * xs[i]) / D;2460- if (z[i] - los > worst) { worst = z[i] - los; wi = i; }2641+ const m = state.byRF.get(state.aRF);2642+ const st = m && muxStation(m);2643+ if (!ray) return;2644+ if (!st) {2645+ ray.setAttribute('x2', 0);2646+ ray.setAttribute('y2', 0);2647+ return;24612648 }2462- const blocked = worst > 0;2463-2464- let d = `M0 ${H} `;2465- z.forEach((h, i) => { d += `L${X(xs[i]).toFixed(1)} ${Y(h).toFixed(1)} `; });2466- d += `L${W} ${H} Z`;2467- svg.innerHTML = `<defs><linearGradient id="terrainfill" x1="0" y1="0" x2="0" y2="1">2468- <stop offset="0" stop-color="#4a4236"/><stop offset="1" stop-color="#15130f"/></linearGradient></defs>2469- <path class="terrain" d="${d}"/>2470- <line class="los${blocked ? ' blocked' : ''}" x1="${X(0)}" y1="${Y(rxH)}" x2="${X(D)}" y2="${Y(txH)}"/>2471- <line class="mast" x1="1" y1="${Y(prof.rxGroundM)}" x2="1" y2="${Y(rxH)}"/>2472- <line class="mast" x1="${W - 1}" y1="${Y(prof.elevations[n - 1])}" x2="${W - 1}" y2="${Y(txH)}"/>2473- ${blocked ? `<circle class="hit" cx="${X(xs[wi])}" cy="${Y(z[wi])}" r="4"/>` : ''}`;2474-2475- const sig = prof.signal;2476- const parts = state.presets.map((p) => {2477- const e = sig[p.name];2478- if (!e || e.tier === 'unknown') return `${p.name} ?`;2479- return `${p.name} <b style="color:${TIER_COLOR[e.tier]}">${e.noiseMarginDb > 0 ? '+' : ''}${e.noiseMarginDb.toFixed(0)} dB</b>`;2480- });2481- const e = sig[preset.name] || {};2482- const why = blocked2483- ? `<span style="color:var(--red)">Blocked: terrain ${(xs[wi] / 1000).toFixed(0)} km out rises ${worst.toFixed(0)} m above the line of sight</span>`2484- : '<span style="color:var(--amber)">Clear line of sight</span>';2485- f($('#antenna'), 'pfoot').innerHTML = `${why}<span>${parts.join(' ')}</span><span>diffraction ${e.diffractionDb ?? '?'} dB</span><span>field ${e.fieldDbuVm ?? '?'} dBuV/m</span>`;2649+ const maxKm = state.report.radiusKm || 160;2650+ const rr = 200 * Math.sqrt(Math.min(st.distanceKm, maxKm) / maxKm);2651+ const a = (st.bearingDeg - 90) * Math.PI / 180;2652+ ray.setAttribute('x2', rr * Math.cos(a));2653+ ray.setAttribute('y2', rr * Math.sin(a));24862654 }24872655 2488-function renderNextGen(preset) {2656+// renderNextGen lists the stations broadcasting ATSC 3.0 here, as facts2657+// about them: what this tuner can't take in.2658+function renderNextGen() {24892659 const box = f($('#antenna'), 'nextgen');2490- const hosts = rx().report.atsc3 || [];2660+ const hosts = state.report.atsc3 || [];24912661 if (!hosts.length) {24922662 box.innerHTML = '';24932663 return;24942664 }24952665 const items = hosts.map((h) => {2496- const s = rx().byFacility.get(h.facilityId);2497- const e = s && s.signal[preset];2498- const tier = e ? e.tier : 'unknown';2499- const svcs = (h.services || []).map((v) => `<li><span>${esc(v.display.replace('-', '.'))}</span>${esc(v.network)} ${esc(v.name)}${v.atsc1Call && v.atsc1Call.split('-')[0] !== v.name.split('-')[0] ? ` <em>(1.0 on ${esc(v.atsc1Call)})</em>` : ''}</li>`).join('');2500- return `<div class="ng-host"><b>${esc(h.callSign)}</b> ${meter(tier)} <div class="mono">RF ${esc(h.rf)}${h.launched ? `, since ${esc(h.launched)}` : ''}${e && tier !== 'unknown' ? `, ${e.noiseMarginDb > 0 ? '+' : ''}${e.noiseMarginDb.toFixed(0)} dB` : ''}</div><ul>${svcs}</ul></div>`;2666+ const svcs = (h.services || []).map((v) => `<span><i>${esc(v.display.replace('-', '.').replace(/^0/, ''))}</i> ${esc(v.network || v.name)}</span>`).join(' ');2667+ return `<div class="ng-host"><b>${esc(h.callSign)}</b> <em>RF ${esc(h.rf)}</em> ${svcs}</div>`;25012668 }).join('');2502- box.innerHTML = `<div class="ng-label">NextGen TV (ATSC 3.0)</div><div class="ng-hosts">${items}</div>`;2669+ box.innerHTML = `<div class="ng-label">NextGen TV (ATSC 3.0) on the air here${nextGen() ? '' : '<small>This tuner takes in ATSC 1.0 only</small>'}</div><div class="ng-hosts">${items}</div>`;25032670 }25042671 25052672 // ---------- settings ----------@@ -2535,7 +2702,7 @@ const CONTROLS = [
25352702 ['Enter', 'A', 'Enter', 'Choose. On TV, Enter (the remote\'s OK) brings up the controls, Guide first, and A plays and pauses'],25362703 ['Escape', 'B', 'Esc, Back', 'Back, and puts the controls away'],25372704 ['g', 'Y', 'G', 'Guide'],2538- ['i', 'X', 'I', 'Program info, twice for reception. In the guide, more for a program: record, favorite, hide'],2705+ ['i', 'X', 'I', 'Program info, twice for the measured signal. In the guide, more for a program: record, favorite, hide'],25392706 ['PageUp PageDown', 'LB, RB', 'Page Up, Page Down', 'Channel down or up, a page of the guide or of Settings'],25402707 ['ShiftLeft ShiftRight', 'LT, RT', 'Shift Left, Shift Right', 'Back or ahead a minute'],25412708 ['Space', 'R3', 'Space, Play', 'Pause'],@@ -2609,20 +2776,6 @@ function settingsRows() {
26092776 choices: PAD_LABELS.map(([k, label]) => [k, k ? label : `Auto, ${detected}`]), get: () => localStorage.getItem(padLabelsKey) || '', set: setPadLabels,26102777 });26112778 2612- if (info.antenna !== false) {2613- add('Watching', {2614- id: 'antenna', kind: 'choice', label: 'Antenna', choices: state.presets.map((p) => [p.name, p.label]), get: () => s.antenna, set: setPreset,2615- subFor: (name) => {2616- const p = state.presets.find((x) => x.name === name);2617- const n = state.report ? state.report.channels.filter((c) => receivable(c.tier && c.tier[name])).length : 0;2618- return p ? `${p.heightM} m above ground${n ? `, ${n} channels` : ''}` : '';2619- },2620- });2621- add('Watching', {2622- id: 'showAll', kind: 'switch', label: 'Unlikely channels', sub: 'List channels you probably can\'t receive too',2623- get: () => !!s.showAll, set: setShowAll,2624- });2625- }26262779 const hours = (state.config && state.config.guideHours) || 24;26272780 add('Watching', {26282781 id: 'guideHours', kind: 'choice', delay: true, label: 'Listings', sub: 'How far ahead the guide goes',@@ -2658,13 +2811,17 @@ function settingsRows() {
26582811 id: 'test', kind: 'action', verb: 'tests it', label: 'Test connection',26592812 value: settingsUI.tested || (info.name ? `Connected to ${info.name}` : 'Test'), run: testServer,26602813 });2661- add('Server', { id: 'refresh', kind: 'action', verb: 'refreshes', label: 'Refresh all data', sub: 'Transmitters, reception and listings, fetched again', value: 'Refresh', run: refreshAll });2814+ add('Server', { id: 'refresh', kind: 'action', verb: 'refreshes', label: 'Refresh all data', sub: 'Transmitters and listings, fetched again', value: 'Refresh', run: refreshAll });26622815 26632816 add('About', { id: 'app', kind: 'info', label: 'Airwaves', value: state.boot && state.boot.version ? `Version ${state.boot.version}` : '' });26642817 if (info.name) {26652818 add('About', { id: 'server-info', kind: 'info', label: 'Server', value: info.name, sub: [info.version && `Version ${info.version}`, s.server].filter(Boolean).join(', ') });26662819 }2667- if (info.tuner && info.tuner.name) add('About', { id: 'tuner', kind: 'info', label: 'Tuner', value: info.tuner.name, sub: info.tuner.detail || '' });2820+ if (info.antenna !== false && info.tuner) {2821+ const t = info.tuner;2822+ const what = t.tuners ? `${t.tuners} tuner${t.tuners > 1 ? 's' : ''}, ${(t.standards || []).join(', ')}` : 'No tuner yet';2823+ add('About', { id: 'tuner', kind: 'info', label: 'Tuner', value: t.tuners ? tunerName(t) : what, sub: [t.tuners && what, t.model, t.tuners && !t.atsc3 && 'NextGen TV (ATSC 3.0) needs another tuner'].filter(Boolean).join(', ') });2824+ }26682825 add('About', { id: 'controls', kind: 'open', label: SETTINGS_LISTS.controls, open: 'controls' });26692826 if (state.report && state.report.sources.length) add('About', { id: 'sources', kind: 'open', label: SETTINGS_LISTS.sources, value: `${state.report.sources.length}`, open: 'sources' });26702827 for (const [i, w] of ((state.report && state.report.warnings) || []).entries()) add('About', { id: `warning${i}`, kind: 'info', label: 'Note', sub: w });@@ -2677,7 +2834,7 @@ function settingsList(name) {
26772834 const rows = [];26782835 const add = (row) => rows.push({ section: title, ...row });26792836 if (name === 'hidden') {2680- const known = state.report ? [...state.custom, ...state.report.channels] : [];2837+ const known = [...state.custom, ...state.antennaChans];26812838 const hidden = (state.settings.hidden || []).map((k) => known.find((c) => c.key === k) || unlistedChannel(k));26822839 if (hidden.length > 1) add({ id: 'all', kind: 'action', verb: 'shows them', label: 'All of them', value: 'Show', run: () => showHidden(hidden.map((c) => c.key)) });26832840 for (const c of hidden) {@@ -2900,12 +3057,6 @@ function closeSettingsList() {
29003057 }29013058 29023059 // ---------- what the settings rows do ----------2903-async function setShowAll(on) {2904- await saveAppSettings({ ...state.settings, showAll: on });2905- buildLineup();2906- renderAll();2907- toast(`${on ? 'Listing' : 'Not listing'} channels you probably can't receive. ${state.lineup.length} channels.`, 2500);2908-}29093060 29103061 async function setGuideHours(hours) {29113062 toast('Updating the listings...', 30000);@@ -2935,7 +3086,8 @@ async function setWatched(days) {
29353086 29363087 async function hideShopping() {29373088 if (!state.report) return;2938- const shop = state.report.channels.filter((c) => SHOPPING.test(c.network || '')).map((c) => c.key);3089+ // By the listings' network, or the broadcast's own name ("ShopLC").3090+ const shop = state.antennaChans.filter((c) => SHOPPING.test(c.network || '') || SHOPPING.test(c.name || '')).map((c) => c.key);29393091 const was = new Set(state.settings.hidden || []);29403092 const added = shop.filter((k) => !was.has(k)).length;29413093 if (!added) return toast(shop.length ? 'Shopping channels are hidden already' : 'No shopping channels to hide', 2500);@@ -4351,72 +4503,6 @@ function updatePlaybackState() {
43514503 mediaSession.playbackState = !state.current && !state.recording ? 'none' : wx || !video.paused ? 'playing' : 'paused';43524504 }43534505 4354-// ---------- preview another ZIP code ----------4355-async function startPreview(zip) {4356- if (!/^\d{5}$/.test(zip)) return toast('Enter a 5-digit ZIP code');4357- toast(`Estimating reception at ${zip}`, 30000);4358- try {4359- const rep = (await api().Preview(zip)).report;4360- const bySite = new Map();4361- const byFacility = new Map();4362- for (const st of rep.stations) {4363- if (!byFacility.has(st.facilityId)) byFacility.set(st.facilityId, st);4364- bySite.set(`${st.facilityId}:${st.rfChannel}`, st);4365- }4366- for (const c of rep.channels) c.key = `${c.number}|${c.callSign}`;4367- state.preview = { report: rep, bySite, byFacility, zip };4368- state.aSel = null;4369- renderAntenna();4370- toast(`Previewing ${rep.place.name}, ${rep.place.state}`, 2000);4371- } catch (e) {4372- toast(String(e && e.message ? e.message : e), 6000);4373- }4374-}4375-4376-function endPreview() {4377- state.preview = null;4378- state.aSel = null;4379- renderAntenna();4380-}4381-4382-function renderPreviewRail(rep) {4383- const a = $('#antenna');4384- const on = !!state.preview;4385- f(a, 'preview').hidden = on;4386- f(a, 'previewing').hidden = !on;4387- if (on) f(a, 'pvplace').innerHTML = `<dt>Preview</dt><dd>${esc(rep.place.name)}, ${esc(rep.place.state)}<small>ZIP ${esc(rep.place.zip)}</small></dd>`;4388-}4389-4390-// receptionSummary is a shareable plain-text version of the reception view.4391-function receptionSummary() {4392- const rep = rx().report;4393- const order = ['indoor', 'attic', 'rooftop'];4394- const best = (c) => order.find((p) => receivable(c.tier && c.tier[p]));4395- // One line per station: its main channel, deduplicated by number.4396- const mains = new Map();4397- for (const c of rep.channels) {4398- if (c.atsc3Only || (c.minor !== 1 && c.minor !== 0)) continue;4399- const prev = mains.get(c.number);4400- if (!prev || order.indexOf(best(c) ?? 'x') < order.indexOf(best(prev) ?? 'x')) mains.set(c.number, c);4401- }4402- const name = (c) => `${c.number} ${c.network || displayCall(c)}`;4403- const group = (p) => [...mains.values()].filter((c) => best(c) === p).map(name);4404- const lines = [`Over-the-air TV at ${rep.place.name}, ${rep.place.state} ${rep.place.zip}`, ''];4405- lines.push(`Channels by antenna: ${order.map((p) => `${p} ${rep.channels.filter((c) => receivable(c.tier && c.tier[p])).length}`).join(', ')}`);4406- const aim = aimOf([...rep.stations], 'rooftop');4407- if (aim) lines.push(`Point an outdoor antenna ${aim.deg.toFixed(0)}° ${compass(aim.deg)}`);4408- lines.push('');4409- for (const [p, label] of [['indoor', 'With an indoor antenna'], ['attic', 'Adds with an attic antenna'], ['rooftop', 'Adds with a rooftop antenna']]) {4410- const g = group(p);4411- if (g.length) lines.push(`${label}: ${g.join(', ')}`);4412- }4413- if ((rep.atsc3 || []).length) {4414- lines.push('', `NextGen TV (ATSC 3.0): ${rep.atsc3.map((h) => h.callSign).join(', ')}`);4415- }4416- lines.push('', 'Estimated from FCC transmitter data and terrain. Walls, trees and nearby buildings can cost more.');4417- return lines.join('\n');4418-}4419-44204506 // ---------- weather ----------44214507 // showWX puts the Airwaves Weather display on the stage: as the weather channel44224508 // (with its music), or as a silent preview on the weather page. The display@@ -89,36 +89,27 @@
8989 </div>9090 </section>9191 92- <!-- Reception -->92+ <!-- Reception: what the tuners measured, by RF channel (app.js) -->9393 <section id="antenna" class="view railed" aria-label="Reception">9494 <nav class="rail">95- <div class="rail-previewing" data-f="previewing" hidden>96- <dl class="rail-stats" data-f="pvplace"></dl>97- <button type="button" class="btn fx" data-act="copy">Copy summary</button>98- <button type="button" class="btn ghost fx" data-act="back">Back to my setup</button>95+ <div class="a-tuner fx" data-f="tuner" data-act="tuner" tabindex="-1"></div>96+ <div class="a-measure">97+ <button type="button" class="btn fx" data-act="measure" data-f="measure"><span data-f="mlabel">Measure now</span></button>98+ <div class="a-mprog" data-f="mprog" hidden><i data-f="mbar"></i></div>99+ <div class="a-mnote" data-f="mnote"></div>99100 </div>100- <div class="rail-menu" data-f="presets" role="radiogroup" aria-label="Antenna"></div>101101 <dl class="rail-stats" data-f="stats"></dl>102- <form class="rail-preview" data-f="preview" autocomplete="off">103- <!-- OK on the label puts the cursor in the field (app.js). -->104- <label class="fx" tabindex="-1">Preview a ZIP code <input name="zip" inputmode="numeric" maxlength="5" placeholder="80302"></label>105- </form>106102 </nav>107103 <div class="main a-body">108104 <div class="a-left">109105 <div class="radar-box"><svg class="radar" viewBox="-226 -226 452 452" role="img" aria-label="Transmitter map"></svg></div>110- <div class="a-profile">111- <div class="ap-head" data-f="phead"></div>112- <svg class="ap-svg" viewBox="0 0 600 180" preserveAspectRatio="none"></svg>113- <div class="ap-foot" data-f="pfoot"></div>114- </div>106+ <div class="a-detail" data-f="detail"></div>115107 </div>116108 <div class="a-right">117109 <div class="a-tablewrap">118110 <table class="a-table">119111 <thead><tr>120- <th>Transmitter</th><th>RF</th><th>Carries</th><th class="r">Dist</th><th class="r">ERP</th>121- <th class="c" title="Noise margin, indoor">In</th><th class="c" title="Noise margin, attic">Attic</th><th class="c" title="Noise margin, rooftop">Roof</th>112+ <th>RF</th><th>Transmitter</th><th>Channels</th><th class="c">Signal</th><th class="r">Quality</th><th class="r">Errors</th><th class="r">Measured</th>122113 </tr></thead>123114 <tbody></tbody>124115 </table>@@ -21,11 +21,6 @@
2121 --amber-2: #c98714;2222 --teal: #3fe0c5;2323 --red: #ff5d4a;24- --t-strong: #ffc247;25- --t-good: #e59c2f;26- --t-fair: #a9813f;27- --t-weak: #6d5f4a;28- --t-unlikely: #34312d;2924 --f-display: "Shoulders", "Impact", sans-serif;3025 --f-body: "Schibsted", "Helvetica Neue", sans-serif;3126 --f-mono: "Plex Mono", "Menlo", monospace;@@ -289,60 +284,96 @@ body.mode-guide #guide, body.mode-antenna #antenna, body.mode-settings #settings
289284 .rail-stats dd { margin: 4px 0 0; font: 800 36px/1 var(--f-display); color: var(--paper); }290285 .rail-stats dd small { display: block; margin-top: 5px; font: 400 12px/1.35 var(--f-body); color: var(--muted); }291286 .main { min-height: 0; }292-.a-body { display: grid; grid-template-columns: minmax(340px, 42%) minmax(0, 1fr); gap: 30px; }293-.a-left { display: grid; grid-template-rows: minmax(0, 1fr) auto auto; min-height: 0; }287+/* The tuner, and Measure now with its progress, in the rail. */288+.a-tuner { display: grid; gap: 3px; padding: 12px 14px; border-radius: 3px; background: var(--ink-2); box-shadow: inset 3px 0 0 var(--line-2); }289+.at-label { font: 500 10px var(--f-mono); letter-spacing: .16em; text-transform: uppercase; color: var(--faint); }290+.at-name { font: 800 30px/1 var(--f-display); color: var(--paper); margin: 3px 0 2px; }291+.at-line { font-size: 13px; color: var(--muted); }292+.at-warn { color: var(--amber); }293+.at-note { font-size: 12px; color: var(--teal); margin-top: 4px; }294+.at-model { font: 400 10px var(--f-mono); color: var(--faint); margin-top: 4px; }295+.a-measure { display: grid; gap: 8px; margin-top: -14px; }296+.a-measure .btn { justify-self: stretch; text-align: center; }297+.a-measure .btn.busy:not(:focus) { background: var(--ink-3); color: var(--amber); box-shadow: inset 0 0 0 1px var(--amber-2); }298+.a-mprog { height: 4px; border-radius: 2px; background: var(--ink-4); overflow: hidden; }299+.a-mprog[hidden] { display: none; }300+.a-mprog i { display: block; height: 100%; width: 0; background: var(--amber); transition: width .4s var(--ease); }301+.a-mnote { font-size: 12px; line-height: 1.35; color: var(--muted); padding: 0 2px; }302+303+.a-body { display: grid; grid-template-columns: minmax(280px, 34%) minmax(0, 1fr); gap: 28px; }304+.a-left { display: grid; grid-template-rows: minmax(0, 1fr) auto; min-height: 0; gap: 10px; }294305 .radar-box { position: relative; min-height: 0; }295-.radar { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }306+.radar { position: absolute; inset: 0; width: 100%; height: 100%; overflow: hidden; }296307 .radar .ring { fill: none; stroke: var(--line-2); stroke-dasharray: 2 4; }297308 .radar .ring.edge { stroke-dasharray: none; stroke: var(--line); }298309 .radar .axis { stroke: var(--line); }299310 .radar text { font: 400 11px var(--f-mono); fill: var(--faint); letter-spacing: .08em; }300311 .radar text.card { font: 600 17px var(--f-display); fill: var(--muted); letter-spacing: .1em; }301312 .radar text.site { font: 500 13px var(--f-mono); fill: var(--paper); paint-order: stroke; stroke: var(--ink); stroke-width: 3px; }302-.radar .beam { fill: url(#beam); }303313 .radar .home { fill: var(--paper); }304-.radar .site-dot { cursor: pointer; stroke: var(--ink); stroke-width: 1.5px; transition: r .2s; }305-.radar .site-dot.ng { stroke: var(--teal); stroke-width: 2px; }314+.radar .site-dot { cursor: pointer; stroke: var(--ink); stroke-width: 1.5px; transition: r .2s; fill: var(--ink-4); }315+.radar .site-dot.s-lock { fill: var(--amber); }316+.radar .site-dot.s-part { fill: var(--amber-2); }317+.radar .site-dot.s-off { fill: var(--red); }306318 .radar .site-dot.sel { stroke: var(--paper); stroke-width: 2.5px; }307319 .radar .ray { stroke: var(--amber); stroke-width: 1; stroke-dasharray: 3 3; opacity: .7; }308-.a-profile { padding-top: 12px; }309-.ap-head { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; font: 400 12px var(--f-mono); color: var(--muted); }310-.ap-head b { font: 600 20px var(--f-display); color: var(--paper); letter-spacing: .03em; }311-.ap-svg { width: 100%; height: 150px; display: block; margin-top: 8px; }312-.ap-svg .terrain { fill: url(#terrainfill); stroke: #6d6253; stroke-width: 1; vector-effect: non-scaling-stroke; }313-.ap-svg .los { fill: none; stroke: var(--amber); stroke-width: 1.5; stroke-dasharray: 5 4; vector-effect: non-scaling-stroke; }314-.ap-svg .los.blocked { stroke: var(--red); }315-.ap-svg .mast { stroke: var(--paper); stroke-width: 2; vector-effect: non-scaling-stroke; }316-.ap-svg .hit { fill: var(--red); }317-.ap-foot { display: flex; gap: 18px; flex-wrap: wrap; font: 400 12px var(--f-mono); color: var(--muted); margin-top: 8px; }318-.ap-foot b { font-weight: 500; }320+321+/* The picked RF channel: its last reading, its readings over time (a bar322+ a stretch: the mean quality, the low as a notch), its channels and the323+ stations licensed on it. */324+.a-detail { display: grid; gap: 8px; min-height: 0; }325+.a-detail:empty { display: none; }326+.ad-head { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; font: 400 12px var(--f-mono); color: var(--muted); }327+.ad-head b { font: 800 26px/1 var(--f-display); color: var(--paper); }328+.ad-scan { margin-left: auto; }329+.ad-sig { display: flex; gap: 14px; align-items: center; }330+.ad-label { font: 600 22px/1 var(--f-display); letter-spacing: .02em; }331+.ad-sub { font-size: 12px; color: var(--muted); margin-top: 3px; }332+.ad-hist { display: flex; align-items: flex-end; gap: 3px; height: 46px; padding-bottom: 1px; border-bottom: 1px solid var(--line-2); }333+.ad-hist i { position: relative; flex: 1 1 0; max-width: 14px; height: var(--h); border-radius: 2px 2px 0 0; background: var(--amber); }334+.ad-hist i::after { content: ""; position: absolute; left: 0; right: 0; bottom: var(--lo); height: 1px; background: var(--ink); opacity: .55; }335+.ad-hist i.h-part { background: var(--amber-2); }336+.ad-hist i.h-off { background: var(--red); height: 4px; }337+.ad-hist i.h-off::after { display: none; }338+.ad-axis { display: flex; justify-content: space-between; font: 400 10px var(--f-mono); color: var(--faint); }339+.ad-chans { display: flex; flex-wrap: wrap; gap: 3px 14px; font-size: 12px; color: var(--muted); }340+.ad-chans b { font: 500 11px var(--f-mono); color: var(--paper); }341+.ad-stations { margin: 0; padding: 0; list-style: none; display: grid; gap: 2px; font-size: 12px; color: var(--muted); max-height: calc(14 * var(--vh)); overflow: hidden; }342+.ad-stations b { color: var(--paper); font-weight: 500; }343+.ad-hint { font: 400 11px var(--f-mono); color: var(--faint); }319344 320345 .a-right { display: grid; grid-template-rows: minmax(0, 1fr) auto; gap: 16px; min-height: 0; }321346 .a-tablewrap { overflow: auto; min-height: 0; scrollbar-width: thin; }322347 .a-table { width: 100%; border-collapse: collapse; font-size: 13px; }323348 .a-table th { position: sticky; top: 0; z-index: 1; background: var(--ink); text-align: left; font: 500 10px var(--f-mono); letter-spacing: .14em; text-transform: uppercase; color: var(--muted); padding: 8px; border-bottom: 1px solid var(--line-2); }324-.a-table td { padding: 8px; border-bottom: 1px solid var(--line); vertical-align: middle; white-space: nowrap; }349+.a-table td { padding: 7px 8px; border-bottom: 1px solid var(--line); vertical-align: middle; white-space: nowrap; }325350 .a-table .r { text-align: right; } .a-table .c { text-align: center; }326-.a-table tr { cursor: pointer; transition: background .15s; }327-.a-table tbody tr:hover { background: var(--ink-2); }351+.a-table tr[data-rf] { cursor: pointer; transition: background .15s; }352+.a-table tbody tr[data-rf]:hover { background: var(--ink-2); }328353 .a-table tr.sel { background: var(--ink-3); box-shadow: inset 3px 0 0 var(--amber-2); }329-.a-table tr.dim td { opacity: .42; }354+.a-table tr.nf td { color: var(--muted); }355+.a-table tr.nf .call, .a-table tr.nf .rf b { color: var(--muted); }356+.a-table .rf b { font: 800 24px/1 var(--f-display); }330357 .a-table .call { font: 600 18px var(--f-display); letter-spacing: .03em; }331358 .a-table .sub { font-size: 11px; color: var(--muted); }332359 .a-table .mono { font-family: var(--f-mono); font-size: 12px; }333-.a-table .carries { font: 400 11px var(--f-mono); color: var(--muted); white-space: normal; max-width: 220px; }334-.nm { display: inline-block; min-width: 48px; padding: 3px 0; border-radius: 2px; font: 500 11px var(--f-mono); text-align: center; color: var(--ink); background: var(--c); }335-.nm.dark { color: var(--paper); }336-337-.a-nextgen { max-height: calc(36 * var(--vh)); overflow: auto; scrollbar-width: thin; padding: 14px 18px; background: var(--ink-2); border-top: 2px solid var(--teal); border-radius: 3px; }360+.a-table .carries { font: 400 11px var(--f-mono); color: var(--paper); white-space: normal; max-width: 150px; }361+.a-table .carries span { white-space: nowrap; }362+.a-table .errs { color: var(--red); }363+.a-table tr.a-sect td { padding: 18px 8px 6px; font: 500 10px var(--f-mono); letter-spacing: .14em; text-transform: uppercase; color: var(--faint); border-bottom: 1px solid var(--line-2); }364+.meter.m-none i { background: var(--ink-4); }365+.meter.m-off i:first-child { background: var(--red); }366+367+.a-nextgen { max-height: calc(24 * var(--vh)); overflow: hidden; padding: 12px 18px; background: var(--ink-2); border-top: 2px solid var(--teal); border-radius: 3px; }338368 .a-nextgen:empty { display: none; }339-.ng-label { font: 500 11px var(--f-mono); letter-spacing: .16em; text-transform: uppercase; color: var(--teal); margin-bottom: 10px; }340-.ng-hosts { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px 22px; }341-.ng-host b { font: 600 17px var(--f-display); letter-spacing: .03em; }342-.ng-host .mono { font: 400 11px var(--f-mono); color: var(--muted); }343-.ng-host ul { margin: 4px 0 0; padding: 0; list-style: none; font-size: 13px; }344-.ng-host li span { font-family: var(--f-mono); font-size: 11px; color: var(--teal); margin-right: 8px; }345-.ng-host li em { color: var(--muted); font-style: normal; font-size: 12px; }369+.ng-label { font: 500 11px var(--f-mono); letter-spacing: .16em; text-transform: uppercase; color: var(--teal); margin-bottom: 10px; display: flex; gap: 14px; align-items: baseline; flex-wrap: wrap; }370+.ng-label small { font: 400 11px var(--f-body); letter-spacing: 0; text-transform: none; color: var(--muted); }371+.ng-hosts { display: grid; gap: 4px; }372+.ng-host { font-size: 13px; color: var(--muted); line-height: 1.5; }373+.ng-host b { font: 600 17px var(--f-display); letter-spacing: .03em; color: var(--paper); margin-right: 4px; }374+.ng-host em { font: 400 11px var(--f-mono); font-style: normal; margin-right: 10px; }375+.ng-host span { white-space: nowrap; margin-right: 10px; }376+.ng-host span i { font: 400 11px var(--f-mono); font-style: normal; color: var(--teal); }346377 347378 /* ---------- Settings ---------- */348379 /* One list of rows (settingsRows in app.js), moved through with a remote or@@ -530,19 +561,6 @@ body:not(.mode-guide) .g-cell.rec-on::after { animation: none; } /* hidden, so s
530561 .r-card.watched .r-art { filter: saturate(.4) brightness(.7); }531562 .r-hint { list-style: none; padding: 14px 16px; font: 400 11px var(--f-mono); color: var(--faint); letter-spacing: .06em; }532563 533-/* ---------- Reception preview ---------- */534-.rail-preview { padding: 0 14px; }535-.rail-preview label { display: grid; gap: 6px; font: 500 10px var(--f-mono); letter-spacing: .16em; text-transform: uppercase; color: var(--faint); }536-.rail-preview input {537- background: var(--ink); border: 1px solid var(--line-2); border-radius: 3px; padding: 9px 11px; width: 100%;538- font: 400 15px var(--f-mono); color: var(--paper); outline: none; letter-spacing: .06em; -webkit-user-select: text; user-select: text;539-}540-.rail-preview input:focus { border-color: var(--amber); }541-.rail-previewing { display: grid; gap: 10px; padding: 14px; border-radius: 3px; background: var(--ink-2); box-shadow: inset 3px 0 0 var(--teal); }542-.rail-previewing[hidden], .rail-preview[hidden] { display: none; }543-.rail-previewing .rail-stats { padding: 0; }544-.rail-previewing .btn { justify-self: stretch; text-align: center; }545-546564 /* ---------- Weather ---------- */547565 /* The frame never takes focus, so channel keys keep working on WX. */548566 /* Always 854x480, the display's own size, scaled by fitWX in app.js to@@ -713,7 +731,7 @@ body.lite { --glow: 0 0 0 0 transparent; }
713731 }714732 /* A filled button keeps its fill, ringed apart from it. */715733 .btn.fx:not(.ghost):focus { background-color: var(--amber); color: var(--ink); box-shadow: 0 0 0 2px var(--ink), 0 0 0 5px var(--amber), var(--glow); }716-.wx-main .fx:focus, .rail-preview label.fx:focus { box-shadow: var(--ring-in), var(--glow); }734+.wx-main .fx:focus, .a-tuner.fx:focus { box-shadow: var(--ring-in), var(--glow); }717735 body:not(.dock-nav) #guide[data-zone="grid"] .g-cell.sel,718736 body:not(.dock-nav) .s-row.focus,719737 body:not(.dock-nav) #recordings[data-zone="list"] .r-item.sel,@@ -726,7 +744,6 @@ body:not(.dock-nav) #recordings[data-zone="list"] .r-card.sel { box-shadow: var(
726744 /* Where the focus goes back to when it's elsewhere in the view. */727745 .rm-item.fx:focus { box-shadow: var(--ring-in), var(--glow); }728746 .g-filters button.fx:focus { box-shadow: var(--ring-in); color: var(--amber); }729-.rail-preview label.fx { margin: -8px -10px; padding: 8px 10px; border-radius: 3px; }730747 .wx-main > .fx { border-radius: 3px; }731748 .wx-main .wx-block.fx, .wx-main .wx-days.fx, .wx-main .wx-maps.fx { padding: 10px 12px; margin: -10px -12px; }732749 .wx-main { padding: 12px 20px 12px 14px; }@@ -740,5 +757,5 @@ body:not(.dock-nav) #recordings[data-zone="list"] .r-card.sel { box-shadow: var(
740757 .act.armed { box-shadow: inset 0 0 0 1px var(--red); color: var(--red); }741758 .act.fx.armed:focus { box-shadow: var(--ring), var(--glow); color: var(--red); }742759 /* A field being typed in has the ring, not its row. */743-.boot-connect input:focus, .rail-preview input:focus, .s-row input:focus { box-shadow: var(--ring); border-color: var(--amber); }760+.boot-connect input:focus, .s-row input:focus { box-shadow: var(--ring); border-color: var(--amber); }744761 body:not(.dock-nav) .s-row.focus:focus-within { box-shadow: inset 0 0 0 1px var(--line-2); transform: none; }@@ -54,8 +54,10 @@
5454 let memory = null; // when localStorage can't be used5555 5656 function normalized(s) {57- const out = { server: '', token: '', clientId: '', antenna: 'rooftop', showAll: false, lastChannel: '', captions: false, ...s };58- if (!['indoor', 'attic', 'rooftop'].includes(out.antenna)) out.antenna = 'rooftop';57+ const out = { server: '', token: '', clientId: '', lastChannel: '', captions: false, ...s };58+ // Settings of reception estimates, from before the measured lineup.59+ delete out.antenna;60+ delete out.showAll;5961 if (out.scale && (out.scale < 50 || out.scale > 300)) out.scale = 0;6062 // Its own client ID, so the server keeps this screen's stream apart.6163 if (!out.clientId) out.clientId = `${tvApp ? 'tv' : 'web'}-${hex(6)}`;@@ -140,7 +142,7 @@
140142 141143 async function boot() {142144 const out = {143- settings: load(), presets: [], info: { name: '', mode: '', version: '', tuner: {}, dvr: false, antenna: false }, config: {},145+ settings: load(), info: { name: '', mode: '', version: '', tuner: {}, dvr: false, antenna: false }, config: {},144146 serverUrl: base,145147 view: query.get('view') || '',146148 // Lite on the Android TV app, a TV box like the Linux ones;@@ -157,12 +159,11 @@
157159 };158160 lastBoot = out;159161 try {160- const [info, config, presets] = await Promise.all([162+ const [info, config] = await Promise.all([161163 call('GET', '/api/info', undefined, { ms: 10_000 }),162164 call('GET', '/api/config', undefined, { ms: 10_000 }),163- call('GET', '/api/presets', undefined, { ms: 10_000 }),164165 ]);165- Object.assign(out, { info, config, presets });166+ Object.assign(out, { info, config });166167 out.version = out.version || info.version;167168 } catch (e) {168169 out.error = String(e);@@ -208,8 +209,9 @@
208209 emit('progress', `Loading lineup and listings from ${serverName()}`);209210 return call('GET', `/api/snapshot${refresh ? '?refresh=1' : ''}`);210211 },211- Profile: (facilityID, rf, zip) => call('GET', `/api/profile?facility=${facilityID}&rf=${rf}&zip=${enc(zip || '')}`),212- Preview: (zip) => call('GET', `/api/preview?zip=${enc(zip || '')}`),212+ // What the tuners measured, and Measure now.213+ Signal: () => call('GET', '/api/signal', undefined, { ms: 15_000 }),214+ Measure: () => call('POST', '/api/signal/measure', {}, { ms: 15_000 }),213215 Weather: () => call('GET', '/api/weather'),214216 215217 async CopyText(text) {@@ -19,7 +19,6 @@ import (
1919 2020 "airwaves/internal/dvr"2121 "airwaves/internal/phase"22- "airwaves/internal/reception"2322 "airwaves/internal/service"2423 "airwaves/internal/stream"2524 "airwaves/internal/weather"@@ -54,10 +53,6 @@ var (
5453 _ Backend = (*Client)(nil)5554 )5655 57-// ErrEstimates answers the calls for reception estimates the app used to58-// make.59-var ErrEstimates = errors.New("reception estimates moved to the otascan planning tool: the app shows what the tuner measures")60-6156 // Server serves a Backend.6257 type Server struct {6358 Backend Backend@@ -120,11 +115,6 @@ func (s *Server) Handler() http.Handler {
120115 mux.HandleFunc("GET /api/config", func(w http.ResponseWriter, r *http.Request) {121116 reply(w)(b.Config(r.Context()))122117 })123- // The antenna presets of the reception estimates, which TV pages from124- // before the measured lineup still ask for when they start.125- mux.HandleFunc("GET /api/presets", func(w http.ResponseWriter, r *http.Request) {126- reply(w)(reception.Presets, nil)127- })128118 mux.HandleFunc("PUT /api/config", func(w http.ResponseWriter, r *http.Request) {129119 var c service.Config130120 if !decode(w, r, &c) {@@ -141,17 +131,9 @@ func (s *Server) Handler() http.Handler {
141131 mux.HandleFunc("POST /api/signal/measure", func(w http.ResponseWriter, r *http.Request) {142132 reply(w)(b.Measure(r.Context()))143133 })144- // Reception estimates (terrain profiles, ZIP code previews) are for145- // planning an antenna, in otascan; the app shows what was measured.146- mux.HandleFunc("GET /api/profile", func(w http.ResponseWriter, r *http.Request) {147- writeErr(w, http.StatusGone, ErrEstimates)148- })149134 mux.HandleFunc("GET /api/weather", func(w http.ResponseWriter, r *http.Request) {150135 reply(w)(b.Weather(r.Context()))151136 })152- mux.HandleFunc("GET /api/preview", func(w http.ResponseWriter, r *http.Request) {153- writeErr(w, http.StatusGone, ErrEstimates)154- })155137 mux.HandleFunc("POST /api/tune", func(w http.ResponseWriter, r *http.Request) {156138 var req clientReq157139 if !decode(w, r, &req) {@@ -159,31 +159,21 @@ func TestClientServerRoundTrip(t *testing.T) {
159159 }160160 }161161 162-// TestNoEstimates: the reception estimates the app used to ask for are163-// gone, with a reason old app versions show; the presets they still load164-// at start remain.162+// TestNoEstimates: the reception estimates the app used to ask for, and163+// the antenna presets they were for, are gone.165164 func TestNoEstimates(t *testing.T) {166165 srv := httptest.NewServer((&Server{Backend: &fake{}}).Handler())167166 defer srv.Close()168- for _, path := range []string{"/api/preview?zip=80302", "/api/profile?facility=1&rf=2"} {167+ for _, path := range []string{"/api/preview?zip=80302", "/api/profile?facility=1&rf=2", "/api/presets"} {169168 resp, err := http.Get(srv.URL + path)170169 if err != nil {171170 t.Fatal(err)172171 }173- body, _ := io.ReadAll(resp.Body)174172 resp.Body.Close()175- if resp.StatusCode != http.StatusGone || !strings.Contains(string(body), "otascan") {176- t.Errorf("%s: %d %s", path, resp.StatusCode, body)173+ if resp.StatusCode != http.StatusNotFound {174+ t.Errorf("%s: %d", path, resp.StatusCode)177175 }178176 }179- resp, err := http.Get(srv.URL + "/api/presets")180- if err != nil {181- t.Fatal(err)182- }183- resp.Body.Close()184- if resp.StatusCode != http.StatusOK {185- t.Errorf("presets: %d", resp.StatusCode)186- }187177 }188178 189179 // TestErrorStatus: what a server lacks is 501, a channel with no signal@@ -1,14 +1,11 @@
11 package api22 33 import (4- "encoding/json"54 "io"65 "net/http"76 "net/http/httptest"87 "testing"98 "testing/fstest"10-11- "airwaves/internal/reception"129 )1310 1411 // TestTV: the TV interface is at /tv/ without the API's token, pages and@@ -89,32 +86,3 @@ func TestTV(t *testing.T) {
8986 t.Errorf("no TV: %v %v", resp, err)9087 }9188 }92-93-// TestPresets: the web app gets the antenna presets the desktop app has94-// built in.95-func TestPresets(t *testing.T) {96- srv := httptest.NewServer((&Server{Backend: &fake{}, Token: "s3cret"}).Handler())97- defer srv.Close()98- req, _ := http.NewRequest("GET", srv.URL+"/api/presets", nil)99- resp, err := http.DefaultClient.Do(req)100- if err != nil {101- t.Fatal(err)102- }103- resp.Body.Close()104- if resp.StatusCode != http.StatusUnauthorized {105- t.Errorf("without the token: %d", resp.StatusCode)106- }107- req.Header.Set("Authorization", "Bearer s3cret")108- if resp, err = http.DefaultClient.Do(req); err != nil {109- t.Fatal(err)110- }111- defer resp.Body.Close()112- var got []reception.Preset113- if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {114- t.Fatal(err)115- }116- if len(got) != len(reception.Presets) || got[0].Name != "indoor" || got[2].Name != "rooftop" || got[2].Label != reception.Presets[2].Label ||117- got[1].HeightM != reception.Presets[1].HeightM || got[0].GainDBi["UHF"] != reception.Presets[0].GainDBi["UHF"] {118- t.Errorf("presets = %+v", got)119- }120-}@@ -31,8 +31,8 @@ type Station struct {
3131 fcc.Facility3232 Band string `json:"band"`3333 // Signal is the estimate for each antenna preset, keyed by preset name;34- // empty without terrain.35- Signal map[string]reception.Estimate `json:"signal"`34+ // none without terrain.35+ Signal map[string]reception.Estimate `json:"signal,omitempty"`3636 ATSC3 bool `json:"atsc3"`3737 // Carries lists the virtual channels this transmitter is matched to.3838 Carries []string `json:"carries"`@@ -9,7 +9,6 @@ import (
99 1010 "airwaves/internal/fcc"1111 "airwaves/internal/guide"12- "airwaves/internal/reception"1312 )1413 1514 // Scanned is a channel a tuner's scan found.@@ -210,10 +209,9 @@ func nextGen(rep *Report, ch TunerChannel, t *Station) *NextGen {
210209 }211210 212211 // ForTuner is rep for an app whose lineup is the tuner's: the same213-// records, with no estimates (no presets, and each station's Signal214-// empty), each station's Carries the channels the tuner receives from it215-// and the ATSC 3.0 services it hosts, and no Channels (the caller has216-// them).212+// records, with no estimates (no presets, and no station's Signal), each213+// station's Carries the channels the tuner receives from it and the ATSC214+// 3.0 services it hosts, and no Channels (the caller has them).217215 func ForTuner(rep *Report, chans []TunerChannel) *Report {218216 out := *rep219217 out.Presets = nil@@ -233,7 +231,7 @@ func ForTuner(rep *Report, chans []TunerChannel) *Report {
233231 }234232 out.Stations = make([]Station, len(rep.Stations))235233 for i, s := range rep.Stations {236- s.Signal = map[string]reception.Estimate{}234+ s.Signal = nil237235 s.Carries = append(slices.Clone(carried[[2]int{s.FacilityID, s.RFChannel}]), hosted[s.FacilityID]...)238236 if s.Carries == nil {239237 s.Carries = []string{}@@ -94,7 +94,7 @@ func TestForTuner(t *testing.T) {
9494 carries := map[string][]string{}9595 for _, s := range out.Stations {9696 carries[s.CallSign] = s.Carries97- if s.Signal == nil || len(s.Signal) != 0 {97+ if s.Signal != nil {9898 t.Errorf("%s signal %v", s.CallSign, s.Signal)9999 }100100 }@@ -111,7 +111,7 @@ func TestForTuner(t *testing.T) {
111111 t.Errorf("stations not nearest first, or the report changed: %+v", out.Stations[0])112112 }113113 raw, _ := json.Marshal(out)114- for _, bad := range []string{"noiseMarginDb", "tier", `"presets"`} {114+ for _, bad := range []string{"noiseMarginDb", "tier", `"presets"`, `"signal"`} {115115 if strings.Contains(string(raw), bad) {116116 t.Errorf("report has %s", bad)117117 }@@ -14,7 +14,6 @@ import (
1414 "airwaves/internal/fcc"1515 "airwaves/internal/guide"1616 "airwaves/internal/lineup"17- "airwaves/internal/reception"1817 "airwaves/internal/signal"1918 "airwaves/internal/tuner"2019 "airwaves/internal/tvh"@@ -365,42 +364,6 @@ func hostsRF(s string, rf int) bool {
365364 return err == nil && n == rf366365 }367366 368-// compatChannels is the tuner's lineup in the report's older Channel369-// shape, for app versions from before the measured lineup, which list a370-// channel only when its "tier" reads as receivable, and key favorites by371-// the listings' call sign ("KWGNDT"), as that shape had it. Its tier is measured,372-// not estimated, and the same for every antenna: "good" when the latest373-// reading of its multiplex (or, before any, Tvheadend's scan) locked,374-// "weak" when it didn't.375-func compatChannels(a *Antenna) []lineup.Channel {376- scans := map[int]*signal.Scan{}377- for _, m := range a.Muxes {378- scans[m.RF] = m.Scan379- }380- out := make([]lineup.Channel, 0, len(a.Channels))381- for _, c := range a.Channels {382- t := reception.Unknown383- switch {384- case c.Signal != nil && c.Signal.Lock:385- t = reception.Good386- case c.Signal != nil:387- t = reception.Weak388- case scans[c.RF] != nil && scans[c.RF].Lock, c.Demo:389- t = reception.Good390- }391- lc := lineup.Channel{392- Number: c.Number, Major: c.Major, Minor: c.Minor, GuideID: c.GuideID, CallSign: cmp.Or(c.GuideCallSign, c.CallSign), BaseCall: c.BaseCall,393- Network: c.Network, Logo: c.Logo, FacilityID: c.FacilityID, Via: c.Via,394- Tier: map[string]reception.Tier{"indoor": t, "attic": t, "rooftop": t},395- }396- if c.NextGen != nil {397- lc.ATSC3 = &lineup.Carriage{HostCall: c.NextGen.HostCall, FacilityID: c.NextGen.FacilityID, RF: c.NextGen.RF, Display: c.NextGen.Display}398- }399- out = append(out, lc)400- }401- return out402-}403-404367 // dvrChannels is the tuner's lineup for recording: number, call sign (the405368 // listings', as recording rules have it) and listings.406369 func dvrChannels(chans []lineup.TunerChannel) []lineup.Channel {@@ -17,7 +17,6 @@ import (
1717 "airwaves/internal/guide"1818 "airwaves/internal/hdhr"1919 "airwaves/internal/lineup/lineuptest"20- "airwaves/internal/reception"2120 "airwaves/internal/signal"2221 "airwaves/internal/tuner"2322 "airwaves/internal/tvh"@@ -126,23 +125,10 @@ func TestTunerLineup(t *testing.T) {
126125 t.Errorf("RF 34 = %+v", m)127126 }128127 129- // The report: the same channels for app versions that read it, with130- // a measured tier; the records with no estimates; the listings of the131- // tuner's channels only.132- var compat []string133- for _, c := range snap.Report.Channels {134- compat = append(compat, c.Number)135- // The listings' call sign, which that shape had (and app136- // versions key favorites by).137- if c.Number == "20.1" && (c.CallSign != "KTVDDT" || c.BaseCall != "KTVD") {138- t.Errorf("report 20.1 = %+v", c)139- }140- if c.Tier["indoor"] != reception.Good || c.Tier["rooftop"] != reception.Good {141- t.Errorf("%s tier %v", c.Number, c.Tier)142- }143- }144- if !slices.Equal(compat, numbers) {145- t.Errorf("report channels %v", compat)128+ // The report: the records with no estimates and no channels of its129+ // own (they're the tuner's); the listings of the tuner's channels only.130+ if snap.Report.Channels == nil || len(snap.Report.Channels) != 0 {131+ t.Errorf("report channels %v", snap.Report.Channels)146132 }147133 var ids []string148134 for _, c := range snap.Guide.Channels {@@ -155,12 +141,12 @@ func TestTunerLineup(t *testing.T) {
155141 t.Error("the server's own listings were trimmed")156142 }157143 raw, _ := json.Marshal(snap)158- for _, bad := range []string{"noiseMarginDb", `"presets"`, `"strong"`, `"fair"`} {144+ for _, bad := range []string{"noiseMarginDb", `"presets"`, `"strong"`, `"fair"`, `"tier"`, `"signal":{}`} {159145 if strings.Contains(string(raw), bad) {160146 t.Errorf("snapshot has %s", bad)161147 }162148 }163- for _, want := range []string{`"signal":{}`, `"antenna":{"tuner":{"ready":true`, `"standards":["ATSC 1.0"],"atsc3":false`, `"history":null`} {149+ for _, want := range []string{`"channels":[]`, `"antenna":{"tuner":{"ready":true`, `"standards":["ATSC 1.0"],"atsc3":false`, `"history":null`} {164150 if !strings.Contains(string(raw), want) {165151 t.Errorf("snapshot lacks %s", want)166152 }@@ -103,9 +103,8 @@ type Info struct {
103103 // Snapshot is the lineup with its listings.104104 type Snapshot struct {105105 // Report is what is on record at the location: the FCC's transmitters106- // (with no estimates: their "signal" is empty), the ATSC 3.0 hosts and107- // the data sources. Its Channels are the tuner's lineup, as Antenna108- // has it, in the shape app versions before the measured lineup read.106+ // (with no estimates), the ATSC 3.0 hosts and the data sources. Its107+ // Channels are empty: the channels are the tuner's, in Antenna.109108 Report *lineup.Report `json:"report"`110109 // Guide is the listings of the tuner's channels.111110 Guide *guide.Guide `json:"guide"`@@ -535,7 +534,6 @@ func (s *Service) Snapshot(ctx context.Context, refresh bool) (*Snapshot, error)
535534 chans[i] = c.TunerChannel536535 }537536 out.Report = lineup.ForTuner(snap.Report, chans)538- out.Report.Channels = compatChannels(a)539537 if len(a.Channels) == 0 && a.Tuner.Reason != "" {540538 out.Report.Warnings = append(slices.Clone(out.Report.Warnings), "No antenna channels: "+a.Tuner.Reason)541539 }@@ -19,11 +19,6 @@ type Settings struct {
1919 Token string `json:"token"`2020 // ClientID identifies this app to the server so its streams are its own.2121 ClientID string `json:"clientId"`22- // Antenna is the reception preset used to decide what is receivable:23- // "indoor", "attic" or "rooftop".24- Antenna string `json:"antenna"`25- // ShowAll lists every channel in the lineup, not just receivable ones.26- ShowAll bool `json:"showAll"`2722 // LastChannel is restored on launch.2823 LastChannel string `json:"lastChannel"`2924 // Favorites and Hidden hold channel keys: "number|callSign" for antenna@@ -41,15 +36,10 @@ type Settings struct {
4136 4237 // DefaultSettings returns settings for a first launch.4338 func DefaultSettings() Settings {44- return Settings{Antenna: "rooftop"}39+ return Settings{}4540 }4641 4742 func (s Settings) normalized() Settings {48- switch s.Antenna {49- case "indoor", "attic", "rooftop":50- default:51- s.Antenna = DefaultSettings().Antenna52- }5343 if s.Scale != 0 && (s.Scale < 50 || s.Scale > 300) {5444 s.Scale = 05545 }