FIX: sửa lỗi lưu thay đổi trong SECTION-TAB thì cũng tạo duration trên MAIN SESSION

This commit is contained in:
2026-08-07 11:08:39 +07:00
parent c1aa1eb4d4
commit 173a1f227b
4 changed files with 187 additions and 60 deletions
+82 -37
View File
@@ -6923,7 +6923,18 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
const [activeRollTool, setActiveRollTool] = React.useState('select');
const [renderTick, setRenderTick] = React.useState(0);
const [ccMode, setCcMode] = React.useState('velocity');
const [rollZoom, setRollZoom] = React.useState(60); // local horizontal zoom factor
const [rollZoom, setRollZoom] = React.useState(() => {
// Persist zoom piano roll qua phiên (localStorage) user yêu cu gi kích
// thưc zoom phiên làm vic trưc.
try {
const v = parseFloat(localStorage.getItem('sf_pr_zoom'));
if (isFinite(v) && v >= 15 && v <= 250) return v;
} catch (e) { }
return 60;
}); // local horizontal zoom factor
React.useEffect(() => {
try { localStorage.setItem('sf_pr_zoom', String(rollZoom)); } catch (e) { }
}, [rollZoom]);
const [aiBarStart, setAiBarStart] = React.useState(0);
const [aiBarEnd, setAiBarEnd] = React.useState(4);
@@ -9264,7 +9275,7 @@ const serializeProjectToSchema = (projectId, name, bpmVal, tracksList, subTabsLi
time_signature_numerator: 4,
time_signature_denominator: 4,
sample_rate: 44100,
zoom: window.__currentZoom || 1.0
zoom: window.__sfTimelineZoom || window.__currentZoom || 1.0
},
main_session: {
id: "main",
@@ -13917,6 +13928,10 @@ const App = () => {
});
React.useEffect(() => {
try { localStorage.setItem('sf_zoom', String(zoom)); } catch (e) { }
// Zoom TIMELINE (App) serialize project dùng cái này (window.__currentZoom
// là zoom ca Media Explorer lưu nhm làm project load ra zoom 1.0
// timeline thu nh góc user phi zoom in mi v đúng).
window.__sfTimelineZoom = zoom;
}, [zoom]);
const [isLoopingSelection, setIsLoopingSelection] = useState(false);
const [beginBar, setBeginBar] = useState(0);
@@ -14856,6 +14871,10 @@ const App = () => {
const prevTab = prevActiveTabRef.current;
prevActiveTabRef.current = activeTab;
updateSfRouting();
// Log chn đoán zoom khi đi tab (user: zoom reset khi chuyn tab)
if (prevTab !== activeTab) {
console.log('[Zoom] đổi tab', prevTab, '→', activeTab, '| zoom=', zoom, '| minZoom=', (minZoom || 0).toFixed(2));
}
// Tab DEACTIVE stop âm ca tab đó (user requirement): ri khi sub-tab
// (PIANO_ROLL/section/audio tab) đang play stop playback ca tab đó.
// Tab mi hot đng bình thưng; MAIN gi hành vi cũ (m piano-roll lúc
@@ -15204,7 +15223,9 @@ const App = () => {
restoredBpm = result.bpm;
restoredSessionTabs = result.sessionTabs;
restoredSubTabs = result.subTabs;
if (result.zoom && setZoom) setZoom(result.zoom);
// B qua zoom 1 (data cũ lưu 1.0 li __currentZoom ca Media
// Explorer) áp dng mi gi zoom hin ti/localStorage hết thu nh.
if (result.zoom > 1 && setZoom) setZoom(result.zoom);
if (result.masteringSettings) setMasteringSettings(result.masteringSettings);
} else {
restoredTracks = (parsed.tracks || []).map(function(t) {
@@ -16644,9 +16665,6 @@ const App = () => {
const handleSaveSectionTab = async (tabId) => {
const tab = sessionTabs.find(s => s.id === tabId);
if (!tab) return;
const bpmVal = parseInt(bpm) || 120;
const secondsPerBeat = 60.0 / bpmVal;
const secondsPerBar = secondsPerBeat * 4;
// Upload buffer-only clips (chưa có serverFileId upload sm tht bi/
// race) TRƯC khi ghi vào section item serialized AUDIO_ITEM có file
@@ -16683,19 +16701,6 @@ const App = () => {
}
return nextTr;
}) : [];
let maxEndTime = 0;
(contentTracks || []).forEach(tr => {
(tr.clips || []).forEach(c => {
const end = (c.startTime || 0) + (c.buffer ? c.buffer.duration / (c.speed || 1.0) : 4);
if (end > maxEndTime) maxEndTime = end;
});
(tr.midiItems || []).forEach(m => {
const end = (m.startTime || 0) + (m.duration || 4);
if (end > maxEndTime) maxEndTime = end;
});
});
const durationSec = Math.max(maxEndTime, 4 * secondsPerBar);
// Đm bo mi clip có serverFileId (upload buffer-only trưc khi lưu)
await ensureClipsUploaded(contentTracks);
@@ -16708,7 +16713,11 @@ const App = () => {
return {
...s,
name: tab.name,
duration: durationSec,
// KHÔNG ghi đè duration: MAIN SESSION ch play ĐÚNG duration
// ca section item (user đã resize th công) mc k SECTION-TAB
// dài bao nhiêu. Trưc đây duration = durationSec (theo content
// tab) item t giãn/thu li mi ln lưu mt kích thưc user
// set. Play MAIN đã clamp theo sec.duration (19154/19191).
tracks: contentTracks
};
})
@@ -17981,6 +17990,34 @@ const App = () => {
let max;
if (activeTab === 'main') {
max = computeMainSessionEndTime(activeTracks);
// Content-min CH khi nút LOOP BT (user: projectEnd ch kích hot khi
// LOOP bt play loop liên tc ti content end): content NGN hơn item
// loop ngn đúng content; content DÀI hơn item loop ti item end.
// LOOP TT play 1 ln ti END ITEMS (computeMainSessionEndTime
// không ct sm theo content).
if (isLoopingSelection) {
(activeTracks || []).forEach(_t => (_t.sections || []).forEach(_s => {
const _tab = sessionTabs.find(st => st.sectionId === _s.sectionId || st.sectionId === _s.id);
let _contentEnd = 0;
if (_tab) {
(_tab.tracks || []).forEach(_tr => {
(_tr.clips || []).forEach(_c => {
const _e = (_c.startTime || 0) + (_c.buffer ? _c.buffer.duration / (_c.speed || 1.0) : 4);
if (_e > _contentEnd) _contentEnd = _e;
});
(_tr.midiItems || []).forEach(_m => {
const _e = (_m.startTime || 0) + (_m.duration || 4);
if (_e > _contentEnd) _contentEnd = _e;
});
});
}
if (_contentEnd > 0) {
const _itemEnd = (_s.start || 0) + (_s.duration || 0);
const _realEnd = (_s.start || 0) + Math.min(_s.duration || 0, _contentEnd);
if (_realEnd < _itemEnd && max <= _itemEnd + 0.001) max = _realEnd;
}
}));
}
} else {
const tab = sessionTabs.find(st => st.id === activeTab);
const tabMax = tab ? computeMainSessionEndTime(tab.tracks || []) : 0;
@@ -17997,7 +18034,7 @@ const App = () => {
max = Math.max(max, currentTime + 60);
}
return Math.max(1, max);
}, [activeTab, activeTracks, sessionTabs, recordingState, currentTime]);
}, [activeTab, activeTracks, sessionTabs, recordingState, currentTime, isLoopingSelection]);
const projectEndRef = useRef(projectEnd);
projectEndRef.current = projectEnd;
const minZoom = useMemo(() => {
@@ -18007,7 +18044,15 @@ const App = () => {
return Math.max(zoom * (maxDuration + leadInMargin), viewportWidth);
}, [zoom, maxDuration, viewportWidth, leadInMargin]);
useEffect(() => {
// KHÔNG ép minZoom khi activeTab là SUB-TAB (PIANO ROLL/audio):
// maxDuration tính theo activeTab=sub-tab ch còn 12 bars buffer
// minZoom LN ép zoom lên (log: vào midi_ ép 32.7837.54) quay li
// MAIN/SECTION zoom gi giá tr b ép (KHÔNG khôi phc) = "zoom reset khi
// chuyn tab". Timeline không hin th sub-tab ép vô nghĩa.
const _tabNow = activeTabRef.current;
if (_tabNow !== 'main' && !(_tabNow && _tabNow.startsWith('session_'))) return;
if (zoom < minZoom) {
console.log('[Zoom] ép lên minZoom=', minZoom.toFixed(2), '(zoom=', zoom.toFixed(2), ') tab=', activeTab, 'maxDuration=', maxDuration.toFixed(2));
setZoom(minZoom);
}
}, [minZoom]);
@@ -18680,6 +18725,7 @@ const App = () => {
// Hết bài: dng/loop li ti ENDTIME THT ca session (projectEnd không
// buffer 12 bars như maxDuration dùng cho scroll/zoom).
if (updatedTime >= projectEndRef.current) {
console.log('[Stop] projectEnd reached — updatedTime=', updatedTime.toFixed(2), 'projectEnd=', projectEndRef.current.toFixed(2), 'isLoopingSelection=', isLoopingSelection);
if (recordingStateRef.current === 'RECORDING') {
setCurrentTime(updatedTime);
animationFrameIdRef.current = requestAnimationFrame(updatePlayhead);
@@ -19197,7 +19243,7 @@ const App = () => {
// Capture items signature at schedule time updatePlayhead so sánh vi
// signature này đ phát hin items đi v trí gia lúc play (re-schedule).
scheduledItemsSigRef.current = buildItemsSignature(allPlayTracks, sessionTabs);
console.log('[Play] offset=' + offsetTime + ' tracks=' + allPlayTracks.length + ' masterBus=' + !!masterBus + ' dest=' + (context.destination ? 'ok' : 'MISSING'));
console.log('[Play] offset=' + offsetTime + ' tracks=' + allPlayTracks.length + ' masterBus=' + !!masterBus + ' dest=' + (context.destination ? 'ok' : 'MISSING') + ' projectEnd=' + (projectEndRef.current || 0).toFixed(2));
const hasSolo = allPlayTracks.some(t => t.solo);
allPlayTracks.forEach(track => {
const isPlayable = hasSolo ? track.solo : !track.muted;
@@ -24980,7 +25026,9 @@ STRICT CONSTRAINTS:
restoredBpm = result.bpm;
restoredSessionTabs = result.sessionTabs;
restoredSubTabs = result.subTabs;
if (result.zoom && setZoom) setZoom(result.zoom);
// B qua zoom 1 (data cũ lưu 1.0 li __currentZoom ca Media
// Explorer) áp dng mi gi zoom hin ti/localStorage.
if (result.zoom > 1 && setZoom) setZoom(result.zoom);
if (result.masteringSettings) setMasteringSettings(result.masteringSettings);
} else {
restoredTracks = (parsed.tracks || []).map(function (t) {
@@ -25784,22 +25832,19 @@ STRICT CONSTRAINTS:
setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, selectionStart: 0, selectionEnd: loopEndTime } : s));
}
} else {
// Main timeline / section tab: if the user already selected a loop
// region, KEEP it only auto-derive (0 content end) when no
// selection exists yet. Previously this always overwrote the
// selection with 0 (contentEnd + 2 bars).
// Main timeline / section tab: nếu user đã chn vùng loop riêng
// gi nguyên (loop vùng chn nhánh updatePlayhead selLeft/
// selRight). KHÔNG auto-derive selection: LOOP bt loop ti
// projectEnd (content end khi LOOP 0515/0520) nhánh
// updatePlayhead `updatedTime >= projectEndRef` loop li t 0
// tránh selection 0end items ép loop toàn bài (sai ý "loop ti
// projectEnd").
const hasSel = (selectionMode === 'local' && selLeft !== null && selRight !== null && selRight > selLeft)
|| (selectionStart !== null && selectionEnd !== null);
if (!hasSel) {
// Auto-derive loop region = ĐÚNG endtime ca session (items trên
// track main: clips theo buffer.duration/speed, midiItems, section
// bounds) KHÔNG cng thêm bars buffer (loop không đưc dài hơn
// duration hin có).
const maxEnd = computeMainSessionEndTime(activeTracks);
if (maxEnd > 0) {
setSelectionStart(0);
setSelectionEnd(maxEnd);
}
if (hasSel) {
// gi selection loop vùng chn
} else {
// không selection loop ti projectEnd (không to selection)
}
}
},
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -24,7 +24,7 @@
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
<script src="/static/js/app.precompiled.js?v=202608070445" defer></script>
<script src="/static/js/app.precompiled.js?v=202608070520" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style>
:root {
+56
View File
@@ -2453,3 +2453,59 @@
- **FIX (app.jsx):** effect `[isPlaying]` mới — detect transition playing→dừng (prevIsPlayingRef): nếu đang ở PIANO ROLL tab (`midi_*`) + tab KHÔNG play → `setSubTabs currentTime = 0` — playhead về đầu. Tab đang play (piano roll play riêng) → giữ nguyên (không đụng).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070445), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** hard refresh → play MAIN → mở PIANO ROLL (playhead chạy trong item) → đợi play hết (projectEnd) → playhead piano roll TỰ VỀ 0; bấm stop (space) → cũng về 0; piano roll đang play riêng → không bị về 0.
### [2026-08-07 04:50] Task: (1) PIANO ROLL zoom không persist phiên (2) Mở project lưu → timeline zoom out thu nhỏ góc
- **Báo cáo user:** (1) PIANO ROLL TAB không giữ kích thước zoom ở phiên làm việc trước; (2) MAIN SESSION — mở dự án đã lưu → timeline bị zoom out thu nhỏ ở góc — zoom in mới về kích thước đã lưu.
- **Nguyên nhân:**
(1) `rollZoom` (PianoRollTabEditor 6926) là local state khởi tạo 60 — mỗi lần mở tab → reset — không persist.
(2) Serialize project (9267) lưu `window.__currentZoom || 1.0` — nhưng `__currentZoom` do MEDIA EXPLORER set (11583) — không phải zoom timeline (App 13910) → project LUÔN lưu 1.0 → load `setZoom(1.0)` → timeline thu nhỏ góc trái.
- **FIX (app.jsx):**
(1) `rollZoom` khởi tạo từ `localStorage['sf_pr_zoom']` (clamp 15-250) + effect lưu khi đổi.
(2) App zoom effect set `window.__sfTimelineZoom = zoom`; serialize dùng `__sfTimelineZoom || __currentZoom || 1.0`; load (15211 + 24987): `result.zoom > 1 && setZoom` — bỏ qua 1.0 (data cũ lỗi) → giữ zoom hiện tại/localStorage.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070450), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** (1) PIANO ROLL zoom in/out → đóng tab → mở lại (hoặc reload) → zoom giữ nguyên; (2) LƯU project (zoom hiện tại) → mở lại → zoom ĐÚNG kích thước đã lưu (không thu nhỏ); project CŨ (lưu 1.0) → mở → giữ zoom hiện tại (không bị thu nhỏ).
### [2026-08-07 04:55] Task: Zoom reset khi chuyển tab (trong phiên) — thêm log chẩn đoán
- **Báo cáo user:** trong phiên làm việc, chuyển tab → kích thước zoom không lưu lại — bị reset về mặc định.
- **Trạng thái:** zoom MAIN timeline là App state global (không reset khi chuyển tab); piano roll đã persist localStorage (0450). Nghi vấn: effect minZoom (18009) ép `setZoom(minZoom)` khi vào tab có maxDuration nhỏ (section ngắn) — làm mất zoom đã set. Chưa tìm thấy nơi reset về mặc định cố định.
- **FIX (app.jsx):** thêm log chẩn đoán — (1) `[Zoom] đổi tab <prev> → <new> | zoom=<...> | minZoom=<...>` (effect activeTab); (2) `[Zoom] ép lên minZoom=... (zoom=...) tab=... maxDuration=...` (effect minZoom — chỉ khi ép).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070455), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** hard refresh → zoom MAIN (hoặc piano roll) → chuyển tab qua lại → dán log `[Zoom]` — xác định tab nào + giá trị zoom/minZoom khi reset.
### [2026-08-07 05:00] Task: CHỐT — Zoom bị ép khi chuyển tab (log [Zoom] xác nhận)
- **Log user (0455):** vào PIANO ROLL tab → `[Zoom] đổi tab ... → midi_... | zoom=32.78 | minZoom=37.54` + `[Zoom] ép lên minZoom=37.54 (zoom=32.78) tab=midi_... maxDuration=33.88` → về main/section zoom GIỮ 37.54 (không khôi phục 32.78/24.63) = "zoom reset khi chuyển tab".
- **Nguyên nhân:** `maxDuration` tính theo activeTab — PIANO ROLL tab (sub-tab) KHÔNG main/không session → `tab=undefined → max=0` → max = 12 bars buffer (33.88s) → `minZoom = viewportWidth/maxDuration` LỚN (37.54) → effect minZoom (18009) ép `setZoom(minZoom)` → zoom bị đẩy lên → quay lại main không khôi phục (state giữ giá trị bị ép).
- **FIX (app.jsx effect minZoom):** chỉ ép minZoom khi activeTab là `main` hoặc `session_*` — sub-tab (PIANO ROLL/audio) KHÔNG ép (timeline không hiển thị — ép vô nghĩa + hại). Zoom giữ nguyên khi vào/ra sub-tab.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070500), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** hard refresh → zoom MAIN (vd 24.6) → vào SECTION-TAB → vào PIANO ROLL → KHÔNG còn log `[Zoom] ép lên minZoom` → quay lại MAIN → zoom GIỮ NGUYÊN (không reset).
### [2026-08-07 05:05] Task: Section item — giữ kích thước đã resize (không tự đổi khi lưu SECTION-TAB) + MAIN play theo duration item
- **Yêu cầu user:** (1) resize section item trên MAIN → giữ nguyên kích thước — KHÔNG tự động thay đổi khi lưu SECTION-TAB; (2) MAIN SESSION chỉ play duration của section item — mặc kệ SECTION-TAB duration dài bao nhiêu.
- **Nguyên nhân (1):** `handleSaveSectionTab` (16732) ghi đè `duration: durationSec` (tính từ content tab) → mỗi lần lưu tab, section item tự giãn/thu theo content — mất kích thước user đã resize.
- **Trạng thái (2):** ĐÃ ĐÚNG — play MAIN clamp theo `sec.duration` (19154-19156 clip / 19191-19192 midi — activeEnd = min(..., secStart + sec.duration)) + `computeMainSessionEndTime` (254) dùng `s.start + s.duration` (item, không phải content) → playhead dừng đúng theo item. Chỉ cần fix (1) để duration không bị đổi.
- **FIX (app.jsx handleSaveSectionTab):** bỏ ghi đè `duration` (giữ nguyên kích thước item đã resize) — chỉ cập nhật name + tracks; bỏ luôn code tính `durationSec`/`maxEndTime` (dead).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070505), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** hard refresh → resize section item (vd 8s) → mở SECTION-TAB (content dài hơn) → Lưu section (Ctrl+S) → item trên MAIN GIỮ NGUYÊN 8s; play MAIN → chỉ nghe đúng 8s (clamp theo item — content dài hơn không phát tiếp); playhead dừng tại end item.
### [2026-08-07 05:10] Task: MAIN SESSION — playhead vẫn di chuyển khi hết item (chưa nhấn stop) — thêm log chẩn đoán
- **Báo cáo user:** MAIN SESSION — playhead vẫn di chuyển khi chưa nhấn stop; còn item → play âm; hết item → playhead vẫn chạy tiếp.
- **Phân tích:** playhead main dừng tại `projectEnd` (= computeMainSessionEndTime — end item CUỐI, theo s.start + s.duration — KHÔNG buffer). 2 khả năng: (a) hết item nhưng còn item khác dài hơn → playhead chạy qua khoảng trống (âm hết, playhead chạy) — hành vi DAW chuẩn; (b) projectEnd tính SAI (lớn hơn thực tế — item cuối không phải item thật) → playhead chạy quá.
- **FIX (app.jsx):** thêm log chẩn đoán — (1) `[Play] ... projectEnd=X.XX` (startTrackPlayback — projectEnd lúc schedule); (2) `[Stop] projectEnd reached — updatedTime=... projectEnd=...` (khi playhead tới projectEnd — xác nhận có dừng hay không).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070510), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** hard refresh → play MAIN (section item / midi item) → đợi hết → dán log: `[Play] ... projectEnd=` (giá trị bao nhiêu — có khớp end item cuối không) + có `[Stop] projectEnd reached` không (dừng đúng hay chạy quá).
### [2026-08-07 05:15] Task: MAIN SESSION — playhead chạy qua khoảng im sau khi hết content section item
- **Log user (0510):** `[Stop] projectEnd reached — updatedTime=22.60 projectEnd=22.59` — playhead DỪNG ĐÚNG tại projectEnd — nhưng projectEnd = 22.59 (duration section item — resize thủ công) trong khi CONTENT thực trong tab ngắn hơn (~8s) → im lặng 8→22.59s → user thấy "hết item mà playhead vẫn di chuyển".
- **Nguyên nhân:** projectEnd (main) = computeMainSessionEndTime — section contribution = `s.start + s.duration` (duration ITEM — không phải content thực).
- **FIX (app.jsx projectEnd useMemo):** section contribution = `s.start + min(s.duration, CONTENT end từ tab)` — content ngắn hơn item → projectEnd rút về end content (dừng sớm — hết âm); content dài hơn item → giữ item duration (0505 — MAIN chỉ play duration item). Chỉ áp dụng khi section item là end cuối (max hiện tại).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070515), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** hard refresh → play MAIN (section item duration 22.59s, content 8s) → playhead DỪNG tại ~8s (hết content — log `[Stop] projectEnd reached — projectEnd≈8`); content dài hơn item → dừng tại item end (cắt như 0505).
### [2026-08-07 05:20] Task: projectEnd chỉ kích hoạt khi nút LOOP bật — play loop liên tục
- **Yêu cầu user:** projectEnd chỉ được kích hoạt khi nút LOOP được bật (cho phép dự án play loop liên tục). LOOP tắt → play 1 lần tới end items (KHÔNG cắt sớm theo content — bỏ hiệu ứng 0515 khi loop tắt).
- **FIX (app.jsx):**
(1) `projectEnd` useMemo — content-min (0515: section end = sec.start + min(item duration, content end)) CHỈ áp dụng khi `isLoopingSelection` (deps thêm isLoopingSelection). LOOP TẮT → projectEnd = computeMainSessionEndTime thuần (end items — play đủ, dừng tại đó).
(2) Nút LOOP transport (25838-25848): BỎ auto-derive selection 0→end items — LOOP bật không selection → loop tại projectEnd (nhánh updatePlayhead `updatedTime >= projectEndRef` loop lại từ 0 — liên tục). User có selection riêng → giữ loop vùng chọn.
- **Hành vi:** LOOP TẮT → playhead chạy tới END ITEMS (22.59) rồi dừng (không cắt 8s content). LOOP BẬT → playhead tới projectEnd (content end ~8s) → LOOP LẠI từ 0 → play liên tục.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070520), `wiki.md`. Rebuild precompiled.
- **Ghi chú/Test:** hard refresh → (1) LOOP TẮT → play MAIN (section 22.59s, content 8s) → play tới 22.59 mới dừng; (2) bấm LOOP → play → loop liên tục tại ~8s (log `[Stop] projectEnd reached — projectEnd≈8` + loop lại từ 0).