@jjlmoya/utils-streaming 1.21.0 → 1.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +3 -1
  3. package/src/entries.ts +4 -1
  4. package/src/tests/locale_completeness.test.ts +1 -1
  5. package/src/tests/seo_translation_completeness.test.ts +69 -0
  6. package/src/tests/tool_validation.test.ts +1 -1
  7. package/src/tool/streamSceneCountdownClock/bibliography.astro +6 -0
  8. package/src/tool/streamSceneCountdownClock/bibliography.ts +12 -0
  9. package/src/tool/streamSceneCountdownClock/component.astro +180 -0
  10. package/src/tool/streamSceneCountdownClock/controller.ts +271 -0
  11. package/src/tool/streamSceneCountdownClock/design-state.ts +81 -0
  12. package/src/tool/streamSceneCountdownClock/dom-views.ts +53 -0
  13. package/src/tool/streamSceneCountdownClock/entry.ts +29 -0
  14. package/src/tool/streamSceneCountdownClock/evaluator.ts +28 -0
  15. package/src/tool/streamSceneCountdownClock/i18n/de.ts +47 -0
  16. package/src/tool/streamSceneCountdownClock/i18n/en.ts +253 -0
  17. package/src/tool/streamSceneCountdownClock/i18n/es.ts +47 -0
  18. package/src/tool/streamSceneCountdownClock/i18n/fr.ts +47 -0
  19. package/src/tool/streamSceneCountdownClock/i18n/id.ts +47 -0
  20. package/src/tool/streamSceneCountdownClock/i18n/it.ts +44 -0
  21. package/src/tool/streamSceneCountdownClock/i18n/ja.ts +44 -0
  22. package/src/tool/streamSceneCountdownClock/i18n/ko.ts +44 -0
  23. package/src/tool/streamSceneCountdownClock/i18n/locale-factory.ts +50 -0
  24. package/src/tool/streamSceneCountdownClock/i18n/nl.ts +44 -0
  25. package/src/tool/streamSceneCountdownClock/i18n/pl.ts +44 -0
  26. package/src/tool/streamSceneCountdownClock/i18n/pt.ts +44 -0
  27. package/src/tool/streamSceneCountdownClock/i18n/ru.ts +44 -0
  28. package/src/tool/streamSceneCountdownClock/i18n/sv.ts +44 -0
  29. package/src/tool/streamSceneCountdownClock/i18n/tr.ts +44 -0
  30. package/src/tool/streamSceneCountdownClock/i18n/zh.ts +44 -0
  31. package/src/tool/streamSceneCountdownClock/index.ts +13 -0
  32. package/src/tool/streamSceneCountdownClock/logic.test.ts +51 -0
  33. package/src/tool/streamSceneCountdownClock/logic.ts +108 -0
  34. package/src/tool/streamSceneCountdownClock/seo.astro +15 -0
  35. package/src/tool/streamSceneCountdownClock/settings-state.ts +24 -0
  36. package/src/tool/streamSceneCountdownClock/storage.ts +32 -0
  37. package/src/tool/streamSceneCountdownClock/stream-scene-countdown-clock.css +1745 -0
  38. package/src/tool/streamSceneCountdownClock/stream-url-view.ts +32 -0
  39. package/src/tool/streamSceneCountdownClock/text-input-state.ts +31 -0
  40. package/src/tool/streamSceneCountdownClock/ui.ts +69 -0
  41. package/src/tool/streamSceneCountdownClock/url-state.test.ts +48 -0
  42. package/src/tool/streamSceneCountdownClock/url-state.ts +118 -0
  43. package/src/tools.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-streaming",
3
- "version": "1.21.0",
3
+ "version": "1.22.1",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -1,10 +1,12 @@
1
+ import type { StreamingCategoryEntry } from '../types';
1
2
  import { sorteo } from '../tool/sorteo/entry';
2
3
  import { tebasCheck } from '../tool/tebasCheck/entry';
3
4
  import { videoBitratePlanner } from '../tool/videoBitratePlanner/entry';
5
+ import { streamSceneCountdownClock } from '../tool/streamSceneCountdownClock/entry';
4
6
 
5
7
  export const streamingCategory: StreamingCategoryEntry = {
6
8
  icon: 'mdi:broadcast',
7
- tools: [sorteo, tebasCheck, videoBitratePlanner],
9
+ tools: [sorteo, tebasCheck, videoBitratePlanner, streamSceneCountdownClock],
8
10
  i18n: {
9
11
  es: () => import('./i18n/es').then((m) => m.content),
10
12
  en: () => import('./i18n/en').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -4,8 +4,11 @@ export { tebasCheck } from './tool/tebasCheck/entry';
4
4
  export type { TebasCheckLocaleContent } from './tool/tebasCheck/entry';
5
5
  export { videoBitratePlanner } from './tool/videoBitratePlanner/entry';
6
6
  export type { VideoBitratePlannerLocaleContent } from './tool/videoBitratePlanner/entry';
7
+ export { streamSceneCountdownClock } from './tool/streamSceneCountdownClock/entry';
8
+ export type { StreamSceneCountdownClockLocaleContent } from './tool/streamSceneCountdownClock/entry';
7
9
  export { streamingCategory } from './category';
8
10
  import { sorteo } from './tool/sorteo/entry';
9
11
  import { tebasCheck } from './tool/tebasCheck/entry';
10
12
  import { videoBitratePlanner } from './tool/videoBitratePlanner/entry';
11
- export const ALL_ENTRIES = [sorteo, tebasCheck, videoBitratePlanner];
13
+ import { streamSceneCountdownClock } from './tool/streamSceneCountdownClock/entry';
14
+ export const ALL_ENTRIES = [sorteo, tebasCheck, videoBitratePlanner, streamSceneCountdownClock];
@@ -20,6 +20,6 @@ describe('Locale Completeness Validation', () => {
20
20
  });
21
21
 
22
22
  it('all 3 tools registered', () => {
23
- expect(ALL_TOOLS.length).toBe(3);
23
+ expect(ALL_TOOLS.length).toBe(4);
24
24
  });
25
25
  });
@@ -0,0 +1,69 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+
4
+ const ASIAN_LOCALES = ['ja', 'ko', 'zh'];
5
+
6
+ function getSimpleSectionLength(section: any): number {
7
+ if (section.type === 'title') return section.text ? section.text.length : 0;
8
+ if (section.type === 'paragraph') return section.html ? section.html.replace(/<[^>]*>/g, '').length : 0;
9
+ if (section.type === 'list') return section.items ? section.items.join('').length : 0;
10
+ return 0;
11
+ }
12
+
13
+ function getComplexSectionLength(section: any): number {
14
+ if (section.type === 'diagnostic' || section.type === 'tip') {
15
+ return (section.title ? section.title.length : 0) + (section.html ? section.html.replace(/<[^>]*>/g, '').length : 0);
16
+ }
17
+ if (section.type === 'table') {
18
+ return ((section.headers || []).join('').length) + ((section.rows || []).flat().join('').length);
19
+ }
20
+ return 0;
21
+ }
22
+
23
+ function getSectionLength(section: any): number {
24
+ return getSimpleSectionLength(section) || getComplexSectionLength(section);
25
+ }
26
+
27
+ function calculateSeoTextLength(seoSections: any[]): number {
28
+ return seoSections.reduce((acc, section) => acc + getSectionLength(section), 0);
29
+ }
30
+
31
+ function checkLocaleSeoLength(toolId: string, locale: string, localeLen: number, enLen: number): void {
32
+ const isAsian = ASIAN_LOCALES.includes(locale);
33
+ const minLength = Math.floor(enLen * (isAsian ? 0.25 : 0.70));
34
+ const msgType = isAsian ? 'suspiciously short' : 'truncated/lazy';
35
+
36
+ expect(
37
+ localeLen,
38
+ `[LAZY SEO TRANSLATION] Tool "${toolId}" locale "${locale}" SEO text is ${msgType} (${localeLen} chars vs EN ${enLen} chars, expected min ${minLength})`,
39
+ ).toBeGreaterThanOrEqual(minLength);
40
+ }
41
+
42
+ async function auditSingleLocale(toolId: string, locale: string, loader: any, enSeoLength: number): Promise<void> {
43
+ if (locale === 'en' || !loader) return;
44
+ const content = await loader();
45
+ if (!content.seo || !Array.isArray(content.seo)) return;
46
+
47
+ checkLocaleSeoLength(toolId, locale, calculateSeoTextLength(content.seo), enSeoLength);
48
+ }
49
+
50
+ describe('SEO Translation Completeness & Laziness Audit', () => {
51
+ ALL_TOOLS.forEach(({ entry }) => {
52
+ describe(`Tool: ${entry.id}`, () => {
53
+ it('should not have lazy or truncated SEO content compared to English reference', async () => {
54
+ const enLoader = entry.i18n['en' as keyof typeof entry.i18n];
55
+ if (!enLoader) return;
56
+
57
+ const enContent = await enLoader();
58
+ if (!enContent.seo || !Array.isArray(enContent.seo)) return;
59
+
60
+ const enSeoLength = calculateSeoTextLength(enContent.seo);
61
+
62
+ for (const [locale, loader] of Object.entries(entry.i18n)) {
63
+ await auditSingleLocale(entry.id, locale, loader, enSeoLength);
64
+ }
65
+ });
66
+ });
67
+ });
68
+ });
69
+
@@ -5,7 +5,7 @@ import { streamingCategory } from '../data';
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
7
  it('should have at least 2 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(3);
8
+ expect(ALL_TOOLS.length).toBe(4);
9
9
  });
10
10
 
11
11
  it('streamingCategory should be defined', () => {
@@ -0,0 +1,6 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { bibliography } from './bibliography';
4
+ ---
5
+
6
+ <SharedBibliography links={bibliography} />
@@ -0,0 +1,12 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'Twitch Channel Page Setup',
6
+ url: 'https://help.twitch.tv/s/article/channel-page-setup?language=en_US',
7
+ },
8
+ {
9
+ name: 'OBS Studio Overview Guide',
10
+ url: 'https://obsproject.com/kb/obs-studio-overview',
11
+ },
12
+ ];
@@ -0,0 +1,180 @@
1
+ ---
2
+ import type { StreamSceneCountdownClockUI } from './ui';
3
+
4
+ interface Props {
5
+ ui: StreamSceneCountdownClockUI;
6
+ }
7
+
8
+ const { ui } = Astro.props;
9
+ ---
10
+
11
+ <div class="ssc-root" data-status="ready" data-stream-scene-clock>
12
+ <section class="ssc-panel" aria-label={ui.stageEyebrow}>
13
+ <div class="ssc-controls">
14
+ <p class="ssc-route">{ui.flowText}</p>
15
+ <div class="ssc-control-block">
16
+ <span class="ssc-label">{ui.sceneLabel}</span>
17
+ <div class="ssc-scene-list" role="group" aria-label={ui.sceneLabel}>
18
+ <button type="button" class="ssc-choice" data-scene="starting" aria-pressed="true">{ui.sceneStarting}</button>
19
+ <button type="button" class="ssc-choice" data-scene="brb" aria-pressed="false">{ui.sceneBrb}</button>
20
+ <button type="button" class="ssc-choice" data-scene="raid" aria-pressed="false">{ui.sceneRaid}</button>
21
+ <button type="button" class="ssc-choice" data-scene="intermission" aria-pressed="false">{ui.sceneIntermission}</button>
22
+ </div>
23
+ </div>
24
+ <label class="ssc-field">
25
+ <span class="ssc-label">{ui.sceneTitleLabel}</span>
26
+ <input data-scene-title type="text" maxlength="40" placeholder={ui.sceneTitlePlaceholder} value={ui.sceneTitlePlaceholder} />
27
+ </label>
28
+ <label class="ssc-field">
29
+ <span class="ssc-label">{ui.designLabel}</span>
30
+ <div class="ssc-custom-select" data-design-select>
31
+ <input type="hidden" data-design value="aurora" />
32
+ <button type="button" class="ssc-select-trigger" data-design-trigger aria-haspopup="listbox" aria-expanded="false" aria-label={ui.designLabel}>{ui.designAurora}</button>
33
+ <div class="ssc-select-options" data-design-options role="listbox" aria-label={ui.designLabel} hidden>
34
+ <button type="button" class="ssc-select-option is-active" data-design-option="aurora" role="option" aria-selected="true">{ui.designAurora}</button>
35
+ <button type="button" class="ssc-select-option" data-design-option="type" role="option" aria-selected="false">{ui.designType}</button>
36
+ <button type="button" class="ssc-select-option" data-design-option="pulse" role="option" aria-selected="false">{ui.designPulse}</button>
37
+ <button type="button" class="ssc-select-option" data-design-option="glitch" role="option" aria-selected="false">{ui.designGlitch}</button>
38
+ <button type="button" class="ssc-select-option" data-design-option="sunset" role="option" aria-selected="false">{ui.designSunset}</button>
39
+ </div>
40
+ </div>
41
+ </label>
42
+ <div class="ssc-color-row">
43
+ <label class="ssc-color-field">
44
+ <span class="ssc-label">{ui.accentColorLabel}</span>
45
+ <input data-accent-color type="color" value="#a78bfa" aria-label={ui.accentColorLabel} />
46
+ </label>
47
+ <label class="ssc-color-field">
48
+ <span class="ssc-label">{ui.glowColorLabel}</span>
49
+ <input data-glow-color type="color" value="#8ff5d7" aria-label={ui.glowColorLabel} />
50
+ </label>
51
+ </div>
52
+ <label class="ssc-field">
53
+ <span class="ssc-label">{ui.messageLabel}</span>
54
+ <input data-message type="text" maxlength="90" placeholder={ui.messagePlaceholder} value={ui.messagePlaceholder} />
55
+ </label>
56
+ <div class="ssc-control-block">
57
+ <span class="ssc-label">{ui.durationLabel}</span>
58
+ <div class="ssc-duration-row" role="group" aria-label={ui.durationLabel}>
59
+ <button type="button" class="ssc-choice" data-duration="60">{ui.duration60}</button>
60
+ <button type="button" class="ssc-choice is-active" data-duration="300">{ui.duration300}</button>
61
+ <button type="button" class="ssc-choice" data-duration="600">{ui.duration600}</button>
62
+ <label class="ssc-number-wrap">
63
+ <span class="sr-only">{ui.durationCustom}</span>
64
+ <input data-duration-input type="number" min="10" max="86400" step="1" value="300" aria-label={ui.durationCustom} />
65
+ <span>{ui.secondsLabel}</span>
66
+ </label>
67
+ </div>
68
+ </div>
69
+ <div class="ssc-control-block">
70
+ <span class="ssc-label">{ui.startLabel}</span>
71
+ <div class="ssc-start-row" role="group" aria-label={ui.startLabel}>
72
+ <button type="button" class="ssc-choice is-active" data-start-mode="now" aria-pressed="true">{ui.startNow}</button>
73
+ <button type="button" class="ssc-choice" data-start-mode="scheduled" aria-pressed="false">{ui.scheduleTime}</button>
74
+ <label class="ssc-time-wrap" hidden>
75
+ <span class="sr-only">{ui.timeLabel}</span>
76
+ <input data-start-time-input type="time" value="20:00" aria-label={ui.timeLabel} />
77
+ </label>
78
+ </div>
79
+ <p class="ssc-error" data-time-error aria-live="polite"></p>
80
+ </div>
81
+ <div class="ssc-actions">
82
+ <button type="button" class="ssc-primary" data-action="start">{ui.startAction}</button>
83
+ <button type="button" class="ssc-secondary" data-action="focus">{ui.focusAction}</button>
84
+ <button type="button" class="ssc-secondary" data-action="reset">{ui.resetAction}</button>
85
+ </div>
86
+ <aside class="ssc-obs-card" aria-labelledby="ssc-obs-title">
87
+ <div class="ssc-obs-heading">
88
+ <span class="ssc-label" id="ssc-obs-title">{ui.obsTitle}</span>
89
+ <span class="ssc-obs-mark" aria-hidden="true">OBS</span>
90
+ </div>
91
+ <p>{ui.obsText}</p>
92
+ <ol class="ssc-obs-steps">
93
+ <li>{ui.obsStepCopy}</li>
94
+ <li>{ui.obsStepAdd}</li>
95
+ <li>{ui.obsStepPaste}</li>
96
+ </ol>
97
+ <a class="ssc-stream-url" data-stream-url aria-label={ui.streamUrlAria} target="_blank" rel="noreferrer"></a>
98
+ <div class="ssc-obs-actions">
99
+ <button type="button" class="ssc-secondary" data-copy-url>{ui.copyUrlAction}</button>
100
+ <span class="ssc-copy-state" data-copy-state aria-live="polite"></span>
101
+ </div>
102
+ </aside>
103
+ </div>
104
+ <div class="ssc-stage" data-stage>
105
+ <div class="ssc-stage-heading">
106
+ <span class="ssc-eyebrow">{ui.stageEyebrow}</span>
107
+ <span class="ssc-status-badge" data-status-badge aria-live="polite">{ui.readyBadge}</span>
108
+ </div>
109
+ <div class="ssc-stage-main">
110
+ <div class="ssc-dial-wrap">
111
+ <svg class="ssc-dial" viewBox="0 0 120 120" aria-hidden="true">
112
+ <circle class="ssc-dial-track" cx="60" cy="60" r="50"></circle>
113
+ <circle class="ssc-dial-progress" data-progress-ring cx="60" cy="60" r="50"></circle>
114
+ <circle class="ssc-dial-pin" cx="60" cy="10" r="3"></circle>
115
+ </svg>
116
+ <div class="ssc-dial-content">
117
+ <span class="ssc-scene-name" data-scene-name>{ui.sceneStarting}</span>
118
+ <time class="ssc-clock" data-clock aria-label={ui.clockAria}>00:05:00</time>
119
+ <span class="ssc-clock-label">{ui.remainingLabel}</span>
120
+ </div>
121
+ </div>
122
+ <div class="ssc-stage-copy">
123
+ <p class="ssc-stage-caption">{ui.stageCaption}</p>
124
+ <p class="ssc-stage-message" data-stage-message>{ui.messagePlaceholder}</p>
125
+ <p class="ssc-status-copy" data-status-copy aria-live="polite">{ui.readyText}</p>
126
+ </div>
127
+ </div>
128
+ <div class="ssc-alt-progress" aria-hidden="true"><span data-alt-progress></span></div>
129
+ <div class="ssc-preview-strip">
130
+ <div class="ssc-preview-heading">
131
+ <span class="ssc-label">{ui.previewTitle}</span>
132
+ <span>{ui.previewHint}</span>
133
+ </div>
134
+ <div class="ssc-design-gallery" role="listbox" aria-label={ui.previewAria}>
135
+ <button type="button" role="option" class="ssc-design-thumb" data-preview-design="aurora" aria-selected="true">
136
+ <span class="ssc-mini-art ssc-mini-art-aurora"><span>05:00</span></span>
137
+ <span>{ui.designAurora}</span>
138
+ </button>
139
+ <button type="button" role="option" class="ssc-design-thumb" data-preview-design="type" aria-selected="false">
140
+ <span class="ssc-mini-art ssc-mini-art-type"><span>05 00</span></span>
141
+ <span>{ui.designType}</span>
142
+ </button>
143
+ <button type="button" role="option" class="ssc-design-thumb" data-preview-design="pulse" aria-selected="false">
144
+ <span class="ssc-mini-art ssc-mini-art-pulse"><span>05:00</span></span>
145
+ <span>{ui.designPulse}</span>
146
+ </button>
147
+ <button type="button" role="option" class="ssc-design-thumb" data-preview-design="glitch" aria-selected="false">
148
+ <span class="ssc-mini-art ssc-mini-art-glitch"><span>05:00</span></span>
149
+ <span>{ui.designGlitch}</span>
150
+ </button>
151
+ <button type="button" role="option" class="ssc-design-thumb" data-preview-design="sunset" aria-selected="false">
152
+ <span class="ssc-mini-art ssc-mini-art-sunset"><span>05:00</span></span>
153
+ <span>{ui.designSunset}</span>
154
+ </button>
155
+ </div>
156
+ </div>
157
+ <div class="ssc-stage-footer">
158
+ <div><span>{ui.startTimeLabel}</span><strong data-start-time>--:--</strong></div>
159
+ <div><span>{ui.endTimeLabel}</span><strong data-end-time>--:--</strong></div>
160
+ <div><span>{ui.progressLabel}</span><strong data-progress-copy>0%</strong></div>
161
+ </div>
162
+ <div class="ssc-notes">
163
+ <details>
164
+ <summary>{ui.assumptionTitle}</summary>
165
+ <p>{ui.assumptionText}</p>
166
+ </details>
167
+ <p class="ssc-warning"><strong>{ui.warningTitle}</strong> {ui.warningText}</p>
168
+ </div>
169
+ </div>
170
+ </section>
171
+ </div>
172
+
173
+ <script is:inline type="application/json" id="stream-scene-countdown-clock-ui" set:html={JSON.stringify(ui)}></script>
174
+ <script>
175
+ import { mountCountdown } from './controller';
176
+
177
+ const root = document.querySelector<HTMLElement>('[data-stream-scene-clock]');
178
+ const data = document.getElementById('stream-scene-countdown-clock-ui');
179
+ if (root && data?.textContent) mountCountdown(root, JSON.parse(data.textContent));
180
+ </script>
@@ -0,0 +1,271 @@
1
+ import { getSceneLabel, renderClock } from './dom-views';
2
+ import { getCountdown, parseStartTime } from './logic';
3
+ import { loadSettings, saveSettings, type SavedClockSettings } from './storage';
4
+ import type { SceneKey, StreamSceneCountdownClockUI } from './ui';
5
+ import { readUrlState } from './url-state';
6
+ import { collectSettings } from './settings-state';
7
+ import { bindCopyUrlButton, renderStreamingUrl } from './stream-url-view';
8
+ import { bindDesignSelect as bindDesignSelectControl, bindPreviewDesignButtons, designPalettes, renderDesignChoices, renderDesignSelect } from './design-state';
9
+ import { bindTextInputs as bindInputFields } from './text-input-state';
10
+
11
+ const defaults: SavedClockSettings = {
12
+ scene: 'starting',
13
+ sceneTitle: '',
14
+ design: 'aurora',
15
+ accentColor: '#a78bfa',
16
+ glowColor: '#8ff5d7',
17
+ message: 'Back in 5 minutes',
18
+ durationSeconds: 300,
19
+ startMode: 'now',
20
+ startTime: '20:00',
21
+ };
22
+
23
+ interface ControllerState {
24
+ settings: SavedClockSettings;
25
+ startAtMs: number | null;
26
+ }
27
+
28
+ function getElement<T extends HTMLElement>(root: HTMLElement, selector: string): T | null {
29
+ return root.querySelector<T>(selector);
30
+ }
31
+
32
+ function getAccentInk(hex: string): string {
33
+ const match = hex.match(/^#([\da-f]{6})$/i);
34
+ if (!match) return '#fffaf2';
35
+ const value = match[1] ?? '';
36
+ const channels = [0, 2, 4].map((offset) => Number.parseInt(value.slice(offset, offset + 2), 16) / 255);
37
+ const weights = [0.2126, 0.7152, 0.0722];
38
+ const luminance = channels.reduce((sum, channel, index) => {
39
+ const linear = channel <= .03928 ? channel / 12.92 : ((channel + .055) / 1.055) ** 2.4;
40
+ return sum + linear * (weights[index] ?? 0);
41
+ }, 0);
42
+ const darkContrast = (luminance + .05) / .06;
43
+ const lightContrast = 1.05 / (luminance + .05);
44
+ return darkContrast >= lightContrast ? '#171615' : '#fffaf2';
45
+ }
46
+
47
+ function renderInputValues(root: HTMLElement, settings: SavedClockSettings): void {
48
+ const values = [
49
+ ['[data-message]', settings.message],
50
+ ['[data-scene-title]', settings.sceneTitle],
51
+ ['[data-duration-input]', String(settings.durationSeconds)],
52
+ ['[data-start-time-input]', settings.startTime],
53
+ ['[data-design]', settings.design],
54
+ ['[data-accent-color]', settings.accentColor],
55
+ ['[data-glow-color]', settings.glowColor],
56
+ ] as const;
57
+ values.forEach(([selector, value]) => {
58
+ const element = getElement<HTMLInputElement | HTMLSelectElement>(root, selector);
59
+ if (element) element.value = value;
60
+ });
61
+ }
62
+
63
+ function renderSettings(root: HTMLElement, settings: SavedClockSettings): void {
64
+ const timeWrap = getElement<HTMLElement>(root, '.ssc-time-wrap');
65
+ renderInputValues(root, settings);
66
+ renderDesignSelect(root, settings.design);
67
+ root.querySelectorAll<HTMLButtonElement>('[data-scene]').forEach((button) => {
68
+ const active = button.dataset.scene === settings.scene;
69
+ button.classList.toggle('is-active', active);
70
+ button.setAttribute('aria-pressed', String(active));
71
+ });
72
+ root.querySelectorAll<HTMLButtonElement>('[data-duration]').forEach((button) => {
73
+ button.classList.toggle('is-active', Number(button.dataset.duration) === settings.durationSeconds);
74
+ });
75
+ root.querySelectorAll<HTMLButtonElement>('[data-start-mode]').forEach((button) => {
76
+ const active = button.dataset.startMode === settings.startMode;
77
+ button.classList.toggle('is-active', active);
78
+ button.setAttribute('aria-pressed', String(active));
79
+ });
80
+ renderDesignChoices(root, settings.design);
81
+ if (timeWrap) timeWrap.hidden = settings.startMode !== 'scheduled';
82
+ }
83
+
84
+ function renderState(root: HTMLElement, state: ControllerState, ui: StreamSceneCountdownClockUI): void {
85
+ renderSettings(root, state.settings);
86
+ root.dataset.design = state.settings.design;
87
+ root.style.setProperty('--n-user-accent', state.settings.accentColor);
88
+ root.style.setProperty('--n-user-glow', state.settings.glowColor);
89
+ root.style.setProperty('--n-accent-ink', getAccentInk(state.settings.accentColor));
90
+ renderStreamingUrl(root, state.settings);
91
+ renderClock({
92
+ root,
93
+ snapshot: getCountdown({ durationSeconds: state.settings.durationSeconds, startAtMs: state.startAtMs, nowMs: Date.now() }),
94
+ scene: state.settings.scene,
95
+ sceneTitle: state.settings.sceneTitle,
96
+ message: state.settings.message,
97
+ ui,
98
+ });
99
+ }
100
+
101
+ function persistAndRender(root: HTMLElement, state: ControllerState, ui: StreamSceneCountdownClockUI): void {
102
+ saveSettings(state.settings);
103
+ renderState(root, state, ui);
104
+ }
105
+
106
+ function applyDefaultSceneTitle(settings: SavedClockSettings, ui: StreamSceneCountdownClockUI): SavedClockSettings {
107
+ return settings.sceneTitle.trim() ? settings : { ...settings, sceneTitle: getSceneLabel(settings.scene, ui) };
108
+ }
109
+
110
+ function getStartTimestamp(state: ControllerState, nowMs: number, ui: StreamSceneCountdownClockUI, root: HTMLElement): number | null {
111
+ if (state.settings.startMode === 'now') return nowMs;
112
+ const value = state.settings.startTime;
113
+ const timestamp = parseStartTime(value, nowMs);
114
+ if (timestamp === null) {
115
+ const error = getElement<HTMLElement>(root, '[data-time-error]');
116
+ if (error) error.textContent = ui.invalidTime;
117
+ }
118
+ return timestamp;
119
+ }
120
+
121
+ export function mountCountdown(root: HTMLElement, ui: StreamSceneCountdownClockUI): void {
122
+ const stored = loadSettings();
123
+ const urlState = readUrlState(window.location.search);
124
+ let settings: SavedClockSettings = { ...defaults, ...stored, ...urlState.settings };
125
+ settings = applyDefaultSceneTitle(settings, ui);
126
+ if (!designPalettes[settings.design]) settings.design = defaults.design;
127
+ const state: ControllerState = { settings, startAtMs: null };
128
+ if (urlState.streaming) {
129
+ root.dataset.streaming = 'true';
130
+ hideStreamingShell(root);
131
+ }
132
+ if (urlState.autoStart) state.startAtMs = getStartTimestamp(state, Date.now(), ui, root);
133
+ bindChoiceButtons(root, state, ui);
134
+ bindInputFields({ root, state, ui, render: renderState });
135
+ bindActionButtons(root, state, ui);
136
+ renderState(root, state, ui);
137
+ const timer = window.setInterval(() => renderState(root, state, ui), 250);
138
+ root.addEventListener('astro:unmount', () => window.clearInterval(timer), { once: true });
139
+ }
140
+
141
+ function bindChoiceButtons(root: HTMLElement, state: ControllerState, ui: StreamSceneCountdownClockUI): void {
142
+ bindSceneButtons(root, state, ui);
143
+ bindDurationButtons(root, state, ui);
144
+ bindStartModeButtons(root, state, ui);
145
+ bindDesignSelectControl(root, (design) => {
146
+ state.settings = { ...collectSettings(root, state.settings), design, ...designPalettes[design] };
147
+ persistAndRender(root, state, ui);
148
+ });
149
+ bindPreviewDesignButtons(root, (design) => {
150
+ state.settings = { ...collectSettings(root, state.settings), design, ...designPalettes[design] };
151
+ persistAndRender(root, state, ui);
152
+ });
153
+ bindColorInputs(root, state, ui);
154
+ }
155
+
156
+ function bindColorInputs(root: HTMLElement, state: ControllerState, ui: StreamSceneCountdownClockUI): void {
157
+ root.querySelectorAll<HTMLInputElement>('[data-accent-color], [data-glow-color]').forEach((input) => {
158
+ input.addEventListener('input', () => {
159
+ state.settings = collectSettings(root, state.settings);
160
+ persistAndRender(root, state, ui);
161
+ });
162
+ });
163
+ }
164
+
165
+ function bindSceneButtons(root: HTMLElement, state: ControllerState, ui: StreamSceneCountdownClockUI): void {
166
+ root.querySelectorAll<HTMLButtonElement>('[data-scene]').forEach((button) => {
167
+ button.addEventListener('click', () => {
168
+ const scene = button.dataset.scene as SceneKey;
169
+ state.settings = { ...collectSettings(root, state.settings), scene, sceneTitle: getSceneLabel(scene, ui) };
170
+ persistAndRender(root, state, ui);
171
+ });
172
+ });
173
+ }
174
+
175
+ function bindDurationButtons(root: HTMLElement, state: ControllerState, ui: StreamSceneCountdownClockUI): void {
176
+ root.querySelectorAll<HTMLButtonElement>('[data-duration]').forEach((button) => {
177
+ button.addEventListener('click', () => {
178
+ state.settings = { ...collectSettings(root, state.settings), durationSeconds: Number(button.dataset.duration) };
179
+ persistAndRender(root, state, ui);
180
+ });
181
+ });
182
+ }
183
+
184
+ function bindStartModeButtons(root: HTMLElement, state: ControllerState, ui: StreamSceneCountdownClockUI): void {
185
+ root.querySelectorAll<HTMLButtonElement>('[data-start-mode]').forEach((button) => {
186
+ button.addEventListener('click', () => {
187
+ state.settings = { ...collectSettings(root, state.settings), startMode: button.dataset.startMode as SavedClockSettings['startMode'] };
188
+ persistAndRender(root, state, ui);
189
+ });
190
+ });
191
+ }
192
+
193
+ function bindActionButtons(root: HTMLElement, state: ControllerState, ui: StreamSceneCountdownClockUI): void {
194
+ bindStartButton(root, state, ui);
195
+ bindFocusButton(root, ui);
196
+ bindResetButton(root, state, ui);
197
+ bindCopyUrlButton(root, () => state.settings, ui);
198
+ }
199
+
200
+ function bindFocusButton(root: HTMLElement, ui: StreamSceneCountdownClockUI): void {
201
+ const button = getElement<HTMLButtonElement>(root, '[data-action="focus"]');
202
+ if (!button) return;
203
+ button.addEventListener('click', async () => {
204
+ if (document.fullscreenElement === root || root.dataset.focus === 'true') {
205
+ if (document.fullscreenElement === root) await document.exitFullscreen();
206
+ delete root.dataset.focus;
207
+ } else {
208
+ await enterFocus(root);
209
+ }
210
+ updateFocusButton(button, ui);
211
+ });
212
+ document.addEventListener('fullscreenchange', () => {
213
+ if (document.fullscreenElement !== root && root.dataset.focus === 'true') {
214
+ delete root.dataset.focus;
215
+ }
216
+ updateFocusButton(button, ui);
217
+ });
218
+ }
219
+
220
+ async function enterFocus(root: HTMLElement): Promise<void> {
221
+ if (document.fullscreenElement === root) return;
222
+ root.dataset.focus = 'true';
223
+ try {
224
+ await root.requestFullscreen();
225
+ } catch {}
226
+ }
227
+
228
+ function updateFocusButton(button: HTMLButtonElement, ui: StreamSceneCountdownClockUI): void {
229
+ const root = button.closest<HTMLElement>('[data-stream-scene-clock]');
230
+ const focused = document.fullscreenElement === root || root?.dataset.focus === 'true';
231
+ button.textContent = focused ? ui.exitFocusAction : ui.focusAction;
232
+ button.setAttribute('aria-pressed', String(focused));
233
+ }
234
+
235
+ function hideStreamingShell(root: HTMLElement): void {
236
+ let child: HTMLElement = root;
237
+ let parent = root.parentElement;
238
+ document.documentElement.dataset.sscStreaming = 'true';
239
+ while (parent && parent !== document.body) {
240
+ Array.from(parent.children).forEach((sibling) => {
241
+ if (sibling !== child) (sibling as HTMLElement).dataset.sscHidden = 'true';
242
+ });
243
+ parent.dataset.sscContainer = 'true';
244
+ child = parent;
245
+ parent = parent.parentElement;
246
+ }
247
+ Array.from(document.body.children).forEach((sibling) => {
248
+ if (sibling !== child) (sibling as HTMLElement).dataset.sscHidden = 'true';
249
+ });
250
+ }
251
+
252
+ function bindStartButton(root: HTMLElement, state: ControllerState, ui: StreamSceneCountdownClockUI): void {
253
+ getElement<HTMLButtonElement>(root, '[data-action="start"]')?.addEventListener('click', async () => {
254
+ state.settings = collectSettings(root, state.settings);
255
+ const timestamp = getStartTimestamp(state, Date.now(), ui, root);
256
+ if (timestamp === null) return;
257
+ state.startAtMs = timestamp;
258
+ const error = getElement<HTMLElement>(root, '[data-time-error]');
259
+ if (error) error.textContent = '';
260
+ persistAndRender(root, state, ui);
261
+ await enterFocus(root);
262
+ });
263
+ }
264
+
265
+ function bindResetButton(root: HTMLElement, state: ControllerState, ui: StreamSceneCountdownClockUI): void {
266
+ getElement<HTMLButtonElement>(root, '[data-action="reset"]')?.addEventListener('click', () => {
267
+ state.settings = { ...defaults };
268
+ state.startAtMs = null;
269
+ persistAndRender(root, state, ui);
270
+ });
271
+ }