Files
furumusic/templates/player.html
T

4619 lines
168 KiB
HTML
Raw Normal View History

2026-05-23 13:08:09 +03:00
{% 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;
2026-05-25 14:42:25 +03:00
--safe-bottom: env(safe-area-inset-bottom, 0px);
--player-bar-space: calc(var(--player-height) + var(--safe-bottom));
2026-05-23 13:08:09 +03:00
}
* { margin: 0; padding: 0; box-sizing: border-box; }
2026-05-25 14:42:25 +03:00
[x-cloak] { display: none !important; }
html, body {
height: 100%;
overflow: hidden;
}
2026-05-23 13:08:09 +03:00
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;
2026-05-25 14:42:25 +03:00
height: 100dvh;
min-height: 0;
2026-05-23 13:08:09 +03:00
}
.main-content {
display: flex;
flex: 1;
2026-05-25 14:42:25 +03:00
min-height: 0;
2026-05-23 13:08:09 +03:00
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;
}
2026-05-25 14:42:25 +03:00
.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);
}
2026-05-25 15:57:10 +03:00
button.user-stat {
border: 0;
color: inherit;
cursor: pointer;
text-align: left;
}
button.user-stat:hover {
background: var(--bg-hover);
}
2026-05-25 14:42:25 +03:00
.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);
}
2026-05-23 13:08:09 +03:00
.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; }
2026-05-25 17:41:00 +03:00
.sidebar-section {
padding: 8px;
border-top: 1px solid var(--border-color);
}
.sidebar-section-title {
padding: 6px 12px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-subdued);
}
.following-list {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 220px;
overflow-y: auto;
}
.following-artist {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 12px;
border-radius: 6px;
cursor: pointer;
color: var(--text-secondary);
transition: background 0.15s, color 0.15s;
}
.following-artist:hover,
.following-artist.active {
background: var(--bg-hover);
color: var(--text-primary);
}
.following-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--bg-elevated);
overflow: hidden;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.following-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.following-avatar svg {
width: 16px;
height: 16px;
color: var(--text-subdued);
}
.following-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 500;
}
.following-empty {
padding: 8px 12px;
color: var(--text-subdued);
font-size: 12px;
}
2026-05-23 13:08:09 +03:00
.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;
2026-05-25 14:42:25 +03:00
min-width: 0;
2026-05-23 13:08:09 +03:00
overflow-y: auto;
padding: 24px;
background: var(--bg-primary);
2026-05-25 14:42:25 +03:00
-webkit-overflow-scrolling: touch;
2026-05-23 13:08:09 +03:00
}
.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);
2026-05-25 14:42:25 +03:00
line-height: 1.35;
2026-05-23 13:08:09 +03:00
}
/* 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; }
2026-05-25 13:50:24 +03:00
.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;
}
2026-05-23 13:08:09 +03:00
/* 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); }
2026-05-25 16:26:45 +03:00
.artist-link {
color: inherit;
cursor: pointer;
text-decoration: none;
}
.artist-link:hover {
color: var(--text-primary);
text-decoration: underline;
}
2026-05-23 13:08:09 +03:00
.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; }
2026-05-25 14:42:25 +03:00
.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;
}
2026-05-23 13:08:09 +03:00
.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; }
2026-05-25 23:04:58 +03:00
.info-btn {
color: var(--text-subdued);
}
.info-btn:hover {
color: var(--text-primary);
}
.card-info-btn {
position: absolute;
top: 8px;
right: 8px;
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid var(--border-color);
background: rgba(18,18,18,0.78);
color: var(--text-primary);
cursor: help;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s, background 0.15s;
box-shadow: 0 2px 8px rgba(0,0,0,0.35);
}
.card:hover .card-info-btn,
.search-release-card:hover .card-info-btn {
opacity: 1;
}
.card-info-btn:hover {
background: var(--bg-hover);
}
.card-info-btn svg {
width: 15px;
height: 15px;
}
2026-05-23 13:08:09 +03:00
/* 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);
}
2026-05-25 17:41:00 +03:00
.release-action-btn.followed {
background: var(--accent);
color: #000;
}
.artist-follow-card-btn {
position: absolute;
bottom: 8px;
right: 8px;
width: 36px;
height: 36px;
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, background 0.15s, color 0.15s;
box-shadow: 0 4px 12px rgba(0,0,0,0.35);
color: var(--text-primary);
}
.card:hover .artist-follow-card-btn,
.search-artist-card:hover .artist-follow-card-btn,
.artist-follow-card-btn.followed {
opacity: 1;
transform: translateY(0);
}
.artist-follow-card-btn.followed {
background: var(--accent);
border-color: var(--accent);
color: #000;
}
.artist-follow-card-btn svg {
width: 17px;
height: 17px;
}
2026-05-23 13:08:09 +03:00
/* 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 {
2026-05-25 14:42:25 +03:00
height: var(--player-bar-space);
2026-05-23 13:08:09 +03:00
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
display: grid;
grid-template-columns: 1fr 2fr 1fr;
align-items: center;
2026-05-25 14:42:25 +03:00
padding: 0 16px var(--safe-bottom);
2026-05-23 13:08:09 +03:00
z-index: 10;
2026-05-25 14:42:25 +03:00
flex-shrink: 0;
2026-05-23 13:08:09 +03:00
}
.player-now-playing {
display: flex;
align-items: center;
gap: 12px;
overflow: hidden;
2026-05-25 14:42:25 +03:00
min-width: 0;
2026-05-23 13:08:09 +03:00
}
2026-05-25 14:42:25 +03:00
.player-now-playing > div { min-width: 0; }
2026-05-23 13:08:09 +03:00
.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); }
2026-05-25 14:42:25 +03:00
.player-track-info {
overflow: hidden;
min-width: 0;
}
2026-05-23 13:08:09 +03:00
.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;
2026-05-25 14:42:25 +03:00
min-width: 0;
2026-05-23 13:08:09 +03:00
}
.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;
2026-05-25 14:42:25 +03:00
min-width: 0;
2026-05-23 13:08:09 +03:00
}
.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; }
2026-05-25 14:59:01 +03:00
.content-topbar {
display: flex;
align-items: center;
gap: 10px;
2026-05-25 14:59:01 +03:00
margin-bottom: 20px;
}
2026-05-23 13:08:09 +03:00
/* Search bar */
.search-bar {
position: relative;
flex: 1 1 auto;
2026-05-25 14:59:01 +03:00
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;
}
2026-05-25 14:59:01 +03:00
.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);
}
2026-05-25 15:57:10 +03:00
.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;
}
2026-05-25 14:59:01 +03:00
.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;
2026-05-23 13:08:09 +03:00
}
.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;
2026-05-25 17:41:00 +03:00
position: relative;
2026-05-23 13:08:09 +03:00
}
.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;
}
2026-05-25 15:57:10 +03:00
.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;
}
2026-05-23 13:08:09 +03:00
/* 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 */
2026-05-25 14:42:25 +03:00
@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; }
}
2026-05-23 13:08:09 +03:00
@media (max-width: 900px) {
2026-05-25 14:42:25 +03:00
:root {
--player-height: 118px;
--player-bar-space: calc(var(--player-height) + var(--safe-bottom));
}
2026-05-23 13:08:09 +03:00
.sidebar-left { display: none; }
2026-05-25 14:42:25 +03:00
.center-content {
padding: 16px;
}
2026-05-25 14:59:01 +03:00
.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);
}
2026-05-25 14:42:25 +03:00
.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,
2026-05-25 23:04:58 +03:00
.card-info-btn,
2026-05-25 17:41:00 +03:00
.artist-follow-card-btn,
2026-05-25 14:42:25 +03:00
.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;
}
2026-05-25 14:59:01 +03:00
.content-topbar {
2026-05-25 14:42:25 +03:00
margin-bottom: 14px;
}
.search-bar input {
padding-top: 11px;
padding-bottom: 11px;
font-size: 16px;
}
.search-bar .search-shortcut {
display: none;
}
2026-05-25 14:59:01 +03:00
.mobile-account-chip {
max-width: 42px;
width: 42px;
padding: 0;
justify-content: center;
}
.mobile-account-name {
display: none;
}
2026-05-25 15:57:10 +03:00
.mobile-account-popover {
right: 8px;
top: 64px;
}
2026-05-25 14:42:25 +03:00
.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;
2026-05-25 14:42:25 +03:00
}
.modal-box {
width: 100%;
min-width: 0;
max-width: none;
max-height: calc(100dvh - 16px);
2026-05-25 14:42:25 +03:00
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;
2026-05-25 14:42:25 +03:00
}
.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;
}
2026-05-23 13:08:09 +03:00
}
/* 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">
2026-05-25 14:42:25 +03:00
<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">
2026-05-25 15:57:10 +03:00
<button class="user-stat" @click="$store.history.open()">
2026-05-25 14:42:25 +03:00
<span class="user-stat-value" x-text="$store.user.format($store.user.profile?.stats?.plays)"></span>
<span class="user-stat-label">plays</span>
2026-05-25 15:57:10 +03:00
</button>
2026-05-25 14:42:25 +03:00
<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">
2026-05-25 15:57:10 +03:00
<span class="user-stat-value" x-text="$store.user.duration($store.user.profile?.stats?.listened_minutes)"></span>
<span class="user-stat-label">listened</span>
2026-05-25 14:42:25 +03:00
</div>
</div>
</div>
2026-05-23 13:08:09 +03:00
<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>
2026-05-25 17:41:00 +03:00
<div class="sidebar-section">
<div class="sidebar-section-title">
Following
<span x-show="$store.follows.artists.length > 0"
x-text="'(' + $store.follows.artists.length + ')'"></span>
</div>
<template x-if="$store.follows.artists.length === 0">
<div class="following-empty">No followed artists</div>
</template>
<div class="following-list" x-show="$store.follows.artists.length > 0" x-cloak>
<template x-for="artist in $store.follows.artists" :key="artist.id">
<div class="following-artist"
:class="{ active: $store.library.currentArtist && $store.library.currentArtist.id === artist.id }"
@click="$store.library.openArtist(artist.id)">
<div class="following-avatar">
<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.6"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
</template>
</div>
<div class="following-name" x-text="artist.name"></div>
</div>
</template>
</div>
</div>
2026-05-23 13:08:09 +03:00
<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">
2026-05-25 14:59:01 +03:00
<!-- Search / account bar -->
2026-05-25 15:57:10 +03:00
<div class="content-topbar" @click.outside="$store.user.menuOpen = false">
2026-05-25 14:59:01 +03:00
<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>
2026-05-25 14:59:01 +03:00
<button class="mobile-account-chip"
x-show="$store.user.profile"
x-cloak
2026-05-25 15:57:10 +03:00
@click="$store.user.menuOpen = !$store.user.menuOpen"
:title="$store.user.profile?.name || 'Account'">
2026-05-25 14:59:01 +03:00
<span class="user-avatar" x-text="$store.user.initials()"></span>
<span class="mobile-account-name" x-text="$store.user.profile?.name || ''"></span>
</button>
2026-05-25 15:57:10 +03:00
<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>
2026-05-23 13:08:09 +03:00
</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>
2026-05-25 17:41:00 +03:00
<button class="artist-follow-card-btn"
:class="{ followed: $store.follows.has(artist.id) }"
@click.stop="$store.follows.toggle(artist.id)"
:title="$store.follows.has(artist.id) ? 'Unfollow artist' : 'Follow artist'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path x-show="!$store.follows.has(artist.id)" d="M19 8v6M16 11h6"/>
<path x-show="$store.follows.has(artist.id)" d="M16 11l2 2 4-5"/>
</svg>
</button>
2026-05-23 13:08:09 +03:00
</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>
2026-05-25 23:04:58 +03:00
<button class="card-info-btn" @click.stop :title="$store.library.releaseInfo(release)" aria-label="Release info">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
</button>
2026-05-23 13:08:09 +03:00
</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>
2026-05-25 16:26:45 +03:00
<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>
2026-05-23 13:08:09 +03:00
</div>
<span></span>
<div class="track-actions">
2026-05-25 23:04:58 +03:00
<button class="track-action-btn info-btn" @click.stop :title="$store.library.trackInfo(track)" aria-label="Track info">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
</button>
2026-05-23 13:08:09 +03:00
<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>
2026-05-25 17:41:00 +03:00
<button class="artist-follow-card-btn"
:class="{ followed: $store.follows.has(artist.id) }"
@click.stop="$store.follows.toggle(artist.id)"
:title="$store.follows.has(artist.id) ? 'Unfollow artist' : 'Follow artist'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path x-show="!$store.follows.has(artist.id)" d="M19 8v6M16 11h6"/>
<path x-show="$store.follows.has(artist.id)" d="M16 11l2 2 4-5"/>
</svg>
</button>
2026-05-23 13:08:09 +03:00
</div>
<div class="card-title" x-text="artist.name"></div>
2026-05-25 14:30:33 +03:00
<div class="card-subtitle" x-text="artist.release_count + ' releases · ' + artist.track_count + ' tracks'"></div>
2026-05-23 13:08:09 +03:00
</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>
2026-05-25 14:42:25 +03:00
<div class="artist-name" x-text="$store.library.currentArtist.name"></div>
2026-05-25 13:50:24 +03:00
<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>
2026-05-25 17:41:00 +03:00
<div class="release-actions">
<button class="release-action-btn secondary"
:class="{ followed: $store.follows.has($store.library.currentArtist.id) }"
@click="$store.follows.toggle($store.library.currentArtist.id)"
:title="$store.follows.has($store.library.currentArtist.id) ? 'Unfollow artist' : 'Follow artist'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path x-show="!$store.follows.has($store.library.currentArtist.id)" d="M19 8v6M16 11h6"/>
<path x-show="$store.follows.has($store.library.currentArtist.id)" d="M16 11l2 2 4-5"/>
</svg>
<span x-text="$store.follows.has($store.library.currentArtist.id) ? 'Following' : 'Follow'"></span>
</button>
</div>
2026-05-23 13:08:09 +03:00
</div>
</div>
2026-05-25 13:50:24 +03:00
<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>
2026-05-25 23:04:58 +03:00
<button class="card-info-btn" @click.stop :title="$store.library.releaseInfo(release)" aria-label="Release info">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
</button>
2026-05-25 13:50:24 +03:00
<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>
2026-05-23 13:08:09 +03:00
</div>
2026-05-25 13:50:24 +03:00
</section>
</template>
2026-05-25 16:26:45 +03:00
<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">
2026-05-25 23:04:58 +03:00
<button class="track-action-btn info-btn" @click.stop :title="$store.library.trackInfo(track)" aria-label="Track info">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
</button>
2026-05-25 16:26:45 +03:00
<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>
2026-05-23 13:08:09 +03:00
</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>
2026-05-25 16:26:45 +03:00
<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>
2026-05-23 13:08:09 +03:00
<div class="release-year" x-text="$store.library.currentRelease.year || ''"></div>
<div class="release-actions">
2026-05-25 23:04:58 +03:00
<button class="release-action-btn secondary"
@click.stop
:title="$store.library.releaseInfo($store.library.currentRelease)"
aria-label="Release info">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
Info
</button>
2026-05-23 13:08:09 +03:00
<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>
2026-05-25 16:26:45 +03:00
<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>
2026-05-23 13:08:09 +03:00
</div>
<span></span>
<div class="track-actions">
2026-05-25 23:04:58 +03:00
<button class="track-action-btn info-btn" @click.stop :title="$store.library.trackInfo(track)" aria-label="Track info">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
</button>
2026-05-23 13:08:09 +03:00
<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>
2026-05-25 16:26:45 +03:00
<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>
2026-05-23 13:08:09 +03:00
</div>
<span></span>
<div class="track-actions">
2026-05-25 23:04:58 +03:00
<button class="track-action-btn info-btn" @click.stop :title="$store.library.trackInfo(track)" aria-label="Track info">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
</button>
2026-05-23 13:08:09 +03:00
<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>
2026-05-25 16:26:45 +03:00
<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>
2026-05-23 13:08:09 +03:00
</div>
<div class="queue-track-actions">
2026-05-25 23:04:58 +03:00
<button class="queue-track-remove info-btn" @click.stop :title="$store.library.trackInfo(track)" aria-label="Track info">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
</button>
2026-05-23 13:08:09 +03:00
<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>
2026-05-25 16:26:45 +03:00
<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>
2026-05-23 13:08:09 +03:00
</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>
2026-05-25 15:57:10 +03:00
<!-- 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>
2026-05-23 13:08:09 +03:00
</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';
2026-05-25 14:42:25 +03:00
// -----------------------------------------------------------------------
// User store
// -----------------------------------------------------------------------
Alpine.store('user', {
profile: null,
2026-05-25 15:57:10 +03:00
menuOpen: false,
2026-05-25 14:42:25 +03:00
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);
},
2026-05-25 15:57:10 +03:00
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';
},
2026-05-25 14:42:25 +03:00
logout() {
window.location.href = '/logout';
},
});
2026-05-25 15:57:10 +03:00
// -----------------------------------------------------------------------
// 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));
},
});
2026-05-23 13:08:09 +03:00
// -----------------------------------------------------------------------
// 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,
2026-05-25 16:26:45 +03:00
visible: false,
2026-05-23 13:08:09 +03:00
_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 {}
},
2026-05-25 13:50:24 +03:00
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,
}));
},
2026-05-25 16:26:45 +03:00
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];
},
2026-05-25 23:04:58 +03:00
bytes(value) {
if (!value) return 'unknown size';
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];
},
uploadersInfo(uploaders) {
const rows = uploaders || [];
if (!rows.length) return 'UFO';
return rows
.map(row => `${row.name || 'UFO'} (${row.track_count} track${row.track_count === 1 ? '' : 's'})`)
.join(', ');
},
releaseInfo(release) {
if (!release) return '';
const lines = [
release.title || 'Unknown release',
`Type: ${release.release_type || 'unknown'}`,
`Year: ${release.year || 'unknown'}`,
`Tracks: ${release.track_count || release.tracks?.length || 0}`,
`Uploaders: ${this.uploadersInfo(release.uploaders || [])}`,
];
return lines.join('\n');
},
trackInfo(track) {
if (!track) return '';
const artists = this.trackArtistLinks(track).map(artist => artist.label).join(', ') || 'unknown';
const audio = [
track.audio_format || null,
track.audio_bitrate ? `${track.audio_bitrate} kbps` : null,
track.audio_sample_rate ? `${track.audio_sample_rate} Hz` : null,
track.audio_bit_depth ? `${track.audio_bit_depth}-bit` : null,
].filter(Boolean).join(' · ') || 'unknown audio details';
const lines = [
track.title || 'Unknown track',
`Artists: ${artists}`,
`Duration: ${formatTime(track.duration_seconds)}`,
`Audio: ${audio}`,
`Size: ${this.bytes(track.file_size_bytes)}`,
`Uploader: ${track.uploader_name || 'UFO'}`,
];
return lines.join('\n');
},
2026-05-23 13:08:09 +03:00
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));
},
});
2026-05-25 17:41:00 +03:00
// -----------------------------------------------------------------------
// Artist follows store
// -----------------------------------------------------------------------
Alpine.store('follows', {
_set: new Set(),
artists: [],
init() {
this.reload();
},
has(artistId) {
return this._set.has(Number(artistId));
},
async reload() {
try {
const res = await fetch('/api/player/follows');
if (!res.ok) return;
const data = await res.json();
this._set = new Set((data.artist_ids || []).map(Number));
this.artists = data.artists || [];
} catch {}
},
_artistSnapshot(artistId) {
const id = Number(artistId);
const library = Alpine.store('library');
const fromLists = [
...(library.artists || []),
...((library.searchResults && library.searchResults.artists) || []),
].find(artist => Number(artist.id) === id);
if (fromLists) return fromLists;
if (library.currentArtist && Number(library.currentArtist.id) === id) {
return {
id,
name: library.currentArtist.name,
image_url: library.currentArtist.image_url,
release_count: (library.currentArtist.releases || []).length,
track_count: library.currentArtist.total_track_count || 0,
};
}
return null;
},
async toggle(artistId) {
const id = Number(artistId);
if (!id) return;
if (this._set.has(id)) {
this._set.delete(id);
this.artists = this.artists.filter(artist => Number(artist.id) !== id);
} else {
this._set.add(id);
const snapshot = this._artistSnapshot(id);
if (snapshot && !this.artists.some(artist => Number(artist.id) === id)) {
this.artists = [snapshot, ...this.artists];
}
}
this._set = new Set(this._set);
try {
const res = await fetch(`/api/player/follows/toggle/${id}`, { method: 'POST' });
if (res.ok) {
const data = await res.json();
if (data.followed) this._set.add(id);
else this._set.delete(id);
this._set = new Set(this._set);
}
} catch {}
await this.reload();
},
});
// -----------------------------------------------------------------------
// 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);
},
});
2026-05-23 13:08:09 +03:00
// -----------------------------------------------------------------------
// 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 %}