@jjlmoya/utils-streaming 1.20.0 → 1.22.0
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.
- package/package.json +1 -1
- package/src/category/index.ts +3 -1
- package/src/entries.ts +2 -0
- package/src/tests/locale_completeness.test.ts +1 -1
- package/src/tests/seo_component_contract.test.ts +22 -0
- package/src/tests/seo_translation_completeness.test.ts +69 -0
- package/src/tests/tool_validation.test.ts +1 -1
- package/src/tool/streamSceneCountdownClock/bibliography.astro +6 -0
- package/src/tool/streamSceneCountdownClock/bibliography.ts +12 -0
- package/src/tool/streamSceneCountdownClock/component.astro +180 -0
- package/src/tool/streamSceneCountdownClock/controller.ts +271 -0
- package/src/tool/streamSceneCountdownClock/design-state.ts +81 -0
- package/src/tool/streamSceneCountdownClock/dom-views.ts +53 -0
- package/src/tool/streamSceneCountdownClock/entry.ts +29 -0
- package/src/tool/streamSceneCountdownClock/evaluator.ts +28 -0
- package/src/tool/streamSceneCountdownClock/i18n/de.ts +47 -0
- package/src/tool/streamSceneCountdownClock/i18n/en.ts +253 -0
- package/src/tool/streamSceneCountdownClock/i18n/es.ts +47 -0
- package/src/tool/streamSceneCountdownClock/i18n/fr.ts +47 -0
- package/src/tool/streamSceneCountdownClock/i18n/id.ts +47 -0
- package/src/tool/streamSceneCountdownClock/i18n/it.ts +44 -0
- package/src/tool/streamSceneCountdownClock/i18n/ja.ts +44 -0
- package/src/tool/streamSceneCountdownClock/i18n/ko.ts +44 -0
- package/src/tool/streamSceneCountdownClock/i18n/locale-factory.ts +50 -0
- package/src/tool/streamSceneCountdownClock/i18n/nl.ts +44 -0
- package/src/tool/streamSceneCountdownClock/i18n/pl.ts +44 -0
- package/src/tool/streamSceneCountdownClock/i18n/pt.ts +44 -0
- package/src/tool/streamSceneCountdownClock/i18n/ru.ts +44 -0
- package/src/tool/streamSceneCountdownClock/i18n/sv.ts +44 -0
- package/src/tool/streamSceneCountdownClock/i18n/tr.ts +44 -0
- package/src/tool/streamSceneCountdownClock/i18n/zh.ts +44 -0
- package/src/tool/streamSceneCountdownClock/index.ts +13 -0
- package/src/tool/streamSceneCountdownClock/logic.test.ts +51 -0
- package/src/tool/streamSceneCountdownClock/logic.ts +108 -0
- package/src/tool/streamSceneCountdownClock/seo.astro +15 -0
- package/src/tool/streamSceneCountdownClock/settings-state.ts +24 -0
- package/src/tool/streamSceneCountdownClock/storage.ts +32 -0
- package/src/tool/streamSceneCountdownClock/stream-scene-countdown-clock.css +1745 -0
- package/src/tool/streamSceneCountdownClock/stream-url-view.ts +32 -0
- package/src/tool/streamSceneCountdownClock/text-input-state.ts +31 -0
- package/src/tool/streamSceneCountdownClock/ui.ts +69 -0
- package/src/tool/streamSceneCountdownClock/url-state.test.ts +48 -0
- package/src/tool/streamSceneCountdownClock/url-state.ts +118 -0
- package/src/tool/videoBitratePlanner/seo.astro +7 -4
- package/src/tools.ts +2 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { makeContent } from './locale-factory';
|
|
2
|
+
|
|
3
|
+
export const content = makeContent({
|
|
4
|
+
language: 'tr',
|
|
5
|
+
slug: 'obs-yayin-sahnesi-geri-sayim',
|
|
6
|
+
title: 'Yayın sahnesi geri sayımı',
|
|
7
|
+
description: 'Yayına başlama, BRB, raid ve molalar için yayıncı odaklı bir geri sayım ekranı oluşturun.',
|
|
8
|
+
ui: {
|
|
9
|
+
sceneLabel: 'Hangi sahneyi hazırlıyorsunuz?', sceneBrb: 'BRB', sceneStarting: 'Az sonra başlıyor', sceneRaid: 'Raid', sceneIntermission: 'Mola', sceneTitleLabel: 'Zamanlayıcının üstünde ne görünsün?', sceneTitlePlaceholder: 'Az sonra başlıyor', designLabel: 'Sahnenizin havası nasıl olsun?', designAurora: 'Aurora sisi', designType: 'Kinetik tipografi', designPulse: 'Darbeli ışıma', designGlitch: 'Glitch sinyali', designSunset: 'Güneş parlaması', accentColorLabel: 'Vurgu rengi', glowColorLabel: 'Işıma rengi', messageLabel: 'İzleyicileriniz ne bilmeli?', messagePlaceholder: '5 dakika içinde döneceğim', durationLabel: 'Ne kadar zamana ihtiyacınız var?', duration60: '1 dk', duration300: '5 dk', duration600: '10 dk', durationCustom: 'Özel', secondsLabel: 'saniye', startLabel: 'Bu sinyal ne zaman başlasın?', startNow: 'Şimdi başlat', scheduleTime: 'Zaman planla', timeLabel: 'Yerel saatte kaçta başlasın?', startAction: 'Sinyalimi yayına al', focusAction: 'Sahnemi göster', exitFocusAction: 'Sahne görünümünden çık', resetAction: 'Sinyali sıfırla', flowText: 'Yayıncı sizsiniz: olanı seçin, gereken süreyi ayarlayın ve sinyalinizi yayına alın.', obsTitle: 'Bu sahneyi OBS içine koyun', obsText: 'Bağlantıyı kopyalayın, OBS içinde Browser Source ekleyin ve yapıştırın. STREAMING parametresi kontroller olmadan temiz tam ekran sahnesini otomatik açar.', obsStepCopy: 'Bu bağlantıyı kopyala', obsStepAdd: 'OBS içine Browser Source ekle', obsStepPaste: 'Yapıştır ve tuval boyutuna uyarla', copyUrlAction: 'OBS bağlantısını kopyala', copiedUrlText: 'OBS bağlantısı kopyalandı', streamUrlAria: 'Oluşturulan OBS yayın URL\'si', previewTitle: 'Sahne görünümünü seçin', previewHint: 'Kullanmak için önizlemeye tıklayın', previewAria: 'Sahne görünümü önizlemeleri', stageEyebrow: 'Yayın ekranı', stageCaption: 'Sonraki sahneniz hazır', readyBadge: 'Hazır', waitingBadge: 'Bekliyor', liveBadge: 'Canlı sinyal', endedBadge: 'Bitti', readyText: 'Ekranı kontrol edin ve sahneniz hazır olduğunda sinyali başlatın.', waitingText: 'Canlı geri sayım planlanan yerel saatte başlayacak.', liveText: 'Sahne değişimi hazır olana kadar bu ekranı görünür tutun.', endedText: 'Sinyal sona erdi. Sıfırlayın veya yeni bir sahne hazırlayın.', remainingLabel: 'Sahnede kalan süre', startTimeLabel: 'Başlangıç', endTimeLabel: 'Bitiş', progressLabel: 'Sahne ilerlemesi', assumptionTitle: 'Zaman notu', assumptionText: 'Planlanan saatler cihazınızın saatini kullanır. Zamanlayıcı görsel bir sinyaldir; OBS, Twitch, sohbet veya kodlayıcıyı senkronize etmez.', warningTitle: 'Sahne sinyali olarak kullanın', warningText: 'Uyuyan bir sekme, değişen sistem saati veya yayın gecikmesi ekrandaki zamanın yayından farklı görünmesine neden olabilir. Değiştirmeden önce canlı sahneyi kontrol edin.', invalidTime: 'Yerel saati HH:MM biçiminde girin.', clockAria: 'Geri sayımda kalan süre', statusAria: 'Geri sayım durumu',
|
|
10
|
+
},
|
|
11
|
+
faq: [
|
|
12
|
+
{ question: 'Bu geri sayım OBS veya Twitch\'e bağlanır mı?', answer: 'Hayır. OBS, Twitch, sohbet veya yayın sunucusunu kontrol etmeyen bağımsız bir zamanlayıcıdır. Sahnemi göster seçeneğiyle ekranı doldurun ve bu görünümü Browser Source olarak yakalayın.' },
|
|
13
|
+
{ question: 'Geri sayımı OBS içinde doğrudan nasıl kullanırım?', answer: 'Aracı yayın parametresi ve ayarlarıyla birlikte OBS Browser Source olarak ekleyin. Örnek: ?STREAMING&scene=starting&duration=300&design=aurora&title=Az%20sonra%20başlıyor. Kontroller kaybolur ve geri sayım kaynak alanını doldurur.' },
|
|
14
|
+
{ question: 'Başlangıç saatini planlarsam ne olur?', answer: 'Saat seçtiğiniz yerel zamana kadar bekler, ardından sahne süresini geri sayar. Geçmiş bir saat sonraki günün aynı planına taşınır.' },
|
|
15
|
+
{ question: 'Kendi mesajımı kullanabilir miyim?', answer: 'Evet. Yayıncı olarak dönüş saati, sıradaki adım veya raid mesajı gibi izleyicilerin görmesini istediğiniz kısa sinyali yazabilirsiniz.' },
|
|
16
|
+
{ question: 'Sahne hazır ayarları neyi değiştirir?', answer: 'Yayın anını adlandırır ve sahnenin bir bakışta anlaşılmasını sağlar. Zaman hesabı, süre ve platform bağlantısı değişmez.' },
|
|
17
|
+
{ question: 'Bitiş zamanı kesin midir?', answer: 'Cihazınızın yerel saati ve girdiğiniz süreyle hesaplanır. Tarayıcı uykusu, duraklatılmış sekme veya saat değişikliği görünür güncellemeyi etkileyebilir. Bu bir hazırlık göstergesidir, yayın senkronizasyonu garantisi değildir.' },
|
|
18
|
+
],
|
|
19
|
+
howTo: [
|
|
20
|
+
{ name: 'Sahne anını seçin', text: 'Az sonra başlıyor, BRB, Raid veya Mola seçeneklerinden izleyicilerin göreceği anı seçin.' },
|
|
21
|
+
{ name: 'Yayıncı sinyalinizi yazın', text: 'Sonraki sahneyi hazırlarken izleyicilerin okuyacağı kısa mesajı girin.' },
|
|
22
|
+
{ name: 'Süreyi veya saati ayarlayın', text: 'Hazır bir süre seçin veya kendi sürenizi girin. Hemen başlayın ya da yerel bir saat planlayın.' },
|
|
23
|
+
{ name: 'Durumu okuyun', text: 'Sahne değişimine ne zaman karar vereceğinizi anlamak için büyük saati ve durum etiketini kullanın.' },
|
|
24
|
+
],
|
|
25
|
+
seo: [
|
|
26
|
+
{ type: 'title', text: 'Bir bakışta okunan yayın sahnesi sinyali hazırlayın', level: 2 },
|
|
27
|
+
{ type: 'paragraph', html: 'Geri sayım, Az sonra başlıyor, BRB, raid veya mola sahnenize net bir dönüş noktası verir. Ne yaptığınızı yazın, gereken süreyi ayarlayın ve hazırlık sırasında büyük zamanlayıcıyı görünür bırakın.' },
|
|
28
|
+
{ type: 'title', text: 'Sahne saati neyi hesaplar', level: 3 },
|
|
29
|
+
{ type: 'list', items: ['<strong>Anında sinyal:</strong> yayına aldığınız anda başlar ve seçilen süreyi geri sayar.', '<strong>Planlı sinyal:</strong> yerel saati bekler ve ardından sahne süresini başlatır.', '<strong>Sahne durumu:</strong> hazır, bekliyor, canlı ve bitti durumlarını ayırarak sonraki eylemi gösterir.'] },
|
|
30
|
+
{ type: 'title', text: 'Kullanışlı bir sahne süresi seçin', level: 3 },
|
|
31
|
+
{ type: 'paragraph', html: 'Kaynak değiştirirken veya kısa bir kesintiyi çözerken kısa süre kullanın. Konuk, oyun ya da teknik yeniden başlatma hazırlıyorsanız daha uzun süre seçin. Mesajınız, zamanlayıcının tek başına anlatamadığı bilgiyi vermelidir.' },
|
|
32
|
+
{ type: 'tip', title: 'Dönüşü somutlaştırın', html: 'Genel bir söz yerine "20:30\'da final için dönüyorum" veya "Raid hazırlanıyor" yazın. İzleyiciler ne kadar bekleyeceklerini ve nedenini anlar.' },
|
|
33
|
+
{ type: 'title', text: 'Saat neden çevrimdışı çalışır', level: 3 },
|
|
34
|
+
{ type: 'paragraph', html: 'Saatin kanalınıza veya yayın yazılımınıza erişmesi gerekmez. Metin ve zaman tarayıcıda kalır, bu nedenle hazırlık sahnesi olarak kullanılabilir. Yayından önce OBS içindeki kaynak görünümünü ve sahne geçişini kontrol edin.' },
|
|
35
|
+
{ type: 'title', text: 'Hazır sahneyi OBS\'ye gönderin', level: 3 },
|
|
36
|
+
{ type: 'paragraph', html: '<strong>OBS bağlantısını kopyala</strong> düğmesine basın, yayın sahnenize Browser Source ekleyin, bağlantıyı yapıştırın ve tuval çözünürlüğünü ayarlayın. <code>?STREAMING</code> kontrolleri olmayan temiz tam ekran sahnesini otomatik başlatır.' },
|
|
37
|
+
{ type: 'title', text: 'Görünümü kanalınıza uydurun', level: 3 },
|
|
38
|
+
{ type: 'paragraph', html: 'Beş farklı kompozisyon arasından seçim yapın: Aurora sisi atmosferik halka, Kinetik tipografi büyük rakamlar, Darbeli ışıma genişleyen dalgalar, Glitch sinyali keskin yayın enerjisi, Güneş parlaması ise sıcak bir ufuk oluşturur. Önizlemeyi seçip renkleri ayarlayın.' },
|
|
39
|
+
{ type: 'list', items: ['<strong>Sahne:</strong> Az sonra başlıyor, BRB, raid veya molayı gösterir.', '<strong>Başlık:</strong> varsayılan satırı kendi başlığınızla değiştirir.', '<strong>Mesaj:</strong> izleyicilerin okuması gereken bilgiyi ekler.', '<strong>Süre ve saat:</strong> başlangıcı ve görünür kalma süresini belirler.'] },
|
|
40
|
+
{ type: 'paragraph', html: 'URL\'yi kendiniz oluşturursanız <code>?STREAMING&scene=raid&title=Raid%20hazırlanıyor&design=pulse</code> kullanabilirsiniz. Oluşturucu süreyi, mesajı ve özel renkleri otomatik ekler.' },
|
|
41
|
+
{ type: 'title', text: 'Bitiş saatini hazırlık göstergesi olarak okuyun', level: 3 },
|
|
42
|
+
{ type: 'paragraph', html: 'Bitiş saati cihazınızın saati ve seçtiğiniz süreyle hesaplanır. Yayının herkese aynı anda ulaşacağını garanti etmez. Değişim bir konuğa, bağlantıya veya yayın geçişine bağlıysa biraz pay bırakın.' },
|
|
43
|
+
],
|
|
44
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { makeContent } from './locale-factory';
|
|
2
|
+
|
|
3
|
+
export const content = makeContent({
|
|
4
|
+
language: 'zh',
|
|
5
|
+
slug: 'stream-scene-countdown-clock',
|
|
6
|
+
title: '直播场景倒计时',
|
|
7
|
+
description: '为直播开始前、BRB、Raid 和休息场景制作专为主播设计的倒计时画面。',
|
|
8
|
+
ui: {
|
|
9
|
+
sceneLabel: '你正在设置哪个场景?', sceneBrb: 'BRB', sceneStarting: '即将开始', sceneRaid: 'Raid', sceneIntermission: '休息', sceneTitleLabel: '计时器上方显示什么?', sceneTitlePlaceholder: '即将开始', designLabel: '场景应该有什么氛围?', designAurora: '极光薄雾', designType: '动感文字', designPulse: '脉冲光晕', designGlitch: '故障信号', designSunset: '日落闪光', accentColorLabel: '强调色', glowColorLabel: '发光色', messageLabel: '你希望观众知道什么?', messagePlaceholder: '5 分钟后回来', durationLabel: '你需要多长时间?', duration60: '1 分钟', duration300: '5 分钟', duration600: '10 分钟', durationCustom: '自定义', secondsLabel: '秒', startLabel: '这个提示何时开始?', startNow: '立即开始', scheduleTime: '安排时间', timeLabel: '按当地时间何时开始?', startAction: '让我的提示上线', focusAction: '显示我的场景', exitFocusAction: '退出场景视图', resetAction: '重置提示', flowText: '你是主播:选择直播时刻,设置需要的时间,然后让提示上线。', obsTitle: '将此场景放入 OBS', obsText: '复制链接,在 OBS 中添加浏览器源并粘贴链接。使用 STREAMING 后会自动打开没有控制栏的干净全屏场景。', obsStepCopy: '复制此链接', obsStepAdd: '在 OBS 中添加浏览器源', obsStepPaste: '粘贴并匹配画布尺寸', copyUrlAction: '复制 OBS 链接', copiedUrlText: 'OBS 链接已复制', streamUrlAria: '已生成的 OBS 直播 URL', previewTitle: '选择场景外观', previewHint: '点击预览即可使用', previewAria: '场景设计预览', stageEyebrow: '直播画面', stageCaption: '你的下一个场景已准备好', readyBadge: '准备好了', waitingBadge: '等待中', liveBadge: '直播提示', endedBadge: '已结束', readyText: '查看画面,准备好后开始提示。', waitingText: '直播倒计时将在安排的当地时间开始。', liveText: '在场景切换准备好之前保持此画面显示。', endedText: '提示已结束。重置它或设置新的场景。', remainingLabel: '场景剩余时间', startTimeLabel: '开始', endTimeLabel: '结束', progressLabel: '场景进度', assumptionTitle: '时间说明', assumptionText: '安排的时间使用设备时钟。计时器是视觉提示,不会同步 OBS、Twitch、聊天或编码器。', warningTitle: '将它作为场景提示', warningText: '休眠标签页、系统时间变化或直播延迟可能导致画面时间与实际直播不同。切换前请检查直播场景。', invalidTime: '请输入 HH:MM 格式的当地时间。', clockAria: '倒计时剩余时间', statusAria: '倒计时状态',
|
|
10
|
+
},
|
|
11
|
+
faq: [
|
|
12
|
+
{ question: '这个倒计时会连接 OBS 或 Twitch 吗?', answer: '不会。这是独立计时器,不会控制 OBS、Twitch、聊天或直播服务器。使用显示我的场景填满画面,然后把它作为浏览器源捕获。' },
|
|
13
|
+
{ question: '如何在 OBS 中直接使用倒计时?', answer: '将带有直播参数和设置的工具 URL 添加为 OBS 浏览器源,例如 ?STREAMING&scene=starting&duration=300&design=aurora&title=即将%20开始。控制栏会消失,倒计时会填满源区域。' },
|
|
14
|
+
{ question: '安排开始时间后会发生什么?', answer: '时钟会等到选择的当地时间,然后开始倒计时场景时长。已经过去的时间会被视为第二天的下一次发生时间。' },
|
|
15
|
+
{ question: '可以使用自定义消息吗?', answer: '可以。作为主播,你可以写下希望观众看到的简短提示,例如回来时间、下一步或 Raid 消息。' },
|
|
16
|
+
{ question: '场景预设会改变什么?', answer: '预设会标记直播时刻,让场景一眼就能看懂。计时计算、时长和平台连接不会改变。' },
|
|
17
|
+
{ question: '结束时间准确吗?', answer: '结束时间根据浏览器本地时钟和输入的时长计算。浏览器休眠、暂停标签页或时钟变化可能影响画面更新,因此它是准备参考,不是直播同步保证。' },
|
|
18
|
+
],
|
|
19
|
+
howTo: [
|
|
20
|
+
{ name: '选择场景时刻', text: '选择即将开始、BRB、Raid 或休息,为观众标记当前场景。' },
|
|
21
|
+
{ name: '写下主播提示', text: '输入观众在你准备下一个场景时需要阅读的简短消息。' },
|
|
22
|
+
{ name: '设置时长或时间', text: '选择常用时长或输入自定义时长。立即开始,或安排一个当地时间。' },
|
|
23
|
+
{ name: '查看状态', text: '使用大号时钟和状态标签决定何时切换直播场景。' },
|
|
24
|
+
],
|
|
25
|
+
seo: [
|
|
26
|
+
{ type: 'title', text: '制作一眼就能读懂的直播场景提示', level: 2 },
|
|
27
|
+
{ type: 'paragraph', html: '倒计时可以为即将开始、BRB、Raid 或休息场景提供清晰的回归时间。写下你正在准备什么,设置需要的时长,并在准备直播时保留大号计时器。' },
|
|
28
|
+
{ type: 'title', text: '场景时钟会计算什么', level: 3 },
|
|
29
|
+
{ type: 'list', items: ['<strong>立即提示:</strong> 上线后立即开始,并倒计时选择的时长。', '<strong>安排提示:</strong> 等待当地时间,然后开始场景时长。', '<strong>场景状态:</strong> 区分准备好、等待、直播和结束,让下一步行动清楚可见。'] },
|
|
30
|
+
{ type: 'title', text: '如何选择实用的场景时长', level: 3 },
|
|
31
|
+
{ type: 'paragraph', html: '切换来源或处理短暂中断时使用较短时长。准备嘉宾、游戏或技术重启时选择更长时长。消息应该补充计时器本身无法表达的信息。' },
|
|
32
|
+
{ type: 'tip', title: '具体说明回来时做什么', html: '不要只写"马上回来",可以写"20:30 回来打决赛"或"正在准备 Raid"。观众就能知道等待多久以及原因。' },
|
|
33
|
+
{ type: 'title', text: '为什么计时器保持离线', level: 3 },
|
|
34
|
+
{ type: 'paragraph', html: '时钟不需要访问你的频道或直播软件。文字和时间都在浏览器中处理,因此适合作为准备场景。直播前仍应在 OBS 中检查源的显示和场景切换。' },
|
|
35
|
+
{ type: 'title', text: '将完成的场景发送到 OBS', level: 3 },
|
|
36
|
+
{ type: 'paragraph', html: '点击<strong>复制 OBS 链接</strong>,在直播场景中添加浏览器源,粘贴链接并匹配画布分辨率。使用 <code>?STREAMING</code> 会自动打开没有控制栏的干净全屏场景。' },
|
|
37
|
+
{ type: 'title', text: '选择适合频道的设计', level: 3 },
|
|
38
|
+
{ type: 'paragraph', html: '五种构图各不相同:极光薄雾使用有氛围感的圆环,动感文字使用超大数字,脉冲光晕使用扩散波纹,故障信号带来锐利的广播能量,日落闪光创造温暖地平线。选择预览后还可以调整颜色。' },
|
|
39
|
+
{ type: 'list', items: ['<strong>场景:</strong> 标记即将开始、BRB、Raid 或休息。', '<strong>标题:</strong> 用自己的标题替换默认场景文字。', '<strong>消息:</strong> 添加观众需要阅读的补充提示。', '<strong>时长和时间:</strong> 控制场景何时开始以及显示多久。'] },
|
|
40
|
+
{ type: 'paragraph', html: '如果手动创建 URL,可以使用 <code>?STREAMING&scene=raid&title=正在%20准备%20Raid&design=pulse</code>。生成器会自动加入时长、消息和自定义颜色。' },
|
|
41
|
+
{ type: 'title', text: '把结束时间当作准备参考', level: 3 },
|
|
42
|
+
{ type: 'paragraph', html: '结束时间由设备时钟和选择的时长计算。它不保证直播会在同一时刻到达每位观众。如果切换依赖嘉宾、连接或直播转场,请留出一点余量。' },
|
|
43
|
+
],
|
|
44
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { streamSceneCountdownClock } from './entry';
|
|
2
|
+
import type { ToolDefinition } from '../../types';
|
|
3
|
+
import streamSceneCountdownClockCss from './stream-scene-countdown-clock.css?raw';
|
|
4
|
+
|
|
5
|
+
export * from './entry';
|
|
6
|
+
|
|
7
|
+
export const STREAM_SCENE_COUNTDOWN_CLOCK_TOOL: ToolDefinition = {
|
|
8
|
+
entry: streamSceneCountdownClock,
|
|
9
|
+
Component: () => import('./component.astro'),
|
|
10
|
+
SEOComponent: () => import('./seo.astro'),
|
|
11
|
+
BibliographyComponent: () => import('./bibliography.astro'),
|
|
12
|
+
css: streamSceneCountdownClockCss,
|
|
13
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
clampDuration,
|
|
4
|
+
formatCountdown,
|
|
5
|
+
getCountdown,
|
|
6
|
+
parseStartTime,
|
|
7
|
+
} from './logic';
|
|
8
|
+
|
|
9
|
+
describe('stream scene countdown logic', () => {
|
|
10
|
+
it('clamps invalid and extreme durations', () => {
|
|
11
|
+
expect(clampDuration(Number.NaN)).toBe(300);
|
|
12
|
+
expect(clampDuration(1)).toBe(10);
|
|
13
|
+
expect(clampDuration(90000)).toBe(86400);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('formats a countdown as hours, minutes, and seconds', () => {
|
|
17
|
+
expect(formatCountdown(3661)).toBe('01:01:01');
|
|
18
|
+
expect(formatCountdown(-2)).toBe('00:00:00');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('starts in preview mode without an armed timestamp', () => {
|
|
22
|
+
expect(getCountdown({ durationSeconds: 300, startAtMs: null, nowMs: 1000 }).status).toBe('ready');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('counts down to a scheduled start before going live', () => {
|
|
26
|
+
const snapshot = getCountdown({ durationSeconds: 300, startAtMs: 10000, nowMs: 5500 });
|
|
27
|
+
expect(snapshot.status).toBe('waiting');
|
|
28
|
+
expect(snapshot.displaySeconds).toBe(5);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('reports live progress and remaining time', () => {
|
|
32
|
+
const snapshot = getCountdown({ durationSeconds: 300, startAtMs: 10000, nowMs: 16000 });
|
|
33
|
+
expect(snapshot.status).toBe('live');
|
|
34
|
+
expect(snapshot.remainingSeconds).toBe(294);
|
|
35
|
+
expect(snapshot.elapsedFraction).toBeCloseTo(0.02);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('reports an ended clock at its exact end boundary', () => {
|
|
39
|
+
const snapshot = getCountdown({ durationSeconds: 300, startAtMs: 10000, nowMs: 310000 });
|
|
40
|
+
expect(snapshot.status).toBe('ended');
|
|
41
|
+
expect(snapshot.displaySeconds).toBe(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('parses a local clock time and rolls a passed time to tomorrow', () => {
|
|
45
|
+
const reference = new Date(2026, 7, 28, 20, 30).getTime();
|
|
46
|
+
const nextDay = parseStartTime('19:15', reference);
|
|
47
|
+
expect(nextDay).not.toBeNull();
|
|
48
|
+
expect(new Date(nextDay as number).getDate()).toBe(29);
|
|
49
|
+
expect(parseStartTime('25:00', reference)).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
export type ClockStatus = 'ready' | 'waiting' | 'live' | 'ended';
|
|
2
|
+
|
|
3
|
+
export interface CountdownInput {
|
|
4
|
+
durationSeconds: number;
|
|
5
|
+
startAtMs: number | null;
|
|
6
|
+
nowMs: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface CountdownSnapshot {
|
|
10
|
+
status: ClockStatus;
|
|
11
|
+
displaySeconds: number;
|
|
12
|
+
remainingSeconds: number;
|
|
13
|
+
elapsedFraction: number;
|
|
14
|
+
startAtMs: number | null;
|
|
15
|
+
endAtMs: number | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const MIN_DURATION_SECONDS = 10;
|
|
19
|
+
export const MAX_DURATION_SECONDS = 86400;
|
|
20
|
+
|
|
21
|
+
export function clampDuration(seconds: number): number {
|
|
22
|
+
if (!Number.isFinite(seconds)) return 300;
|
|
23
|
+
return Math.min(MAX_DURATION_SECONDS, Math.max(MIN_DURATION_SECONDS, Math.round(seconds)));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function parseStartTime(value: string, referenceMs: number): number | null {
|
|
27
|
+
const match = /^(\d{2}):(\d{2})$/.exec(value);
|
|
28
|
+
if (!match) return null;
|
|
29
|
+
const hours = Number(match[1]);
|
|
30
|
+
const minutes = Number(match[2]);
|
|
31
|
+
if (hours > 23 || minutes > 59) return null;
|
|
32
|
+
const date = new Date(referenceMs);
|
|
33
|
+
date.setHours(hours, minutes, 0, 0);
|
|
34
|
+
if (date.getTime() <= referenceMs) date.setDate(date.getDate() + 1);
|
|
35
|
+
return date.getTime();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getCountdown(input: CountdownInput): CountdownSnapshot {
|
|
39
|
+
const durationSeconds = clampDuration(input.durationSeconds);
|
|
40
|
+
if (input.startAtMs === null) return createReadySnapshot(durationSeconds);
|
|
41
|
+
|
|
42
|
+
const endAtMs = input.startAtMs + durationSeconds * 1000;
|
|
43
|
+
if (input.nowMs < input.startAtMs) return createWaitingSnapshot(input, durationSeconds, endAtMs);
|
|
44
|
+
if (input.nowMs >= endAtMs) return createEndedSnapshot(input.startAtMs, endAtMs);
|
|
45
|
+
|
|
46
|
+
const remainingSeconds = Math.ceil((endAtMs - input.nowMs) / 1000);
|
|
47
|
+
const elapsedFraction = Math.min(1, Math.max(0, (input.nowMs - input.startAtMs) / (durationSeconds * 1000)));
|
|
48
|
+
return createLiveSnapshot({
|
|
49
|
+
remainingSeconds,
|
|
50
|
+
elapsedFraction,
|
|
51
|
+
startAtMs: input.startAtMs,
|
|
52
|
+
endAtMs,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function createReadySnapshot(durationSeconds: number): CountdownSnapshot {
|
|
57
|
+
return {
|
|
58
|
+
status: 'ready',
|
|
59
|
+
displaySeconds: durationSeconds,
|
|
60
|
+
remainingSeconds: durationSeconds,
|
|
61
|
+
elapsedFraction: 0,
|
|
62
|
+
startAtMs: null,
|
|
63
|
+
endAtMs: null,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function createWaitingSnapshot(input: CountdownInput, durationSeconds: number, endAtMs: number): CountdownSnapshot {
|
|
68
|
+
return {
|
|
69
|
+
status: 'waiting',
|
|
70
|
+
displaySeconds: Math.ceil((input.startAtMs as number - input.nowMs) / 1000),
|
|
71
|
+
remainingSeconds: durationSeconds,
|
|
72
|
+
elapsedFraction: 0,
|
|
73
|
+
startAtMs: input.startAtMs,
|
|
74
|
+
endAtMs,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function createEndedSnapshot(startAtMs: number, endAtMs: number): CountdownSnapshot {
|
|
79
|
+
return {
|
|
80
|
+
status: 'ended',
|
|
81
|
+
displaySeconds: 0,
|
|
82
|
+
remainingSeconds: 0,
|
|
83
|
+
elapsedFraction: 1,
|
|
84
|
+
startAtMs,
|
|
85
|
+
endAtMs,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function createLiveSnapshot(input: Omit<CountdownSnapshot, 'status' | 'displaySeconds'>): CountdownSnapshot {
|
|
90
|
+
return {
|
|
91
|
+
status: 'live',
|
|
92
|
+
displaySeconds: input.remainingSeconds,
|
|
93
|
+
...input,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function formatCountdown(seconds: number): string {
|
|
98
|
+
const safeSeconds = Math.max(0, Math.floor(seconds));
|
|
99
|
+
const hours = Math.floor(safeSeconds / 3600);
|
|
100
|
+
const minutes = Math.floor((safeSeconds % 3600) / 60);
|
|
101
|
+
const remainder = safeSeconds % 60;
|
|
102
|
+
return [hours, minutes, remainder].map((part) => String(part).padStart(2, '0')).join(':');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function formatLocalTime(timestamp: number | null): string {
|
|
106
|
+
if (timestamp === null) return '--:--';
|
|
107
|
+
return new Intl.DateTimeFormat('en', { hour: 'numeric', minute: '2-digit' }).format(timestamp);
|
|
108
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { STREAM_SCENE_COUNTDOWN_CLOCK_TOOL } from './index';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
locale?: KnownLocale;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { locale = 'en' } = Astro.props;
|
|
11
|
+
const content = await STREAM_SCENE_COUNTDOWN_CLOCK_TOOL.entry.i18n[locale]?.();
|
|
12
|
+
if (!content) return null;
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
{content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { clampDuration } from './logic';
|
|
2
|
+
import type { SavedClockSettings } from './storage';
|
|
3
|
+
import type { DesignKey } from './ui';
|
|
4
|
+
|
|
5
|
+
function getTextValue(root: HTMLElement, selector: string, fallback: string): string {
|
|
6
|
+
const element = root.querySelector<HTMLInputElement | HTMLSelectElement>(selector);
|
|
7
|
+
return element ? element.value : fallback;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function getDurationValue(root: HTMLElement, fallback: number): number {
|
|
11
|
+
const element = root.querySelector<HTMLInputElement>('[data-duration-input]');
|
|
12
|
+
return element && Number.isFinite(element.valueAsNumber) ? element.valueAsNumber : fallback;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function collectSettings(root: HTMLElement, current: SavedClockSettings): SavedClockSettings {
|
|
16
|
+
const message = getTextValue(root, '[data-message]', current.message);
|
|
17
|
+
const sceneTitle = getTextValue(root, '[data-scene-title]', current.sceneTitle);
|
|
18
|
+
const duration = getDurationValue(root, current.durationSeconds);
|
|
19
|
+
const startTime = getTextValue(root, '[data-start-time-input]', current.startTime);
|
|
20
|
+
const design = getTextValue(root, '[data-design]', current.design) as DesignKey;
|
|
21
|
+
const accentColor = getTextValue(root, '[data-accent-color]', current.accentColor);
|
|
22
|
+
const glowColor = getTextValue(root, '[data-glow-color]', current.glowColor);
|
|
23
|
+
return { ...current, message, sceneTitle, durationSeconds: clampDuration(duration), startTime, design, accentColor, glowColor };
|
|
24
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { DesignKey, SceneKey } from './ui';
|
|
2
|
+
|
|
3
|
+
export interface SavedClockSettings {
|
|
4
|
+
scene: SceneKey;
|
|
5
|
+
sceneTitle: string;
|
|
6
|
+
design: DesignKey;
|
|
7
|
+
accentColor: string;
|
|
8
|
+
glowColor: string;
|
|
9
|
+
message: string;
|
|
10
|
+
durationSeconds: number;
|
|
11
|
+
startMode: 'now' | 'scheduled';
|
|
12
|
+
startTime: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const STORAGE_KEY = 'jjlmoya-stream-scene-countdown-clock';
|
|
16
|
+
|
|
17
|
+
export function loadSettings(): Partial<SavedClockSettings> {
|
|
18
|
+
try {
|
|
19
|
+
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
20
|
+
if (!raw) return {};
|
|
21
|
+
const parsed = JSON.parse(raw) as Partial<SavedClockSettings>;
|
|
22
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
23
|
+
} catch {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function saveSettings(settings: SavedClockSettings): void {
|
|
29
|
+
try {
|
|
30
|
+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
|
31
|
+
} catch {}
|
|
32
|
+
}
|