frontend prettier

This commit is contained in:
cool.gitter.choco
2025-03-15 20:39:29 -06:00
parent c7653a2ab3
commit ce3dfd0cdd
22 changed files with 2635 additions and 1047 deletions

View File

@@ -95,54 +95,52 @@ body {
/* Individual Track Styling */
.track {
display: flex;
display: grid;
grid-template-columns: 40px 1fr auto auto;
align-items: center;
padding: 1rem;
background: #181818;
border-radius: 8px;
transition: background 0.3s ease;
flex-wrap: wrap;
padding: 0.75rem 1rem;
border-radius: var(--radius-sm);
background-color: var(--color-surface);
margin-bottom: 0.5rem;
transition: background-color 0.2s ease;
}
.track:hover {
background: #2a2a2a;
background-color: var(--color-surface-hover);
}
.track-number {
width: 30px;
font-size: 1rem;
font-weight: 500;
text-align: center;
margin-right: 1rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.9rem;
color: var(--color-text-secondary);
width: 24px;
}
.track-info {
padding: 0 1rem;
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
align-items: flex-start;
}
.track-name {
font-size: 1rem;
font-weight: bold;
word-wrap: break-word;
font-weight: 500;
margin-bottom: 0.25rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.track-artist {
font-size: 0.9rem;
color: #b3b3b3;
color: var(--color-text-secondary);
}
.track-duration {
width: 60px;
text-align: right;
color: var(--color-text-tertiary);
font-size: 0.9rem;
color: #b3b3b3;
margin-left: 1rem;
flex-shrink: 0;
margin-right: 1rem;
}
/* Loading and Error States */
@@ -296,15 +294,12 @@ body {
}
.track {
flex-direction: column;
align-items: flex-start;
grid-template-columns: 30px 1fr auto auto;
padding: 0.6rem 0.8rem;
}
.track-duration {
margin-left: 0;
margin-top: 0.5rem;
width: 100%;
text-align: left;
margin-right: 0.5rem;
}
}
@@ -336,23 +331,19 @@ body {
}
.track {
padding: 0.8rem;
flex-direction: column;
align-items: center;
text-align: center;
grid-template-columns: 30px 1fr auto;
}
.track-number {
font-size: 0.9rem;
margin-right: 0;
margin-bottom: 0.5rem;
.track-info {
padding: 0 0.5rem;
}
.track-duration {
margin-left: 0;
margin-top: 0.5rem;
width: 100%;
text-align: center;
.track-name, .track-artist {
max-width: 200px;
}
.section-title {
font-size: 1.25rem;
}
/* Ensure the actions container lays out buttons properly */
@@ -385,3 +376,31 @@ a:focus {
.download-btn--circle::before {
content: none;
}
/* Album page specific styles */
/* Add some context styles for the album copyright */
.album-copyright {
font-size: 0.85rem;
color: var(--color-text-tertiary);
margin-top: 0.5rem;
font-style: italic;
}
/* Section title styling */
.section-title {
font-size: 1.5rem;
margin-bottom: 1rem;
position: relative;
padding-bottom: 0.5rem;
}
.section-title:after {
content: '';
position: absolute;
left: 0;
bottom: 0;
width: 50px;
height: 2px;
background-color: var(--color-primary);
}

View File

@@ -72,72 +72,94 @@ body {
padding-bottom: 0.5rem;
}
/* Album Groups */
/* Album groups layout */
.album-groups {
display: flex;
flex-direction: column;
gap: 2rem;
}
/* Album group section */
.album-group {
margin-bottom: 2rem;
margin-bottom: 1.5rem;
}
.album-group-header {
display: flex;
justify-content: space-between;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
padding: 0.5rem 0;
border-bottom: 1px solid #2a2a2a;
}
.album-group-header h3 {
font-size: 1.5rem;
margin: 0;
text-transform: capitalize;
font-size: 1.3rem;
position: relative;
padding-bottom: 0.5rem;
}
.album-group-header h3::after {
content: '';
position: absolute;
left: 0;
bottom: 0;
width: 40px;
height: 2px;
background-color: var(--color-primary);
}
.group-download-btn {
padding: 0.4rem 0.8rem;
font-size: 0.9rem;
}
/* Albums grid layout */
.albums-list {
display: flex;
flex-direction: column;
gap: 1rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1.5rem;
margin-top: 1rem;
}
/* Unified Album Card (Desktop & Mobile) */
/* Album card styling */
.album-card {
background: #181818;
border-radius: 8px;
background-color: var(--color-surface);
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
transition: transform 0.2s ease, box-shadow 0.2s ease;
display: flex;
align-items: center;
padding: 0.5rem;
position: relative;
box-shadow: var(--shadow-sm);
}
.album-card:hover {
transform: translateY(-5px);
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
box-shadow: var(--shadow-md);
}
/* Album Cover Image */
.album-cover {
width: 80px;
height: 80px;
width: 100%;
aspect-ratio: 1/1;
object-fit: cover;
border-radius: 4px;
margin-right: 1rem;
transition: opacity 0.2s ease;
}
/* Album Info */
.album-info {
flex: 1;
padding: 0.75rem;
}
.album-title {
font-size: 1rem;
font-weight: bold;
font-weight: 600;
margin-bottom: 0.25rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.album-artist {
font-size: 0.9rem;
color: #b3b3b3;
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Track Card (for Albums or Songs) */
@@ -331,6 +353,20 @@ body {
width: 100%;
text-align: center;
}
.albums-list {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 1rem;
}
.album-group-header {
flex-direction: column;
align-items: flex-start;
}
.group-download-btn {
margin-top: 0.5rem;
}
}
/* Small Devices (Mobile Phones) */
@@ -368,36 +404,21 @@ body {
text-align: center;
}
/* Mobile Album Grid Styles Inspired by Spotify */
.albums-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
/* Adjust album card for mobile grid */
.album-card {
flex-direction: column;
padding: 0;
}
.album-cover {
width: 100%;
aspect-ratio: 1 / 1;
margin: 0;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 0.75rem;
}
.album-info {
padding: 0.5rem;
text-align: center;
}
.album-title {
font-size: 1rem;
font-size: 0.9rem;
}
.album-artist {
font-size: 0.9rem;
font-size: 0.8rem;
}
}

View File

@@ -32,39 +32,223 @@ body {
transition: all 0.3s ease;
}
/* Modern Back Button */
.back-button {
background: #1db954;
color: #ffffff;
padding: 0.8rem 1.5rem;
border-radius: 25px;
text-decoration: none;
font-weight: 500;
transition: background 0.3s ease, transform 0.2s ease;
/* Back button as floating icon - keep this for our floating button */
.back-button.floating-icon {
position: fixed;
width: 56px;
height: 56px;
bottom: 20px;
left: 20px;
background-color: var(--color-primary);
border-radius: 50%;
box-shadow: var(--shadow-lg);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease, background-color 0.2s ease;
text-decoration: none !important;
}
.back-button:hover {
background: #1ed760;
.back-button.floating-icon:hover {
background-color: var(--color-primary-hover);
transform: scale(1.05);
}
.back-button.floating-icon img {
width: 24px;
height: 24px;
filter: brightness(0) invert(1);
margin: 0;
}
/* Queue button as floating icon */
.queue-icon.floating-icon {
position: fixed;
width: 56px;
height: 56px;
bottom: 20px;
right: 20px;
background-color: var(--color-primary);
border-radius: 50%;
box-shadow: var(--shadow-lg);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease, background-color 0.2s ease;
text-decoration: none !important;
}
.queue-icon.floating-icon:hover {
background-color: var(--color-primary-hover);
transform: scale(1.05);
}
.queue-icon.floating-icon img {
width: 24px;
height: 24px;
filter: brightness(0) invert(1);
margin: 0;
}
/* Queue Icon Active State */
.queue-icon.queue-icon-active {
background-color: #d13838 !important;
transition: background-color 0.3s ease;
}
.queue-icon.queue-icon-active:hover {
background-color: #e04c4c !important;
}
.queue-icon .queue-x {
font-size: 28px;
font-weight: bold;
color: white;
line-height: 24px;
display: inline-block;
transform: translateY(-2px);
}
/* Queue Icon in Header */
#queueIcon {
background: none;
/* Queue Sidebar for Config Page */
#downloadQueue {
position: fixed;
top: 0;
right: -350px;
width: 350px;
height: 100vh;
background: #181818;
padding: 20px;
transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1001;
box-shadow: -20px 0 30px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
}
#downloadQueue.active {
right: 0;
}
/* Header inside the queue sidebar */
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 15px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
margin-bottom: 20px;
}
.sidebar-header h2 {
font-size: 1.25rem;
font-weight: 600;
color: #fff;
margin: 0;
}
.header-actions {
display: flex;
gap: 10px;
align-items: center;
}
/* Cancel all button styling */
#cancelAllBtn {
background: #8b0000;
border: none;
color: #fff;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
padding: 4px;
transition: background 0.3s ease, transform 0.2s ease;
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
#queueIcon img {
width: 24px;
height: 24px;
filter: invert(1);
transition: opacity 0.3s ease;
#cancelAllBtn:hover {
background: #a30000;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
}
#queueIcon:hover img {
opacity: 0.8;
#cancelAllBtn:active {
transform: scale(0.98);
}
#cancelAllBtn .skull-icon {
width: 16px;
height: 16px;
margin-right: 8px;
vertical-align: middle;
filter: brightness(0) invert(1);
transition: transform 0.3s ease;
}
#cancelAllBtn:hover .skull-icon {
transform: rotate(-10deg) scale(1.2);
animation: skullShake 0.5s infinite alternate;
}
@keyframes skullShake {
0% { transform: rotate(-5deg); }
100% { transform: rotate(5deg); }
}
/* Empty queue state */
.queue-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
color: #b3b3b3;
text-align: center;
padding: 20px;
}
.queue-empty img {
width: 60px;
height: 60px;
margin-bottom: 15px;
opacity: 0.6;
}
.queue-empty p {
font-size: 14px;
line-height: 1.5;
}
/* Mobile responsiveness for queue in Config page */
@media (max-width: 600px) {
#downloadQueue {
width: 100%;
right: -100%;
padding: 15px;
}
#downloadQueue.active {
right: 0;
}
.sidebar-header {
padding-bottom: 12px;
margin-bottom: 15px;
}
.sidebar-header h2 {
font-size: 1.1rem;
}
#cancelAllBtn {
padding: 6px 10px;
font-size: 12px;
}
}
/* Account Configuration Section */
@@ -81,6 +265,25 @@ body {
transform: translateY(-2px);
}
/* Section Titles */
.section-title {
font-size: 1.5rem;
margin-bottom: 1.5rem;
position: relative;
padding-bottom: 0.75rem;
color: var(--color-text-primary);
}
.section-title::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 50px;
height: 2px;
background-color: var(--color-primary);
}
.config-item {
margin-bottom: 1.5rem;
position: relative;
@@ -94,10 +297,7 @@ body {
}
/* Enhanced Dropdown Styling */
#spotifyAccountSelect,
#deezerAccountSelect,
#spotifyQualitySelect,
#deezerQualitySelect {
.form-select {
background: #2a2a2a;
color: #ffffff;
border: 1px solid #404040;
@@ -115,33 +315,24 @@ body {
transition: all 0.3s ease;
}
#spotifyAccountSelect:focus,
#deezerAccountSelect:focus,
#spotifyQualitySelect:focus,
#deezerQualitySelect:focus {
.form-select:focus {
outline: none;
border-color: #1db954;
box-shadow: 0 0 0 2px rgba(29, 185, 84, 0.2);
}
#spotifyAccountSelect option,
#deezerAccountSelect option,
#spotifyQualitySelect option,
#deezerQualitySelect option {
.form-select option {
background: #181818;
color: #ffffff;
padding: 0.8rem;
}
#spotifyAccountSelect option:hover,
#deezerAccountSelect option:hover,
#spotifyQualitySelect option:hover,
#deezerQualitySelect option:hover {
.form-select option:hover {
background: #1db954;
}
/* New Input Styling for Custom Format Fields */
.config-item input[type="text"] {
.form-input {
width: 100%;
padding: 0.8rem;
background: #2a2a2a;
@@ -152,22 +343,29 @@ body {
transition: border-color 0.3s ease;
}
.config-item input[type="text"]:focus {
.form-input:focus {
outline: none;
border-color: #1db954;
box-shadow: 0 0 0 2px rgba(29, 185, 84, 0.2);
}
/* Improved Toggle Switches */
.switch {
position: relative;
display: inline-block;
width: 40px;
height: 20px;
margin-left: 1rem;
width: 48px;
height: 24px;
margin-top: 0.5rem;
vertical-align: middle;
overflow: hidden;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
@@ -177,16 +375,16 @@ body {
bottom: 0;
background-color: #666;
transition: 0.4s;
border-radius: 20px;
border-radius: 24px;
}
.slider:before {
position: absolute;
content: "";
height: 16px;
width: 16px;
left: 2px;
bottom: 2px;
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: #ffffff;
transition: 0.4s;
border-radius: 50%;
@@ -198,7 +396,7 @@ input:checked + .slider {
}
input:checked + .slider:before {
transform: translateX(20px);
transform: translateX(24px);
}
/* Setting description */
@@ -209,6 +407,11 @@ input:checked + .slider:before {
line-height: 1.4;
}
/* Accounts section layout */
.accounts-section {
margin-top: 2rem;
}
/* Service Tabs */
.service-tabs {
display: flex;
@@ -225,6 +428,8 @@ input:checked + .slider:before {
cursor: pointer;
font-size: 0.95rem;
transition: background 0.3s ease, transform 0.2s ease;
flex: 1;
text-align: center;
}
.tab-button.active {
@@ -232,6 +437,10 @@ input:checked + .slider:before {
transform: translateY(-2px);
}
.tab-button:hover:not(.active) {
background: #333333;
}
/* No Credentials Message */
.no-credentials {
padding: 1.5rem;
@@ -245,21 +454,32 @@ input:checked + .slider:before {
/* Credentials List */
.credentials-list {
margin-bottom: 2rem;
padding: 1.5rem;
background: #181818;
border-radius: 12px;
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.credential-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
padding: 1.25rem;
background: #2a2a2a;
border-radius: 8px;
margin-bottom: 0.75rem;
transition: background 0.3s ease;
transition: all 0.3s ease;
border-left: 3px solid var(--color-primary);
}
.credential-item:hover {
background: #3a3a3a;
background: #333333;
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.credential-item:last-child {
margin-bottom: 0;
}
/* New styling for credential info and actions */
@@ -267,19 +487,40 @@ input:checked + .slider:before {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
padding-right: 1rem;
}
.credential-name {
font-weight: 600;
font-size: 1rem;
font-size: 1.1rem;
color: var(--color-text-primary);
margin-bottom: 0.5rem;
}
.credential-type {
display: inline-block;
padding: 0.25rem 0.5rem;
background-color: rgba(29, 185, 84, 0.1);
border-radius: 8px;
font-size: 0.8rem;
color: var(--color-primary);
margin-bottom: 0.5rem;
}
.credential-details {
font-size: 0.9rem;
color: var(--color-text-secondary);
margin-top: 0.25rem;
}
.search-credentials-status {
font-size: 0.8rem;
padding: 0.2rem 0.5rem;
font-size: 0.85rem;
padding: 0.25rem 0.5rem;
border-radius: 12px;
display: inline-block;
width: fit-content;
margin-top: 0.5rem;
}
.search-credentials-status.has-api {
@@ -294,37 +535,44 @@ input:checked + .slider:before {
.credential-actions {
display: flex;
gap: 0.5rem;
gap: 0.75rem;
}
.credential-actions button {
padding: 0.5rem 0.8rem;
background-color: #222222;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
transition: opacity 0.3s ease, transform 0.2s ease;
white-space: nowrap;
padding: 0.6rem;
border-radius: 8px;
transition: background-color 0.2s ease, transform 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.9rem;
font-weight: 500;
color: white;
}
.edit-btn {
background: #1db954;
color: #ffffff;
}
.edit-search-btn {
background: #2d6db5;
color: #ffffff;
}
.delete-btn {
background: #ff5555;
color: #ffffff;
.credential-actions button img {
width: 20px;
height: 20px;
filter: brightness(0) invert(1);
}
.credential-actions button:hover {
opacity: 0.9;
transform: translateY(-1px);
transform: translateY(-2px);
}
.credential-actions button.delete-btn {
color: #ff5555;
}
.credential-actions button.delete-btn:hover {
background-color: rgba(192, 57, 43, 0.2);
}
.credential-actions button.edit-btn:hover {
background-color: rgba(52, 152, 219, 0.2);
}
/* Credentials Form */
@@ -375,10 +623,10 @@ input:checked + .slider:before {
padding: 0.8rem 1.5rem;
border: none;
border-radius: 25px;
width: 100%;
cursor: pointer;
font-weight: 500;
transition: background 0.3s ease, transform 0.2s ease;
margin-top: 1.5rem;
}
.save-btn:hover {
@@ -386,23 +634,39 @@ input:checked + .slider:before {
transform: translateY(-2px);
}
/* Error Messages */
/* Error Messages - Hidden by default */
#configError {
background-color: rgba(192, 57, 43, 0.1);
color: #ff5555;
margin-top: 1rem;
text-align: center;
margin-top: 1.5rem;
padding: 1rem;
border-radius: 8px;
border-left: 3px solid #ff5555;
font-size: 0.9rem;
min-height: 1.2rem;
display: none;
}
/* Success Messages */
/* Show the messages when they have content */
#configError:not(:empty) {
display: block;
}
/* Success Messages - Hidden by default */
#configSuccess {
background-color: rgba(46, 204, 113, 0.1);
color: #1db954;
margin-top: 1rem;
text-align: center;
margin-top: 1.5rem;
padding: 1rem;
border-radius: 8px;
border-left: 3px solid #1db954;
font-size: 0.9rem;
min-height: 1.2rem;
font-weight: 500;
display: none;
}
/* Show the messages when they have content */
#configSuccess:not(:empty) {
display: block;
}
/* MOBILE RESPONSIVENESS */
@@ -418,16 +682,7 @@ input:checked + .slider:before {
}
/* Increase touch target sizes for buttons and selects */
.back-button {
width: 100%;
text-align: center;
padding: 0.8rem;
}
#spotifyAccountSelect,
#deezerAccountSelect,
#spotifyQualitySelect,
#deezerQualitySelect {
.form-select {
padding: 0.8rem 2rem 0.8rem 1rem;
font-size: 0.9rem;
}
@@ -448,31 +703,28 @@ input:checked + .slider:before {
gap: 0.75rem;
}
.credential-info {
width: 100%;
margin-bottom: 1rem;
}
.credential-actions {
width: 100%;
display: flex;
justify-content: space-between;
justify-content: flex-end;
flex-wrap: wrap;
gap: 0.5rem;
}
.credential-actions button {
flex: 1;
text-align: center;
padding: 0.7rem 0.5rem;
}
/* Adjust toggle switch size for better touch support */
.switch {
width: 50px;
height: 24px;
width: 52px;
height: 26px;
}
.slider:before {
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
height: 20px;
width: 20px;
}
}
@@ -482,25 +734,60 @@ input:checked + .slider:before {
}
.account-config,
.credentials-list,
.credentials-form {
padding: 1rem;
border-radius: 8px;
}
.form-group input {
padding: 0.7rem;
.section-title {
font-size: 1.3rem;
}
.save-btn,
.back-button {
.config-item label {
font-size: 0.95rem;
}
.form-select,
.form-input {
padding: 0.7rem 1.8rem 0.7rem 0.8rem;
font-size: 0.9rem;
}
.save-btn {
width: 100%;
padding: 0.7rem;
font-size: 0.9rem;
}
/* Reduce dropdown padding for very small screens */
#spotifyAccountSelect,
#deezerAccountSelect,
#spotifyQualitySelect,
#deezerQualitySelect {
padding: 0.7rem 1.8rem 0.7rem 0.8rem;
/* Position floating icons a bit closer to the edges on small screens */
.back-button.floating-icon {
width: 60px;
height: 60px;
left: 16px;
bottom: 16px;
}
.back-button.floating-icon img {
width: 28px;
height: 28px;
}
/* Queue icon mobile styles */
.queue-icon.floating-icon {
width: 50px;
height: 50px;
right: 16px;
bottom: 16px;
}
.queue-icon.floating-icon img {
width: 22px;
height: 22px;
}
.queue-icon .queue-x {
font-size: 24px;
line-height: 20px;
}
}

457
static/css/main/base.css Normal file
View File

@@ -0,0 +1,457 @@
/* Spotizerr Base Styles
Provides consistent styling across all pages */
/* Reset and base styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
:root {
/* Main colors */
--color-background: #121212;
--color-background-gradient: linear-gradient(135deg, #121212, #1e1e1e);
--color-surface: #1c1c1c;
--color-surface-hover: #2a2a2a;
--color-border: #2a2a2a;
/* Text colors */
--color-text-primary: #ffffff;
--color-text-secondary: #b3b3b3;
--color-text-tertiary: #757575;
/* Brand colors */
--color-primary: #1db954;
--color-primary-hover: #17a44b;
--color-error: #c0392b;
--color-success: #2ecc71;
/* Spacing */
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
/* Shadow */
--shadow-sm: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24);
--shadow-md: 0 4px 6px rgba(0,0,0,0.1);
--shadow-lg: 0 10px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23);
/* Border radius */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-round: 50%;
}
body {
background: var(--color-background-gradient);
color: var(--color-text-primary);
min-height: 100vh;
line-height: 1.4;
}
a {
color: inherit;
text-decoration: none;
transition: color 0.2s ease;
}
a:hover, a:focus {
color: var(--color-primary);
text-decoration: underline;
}
/* Container for main content */
.app-container {
max-width: 1200px;
margin: 0 auto;
padding: var(--space-lg);
position: relative;
z-index: 1;
}
/* Card component */
.card {
background: var(--color-surface);
border-radius: var(--radius-md);
padding: var(--space-md);
box-shadow: var(--shadow-sm);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
/* Button variants */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--space-sm) var(--space-md);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-weight: 500;
transition: background-color 0.2s ease, transform 0.2s ease;
background-color: var(--color-surface-hover);
color: var(--color-text-primary);
}
.btn:hover {
transform: translateY(-1px);
}
.btn:active {
transform: scale(0.98);
}
.btn-primary {
background-color: var(--color-primary);
color: white;
}
.btn-primary:hover {
background-color: var(--color-primary-hover);
}
/* Icon button */
.btn-icon {
width: 40px;
height: 40px;
border-radius: var(--radius-round);
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
cursor: pointer;
transition: background-color 0.2s ease;
}
.btn-icon:hover {
background-color: var(--color-surface-hover);
}
.btn-icon img {
width: 20px;
height: 20px;
filter: brightness(0) invert(1);
}
/* Queue icon styling */
.queue-icon {
background-color: transparent;
transition: background-color 0.2s ease, transform 0.2s ease;
}
.queue-icon:hover {
background-color: var(--color-surface-hover);
}
/* Floating icons (queue and settings) */
.floating-icon {
position: fixed;
width: 56px;
height: 56px;
bottom: 20px;
background-color: var(--color-primary);
border-radius: var(--radius-round);
box-shadow: var(--shadow-lg);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease, background-color 0.2s ease;
text-decoration: none !important;
}
.floating-icon:hover,
.floating-icon:active {
background-color: var(--color-primary-hover);
transform: scale(1.05);
}
.floating-icon img {
width: 24px;
height: 24px;
filter: brightness(0) invert(1);
margin: 0;
}
/* Settings icon - bottom left */
.settings-icon {
left: 20px;
}
/* Queue icon - bottom right */
.queue-icon {
right: 20px;
}
/* Home button */
.home-btn {
background-color: transparent;
border: none;
cursor: pointer;
padding: 0;
}
.home-btn img {
width: 32px;
height: 32px;
filter: invert(1);
transition: transform 0.2s ease;
}
.home-btn:hover img {
transform: scale(1.05);
}
.home-btn:active img {
transform: scale(0.98);
}
/* When home button is used as a floating button */
.floating-icon.home-btn {
background-color: var(--color-primary);
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.floating-icon.home-btn img {
width: 24px;
height: 24px;
filter: brightness(0) invert(1);
margin: 0;
}
/* Download button */
.download-btn {
background-color: var(--color-primary);
border: none;
border-radius: var(--radius-sm);
padding: 0.7rem 1.2rem;
cursor: pointer;
font-weight: 600;
transition: background-color 0.2s ease, transform 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
color: white;
}
.download-btn img {
width: 18px;
height: 18px;
margin-right: 8px;
filter: brightness(0) invert(1);
}
.download-btn:hover {
background-color: var(--color-primary-hover);
transform: translateY(-2px);
}
.download-btn:active {
transform: scale(0.98);
}
.download-btn--circle {
width: 40px;
height: 40px;
border-radius: var(--radius-round);
padding: 0;
}
.download-btn--circle img {
margin-right: 0;
}
/* Header patterns */
.content-header {
display: flex;
align-items: center;
gap: var(--space-md);
margin-bottom: var(--space-xl);
padding-bottom: var(--space-md);
border-bottom: 1px solid var(--color-border);
}
.header-image {
width: 180px;
height: 180px;
object-fit: cover;
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
}
.header-info {
flex: 1;
}
.header-title {
font-size: 2rem;
margin-bottom: var(--space-sm);
font-weight: 700;
}
.header-subtitle {
font-size: 1.1rem;
color: var(--color-text-secondary);
margin-bottom: var(--space-xs);
}
.header-actions {
display: flex;
gap: var(--space-sm);
margin-top: var(--space-md);
}
/* Track list styling */
.tracks-list {
display: flex;
flex-direction: column;
gap: var(--space-xs);
margin-top: var(--space-md);
}
.track-item {
display: grid;
grid-template-columns: 40px 1fr auto auto;
align-items: center;
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm);
transition: background-color 0.2s ease;
}
.track-item:hover {
background-color: var(--color-surface-hover);
}
/* Utility classes */
.text-truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hidden {
display: none !important;
}
.flex {
display: flex;
}
.flex-column {
flex-direction: column;
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.gap-sm {
gap: var(--space-sm);
}
.gap-md {
gap: var(--space-md);
}
/* Loading and error states */
.loading,
.error {
width: 100%;
text-align: center;
font-size: 1rem;
padding: var(--space-md);
}
.error {
color: var(--color-error);
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.content-header {
flex-direction: column;
text-align: center;
}
.header-image {
width: 150px;
height: 150px;
}
.header-title {
font-size: 1.75rem;
}
.track-item {
grid-template-columns: 30px 1fr auto;
}
}
@media (max-width: 480px) {
.app-container {
padding: var(--space-md);
}
.header-image {
width: 120px;
height: 120px;
}
.header-title {
font-size: 1.5rem;
}
.header-subtitle {
font-size: 0.9rem;
}
.header-actions {
flex-direction: column;
width: 100%;
}
.download-btn {
width: 100%;
}
/* Adjust floating icons size for very small screens */
.floating-icon {
width: 60px;
height: 60px;
}
.floating-icon img {
width: 28px;
height: 28px;
}
/* Position floating icons a bit closer to the edges on small screens */
.settings-icon {
left: 16px;
bottom: 16px;
}
.queue-icon {
right: 16px;
bottom: 16px;
}
}

View File

@@ -20,6 +20,46 @@
padding: 4px;
}
/* Style for the skull icon in the Cancel all button */
.skull-icon {
width: 16px;
height: 16px;
margin-right: 8px;
vertical-align: middle;
filter: brightness(0) invert(1); /* Makes icon white */
transition: transform 0.3s ease;
}
#cancelAllBtn:hover .skull-icon {
transform: rotate(-10deg) scale(1.2);
animation: skullShake 0.5s infinite alternate;
}
@keyframes skullShake {
0% { transform: rotate(-5deg); }
100% { transform: rotate(5deg); }
}
/* Style for the X that appears when the queue is visible */
.queue-x {
font-size: 28px;
font-weight: bold;
color: white;
line-height: 24px;
display: inline-block;
transform: translateY(-2px);
}
/* Queue icon with red tint when X is active */
.queue-icon-active {
background-color: #d13838 !important; /* Red background for active state */
transition: background-color 0.3s ease;
}
.queue-icon-active:hover {
background-color: #e04c4c !important; /* Lighter red on hover */
}
.download-icon,
.type-icon,
.toggle-chevron {

View File

@@ -24,18 +24,35 @@ body {
/* LOADING & ERROR STATES */
.loading,
.error {
.error,
.success {
width: 100%;
text-align: center;
font-size: 1rem;
padding: 1rem;
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
border-radius: 8px;
max-width: 80%;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.error {
color: #c0392b;
color: #fff;
background-color: rgba(192, 57, 43, 0.9);
}
/* SEARCH HEADER COMPONENT */
.success {
color: #fff;
background-color: rgba(46, 204, 113, 0.9);
}
/* Main search page specific styles */
/* Search header improvements */
.search-header {
display: flex;
align-items: center;
@@ -43,11 +60,17 @@ body {
margin-bottom: 30px;
position: sticky;
top: 0;
background: rgba(18, 18, 18, 1);
background: rgba(18, 18, 18, 0.95);
backdrop-filter: blur(10px);
padding: 20px 0;
z-index: 100;
border-bottom: 1px solid #2a2a2a;
border-bottom: 1px solid var(--color-border);
}
.search-input-container {
display: flex;
flex: 1;
gap: 10px;
}
.search-input {
@@ -55,72 +78,121 @@ body {
padding: 12px 20px;
border: none;
border-radius: 25px;
background: #2a2a2a;
color: #ffffff;
background: var(--color-surface);
color: var(--color-text-primary);
font-size: 16px;
outline: none;
transition: background-color 0.3s ease;
transition: background-color 0.3s ease, box-shadow 0.3s ease;
}
.search-input:focus {
background: #333333;
background: var(--color-surface-hover);
box-shadow: 0 0 0 2px rgba(29, 185, 84, 0.3);
}
.search-type {
padding: 12px 15px;
background: #2a2a2a;
background: var(--color-surface);
border: none;
border-radius: 25px;
color: #ffffff;
color: var(--color-text-primary);
cursor: pointer;
transition: background-color 0.3s ease;
min-width: 100px;
}
.search-type:hover {
background: #3a3a3a;
.search-type:hover,
.search-type:focus {
background: var(--color-surface-hover);
}
.search-button {
padding: 12px 30px;
background-color: #1db954;
padding: 12px 25px;
background-color: var(--color-primary);
border: none;
border-radius: 25px;
color: #ffffff;
font-weight: bold;
color: white;
font-weight: 600;
cursor: pointer;
transition: background-color 0.3s ease, transform 0.2s ease;
display: flex;
align-items: center;
gap: 8px;
}
.search-button img {
width: 18px;
height: 18px;
filter: brightness(0) invert(1);
}
.search-button:hover {
background-color: #1ed760;
background-color: var(--color-primary-hover);
transform: translateY(-2px);
}
/* RESULTS GRID COMPONENT Minimalistic Version */
/* Empty state styles */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 60vh;
text-align: center;
}
.empty-state-content {
max-width: 450px;
}
.empty-state-icon {
width: 80px;
height: 80px;
margin-bottom: 1.5rem;
opacity: 0.7;
}
.empty-state h2 {
font-size: 1.75rem;
margin-bottom: 1rem;
background: linear-gradient(90deg, var(--color-primary), #2ecc71);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.empty-state p {
color: var(--color-text-secondary);
font-size: 1rem;
line-height: 1.5;
}
/* Results grid improvement */
.results-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 15px;
gap: 20px;
margin-top: 20px;
}
/* Each result card now features a clean, flat design with minimal decoration */
/* Result card style */
.result-card {
background: #1c1c1c; /* A uniform dark background */
border: 1px solid #2a2a2a; /* A subtle border for separation */
border-radius: 4px; /* Slight rounding for a modern look */
background: var(--color-surface);
border-radius: var(--radius-md);
overflow: hidden;
display: flex;
flex-direction: column;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.2s ease;
transition: transform 0.2s ease, box-shadow 0.2s ease;
box-shadow: var(--shadow-sm);
height: 100%;
}
.result-card:hover {
background-color: #2a2a2a; /* Lightens the card on hover */
transform: translateY(-2px); /* A gentle lift effect */
transform: translateY(-5px);
box-shadow: var(--shadow-md);
}
/* Album/Art image wrapper Maintains aspect ratio and a clean presentation */
/* Album art styling */
.album-art-wrapper {
position: relative;
width: 100%;
@@ -130,7 +202,7 @@ body {
.album-art-wrapper::before {
content: "";
display: block;
padding-top: 100%; /* 1:1 aspect ratio */
padding-top: 100%;
}
.album-art {
@@ -140,15 +212,19 @@ body {
width: 100%;
height: 100%;
object-fit: cover;
transition: opacity 0.2s ease;
transition: transform 0.3s ease;
}
/* Text details are kept simple and legible */
.result-card:hover .album-art {
transform: scale(1.05);
}
/* Track title and details */
.track-title {
padding: 0.75rem 1rem;
padding: 1rem 1rem 0.5rem;
font-size: 1rem;
font-weight: bold;
color: #ffffff;
font-weight: 600;
color: var(--color-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -157,128 +233,106 @@ body {
.track-artist {
padding: 0 1rem;
font-size: 0.9rem;
color: #aaaaaa;
margin-top: 0.25rem;
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 0.75rem;
}
.track-details {
padding: 0.75rem 1rem;
font-size: 0.85rem;
color: #bbbbbb;
border-top: 1px solid #2a2a2a;
color: var(--color-text-tertiary);
border-top: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
margin-top: auto;
}
.duration {
font-style: italic;
color: #999;
}
/* Centered Download Button styling */
/* Download button within result cards */
.download-btn {
margin: 0.75rem 1rem 1rem;
padding: 0.5rem 1rem;
background-color: #1db954;
color: #ffffff;
border: none;
border-radius: 4px;
font-size: 0.95rem;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.2s ease;
display: block;
text-align: center;
width: calc(100% - 2rem);
margin: 0 1rem 1rem;
max-width: calc(100% - 2rem); /* Ensure button doesn't overflow container */
width: auto; /* Allow button to shrink if needed */
font-size: 0.9rem; /* Slightly smaller font size */
padding: 0.6rem 1rem; /* Reduce padding slightly */
overflow: hidden; /* Hide overflow */
text-overflow: ellipsis; /* Add ellipsis for long text */
white-space: nowrap; /* Prevent wrapping */
}
.download-btn:hover {
background-color: #17a44b;
transform: scale(1.02);
}
/* ARTIST DOWNLOAD OPTIONS */
.artist-download-buttons {
border-top: 1px solid #2a2a2a;
padding: 0.5rem 1rem;
}
.options-toggle {
width: 100%;
background: none;
border: none;
color: #b3b3b3;
padding: 8px 16px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
transition: color 0.2s ease;
}
.options-toggle:hover {
color: #ffffff;
}
.download-options-container {
margin-top: 0.5rem;
}
.secondary-options {
display: none;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.secondary-options.expanded {
display: flex;
}
.option-btn {
flex: 1;
background-color: #2a2a2a;
color: #ffffff;
padding: 0.4rem 0.6rem;
border: none;
border-radius: 4px;
font-size: 0.85rem;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.2s ease;
}
.option-btn:hover {
background-color: #3a3a3a;
transform: translateY(-1px);
}
/* MOBILE RESPONSIVENESS */
@media (max-width: 600px) {
/* Mobile responsiveness */
@media (max-width: 768px) {
.search-header {
flex-wrap: wrap;
justify-content: center;
padding: 10px 0;
padding: 15px 0;
gap: 12px;
}
.search-input,
.search-type,
.search-button {
.search-input-container {
flex: 1 1 100%;
margin-bottom: 10px;
order: 1;
}
.search-type,
.search-button {
padding: 10px;
font-size: 15px;
order: 2;
flex: 1;
}
.results-grid {
justify-content: center;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 15px;
}
.result-card {
width: 90%;
margin: 0 auto;
/* Smaller download button for mobile */
.download-btn {
padding: 0.5rem 0.8rem;
font-size: 0.85rem;
}
}
@media (max-width: 480px) {
.search-header {
padding: 10px 0;
}
.search-type {
min-width: 80px;
padding: 12px 10px;
}
.search-button {
padding: 12px 15px;
}
.results-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
}
.track-title, .track-artist {
font-size: 0.9rem;
}
.track-details {
font-size: 0.8rem;
}
/* Even smaller download button for very small screens */
.download-btn {
padding: 0.4rem 0.7rem;
font-size: 0.8rem;
margin: 0 0.8rem 0.8rem;
max-width: calc(100% - 1.6rem);
}
.empty-state h2 {
font-size: 1.5rem;
}
.empty-state p {
font-size: 0.9rem;
}
}

View File

@@ -392,3 +392,70 @@ a:focus {
.download-btn--circle:active {
transform: scale(0.98);
}
/* Playlist page specific styles */
/* Playlist description */
.playlist-description {
font-size: 0.9rem;
color: var(--color-text-secondary);
margin-top: 0.75rem;
max-width: 90%;
line-height: 1.5;
}
/* Additional column for album in playlist tracks */
.track-album {
font-size: 0.9rem;
color: var(--color-text-secondary);
margin-right: 1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
/* Overriding the track layout for playlists to include the album column */
.track {
grid-template-columns: 40px 1fr 1fr auto auto;
}
/* Style for the download albums button */
#downloadAlbumsBtn {
background-color: rgba(255, 255, 255, 0.1);
}
#downloadAlbumsBtn:hover {
background-color: rgba(255, 255, 255, 0.2);
}
/* Mobile responsiveness adjustments */
@media (max-width: 1024px) {
.track {
grid-template-columns: 40px 1fr auto auto;
}
.track-album {
display: none;
}
}
@media (max-width: 768px) {
.playlist-description {
max-width: 100%;
}
#downloadAlbumsBtn {
margin-top: 0.5rem;
}
}
@media (max-width: 480px) {
.track {
grid-template-columns: 30px 1fr auto;
}
.playlist-description {
margin-bottom: 1rem;
}
}

View File

@@ -51,18 +51,29 @@
/* Cancel all button styling */
#cancelAllBtn {
background: #ff5555;
background: #8b0000; /* Dark blood red */
border: none;
color: #fff;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
transition: background 0.3s ease;
transition: background 0.3s ease, transform 0.2s ease;
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
#cancelAllBtn:hover {
background: #ff7777;
background: #a30000; /* Slightly lighter red on hover */
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
}
#cancelAllBtn:active {
transform: scale(0.98);
}
/* Close button for the queue sidebar */
@@ -78,11 +89,16 @@
color: #ffffff;
font-size: 20px;
cursor: pointer;
transition: background-color 0.3s ease;
transition: background-color 0.3s ease, transform 0.2s ease;
}
.close-btn:hover {
background-color: #333;
transform: scale(1.05);
}
.close-btn:active {
transform: scale(0.95);
}
/* Container for all queue items */
@@ -90,6 +106,24 @@
/* Allow the container to fill all available space in the sidebar */
flex: 1;
overflow-y: auto;
padding-right: 5px; /* Add slight padding for scrollbar */
scrollbar-width: thin;
scrollbar-color: #1DB954 rgba(255, 255, 255, 0.1);
}
/* Custom scrollbar styles */
#queueItems::-webkit-scrollbar {
width: 6px;
}
#queueItems::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
}
#queueItems::-webkit-scrollbar-thumb {
background-color: #1DB954;
border-radius: 10px;
}
/* Each download queue item */
@@ -98,33 +132,72 @@
padding: 15px;
border-radius: 8px;
margin-bottom: 15px;
transition: background-color 0.3s ease, transform 0.2s ease;
transition: all 0.3s ease;
display: flex;
flex-direction: column;
gap: 6px;
position: relative;
border-left: 4px solid transparent;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
}
/* Animation only for newly added items */
.queue-item-new {
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}
.queue-item:hover {
background-color: #333;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
/* Title text in a queue item */
.queue-item .title {
font-weight: 500;
font-weight: 600;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #fff;
font-size: 14px;
}
/* Type indicator (e.g. track, album) */
.queue-item .type {
font-size: 12px;
font-size: 11px;
color: #1DB954;
text-transform: uppercase;
letter-spacing: 0.5px;
letter-spacing: 0.7px;
font-weight: 600;
background-color: rgba(29, 185, 84, 0.1);
padding: 3px 6px;
border-radius: 4px;
display: inline-block;
width: fit-content;
}
/* Album type - for better visual distinction */
.queue-item .type.album {
color: #4a90e2;
background-color: rgba(74, 144, 226, 0.1);
}
/* Track type */
.queue-item .type.track {
color: #1DB954;
background-color: rgba(29, 185, 84, 0.1);
}
/* Playlist type */
.queue-item .type.playlist {
color: #e67e22;
background-color: rgba(230, 126, 34, 0.1);
}
/* Log text for status messages */
@@ -133,19 +206,22 @@
color: #b3b3b3;
line-height: 1.4;
font-family: 'SF Mono', Menlo, monospace;
padding: 8px 0;
word-break: break-word;
}
/* Optional state indicators for each queue item */
.queue-item--complete {
border-left: 4px solid #1DB954;
.queue-item--complete,
.queue-item.download-success {
border-left-color: #1DB954;
}
.queue-item--error {
border-left: 4px solid #ff5555;
border-left-color: #ff5555;
}
.queue-item--processing {
border-left: 4px solid #4a90e2;
border-left-color: #4a90e2;
}
/* Progress bar for downloads */
@@ -155,13 +231,16 @@
width: 0;
transition: width 0.3s ease;
margin-top: 8px;
border-radius: 2px;
}
/* Progress percentage text */
.progress-percent {
text-align: right;
font-weight: bold;
font-size: 12px;
color: #1DB954;
margin-top: 4px;
}
/* Optional status message colors (if using state classes) */
@@ -197,12 +276,14 @@
/* Loading spinner style */
.loading-spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 3px solid rgba(255, 255, 255, 0.3);
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: #1DB954;
animation: spin 1s ease-in-out infinite;
margin-right: 6px;
vertical-align: middle;
}
@keyframes spin {
@@ -220,6 +301,15 @@
/* Optionally constrain the overall size */
max-width: 24px;
max-height: 24px;
position: absolute;
top: 10px;
right: 10px;
opacity: 0.7;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.cancel-btn:hover {
opacity: 1;
}
.cancel-btn img {
@@ -237,28 +327,65 @@
transform: scale(0.9);
}
/* Group header for multiple albums from same artist */
.queue-group-header {
font-size: 14px;
color: #b3b3b3;
margin: 15px 0 10px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
justify-content: space-between;
}
.queue-group-header span {
display: flex;
align-items: center;
}
.queue-group-header span::before {
content: '';
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
background-color: #1DB954;
margin-right: 8px;
}
/* ------------------------------- */
/* FOOTER & "SHOW MORE" BUTTON */
/* ------------------------------- */
#queueFooter {
text-align: center;
padding-top: 10px;
padding-top: 15px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin-top: 10px;
}
#queueFooter button {
background: #1DB954;
border: none;
padding: 8px 16px;
border-radius: 4px;
padding: 10px 18px;
border-radius: 20px;
color: #fff;
cursor: pointer;
transition: background 0.3s ease;
transition: all 0.3s ease;
font-size: 14px;
font-weight: 500;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
}
#queueFooter button:hover {
background: #17a448;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
#queueFooter button:active {
transform: scale(0.98);
}
/* -------------------------- */
@@ -287,35 +414,77 @@
/* Hover state for all error buttons */
.error-buttons button:hover {
background: #333;
transform: translateY(-1px);
}
.error-buttons button:active {
transform: scale(0.98);
}
/* Specific styles for the Close (X) error button */
.close-error-btn {
background: #ff5555;
background: #ff5555 !important;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 20px;
padding: 0;
border-radius: 50% !important;
font-size: 18px !important;
padding: 0 !important;
}
.close-error-btn:hover {
background: #ff7777;
background: #ff7777 !important;
}
/* Specific styles for the Retry button */
.retry-btn {
background: #1DB954;
padding: 8px 16px;
border-radius: 4px;
font-weight: bold;
background: #1DB954 !important;
padding: 8px 16px !important;
border-radius: 20px !important;
font-weight: 500 !important;
flex: 1;
}
.retry-btn:hover {
background: #17a448;
background: #17a448 !important;
}
/* Empty queue state */
.queue-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
color: #b3b3b3;
text-align: center;
padding: 20px;
}
.queue-empty img {
width: 60px;
height: 60px;
margin-bottom: 15px;
opacity: 0.6;
}
.queue-empty p {
font-size: 14px;
line-height: 1.5;
}
/* Error notification in queue */
.queue-error {
background-color: rgba(192, 57, 43, 0.1);
color: #ff5555;
padding: 10px 15px;
border-radius: 8px;
margin-bottom: 15px;
font-size: 14px;
border-left: 3px solid #ff5555;
animation: fadeIn 0.3s ease;
}
/* ------------------------------- */
@@ -336,8 +505,10 @@
/* Adjust header and title for smaller screens */
.sidebar-header {
flex-direction: column;
align-items: flex-start;
flex-direction: row;
align-items: center;
padding-bottom: 12px;
margin-bottom: 15px;
}
.sidebar-header h2 {
@@ -362,4 +533,22 @@
.queue-item .type {
font-size: 12px;
}
#cancelAllBtn {
padding: 6px 10px;
font-size: 12px;
}
.error-buttons {
flex-direction: row;
}
.close-error-btn {
width: 28px;
height: 28px;
}
.retry-btn {
padding: 6px 12px !important;
}
}

View File

@@ -235,7 +235,7 @@ a:focus {
justify-content: center;
}
/* Style the download buttons icon */
/* Style the download button's icon */
.download-btn img {
width: 20px;
height: 20px;
@@ -260,3 +260,101 @@ a:focus {
text-align: center;
}
}
/* Track page specific styles */
/* Track details formatting */
.track-details {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
color: var(--color-text-secondary);
font-size: 0.9rem;
}
.track-detail-item {
display: flex;
align-items: center;
}
/* Make explicit tag stand out if needed */
#track-explicit:not(:empty) {
background-color: rgba(255, 255, 255, 0.1);
padding: 0.2rem 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Loading indicator animation */
.loading-indicator {
display: inline-block;
position: relative;
width: 80px;
height: 80px;
}
.loading-indicator:after {
content: " ";
display: block;
width: 40px;
height: 40px;
margin: 8px;
border-radius: 50%;
border: 4px solid var(--color-primary);
border-color: var(--color-primary) transparent var(--color-primary) transparent;
animation: loading-rotation 1.2s linear infinite;
}
@keyframes loading-rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Modern gradient for the track name */
#track-name a {
background: linear-gradient(90deg, var(--color-primary), #2ecc71);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
display: inline-block;
}
/* Ensure proper spacing for album and artist links */
#track-artist a,
#track-album a {
transition: color 0.2s ease, text-decoration 0.2s ease;
}
#track-artist a:hover,
#track-album a:hover {
color: var(--color-primary);
}
/* Mobile-specific adjustments */
@media (max-width: 768px) {
.track-details {
flex-direction: column;
gap: 0.25rem;
margin-bottom: 1rem;
}
#track-name a {
font-size: 1.75rem;
}
}
@media (max-width: 480px) {
#track-name a {
font-size: 1.5rem;
}
.track-details {
margin-bottom: 1.5rem;
}
}

5
static/images/album.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="12" cy="12" r="5"></circle>
<circle cx="12" cy="12" r="1"></circle>
</svg>

After

Width:  |  Height:  |  Size: 337 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</svg>

After

Width:  |  Height:  |  Size: 285 B

5
static/images/music.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 18V5l12-2v13"></path>
<circle cx="6" cy="18" r="3"></circle>
<circle cx="18" cy="16" r="3"></circle>
</svg>

After

Width:  |  Height:  |  Size: 308 B

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<!-- Main container -->
<rect x="20" y="15" width="60" height="70" rx="5" ry="5" fill="none" stroke="#b3b3b3" stroke-width="3" stroke-dasharray="5,3"/>
<!-- Upper item (faded) -->
<rect x="25" y="25" width="50" height="10" rx="2" ry="2" fill="#b3b3b3" opacity="0.3"/>
<!-- Middle item (faded) -->
<rect x="25" y="45" width="50" height="10" rx="2" ry="2" fill="#b3b3b3" opacity="0.2"/>
<!-- Bottom item (very faded) -->
<rect x="25" y="65" width="50" height="10" rx="2" ry="2" fill="#b3b3b3" opacity="0.1"/>
<!-- X mark for empty -->
<g transform="translate(50, 50)" stroke="#b3b3b3" stroke-width="3" opacity="0.6">
<line x1="-15" y1="-15" x2="15" y2="15" />
<line x1="15" y1="-15" x2="-15" y2="15" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 882 B

4
static/images/search.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>

After

Width:  |  Height:  |  Size: 285 B

View File

@@ -1,428 +1,401 @@
// main.js
import { downloadQueue } from './queue.js';
document.addEventListener('DOMContentLoaded', () => {
const searchButton = document.getElementById('searchButton');
document.addEventListener('DOMContentLoaded', function() {
// DOM elements
const searchInput = document.getElementById('searchInput');
const searchButton = document.getElementById('searchButton');
const searchType = document.getElementById('searchType');
const resultsContainer = document.getElementById('resultsContainer');
const queueIcon = document.getElementById('queueIcon');
const searchType = document.getElementById('searchType'); // Ensure this element exists in your HTML
const emptyState = document.getElementById('emptyState');
const loadingResults = document.getElementById('loadingResults');
// Preselect the saved search type if available
const storedSearchType = localStorage.getItem('searchType');
if (storedSearchType && searchType) {
searchType.value = storedSearchType;
}
// Save the search type to local storage whenever it changes
if (searchType) {
searchType.addEventListener('change', () => {
localStorage.setItem('searchType', searchType.value);
// Initialize the queue
if (queueIcon) {
queueIcon.addEventListener('click', () => {
downloadQueue.toggleVisibility();
});
}
// Initialize queue icon
if (queueIcon) {
queueIcon.addEventListener('click', () => downloadQueue.toggleVisibility());
}
// Search functionality
// Add event listeners
if (searchButton) {
searchButton.addEventListener('click', performSearch);
}
if (searchInput) {
searchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') performSearch();
searchInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
performSearch();
}
});
// Auto-detect and handle pasted Spotify URLs
searchInput.addEventListener('input', function(e) {
const inputVal = e.target.value.trim();
if (isSpotifyUrl(inputVal)) {
const details = getSpotifyResourceDetails(inputVal);
if (details) {
searchType.value = details.type;
}
}
});
}
// Check for URL parameters
const urlParams = new URLSearchParams(window.location.search);
const query = urlParams.get('q');
const type = urlParams.get('type');
if (query) {
searchInput.value = query;
if (type && ['track', 'album', 'playlist', 'artist'].includes(type)) {
searchType.value = type;
}
performSearch();
} else {
// Show empty state if no query
showEmptyState(true);
}
/**
* Performs the search based on input values
*/
async function performSearch() {
const query = searchInput.value.trim();
if (!query) return;
// Handle direct Spotify URLs
if (isSpotifyUrl(query)) {
const details = getSpotifyResourceDetails(query);
if (details && details.id) {
// Redirect to the appropriate page
window.location.href = `/${details.type}/${details.id}`;
return;
}
}
// Update URL without reloading page
const newUrl = `${window.location.pathname}?q=${encodeURIComponent(query)}&type=${searchType.value}`;
window.history.pushState({ path: newUrl }, '', newUrl);
// Show loading state
showEmptyState(false);
showLoading(true);
resultsContainer.innerHTML = '';
try {
const url = `/api/search?q=${encodeURIComponent(query)}&search_type=${searchType.value}&limit=40`;
const response = await fetch(url);
if (!response.ok) {
throw new Error('Network response was not ok');
}
const data = await response.json();
// Hide loading indicator
showLoading(false);
// Render results
if (data && data.items && data.items.length > 0) {
resultsContainer.innerHTML = '';
data.items.forEach((item, index) => {
if (!item) return; // Skip null/undefined items
const cardElement = createResultCard(item, searchType.value, index);
resultsContainer.appendChild(cardElement);
});
// Attach download handlers to the newly created cards
attachDownloadListeners(data.items);
} else {
// No results found
resultsContainer.innerHTML = `
<div class="empty-search-results">
<p>No results found for "${query}"</p>
</div>
`;
}
} catch (error) {
console.error('Error:', error);
showLoading(false);
resultsContainer.innerHTML = `
<div class="error">
<p>Error searching: ${error.message}</p>
</div>
`;
}
}
/**
* Attaches download handlers to result cards
*/
function attachDownloadListeners(items) {
document.querySelectorAll('.download-btn').forEach((btn, index) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
// Get the corresponding item
const item = items[index];
if (!item) return;
const type = searchType.value;
let url;
// Determine the URL based on item type
if (item.external_urls && item.external_urls.spotify) {
url = item.external_urls.spotify;
} else if (item.href) {
url = item.href;
} else {
showError('Could not determine download URL');
return;
}
// Prepare metadata for the download
const metadata = {
name: item.name || 'Unknown',
artist: item.artists ? item.artists[0]?.name : undefined
};
// Disable the button and update text
btn.disabled = true;
// For artist downloads, show a different message since it will queue multiple albums
if (type === 'artist') {
btn.innerHTML = 'Queueing albums...';
} else {
btn.innerHTML = 'Queueing...';
}
// Start the download
startDownload(url, type, metadata, item.album ? item.album.album_type : null)
.then(() => {
// For artists, show how many albums were queued
if (type === 'artist') {
btn.innerHTML = 'Albums queued!';
// Open the queue automatically for artist downloads
downloadQueue.toggleVisibility(true);
} else {
btn.innerHTML = 'Queued!';
}
})
.catch((error) => {
btn.disabled = false;
btn.innerHTML = 'Download';
showError('Failed to queue download: ' + error.message);
});
});
});
}
/**
* Starts the download process via API
*/
async function startDownload(url, type, item, albumType) {
if (!url || !type) {
showError('Missing URL or type for download');
return;
}
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
let apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`;
// Add name and artist if available for better progress display
if (item.name) {
apiUrl += `&name=${encodeURIComponent(item.name)}`;
}
if (item.artist) {
apiUrl += `&artist=${encodeURIComponent(item.artist)}`;
}
// For artist downloads, include album_type
if (type === 'artist' && albumType) {
apiUrl += `&album_type=${encodeURIComponent(albumType)}`;
}
try {
const response = await fetch(apiUrl);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'Download request failed');
}
const data = await response.json();
// Handle artist downloads which return multiple album_prg_files
if (type === 'artist' && data.album_prg_files && Array.isArray(data.album_prg_files)) {
// Add each album to the download queue separately
data.album_prg_files.forEach(prgFile => {
downloadQueue.addDownload(item, 'album', prgFile, apiUrl);
});
// Show success message for artist download
if (data.message) {
showSuccess(data.message);
}
} else if (data.prg_file) {
// Handle single-file downloads (tracks, albums, playlists)
downloadQueue.addDownload(item, type, data.prg_file, apiUrl);
} else {
throw new Error('Invalid response format from server');
}
} catch (error) {
showError('Download failed: ' + (error.message || 'Unknown error'));
throw error;
}
}
/**
* Shows an error message
*/
function showError(message) {
const errorDiv = document.createElement('div');
errorDiv.className = 'error';
errorDiv.textContent = message;
document.body.appendChild(errorDiv);
// Auto-remove after 5 seconds
setTimeout(() => errorDiv.remove(), 5000);
}
/**
* Shows a success message
*/
function showSuccess(message) {
const successDiv = document.createElement('div');
successDiv.className = 'success';
successDiv.textContent = message;
document.body.appendChild(successDiv);
// Auto-remove after 5 seconds
setTimeout(() => successDiv.remove(), 5000);
}
/**
* Checks if a string is a valid Spotify URL
*/
function isSpotifyUrl(url) {
return url.includes('open.spotify.com') ||
url.includes('spotify:') ||
url.includes('link.tospotify.com');
}
/**
* Extracts details from a Spotify URL
*/
function getSpotifyResourceDetails(url) {
const regex = /spotify\.com\/(track|album|playlist|artist)\/([a-zA-Z0-9]+)/;
const match = url.match(regex);
if (match) {
return {
type: match[1],
id: match[2]
};
}
return null;
}
/**
* Formats milliseconds to MM:SS
*/
function msToMinutesSeconds(ms) {
if (!ms) return '0:00';
const minutes = Math.floor(ms / 60000);
const seconds = ((ms % 60000) / 1000).toFixed(0);
return `${minutes}:${seconds.padStart(2, '0')}`;
}
/**
* Creates a result card element
*/
function createResultCard(item, type, index) {
const cardElement = document.createElement('div');
cardElement.className = 'result-card';
// Set cursor to pointer for clickable cards
cardElement.style.cursor = 'pointer';
// Get the appropriate image URL
let imageUrl = '/static/images/placeholder.jpg';
if (item.album && item.album.images && item.album.images.length > 0) {
imageUrl = item.album.images[0].url;
} else if (item.images && item.images.length > 0) {
imageUrl = item.images[0].url;
}
// Get the appropriate details based on type
let subtitle = '';
let details = '';
switch (type) {
case 'track':
subtitle = item.artists ? item.artists.map(a => a.name).join(', ') : 'Unknown Artist';
details = item.album ? `<span>${item.album.name}</span><span class="duration">${msToMinutesSeconds(item.duration_ms)}</span>` : '';
break;
case 'album':
subtitle = item.artists ? item.artists.map(a => a.name).join(', ') : 'Unknown Artist';
details = `<span>${item.total_tracks || 0} tracks</span><span>${item.release_date ? new Date(item.release_date).getFullYear() : ''}</span>`;
break;
case 'playlist':
subtitle = `By ${item.owner ? item.owner.display_name : 'Unknown'}`;
details = `<span>${item.tracks && item.tracks.total ? item.tracks.total : 0} tracks</span>`;
break;
case 'artist':
subtitle = 'Artist';
details = item.genres ? `<span>${item.genres.slice(0, 2).join(', ')}</span>` : '';
break;
}
// Build the HTML
cardElement.innerHTML = `
<div class="album-art-wrapper">
<img class="album-art" src="${imageUrl}" alt="${item.name || 'Item'}" onerror="this.src='/static/images/placeholder.jpg'">
</div>
<div class="track-title">${item.name || 'Unknown'}</div>
<div class="track-artist">${subtitle}</div>
<div class="track-details">${details}</div>
<button class="download-btn btn-primary">
<img src="/static/images/download.svg" alt="Download" />
Download
</button>
`;
// Add click event to navigate to the item's detail page
cardElement.addEventListener('click', (e) => {
// Don't trigger if the download button was clicked
if (e.target.classList.contains('download-btn') ||
e.target.parentElement.classList.contains('download-btn')) {
return;
}
if (item.id) {
window.location.href = `/${type}/${item.id}`;
}
});
return cardElement;
}
/**
* Show/hide the empty state
*/
function showEmptyState(show) {
if (emptyState) {
emptyState.style.display = show ? 'flex' : 'none';
}
}
/**
* Show/hide the loading indicator
*/
function showLoading(show) {
if (loadingResults) {
loadingResults.classList.toggle('hidden', !show);
}
}
});
async function performSearch() {
const searchInput = document.getElementById('searchInput');
const searchType = document.getElementById('searchType');
const resultsContainer = document.getElementById('resultsContainer');
if (!searchInput || !searchType || !resultsContainer) {
console.error('Required DOM elements not found');
return;
}
const query = searchInput.value.trim();
const typeValue = searchType.value;
if (!query) {
showError('Please enter a search term');
return;
}
// If the query is a Spotify URL for a supported resource, redirect to our route.
if (isSpotifyUrl(query)) {
try {
const { type, id } = getSpotifyResourceDetails(query);
const supportedTypes = ['track', 'album', 'playlist', 'artist'];
if (!supportedTypes.includes(type))
throw new Error('Unsupported URL type');
// Redirect to {base_url}/{type}/{id}
window.location.href = `${window.location.origin}/${type}/${id}`;
return;
} catch (error) {
showError(`Invalid Spotify URL: ${error?.message || 'Unknown error'}`);
return;
}
}
resultsContainer.innerHTML = '<div class="loading">Searching...</div>';
try {
// Fetch config to get active Spotify account
const configResponse = await fetch('/api/config');
const config = await configResponse.json();
const mainAccount = config?.spotify || '';
// Add the main parameter to the search API call
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}&search_type=${typeValue}&limit=50&main=${mainAccount}`);
const data = await response.json();
if (data.error) throw new Error(data.error);
// When mapping the items, include the index so that each card gets a data-index attribute.
const items = data.data?.[`${typeValue}s`]?.items;
if (!items?.length) {
resultsContainer.innerHTML = '<div class="error">No results found</div>';
return;
}
resultsContainer.innerHTML = items
.map((item, index) => item ? createResultCard(item, typeValue, index) : '')
.filter(card => card) // Filter out empty strings
.join('');
attachDownloadListeners(items);
} catch (error) {
showError(error?.message || 'Search failed');
}
}
/**
* Attaches event listeners to all download buttons (both standard and small versions).
* Instead of using the NodeList index (which can be off when multiple buttons are in one card),
* we look up the closest result card's data-index to get the correct item.
*/
function attachDownloadListeners(items) {
document.querySelectorAll('.download-btn, .download-btn-small').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const url = e.currentTarget.dataset.url || '';
const type = e.currentTarget.dataset.type || '';
const albumType = e.currentTarget.dataset.albumType || '';
// Get the parent result card and its data-index
const card = e.currentTarget.closest('.result-card');
const idx = card ? card.getAttribute('data-index') : null;
const item = (idx !== null && items[idx]) ? items[idx] : null;
// Remove the button or card from the UI as appropriate.
if (e.currentTarget.classList.contains('main-download')) {
if (card) card.remove();
} else {
e.currentTarget.remove();
}
if (url && type) {
startDownload(url, type, item, albumType);
}
});
});
}
/**
* Calls the appropriate downloadQueue method based on the type.
* For artists, this function will use the default parameters (which you can adjust)
* so that the backend endpoint (at /artist/download) receives the required query parameters.
*/
async function startDownload(url, type, item, albumType) {
if (!url || !type) {
showError('Missing URL or type for download');
return;
}
// Enrich the item object with the artist property.
if (item) {
if (type === 'track' || type === 'album') {
item.artist = item.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist';
} else if (type === 'playlist') {
item.artist = item.owner?.display_name || 'Unknown Owner';
} else if (type === 'artist') {
item.artist = item.name || 'Unknown Artist';
}
} else {
item = { name: 'Unknown', artist: 'Unknown Artist' };
}
try {
if (type === 'track') {
await downloadQueue.startTrackDownload(url, item);
} else if (type === 'playlist') {
await downloadQueue.startPlaylistDownload(url, item);
} else if (type === 'album') {
await downloadQueue.startAlbumDownload(url, item);
} else if (type === 'artist') {
// The downloadQueue.startArtistDownload should be implemented to call your
// backend artist endpoint (e.g. /artist/download) with proper query parameters.
await downloadQueue.startArtistDownload(url, item, albumType);
} else {
throw new Error(`Unsupported type: ${type}`);
}
} catch (error) {
showError('Download failed: ' + (error?.message || 'Unknown error'));
}
}
// UI Helper Functions
function showError(message) {
const resultsContainer = document.getElementById('resultsContainer');
if (resultsContainer) {
resultsContainer.innerHTML = `<div class="error">${message || 'An error occurred'}</div>`;
}
}
function isSpotifyUrl(url) {
return url && url.startsWith('https://open.spotify.com/');
}
/**
* Extracts the resource type and ID from a Spotify URL.
* Expected URL format: https://open.spotify.com/{type}/{id}
*/
function getSpotifyResourceDetails(url) {
if (!url) throw new Error('Empty URL provided');
const urlObj = new URL(url);
const pathParts = urlObj.pathname.split('/');
if (pathParts.length < 3 || !pathParts[1] || !pathParts[2]) {
throw new Error('Invalid Spotify URL');
}
return {
type: pathParts[1],
id: pathParts[2]
};
}
function msToMinutesSeconds(ms) {
if (!ms || isNaN(ms)) return '0:00';
const minutes = Math.floor(ms / 60000);
const seconds = ((ms % 60000) / 1000).toFixed(0);
return `${minutes}:${seconds.padStart(2, '0')}`;
}
/**
* Create a result card for a search result.
* The additional parameter "index" is used to set a data-index attribute on the card.
*/
function createResultCard(item, type, index) {
if (!item) return '';
let newUrl = '#';
try {
const spotifyUrl = item.external_urls?.spotify;
if (spotifyUrl) {
const parsedUrl = new URL(spotifyUrl);
newUrl = window.location.origin + parsedUrl.pathname;
}
} catch (e) {
console.error('Error parsing URL:', e);
}
let imageUrl, title, subtitle, details;
switch (type) {
case 'track':
imageUrl = item.album?.images?.[0]?.url || '/static/images/placeholder.jpg';
title = item.name || 'Unknown Track';
subtitle = item.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist';
details = `
<span>${item.album?.name || 'Unknown Album'}</span>
<span class="duration">${msToMinutesSeconds(item.duration_ms)}</span>
`;
return `
<div class="result-card" data-id="${item.id || ''}" data-index="${index}">
<div class="album-art-wrapper">
<img src="${imageUrl}" class="album-art" alt="${type} cover">
</div>
<div class="title-and-view">
<div class="track-title">${title}</div>
<div class="title-buttons">
<button class="download-btn-small"
data-url="${item.external_urls?.spotify || ''}"
data-type="${type}"
title="Download">
<img src="/static/images/download.svg" alt="Download">
</button>
<button class="view-btn" onclick="window.location.href='${newUrl}'" title="View">
<img src="/static/images/view.svg" alt="View">
</button>
</div>
</div>
<div class="track-artist">${subtitle}</div>
<div class="track-details">${details}</div>
</div>
`;
case 'playlist':
imageUrl = item.images?.[0]?.url || '/static/images/placeholder.jpg';
title = item.name || 'Unknown Playlist';
subtitle = item.owner?.display_name || 'Unknown Owner';
details = `
<span>${item.tracks?.total || '0'} tracks</span>
<span class="duration">${item.description || 'No description'}</span>
`;
return `
<div class="result-card" data-id="${item.id || ''}" data-index="${index}">
<div class="album-art-wrapper">
<img src="${imageUrl}" class="album-art" alt="${type} cover">
</div>
<div class="title-and-view">
<div class="track-title">${title}</div>
<div class="title-buttons">
<button class="download-btn-small"
data-url="${item.external_urls?.spotify || ''}"
data-type="${type}"
title="Download">
<img src="/static/images/download.svg" alt="Download">
</button>
<button class="view-btn" onclick="window.location.href='${newUrl}'" title="View">
<img src="/static/images/view.svg" alt="View">
</button>
</div>
</div>
<div class="track-artist">${subtitle}</div>
<div class="track-details">${details}</div>
</div>
`;
case 'album':
imageUrl = item.images?.[0]?.url || '/static/images/placeholder.jpg';
title = item.name || 'Unknown Album';
subtitle = item.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist';
details = `
<span>${item.release_date || 'Unknown release date'}</span>
<span class="duration">${item.total_tracks || '0'} tracks</span>
`;
return `
<div class="result-card" data-id="${item.id || ''}" data-index="${index}">
<div class="album-art-wrapper">
<img src="${imageUrl}" class="album-art" alt="${type} cover">
</div>
<div class="title-and-view">
<div class="track-title">${title}</div>
<div class="title-buttons">
<button class="download-btn-small"
data-url="${item.external_urls?.spotify || ''}"
data-type="${type}"
title="Download">
<img src="/static/images/download.svg" alt="Download">
</button>
<button class="view-btn" onclick="window.location.href='${newUrl}'" title="View">
<img src="/static/images/view.svg" alt="View">
</button>
</div>
</div>
<div class="track-artist">${subtitle}</div>
<div class="track-details">${details}</div>
</div>
`;
case 'artist':
imageUrl = (item.images && item.images.length) ? item.images[0].url : '/static/images/placeholder.jpg';
title = item.name || 'Unknown Artist';
subtitle = (item.genres && item.genres.length) ? item.genres.join(', ') : 'Unknown genres';
details = `<span>Followers: ${item.followers?.total || 'N/A'}</span>`;
return `
<div class="result-card" data-id="${item.id || ''}" data-index="${index}">
<div class="album-art-wrapper">
<img src="${imageUrl}" class="album-art" alt="${type} cover">
</div>
<div class="title-and-view">
<div class="track-title">${title}</div>
<div class="title-buttons">
<!-- A primary download button (if you want one for a "default" download) -->
<button class="download-btn-small"
data-url="${item.external_urls?.spotify || ''}"
data-type="${type}"
title="Download">
<img src="/static/images/download.svg" alt="Download">
</button>
<button class="view-btn" onclick="window.location.href='${newUrl}'" title="View">
<img src="/static/images/view.svg" alt="View">
</button>
</div>
</div>
<div class="track-artist">${subtitle}</div>
<div class="track-details">${details}</div>
<!-- Artist-specific download options -->
<div class="artist-download-buttons">
<div class="download-options-container">
<button class="options-toggle" onclick="this.nextElementSibling.classList.toggle('expanded')">
More Options
<svg class="toggle-chevron" viewBox="0 0 24 24">
<path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/>
</svg>
</button>
<div class="secondary-options">
<button class="download-btn option-btn"
data-url="${item.external_urls?.spotify || ''}"
data-type="${type}"
data-album-type="album">
<img src="https://www.svgrepo.com/show/40029/vinyl-record.svg"
alt="Albums"
class="type-icon" />
Albums
</button>
<button class="download-btn option-btn"
data-url="${item.external_urls?.spotify || ''}"
data-type="${type}"
data-album-type="single">
<img src="https://www.svgrepo.com/show/147837/cassette.svg"
alt="Singles"
class="type-icon" />
Singles
</button>
<button class="download-btn option-btn"
data-url="${item.external_urls?.spotify || ''}"
data-type="${type}"
data-album-type="compilation">
<img src="https://brandeps.com/icon-download/C/Collection-icon-vector-01.svg"
alt="Compilations"
class="type-icon" />
Compilations
</button>
</div>
</div>
</div>
</div>
`;
default:
title = item.name || 'Unknown';
subtitle = '';
details = '';
return `
<div class="result-card" data-id="${item.id || ''}" data-index="${index}">
<div class="album-art-wrapper">
<img src="${imageUrl || '/static/images/placeholder.jpg'}" class="album-art" alt="${type} cover">
</div>
<div class="title-and-view">
<div class="track-title">${title}</div>
<div class="title-buttons">
<button class="download-btn-small"
data-url="${item.external_urls?.spotify || ''}"
data-type="${type}"
title="Download">
<img src="/static/images/download.svg" alt="Download">
</button>
<button class="view-btn" onclick="window.location.href='${newUrl}'" title="View">
<img src="/static/images/view.svg" alt="View">
</button>
</div>
</div>
<div class="track-artist">${subtitle}</div>
<div class="track-details">${details}</div>
</div>
`;
}
}

View File

@@ -46,8 +46,10 @@ class DownloadQueue {
<div class="sidebar-header">
<h2>Download Queue (<span id="queueTotalCount">0</span> items)</h2>
<div class="header-actions">
<button id="cancelAllBtn" aria-label="Cancel all downloads">Cancel all</button>
<button class="close-btn" aria-label="Close queue">&times;</button>
<button id="cancelAllBtn" aria-label="Cancel all downloads">
<img src="https://www.svgrepo.com/show/488384/skull-head.svg" alt="Skull" class="skull-icon">
Cancel all
</button>
</div>
</div>
<div id="queueItems" aria-live="polite"></div>
@@ -68,6 +70,20 @@ class DownloadQueue {
const queueSidebar = document.getElementById('downloadQueue');
queueSidebar.hidden = !this.currentConfig.downloadQueueVisible;
queueSidebar.classList.toggle('active', this.currentConfig.downloadQueueVisible);
// Initialize the queue icon based on sidebar visibility
const queueIcon = document.getElementById('queueIcon');
if (queueIcon) {
if (this.currentConfig.downloadQueueVisible) {
queueIcon.innerHTML = '<span class="queue-x">&times;</span>';
queueIcon.setAttribute('aria-expanded', 'true');
queueIcon.classList.add('queue-icon-active'); // Add red tint class
} else {
queueIcon.innerHTML = '<img src="/static/images/queue.svg" alt="Queue Icon">';
queueIcon.setAttribute('aria-expanded', 'false');
queueIcon.classList.remove('queue-icon-active'); // Remove red tint class
}
}
}
/* Event Handling */
@@ -80,16 +96,6 @@ class DownloadQueue {
}
});
// Close queue when the close button is clicked.
const queueSidebar = document.getElementById('downloadQueue');
if (queueSidebar) {
queueSidebar.addEventListener('click', async (e) => {
if (e.target.closest('.close-btn')) {
await this.toggleVisibility();
}
});
}
// "Cancel all" button.
const cancelAllBtn = document.getElementById('cancelAllBtn');
if (cancelAllBtn) {
@@ -118,13 +124,30 @@ class DownloadQueue {
}
/* Public API */
async toggleVisibility() {
async toggleVisibility(force) {
const queueSidebar = document.getElementById('downloadQueue');
const isVisible = !queueSidebar.classList.contains('active');
// If force is provided, use that value, otherwise toggle the current state
const isVisible = force !== undefined ? force : !queueSidebar.classList.contains('active');
queueSidebar.classList.toggle('active', isVisible);
queueSidebar.hidden = !isVisible;
// Update the queue icon to show X when visible or queue icon when hidden
const queueIcon = document.getElementById('queueIcon');
if (queueIcon) {
if (isVisible) {
// Replace the image with an X and add red tint
queueIcon.innerHTML = '<span class="queue-x">&times;</span>';
queueIcon.setAttribute('aria-expanded', 'true');
queueIcon.classList.add('queue-icon-active'); // Add red tint class
} else {
// Restore the original queue icon and remove red tint
queueIcon.innerHTML = '<img src="/static/images/queue.svg" alt="Queue Icon">';
queueIcon.setAttribute('aria-expanded', 'false');
queueIcon.classList.remove('queue-icon-active'); // Remove red tint class
}
}
// Persist the state locally so it survives refreshes.
localStorage.setItem("downloadQueueVisible", isVisible);
@@ -138,6 +161,18 @@ class DownloadQueue {
// Revert UI if save failed.
queueSidebar.classList.toggle('active', !isVisible);
queueSidebar.hidden = isVisible;
// Also revert the icon back
if (queueIcon) {
if (!isVisible) {
queueIcon.innerHTML = '<span class="queue-x">&times;</span>';
queueIcon.setAttribute('aria-expanded', 'true');
queueIcon.classList.add('queue-icon-active'); // Add red tint class
} else {
queueIcon.innerHTML = '<img src="/static/images/queue.svg" alt="Queue Icon">';
queueIcon.setAttribute('aria-expanded', 'false');
queueIcon.classList.remove('queue-icon-active'); // Remove red tint class
}
}
this.dispatchEvent('queueVisibilityChanged', { visible: !isVisible });
this.showError('Failed to save queue visibility');
}
@@ -186,6 +221,14 @@ class DownloadQueue {
if (data.type) {
entry.type = data.type;
// Update type display if element exists
const typeElement = entry.element.querySelector('.type');
if (typeElement) {
typeElement.textContent = data.type.charAt(0).toUpperCase() + data.type.slice(1);
// Update type class without triggering animation
typeElement.className = `type ${data.type}`;
}
}
if (!entry.requestUrl && data.original_request) {
@@ -224,8 +267,16 @@ class DownloadQueue {
entry.lastStatus = progress;
entry.lastUpdated = Date.now();
entry.status = progress.status;
logElement.textContent = this.getStatusMessage(progress);
// Update status message without recreating the element
if (logElement) {
const statusMessage = this.getStatusMessage(progress);
logElement.textContent = statusMessage;
}
// Apply appropriate CSS classes based on status
this.applyStatusClasses(entry, progress);
// Save updated status to cache.
this.queueCache[entry.prgFile] = progress;
localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache));
@@ -266,13 +317,71 @@ class DownloadQueue {
intervalId: null,
uniqueId: queueId,
retryCount: 0,
autoRetryInterval: null
autoRetryInterval: null,
isNew: true // Add flag to track if this is a new entry
};
// If cached info exists for this PRG file, use it.
if (this.queueCache[prgFile]) {
entry.lastStatus = this.queueCache[prgFile];
const logEl = entry.element.querySelector('.log');
logEl.textContent = this.getStatusMessage(this.queueCache[prgFile]);
// Special handling for error states to restore UI with buttons
if (entry.lastStatus.status === 'error') {
// Hide the cancel button if in error state
const cancelBtn = entry.element.querySelector('.cancel-btn');
if (cancelBtn) {
cancelBtn.style.display = 'none';
}
// Determine if we can retry
const canRetry = entry.retryCount < this.MAX_RETRIES && entry.requestUrl;
if (canRetry) {
// Create error UI with retry button
logEl.innerHTML = `
<div class="error-message">${this.getStatusMessage(entry.lastStatus)}</div>
<div class="error-buttons">
<button class="close-error-btn" title="Close">&times;</button>
<button class="retry-btn" title="Retry">Retry</button>
</div>
`;
// Add event listeners
logEl.querySelector('.close-error-btn').addEventListener('click', () => {
if (entry.autoRetryInterval) {
clearInterval(entry.autoRetryInterval);
entry.autoRetryInterval = null;
}
this.cleanupEntry(queueId);
});
logEl.querySelector('.retry-btn').addEventListener('click', async () => {
if (entry.autoRetryInterval) {
clearInterval(entry.autoRetryInterval);
entry.autoRetryInterval = null;
}
this.retryDownload(queueId, logEl);
});
} else {
// Cannot retry - just show error with close button
logEl.innerHTML = `
<div class="error-message">${this.getStatusMessage(entry.lastStatus)}</div>
<div class="error-buttons">
<button class="close-error-btn" title="Close">&times;</button>
</div>
`;
logEl.querySelector('.close-error-btn').addEventListener('click', () => {
this.cleanupEntry(queueId);
});
}
} else {
// For non-error states, just set the message text
logEl.textContent = this.getStatusMessage(entry.lastStatus);
}
// Apply appropriate CSS classes based on cached status
this.applyStatusClasses(entry, this.queueCache[prgFile]);
}
return entry;
}
@@ -288,21 +397,53 @@ class DownloadQueue {
const displayType = type.charAt(0).toUpperCase() + type.slice(1);
const div = document.createElement('article');
div.className = 'queue-item';
div.className = 'queue-item queue-item-new'; // Add the animation class
div.setAttribute('aria-live', 'polite');
div.setAttribute('aria-atomic', 'true');
div.innerHTML = `
<div class="title">${displayTitle}</div>
<div class="type">${displayType}</div>
<div class="type ${type}">${displayType}</div>
<div class="log" id="log-${queueId}-${prgFile}">${defaultMessage}</div>
<button class="cancel-btn" data-prg="${prgFile}" data-type="${type}" data-queueid="${queueId}" title="Cancel Download">
<img src="https://www.svgrepo.com/show/488384/skull-head.svg" alt="Cancel Download">
</button>
`;
div.querySelector('.cancel-btn').addEventListener('click', (e) => this.handleCancelDownload(e));
// Remove the animation class after animation completes
setTimeout(() => {
div.classList.remove('queue-item-new');
}, 300); // Match the animation duration
return div;
}
// Add a helper method to apply the right CSS classes based on status
applyStatusClasses(entry, status) {
if (!entry || !entry.element || !status) return;
// Clear existing status classes
entry.element.classList.remove('queue-item--processing', 'queue-item--error', 'download-success');
// Apply appropriate class based on status
if (status.status === 'processing' || status.status === 'downloading' || status.status === 'progress') {
entry.element.classList.add('queue-item--processing');
} else if (status.status === 'error') {
entry.element.classList.add('queue-item--error');
entry.hasEnded = true;
} else if (status.status === 'complete' || status.status === 'done') {
entry.element.classList.add('download-success');
entry.hasEnded = true;
} else if (status.status === 'cancel' || status.status === 'interrupted') {
entry.hasEnded = true;
}
// Special case for retry status
if (status.retrying || status.status === 'retrying') {
entry.element.classList.add('queue-item--processing');
}
}
async handleCancelDownload(e) {
const btn = e.target.closest('button');
btn.style.display = 'none';
@@ -358,14 +499,61 @@ class DownloadQueue {
});
document.getElementById('queueTotalCount').textContent = entries.length;
// Only recreate the container content if really needed
const visibleEntries = entries.slice(0, this.visibleCount);
container.innerHTML = '';
visibleEntries.forEach(entry => {
container.appendChild(entry.element);
if (!entry.intervalId) {
this.startEntryMonitoring(entry.uniqueId);
// Handle empty state
if (entries.length === 0) {
container.innerHTML = `
<div class="queue-empty">
<img src="/static/images/queue-empty.svg" alt="Empty queue" onerror="this.src='/static/images/queue.svg'">
<p>Your download queue is empty</p>
</div>
`;
} else {
// Get currently visible items
const visibleItems = Array.from(container.children).filter(el => el.classList.contains('queue-item'));
// Update container more efficiently
if (visibleItems.length === 0) {
// No items in container, append all visible entries
container.innerHTML = ''; // Clear any empty state
visibleEntries.forEach(entry => {
// Start monitoring if needed
if (!entry.intervalId) {
this.startEntryMonitoring(entry.uniqueId);
}
container.appendChild(entry.element);
});
} else {
// Container already has items, update more efficiently
// Create a map of current DOM elements by queue ID
const existingElementMap = {};
visibleItems.forEach(el => {
const queueId = el.querySelector('.cancel-btn')?.dataset.queueid;
if (queueId) existingElementMap[queueId] = el;
});
// Clear container to re-add in correct order
container.innerHTML = '';
// Add visible entries in correct order
visibleEntries.forEach(entry => {
// Start monitoring if needed
if (!entry.intervalId) {
this.startEntryMonitoring(entry.uniqueId);
}
container.appendChild(entry.element);
// Mark the entry as not new anymore
entry.isNew = false;
});
}
});
}
// Stop monitoring entries that are no longer visible
entries.slice(this.visibleCount).forEach(entry => {
if (entry.intervalId) {
clearInterval(entry.intervalId);
@@ -373,6 +561,7 @@ class DownloadQueue {
}
});
// Update footer
footer.innerHTML = '';
if (entries.length > this.visibleCount) {
const remaining = entries.length - this.visibleCount;
@@ -576,6 +765,14 @@ class DownloadQueue {
clearInterval(entry.intervalId);
const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`);
if (!logElement) return;
// Save the terminal state to the cache for persistence across reloads
this.queueCache[entry.prgFile] = progress;
localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache));
// Add status classes without triggering animations
this.applyStatusClasses(entry, progress);
if (progress.status === 'error') {
const cancelBtn = entry.element.querySelector('.cancel-btn');
if (cancelBtn) {
@@ -655,8 +852,6 @@ class DownloadQueue {
if (cancelBtn) {
cancelBtn.style.display = 'none';
}
// Add success styling
entry.element.classList.add('download-success');
setTimeout(() => this.cleanupEntry(queueId), 5000);
} else {
logElement.textContent = this.getStatusMessage(progress);
@@ -910,12 +1105,27 @@ class DownloadQueue {
const queueId = this.generateQueueId();
const entry = this.createQueueEntry(dummyItem, dummyItem.type, prgFile, queueId, requestUrl);
entry.retryCount = retryCount;
// Set the entry's last status from the PRG file
if (prgData.last_line) {
entry.lastStatus = prgData.last_line;
// Make sure to save the status to the cache for persistence
this.queueCache[prgFile] = prgData.last_line;
// Apply proper status classes
this.applyStatusClasses(entry, prgData.last_line);
}
this.downloadQueue[queueId] = entry;
} catch (error) {
console.error("Error fetching details for", prgFile, error);
}
}
// Save updated cache to localStorage
localStorage.setItem("downloadQueueCache", JSON.stringify(this.queueCache));
// After adding all entries, update the queue
this.updateQueueOrder();
} catch (error) {