FIX: local files in desktop app

This commit is contained in:
Julien Maille 2026-02-18 21:33:34 +01:00
parent a9f3acb289
commit d05a0ea9c0
3 changed files with 220 additions and 25 deletions

104
js/app.js
View file

@ -374,10 +374,17 @@ document.addEventListener('DOMContentLoaded', async () => {
const ua = navigator.userAgent; const ua = navigator.userAgent;
const isChromeOrEdge = (ua.indexOf('Chrome') > -1 || ua.indexOf('Edg') > -1) && !/Mobile|Android/.test(ua); const isChromeOrEdge = (ua.indexOf('Chrome') > -1 || ua.indexOf('Edg') > -1) && !/Mobile|Android/.test(ua);
const hasFileSystemApi = 'showDirectoryPicker' in window; const hasFileSystemApi = 'showDirectoryPicker' in window;
const isNeutralino =
window.NL_MODE ||
window.location.search.includes('mode=neutralino') ||
window.location.search.includes('nl_port=');
if (!isChromeOrEdge || !hasFileSystemApi) { if (!isNeutralino && (!isChromeOrEdge || !hasFileSystemApi)) {
selectLocalBtn.style.display = 'none'; selectLocalBtn.style.display = 'none';
browserWarning.style.display = 'block'; browserWarning.style.display = 'block';
} else if (isNeutralino) {
selectLocalBtn.style.display = 'flex';
browserWarning.style.display = 'none';
} }
} }
@ -1965,10 +1972,22 @@ document.addEventListener('DOMContentLoaded', async () => {
if (e.target.closest('#select-local-folder-btn') || e.target.closest('#change-local-folder-btn')) { if (e.target.closest('#select-local-folder-btn') || e.target.closest('#change-local-folder-btn')) {
const isChange = e.target.closest('#change-local-folder-btn') !== null; const isChange = e.target.closest('#change-local-folder-btn') !== null;
try { try {
const handle = await window.showDirectoryPicker({ const isNeutralino =
id: 'music-folder', window.Neutralino && (window.NL_MODE || window.location.search.includes('mode=neutralino'));
mode: 'read', let handle;
}); let path;
if (isNeutralino) {
path = await window.Neutralino.os.showFolderDialog('Select Music Folder');
if (!path) return;
// Mock a handle object for UI compatibility
handle = { name: path.split(/[/\\]/).pop() || path, isNeutralino: true, path };
} else {
handle = await window.showDirectoryPicker({
id: 'music-folder',
mode: 'read',
});
}
await db.saveSetting('local_folder_handle', handle); await db.saveSetting('local_folder_handle', handle);
if (isChange) { if (isChange) {
@ -1985,32 +2004,67 @@ document.addEventListener('DOMContentLoaded', async () => {
const tracks = []; const tracks = [];
let idCounter = 0; let idCounter = 0;
const { readTrackMetadata } = await loadMetadataModule();
async function scanDirectory(dirHandle) { if (isNeutralino) {
for await (const entry of dirHandle.values()) { async function scanDirectoryNeu(dirPath) {
if (entry.kind === 'file') { const entries = await window.Neutralino.filesystem.readDirectory(dirPath);
const name = entry.name.toLowerCase(); for (const entry of entries) {
if ( if (entry.entry === '.' || entry.entry === '..') continue;
name.endsWith('.flac') || const fullPath = `${dirPath}/${entry.entry}`;
name.endsWith('.mp3') || if (entry.type === 'FILE') {
name.endsWith('.m4a') || const name = entry.entry.toLowerCase();
name.endsWith('.wav') || if (
name.endsWith('.ogg') name.endsWith('.flac') ||
) { name.endsWith('.mp3') ||
const file = await entry.getFile(); name.endsWith('.m4a') ||
const { readTrackMetadata } = await loadMetadataModule(); name.endsWith('.wav') ||
const metadata = await readTrackMetadata(file); name.endsWith('.ogg')
metadata.id = `local-${idCounter++}-${file.name}`; ) {
tracks.push(metadata); try {
const buffer = await window.Neutralino.filesystem.readBinaryFile(fullPath);
const stats = await window.Neutralino.filesystem.getStats(fullPath);
const file = new File([buffer], entry.entry, {
lastModified: stats.mtime,
});
const metadata = await readTrackMetadata(file);
metadata.id = `local-${idCounter++}-${entry.entry}`;
tracks.push(metadata);
} catch (e) {
console.error('Failed to read file:', fullPath, e);
}
}
} else if (entry.type === 'DIRECTORY') {
await scanDirectoryNeu(fullPath);
} }
} else if (entry.kind === 'directory') {
await scanDirectory(entry);
} }
} }
await scanDirectoryNeu(path);
} else {
async function scanDirectory(dirHandle) {
for await (const entry of dirHandle.values()) {
if (entry.kind === 'file') {
const name = entry.name.toLowerCase();
if (
name.endsWith('.flac') ||
name.endsWith('.mp3') ||
name.endsWith('.m4a') ||
name.endsWith('.wav') ||
name.endsWith('.ogg')
) {
const file = await entry.getFile();
const metadata = await readTrackMetadata(file);
metadata.id = `local-${idCounter++}-${file.name}`;
tracks.push(metadata);
}
} else if (entry.kind === 'directory') {
await scanDirectory(entry);
}
}
}
await scanDirectory(handle);
} }
await scanDirectory(handle);
tracks.sort((a, b) => { tracks.sort((a, b) => {
const artistA = a.artist.name || ''; const artistA = a.artist.name || '';
const artistB = b.artist.name || ''; const artistB = b.artist.name || '';

View file

@ -84,9 +84,68 @@ export const os = {
window.parent.postMessage({ type: 'NL_OS_SHOW_SAVE_DIALOG', id, title, options }, '*'); window.parent.postMessage({ type: 'NL_OS_SHOW_SAVE_DIALOG', id, title, options }, '*');
}); });
}, },
showFolderDialog: async (title, options) => {
if (!isNeutralino) return;
return new Promise((resolve) => {
const id = Math.random().toString(36).substring(7);
const handler = (event) => {
if (event.data?.type === 'NL_RESPONSE' && event.data.id === id) {
window.removeEventListener('message', handler);
resolve(event.data.result);
}
};
window.addEventListener('message', handler);
window.parent.postMessage({ type: 'NL_OS_SHOW_FOLDER_DIALOG', id, title, options }, '*');
});
},
}; };
export const filesystem = { export const filesystem = {
readBinaryFile: async (path) => {
if (!isNeutralino) return;
return new Promise((resolve, reject) => {
const id = Math.random().toString(36).substring(7);
const handler = (event) => {
if (event.data?.type === 'NL_RESPONSE' && event.data.id === id) {
window.removeEventListener('message', handler);
if (event.data.error) reject(event.data.error);
else resolve(event.data.result);
}
};
window.addEventListener('message', handler);
window.parent.postMessage({ type: 'NL_FS_READ_BINARY', id, path }, '*');
});
},
readDirectory: async (path) => {
if (!isNeutralino) return;
return new Promise((resolve, reject) => {
const id = Math.random().toString(36).substring(7);
const handler = (event) => {
if (event.data?.type === 'NL_RESPONSE' && event.data.id === id) {
window.removeEventListener('message', handler);
if (event.data.error) reject(event.data.error);
else resolve(event.data.result);
}
};
window.addEventListener('message', handler);
window.parent.postMessage({ type: 'NL_FS_READ_DIR', id, path }, '*');
});
},
getStats: async (path) => {
if (!isNeutralino) return;
return new Promise((resolve, reject) => {
const id = Math.random().toString(36).substring(7);
const handler = (event) => {
if (event.data?.type === 'NL_RESPONSE' && event.data.id === id) {
window.removeEventListener('message', handler);
if (event.data.error) reject(event.data.error);
else resolve(event.data.result);
}
};
window.addEventListener('message', handler);
window.parent.postMessage({ type: 'NL_FS_STATS', id, path }, '*');
});
},
writeBinaryFile: async (path, buffer) => { writeBinaryFile: async (path, buffer) => {
if (!isNeutralino) return; if (!isNeutralino) return;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {

View file

@ -228,6 +228,88 @@
} }
break; break;
case 'NL_OS_SHOW_FOLDER_DIALOG':
try {
const result = await Neutralino.os.showFolderDialog(event.data.title, event.data.options);
if (iframe && iframe.contentWindow) {
iframe.contentWindow.postMessage(
{ type: 'NL_RESPONSE', id: event.data.id, result },
'*'
);
}
} catch (e) {
console.error('[Shell] Show Folder Dialog failed:', e);
if (iframe && iframe.contentWindow) {
iframe.contentWindow.postMessage(
{ type: 'NL_RESPONSE', id: event.data.id, error: e },
'*'
);
}
}
break;
case 'NL_FS_READ_BINARY':
try {
const result = await Neutralino.filesystem.readBinaryFile(event.data.path);
if (iframe && iframe.contentWindow) {
// result is ArrayBuffer, should be transferable
iframe.contentWindow.postMessage(
{ type: 'NL_RESPONSE', id: event.data.id, result },
'*',
[result]
);
}
} catch (e) {
console.error('[Shell] Read Binary File failed:', e);
if (iframe && iframe.contentWindow) {
iframe.contentWindow.postMessage(
{ type: 'NL_RESPONSE', id: event.data.id, error: e },
'*'
);
}
}
break;
case 'NL_FS_READ_DIR':
try {
const result = await Neutralino.filesystem.readDirectory(event.data.path);
if (iframe && iframe.contentWindow) {
iframe.contentWindow.postMessage(
{ type: 'NL_RESPONSE', id: event.data.id, result },
'*'
);
}
} catch (e) {
console.error('[Shell] Read Directory failed:', e);
if (iframe && iframe.contentWindow) {
iframe.contentWindow.postMessage(
{ type: 'NL_RESPONSE', id: event.data.id, error: e },
'*'
);
}
}
break;
case 'NL_FS_STATS':
try {
const result = await Neutralino.filesystem.getStats(event.data.path);
if (iframe && iframe.contentWindow) {
iframe.contentWindow.postMessage(
{ type: 'NL_RESPONSE', id: event.data.id, result },
'*'
);
}
} catch (e) {
console.error('[Shell] Get Stats failed:', e);
if (iframe && iframe.contentWindow) {
iframe.contentWindow.postMessage(
{ type: 'NL_RESPONSE', id: event.data.id, error: e },
'*'
);
}
}
break;
case 'NL_FS_WRITE_BINARY': case 'NL_FS_WRITE_BINARY':
try { try {
// buffer comes as ArrayBuffer in event.data.buffer (if transferred) or event.data.buffer // buffer comes as ArrayBuffer in event.data.buffer (if transferred) or event.data.buffer