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:
@@ -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
|
||||
@@ -35,6 +35,7 @@ const INDEX_VIEW_CSS: &[&str] = &[
|
||||
"views/trash.css",
|
||||
"views/photos.css",
|
||||
"views/photosLightbox.css",
|
||||
"views/music.css",
|
||||
];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user