From f89b4c6378889c6bc8105da255ab82fc65c9bb9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87A=C4=9EAN=20TURGUT?= Date: Thu, 1 Oct 2026 23:00:50 +0300 Subject: [PATCH] fix(nowplaying): manual cover off Windows and keep Spout frames moving Linux and macOS cannot read system artwork or playback time. Show Cover stays, but those platforms upload an image and hide elapsed time, the progress bar, and automatic logo sources. Windows still defaults to the system cover and can fall back to an uploaded image. The MilkDrop WebGL2 failure is translated and tells the user to update the GPU driver. Smoke measures a live waves scene so a frozen shared texture cannot pass, and the last paint releases its texture on stop. --- README.md | 4 +- README.tr.md | 4 +- ROADMAP.md | 2 +- src/admin/admin.js | 112 ++++++++++++++++++++- src/admin/nowplaying-panel.js | 85 +++++++++++++--- src/admin/scene-panels.js | 66 ++++++++++-- src/main/main.js | 127 ++++++++++++++++-------- src/main/preload-visualizer.js | 7 ++ src/main/texture-share.js | 13 ++- src/shared/defaults.js | 3 + src/shared/i18n.js | 20 ++++ src/visualizer/layers.js | 5 +- src/visualizer/modes/milkdrop.js | 20 +++- src/visualizer/modes/nowplaying.js | 16 ++- tests/layers.test.js | 11 ++ tests/nowplaying-cover-platform.test.js | 45 +++++++++ 16 files changed, 459 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index 63078cca..7899ad89 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-111997.svg)](#build--distribution) [![Electron](https://img.shields.io/badge/Electron-43-47848F.svg)](https://www.electronjs.org/) [![Downloads](https://img.shields.io/github/downloads/CaYatur/SoundVisualizer/total?label=downloads)](https://github.com/CaYatur/SoundVisualizer/releases) -[![Tests](https://img.shields.io/badge/tests-2556%20passing-2ea043.svg)](#tests) +[![Tests](https://img.shields.io/badge/tests-2561%20passing-2ea043.svg)](#tests) [![cayadev.com](https://img.shields.io/badge/cayadev.com-e11d2a.svg)](https://cayadev.com) @@ -1341,7 +1341,7 @@ npm test npm start -- --smoke ``` -**2556 unit tests, all passing.** They are written to check answers, not to exercise lines: +**2561 unit tests, all passing.** They are written to check answers, not to exercise lines: - **Formulas** are checked against values derived by hand from their definitions — Viviani's curve staying on its sphere, the torus tube radius, Chladni's m↔n antisymmetry, every attractor diff --git a/README.tr.md b/README.tr.md index c3a9848c..e538a348 100644 --- a/README.tr.md +++ b/README.tr.md @@ -12,7 +12,7 @@ [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-111997.svg)](#paketleme--dağıtım) [![Electron](https://img.shields.io/badge/Electron-43-47848F.svg)](https://www.electronjs.org/) [![İndirme](https://img.shields.io/github/downloads/CaYatur/SoundVisualizer/total?label=indirme)](https://github.com/CaYatur/SoundVisualizer/releases) -[![Test](https://img.shields.io/badge/test-2556%20geçiyor-2ea043.svg)](#testler) +[![Test](https://img.shields.io/badge/test-2561%20geçiyor-2ea043.svg)](#testler) [![cayadev.com](https://img.shields.io/badge/cayadev.com-e11d2a.svg)](https://cayadev.com) @@ -1341,7 +1341,7 @@ npm test npm start -- --smoke ``` -**2556 birim testi, hepsi geçiyor.** Satır çalıştırmak için değil, cevap denetlemek için yazıldılar: +**2561 birim testi, hepsi geçiyor.** Satır çalıştırmak için değil, cevap denetlemek için yazıldılar: - **Formüller**, tanımlarından elle türetilmiş değerlerle sınanıyor — Viviani eğrisinin küre üzerinde kalması, simidin boru yarıçapı, Chladni'nin m↔n antisimetrisi, her çekicinin sınırlı diff --git a/ROADMAP.md b/ROADMAP.md index fc37015b..6447935b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,7 +129,7 @@ npm test npm start -- --smoke ``` -- **2556 unit tests, all passing** on `main`. 703 of those shipped in v3.1.0; +- **2561 unit tests, all passing** on `main`. 703 of those shipped in v3.1.0; 105 came with v3.1.1; 163 came with v3.1.2; 157 came with v3.1.3 — 1128 at that tag — 469 more with v3.1.4, most of them from the MilkDrop work, and 878 on `main` since. diff --git a/src/admin/admin.js b/src/admin/admin.js index fd05ce30..5db4a55c 100644 --- a/src/admin/admin.js +++ b/src/admin/admin.js @@ -456,6 +456,8 @@ return imagesCtrl(def); case 'logofile': return logoFileCtrl(def); + case 'npcoverfile': + return npCoverFileCtrl(def); case 'logolibrary': return logoLibraryCtrl(def); case 'floatingtoggle': @@ -1092,6 +1094,8 @@ } function logoFileCtrl() { + /* macOS/Linux albüm kapağını okuyamaz. Kayıtlı auto/track burada özel resme iner. */ + if (!isWindows() && cfg.logo && cfg.logo.source !== 'manual') cfg.logo.source = 'manual'; const fileInput = el('input', { type: 'file', accept: 'image/*' }); fileInput.style.display = 'none'; fileInput.addEventListener('change', (e) => { @@ -1126,7 +1130,7 @@ }); removeBtn.style.marginLeft = '8px'; - const mode = cfg.logo.source || 'auto'; + const mode = isWindows() ? (cfg.logo.source || 'auto') : 'manual'; if (mode === 'track') { const live = (window.SVNowLive && window.SVNowLive.state && window.SVNowLive.state.has) ? window.SVNowLive.state : null; const note = el('div', { @@ -1159,6 +1163,46 @@ ]); } + /* Çalan parça kapağı: Windows'ta otomatik kapağın yedeği, diğer platformlarda tek kaynak. */ + function npCoverFileCtrl() { + const C = cfg.nowplaying || (cfg.nowplaying = {}); + C.manual = C.manual || {}; + if (!isWindows()) C.coverSource = 'manual'; + const fileInput = el('input', { type: 'file', accept: 'image/*' }); + fileInput.style.display = 'none'; + fileInput.addEventListener('change', (e) => { + const file = e.target.files && e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + C.manual.artwork = reader.result; + push(true); + render(); + }; + reader.readAsDataURL(file); + }); + const pick = el('button', { + class: 'btn small', type: 'button', icon: 'image', + text: C.manual.artwork ? 'Kapağı Değiştir' : 'Kapak Seç', + onclick: () => fileInput.click(), + }); + const remove = C.manual.artwork ? el('button', { + class: 'btn ghost small danger', type: 'button', text: 'Kapağı Kaldır', + onclick: () => { + C.manual.artwork = ''; + push(true); + render(); + }, + }) : null; + const preview = C.manual.artwork + ? el('img', { class: 'logo-preview', src: C.manual.artwork, alt: 'Kapak Görseli', style: 'display:block' }) + : null; + return el('div', { class: 'ctrl' }, [ + el('div', { class: 'row' }, [pick, remove, fileInput].filter(Boolean)), + preview, + ].filter(Boolean)); + } + function isGifLogo(lg) { if (!lg) return false; if (window.SVGif && window.SVGif.isAnimatedLogo) return window.SVGif.isAnimatedLogo(lg, lg.src); @@ -2278,6 +2322,8 @@ { type: 'color', path: 'nowplaying.colorBar', label: 'Çubuk', show: () => v.type === 'nowplaying' + && isWindows() + && ((cfg.nowplaying && cfg.nowplaying.source) || 'system') === 'system' && (v.colorMode || (v.rainbow ? 'rainbow' : 'custom')) === 'custom', }, { @@ -2290,6 +2336,29 @@ text: 'Çalan parçanın albüm kapağını yazının yanına veya üstüne yerleştirir. Boyut ekranın kısa kenarına göredir. Kapak yoksa bindirme çizilmez. Varsayılan kapalıdır.', show: () => v.type === 'nowplaying' && !!(cfg.nowplaying && cfg.nowplaying.coverOverlay), }, + { + type: 'select', path: 'nowplaying.coverSource', label: 'Kapak Kaynağı', rebuild: true, + options: [ + { value: 'auto', label: 'Otomatik (Sistem)' }, + { value: 'manual', label: 'Elle Yükle' }, + ], + show: () => isWindows() && v.type === 'nowplaying' && !!(cfg.nowplaying && cfg.nowplaying.coverOverlay), + }, + { + type: 'note', + text: () => { + if (!isWindows()) return 'Bu platformda albüm kapağı otomatik okunamaz. Gösterilecek resmi elle yükleyin.'; + const src = (cfg.nowplaying && cfg.nowplaying.coverSource) || 'auto'; + return src === 'manual' + ? 'Elle yüklenen resim yazının yanında gösterilir.' + : 'Windows’ta kapak çalan parçadan otomatik gelir. İsterseniz kendi resminizi de yükleyebilirsiniz; otomatik kapak yoksa o resim kullanılır.'; + }, + show: () => v.type === 'nowplaying' && !!(cfg.nowplaying && cfg.nowplaying.coverOverlay), + }, + { + type: 'npcoverfile', + show: () => v.type === 'nowplaying' && !!(cfg.nowplaying && cfg.nowplaying.coverOverlay), + }, { type: 'slider', path: 'nowplaying.coverSize', label: 'Kapak Boyutu', min: 0.05, max: 0.5, step: 0.01, percent: true, @@ -2505,7 +2574,9 @@ category: 'scene', icon: 'music', title: 'Çalan Parça', - desc: 'Bilgisayarda çalan parçayı ekrana getirir: ad, sanatçı, geçen ve kalan süre, ilerleme çubuğu. Sürekli görünebilir ya da yalnızca parça değişince canlandırmayla belirir.', + desc: isWindows() + ? 'Bilgisayarda çalan parçayı ekrana getirir: ad, sanatçı, geçen ve kalan süre, ilerleme çubuğu. Sürekli görünebilir ya da yalnızca parça değişince canlandırmayla belirir.' + : 'Elle yazılan parça adı ve sanatçı ekrana gelir. Albüm kapağı elle yüklenir. Süre ve ilerleme çubuğu sistemden okunduğu için bu platformda yoktur.', controls: [{ type: 'nowplayingpanel' }], show: () => notStack() && v.type === 'nowplaying', }, @@ -2791,7 +2862,7 @@ { value: 'manual', label: 'Özel Resim (Yalnızca seçilen dosya)' }, { value: 'track', label: 'Sadece Çalan Şarkı Resmi' }, ], - show: () => cfg.logo.enabled, + show: () => cfg.logo.enabled && isWindows(), rebuild: true, }, { type: 'logofile', show: () => cfg.logo.enabled }, @@ -4725,8 +4796,39 @@ if (advBox) advBox.checked = advancedOn; if (!CATEGORIES.some((c) => c.id === activeCategory)) activeCategory = CATEGORIES[0].id; if (!(window.SV_PLATFORM && window.SV_PLATFORM.isWindows)) { - if (cfg.dynamicTheme) cfg.dynamicTheme.enabled = false; - if (cfg.nowplaying && cfg.nowplaying.source === 'system') cfg.nowplaying.source = 'manual'; + let platformTouched = false; + if (cfg.dynamicTheme && cfg.dynamicTheme.enabled) { + cfg.dynamicTheme.enabled = false; + platformTouched = true; + } + if (cfg.nowplaying) { + if (cfg.nowplaying.source === 'system') { cfg.nowplaying.source = 'manual'; platformTouched = true; } + if (cfg.nowplaying.coverSource !== 'manual') { cfg.nowplaying.coverSource = 'manual'; platformTouched = true; } + } + if (cfg.logo && cfg.logo.source && cfg.logo.source !== 'manual') { + cfg.logo.source = 'manual'; + platformTouched = true; + } + if (cfg.text && cfg.text.nowSource === 'system') { + cfg.text.nowSource = 'manual'; + platformTouched = true; + } + if (Array.isArray(cfg.layers)) { + cfg.layers.forEach((l) => { + if (!l || !l.settings) return; + if (l.kind === 'logo' && l.settings.logo && l.settings.logo.source && l.settings.logo.source !== 'manual') { + l.settings.logo.source = 'manual'; + platformTouched = true; + } + if (l.settings.nowplaying && l.settings.nowplaying.coverSource !== 'manual') { + l.settings.nowplaying.coverSource = 'manual'; + platformTouched = true; + } + const lt = l.settings.text; + if (lt && lt.nowSource === 'system') { lt.nowSource = 'manual'; platformTouched = true; } + }); + } + if (platformTouched) push(true); } // Seçili arayüz dilini ana sürece bildir (diyaloglar ve yayın sayfaları) diff --git a/src/admin/nowplaying-panel.js b/src/admin/nowplaying-panel.js index 4ae82568..8618570e 100644 --- a/src/admin/nowplaying-panel.js +++ b/src/admin/nowplaying-panel.js @@ -94,6 +94,43 @@ setInterval(paintStatus, 500); } + function coverFileRow(el, P, C, rerender) { + C.manual = C.manual || {}; + const fileInput = el('input', { type: 'file', accept: 'image/*' }); + fileInput.style.display = 'none'; + fileInput.addEventListener('change', (e) => { + const file = e.target.files && e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + C.manual.artwork = reader.result; + P().push(true); + rerender(); + }; + reader.readAsDataURL(file); + }); + const pick = el('button', { + class: 'btn small', type: 'button', icon: 'image', + text: C.manual.artwork ? 'Kapağı Değiştir' : 'Kapak Seç', + onclick: () => fileInput.click(), + }); + const remove = C.manual.artwork ? el('button', { + class: 'btn ghost small danger', type: 'button', text: 'Kapağı Kaldır', + onclick: () => { + C.manual.artwork = ''; + P().push(true); + rerender(); + }, + }) : null; + const preview = C.manual.artwork + ? el('img', { class: 'logo-preview', src: C.manual.artwork, alt: 'Kapak Görseli', style: 'display:block' }) + : null; + return el('div', { class: 'ctrl' }, [ + el('div', { class: 'row' }, [pick, remove, fileInput].filter(Boolean)), + preview, + ].filter(Boolean)); + } + // ------------------------------------------------------------------ panel function panel() { const el = P().el; @@ -105,7 +142,9 @@ const isWin = !!(window.SV_PLATFORM && window.SV_PLATFORM.isWindows); if (!isWin && (C.source || 'system') === 'system') C.source = 'manual'; + if (!isWin) C.coverSource = 'manual'; const src = C.source || (isWin ? 'system' : 'manual'); + const fromSystem = isWin && src === 'system'; if (src === 'system' && isWin && window.api && window.api.nowPlayingSubscribe) { window.api.nowPlayingSubscribe(true); } @@ -164,12 +203,16 @@ f('title', 'Parça Adı', true), f('artist', 'Sanatçı', true), f('album', 'Albüm', false), - f('appName', 'Oynatıcı Adı', false), - f('elapsed', 'Geçen Süre', true), - f('remaining', 'Kalan Süre', false), - f('total', 'Toplam Süre', true), - f('bar', 'İlerleme Çubuğu', true), ]; + /* Süre ve oynatıcı adı sistem oturumundan gelir. Elle yazılan + parçada bu alanlar 0:00 üretirdi; o yüzden gizlenir. */ + if (fromSystem) { + kids.push(f('appName', 'Oynatıcı Adı', false)); + kids.push(f('elapsed', 'Geçen Süre', true)); + kids.push(f('remaining', 'Kalan Süre', false)); + kids.push(f('total', 'Toplam Süre', true)); + kids.push(f('bar', 'İlerleme Çubuğu', true)); + } kids.push(SP().miniToggle('Parça ve Sanatçı Tek Satırda', () => !!C.oneLine, (v) => { C.oneLine = v; }, rerender)); if (C.oneLine) { kids.push(P().row('Ayırıcı', el('input', { @@ -177,12 +220,16 @@ oninput: (e) => { C.separator = e.target.value; P().push(false); }, }))); } - kids.push(P().row('Süre Ayırıcı', el('input', { - class: 'p-in', type: 'text', value: C.timeSeparator === undefined ? ' / ' : C.timeSeparator, - oninput: (e) => { C.timeSeparator = e.target.value; P().push(false); }, - }))); + if (fromSystem) { + kids.push(P().row('Süre Ayırıcı', el('input', { + class: 'p-in', type: 'text', value: C.timeSeparator === undefined ? ' / ' : C.timeSeparator, + oninput: (e) => { C.timeSeparator = e.target.value; P().push(false); }, + }))); + } kids.push(el('div', { class: 'studio-note dim-hint', - text: 'Her alan tek tek kapatılabilir: yalnızca parça adı, yalnızca süre ya da yalnızca çubuk gösterilebilir.' })); + text: fromSystem + ? 'Her alan tek tek kapatılabilir: yalnızca parça adı, yalnızca süre ya da yalnızca çubuk gösterilebilir.' + : 'Parça adı, sanatçı ve albüm tek tek kapatılabilir.' })); return kids; })); @@ -226,7 +273,7 @@ })); // ---------------------------------------------------------------- çubuk - if (C.show.bar !== false) { + if (fromSystem && C.show.bar !== false) { nodes.push(SP().foldable('İlerleme Çubuğu', () => { const style = NP() ? NP().styleOf(C.style) : { barHeight: 0.005, barSegments: 0, barBackOpacity: 0.22 }; return [ @@ -251,6 +298,20 @@ if (C.coverOverlay) { nodes.push(el('div', { class: 'studio-note dim-hint', text: 'Çalan parçanın albüm kapağını yazının yanına veya üstüne yerleştirir. Boyut ekranın kısa kenarına göredir. Kapak yoksa bindirme çizilmez. Varsayılan kapalıdır.' })); + if (isWin) { + nodes.push(SP().miniSelect('Kapak Kaynağı', [ + ['auto', 'Otomatik (Sistem)'], + ['manual', 'Elle Yükle'], + ], () => C.coverSource || 'auto', (v) => { C.coverSource = v; }, rerender)); + nodes.push(el('div', { class: 'studio-note dim-hint', + text: (C.coverSource || 'auto') === 'manual' + ? 'Elle yüklenen resim yazının yanında gösterilir.' + : 'Windows’ta kapak çalan parçadan otomatik gelir. İsterseniz kendi resminizi de yükleyebilirsiniz; otomatik kapak yoksa o resim kullanılır.' })); + } else { + nodes.push(el('div', { class: 'studio-note dim-hint', + text: 'Bu platformda albüm kapağı otomatik okunamaz. Gösterilecek resmi elle yükleyin.' })); + } + nodes.push(coverFileRow(el, P, C, rerender)); nodes.push(SP().foldable('Albüm Kapağı (Bindirme)', () => [ SP().miniSlider('Boyut', () => (C.coverSize == null ? 0.14 : C.coverSize), (v) => { C.coverSize = v; }, { min: 0.05, max: 0.5, step: 0.01, percent: true }), @@ -295,7 +356,7 @@ if (getMode() === 'custom') { kids.push(P().color('Parça Adı', 'nowplaying.color')); kids.push(P().color('İkincil Yazı', 'nowplaying.colorDim')); - kids.push(P().color('Çubuk', 'nowplaying.colorBar')); + if (fromSystem) kids.push(P().color('Çubuk', 'nowplaying.colorBar')); } else if (getMode() === 'theme') { kids.push(el('div', { class: 'studio-note dim-hint', text: 'Renkler sahne paletinden alınır; palet değişince yazı da değişir.' })); diff --git a/src/admin/scene-panels.js b/src/admin/scene-panels.js index 6338962c..3fdc3ef2 100644 --- a/src/admin/scene-panels.js +++ b/src/admin/scene-panels.js @@ -555,17 +555,21 @@ lg[k] = val; }; - out.push(miniSelect('Resim Kaynağı', [ - ['auto', 'Otomatik (Şarkı resmi varsa göster, yoksa özel)'], - ['manual', 'Özel Resim (Yalnızca seçilen dosya)'], - ['track', 'Sadece Çalan Şarkı Resmi'], - ], () => lg.source || 'auto', (v) => { - lg.source = v; - P().push(true); - rerender(); - }, undefined, 'auto')); + const logoWin = isWindowsPlatform(); + if (!logoWin) lg.source = 'manual'; + if (logoWin) { + out.push(miniSelect('Resim Kaynağı', [ + ['auto', 'Otomatik (Şarkı resmi varsa göster, yoksa özel)'], + ['manual', 'Özel Resim (Yalnızca seçilen dosya)'], + ['track', 'Sadece Çalan Şarkı Resmi'], + ], () => lg.source || 'auto', (v) => { + lg.source = v; + P().push(true); + rerender(); + }, undefined, 'auto')); + } - const mode = lg.source || 'auto'; + const mode = logoWin ? (lg.source || 'auto') : 'manual'; if (mode === 'track') { const live = (window.SVNowLive && window.SVNowLive.state && window.SVNowLive.state.has) ? window.SVNowLive.state : null; out.push(el('div', { @@ -999,7 +1003,9 @@ }; out.push(miniColor('Parça Adı', () => getNp('color', '#ffffff'), (v) => setNp('color', v))); out.push(miniColor('İkincil Yazı', () => getNp('colorDim', '#c8c8d0'), (v) => setNp('colorDim', v))); - out.push(miniColor('Çubuk', () => getNp('colorBar', '#3aa6ff'), (v) => setNp('colorBar', v))); + if (isWindowsPlatform() && ((cfg.nowplaying && cfg.nowplaying.source) || 'system') === 'system') { + out.push(miniColor('Çubuk', () => getNp('colorBar', '#3aa6ff'), (v) => setNp('colorBar', v))); + } } else if (l.type === 'text') { /* text layers use settings.text; color controls live in the text block above when type===text. This branch is for non-text types only — text returns earlier. */ @@ -1029,8 +1035,46 @@ setCover('coverOverlay', !!v); }, rerender)); if (getCover('coverOverlay', false)) { + const coverWin = isWindowsPlatform(); + if (!coverWin) setCover('coverSource', 'manual'); out.push(el('div', { class: 'studio-note dim-hint', text: 'Çalan parçanın albüm kapağını yazının yanına veya üstüne yerleştirir. Boyut ekranın kısa kenarına göredir. Kapak yoksa bindirme çizilmez. Varsayılan kapalıdır.' })); + if (coverWin) { + out.push(miniSelect('Kapak Kaynağı', [ + ['auto', 'Otomatik (Sistem)'], + ['manual', 'Elle Yükle'], + ], () => getCover('coverSource', 'auto'), (v) => setCover('coverSource', v), rerender)); + out.push(el('div', { class: 'studio-note dim-hint', + text: (getCover('coverSource', 'auto') === 'manual') + ? 'Elle yüklenen resim yazının yanında gösterilir.' + : 'Windows’ta kapak çalan parçadan otomatik gelir. İsterseniz kendi resminizi de yükleyebilirsiniz; otomatik kapak yoksa o resim kullanılır.' })); + } else { + out.push(el('div', { class: 'studio-note dim-hint', + text: 'Bu platformda albüm kapağı otomatik okunamaz. Gösterilecek resmi elle yükleyin.' })); + } + const artNow = () => { + const m = npCover.manual || (cfg.nowplaying && cfg.nowplaying.manual) || {}; + return m.artwork || ''; + }; + const setArt = (url) => { + npCover.manual = Object.assign({}, npCover.manual, { artwork: url || '' }); + cfg.nowplaying = cfg.nowplaying || {}; + cfg.nowplaying.manual = Object.assign({}, cfg.nowplaying.manual, { artwork: url || '' }); + }; + if (artNow()) out.push(el('img', { class: 'layer-preview', src: artNow(), alt: 'Kapak Görseli' })); + out.push(el('div', { class: 'row' }, [ + el('button', { + class: 'btn small', type: 'button', icon: 'image', + text: artNow() ? 'Kapağı Değiştir' : 'Kapak Seç', + onclick: () => { + pickImage((dataUrl) => { setArt(dataUrl); P().push(true); rerender(); }); + }, + }), + artNow() ? el('button', { + class: 'btn ghost small danger', type: 'button', text: 'Kapağı Kaldır', + onclick: () => { setArt(''); P().push(true); rerender(); }, + }) : null, + ].filter(Boolean))); out.push(miniSlider('Kapak Boyutu', () => getCover('coverSize', 0.14), (v) => setCover('coverSize', v), { min: 0.05, max: 0.5, step: 0.01, percent: true, def: 0.14})); out.push(miniSlider('Yazı Aralığı', () => getCover('coverGap', 0.35), (v) => setCover('coverGap', v), diff --git a/src/main/main.js b/src/main/main.js index 42b37842..3043a6eb 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -5458,50 +5458,99 @@ async function runSmoke() { if (!av.ok) { console.log('[SMOKE] Spout/Syphon: bu platformda yok (' + av.reason + ')'); } else { + /* Önceki adımlar katman yığınını kapatıp görselleştiriciyi 'none' + bırakabiliyor. Chromium, pikseller değişmezse ikinci bir paint + üretmez; o zaman gönderici kaydı oluşur ama sayaç 1'de kalır ve + alıcı donmuş bir kare görür. Ölçüm kendi sahnesini kullanır: + tuval her karede değişen 2D dalga, sessizlikte duraklama kapalı. + Ekran pencereleri de kapanır — içlerindeki WebGL bağlamları yeni + offscreen pencerenin karelerini GPU'da bekletebiliyor. Bitince + aynı ekranlar geri açılır. */ + const heldIds = Array.from(visualizerWins.keys()); + if (heldIds.length) { + closeVisualizer(); + for (let i = 0; i < 20 && anyVisualizerOpen(); i++) await wait(100); + } + const prevConfig = currentConfig; + currentConfig = JSON.parse(JSON.stringify(prevConfig || {})); currentConfig.textureShare = Object.assign({}, currentConfig.textureShare, { enabled: true, name: 'CAYADEV Smoke', width: 640, height: 360, fps: 30, }); - await syncTextureShare(); - - /* Sabit bekleme yerine YOKLAMA. Spout'un gönderici kaydı paylaşılan bir - defterde oluşuyor ve bunun ne kadar süreceği makineye göre değişiyor; - 3 saniyelik sabit bekleme koşuların bir kısmında yetmiyor ve öz test - ürün sağlamken "sender is not registered" diye düşüyordu. Ölçüldü: - aynı derlemede arka arkaya FAIL, PASS, PASS. - - Denetim zayıflamıyor — kayıt hiç oluşmazsa süre dolduğunda yine - düşüyor; yalnızca zamanlamaya duyarlılığı kalkıyor. */ - let senders = []; + currentConfig.power = Object.assign({}, currentConfig.power, { pauseOnSilence: false, fpsCap: 30 }); + currentConfig.layerStack = Object.assign({}, currentConfig.layerStack, { enabled: false }); + currentConfig.layers = []; + currentConfig.visualizer = Object.assign({}, currentConfig.visualizer, { type: 'none' }); + currentConfig.background = { type: 'waves' }; + if (currentConfig.mapping) currentConfig.mapping.enabled = false; + if (currentConfig.aspect) currentConfig.aspect.enabled = false; + let ts; let mine = false; - for (let k = 0; k < 20 && !mine; k++) { - await wait(300); - senders = textureShare.listSenders().map((x) => x.name); - mine = senders.indexOf('CAYADEV Smoke') >= 0; - } - /* Kayıt göründükten SONRA sabit bir ölçüm penceresi. Kayıt var ama kare - akmıyorsa alıcı donmuş bir görüntü alır ve her şey yolunda sanır; - ölçülen şey o. Pencere sabit tutuluyor ki eşik anlamını korusun — - yoklama süresi makineye göre değiştiği için ona yaslanamaz. */ - const MEASURE_MS = 2000; - await wait(MEASURE_MS); - const ts = textureShare.status(); - console.log('[SMOKE] Spout/Syphon: ' + JSON.stringify({ - protokol: ts.protocol, kare: ts.frames, düşen: ts.dropped, - boyut: ts.width + 'x' + ts.height, kayıt: mine, hata: ts.error, - })); - if (!ts.running) errors.push('texture-share: did not start'); - if (!mine) errors.push('texture-share: the sender is not registered with ' + ts.protocol); - /* 30 fps ayarında 2 saniye ~60 kare demek; eşik 20'de üç kat pay var. */ - if (!(ts.frames > 20)) { - errors.push('texture-share: only ' + ts.frames + ' frames in ' - + (MEASURE_MS / 1000) + 's — the receiver would see a frozen image'); + try { + await syncTextureShare(); + + /* Sabit bekleme yerine YOKLAMA. Spout'un gönderici kaydı paylaşılan bir + defterde oluşuyor ve bunun ne kadar süreceği makineye göre değişiyor; + 3 saniyelik sabit bekleme koşuların bir kısmında yetmiyor ve öz test + ürün sağlamken "sender is not registered" diye düşüyordu. Ölçüldü: + aynı derlemede arka arkaya FAIL, PASS, PASS. + + Denetim zayıflamıyor — kayıt hiç oluşmazsa süre dolduğunda yine + düşüyor; yalnızca zamanlamaya duyarlılığı kalkıyor. */ + for (let k = 0; k < 20 && !mine; k++) { + await wait(300); + mine = textureShare.listSenders().map((x) => x.name).indexOf('CAYADEV Smoke') >= 0; + } + /* Kayıt, ilk paint'te oluşuyor. O kareden sonra akış gecikirse sayaç + hâlâ 1'dir. Akış başlayana kadar kısa yoklama, sonra taze 2 saniye: + eşik bu pencerenin kendisine ait, yoklama süresine değil. */ + const born = textureShare.status().frames; + let moved = born > 2; + for (let k = 0; k < 16 && !moved; k++) { + await wait(500); + moved = textureShare.status().frames > born + 2; + } + const before = textureShare.status().frames; + const MEASURE_MS = 2000; + await wait(MEASURE_MS); + ts = textureShare.status(); + const delta = ts.frames - before; + console.log('[SMOKE] Spout/Syphon: ' + JSON.stringify({ + protokol: ts.protocol, kare: delta, toplam: ts.frames, düşen: ts.dropped, + boyut: ts.width + 'x' + ts.height, kayıt: mine, hata: ts.error, + })); + if (!ts.running) errors.push('texture-share: did not start'); + if (!mine) errors.push('texture-share: the sender is not registered with ' + ts.protocol); + /* 30 fps ayarında 2 saniye ~60 kare demek; eşik 20'de üç kat pay var. */ + if (!(delta > 20)) { + let page = ''; + const tw = textureShare.window(); + if (tw && !tw.isDestroyed()) { + try { + page = await tw.webContents.executeJavaScript( + '(function(){var e=document.getElementById("error");' + + 'return JSON.stringify({err:e?e.textContent:"",' + + 'canvases:document.querySelectorAll("#stage canvas").length,' + + 'hidden:document.hidden,iw:innerWidth,ih:innerHeight});})()' + ); + } catch (e) { page = String((e && e.message) || e); } + } + errors.push('texture-share: only ' + delta + ' frames in ' + + (MEASURE_MS / 1000) + 's — the receiver would see a frozen image' + + (page ? ' [' + page + ']' : '')); + } + if (ts.dropped > ts.frames / 10) errors.push('texture-share: dropped ' + ts.dropped + ' of ' + (ts.frames + ts.dropped) + ' frames'); + } finally { + const stopped = await textureShare.stop(); + const after = textureShare.listSenders().map((x) => x.name); + if (after.indexOf('CAYADEV Smoke') >= 0) errors.push('texture-share: the sender stayed registered after stop'); + if (stopped.running) errors.push('texture-share: still running after stop'); + currentConfig = prevConfig; + if (currentConfig && currentConfig.textureShare) currentConfig.textureShare.enabled = false; + if (heldIds.length) { + openVisualizer(heldIds); + await wait(1800); + } } - if (ts.dropped > ts.frames / 10) errors.push('texture-share: dropped ' + ts.dropped + ' of ' + (ts.frames + ts.dropped) + ' frames'); - const stopped = await textureShare.stop(); - const after = textureShare.listSenders().map((x) => x.name); - if (after.indexOf('CAYADEV Smoke') >= 0) errors.push('texture-share: the sender stayed registered after stop'); - if (stopped.running) errors.push('texture-share: still running after stop'); - currentConfig.textureShare.enabled = false; } } diff --git a/src/main/preload-visualizer.js b/src/main/preload-visualizer.js index 376478d7..ddf9b7f8 100644 --- a/src/main/preload-visualizer.js +++ b/src/main/preload-visualizer.js @@ -2,6 +2,13 @@ const { contextBridge, ipcRenderer } = require('electron'); +contextBridge.exposeInMainWorld('SV_PLATFORM', { + os: process.platform, + isWindows: process.platform === 'win32', + isMac: process.platform === 'darwin', + isLinux: process.platform === 'linux', +}); + // Bu pencerenin ekran kimliği (ana süreç komut satırında veriyor). // Projeksiyon haritalaması ekran başına tanımlandığı için gerekli. const displayArg = process.argv.find((a) => a.startsWith('--sv-display-id=')); diff --git a/src/main/texture-share.js b/src/main/texture-share.js index a9e6b5ef..8a03a50b 100644 --- a/src/main/texture-share.js +++ b/src/main/texture-share.js @@ -174,6 +174,7 @@ function start(cfg, hooks) { } } catch {} try { + win.webContents.setBackgroundThrottling(false); win.webContents.setFrameRate(Math.max(1, Math.min(60, Number(c.fps) || 60))); if (typeof win.webContents.startPainting === 'function' && (!win.webContents.isPainting || !win.webContents.isPainting())) { win.webContents.startPainting(); @@ -213,6 +214,7 @@ function start(cfg, hooks) { destroySender(); state = Object.assign({}, state, { running: false }); }); + try { win.webContents.setBackgroundThrottling(false); } catch {} win.webContents.setFrameRate(Math.max(1, Math.min(60, Number(c.fps) || 60))); if (typeof win.webContents.startPainting === 'function') { try { win.webContents.startPainting(); } catch {} @@ -243,10 +245,15 @@ function stop() { try { if (typeof win.webContents.stopPainting === 'function') win.webContents.stopPainting(); } catch {} - win.webContents.removeListener('paint', onPaint); - win.destroy(); + /* Dinleyici pencere yok olana kadar kalır. Erken sökülürse son paint'in + dokusu serbest bırakılmaz; Electron o dokuyu çöp toplayınca kare + havuzu tıkanır ve bir sonraki yayın donar. */ + const dying = win; + win = null; + dying.destroy(); + } else { + win = null; } - win = null; state = Object.assign({}, state, { running: false, frames: 0, dropped: 0, error: null }); return Promise.resolve(snapshot()); } diff --git a/src/shared/defaults.js b/src/shared/defaults.js index e7b55bb4..092fdd20 100644 --- a/src/shared/defaults.js +++ b/src/shared/defaults.js @@ -564,6 +564,9 @@ showArtwork: true, // logo layer may show track artwork when NP/lyrics active /* NP overlay: album cover beside the text block. Default OFF. */ coverOverlay: false, + /* auto = Windows medya oturumu; manual = elle yüklenen resim. + macOS/Linux paneli bunu manual'a çeker. */ + coverSource: 'auto', coverSize: 0.14, // fraction of display short side (legacy >1 = text-block height) coverGap: 0.35, // gap between cover and text (fraction of cover) coverRadius: 0.14, // corner radius 0..0.5 of min(coverW,coverH) (0.5 = circle/oval) diff --git a/src/shared/i18n.js b/src/shared/i18n.js index e4f36859..23a5a08f 100644 --- a/src/shared/i18n.js +++ b/src/shared/i18n.js @@ -1576,6 +1576,8 @@ 'Süre Ayırıcı': 'Time Separator', 'Her alan tek tek kapatılabilir: yalnızca parça adı, yalnızca süre ya da yalnızca çubuk gösterilebilir.': 'Each field can be toggled individually: show only the track name, only the time, or only the progress bar.', + 'Parça adı, sanatçı ve albüm tek tek kapatılabilir.': + 'Track title, artist, and album can each be turned off.', 'Yazı ve Yerleşim': 'Typography & Layout', 'Kalıp': 'Preset Style', 'Büyük Harf': 'Uppercase', @@ -2976,6 +2978,24 @@ 'Sağda': 'Right', 'Çalan parçanın albüm kapağını yazının yanına yerleştirir. Kapak yoksa bindirme çizilmez. Varsayılan kapalıdır.': 'Places the playing track\'s album cover beside the text. If no cover is available, nothing is drawn. Off by default.', + 'Çalan parçanın albüm kapağını yazının yanına veya üstüne yerleştirir. Boyut ekranın kısa kenarına göredir. Kapak yoksa bindirme çizilmez. Varsayılan kapalıdır.': + 'Places the playing track\'s album cover beside or above the text. Size follows the short side of the screen. If no cover is available, nothing is drawn. Off by default.', + 'Kapak Kaynağı': 'Cover Source', + 'Otomatik (Sistem)': 'Automatic (System)', + 'Elle Yükle': 'Upload Image', + 'Kapak Seç': 'Choose Cover', + 'Kapağı Değiştir': 'Change Cover', + 'Kapağı Kaldır': 'Remove Cover', + 'Windows’ta kapak çalan parçadan otomatik gelir. İsterseniz kendi resminizi de yükleyebilirsiniz; otomatik kapak yoksa o resim kullanılır.': + 'On Windows the cover comes from the playing track. You can also upload your own image; it is used when the track has no artwork.', + 'Bu platformda albüm kapağı otomatik okunamaz. Gösterilecek resmi elle yükleyin.': + 'Album artwork cannot be read automatically on this platform. Upload the image you want to show.', + 'Elle yüklenen resim yazının yanında gösterilir.': + 'The image you upload is shown beside the text.', + 'Elle yazılan parça adı ve sanatçı ekrana gelir. Albüm kapağı elle yüklenir. Süre ve ilerleme çubuğu sistemden okunduğu için bu platformda yoktur.': + 'The title and artist you type are shown. Album artwork is uploaded by hand. Elapsed time and the progress bar are absent here because they are read from the system.', + 'WebGL2 yok. Görüntü kartı sürücüsünü güncelleyin. Sürücü WebGL2 vermezse MilkDrop bu ekranda çalışmaz.': + 'WebGL2 is missing. Update your graphics driver. If the driver does not provide WebGL2, MilkDrop cannot run on this display.', 'Rastgele Armoni': 'Random Harmony', 'Ruh Hali': 'Mood', diff --git a/src/visualizer/layers.js b/src/visualizer/layers.js index 40104c4f..45d106ad 100644 --- a/src/visualizer/layers.js +++ b/src/visualizer/layers.js @@ -610,7 +610,10 @@ function resolveLogoSrc(lg, cfg) { if (!lg) return null; const mode = lg.source || 'auto'; - if (mode === 'manual') return logoFileSrc(lg); + /* macOS/Linux albüm kapağını okuyamaz. auto ve track orada özel resmi + göstermeli; aksi halde çalan parça kapağı logonun yerine geçer. */ + const winOk = !(typeof window !== 'undefined' && window.SV_PLATFORM && window.SV_PLATFORM.isWindows === false); + if (mode === 'manual' || !winOk) return logoFileSrc(lg); const live = (typeof window !== 'undefined' && window.SVNowLive && window.SVNowLive.state && window.SVNowLive.state.has) ? window.SVNowLive.state : null; diff --git a/src/visualizer/modes/milkdrop.js b/src/visualizer/modes/milkdrop.js index 22b0d4fc..a5c31193 100644 --- a/src/visualizer/modes/milkdrop.js +++ b/src/visualizer/modes/milkdrop.js @@ -1178,7 +1178,7 @@ void main(){ alpha: false, antialias: false, preserveDrawingBuffer: true, }); if (!gl) { - this.error = 'WebGL2 yok'; + this.error = 'WebGL2 yok. Görüntü kartı sürücüsünü güncelleyin. Sürücü WebGL2 vermezse MilkDrop bu ekranda çalışmaz.'; this._noCtxAt = performance.now(); return false; } @@ -5900,7 +5900,23 @@ void main(){ c.fillStyle = 'rgba(255,255,255,0.35)'; c.font = Math.round(Math.min(W, H) * 0.03) + 'px system-ui, sans-serif'; c.textAlign = 'center'; - c.fillText(this.error || 'MilkDrop motoru başlatılamadı', W / 2, H / 2); + const rawMsg = this.error || 'MilkDrop motoru başlatılamadı'; + const msg = (window.SVI18n && window.SVI18n.t) ? window.SVI18n.t(rawMsg) : rawMsg; + const maxW = Math.max(40, W * 0.86); + const words = String(msg).split(/\s+/); + const lines = []; + let line = ''; + words.forEach((w) => { + const next = line ? (line + ' ' + w) : w; + if (line && c.measureText(next).width > maxW) { + lines.push(line); + line = w; + } else line = next; + }); + if (line) lines.push(line); + const lh = Math.round(Math.min(W, H) * 0.042); + let y = H / 2 - ((lines.length - 1) * lh) / 2; + lines.forEach((ln) => { c.fillText(ln, W / 2, y); y += lh; }); } /* `monitor` — presetin kendi hata ayıklama probu. diff --git a/src/visualizer/modes/nowplaying.js b/src/visualizer/modes/nowplaying.js index efce173f..1bd6e838 100644 --- a/src/visualizer/modes/nowplaying.js +++ b/src/visualizer/modes/nowplaying.js @@ -107,7 +107,15 @@ const style = N.styleOf(c.style); const pick = (v, k) => (v === null || v === undefined ? style[k] : v); + /* Süre, çubuk ve oynatıcı adı yalnız Windows medya oturumundan gelir. + Elle yazılan parçada, ya da macOS/Linux'ta, 0:00 göstermek yanlış bilgi olur. */ + const platWin = !(typeof window !== 'undefined' && window.SV_PLATFORM && window.SV_PLATFORM.isWindows === false); + const fromSystem = platWin && (c.source || 'system') === 'system'; + const show = fromSystem ? c.show : Object.assign({}, c.show, { + appName: false, elapsed: false, remaining: false, total: false, bar: false, + }); const parts = N.compose(st, Object.assign({}, c, { + show, uppercase: pick(c.uppercase, 'uppercase'), })); if (!parts.hasText && !parts.hasTime && !parts.showBar) return; @@ -212,9 +220,11 @@ // the display short side (minDim), like logo scale; values > 1 keep the // legacy "relative to text-block height" meaning. const wantCover = !!c.coverOverlay; - const artUrl = wantCover - ? ((raw && raw.artwork) || (c.manual && c.manual.artwork) || '') - : ''; + /* auto: Windows oturumundaki kapak, yoksa elle yüklenen. + manual: yalnız elle yüklenen. Sistem kaynağı kapalıysa otomatik kapak yok. */ + const uploaded = (c.manual && c.manual.artwork) || ''; + const useSystemArt = fromSystem && (c.coverSource || 'auto') !== 'manual'; + const artUrl = !wantCover ? '' : (useSystemArt ? ((raw && raw.artwork) || uploaded) : uploaded); const coverImg = wantCover ? this._coverImage(artUrl) : null; const coverReady = !!(coverImg && coverImg.complete && coverImg.naturalWidth > 0); const coverSizeVal = c.coverSize == null ? 0.14 : c.coverSize; diff --git a/tests/layers.test.js b/tests/layers.test.js index e1db5f0b..e0f56a75 100644 --- a/tests/layers.test.js +++ b/tests/layers.test.js @@ -351,6 +351,9 @@ test('grupsuz katman grup ayarlarından etkilenmez', () => { assert.strictEqual(row.ctrl.children[0].attrs.disabled, true); // 5) Logo ve Şarkı Kapağı / Logo Source testleri (auto, manual, track) + // Yukarıdaki panel denemesi platformu macOS yapmıştı. Kapak çözümlemesi + // Windows'ta şarkı resmini, diğerlerinde yalnız özel dosyayı kullanır. + global.window.SV_PLATFORM = { isWindows: true, isMac: false, isLinux: false }; const defaults = window.SV ? window.SV.defaultConfig() : require('../src/shared/defaults.js'); assert.strictEqual(defaults.logo.source, 'auto', 'Varsayılan logo kaynağı auto olmalı'); assert.strictEqual(defaults.text.showArtwork, true, 'Varsayılan metin kapak gösterme açık olmalı'); @@ -378,6 +381,14 @@ test('grupsuz katman grup ayarlarından etkilenmez', () => { global.window.SVNowLive = { state: null }; assert.strictEqual(L.resolveLogoSrc({ source: 'track', src: 'manual.png' }, autoCfgWithText), null); + // e2) macOS/Linux: auto ve track özel resme iner, çalan kapak logoyu değiştirmez + global.window.SV_PLATFORM = { isWindows: false, isMac: true, isLinux: false }; + global.window.SVNowLive = { state: { has: true, artwork: 'data:image/png;base64,ARTWORK123' } }; + assert.strictEqual(L.resolveLogoSrc({ source: 'auto', src: 'fallback.png' }, autoCfgWithText), 'fallback.png'); + assert.strictEqual(L.resolveLogoSrc({ source: 'track', src: 'manual.png' }, autoCfgWithText), 'manual.png'); + global.window.SV_PLATFORM = { isWindows: true, isMac: false, isLinux: false }; + global.window.SVNowLive = { state: null }; + // f) katmanlar (layers) dizisinde metin katmanı kapak bilgisi const cfgWithLayerArt = { layers: [ diff --git a/tests/nowplaying-cover-platform.test.js b/tests/nowplaying-cover-platform.test.js index b21de569..a97cd871 100644 --- a/tests/nowplaying-cover-platform.test.js +++ b/tests/nowplaying-cover-platform.test.js @@ -111,3 +111,48 @@ test('kaynak: natural fit uses aspect coverW/coverH not forced square', () => { assert.match(npSrc, /coverW = coverPx; coverH = coverPx \* \(ih \/ iw\)/); assert.match(npSrc, /fit:\s*drawFit/); }); + +test('defaults: coverSource auto, macOS/Linux paneli manuala ceker', () => { + assert.match(defaultsSrc, /coverSource:\s*'auto'/); + assert.match(panelSrc, /if \(!isWin\) C\.coverSource = 'manual'/); + assert.match(sceneSrc, /if \(!coverWin\) setCover\('coverSource', 'manual'\)/); +}); + +test('UI: sisteme ozgu NP alanlari yalniz fromSystem icinde', () => { + assert.match(panelSrc, /if \(fromSystem\) \{[\s\S]{0,500}appName/); + assert.match(panelSrc, /if \(fromSystem\) \{[\s\S]{0,700}elapsed/); + assert.match(panelSrc, /if \(fromSystem && C\.show\.bar !== false\)/); + assert.match(panelSrc, /if \(fromSystem\) kids\.push\(P\(\)\.color\('Çubuk'/); + assert.doesNotMatch(panelSrc, /Sistemden okuma \(SMTC\)/); +}); + +test('UI: logo kaynagi ve kapak dosyasi platforma gore', () => { + assert.match(adminSrc, /path:\s*'logo\.source'[\s\S]{0,450}isWindows\(\)/); + assert.match(adminSrc, /case 'npcoverfile'/); + assert.match(adminSrc, /path:\s*'nowplaying\.coverSource'/); + assert.match(adminSrc, /Elle yazılan parça adı ve sanatçı ekrana gelir/); + assert.match(sceneSrc, /if \(!logoWin\) lg\.source = 'manual'/); + assert.match(sceneSrc, /if \(logoWin\)/); +}); + +test('kaynak: NP ve logo macOS/Linux otomatik kapagi kullanmaz', () => { + assert.match(npSrc, /SV_PLATFORM\.isWindows === false/); + assert.match(npSrc, /const fromSystem = platWin && \(c\.source \|\| 'system'\) === 'system'/); + assert.match(npSrc, /useSystemArt = fromSystem && \(c\.coverSource \|\| 'auto'\) !== 'manual'/); + const layersSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'visualizer', 'layers.js'), 'utf8'); + assert.match(layersSrc, /isWindows === false/); + assert.match(layersSrc, /if \(mode === 'manual' \|\| !winOk\) return logoFileSrc\(lg\)/); +}); + +test('i18n: kapak aciklamasi ve WebGL2 uyarisi Ingilizce', () => { + const i18n = fs.readFileSync(path.join(__dirname, '..', 'src', 'shared', 'i18n.js'), 'utf8'); + const milk = fs.readFileSync(path.join(__dirname, '..', 'src', 'visualizer', 'modes', 'milkdrop.js'), 'utf8'); + const trKey = 'WebGL2 yok. Görüntü kartı sürücüsünü güncelleyin. Sürücü WebGL2 vermezse MilkDrop bu ekranda çalışmaz.'; + assert.ok(i18n.includes(trKey)); + assert.match(i18n, /WebGL2 is missing\. Update your graphics driver/); + assert.ok(milk.includes(trKey)); + assert.match(milk, /SVI18n\.t\(rawMsg\)/); + assert.ok(i18n.includes('Album artwork cannot be read automatically on this platform')); + assert.ok(i18n.includes('beside or above the text')); + assert.ok(i18n.includes('WebGL2 is missing. Update your graphics driver.')); +});