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.')); +});