feat: add VitePress docs site, music file picker modal, Dockerfile optimization, i18n keys for 14 locales
- Add docs/ with VitePress site (19 pages): guide, config, architecture, FAQ - Add GitHub Actions workflow for auto-deploy to GitHub Pages - Replace music 'Add Tracks' upload picker with in-app audio file browser modal - Add music picker CSS styles with dark theme support - Add missing i18n keys (search_audio, no_audio_files, etc.) to all 14 locales - Optimize Dockerfile: shared base stage, COPY --chmod, consolidated RUN, HEALTHCHECK - Improve README: docs links, updated stats (222+ tests, 14 languages), feature status
This commit is contained in:
@@ -1288,3 +1288,186 @@
|
||||
[data-theme="dark"] .music-empty-state-desc {
|
||||
color: var(--color-text-muted, #888);
|
||||
}
|
||||
|
||||
/* ═══════════════ Audio file picker modal ═══════════════ */
|
||||
|
||||
.music-picker-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.music-picker-overlay.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.music-picker-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(560px, 94vw);
|
||||
max-height: min(620px, 85vh);
|
||||
background: var(--bg-primary, #fff);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden;
|
||||
transform: translateY(12px);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.music-picker-overlay.active .music-picker-modal {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* ── header ── */
|
||||
.music-picker-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-color, #e2e8f0);
|
||||
}
|
||||
.music-picker-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #2d3748);
|
||||
}
|
||||
.music-picker-header h3 i { margin-right: 8px; color: var(--color-primary, #667eea); }
|
||||
.music-picker-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted, #718096);
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
.music-picker-close:hover { color: var(--color-text, #2d3748); }
|
||||
|
||||
/* ── search bar ── */
|
||||
.music-picker-search {
|
||||
position: relative;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--border-color, #e2e8f0);
|
||||
}
|
||||
.music-picker-search i {
|
||||
position: absolute;
|
||||
left: 32px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--color-text-muted, #a0aec0);
|
||||
font-size: 13px;
|
||||
}
|
||||
.music-picker-search input {
|
||||
width: 100%;
|
||||
padding: 8px 12px 8px 32px;
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
background: var(--bg-secondary, #f7fafc);
|
||||
color: var(--color-text, #2d3748);
|
||||
outline: none;
|
||||
}
|
||||
.music-picker-search input:focus {
|
||||
border-color: var(--color-primary, #667eea);
|
||||
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.15);
|
||||
}
|
||||
|
||||
/* ── file list ── */
|
||||
.music-picker-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
min-height: 200px;
|
||||
}
|
||||
.music-picker-loading,
|
||||
.music-picker-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 40px 20px;
|
||||
color: var(--color-text-muted, #718096);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.music-picker-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 20px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
font-size: 13px;
|
||||
color: var(--color-text, #2d3748);
|
||||
}
|
||||
.music-picker-item:hover { background: var(--bg-hover, #edf2f7); }
|
||||
.music-picker-item.selected { background: rgba(102, 126, 234, 0.08); }
|
||||
|
||||
.music-picker-item input[type="checkbox"] {
|
||||
accent-color: var(--color-primary, #667eea);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.music-picker-item i.fa-file-audio {
|
||||
color: var(--color-primary, #667eea);
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.music-picker-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.music-picker-size {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted, #a0aec0);
|
||||
}
|
||||
|
||||
/* ── footer ── */
|
||||
.music-picker-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--border-color, #e2e8f0);
|
||||
}
|
||||
.music-picker-selected-count {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted, #718096);
|
||||
font-weight: 500;
|
||||
}
|
||||
.music-picker-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ── dark theme ── */
|
||||
[data-theme="dark"] .music-picker-modal {
|
||||
background: var(--bg-primary, #1a202c);
|
||||
}
|
||||
[data-theme="dark"] .music-picker-header h3 {
|
||||
color: var(--color-text, #e0e0e0);
|
||||
}
|
||||
[data-theme="dark"] .music-picker-search input {
|
||||
background: var(--bg-secondary, #2d3748);
|
||||
border-color: var(--border-color, #4a5568);
|
||||
color: var(--color-text, #e0e0e0);
|
||||
}
|
||||
[data-theme="dark"] .music-picker-item {
|
||||
color: var(--color-text, #e0e0e0);
|
||||
}
|
||||
[data-theme="dark"] .music-picker-item:hover {
|
||||
background: var(--bg-hover, #2d3748);
|
||||
}
|
||||
[data-theme="dark"] .music-picker-item.selected {
|
||||
background: rgba(102, 126, 234, 0.12);
|
||||
}
|
||||
|
||||
@@ -738,53 +738,117 @@ const musicView = {
|
||||
return typeof i18n !== 'undefined' && i18n.t ? i18n.t(key) : fallback || key;
|
||||
};
|
||||
|
||||
// Create a file picker input for audio files
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'audio/*';
|
||||
input.multiple = true;
|
||||
input.style.display = 'none';
|
||||
document.body.appendChild(input);
|
||||
// ── Build modal overlay ──
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'music-picker-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="music-picker-modal">
|
||||
<div class="music-picker-header">
|
||||
<h3><i class="fas fa-music"></i> ${t('music.add_tracks', 'Add Tracks')}</h3>
|
||||
<button class="music-picker-close" title="${t('common.close', 'Close')}">×</button>
|
||||
</div>
|
||||
<div class="music-picker-search">
|
||||
<i class="fas fa-search"></i>
|
||||
<input type="text" id="music-picker-query"
|
||||
placeholder="${t('music.search_audio', 'Search audio files…')}" autocomplete="off">
|
||||
</div>
|
||||
<div class="music-picker-list" id="music-picker-list">
|
||||
<div class="music-picker-loading"><i class="fas fa-spinner fa-spin"></i> ${t('music.loading', 'Loading…')}</div>
|
||||
</div>
|
||||
<div class="music-picker-footer">
|
||||
<span class="music-picker-selected-count" id="music-picker-count">0 ${t('music.selected', 'selected')}</span>
|
||||
<div class="music-picker-actions">
|
||||
<button class="btn btn-secondary music-picker-cancel">${t('common.cancel', 'Cancel')}</button>
|
||||
<button class="btn btn-primary music-picker-add" id="music-picker-add-btn" disabled>
|
||||
<i class="fas fa-plus"></i> ${t('music.add', 'Add')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
requestAnimationFrame(() => overlay.classList.add('active'));
|
||||
|
||||
input.addEventListener('change', async () => {
|
||||
const files = Array.from(input.files);
|
||||
input.remove();
|
||||
if (files.length === 0) return;
|
||||
const listEl = document.getElementById('music-picker-list');
|
||||
const queryInput = document.getElementById('music-picker-query');
|
||||
const addBtn = document.getElementById('music-picker-add-btn');
|
||||
const countEl = document.getElementById('music-picker-count');
|
||||
const selectedIds = new Set();
|
||||
|
||||
// Upload each file first, then add to playlist
|
||||
const fileIds = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const folderId = window.app?.currentPath || window.app?.userHomeFolderId || '';
|
||||
formData.append('folder_id', folderId);
|
||||
// ── Close helpers ──
|
||||
const close = () => {
|
||||
overlay.classList.remove('active');
|
||||
setTimeout(() => overlay.remove(), 200);
|
||||
};
|
||||
overlay.querySelector('.music-picker-close').addEventListener('click', close);
|
||||
overlay.querySelector('.music-picker-cancel').addEventListener('click', close);
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||
|
||||
const uploadResp = await fetch('/api/files/upload', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: typeof getCsrfHeaders === 'function' ? getCsrfHeaders() : {},
|
||||
body: formData
|
||||
});
|
||||
// ── Fetch & render audio files ──
|
||||
const AUDIO_EXTENSIONS = 'mp3,ogg,flac,wav,aac,m4a,wma,opus,webm';
|
||||
|
||||
if (!uploadResp.ok) throw new Error(`Upload failed: ${file.name}`);
|
||||
const uploaded = await uploadResp.json();
|
||||
if (uploaded.id) fileIds.push(uploaded.id);
|
||||
} catch (err) {
|
||||
console.error('Upload error:', err);
|
||||
}
|
||||
const fetchAudioFiles = async (query = '') => {
|
||||
listEl.innerHTML = `<div class="music-picker-loading"><i class="fas fa-spinner fa-spin"></i> ${t('music.loading', 'Loading…')}</div>`;
|
||||
try {
|
||||
const params = new URLSearchParams({ type_filter: AUDIO_EXTENSIONS, limit: '200', recursive: 'true' });
|
||||
if (query.trim()) params.set('query', query.trim());
|
||||
const resp = await fetch(`/api/search?${params}`, { credentials: 'include' });
|
||||
if (!resp.ok) throw new Error('Search failed');
|
||||
const data = await resp.json();
|
||||
renderFiles(data.files || []);
|
||||
} catch (err) {
|
||||
console.error('Audio search error:', err);
|
||||
listEl.innerHTML = `<div class="music-picker-empty"><i class="fas fa-exclamation-triangle"></i> ${t('music.search_error', 'Could not load audio files')}</div>`;
|
||||
}
|
||||
};
|
||||
|
||||
if (fileIds.length === 0) return;
|
||||
const renderFiles = (files) => {
|
||||
if (files.length === 0) {
|
||||
listEl.innerHTML = `<div class="music-picker-empty"><i class="fas fa-folder-open"></i> ${t('music.no_audio_files', 'No audio files found')}</div>`;
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = '';
|
||||
for (const file of files) {
|
||||
const row = document.createElement('label');
|
||||
row.className = 'music-picker-item' + (selectedIds.has(file.id) ? ' selected' : '');
|
||||
const sizeStr = file.size != null && window.formatFileSize ? window.formatFileSize(file.size) : '';
|
||||
row.innerHTML = `
|
||||
<input type="checkbox" value="${file.id}" ${selectedIds.has(file.id) ? 'checked' : ''}>
|
||||
<i class="fas fa-file-audio"></i>
|
||||
<span class="music-picker-name" title="${this._escapeHtml(file.name)}">${this._escapeHtml(file.name)}</span>
|
||||
<span class="music-picker-size">${sizeStr}</span>
|
||||
`;
|
||||
const cb = row.querySelector('input');
|
||||
cb.addEventListener('change', () => {
|
||||
if (cb.checked) { selectedIds.add(file.id); row.classList.add('selected'); }
|
||||
else { selectedIds.delete(file.id); row.classList.remove('selected'); }
|
||||
countEl.textContent = `${selectedIds.size} ${t('music.selected', 'selected')}`;
|
||||
addBtn.disabled = selectedIds.size === 0;
|
||||
});
|
||||
listEl.appendChild(row);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Debounced search ──
|
||||
let searchTimer = null;
|
||||
queryInput.addEventListener('input', () => {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => fetchAudioFiles(queryInput.value), 300);
|
||||
});
|
||||
|
||||
// ── Add button ──
|
||||
addBtn.addEventListener('click', async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
addBtn.disabled = true;
|
||||
addBtn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${t('music.adding', 'Adding…')}`;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/playlists/${this.currentPlaylist.id}/tracks`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: this._headers(true),
|
||||
body: JSON.stringify({ file_ids: fileIds })
|
||||
body: JSON.stringify({ file_ids: [...selectedIds] })
|
||||
});
|
||||
|
||||
if (!resp.ok) throw new Error('Failed to add tracks');
|
||||
|
||||
if (window.notifications) {
|
||||
@@ -792,15 +856,14 @@ const musicView = {
|
||||
icon: 'fa-check-circle',
|
||||
iconClass: 'upload',
|
||||
title: t('music.add_tracks', 'Add Tracks'),
|
||||
text: `${fileIds.length} ${t('music.added_to_playlist', 'added to playlist')}`
|
||||
text: `${selectedIds.size} ${t('music.added_to_playlist', 'added to playlist')}`
|
||||
});
|
||||
}
|
||||
|
||||
close();
|
||||
await this._loadPlaylistTracks(this.currentPlaylist.id);
|
||||
// Update track count
|
||||
const playlist = this.playlists.find((p) => p.id === this.currentPlaylist.id);
|
||||
if (playlist) {
|
||||
playlist.track_count = (playlist.track_count || 0) + fileIds.length;
|
||||
playlist.track_count = (playlist.track_count || 0) + selectedIds.size;
|
||||
this.currentPlaylist.track_count = playlist.track_count;
|
||||
this._renderPlaylistList();
|
||||
const metaEl = document.getElementById('music-playlist-meta');
|
||||
@@ -816,10 +879,14 @@ const musicView = {
|
||||
text: t('music.add_error', 'Could not add tracks to playlist')
|
||||
});
|
||||
}
|
||||
addBtn.disabled = false;
|
||||
addBtn.innerHTML = `<i class="fas fa-plus"></i> ${t('music.add', 'Add')}`;
|
||||
}
|
||||
});
|
||||
|
||||
input.click();
|
||||
// ── Initial load (all audio files) ──
|
||||
queryInput.focus();
|
||||
fetchAudioFiles();
|
||||
},
|
||||
|
||||
async _removeTrackFromPlaylist(trackId, fileId) {
|
||||
|
||||
@@ -62,7 +62,13 @@
|
||||
"add_error": "تعذر إضافة المقاطع",
|
||||
"no_playlists_yet": "لا توجد قوائم بعد. أنشئ واحدة أولاً!",
|
||||
"selected_files": "محدد:",
|
||||
"error": "خطأ"
|
||||
"error": "خطأ",
|
||||
"search_audio": "البحث عن ملفات صوتية…",
|
||||
"no_audio_files": "لم يتم العثور على ملفات صوتية",
|
||||
"selected": "محدد",
|
||||
"loading": "جارٍ التحميل…",
|
||||
"search_error": "تعذر تحميل الملفات الصوتية",
|
||||
"adding": "جارٍ الإضافة…"
|
||||
},
|
||||
"actions": {
|
||||
"search": "البحث في الملفات...",
|
||||
|
||||
@@ -62,7 +62,13 @@
|
||||
"add_error": "Tracks konnten nicht hinzugefügt werden",
|
||||
"no_playlists_yet": "Noch keine Playlists. Erstellen Sie zuerst eine!",
|
||||
"selected_files": "Ausgewählt:",
|
||||
"error": "Fehler"
|
||||
"error": "Fehler",
|
||||
"search_audio": "Audiodateien suchen…",
|
||||
"no_audio_files": "Keine Audiodateien gefunden",
|
||||
"selected": "ausgewählt",
|
||||
"loading": "Wird geladen…",
|
||||
"search_error": "Audiodateien konnten nicht geladen werden",
|
||||
"adding": "Wird hinzugefügt…"
|
||||
},
|
||||
"actions": {
|
||||
"search": "Dateien suchen...",
|
||||
|
||||
@@ -79,7 +79,13 @@
|
||||
"make_public": "Make public",
|
||||
"make_private": "Make private",
|
||||
"set_cover": "Set cover",
|
||||
"cover_updated": "Cover updated"
|
||||
"cover_updated": "Cover updated",
|
||||
"search_audio": "Search audio files…",
|
||||
"no_audio_files": "No audio files found",
|
||||
"selected": "selected",
|
||||
"loading": "Loading…",
|
||||
"search_error": "Could not load audio files",
|
||||
"adding": "Adding…"
|
||||
},
|
||||
"actions": {
|
||||
"search": "Search files...",
|
||||
|
||||
@@ -79,7 +79,13 @@
|
||||
"make_public": "Hacer pública",
|
||||
"make_private": "Hacer privada",
|
||||
"set_cover": "Establecer portada",
|
||||
"cover_updated": "Portada actualizada"
|
||||
"cover_updated": "Portada actualizada",
|
||||
"search_audio": "Buscar archivos de audio…",
|
||||
"no_audio_files": "No se encontraron archivos de audio",
|
||||
"selected": "seleccionados",
|
||||
"loading": "Cargando…",
|
||||
"search_error": "No se pudieron cargar los archivos de audio",
|
||||
"adding": "Añadiendo…"
|
||||
},
|
||||
"share": {
|
||||
"dialogTitle": "Compartir Enlace",
|
||||
|
||||
@@ -62,7 +62,13 @@
|
||||
"add_error": "امکان افزودن آهنگها به فهرست پخش نیست",
|
||||
"no_playlists_yet": "فهرست پخشی وجود ندارد. اول یکی بسازید!",
|
||||
"selected_files": "انتخاب شده:",
|
||||
"error": "خطا"
|
||||
"error": "خطا",
|
||||
"search_audio": "جستجوی فایلهای صوتی…",
|
||||
"no_audio_files": "فایل صوتی یافت نشد",
|
||||
"selected": "انتخاب شده",
|
||||
"loading": "در حال بارگذاری…",
|
||||
"search_error": "بارگذاری فایلهای صوتی ممکن نشد",
|
||||
"adding": "در حال افزودن…"
|
||||
},
|
||||
"actions": {
|
||||
"search": "جستوجوی پروندهها..",
|
||||
|
||||
@@ -62,7 +62,13 @@
|
||||
"add_error": "Impossible d'ajouter les pistes",
|
||||
"no_playlists_yet": "Pas encore de playlists. Créez-en une d'abord !",
|
||||
"selected_files": "Sélectionnés :",
|
||||
"error": "Erreur"
|
||||
"error": "Erreur",
|
||||
"search_audio": "Rechercher des fichiers audio…",
|
||||
"no_audio_files": "Aucun fichier audio trouvé",
|
||||
"selected": "sélectionnés",
|
||||
"loading": "Chargement…",
|
||||
"search_error": "Impossible de charger les fichiers audio",
|
||||
"adding": "Ajout en cours…"
|
||||
},
|
||||
"actions": {
|
||||
"search": "Rechercher des fichiers...",
|
||||
|
||||
@@ -62,7 +62,13 @@
|
||||
"add_error": "प्लेलिस्ट में ट्रैक नहीं जोड़े जा सके",
|
||||
"no_playlists_yet": "अभी तक कोई प्लेलिस्ट नहीं। पहले एक बनाएं!",
|
||||
"selected_files": "चयनित:",
|
||||
"error": "त्रुटि"
|
||||
"error": "त्रुटि",
|
||||
"search_audio": "ऑडियो फ़ाइलें खोजें…",
|
||||
"no_audio_files": "कोई ऑडियो फ़ाइल नहीं मिली",
|
||||
"selected": "चयनित",
|
||||
"loading": "लोड हो रहा है…",
|
||||
"search_error": "ऑडियो फ़ाइलें लोड नहीं हो सकीं",
|
||||
"adding": "जोड़ा जा रहा है…"
|
||||
},
|
||||
"actions": {
|
||||
"search": "फ़ाइलें खोजें...",
|
||||
|
||||
@@ -62,7 +62,13 @@
|
||||
"add_error": "Impossibile aggiungere le tracce",
|
||||
"no_playlists_yet": "Nessuna playlist ancora. Creane una prima!",
|
||||
"selected_files": "Selezionati:",
|
||||
"error": "Errore"
|
||||
"error": "Errore",
|
||||
"search_audio": "Cerca file audio…",
|
||||
"no_audio_files": "Nessun file audio trovato",
|
||||
"selected": "selezionati",
|
||||
"loading": "Caricamento…",
|
||||
"search_error": "Impossibile caricare i file audio",
|
||||
"adding": "Aggiunta in corso…"
|
||||
},
|
||||
"actions": {
|
||||
"search": "Cerca file...",
|
||||
|
||||
@@ -62,7 +62,13 @@
|
||||
"add_error": "曲を追加できませんでした",
|
||||
"no_playlists_yet": "プレイリストがありません。最初に作成してください!",
|
||||
"selected_files": "選択中:",
|
||||
"error": "エラー"
|
||||
"error": "エラー",
|
||||
"search_audio": "オーディオファイルを検索…",
|
||||
"no_audio_files": "オーディオファイルが見つかりません",
|
||||
"selected": "件選択中",
|
||||
"loading": "読み込み中…",
|
||||
"search_error": "オーディオファイルを読み込めませんでした",
|
||||
"adding": "追加中…"
|
||||
},
|
||||
"actions": {
|
||||
"search": "ファイルを検索...",
|
||||
|
||||
@@ -62,7 +62,13 @@
|
||||
"add_error": "트랙을 플레이리스트에 추가할 수 없습니다",
|
||||
"no_playlists_yet": "플레이리스트가 없습니다. 먼저 하나를 만드세요!",
|
||||
"selected_files": "선택됨:",
|
||||
"error": "오류"
|
||||
"error": "오류",
|
||||
"search_audio": "오디오 파일 검색…",
|
||||
"no_audio_files": "오디오 파일을 찾을 수 없습니다",
|
||||
"selected": "선택됨",
|
||||
"loading": "로딩 중…",
|
||||
"search_error": "오디오 파일을 불러올 수 없습니다",
|
||||
"adding": "추가 중…"
|
||||
},
|
||||
"actions": {
|
||||
"search": "파일 검색...",
|
||||
|
||||
@@ -62,7 +62,13 @@
|
||||
"add_error": "Kon tracks niet toevoegen aan playlist",
|
||||
"no_playlists_yet": "Nog geen playlists. Maak er eerst een!",
|
||||
"selected_files": "Geselecteerd:",
|
||||
"error": "Fout"
|
||||
"error": "Fout",
|
||||
"search_audio": "Audiobestanden zoeken…",
|
||||
"no_audio_files": "Geen audiobestanden gevonden",
|
||||
"selected": "geselecteerd",
|
||||
"loading": "Laden…",
|
||||
"search_error": "Kan audiobestanden niet laden",
|
||||
"adding": "Toevoegen…"
|
||||
},
|
||||
"actions": {
|
||||
"search": "Zoek bestanden...",
|
||||
|
||||
@@ -62,7 +62,13 @@
|
||||
"add_error": "Não foi possível adicionar as faixas",
|
||||
"no_playlists_yet": "Nenhuma playlist ainda. Crie uma primeiro!",
|
||||
"selected_files": "Selecionados:",
|
||||
"error": "Erro"
|
||||
"error": "Erro",
|
||||
"search_audio": "Pesquisar ficheiros de áudio…",
|
||||
"no_audio_files": "Nenhum ficheiro de áudio encontrado",
|
||||
"selected": "selecionados",
|
||||
"loading": "A carregar…",
|
||||
"search_error": "Não foi possível carregar os ficheiros de áudio",
|
||||
"adding": "A adicionar…"
|
||||
},
|
||||
"actions": {
|
||||
"search": "Pesquisar arquivos...",
|
||||
|
||||
@@ -62,7 +62,13 @@
|
||||
"add_error": "Не удалось добавить треки в плейлист",
|
||||
"no_playlists_yet": "Плейлистов пока нет. Создайте сначала!",
|
||||
"selected_files": "Выбрано:",
|
||||
"error": "Ошибка"
|
||||
"error": "Ошибка",
|
||||
"search_audio": "Поиск аудиофайлов…",
|
||||
"no_audio_files": "Аудиофайлы не найдены",
|
||||
"selected": "выбрано",
|
||||
"loading": "Загрузка…",
|
||||
"search_error": "Не удалось загрузить аудиофайлы",
|
||||
"adding": "Добавление…"
|
||||
},
|
||||
"actions": {
|
||||
"search": "Поиск файлов...",
|
||||
|
||||
@@ -62,7 +62,13 @@
|
||||
"add_error": "无法将曲目添加到播放列表",
|
||||
"no_playlists_yet": "暂无播放列表。请先创建一个!",
|
||||
"selected_files": "已选择:",
|
||||
"error": "错误"
|
||||
"error": "错误",
|
||||
"search_audio": "搜索音频文件…",
|
||||
"no_audio_files": "未找到音频文件",
|
||||
"selected": "已选择",
|
||||
"loading": "加载中…",
|
||||
"search_error": "无法加载音频文件",
|
||||
"adding": "添加中…"
|
||||
},
|
||||
"actions": {
|
||||
"search": "搜索文件...",
|
||||
|
||||
Reference in New Issue
Block a user