feat: My Computer mở explorer client qua File System Access API
This commit is contained in:
+106
-22
@@ -9029,6 +9029,8 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
const [computerTree, setComputerTree] = React.useState({});
|
const [computerTree, setComputerTree] = React.useState({});
|
||||||
const [computerPath, setComputerPath] = React.useState(null);
|
const [computerPath, setComputerPath] = React.useState(null);
|
||||||
const [computerFiles, setComputerFiles] = React.useState([]);
|
const [computerFiles, setComputerFiles] = React.useState([]);
|
||||||
|
const [computerMode, setComputerMode] = React.useState('server');
|
||||||
|
const [clientRoot, setClientRoot] = React.useState(null);
|
||||||
const canvasRef = React.useRef(null);
|
const canvasRef = React.useRef(null);
|
||||||
const playStateRef = React.useRef(null);
|
const playStateRef = React.useRef(null);
|
||||||
const rafRef = React.useRef(null);
|
const rafRef = React.useRef(null);
|
||||||
@@ -9065,12 +9067,10 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
}, [folderFiles, filterText]);
|
}, [folderFiles, filterText]);
|
||||||
|
|
||||||
const loadWaveform = async (f) => {
|
const loadWaveform = async (f) => {
|
||||||
if (f && f.path) {
|
if (f && (f.handle || f.path)) {
|
||||||
try {
|
try {
|
||||||
const url = filePreviewUrl(f);
|
const buf = await readLocalFileBuffer(f);
|
||||||
if (!url) { setPeaks(null); return; }
|
if (!buf) { setPeaks(null); return; }
|
||||||
const resp = await fetch(url);
|
|
||||||
const buf = await resp.arrayBuffer();
|
|
||||||
const ctx = getAudioContext();
|
const ctx = getAudioContext();
|
||||||
const decoded = await ctx.decodeAudioData(buf);
|
const decoded = await ctx.decodeAudioData(buf);
|
||||||
setAudioBuffer(decoded);
|
setAudioBuffer(decoded);
|
||||||
@@ -9101,6 +9101,26 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
|
|
||||||
const openMyComputer = async () => {
|
const openMyComputer = async () => {
|
||||||
setFolder('computer');
|
setFolder('computer');
|
||||||
|
if (window.showDirectoryPicker) {
|
||||||
|
try {
|
||||||
|
const handle = await window.showDirectoryPicker({ mode: 'read' });
|
||||||
|
setComputerMode('client');
|
||||||
|
setClientRoot(handle);
|
||||||
|
const rootEntry = { name: handle.name, path: 'root', is_dir: true, handle };
|
||||||
|
setComputerRoots([rootEntry]);
|
||||||
|
setComputerPath('root');
|
||||||
|
setComputerFiles([]);
|
||||||
|
setComputerTree({ root: { handle, parent: null, dirs: [], expanded: true } });
|
||||||
|
await browseComputerDir(rootEntry);
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
if (e && e.name === 'AbortError') return;
|
||||||
|
window.showToast && window.showToast('Không thể mở thư mục: ' + e.message, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: server API (trình duyệt không hỗ trợ File System Access API)
|
||||||
|
setComputerMode('server');
|
||||||
if (computerRoots) return;
|
if (computerRoots) return;
|
||||||
setComputerRoots(null);
|
setComputerRoots(null);
|
||||||
try {
|
try {
|
||||||
@@ -9116,7 +9136,48 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const browseComputerDir = async (path) => {
|
const browseClientDir = async (entry) => {
|
||||||
|
const handle = entry && entry.handle;
|
||||||
|
if (!handle || !handle.entries) return;
|
||||||
|
const dirs = [];
|
||||||
|
const files = [];
|
||||||
|
const parentPath = entry.path;
|
||||||
|
try {
|
||||||
|
for await (const [name, h] of handle.entries()) {
|
||||||
|
if (name.startsWith('.')) continue;
|
||||||
|
if (h.kind === 'directory') {
|
||||||
|
dirs.push({ name, path: parentPath + '/' + name, is_dir: true, handle: h });
|
||||||
|
} else {
|
||||||
|
const ext = (name.split('.').pop() || '').toLowerCase();
|
||||||
|
const kind = ext === 'mid' || ext === 'midi' ? 'midi' : (['wav','mp3','ogg','flac','aiff','aif','m4a','aac','opus'].includes(ext) ? 'audio' : 'other');
|
||||||
|
let size = 0;
|
||||||
|
try { const f = await h.getFile(); size = f.size; } catch (e2) {}
|
||||||
|
files.push({ name, path: parentPath + '/' + name, is_dir: false, size_mb: size ? +(size / 1048576).toFixed(2) : 0, ext: '.' + ext, kind, handle: h });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dirs.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
files.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
setComputerPath(parentPath);
|
||||||
|
setComputerFiles(files);
|
||||||
|
setSelected(null);
|
||||||
|
setCurrentTime(0);
|
||||||
|
stopMediaPlayback();
|
||||||
|
setComputerTree(prev => ({ ...prev, [parentPath]: { handle, parent: entry.parent || null, dirs, expanded: true } }));
|
||||||
|
} catch (e) {
|
||||||
|
window.showToast && window.showToast('Không thể đọc thư mục: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const browseComputerDir = async (entry) => {
|
||||||
|
if (!entry) return;
|
||||||
|
if (computerMode === 'client' || entry.handle) {
|
||||||
|
if (entry.handle && entry.handle.entries) {
|
||||||
|
const parentInfo = computerTree[entry.path];
|
||||||
|
await browseClientDir({ ...entry, parent: parentInfo ? parentInfo.parent : null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const path = entry.path || entry;
|
||||||
if (!path) return;
|
if (!path) return;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${API_BASE_URL}/api/v1/media/browse?path=${encodeURIComponent(path)}`);
|
const resp = await fetch(`${API_BASE_URL}/api/v1/media/browse?path=${encodeURIComponent(path)}`);
|
||||||
@@ -9133,17 +9194,30 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleComputerDir = async (path) => {
|
const toggleComputerDir = async (entry) => {
|
||||||
|
if (!entry) return;
|
||||||
|
const path = entry.path || entry;
|
||||||
const node = computerTree[path];
|
const node = computerTree[path];
|
||||||
if (node && node.expanded) {
|
if (node && node.expanded) {
|
||||||
setComputerTree(prev => ({ ...prev, [path]: { ...prev[path], expanded: false } }));
|
setComputerTree(prev => ({ ...prev, [path]: { ...prev[path], expanded: false } }));
|
||||||
} else {
|
} else {
|
||||||
await browseComputerDir(path);
|
await browseComputerDir(entry);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const goComputerParent = () => {
|
const goComputerParent = () => {
|
||||||
if (!computerPath) return;
|
if (!computerPath) return;
|
||||||
|
if (computerMode === 'client' || clientRoot) {
|
||||||
|
const node = computerTree[computerPath];
|
||||||
|
const parentHandle = node && node.parent;
|
||||||
|
if (parentHandle) {
|
||||||
|
const parentEntry = computerRoots && computerRoots[0] && parentHandle === clientRoot
|
||||||
|
? computerRoots[0]
|
||||||
|
: { name: parentHandle.name, path: (computerPath.split('/').slice(0, -1).join('/')) || 'root', is_dir: true, handle: parentHandle };
|
||||||
|
browseComputerDir({ ...parentEntry, parent: parentHandle === clientRoot ? null : (computerTree[parentEntry.path] ? computerTree[parentEntry.path].parent : null) });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const isUnix = computerPath.startsWith('/');
|
const isUnix = computerPath.startsWith('/');
|
||||||
const parts = computerPath.split(/[\\/]/).filter(Boolean);
|
const parts = computerPath.split(/[\\/]/).filter(Boolean);
|
||||||
parts.pop();
|
parts.pop();
|
||||||
@@ -9155,8 +9229,19 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const readLocalFileBuffer = async (f) => {
|
||||||
|
if (f && f.handle && typeof f.handle.getFile === 'function') {
|
||||||
|
const file = await f.handle.getFile();
|
||||||
|
return await file.arrayBuffer();
|
||||||
|
}
|
||||||
|
const url = filePreviewUrl(f);
|
||||||
|
if (!url) return null;
|
||||||
|
const resp = await fetch(url);
|
||||||
|
return await resp.arrayBuffer();
|
||||||
|
};
|
||||||
|
|
||||||
const filePreviewUrl = (f) => {
|
const filePreviewUrl = (f) => {
|
||||||
if (f && f.path) return `${API_BASE_URL}/api/v1/media/file?path=${encodeURIComponent(f.path)}`;
|
if (f && f.path && !(f.handle && f.handle.getFile)) return `${API_BASE_URL}/api/v1/media/file?path=${encodeURIComponent(f.path)}`;
|
||||||
const fid = f && (f.file_id || f.fileId);
|
const fid = f && (f.file_id || f.fileId);
|
||||||
return fid ? `${API_BASE_URL}/api/v1/audio/download/${fid}` : null;
|
return fid ? `${API_BASE_URL}/api/v1/audio/download/${fid}` : null;
|
||||||
};
|
};
|
||||||
@@ -9184,12 +9269,10 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fid = f.file_id || f.fileId;
|
const fid = f.file_id || f.fileId;
|
||||||
const url = filePreviewUrl(f);
|
const buf = await readLocalFileBuffer(f);
|
||||||
if (!url) return;
|
if (!buf) return;
|
||||||
try {
|
try {
|
||||||
const ctx = getAudioContext();
|
const ctx = getAudioContext();
|
||||||
const resp = await fetch(url);
|
|
||||||
const buf = await resp.arrayBuffer();
|
|
||||||
const decoded = await ctx.decodeAudioData(buf);
|
const decoded = await ctx.decodeAudioData(buf);
|
||||||
setAudioBuffer(decoded);
|
setAudioBuffer(decoded);
|
||||||
const src = ctx.createBufferSource();
|
const src = ctx.createBufferSource();
|
||||||
@@ -9313,7 +9396,8 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
if (!isMidiFile(f) && (f.path || f.file_id || f.fileId)) loadWaveform(f);
|
if (!isMidiFile(f) && (f.path || f.file_id || f.fileId)) loadWaveform(f);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderComputerNode = (nodePath, name, depth, isRoot) => {
|
const renderComputerNode = (entry, depth, isRoot) => {
|
||||||
|
const nodePath = entry.path;
|
||||||
const node = computerTree[nodePath];
|
const node = computerTree[nodePath];
|
||||||
const expanded = node && node.expanded;
|
const expanded = node && node.expanded;
|
||||||
const dirs = node ? node.dirs : [];
|
const dirs = node ? node.dirs : [];
|
||||||
@@ -9322,13 +9406,13 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
<React.Fragment key={nodePath}>
|
<React.Fragment key={nodePath}>
|
||||||
<div className={`flex items-center gap-1 px-1 py-0.5 cursor-pointer rounded-sm ${computerPath === nodePath ? 'bg-slate-300 text-slate-900' : 'hover:bg-slate-200 text-slate-800'}`}
|
<div className={`flex items-center gap-1 px-1 py-0.5 cursor-pointer rounded-sm ${computerPath === nodePath ? 'bg-slate-300 text-slate-900' : 'hover:bg-slate-200 text-slate-800'}`}
|
||||||
style={{ paddingLeft: pad }}
|
style={{ paddingLeft: pad }}
|
||||||
onClick={() => browseComputerDir(nodePath)}
|
onClick={() => browseComputerDir(entry)}
|
||||||
onDoubleClick={e => { e.stopPropagation(); toggleComputerDir(nodePath); }}>
|
onDoubleClick={e => { e.stopPropagation(); toggleComputerDir(entry); }}>
|
||||||
<i className={`fa-solid ${expanded ? 'fa-minus' : 'fa-plus'} text-[9px] text-slate-500 w-3 text-center shrink-0`} onClick={e => { e.stopPropagation(); toggleComputerDir(nodePath); }}></i>
|
<i className={`fa-solid ${expanded ? 'fa-minus' : 'fa-plus'} text-[9px] text-slate-500 w-3 text-center shrink-0`} onClick={e => { e.stopPropagation(); toggleComputerDir(entry); }}></i>
|
||||||
<i className={`fa-solid ${isRoot ? 'fa-hard-drive text-[#6ea8dc]' : 'fa-folder text-[#d9a752]'} shrink-0`}></i>
|
<i className={`fa-solid ${isRoot ? 'fa-hard-drive text-[#6ea8dc]' : 'fa-folder text-[#d9a752]'} shrink-0`}></i>
|
||||||
<span className="truncate">{name}</span>
|
<span className="truncate">{entry.name}</span>
|
||||||
</div>
|
</div>
|
||||||
{expanded && dirs.map(d => renderComputerNode(d.path, d.name, depth + 1, false))}
|
{expanded && dirs.map(d => renderComputerNode(d, depth + 1, false))}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -9353,7 +9437,7 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
{/* 1. TOP NAVIGATION TOOLBAR */}
|
{/* 1. TOP NAVIGATION TOOLBAR */}
|
||||||
<div className="h-8 bg-[#d4d0c8] border-b border-[#808080] px-2 flex items-center justify-between text-xs shrink-0">
|
<div className="h-8 bg-[#d4d0c8] border-b border-[#808080] px-2 flex items-center justify-between text-xs shrink-0">
|
||||||
<div className="flex items-center gap-1 flex-1 mr-2">
|
<div className="flex items-center gap-1 flex-1 mr-2">
|
||||||
<button className="w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]" title="Back" onClick={() => folder === 'computer' ? browseComputerDir(computerPath || '') : setFolder('library')}>
|
<button className="w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]" title="Back" onClick={() => folder === 'computer' ? goComputerParent() : setFolder('library')}>
|
||||||
<i className="fa-solid fa-arrow-left"></i>
|
<i className="fa-solid fa-arrow-left"></i>
|
||||||
</button>
|
</button>
|
||||||
<button className="w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]" title="Forward" onClick={() => setFolder('uploads')}>
|
<button className="w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]" title="Forward" onClick={() => setFolder('uploads')}>
|
||||||
@@ -9362,7 +9446,7 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
<button className="w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]" title="Up Directory" onClick={() => folder === 'computer' ? goComputerParent() : setFolder('library')}>
|
<button className="w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]" title="Up Directory" onClick={() => folder === 'computer' ? goComputerParent() : setFolder('library')}>
|
||||||
<i className="fa-solid fa-arrow-up"></i>
|
<i className="fa-solid fa-arrow-up"></i>
|
||||||
</button>
|
</button>
|
||||||
<button className="w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]" title="Refresh" onClick={() => { if (folder === 'computer' && computerPath) browseComputerDir(computerPath); else if (window.SonicAPI && window.SonicAPI.listMyFiles) window.SonicAPI.listMyFiles([]).then(d => setUserFiles(d || [])); }}>
|
<button className="w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]" title="Refresh" onClick={() => { if (folder === 'computer') { const node = computerTree[computerPath]; if (node && node.handle) browseComputerDir({ name: computerPath.split('/').pop() || computerPath, path: computerPath, is_dir: true, handle: node.handle }); else if (computerPath) browseComputerDir({ name: computerPath.split(/[\\/]/).pop() || computerPath, path: computerPath, is_dir: true }); } else if (window.SonicAPI && window.SonicAPI.listMyFiles) window.SonicAPI.listMyFiles([]).then(d => setUserFiles(d || [])); }}>
|
||||||
<i className="fa-solid fa-rotate-right"></i>
|
<i className="fa-solid fa-rotate-right"></i>
|
||||||
</button>
|
</button>
|
||||||
<div className="flex-1 flex items-center bg-white border border-[#808080] h-5 px-1">
|
<div className="flex-1 flex items-center bg-white border border-[#808080] h-5 px-1">
|
||||||
@@ -9394,7 +9478,7 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
</div>
|
</div>
|
||||||
{folder === 'computer' && computerRoots && (
|
{folder === 'computer' && computerRoots && (
|
||||||
<div className="pl-3 space-y-0.5">
|
<div className="pl-3 space-y-0.5">
|
||||||
{computerRoots.map(root => renderComputerNode(root.path, root.name, 0, true))}
|
{computerRoots.map(root => renderComputerNode(root, 0, true))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={`flex items-center gap-1 px-1 py-0.5 cursor-pointer font-semibold rounded-sm ${folder === 'library' ? 'bg-slate-300 text-slate-900' : 'hover:bg-slate-200 text-slate-800'}`} onClick={() => setFolder('library')}>
|
<div className={`flex items-center gap-1 px-1 py-0.5 cursor-pointer font-semibold rounded-sm ${folder === 'library' ? 'bg-slate-300 text-slate-900' : 'hover:bg-slate-200 text-slate-800'}`} onClick={() => setFolder('library')}>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -23,7 +23,7 @@
|
|||||||
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
|
<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/promptTemplateManager.js?v=202607281039"></script>
|
||||||
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
|
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
|
||||||
<script src="/static/js/app.precompiled.js?v=202608021430" defer></script>
|
<script src="/static/js/app.precompiled.js?v=202608021445" defer></script>
|
||||||
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
@@ -1084,3 +1084,8 @@
|
|||||||
- **Tóm tắt thay đổi:** `app/api/v1/media.py` `/browse`: trả về TẤT CẢ file (bỏ lọc media) kèm `kind` (`midi`/`audio`/`other`). Frontend `MediaExplorerPanel`: (1) tree My Computer đệ quy sâu vô hạn (`renderComputerNode`) — click folder chọn + load files, click +/- expand/collapse ở mọi cấp; (2) file list merge dirs + files, click file chọn/preview, click folder vào thư mục; (3) icon theo loại (`fa-folder`/`fa-music`/`fa-file-audio`/`fa-file`); (4) guard: file `kind==='other'` không preview; metadata Type hiện "Local File".
|
- **Tóm tắt thay đổi:** `app/api/v1/media.py` `/browse`: trả về TẤT CẢ file (bỏ lọc media) kèm `kind` (`midi`/`audio`/`other`). Frontend `MediaExplorerPanel`: (1) tree My Computer đệ quy sâu vô hạn (`renderComputerNode`) — click folder chọn + load files, click +/- expand/collapse ở mọi cấp; (2) file list merge dirs + files, click file chọn/preview, click folder vào thư mục; (3) icon theo loại (`fa-folder`/`fa-music`/`fa-file-audio`/`fa-file`); (4) guard: file `kind==='other'` không preview; metadata Type hiện "Local File".
|
||||||
- **Các file ảnh hưởng:** `app/api/v1/media.py`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
|
- **Các file ảnh hưởng:** `app/api/v1/media.py`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
|
||||||
- **Ghi chú/Test (nếu có):** Test backend: browse trả subdir + a.wav(audio)/b.mid(midi)/notes.txt+readme.md(other). Smoke jsdom: root → list folder+file, expand `/data` → `/data/deep` → list loop.mid OK. Hard reload.
|
- **Ghi chú/Test (nếu có):** Test backend: browse trả subdir + a.wav(audio)/b.mid(midi)/notes.txt+readme.md(other). Smoke jsdom: root → list folder+file, expand `/data` → `/data/deep` → list loop.mid OK. Hard reload.
|
||||||
|
|
||||||
|
### [2026-08-02 14:45] Task: My Computer mở explorer client local qua File System Access API
|
||||||
|
- **Tóm tắt thay đổi:** `MediaExplorerPanel` "My Computer" giờ dùng Web API `window.showDirectoryPicker()` (Chrome/Edge) để mở Windows Explorer của MÁY CLIENT chọn thư mục, duyệt qua `FileSystemDirectoryHandle` (`handle.entries()`) — không còn phụ thuộc server/cloud. Tree đệ quy giữ handle mỗi node, `computerMode='client'` + `clientRoot`. Preview audio/MIDI đọc trực tiếp `handle.getFile() → arrayBuffer → decodeAudioData` (helper `readLocalFileBuffer`), không cần URL server. Back/Up/Refresh hoạt động cả 2 mode. Trình duyệt không hỗ trợ FS API → fallback về server API cũ.
|
||||||
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
|
||||||
|
- **Ghi chú/Test (nếu có):** Smoke jsdom mock `showDirectoryPicker` + handle tree: click My Computer → picker → drive `C:` hiển thị → browse `Users`/`readme.txt` → expand `Users` → `me` OK. Chỉ hoạt động Chrome/Edge (FS Access API). Hard reload.
|
||||||
|
|||||||
Reference in New Issue
Block a user