feat(music): complete music UI overhaul - bugs, UX, missing features & styling

- Fix dead buttons (fa-edit/fa-share → fa-pen/fa-share-alt matching icon registry)
- Fix volume icon, shuffle bias, queue removal, repeat-one CSS
- Replace native prompt/confirm/alert with Modal system
- Add click-to-select, dblclick-to-play, playback error notifications
- Add loading indicators, success toasts, close player button
- Implement remove track from playlist (DELETE endpoint)
- Implement drag & drop track reorder (PUT reorder endpoint)
- Implement manage shares dialog (GET/DELETE shares endpoints)
- Implement cover art upload & display (cover_file_id in UpdatePlaylistDto)
- Implement public/private toggle (is_public in UpdatePlaylistDto)
- Mount get_audio_metadata route in backend routes.rs
- Redesign empty state: full-width centered onboarding when 0 playlists
- Move create button into sidebar header as compact + button
- Add music.css to build.rs production CSS bundle
- Add 16+ i18n keys in en.json and es.json
- Add CSS for drag handles, track remove, cover overlay, shares dialog, public badge
This commit is contained in:
Diocrafts
2026-04-11 10:59:01 +02:00
parent 6880edf641
commit 3ce8ec25d6
7 changed files with 1166 additions and 241 deletions
-173
View File
@@ -1,173 +0,0 @@
## OxiCloud v0.5.3 — Security, Stability & Kubernetes Ready
A community-powered release with **42 commits** from **7 contributors**, touching **106 files** with nearly **2,000 lines of improvements**. This release focuses on **critical security hardening**, **memory & reliability optimizations**, **Kubernetes-native deployment via Helm**, and a wave of **UI/UX and WebDAV fixes** that dramatically improve the day-to-day experience.
---
### Highlights
- **Kubernetes Helm Chart** — First-class Kubernetes deployment with a full Helm chart, including optional WOPI integration
- **SQLx Migration System** — Replaced custom schema loader with `sqlx::migrate!()` for robust, versioned database migrations
- **Security Advisory Fix** — Patched RUSTSEC-2026-0037 (quinn-proto) to eliminate a known vulnerability
- **Folder Ownership Verification** — Files can no longer be moved to another user's folder, closing a critical access control gap
- **Drag & Drop into Breadcrumbs** — Drag files directly into breadcrumb folders for faster file organization
- **Download Progress for Large Files** — New progress bar for files >2GB with correct 64-bit math
- **Thumbnail Timeout Protection** — Large image processing now has configurable timeouts to prevent server hangs
---
### Features
- **SQL migration system using `sqlx::migrate!()`** — Automatic, versioned schema migrations on startup replace the previous manual schema loader (@jaredwolff — #191)
- **Helm chart for Kubernetes deployment** — Full Helm chart with configurable values, optional WOPI sidecar, and comprehensive documentation (@nk-designz — #198)
- **Drag & drop files into breadcrumb folders** — Move files by dragging them onto any breadcrumb folder in the navigation bar (@EdouardVanbelle — #238)
- **Download progress bar for files >2GB** — Inline viewer now shows real-time download progress with correct 64-bit float division, avoiding 32-bit overflow (@BillionClaw — #227)
- **Thumbnail generation timeout protection** — Configurable timeout (default 30s) prevents large image processing from hanging the server indefinitely (@DioCrafts — #242)
- **Add missing home icon** — Breadcrumb now displays the proper home icon for root folder navigation (@EdouardVanbelle — #235)
### Security & Access Control
- **Fix RUSTSEC-2026-0037** — Updated `quinn-proto` to 0.11.14 to patch a known security advisory (@jaredwolff — b6bcb7d)
- **Verify target folder ownership on file move** — Moving a file now validates that the caller owns the destination folder, preventing cross-user file injection (@BillionClaw — #224)
- **Enforce storage quota on WebDAV PUT uploads** — WebDAV uploads now check storage quota before persisting, returning 507 Insufficient Storage when exceeded — previously only REST and chunked uploads had this check (@BillionClaw — #220)
- **Cap admin initial quota to available disk space** — User creation no longer sets quotas exceeding actual available disk space (@BillionClaw — #226)
- **Resolve CSP blocking and session refresh loop** — Fixed Content Security Policy violations blocking inline styles and an infinite session refresh loop (@BillionClaw — #211)
### Performance & Memory
- **Drop encoded image data after decoding** — Explicitly frees the original encoded buffer after image decoding, reducing peak memory consumption during thumbnail generation by the original file size (@BillionClaw — #228)
- **Thumbnail generation timeout** — Wraps `spawn_blocking` in `tokio::time::timeout` so a single slow image can't block the thumbnail pipeline (@DioCrafts — #242)
### Bug Fixes
**Nextcloud Compatibility:**
- **Fix Nextcloud sync conflict** — Replaced static UUID-based ETags with content-hash ETags, resolving persistent sync conflicts in the Nextcloud desktop and mobile clients (@jaredwolff — #207)
**WebDAV:**
- **Preserve correct status codes for rename/move failures** — AlreadyExists→409, NotFound→404, AccessDenied→403 instead of blanket 500 errors (@BillionClaw — #222)
- **Enforce storage quota on PUT uploads** — Closes a gap where WebDAV could bypass quota checks (@BillionClaw — #220)
**Files & Storage:**
- **Batch folder deletion fails** — Added debug logging to diagnose and fix batch trash operation failures (@BillionClaw — #216)
- **Improve error messages for file/folder already exists** — More descriptive error messages when duplicate file/folder names are encountered (@BillionClaw — #225)
- **Correct shared link URL to include `/api` prefix** — Shared links previously generated 404 URLs missing the API path prefix (@BillionClaw — #223)
**Calendar & Contacts:**
- **Change calendar `owner_id` from String to Uuid** — Aligns calendar ownership with the native UUID type used everywhere else, fixing lookup failures (@BillionClaw — #208)
- **Allow RGBA colors in calendar events** — Calendar color validation now accepts RGBA format in addition to RGB (@JVMerkle — #202)
**Trash:**
- **Add missing display fields to `TrashedItemDto`** — Added `category`, `icon_class`, and `icon_special_class` fields so the trash view renders file type information correctly (@BillionClaw — #221)
**UI/UX:**
- **Resolve broken menu navigation** — Fixed menu items not responding to clicks (@BillionClaw — #212)
- **Resolve dark mode toggle and file search errors** — Dark mode toggle now derives state from localStorage; empty folder_id no longer causes search errors (@BillionClaw — #218)
- **Photos view bleeding into trash view** — Fixed CSS isolation issue where photos grid styles leaked into the trash panel (@jaredwolff — #196)
- **Align size values in table view** — File sizes now use `tabular-nums` for proper column alignment (@BillionClaw — #219)
- **WOPI public base URL for Docker** — Added `OXICLOUD_WOPI_PUBLIC_BASE_URL` env var support so WOPI document editing works behind reverse proxies in Docker (@BillionClaw — #234)
**Internationalization:**
- **Use translation keys for upload notification titles** — Replaced hardcoded English/Spanish strings with proper i18n lookup (@BillionClaw — #217)
- **Add missing `dialogs.share_folder` translation key** — Added to all 14 locale files, fixing share dialog failures for folders (@BillionClaw — #215)
**Build, CI & Deployment:**
- **Add PostgreSQL service to Docker publish workflow** — The Docker Hub release CI job was failing because it lacked the PostgreSQL service required by tests, causing missing container images for v0.5.2 (@BillionClaw — #214)
- **Remove `target-cpu=native` from Dockerfile** — Ensures Docker images are portable across different CPU architectures (@jaredwolff — a0ee538)
- **ARMv7 32-bit compilation overflow** — Fixed integer overflow on 32-bit ARM targets (@BillionClaw — #209)
- **Resolve clippy warnings and rustfmt issues for CI compliance** — Cleaned up all remaining linting issues (@zjean — #188)
- **Update CI references from `db/schema.sql` to sqlx migrations** — Aligned CI pipelines with the new migration system (@jaredwolff — f6e2b30)
- **Add pre-commit checks to CLAUDE.md** — Documented required `cargo fmt` + `cargo clippy` checks (@jaredwolff — #192)
**Migrations:**
- **Add ALTER TABLE fallback for `media_sort_date` column** — Handles pre-existing tables gracefully during migration (@jaredwolff — #195)
### Documentation
- **Add feature status table to README** — Clear overview of which features are stable, beta, or planned (@BillionClaw — #210)
- **Fix incorrect path in development guide** — Corrected branch path references in CONTRIBUTING.md (@BillionClaw — #213)
- **Helm chart documentation** — Comprehensive deployment guide for Kubernetes users (@nk-designz — #198)
- **Update README.md & example.env** — Improved documentation for remote access setup (@raenur — #197)
### Developer Experience
- **Dev-mode static assets without cache** — When `PROFILE=dev`, static assets are served directly from `/static` with no caching, enabling faster frontend iteration (@EdouardVanbelle — #236)
- **Remove duplicate breadcrumb home-folder code** — Refactored redundant logic in breadcrumb handling (@EdouardVanbelle — 276b9ff)
- **Apply rust format + fix clippy warning** — Code style cleanup (@EdouardVanbelle — #240)
---
### Stats
| Metric | Value |
|---|---|
| Commits | 42 |
| Contributors | 7 |
| Files changed | 106 |
| Insertions | +1,997 |
| Deletions | −882 |
| Issues closed | #82, #92, #101, #102, #104, #107, #108, #124, #189, #193, #230 |
| PRs merged | 35 |
---
### 🙏 Contributor Acknowledgements
This release would not have been possible without the incredible dedication and talent of every single contributor. The OxiCloud community continues to grow, and every contribution — from a one-line fix to a 22-commit marathon — makes this project stronger.
---
#### @BillionClaw — 22 commits ⭐ MVP of this release
An absolutely extraordinary contribution. **BillionClaw** single-handedly tackled the majority of this release, delivering a sweeping wave of fixes that touched every layer of OxiCloud — from **WebDAV quota enforcement** and **folder ownership security**, to **dark mode toggle fixes**, **i18n completeness**, **trash view rendering**, **shared link URLs**, **CI pipeline fixes**, and **ARMv7 compilation support**. The depth and breadth of these contributions is remarkable. Every fix came with clear commit messages, proper issue references, and thoughtful descriptions. BillionClaw didn't just fix bugs — they systematically audited and hardened OxiCloud's core functionality. The download progress bar for >2GB files and the thumbnail memory optimization show a keen eye for performance and user experience. **Thank you, BillionClaw, for this exceptional level of commitment to OxiCloud. You are a pillar of this community.** 🏆
---
#### @jaredwolff — 8 commits
**Jared** continues to be one of OxiCloud's most impactful contributors. This release features his landmark **SQLx migration system** — a foundational infrastructure change that replaces the fragile custom schema loader with proper versioned migrations, ensuring rock-solid database upgrades for every deployment going forward. He also patched the critical **RUSTSEC-2026-0037 security advisory**, fixed the persistent **Nextcloud sync conflict** that plagued desktop and mobile clients, resolved the **photos-view-in-trash CSS leak**, removed the non-portable `target-cpu=native` from Docker builds, and aligned the entire CI pipeline with the new migration system. Jared's contributions consistently tackle the hardest, most impactful problems. **Thank you, Jared, for your continued engineering excellence and for making OxiCloud more reliable and secure with every release.**
---
#### @EdouardVanbelle — 5 commits
**Edouard** brought a beautiful **drag & drop into breadcrumbs** feature that makes file organization feel natural and intuitive. He also added the missing home icon, eliminated duplicate breadcrumb code, improved the developer experience with cache-free dev-mode static assets, and cleaned up code style. Every contribution shows a strong focus on user experience and code quality. **Thank you, Edouard, for bringing polish and elegance to OxiCloud's interface. Your UI contributions make a real difference in how people interact with the platform every day.**
---
#### @nk-designz (Nico Kahlert) — 2 commits
**Nico** opened the door to **enterprise Kubernetes deployment** by creating a complete Helm chart with configurable values, optional WOPI integration, and thorough documentation. This is a game-changer for teams looking to deploy OxiCloud in production Kubernetes clusters. **Thank you, Nico, for bringing OxiCloud to the cloud-native world. This Helm chart makes professional deployment accessible to an entirely new audience.**
---
#### @raenur (Nathan Shepperd) — 2 commits
**Nathan** contributed practical improvements to the **README** and **example.env** with documentation suggestions for accessing OxiCloud remotely after first install — exactly the kind of first-time-user perspective that makes onboarding smoother for everyone. **Thank you, Nathan, for thinking about the new user experience and making the first steps with OxiCloud clearer and more welcoming.**
---
#### @JVMerkle (Julian Merkle) — 1 commit
**Julian** fixed the calendar color validation to support **RGBA colors**, a small but important change that unblocks users who rely on RGBA color specs in their CalDAV clients. **Thank you, Julian, for this targeted and well-crafted fix. CalDAV compatibility improves with every contribution like this.**
---
#### @zjean — 1 commit
**zjean** resolved all remaining **clippy warnings and rustfmt issues** to bring the codebase into full CI compliance — a foundational cleanup that keeps the build green for everyone. **Thank you, zjean, for your dedication to code quality and for ensuring OxiCloud maintains a clean, warning-free codebase.**
---
### New Contributors 🎉
A warm welcome to the contributors making their first contribution to OxiCloud in this release:
* @BillionClaw made their first contribution in https://github.com/DioCrafts/OxiCloud/pull/208
* @EdouardVanbelle made their first contribution in https://github.com/DioCrafts/OxiCloud/pull/235
* @nk-designz made their first contribution in https://github.com/DioCrafts/OxiCloud/pull/198
* @raenur made their first contribution in https://github.com/DioCrafts/OxiCloud/pull/197
* @JVMerkle made their first contribution in https://github.com/DioCrafts/OxiCloud/pull/202
---
**Full Changelog**: https://github.com/DioCrafts/OxiCloud/compare/v0.5.2...v0.5.3
+1
View File
@@ -35,6 +35,7 @@ const INDEX_VIEW_CSS: &[&str] = &[
"views/trash.css",
"views/photos.css",
"views/photosLightbox.css",
"views/music.css",
];
// ═══════════════════════════════════════════════════════════════════════════════
+4
View File
@@ -386,6 +386,10 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
"/{playlist_id}/shares",
get(music_handler::get_playlist_shares),
)
.route(
"/audio-metadata/{file_id}",
get(music_handler::get_audio_metadata),
)
.with_state(music_svc.clone());
router = router.nest("/playlists", music_router);
+401 -13
View File
@@ -8,11 +8,52 @@
display: block;
}
.music-toolbar {
/* ========= Full-width empty state (zero playlists) ========== */
.music-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 80px 40px;
min-height: calc(100vh - 260px);
}
.music-empty-state-icon {
width: 96px;
height: 96px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: flex-end;
padding: 8px;
justify-content: center;
margin-bottom: 24px;
box-shadow: 0 8px 32px rgba(102, 126, 234, 0.25);
}
.music-empty-state-icon i {
font-size: 40px;
color: #fff;
}
.music-empty-state-title {
margin: 0 0 8px;
font-size: 20px;
font-weight: 600;
color: var(--color-text, #2d3748);
}
.music-empty-state-desc {
margin: 0 0 28px;
font-size: 14px;
color: var(--color-text-muted, #718096);
max-width: 360px;
line-height: 1.5;
}
/* ========= Toolbar (when playlists exist) ========== */
.music-toolbar {
display: none;
}
.music-content {
@@ -20,6 +61,11 @@
gap: 0;
height: calc(100vh - 200px);
min-height: 400px;
transition: height 0.3s ease;
}
.music-player-active .music-content {
height: calc(100vh - 290px);
}
.music-sidebar {
@@ -32,19 +78,43 @@
}
.music-sidebar-header {
padding: 16px;
padding: 14px 16px;
border-bottom: 1px solid var(--border-color, #e2e8f0);
display: flex;
align-items: center;
justify-content: space-between;
}
.music-sidebar-header h3 {
margin: 0;
font-size: 14px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary, #64748b);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.music-sidebar-add-btn {
width: 28px;
height: 28px;
border-radius: 6px;
border: none;
background: var(--color-accent-gradient, linear-gradient(135deg, #ff5e3a, #ff2d55));
color: #fff;
font-size: 13px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.15s, box-shadow 0.15s;
box-shadow: 0 2px 8px var(--color-accent-shadow, rgba(255, 94, 58, 0.25));
}
.music-sidebar-add-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px var(--color-accent-shadow, rgba(255, 94, 58, 0.35));
}
.music-playlist-list {
flex: 1;
overflow-y: auto;
@@ -70,15 +140,16 @@
}
.music-playlist-icon {
width: 40px;
height: 40px;
border-radius: 6px;
width: 36px;
height: 36px;
border-radius: 8px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 16px;
font-size: 14px;
flex-shrink: 0;
}
.music-playlist-item-info {
@@ -147,8 +218,9 @@
}
.music-playlist-cover {
width: 180px;
height: 180px;
width: 140px;
height: 140px;
min-width: 140px;
border-radius: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
@@ -178,9 +250,27 @@
.music-playlist-actions {
display: flex;
gap: 12px;
gap: 8px;
margin-bottom: 24px;
flex-wrap: wrap;
align-items: center;
}
.music-playlist-actions .btn {
padding: 8px 16px;
font-size: 13px;
border-radius: 8px;
}
.music-playlist-actions .btn i {
font-size: 13px;
}
/* Icon-only action buttons (no text label) */
.music-playlist-actions .btn:not(:has(span)) {
padding: 8px 12px;
min-width: 36px;
justify-content: center;
}
.music-track-list {
@@ -218,6 +308,10 @@
background: var(--hover-bg, #f1f5f9);
}
.music-track.selected {
background: var(--active-bg, #dbeafe);
}
.music-track-col {
padding: 0 8px;
}
@@ -596,10 +690,20 @@
display: flex;
align-items: center;
gap: 8px;
min-width: 140px;
min-width: 160px;
justify-content: flex-end;
}
.player-close-btn {
opacity: 0.5;
transition: opacity 0.15s ease;
margin-left: 4px;
}
.player-close-btn:hover {
opacity: 1;
}
.player-volume-slider {
width: 80px;
display: flex;
@@ -652,6 +756,20 @@
overflow: hidden;
}
.player-queue::after {
content: '';
position: absolute;
bottom: -8px;
right: 60px;
width: 14px;
height: 14px;
background: var(--bg-primary, #fff);
border-right: 1px solid var(--border-color, #e2e8f0);
border-bottom: 1px solid var(--border-color, #e2e8f0);
transform: rotate(45deg);
z-index: -1;
}
.player-queue.hidden {
display: none;
}
@@ -775,6 +893,10 @@
background: var(--active-bg, #dbeafe);
}
.music-track.playing.selected {
background: var(--active-bg, #dbeafe);
}
.music-track.playing .music-track-name {
color: var(--primary-color, #667eea);
}
@@ -789,6 +911,10 @@
}
/* Repeat one icon */
.player-btn.repeat-one i {
position: relative;
}
.player-btn.repeat-one i::after {
content: '1';
font-size: 8px;
@@ -812,6 +938,11 @@
border-color: var(--border-color, #334155);
}
[data-theme="dark"] .player-queue::after {
background: var(--bg-secondary, #1e293b);
border-color: var(--border-color, #334155);
}
[data-theme="dark"] .player-queue-header {
border-color: var(--border-color, #334155);
}
@@ -840,6 +971,10 @@
gap: 8px;
}
.music-player-active .music-content {
height: calc(100vh - 270px);
}
.player-track-info {
min-width: auto;
max-width: 120px;
@@ -894,3 +1029,256 @@
width: auto;
}
}
/* ========== Drag handle column ========== */
.music-track-drag {
width: 28px;
flex: 0 0 28px;
cursor: grab;
color: var(--text-secondary, #888);
opacity: 0;
transition: opacity 0.15s;
display: flex;
align-items: center;
justify-content: center;
}
.music-track-header .music-track-drag {
cursor: default;
}
.music-track:hover .music-track-drag {
opacity: 0.6;
}
.music-track-drag:active {
cursor: grabbing;
}
.music-track.dragging {
opacity: 0.35;
background: var(--bg-hover, #f0f0f0);
}
.music-track.drag-over {
border-top: 2px solid var(--primary, #4a90d9);
margin-top: -2px;
}
/* ========== Track actions column ========== */
.music-track-actions {
width: 36px;
flex: 0 0 36px;
display: flex;
align-items: center;
justify-content: center;
}
.music-track-remove-btn {
background: none;
border: none;
color: var(--text-secondary, #888);
cursor: pointer;
padding: 4px 6px;
border-radius: 4px;
opacity: 0;
transition: opacity 0.15s, color 0.15s;
font-size: 0.85rem;
}
.music-track:hover .music-track-remove-btn {
opacity: 1;
}
.music-track-remove-btn:hover {
color: var(--danger, #e74c3c);
background: rgba(231, 76, 60, 0.08);
}
/* ========== Cover art ========== */
.music-playlist-cover {
cursor: pointer;
position: relative;
overflow: hidden;
}
.music-cover-img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
}
.music-cover-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.4);
color: #fff;
font-size: 1.1rem;
opacity: 0;
transition: opacity 0.2s;
border-radius: inherit;
}
.music-playlist-cover:hover .music-cover-overlay {
opacity: 1;
}
/* ========== Public badge ========== */
.music-public-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
font-size: 0.72rem;
background: rgba(74, 144, 217, 0.12);
color: var(--primary, #4a90d9);
border-radius: 12px;
margin-top: 4px;
}
.music-public-badge.hidden {
display: none;
}
#music-toggle-public-btn.active {
color: var(--primary, #4a90d9);
background: rgba(74, 144, 217, 0.1);
}
/* ========== Shares management dialog ========== */
.music-shares-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.music-shares-panel {
background: var(--bg-primary, #fff);
border-radius: 12px;
width: 420px;
max-width: 90vw;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0,0,0,0.18);
}
.music-shares-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
border-bottom: 1px solid var(--border-color, #eee);
}
.music-shares-header h3 {
margin: 0;
font-size: 0.95rem;
display: flex;
align-items: center;
gap: 8px;
}
.music-shares-close-btn {
background: none;
border: none;
cursor: pointer;
color: var(--text-secondary, #888);
padding: 4px;
font-size: 1rem;
}
.music-shares-close-btn:hover {
color: var(--text-primary, #333);
}
.music-shares-body {
flex: 1;
overflow-y: auto;
padding: 12px 20px;
min-height: 60px;
}
.music-shares-loading {
text-align: center;
padding: 20px;
color: var(--text-secondary, #888);
}
.music-shares-empty {
text-align: center;
color: var(--text-secondary, #888);
padding: 16px 0;
margin: 0;
}
.music-share-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid var(--border-color, #f0f0f0);
}
.music-share-item:last-child {
border-bottom: none;
}
.music-share-user {
flex: 1;
font-size: 0.88rem;
display: flex;
align-items: center;
gap: 6px;
}
.music-share-perm {
font-size: 0.78rem;
color: var(--text-secondary, #888);
}
.music-share-remove-btn {
background: none;
border: none;
cursor: pointer;
color: var(--text-secondary, #888);
padding: 4px;
transition: color 0.15s;
}
.music-share-remove-btn:hover {
color: var(--danger, #e74c3c);
}
.music-shares-add {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
border-top: 1px solid var(--border-color, #eee);
flex-wrap: wrap;
}
.music-shares-input {
flex: 1;
min-width: 120px;
padding: 6px 10px;
border: 1px solid var(--border-color, #ddd);
border-radius: 6px;
font-size: 0.88rem;
background: var(--bg-secondary, #f9f9f9);
color: var(--text-primary, #333);
}
.music-shares-input:focus {
outline: none;
border-color: var(--primary, #4a90d9);
}
.music-shares-write-label {
font-size: 0.78rem;
display: flex;
align-items: center;
gap: 4px;
color: var(--text-secondary, #666);
white-space: nowrap;
}
/* ========== Dark mode ========== */
[data-theme="dark"] .music-shares-panel {
background: var(--bg-primary, #1e1e1e);
}
[data-theme="dark"] .music-shares-input {
background: var(--bg-secondary, #2d2d2d);
border-color: var(--border-color, #444);
color: var(--text-primary, #e0e0e0);
}
[data-theme="dark"] .music-track.dragging {
background: var(--bg-hover, #2d2d2d);
}
[data-theme="dark"] .music-track-remove-btn:hover {
background: rgba(231, 76, 60, 0.15);
}
[data-theme="dark"] .music-empty-state-title {
color: var(--color-text, #e0e0e0);
}
[data-theme="dark"] .music-empty-state-desc {
color: var(--color-text-muted, #888);
}
+724 -53
View File
@@ -82,26 +82,39 @@ const musicView = {
return typeof i18n !== 'undefined' && i18n.t ? i18n.t(key) : fallback || key;
};
// Empty state: no playlists at all — show full-width centered onboarding
if (this.playlists.length === 0) {
this._container.innerHTML = `
<div class="music-empty-state">
<div class="music-empty-state-icon">
<i class="fas fa-music"></i>
</div>
<h3 class="music-empty-state-title">${t('music.no_playlists', 'No playlists yet')}</h3>
<p class="music-empty-state-desc">${t('music.empty_hint', 'Create your first playlist to start organizing your music')}</p>
<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>
`;
const createBtn = document.getElementById('music-create-playlist-btn');
if (createBtn) {
createBtn.addEventListener('click', () => this._showCreatePlaylistDialog());
}
return;
}
// Normal layout: sidebar + main
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>
<button class="music-sidebar-add-btn" id="music-create-playlist-btn" title="${t('music.create_playlist', 'Create Playlist')}">
<i class="fas fa-plus"></i>
</button>
</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 class="music-playlist-list" id="music-playlist-list"></div>
</div>
<div class="music-main">
<div class="music-welcome">
@@ -111,12 +124,15 @@ const musicView = {
</div>
<div class="music-playlist-detail hidden" id="music-playlist-detail">
<div class="music-playlist-header">
<div class="music-playlist-cover">
<div class="music-playlist-cover" id="music-playlist-cover" title="${t('music.set_cover', 'Set 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>
<span class="music-public-badge hidden" id="music-public-badge">
<i class="fas fa-globe"></i> <span id="music-public-text">${t('music.public', 'Public')}</span>
</span>
</div>
</div>
<div class="music-playlist-actions">
@@ -131,11 +147,17 @@ const musicView = {
<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 class="btn btn-secondary" id="music-edit-playlist-btn" title="${t('music.edit', 'Edit')}">
<i class="fas fa-pen"></i>
</button>
<button class="btn btn-secondary" id="music-share-playlist-btn">
<i class="fas fa-share"></i>
<button class="btn btn-secondary" id="music-share-playlist-btn" title="${t('music.share', 'Share')}">
<i class="fas fa-share-alt"></i>
</button>
<button class="btn btn-secondary" id="music-manage-shares-btn" title="${t('music.manage_shares', 'Manage Shares')}">
<i class="fas fa-users"></i>
</button>
<button class="btn btn-secondary" id="music-toggle-public-btn" title="${t('music.toggle_public', 'Toggle public')}">
<i class="fas fa-globe"></i>
</button>
<button class="btn btn-secondary" id="music-delete-playlist-btn">
<i class="fas fa-trash"></i>
@@ -155,24 +177,24 @@ const musicView = {
const listEl = document.getElementById('music-playlist-list');
if (!listEl) return;
const t = (key, fallback = '') => {
return typeof i18n !== 'undefined' && i18n.t ? i18n.t(key) : fallback || key;
};
if (this.playlists.length === 0) {
listEl.innerHTML = `
<div class="music-empty">
<i class="fas fa-music"></i>
<p>No playlists yet</p>
<p>${t('music.no_playlists', '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>
<i class="fas fa-music"></i>
</div>
<div class="music-playlist-item-info">
<span class="music-playlist-item-name">${this._escapeHtml(p.name)}</span>
@@ -209,6 +231,36 @@ const musicView = {
if (deleteBtn) {
deleteBtn.addEventListener('click', () => this._deletePlaylist());
}
const editBtn = document.getElementById('music-edit-playlist-btn');
if (editBtn) {
editBtn.addEventListener('click', () => this._showEditPlaylistDialog());
}
const shareBtn = document.getElementById('music-share-playlist-btn');
if (shareBtn) {
shareBtn.addEventListener('click', () => this._showSharePlaylistDialog());
}
const addTracksBtn = document.getElementById('music-add-tracks-btn');
if (addTracksBtn) {
addTracksBtn.addEventListener('click', () => this._showAddTracksDialog());
}
const manageSharesBtn = document.getElementById('music-manage-shares-btn');
if (manageSharesBtn) {
manageSharesBtn.addEventListener('click', () => this._showManageSharesDialog());
}
const togglePublicBtn = document.getElementById('music-toggle-public-btn');
if (togglePublicBtn) {
togglePublicBtn.addEventListener('click', () => this._togglePublic());
}
const coverEl = document.getElementById('music-playlist-cover');
if (coverEl) {
coverEl.addEventListener('click', () => this._showCoverPicker());
}
},
async _selectPlaylist(playlistId) {
@@ -226,7 +278,32 @@ const musicView = {
if (detailEl) detailEl.classList.remove('hidden');
if (nameEl) nameEl.textContent = playlist.name;
if (metaEl) {
metaEl.textContent = `${playlist.track_count || 0} tracks`;
const t = (key, fallback = '') => {
return typeof i18n !== 'undefined' && i18n.t ? i18n.t(key) : fallback || key;
};
metaEl.textContent = `${playlist.track_count || 0} ${t('music.tracks', 'tracks')}`;
}
// Cover art
const coverEl = document.getElementById('music-playlist-cover');
if (coverEl) {
if (playlist.cover_file_id) {
coverEl.innerHTML = `<img src="/api/files/${encodeURIComponent(playlist.cover_file_id)}" alt="" class="music-cover-img"><div class="music-cover-overlay"><i class="fas fa-camera"></i></div>`;
} else {
coverEl.innerHTML = `<i class="fas fa-music"></i><div class="music-cover-overlay"><i class="fas fa-camera"></i></div>`;
}
}
// Public badge
const publicBadge = document.getElementById('music-public-badge');
if (publicBadge) {
publicBadge.classList.toggle('hidden', !playlist.is_public);
}
const togglePublicBtn = document.getElementById('music-toggle-public-btn');
if (togglePublicBtn) {
const t2 = (key, fallback = '') => typeof i18n !== 'undefined' && i18n.t ? i18n.t(key) : fallback || key;
togglePublicBtn.title = playlist.is_public ? t2('music.make_private', 'Make private') : t2('music.make_public', 'Make public');
togglePublicBtn.classList.toggle('active', playlist.is_public);
}
document.querySelectorAll('.music-playlist-item').forEach(item => {
@@ -278,14 +355,17 @@ const musicView = {
trackListEl.innerHTML = `
<div class="music-track-header">
<span class="music-track-col music-track-drag"></span>
<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>
<span class="music-track-col music-track-actions"></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}">
<div class="music-track ${musicPlayer.currentTrack?.id === track.id ? 'playing' : ''}" data-idx="${idx}" data-id="${track.id}" data-file-id="${track.file_id}" draggable="true">
<span class="music-track-col music-track-drag"><i class="fas fa-grip-vertical"></i></span>
<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>
@@ -297,6 +377,9 @@ const musicView = {
<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>
<span class="music-track-col music-track-actions">
<button class="music-track-remove-btn" title="${t('music.remove', 'Remove')}"><i class="fas fa-times"></i></button>
</span>
</div>
`).join('')}
`;
@@ -305,20 +388,72 @@ const musicView = {
trackListEl.querySelectorAll('.music-track').forEach(row => {
row.addEventListener('click', () => {
const idx = parseInt(row.dataset.idx);
self._playTrack(idx);
// Toggle selection
trackListEl.querySelectorAll('.music-track').forEach(r => {
if (r !== row) r.classList.remove('selected');
});
row.classList.toggle('selected');
self.selected.clear();
if (row.classList.contains('selected')) {
self.selected.add(idx);
}
});
row.addEventListener('dblclick', () => {
row.addEventListener('dblclick', (e) => {
e.preventDefault();
const idx = parseInt(row.dataset.idx);
self._playTrack(idx);
});
// Drag & drop
row.addEventListener('dragstart', (e) => {
row.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', row.dataset.idx);
});
row.addEventListener('dragend', () => {
row.classList.remove('dragging');
trackListEl.querySelectorAll('.music-track').forEach(r => r.classList.remove('drag-over'));
});
row.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const dragging = trackListEl.querySelector('.dragging');
if (dragging && dragging !== row) {
row.classList.add('drag-over');
}
});
row.addEventListener('dragleave', () => {
row.classList.remove('drag-over');
});
row.addEventListener('drop', (e) => {
e.preventDefault();
row.classList.remove('drag-over');
const fromIdx = parseInt(e.dataTransfer.getData('text/plain'));
const toIdx = parseInt(row.dataset.idx);
if (fromIdx !== toIdx) {
self._reorderTrack(fromIdx, toIdx);
}
});
// Remove track button
const removeBtn = row.querySelector('.music-track-remove-btn');
if (removeBtn) {
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
self._removeTrackFromPlaylist(row.dataset.id, row.dataset.fileId);
});
}
});
},
_playTrack(idx) {
if (!this.currentTracks[idx]) return;
musicPlayer.setQueue(this.currentTracks, this.currentPlaylist?.name || 'Playlist');
const t = (key, fallback = '') => {
return typeof i18n !== 'undefined' && i18n.t ? i18n.t(key) : fallback || key;
};
musicPlayer.setQueue(this.currentTracks, this.currentPlaylist?.name || t('music.playlists', 'Playlist'));
musicPlayer.playTrack(idx);
},
@@ -330,24 +465,43 @@ const musicView = {
_shufflePlay() {
if (this.currentTracks.length > 0) {
const shuffled = [...this.currentTracks].sort(() => Math.random() - 0.5);
musicPlayer.setQueue(shuffled, this.currentPlaylist?.name || 'Shuffle');
const shuffled = [...this.currentTracks];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
const t = (key, fallback = '') => {
return typeof i18n !== 'undefined' && i18n.t ? i18n.t(key) : fallback || key;
};
musicPlayer.setQueue(shuffled, this.currentPlaylist?.name || t('music.shuffle', 'Shuffle'));
musicPlayer.playTrack(0);
}
},
_showCreatePlaylistDialog() {
async _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 (!window.Modal) return;
const name = await window.Modal.prompt({
title: t('music.create_playlist', 'Create Playlist'),
label: t('music.playlist_name', 'Playlist name'),
placeholder: t('music.playlist_name', 'Playlist name'),
icon: 'fa-music',
confirmText: t('music.create', 'Create')
});
if (!name || !name.trim()) return;
this._createPlaylist(name.trim());
},
async _createPlaylist(name) {
const t = (key, fallback = '') => {
return typeof i18n !== 'undefined' && i18n.t ? i18n.t(key) : fallback || key;
};
const createBtn = document.getElementById('music-create-playlist-btn');
if (createBtn) createBtn.disabled = true;
try {
const resp = await fetch('/api/playlists', {
method: 'POST',
@@ -360,11 +514,18 @@ const musicView = {
const playlist = await resp.json();
this.playlists.unshift(playlist);
this._renderPlaylistList();
this._renderPlaylists();
this._selectPlaylist(playlist.id);
if (window.notifications) {
window.notifications.addNotification({ icon: 'fa-check-circle', iconClass: 'upload', title: t('music.create_playlist', 'Create Playlist'), text: name });
}
} catch (err) {
console.error('Create playlist error:', err);
alert('Failed to create playlist: ' + err.message);
if (window.notifications) {
window.notifications.addNotification({ icon: 'fa-exclamation-circle', iconClass: 'error', title: t('music.error', 'Error'), text: err.message });
}
} finally {
if (createBtn) createBtn.disabled = false;
}
},
@@ -374,8 +535,22 @@ const musicView = {
};
if (!this.currentPlaylist) return;
if (!confirm(t('music.confirm_delete', 'Delete this playlist?'))) return;
const confirmed = await new Promise(resolve => {
if (!window.Modal) { resolve(confirm(t('music.confirm_delete', 'Delete this playlist?'))); return; }
window.Modal.prompt({
title: t('music.delete', 'Delete'),
label: t('music.confirm_delete', 'Delete this playlist?'),
placeholder: '',
value: this.currentPlaylist.name,
icon: 'fa-trash',
confirmText: t('music.delete', 'Delete')
}).then(val => resolve(val !== null));
});
if (!confirmed) return;
const deleteBtn = document.getElementById('music-delete-playlist-btn');
if (deleteBtn) deleteBtn.disabled = true;
try {
const resp = await fetch(`/api/playlists/${this.currentPlaylist.id}`, {
method: 'DELETE',
@@ -385,13 +560,21 @@ const musicView = {
if (!resp.ok) throw new Error('Failed to delete playlist');
const deletedName = this.currentPlaylist.name;
this.playlists = this.playlists.filter(p => p.id !== this.currentPlaylist.id);
this.currentPlaylist = null;
this.currentTracks = [];
this._renderPlaylists();
if (window.notifications) {
window.notifications.addNotification({ icon: 'fa-check-circle', iconClass: 'upload', title: t('music.delete', 'Delete'), text: deletedName });
}
} catch (err) {
console.error('Delete playlist error:', err);
alert('Failed to delete playlist: ' + err.message);
if (window.notifications) {
window.notifications.addNotification({ icon: 'fa-exclamation-circle', iconClass: 'error', title: t('music.error', 'Error'), text: err.message });
}
} finally {
if (deleteBtn) deleteBtn.disabled = false;
}
},
@@ -427,6 +610,451 @@ const musicView = {
<p>${this._escapeHtml(message)}</p>
</div>
`;
},
async _showEditPlaylistDialog() {
if (!this.currentPlaylist) return;
const t = (key, fallback = '') => {
return typeof i18n !== 'undefined' && i18n.t ? i18n.t(key) : fallback || key;
};
if (!window.Modal) return;
const newName = await window.Modal.prompt({
title: t('music.edit', 'Edit'),
label: t('music.playlist_name', 'Playlist name'),
placeholder: t('music.playlist_name', 'Playlist name'),
value: this.currentPlaylist.name,
icon: 'fa-pen',
confirmText: t('actions.confirm', 'Save')
});
if (!newName || !newName.trim() || newName.trim() === this.currentPlaylist.name) return;
try {
const resp = await fetch(`/api/playlists/${this.currentPlaylist.id}`, {
method: 'PUT',
credentials: 'include',
headers: this._headers(true),
body: JSON.stringify({ name: newName.trim() })
});
if (!resp.ok) throw new Error('Failed to update playlist');
this.currentPlaylist.name = newName.trim();
const idx = this.playlists.findIndex(p => p.id === this.currentPlaylist.id);
if (idx !== -1) this.playlists[idx].name = newName.trim();
const nameEl = document.getElementById('music-playlist-name');
if (nameEl) nameEl.textContent = newName.trim();
this._renderPlaylistList();
} catch (err) {
console.error('Edit playlist error:', err);
if (window.notifications) {
window.notifications.addNotification({ icon: 'fa-exclamation-circle', iconClass: 'error', title: t('music.error', 'Error'), text: err.message });
}
}
},
async _showSharePlaylistDialog() {
if (!this.currentPlaylist) return;
const t = (key, fallback = '') => {
return typeof i18n !== 'undefined' && i18n.t ? i18n.t(key) : fallback || key;
};
if (!window.Modal) return;
const userId = await window.Modal.prompt({
title: t('music.share', 'Share'),
label: t('music.share_with_user', 'User ID or email'),
placeholder: t('music.share_with_user', 'User ID or email'),
icon: 'fa-share-alt',
confirmText: t('music.share', 'Share')
});
if (!userId || !userId.trim()) return;
try {
const resp = await fetch(`/api/playlists/${this.currentPlaylist.id}/share`, {
method: 'POST',
credentials: 'include',
headers: this._headers(true),
body: JSON.stringify({ user_id: userId.trim(), can_write: false })
});
if (!resp.ok) throw new Error('Failed to share playlist');
if (window.notifications) {
window.notifications.addNotification({ icon: 'fa-check-circle', iconClass: 'upload', title: t('music.share', 'Share'), text: t('music.added', 'Added!') });
}
} catch (err) {
console.error('Share playlist error:', err);
if (window.notifications) {
window.notifications.addNotification({ icon: 'fa-exclamation-circle', iconClass: 'error', title: t('music.error', 'Error'), text: err.message });
}
}
},
async _showAddTracksDialog() {
if (!this.currentPlaylist) return;
const t = (key, fallback = '') => {
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);
input.addEventListener('change', async () => {
const files = Array.from(input.files);
input.remove();
if (files.length === 0) return;
// 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);
const uploadResp = await fetch('/api/files/upload', {
method: 'POST',
credentials: 'include',
headers: typeof getCsrfHeaders === 'function' ? getCsrfHeaders() : {},
body: formData
});
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);
}
}
if (fileIds.length === 0) return;
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 })
});
if (!resp.ok) throw new Error('Failed to add tracks');
if (window.notifications) {
window.notifications.addNotification({ icon: 'fa-check-circle', iconClass: 'upload', title: t('music.add_tracks', 'Add Tracks'), text: `${fileIds.length} ${t('music.added_to_playlist', 'added to playlist')}` });
}
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;
this.currentPlaylist.track_count = playlist.track_count;
this._renderPlaylistList();
const metaEl = document.getElementById('music-playlist-meta');
if (metaEl) metaEl.textContent = `${playlist.track_count} ${t('music.tracks', 'tracks')}`;
}
} catch (err) {
console.error('Add tracks error:', err);
if (window.notifications) {
window.notifications.addNotification({ icon: 'fa-exclamation-circle', iconClass: 'error', title: t('music.error', 'Error'), text: t('music.add_error', 'Could not add tracks to playlist') });
}
}
});
input.click();
},
async _removeTrackFromPlaylist(trackId, fileId) {
if (!this.currentPlaylist) return;
const t = (key, fallback = '') => typeof i18n !== 'undefined' && i18n.t ? i18n.t(key) : fallback || key;
try {
const resp = await fetch(`/api/playlists/${this.currentPlaylist.id}/tracks/${encodeURIComponent(fileId)}`, {
method: 'DELETE',
credentials: 'include',
headers: this._headers()
});
if (!resp.ok) throw new Error('Failed to remove track');
if (window.notifications) {
window.notifications.addNotification({ icon: 'fa-check-circle', iconClass: 'upload', title: t('music.remove', 'Remove'), text: t('music.track_removed', 'Track removed') });
}
await this._loadPlaylistTracks(this.currentPlaylist.id);
const playlist = this.playlists.find(p => p.id === this.currentPlaylist.id);
if (playlist) {
playlist.track_count = Math.max(0, (playlist.track_count || 1) - 1);
this.currentPlaylist.track_count = playlist.track_count;
this._renderPlaylistList();
const metaEl = document.getElementById('music-playlist-meta');
if (metaEl) metaEl.textContent = `${playlist.track_count} ${t('music.tracks', 'tracks')}`;
}
} catch (err) {
console.error('Remove track error:', err);
if (window.notifications) {
window.notifications.addNotification({ icon: 'fa-exclamation-circle', iconClass: 'error', title: t('music.error', 'Error'), text: err.message });
}
}
},
async _reorderTrack(fromIdx, toIdx) {
if (!this.currentPlaylist) return;
const t = (key, fallback = '') => typeof i18n !== 'undefined' && i18n.t ? i18n.t(key) : fallback || key;
const tracks = [...this.currentTracks];
const [moved] = tracks.splice(fromIdx, 1);
tracks.splice(toIdx, 0, moved);
this.currentTracks = tracks;
this._renderTracks();
const itemIds = tracks.map(tr => tr.id);
try {
const resp = await fetch(`/api/playlists/${this.currentPlaylist.id}/reorder`, {
method: 'PUT',
credentials: 'include',
headers: this._headers(true),
body: JSON.stringify({ item_ids: itemIds })
});
if (!resp.ok) throw new Error('Failed to reorder tracks');
} catch (err) {
console.error('Reorder error:', err);
if (window.notifications) {
window.notifications.addNotification({ icon: 'fa-exclamation-circle', iconClass: 'error', title: t('music.error', 'Error'), text: err.message });
}
await this._loadPlaylistTracks(this.currentPlaylist.id);
}
},
async _showManageSharesDialog() {
if (!this.currentPlaylist) return;
const t = (key, fallback = '') => typeof i18n !== 'undefined' && i18n.t ? i18n.t(key) : fallback || key;
const existing = document.getElementById('music-shares-dialog');
if (existing) existing.remove();
const dialog = document.createElement('div');
dialog.id = 'music-shares-dialog';
dialog.className = 'music-shares-overlay';
dialog.innerHTML = `
<div class="music-shares-panel">
<div class="music-shares-header">
<h3><i class="fas fa-users"></i> ${t('music.manage_shares', 'Manage Shares')}</h3>
<button class="music-shares-close-btn"><i class="fas fa-times"></i></button>
</div>
<div class="music-shares-body">
<div class="music-shares-loading"><i class="fas fa-spinner fa-spin"></i></div>
</div>
<div class="music-shares-add">
<input type="text" id="music-share-user-input" placeholder="${t('music.share_with_user', 'User ID or email')}" class="music-shares-input">
<label class="music-shares-write-label">
<input type="checkbox" id="music-share-write-input"> ${t('music.can_write', 'Can edit')}
</label>
<button class="btn btn-primary btn-sm" id="music-share-add-btn">
<i class="fas fa-plus"></i> ${t('music.share', 'Share')}
</button>
</div>
</div>
`;
document.body.appendChild(dialog);
dialog.querySelector('.music-shares-close-btn').addEventListener('click', () => dialog.remove());
dialog.addEventListener('click', (e) => { if (e.target === dialog) dialog.remove(); });
dialog.querySelector('#music-share-add-btn').addEventListener('click', async () => {
const userInput = dialog.querySelector('#music-share-user-input');
const writeInput = dialog.querySelector('#music-share-write-input');
const userId = userInput.value.trim();
if (!userId) return;
try {
const resp = await fetch(`/api/playlists/${this.currentPlaylist.id}/share`, {
method: 'POST',
credentials: 'include',
headers: this._headers(true),
body: JSON.stringify({ user_id: userId, can_write: writeInput.checked })
});
if (!resp.ok) throw new Error('Failed to share');
userInput.value = '';
writeInput.checked = false;
this._loadSharesList(dialog);
if (window.notifications) {
window.notifications.addNotification({ icon: 'fa-check-circle', iconClass: 'upload', title: t('music.share', 'Share'), text: t('music.added', 'Added!') });
}
} catch (err) {
if (window.notifications) {
window.notifications.addNotification({ icon: 'fa-exclamation-circle', iconClass: 'error', title: t('music.error', 'Error'), text: err.message });
}
}
});
this._loadSharesList(dialog);
},
async _loadSharesList(dialog) {
if (!this.currentPlaylist) return;
const t = (key, fallback = '') => typeof i18n !== 'undefined' && i18n.t ? i18n.t(key) : fallback || key;
const body = dialog.querySelector('.music-shares-body');
if (!body) return;
body.innerHTML = '<div class="music-shares-loading"><i class="fas fa-spinner fa-spin"></i></div>';
try {
const resp = await fetch(`/api/playlists/${this.currentPlaylist.id}/shares`, {
credentials: 'include',
headers: this._headers()
});
if (!resp.ok) throw new Error('Failed to load shares');
const shares = await resp.json();
if (shares.length === 0) {
body.innerHTML = `<p class="music-shares-empty">${t('music.no_shares', 'No shares yet')}</p>`;
return;
}
body.innerHTML = shares.map(s => `
<div class="music-share-item" data-user-id="${this._escapeHtml(s.user_id)}">
<span class="music-share-user"><i class="fas fa-user"></i> ${this._escapeHtml(s.user_id)}</span>
<span class="music-share-perm">${s.can_write ? t('music.can_write', 'Can edit') : t('music.read_only', 'Read only')}</span>
<button class="music-share-remove-btn" title="${t('music.remove_share', 'Remove share')}"><i class="fas fa-times"></i></button>
</div>
`).join('');
body.querySelectorAll('.music-share-remove-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const item = btn.closest('.music-share-item');
const userId = item.dataset.userId;
await this._removeShare(userId, dialog);
});
});
} catch (err) {
body.innerHTML = `<p class="music-shares-empty">${this._escapeHtml(err.message)}</p>`;
}
},
async _removeShare(userId, dialog) {
if (!this.currentPlaylist) return;
const t = (key, fallback = '') => typeof i18n !== 'undefined' && i18n.t ? i18n.t(key) : fallback || key;
try {
const resp = await fetch(`/api/playlists/${this.currentPlaylist.id}/share/${encodeURIComponent(userId)}`, {
method: 'DELETE',
credentials: 'include',
headers: this._headers()
});
if (!resp.ok) throw new Error('Failed to remove share');
this._loadSharesList(dialog);
} catch (err) {
if (window.notifications) {
window.notifications.addNotification({ icon: 'fa-exclamation-circle', iconClass: 'error', title: t('music.error', 'Error'), text: err.message });
}
}
},
async _togglePublic() {
if (!this.currentPlaylist) return;
const t = (key, fallback = '') => typeof i18n !== 'undefined' && i18n.t ? i18n.t(key) : fallback || key;
const newValue = !this.currentPlaylist.is_public;
try {
const resp = await fetch(`/api/playlists/${this.currentPlaylist.id}`, {
method: 'PUT',
credentials: 'include',
headers: this._headers(true),
body: JSON.stringify({ is_public: newValue })
});
if (!resp.ok) throw new Error('Failed to update playlist');
this.currentPlaylist.is_public = newValue;
const idx = this.playlists.findIndex(p => p.id === this.currentPlaylist.id);
if (idx !== -1) this.playlists[idx].is_public = newValue;
const badge = document.getElementById('music-public-badge');
if (badge) badge.classList.toggle('hidden', !newValue);
const btn = document.getElementById('music-toggle-public-btn');
if (btn) {
btn.title = newValue ? t('music.make_private', 'Make private') : t('music.make_public', 'Make public');
btn.classList.toggle('active', newValue);
}
if (window.notifications) {
const status = newValue ? t('music.public', 'Public') : t('music.private', 'Private');
window.notifications.addNotification({ icon: 'fa-check-circle', iconClass: 'upload', title: t('music.toggle_public', 'Visibility'), text: status });
}
} catch (err) {
console.error('Toggle public error:', err);
if (window.notifications) {
window.notifications.addNotification({ icon: 'fa-exclamation-circle', iconClass: 'error', title: t('music.error', 'Error'), text: err.message });
}
}
},
async _showCoverPicker() {
if (!this.currentPlaylist) return;
const t = (key, fallback = '') => typeof i18n !== 'undefined' && i18n.t ? i18n.t(key) : fallback || key;
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.style.display = 'none';
document.body.appendChild(input);
input.addEventListener('change', async () => {
const file = input.files[0];
input.remove();
if (!file) return;
try {
const formData = new FormData();
formData.append('file', file);
const folderId = window.app?.currentPath || window.app?.userHomeFolderId || '';
formData.append('folder_id', folderId);
const uploadResp = await fetch('/api/files/upload', {
method: 'POST',
credentials: 'include',
headers: typeof getCsrfHeaders === 'function' ? getCsrfHeaders() : {},
body: formData
});
if (!uploadResp.ok) throw new Error('Upload failed');
const uploaded = await uploadResp.json();
if (!uploaded.id) throw new Error('No file ID returned');
const resp = await fetch(`/api/playlists/${this.currentPlaylist.id}`, {
method: 'PUT',
credentials: 'include',
headers: this._headers(true),
body: JSON.stringify({ cover_file_id: uploaded.id })
});
if (!resp.ok) throw new Error('Failed to set cover');
this.currentPlaylist.cover_file_id = uploaded.id;
const plIdx = this.playlists.findIndex(p => p.id === this.currentPlaylist.id);
if (plIdx !== -1) this.playlists[plIdx].cover_file_id = uploaded.id;
const coverEl = document.getElementById('music-playlist-cover');
if (coverEl) {
coverEl.innerHTML = `<img src="/api/files/${encodeURIComponent(uploaded.id)}" alt="" class="music-cover-img"><div class="music-cover-overlay"><i class="fas fa-camera"></i></div>`;
}
if (window.notifications) {
window.notifications.addNotification({ icon: 'fa-check-circle', iconClass: 'upload', title: t('music.set_cover', 'Set cover'), text: t('music.cover_updated', 'Cover updated') });
}
} catch (err) {
console.error('Cover upload error:', err);
if (window.notifications) {
window.notifications.addNotification({ icon: 'fa-exclamation-circle', iconClass: 'error', title: t('music.error', 'Error'), text: err.message });
}
}
});
input.click();
}
};
@@ -514,11 +1142,14 @@ const musicPlayer = {
<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>
<i class="fas fa-volume-up"></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>
<button class="player-btn player-btn-small player-close-btn" id="player-close-btn" title="${i18n?.t('actions.close', 'Close') || 'Close'}">
<i class="fas fa-times"></i>
</button>
</div>
<div class="player-queue hidden" id="player-queue">
<div class="player-queue-header">
@@ -595,6 +1226,24 @@ const musicPlayer = {
}
});
}
const closeBtn = document.getElementById('player-close-btn');
if (closeBtn) {
closeBtn.addEventListener('click', () => this.closePlayer());
}
},
closePlayer() {
this.audio.pause();
this.audio.src = '';
this.isPlaying = false;
this.currentTrack = null;
this.currentIndex = -1;
this.queue = [];
this._updateUI();
this._updateQueueUI();
this._toggleQueue(false);
document.body.classList.remove('music-player-active');
},
setQueue(tracks, playlistName = '') {
@@ -816,6 +1465,13 @@ const musicPlayer = {
console.error('Audio error:', e);
this.isPlaying = false;
this._updateUI();
const t = (key, fallback = '') => {
return typeof i18n !== 'undefined' && i18n.t ? i18n.t(key) : fallback || key;
};
if (window.notifications) {
const trackName = this.currentTrack?.title || this.currentTrack?.file_name || t('music.unknown_title', 'Unknown');
window.notifications.addNotification({ icon: 'fa-exclamation-circle', iconClass: 'error', title: t('music.error', 'Error'), text: `${t('music.playback_error', 'Playback failed')}: ${trackName}` });
}
},
_updateUI() {
@@ -837,9 +1493,12 @@ const musicPlayer = {
}
if (trackName) {
const t = (key, fallback = '') => {
return typeof i18n !== 'undefined' && i18n.t ? i18n.t(key) : fallback || key;
};
trackName.textContent = this.currentTrack
? (this.currentTrack.title || this.currentTrack.file_name || 'Unknown')
: (i18n?.t('music.not_playing', 'Not playing') || 'Not playing');
? (this.currentTrack.title || this.currentTrack.file_name || t('music.unknown_title', 'Unknown'))
: t('music.not_playing', 'Not playing');
}
if (trackArtist) {
@@ -874,7 +1533,14 @@ const musicPlayer = {
const player = document.getElementById('music-player');
if (player) {
player.classList.toggle('has-track', !!this.currentTrack);
const hadTrack = player.classList.contains('has-track');
const hasTrack = !!this.currentTrack;
player.classList.toggle('has-track', hasTrack);
if (hasTrack && !hadTrack) {
document.body.classList.add('music-player-active');
} else if (!hasTrack && hadTrack) {
document.body.classList.remove('music-player-active');
}
}
},
@@ -931,15 +1597,24 @@ const musicPlayer = {
if (idx === this.currentIndex) {
if (this.queue.length === 1) {
this.audio.pause();
this.queue.splice(idx, 1);
this.currentTrack = null;
this.currentIndex = -1;
} else {
this.next();
this.queue.splice(idx, 1);
if (idx >= this.queue.length) {
this.currentIndex = 0;
} else {
this.currentIndex = idx;
}
this.currentTrack = this.queue[this.currentIndex];
this._loadAndPlay();
}
} else {
this.queue.splice(idx, 1);
if (idx < this.currentIndex) {
this.currentIndex--;
}
}
this.queue.splice(idx, 1);
if (idx < this.currentIndex) {
this.currentIndex--;
}
this._updateQueueUI();
this._updateUI();
@@ -973,8 +1648,4 @@ const musicPlayer = {
}
};
document.addEventListener('DOMContentLoaded', () => {
musicPlayer.init();
});
window.musicView = musicView;
+18 -1
View File
@@ -24,6 +24,7 @@
"create_playlist": "Create Playlist",
"playlists": "Playlists",
"no_playlists": "No playlists yet",
"empty_hint": "Create your first playlist to start organizing your music",
"select_playlist": "Select a playlist",
"select_hint": "Choose a playlist from the sidebar or create a new one",
"add_tracks": "Add Tracks",
@@ -62,7 +63,23 @@
"artist": "Artist",
"album": "Album",
"tracks": "tracks",
"error": "Error"
"share_with_user": "User ID or email",
"playback_error": "Playback failed",
"error": "Error",
"remove": "Remove",
"track_removed": "Track removed",
"manage_shares": "Manage Shares",
"no_shares": "No shares yet",
"remove_share": "Remove share",
"can_write": "Can edit",
"read_only": "Read only",
"public": "Public",
"private": "Private",
"toggle_public": "Visibility",
"make_public": "Make public",
"make_private": "Make private",
"set_cover": "Set cover",
"cover_updated": "Cover updated"
},
"actions": {
"search": "Search files...",
+18 -1
View File
@@ -24,6 +24,7 @@
"create_playlist": "Crear Lista",
"playlists": "Listas",
"no_playlists": "Sin listas aún",
"empty_hint": "Crea tu primera lista para empezar a organizar tu música",
"select_playlist": "Selecciona una lista",
"select_hint": "Elige una lista de la barra lateral o crea una nueva",
"add_tracks": "Añadir Pistas",
@@ -62,7 +63,23 @@
"add_error": "No se pudieron añadir las pistas",
"no_playlists_yet": "No hay listas aún. ¡Crea una primero!",
"selected_files": "Seleccionados:",
"error": "Error"
"share_with_user": "ID de usuario o email",
"playback_error": "Error de reproducción",
"error": "Error",
"remove": "Eliminar",
"track_removed": "Pista eliminada",
"manage_shares": "Gestionar compartidos",
"no_shares": "Sin compartidos aún",
"remove_share": "Eliminar compartido",
"can_write": "Puede editar",
"read_only": "Solo lectura",
"public": "Pública",
"private": "Privada",
"toggle_public": "Visibilidad",
"make_public": "Hacer pública",
"make_private": "Hacer privada",
"set_cover": "Establecer portada",
"cover_updated": "Portada actualizada"
},
"share": {
"dialogTitle": "Compartir Enlace",