Music Player & Playlist Manager

This commit is contained in:
Andrey Tkachenko
2026-04-08 15:14:03 +03:00
parent 2e8e0ef6c5
commit da066f47fa
53 changed files with 6043 additions and 23 deletions
+5 -1
View File
@@ -618,7 +618,11 @@ function setupEventListeners() {
switchToPhotosSection();
break;
case 'nav.trash':
case 'nav.music':
switchToMusicSection();
break;
case 'nav.trash':
switchToTrashSection();
break;
+35 -1
View File
@@ -108,7 +108,8 @@ const VIEW_FLAGS = {
recent: 'isRecentView',
favorites: 'isFavoritesView',
trash: 'isTrashView',
photos: 'isPhotosView'
photos: 'isPhotosView',
music: 'isMusicView'
};
/**
@@ -158,6 +159,11 @@ function setCurrentSection(section) {
window.photosView.hide();
}
// Hide musicView when switching to any other section
if (section !== 'music' && window.musicView) {
window.musicView.hide();
}
return true;
}
@@ -323,10 +329,38 @@ function switchToTrashSection() {
if (window.multiSelect) window.multiSelect.clear();
}
function switchToMusicSection() {
if (!setCurrentSection('music')) return;
// Hide breadcrumb
const breadcrumb = document.querySelector('.breadcrumb');
breadcrumb?.classList.add("hidden");
// Hide file containers
toggleFileContainer(false);
// Hide actions-bar
window.setActionsBarMode('hidden');
// Reset files view + remove any error
window.ui.resetFilesList();
// Hide list header (created by resetFilesList)
const listHeader = document.querySelector('.list-header');
listHeader?.classList.add('hidden');
// Show music view
if (window.musicView) {
window.musicView.show();
}
if (window.multiSelect) window.multiSelect.clear();
}
window.switchToFilesSection = switchToFilesSection;
window.switchToSharedSection = switchToSharedSection;
window.switchToFavoritesSection = switchToFavoritesSection;
window.switchToRecentFilesSection = switchToRecentFilesSection;
window.switchToPhotosSection = switchToPhotosSection;
window.switchToTrashSection = switchToTrashSection;
window.switchToMusicSection = switchToMusicSection;
window.syncViewContainers = syncViewContainers;
+39 -1
View File
@@ -69,6 +69,9 @@ const ui = {
<div class="context-menu-item" id="share-file-option">
<i class="fas fa-share-alt"></i> <span data-i18n="actions.share">Share</span>
</div>
<div class="context-menu-item hidden" id="add-to-playlist-option">
<i class="fas fa-compact-disc"></i> <span data-i18n="music.add_to_playlist">Add to Playlist</span>
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" id="rename-file-option">
<i class="fas fa-pen"></i> <span data-i18n="actions.rename">Rename</span>
@@ -271,6 +274,33 @@ const ui = {
});
}
// Playlist selection dialog
if (!document.getElementById('playlist-dialog')) {
const playlistDialog = document.createElement('div');
playlistDialog.className = 'share-dialog';
playlistDialog.id = 'playlist-dialog';
playlistDialog.innerHTML = `
<div class="share-dialog-content">
<div class="share-dialog-header">
<i class="fas fa-music dialog-header-icon"></i>
<span data-i18n="music.add_to_playlist">Add to Playlist</span>
</div>
<div id="playlist-dialog-files-info" class="shared-item-info"></div>
<div id="playlist-select-container" class="folder-select-container">
</div>
<div class="rename-dialog-buttons">
<button class="btn btn-secondary" id="playlist-cancel-btn" data-i18n="actions.cancel">Cancel</button>
<button class="btn btn-primary" id="playlist-add-btn" data-i18n="music.add">Add</button>
</div>
</div>
`;
document.body.appendChild(playlistDialog);
document.getElementById('playlist-cancel-btn').addEventListener('click', () => {
if (window.contextMenus) window.contextMenus.closePlaylistDialog();
});
}
// Assign events to menu items
if (window.contextMenus) {
window.contextMenus.assignMenuEvents();
@@ -855,10 +885,12 @@ const ui = {
parent_id: card.dataset.parentId || ''
};
} else {
const fileData = info.data || self._items.get(info.id);
window.app.contextMenuTargetFile = {
id: info.id,
name: card.dataset.fileName,
folder_id: card.dataset.folderId || ''
folder_id: card.dataset.folderId || '',
mime_type: fileData?.mime_type || null
};
}
};
@@ -936,6 +968,9 @@ const ui = {
if (window.contextMenus && typeof window.contextMenus.syncWopiOptionVisibility === 'function') {
window.contextMenus.syncWopiOptionVisibility().catch(() => {});
}
if (window.contextMenus && typeof window.contextMenus.syncAddToPlaylistOption === 'function') {
window.contextMenus.syncAddToPlaylistOption();
}
menu.style.left = `${e.pageX}px`;
menu.style.top = `${e.pageY}px`;
menu.style.display = 'block';
@@ -1406,6 +1441,9 @@ function showContextMenuAtElement(triggerElement, menuId) {
if (window.contextMenus && typeof window.contextMenus.syncWopiOptionVisibility === 'function') {
window.contextMenus.syncWopiOptionVisibility().catch(() => {});
}
if (window.contextMenus && typeof window.contextMenus.syncAddToPlaylistOption === 'function') {
window.contextMenus.syncAddToPlaylistOption();
}
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
+28
View File
@@ -253,6 +253,34 @@ const _ICONS = {
384,
'M73 39c-14.8-9.1-33.4-9.4-48.5-.9S0 62.6 0 80L0 432c0 17.4 9.4 33.4 24.5 41.9s33.7 8.1 48.5-.9L361 297c14.3-8.7 23-24.2 23-41s-8.7-32.2-23-41L73 39z'
],
pause: [
384,
'M48 32C21.5 32 0 53.5 0 80L0 432c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48L48 32zm224 0c-26.5 0-48 21.5-48 48l0 352c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48l-64 0z'
],
shuffle: [
512,
'M403.8 34.4c12-5 25.7-2.2 34.9 6.9l64 64c6 6 9.4 14.1 9.4 22.6s-3.4 16.6-9.4 22.6l-64 64c-9.2 9.2-22.9 11.9-34.9 6.9S384 204.9 384 192l0-32-32 0c-10.1 0-19.6 4.7-25.6 12.8l-32.4 43.2-40-53.3 21.2-28.3C293.3 110.2 321.8 96 352 96l32 0 0-32c0-12.9 7.8-24.6 19.8-29.6zM154 296l40 53.3-21.2 28.3C154.7 401.8 126.2 416 96 416l-64 0c-17.7 0-32-14.3-32-32s14.3-32 32-32l64 0c10.1 0 19.6-4.7 25.6-12.8L154 296zM438.6 470.6c-9.2 9.2-22.9 11.9-34.9 6.9S384 460.9 384 448l0-32-32 0c-30.2 0-58.7-14.2-76.8-38.4L121.6 172.8c-6-8.1-15.5-12.8-25.6-12.8l-64 0c-17.7 0-32-14.3-32-32S14.3 96 32 96l64 0c30.2 0 58.7 14.2 76.8 38.4L326.4 339.2c6 8.1 15.5 12.8 25.6 12.8l32 0 0-32c0-12.9 7.8-24.6 19.8-29.6s25.7-2.2 34.9 6.9l64 64c6 6 9.4 14.1 9.4 22.6s-3.4 16.6-9.4 22.6l-64 64z'
],
repeat: [
512,
'M470.6 118.6c12.5-12.5 12.5-32.8 0-45.3l-64-64c-9.2-9.2-22.9-11.9-34.9-6.9S352 19.1 352 32l0 32-160 0C86 64 0 150 0 256 0 273.7 14.3 288 32 288s32-14.3 32-32c0-70.7 57.3-128 128-128l160 0 0 32c0 12.9 7.8 24.6 19.8 29.6s25.7 2.2 34.9-6.9l64-64zM41.4 393.4c-12.5 12.5-12.5 32.8 0 45.3l64 64c9.2 9.2 22.9 11.9 34.9 6.9S160 492.9 160 480l0-32 160 0c106 0 192-86 192-192 0-17.7-14.3-32-32-32s-32 14.3-32 32c0 70.7-57.3 128-128 128l-160 0 0-32c0-12.9-7.8-24.6-19.8-29.6s-25.7-2.2-34.9 6.9l-64 64z'
],
forward: [
512,
'M371.7 43.1C360.1 32 343 28.9 328.3 35.2S304 56 304 72l0 136.3-172.3-165.1C120.1 32 103 28.9 88.3 35.2S64 56 64 72l0 368c0 16 9.6 30.5 24.3 36.8s31.8 3.2 43.4-7.9L304 303.7 304 440c0 16 9.6 30.5 24.3 36.8s31.8 3.2 43.4-7.9l192-184c7.9-7.5 12.3-18 12.3-28.9s-4.5-21.3-12.3-28.9l-192-184z'
],
backward: [
512,
'M204.3 43.1C215.9 32 233 28.9 247.7 35.2S272 56 272 72l0 136.3 172.3-165.1C455.9 32 473 28.9 487.7 35.2S512 56 512 72l0 368c0 16-9.6 30.5-24.3 36.8s-31.8 3.2-43.4-7.9L272 303.7 272 440c0 16-9.6 30.5-24.3 36.8s-31.8 3.2-43.4-7.9l-192-184C4.5 277.3 0 266.9 0 256s4.5-21.3 12.3-28.9l192-184z'
],
volume: [
512,
'M48 352l48 0 134.1 119.2c6.4 5.7 14.6 8.8 23.1 8.8 19.2 0 34.8-15.6 34.8-34.8l0-378.4c0-19.2-15.6-34.8-34.8-34.8-8.5 0-16.7 3.1-23.1 8.8L96 160 48 160c-26.5 0-48 21.5-48 48l0 96c0 26.5 21.5 48 48 48zM441.1 107c-10.3-8.4-25.4-6.8-33.8 3.5s-6.8 25.4 3.5 33.8C443.3 170.7 464 210.9 464 256s-20.7 85.3-53.2 111.8c-10.3 8.4-11.8 23.5-3.5 33.8s23.5 11.8 33.8 3.5c43.2-35.2 70.9-88.9 70.9-149s-27.7-113.8-70.9-149zm-60.5 74.5c-10.3-8.4-25.4-6.8-33.8 3.5s-6.8 25.4 3.5 33.8C361.1 227.6 368 241 368 256s-6.9 28.4-17.7 37.3c-10.3 8.4-11.8 23.5-3.5 33.8s23.5 11.8 33.8 3.5C402.1 312.9 416 286.1 416 256s-13.9-56.9-35.5-74.5z'
],
'compact-disc': [
512,
'M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zM88 256H56c0-105.9 86.1-192 192-192v32c-88.2 0-160 71.8-160 160zm160 96c-53 0-96-43-96-96s43-96 96-96 96 43 96 96-43 96-96 96zm0-128c-17.7 0-32 14.3-32 32s14.3 32 32 32 32-14.3 32-32-14.3-32-32-32z'
],
'question-circle': [
512,
'M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM169.8 165.3c7.9-22.3 29.1-37.3 52.8-37.3l58.3 0c34.9 0 63.1 28.3 63.1 63.1c0 22.6-12.1 43.5-31.7 54.8L280 264.4c-.2 13-10.9 23.6-24 23.6c-13.3 0-24-10.7-24-24l0-13.5c0-8.6 4.6-16.5 12.1-20.8l44.3-25.4c4.7-2.7 7.6-7.7 7.6-13.1c0-8.4-6.8-15.1-15.1-15.1l-58.3 0c-3.4 0-6.4 2.1-7.5 5.3l-.4 1.2c-4.4 12.5-18.2 19-30.6 14.6s-19-18.2-14.6-30.6l.4-1.2zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z'
+166
View File
@@ -51,6 +51,19 @@ const contextMenus = {
}
},
syncAddToPlaylistOption() {
const option = document.getElementById('add-to-playlist-option');
if (!option) return;
const targetFile = window.app && window.app.contextMenuTargetFile;
if (targetFile) {
const isAudio = targetFile.mime_type && targetFile.mime_type.startsWith('audio/');
option.classList.toggle('hidden', !isAudio);
} else {
option.classList.add('hidden');
}
},
/**
* Assign events to menu items and dialogs
*/
@@ -217,6 +230,18 @@ const contextMenus = {
window.ui.closeFileContextMenu();
});
document.getElementById('add-to-playlist-option').addEventListener('click', () => {
const file = window.app.contextMenuTargetFile;
if (file) {
this.showPlaylistDialog(file);
}
window.ui.closeFileContextMenu();
});
document.getElementById('playlist-add-btn').addEventListener('click', () => {
this.addSelectedFilesToPlaylist();
});
document.getElementById('delete-file-option').addEventListener('click', async () => {
const file = window.app.contextMenuTargetFile;
window.ui.closeFileContextMenu();
@@ -1049,6 +1074,147 @@ const contextMenus = {
closeNotificationDialog() {
document.getElementById('notification-dialog').style.display = 'none';
window.app.notificationShareUrl = null;
},
_selectedPlaylistId: null,
async showPlaylistDialog(file) {
const dialog = document.getElementById('playlist-dialog');
const container = document.getElementById('playlist-select-container');
const filesInfo = document.getElementById('playlist-dialog-files-info');
if (!dialog || !container) {
console.error('Playlist dialog elements not found');
return;
}
// Store the file(s) to add
window.app.playlistDialogFiles = [file];
// Update files info
if (filesInfo) {
filesInfo.innerHTML = `<strong>${window.i18n ? window.i18n.t('music.selected_files', 'Selected:') : 'Selected:'} </strong>${file.name}`;
}
// Reset selection
this._selectedPlaylistId = null;
container.innerHTML = '<div class="folder-select-loading"><i class="fas fa-spinner fa-spin"></i></div>';
// Reset add button state
const addBtn = document.getElementById('playlist-add-btn');
if (addBtn) addBtn.disabled = true;
// Show dialog
dialog.style.display = 'flex';
requestAnimationFrame(() => dialog.classList.add('active'));
// Load playlists
try {
const resp = await fetch('/api/playlists', { credentials: 'include' });
if (!resp.ok) throw new Error('Failed to load playlists');
const playlists = await resp.json();
this._renderPlaylistSelect(container, playlists);
} catch (err) {
console.error('Error loading playlists:', err);
container.innerHTML = `<div class="folder-select-empty">${window.i18n ? window.i18n.t('music.load_error', 'Error loading playlists') : 'Error loading playlists'}</div>`;
}
},
_renderPlaylistSelect(container, playlists) {
const t = (key, fallback) => window.i18n ? window.i18n.t(key, fallback) : fallback;
container.innerHTML = '';
if (playlists.length === 0) {
container.innerHTML = `<div class="folder-select-empty">${t('music.no_playlists', 'No playlists yet. Create one first!')}</div>`;
return;
}
playlists.forEach(playlist => {
const item = document.createElement('div');
item.className = 'folder-select-item';
item.dataset.id = playlist.id;
item.innerHTML = `
<i class="fas fa-list"></i>
<span>${this._escapeHtml(playlist.name)}</span>
<span class="playlist-track-count">${playlist.track_count || 0} ${t('music.tracks', 'tracks')}</span>
`;
item.addEventListener('click', () => {
container.querySelectorAll('.folder-select-item').forEach(el => el.classList.remove('selected'));
item.classList.add('selected');
this._selectedPlaylistId = playlist.id;
const addBtn = document.getElementById('playlist-add-btn');
if (addBtn) addBtn.disabled = false;
});
container.appendChild(item);
});
},
async addSelectedFilesToPlaylist() {
const playlistId = this._selectedPlaylistId;
const files = window.app.playlistDialogFiles || [];
if (!playlistId || files.length === 0) return;
const addBtn = document.getElementById('playlist-add-btn');
if (addBtn) addBtn.disabled = true;
try {
const resp = await fetch(`/api/playlists/${playlistId}/tracks`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...getCsrfHeaders()
},
body: JSON.stringify({ file_ids: files.map(f => f.id) })
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || 'Failed to add tracks');
}
const result = await resp.json();
window.ui.showNotification(
window.i18n ? window.i18n.t('music.added', 'Added!') : 'Added!',
`${files.length} ${files.length === 1 ? 'track' : 'tracks'} ${window.i18n ? window.i18n.t('music.added_to_playlist', 'added to playlist') : 'added to playlist'}`
);
this.closePlaylistDialog();
// Refresh music view if open
if (window.musicView && window.musicView.playlists) {
window.musicView._loadPlaylists();
}
} catch (err) {
console.error('Error adding to playlist:', err);
window.ui.showNotification(
window.i18n ? window.i18n.t('music.error', 'Error') : 'Error',
err.message || window.i18n.t('music.add_error', 'Could not add tracks to playlist')
);
if (addBtn) addBtn.disabled = false;
}
},
closePlaylistDialog() {
const dialog = document.getElementById('playlist-dialog');
if (dialog) {
dialog.classList.remove('active');
setTimeout(() => {
dialog.style.display = 'none';
}, 200);
}
window.app.playlistDialogFiles = null;
this._selectedPlaylistId = null;
},
_escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
};
+980
View File
@@ -0,0 +1,980 @@
/**
* OxiCloud - Music Library View
* Playlist management with track listings and audio player
*/
const musicView = {
playlists: [],
currentPlaylist: null,
currentTracks: [],
loading: false,
_container: null,
_initialized: false,
selected: new Set(),
_headers(json = false) {
const h = typeof getCsrfHeaders === 'function' ? { ...getCsrfHeaders() } : {};
if (json) h['Content-Type'] = 'application/json';
return h;
},
init() {
if (!this._container) {
const contentArea = document.querySelector('.content-area');
if (!contentArea) return;
const el = document.createElement('div');
el.id = 'music-container';
el.className = 'music-container';
contentArea.appendChild(el);
this._container = el;
}
musicPlayer.init();
this._initialized = true;
},
show() {
this.init();
if (!this._container) return;
this._container.classList.add('active');
this.currentPlaylist = null;
this.currentTracks = [];
this._container.innerHTML = '';
this._renderPlaylists();
this._loadPlaylists();
},
hide() {
if (this._container) {
this._container.classList.remove('active');
}
this.currentPlaylist = null;
this.currentTracks = [];
},
async _loadPlaylists() {
if (this.loading) return;
this.loading = true;
this._showLoading(true);
try {
const resp = await fetch('/api/playlists', {
credentials: 'include',
headers: this._headers()
});
if (!resp.ok) throw new Error('Failed to load playlists');
this.playlists = await resp.json();
this._renderPlaylists();
} catch (err) {
console.error('Music load error:', err);
this._showError(err.message);
} finally {
this.loading = false;
this._showLoading(false);
}
},
_renderPlaylists() {
if (!this._container) return;
const t = (key, fallback = '') => {
return typeof i18n !== 'undefined' && i18n.t ? i18n.t(key) : fallback || key;
};
this._container.innerHTML = `
<div class="music-toolbar">
<button class="btn btn-primary" id="music-create-playlist-btn">
<i class="fas fa-plus"></i>
<span>${t('music.create_playlist', 'Create Playlist')}</span>
</button>
</div>
<div class="music-content">
<div class="music-sidebar">
<div class="music-sidebar-header">
<h3>${t('music.playlists', 'Playlists')}</h3>
</div>
<div class="music-playlist-list" id="music-playlist-list">
${this.playlists.length === 0 ? `
<div class="music-empty">
<i class="fas fa-music"></i>
<p>${t('music.no_playlists', 'No playlists yet')}</p>
</div>
` : ''}
</div>
</div>
<div class="music-main">
<div class="music-welcome">
<i class="fas fa-music"></i>
<h3>${t('music.select_playlist', 'Select a playlist')}</h3>
<p>${t('music.select_hint', 'Choose a playlist from the sidebar or create a new one')}</p>
</div>
<div class="music-playlist-detail hidden" id="music-playlist-detail">
<div class="music-playlist-header">
<div class="music-playlist-cover">
<i class="fas fa-music"></i>
</div>
<div class="music-playlist-info">
<h2 id="music-playlist-name"></h2>
<p id="music-playlist-meta"></p>
</div>
</div>
<div class="music-playlist-actions">
<button class="btn btn-secondary" id="music-play-all-btn">
<i class="fas fa-play"></i>
<span>${t('music.play_all', 'Play All')}</span>
</button>
<button class="btn btn-secondary" id="music-shuffle-btn">
<i class="fas fa-shuffle"></i>
</button>
<button class="btn btn-secondary" id="music-add-tracks-btn">
<i class="fas fa-plus"></i>
<span>${t('music.add_tracks', 'Add Tracks')}</span>
</button>
<button class="btn btn-secondary" id="music-edit-playlist-btn">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-secondary" id="music-share-playlist-btn">
<i class="fas fa-share"></i>
</button>
<button class="btn btn-secondary" id="music-delete-playlist-btn">
<i class="fas fa-trash"></i>
</button>
</div>
<div class="music-track-list" id="music-track-list"></div>
</div>
</div>
</div>
`;
this._bindPlaylistEvents();
this._renderPlaylistList();
},
_renderPlaylistList() {
const listEl = document.getElementById('music-playlist-list');
if (!listEl) return;
if (this.playlists.length === 0) {
listEl.innerHTML = `
<div class="music-empty">
<i class="fas fa-music"></i>
<p>No playlists yet</p>
</div>
`;
return;
}
const t = (key, fallback = '') => {
return typeof i18n !== 'undefined' && i18n.t ? i18n.t(key) : fallback || key;
};
listEl.innerHTML = this.playlists.map(p => `
<div class="music-playlist-item" data-id="${p.id}">
<div class="music-playlist-icon">
<i class="fas fa-list"></i>
</div>
<div class="music-playlist-item-info">
<span class="music-playlist-item-name">${this._escapeHtml(p.name)}</span>
<span class="music-playlist-item-count">${p.track_count || 0} ${t('music.tracks', 'tracks')}</span>
</div>
</div>
`).join('');
listEl.querySelectorAll('.music-playlist-item').forEach(item => {
item.addEventListener('click', () => {
const id = item.dataset.id;
this._selectPlaylist(id);
});
});
},
_bindPlaylistEvents() {
const createBtn = document.getElementById('music-create-playlist-btn');
if (createBtn) {
createBtn.addEventListener('click', () => this._showCreatePlaylistDialog());
}
const playAllBtn = document.getElementById('music-play-all-btn');
if (playAllBtn) {
playAllBtn.addEventListener('click', () => this._playAll());
}
const shuffleBtn = document.getElementById('music-shuffle-btn');
if (shuffleBtn) {
shuffleBtn.addEventListener('click', () => this._shufflePlay());
}
const deleteBtn = document.getElementById('music-delete-playlist-btn');
if (deleteBtn) {
deleteBtn.addEventListener('click', () => this._deletePlaylist());
}
},
async _selectPlaylist(playlistId) {
const playlist = this.playlists.find(p => p.id === playlistId);
if (!playlist) return;
this.currentPlaylist = playlist;
const detailEl = document.getElementById('music-playlist-detail');
const welcomeEl = this._container.querySelector('.music-welcome');
const nameEl = document.getElementById('music-playlist-name');
const metaEl = document.getElementById('music-playlist-meta');
if (welcomeEl) welcomeEl.classList.add('hidden');
if (detailEl) detailEl.classList.remove('hidden');
if (nameEl) nameEl.textContent = playlist.name;
if (metaEl) {
metaEl.textContent = `${playlist.track_count || 0} tracks`;
}
document.querySelectorAll('.music-playlist-item').forEach(item => {
item.classList.toggle('active', item.dataset.id === playlistId);
});
await this._loadPlaylistTracks(playlistId);
},
async _loadPlaylistTracks(playlistId) {
const trackListEl = document.getElementById('music-track-list');
if (!trackListEl) return;
trackListEl.innerHTML = '<div class="music-loading"><i class="fas fa-spinner fa-spin"></i></div>';
try {
const resp = await fetch(`/api/playlists/${playlistId}/tracks`, {
credentials: 'include',
headers: this._headers()
});
if (!resp.ok) throw new Error('Failed to load tracks');
this.currentTracks = await resp.json();
this._renderTracks();
} catch (err) {
console.error('Track load error:', err);
trackListEl.innerHTML = `<div class="music-error">${err.message}</div>`;
}
},
_renderTracks() {
const trackListEl = document.getElementById('music-track-list');
if (!trackListEl) return;
const t = (key, fallback = '') => {
return typeof i18n !== 'undefined' && i18n.t ? i18n.t(key) : fallback || key;
};
if (this.currentTracks.length === 0) {
trackListEl.innerHTML = `
<div class="music-empty">
<i class="fas fa-music"></i>
<p>${t('music.no_tracks', 'No tracks in this playlist')}</p>
</div>
`;
return;
}
trackListEl.innerHTML = `
<div class="music-track-header">
<span class="music-track-col music-track-num">#</span>
<span class="music-track-col music-track-title">${t('music.title', 'Title')}</span>
<span class="music-track-col music-track-artist">${t('music.artist', 'Artist')}</span>
<span class="music-track-col music-track-album">${t('music.album', 'Album')}</span>
<span class="music-track-col music-track-duration"><i class="far fa-clock"></i></span>
</div>
${this.currentTracks.map((track, idx) => `
<div class="music-track ${musicPlayer.currentTrack?.id === track.id ? 'playing' : ''}" data-idx="${idx}" data-id="${track.id}" data-file-id="${track.file_id}">
<span class="music-track-col music-track-num">
<span class="track-num-text">${idx + 1}</span>
<i class="fas fa-play track-play-icon hidden"></i>
</span>
<span class="music-track-col music-track-title">
<i class="fas fa-music music-track-icon"></i>
<span class="music-track-name">${this._escapeHtml(track.title || track.file_name || t('music.unknown_title', 'Unknown'))}</span>
</span>
<span class="music-track-col music-track-artist">${this._escapeHtml(track.artist || t('music.unknown_artist', 'Unknown Artist'))}</span>
<span class="music-track-col music-track-album">${this._escapeHtml(track.album || '-')}</span>
<span class="music-track-col music-track-duration">${this._formatDuration(track.duration_secs)}</span>
</div>
`).join('')}
`;
const self = this;
trackListEl.querySelectorAll('.music-track').forEach(row => {
row.addEventListener('click', () => {
const idx = parseInt(row.dataset.idx);
self._playTrack(idx);
});
row.addEventListener('dblclick', () => {
const idx = parseInt(row.dataset.idx);
self._playTrack(idx);
});
});
},
_playTrack(idx) {
if (!this.currentTracks[idx]) return;
musicPlayer.setQueue(this.currentTracks, this.currentPlaylist?.name || 'Playlist');
musicPlayer.playTrack(idx);
},
_playAll() {
if (this.currentTracks.length > 0) {
this._playTrack(0);
}
},
_shufflePlay() {
if (this.currentTracks.length > 0) {
const shuffled = [...this.currentTracks].sort(() => Math.random() - 0.5);
musicPlayer.setQueue(shuffled, this.currentPlaylist?.name || 'Shuffle');
musicPlayer.playTrack(0);
}
},
_showCreatePlaylistDialog() {
const t = (key, fallback = '') => {
return typeof i18n !== 'undefined' && i18n.t ? i18n.t(key) : fallback || key;
};
const name = prompt(t('music.playlist_name', 'Playlist name:'));
if (!name || !name.trim()) return;
this._createPlaylist(name.trim());
},
async _createPlaylist(name) {
try {
const resp = await fetch('/api/playlists', {
method: 'POST',
credentials: 'include',
headers: this._headers(true),
body: JSON.stringify({ name, description: null })
});
if (!resp.ok) throw new Error('Failed to create playlist');
const playlist = await resp.json();
this.playlists.unshift(playlist);
this._renderPlaylistList();
this._selectPlaylist(playlist.id);
} catch (err) {
console.error('Create playlist error:', err);
alert('Failed to create playlist: ' + err.message);
}
},
async _deletePlaylist() {
const t = (key, fallback = '') => {
return typeof i18n !== 'undefined' && i18n.t ? i18n.t(key) : fallback || key;
};
if (!this.currentPlaylist) return;
if (!confirm(t('music.confirm_delete', 'Delete this playlist?'))) return;
try {
const resp = await fetch(`/api/playlists/${this.currentPlaylist.id}`, {
method: 'DELETE',
credentials: 'include',
headers: this._headers()
});
if (!resp.ok) throw new Error('Failed to delete playlist');
this.playlists = this.playlists.filter(p => p.id !== this.currentPlaylist.id);
this.currentPlaylist = null;
this.currentTracks = [];
this._renderPlaylists();
} catch (err) {
console.error('Delete playlist error:', err);
alert('Failed to delete playlist: ' + err.message);
}
},
_formatDuration(secs) {
if (!secs) return '-';
const mins = Math.floor(secs / 60);
const s = Math.floor(secs % 60);
return `${mins}:${s.toString().padStart(2, '0')}`;
},
_escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
},
_showLoading(show) {
const existing = this._container?.querySelector('.music-loading');
if (show && !existing) {
const loading = document.createElement('div');
loading.className = 'music-loading';
loading.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
this._container?.appendChild(loading);
} else if (!show && existing) {
existing.remove();
}
},
_showError(message) {
if (!this._container) return;
this._container.innerHTML = `
<div class="music-error">
<i class="fas fa-exclamation-circle"></i>
<p>${this._escapeHtml(message)}</p>
</div>
`;
}
};
/**
* Music Player Module
* Handles audio playback, queue, and controls
*/
const musicPlayer = {
audio: null,
queue: [],
currentIndex: -1,
currentTrack: null,
isPlaying: false,
volume: 0.7,
isMuted: false,
shuffle: false,
repeat: 'none',
playlistName: '',
_initialized: false,
init() {
if (this._initialized) return;
this.audio = new Audio();
this.audio.volume = this.volume;
this.audio.addEventListener('ended', () => this._onEnded());
this.audio.addEventListener('timeupdate', () => this._onTimeUpdate());
this.audio.addEventListener('loadedmetadata', () => this._onLoadedMetadata());
this.audio.addEventListener('play', () => this._onPlay());
this.audio.addEventListener('pause', () => this._onPause());
this.audio.addEventListener('error', (e) => this._onError(e));
this.audio.addEventListener('canplay', () => this._onCanPlay());
this._createPlayerUI();
this._bindEvents();
this._initialized = true;
},
_createPlayerUI() {
if (document.getElementById('music-player')) return;
const playerEl = document.createElement('div');
playerEl.id = 'music-player';
playerEl.className = 'music-player';
playerEl.innerHTML = `
<div class="player-track-info">
<div class="player-album-art">
<i class="fas fa-music"></i>
</div>
<div class="player-track-details">
<span class="player-track-name">${i18n?.t('music.not_playing', 'Not playing') || 'Not playing'}</span>
<span class="player-track-artist"></span>
</div>
</div>
<div class="player-controls">
<div class="player-buttons">
<button class="player-btn" id="player-shuffle-btn" title="${i18n?.t('music.shuffle', 'Shuffle') || 'Shuffle'}">
<i class="fas fa-shuffle"></i>
</button>
<button class="player-btn" id="player-prev-btn" title="${i18n?.t('music.previous', 'Previous') || 'Previous'}">
<i class="fas fa-backward"></i>
</button>
<button class="player-btn player-btn-main" id="player-play-btn" title="${i18n?.t('music.play', 'Play') || 'Play'}">
<i class="fas fa-play"></i>
</button>
<button class="player-btn" id="player-next-btn" title="${i18n?.t('music.next', 'Next') || 'Next'}">
<i class="fas fa-forward"></i>
</button>
<button class="player-btn" id="player-repeat-btn" title="${i18n?.t('music.repeat', 'Repeat') || 'Repeat'}">
<i class="fas fa-repeat"></i>
</button>
</div>
<div class="player-progress">
<span class="player-time player-time-current" id="player-current-time">0:00</span>
<div class="player-progress-bar" id="player-progress-bar">
<div class="player-progress-fill" id="player-progress-fill"></div>
<div class="player-progress-handle" id="player-progress-handle"></div>
</div>
<span class="player-time player-time-total" id="player-total-time">0:00</span>
</div>
</div>
<div class="player-extra">
<button class="player-btn player-btn-small" id="player-playlist-btn" title="${i18n?.t('music.queue', 'Queue') || 'Queue'}">
<i class="fas fa-list"></i>
</button>
<button class="player-btn player-btn-small" id="player-vol-btn" title="${i18n?.t('music.volume', 'Volume') || 'Volume'}">
<i class="fas fa-volume"></i>
</button>
<div class="player-volume-slider" id="player-volume-slider">
<input type="range" min="0" max="100" value="70" id="player-volume-input">
</div>
</div>
<div class="player-queue hidden" id="player-queue">
<div class="player-queue-header">
<h3>${i18n?.t('music.queue', 'Queue') || 'Queue'}</h3>
<button class="player-btn player-btn-small" id="player-close-queue-btn">
<i class="fas fa-times"></i>
</button>
</div>
<div class="player-queue-list" id="player-queue-list"></div>
</div>
`;
document.body.appendChild(playerEl);
},
_bindEvents() {
const playBtn = document.getElementById('player-play-btn');
const prevBtn = document.getElementById('player-prev-btn');
const nextBtn = document.getElementById('player-next-btn');
const shuffleBtn = document.getElementById('player-shuffle-btn');
const repeatBtn = document.getElementById('player-repeat-btn');
const progressBar = document.getElementById('player-progress-bar');
const volumeInput = document.getElementById('player-volume-input');
const volBtn = document.getElementById('player-vol-btn');
const playlistBtn = document.getElementById('player-playlist-btn');
const closeQueueBtn = document.getElementById('player-close-queue-btn');
const queue = document.getElementById('player-queue');
if (playBtn) {
playBtn.addEventListener('click', () => this.togglePlay());
}
if (prevBtn) {
prevBtn.addEventListener('click', () => this.prev());
}
if (nextBtn) {
nextBtn.addEventListener('click', () => this.next());
}
if (shuffleBtn) {
shuffleBtn.addEventListener('click', () => this.toggleShuffle());
}
if (repeatBtn) {
repeatBtn.addEventListener('click', () => this.toggleRepeat());
}
if (progressBar) {
progressBar.addEventListener('click', (e) => this._seek(e));
}
if (volumeInput) {
volumeInput.addEventListener('input', (e) => {
this.setVolume(e.target.value / 100);
});
}
if (volBtn) {
volBtn.addEventListener('click', () => this.toggleMute());
}
if (playlistBtn) {
playlistBtn.addEventListener('click', () => this._toggleQueue());
}
if (closeQueueBtn) {
closeQueueBtn.addEventListener('click', () => this._toggleQueue(false));
}
if (queue) {
queue.addEventListener('click', (e) => {
if (e.target === queue) {
this._toggleQueue(false);
}
});
}
},
setQueue(tracks, playlistName = '') {
this.queue = [...tracks];
this.playlistName = playlistName;
this._updateQueueUI();
},
playTrack(index) {
if (index < 0 || index >= this.queue.length) return;
this.currentIndex = index;
this.currentTrack = this.queue[index];
this._loadAndPlay();
},
_loadAndPlay() {
if (!this.currentTrack) return;
const fileId = this.currentTrack.file_id;
this.audio.src = `/api/files/${fileId}`;
this.audio.play().catch(err => {
console.error('Playback error:', err);
});
this.isPlaying = true;
this._updateUI();
},
togglePlay() {
if (!this.currentTrack) {
if (this.queue.length > 0) {
this.playTrack(0);
}
return;
}
if (this.isPlaying) {
this.audio.pause();
} else {
this.audio.play();
}
},
play() {
if (this.currentTrack && !this.isPlaying) {
this.audio.play();
}
},
pause() {
if (this.isPlaying) {
this.audio.pause();
}
},
next() {
if (this.queue.length === 0) return;
let nextIndex;
if (this.shuffle) {
nextIndex = Math.floor(Math.random() * this.queue.length);
} else {
nextIndex = this.currentIndex + 1;
if (nextIndex >= this.queue.length) {
nextIndex = 0;
}
}
this.playTrack(nextIndex);
},
prev() {
if (this.queue.length === 0) return;
if (this.audio.currentTime > 3) {
this.audio.currentTime = 0;
return;
}
let prevIndex = this.currentIndex - 1;
if (prevIndex < 0) {
prevIndex = this.queue.length - 1;
}
this.playTrack(prevIndex);
},
toggleShuffle() {
this.shuffle = !this.shuffle;
const btn = document.getElementById('player-shuffle-btn');
if (btn) {
btn.classList.toggle('active', this.shuffle);
}
},
toggleRepeat() {
const modes = ['none', 'all', 'one'];
const currentIdx = modes.indexOf(this.repeat);
this.repeat = modes[(currentIdx + 1) % modes.length];
const btn = document.getElementById('player-repeat-btn');
if (btn) {
btn.classList.remove('active', 'repeat-one');
if (this.repeat === 'all') {
btn.classList.add('active');
} else if (this.repeat === 'one') {
btn.classList.add('active', 'repeat-one');
}
}
},
setVolume(vol) {
this.volume = Math.max(0, Math.min(1, vol));
this.audio.volume = this.volume;
this.isMuted = this.volume === 0;
this._updateVolumeIcon();
const input = document.getElementById('player-volume-input');
if (input) {
input.value = this.volume * 100;
}
},
toggleMute() {
this.isMuted = !this.isMuted;
this.audio.muted = this.isMuted;
this._updateVolumeIcon();
},
_updateVolumeIcon() {
const btn = document.getElementById('player-vol-btn');
if (!btn) return;
let icon;
if (this.isMuted || this.volume === 0) {
icon = 'fa-volume-mute';
} else if (this.volume < 0.5) {
icon = 'fa-volume-down';
} else {
icon = 'fa-volume-up';
}
btn.querySelector('i').className = `fas ${icon}`;
},
_seek(e) {
const bar = document.getElementById('player-progress-bar');
if (!bar) return;
const rect = bar.getBoundingClientRect();
const percent = (e.clientX - rect.left) / rect.width;
if (this.audio.duration) {
this.audio.currentTime = percent * this.audio.duration;
}
},
_onEnded() {
if (this.repeat === 'one') {
this.audio.currentTime = 0;
this.audio.play();
} else if (this.repeat === 'all' || this.currentIndex < this.queue.length - 1) {
this.next();
} else {
this.isPlaying = false;
this._updateUI();
}
},
_onTimeUpdate() {
if (!this.audio.duration) return;
const current = this.audio.currentTime;
const total = this.audio.duration;
const percent = (current / total) * 100;
const fill = document.getElementById('player-progress-fill');
const handle = document.getElementById('player-progress-handle');
const currentTimeEl = document.getElementById('player-current-time');
if (fill) fill.style.width = `${percent}%`;
if (handle) handle.style.left = `${percent}%`;
if (currentTimeEl) currentTimeEl.textContent = this._formatTime(current);
},
_onLoadedMetadata() {
const totalTimeEl = document.getElementById('player-total-time');
if (totalTimeEl) {
totalTimeEl.textContent = this._formatTime(this.audio.duration);
}
if (this.currentTrack && this.audio.duration) {
this.currentTrack.duration_secs = Math.round(this.audio.duration);
const trackDurationEl = document.querySelector(`.music-track[data-idx="${this.currentIndex}"] .music-track-duration`);
if (trackDurationEl) {
trackDurationEl.textContent = this._formatDuration(this.audio.duration);
}
const queueDurationEl = document.querySelector(`.queue-item[data-idx="${this.currentIndex}"] .queue-item-duration`);
if (queueDurationEl) {
queueDurationEl.textContent = this._formatDuration(this.audio.duration);
}
}
},
_onPlay() {
this.isPlaying = true;
this._updateUI();
},
_onPause() {
this.isPlaying = false;
this._updateUI();
},
_onCanPlay() {
this._updateUI();
},
_onError(e) {
console.error('Audio error:', e);
this.isPlaying = false;
this._updateUI();
},
_updateUI() {
const playBtn = document.getElementById('player-play-btn');
const trackName = document.querySelector('.player-track-name');
const trackArtist = document.querySelector('.player-track-artist');
if (playBtn) {
const icon = playBtn.querySelector('i') || playBtn.querySelector('svg');
if (icon) {
const iconName = this.isPlaying ? 'pause' : 'play';
const extraClass = 'player-btn-main';
if (window.oxiIcon) {
icon.outerHTML = window.oxiIcon(iconName, extraClass);
} else {
icon.className = `fas fa-${iconName} ${extraClass}`;
}
}
}
if (trackName) {
trackName.textContent = this.currentTrack
? (this.currentTrack.title || this.currentTrack.file_name || 'Unknown')
: (i18n?.t('music.not_playing', 'Not playing') || 'Not playing');
}
if (trackArtist) {
trackArtist.textContent = this.currentTrack?.artist || '';
}
if (musicView.currentTracks.length > 0) {
document.querySelectorAll('.music-track').forEach(row => {
const idx = parseInt(row.dataset.idx);
row.classList.toggle('playing', idx === this.currentIndex && this.isPlaying);
const numText = row.querySelector('.track-num-text');
const playIcon = row.querySelector('.track-play-icon');
if (idx === this.currentIndex) {
if (numText) numText.classList.add('hidden');
if (playIcon) playIcon.classList.remove('hidden');
if (playIcon) {
const iconName = this.isPlaying ? 'pause' : 'play';
if (window.oxiIcon) {
playIcon.outerHTML = window.oxiIcon(iconName, 'track-play-icon');
} else {
playIcon.className = `fas fa-${iconName} track-play-icon`;
}
}
} else {
if (numText) numText.classList.remove('hidden');
if (playIcon) playIcon.classList.add('hidden');
}
});
}
const player = document.getElementById('music-player');
if (player) {
player.classList.toggle('has-track', !!this.currentTrack);
}
},
_updateQueueUI() {
const queueList = document.getElementById('player-queue-list');
if (!queueList) return;
const t = (key, fallback = '') => {
return typeof i18n !== 'undefined' && i18n.t ? i18n.t(key) : fallback || key;
};
if (this.queue.length === 0) {
queueList.innerHTML = `
<div class="player-queue-empty">
<i class="fas fa-music"></i>
<p>${t('music.queue_empty', 'Queue is empty')}</p>
</div>
`;
return;
}
queueList.innerHTML = this.queue.map((track, idx) => `
<div class="player-queue-item ${idx === this.currentIndex ? 'active' : ''}" data-idx="${idx}">
<span class="queue-item-num">${idx + 1}</span>
<span class="queue-item-info">
<span class="queue-item-name">${this._escapeHtml(track.title || track.file_name || t('music.unknown_title', 'Unknown'))}</span>
<span class="queue-item-artist">${this._escapeHtml(track.artist || t('music.unknown_artist', 'Unknown Artist'))}</span>
</span>
<span class="queue-item-duration">${this._formatDuration(track.duration_secs)}</span>
<button class="queue-item-remove" data-idx="${idx}">
<i class="fas fa-times"></i>
</button>
</div>
`).join('');
queueList.querySelectorAll('.player-queue-item').forEach(item => {
item.addEventListener('click', (e) => {
if (e.target.closest('.queue-item-remove')) return;
const idx = parseInt(item.dataset.idx);
this.playTrack(idx);
});
});
queueList.querySelectorAll('.queue-item-remove').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const idx = parseInt(btn.dataset.idx);
this._removeFromQueue(idx);
});
});
},
_removeFromQueue(idx) {
if (idx === this.currentIndex) {
if (this.queue.length === 1) {
this.audio.pause();
this.currentTrack = null;
this.currentIndex = -1;
} else {
this.next();
}
}
this.queue.splice(idx, 1);
if (idx < this.currentIndex) {
this.currentIndex--;
}
this._updateQueueUI();
this._updateUI();
},
_toggleQueue(show) {
const queue = document.getElementById('player-queue');
if (queue) {
if (show === undefined) {
queue.classList.toggle('hidden');
} else {
queue.classList.toggle('hidden', !show);
}
}
},
_formatTime(secs) {
if (!secs || isNaN(secs)) return '0:00';
const mins = Math.floor(secs / 60);
const s = Math.floor(secs % 60);
return `${mins}:${s.toString().padStart(2, '0')}`;
},
_formatDuration(secs) {
return this._formatTime(secs);
},
_escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
};
document.addEventListener('DOMContentLoaded', () => {
musicPlayer.init();
});
window.musicView = musicView;