clone

a desktop tv and program guide for over-the-air broadcasts: what's on air, what your tuner gets, live tv, recordings

Show measured signal in the app, and drop its estimates

FelineStateMachine committed c21939b
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
16 files changed, +617 -650
MREADME.md21+-
Mapp.go19+-
Mfrontend/dist/app.js890++++++++++++++++--------------
Mfrontend/dist/index.html25+-
Mfrontend/dist/style.css125++--
Mfrontend/dist/web.js18+-
Minternal/api/api.go18-
Minternal/api/api_test.go20+-
Minternal/api/tv_test.go32-
Minternal/lineup/lineup.go4+-
Minternal/lineup/tuner.go10+-
Minternal/lineup/tuner_test.go4+-
Minternal/service/antenna.go37-
Minternal/service/antenna_test.go26+-
Minternal/service/service.go6+-
Minternal/store/settings.go12+-
M README.md +13 -8
@@ -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
M app.go +5 -14
@@ -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()
M frontend/dist/app.js +488 -402
@@ -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
M frontend/dist/index.html +8 -17
@@ -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>
M frontend/dist/style.css +71 -54
@@ -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; }
M frontend/dist/web.js +10 -8
@@ -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) {
M internal/api/api.go +0 -18
@@ -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) {
M internal/api/api_test.go +5 -15
M internal/api/tv_test.go +0 -32
M internal/lineup/lineup.go +2 -2
M internal/lineup/tuner.go +4 -6
M internal/lineup/tuner_test.go +2 -2
M internal/service/antenna.go +0 -37
@@ -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 {
M internal/service/antenna_test.go +6 -20
@@ -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 }
M internal/service/service.go +2 -4
M internal/store/settings.go +1 -11