From fd03f7261259309a589299c3266f7bc3bfb9fa6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87A=C4=9EAN=20TURGUT?= Date: Tue, 22 Sep 2026 17:14:27 +0300 Subject: [PATCH 1/2] feat(milkdrop): favourites, tags and search by author MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search matched the name only. Presets can now be starred and tagged, searched by author and tag, and auto advance can be limited to favourites or one tag. Favourites and free-form tags are the user's, not the preset's: they live in the settings next to the ratings (#569), keyed by preset id (milkdropLibrary.favorites / .tags), outside every scene. A pack cannot carry an id map because an import gives every preset a new id, so each preset in a pack carries its own `library` record (favourite, tags, rating) and the import folds it onto the new id. The record never reaches the preset file, and only presets that were really saved get one. The MilkDrop panel (Pack What Is Shown, Import Pack) and the Studio's pack export both do this; built-ins stay out of packs. MilkDrop files have no author field and names are mostly "Author - Title", so the author is derived from the name at search time and nothing on disk needs migrating. Search words match the name (also a built-in's translated name), the authors and the tags; `#tag`/`tag:` and `author:`/`yazar:` narrow the field; comparison ignores case and the Turkish I/ı, İ/i difference. Show and Author selectors narrow the list without sending config. The pool (milkdrop.autoFrom / autoTag, saved with the scene) filters where the cycle's list is built, so the timer, hard cuts, track changes and the look-ahead compile all see it; an empty or one-preset pool is reported through the cycle's own reasons and in the panel. A follower does not filter, since the leader's pick came from the pool. Manual prev/next, random and list picks still reach every preset. Also: a Favourite button and MIDI/OSC action for the preset on screen, deleting a preset clears its rating, favourite and tags, and a search or filter hidden once six or fewer presets remain no longer keeps narrowing the list. The library module loads on every page that runs the engine, the exporter and web overlay included. Refs #576, #560 --- README.md | 15 +- README.tr.md | 15 +- ROADMAP.md | 52 ++- src/admin/control.js | 2 + src/admin/index.html | 1 + src/admin/milkdrop-panel.js | 288 ++++++++++++++++- src/admin/studio.css | 44 +++ src/admin/studio.js | Bin 29587 -> 30268 bytes src/exporter/index.html | 1 + src/shared/defaults.js | 15 + src/shared/i18n.js | 34 ++ src/shared/milkdrop-library.js | 295 +++++++++++++++++ src/shared/presets.js | 26 +- src/visualizer/index.html | 1 + src/visualizer/modes/milkdrop.js | 15 +- src/web/overlay.html | 1 + tests/milkdrop-control.test.js | 4 +- tests/milkdrop-library.test.js | 530 +++++++++++++++++++++++++++++++ 18 files changed, 1317 insertions(+), 22 deletions(-) create mode 100644 src/shared/milkdrop-library.js create mode 100644 tests/milkdrop-library.test.js diff --git a/README.md b/README.md index f3b4d09..092a02c 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-2003%20passing-2ea043.svg)](#tests) +[![Tests](https://img.shields.io/badge/tests-2029%20passing-2ea043.svg)](#tests) [![cayadev.com](https://img.shields.io/badge/cayadev.com-e11d2a.svg)](https://cayadev.com) @@ -328,6 +328,17 @@ that asserts the bar profile has no step in it. with the timer off, the lock stops it too, and only the leading screen picks, so every screen changes together. The track already playing when the app opens does not count, nor does the gap between two tracks. +- **Favourites, tags and search by author.** Star presets in the MilkDrop panel's list, or the one + on screen with *Favourite* (also from a MIDI or OSC mapping), and give them tags of your own. + Search matches the name, the author and the tags. MilkDrop names are mostly "Author - Title", so + `author:geiss` (or `yazar:`) finds Geiss's presets but not a title that mentions him, and `#calm` + finds a tag. *Show* narrows the list to favourites or one tag, *Author* to one author. *Pool* + limits auto advance — the timer, hard cuts and track changes — to favourites or one tag, while ◀, + ▶, Random and the list still reach every preset. Favourites and tags live in the settings next to + your ratings, not in the preset files, and a pack carries all three: *Pack What Is Shown* writes + the presets in view with them, and importing the pack attaches them to the new copies. Checked in + an isolated copy: with the pool set to three favourites, the visualizer window went round those + three and nothing else. - **Sprites from `milk_img.ini`.** MilkDrop 2 draws images of your own over the picture during a show: each one is defined in `milk_img.ini` with an image, code that runs once and code that runs every frame, and launched by number. Choose the file in the MilkDrop panel and launch from @@ -1227,7 +1238,7 @@ npm test npm start -- --smoke ``` -**2003 unit tests, all passing.** They are written to check answers, not to exercise lines: +**2029 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 efecfef..3f33bab 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-2003%20geçiyor-2ea043.svg)](#testler) +[![Test](https://img.shields.io/badge/test-2029%20geçiyor-2ea043.svg)](#testler) [![cayadev.com](https://img.shields.io/badge/cayadev.com-e11d2a.svg)](https://cayadev.com) @@ -321,6 +321,17 @@ profilinde basamak olmadığını doğruluyor. dahil. Zamanlayıcı kapalıyken de çalışıyor, kilit bunu da durduruyor ve seçimi yalnız lider ekran yapıyor, yani bütün ekranlar birlikte değişiyor. Uygulama açılırken zaten çalan parça sayılmıyor, iki parça arasındaki boşluk da. +- **Favoriler, etiketler ve yazara göre arama.** MilkDrop panelinin listesinde presetleri yıldızla + işaretleyin, ekrandakini *Favori* ile (bir MIDI ya da OSC eşlemesinden de) ve onlara kendi + etiketlerinizi verin. Arama adda, yazarda ve etiketlerde. MilkDrop adları çoğunlukla "Yazar - + Başlık"; `yazar:geiss` (ya da `author:`) Geiss'in presetlerini buluyor, adını anan bir başlığı + değil, `#sakin` bir etiketi buluyor. *Süz* listeyi favorilere ya da bir etikete, *Yazar* bir + yazara daraltıyor. *Havuz* otomatik geçişi — zamanlayıcı, sert geçiş ve parça değişimi — + favorilerle ya da bir etiketle sınırlıyor; ◀, ▶, Rastgele ve liste yine bütün presetlere gidiyor. + Favori ve etiketler puanlarınızın yanında, ayarlarda duruyor, preset dosyalarında değil; paket + üçünü de taşıyor: *Görünenleri Paketle* listede görünen presetleri onlarla birlikte yazıyor, paketi + içe aktarmak onları yeni kopyalara bağlıyor. Yalıtılmış bir kopyada denetlendi: havuz üç favoriye + ayarlıyken görselleştirici penceresi o üçünde döndü, başka hiçbir presete geçmedi. - **`milk_img.ini`'den sprite'lar.** MilkDrop 2 gösteri sırasında kendi resimlerinizi görüntünün üstüne çiziyor: her biri `milk_img.ini`de bir resim, bir kez çalışan ve her kare çalışan koduyla tanımlı ve numarasıyla başlatılıyor. Dosyayı MilkDrop panelinde seçin; panelin listesinden, bir @@ -1224,7 +1235,7 @@ npm test npm start -- --smoke ``` -**2003 birim testi, hepsi geçiyor.** Satır çalıştırmak için değil, cevap denetlemek için yazıldılar: +**2029 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 599331e..8aff202 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,9 +129,9 @@ npm test npm start -- --smoke ``` -- **2003 unit tests, all passing** on `main`. 703 of those shipped in v3.1.0; +- **2029 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 406 + that tag — 469 more with v3.1.4, most of them from the MilkDrop work, and 432 on `main` since. Formulas are checked against values derived by hand from their definitions — Viviani's curve staying on its sphere, the @@ -1565,6 +1565,54 @@ rest after. No version number yet. and `_ensurePreset` in a fake window with a fake `matchMedia`, draw the panel with a fake DOM, and follow *Always* from the export job to the layer the engine draws. 29 of 29 mutations are caught. +- **Favourites, tags and search by author (#576)** · done. Search matched + the name only. + - **Where they live.** Favourites and free-form tags are the user's, not + the preset's: in the settings next to the ratings (#569), keyed by + preset id (`milkdropLibrary.favorites`, `milkdropLibrary.tags`), outside + every scene. The preset files are untouched. + - **In a pack.** Ids differ on the other side — an import gives every + preset a new one — so a pack carries each preset's own `library` record + (favourite, tags, rating), and the import folds it onto the new id. The + record never reaches the preset file, and only presets that were really + saved get one. Both the MilkDrop panel (*Pack What Is Shown*, *Import + Pack*) and the Studio's pack export do this. Built-in presets stay out of + packs: every install has them. + - **Author.** MilkDrop files have no author field; names are mostly + "Author - Title" ("Flexi, martin + geiss - …"). The author comes from + the name at search time, so nothing on disk needs migrating. `+`, `&` + and commas separate authors; the first " - " ends them; a part without a + letter is no author. + - **Search.** Words match the name (also the translated name of a + built-in), the authors and the tags, and all must match. `#tag` or + `tag:`/`etiket:` search tags only, `author:`/`yazar:` authors only. + Comparison ignores case and the Turkish I/ı, İ/i difference. *Show* + narrows to favourites or one tag, *Author* to one author; these are + view settings and send no config. + - **Pool.** Auto advance — the timer, hard cuts, track changes and the + look-ahead compile (#573) — can pick from favourites or one tag only + (`milkdrop.autoFrom`/`autoTag`, saved with the scene). The filter sits + where the cycle's list is built; an empty or one-preset pool says so + through the cycle's own reasons (EMPTY, ALONE) and in the panel. A + follower does not filter: the leader's pick already came from the pool. + ◀, ▶, Random and the list reach every preset. + - **Also:** *Favourite* for the preset on screen, as a button and a MIDI/ + OSC action; deleting a preset clears its rating, favourite and tags; a + search or filter hidden because six or fewer presets remain no longer + keeps narrowing the list. + - **Measured** in an isolated copy with eight presets of our own. Stars in + the list and the on-screen *Favourite* wrote the settings; + `author:martin` found the three Martin presets and not a title naming + him; with the pool on three favourites the visualizer window went round + those three for nine seconds and nothing else. A pack of the eight + carried two records; imported through the real save path, the favourites + and tags landed on the new ids and in `settings.json`, and no preset + file held a `library` field. The file dialog itself was not driven: the + bridge object cannot be stubbed from the page. + - **Tests.** 26 tests cover authors, folding, tags, search, the pool, the + pack round trip, the engine's own `_autoCycle` with a pool (timer, hard + cut, track change, follower) and the panel drawn with a fake DOM. + 42 of 42 mutations are caught. ## v3.1.6 — Comprehensive video export diff --git a/src/admin/control.js b/src/admin/control.js index 251e794..425673b 100644 --- a/src/admin/control.js +++ b/src/admin/control.js @@ -76,6 +76,8 @@ { action: 'mdLock', label: '🔒 MilkDrop · Kilit (aç/kapa)' }, { action: 'mdRateUp', label: '⭐ MilkDrop · Puanı Artır' }, { action: 'mdRateDown', label: '⭐ MilkDrop · Puanı Azalt' }, + // Ekrandaki preset favorilere girer ya da çıkar (#576) + { action: 'mdFavorite', label: '★ MilkDrop · Favori (aç/kapa)' }, /* Sprite'lar (#577): başlatma hedefleri ini'deki her sprite için canlı ekleniyor (milkdrop-panel.js `spriteTargets`); silme üçü sabit. */ { action: 'mdSpriteNewest', label: '🖼 MilkDrop · Sprite: En Yeniyi Sil' }, diff --git a/src/admin/index.html b/src/admin/index.html index 8e9af84..163d6d5 100644 --- a/src/admin/index.html +++ b/src/admin/index.html @@ -325,6 +325,7 @@

Ayarlar

+ diff --git a/src/admin/milkdrop-panel.js b/src/admin/milkdrop-panel.js index ca4f1b2..08a73e9 100644 --- a/src/admin/milkdrop-panel.js +++ b/src/admin/milkdrop-panel.js @@ -17,6 +17,10 @@ let loading = false; let presets = []; let filter = ''; + /* Listenin süzgeci (#576): 'all' | 'fav' | 'tag:', ve yazar + (katlanmış anahtar, boşsa hepsi). Aramayla birleşiyor. */ + let showSel = 'all'; + let authorSel = ''; let busy = ''; let listScroll = 0; /* Doku klasöründe kaç görsel bulunduğu. null = henüz sorulmadı. @@ -74,10 +78,91 @@ }); } - function visible() { - const f = filter.trim().toLowerCase(); - if (!f) return presets; - return presets.filter((p) => (p.name || '').toLowerCase().includes(f)); + /* ARAMA VE SÜZGEÇ (#576). Arama adda, yazarda ve etiketlerde; `#etiket` + ve `yazar:` sözcükleri yalnız oralarda. Görünen ad da aranıyor: + yerleşiklerin adı İngilizce arayüzde çevrili. */ + function visible(cfg) { + const L = LB(); + if (!L) { + const f = filter.trim().toLowerCase(); + return f ? presets.filter((p) => (p.name || '').toLowerCase().includes(f)) : presets; + } + const tag = showSel.indexOf('tag:') === 0 ? showSel.slice(4) : ''; + return L.filter(presets, { + query: filter, show: tag ? 'tag' : showSel, tag, author: authorSel, nameOf: tr, + }, cfg && cfg.milkdropLibrary); + } + + /* Favori ve etiket yazımı: puanlarla aynı desen (`setRating`), yeni bir + eşlem kitaplığa atanıyor ve yapılandırma gönderiliyor. */ + function setFavorite(id, on) { + const L = LB(); + const cfg = P().cfg(); + if (!L || !id) return; + const lib = library(cfg); + lib.favorites = L.withFavorite(lib, id, on); + P().apply(); + } + + /* PAKET (#576). Dışa aktarılan: listede GÖRÜNEN kendi presetleri — süzgeç + "favoriler" ya da bir etiketse yalnız onlar — favori, etiket ve + puanlarıyla. Yerleşikler girmiyor: her kurulumda zaten varlar. + İçe aktarımda presetler yeni kimlik alıyor ve kayıtları o kimliğe + katlanıyor (shared/milkdrop-library.js `mergeEntries`). */ + async function exportPack(cfg) { + const IS = window.SVPresets; + const L = LB(); + if (!IS || !IS.makePack || !window.api || !window.api.exportJson) { + P().toast(tr('Dışa aktarma kullanılamıyor.'), 'err'); + return; + } + const list = visible(cfg).filter((p) => p && !p.builtin); + if (!list.length) { + P().toast(tr('Listede dışa aktarılacak kendi presetiniz yok.'), 'err'); + return; + } + const lib = cfg.milkdropLibrary; + const data = IS.makePack(list, { name: 'MilkDrop' }, L ? (p) => L.packEntry(lib, p.id) : null); + const r = await window.api.exportJson('milkdrop-presetler.svpack', data); + if (r && r.ok) P().toast(list.length + ' ' + tr('preset pakete yazıldı.'), 'ok'); + } + + async function importPack() { + const IS = window.SVPresets; + const L = LB(); + if (!IS || !IS.readImported || !window.api || !window.api.importShaderText || !window.api.savePresets) { + P().toast(tr('İçe aktarma kullanılamıyor.'), 'err'); + return; + } + const r = await window.api.importShaderText(); + if (!r || !r.ok) { + if (r && r.error === 'FILE_TOO_LARGE') P().toast(tr('Dosya çok büyük (2 MB üstü).'), 'err'); + return; + } + let data = null; + try { data = JSON.parse(r.text); } catch (e) { data = null; } + const read = data ? IS.readImported(data) : { ok: false }; + if (!read.ok) { + P().toast(tr('Paket okunamadı (.svpack ya da .svpreset bekleniyordu).'), 'err'); + return; + } + const res = await window.api.savePresets(read.presets); + const saved = (res && res.saved) || []; + if (L) L.adopt(library(P().cfg()), read.library, saved); + refresh(() => { + P().apply(); + P().toast(saved.length + ' ' + tr('preset içe aktarıldı.'), 'ok'); + }); + } + + function setTags(id, input) { + const L = LB(); + const cfg = P().cfg(); + if (!L || !id) return; + const lib = library(cfg); + const before = JSON.stringify(L.tagsOf(lib, id)); + lib.tags = L.withTags(lib, id, input); + if (JSON.stringify(L.tagsOf(lib, id)) !== before) P().apply(); } /* Katman yığını AÇIKKEN sahneyi cfg.visualizer.type belirlemiyor: @@ -139,8 +224,12 @@ function library(cfg) { const l = cfg.milkdropLibrary || (cfg.milkdropLibrary = {}); if (!l.ratings || typeof l.ratings !== 'object') l.ratings = {}; + if (!l.favorites || typeof l.favorites !== 'object') l.favorites = {}; + if (!l.tags || typeof l.tags !== 'object') l.tags = {}; return l; } + // Favoriler, etiketler, yazar, arama ve havuz (#576) + const LB = () => window.SVMilkdropLibrary; function control(cfg) { return cfg.milkdropControl || (cfg.milkdropControl = { locked: false, cutTo: '' }); } @@ -325,6 +414,12 @@ dosyada 5 yazan bir preset boş yıldızdan 1'e inseydi öyle olurdu. */ const r = C.ratingOf(p, ratingsOf(cfg)); setRating(id, name === 'RateUp' ? Math.min(5, Math.floor(r) + 1) : Math.max(0, Math.ceil(r) - 1)); + } else if (name === 'Favorite') { + // Ekrandaki preset favorilere girer ya da çıkar (#576) + const L = LB(); + const id = liveId(md); + if (!L || !byId(id)) return false; + setFavorite(id, !L.isFavorite(cfg.milkdropLibrary, id)); } else { return false; } @@ -386,6 +481,75 @@ const wrap = document.getElementById('mdStars'); const cfg = P() && P().cfg && P().cfg(); if (wrap && cfg && cfg.milkdrop) fillStars(wrap, cfg); + const fav = document.getElementById('mdFav'); + if (fav && cfg && cfg.milkdrop) fillFav(fav, cfg); + /* Etiket kutusu yazılırken tazelenmiyor: otomatik geçiş kullanıcının + yazdığını silip başka presetin etiketlerini koyardı. */ + const box = document.getElementById('mdTagsBox'); + const typing = box && document.activeElement && box.contains(document.activeElement); + if (box && cfg && cfg.milkdrop && !typing) fillTags(box, cfg); + } + + /* EKRANDAKİ PRESETİN FAVORİSİ VE ETİKETLERİ (#576). Yıldızlar gibi + ekrandakinin ve yerinde tazeleniyor; metinler burada çevriliyor, çünkü + tazeleme DOM çevirmeninden sonra geliyor. */ + function fillFav(btn, cfg) { + const L = LB(); + const id = liveId(cfg.milkdrop || {}); + const ok = !!(L && byId(id)); + const on = ok && L.isFavorite(cfg.milkdropLibrary, id); + btn.textContent = tr(on ? '★ Favori' : '☆ Favorilere Ekle'); + btn.className = 'btn ghost small md-fav-live' + (on ? ' on' : ''); + btn.setAttribute('aria-pressed', on ? 'true' : 'false'); + btn.disabled = !ok; + } + + function fillTags(box, cfg) { + const el = P().el; + const L = LB(); + const id = liveId(cfg.milkdrop || {}); + box.textContent = ''; + box.setAttribute('data-for', id); + if (!L || !byId(id)) { + box.appendChild(el('span', { class: 'dim-hint', text: '—' })); + return; + } + const lib = cfg.milkdropLibrary; + const mine = L.tagsOf(lib, id); + box.appendChild(el('input', { + class: 'p-in md-tags-in', type: 'text', value: mine.join(', '), + placeholder: tr('virgülle ayırın: sakin, dans'), + 'aria-label': tr('Etiketler'), + /* Yazılan etiket KUTUNUN presetine gidiyor: kutu yazılırken + tazelenmediği için (`syncStars`) bu, yazmaya başlanan preset — + otomatik geçiş o arada ekrandakini değiştirse de. */ + onchange: (e) => { setTags(id, e.target.value); }, + onkeydown: (e) => { if (e.key === 'Enter') e.target.blur(); }, + onblur: () => { + // Yazarken ekrandaki preset değiştiyse kutu şimdi ona dönüyor + setTimeout(() => { + const b = document.getElementById('mdTagsBox'); + const c = P().cfg(); + if (b && c && c.milkdrop && b.getAttribute('data-for') !== liveId(c.milkdrop)) fillTags(b, c); + }, 0); + }, + })); + /* Var olan etiketler tek tıkla eklenip çıkarılıyor: virgüllü kutuda + tarayıcının öneri listesi yalnız ilk etikete yarardı. */ + const known = L.tagCounts(presets, lib); + if (known.length) { + const wrap = el('span', { class: 'md-tagchips' }); + for (const t of known.slice(0, 40)) { + const on = mine.some((m) => L.fold(m) === t.key); + wrap.appendChild(el('button', { + class: 'md-tagchip' + (on ? ' on' : ''), type: 'button', text: '#' + t.name, + 'aria-pressed': on ? 'true' : 'false', + title: tr(on ? 'Bu presetten çıkar' : 'Bu presete ekle'), + onclick: () => setTags(id, on ? mine.filter((m) => L.fold(m) !== t.key) : mine.concat([t.name])), + })); + } + box.appendChild(wrap); + } } /* ÖLÇÜ DURUMU (#571) — görselleştiricinin söylediği; sayı ve metin ayrı @@ -461,6 +625,14 @@ const stars = el('span', { id: 'mdStars', class: 'md-stars' }); fillStars(stars, cfg); nodes.push(P().row('Puan', stars)); + /* FAVORİ VE ETİKETLER (#576) — ekrandaki presetin. Ayarlara, preset + kimliğine bağlı yazılıyor; preset dosyasına dokunulmuyor. */ + const favBtn = el('button', { id: 'mdFav', type: 'button', onclick: () => act('Favorite') }); + fillFav(favBtn, cfg); + nodes.push(P().row('Favori', favBtn)); + const tagsBox = el('span', { id: 'mdTagsBox', class: 'md-tagsbox' }); + fillTags(tagsBox, cfg); + nodes.push(P().row('Etiketler', tagsBox)); // Doğrulama: yüklü presetin derleme durumu if (md.source && window.SVMilkdrop) { @@ -853,12 +1025,25 @@ onclick: () => { load(cfg, null); rerender(); }, }), ])); + // Paket (#576): favori, etiket ve puanlar presetlerle birlikte + nodes.push(el('div', { class: 'row' }, [ + el('button', { + class: 'btn ghost', type: 'button', text: '📦 Görünenleri Paketle', + title: 'Listede görünen kendi presetlerinizi favori, etiket ve puanlarıyla tek dosyaya yazar', + onclick: () => exportPack(cfg), + }), + el('button', { + class: 'btn ghost', type: 'button', text: '📥 Paket İçe Aktar', + title: 'Bir .svpack paketini favori, etiket ve puanlarıyla ekler', + onclick: () => importPack(), + }), + ])); // Arama if (presets.length > 6) { nodes.push(P().row('Ara', el('input', { class: 'p-in md-search', type: 'search', value: filter, - placeholder: 'preset adı', + placeholder: 'ad, yazar ya da #etiket', /* Filtre paneli baştan çiziyor, yani bu girdi düğümü siliniyor ve yerine yenisi geliyor. Odak da onunla birlikte gidiyordu: kullanıcı her harften sonra kutuya yeniden tıklamak zorunda kalıyordu. @@ -876,6 +1061,38 @@ try { again.setSelectionRange(caret, caret); } catch (_) { /* desteklemeyen tarayıcı */ } }, }))); + /* SÜZGEÇ VE YAZAR (#576). Görünümün ayarı, sahnenin değil: seçmek + yapılandırma göndermiyor, yalnız paneli yeniden çiziyor. Sayılar + seçeneğin içinde, o yüzden metin burada çevriliyor. */ + const L = LB(); + if (L) { + const lib = library(cfg); + const viewSel = (pairs, value, onChange) => { + const sel = el('select', { class: 'sel' }); + for (const [v, label] of pairs) { + const o = el('option', { value: String(v), text: label }); + if (String(v) === String(value)) o.selected = true; + sel.appendChild(o); + } + sel.addEventListener('change', () => { onChange(sel.value); listScroll = 0; P().rerender(); }); + return sel; + }; + const tags = L.tagCounts(presets, lib); + if (showSel.indexOf('tag:') === 0 && !tags.some((t) => 'tag:' + t.key === showSel)) showSel = 'all'; + const favN = presets.reduce((n, p) => n + (L.isFavorite(lib, p.id) ? 1 : 0), 0); + nodes.push(P().row('Süz', viewSel([ + ['all', tr('Tümü') + ' (' + presets.length + ')'], + ['fav', tr('★ Favoriler') + ' (' + favN + ')'], + ].concat(tags.map((t) => ['tag:' + t.key, '#' + t.name + ' (' + t.count + ')'])), + showSel, (v) => { showSel = String(v); }))); + const authors = L.authorCounts(presets); + if (authorSel && !authors.some((a) => a.key === authorSel)) authorSel = ''; + if (authors.length > 1) { + nodes.push(P().row('Yazar', viewSel([['', tr('Tüm yazarlar')]] + .concat(authors.map((a) => [a.key, a.name + ' (' + a.count + ')'])), + authorSel, (v) => { authorSel = String(v); }))); + } + } } // Liste @@ -883,7 +1100,12 @@ class: 'md-list', onscroll: (e) => { listScroll = e.target.scrollTop; }, }); - const vis = visible(); + /* Arama ve süzgeç satırları az presette gizli; görünmeyen bir süzgeç + listeyi sessizce daraltmasın (silme sonrası 6'ya inince de). */ + if (presets.length <= 6) { filter = ''; showSel = 'all'; authorSel = ''; } + const vis = visible(cfg); + const LBn = LB(); + const libNow = library(cfg); if (!vis.length) { list.appendChild(el('div', { class: 'studio-note', @@ -896,7 +1118,20 @@ } vis.slice(0, 400).forEach((p) => { const active = md.presetId === p.id; + // Favori ve etiketler (#576): yıldız tek tıkla, etiketler adın yanında + const fav = !!(LBn && LBn.isFavorite(libNow, p.id)); + const tags = LBn ? LBn.tagsOf(libNow, p.id) : []; list.appendChild(el('div', { class: 'md-item' + (active ? ' active' : '') }, [ + LBn ? el('button', { + class: 'md-fav' + (fav ? ' on' : ''), type: 'button', text: fav ? '★' : '☆', + title: fav ? 'Favorilerden çıkar' : 'Favorilere ekle', + 'aria-pressed': fav ? 'true' : 'false', + onclick: () => { + const listEl = document.querySelector('.md-list'); + if (listEl) listScroll = listEl.scrollTop; + setFavorite(p.id, !fav); + }, + }) : null, el('button', { class: 'md-name', type: 'button', text: tr(p.name || p.id), onclick: () => { @@ -906,6 +1141,7 @@ rerender(); }, }), + tags.length ? el('span', { class: 'md-tagline', text: tags.map((t) => '#' + t).join(' ') }) : null, /* Yerleşiğin silme düğmesi YOK: dosyası olmadığı için `deletePreset` onu bulamaz, satır da bir sonraki tazelemede geri gelirdi — kullanıcıya çalışmayan bir düğme göstermiş olurduk. */ @@ -917,6 +1153,8 @@ if (!(await P().confirm('"' + (p.name || p.id) + '" silinsin mi?'))) return; if (window.api.deletePreset) await window.api.deletePreset(p.id); if (md.presetId === p.id) load(cfg, null); + // Puanı, favorisi ve etiketleri de gidiyor (#576): kimlik bir daha gelmiyor + if (LBn) Object.assign(library(cfg), LBn.forget(library(cfg), p.id)); refresh(() => rerender()); }, }), @@ -1009,6 +1247,44 @@ [0, 'Kapalı (eşit olasılık)'], ], md.useRatings === false ? 0 : 1, (v) => { md.useRatings = Number(v) === 1; }))); } + /* HAVUZ (#576). Otomatik geçiş — zamanlayıcı, sert geçiş, parça + değişimi — yalnız favorilerden ya da bir etiketten seçebiliyor. + Seçim sahneyle kaydediliyor (`milkdrop.autoFrom`/`autoTag`). Elle + ◀/▶/🎲 ve listeden seçim bütün presetlere gidiyor. */ + const LP = LB(); + if (LP) { + const lib = library(cfg); + const tags = LP.tagCounts(presets, lib); + const favN = presets.reduce((n, p) => n + (LP.isFavorite(lib, p.id) ? 1 : 0), 0); + const cur = LP.poolOf(md); + /* Seçilen etiket en sık yazılışıyla eşleşiyor; hiçbir presette + kalmadıysa seçim yine görünüyor (0 ile), sessizce "hepsi"ne dönmüyor. */ + const hit = cur.from === 'tag' ? tags.find((t) => t.key === LP.fold(cur.tag)) : null; + const value = cur.from === 'tag' ? 'tag:' + (hit ? hit.name : cur.tag) : cur.from; + const pairs = [ + ['all', tr('Tüm presetler')], + ['favorites', tr('★ Favoriler') + ' (' + favN + ')'], + ].concat(tags.map((t) => ['tag:' + t.name, '#' + t.name + ' (' + t.count + ')'])); + if (cur.from === 'tag' && !hit) pairs.push([value, '#' + cur.tag + ' (0)']); + nodes.push(P().row('Havuz', selOf(pairs, value, (v) => { + const s = String(v); + if (s.indexOf('tag:') === 0) { md.autoFrom = 'tag'; md.autoTag = s.slice(4); } else { md.autoFrom = s === 'favorites' ? 'favorites' : 'all'; md.autoTag = ''; } + }))); + const n = cur.from === 'all' ? presets.length : LP.pool(presets, md, lib).length; + if (cur.from !== 'all') { + nodes.push(el('div', { + class: 'studio-note dim-hint', + text: 'Havuz yalnız otomatik geçişi sınırlar: zamanlayıcı, sert geçiş ve parça değişimi yalnız bunlardan seçer. ◀ Önceki, Sonraki ▶, Rastgele ve listeden seçim bütün presetlere gider.', + })); + } + if (cur.from !== 'all' && n < 2) { + nodes.push(el('div', { + class: 'studio-note', + text: n ? 'Havuzda tek preset var; otomatik geçiş bekliyor. Favori ekleyin ya da etiketleyin.' + : 'Havuzda preset yok; otomatik geçiş bekliyor. Favori ekleyin ya da etiketleyin.', + })); + } + } /* SERT GEÇİŞ (#568). Sesin ani yükselişinde karışmadan yeni preset. MilkDrop 2'nin kuralı ve varsayılanları; orada da KAPALI başlıyor. */ nodes.push(P().row('Sert Geçiş', selOf([ diff --git a/src/admin/studio.css b/src/admin/studio.css index fcaa37a..e2d99f8 100644 --- a/src/admin/studio.css +++ b/src/admin/studio.css @@ -607,6 +607,50 @@ input.p-in[type='color'] { padding: 2px; height: 28px; width: 46px; flex: none; white-space: nowrap; } .md-name:hover { background: rgba(255, 255, 255, 0.06); } +/* Favori ve etiketler (#576). Listede yıldız satırın başında ve yıldızlar + gibi satır içi; etiketler adın yanında soluk ve kısalabiliyor — ad önce + okunsun. */ +.md-fav { + flex: none; + background: none; + border: 0; + padding: 0 2px 0 4px; + font-size: 14px; + line-height: 1; + cursor: pointer; + color: var(--muted, #9aa); +} +.md-fav.on { color: #ffc94d; } +.md-tagline { + flex: 0 1 auto; + max-width: 40%; + font-size: 10px; + color: var(--muted, #9aa); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.btn.md-fav-live.on { + color: #ffc94d; + border-color: rgba(255, 201, 77, 0.5); +} +.md-tagsbox { display: flex; flex-direction: column; gap: 4px; min-width: 0; } +.md-tagsbox .md-tags-in { width: 100%; } +.md-tagchips { display: flex; flex-wrap: wrap; gap: 3px; } +.md-tagchip { + padding: 1px 7px; + font-size: 11px; + color: var(--muted, #9aa); + background: none; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 999px; + cursor: pointer; +} +.md-tagchip.on { + color: inherit; + background: rgba(124, 92, 255, 0.22); + border-color: rgba(124, 92, 255, 0.55); +} /* ===== Kayıt paneli ===== */ diff --git a/src/admin/studio.js b/src/admin/studio.js index 36d9310ac07d3d1f22e2589cf69f05541b7b72b0..3d880d07cee8ffa794b44ebe3ee4b7c4dbe8e81e 100644 GIT binary patch delta 641 zcma)(K}#bs7>1GEi|chg`!36tiPTIzbQiW%FS~eAS`qdlg0cCg#)+9BjV*&p{SSi| ze}%=X$Nm-ZvOmI%sZd1390Ez+@BQ99U-`Ij|9|23rZJ9J(G@8-pVVx5#zn2 z@AuPprB&BX;tRb0CZe37{cXb^crJSBU*l{MdBZw#t|Y^6m@}Y+p!JS{SfWI1cNW+E9au>kOcdjEU ZubN*MS>?BXz*w2Z%b$SDyB|%n@dW#E(-;5% delta 27 jcmdn(9#tpL!Ccjr;n|#F3b+d?(JIm&)=IM3-v_=cO diff --git a/src/exporter/index.html b/src/exporter/index.html index 52f2dff..c801d6a 100644 --- a/src/exporter/index.html +++ b/src/exporter/index.html @@ -56,6 +56,7 @@ + diff --git a/src/shared/defaults.js b/src/shared/defaults.js index 962874c..1166c3c 100644 --- a/src/shared/defaults.js +++ b/src/shared/defaults.js @@ -645,6 +645,14 @@ sıradaki preset (autoOrder'a göre). Zamanlayıcıdan bağımsız; kilit bunu da durduruyor. */ trackAdvance: false, + /* HAVUZ (#576): otomatik geçiş (zamanlayıcı, sert geçiş, parça + değişimi) yalnız bunlardan seçiyor. 'all' | 'favorites' | 'tag' + (+ autoTag). Elle ◀/▶/🎲 ve listeden seçim bütün presetlere gidiyor. + Sahneyle kaydediliyor: bir sahne "sakin" etiketinden, öbürü + favorilerden geçebilir. Favori ve etiketlerin kendisi aşağıda, + `milkdropLibrary`de. */ + autoFrom: 'all', + autoTag: '', hardCut: 'off', hardCutThreshold: 2.5, hardCutHalfLife: 60, @@ -726,6 +734,11 @@ kimlik → 0..5. Verilmemiş puan presetin kendi dosyasındaki fRating'den geliyor (yoksa 3). Preset kaydına yazılmıyor: her kayıt bütün listeyi kaynaklarıyla bütün pencerelere yeniden yayınlıyor. + milkdropLibrary.favorites (#576) — kimlik → true. + milkdropLibrary.tags (#576) — kimlik → etiket dizisi (en çok 16, her + biri en çok 32 karakter; shared/milkdrop-library.js). Puanlarla + birlikte paketle dışa aktarılıyor, içe aktarımda yeni kimliğe + katlanıyor. milkdropControl.locked (#568) — kilit: otomatik geçiş ve sert geçiş durur, elle seçim çalışır. milkdropControl.cutTo (#570) — "şimdi kes": elle seçilen preset bu @@ -740,6 +753,8 @@ sınırlayıcı açık kalıyor, sert geçiş yok, geçişler uzun. */ milkdropLibrary: { ratings: {}, + favorites: {}, + tags: {}, }, milkdropControl: { locked: false, diff --git a/src/shared/i18n.js b/src/shared/i18n.js index 099f864..26a1d99 100644 --- a/src/shared/i18n.js +++ b/src/shared/i18n.js @@ -1637,6 +1637,39 @@ 'Kapalı — işletim sistemi istemiyor': 'Off — the operating system does not ask for it', 'Flaş sınırlayıcı açık kalıyor, sesin yükselişinde sert geçiş olmuyor ve geçişler 5 saniye sürüyor. Elle "şimdi kes" yine keser. Video dışa aktarımında sistemin ayarı değil yalnız bu ayar ("Her zaman") geçerli.': 'The flash limiter stays on, there are no hard cuts on a rise in the sound, and transitions last 5 seconds. A manual "cut now" still cuts. Video export follows this setting ("Always") only, not the system’s.', + // ---- Favoriler, etiketler, yazar, havuz ve paket (#576) ---- + 'Favori': 'Favourite', + '★ Favori': '★ Favourite', + '☆ Favorilere Ekle': '☆ Add to Favourites', + 'Etiketler': 'Tags', + 'virgülle ayırın: sakin, dans': 'comma-separated: calm, dance', + 'Bu presetten çıkar': 'Remove from this preset', + 'Bu presete ekle': 'Add to this preset', + 'ad, yazar ya da #etiket': 'name, author or #tag', + 'Süz': 'Show', + '★ Favoriler': '★ Favourites', + 'Yazar': 'Author', + 'Tüm yazarlar': 'All authors', + 'Favorilerden çıkar': 'Remove from favourites', + 'Favorilere ekle': 'Add to favourites', + 'Havuz': 'Pool', + 'Tüm presetler': 'All presets', + 'Havuz yalnız otomatik geçişi sınırlar: zamanlayıcı, sert geçiş ve parça değişimi yalnız bunlardan seçer. ◀ Önceki, Sonraki ▶, Rastgele ve listeden seçim bütün presetlere gider.': + 'The pool limits auto advance only: the timer, hard cuts and track changes pick from it alone. ◀ Previous, Next ▶, Random and picking from the list reach every preset.', + 'Havuzda tek preset var; otomatik geçiş bekliyor. Favori ekleyin ya da etiketleyin.': + 'The pool holds one preset; auto advance is waiting. Add favourites or tag presets.', + 'Havuzda preset yok; otomatik geçiş bekliyor. Favori ekleyin ya da etiketleyin.': + 'The pool is empty; auto advance is waiting. Add favourites or tag presets.', + '📦 Görünenleri Paketle': '📦 Pack What Is Shown', + 'Listede görünen kendi presetlerinizi favori, etiket ve puanlarıyla tek dosyaya yazar': + 'Writes your own presets shown in the list to one file, with their favourites, tags and ratings', + '📥 Paket İçe Aktar': '📥 Import Pack', + 'Bir .svpack paketini favori, etiket ve puanlarıyla ekler': 'Adds a .svpack pack with its favourites, tags and ratings', + 'Dışa aktarma kullanılamıyor.': 'Export is not available.', + 'Listede dışa aktarılacak kendi presetiniz yok.': 'The list shows none of your own presets to export.', + 'preset pakete yazıldı.': 'presets written to the pack.', + 'Paket okunamadı (.svpack ya da .svpreset bekleniyordu).': 'The pack could not be read (.svpack or .svpreset expected).', + 'preset içe aktarıldı.': 'presets imported.', 'Sil': 'Delete', @@ -2079,6 +2112,7 @@ '🔒 MilkDrop · Kilit (aç/kapa)': '🔒 MilkDrop · Lock (toggle)', '⭐ MilkDrop · Puanı Artır': '⭐ MilkDrop · Rating Up', '⭐ MilkDrop · Puanı Azalt': '⭐ MilkDrop · Rating Down', + '★ MilkDrop · Favori (aç/kapa)': '★ MilkDrop · Favourite (on/off)', // Sahne Panelleri, Aydınlatma ve Arayüz 'Zincir boşken sahne doğrudan kompozit edilir; hiçbir ek maliyet yoktur. Efekt eklediğinizde sahne tek yüzeye birleştirilip GPU\'da işlenir ve efektler dışa aktarımda da aynı sırayla uygulanır.': 'When chain is empty, scene is directly composited with no overhead. When you add effects, scene is rendered to a single surface on the GPU and effects are applied in the same order during export.', diff --git a/src/shared/milkdrop-library.js b/src/shared/milkdrop-library.js new file mode 100644 index 0000000..30a7102 --- /dev/null +++ b/src/shared/milkdrop-library.js @@ -0,0 +1,295 @@ +'use strict'; +/* MilkDrop kitaplığı (#576): favoriler, etiketler, yazar ve arama. + * + * NEREDE DURUYOR + * Favoriler ve etiketler kullanıcının verdiği şeyler, presetin kendisine ait + * değil: #569'un puanları gibi AYARLARDA, preset kimliğine bağlı + * (`milkdropLibrary.favorites`: kimlik → true, `milkdropLibrary.tags`: + * kimlik → dizi). Preset dosyasına dokunulmuyor; kitaplık bloğu sahne + * anahtarı değil, yani sahne değişince kaybolmuyor. + * + * PAKETTE NASIL GİDİYOR + * Kimlikler karşı tarafta aynı değil: içe aktarım her presete yeni kimlik + * veriyor (`SVPresets.readImported`). Bu yüzden paket kimlik → değer eşlemini + * değil, her presetin kendi kaydında bir `library` alanı taşıyor (favori, + * etiketler, puan) ve içe aktarım onu yeni kimliğe katlıyor. Alan preset + * dosyasına yazılmıyor. + * + * YAZAR + * MilkDrop preset dosyasında yazar alanı yok; adlar çoğunlukla + * "Yazar - Başlık" ("Flexi, martin + geiss - ..."). Yazar arama sırasında + * addan türetiliyor, diske yazılmıyor: diskteki presetler için taşıma + * gerekmiyor ve yeniden içe aktarılan preset aynı yazarı buluyor. Kaydın + * kendi `author` alanı doluysa (yerleşikler) o geçerli. + */ +(function () { + const MAX_TAGS = 16; + const MAX_TAG_LEN = 32; + // Birleşik nokta (İ'nin küçüğünde kalan): kaçış yerine koddan kuruluyor + const COMBINING_DOT = new RegExp(String.fromCharCode(0x307), 'g'); + + /* Karşılaştırma anahtarı. Büyük/küçük harf ve Türkçe I/ı, İ/i farkı yok: + "IŞIK" ile "ışık" aynı etiket, "INFINITY" ile "infinity" aynı ad. + Görünen metin olduğu gibi kalıyor; yalnız eşleştirme bununla. */ + function fold(s) { + return String(s == null ? '' : s) + .toLowerCase() + .replace(COMBINING_DOT, '') + .replace(/ı/g, 'i') + .replace(/\s+/g, ' ') + .trim(); + } + + const own = (o, k) => !!o && typeof o === 'object' && Object.prototype.hasOwnProperty.call(o, k); + /* Eşlem anahtarı olabilecek kimlik. Kayıtlar dosyadan da geliyor (paket); + nesnenin kendi alanlarına denk gelen anahtar yazılmıyor. */ + const safeKey = (id) => typeof id === 'string' && id !== '' && + id !== '__proto__' && id !== 'constructor' && id !== 'prototype'; + + /* Yazar(lar). Birden çok yazar `+`, `&` ya da virgülle ayrılıyor; harf + taşımayan parça ("001 - ...") yazar sayılmıyor. */ + function authorsOf(p) { + if (!p) return []; + let src = typeof p.author === 'string' ? p.author.trim() : ''; + if (!src) { + const m = /^\s*(.+?)\s+-\s+\S/.exec(String(p.name || '')); + if (!m) return []; + src = m[1]; + } + const out = []; + const seen = new Set(); + for (const part of src.split(/\s*(?:\+|&|,)\s*/)) { + const a = part.trim(); + if (!a || !/\p{L}/u.test(a)) continue; + const k = fold(a); + if (seen.has(k)) continue; + seen.add(k); + out.push(a); + } + return out; + } + + /* Etiket girişi: virgülle (ya da noktalı virgülle, satırla) ayrılmış metin + veya dizi. Baştaki `#` atılıyor, aynı etiket bir kez, en çok 16 etiket + ve etiket başına 32 karakter. */ + function normTags(input) { + const raw = Array.isArray(input) ? input : String(input == null ? '' : input).split(/[,;\n]/); + const out = []; + const seen = new Set(); + for (const t of raw) { + if (typeof t !== 'string' && typeof t !== 'number') continue; + let s = String(t).replace(/\s+/g, ' ').trim().replace(/^#+/, '').trim(); + if (s.length > MAX_TAG_LEN) s = s.slice(0, MAX_TAG_LEN).trim(); + const k = fold(s); + if (!k || seen.has(k)) continue; + seen.add(k); + out.push(s); + if (out.length >= MAX_TAGS) break; + } + return out; + } + + function isFavorite(lib, id) { + return !!lib && own(lib.favorites, id) && lib.favorites[id] === true; + } + + function tagsOf(lib, id) { + return lib && own(lib.tags, id) && Array.isArray(lib.tags[id]) ? normTags(lib.tags[id]) : []; + } + + /* Değişiklikler YENİ bir eşlem döndürüyor; çağıran kitaplığa atıyor + (panelin puan yazımıyla aynı desen, milkdrop-panel.js `setRating`). */ + function withFavorite(lib, id, on) { + const fav = Object.assign({}, lib && lib.favorites); + if (!safeKey(id)) return fav; + if (on) fav[id] = true; + else delete fav[id]; + return fav; + } + + function withTags(lib, id, input) { + const all = Object.assign({}, lib && lib.tags); + if (!safeKey(id)) return all; + const t = normTags(input); + if (t.length) all[id] = t; + else delete all[id]; + return all; + } + + /* Silinen presetin izi: puan, favori ve etiketler birlikte gidiyor. */ + function forget(lib, id) { + const drop = (o) => { + const c = Object.assign({}, o); + delete c[id]; + return c; + }; + return { ratings: drop(lib && lib.ratings), favorites: drop(lib && lib.favorites), tags: drop(lib && lib.tags) }; + } + + /* ARAMA. Sorgu boşlukla ayrılmış sözcükler ve HEPSİ uymalı. `#etiket` + (ya da `etiket:`, `tag:`) yalnız etiketlerde, `yazar:` (ya da + `author:`) yalnız yazarlarda arıyor; düz sözcük adda, yazarlarda ve + etiketlerde. Eşleşme parça eşleşmesi: yazarken daralıyor. */ + function parseQuery(q) { + const terms = []; + for (const w of String(q == null ? '' : q).split(/\s+/)) { + if (!w) continue; + let m; + if (w[0] === '#') { + if (w.length > 1) terms.push({ in: 'tag', v: fold(w.slice(1)) }); + } else if ((m = /^(?:yazar|author):(.*)$/i.exec(w))) { + if (m[1]) terms.push({ in: 'author', v: fold(m[1]) }); + } else if ((m = /^(?:etiket|tag):(.*)$/i.exec(w))) { + if (m[1]) terms.push({ in: 'tag', v: fold(m[1].replace(/^#+/, '')) }); + } else { + terms.push({ in: 'any', v: fold(w) }); + } + } + return terms.filter((t) => t.v); + } + + /* `nameOf`: panelde görünen ad (yerleşiklerin adı çevriliyor); arama + hem onda hem kayıttaki adda. */ + function matches(p, terms, lib, nameOf) { + if (!terms.length) return true; + const names = [fold(p.name || p.id)]; + if (typeof nameOf === 'function') names.push(fold(nameOf(p.name || p.id))); + const authors = authorsOf(p).map(fold); + const tags = tagsOf(lib, p.id).map(fold); + const inName = (v) => names.some((n) => n.includes(v)); + const inList = (arr, v) => arr.some((x) => x.includes(v)); + return terms.every((t) => { + if (t.in === 'tag') return inList(tags, t.v); + if (t.in === 'author') return inList(authors, t.v); + return inName(t.v) || inList(authors, t.v) || inList(tags, t.v); + }); + } + + /* Listenin süzgeci. `show`: 'all' | 'fav' | 'tag' (+ `tag`), `author` + yazar (tam, katlanmış karşılaştırma), `query` arama metni. */ + function filter(list, opts, lib) { + const o = opts || {}; + const terms = parseQuery(o.query); + const tagK = o.show === 'tag' ? fold(o.tag) : ''; + const authorK = o.author ? fold(o.author) : ''; + return (Array.isArray(list) ? list : []).filter((p) => { + if (!p) return false; + if (o.show === 'fav' && !isFavorite(lib, p.id)) return false; + if (o.show === 'tag' && !tagsOf(lib, p.id).some((t) => fold(t) === tagK)) return false; + if (authorK && !authorsOf(p).some((a) => fold(a) === authorK)) return false; + return matches(p, terms, lib, o.nameOf); + }); + } + + /* OTOMATİK GEÇİŞİN HAVUZU. `md.autoFrom`: 'all' (varsayılan) | + 'favorites' | 'tag' (+ `md.autoTag`). Sıra listeninki; havuz boşsa + döngü bunu kendi nedeniyle söylüyor (EMPTY, ALONE). */ + function poolOf(md) { + const from = md && (md.autoFrom === 'favorites' || md.autoFrom === 'tag') ? md.autoFrom : 'all'; + return { from, tag: from === 'tag' && md && typeof md.autoTag === 'string' ? md.autoTag : '' }; + } + + function pool(list, md, lib) { + const o = poolOf(md); + const arr = Array.isArray(list) ? list : []; + if (o.from === 'all') return arr; + if (o.from === 'favorites') return arr.filter((p) => p && isFavorite(lib, p.id)); + const k = fold(o.tag); + if (!k) return []; + return arr.filter((p) => p && tagsOf(lib, p.id).some((t) => fold(t) === k)); + } + + /* Seçiciler için sayımlar: etiketler ve yazarlar, katlanmış anahtara + göre gruplanıp alfabetik. Görünen biçim en sık yazılışı. */ + function counted(pairs) { + const by = new Map(); + for (const [shown, key] of pairs) { + let e = by.get(key); + if (!e) { e = { key, count: 0, forms: new Map() }; by.set(key, e); } + e.count++; + e.forms.set(shown, (e.forms.get(shown) || 0) + 1); + } + const out = []; + for (const e of by.values()) { + let best = ''; + let n = -1; + for (const [f, c] of e.forms) if (c > n) { best = f; n = c; } + out.push({ name: best, key: e.key, count: e.count }); + } + return out.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)); + } + + function tagCounts(list, lib) { + const pairs = []; + for (const p of Array.isArray(list) ? list : []) { + if (p) for (const t of tagsOf(lib, p.id)) pairs.push([t, fold(t)]); + } + return counted(pairs); + } + + function authorCounts(list) { + const pairs = []; + for (const p of Array.isArray(list) ? list : []) { + if (p) for (const a of authorsOf(p)) pairs.push([a, fold(a)]); + } + return counted(pairs); + } + + /* PAKET. Dışa aktarımda her presetin kaydına giden bölüm: yalnız bu + kullanıcının verdikleri ve varsa. */ + function packEntry(lib, id) { + const e = {}; + if (isFavorite(lib, id)) e.favorite = true; + const t = tagsOf(lib, id); + if (t.length) e.tags = t; + const r = lib && own(lib.ratings, id) ? Number(lib.ratings[id]) : NaN; + if (isFinite(r)) e.rating = Math.max(0, Math.min(5, r)); + return Object.keys(e).length ? e : null; + } + + /* İçe aktarımda gelenler (`yeni kimlik → kayıt`) kitaplığa. Dosyadan + geldikleri için doğrulanıyor: favori yalnız `true`, etiketler temizleniyor, + puan 0..5 sayı. Dönüş: yeni `ratings`, `favorites`, `tags` eşlemleri. */ + function mergeEntries(lib, entries) { + const ratings = Object.assign({}, lib && lib.ratings); + const favorites = Object.assign({}, lib && lib.favorites); + const tags = Object.assign({}, lib && lib.tags); + let n = 0; + for (const id of Object.keys(entries || {})) { + const e = entries[id]; + if (!safeKey(id) || !e || typeof e !== 'object') continue; + let any = false; + if (e.favorite === true) { favorites[id] = true; any = true; } + const t = normTags(Array.isArray(e.tags) ? e.tags : []); + if (t.length) { tags[id] = t; any = true; } + const r = typeof e.rating === 'number' ? e.rating : NaN; + if (isFinite(r)) { ratings[id] = Math.max(0, Math.min(5, r)); any = true; } + if (any) n++; + } + return { ratings, favorites, tags, count: n }; + } + + /* İçe aktarımın son adımı (MilkDrop paneli ve Studio ortak): yalnız + GERÇEKTEN kaydedilen presetlerin kayıtları kitaplığa — kaydedilemeyen + presetin izi ayarlarda kalmasın. `entries` `SVPresets.readImported`in + `library`si, `saved` kaydın döndürdüğü presetler. `lib` yerinde + güncelleniyor; dönüş: kaç presetin kaydı geldi. */ + function adopt(lib, entries, saved) { + if (!lib || typeof lib !== 'object') return 0; + const ids = new Set((Array.isArray(saved) ? saved : []).map((p) => p && p.id).filter(Boolean)); + const pick = {}; + for (const id of Object.keys(entries || {})) if (ids.has(id)) pick[id] = entries[id]; + const m = mergeEntries(lib, pick); + lib.ratings = m.ratings; + lib.favorites = m.favorites; + lib.tags = m.tags; + return m.count; + } + + const api = { + MAX_TAGS, MAX_TAG_LEN, fold, authorsOf, normTags, isFavorite, tagsOf, withFavorite, withTags, forget, + parseQuery, matches, filter, poolOf, pool, tagCounts, authorCounts, packEntry, mergeEntries, adopt, + }; + if (typeof module !== 'undefined' && module.exports) module.exports = api; + if (typeof window !== 'undefined') window.SVMilkdropLibrary = api; +})(); diff --git a/src/shared/presets.js b/src/shared/presets.js index 3caacf1..d3db313 100644 --- a/src/shared/presets.js +++ b/src/shared/presets.js @@ -460,7 +460,10 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord){ // -------------------------------------------------------------------------- // Paket (birden çok preset tek dosyada) // -------------------------------------------------------------------------- - function makePack(list, meta) { + /* `libraryOf(p)` (#576): presetin kullanıcıya ait kaydı — favori, etiket, + puan — ya da null. Kayıt presetin `library` alanında gidiyor: kimlik + karşı tarafta aynı olmayacağı için ayrı bir kimlik eşlemi işe yaramaz. */ + function makePack(list, meta, libraryOf) { return { format: PACK_FORMAT, version: VERSION, @@ -471,19 +474,34 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord){ presets: (list || []).map((p) => { const c = normalize(p); delete c.builtin; + delete c.library; + const e = typeof libraryOf === 'function' ? libraryOf(p) : null; + if (e && typeof e === 'object') c.library = e; return c; }), }; } - // Dosyadan gelen veriyi preset listesine çevirir (tek preset veya paket) + /* Dosyadan gelen veriyi preset listesine çevirir (tek preset veya paket). + Her preset yeni kimlik alıyor; `library` kaydı presetten AYRILIYOR — + preset dosyasına yazılmasın, ayarlara gitsin — ve `library` eşleminde + yeni kimliğe bağlı dönüyor. Doğrulaması kitaplığın işi + (shared/milkdrop-library.js `mergeEntries`). */ function readImported(data) { if (!data || typeof data !== 'object') return { ok: false, error: 'Dosya okunamadı.' }; + const library = {}; + const take = (p) => { + const c = Object.assign({}, p, { id: newId(), builtin: false }); + const e = c.library; + delete c.library; + if (e && typeof e === 'object' && !Array.isArray(e)) library[c.id] = e; + return normalize(c); + }; if (data.format === PACK_FORMAT && Array.isArray(data.presets)) { - return { ok: true, presets: data.presets.map((p) => normalize(Object.assign({}, p, { id: newId(), builtin: false }))) }; + return { ok: true, presets: data.presets.filter((p) => p && typeof p === 'object').map(take), library }; } if (data.format === FORMAT) { - return { ok: true, presets: [normalize(Object.assign({}, data, { id: newId(), builtin: false }))] }; + return { ok: true, presets: [take(data)], library }; } return { ok: false, error: 'Tanınmayan dosya biçimi (svpreset veya svpack bekleniyordu).' }; } diff --git a/src/visualizer/index.html b/src/visualizer/index.html index 0dddb43..82966bb 100644 --- a/src/visualizer/index.html +++ b/src/visualizer/index.html @@ -55,6 +55,7 @@ + diff --git a/src/visualizer/modes/milkdrop.js b/src/visualizer/modes/milkdrop.js index b340b06..9097bfc 100644 --- a/src/visualizer/modes/milkdrop.js +++ b/src/visualizer/modes/milkdrop.js @@ -1804,19 +1804,26 @@ void main(){ const o = C.normalize(md); const beat = this._beat(audio, cfg, step); this._barsOf = o.bars; - const list = o.seconds > 0 || o.bars > 0 || o.hardCut !== 'off' ? listOf() : []; + /* HAVUZ (#576): otomatik geçiş yalnız favorilerden ya da bir etiketten + seçebiliyor. Süzgeç listenin kurulduğu yerde, yani zamanlayıcı, sert + geçiş, parça değişimi ve önceden derleme aynı havuzu görüyor. Havuz + boşsa ya da tek presetse döngü bunu kendi nedeniyle söylüyor (EMPTY, + ALONE). Puanlar ve favoriler sahnenin değil gösterinin (bkz. + `_cycleMd`). */ + const lib = cfg.milkdropLibrary || {}; + const LB = typeof window !== 'undefined' && window.SVMilkdropLibrary; + const poolList = () => (LB ? LB.pool(listOf(), md, lib) : listOf()); + const list = o.seconds > 0 || o.bars > 0 || o.hardCut !== 'off' ? poolList() : []; const cur = this.autoPick ? this.autoPick.id : ((cfg.milkdrop && cfg.milkdrop.presetId) || ''); /* Sert geçiş bir ÖNCEKİ karenin bantlarına bakıyor: bu karenin analizi preset yüklendikten sonra yapılıyor. MilkDrop'ta da kesim, analizden sonraki yüklemede — yani bir kare sonra — ekrana geliyor. */ - // Puanlar da sahnenin değil gösterinin (bkz. `_cycleMd`) - const lib = cfg.milkdropLibrary || {}; let p = this.cycle.step(step, md, list, cur, this._rel, lib.ratings, beat); /* PARÇA DEĞİŞİNCE (#582) sıradaki preset. Yalnız LİDER seçiyor — izleyenler yukarıda liderin seçimine geçti; her ekran kendi başına geçseydi #585'in "her ekranda aynı preset"i bozulurdu. Liste zamanlayıcı kapalıyken kurulmamış olabilir; yalnız o an kuruluyor. */ - if (!p && trackNew && o.onTrack) p = this.cycle.onTrack(md, list.length ? list : listOf(), cur, lib.ratings); + if (!p && trackNew && o.onTrack) p = this.cycle.onTrack(md, list.length ? list : poolList(), cur, lib.ratings); /* SIRADAKİNİ ÖNCEDEN DERLE (#573). Değişime bir saniye kala döngü sıradaki seçimi yapıyor ve motor onun shader'larını arka planda derliyor; vakti gelince derleme bitmiş oluyor ve değişim — ölçü diff --git a/src/web/overlay.html b/src/web/overlay.html index e7ee0fe..7765659 100644 --- a/src/web/overlay.html +++ b/src/web/overlay.html @@ -95,6 +95,7 @@ + diff --git a/tests/milkdrop-control.test.js b/tests/milkdrop-control.test.js index ee5f0d5..11c2b57 100644 --- a/tests/milkdrop-control.test.js +++ b/tests/milkdrop-control.test.js @@ -181,7 +181,7 @@ test('motor: "şimdi kes" yalnız o elle seçimde karışmıyor', () => { test('eylem ve hedef etiketlerinin İngilizcesi var', () => { const I = read('src/shared/i18n.js'); const mine = CT.TARGETS.filter((t) => /^milkdrop\./.test(t.path || '') || /^md/.test(t.action || '')); - // 13 + sprite silme eylemleri (#577: en yeni, en eski, hepsi) - assert.strictEqual(mine.length, 16); + // 13 + sprite silme eylemleri (#577: en yeni, en eski, hepsi) + favori (#576) + assert.strictEqual(mine.length, 17); for (const t of mine) assert.ok(I.includes("'" + t.label + "':"), 'çevirisi yok: ' + t.label); }); diff --git a/tests/milkdrop-library.test.js b/tests/milkdrop-library.test.js new file mode 100644 index 0000000..0e15864 --- /dev/null +++ b/tests/milkdrop-library.test.js @@ -0,0 +1,530 @@ +'use strict'; +/* MİLKDROP KİTAPLIĞI (#576): favoriler, etiketler, yazar araması, otomatik + * geçişin havuzu ve paketle taşınmaları. + * + * Favori ve etiketler #569'un puanları gibi AYARLARDA, preset kimliğine + * bağlı (`milkdropLibrary`). Paket kimlik eşlemi değil, her presetin kendi + * `library` kaydını taşıyor: içe aktarım presetlere yeni kimlik veriyor ve + * kayıt o kimliğe katlanıyor. Yazar dosyada yok; "Yazar - Başlık" adından + * arama sırasında türetiliyor. + * + * Motor ve panel sahte bir pencereyle gerçekten çalıştırılıyor. + */ +global.window = global.window || {}; +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); +require('../src/shared/defaults.js'); +require('../src/shared/presets.js'); +const L = require('../src/shared/milkdrop-library.js'); + +const ROOT = path.join(__dirname, '..'); +const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf-8'); +const SV = global.window.SV; +const PR = global.window.SVPresets; + +// ------------------------------------------------------------------ yazar + +test('yazar: "Yazar - Başlık" adından; birden çok yazar ayrılıyor', () => { + const a = (name, author) => L.authorsOf({ name, author }); + assert.deepStrictEqual(a('Geiss - Cauldron'), ['Geiss']); + assert.deepStrictEqual(a('Flexi, martin + geiss - dedicated to the sherwin maxawow'), ['Flexi', 'martin', 'geiss']); + assert.deepStrictEqual(a('Rovastar & Idiot24-7 - Balk Acid'), ['Rovastar', 'Idiot24-7'], 'boşluksuz tire yazarın parçası'); + assert.deepStrictEqual(a('yin - 191 - Temporal singularities'), ['yin'], 'ilk " - " ayırıyor'); + assert.deepStrictEqual(a('$$$ Royal - Mashup (220)'), ['$$$ Royal']); + assert.deepStrictEqual(a('Geiss + GEISS - Twice'), ['Geiss'], 'aynı yazar bir kez'); + assert.deepStrictEqual(a('001 - test'), [], 'harf taşımayan parça yazar değil'); + assert.deepStrictEqual(a('_Mig_049'), [], 'tire yoksa yazar yok'); + assert.deepStrictEqual(a('Kutup Işığı', 'CAYADEV'), ['CAYADEV'], 'kaydın kendi alanı önce'); + assert.deepStrictEqual(L.authorsOf(null), []); +}); + +test('katlama: büyük/küçük harf ve Türkçe I/ı, İ/i farkı yok', () => { + assert.strictEqual(L.fold('IŞIK'), L.fold('ışık')); + assert.strictEqual(L.fold('İSTANBUL'), 'istanbul'); + assert.strictEqual(L.fold('INFINITY'), 'infinity'); + assert.strictEqual(L.fold(' Çok boşluk '), 'çok boşluk'); +}); + +// ---------------------------------------------------------------- etiket + +test('etiket girişi: virgül, # ve tekrar temizleniyor; sınırlar', () => { + assert.deepStrictEqual(L.normTags(' #Dans, sakin ,DANS,, ışık ; IŞIK '), ['Dans', 'sakin', 'ışık']); + assert.deepStrictEqual(L.normTags(['a', 5, null, {}, ' #b ']), ['a', '5', 'b']); + const many = Array.from({ length: 30 }, (_, i) => 't' + i); + assert.strictEqual(L.normTags(many).length, L.MAX_TAGS); + assert.strictEqual(L.normTags(['x'.repeat(80)])[0].length, L.MAX_TAG_LEN); + assert.deepStrictEqual(L.normTags(''), []); +}); + +test('favori ve etiket yazımı yeni eşlem döndürüyor; silinen presetin izi gidiyor', () => { + const lib = { ratings: { a: 4 }, favorites: {}, tags: {} }; + const fav = L.withFavorite(lib, 'a', true); + assert.notStrictEqual(fav, lib.favorites, 'yerinde değişmemeli'); + assert.deepStrictEqual(fav, { a: true }); + assert.deepStrictEqual(L.withFavorite({ favorites: fav }, 'a', false), {}); + const tags = L.withTags(lib, 'a', 'sakin, #dans'); + assert.deepStrictEqual(tags, { a: ['sakin', 'dans'] }); + assert.deepStrictEqual(L.withTags({ tags }, 'a', ' '), {}, 'boş giriş etiketleri siliyor'); + const gone = L.forget({ ratings: lib.ratings, favorites: fav, tags }, 'a'); + assert.deepStrictEqual(gone, { ratings: {}, favorites: {}, tags: {} }); + // Nesnenin kendi alanlarına denk gelen anahtar yazılmıyor + assert.deepStrictEqual(L.withFavorite({}, '__proto__', true), {}); + assert.strictEqual({}.a, undefined); +}); + +// ----------------------------------------------------------------- arama + +const LIST = [ + { id: 'a', name: 'Geiss - Cauldron' }, + { id: 'b', name: 'Martin - Liquid' }, + { id: 'c', name: 'Flexi + Geiss - Martin Tribute' }, + { id: 'd', name: 'Rovastar - Altars' }, + { id: 'e', name: 'Kutup Işığı', author: 'CAYADEV' }, +]; +const LIB = { favorites: { a: true, d: true }, tags: { a: ['Sakin'], b: ['sakin', 'dans'], c: ['Dans'] }, ratings: { b: 3 } }; +const ids = (arr) => arr.map((p) => p.id); + +test('arama: adda, yazarda ve etiketlerde; bütün sözcükler uymalı', () => { + const f = (q, extra) => ids(L.filter(LIST, Object.assign({ query: q }, extra), LIB)); + assert.deepStrictEqual(f(''), ['a', 'b', 'c', 'd', 'e']); + assert.deepStrictEqual(f('geiss'), ['a', 'c']); + assert.deepStrictEqual(f('#dans'), ['b', 'c'], 'yalnız etiket'); + assert.deepStrictEqual(f('#DA'), ['b', 'c'], 'yazarken daralıyor'); + assert.deepStrictEqual(f('sakin'), ['a', 'b'], 'düz sözcük etiketlerde de'); + assert.deepStrictEqual(f('yazar:martin'), ['b'], 'başlıktaki "Martin" yazar değil'); + assert.deepStrictEqual(f('author:MARTIN'), ['b']); + assert.deepStrictEqual(f('etiket:#sakin'), ['a', 'b']); + assert.deepStrictEqual(f('tag:dans geiss'), ['c'], 'iki koşul birlikte'); + assert.deepStrictEqual(f('cayadev'), ['e'], 'kaydın yazarı'); + assert.deepStrictEqual(f('ışığ'), ['e']); + assert.deepStrictEqual(f('IŞIĞ'), ['e'], 'Türkçe büyük harf'); + // Görünen ad (çevrilen yerleşik) da aranıyor + assert.deepStrictEqual(ids(L.filter(LIST, { query: 'aurora', nameOf: (n) => (n === 'Kutup Işığı' ? 'Aurora' : n) }, LIB)), ['e']); +}); + +test('süzgeç: favoriler, bir etiket, bir yazar — aramayla birlikte', () => { + const f = (o) => ids(L.filter(LIST, o, LIB)); + assert.deepStrictEqual(f({ show: 'fav' }), ['a', 'd']); + assert.deepStrictEqual(f({ show: 'tag', tag: 'SAKIN' }), ['a', 'b'], 'etiket katlanarak eşleşiyor'); + assert.deepStrictEqual(f({ show: 'tag', tag: 'sak' }), [], 'süzgeçte etiket TAM eşleşme'); + assert.deepStrictEqual(f({ author: 'geiss' }), ['a', 'c']); + assert.deepStrictEqual(f({ author: 'martin' }), ['b'], 'yazar seçimi başlığa bakmıyor'); + assert.deepStrictEqual(f({ show: 'fav', author: 'geiss' }), ['a']); + assert.deepStrictEqual(f({ show: 'tag', tag: 'dans', query: 'flexi' }), ['c']); +}); + +test('sayımlar: katlanmış anahtara göre, en sık yazılışla, alfabetik', () => { + // Eşit sıklıkta listede ilk görülen yazılış: "dans" (b), "Sakin" (a) + assert.deepStrictEqual(L.tagCounts(LIST, LIB).map((t) => [t.name, t.count]), [['dans', 2], ['Sakin', 2]]); + const au = L.authorCounts(LIST); + assert.deepStrictEqual(au.map((a) => [a.key, a.count]), [['cayadev', 1], ['flexi', 1], ['geiss', 2], ['martin', 1], ['rovastar', 1]]); + const mixed = L.authorCounts([{ name: 'geiss - x' }, { name: 'Geiss - y' }, { name: 'Geiss - z' }]); + assert.deepStrictEqual(mixed.map((a) => a.name), ['Geiss']); +}); + +// ------------------------------------------------------------------ havuz + +test('havuz: hepsi, favoriler, bir etiket; sıra listeninki', () => { + assert.strictEqual(L.pool(LIST, {}, LIB), LIST, 'varsayılan: liste olduğu gibi'); + assert.deepStrictEqual(ids(L.pool(LIST, { autoFrom: 'favorites' }, LIB)), ['a', 'd']); + assert.deepStrictEqual(ids(L.pool(LIST, { autoFrom: 'tag', autoTag: 'DANS' }, LIB)), ['b', 'c']); + assert.deepStrictEqual(L.pool(LIST, { autoFrom: 'tag', autoTag: '' }, LIB), [], 'etiketsiz etiket havuzu boş'); + assert.deepStrictEqual(L.pool(LIST, { autoFrom: 'favorites' }, {}), [], 'kitaplık yoksa favori yok'); + assert.deepStrictEqual(L.poolOf({ autoFrom: 'bilinmeyen' }), { from: 'all', tag: '' }); + assert.deepStrictEqual(L.poolOf({ autoFrom: 'favorites', autoTag: 'x' }), { from: 'favorites', tag: '' }); +}); + +// ------------------------------------------------------------------ paket + +test('paket kaydı: yalnız verilenler; içe aktarımda doğrulanıyor', () => { + assert.deepStrictEqual(L.packEntry(LIB, 'a'), { favorite: true, tags: ['Sakin'] }); + assert.deepStrictEqual(L.packEntry(LIB, 'b'), { tags: ['sakin', 'dans'], rating: 3 }); + assert.strictEqual(L.packEntry(LIB, 'e'), null, 'verilmiş bir şey yoksa kayıt yok'); + // Dosyadan gelen JSON: `__proto__` burada nesnenin KENDİ anahtarı + const entries = JSON.parse('{"n1":{"favorite":true,"tags":["#Yeni","yeni"],"rating":9},' + + '"n2":{"favorite":"evet","tags":"dizi değil","rating":"4"},"n3":null,' + + '"__proto__":{"favorite":true,"tags":["kirli"],"rating":2}}'); + assert.ok(Object.keys(entries).includes('__proto__')); + const m = L.mergeEntries({ ratings: { x: 1 } }, entries); + assert.deepStrictEqual(m.favorites, { n1: true }); + assert.deepStrictEqual(m.tags, { n1: ['Yeni'] }, 'eşlemin prototipi de değişmemiş olmalı'); + assert.deepStrictEqual(m.ratings, { x: 1, n1: 5 }, 'puan 0..5, metin puan değil'); + assert.strictEqual(m.count, 1); + assert.strictEqual({}.favorite, undefined, 'prototip kirlenmedi'); +}); + +test('paket gidiş-dönüş: favori, etiket ve puan yeni kimliklerle geri geliyor', () => { + const mine = [ + { id: 'md_bir', kind: 'milkdrop', name: 'Geiss - Bir', source: '[preset00]\n' }, + { id: 'md_iki', kind: 'milkdrop', name: 'Martin - İki', source: '[preset00]\n', library: { favorite: true } }, + // Kaydı olmayan presette bayat alan: pakete sızmamalı + { id: 'md_uc', kind: 'milkdrop', name: 'Üç', source: '[preset00]\n', library: { favorite: true, tags: ['bayat'] } }, + ]; + const lib = { favorites: { md_bir: true }, tags: { md_iki: ['dans'] }, ratings: { md_bir: 2, md_iki: 0 } }; + const pack = JSON.parse(JSON.stringify(PR.makePack(mine, { name: 'x' }, (p) => L.packEntry(lib, p.id)))); + assert.deepStrictEqual(pack.presets.map((p) => p.library || null), [ + { favorite: true, rating: 2 }, + { tags: ['dans'], rating: 0 }, + null, + ], 'kaynaktaki bayat `library` alanı pakete sızmıyor'); + const r = PR.readImported(pack); + assert.ok(r.ok); + assert.strictEqual(r.presets.length, 3); + for (const p of r.presets) { + assert.ok(!['md_bir', 'md_iki', 'md_uc'].includes(p.id), 'yeni kimlik'); + assert.strictEqual(p.library, undefined, 'kayıt preset dosyasına yazılmıyor'); + } + const [n1, n2, n3] = r.presets.map((p) => p.id); + assert.deepStrictEqual(Object.keys(r.library).sort(), [n1, n2].sort()); + // Yalnız kaydedilenler: üçüncü kaydedilemedi say + const target = { ratings: { eski: 4 }, favorites: {}, tags: {} }; + const n = L.adopt(target, r.library, [r.presets[0], r.presets[1]]); + assert.strictEqual(n, 2); + assert.deepStrictEqual(target.favorites, { [n1]: true }); + assert.deepStrictEqual(target.tags, { [n2]: ['dans'] }); + assert.deepStrictEqual(target.ratings, { eski: 4, [n1]: 2, [n2]: 0 }); + assert.ok(!Object.prototype.hasOwnProperty.call(target.favorites, n3)); + // Kaydedilmeyenin kaydı alınmıyor + const t2 = { ratings: {}, favorites: {}, tags: {} }; + assert.strictEqual(L.adopt(t2, r.library, []), 0); + assert.deepStrictEqual(t2.favorites, {}); +}); + +test('eski paketler ve tek preset: kayıt yoksa boş eşlem', () => { + const r = PR.readImported({ format: 'svpack', presets: [{ name: 'eski', kind: 'milkdrop', source: '' }] }); + assert.ok(r.ok); + assert.deepStrictEqual(r.library, {}); + const one = PR.readImported({ format: 'svpreset', name: 'tek', library: { favorite: true } }); + assert.ok(one.ok); + assert.deepStrictEqual(one.library, { [one.presets[0].id]: { favorite: true } }); + assert.strictEqual(one.presets[0].library, undefined); + const odd = PR.readImported({ format: 'svpack', presets: [{ name: 'x', library: ['dizi'] }, { name: 'y', library: 'metin' }] }); + assert.deepStrictEqual(odd.library, {}, 'nesne olmayan kayıt yok sayılıyor'); +}); + +// ------------------------------------------------------------------ motor + +const PRESETS = ['p1', 'p2', 'p3', 'p4', 'p5'].map((id, i) => ({ + id, name: 'Yazar - ' + id, kind: 'milkdrop', source: '[preset00]\nfDecay=0.9' + i + '\n', +})); + +function engine() { + const canvas = () => ({ width: 320, height: 240, getContext: (k) => (k === '2d' ? {} : null), addEventListener() {} }); + const ctx = { window: {}, document: { createElement: canvas }, console, performance }; + ctx.window.document = ctx.document; + ctx.window.SVPresets = { byKind: (k) => PRESETS.filter((p) => p.kind === k) }; + vm.createContext(ctx); + for (const f of ['src/shared/milkdrop.js', 'src/shared/milkdrop-cycle.js', 'src/shared/milkdrop-library.js', 'src/visualizer/modes/milkdrop.js']) { + vm.runInContext(read(f), ctx, { filename: f }); + } + return { m: new ctx.window.SVModes.milkdrop(canvas()), win: ctx.window }; +} + +const cfgFor = (md, lib) => ({ + milkdrop: Object.assign({ presetId: 'p1', source: PRESETS[0].source, blendTime: 0, autoNext: 1, autoNextRand: 0, autoOrder: 'sequential' }, md), + milkdropControl: {}, + milkdropLibrary: lib || {}, +}); + +// Zamanlayıcıyla art arda seçimler (her seçim yüklenip bir sonrakine geçiliyor) +function picks(e, cfg, n) { + const out = []; + e.m._ensurePreset(cfg); + for (let f = 0; f < 60 * 4 * n && out.length < n; f++) { + e.m._autoCycle(cfg, 1 / 60, {}); + if (e.m.autoPick && e.m.autoPick.id !== out[out.length - 1]) out.push(e.m.autoPick.id); + } + return out; +} + +test('motor: zamanlayıcı yalnız havuzdan, sırayla', () => { + const lib = { favorites: { p2: true, p4: true }, tags: { p3: ['sakin'], p5: ['SAKIN'] } }; + assert.deepStrictEqual(picks(engine(), cfgFor({}, lib), 5), ['p2', 'p3', 'p4', 'p5', 'p1'], 'havuzsuz: bütün liste'); + assert.deepStrictEqual(picks(engine(), cfgFor({ autoFrom: 'favorites' }, lib), 4), ['p2', 'p4', 'p2', 'p4']); + assert.deepStrictEqual(picks(engine(), cfgFor({ autoFrom: 'tag', autoTag: 'Sakin' }, lib), 3), ['p3', 'p5', 'p3']); +}); + +test('motor: havuz boşsa ya da tek presetse geçmiyor ve nedenini söylüyor', () => { + const run = (md, lib) => { + const e = engine(); + const cfg = cfgFor(md, lib); + e.m._ensurePreset(cfg); + for (let f = 0; f < 300; f++) e.m._autoCycle(cfg, 1 / 60, {}); + return { pick: e.m.autoPick, reason: e.m.cycle.reason }; + }; + assert.deepStrictEqual(run({ autoFrom: 'favorites' }, {}), { pick: null, reason: 'EMPTY' }); + assert.deepStrictEqual(run({ autoFrom: 'favorites' }, { favorites: { p3: true } }), { pick: null, reason: 'ALONE' }); + assert.deepStrictEqual(run({ autoFrom: 'tag', autoTag: 'yok' }, { tags: { p2: ['var'] } }), { pick: null, reason: 'EMPTY' }); +}); + +test('motor: sert geçiş ve parça değişimi de havuzdan', () => { + const lib = { favorites: { p4: true, p5: true } }; + const e = engine(); + const cfg = cfgFor({ autoNext: 0, hardCut: 'md2', autoFrom: 'favorites' }, lib); + e.m._ensurePreset(cfg); + e.m._rel = { bass: 10, mid: 10, treb: 10 }; + e.m._autoCycle(cfg, 1 / 60, {}); + assert.ok(e.m.autoPick && e.m.autoPick.cut, 'sert geçiş oldu'); + assert.ok(['p4', 'p5'].includes(e.m.autoPick.id), 'havuzdan: ' + e.m.autoPick.id); + const t = engine(); + const cfg2 = cfgFor({ autoNext: 0, trackAdvance: true, autoFrom: 'favorites' }, lib); + t.m._ensurePreset(cfg2); + t.win.SVNowLive = { state: { has: true, title: 'bir', artist: 'x', album: '' } }; + t.m._autoCycle(cfg2, 1 / 60, {}); + t.win.SVNowLive = { state: { has: true, title: 'iki', artist: 'x', album: '' } }; + t.m._autoCycle(cfg2, 1 / 60, {}); + assert.strictEqual(t.m.autoPick && t.m.autoPick.id, 'p4', 'parça değişince havuzun sıradakisi'); +}); + +test('motor: izleyen havuzu süzmüyor — liderin seçimi zaten havuzdan', () => { + const e = engine(); + const cfg = cfgFor({ autoFrom: 'favorites' }, { favorites: { p2: true } }); + e.m._ensurePreset(cfg); + // Liderin havuzu başka (başka bir sahne ya da ayar): seçimi yine izleniyor + e.win.SVMdFollow = { at: performance.now(), base: e.m._manualKey, id: 'p5', seed: 1, cut: false, blend: null }; + e.m._autoCycle(cfg, 1 / 60, {}); + assert.strictEqual(e.m.autoPick && e.m.autoPick.id, 'p5'); +}); + +test('motor kaynağı: süzgeç listenin kurulduğu yerde, izleme dalında değil', () => { + const MODE = read('src/visualizer/modes/milkdrop.js'); + const fn = /_autoCycle\(cfg, step, audio\) \{[\s\S]*?\n \}/.exec(MODE)[0]; + const follow = fn.indexOf('if (F && F.base ==='); + const pool = fn.indexOf('LB.pool(listOf(), md, lib)'); + assert.ok(follow > 0 && pool > follow, 'havuz izleme dalından SONRA'); + assert.match(fn, /const list = o\.seconds > 0 \|\| o\.bars > 0 \|\| o\.hardCut !== 'off' \? poolList\(\) : \[\];/); + assert.match(fn, /this\.cycle\.onTrack\(md, list\.length \? list : poolList\(\), cur, lib\.ratings\)/); +}); + +// ------------------------------------------------------------ sayfalar + +test('motoru çalıştıran her sayfa kitaplığı döngüden sonra yüklüyor', () => { + for (const [f, pre] of [ + ['src/visualizer/index.html', '../shared/'], + ['src/admin/index.html', '../shared/'], + ['src/exporter/index.html', '../shared/'], + ['src/web/overlay.html', '/app/shared/'], + ]) { + const H = read(f); + const c = H.indexOf(''); + const l = H.indexOf(''); + const m = H.indexOf('modes/milkdrop.js'); + assert.ok(c > 0 && l > c, f + ': kitaplık döngüden sonra'); + assert.ok(m < 0 || l < m, f + ': kitaplık motordan önce'); + } +}); + +test('varsayılanlar: havuz "hepsi"; favori ve etiketler kitaplıkta, sahnenin dışında', () => { + const d = SV.defaultConfig(); + assert.strictEqual(d.milkdrop.autoFrom, 'all'); + assert.strictEqual(d.milkdrop.autoTag, ''); + assert.deepStrictEqual(d.milkdropLibrary, { ratings: {}, favorites: {}, tags: {} }); + for (const f of ['src/main/main.js', 'src/admin/control.js', 'src/admin/admin.js']) { + const m = /const SCENE_KEYS = \[([\s\S]*?)\];/.exec(read(f)); + assert.ok(m && !m[1].includes('milkdropLibrary'), f); + } +}); + +// ------------------------------------------------------------------ panel + +function el(tag, props, kids) { + const n = { tag, props: props || {}, text: (props && props.text) || '', kids: [], on: {}, value: (props && props.value) || '', attrs: {} }; + n.className = (props && props.class) || ''; + n.appendChild = (c) => { n.kids.push(c); return c; }; + n.addEventListener = (ev, f) => { n.on[ev] = f; }; + n.setAttribute = (k, v) => { n.attrs[k] = v; }; + n.getAttribute = (k) => n.attrs[k]; + n.removeAttribute = () => {}; + n.contains = () => false; + Object.defineProperty(n, 'textContent', { get: () => n.text, set: (v) => { n.text = v; n.kids = []; } }); + for (const k of Object.keys(n.props)) if (k.indexOf('on') === 0 && typeof n.props[k] === 'function') n.on[k.slice(2)] = n.props[k]; + (kids || []).forEach((c) => c && n.kids.push(c)); + return n; +} +const walk = (n, f) => { if (!n || typeof n !== 'object') return; f(n); (n.kids || []).forEach((k) => walk(k, f)); if (n.node) walk(n.node, f); }; + +const MY = Array.from({ length: 8 }, (_, i) => ({ + id: 'u' + i, kind: 'milkdrop', name: (i % 2 ? 'Geiss' : 'Martin') + ' - Preset ' + i, source: '[preset00]\n', +})); + +async function panelWith(setup) { + const key = require.resolve('../src/admin/milkdrop-panel.js'); + delete require.cache[key]; + const cfg = SV.defaultConfig(); + cfg.milkdrop.presetId = 'u1'; + if (setup) setup(cfg); + const calls = { apply: 0, rerender: 0, deleted: [] }; + global.document = { querySelector: () => null, getElementById: () => null, activeElement: null }; + window.SVMilkdropCycle = require('../src/shared/milkdrop-cycle.js'); + window.SVMilkdropLibrary = L; + window.api = { + listPresets: () => Promise.resolve(MY), + deletePreset: (id) => { calls.deleted.push(id); return Promise.resolve({ ok: true }); }, + }; + window.SVPanel = { + cfg: () => cfg, apply() { calls.apply++; }, rerender() { calls.rerender++; }, el, + row: (label, node) => ({ label, node }), confirm: () => Promise.resolve(true), toast() {}, + }; + window.SVScenePanels = { miniSlider: (label) => ({ label, kids: [] }) }; + window.SVMdFollow = null; + const M = require('../src/admin/milkdrop-panel.js'); + M.init(); + await new Promise((r) => setImmediate(r)); + const root = M.panel(); + const row = (label) => root.kids.find((n) => n && n.label === label); + return { M, cfg, root, row, calls, render: () => M.panel() }; +} + +test('panel: ekrandaki presetin favorisi ve etiketleri', async () => { + const p = await panelWith(); + const fav = p.row('Favori').node; + assert.strictEqual(fav.text, '☆ Favorilere Ekle'); + fav.on.click(); + assert.deepStrictEqual(p.cfg.milkdropLibrary.favorites, { u1: true }, 'ekrandaki preset favori'); + assert.ok(p.calls.apply > 0, 'yapılandırma gönderildi'); + const again = p.render(); + const fav2 = again.kids.find((n) => n.label === 'Favori').node; + assert.strictEqual(fav2.text, '★ Favori'); + /* Etiket kutusu KUTUNUN presetine yazıyor: yazarken otomatik geçiş + ekrandakini değiştirse de (izlenen seçim u3) etiket u1'e gidiyor. */ + const box = again.kids.find((n) => n.label === 'Etiketler').node; + assert.strictEqual(box.attrs['data-for'], 'u1'); + const input = box.kids.find((k) => k.tag === 'input'); + window.SVMdFollow = { id: 'u3', base: 'u1|0', at: performance.now() }; + input.on.change({ target: { value: 'sakin, #Dans, sakin' } }); + window.SVMdFollow = null; + assert.deepStrictEqual(p.cfg.milkdropLibrary.tags, { u1: ['sakin', 'Dans'] }); + // Var olan etiketler tek tıkla: çıkar + const box2 = p.render().kids.find((n) => n.label === 'Etiketler').node; + const chips = box2.kids.find((k) => k.className === 'md-tagchips'); + assert.deepStrictEqual(chips.kids.map((c) => [c.text, c.className.includes(' on')]), [['#Dans', true], ['#sakin', true]]); + chips.kids[0].on.click(); + assert.deepStrictEqual(p.cfg.milkdropLibrary.tags, { u1: ['sakin'] }); +}); + +test('panel: arama, süzgeç ve yazar seçicisi listeyi daraltıyor', async () => { + const p = await panelWith((cfg) => { + cfg.milkdropLibrary.favorites = { u2: true, u3: true }; + cfg.milkdropLibrary.tags = { u3: ['sakin'], u4: ['Sakin'] }; + }); + const names = (root) => { + const list = root.kids.find((n) => n && n.className === 'md-list'); + return list.kids.map((it) => it.kids.find((k) => k.className === 'md-name').text); + }; + assert.strictEqual(names(p.root).length, 8); + const show = p.row('Süz').node; + assert.deepStrictEqual(show.kids.map((o) => o.props.value), ['all', 'fav', 'tag:sakin']); + assert.deepStrictEqual(show.kids.map((o) => o.text), ['Tümü (8)', '★ Favoriler (2)', '#sakin (2)']); + show.value = 'fav'; + show.on.change(); + assert.strictEqual(p.calls.apply, 0, 'görünüm süzgeci yapılandırma göndermiyor'); + assert.deepStrictEqual(names(p.render()), ['Martin - Preset 2', 'Geiss - Preset 3']); + const author = p.render().kids.find((n) => n.label === 'Yazar').node; + assert.deepStrictEqual(author.kids.map((o) => o.text), ['Tüm yazarlar', 'Geiss (4)', 'Martin (4)']); + author.value = 'geiss'; + author.on.change(); + assert.deepStrictEqual(names(p.render()), ['Geiss - Preset 3']); + // Arama kutusu: etiket sözcüğü + const r3 = p.render(); + const search = r3.kids.find((n) => n.label === 'Ara').node; + assert.strictEqual(search.props.placeholder, 'ad, yazar ya da #etiket'); +}); + +test('panel: az presette gizlenen süzgeç listeyi daraltmaya devam etmiyor', async () => { + const p = await panelWith((cfg) => { cfg.milkdropLibrary.favorites = { u2: true }; }); + const show = p.row('Süz').node; + show.value = 'fav'; + show.on.change(); + const count = (root) => root.kids.find((n) => n && n.className === 'md-list').kids.length; + assert.strictEqual(count(p.render()), 1); + // Presetler silindi, 5 kaldı: arama ve süzgeç satırları yok, liste tam + window.api.listPresets = () => Promise.resolve(MY.slice(0, 5)); + await new Promise((r) => p.M.refresh(r)); + const r = p.render(); + assert.ok(!r.kids.some((n) => n && n.label === 'Süz'), 'süzgeç satırı gizli'); + assert.strictEqual(count(r), 5, 'görünmeyen süzgeç uygulanmıyor'); +}); + +test('panel: listede yıldız ve etiketler; silinen presetin kaydı gidiyor', async () => { + const p = await panelWith((cfg) => { + cfg.milkdropLibrary.tags = { u5: ['dans'] }; + cfg.milkdropLibrary.ratings = { u5: 4 }; + }); + const list = p.root.kids.find((n) => n && n.className === 'md-list'); + const item = list.kids[5]; + const star = item.kids[0]; + assert.strictEqual(star.className, 'md-fav'); + assert.strictEqual(item.kids.find((k) => k.className === 'md-tagline').text, '#dans'); + star.on.click(); + assert.deepStrictEqual(p.cfg.milkdropLibrary.favorites, { u5: true }); + const del = item.kids[item.kids.length - 1]; + await del.on.click(); + assert.deepStrictEqual(p.calls.deleted, ['u5']); + assert.deepStrictEqual([p.cfg.milkdropLibrary.favorites, p.cfg.milkdropLibrary.tags, p.cfg.milkdropLibrary.ratings], [{}, {}, {}]); +}); + +test('panel: havuz seçimi sahneye yazılıyor; boş havuz söyleniyor', async () => { + const p = await panelWith((cfg) => { cfg.milkdropLibrary.tags = { u3: ['Sakin'], u6: ['sakin'] }; }); + const pool = p.row('Havuz').node; + assert.deepStrictEqual(pool.kids.map((o) => o.props.value), ['all', 'favorites', 'tag:Sakin']); + pool.value = 'tag:Sakin'; + pool.on.change(); + assert.deepStrictEqual([p.cfg.milkdrop.autoFrom, p.cfg.milkdrop.autoTag], ['tag', 'Sakin']); + const notes = (root) => root.kids.filter((n) => n && /studio-note/.test(n.className)).map((n) => n.text); + let r = p.render(); + assert.ok(notes(r).some((t) => /^Havuz yalnız otomatik geçişi sınırlar/.test(t))); + assert.ok(!notes(r).some((t) => /^Havuzda/.test(t)), 'iki preset: bekleme notu yok'); + pool.value = 'favorites'; + pool.on.change(); + r = p.render(); + assert.ok(notes(r).some((t) => /^Havuzda preset yok/.test(t))); + // Hiçbir presette kalmayan etiket seçili görünmeye devam ediyor + p.cfg.milkdrop.autoFrom = 'tag'; + p.cfg.milkdrop.autoTag = 'eski'; + const pool2 = p.render().kids.find((n) => n.label === 'Havuz').node; + const sel = pool2.kids.find((o) => o.selected); + assert.deepStrictEqual([sel.props.value, sel.text], ['tag:eski', '#eski (0)']); +}); + +test('panel: denetleyiciden favori ekrandakine', async () => { + const p = await panelWith(); + assert.strictEqual(p.M.act('Favorite'), true); + assert.deepStrictEqual(p.cfg.milkdropLibrary.favorites, { u1: true }); + p.M.act('Favorite'); + assert.deepStrictEqual(p.cfg.milkdropLibrary.favorites, {}); + const CT = read('src/admin/control.js'); + assert.match(CT, /\{ action: 'mdFavorite', label: '★ MilkDrop · Favori \(aç\/kapa\)' \}/); +}); + +test('Studio paketi de kayıtları taşıyor', () => { + const S = read('src/admin/studio.js'); + assert.match(S, /IS\(\)\.makePack\(list, \{ name: 'CAYADEV Preset Paketi' \}, libraryOf\)/); + assert.match(S, /const libraryOf = ML \? \(p\) => ML\.packEntry\(lib, p\.id\) : null;/); + assert.match(S, /if \(ML\.adopt\(lib, read\.library, \(saved && saved\.saved\) \|\| \[\]\)\) P\(\)\.push\(true\);/); +}); + +test('metinlerin İngilizcesi var', () => { + const I = read('src/shared/i18n.js'); + const PANEL = read('src/admin/milkdrop-panel.js'); + for (const k of [ + 'Favori', '★ Favori', '☆ Favorilere Ekle', 'Etiketler', 'virgülle ayırın: sakin, dans', 'Bu presetten çıkar', + 'Bu presete ekle', 'ad, yazar ya da #etiket', 'Süz', 'Tümü', '★ Favoriler', 'Yazar', 'Tüm yazarlar', + 'Favorilerden çıkar', 'Favorilere ekle', 'Havuz', 'Tüm presetler', '📦 Görünenleri Paketle', '📥 Paket İçe Aktar', + 'Dışa aktarma kullanılamıyor.', 'Listede dışa aktarılacak kendi presetiniz yok.', 'preset pakete yazıldı.', + 'Paket okunamadı (.svpack ya da .svpreset bekleniyordu).', 'preset içe aktarıldı.', + 'Listede görünen kendi presetlerinizi favori, etiket ve puanlarıyla tek dosyaya yazar', + 'Bir .svpack paketini favori, etiket ve puanlarıyla ekler', + ]) { + assert.ok(PANEL.includes("'" + k + "'"), k + ' panelde yok'); + assert.ok(I.includes("'" + k + "':"), k + ' sözlükte yok'); + } + for (const re of [/text: '(Havuz yalnız otomatik[^']*)'/, /\? '(Havuzda tek preset[^']*)'/, /: '(Havuzda preset yok[^']*)'/]) { + const m = re.exec(PANEL); + assert.ok(m, re + ' panelde yok'); + assert.ok(I.includes("'" + m[1] + "':"), m[1].slice(0, 30) + '… sözlükte yok'); + } + assert.ok(I.includes("'★ MilkDrop · Favori (aç/kapa)':")); +}); From f9745b1096d212869ce074a9b302b8d952283c86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87A=C4=9EAN=20TURGUT?= Date: Tue, 22 Sep 2026 17:19:17 +0300 Subject: [PATCH 2/2] test(milkdrop): run the panel's pack buttons and pin the pool's limits The pack export and import functions behind the MilkDrop panel's buttons had not been executed by any test. They now run from the rendered buttons with only the file dialogs stubbed: the visible (filtered) presets are packed with their records, the same pack imports onto new ids without touching existing ratings, a non-pack file is refused, and an empty view exports nothing. Two documented limits are pinned too: a pick made in advance with the full list is not used once the pool no longer holds it (timer and track change), and prev/next and random still reach presets outside the pool. Refs #576, #560 --- README.md | 4 +- README.tr.md | 4 +- ROADMAP.md | 12 +++--- tests/milkdrop-library.test.js | 79 ++++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 092a02c..72c1304 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-2029%20passing-2ea043.svg)](#tests) +[![Tests](https://img.shields.io/badge/tests-2032%20passing-2ea043.svg)](#tests) [![cayadev.com](https://img.shields.io/badge/cayadev.com-e11d2a.svg)](https://cayadev.com) @@ -1238,7 +1238,7 @@ npm test npm start -- --smoke ``` -**2029 unit tests, all passing.** They are written to check answers, not to exercise lines: +**2032 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 3f33bab..f974c4c 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-2029%20geçiyor-2ea043.svg)](#testler) +[![Test](https://img.shields.io/badge/test-2032%20geçiyor-2ea043.svg)](#testler) [![cayadev.com](https://img.shields.io/badge/cayadev.com-e11d2a.svg)](https://cayadev.com) @@ -1235,7 +1235,7 @@ npm test npm start -- --smoke ``` -**2029 birim testi, hepsi geçiyor.** Satır çalıştırmak için değil, cevap denetlemek için yazıldılar: +**2032 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 8aff202..76f0a9f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,9 +129,9 @@ npm test npm start -- --smoke ``` -- **2029 unit tests, all passing** on `main`. 703 of those shipped in v3.1.0; +- **2032 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 432 + that tag — 469 more with v3.1.4, most of them from the MilkDrop work, and 435 on `main` since. Formulas are checked against values derived by hand from their definitions — Viviani's curve staying on its sphere, the @@ -1609,10 +1609,12 @@ rest after. No version number yet. and tags landed on the new ids and in `settings.json`, and no preset file held a `library` field. The file dialog itself was not driven: the bridge object cannot be stubbed from the page. - - **Tests.** 26 tests cover authors, folding, tags, search, the pool, the + - **Tests.** 29 tests cover authors, folding, tags, search, the pool, the pack round trip, the engine's own `_autoCycle` with a pool (timer, hard - cut, track change, follower) and the panel drawn with a fake DOM. - 42 of 42 mutations are caught. + cut, track change, follower), a pick made in advance that the pool no + longer holds, and the panel drawn with a fake DOM — its own pack buttons + included, with only the file dialogs stubbed, and ◀/▶/🎲 still reaching + presets outside the pool. 49 of 49 mutations are caught. ## v3.1.6 — Comprehensive video export diff --git a/tests/milkdrop-library.test.js b/tests/milkdrop-library.test.js index 0e15864..4ff3a89 100644 --- a/tests/milkdrop-library.test.js +++ b/tests/milkdrop-library.test.js @@ -289,6 +289,22 @@ test('motor: izleyen havuzu süzmüyor — liderin seçimi zaten havuzdan', () = assert.strictEqual(e.m.autoPick && e.m.autoPick.id, 'p5'); }); +test('döngü: önceden yapılmış seçim havuz değişince havuzun dışına taşmıyor', () => { + /* Önceden derleme (#573) sıradakini havuzsuz listeyle seçmiş olabilir; + kullanıcı sonra havuzu favorilere çevirirse o seçim kullanılmamalı. */ + const C = require('../src/shared/milkdrop-cycle.js'); + const md = { autoNext: 1, autoNextRand: 0, blendTime: 0, autoOrder: 'sequential' }; + const pooled = L.pool(PRESETS, { autoFrom: 'favorites' }, { favorites: { p4: true, p5: true } }); + const cy = new C.Cycle(() => 0.5); + assert.strictEqual(cy.upcoming(md, PRESETS, 'p1', null).id, 'p2', 'havuzsuz sıradaki'); + let p = null; + for (let i = 0; i < 200 && !p; i++) p = cy.step(1 / 60, md, pooled, 'p1', null, null, null); + assert.strictEqual(p && p.id, 'p4', 'zamanlayıcı havuzdan'); + const cy2 = new C.Cycle(() => 0.5); + cy2.upcoming(md, PRESETS, 'p1', null); + assert.strictEqual(cy2.onTrack(Object.assign({ trackAdvance: true }, md), pooled, 'p1', null).id, 'p4', 'parça değişimi havuzdan'); +}); + test('motor kaynağı: süzgeç listenin kurulduğu yerde, izleme dalında değil', () => { const MODE = read('src/visualizer/modes/milkdrop.js'); const fn = /_autoCycle\(cfg, step, audio\) \{[\s\S]*?\n \}/.exec(MODE)[0]; @@ -489,6 +505,69 @@ test('panel: havuz seçimi sahneye yazılıyor; boş havuz söyleniyor', async ( assert.deepStrictEqual([sel.props.value, sel.text], ['tag:eski', '#eski (0)']); }); +test('panel: elle ▶ ve 🎲 havuzu dinlemiyor — yalnız otomatik geçiş sınırlı', async () => { + // Belgelerdeki söz: ◀, ▶, Rastgele ve liste bütün presetlere gidiyor + const p = await panelWith((cfg) => { + cfg.milkdrop.autoFrom = 'favorites'; + cfg.milkdropLibrary.favorites = { u5: true }; + }); + p.M.act('Next'); + assert.strictEqual(p.cfg.milkdrop.presetId, 'u2', 'sıradaki, havuzun dışında olsa da'); + const seen = new Set(); + for (let i = 0; i < 40; i++) { p.M.act('Random'); seen.add(p.cfg.milkdrop.presetId); } + assert.ok([...seen].some((id) => id !== 'u5'), 'rastgele havuzun dışından da seçiyor'); +}); + +test('panel: paket düğmeleri — görünenler kayıtlarıyla, içe aktarım yeni kimliklere', async () => { + const p = await panelWith((cfg) => { + cfg.milkdropLibrary.favorites = { u2: true, u3: true }; + cfg.milkdropLibrary.tags = { u3: ['sakin'] }; + cfg.milkdropLibrary.ratings = { u2: 4, u6: 1 }; + }); + const toasts = []; + window.SVPanel.toast = (m) => { toasts.push(m); }; + let packed = null; + window.api.exportJson = (name, data) => { packed = { name, data: JSON.parse(JSON.stringify(data)) }; return Promise.resolve({ ok: true }); }; + const button = (root, text) => { let b = null; walk(root, (n) => { if (!b && n.props && n.props.text === text) b = n; }); return b; }; + // Süzgeç favorilerde: yalnız görünenler pakete giriyor + const show = p.row('Süz').node; + show.value = 'fav'; + show.on.change(); + await button(p.render(), '📦 Görünenleri Paketle').on.click(); + assert.strictEqual(packed.name, 'milkdrop-presetler.svpack'); + assert.deepStrictEqual(packed.data.presets.map((x) => [x.name, x.library || null]), [ + ['Martin - Preset 2', { favorite: true, rating: 4 }], + ['Geiss - Preset 3', { favorite: true, tags: ['sakin'] }], + ]); + // Aynı paket içe aktarılıyor: dosya seçimi ve kayıt taklit, geri kalanı panelin kendisi + const saved = []; + window.api.importShaderText = () => Promise.resolve({ ok: true, text: JSON.stringify(packed.data), ext: '.svpack', name: 'x' }); + window.api.savePresets = (list) => { saved.push(...list); return Promise.resolve({ ok: true, saved: list }); }; + const applied = p.calls.apply; + await button(p.render(), '📥 Paket İçe Aktar').on.click(); + await new Promise((r) => setImmediate(r)); + assert.strictEqual(saved.length, 2); + const [a, b] = saved.map((x) => x.id); + assert.ok(![a, b].some((id) => id === 'u2' || id === 'u3'), 'yeni kimlik'); + assert.ok(saved.every((x) => x.library === undefined), 'kayıt preset dosyasına gitmiyor'); + const lib = p.cfg.milkdropLibrary; + assert.deepStrictEqual([lib.favorites[a], lib.favorites[b], lib.tags[b], lib.ratings[a]], [true, true, ['sakin'], 4]); + assert.strictEqual(lib.ratings.u6, 1, 'var olan puanlar yerinde'); + assert.ok(p.calls.apply > applied, 'yapılandırma gönderildi'); + assert.ok(toasts.includes('2 preset içe aktarıldı.'), toasts.join(' | ')); + // Paket olmayan dosya: hata, kayıt yok + window.api.importShaderText = () => Promise.resolve({ ok: true, text: 'MILKDROP_PRESET_VERSION=201', ext: '.milk', name: 'y' }); + await button(p.render(), '📥 Paket İçe Aktar').on.click(); + assert.strictEqual(saved.length, 2); + assert.ok(toasts.some((t) => /^Paket okunamadı/.test(t))); + // Listede kendi preseti yoksa dışa aktarım yok + p.cfg.milkdropLibrary.favorites = {}; + packed = null; + await button(p.render(), '📦 Görünenleri Paketle').on.click(); + assert.strictEqual(packed, null); + assert.ok(toasts.some((t) => /^Listede dışa aktarılacak/.test(t))); +}); + test('panel: denetleyiciden favori ekrandakine', async () => { const p = await panelWith(); assert.strictEqual(p.M.act('Favorite'), true);