diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx
index aa30d6e..3fc55f4 100644
--- a/app/static/js/app.jsx
+++ b/app/static/js/app.jsx
@@ -9029,6 +9029,8 @@ const MediaExplorerPanel = ({ height }) => {
const [computerTree, setComputerTree] = React.useState({});
const [computerPath, setComputerPath] = React.useState(null);
const [computerFiles, setComputerFiles] = React.useState([]);
+ const [computerMode, setComputerMode] = React.useState('server');
+ const [clientRoot, setClientRoot] = React.useState(null);
const canvasRef = React.useRef(null);
const playStateRef = React.useRef(null);
const rafRef = React.useRef(null);
@@ -9065,12 +9067,10 @@ const MediaExplorerPanel = ({ height }) => {
}, [folderFiles, filterText]);
const loadWaveform = async (f) => {
- if (f && f.path) {
+ if (f && (f.handle || f.path)) {
try {
- const url = filePreviewUrl(f);
- if (!url) { setPeaks(null); return; }
- const resp = await fetch(url);
- const buf = await resp.arrayBuffer();
+ const buf = await readLocalFileBuffer(f);
+ if (!buf) { setPeaks(null); return; }
const ctx = getAudioContext();
const decoded = await ctx.decodeAudioData(buf);
setAudioBuffer(decoded);
@@ -9101,6 +9101,26 @@ const MediaExplorerPanel = ({ height }) => {
const openMyComputer = async () => {
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;
setComputerRoots(null);
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;
try {
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];
if (node && node.expanded) {
setComputerTree(prev => ({ ...prev, [path]: { ...prev[path], expanded: false } }));
} else {
- await browseComputerDir(path);
+ await browseComputerDir(entry);
}
};
const goComputerParent = () => {
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 parts = computerPath.split(/[\\/]/).filter(Boolean);
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) => {
- 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);
return fid ? `${API_BASE_URL}/api/v1/audio/download/${fid}` : null;
};
@@ -9184,12 +9269,10 @@ const MediaExplorerPanel = ({ height }) => {
return;
}
const fid = f.file_id || f.fileId;
- const url = filePreviewUrl(f);
- if (!url) return;
+ const buf = await readLocalFileBuffer(f);
+ if (!buf) return;
try {
const ctx = getAudioContext();
- const resp = await fetch(url);
- const buf = await resp.arrayBuffer();
const decoded = await ctx.decodeAudioData(buf);
setAudioBuffer(decoded);
const src = ctx.createBufferSource();
@@ -9313,7 +9396,8 @@ const MediaExplorerPanel = ({ height }) => {
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 expanded = node && node.expanded;
const dirs = node ? node.dirs : [];
@@ -9322,13 +9406,13 @@ const MediaExplorerPanel = ({ height }) => {
browseComputerDir(nodePath)}
- onDoubleClick={e => { e.stopPropagation(); toggleComputerDir(nodePath); }}>
- { e.stopPropagation(); toggleComputerDir(nodePath); }}>
+ onClick={() => browseComputerDir(entry)}
+ onDoubleClick={e => { e.stopPropagation(); toggleComputerDir(entry); }}>
+ { e.stopPropagation(); toggleComputerDir(entry); }}>
- {name}
+ {entry.name}
- {expanded && dirs.map(d => renderComputerNode(d.path, d.name, depth + 1, false))}
+ {expanded && dirs.map(d => renderComputerNode(d, depth + 1, false))}
);
};
@@ -9353,7 +9437,7 @@ const MediaExplorerPanel = ({ height }) => {
{/* 1. TOP NAVIGATION TOOLBAR */}
-