frontend prettier
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
457
static/css/main/base.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,7 +235,7 @@ a:focus {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Style the download button’s 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
5
static/images/album.svg
Normal 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 |
4
static/images/arrow-left.svg
Normal file
4
static/images/arrow-left.svg
Normal 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
5
static/images/music.svg
Normal 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 |
20
static/images/queue-empty.svg
Normal file
20
static/images/queue-empty.svg
Normal 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
4
static/images/search.svg
Normal 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 |
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">×</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">×</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">×</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">×</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">×</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">×</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) {
|
||||
|
||||
Reference in New Issue
Block a user