4236 lines
152 KiB
HTML
4236 lines
152 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}{{ t.site_name }}{% endblock title %}
|
|
|
|
{% block head_extra %}
|
|
<style>
|
|
:root {
|
|
--bg-primary: #121212;
|
|
--bg-secondary: #181818;
|
|
--bg-elevated: #232323;
|
|
--bg-hover: #2a2a2a;
|
|
--bg-active: #333;
|
|
--text-primary: #fff;
|
|
--text-secondary: #b3b3b3;
|
|
--text-subdued: #6a6a6a;
|
|
--accent: #1db954;
|
|
--accent-hover: #1ed760;
|
|
--player-height: 80px;
|
|
--sidebar-width: 240px;
|
|
--queue-width: 300px;
|
|
--border-color: #282828;
|
|
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
|
--player-bar-space: calc(var(--player-height) + var(--safe-bottom));
|
|
}
|
|
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
|
|
[x-cloak] { display: none !important; }
|
|
|
|
html, body {
|
|
height: 100%;
|
|
overflow: hidden;
|
|
}
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
/* Layout */
|
|
.app-layout {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100vh;
|
|
height: 100dvh;
|
|
min-height: 0;
|
|
}
|
|
|
|
.main-content {
|
|
display: flex;
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* Left Sidebar */
|
|
.sidebar-left {
|
|
width: var(--sidebar-width);
|
|
min-width: var(--sidebar-width);
|
|
background: var(--bg-secondary);
|
|
border-right: 1px solid var(--border-color);
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.user-widget {
|
|
padding: 14px 12px 12px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.user-widget-main {
|
|
display: grid;
|
|
grid-template-columns: 36px minmax(0, 1fr) 32px;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.user-avatar {
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: 50%;
|
|
background: var(--accent);
|
|
color: #000;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-weight: 800;
|
|
font-size: 15px;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.user-name {
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
color: var(--text-primary);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.user-role {
|
|
margin-top: 2px;
|
|
font-size: 11px;
|
|
color: var(--text-subdued);
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.user-logout-btn {
|
|
width: 32px;
|
|
height: 32px;
|
|
border: none;
|
|
border-radius: 6px;
|
|
background: transparent;
|
|
color: var(--text-subdued);
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: background 0.15s, color 0.15s;
|
|
}
|
|
|
|
.user-logout-btn:hover {
|
|
background: var(--bg-hover);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.user-logout-btn svg { width: 17px; height: 17px; }
|
|
|
|
.user-stats {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
gap: 6px;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.user-stat {
|
|
min-width: 0;
|
|
padding: 7px 6px;
|
|
border-radius: 6px;
|
|
background: var(--bg-primary);
|
|
}
|
|
|
|
button.user-stat {
|
|
border: 0;
|
|
color: inherit;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
}
|
|
|
|
button.user-stat:hover {
|
|
background: var(--bg-hover);
|
|
}
|
|
|
|
.user-stat-value {
|
|
display: block;
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
color: var(--text-primary);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.user-stat-label {
|
|
display: block;
|
|
margin-top: 2px;
|
|
font-size: 10px;
|
|
color: var(--text-subdued);
|
|
}
|
|
|
|
.sidebar-header {
|
|
padding: 16px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.sidebar-header h2 {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.sidebar-nav {
|
|
padding: 8px;
|
|
}
|
|
|
|
.sidebar-nav-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 8px 12px;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
color: var(--text-secondary);
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
transition: background 0.15s, color 0.15s;
|
|
}
|
|
|
|
.sidebar-nav-item:hover { background: var(--bg-hover); color: var(--text-primary); }
|
|
.sidebar-nav-item.active { background: var(--bg-active); color: var(--text-primary); }
|
|
|
|
.sidebar-nav-item svg { width: 20px; height: 20px; flex-shrink: 0; }
|
|
|
|
.playlist-list {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 8px;
|
|
border-top: 1px solid var(--border-color);
|
|
}
|
|
|
|
.playlist-item {
|
|
padding: 8px 12px;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
color: var(--text-secondary);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
transition: background 0.15s, color 0.15s;
|
|
}
|
|
|
|
.playlist-item:hover { background: var(--bg-hover); color: var(--text-primary); }
|
|
|
|
.playlist-count {
|
|
font-size: 11px;
|
|
color: var(--text-subdued);
|
|
}
|
|
|
|
.sidebar-bottom {
|
|
padding: 12px 16px;
|
|
border-top: 1px solid var(--border-color);
|
|
}
|
|
|
|
.sidebar-bottom a {
|
|
color: var(--text-subdued);
|
|
text-decoration: none;
|
|
font-size: 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
.sidebar-bottom a:hover { color: var(--text-secondary); }
|
|
|
|
/* Center Content */
|
|
.center-content {
|
|
flex: 1;
|
|
min-width: 0;
|
|
overflow-y: auto;
|
|
padding: 24px;
|
|
background: var(--bg-primary);
|
|
-webkit-overflow-scrolling: touch;
|
|
}
|
|
|
|
.center-content::-webkit-scrollbar { width: 8px; }
|
|
.center-content::-webkit-scrollbar-track { background: transparent; }
|
|
.center-content::-webkit-scrollbar-thumb { background: var(--bg-active); border-radius: 4px; }
|
|
|
|
.section-title {
|
|
font-size: 24px;
|
|
font-weight: 700;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.breadcrumb {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 16px;
|
|
font-size: 13px;
|
|
color: var(--text-subdued);
|
|
}
|
|
|
|
.breadcrumb a {
|
|
color: var(--text-secondary);
|
|
text-decoration: none;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.breadcrumb a:hover { color: var(--text-primary); text-decoration: underline; }
|
|
|
|
/* Artist / Release Grid */
|
|
.card-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
|
gap: 20px;
|
|
}
|
|
|
|
.card {
|
|
background: var(--bg-secondary);
|
|
border-radius: 8px;
|
|
padding: 14px;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.card:hover { background: var(--bg-elevated); }
|
|
|
|
.card-img {
|
|
width: 100%;
|
|
aspect-ratio: 1;
|
|
border-radius: 6px;
|
|
background: var(--bg-elevated);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
overflow: hidden;
|
|
margin-bottom: 12px;
|
|
position: relative;
|
|
}
|
|
|
|
.card-img img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.card-img .placeholder-icon {
|
|
color: var(--text-subdued);
|
|
}
|
|
|
|
.card-img .placeholder-icon svg { width: 48px; height: 48px; }
|
|
|
|
.card-play-btn {
|
|
position: absolute;
|
|
bottom: 8px;
|
|
right: 8px;
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 50%;
|
|
background: var(--accent);
|
|
border: none;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
opacity: 0;
|
|
transform: translateY(8px);
|
|
transition: opacity 0.2s, transform 0.2s;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
|
}
|
|
|
|
.card:hover .card-play-btn { opacity: 1; transform: translateY(0); }
|
|
.card-play-btn:hover { background: var(--accent-hover); transform: scale(1.05); }
|
|
.card-play-btn svg { width: 18px; height: 18px; fill: #000; }
|
|
|
|
.card-title {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.card-subtitle {
|
|
font-size: 12px;
|
|
color: var(--text-subdued);
|
|
line-height: 1.35;
|
|
}
|
|
|
|
/* Artist detail header */
|
|
.artist-header {
|
|
display: flex;
|
|
align-items: flex-end;
|
|
gap: 24px;
|
|
margin-bottom: 32px;
|
|
}
|
|
|
|
.artist-header .artist-img {
|
|
width: 200px;
|
|
height: 200px;
|
|
border-radius: 50%;
|
|
background: var(--bg-elevated);
|
|
overflow: hidden;
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.artist-header .artist-img img { width: 100%; height: 100%; object-fit: cover; }
|
|
.artist-header .artist-img svg { width: 80px; height: 80px; color: var(--text-subdued); }
|
|
|
|
.artist-header .artist-name { font-size: 48px; font-weight: 900; line-height: 1.1; }
|
|
.artist-stats {
|
|
color: var(--text-subdued);
|
|
margin-top: 8px;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
font-size: 14px;
|
|
}
|
|
.artist-release-group { margin-top: 28px; }
|
|
.artist-release-group:first-of-type { margin-top: 0; }
|
|
.artist-release-group-title {
|
|
font-size: 20px;
|
|
font-weight: 700;
|
|
margin-bottom: 14px;
|
|
text-transform: capitalize;
|
|
}
|
|
|
|
/* Release detail header */
|
|
.release-header {
|
|
display: flex;
|
|
align-items: flex-end;
|
|
gap: 24px;
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.release-header .release-cover {
|
|
width: 200px;
|
|
height: 200px;
|
|
border-radius: 8px;
|
|
background: var(--bg-elevated);
|
|
overflow: hidden;
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
|
}
|
|
|
|
.release-header .release-cover img { width: 100%; height: 100%; object-fit: cover; }
|
|
.release-header .release-cover svg { width: 80px; height: 80px; color: var(--text-subdued); }
|
|
|
|
.release-meta .release-type { font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary); }
|
|
.release-meta .release-title { font-size: 36px; font-weight: 900; line-height: 1.2; margin: 4px 0; }
|
|
.release-meta .release-artists { font-size: 14px; color: var(--text-secondary); }
|
|
|
|
.artist-link {
|
|
color: inherit;
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.artist-link:hover {
|
|
color: var(--text-primary);
|
|
text-decoration: underline;
|
|
}
|
|
.release-meta .release-year { font-size: 14px; color: var(--text-subdued); margin-top: 4px; }
|
|
|
|
/* Track list table */
|
|
.track-list { width: 100%; border-collapse: collapse; }
|
|
|
|
.track-list-header {
|
|
display: grid;
|
|
grid-template-columns: 40px 1fr 1fr 120px 60px;
|
|
padding: 8px 16px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
color: var(--text-subdued);
|
|
font-size: 12px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.track-row {
|
|
display: grid;
|
|
grid-template-columns: 40px 1fr 1fr 120px 60px;
|
|
padding: 8px 16px;
|
|
border-radius: 4px;
|
|
cursor: default;
|
|
align-items: center;
|
|
transition: background 0.15s;
|
|
}
|
|
|
|
.track-row:hover { background: var(--bg-hover); }
|
|
.track-row.playing { color: var(--accent); }
|
|
.track-row.playing .track-num { color: var(--accent); }
|
|
|
|
.track-num {
|
|
font-size: 14px;
|
|
color: var(--text-subdued);
|
|
text-align: center;
|
|
}
|
|
|
|
.track-info .track-title { font-size: 14px; font-weight: 500; }
|
|
.track-info {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
}
|
|
.track-info .track-title,
|
|
.track-info .track-artists-inline,
|
|
.track-album {
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.track-info .track-artists-inline {
|
|
font-size: 12px;
|
|
color: var(--text-subdued);
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.track-album { font-size: 13px; color: var(--text-subdued); }
|
|
.track-duration { font-size: 13px; color: var(--text-subdued); text-align: right; }
|
|
|
|
/* Track action buttons */
|
|
.track-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 2px;
|
|
opacity: 0;
|
|
transition: opacity 0.15s;
|
|
}
|
|
|
|
.track-row:hover .track-actions { opacity: 1; }
|
|
|
|
.track-action-btn {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-subdued);
|
|
cursor: pointer;
|
|
padding: 4px;
|
|
border-radius: 4px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: color 0.15s, background 0.15s;
|
|
}
|
|
|
|
.track-action-btn:hover { color: var(--text-primary); background: var(--bg-active); }
|
|
.track-action-btn.play-btn:hover { color: var(--accent); }
|
|
.track-action-btn svg { width: 16px; height: 16px; }
|
|
|
|
/* Card enqueue button (next to play button on release cards) */
|
|
.card-enqueue-btn {
|
|
position: absolute;
|
|
bottom: 8px;
|
|
right: 56px;
|
|
width: 32px;
|
|
height: 32px;
|
|
border-radius: 50%;
|
|
background: var(--bg-elevated);
|
|
border: 1px solid var(--border-color);
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
opacity: 0;
|
|
transform: translateY(8px);
|
|
transition: opacity 0.2s, transform 0.2s;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
|
}
|
|
|
|
.card:hover .card-enqueue-btn { opacity: 1; transform: translateY(0); }
|
|
.card-enqueue-btn:hover { background: var(--bg-hover); border-color: var(--text-subdued); }
|
|
.card-enqueue-btn svg { width: 14px; height: 14px; color: var(--text-primary); }
|
|
|
|
/* Release header action buttons */
|
|
.release-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.release-action-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 8px 16px;
|
|
border-radius: 20px;
|
|
border: none;
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
transition: transform 0.1s, filter 0.15s;
|
|
}
|
|
|
|
.release-action-btn:hover { filter: brightness(1.1); }
|
|
.release-action-btn:active { transform: scale(0.97); }
|
|
.release-action-btn svg { width: 16px; height: 16px; }
|
|
|
|
.release-action-btn.primary {
|
|
background: var(--accent);
|
|
color: #000;
|
|
}
|
|
|
|
.release-action-btn.secondary {
|
|
background: var(--bg-active);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
/* Queue Panel */
|
|
.queue-panel {
|
|
width: var(--queue-width);
|
|
min-width: var(--queue-width);
|
|
background: var(--bg-secondary);
|
|
border-left: 1px solid var(--border-color);
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.queue-panel.hidden { display: none; }
|
|
|
|
.queue-header {
|
|
padding: 16px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.queue-header h3 { font-size: 14px; font-weight: 600; }
|
|
|
|
.queue-clear-btn {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-subdued);
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
padding: 4px 8px;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.queue-clear-btn:hover { color: var(--text-primary); background: var(--bg-hover); }
|
|
|
|
.queue-tracks {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 8px;
|
|
}
|
|
|
|
.queue-track {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 8px;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
transition: background 0.15s;
|
|
}
|
|
|
|
.queue-track:hover { background: var(--bg-hover); }
|
|
.queue-track.active { background: var(--bg-active); }
|
|
|
|
.queue-track-cover {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 4px;
|
|
background: var(--bg-elevated);
|
|
overflow: hidden;
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.queue-track-cover img { width: 100%; height: 100%; object-fit: cover; }
|
|
.queue-track-cover svg { width: 20px; height: 20px; color: var(--text-subdued); }
|
|
|
|
.queue-track-info { overflow: hidden; flex: 1; }
|
|
.queue-track-title {
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.queue-track.active .queue-track-title { color: var(--accent); }
|
|
|
|
.queue-track-artist {
|
|
font-size: 11px;
|
|
color: var(--text-subdued);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.queue-track-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 2px;
|
|
opacity: 0;
|
|
transition: opacity 0.15s;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.queue-track:hover .queue-track-actions { opacity: 1; }
|
|
|
|
.queue-track-remove {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-subdued);
|
|
cursor: pointer;
|
|
padding: 4px;
|
|
border-radius: 4px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: color 0.15s, background 0.15s;
|
|
}
|
|
|
|
.queue-track-remove:hover { color: var(--text-primary); background: var(--bg-hover); }
|
|
|
|
/* Drag handle */
|
|
.queue-drag-handle {
|
|
cursor: grab;
|
|
color: var(--text-subdued);
|
|
padding: 4px 2px;
|
|
display: flex;
|
|
align-items: center;
|
|
flex-shrink: 0;
|
|
opacity: 0;
|
|
transition: opacity 0.15s;
|
|
}
|
|
|
|
.queue-track:hover .queue-drag-handle { opacity: 1; }
|
|
.queue-drag-handle:active { cursor: grabbing; }
|
|
.queue-drag-handle svg { width: 14px; height: 14px; }
|
|
|
|
/* Drag states */
|
|
.queue-track.dragging { opacity: 0.4; }
|
|
.queue-track.drag-over { border-top: 2px solid var(--accent); margin-top: -2px; }
|
|
|
|
/* Player Bar */
|
|
.player-bar {
|
|
height: var(--player-bar-space);
|
|
background: var(--bg-secondary);
|
|
border-top: 1px solid var(--border-color);
|
|
display: grid;
|
|
grid-template-columns: 1fr 2fr 1fr;
|
|
align-items: center;
|
|
padding: 0 16px var(--safe-bottom);
|
|
z-index: 10;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.player-now-playing {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
overflow: hidden;
|
|
min-width: 0;
|
|
}
|
|
|
|
.player-now-playing > div { min-width: 0; }
|
|
|
|
.player-cover {
|
|
width: 56px;
|
|
height: 56px;
|
|
border-radius: 4px;
|
|
background: var(--bg-elevated);
|
|
overflow: hidden;
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.player-cover img { width: 100%; height: 100%; object-fit: cover; }
|
|
.player-cover svg { width: 24px; height: 24px; color: var(--text-subdued); }
|
|
|
|
.player-track-info {
|
|
overflow: hidden;
|
|
min-width: 0;
|
|
}
|
|
.player-track-title {
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.player-track-artist {
|
|
font-size: 11px;
|
|
color: var(--text-subdued);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.player-controls {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 4px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.player-buttons {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
}
|
|
|
|
.player-btn {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
padding: 4px;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: color 0.15s, transform 0.1s;
|
|
}
|
|
|
|
.player-btn:hover { color: var(--text-primary); }
|
|
.player-btn:active { transform: scale(0.95); }
|
|
.player-btn svg { width: 18px; height: 18px; }
|
|
|
|
.player-btn.active { color: var(--accent); }
|
|
|
|
.player-btn-play {
|
|
width: 32px;
|
|
height: 32px;
|
|
background: var(--text-primary);
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.player-btn-play svg { width: 16px; height: 16px; fill: #000; color: #000; }
|
|
.player-btn-play:hover { transform: scale(1.06); background: #fff; }
|
|
|
|
.player-timeline {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
width: 100%;
|
|
max-width: 600px;
|
|
}
|
|
|
|
.player-time { font-size: 11px; color: var(--text-subdued); min-width: 40px; text-align: center; }
|
|
|
|
.progress-bar {
|
|
flex: 1;
|
|
height: 4px;
|
|
background: var(--bg-active);
|
|
border-radius: 2px;
|
|
cursor: pointer;
|
|
position: relative;
|
|
}
|
|
|
|
.progress-bar:hover { height: 6px; }
|
|
|
|
.progress-bar-fill {
|
|
height: 100%;
|
|
background: var(--text-primary);
|
|
border-radius: 2px;
|
|
position: relative;
|
|
transition: width 0.1s linear;
|
|
}
|
|
|
|
.progress-bar:hover .progress-bar-fill { background: var(--accent); }
|
|
|
|
.progress-bar-thumb {
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 50%;
|
|
background: var(--text-primary);
|
|
position: absolute;
|
|
right: -6px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
opacity: 0;
|
|
transition: opacity 0.15s;
|
|
}
|
|
|
|
.progress-bar:hover .progress-bar-thumb { opacity: 1; }
|
|
|
|
.player-right {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
gap: 8px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.volume-control {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
.volume-btn {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
padding: 4px;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.volume-btn:hover { color: var(--text-primary); }
|
|
.volume-btn svg { width: 18px; height: 18px; }
|
|
|
|
.volume-slider {
|
|
width: 80px;
|
|
height: 4px;
|
|
background: var(--bg-active);
|
|
border-radius: 2px;
|
|
cursor: pointer;
|
|
position: relative;
|
|
}
|
|
|
|
.volume-slider-fill {
|
|
height: 100%;
|
|
background: var(--text-primary);
|
|
border-radius: 2px;
|
|
}
|
|
|
|
.volume-slider:hover .volume-slider-fill { background: var(--accent); }
|
|
|
|
.queue-toggle-btn {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-subdued);
|
|
cursor: pointer;
|
|
padding: 4px 8px;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.queue-toggle-btn:hover { color: var(--text-primary); }
|
|
.queue-toggle-btn.active { color: var(--accent); }
|
|
.queue-toggle-btn svg { width: 18px; height: 18px; }
|
|
|
|
/* Loading */
|
|
.loading-spinner {
|
|
display: flex;
|
|
justify-content: center;
|
|
padding: 40px;
|
|
}
|
|
|
|
.spinner {
|
|
width: 32px;
|
|
height: 32px;
|
|
border: 3px solid var(--bg-active);
|
|
border-top-color: var(--accent);
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
|
|
/* Empty state */
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 60px 20px;
|
|
color: var(--text-subdued);
|
|
}
|
|
|
|
.empty-state svg { width: 64px; height: 64px; margin-bottom: 16px; }
|
|
.empty-state p { font-size: 14px; }
|
|
|
|
.content-topbar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
/* Search bar */
|
|
.search-bar {
|
|
position: relative;
|
|
flex: 1 1 auto;
|
|
min-width: 0;
|
|
}
|
|
|
|
.version-chip {
|
|
flex: 0 0 auto;
|
|
color: var(--text-subdued);
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
line-height: 1;
|
|
letter-spacing: 0;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.mobile-account-chip {
|
|
display: none;
|
|
align-items: center;
|
|
gap: 8px;
|
|
min-width: 0;
|
|
max-width: 148px;
|
|
height: 42px;
|
|
padding: 0 10px 0 6px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-elevated);
|
|
color: var(--text-primary);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.mobile-account-chip:hover {
|
|
background: var(--bg-hover);
|
|
}
|
|
|
|
.mobile-account-popover {
|
|
position: absolute;
|
|
right: 16px;
|
|
top: 66px;
|
|
z-index: 80;
|
|
width: min(286px, calc(100vw - 32px));
|
|
padding: 12px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 10px;
|
|
background: var(--bg-elevated);
|
|
box-shadow: 0 16px 36px rgba(0,0,0,0.42);
|
|
}
|
|
|
|
.mobile-account-popover .user-widget-main {
|
|
grid-template-columns: 36px minmax(0, 1fr);
|
|
}
|
|
|
|
.mobile-account-logout {
|
|
width: 100%;
|
|
margin-top: 12px;
|
|
justify-content: center;
|
|
}
|
|
|
|
.torrent-import-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 42px;
|
|
height: 42px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-elevated);
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
flex: 0 0 auto;
|
|
transition: background 0.15s, color 0.15s;
|
|
}
|
|
|
|
.torrent-import-btn:hover {
|
|
background: var(--bg-hover);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.torrent-import-btn svg {
|
|
width: 19px;
|
|
height: 19px;
|
|
}
|
|
|
|
.mobile-account-chip .user-avatar {
|
|
width: 30px;
|
|
height: 30px;
|
|
font-size: 13px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.mobile-account-name {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
text-align: left;
|
|
}
|
|
|
|
.search-bar input {
|
|
width: 100%;
|
|
padding: 10px 40px 10px 40px;
|
|
background: var(--bg-elevated);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
color: var(--text-primary);
|
|
font-size: 14px;
|
|
outline: none;
|
|
transition: border-color 0.15s, background 0.15s;
|
|
}
|
|
|
|
.search-bar input::placeholder { color: var(--text-subdued); }
|
|
.search-bar input:focus { border-color: var(--text-secondary); background: var(--bg-hover); }
|
|
|
|
.search-bar .search-icon {
|
|
position: absolute;
|
|
left: 12px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
color: var(--text-subdued);
|
|
pointer-events: none;
|
|
}
|
|
|
|
.search-bar .search-icon svg { width: 18px; height: 18px; }
|
|
|
|
.search-bar .search-clear {
|
|
position: absolute;
|
|
right: 8px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-subdued);
|
|
cursor: pointer;
|
|
padding: 4px;
|
|
border-radius: 4px;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.search-bar .search-clear:hover { color: var(--text-primary); background: var(--bg-hover); }
|
|
.search-bar .search-clear svg { width: 16px; height: 16px; }
|
|
|
|
.search-bar .search-shortcut {
|
|
position: absolute;
|
|
right: 12px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
color: var(--text-subdued);
|
|
font-size: 11px;
|
|
pointer-events: none;
|
|
background: var(--bg-active);
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
/* Search results */
|
|
.search-section { margin-bottom: 24px; }
|
|
.search-section-title {
|
|
font-size: 18px;
|
|
font-weight: 700;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.search-artists-row {
|
|
display: flex;
|
|
gap: 16px;
|
|
overflow-x: auto;
|
|
padding-bottom: 8px;
|
|
}
|
|
|
|
.search-artists-row::-webkit-scrollbar { height: 6px; }
|
|
.search-artists-row::-webkit-scrollbar-track { background: transparent; }
|
|
.search-artists-row::-webkit-scrollbar-thumb { background: var(--bg-active); border-radius: 3px; }
|
|
|
|
.search-artist-card {
|
|
flex-shrink: 0;
|
|
width: 140px;
|
|
background: var(--bg-secondary);
|
|
border-radius: 8px;
|
|
padding: 12px;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
text-align: center;
|
|
}
|
|
|
|
.search-artist-card:hover { background: var(--bg-elevated); }
|
|
|
|
.search-artist-img {
|
|
width: 80px;
|
|
height: 80px;
|
|
border-radius: 50%;
|
|
background: var(--bg-elevated);
|
|
margin: 0 auto 8px;
|
|
overflow: hidden;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.search-artist-img img { width: 100%; height: 100%; object-fit: cover; }
|
|
.search-artist-img svg { width: 32px; height: 32px; color: var(--text-subdued); }
|
|
|
|
.search-artist-name {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.search-releases-row {
|
|
display: flex;
|
|
gap: 16px;
|
|
overflow-x: auto;
|
|
padding-bottom: 8px;
|
|
}
|
|
|
|
.search-releases-row::-webkit-scrollbar { height: 6px; }
|
|
.search-releases-row::-webkit-scrollbar-track { background: transparent; }
|
|
.search-releases-row::-webkit-scrollbar-thumb { background: var(--bg-active); border-radius: 3px; }
|
|
|
|
.search-release-card {
|
|
flex-shrink: 0;
|
|
width: 150px;
|
|
background: var(--bg-secondary);
|
|
border-radius: 8px;
|
|
padding: 12px;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.search-release-card:hover { background: var(--bg-elevated); }
|
|
|
|
.search-release-cover {
|
|
width: 100%;
|
|
aspect-ratio: 1;
|
|
border-radius: 6px;
|
|
background: var(--bg-elevated);
|
|
margin-bottom: 8px;
|
|
overflow: hidden;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.search-release-cover img { width: 100%; height: 100%; object-fit: cover; }
|
|
.search-release-cover svg { width: 40px; height: 40px; color: var(--text-subdued); }
|
|
|
|
/* Like button */
|
|
.like-btn {
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
padding: 4px;
|
|
border-radius: 4px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: color 0.15s, transform 0.1s;
|
|
color: var(--text-subdued);
|
|
}
|
|
|
|
.like-btn:hover { color: var(--text-primary); }
|
|
.like-btn:active { transform: scale(0.9); }
|
|
.like-btn.liked { color: var(--accent); }
|
|
.like-btn svg { width: 16px; height: 16px; }
|
|
|
|
.like-btn-lg svg { width: 22px; height: 22px; }
|
|
|
|
/* Playlist modal overlay */
|
|
.modal-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0,0,0,0.6);
|
|
z-index: 100;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.modal-box {
|
|
background: var(--bg-elevated);
|
|
border-radius: 12px;
|
|
padding: 24px;
|
|
min-width: 320px;
|
|
max-width: 400px;
|
|
max-height: 70vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
box-shadow: 0 16px 48px rgba(0,0,0,0.5);
|
|
}
|
|
|
|
.modal-box h3 {
|
|
font-size: 18px;
|
|
font-weight: 700;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.modal-box input[type="text"] {
|
|
width: 100%;
|
|
padding: 10px 12px;
|
|
background: var(--bg-primary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 6px;
|
|
color: var(--text-primary);
|
|
font-size: 14px;
|
|
outline: none;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.modal-box input[type="text"]:focus { border-color: var(--accent); }
|
|
|
|
.modal-playlist-list {
|
|
overflow-y: auto;
|
|
max-height: 40vh;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.modal-playlist-item {
|
|
padding: 10px 12px;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
color: var(--text-secondary);
|
|
transition: background 0.15s, color 0.15s;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.modal-playlist-item:hover { background: var(--bg-hover); color: var(--text-primary); }
|
|
.modal-playlist-item svg { width: 16px; height: 16px; flex-shrink: 0; }
|
|
|
|
.modal-btn {
|
|
padding: 8px 16px;
|
|
border-radius: 20px;
|
|
border: none;
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
transition: filter 0.15s;
|
|
}
|
|
|
|
.modal-btn:hover { filter: brightness(1.1); }
|
|
.modal-btn-primary { background: var(--accent); color: #000; }
|
|
.modal-btn-ghost { background: transparent; color: var(--text-secondary); }
|
|
|
|
.modal-footer {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 8px;
|
|
}
|
|
|
|
.torrent-modal {
|
|
width: min(860px, calc(100vw - 32px));
|
|
max-width: 860px;
|
|
max-height: min(88dvh, 760px);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.history-modal {
|
|
width: min(620px, calc(100vw - 32px));
|
|
max-width: 620px;
|
|
}
|
|
|
|
.history-list {
|
|
margin-top: 12px;
|
|
overflow-y: auto;
|
|
max-height: min(54vh, 460px);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.history-row {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) auto;
|
|
gap: 8px 12px;
|
|
padding: 10px 12px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.history-row:last-child { border-bottom: 0; }
|
|
|
|
.history-title {
|
|
min-width: 0;
|
|
color: var(--text-primary);
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.history-release,
|
|
.history-date,
|
|
.history-duration {
|
|
color: var(--text-subdued);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.history-date,
|
|
.history-duration {
|
|
text-align: right;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.history-pager {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.torrent-modal-grid {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
|
gap: 12px;
|
|
}
|
|
|
|
.torrent-modal label {
|
|
display: block;
|
|
margin-bottom: 6px;
|
|
color: var(--text-secondary);
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.torrent-modal input[type="file"],
|
|
.torrent-modal textarea {
|
|
width: 100%;
|
|
padding: 10px 12px;
|
|
background: var(--bg-primary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 6px;
|
|
color: var(--text-primary);
|
|
font: inherit;
|
|
outline: none;
|
|
}
|
|
|
|
.torrent-modal textarea {
|
|
resize: vertical;
|
|
min-height: 92px;
|
|
}
|
|
|
|
.torrent-modal input[type="file"]:focus,
|
|
.torrent-modal textarea:focus {
|
|
border-color: var(--text-secondary);
|
|
}
|
|
|
|
.torrent-message {
|
|
margin: 10px 0 0;
|
|
min-height: 18px;
|
|
color: var(--text-subdued);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.torrent-message.error { color: #ff8b8b; }
|
|
|
|
.torrent-preview-head {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
align-items: center;
|
|
margin-top: 16px;
|
|
}
|
|
|
|
.torrent-preview-title {
|
|
min-width: 0;
|
|
font-size: 14px;
|
|
font-weight: 800;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.torrent-preview-meta {
|
|
margin-top: 3px;
|
|
color: var(--text-subdued);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.torrent-preview-panel {
|
|
display: flex;
|
|
flex: 1 1 auto;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
}
|
|
|
|
.torrent-actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: flex-start;
|
|
gap: 8px;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.torrent-tree-toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
margin-top: 12px;
|
|
padding: 10px 12px;
|
|
background: var(--bg-primary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.torrent-selected-summary {
|
|
min-width: 0;
|
|
color: var(--text-secondary);
|
|
font-size: 12px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.torrent-file-tree {
|
|
flex: 1 1 auto;
|
|
margin-top: 10px;
|
|
overflow-y: auto;
|
|
min-height: 140px;
|
|
max-height: min(46vh, 420px);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
background: var(--bg-primary);
|
|
}
|
|
|
|
.torrent-tree-row {
|
|
display: grid;
|
|
grid-template-columns: 28px 24px minmax(0, 1fr) 92px;
|
|
gap: 8px;
|
|
align-items: center;
|
|
min-height: 38px;
|
|
padding: 7px 10px 7px var(--indent, 10px);
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.torrent-tree-row:last-child { border-bottom: 0; }
|
|
.torrent-tree-row:hover { background: var(--bg-hover); }
|
|
|
|
.torrent-tree-toggle,
|
|
.torrent-tree-check {
|
|
width: 24px;
|
|
height: 24px;
|
|
border: 0;
|
|
background: transparent;
|
|
color: var(--text-subdued);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 0;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.torrent-tree-toggle svg {
|
|
width: 16px;
|
|
height: 16px;
|
|
transition: transform 0.15s;
|
|
}
|
|
|
|
.torrent-tree-toggle.expanded svg {
|
|
transform: rotate(90deg);
|
|
}
|
|
|
|
.torrent-tree-check {
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 5px;
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
.torrent-tree-check.checked {
|
|
background: var(--accent);
|
|
border-color: var(--accent);
|
|
color: #000;
|
|
}
|
|
|
|
.torrent-tree-check.partial {
|
|
border-color: var(--text-secondary);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.torrent-tree-check svg {
|
|
width: 15px;
|
|
height: 15px;
|
|
}
|
|
|
|
.torrent-tree-label {
|
|
min-width: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.torrent-tree-label svg {
|
|
flex: 0 0 auto;
|
|
width: 17px;
|
|
height: 17px;
|
|
color: var(--text-subdued);
|
|
}
|
|
|
|
.torrent-file-name {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.torrent-file-size {
|
|
color: var(--text-subdued);
|
|
font-size: 12px;
|
|
text-align: right;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
/* Sidebar playlist actions */
|
|
.playlist-item-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.playlist-item-row .playlist-item { flex: 1; min-width: 0; }
|
|
|
|
.playlist-item-actions {
|
|
display: flex;
|
|
gap: 2px;
|
|
opacity: 0;
|
|
transition: opacity 0.15s;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.playlist-item-row:hover .playlist-item-actions { opacity: 1; }
|
|
|
|
.playlist-action-btn {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-subdued);
|
|
cursor: pointer;
|
|
padding: 3px;
|
|
border-radius: 4px;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.playlist-action-btn:hover { color: var(--text-primary); background: var(--bg-hover); }
|
|
.playlist-action-btn svg { width: 14px; height: 14px; }
|
|
|
|
.sidebar-create-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
width: 100%;
|
|
padding: 8px 12px;
|
|
background: none;
|
|
border: none;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
color: var(--text-subdued);
|
|
font-size: 13px;
|
|
transition: background 0.15s, color 0.15s;
|
|
}
|
|
|
|
.sidebar-create-btn:hover { background: var(--bg-hover); color: var(--text-primary); }
|
|
.sidebar-create-btn svg { width: 16px; height: 16px; }
|
|
|
|
/* Responsive */
|
|
@media (max-width: 1200px) {
|
|
:root {
|
|
--sidebar-width: 220px;
|
|
--queue-width: 280px;
|
|
}
|
|
|
|
.center-content { padding: 20px; }
|
|
.card-grid {
|
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
|
gap: 16px;
|
|
}
|
|
.artist-header .artist-img,
|
|
.release-header .release-cover {
|
|
width: 168px;
|
|
height: 168px;
|
|
}
|
|
.release-meta .release-title { font-size: 32px; }
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
:root {
|
|
--player-height: 118px;
|
|
--player-bar-space: calc(var(--player-height) + var(--safe-bottom));
|
|
}
|
|
|
|
.sidebar-left { display: none; }
|
|
|
|
.center-content {
|
|
padding: 16px;
|
|
}
|
|
|
|
.content-topbar {
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.content-topbar .search-bar {
|
|
flex: 1 1 auto;
|
|
}
|
|
|
|
.mobile-account-chip {
|
|
display: flex;
|
|
flex: 0 0 auto;
|
|
}
|
|
|
|
.version-chip {
|
|
display: none;
|
|
}
|
|
|
|
.torrent-modal {
|
|
width: calc(100vw - 24px);
|
|
}
|
|
|
|
.card-grid {
|
|
grid-template-columns: repeat(auto-fill, minmax(136px, 1fr));
|
|
gap: 14px;
|
|
}
|
|
|
|
.card {
|
|
padding: 10px;
|
|
border-radius: 7px;
|
|
}
|
|
|
|
.card-title { font-size: 13px; }
|
|
.card-subtitle { font-size: 11px; }
|
|
.card-play-btn,
|
|
.card-enqueue-btn,
|
|
.track-actions,
|
|
.playlist-item-actions,
|
|
.queue-track-actions,
|
|
.queue-drag-handle {
|
|
opacity: 1;
|
|
transform: none;
|
|
}
|
|
|
|
.artist-header,
|
|
.release-header {
|
|
align-items: center;
|
|
gap: 16px;
|
|
}
|
|
|
|
.artist-header .artist-img,
|
|
.release-header .release-cover {
|
|
width: 128px;
|
|
height: 128px;
|
|
}
|
|
|
|
.artist-header .artist-name,
|
|
.artist-header .artist-name[style] {
|
|
font-size: 34px !important;
|
|
}
|
|
|
|
.release-meta .release-title { font-size: 28px; }
|
|
|
|
.track-list-header,
|
|
.track-row {
|
|
grid-template-columns: 32px minmax(0, 1fr) auto 54px;
|
|
padding: 8px 10px;
|
|
}
|
|
|
|
.track-list-header span:nth-child(3),
|
|
.track-row > span:nth-child(3) {
|
|
display: none;
|
|
}
|
|
|
|
.track-actions {
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.queue-panel {
|
|
position: fixed;
|
|
left: 12px;
|
|
right: 12px;
|
|
top: 24dvh;
|
|
bottom: calc(var(--player-bar-space) + 12px);
|
|
width: auto;
|
|
min-width: 0;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 10px;
|
|
box-shadow: 0 18px 60px rgba(0,0,0,0.55);
|
|
z-index: 30;
|
|
display: flex;
|
|
}
|
|
|
|
.queue-panel.hidden {
|
|
display: none;
|
|
}
|
|
|
|
.queue-track {
|
|
padding: 10px 8px;
|
|
}
|
|
|
|
.player-bar {
|
|
grid-template-columns: minmax(0, 1fr) auto;
|
|
grid-template-rows: auto auto;
|
|
gap: 8px 12px;
|
|
align-items: center;
|
|
padding: 10px 12px calc(10px + var(--safe-bottom));
|
|
}
|
|
|
|
.player-now-playing {
|
|
grid-column: 1;
|
|
grid-row: 1;
|
|
}
|
|
|
|
.player-cover {
|
|
width: 44px;
|
|
height: 44px;
|
|
}
|
|
|
|
.player-controls {
|
|
grid-column: 1 / -1;
|
|
grid-row: 2;
|
|
gap: 6px;
|
|
width: 100%;
|
|
}
|
|
|
|
.player-buttons {
|
|
gap: 18px;
|
|
}
|
|
|
|
.player-btn {
|
|
min-width: 32px;
|
|
min-height: 32px;
|
|
}
|
|
|
|
.player-btn-play {
|
|
width: 38px;
|
|
height: 38px;
|
|
}
|
|
|
|
.player-timeline {
|
|
max-width: none;
|
|
gap: 6px;
|
|
}
|
|
|
|
.player-time {
|
|
min-width: 34px;
|
|
font-size: 10px;
|
|
}
|
|
|
|
.player-right {
|
|
grid-column: 2;
|
|
grid-row: 1;
|
|
}
|
|
|
|
.volume-control {
|
|
display: none;
|
|
}
|
|
|
|
.queue-toggle-btn {
|
|
min-width: 36px;
|
|
min-height: 36px;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 560px) {
|
|
:root {
|
|
--player-height: 132px;
|
|
--player-bar-space: calc(var(--player-height) + var(--safe-bottom));
|
|
}
|
|
|
|
.center-content {
|
|
padding: 12px;
|
|
}
|
|
|
|
.section-title {
|
|
font-size: 22px;
|
|
margin-bottom: 14px;
|
|
}
|
|
|
|
.content-topbar {
|
|
margin-bottom: 14px;
|
|
}
|
|
|
|
.search-bar input {
|
|
padding-top: 11px;
|
|
padding-bottom: 11px;
|
|
font-size: 16px;
|
|
}
|
|
|
|
.search-bar .search-shortcut {
|
|
display: none;
|
|
}
|
|
|
|
.mobile-account-chip {
|
|
max-width: 42px;
|
|
width: 42px;
|
|
padding: 0;
|
|
justify-content: center;
|
|
}
|
|
|
|
.mobile-account-name {
|
|
display: none;
|
|
}
|
|
|
|
.mobile-account-popover {
|
|
right: 8px;
|
|
top: 64px;
|
|
}
|
|
|
|
.card-grid {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 12px;
|
|
}
|
|
|
|
.search-artists-row,
|
|
.search-releases-row {
|
|
gap: 12px;
|
|
margin-left: -12px;
|
|
margin-right: -12px;
|
|
padding-left: 12px;
|
|
padding-right: 12px;
|
|
scroll-padding-left: 12px;
|
|
}
|
|
|
|
.search-artist-card,
|
|
.search-release-card {
|
|
width: 132px;
|
|
}
|
|
|
|
.artist-header,
|
|
.release-header {
|
|
display: grid;
|
|
grid-template-columns: 96px minmax(0, 1fr);
|
|
align-items: center;
|
|
gap: 14px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.artist-header .artist-img,
|
|
.release-header .release-cover {
|
|
width: 96px;
|
|
height: 96px;
|
|
}
|
|
|
|
.artist-header .artist-img svg,
|
|
.release-header .release-cover svg {
|
|
width: 44px;
|
|
height: 44px;
|
|
}
|
|
|
|
.artist-header .artist-name,
|
|
.artist-header .artist-name[style] {
|
|
font-size: 26px !important;
|
|
line-height: 1.12 !important;
|
|
}
|
|
|
|
.artist-stats {
|
|
font-size: 12px;
|
|
gap: 5px;
|
|
}
|
|
|
|
.release-meta {
|
|
min-width: 0;
|
|
}
|
|
|
|
.release-meta .release-title {
|
|
font-size: 22px;
|
|
line-height: 1.15;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.release-actions {
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.release-action-btn {
|
|
padding: 8px 12px;
|
|
}
|
|
|
|
.track-list-header {
|
|
display: none;
|
|
}
|
|
|
|
.track-row {
|
|
grid-template-columns: 26px minmax(0, 1fr) auto;
|
|
gap: 6px;
|
|
padding: 10px 6px;
|
|
}
|
|
|
|
.track-row > span:nth-child(3),
|
|
.track-duration {
|
|
display: none;
|
|
}
|
|
|
|
.track-actions {
|
|
gap: 0;
|
|
}
|
|
|
|
.track-action-btn,
|
|
.like-btn {
|
|
padding: 6px;
|
|
}
|
|
|
|
.track-action-btn svg,
|
|
.like-btn svg {
|
|
width: 17px;
|
|
height: 17px;
|
|
}
|
|
|
|
.modal-overlay {
|
|
align-items: flex-end;
|
|
padding: 8px;
|
|
}
|
|
|
|
.modal-box {
|
|
width: 100%;
|
|
min-width: 0;
|
|
max-width: none;
|
|
max-height: calc(100dvh - 16px);
|
|
border-radius: 10px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.torrent-modal {
|
|
max-height: calc(100dvh - 16px);
|
|
padding: 16px;
|
|
}
|
|
|
|
.torrent-modal h3 {
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.torrent-modal-grid {
|
|
grid-template-columns: 1fr;
|
|
gap: 10px;
|
|
}
|
|
|
|
.torrent-modal textarea {
|
|
min-height: 68px;
|
|
max-height: 84px;
|
|
}
|
|
|
|
.torrent-message {
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.torrent-actions {
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.torrent-preview-head {
|
|
align-items: flex-start;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.torrent-tree-toolbar {
|
|
align-items: flex-start;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
margin-top: 8px;
|
|
padding: 8px 10px;
|
|
}
|
|
|
|
.torrent-file-tree {
|
|
min-height: 120px;
|
|
max-height: min(32dvh, 260px);
|
|
}
|
|
|
|
.torrent-tree-row {
|
|
grid-template-columns: 24px 22px minmax(0, 1fr) 74px;
|
|
gap: 6px;
|
|
}
|
|
|
|
.torrent-file-size {
|
|
grid-column: 4;
|
|
text-align: right;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.queue-panel {
|
|
left: 8px;
|
|
right: 8px;
|
|
top: 18dvh;
|
|
bottom: calc(var(--player-bar-space) + 8px);
|
|
}
|
|
|
|
.player-bar {
|
|
gap: 8px;
|
|
padding-left: 10px;
|
|
padding-right: 10px;
|
|
}
|
|
|
|
.player-track-title { font-size: 12px; }
|
|
.player-track-artist { font-size: 10px; }
|
|
.player-buttons { gap: 10px; }
|
|
|
|
.player-btn {
|
|
min-width: 30px;
|
|
min-height: 30px;
|
|
}
|
|
|
|
.player-btn svg {
|
|
width: 17px;
|
|
height: 17px;
|
|
}
|
|
|
|
.player-btn-play {
|
|
width: 36px;
|
|
height: 36px;
|
|
}
|
|
}
|
|
|
|
/* Scrollbar for queue and sidebar */
|
|
.queue-tracks::-webkit-scrollbar,
|
|
.playlist-list::-webkit-scrollbar { width: 6px; }
|
|
.queue-tracks::-webkit-scrollbar-track,
|
|
.playlist-list::-webkit-scrollbar-track { background: transparent; }
|
|
.queue-tracks::-webkit-scrollbar-thumb,
|
|
.playlist-list::-webkit-scrollbar-thumb { background: var(--bg-active); border-radius: 3px; }
|
|
</style>
|
|
{% endblock head_extra %}
|
|
|
|
{% block body %}
|
|
<div class="app-layout"
|
|
x-data
|
|
@keydown.window.space="if (!['INPUT','TEXTAREA','SELECT'].includes(document.activeElement?.tagName)) { $event.preventDefault(); $store.player.toggle(); }"
|
|
@keydown.window.arrow-left="if (!['INPUT','TEXTAREA','SELECT'].includes(document.activeElement?.tagName)) { $event.preventDefault(); $store.player.seekRelative(-5); }"
|
|
@keydown.window.arrow-right="if (!['INPUT','TEXTAREA','SELECT'].includes(document.activeElement?.tagName)) { $event.preventDefault(); $store.player.seekRelative(5); }"
|
|
@keydown.window="if ((e=$event).ctrlKey && e.key==='k') { e.preventDefault(); document.getElementById('search-input')?.focus(); } else if (e.key==='/' && !['INPUT','TEXTAREA','SELECT'].includes(document.activeElement?.tagName)) { e.preventDefault(); document.getElementById('search-input')?.focus(); }"
|
|
>
|
|
|
|
<div class="main-content">
|
|
<!-- Left Sidebar -->
|
|
<div class="sidebar-left">
|
|
<div class="user-widget" x-show="$store.user.profile" x-cloak>
|
|
<div class="user-widget-main">
|
|
<div class="user-avatar" x-text="$store.user.initials()"></div>
|
|
<div style="min-width:0">
|
|
<div class="user-name" x-text="$store.user.profile?.name || ''"></div>
|
|
<div class="user-role" x-text="$store.user.profile?.role || ''"></div>
|
|
</div>
|
|
<button class="user-logout-btn" @click="$store.user.logout()" title="Log out">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/>
|
|
<polyline points="16 17 21 12 16 7"/>
|
|
<line x1="21" y1="12" x2="9" y2="12"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="user-stats">
|
|
<button class="user-stat" @click="$store.history.open()">
|
|
<span class="user-stat-value" x-text="$store.user.format($store.user.profile?.stats?.plays)"></span>
|
|
<span class="user-stat-label">plays</span>
|
|
</button>
|
|
<div class="user-stat">
|
|
<span class="user-stat-value" x-text="$store.user.format($store.user.profile?.stats?.liked_tracks)"></span>
|
|
<span class="user-stat-label">likes</span>
|
|
</div>
|
|
<div class="user-stat">
|
|
<span class="user-stat-value" x-text="$store.user.duration($store.user.profile?.stats?.listened_minutes)"></span>
|
|
<span class="user-stat-label">listened</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="sidebar-header">
|
|
<h2>Library</h2>
|
|
</div>
|
|
<div class="sidebar-nav">
|
|
<div class="sidebar-nav-item"
|
|
:class="{ active: $store.library.view === 'artists' }"
|
|
@click="$store.library.goArtists()">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
|
Artists
|
|
</div>
|
|
</div>
|
|
<div class="playlist-list">
|
|
<template x-for="pl in $store.playlists.list" :key="pl.id">
|
|
<div class="playlist-item-row">
|
|
<div class="playlist-item" @click="$store.library.openPlaylist(pl.id)">
|
|
<template x-if="pl.kind === 'likes'">
|
|
<span style="display:flex;align-items:center;gap:6px">
|
|
<svg viewBox="0 0 24 24" fill="var(--accent)" stroke="none" width="14" height="14"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
|
<span x-text="pl.title"></span>
|
|
</span>
|
|
</template>
|
|
<template x-if="pl.kind !== 'likes'">
|
|
<span x-text="pl.title"></span>
|
|
</template>
|
|
<span class="playlist-count" x-text="pl.track_count + ' tracks'"></span>
|
|
</div>
|
|
<template x-if="pl.is_own && pl.kind === 'user'">
|
|
<div class="playlist-item-actions">
|
|
<button class="playlist-action-btn" @click.stop="$store.playlists.startRename(pl)" title="Rename">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
|
</button>
|
|
<button class="playlist-action-btn" @click.stop="$store.playlists.deletePlaylist(pl.id)" title="Delete">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
<button class="sidebar-create-btn" @click="$store.playlists.showCreate()">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
New Playlist
|
|
</button>
|
|
</div>
|
|
<div class="sidebar-bottom">
|
|
<a href="/admin/">Admin Panel</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Center Content -->
|
|
<div class="center-content" id="center-scroll">
|
|
<!-- Search / account bar -->
|
|
<div class="content-topbar" @click.outside="$store.user.menuOpen = false">
|
|
<div class="search-bar">
|
|
<span class="search-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>
|
|
<input id="search-input" type="text" placeholder="Search artists, releases, tracks..."
|
|
x-model="$store.library.searchQuery"
|
|
@input.debounce.300ms="$store.library.search($store.library.searchQuery)"
|
|
@keydown.escape="$store.library.clearSearch(); $el.blur()">
|
|
<template x-if="$store.library.searchQuery">
|
|
<button class="search-clear" @click="$store.library.clearSearch()">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
</button>
|
|
</template>
|
|
<template x-if="!$store.library.searchQuery">
|
|
<span class="search-shortcut">Ctrl+K</span>
|
|
</template>
|
|
</div>
|
|
<button class="torrent-import-btn"
|
|
@click="$store.torrents.open()"
|
|
title="Import torrent">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
|
|
<polyline points="7 10 12 15 17 10"/>
|
|
<line x1="12" y1="15" x2="12" y2="3"/>
|
|
</svg>
|
|
</button>
|
|
<span class="version-chip">v{{ t.app_version() }}</span>
|
|
<button class="mobile-account-chip"
|
|
x-show="$store.user.profile"
|
|
x-cloak
|
|
@click="$store.user.menuOpen = !$store.user.menuOpen"
|
|
:title="$store.user.profile?.name || 'Account'">
|
|
<span class="user-avatar" x-text="$store.user.initials()"></span>
|
|
<span class="mobile-account-name" x-text="$store.user.profile?.name || ''"></span>
|
|
</button>
|
|
<div class="mobile-account-popover"
|
|
x-show="$store.user.menuOpen && $store.user.profile"
|
|
x-cloak>
|
|
<div class="user-widget-main">
|
|
<span class="user-avatar" x-text="$store.user.initials()"></span>
|
|
<div style="min-width:0">
|
|
<div class="user-name" x-text="$store.user.profile?.name || ''"></div>
|
|
<div class="user-role" x-text="$store.user.profile?.role || ''"></div>
|
|
</div>
|
|
</div>
|
|
<div class="user-stats">
|
|
<button class="user-stat" @click="$store.history.open(); $store.user.menuOpen = false">
|
|
<span class="user-stat-value" x-text="$store.user.format($store.user.profile?.stats?.plays)"></span>
|
|
<span class="user-stat-label">plays</span>
|
|
</button>
|
|
<div class="user-stat">
|
|
<span class="user-stat-value" x-text="$store.user.format($store.user.profile?.stats?.liked_tracks)"></span>
|
|
<span class="user-stat-label">likes</span>
|
|
</div>
|
|
<div class="user-stat">
|
|
<span class="user-stat-value" x-text="$store.user.duration($store.user.profile?.stats?.listened_minutes)"></span>
|
|
<span class="user-stat-label">listened</span>
|
|
</div>
|
|
</div>
|
|
<button class="modal-btn modal-btn-primary mobile-account-logout"
|
|
@click="$store.user.logout()">
|
|
Log out
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Search Results -->
|
|
<template x-if="$store.library.view === 'search'">
|
|
<div>
|
|
<template x-if="$store.library.searchLoading">
|
|
<div class="loading-spinner"><div class="spinner"></div></div>
|
|
</template>
|
|
<template x-if="!$store.library.searchLoading && $store.library.searchResults">
|
|
<div>
|
|
<template x-if="$store.library.searchResults.artists.length === 0 && $store.library.searchResults.releases.length === 0 && $store.library.searchResults.tracks.length === 0">
|
|
<div class="empty-state">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
|
<p>No results found</p>
|
|
</div>
|
|
</template>
|
|
<!-- Artists section -->
|
|
<template x-if="$store.library.searchResults.artists.length > 0">
|
|
<div class="search-section">
|
|
<h2 class="search-section-title">Artists</h2>
|
|
<div class="search-artists-row">
|
|
<template x-for="artist in $store.library.searchResults.artists" :key="artist.id">
|
|
<div class="search-artist-card" @click="$store.library.openArtist(artist.id)">
|
|
<div class="search-artist-img">
|
|
<template x-if="artist.image_url">
|
|
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
|
|
</template>
|
|
<template x-if="!artist.image_url">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
|
</template>
|
|
</div>
|
|
<div class="search-artist-name" x-text="artist.name"></div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<!-- Releases section -->
|
|
<template x-if="$store.library.searchResults.releases.length > 0">
|
|
<div class="search-section">
|
|
<h2 class="search-section-title">Releases</h2>
|
|
<div class="search-releases-row">
|
|
<template x-for="release in $store.library.searchResults.releases" :key="release.id">
|
|
<div class="search-release-card" @click="$store.library.openRelease(release.id)" style="position:relative">
|
|
<div class="search-release-cover" style="position:relative">
|
|
<template x-if="release.cover_url">
|
|
<img :src="release.cover_url" :alt="release.title" loading="lazy">
|
|
</template>
|
|
<template x-if="!release.cover_url">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
|
</template>
|
|
</div>
|
|
<div class="card-title" x-text="release.title"></div>
|
|
<div class="card-subtitle">
|
|
<span x-text="release.year || ''"></span>
|
|
<span x-text="release.release_type"></span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<!-- Tracks section -->
|
|
<template x-if="$store.library.searchResults.tracks.length > 0">
|
|
<div class="search-section">
|
|
<h2 class="search-section-title">Tracks</h2>
|
|
<div class="track-list-header">
|
|
<span>#</span>
|
|
<span>Title</span>
|
|
<span></span>
|
|
<span></span>
|
|
<span style="text-align:right">Duration</span>
|
|
</div>
|
|
<template x-for="(track, idx) in $store.library.searchResults.tracks" :key="track.id">
|
|
<div class="track-row"
|
|
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
|
|
@dblclick="$store.library.playSearchTrack(idx)">
|
|
<span class="track-num" x-text="idx + 1"></span>
|
|
<div class="track-info">
|
|
<div class="track-title" x-text="track.title"></div>
|
|
<div class="track-artists-inline">
|
|
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
|
<span>
|
|
<template x-if="artistIdx > 0"><span>, </span></template>
|
|
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
|
|
</span>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
<span></span>
|
|
<div class="track-actions">
|
|
<button class="track-action-btn play-btn" @click.stop="$store.library.playSearchTrack(idx)" title="Play">
|
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
|
</button>
|
|
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="Like">
|
|
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
|
</button>
|
|
<button class="track-action-btn" @click.stop="$store.queue.addNextInQueue([track])" title="Play next">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h8M5 18h14"/><path d="M17 10l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
|
</button>
|
|
<button class="track-action-btn" @click.stop="$store.queue.addToEnd([track])" title="Add to queue">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
</button>
|
|
<button class="track-action-btn" @click.stop="$store.playlists.showPicker([track.id])" title="Add to playlist">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
|
</button>
|
|
</div>
|
|
<span class="track-duration" x-text="formatTime(track.duration_seconds)"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Artists Grid -->
|
|
<template x-if="$store.library.view === 'artists'">
|
|
<div>
|
|
<h1 class="section-title">Artists</h1>
|
|
<div class="card-grid">
|
|
<template x-for="artist in $store.library.artists" :key="artist.id">
|
|
<div class="card" @click="$store.library.openArtist(artist.id)">
|
|
<div class="card-img">
|
|
<template x-if="artist.image_url">
|
|
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
|
|
</template>
|
|
<template x-if="!artist.image_url">
|
|
<span class="placeholder-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg></span>
|
|
</template>
|
|
</div>
|
|
<div class="card-title" x-text="artist.name"></div>
|
|
<div class="card-subtitle" x-text="artist.release_count + ' releases · ' + artist.track_count + ' tracks'"></div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<template x-if="$store.library.loading">
|
|
<div class="loading-spinner"><div class="spinner"></div></div>
|
|
</template>
|
|
<div id="artist-sentinel" style="height:1px"></div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Artist Detail -->
|
|
<template x-if="$store.library.view === 'artist_detail' && $store.library.currentArtist">
|
|
<div>
|
|
<div class="breadcrumb">
|
|
<a @click="$store.library.goArtists()">Artists</a>
|
|
<span>/</span>
|
|
<span x-text="$store.library.currentArtist.name"></span>
|
|
</div>
|
|
<div class="artist-header">
|
|
<div class="artist-img">
|
|
<template x-if="$store.library.currentArtist.image_url">
|
|
<img :src="$store.library.currentArtist.image_url" :alt="$store.library.currentArtist.name">
|
|
</template>
|
|
<template x-if="!$store.library.currentArtist.image_url">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
|
</template>
|
|
</div>
|
|
<div>
|
|
<div class="artist-name" x-text="$store.library.currentArtist.name"></div>
|
|
<div class="artist-stats">
|
|
<span x-text="$store.library.currentArtist.releases.length + ' releases'"></span>
|
|
<span>•</span>
|
|
<span x-text="$store.library.currentArtist.total_track_count + ' tracks'"></span>
|
|
<span>•</span>
|
|
<span x-text="$store.library.currentArtist.total_play_count + ' plays'"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<template x-for="group in $store.library.artistReleaseGroups()" :key="group.type">
|
|
<section class="artist-release-group">
|
|
<h2 class="artist-release-group-title" x-text="group.label"></h2>
|
|
<div class="card-grid">
|
|
<template x-for="release in group.releases" :key="release.id">
|
|
<div class="card" @click="$store.library.openRelease(release.id)">
|
|
<div class="card-img">
|
|
<template x-if="release.cover_url">
|
|
<img :src="release.cover_url" :alt="release.title" loading="lazy">
|
|
</template>
|
|
<template x-if="!release.cover_url">
|
|
<span class="placeholder-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg></span>
|
|
</template>
|
|
<button class="card-enqueue-btn" @click.stop="$store.library.enqueueRelease(release.id)" title="Add to queue">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
</button>
|
|
<button class="card-play-btn" @click.stop="$store.library.playRelease(release.id)">
|
|
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
|
</button>
|
|
</div>
|
|
<div class="card-title" x-text="release.title"></div>
|
|
<div class="card-subtitle">
|
|
<span x-text="release.year || ''"></span>
|
|
<span x-text="release.track_count + ' tracks'"></span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
<template x-if="$store.library.currentArtist.featured_tracks && $store.library.currentArtist.featured_tracks.length > 0">
|
|
<section class="artist-release-group">
|
|
<h2 class="artist-release-group-title">Appears on</h2>
|
|
<div class="track-list-header">
|
|
<span>#</span>
|
|
<span>Title</span>
|
|
<span></span>
|
|
<span></span>
|
|
<span style="text-align:right">Duration</span>
|
|
</div>
|
|
<template x-for="(track, idx) in $store.library.currentArtist.featured_tracks" :key="track.id">
|
|
<div class="track-row"
|
|
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
|
|
@dblclick="$store.queue.playRelease($store.library.currentArtist.featured_tracks, idx)">
|
|
<span class="track-num" x-text="idx + 1"></span>
|
|
<div class="track-info">
|
|
<div class="track-title">
|
|
<span x-text="track.title"></span>
|
|
<span style="color:var(--text-subdued)"> · </span>
|
|
<a class="artist-link" @click.stop="$store.library.openRelease(track.release_id)" x-text="track.release_title"></a>
|
|
</div>
|
|
<div class="track-artists-inline">
|
|
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
|
<span>
|
|
<template x-if="artistIdx > 0"><span>, </span></template>
|
|
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
|
|
</span>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
<span></span>
|
|
<div class="track-actions">
|
|
<button class="track-action-btn play-btn" @click.stop="$store.queue.playRelease($store.library.currentArtist.featured_tracks, idx)" title="Play">
|
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
|
</button>
|
|
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="Like">
|
|
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
|
</button>
|
|
<button class="track-action-btn" @click.stop="$store.queue.addNextInQueue([track])" title="Play next">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h8M5 18h14"/><path d="M17 10l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
|
</button>
|
|
<button class="track-action-btn" @click.stop="$store.queue.addToEnd([track])" title="Add to queue">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
</button>
|
|
<button class="track-action-btn" @click.stop="$store.playlists.showPicker([track.id])" title="Add to playlist">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
|
</button>
|
|
</div>
|
|
<span class="track-duration" x-text="formatTime(track.duration_seconds)"></span>
|
|
</div>
|
|
</template>
|
|
</section>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Release Detail -->
|
|
<template x-if="$store.library.view === 'release_detail' && $store.library.currentRelease">
|
|
<div>
|
|
<div class="breadcrumb">
|
|
<a @click="$store.library.goArtists()">Artists</a>
|
|
<span>/</span>
|
|
<template x-if="$store.library.currentRelease.artists.length > 0">
|
|
<a @click="$store.library.openArtist($store.library.currentRelease.artists[0].id)" x-text="$store.library.currentRelease.artists[0].name"></a>
|
|
</template>
|
|
<span>/</span>
|
|
<span x-text="$store.library.currentRelease.title"></span>
|
|
</div>
|
|
<div class="release-header">
|
|
<div class="release-cover">
|
|
<template x-if="$store.library.currentRelease.cover_url">
|
|
<img :src="$store.library.currentRelease.cover_url" :alt="$store.library.currentRelease.title">
|
|
</template>
|
|
<template x-if="!$store.library.currentRelease.cover_url">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
|
</template>
|
|
</div>
|
|
<div class="release-meta">
|
|
<div class="release-type" x-text="$store.library.currentRelease.release_type"></div>
|
|
<div class="release-title" x-text="$store.library.currentRelease.title"></div>
|
|
<div class="release-artists">
|
|
<template x-for="(artist, artistIdx) in $store.library.currentRelease.artists" :key="artist.id">
|
|
<span>
|
|
<template x-if="artistIdx > 0"><span>, </span></template>
|
|
<a class="artist-link" @click="$store.library.openArtist(artist.id)" x-text="artist.name"></a>
|
|
</span>
|
|
</template>
|
|
</div>
|
|
<div class="release-year" x-text="$store.library.currentRelease.year || ''"></div>
|
|
<div class="release-actions">
|
|
<button class="release-action-btn primary" @click="$store.queue.playRelease($store.library.currentRelease.tracks, 0)">
|
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
|
Play
|
|
</button>
|
|
<button class="like-btn like-btn-lg" style="margin-left:4px"
|
|
:class="{ liked: $store.likes.isReleaseLiked($store.library.currentRelease) }"
|
|
@click.stop="$store.likes.toggleRelease($store.library.currentRelease.id)"
|
|
title="Like">
|
|
<svg viewBox="0 0 24 24" :fill="$store.likes.isReleaseLiked($store.library.currentRelease) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
|
</button>
|
|
<button class="release-action-btn secondary" @click="$store.queue.addToEnd($store.library.currentRelease.tracks)" title="Add to end of queue">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
Queue
|
|
</button>
|
|
<button class="release-action-btn secondary" @click="$store.queue.addNextInQueue($store.library.currentRelease.tracks)" title="Play next">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h8M5 18h14"/><path d="M17 10l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
|
Next
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Track list -->
|
|
<div class="track-list-header">
|
|
<span>#</span>
|
|
<span>Title</span>
|
|
<span></span>
|
|
<span></span>
|
|
<span style="text-align:right">Duration</span>
|
|
</div>
|
|
<template x-for="(track, idx) in $store.library.currentRelease.tracks" :key="track.id">
|
|
<div class="track-row"
|
|
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
|
|
@dblclick="$store.queue.playRelease($store.library.currentRelease.tracks, idx)">
|
|
<span class="track-num" x-text="track.track_number || (idx + 1)"></span>
|
|
<div class="track-info">
|
|
<div class="track-title" x-text="track.title"></div>
|
|
<div class="track-artists-inline">
|
|
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
|
<span>
|
|
<template x-if="artistIdx > 0"><span>, </span></template>
|
|
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
|
|
</span>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
<span></span>
|
|
<div class="track-actions">
|
|
<button class="track-action-btn play-btn" @click.stop="$store.queue.playRelease($store.library.currentRelease.tracks, idx)" title="Play">
|
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
|
</button>
|
|
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="Like">
|
|
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
|
</button>
|
|
<button class="track-action-btn" @click.stop="$store.queue.addNextInQueue([track])" title="Play next">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h8M5 18h14"/><path d="M17 10l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
|
</button>
|
|
<button class="track-action-btn" @click.stop="$store.queue.addToEnd([track])" title="Add to queue">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
</button>
|
|
<button class="track-action-btn" @click.stop="$store.playlists.showPicker([track.id])" title="Add to playlist">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
|
</button>
|
|
</div>
|
|
<span class="track-duration" x-text="formatTime(track.duration_seconds)"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Playlist Detail -->
|
|
<template x-if="$store.library.view === 'playlist_detail' && $store.library.currentPlaylist">
|
|
<div>
|
|
<div class="breadcrumb">
|
|
<a @click="$store.library.goArtists()">Library</a>
|
|
<span>/</span>
|
|
<span x-text="$store.library.currentPlaylist.title"></span>
|
|
</div>
|
|
<h1 class="section-title" x-text="$store.library.currentPlaylist.title"></h1>
|
|
<template x-if="$store.library.currentPlaylist.description">
|
|
<p style="color:var(--text-subdued);margin-bottom:16px" x-text="$store.library.currentPlaylist.description"></p>
|
|
</template>
|
|
<div class="track-list-header">
|
|
<span>#</span>
|
|
<span>Title</span>
|
|
<span></span>
|
|
<span></span>
|
|
<span style="text-align:right">Duration</span>
|
|
</div>
|
|
<template x-for="(track, idx) in $store.library.currentPlaylist.tracks" :key="track.id">
|
|
<div class="track-row"
|
|
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
|
|
@dblclick="$store.queue.playRelease($store.library.currentPlaylist.tracks, idx)">
|
|
<span class="track-num" x-text="idx + 1"></span>
|
|
<div class="track-info">
|
|
<div class="track-title" x-text="track.title"></div>
|
|
<div class="track-artists-inline">
|
|
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
|
<span>
|
|
<template x-if="artistIdx > 0"><span>, </span></template>
|
|
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
|
|
</span>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
<span></span>
|
|
<div class="track-actions">
|
|
<button class="track-action-btn play-btn" @click.stop="$store.queue.playRelease($store.library.currentPlaylist.tracks, idx)" title="Play">
|
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
|
</button>
|
|
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="Like">
|
|
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
|
</button>
|
|
<button class="track-action-btn" @click.stop="$store.queue.addNextInQueue([track])" title="Play next">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h8M5 18h14"/><path d="M17 10l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
|
</button>
|
|
<button class="track-action-btn" @click.stop="$store.queue.addToEnd([track])" title="Add to queue">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
</button>
|
|
<button class="track-action-btn" @click.stop="$store.playlists.showPicker([track.id])" title="Add to playlist">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
|
</button>
|
|
</div>
|
|
<span class="track-duration" x-text="formatTime(track.duration_seconds)"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Queue Panel -->
|
|
<div class="queue-panel" :class="{ hidden: !$store.queue.visible }">
|
|
<div class="queue-header">
|
|
<h3>Queue</h3>
|
|
<button class="queue-clear-btn" @click="$store.queue.clear()">Clear</button>
|
|
</div>
|
|
<div class="queue-tracks">
|
|
<template x-if="$store.queue.tracks.length === 0">
|
|
<div class="empty-state">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
|
<p>Queue is empty</p>
|
|
</div>
|
|
</template>
|
|
<template x-for="(track, idx) in $store.queue.tracks" :key="idx + '-' + track.id">
|
|
<div class="queue-track"
|
|
:class="{ active: idx === $store.queue.currentIndex, dragging: $store.queue._dragIdx === idx }"
|
|
@click="$store.queue.playFromIndex(idx)"
|
|
draggable="true"
|
|
@dragstart="$store.queue._dragIdx = idx; $event.dataTransfer.effectAllowed = 'move'"
|
|
@dragend="$store.queue._dragIdx = null; document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'))"
|
|
@dragover.prevent="$event.dataTransfer.dropEffect = 'move'; $event.currentTarget.classList.add('drag-over')"
|
|
@dragleave="$event.currentTarget.classList.remove('drag-over')"
|
|
@drop.prevent="$event.currentTarget.classList.remove('drag-over'); if ($store.queue._dragIdx !== null) { $store.queue.moveTrack($store.queue._dragIdx, idx); $store.queue._dragIdx = null; }">
|
|
<div class="queue-drag-handle" @mousedown.stop>
|
|
<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
|
|
</div>
|
|
<div class="queue-track-cover">
|
|
<template x-if="track.cover_url">
|
|
<img :src="track.cover_url" :alt="track.title" loading="lazy">
|
|
</template>
|
|
<template x-if="!track.cover_url">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
|
</template>
|
|
</div>
|
|
<div class="queue-track-info">
|
|
<div class="queue-track-title" x-text="track.title"></div>
|
|
<div class="queue-track-artist">
|
|
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
|
<span>
|
|
<template x-if="artistIdx > 0"><span>, </span></template>
|
|
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
|
|
</span>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
<div class="queue-track-actions">
|
|
<button class="queue-track-remove" @click.stop="$store.queue.remove(idx)" title="Remove">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Player Bar -->
|
|
<div class="player-bar">
|
|
<div class="player-now-playing">
|
|
<template x-if="$store.player.currentTrack">
|
|
<div style="display:flex;align-items:center;gap:12px;overflow:hidden">
|
|
<div class="player-cover">
|
|
<template x-if="$store.player.currentTrack.cover_url">
|
|
<img :src="$store.player.currentTrack.cover_url" :alt="$store.player.currentTrack.title">
|
|
</template>
|
|
<template x-if="!$store.player.currentTrack.cover_url">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
|
</template>
|
|
</div>
|
|
<div class="player-track-info">
|
|
<div class="player-track-title" x-text="$store.player.currentTrack.title"></div>
|
|
<div class="player-track-artist">
|
|
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks($store.player.currentTrack)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
|
<span>
|
|
<template x-if="artistIdx > 0"><span>, </span></template>
|
|
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
|
|
</span>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<div class="player-controls">
|
|
<div class="player-buttons">
|
|
<button class="player-btn" :class="{ active: $store.player.shuffle }" @click="$store.player.toggleShuffle()" title="Shuffle">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>
|
|
</button>
|
|
<button class="player-btn" @click="$store.player.prev()" title="Previous">
|
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
|
</button>
|
|
<button class="player-btn player-btn-play" @click="$store.player.toggle()">
|
|
<template x-if="!$store.player.isPlaying">
|
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
|
</template>
|
|
<template x-if="$store.player.isPlaying">
|
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 4h4v16H6zM14 4h4v16h-4z"/></svg>
|
|
</template>
|
|
</button>
|
|
<button class="player-btn" @click="$store.player.next()" title="Next">
|
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
|
</button>
|
|
<button class="player-btn" :class="{ active: $store.player.repeatMode !== 'off' }" @click="$store.player.cycleRepeat()" title="Repeat">
|
|
<template x-if="$store.player.repeatMode !== 'one'">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 014-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 01-4 4H3"/></svg>
|
|
</template>
|
|
<template x-if="$store.player.repeatMode === 'one'">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 014-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 01-4 4H3"/><text x="12" y="14" font-size="8" fill="currentColor" text-anchor="middle" font-weight="bold">1</text></svg>
|
|
</template>
|
|
</button>
|
|
</div>
|
|
<div class="player-timeline">
|
|
<span class="player-time" x-text="formatTime($store.player.currentTime)"></span>
|
|
<div class="progress-bar" @click="$store.player.seekFromClick($event)">
|
|
<div class="progress-bar-fill" :style="'width:' + $store.player.progress + '%'">
|
|
<div class="progress-bar-thumb"></div>
|
|
</div>
|
|
</div>
|
|
<span class="player-time" x-text="formatTime($store.player.duration)"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="player-right">
|
|
<div class="volume-control">
|
|
<button class="volume-btn" @click="$store.player.toggleMute()">
|
|
<template x-if="$store.player.volume === 0">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>
|
|
</template>
|
|
<template x-if="$store.player.volume > 0 && $store.player.volume < 0.5">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 010 7.07"/></svg>
|
|
</template>
|
|
<template x-if="$store.player.volume >= 0.5">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 010 14.14M15.54 8.46a5 5 0 010 7.07"/></svg>
|
|
</template>
|
|
</button>
|
|
<div class="volume-slider" @click="$store.player.setVolumeFromClick($event)">
|
|
<div class="volume-slider-fill" :style="'width:' + ($store.player.volume * 100) + '%'"></div>
|
|
</div>
|
|
</div>
|
|
<button class="queue-toggle-btn" :class="{ active: $store.queue.visible }" @click="$store.queue.visible = !$store.queue.visible" title="Queue">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Create / Rename Playlist Modal -->
|
|
<template x-if="$store.playlists.modal">
|
|
<div class="modal-overlay" @click.self="$store.playlists.modal = null">
|
|
<div class="modal-box">
|
|
<h3 x-text="$store.playlists.modal.mode === 'create' ? 'New Playlist' : 'Rename Playlist'"></h3>
|
|
<input type="text" x-model="$store.playlists.modal.title" placeholder="Playlist name"
|
|
@keydown.enter="$store.playlists.submitModal()" x-init="$nextTick(() => $el.focus())">
|
|
<div class="modal-footer">
|
|
<button class="modal-btn modal-btn-ghost" @click="$store.playlists.modal = null">Cancel</button>
|
|
<button class="modal-btn modal-btn-primary" @click="$store.playlists.submitModal()"
|
|
x-text="$store.playlists.modal.mode === 'create' ? 'Create' : 'Save'"></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Add to Playlist Modal -->
|
|
<template x-if="$store.playlists.picker">
|
|
<div class="modal-overlay" @click.self="$store.playlists.picker = null">
|
|
<div class="modal-box">
|
|
<h3>Add to Playlist</h3>
|
|
<div class="modal-playlist-list">
|
|
<template x-for="pl in $store.playlists.list.filter(p => p.kind === 'user' && p.is_own)" :key="pl.id">
|
|
<div class="modal-playlist-item" @click="$store.playlists.addToPicked(pl.id)">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
|
<span x-text="pl.title"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="modal-btn modal-btn-ghost" @click="$store.playlists.picker = null">Cancel</button>
|
|
<button class="modal-btn modal-btn-primary" @click="$store.playlists.picker = null; $store.playlists.showCreate()">New Playlist</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Torrent Import Modal -->
|
|
<template x-if="$store.torrents.modal">
|
|
<div class="modal-overlay" @click.self="$store.torrents.close()">
|
|
<div class="modal-box torrent-modal">
|
|
<h3>Import torrent</h3>
|
|
<div class="torrent-modal-grid">
|
|
<div>
|
|
<label for="torrent-file-input">Torrent file</label>
|
|
<input id="torrent-file-input" type="file" accept=".torrent,application/x-bittorrent"
|
|
@change="$store.torrents.file = $event.target.files[0] || null">
|
|
</div>
|
|
<div>
|
|
<label for="torrent-magnet-input">Magnet link</label>
|
|
<textarea id="torrent-magnet-input" x-model="$store.torrents.magnet" placeholder="magnet:?xt=urn:btih:..."></textarea>
|
|
</div>
|
|
</div>
|
|
<p class="torrent-message" :class="{ error: $store.torrents.error }"
|
|
x-text="$store.torrents.message"></p>
|
|
<div class="torrent-actions">
|
|
<button class="modal-btn modal-btn-primary" @click="$store.torrents.preview()" :disabled="$store.torrents.loading">
|
|
Preview content
|
|
</button>
|
|
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.selectAudio()" :disabled="!$store.torrents.previewData">Audio only</button>
|
|
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.selectAll(true)" :disabled="!$store.torrents.previewData">All</button>
|
|
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.selectAll(false)" :disabled="!$store.torrents.previewData">Clear</button>
|
|
</div>
|
|
<template x-if="$store.torrents.previewData">
|
|
<div class="torrent-preview-panel">
|
|
<div class="torrent-preview-head">
|
|
<div style="min-width:0">
|
|
<div class="torrent-preview-title" x-text="$store.torrents.previewData.name"></div>
|
|
<div class="torrent-preview-meta"
|
|
x-text="$store.torrents.previewData.files.length + ' files · ' + $store.torrents.bytes($store.torrents.previewData.total_size)"></div>
|
|
</div>
|
|
<button class="modal-btn modal-btn-primary" @click="$store.torrents.start()" :disabled="$store.torrents.loading">
|
|
Download selected
|
|
</button>
|
|
</div>
|
|
<div class="torrent-tree-toolbar">
|
|
<div class="torrent-selected-summary"
|
|
x-text="$store.torrents.selected.size + ' selected · ' + $store.torrents.bytes($store.torrents.selectedBytes())"></div>
|
|
<div class="torrent-actions" style="margin-top:0">
|
|
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.expandAll(true)">Expand all</button>
|
|
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.expandAll(false)">Collapse</button>
|
|
</div>
|
|
</div>
|
|
<div class="torrent-file-tree">
|
|
<template x-for="node in $store.torrents.visibleNodes()" :key="node.key">
|
|
<div class="torrent-tree-row" :style="'--indent:' + $store.torrents.rowIndent(node) + 'px'">
|
|
<button class="torrent-tree-toggle"
|
|
:class="{ expanded: $store.torrents.expanded.has(node.key) }"
|
|
@click="$store.torrents.toggleExpand(node)"
|
|
:style="node.type === 'folder' ? '' : 'visibility:hidden'">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polyline points="9 18 15 12 9 6"/>
|
|
</svg>
|
|
</button>
|
|
<button class="torrent-tree-check"
|
|
:class="$store.torrents.nodeCheckClass(node)"
|
|
@click="$store.torrents.toggleNode(node)">
|
|
<template x-if="$store.torrents.nodeState(node) === 'checked'">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
|
<polyline points="20 6 9 17 4 12"/>
|
|
</svg>
|
|
</template>
|
|
<template x-if="$store.torrents.nodeState(node) === 'partial'">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
|
<line x1="5" y1="12" x2="19" y2="12"/>
|
|
</svg>
|
|
</template>
|
|
</button>
|
|
<div class="torrent-tree-label" :title="node.name">
|
|
<template x-if="node.type === 'folder'">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M3 7a2 2 0 012-2h5l2 2h7a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>
|
|
</svg>
|
|
</template>
|
|
<template x-if="node.type === 'file'">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
|
|
<polyline points="14 2 14 8 20 8"/>
|
|
</svg>
|
|
</template>
|
|
<span class="torrent-file-name" x-text="node.name"></span>
|
|
</div>
|
|
<span class="torrent-file-size" x-text="$store.torrents.bytes(node.size)"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Play History Modal -->
|
|
<template x-if="$store.history.modal">
|
|
<div class="modal-overlay" @click.self="$store.history.close()">
|
|
<div class="modal-box history-modal">
|
|
<h3>Play history</h3>
|
|
<p class="torrent-message" :class="{ error: $store.history.error }"
|
|
x-text="$store.history.message"></p>
|
|
<div class="history-list">
|
|
<template x-if="!$store.history.loading && $store.history.items.length === 0">
|
|
<div class="empty-state" style="padding:32px 16px">
|
|
<p>No plays yet</p>
|
|
</div>
|
|
</template>
|
|
<template x-for="item in $store.history.items" :key="item.id">
|
|
<div class="history-row">
|
|
<div style="min-width:0">
|
|
<div class="history-title" x-text="item.track_title"></div>
|
|
<div class="history-release" x-text="item.release_title || 'Unknown release'"></div>
|
|
</div>
|
|
<div>
|
|
<div class="history-date" x-text="$store.history.date(item.played_at)"></div>
|
|
<div class="history-duration" x-text="$store.history.duration(item.duration_listened)"></div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div class="history-pager">
|
|
<button class="modal-btn modal-btn-ghost"
|
|
@click="$store.history.load($store.history.page - 1)"
|
|
:disabled="$store.history.loading || $store.history.page <= 1">
|
|
Previous
|
|
</button>
|
|
<span class="history-release"
|
|
x-text="'Page ' + $store.history.page + ' of ' + $store.history.totalPages()"></span>
|
|
<button class="modal-btn modal-btn-primary"
|
|
@click="$store.history.load($store.history.page + 1)"
|
|
:disabled="$store.history.loading || $store.history.page >= $store.history.totalPages()">
|
|
Next
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
|
|
<script>
|
|
function formatTime(seconds) {
|
|
if (!seconds || isNaN(seconds)) return '0:00';
|
|
const s = Math.floor(seconds);
|
|
const m = Math.floor(s / 60);
|
|
const sec = s % 60;
|
|
return m + ':' + (sec < 10 ? '0' : '') + sec;
|
|
}
|
|
|
|
document.addEventListener('alpine:init', () => {
|
|
// -----------------------------------------------------------------------
|
|
// Audio element
|
|
// -----------------------------------------------------------------------
|
|
const audio = new Audio();
|
|
audio.preload = 'auto';
|
|
|
|
// -----------------------------------------------------------------------
|
|
// User store
|
|
// -----------------------------------------------------------------------
|
|
Alpine.store('user', {
|
|
profile: null,
|
|
menuOpen: false,
|
|
|
|
init() {
|
|
this.load();
|
|
},
|
|
|
|
async load() {
|
|
try {
|
|
const res = await fetch('/api/player/me');
|
|
if (!res.ok) throw new Error('failed');
|
|
this.profile = await res.json();
|
|
} catch {
|
|
this.profile = null;
|
|
}
|
|
},
|
|
|
|
initials() {
|
|
const name = this.profile?.name || '';
|
|
return name.trim().charAt(0) || '?';
|
|
},
|
|
|
|
format(value) {
|
|
return new Intl.NumberFormat().format(value || 0);
|
|
},
|
|
|
|
duration(minutes) {
|
|
let value = Number(minutes || 0);
|
|
const units = [
|
|
['y', 525600],
|
|
['mo', 43800],
|
|
['d', 1440],
|
|
['h', 60],
|
|
['m', 1],
|
|
];
|
|
const parts = [];
|
|
for (const [label, size] of units) {
|
|
if (value >= size) {
|
|
const count = Math.floor(value / size);
|
|
value -= count * size;
|
|
parts.push(count + label);
|
|
}
|
|
if (parts.length >= 2) break;
|
|
}
|
|
return parts.length ? parts.join(' ') : '0m';
|
|
},
|
|
|
|
logout() {
|
|
window.location.href = '/logout';
|
|
},
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Play history store
|
|
// -----------------------------------------------------------------------
|
|
Alpine.store('history', {
|
|
modal: false,
|
|
items: [],
|
|
page: 1,
|
|
perPage: 20,
|
|
total: 0,
|
|
loading: false,
|
|
message: '',
|
|
error: false,
|
|
|
|
open() {
|
|
this.modal = true;
|
|
this.load(1);
|
|
},
|
|
|
|
close() {
|
|
this.modal = false;
|
|
},
|
|
|
|
totalPages() {
|
|
return Math.max(1, Math.ceil(this.total / this.perPage));
|
|
},
|
|
|
|
async load(page) {
|
|
page = Math.max(1, page || 1);
|
|
this.loading = true;
|
|
this.error = false;
|
|
this.message = 'Loading history...';
|
|
try {
|
|
const res = await fetch(`/api/player/history?page=${page}&limit=${this.perPage}`);
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || 'Failed to load history');
|
|
this.items = data.items || [];
|
|
this.page = data.page || page;
|
|
this.perPage = data.per_page || this.perPage;
|
|
this.total = data.total || 0;
|
|
this.message = this.total ? (this.total + ' total plays') : '';
|
|
} catch (err) {
|
|
this.error = true;
|
|
this.message = err.message || String(err);
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
date(value) {
|
|
if (!value) return '';
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return value;
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
}).format(date);
|
|
},
|
|
|
|
duration(seconds) {
|
|
if (!seconds) return '0:00';
|
|
return formatTime(Number(seconds));
|
|
},
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Player store
|
|
// -----------------------------------------------------------------------
|
|
Alpine.store('player', {
|
|
currentTrack: null,
|
|
isPlaying: false,
|
|
currentTime: 0,
|
|
duration: 0,
|
|
volume: 0.7,
|
|
_prevVolume: 0.7,
|
|
shuffle: false,
|
|
repeatMode: 'off', // off, all, one
|
|
progress: 0,
|
|
_saveTimer: null,
|
|
_historyRecorded: false,
|
|
|
|
init() {
|
|
audio.volume = this.volume;
|
|
|
|
audio.addEventListener('timeupdate', () => {
|
|
this.currentTime = audio.currentTime;
|
|
this.duration = audio.duration || 0;
|
|
this.progress = this.duration > 0 ? (this.currentTime / this.duration) * 100 : 0;
|
|
});
|
|
|
|
audio.addEventListener('ended', () => {
|
|
this._recordHistory(true);
|
|
this.next();
|
|
});
|
|
|
|
audio.addEventListener('play', () => { this.isPlaying = true; });
|
|
audio.addEventListener('pause', () => { this.isPlaying = false; });
|
|
|
|
audio.addEventListener('loadedmetadata', () => {
|
|
this.duration = audio.duration || 0;
|
|
});
|
|
|
|
// Periodic state save
|
|
this._saveTimer = setInterval(() => {
|
|
this._saveState();
|
|
}, 10000);
|
|
|
|
// Restore state
|
|
this._restoreState();
|
|
|
|
// Save state on page unload
|
|
window.addEventListener('beforeunload', () => {
|
|
this._saveStateSync();
|
|
});
|
|
},
|
|
|
|
play(track) {
|
|
this.currentTrack = track;
|
|
this._historyRecorded = false;
|
|
audio.src = track.stream_url;
|
|
audio.play().catch(() => {});
|
|
this._updateMediaSession();
|
|
},
|
|
|
|
pause() { audio.pause(); },
|
|
resume() { audio.play().catch(() => {}); },
|
|
|
|
toggle() {
|
|
if (!this.currentTrack) return;
|
|
if (this.isPlaying) { this.pause(); }
|
|
else { this.resume(); }
|
|
},
|
|
|
|
seek(time) {
|
|
audio.currentTime = time;
|
|
},
|
|
|
|
seekRelative(delta) {
|
|
if (!this.currentTrack) return;
|
|
audio.currentTime = Math.max(0, Math.min(audio.duration || 0, audio.currentTime + delta));
|
|
},
|
|
|
|
seekFromClick(event) {
|
|
const bar = event.currentTarget;
|
|
const rect = bar.getBoundingClientRect();
|
|
const pct = (event.clientX - rect.left) / rect.width;
|
|
if (this.duration > 0) {
|
|
this.seek(pct * this.duration);
|
|
}
|
|
},
|
|
|
|
next() {
|
|
const queue = Alpine.store('queue');
|
|
if (queue.tracks.length === 0) return;
|
|
|
|
let nextIdx;
|
|
if (this.repeatMode === 'one') {
|
|
this.seek(0);
|
|
this.resume();
|
|
return;
|
|
} else if (this.shuffle) {
|
|
nextIdx = Math.floor(Math.random() * queue.tracks.length);
|
|
} else {
|
|
nextIdx = queue.currentIndex + 1;
|
|
if (nextIdx >= queue.tracks.length) {
|
|
if (this.repeatMode === 'all') {
|
|
nextIdx = 0;
|
|
} else {
|
|
this.isPlaying = false;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
queue.playFromIndex(nextIdx);
|
|
},
|
|
|
|
prev() {
|
|
if (this.currentTime > 3) {
|
|
this.seek(0);
|
|
return;
|
|
}
|
|
const queue = Alpine.store('queue');
|
|
if (queue.tracks.length === 0) return;
|
|
|
|
let prevIdx = queue.currentIndex - 1;
|
|
if (prevIdx < 0) {
|
|
if (this.repeatMode === 'all') {
|
|
prevIdx = queue.tracks.length - 1;
|
|
} else {
|
|
this.seek(0);
|
|
return;
|
|
}
|
|
}
|
|
queue.playFromIndex(prevIdx);
|
|
},
|
|
|
|
setVolume(v) {
|
|
this.volume = Math.max(0, Math.min(1, v));
|
|
audio.volume = this.volume;
|
|
},
|
|
|
|
setVolumeFromClick(event) {
|
|
const bar = event.currentTarget;
|
|
const rect = bar.getBoundingClientRect();
|
|
const pct = (event.clientX - rect.left) / rect.width;
|
|
this.setVolume(pct);
|
|
},
|
|
|
|
toggleMute() {
|
|
if (this.volume > 0) {
|
|
this._prevVolume = this.volume;
|
|
this.setVolume(0);
|
|
} else {
|
|
this.setVolume(this._prevVolume || 0.7);
|
|
}
|
|
},
|
|
|
|
toggleShuffle() {
|
|
this.shuffle = !this.shuffle;
|
|
},
|
|
|
|
cycleRepeat() {
|
|
if (this.repeatMode === 'off') this.repeatMode = 'all';
|
|
else if (this.repeatMode === 'all') this.repeatMode = 'one';
|
|
else this.repeatMode = 'off';
|
|
},
|
|
|
|
_updateMediaSession() {
|
|
if (!('mediaSession' in navigator) || !this.currentTrack) return;
|
|
const t = this.currentTrack;
|
|
navigator.mediaSession.metadata = new MediaMetadata({
|
|
title: t.title,
|
|
artist: t.artists.map(a => a.name).join(', '),
|
|
artwork: t.cover_url ? [{ src: t.cover_url, sizes: '512x512', type: 'image/jpeg' }] : [],
|
|
});
|
|
navigator.mediaSession.setActionHandler('play', () => this.resume());
|
|
navigator.mediaSession.setActionHandler('pause', () => this.pause());
|
|
navigator.mediaSession.setActionHandler('previoustrack', () => this.prev());
|
|
navigator.mediaSession.setActionHandler('nexttrack', () => this.next());
|
|
navigator.mediaSession.setActionHandler('seekto', (d) => { if (d.seekTime != null) this.seek(d.seekTime); });
|
|
},
|
|
|
|
_buildStatePayload() {
|
|
const queue = Alpine.store('queue');
|
|
return {
|
|
current_track_id: this.currentTrack ? this.currentTrack.id : null,
|
|
position_ms: Math.floor(this.currentTime * 1000),
|
|
queue: queue.tracks.map(t => t.id),
|
|
queue_position: queue.currentIndex,
|
|
shuffle: this.shuffle,
|
|
repeat_mode: this.repeatMode,
|
|
volume: this.volume,
|
|
};
|
|
},
|
|
|
|
_saveState() {
|
|
const queue = Alpine.store('queue');
|
|
if (!this.currentTrack && queue.tracks.length === 0) return;
|
|
const state = this._buildStatePayload();
|
|
fetch('/api/player/state', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(state),
|
|
}).catch(() => {});
|
|
},
|
|
|
|
_saveStateSync() {
|
|
const queue = Alpine.store('queue');
|
|
if (!this.currentTrack && queue.tracks.length === 0) return;
|
|
const state = this._buildStatePayload();
|
|
const blob = new Blob([JSON.stringify(state)], { type: 'application/json' });
|
|
navigator.sendBeacon('/api/player/state', blob);
|
|
},
|
|
|
|
async _restoreState() {
|
|
try {
|
|
const res = await fetch('/api/player/state');
|
|
if (!res.ok) return;
|
|
const state = await res.json();
|
|
this.shuffle = state.shuffle || false;
|
|
this.repeatMode = state.repeat_mode || 'off';
|
|
this.setVolume(typeof state.volume === 'number' ? state.volume : 0.7);
|
|
|
|
// Restore queue if there are track IDs
|
|
if (state.queue && state.queue.length > 0) {
|
|
try {
|
|
const tracksRes = await fetch('/api/player/tracks-by-ids', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ids: state.queue }),
|
|
});
|
|
if (tracksRes.ok) {
|
|
const tracks = await tracksRes.json();
|
|
if (tracks.length > 0) {
|
|
const queue = Alpine.store('queue');
|
|
queue.tracks = tracks;
|
|
const idx = Math.max(0, Math.min(state.queue_position, tracks.length - 1));
|
|
queue.currentIndex = idx;
|
|
|
|
// Restore current track
|
|
const currentTrack = state.current_track_id
|
|
? tracks.find(t => t.id === state.current_track_id)
|
|
: tracks[idx];
|
|
if (currentTrack) {
|
|
this.currentTrack = currentTrack;
|
|
this._historyRecorded = false;
|
|
audio.src = currentTrack.stream_url;
|
|
// Seek to saved position once metadata is loaded
|
|
const seekMs = state.position_ms || 0;
|
|
if (seekMs > 0) {
|
|
const onLoaded = () => {
|
|
audio.currentTime = seekMs / 1000;
|
|
audio.removeEventListener('loadedmetadata', onLoaded);
|
|
};
|
|
audio.addEventListener('loadedmetadata', onLoaded);
|
|
}
|
|
this._updateMediaSession();
|
|
}
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
} catch {}
|
|
},
|
|
|
|
_recordHistory(completed) {
|
|
if (this._historyRecorded || !this.currentTrack) return;
|
|
this._historyRecorded = true;
|
|
fetch('/api/player/history', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
track_id: this.currentTrack.id,
|
|
duration_listened: Math.floor(this.currentTime),
|
|
completed: completed,
|
|
}),
|
|
}).catch(() => {});
|
|
},
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Queue store
|
|
// -----------------------------------------------------------------------
|
|
Alpine.store('queue', {
|
|
tracks: [],
|
|
currentIndex: 0,
|
|
visible: false,
|
|
_dragIdx: null,
|
|
|
|
add(track) {
|
|
this.tracks.push(track);
|
|
},
|
|
|
|
addToEnd(tracks) {
|
|
this.tracks = [...this.tracks, ...tracks];
|
|
},
|
|
|
|
addNextInQueue(tracks) {
|
|
const insertAt = this.currentIndex + 1;
|
|
this.tracks.splice(insertAt, 0, ...tracks);
|
|
},
|
|
|
|
playRelease(tracks, startIndex) {
|
|
this.tracks = [...tracks];
|
|
this.playFromIndex(startIndex || 0);
|
|
},
|
|
|
|
playFromIndex(idx) {
|
|
if (idx < 0 || idx >= this.tracks.length) return;
|
|
this.currentIndex = idx;
|
|
Alpine.store('player').play(this.tracks[idx]);
|
|
},
|
|
|
|
remove(idx) {
|
|
if (idx < 0 || idx >= this.tracks.length) return;
|
|
this.tracks.splice(idx, 1);
|
|
if (this.tracks.length === 0) {
|
|
this.currentIndex = 0;
|
|
} else if (idx < this.currentIndex) {
|
|
this.currentIndex--;
|
|
} else if (idx === this.currentIndex) {
|
|
if (this.currentIndex >= this.tracks.length) {
|
|
this.currentIndex = this.tracks.length - 1;
|
|
}
|
|
}
|
|
},
|
|
|
|
moveTrack(fromIdx, toIdx) {
|
|
if (fromIdx === toIdx) return;
|
|
if (fromIdx < 0 || fromIdx >= this.tracks.length) return;
|
|
if (toIdx < 0 || toIdx >= this.tracks.length) return;
|
|
const [track] = this.tracks.splice(fromIdx, 1);
|
|
this.tracks.splice(toIdx, 0, track);
|
|
// Adjust currentIndex to follow the currently playing track
|
|
if (this.currentIndex === fromIdx) {
|
|
this.currentIndex = toIdx;
|
|
} else if (fromIdx < this.currentIndex && toIdx >= this.currentIndex) {
|
|
this.currentIndex--;
|
|
} else if (fromIdx > this.currentIndex && toIdx <= this.currentIndex) {
|
|
this.currentIndex++;
|
|
}
|
|
},
|
|
|
|
clear() {
|
|
this.tracks = [];
|
|
this.currentIndex = 0;
|
|
},
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Library store
|
|
// -----------------------------------------------------------------------
|
|
Alpine.store('library', {
|
|
view: 'artists',
|
|
artists: [],
|
|
artistsPage: 0,
|
|
artistsTotal: 0,
|
|
loading: false,
|
|
_allLoaded: false,
|
|
currentArtist: null,
|
|
currentRelease: null,
|
|
currentPlaylist: null,
|
|
_observer: null,
|
|
searchQuery: '',
|
|
searchResults: null,
|
|
searchLoading: false,
|
|
_previousView: 'artists',
|
|
|
|
_hashNav: false, // guard against circular hash updates
|
|
|
|
init() {
|
|
this.loadArtists(1);
|
|
this._setupScroll();
|
|
|
|
// Listen for browser back/forward
|
|
window.addEventListener('hashchange', () => {
|
|
this._navigateFromHash();
|
|
});
|
|
|
|
// Navigate to initial hash (if any)
|
|
this._navigateFromHash();
|
|
},
|
|
|
|
_setHash(hash) {
|
|
this._hashNav = true;
|
|
location.hash = hash;
|
|
// Reset guard after a tick
|
|
setTimeout(() => { this._hashNav = false; }, 0);
|
|
},
|
|
|
|
_navigateFromHash() {
|
|
if (this._hashNav) return;
|
|
const hash = location.hash || '#artists';
|
|
const match = hash.match(/^#(\w+)(?:\/([-]?\d+))?(?:\?(.*))?$/);
|
|
if (!match) {
|
|
this.goArtists();
|
|
return;
|
|
}
|
|
const view = match[1];
|
|
const id = match[2] ? parseInt(match[2], 10) : null;
|
|
const params = match[3] || '';
|
|
|
|
if (view === 'artists' && !id) {
|
|
if (this.view !== 'artists') this.goArtists();
|
|
} else if (view === 'artist' && id) {
|
|
this.openArtist(id);
|
|
} else if (view === 'release' && id) {
|
|
this.openRelease(id);
|
|
} else if (view === 'playlist' && id) {
|
|
this.openPlaylist(id);
|
|
} else if (view === 'search') {
|
|
const qMatch = params.match(/q=([^&]*)/);
|
|
if (qMatch) {
|
|
const q = decodeURIComponent(qMatch[1]);
|
|
this.searchQuery = q;
|
|
this.search(q);
|
|
}
|
|
} else {
|
|
this.goArtists();
|
|
}
|
|
},
|
|
|
|
goArtists() {
|
|
this.view = 'artists';
|
|
this.currentArtist = null;
|
|
this.currentRelease = null;
|
|
this.currentPlaylist = null;
|
|
this.searchQuery = '';
|
|
this.searchResults = null;
|
|
this._previousView = 'artists';
|
|
this._setHash('#artists');
|
|
this.$nextTick(() => { this._setupScroll(); });
|
|
},
|
|
|
|
async loadArtists(page) {
|
|
if (this.loading || this._allLoaded) return;
|
|
this.loading = true;
|
|
try {
|
|
const res = await fetch(`/api/player/artists?page=${page}&limit=60`);
|
|
if (!res.ok) throw new Error('failed');
|
|
const data = await res.json();
|
|
if (page === 1) {
|
|
this.artists = data.items;
|
|
} else {
|
|
this.artists = [...this.artists, ...data.items];
|
|
}
|
|
this.artistsPage = data.page;
|
|
this.artistsTotal = data.total;
|
|
if (this.artists.length >= data.total) {
|
|
this._allLoaded = true;
|
|
}
|
|
} catch {}
|
|
this.loading = false;
|
|
},
|
|
|
|
async openArtist(id) {
|
|
this.searchQuery = '';
|
|
this.searchResults = null;
|
|
this.view = 'artist_detail';
|
|
this.currentArtist = null;
|
|
this._setHash('#artist/' + id);
|
|
try {
|
|
const res = await fetch(`/api/player/artists/${id}`);
|
|
if (!res.ok) throw new Error('failed');
|
|
this.currentArtist = await res.json();
|
|
} catch {}
|
|
},
|
|
|
|
artistReleaseGroups() {
|
|
const releases = this.currentArtist?.releases || [];
|
|
const order = ['album', 'ep', 'single', 'compilation', 'mixtape', 'live', 'soundtrack'];
|
|
const labels = {
|
|
album: 'Albums',
|
|
ep: 'EPs',
|
|
single: 'Singles',
|
|
compilation: 'Compilations',
|
|
mixtape: 'Mixtapes',
|
|
live: 'Live releases',
|
|
soundtrack: 'Soundtracks',
|
|
};
|
|
const groups = new Map();
|
|
for (const release of releases) {
|
|
const type = (release.release_type || 'other').toLowerCase();
|
|
if (!groups.has(type)) {
|
|
groups.set(type, []);
|
|
}
|
|
groups.get(type).push(release);
|
|
}
|
|
return Array.from(groups.entries())
|
|
.sort(([a], [b]) => {
|
|
const ai = order.includes(a) ? order.indexOf(a) : order.length;
|
|
const bi = order.includes(b) ? order.indexOf(b) : order.length;
|
|
return ai === bi ? a.localeCompare(b) : ai - bi;
|
|
})
|
|
.map(([type, groupReleases]) => ({
|
|
type,
|
|
label: labels[type] || type,
|
|
releases: groupReleases,
|
|
}));
|
|
},
|
|
|
|
trackArtistLinks(track) {
|
|
const main = (track?.artists || []).map(artist => ({
|
|
id: artist.id,
|
|
label: artist.name,
|
|
}));
|
|
const featured = (track?.featured_artists || []).map(artist => ({
|
|
id: artist.id,
|
|
label: 'ft. ' + artist.name,
|
|
}));
|
|
return [...main, ...featured];
|
|
},
|
|
|
|
async openRelease(id) {
|
|
this.searchQuery = '';
|
|
this.searchResults = null;
|
|
this.view = 'release_detail';
|
|
this.currentRelease = null;
|
|
this._setHash('#release/' + id);
|
|
try {
|
|
const res = await fetch(`/api/player/releases/${id}`);
|
|
if (!res.ok) throw new Error('failed');
|
|
this.currentRelease = await res.json();
|
|
} catch {}
|
|
},
|
|
|
|
async openPlaylist(id) {
|
|
this.view = 'playlist_detail';
|
|
this.currentPlaylist = null;
|
|
this._setHash('#playlist/' + id);
|
|
try {
|
|
const res = await fetch(`/api/player/playlists/${id}`);
|
|
if (!res.ok) throw new Error('failed');
|
|
this.currentPlaylist = await res.json();
|
|
} catch {}
|
|
},
|
|
|
|
async playRelease(releaseId) {
|
|
try {
|
|
const res = await fetch(`/api/player/releases/${releaseId}`);
|
|
if (!res.ok) return;
|
|
const release = await res.json();
|
|
if (release.tracks.length > 0) {
|
|
Alpine.store('queue').playRelease(release.tracks, 0);
|
|
}
|
|
} catch {}
|
|
},
|
|
|
|
async enqueueRelease(releaseId) {
|
|
try {
|
|
const res = await fetch(`/api/player/releases/${releaseId}`);
|
|
if (!res.ok) return;
|
|
const release = await res.json();
|
|
if (release.tracks.length > 0) {
|
|
Alpine.store('queue').addToEnd(release.tracks);
|
|
}
|
|
} catch {}
|
|
},
|
|
|
|
async enqueueReleaseNext(releaseId) {
|
|
try {
|
|
const res = await fetch(`/api/player/releases/${releaseId}`);
|
|
if (!res.ok) return;
|
|
const release = await res.json();
|
|
if (release.tracks.length > 0) {
|
|
Alpine.store('queue').addNextInQueue(release.tracks);
|
|
}
|
|
} catch {}
|
|
},
|
|
|
|
async search(query) {
|
|
const q = (query || '').trim();
|
|
if (!q) {
|
|
this.clearSearch();
|
|
return;
|
|
}
|
|
if (this.view !== 'search') {
|
|
this._previousView = this.view;
|
|
}
|
|
this.view = 'search';
|
|
this._setHash('#search?q=' + encodeURIComponent(q));
|
|
this.searchLoading = true;
|
|
try {
|
|
const res = await fetch(`/api/player/search?q=${encodeURIComponent(q)}&limit=10`);
|
|
if (!res.ok) throw new Error('failed');
|
|
this.searchResults = await res.json();
|
|
} catch {
|
|
this.searchResults = { artists: [], releases: [], tracks: [] };
|
|
}
|
|
this.searchLoading = false;
|
|
},
|
|
|
|
clearSearch() {
|
|
this.searchQuery = '';
|
|
this.searchResults = null;
|
|
this.searchLoading = false;
|
|
if (this.view === 'search') {
|
|
this.view = this._previousView || 'artists';
|
|
this._setHash('#artists');
|
|
if (this.view === 'artists') {
|
|
this.$nextTick(() => { this._setupScroll(); });
|
|
}
|
|
}
|
|
},
|
|
|
|
playSearchTrack(idx) {
|
|
if (!this.searchResults || !this.searchResults.tracks) return;
|
|
Alpine.store('queue').playRelease(this.searchResults.tracks, idx);
|
|
},
|
|
|
|
_setupScroll() {
|
|
if (this._observer) this._observer.disconnect();
|
|
this.$nextTick(() => {
|
|
const sentinel = document.getElementById('artist-sentinel');
|
|
if (!sentinel) return;
|
|
this._observer = new IntersectionObserver((entries) => {
|
|
if (entries[0].isIntersecting && !this.loading && !this._allLoaded) {
|
|
this.loadArtists(this.artistsPage + 1);
|
|
}
|
|
}, { root: document.getElementById('center-scroll'), threshold: 0.1 });
|
|
this._observer.observe(sentinel);
|
|
});
|
|
},
|
|
|
|
$nextTick(fn) {
|
|
setTimeout(fn, 50);
|
|
},
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Likes store
|
|
// -----------------------------------------------------------------------
|
|
Alpine.store('likes', {
|
|
_set: new Set(),
|
|
|
|
init() {
|
|
fetch('/api/player/likes')
|
|
.then(r => r.json())
|
|
.then(d => { this._set = new Set(d.track_ids || []); })
|
|
.catch(() => {});
|
|
},
|
|
|
|
has(trackId) {
|
|
return this._set.has(trackId);
|
|
},
|
|
|
|
async toggle(trackId) {
|
|
// Optimistic update
|
|
if (this._set.has(trackId)) {
|
|
this._set.delete(trackId);
|
|
} else {
|
|
this._set.add(trackId);
|
|
}
|
|
// Force Alpine reactivity
|
|
this._set = new Set(this._set);
|
|
try {
|
|
const res = await fetch(`/api/player/likes/toggle/${trackId}`, { method: 'POST' });
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
if (data.liked) {
|
|
this._set.add(trackId);
|
|
} else {
|
|
this._set.delete(trackId);
|
|
}
|
|
this._set = new Set(this._set);
|
|
Alpine.store('playlists').reload();
|
|
}
|
|
} catch {}
|
|
},
|
|
|
|
async toggleRelease(releaseId) {
|
|
try {
|
|
const res = await fetch(`/api/player/likes/release/${releaseId}`, { method: 'POST' });
|
|
if (res.ok) {
|
|
// Reload liked IDs
|
|
const likesRes = await fetch('/api/player/likes');
|
|
if (likesRes.ok) {
|
|
const d = await likesRes.json();
|
|
this._set = new Set(d.track_ids || []);
|
|
}
|
|
Alpine.store('playlists').reload();
|
|
}
|
|
} catch {}
|
|
},
|
|
|
|
isReleaseLiked(release) {
|
|
if (!release || !release.tracks || release.tracks.length === 0) return false;
|
|
return release.tracks.every(t => this._set.has(t.id));
|
|
},
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Torrent import store
|
|
// -----------------------------------------------------------------------
|
|
Alpine.store('torrents', {
|
|
modal: false,
|
|
file: null,
|
|
magnet: '',
|
|
previewData: null,
|
|
treeRoot: null,
|
|
selected: new Set(),
|
|
expanded: new Set(),
|
|
loading: false,
|
|
message: '',
|
|
error: false,
|
|
_pollTimer: null,
|
|
|
|
open() {
|
|
this.modal = true;
|
|
this.message = '';
|
|
this.error = false;
|
|
},
|
|
|
|
close() {
|
|
this.modal = false;
|
|
},
|
|
|
|
_setMessage(message, error = false) {
|
|
this.message = message || '';
|
|
this.error = error;
|
|
},
|
|
|
|
bytes(value) {
|
|
if (!value) return '0 B';
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
let size = Number(value);
|
|
let idx = 0;
|
|
while (size >= 1024 && idx < units.length - 1) {
|
|
size /= 1024;
|
|
idx++;
|
|
}
|
|
return (idx === 0 ? size.toFixed(0) : size.toFixed(1)) + ' ' + units[idx];
|
|
},
|
|
|
|
async _fileBase64(file) {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onerror = () => reject(reader.error);
|
|
reader.onload = () => {
|
|
const result = String(reader.result || '');
|
|
resolve(result.includes(',') ? result.split(',')[1] : result);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
});
|
|
},
|
|
|
|
async preview() {
|
|
if (this.loading) return;
|
|
const magnet = this.magnet.trim();
|
|
if (!this.file && !magnet) {
|
|
this._setMessage('Choose a .torrent file or paste a magnet link.', true);
|
|
return;
|
|
}
|
|
|
|
this.loading = true;
|
|
this.previewData = null;
|
|
this.treeRoot = null;
|
|
this.selected = new Set();
|
|
this.expanded = new Set();
|
|
this._setMessage(this.file ? 'Reading torrent file...' : 'Resolving magnet metadata. This can take a while...');
|
|
|
|
try {
|
|
const payload = this.file
|
|
? { kind: 'torrent_file', torrent_base64: await this._fileBase64(this.file) }
|
|
: { kind: 'magnet', magnet };
|
|
const res = await fetch('/api/player/torrents/preview', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || 'Preview failed');
|
|
|
|
this.previewData = data;
|
|
this.selected = new Set(data.files.filter(f => f.selected).map(f => f.index));
|
|
this.treeRoot = this._buildTree(data.files || []);
|
|
this._setMessage('Choose files and start download.');
|
|
} catch (err) {
|
|
this._setMessage(err.message || String(err), true);
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
toggle(index, checked) {
|
|
const next = new Set(this.selected);
|
|
if (checked) next.add(index);
|
|
else next.delete(index);
|
|
this.selected = next;
|
|
},
|
|
|
|
selectAll(value) {
|
|
if (!this.previewData) return;
|
|
this.selected = value
|
|
? new Set(this.previewData.files.map(f => f.index))
|
|
: new Set();
|
|
},
|
|
|
|
selectAudio() {
|
|
if (!this.previewData) return;
|
|
this.selected = new Set(
|
|
this.previewData.files
|
|
.filter(f => /\.(mp3|flac|ogg|opus|aac|m4a|wav|ape|wv|wma|tta|aiff|aif)$/i.test(f.name))
|
|
.map(f => f.index)
|
|
);
|
|
},
|
|
|
|
selectedBytes() {
|
|
if (!this.previewData) return 0;
|
|
return this.previewData.files
|
|
.filter(file => this.selected.has(file.index))
|
|
.reduce((sum, file) => sum + Number(file.length || 0), 0);
|
|
},
|
|
|
|
_buildTree(files) {
|
|
const root = {
|
|
type: 'folder',
|
|
key: 'root',
|
|
name: 'root',
|
|
depth: -1,
|
|
size: 0,
|
|
fileIndexes: [],
|
|
children: [],
|
|
childMap: new Map(),
|
|
};
|
|
|
|
const ensureFolder = (parent, name, key, depth) => {
|
|
let node = parent.childMap.get(key);
|
|
if (!node) {
|
|
node = {
|
|
type: 'folder',
|
|
key,
|
|
name,
|
|
depth,
|
|
size: 0,
|
|
fileIndexes: [],
|
|
children: [],
|
|
childMap: new Map(),
|
|
};
|
|
parent.childMap.set(key, node);
|
|
parent.children.push(node);
|
|
}
|
|
return node;
|
|
};
|
|
|
|
files.forEach(file => {
|
|
const components = file.components && file.components.length
|
|
? file.components
|
|
: String(file.name || '').split('/').filter(Boolean);
|
|
const pathParts = components.length ? components : [file.name || ('file-' + file.index)];
|
|
let parent = root;
|
|
const folderChain = [root];
|
|
|
|
pathParts.slice(0, -1).forEach((part, depth) => {
|
|
const key = parent.key + '/' + part;
|
|
parent = ensureFolder(parent, part, key, depth);
|
|
folderChain.push(parent);
|
|
});
|
|
|
|
const fileNode = {
|
|
type: 'file',
|
|
key: 'file:' + file.index,
|
|
name: pathParts[pathParts.length - 1],
|
|
depth: Math.max(pathParts.length - 1, 0),
|
|
size: Number(file.length || 0),
|
|
fileIndex: file.index,
|
|
fileIndexes: [file.index],
|
|
children: [],
|
|
};
|
|
parent.children.push(fileNode);
|
|
|
|
folderChain.forEach(folder => {
|
|
folder.size += fileNode.size;
|
|
folder.fileIndexes.push(file.index);
|
|
});
|
|
});
|
|
|
|
const sortAndSeal = node => {
|
|
node.children.sort((a, b) => {
|
|
if (a.type !== b.type) return a.type === 'folder' ? -1 : 1;
|
|
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
|
|
});
|
|
node.children.forEach(sortAndSeal);
|
|
delete node.childMap;
|
|
};
|
|
sortAndSeal(root);
|
|
|
|
const initiallyExpanded = new Set();
|
|
const collectExpanded = node => {
|
|
if (node.type === 'folder' && node.depth < 1) initiallyExpanded.add(node.key);
|
|
node.children.forEach(collectExpanded);
|
|
};
|
|
collectExpanded(root);
|
|
this.expanded = initiallyExpanded;
|
|
|
|
return root;
|
|
},
|
|
|
|
visibleNodes() {
|
|
if (!this.treeRoot) return [];
|
|
const rows = [];
|
|
const visit = node => {
|
|
node.children.forEach(child => {
|
|
rows.push(child);
|
|
if (child.type === 'folder' && this.expanded.has(child.key)) {
|
|
visit(child);
|
|
}
|
|
});
|
|
};
|
|
visit(this.treeRoot);
|
|
return rows;
|
|
},
|
|
|
|
rowIndent(node) {
|
|
return Math.min(10 + Math.max(node.depth, 0) * 18, 82);
|
|
},
|
|
|
|
toggleExpand(node) {
|
|
if (node.type !== 'folder') return;
|
|
const next = new Set(this.expanded);
|
|
if (next.has(node.key)) next.delete(node.key);
|
|
else next.add(node.key);
|
|
this.expanded = next;
|
|
},
|
|
|
|
expandAll(value) {
|
|
if (!this.treeRoot) return;
|
|
const next = new Set();
|
|
const visit = node => {
|
|
if (node.type === 'folder' && value) next.add(node.key);
|
|
node.children.forEach(visit);
|
|
};
|
|
visit(this.treeRoot);
|
|
this.expanded = next;
|
|
},
|
|
|
|
nodeState(node) {
|
|
if (node.type === 'file') {
|
|
return this.selected.has(node.fileIndex) ? 'checked' : 'empty';
|
|
}
|
|
const total = node.fileIndexes.length;
|
|
const selected = node.fileIndexes.filter(index => this.selected.has(index)).length;
|
|
if (selected === 0) return 'empty';
|
|
if (selected === total) return 'checked';
|
|
return 'partial';
|
|
},
|
|
|
|
nodeCheckClass(node) {
|
|
const state = this.nodeState(node);
|
|
return {
|
|
checked: state === 'checked',
|
|
partial: state === 'partial',
|
|
};
|
|
},
|
|
|
|
toggleNode(node) {
|
|
const next = new Set(this.selected);
|
|
if (node.type === 'file') {
|
|
if (next.has(node.fileIndex)) next.delete(node.fileIndex);
|
|
else next.add(node.fileIndex);
|
|
this.selected = next;
|
|
return;
|
|
}
|
|
|
|
if (this.nodeState(node) === 'checked') {
|
|
node.fileIndexes.forEach(index => next.delete(index));
|
|
} else {
|
|
node.fileIndexes.forEach(index => next.add(index));
|
|
}
|
|
this.selected = next;
|
|
},
|
|
|
|
async start() {
|
|
if (!this.previewData || this.loading) return;
|
|
const selected = [...this.selected];
|
|
if (selected.length === 0) {
|
|
this._setMessage('Select at least one file.', true);
|
|
return;
|
|
}
|
|
|
|
this.loading = true;
|
|
this._setMessage('Starting download...');
|
|
try {
|
|
const res = await fetch(`/api/player/torrents/${this.previewData.id}/start`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ selected_files: selected }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || 'Start failed');
|
|
this._setMessage('Download started. Files will move to inbox when complete.');
|
|
this._poll(data.id);
|
|
} catch (err) {
|
|
this._setMessage(err.message || String(err), true);
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
_poll(id) {
|
|
if (this._pollTimer) clearInterval(this._pollTimer);
|
|
this._pollTimer = setInterval(async () => {
|
|
try {
|
|
const res = await fetch(`/api/player/torrents/${id}/status`);
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || 'Status failed');
|
|
this._setMessage(
|
|
data.status + ' · ' + data.progress_percent.toFixed(1) + '% · ' + this.bytes(data.downloaded_bytes),
|
|
data.status === 'failed'
|
|
);
|
|
if (data.status === 'complete' || data.status === 'failed') {
|
|
clearInterval(this._pollTimer);
|
|
this._pollTimer = null;
|
|
}
|
|
} catch (err) {
|
|
this._setMessage(err.message || String(err), true);
|
|
clearInterval(this._pollTimer);
|
|
this._pollTimer = null;
|
|
}
|
|
}, 2000);
|
|
},
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Playlists store
|
|
// -----------------------------------------------------------------------
|
|
Alpine.store('playlists', {
|
|
list: [],
|
|
modal: null, // { mode: 'create'|'rename', title: '', id?: number }
|
|
picker: null, // { trackIds: [1,2,3] }
|
|
|
|
init() {
|
|
this.reload();
|
|
},
|
|
|
|
async reload() {
|
|
try {
|
|
const res = await fetch('/api/player/playlists');
|
|
if (res.ok) this.list = await res.json();
|
|
} catch {}
|
|
},
|
|
|
|
showCreate() {
|
|
this.modal = { mode: 'create', title: '' };
|
|
},
|
|
|
|
startRename(pl) {
|
|
this.modal = { mode: 'rename', title: pl.title, id: pl.id };
|
|
},
|
|
|
|
async submitModal() {
|
|
if (!this.modal) return;
|
|
const title = this.modal.title.trim();
|
|
if (!title) return;
|
|
|
|
if (this.modal.mode === 'create') {
|
|
try {
|
|
await fetch('/api/player/playlists', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title }),
|
|
});
|
|
await this.reload();
|
|
} catch {}
|
|
} else if (this.modal.mode === 'rename') {
|
|
try {
|
|
await fetch(`/api/player/playlists/${this.modal.id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title }),
|
|
});
|
|
await this.reload();
|
|
} catch {}
|
|
}
|
|
this.modal = null;
|
|
},
|
|
|
|
async deletePlaylist(id) {
|
|
if (!confirm('Delete this playlist?')) return;
|
|
try {
|
|
await fetch(`/api/player/playlists/${id}`, { method: 'DELETE' });
|
|
await this.reload();
|
|
} catch {}
|
|
},
|
|
|
|
showPicker(trackIds) {
|
|
this.picker = { trackIds };
|
|
},
|
|
|
|
async addToPicked(playlistId) {
|
|
if (!this.picker) return;
|
|
try {
|
|
await fetch(`/api/player/playlists/${playlistId}/tracks`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ track_ids: this.picker.trackIds }),
|
|
});
|
|
await this.reload();
|
|
} catch {}
|
|
this.picker = null;
|
|
},
|
|
});
|
|
});
|
|
</script>
|
|
{% endblock body %}
|