Files
pipu-music/docs/Разработка дизайна приложения/pipu-data.js
T
mrleo1nidandClaude Opus 5.5 9eb1c25147 docs: add product idea, design prototypes and development documentation
- IDEA.md: product vision from the user's perspective (input for design)
- Architecture, ADRs, data model, REST API overview
- Auth and roles, library storage (NFS, path regexes, scanner, search),
  playback, recommendation system ("My Wave"), track upload queue
- Web, Android (offline, self-update), i18n, design system
- Testing strategy, Gitea Actions CI/CD, deployment, conventions, roadmap
- Clickable web/Android design prototypes and wave button variants
- .claude/launch.json to serve the prototypes locally

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:21:31 +03:00

105 lines
9.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
const H = s => { let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; };
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const fmt = s => { s = Math.max(0, Math.floor(s)); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
const plural = (n, f) => { const a = n % 10, b = n % 100; return f[(a === 1 && b !== 11) ? 0 : (a >= 2 && a <= 4 && (b < 10 || b >= 20)) ? 1 : 2]; };
const AR = {
kino: ['Кино', 'Русский рок', 0.62], zemfira: ['Земфира', 'Рок', 0.55], radiohead: ['Radiohead', 'Альтернатива', 0.45],
massive: ['Massive Attack', 'Трип-хоп', 0.38], moderat: ['Moderat', 'Электроника', 0.66], bonobo: ['Bonobo', 'Даунтемпо', 0.48],
daftpunk: ['Daft Punk', 'Электроника', 0.78], splean: ['Сплин', 'Русский рок', 0.56]
};
const AL = [
['rh-ir', 'In Rainbows', 'radiohead', 2007, '#F2A23A', '#1C3B6E', ['15 Step', 'Bodysnatchers', 'Nude', 'Weird Fishes/Arpeggi', 'All I Need', 'Faust Arp', 'Reckoner', 'House of Cards', 'Jigsaw Falling into Place', 'Videotape']],
['kino-gk', 'Группа крови', 'kino', 1988, '#C2412D', '#2A0E0A', ['Группа крови', 'Закрой за мной дверь, я ухожу', 'Война', 'Спокойная ночь', 'Мама, мы все сошли с ума', 'Бошетунмай', 'Попробуй спеть вместе со мной', 'Звезда']],
['kino-zs', 'Звезда по имени Солнце', 'kino', 1989, '#E39B2D', '#3A1A05', ['Звезда по имени Солнце', 'Песня без слов', 'Место для шага вперёд', 'Невесёлая песня', 'Апрель', 'Пачка сигарет']],
['zem-pm', 'Прости меня моя любовь', 'zemfira', 2000, '#D9467A', '#2B0B23', ['Хочешь?', 'Искала', 'Ромашки', 'Непошлое', 'Прости меня моя любовь', 'Город']],
['ma-mz', 'Mezzanine', 'massive', 1998, '#6A7385', '#0E1116', ['Angel', 'Risingson', 'Teardrop', 'Inertia Creeps', 'Exchange', 'Dissolved Girl']],
['mod-2', 'II', 'moderat', 2013, '#3A7BFF', '#0A1636', ['The Mark', 'Bad Kingdom', 'Versions', 'Let in the Light', 'Milk', 'Damage Done']],
['bon-mg', 'Migration', 'bonobo', 2017, '#2FB6A0', '#0B2730', ['Migration', 'Break Apart', 'Outlier', 'Kerala', 'Grains', 'No Reason']],
['dp-ram', 'Random Access Memories', 'daftpunk', 2013, '#A7AFBF', '#141821', ['Give Life Back to Music', 'The Game of Love', 'Instant Crush', 'Lose Yourself to Dance', 'Get Lucky', "Doin' It Right"]],
['spl-ga', 'Гранатовый альбом', 'splean', 1998, '#B4233A', '#1D0710', ['Орбит без сахара', 'Пластмассовая жизнь', 'Иди через лес', 'Феллини']]
];
const ART = {}, ALB = {}, TR = {}, ALL = [];
Object.keys(AR).forEach(k => { ART[k] = { id: k, name: AR[k][0], genre: AR[k][1], energy: AR[k][2], albums: [], tracks: [] }; });
AL.forEach(([id, title, ar, year, c1, c2, list]) => {
const cover = `radial-gradient(circle at 28% 22%, rgba(255,255,255,.32), rgba(255,255,255,0) 55%), linear-gradient(140deg, ${c1}, ${c2})`;
ALB[id] = { id, title, artistId: ar, year, c1, c2, cover, tracks: [] };
const A = ART[ar];
A.albums.push(id);
if (!A.c1) { A.c1 = c1; A.c2 = c2; A.avatar = `radial-gradient(circle at 35% 28%, ${c1}, ${c2} 78%)`; A.banner = `radial-gradient(ellipse at 20% 0%, ${c1}55, transparent 60%), linear-gradient(180deg, ${c2}cc, transparent)`; }
list.forEach((t, i) => {
const h = H(id + t), tid = id + '-' + i;
TR[tid] = { id: tid, title: t, artistId: ar, albumId: id, n: i + 1, dur: 150 + h % 170, bpm: 72 + h % 74, energy: clamp(AR[ar][2] + ((h >> 3) % 40 - 20) / 100, 0.05, 0.98), pop: h % 100 };
ALB[id].tracks.push(tid); A.tracks.push(tid); ALL.push(tid);
});
});
const ACT = [['run', 'Бегаю', 'directions_run', 0.85], ['wake', 'Просыпаюсь', 'wb_twilight', 0.55], ['focus', 'Работаю', 'center_focus_strong', 0.4], ['gym', 'Тренировка', 'fitness_center', 0.92], ['road', 'В дороге', 'directions_car', 0.6], ['rest', 'Отдыхаю', 'weekend', 0.35], ['sleep', 'Засыпаю', 'bedtime', 0.15]];
const MOOD = [['energy', 'Энергичное', 'bolt', 0.85, '#2F80FF', '#35D3FF', '#7CF0FF'], ['happy', 'Весёлое', 'sentiment_satisfied', 0.7, '#2F80FF', '#35D3FF', '#9B7CFF'], ['calm', 'Спокойное', 'spa', 0.3, '#2F80FF', '#6C7CFF', '#5AD1E6'], ['sad', 'Грустное', 'water_drop', 0.22, '#3B5BFF', '#7C5CFF', '#24317A']];
function waveBatch(n, opts) {
const { activity, mood, dislikes = [], likes = [], recent = [], artistId } = opts || {};
const tg = [];
if (activity) tg.push(ACT.find(a => a[0] === activity)[3]);
if (mood) tg.push(MOOD.find(m => m[0] === mood)[3]);
const target = tg.length ? tg.reduce((a, b) => a + b, 0) / tg.length : 0.55;
return ALL.filter(id => !dislikes.includes(id) && !recent.includes(id))
.map(id => {
const t = TR[id];
let sc = -Math.abs(t.energy - target) * 1.4 + (likes.includes(id) ? 0.2 : 0) + Math.random() * 0.35;
if (artistId) sc += t.artistId === artistId ? 0.8 : (ART[t.artistId].genre === ART[artistId].genre ? 0.35 : 0);
return [id, sc];
})
.sort((a, b) => b[1] - a[1]).slice(0, n).map(x => x[0]);
}
const EN = "qwertyuiop[]asdfghjkl;'zxcvbnm,.`", RU = 'йцукенгшщзхъфывапролджэячсмитьбюё';
const swapLayout = q => q.split('').map(c => { const l = c.toLowerCase(); const i = EN.indexOf(l); if (i >= 0) return RU[i]; const j = RU.indexOf(l); if (j >= 0) return EN[j]; return c; }).join('');
const TL = { а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ж: 'zh', з: 'z', и: 'i', й: 'y', к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f', х: 'h', ц: 'c', ч: 'ch', ш: 'sh', щ: 'sch', ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya' };
const norm = s => s.toLowerCase().replace(/ё/g, 'е').replace(/[^a-zа-я0-9 ]/g, ' ').replace(/\s+/g, ' ').trim();
const tl = s => s.split('').map(c => TL[c] ?? c).join('');
const lev = (a, b) => { const d = Array.from({ length: a.length + 1 }, (_, i) => [i]); for (let j = 1; j <= b.length; j++) d[0][j] = j; for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)); return d[a.length][b.length]; };
const m1 = (n, q) => { if (n.includes(q)) return true; if (q.length >= 4) return n.split(' ').some(w => lev(w.slice(0, q.length), q) <= 1) || lev(n.slice(0, q.length), q) <= 1; return false; };
const match = (name, q) => { const n = norm(name), qq = norm(q); if (!qq) return false; return m1(n, qq) || m1(tl(n), tl(qq)); };
function searchRaw(q) {
const artists = Object.values(ART).filter(a => match(a.name, q));
const albums = Object.values(ALB).filter(a => match(a.title, q) || match(ART[a.artistId].name, q));
const tracks = ALL.filter(id => match(TR[id].title, q) || match(ART[TR[id].artistId].name, q)).sort((a, b) => TR[b].pop - TR[a].pop);
return { artists, albums, tracks };
}
function search(q) {
let r = searchRaw(q), fixed = null;
if (q.trim() && !r.artists.length && !r.albums.length && !r.tracks.length) {
const q2 = swapLayout(q), r2 = searchRaw(q2);
if (r2.artists.length || r2.albums.length || r2.tracks.length) { r = r2; fixed = q2; }
}
return { ...r, fixed };
}
const sanitize = v => String(v ?? '').replace(/[\/\\:*?"<>|]/g, '_').replace(/[. ]+$/, '');
function fillTpl(tpl, f) {
let out = tpl.replace(/\[([^\]]*)\]/g, (m, inner) => [...inner.matchAll(/\{(\w+)(?::[^}]*)?\}/g)].every(x => f[x[1]]) ? inner : '');
return out.replace(/\{(\w+)(?::([^}]*))?\}/g, (m, k, mod) => {
let v = f[k]; if (k === 'albumartist' && !v) v = f.artist;
v = v == null ? '' : (k === 'ext' ? v : sanitize(v));
if (mod === '02') v = String(v).padStart(2, '0');
if (mod === '1-') v = v ? v + '-' : '';
return v;
});
}
const GC = { artist: '#5E9BFF', albumartist: '#8FB4FF', album: '#A08BFF', year: '#FFB547', disc: '#FF8FB1', track: '#35D3FF', title: '#2FD39A', genre: '#FF9F5A', skip: '#FF5C70' };
const GL = { artist: 'исполнитель', albumartist: 'исп. альбома', album: 'альбом', year: 'год', disc: 'диск', track: '№', title: 'название', genre: 'жанр', skip: 'пропуск' };
function compile(p) { try { return new RegExp(p.replace(/\(\?P</g, '(?<'), 'd'); } catch (e) { return e; } }
function tokens(p) { return p.split(/(\(\?P<\w+>)/).filter(Boolean).map(t => { const m = t.match(/^\(\?P<(\w+)>$/); return { t, c: m ? (GC[m[1]] || 'var(--accent2)') : 'var(--text)', w: m ? '600' : '400' }; }); }
function segments(path, m) {
const g = m.indices && m.indices.groups ? Object.entries(m.indices.groups).filter(([, v]) => v).sort((a, b) => a[1][0] - b[1][0]) : [];
const out = []; let i = 0;
g.forEach(([k, [s, e]]) => { if (s > i) out.push({ t: path.slice(i, s), c: 'var(--text3)' }); out.push({ t: path.slice(s, e), c: GC[k] || 'var(--text)' }); i = e; });
if (i < path.length) out.push({ t: path.slice(i), c: 'var(--text3)' });
return out;
}
window.PIPU = { H, clamp, fmt, plural, ART, ALB, TR, ALL, ACT, MOOD, waveBatch, search, swapLayout, fillTpl, sanitize, compile, tokens, segments, GC, GL };
})();