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 isChromeOrEdge = (ua.indexOf('Chrome') > -1 || ua.indexOf('Edg') > -1) && !/Mobile|Android/.test(ua);
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';
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')) {
const isChange = e.target.closest('#change-local-folder-btn') !== null;
try {
const handle = await window.showDirectoryPicker({
id: 'music-folder',
mode: 'read',
});
const isNeutralino =
window.Neutralino && (window.NL_MODE || window.location.search.includes('mode=neutralino'));
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);
if (isChange) {
@ -1985,32 +2004,67 @@ document.addEventListener('DOMContentLoaded', async () => {
const tracks = [];
let idCounter = 0;
const { readTrackMetadata } = await loadMetadataModule();
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 { readTrackMetadata } = await loadMetadataModule();
const metadata = await readTrackMetadata(file);
metadata.id = `local-${idCounter++}-${file.name}`;
tracks.push(metadata);
if (isNeutralino) {
async function scanDirectoryNeu(dirPath) {
const entries = await window.Neutralino.filesystem.readDirectory(dirPath);
for (const entry of entries) {
if (entry.entry === '.' || entry.entry === '..') continue;
const fullPath = `${dirPath}/${entry.entry}`;
if (entry.type === 'FILE') {
const name = entry.entry.toLowerCase();
if (
name.endsWith('.flac') ||
name.endsWith('.mp3') ||
name.endsWith('.m4a') ||
name.endsWith('.wav') ||
name.endsWith('.ogg')
) {
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) => {
const artistA = a.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 }, '*');
});
},
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 = {
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) => {
if (!isNeutralino) return;
return new Promise((resolve, reject) => {

View file

@ -228,6 +228,88 @@
}
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':
try {
// buffer comes as ArrayBuffer in event.data.buffer (if transferred) or event.data.buffer