Files
furumusic/templates/player.html
T
2026-05-25 14:59:01 +03:00

3113 lines
110 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);
}
.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); }
.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 {
margin-bottom: 20px;
}
/* Search bar */
.search-bar {
position: relative;
min-width: 0;
}
.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-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;
}
/* 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 {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.content-topbar .search-bar {
flex: 1 1 auto;
}
.mobile-account-chip {
display: flex;
flex: 0 0 auto;
}
.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;
}
.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: 12px;
}
.modal-box {
width: 100%;
min-width: 0;
max-width: none;
max-height: min(76dvh, 560px);
border-radius: 10px;
}
.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">
<div class="user-stat">
<span class="user-stat-value" x-text="$store.user.format($store.user.profile?.stats?.plays)"></span>
<span class="user-stat-label">plays</span>
</div>
<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.format($store.user.profile?.stats?.listened_minutes)"></span>
<span class="user-stat-label">min</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">
<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="mobile-account-chip"
x-show="$store.user.profile"
x-cloak
@click="$store.user.logout()"
:title="'Log out ' + ($store.user.profile?.name || '')">
<span class="user-avatar" x-text="$store.user.initials()"></span>
<span class="mobile-account-name" x-text="$store.user.profile?.name || ''"></span>
</button>
</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" x-text="[...track.artists, ...track.featured_artists.map(a => ({...a, name: 'ft. ' + a.name}))].map(a => a.name).join(', ')"></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>
</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" x-text="$store.library.currentRelease.artists.map(a => a.name).join(', ')"></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" x-text="[...track.artists, ...track.featured_artists.map(a => ({...a, name: 'ft. ' + a.name}))].map(a => a.name).join(', ')"></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" x-text="[...track.artists, ...track.featured_artists.map(a => ({...a, name: 'ft. ' + a.name}))].map(a => a.name).join(', ')"></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" x-text="track.artists.map(a => a.name).join(', ')"></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" x-text="$store.player.currentTrack.artists.map(a => a.name).join(', ')"></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>
</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,
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);
},
logout() {
window.location.href = '/logout';
},
});
// -----------------------------------------------------------------------
// 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: true,
_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,
}));
},
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));
},
});
// -----------------------------------------------------------------------
// 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 %}