diff --git a/build.config.yml b/build.config.yml new file mode 100644 index 0000000..78e4ff4 --- /dev/null +++ b/build.config.yml @@ -0,0 +1,57 @@ +# Android Cast build pipeline (CircleCI-compatible shape) +version: 2.1 + +parameters: + git_ref: + type: string + default: next + gradle_task: + type: string + default: assembleDebug + run_tests: + type: boolean + default: true + run_native: + type: boolean + default: true + run_apk: + type: boolean + default: true + auto_ota: + type: boolean + default: false + ota_channel: + type: string + default: staging + +jobs: + android-build: + docker: + - image: androidcast-ci:00.01.00.1000 + steps: + - checkout + - run: + name: Native codecs + command: | + if [ "<< pipeline.parameters.run_native >>" = "true" ]; then + ./scripts/ci-build-and-publish-ota.sh + fi + - run: + name: Gradle + tests + command: | + export GRADLE_TASK="<< pipeline.parameters.gradle_task >>" + export RUN_UNIT_TESTS="<< pipeline.parameters.run_tests >>" + export RUN_APK="<< pipeline.parameters.run_apk >>" + export AUTO_OTA="<< pipeline.parameters.auto_ota >>" + export OTA_CHANNEL="<< pipeline.parameters.ota_channel >>" + ./scripts/ci-build-and-publish-ota.sh + - store_artifacts: + path: out/builds + +workflows: + build: + jobs: + - android-build: + filters: + branches: + only: [next, master] diff --git a/examples/app_hub/assets/build_ttf_lcd_alphabet.py b/examples/app_hub/assets/build_ttf_lcd_alphabet.py new file mode 100644 index 0000000..e139269 --- /dev/null +++ b/examples/app_hub/assets/build_ttf_lcd_alphabet.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""Vectorize LCD TTF fonts into SVG symbol sets for the hub clock widget.""" + +from __future__ import annotations + +import argparse +import re +from dataclasses import dataclass +from pathlib import Path + +from fontTools.pens.boundsPen import BoundsPen +from fontTools.pens.svgPathPen import SVGPathPen +from fontTools.ttLib import TTFont + +CHARS = ( + [" "] + + [str(d) for d in range(10)] + + [chr(c) for c in range(ord("A"), ord("Z") + 1)] + + [":", "+", "-", "/", "."] +) + +TARGET_HEIGHT = 32.0 +PADDING_X = 1.5 +PADDING_Y = 1.0 + + +@dataclass(frozen=True) +class FontSpec: + ttf: str + prefix: str + outfile: str + comment: str + + +FONTS: dict[str, FontSpec] = { + "dseg14-classic": FontSpec( + ttf="DSEG14Classic-Regular.ttf", + prefix="d14c", + outfile="dseg14-classic-alphabet.svg", + comment="DSEG14 Classic (OFL) — vectorized for hub clock", + ), + "dseg14-modern": FontSpec( + ttf="DSEG14Modern-Regular.ttf", + prefix="d14m", + outfile="dseg14-modern-alphabet.svg", + comment="DSEG14 Modern (OFL) — vectorized for hub clock", + ), + "ds-digital": FontSpec( + ttf="DS-Digital.ttf", + prefix="dsd", + outfile="ds-digital-alphabet.svg", + comment="DS-Digital by Dusit Supasawat (shareware) — vectorized for hub clock", + ), + "digital-7-mono-italic": FontSpec( + ttf="digital-7-mono-italic.ttf", + prefix="d7mi", + outfile="digital-7-mono-italic-alphabet.svg", + comment="Digital-7 Mono Italic by Style-7 (personal use) — vectorized for hub clock", + ), +} + + +def symbol_id(prefix: str, ch: str) -> str: + if ch == " ": + return f"{prefix}-space" + if ch == ":": + return f"{prefix}-colon" + if ch == "+": + return f"{prefix}-plus" + if ch == "-": + return f"{prefix}-minus" + if ch == "/": + return f"{prefix}-slash" + if ch == ".": + return f"{prefix}-dot" + if len(ch) == 1 and "0" <= ch <= "9": + return f"{prefix}-{ch}" + if len(ch) == 1 and "A" <= ch <= "Z": + return f"{prefix}-{ch}" + return f"{prefix}-u{ord(ch):04x}" + + +def glyph_path(font: TTFont, ch: str) -> tuple[str, float, float, float, float] | None: + cmap = font.getBestCmap() + code = ord(ch) + if code not in cmap: + return None + glyph_name = cmap[code] + glyph_set = font.getGlyphSet() + if glyph_name not in glyph_set: + return None + glyph = glyph_set[glyph_name] + + pen = SVGPathPen(glyph_set) + bounds_pen = BoundsPen(glyph_set) + glyph.draw(bounds_pen) + if bounds_pen.bounds is None: + return None + x_min, y_min, x_max, y_max = bounds_pen.bounds + glyph.draw(pen) + path = pen.getCommands() + if not path.strip(): + return None + return path, x_min, y_min, x_max, y_max + + +def char_width(font: TTFont, ch: str, fallback: float) -> float: + cmap = font.getBestCmap() + code = ord(ch) + if code not in cmap: + return fallback + glyph_name = cmap[code] + if glyph_name in font["hmtx"].metrics: + adv, _ = font["hmtx"].metrics[glyph_name] + return float(adv) + return fallback + + +def build_alphabet(spec: FontSpec, fonts_dir: Path, out_dir: Path) -> None: + ttf_path = fonts_dir / spec.ttf + font = TTFont(ttf_path) + units_per_em = float(font["head"].unitsPerEm or 1000) + + lines = [ + '", ""]) + out_path = out_dir / spec.outfile + out_path.write_text("\n".join(lines), encoding="utf-8") + print(f"Wrote {out_path}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--fonts-dir", + type=Path, + default=Path(__file__).resolve().parent / "fonts", + ) + parser.add_argument( + "--out-dir", + type=Path, + default=Path(__file__).resolve().parent, + ) + parser.add_argument( + "--only", + choices=sorted(FONTS.keys()), + action="append", + help="Build one font (repeatable). Default: all.", + ) + args = parser.parse_args() + + selected = args.only or sorted(FONTS.keys()) + for key in selected: + build_alphabet(FONTS[key], args.fonts_dir, args.out_dir) + + +if __name__ == "__main__": + main() diff --git a/examples/app_hub/assets/digital-7-mono-italic-alphabet.svg b/examples/app_hub/assets/digital-7-mono-italic-alphabet.svg new file mode 100644 index 0000000..40ac039 --- /dev/null +++ b/examples/app_hub/assets/digital-7-mono-italic-alphabet.svg @@ -0,0 +1,129 @@ + diff --git a/examples/app_hub/assets/ds-digital-alphabet.svg b/examples/app_hub/assets/ds-digital-alphabet.svg new file mode 100644 index 0000000..e4f9ee8 --- /dev/null +++ b/examples/app_hub/assets/ds-digital-alphabet.svg @@ -0,0 +1,129 @@ + diff --git a/examples/app_hub/assets/dseg14-classic-alphabet.svg b/examples/app_hub/assets/dseg14-classic-alphabet.svg new file mode 100644 index 0000000..525f2f2 --- /dev/null +++ b/examples/app_hub/assets/dseg14-classic-alphabet.svg @@ -0,0 +1,129 @@ + diff --git a/examples/app_hub/assets/dseg14-modern-alphabet.svg b/examples/app_hub/assets/dseg14-modern-alphabet.svg new file mode 100644 index 0000000..cb52047 --- /dev/null +++ b/examples/app_hub/assets/dseg14-modern-alphabet.svg @@ -0,0 +1,129 @@ + diff --git a/examples/app_hub/assets/fonts/DS-Digital.ttf b/examples/app_hub/assets/fonts/DS-Digital.ttf new file mode 100644 index 0000000..0925877 Binary files /dev/null and b/examples/app_hub/assets/fonts/DS-Digital.ttf differ diff --git a/examples/app_hub/assets/fonts/DSEG14Classic-Regular.ttf b/examples/app_hub/assets/fonts/DSEG14Classic-Regular.ttf new file mode 100644 index 0000000..e0ddebd Binary files /dev/null and b/examples/app_hub/assets/fonts/DSEG14Classic-Regular.ttf differ diff --git a/examples/app_hub/assets/fonts/DSEG14Modern-Regular.ttf b/examples/app_hub/assets/fonts/DSEG14Modern-Regular.ttf new file mode 100644 index 0000000..691b77e Binary files /dev/null and b/examples/app_hub/assets/fonts/DSEG14Modern-Regular.ttf differ diff --git a/examples/app_hub/assets/fonts/digital-7-mono-italic.ttf b/examples/app_hub/assets/fonts/digital-7-mono-italic.ttf new file mode 100644 index 0000000..ddbd3f7 Binary files /dev/null and b/examples/app_hub/assets/fonts/digital-7-mono-italic.ttf differ diff --git a/examples/app_hub/hub.css b/examples/app_hub/hub.css index 0bac3a3..0ae17b1 100644 --- a/examples/app_hub/hub.css +++ b/examples/app_hub/hub.css @@ -8,6 +8,38 @@ font-display: swap; } +@font-face { + font-family: "DSEG14 Classic"; + src: url("assets/fonts/DSEG14Classic-Regular.ttf") format("truetype"); + font-weight: normal; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "DSEG14 Modern"; + src: url("assets/fonts/DSEG14Modern-Regular.ttf") format("truetype"); + font-weight: normal; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "DS-Digital"; + src: url("assets/fonts/DS-Digital.ttf") format("truetype"); + font-weight: normal; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Digital-7 Mono Italic"; + src: url("assets/fonts/digital-7-mono-italic.ttf") format("truetype"); + font-weight: normal; + font-style: normal; + font-display: swap; +} + .hub-page { min-height: 100vh; display: flex; @@ -315,7 +347,7 @@ box-sizing: border-box; display: flex; flex-direction: column; - font-family: "Clockopia", "DS-Digital", "Consolas", monospace; + font-family: "DSEG14 Classic", "Consolas", monospace; } .cw-clock-inner { @@ -404,7 +436,7 @@ border: 0; border-radius: 0; padding: 0; - font-family: "Clockopia", "DS-Digital", "Consolas", monospace; + font-family: "DSEG14 Classic", "Consolas", monospace; font-variant-numeric: tabular-nums; color: #d8f6b8; text-shadow: 0 0 10px rgba(190, 240, 150, 0.42); @@ -496,6 +528,11 @@ filter: drop-shadow(0 0 5px rgba(190, 240, 150, 0.38)); } +.ot-glyph .lcd-active, +.ot-glyph path { + fill: #d8f6b8; +} + .ot-fallback-char { display: inline-flex; align-items: flex-end; @@ -532,7 +569,7 @@ letter-spacing: 0.16em; font-weight: 400; text-transform: uppercase; - font-family: "Clockopia", "Consolas", monospace; + font-family: "DSEG14 Classic", "Consolas", monospace; } .cw-line-date { @@ -541,7 +578,7 @@ letter-spacing: 0.12em; font-weight: 400; text-transform: uppercase; - font-family: "Clockopia", "Consolas", monospace; + font-family: "DSEG14 Classic", "Consolas", monospace; } .cw-separator { @@ -591,7 +628,7 @@ letter-spacing: 0.12em; margin-top: 0; margin-bottom: 8px; - font-family: "Clockopia", "Consolas", monospace; + font-family: "DSEG14 Classic", "Consolas", monospace; } .cw-dial { @@ -632,6 +669,26 @@ opacity: 0.25; } +html[data-clock-font="dseg14-modern"] .hub-clock-widget, +html[data-clock-font="dseg14-modern"] .cw-lcd-text { + font-family: "DSEG14 Modern", "Consolas", monospace; +} + +html[data-clock-font="ds-digital"] .hub-clock-widget, +html[data-clock-font="ds-digital"] .cw-lcd-text { + font-family: "DS-Digital", "Consolas", monospace; +} + +html[data-clock-font="digital-7-mono-italic"] .hub-clock-widget, +html[data-clock-font="digital-7-mono-italic"] .cw-lcd-text { + font-family: "Digital-7 Mono Italic", "Consolas", monospace; +} + +.cw-lcd-text { + font-family: "DSEG14 Classic", "Consolas", monospace; + font-variant-numeric: tabular-nums; +} + .cw-resize-handle { position: absolute; width: 50px; @@ -645,7 +702,7 @@ color: rgba(220, 236, 255, 0.9); filter: drop-shadow(0 0 4px rgba(150, 200, 255, 0.35)); pointer-events: auto; - background: rgba(226, 34, 34, 0.5); + background: transparent; box-sizing: border-box; } diff --git a/examples/app_hub/index.html b/examples/app_hub/index.html index d38455a..3a63a4d 100644 --- a/examples/app_hub/index.html +++ b/examples/app_hub/index.html @@ -1,5 +1,5 @@ - +
@@ -11,12 +11,13 @@ })(); - + + @@ -151,14 +152,52 @@ } var stage = document.getElementById('hub-logo-stage'); - if (!stage) return; var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); var supportsSvg = !!(svg && svg.createSVGRect); var supportsLayout = !!(window.CSS && CSS.supports && CSS.supports('object-fit', 'cover')); - if (!supportsSvg || !supportsLayout) return; + var logoEnabled = !!(stage && supportsSvg && supportsLayout); - var logoBuild = '20260530i'; + var logoBuild = '20260530k'; + + function hubAsset(rel) { + var link = document.querySelector('link[href*="hub.css"]'); + if (link) { + var href = link.getAttribute('href') || ''; + return href.replace(/[^/]*$/, '') + rel; + } + return rel; + } + + var LCD_FONT_CATALOG = { + 'dseg14-classic': { + url: 'assets/dseg14-classic-alphabet.svg', + prefix: 'd14c', + css: '"DSEG14 Classic", "Consolas", monospace' + }, + 'dseg14-modern': { + url: 'assets/dseg14-modern-alphabet.svg', + prefix: 'd14m', + css: '"DSEG14 Modern", "Consolas", monospace' + }, + 'ds-digital': { + url: 'assets/ds-digital-alphabet.svg', + prefix: 'dsd', + css: '"DS-Digital", "Consolas", monospace' + }, + 'digital-7-mono-italic': { + url: 'assets/digital-7-mono-italic-alphabet.svg', + prefix: 'd7mi', + css: '"Digital-7 Mono Italic", "Consolas", monospace' + } + }; + + var clockFontParams = new URLSearchParams(window.location.search); + var clockFontKey = (clockFontParams.get('clockFont') || 'dseg14-classic').toLowerCase(); + if (!LCD_FONT_CATALOG[clockFontKey]) clockFontKey = 'dseg14-classic'; + var lcdFontReady = false; + var lcdSymbolIds = {}; + var lcdFontPrefix = LCD_FONT_CATALOG[clockFontKey].prefix; var fragments = { space: 'assets/hub-logo-fragment-space.svg', globe: 'assets/hub-logo-fragment-globe.svg', @@ -185,6 +224,7 @@ return base + '?v=' + logoBuild; } + if (logoEnabled) { list.forEach(function (name) { var img = document.createElement('img'); img.className = 'hub-logo-layer hub-logo-layer--' + name; @@ -208,6 +248,7 @@ stage.hidden = false; stage.setAttribute('aria-hidden', 'true'); + } /* Nav dock overlaps globe left hemisphere; stack top → bottom. */ var GLOBE_VIEW = { imgW: 1920, imgH: 1080, cx: 720, cy: 560, r: 330 }; @@ -443,7 +484,7 @@ } function bindCompassOpen() { - if (!compassLayer) return; + if (!logoEnabled || !compassLayer) return; var open = function () { openDeploymentModel(); }; compassLayer.addEventListener('click', open); compassLayer.addEventListener('keydown', function (ev) { @@ -586,6 +627,113 @@ return null; } + function lcdSymbolId(ch) { + var p = lcdFontPrefix; + if (ch >= '0' && ch <= '9') return p + '-' + ch; + if (ch === ':') return p + '-colon'; + if (ch === '+') return p + '-plus'; + if (ch === '-') return p + '-minus'; + if (ch === '/') return p + '-slash'; + if (ch === '.') return p + '-dot'; + if (ch === ' ') return p + '-space'; + if (ch >= 'A' && ch <= 'Z') return p + '-' + ch; + if (ch >= 'a' && ch <= 'z') return p + '-' + ch.toUpperCase(); + return null; + } + + function loadLcdAlphabet(done) { + var host = document.getElementById('lcd-alphabet-host'); + var spec = LCD_FONT_CATALOG[clockFontKey]; + if (!host || !spec) { done(false); return; } + fetch(hubAsset(spec.url) + '?v=' + logoBuild) + .then(function (res) { + if (!res.ok) throw new Error('alphabet fetch failed'); + return res.text(); + }) + .then(function (svgText) { + host.replaceChildren(); + var parser = new DOMParser(); + var doc = parser.parseFromString(svgText, 'image/svg+xml'); + var sprite = doc.documentElement; + if (sprite.querySelector('parsererror')) throw new Error('alphabet parse failed'); + sprite.setAttribute('aria-hidden', 'true'); + sprite.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden;pointer-events:none'; + host.appendChild(sprite); + lcdSymbolIds = {}; + sprite.querySelectorAll('symbol').forEach(function (sym) { + if (sym.id) lcdSymbolIds[sym.id] = sym; + }); + lcdFontReady = Object.keys(lcdSymbolIds).length > 0; + document.documentElement.setAttribute('data-clock-font', clockFontKey); + var widget = document.getElementById('hub-clock-widget'); + if (widget) widget.setAttribute('data-lcd-mode', lcdFontReady ? 'svg' : 'text'); + done(lcdFontReady); + }) + .catch(function () { + lcdFontReady = false; + var widget = document.getElementById('hub-clock-widget'); + if (widget) widget.setAttribute('data-lcd-mode', 'text'); + done(false); + }); + } + + function cloneLcdSymbolContent(svg, sym) { + sym.childNodes.forEach(function (node) { + if (node.nodeType !== 1) return; + svg.appendChild(node.cloneNode(true)); + }); + } + + function renderLcdText(el, text, opts) { + opts = opts || {}; + if (!el) return; + el.innerHTML = ''; + el.classList.add('cw-lcd-text'); + var fontCss = LCD_FONT_CATALOG[clockFontKey].css; + if (!lcdFontReady) { + el.textContent = text; + el.style.fontFamily = fontCss; + return; + } + var renderedSvg = false; + String(text).split('').forEach(function (ch) { + var up = ch.toUpperCase(); + var sid = lcdSymbolId(up === ch ? ch : up); + if (!sid || !lcdSymbolIds[sid]) { + var fb = document.createElement('span'); + fb.className = 'ot-fallback-char' + (opts.bold ? ' ot-fallback-char--bold' : ''); + fb.style.fontFamily = fontCss; + fb.textContent = ch === ' ' ? '\u00A0' : ch; + el.appendChild(fb); + return; + } + var sym = lcdSymbolIds[sid]; + if (!sym.childNodes.length) { + var emptyFb = document.createElement('span'); + emptyFb.className = 'ot-fallback-char'; + emptyFb.style.fontFamily = fontCss; + emptyFb.textContent = ch === ' ' ? '\u00A0' : ch; + el.appendChild(emptyFb); + return; + } + renderedSvg = true; + var wrap = document.createElement('span'); + wrap.className = 'ot-glyph-wrap' + (opts.bold ? ' ot-glyph-wrap--bold' : ''); + if (opts.blinkColon && ch === ':') wrap.className += ' ot-colon-blink'; + var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', sym.getAttribute('viewBox') || '0 0 20 32'); + svg.setAttribute('class', 'ot-glyph'); + svg.setAttribute('aria-hidden', 'true'); + cloneLcdSymbolContent(svg, sym); + wrap.appendChild(svg); + el.appendChild(wrap); + }); + if (!renderedSvg) { + el.textContent = text; + el.style.fontFamily = fontCss; + } + } + function loadQuartzAlphabet(done) { var host = document.getElementById('quartz-alphabet-host'); if (!host) { done(false); return; } @@ -817,19 +965,18 @@ }); function setClockopiaText(el, text) { - if (!el) return; - el.textContent = text; + renderLcdText(el, text, {}); } function updateClockWidget() { var now = new Date(); var p = timeParts(now, primaryTz); - setClockopiaText(document.getElementById('cw-hm-lcd'), p.hh + ':' + p.mm); - setClockopiaText(document.getElementById('cw-colon-lcd'), ':'); - setClockopiaText(document.getElementById('cw-ss-lcd'), p.ss); - setClockopiaText(document.getElementById('cw-tz-text'), gmtOffsetLabel(now, primaryTz)); - setClockopiaText(document.getElementById('cw-region'), regionLabel(primaryTz)); - setClockopiaText(document.getElementById('cw-date-line'), formatDateLine(now, primaryTz)); + renderLcdText(document.getElementById('cw-hm-lcd'), p.hh + ':' + p.mm, { bold: true }); + renderLcdText(document.getElementById('cw-colon-lcd'), ':', { blinkColon: true }); + renderLcdText(document.getElementById('cw-ss-lcd'), p.ss, {}); + renderLcdText(document.getElementById('cw-tz-text'), gmtOffsetLabel(now, primaryTz), {}); + renderLcdText(document.getElementById('cw-region'), regionLabel(primaryTz), {}); + renderLcdText(document.getElementById('cw-date-line'), formatDateLine(now, primaryTz), {}); secondary.forEach(function (clock) { var t = timeParts(now, clock.tz); @@ -920,8 +1067,10 @@ widget.releasePointerCapture(ev.pointerId); }); })(); - updateClockWidget(); - setInterval(updateClockWidget, 1000); + loadLcdAlphabet(function () { + updateClockWidget(); + setInterval(updateClockWidget, 1000); + }); })();