3 Commits

Author SHA1 Message Date
s
4ce8b41a4c chore: release v1.0.9
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 18:04:41 +02:00
s
0562b326c8 fix: volume slider par alerte, défaut 10%, suppression du pre-cache au démarrage
- Chaque alerte a son propre slider volume (0–100%, step 1, défaut 10%)
- Volume très bas possible (0%) — pas de minimum forcé
- Slider volume aussi dans le formulaire d'ajout pour setter dès la création
- Supprime le pre-cache IndexedDB au démarrage qui ralentissait le lancement
- Supprime le slider volume global (remplacé par le per-alerte)
- Migration automatique des anciennes alertes vers volume=10

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 17:40:35 +02:00
s
857a8477d7 feat: système de sons Minecraft configurable par alerte + volume global
- Volume global réglable (slider, défaut 25%) persisté dans localStorage
- Catalogue de 10 sons Minecraft officiels (Level Up, XP Orb, Item Pop, Pling, Bell, Toast, Challenge, Coffre, Hit, Click)
- Sons téléchargés à la demande depuis le CDN Mojang et cachés en IndexedDB
- Chaque alerte de drop a son propre son sélectionnable via dropdown
- Bouton ▶ preview par alerte et dans la bibliothèque (auto-download si pas en cache)
- Bibliothèque de sons repliable : cards avec preview + statut téléchargé
- Lecture via Web Audio API (AudioContext + GainNode) pour contrôle précis du volume
- Migration automatique des anciennes alertConfigs sans soundId (défaut: levelup)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 17:31:20 +02:00
2 changed files with 312 additions and 30 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "farm-tracker",
"version": "1.0.8",
"version": "1.0.9",
"description": "Suivi de loot Minecraft en direct, avec sauvegarde automatique des stats et des chances de drop.",
"main": "main.js",
"scripts": {

View File

@@ -326,6 +326,28 @@
.timer-card-bar{height:3px;background:var(--border);margin-top:10px;overflow:hidden;}
.timer-card-bar-fill{height:100%;transition:width .9s linear;}
.timer-empty-msg{color:var(--text-dim);font-size:13px;padding:20px 0;}
/* --- Volume slider --- */
input[type=range]{-webkit-appearance:none;height:3px;background:var(--border);outline:none;cursor:pointer;flex:1;}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:11px;height:11px;background:var(--moss);cursor:pointer;}
input[type=range]::-moz-range-thumb{width:11px;height:11px;background:var(--moss);cursor:pointer;border:none;}
/* --- Alert config rows --- */
.alert-cfg-row{display:flex;align-items:center;gap:8px;padding:7px 0;border-bottom:1px solid var(--border);}
.alert-cfg-row:last-child{border-bottom:none;}
.alert-cfg-name{flex:1;font-size:12px;font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.alert-cfg-sound{font-size:11px;background:var(--bg);border:1px solid var(--border);color:var(--text);
padding:2px 5px;cursor:pointer;max-width:140px;}
/* --- Sound library --- */
.sound-lib-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:8px;margin-top:10px;}
.sound-card{background:var(--bg);border:1px solid var(--border);padding:10px 12px;
display:flex;flex-direction:column;gap:5px;}
.sound-card-name{font-size:12px;font-weight:600;}
.sound-card-desc{font-size:11px;color:var(--text-dim);}
.sound-card-actions{display:flex;gap:6px;align-items:center;margin-top:2px;}
.sound-card-status{font-size:10px;font-family:var(--font-mono);color:var(--moss);margin-left:auto;}
#chartTip{position:fixed;z-index:1001;background:var(--bg-panel);border:1px solid var(--border);
padding:4px 10px;font-family:var(--font-mono);font-size:11px;color:var(--text);
pointer-events:none;display:none;white-space:nowrap;box-shadow:0 2px 8px rgba(0,0,0,.5);}
@@ -725,15 +747,34 @@
<div class="panel">
<div class="panel-head-row">
<h2>Alertes de drop <span class="muted">— son au drop d'un item</span></h2>
<h2>Alertes de drop</h2>
</div>
<div class="timer-add-row">
<input type="text" id="alertItemInput" placeholder="Nom de l'item (ex: Diamond)" style="flex:2;" list="alertItemSuggestions" autocomplete="off">
<div class="timer-add-row" style="flex-wrap:wrap;gap:6px;">
<input type="text" id="alertItemInput" placeholder="Nom de l'item (ex: Diamond)" style="flex:2;min-width:140px;" list="alertItemSuggestions" autocomplete="off">
<datalist id="alertItemSuggestions"></datalist>
<select id="alertSoundSelect" class="alert-cfg-sound"></select>
<div style="display:flex;align-items:center;gap:5px;">
<span style="font-size:10px;color:var(--text-dim);">Vol</span>
<input type="range" id="alertVolumeInput" min="0" max="100" step="1" value="10" style="width:64px;">
<span id="alertVolumeVal" style="font-size:10px;font-family:var(--font-mono);color:var(--text-dim);min-width:26px;">10%</span>
</div>
<button class="btn-ghost" id="alertAddBtn">Ajouter</button>
</div>
<p style="font-size:11.5px;color:var(--text-dim);margin:8px 0 4px;">Un ping discret est joué à chaque fois que cet item drop.</p>
<div id="alertConfigList" class="timer-config-list"></div>
<p style="font-size:11.5px;color:var(--text-dim);margin:6px 0 4px;">Chaque alerte a son propre son et volume.</p>
<div id="alertConfigList"></div>
</div>
<div class="panel">
<div class="panel-head-row" id="soundLibToggle" style="cursor:pointer;user-select:none;">
<h2>Bibliothèque de sons Minecraft</h2>
<span id="soundLibArrow" style="margin-left:auto;font-size:11px;color:var(--text-dim);">▶ ouvrir</span>
</div>
<div id="soundLibContent" hidden>
<p style="font-size:11.5px;color:var(--text-dim);margin:0 0 8px;">
Sons téléchargés depuis le CDN officiel Mojang. Clic ▶ pour prévisualiser, ⬇ pour télécharger.
</p>
<div class="sound-lib-grid" id="soundLibGrid"></div>
</div>
</div>
</section>
@@ -1392,7 +1433,7 @@
if (alertConfigs.length){
const pn = mcPlain(it.name || '').toLowerCase();
for (const alert of alertConfigs){
if (pn && alert.nameLower && pn.includes(alert.nameLower)){ playAlertPing(); break; }
if (pn && alert.nameLower && pn.includes(alert.nameLower)){ playAlert(alert); break; }
}
}
}
@@ -2177,21 +2218,127 @@
// ======================================================================
let timerConfigs = JSON.parse(localStorage.getItem('timerConfigs') || '[]');
let alertConfigs = JSON.parse(localStorage.getItem('alertConfigs') || '[]');
// ---- Sound catalog ----
const SOUND_CATALOG = [
{ id:'levelup', label:'Level Up XP', desc:'Montée de niveau', hash:'19034765ba8ba5389b35804ab213537ab5cf706f' },
{ id:'orb', label:'XP Orb', desc:'Ramassage d\'orbe XP', hash:'8a04a60d5c28fc60df472a877ca57f37eabc78d7' },
{ id:'pop', label:'Item Pop', desc:'Ramassage d\'item', hash:'d6ae1c04d0a7376a33d1df12e1b8057cfbab6bc2' },
{ id:'pling', label:'Pling', desc:'Note de bloc Pling', hash:'774ae41e86f0b62a5cc961d1bc2e3d0aef9d229c' },
{ id:'bell', label:'Bell', desc:'Note de bloc Bell', hash:'a1e833dec61595dc79d0c672fcd7838579ca4b14' },
{ id:'toast', label:'Toast Avancement', desc:'Notification avancement', hash:'506f2fdb1b7530df66134aa04c71e66513df0c93' },
{ id:'challenge', label:'Défi Terminé', desc:'Challenge complété', hash:'bd01dff39a7bd1e0e7f6847f7aee981f157e4f94' },
{ id:'chestopen', label:'Coffre', desc:'Ouverture de coffre', hash:'186d5d9481d59cc99bc4be1b5fbb98d0ef877b8e' },
{ id:'hit', label:'Hit', desc:'Coup réussi', hash:'57f50076e7b91b12595a17cf0d38303f979f862b' },
{ id:'click', label:'Click', desc:'Clic de bouton', hash:'3455ca942556c7d5eac7dd5e458e7fb3bad564c9' },
];
// ---- IndexedDB cache pour sons téléchargés ----
let _soundDb = null;
function openSoundDb(){
if (_soundDb) return Promise.resolve(_soundDb);
return new Promise((res, rej) => {
const req = indexedDB.open('FarmTrackerSounds', 1);
req.onupgradeneeded = e => e.target.result.createObjectStore('sounds', { keyPath: 'id' });
req.onsuccess = e => { _soundDb = e.target.result; res(_soundDb); };
req.onerror = e => rej(e.target.error);
});
}
async function isSoundCached(soundId){
try {
const db = await openSoundDb();
return new Promise(res => {
const req = db.transaction('sounds','readonly').objectStore('sounds').get(soundId);
req.onsuccess = () => res(!!req.result);
req.onerror = () => res(false);
});
} catch { return false; }
}
async function getSoundBytes(soundId){
const db = await openSoundDb();
return new Promise((res, rej) => {
const req = db.transaction('sounds','readonly').objectStore('sounds').get(soundId);
req.onsuccess = () => res(req.result ? req.result.buffer : null);
req.onerror = () => rej(req.error);
});
}
async function saveSoundBytes(soundId, buffer){
const db = await openSoundDb();
return new Promise((res, rej) => {
const tx = db.transaction('sounds','readwrite');
tx.objectStore('sounds').put({ id: soundId, buffer });
tx.oncomplete = res;
tx.onerror = () => rej(tx.error);
});
}
async function downloadSound(soundId, onProgress){
const entry = SOUND_CATALOG.find(s => s.id === soundId);
if (!entry) throw new Error('Unknown sound: ' + soundId);
const prefix = entry.hash.slice(0, 2);
const url = `https://resources.download.minecraft.net/${prefix}/${entry.hash}`;
const resp = await fetch(url);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const buffer = await resp.arrayBuffer();
await saveSoundBytes(soundId, buffer);
_audioBufferCache.delete(soundId);
return buffer;
}
// ---- Web Audio playback ----
const _audioBufferCache = new Map();
let _audioCtx = null;
function getAudioCtx(){
if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
return _audioCtx;
}
async function _getDecodedBuffer(soundId){
if (_audioBufferCache.has(soundId)) return _audioBufferCache.get(soundId);
let bytes = await getSoundBytes(soundId);
if (!bytes && soundId === 'levelup'){
// fallback : fichier bundlé dans renderer/
const r = await fetch('levelup.ogg');
bytes = await r.arrayBuffer();
await saveSoundBytes('levelup', bytes);
}
if (!bytes) return null;
const ctx = getAudioCtx();
const decoded = await ctx.decodeAudioData(bytes.slice(0));
_audioBufferCache.set(soundId, decoded);
return decoded;
}
async function playAlert(cfg){
try {
const soundId = cfg ? (cfg.soundId || 'levelup') : 'levelup';
const vol = Math.max(0, Math.min(1, ((cfg && cfg.volume != null) ? cfg.volume : 10) / 100));
const buf = await _getDecodedBuffer(soundId);
if (!buf){ console.warn('Sound not cached:', soundId); return; }
const ctx = getAudioCtx();
const src = ctx.createBufferSource();
const gain = ctx.createGain();
src.buffer = buf;
src.connect(gain);
gain.connect(ctx.destination);
gain.gain.value = vol;
src.start();
} catch(e){ console.warn('playAlert failed', e); }
}
// migration : ajoute soundId et volume si absents
let alertConfigs = JSON.parse(localStorage.getItem('alertConfigs') || '[]').map(c => ({
soundId: 'levelup', volume: 10, ...c
}));
function saveAlertConfigs(){
localStorage.setItem('alertConfigs', JSON.stringify(alertConfigs));
}
let _alertAudio = null;
function playAlertPing(){
try {
if (!_alertAudio) _alertAudio = new Audio('levelup.ogg');
_alertAudio.currentTime = 0;
_alertAudio.play().catch(e => console.warn('Alert ping failed', e));
} catch(e){ console.warn('Alert ping failed', e); }
}
// activeTimers : Map<configId, { endsAt: number, durationSec: number, mobName: string }>
const activeTimers = new Map();
let timerClockId = null;
@@ -2307,6 +2454,7 @@
populateTimerMobSuggestions();
renderAlertConfigList();
populateAlertItemSuggestions();
populateAddSoundSelect();
startTimerClock();
}
@@ -2336,16 +2484,38 @@
populateTimerMobSuggestions();
});
function renderAlertConfigList(){
// ---- Alert config list ----
function buildSoundOptions(selectedId){
return SOUND_CATALOG.map(s =>
'<option value="'+escHtml(s.id)+'"'+(s.id === selectedId ? ' selected' : '')+'>'+escHtml(s.label)+'</option>'
).join('');
}
async function renderAlertConfigList(){
const list = $('alertConfigList');
if (!list) return;
if (!alertConfigs.length){ list.innerHTML = ''; return; }
list.innerHTML = alertConfigs.map(cfg =>
'<div class="timer-config-row">'+
'<span class="timer-config-mob">'+escHtml(cfg.itemName)+'</span>'+
'<button class="btn-ghost small danger" data-del-alert="'+escHtml(cfg.id)+'">✕</button>'+
'</div>'
).join('');
const cachedIds = new Set();
for (const s of SOUND_CATALOG){
if (await isSoundCached(s.id)) cachedIds.add(s.id);
}
list.innerHTML = alertConfigs.map(cfg => {
const sid = cfg.soundId || 'levelup';
const opts = SOUND_CATALOG.map(s => {
const notCached = !cachedIds.has(s.id) && s.id !== 'levelup';
const label = s.label + (notCached ? ' (⬇)' : '');
return '<option value="'+escHtml(s.id)+'"'+(s.id === sid ? ' selected' : '')+'>'+escHtml(label)+'</option>';
}).join('');
const vol = cfg.volume != null ? cfg.volume : 10;
return '<div class="alert-cfg-row">'+
'<span class="alert-cfg-name">'+escHtml(cfg.itemName)+'</span>'+
'<select class="alert-cfg-sound" data-alert-sound="'+escHtml(cfg.id)+'">'+opts+'</select>'+
'<input type="range" min="0" max="100" step="1" value="'+vol+'" data-alert-vol="'+escHtml(cfg.id)+'" style="width:64px;" title="Volume: '+vol+'%">'+
'<span data-alert-vol-label="'+escHtml(cfg.id)+'" style="font-size:10px;font-family:var(--font-mono);color:var(--text-dim);min-width:26px;">'+vol+'%</span>'+
'<button class="btn-ghost small" data-preview-alert="'+escHtml(cfg.id)+'" title="Prévisualiser">▶</button>'+
'<button class="btn-ghost small danger" data-del-alert="'+escHtml(cfg.id)+'">✕</button>'+
'</div>';
}).join('');
}
function populateAlertItemSuggestions(){
@@ -2370,25 +2540,137 @@
.join('');
}
function populateAddSoundSelect(){
const sel = $('alertSoundSelect');
if (!sel) return;
sel.innerHTML = buildSoundOptions('levelup');
}
// slider volume dans le formulaire d'ajout
$('alertVolumeInput').addEventListener('input', () => {
$('alertVolumeVal').textContent = $('alertVolumeInput').value + '%';
});
$('alertAddBtn').addEventListener('click', () => {
const itemName = $('alertItemInput').value.trim();
if (!itemName) return;
alertConfigs.push({ id: generateUUID(), itemName, nameLower: itemName.toLowerCase() });
const soundId = $('alertSoundSelect').value || 'levelup';
const volume = parseInt($('alertVolumeInput').value, 10);
alertConfigs.push({ id: generateUUID(), itemName, nameLower: itemName.toLowerCase(), soundId, volume });
saveAlertConfigs();
$('alertItemInput').value = '';
renderAlertConfigList();
populateAlertItemSuggestions();
if (soundId !== 'levelup') isSoundCached(soundId).then(ok => { if (!ok) downloadSound(soundId).catch(()=>{}); });
});
$('alertItemInput').addEventListener('keydown', e => { if (e.key === 'Enter') $('alertAddBtn').click(); });
$('alertConfigList').addEventListener('click', e => {
const id = e.target.dataset.delAlert;
$('alertConfigList').addEventListener('click', async e => {
const delId = e.target.dataset.delAlert;
if (delId){
alertConfigs = alertConfigs.filter(c => c.id !== delId);
saveAlertConfigs();
await renderAlertConfigList();
populateAlertItemSuggestions();
return;
}
const prevId = e.target.dataset.previewAlert;
if (prevId){
const cfg = alertConfigs.find(c => c.id === prevId);
if (cfg){
const soundId = cfg.soundId || 'levelup';
const cached = await isSoundCached(soundId) || soundId === 'levelup';
if (!cached){
e.target.textContent = '⬇';
await downloadSound(soundId).catch(err => console.warn('Download failed', err));
await renderAlertConfigList();
renderSoundLibrary();
}
playAlert(cfg);
}
}
});
$('alertConfigList').addEventListener('change', e => {
const soundId = e.target.dataset.alertSound;
if (soundId){
const cfg = alertConfigs.find(c => c.id === soundId);
if (!cfg) return;
cfg.soundId = e.target.value;
saveAlertConfigs();
if (cfg.soundId !== 'levelup') isSoundCached(cfg.soundId).then(ok => { if (!ok) downloadSound(cfg.soundId).catch(()=>{}); });
return;
}
});
$('alertConfigList').addEventListener('input', e => {
const id = e.target.dataset.alertVol;
if (!id) return;
alertConfigs = alertConfigs.filter(c => c.id !== id);
const cfg = alertConfigs.find(c => c.id === id);
if (!cfg) return;
cfg.volume = parseInt(e.target.value, 10);
saveAlertConfigs();
renderAlertConfigList();
populateAlertItemSuggestions();
const label = document.querySelector('[data-alert-vol-label="'+id+'"]');
if (label) label.textContent = cfg.volume + '%';
});
// ---- Sound library ----
$('soundLibToggle').addEventListener('click', () => {
const content = $('soundLibContent');
const arrow = $('soundLibArrow');
const open = content.hidden;
content.hidden = !open;
arrow.textContent = open ? '▼ fermer' : '▶ ouvrir';
if (open) renderSoundLibrary();
});
async function renderSoundLibrary(){
const grid = $('soundLibGrid');
if (!grid) return;
const cards = await Promise.all(SOUND_CATALOG.map(async s => {
const cached = await isSoundCached(s.id) || s.id === 'levelup';
return '<div class="sound-card" data-sound-id="'+escHtml(s.id)+'">'+
'<span class="sound-card-name">'+escHtml(s.label)+'</span>'+
'<span class="sound-card-desc">'+escHtml(s.desc)+'</span>'+
'<div class="sound-card-actions">'+
'<button class="btn-ghost small" data-preview-sound="'+escHtml(s.id)+'" title="Prévisualiser">▶ Preview</button>'+
(cached
? '<span class="sound-card-status">✓ Téléchargé</span>'
: '<button class="btn-ghost small" data-dl-sound="'+escHtml(s.id)+'">⬇ Télécharger</button>')+
'</div>'+
'</div>';
}));
grid.innerHTML = cards.join('');
}
$('soundLibGrid').addEventListener('click', async e => {
const dlId = e.target.dataset.dlSound;
if (dlId){
e.target.textContent = '…';
e.target.disabled = true;
try {
await downloadSound(dlId);
await renderSoundLibrary();
await renderAlertConfigList();
populateAddSoundSelect();
} catch(err){
e.target.textContent = '✗ Erreur';
console.warn('Sound download failed', err);
}
return;
}
const prevId = e.target.dataset.previewSound;
if (prevId){
const cached = await isSoundCached(prevId) || prevId === 'levelup';
if (!cached){
e.target.textContent = '⬇…';
await downloadSound(prevId).catch(err => console.warn('dl failed', err));
await renderSoundLibrary();
await renderAlertConfigList();
}
playAlert({ soundId: prevId });
}
});
updateTimerTileStatus();