complete ui overhaul
@@ -4,23 +4,34 @@ repos:
|
|||||||
rev: v5.0.0
|
rev: v5.0.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: check-symlinks
|
- id: check-symlinks
|
||||||
|
exclude: ^spotizerr-ui/
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
|
exclude: ^spotizerr-ui/
|
||||||
- id: mixed-line-ending
|
- id: mixed-line-ending
|
||||||
args: [--fix=lf]
|
args: [--fix=lf]
|
||||||
|
exclude: ^spotizerr-ui/
|
||||||
- id: check-yaml
|
- id: check-yaml
|
||||||
exclude: 'mkdocs.yml'
|
exclude: 'mkdocs.yml|^spotizerr-ui/'
|
||||||
- id: check-toml
|
- id: check-toml
|
||||||
|
exclude: ^spotizerr-ui/
|
||||||
- id: check-json
|
- id: check-json
|
||||||
|
exclude: ^spotizerr-ui/
|
||||||
- id: check-ast
|
- id: check-ast
|
||||||
|
exclude: ^spotizerr-ui/
|
||||||
- id: debug-statements
|
- id: debug-statements
|
||||||
|
exclude: ^spotizerr-ui/
|
||||||
- id: check-merge-conflict
|
- id: check-merge-conflict
|
||||||
|
exclude: ^spotizerr-ui/
|
||||||
- id: check-shebang-scripts-are-executable
|
- id: check-shebang-scripts-are-executable
|
||||||
|
exclude: ^spotizerr-ui/
|
||||||
- id: check-added-large-files
|
- id: check-added-large-files
|
||||||
args: [--maxkb=10000]
|
args: [--maxkb=10000]
|
||||||
|
exclude: ^spotizerr-ui/
|
||||||
- repo: https://github.com/python-jsonschema/check-jsonschema
|
- repo: https://github.com/python-jsonschema/check-jsonschema
|
||||||
rev: '0.33.0'
|
rev: '0.33.0'
|
||||||
hooks:
|
hooks:
|
||||||
- id: check-github-workflows
|
- id: check-github-workflows
|
||||||
|
exclude: ^spotizerr-ui/
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
# Ruff version.
|
# Ruff version.
|
||||||
rev: v0.11.13
|
rev: v0.11.13
|
||||||
@@ -29,13 +40,16 @@ repos:
|
|||||||
- id: ruff
|
- id: ruff
|
||||||
types_or: [python, pyi, jupyter]
|
types_or: [python, pyi, jupyter]
|
||||||
args: [--fix]
|
args: [--fix]
|
||||||
|
exclude: ^spotizerr-ui/
|
||||||
# Run the formatter.
|
# Run the formatter.
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
types_or: [python, pyi, jupyter]
|
types_or: [python, pyi, jupyter]
|
||||||
|
exclude: ^spotizerr-ui/
|
||||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||||
rev: 'v1.16.0'
|
rev: 'v1.16.0'
|
||||||
hooks:
|
hooks:
|
||||||
- id: mypy
|
- id: mypy
|
||||||
args: [--no-strict-optional, --ignore-missing-imports]
|
args: [--no-strict-optional, --ignore-missing-imports]
|
||||||
|
exclude: ^spotizerr-ui/
|
||||||
# NOTE: you might need to add some deps here:
|
# NOTE: you might need to add some deps here:
|
||||||
additional_dependencies: [waitress==3.0.2, types-waitress]
|
additional_dependencies: [waitress==3.0.2, types-waitress]
|
||||||
|
|||||||
29
Dockerfile
@@ -4,7 +4,7 @@ FROM python:3.12-slim
|
|||||||
# Set the working directory in the container
|
# Set the working directory in the container
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies
|
# Install system dependencies, including Node.js and npm (for pnpm)
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
build-essential \
|
||||||
gosu \
|
gosu \
|
||||||
@@ -15,23 +15,28 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& apt-get clean \
|
&& apt-get clean \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy requirements file
|
# Install pnpm globally
|
||||||
COPY requirements.txt .
|
RUN npm install -g pnpm
|
||||||
|
|
||||||
|
# --- Backend Python Dependencies ---
|
||||||
|
# Copy only the requirements file to leverage Docker cache
|
||||||
|
COPY requirements.txt .
|
||||||
# Install Python dependencies
|
# Install Python dependencies
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Copy application code
|
# --- Frontend Node.js Dependencies ---
|
||||||
|
# Copy package manager files to leverage Docker cache
|
||||||
|
COPY spotizerr-ui/package.json spotizerr-ui/pnpm-lock.yaml ./spotizerr-ui/
|
||||||
|
# Install frontend dependencies
|
||||||
|
RUN cd spotizerr-ui && pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# --- Application Code & Frontend Build ---
|
||||||
|
# Copy the rest of the application code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
# Build the frontend application
|
||||||
|
RUN cd spotizerr-ui && pnpm build
|
||||||
|
|
||||||
# Install TypeScript globally
|
# --- Final Container Setup ---
|
||||||
RUN npm install -g typescript
|
|
||||||
|
|
||||||
# Compile TypeScript
|
|
||||||
# tsc will use tsconfig.json from the current directory (/app)
|
|
||||||
# It will read from /app/src/js and output to /app/static/js
|
|
||||||
RUN tsc
|
|
||||||
|
|
||||||
# Create necessary directories with proper permissions
|
# Create necessary directories with proper permissions
|
||||||
RUN mkdir -p downloads data/config data/creds data/watch data/history logs/tasks && \
|
RUN mkdir -p downloads data/config data/creds data/watch data/history logs/tasks && \
|
||||||
chmod -R 777 downloads data logs
|
chmod -R 777 downloads data logs
|
||||||
|
|||||||
60
app.py
@@ -1,4 +1,4 @@
|
|||||||
from flask import Flask, request, send_from_directory, render_template
|
from flask import Flask, request, send_from_directory
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
from routes.search import search_bp
|
from routes.search import search_bp
|
||||||
from routes.credentials import credentials_bp
|
from routes.credentials import credentials_bp
|
||||||
@@ -145,7 +145,7 @@ def check_redis_connection():
|
|||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
app = Flask(__name__, template_folder="static/html")
|
app = Flask(__name__, static_folder="spotizerr-ui/dist", static_url_path="/")
|
||||||
|
|
||||||
# Set up CORS
|
# Set up CORS
|
||||||
CORS(app)
|
CORS(app)
|
||||||
@@ -164,54 +164,14 @@ def create_app():
|
|||||||
app.register_blueprint(prgs_bp, url_prefix="/api/prgs")
|
app.register_blueprint(prgs_bp, url_prefix="/api/prgs")
|
||||||
app.register_blueprint(history_bp, url_prefix="/api/history")
|
app.register_blueprint(history_bp, url_prefix="/api/history")
|
||||||
|
|
||||||
# Serve frontend
|
# Serve React App
|
||||||
@app.route("/")
|
@app.route("/", defaults={"path": ""})
|
||||||
def serve_index():
|
@app.route("/<path:path>")
|
||||||
return render_template("main.html")
|
def serve_react_app(path):
|
||||||
|
if path != "" and os.path.exists(os.path.join(app.static_folder, path)):
|
||||||
# Config page route
|
return send_from_directory(app.static_folder, path)
|
||||||
@app.route("/config")
|
else:
|
||||||
def serve_config():
|
return send_from_directory(app.static_folder, "index.html")
|
||||||
return render_template("config.html")
|
|
||||||
|
|
||||||
# New route: Serve watch.html under /watchlist
|
|
||||||
@app.route("/watchlist")
|
|
||||||
def serve_watchlist():
|
|
||||||
return render_template("watch.html")
|
|
||||||
|
|
||||||
# New route: Serve playlist.html under /playlist/<id>
|
|
||||||
@app.route("/playlist/<id>")
|
|
||||||
def serve_playlist(id):
|
|
||||||
# The id parameter is captured, but you can use it as needed.
|
|
||||||
return render_template("playlist.html")
|
|
||||||
|
|
||||||
@app.route("/album/<id>")
|
|
||||||
def serve_album(id):
|
|
||||||
# The id parameter is captured, but you can use it as needed.
|
|
||||||
return render_template("album.html")
|
|
||||||
|
|
||||||
@app.route("/track/<id>")
|
|
||||||
def serve_track(id):
|
|
||||||
# The id parameter is captured, but you can use it as needed.
|
|
||||||
return render_template("track.html")
|
|
||||||
|
|
||||||
@app.route("/artist/<id>")
|
|
||||||
def serve_artist(id):
|
|
||||||
# The id parameter is captured, but you can use it as needed.
|
|
||||||
return render_template("artist.html")
|
|
||||||
|
|
||||||
@app.route("/history")
|
|
||||||
def serve_history_page():
|
|
||||||
return render_template("history.html")
|
|
||||||
|
|
||||||
@app.route("/static/<path:path>")
|
|
||||||
def serve_static(path):
|
|
||||||
return send_from_directory("static", path)
|
|
||||||
|
|
||||||
# Serve favicon.ico from the same directory as index.html (templates)
|
|
||||||
@app.route("/favicon.ico")
|
|
||||||
def serve_favicon():
|
|
||||||
return send_from_directory("static/html", "favicon.ico")
|
|
||||||
|
|
||||||
# Add request logging middleware
|
# Add request logging middleware
|
||||||
@app.before_request
|
@app.before_request
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ services:
|
|||||||
- ./logs:/app/logs # <-- Volume for persistent logs
|
- ./logs:/app/logs # <-- Volume for persistent logs
|
||||||
ports:
|
ports:
|
||||||
- 7171:7171
|
- 7171:7171
|
||||||
image: cooldockerizer93/spotizerr
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
container_name: spotizerr-app
|
container_name: spotizerr-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
24
spotizerr-ui/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
54
spotizerr-ui/README.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default tseslint.config({
|
||||||
|
extends: [
|
||||||
|
// Remove ...tseslint.configs.recommended and replace with this
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
...tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
...tseslint.configs.stylisticTypeChecked,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
// other options...
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default tseslint.config({
|
||||||
|
plugins: {
|
||||||
|
// Add the react-x and react-dom plugins
|
||||||
|
'react-x': reactX,
|
||||||
|
'react-dom': reactDom,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// other rules...
|
||||||
|
// Enable its recommended typescript rules
|
||||||
|
...reactX.configs['recommended-typescript'].rules,
|
||||||
|
...reactDom.configs.recommended.rules,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
28
spotizerr-ui/eslint.config.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist'] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
13
spotizerr-ui/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Spotizerr</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
41
spotizerr-ui/package.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "spotizerr-ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.1.8",
|
||||||
|
"@tailwindcss/vite": "^4.1.8",
|
||||||
|
"@tanstack/react-query": "^5.80.6",
|
||||||
|
"@tanstack/react-router": "^1.120.18",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tanstack/router-devtools": "^1.120.18",
|
||||||
|
"axios": "^1.9.0",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-hook-form": "^7.57.0",
|
||||||
|
"sonner": "^2.0.5",
|
||||||
|
"tailwindcss": "^4.1.8",
|
||||||
|
"use-debounce": "^10.0.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.25.0",
|
||||||
|
"@types/node": "^22.15.30",
|
||||||
|
"@types/react": "^19.1.2",
|
||||||
|
"@types/react-dom": "^19.1.2",
|
||||||
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
|
"eslint": "^9.25.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
|
"globals": "^16.0.0",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"typescript-eslint": "^8.30.1",
|
||||||
|
"vite": "^6.3.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
2949
spotizerr-ui/pnpm-lock.yaml
generated
Normal file
5
spotizerr-ui/postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import tailwindcss from '@tailwindcss/postcss';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
plugins: [tailwindcss],
|
||||||
|
};
|
||||||
|
Before Width: | Height: | Size: 337 B After Width: | Height: | Size: 337 B |
|
Before Width: | Height: | Size: 284 B After Width: | Height: | Size: 284 B |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 723 B After Width: | Height: | Size: 723 B |
|
Before Width: | Height: | Size: 356 B After Width: | Height: | Size: 356 B |
|
Before Width: | Height: | Size: 891 B After Width: | Height: | Size: 891 B |
|
Before Width: | Height: | Size: 752 B After Width: | Height: | Size: 752 B |
|
Before Width: | Height: | Size: 597 B After Width: | Height: | Size: 597 B |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 673 B After Width: | Height: | Size: 673 B |
|
Before Width: | Height: | Size: 527 B After Width: | Height: | Size: 527 B |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 307 B After Width: | Height: | Size: 307 B |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 652 B After Width: | Height: | Size: 652 B |
|
Before Width: | Height: | Size: 873 B After Width: | Height: | Size: 873 B |
|
Before Width: | Height: | Size: 666 B After Width: | Height: | Size: 666 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 500 B After Width: | Height: | Size: 500 B |
|
Before Width: | Height: | Size: 284 B After Width: | Height: | Size: 284 B |
|
Before Width: | Height: | Size: 778 B After Width: | Height: | Size: 778 B |
|
Before Width: | Height: | Size: 550 B After Width: | Height: | Size: 550 B |
|
Before Width: | Height: | Size: 597 B After Width: | Height: | Size: 597 B |
73
spotizerr-ui/src/components/Queue.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { useQueue, type QueueItem } from '../contexts/queue-context';
|
||||||
|
|
||||||
|
export function Queue() {
|
||||||
|
const { items, isVisible, removeItem, clearQueue, toggleVisibility } = useQueue();
|
||||||
|
|
||||||
|
if (!isVisible) return null;
|
||||||
|
|
||||||
|
const renderStatus = (item: QueueItem) => {
|
||||||
|
switch (item.status) {
|
||||||
|
case 'downloading':
|
||||||
|
return (
|
||||||
|
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
|
||||||
|
<div
|
||||||
|
className="bg-blue-600 h-1.5 rounded-full"
|
||||||
|
style={{ width: `${item.progress || 0}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'completed':
|
||||||
|
return <span className="text-green-500 font-semibold">Completed</span>;
|
||||||
|
case 'error':
|
||||||
|
return <span className="text-red-500 font-semibold truncate" title={item.error}>{item.error || 'Failed'}</span>;
|
||||||
|
default:
|
||||||
|
return <span className="text-gray-500">{item.status}</span>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderItemDetails = (item: QueueItem) => {
|
||||||
|
if (item.status !== 'downloading' || !item.progress) return null;
|
||||||
|
return (
|
||||||
|
<div className="text-xs text-gray-400 flex justify-between w-full">
|
||||||
|
<span>{item.progress.toFixed(0)}%</span>
|
||||||
|
<span>{item.speed}</span>
|
||||||
|
<span>{item.size}</span>
|
||||||
|
<span>{item.eta}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 w-96 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-50 flex flex-col">
|
||||||
|
<div className="flex justify-between items-center p-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="font-semibold">Download Queue</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={clearQueue} className="text-sm text-gray-500 hover:text-red-500" title="Clear All">Clear</button>
|
||||||
|
<button onClick={() => toggleVisibility()} className="text-gray-500 hover:text-white" title="Close">
|
||||||
|
<img src="/cross.svg" alt="Close" className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 max-h-96 overflow-y-auto space-y-3">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-center py-4">Queue is empty.</p>
|
||||||
|
) : (
|
||||||
|
items.map((item) => (
|
||||||
|
<div key={item.id} className="text-sm">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="font-medium truncate pr-2">{item.name}</span>
|
||||||
|
<button onClick={() => removeItem(item.id)} className="text-gray-400 hover:text-red-500 flex-shrink-0">
|
||||||
|
<img src="/cross.svg" alt="Remove" className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 space-y-1">
|
||||||
|
{renderStatus(item)}
|
||||||
|
{renderItemDetails(item)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
spotizerr-ui/src/components/config/AccountsTab.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useForm, type SubmitHandler } from 'react-hook-form';
|
||||||
|
import apiClient from '../../lib/api-client';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
// --- Type Definitions ---
|
||||||
|
type Service = 'spotify' | 'deezer';
|
||||||
|
|
||||||
|
interface Credential {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single form shape with optional fields
|
||||||
|
interface AccountFormData {
|
||||||
|
accountName: string;
|
||||||
|
accountRegion?: string;
|
||||||
|
authBlob?: string; // Spotify specific
|
||||||
|
arl?: string; // Deezer specific
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API Functions ---
|
||||||
|
const fetchCredentials = async (service: Service): Promise<Credential[]> => {
|
||||||
|
const { data } = await apiClient.get<string[]>(`/credentials/${service}`);
|
||||||
|
return data.map(name => ({ name }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCredential = async ({ service, data }: { service: Service, data: AccountFormData }) => {
|
||||||
|
const payload = service === 'spotify'
|
||||||
|
? { blob_content: data.authBlob, region: data.accountRegion }
|
||||||
|
: { arl: data.arl, region: data.accountRegion };
|
||||||
|
|
||||||
|
const { data: response } = await apiClient.post(`/credentials/${service}/${data.accountName}`, payload);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteCredential = async ({ service, name }: { service: Service, name:string }) => {
|
||||||
|
const { data: response } = await apiClient.delete(`/credentials/${service}/${name}`);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Component ---
|
||||||
|
export function AccountsTab() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [activeService, setActiveService] = useState<Service>('spotify');
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
|
||||||
|
const { data: credentials, isLoading } = useQuery({
|
||||||
|
queryKey: ['credentials', activeService],
|
||||||
|
queryFn: () => fetchCredentials(activeService),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { register, handleSubmit, reset, formState: { errors } } = useForm<AccountFormData>();
|
||||||
|
|
||||||
|
const addMutation = useMutation({
|
||||||
|
mutationFn: addCredential,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Account added successfully!');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['credentials', activeService] });
|
||||||
|
setIsAdding(false);
|
||||||
|
reset();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(`Failed to add account: ${error.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: deleteCredential,
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
toast.success(`Account "${variables.name}" deleted.`);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['credentials', activeService] });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(`Failed to delete account: ${error.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit: SubmitHandler<AccountFormData> = (data) => {
|
||||||
|
addMutation.mutate({ service: activeService, data });
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAddForm = () => (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="p-4 border rounded-lg mt-4 space-y-4">
|
||||||
|
<h4 className="font-semibold">Add New {activeService === 'spotify' ? 'Spotify' : 'Deezer'} Account</h4>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="accountName">Account Name</label>
|
||||||
|
<input id="accountName" {...register('accountName', { required: 'This field is required' })} className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||||
|
{errors.accountName && <p className="text-red-500 text-sm">{errors.accountName.message}</p>}
|
||||||
|
</div>
|
||||||
|
{activeService === 'spotify' && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="authBlob">Auth Blob (JSON)</label>
|
||||||
|
<textarea id="authBlob" {...register('authBlob', { required: activeService === 'spotify' ? 'Auth Blob is required' : false })} className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" rows={4}></textarea>
|
||||||
|
{errors.authBlob && <p className="text-red-500 text-sm">{errors.authBlob.message}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeService === 'deezer' && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="arl">ARL Token</label>
|
||||||
|
<input id="arl" {...register('arl', { required: activeService === 'deezer' ? 'ARL is required' : false })} className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||||
|
{errors.arl && <p className="text-red-500 text-sm">{errors.arl.message}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="accountRegion">Region (Optional)</label>
|
||||||
|
<input id="accountRegion" {...register('accountRegion')} placeholder="e.g. US, GB" className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="submit" disabled={addMutation.isPending} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50">
|
||||||
|
{addMutation.isPending ? 'Saving...' : 'Save Account'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setIsAdding(false)} className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex gap-2 border-b">
|
||||||
|
<button onClick={() => setActiveService('spotify')} className={`p-2 ${activeService === 'spotify' ? 'border-b-2 border-blue-500 font-semibold' : ''}`}>Spotify</button>
|
||||||
|
<button onClick={() => setActiveService('deezer')} className={`p-2 ${activeService === 'deezer' ? 'border-b-2 border-blue-500 font-semibold' : ''}`}>Deezer</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p>Loading accounts...</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{credentials?.map(cred => (
|
||||||
|
<div key={cred.name} className="flex justify-between items-center p-3 bg-gray-800 rounded-md">
|
||||||
|
<span>{cred.name}</span>
|
||||||
|
<button onClick={() => deleteMutation.mutate({ service: activeService, name: cred.name })} disabled={deleteMutation.isPending && deleteMutation.variables?.name === cred.name} className="text-red-500 hover:text-red-400">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isAdding && (
|
||||||
|
<button onClick={() => setIsAdding(true)} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50">
|
||||||
|
Add Account
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isAdding && renderAddForm()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
spotizerr-ui/src/components/config/DownloadsTab.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { useForm, type SubmitHandler } from 'react-hook-form';
|
||||||
|
import apiClient from '../../lib/api-client';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
// --- Type Definitions ---
|
||||||
|
interface DownloadSettings {
|
||||||
|
maxConcurrentDownloads: number;
|
||||||
|
realTime: boolean;
|
||||||
|
fallback: boolean;
|
||||||
|
convertTo: 'MP3' | 'AAC' | 'OGG' | 'OPUS' | 'FLAC' | 'WAV' | 'ALAC' | '';
|
||||||
|
bitrate: string;
|
||||||
|
maxRetries: number;
|
||||||
|
retryDelaySeconds: number;
|
||||||
|
retryDelayIncrease: number;
|
||||||
|
threads: number;
|
||||||
|
path: string;
|
||||||
|
skipExisting: boolean;
|
||||||
|
m3u: boolean;
|
||||||
|
hlsThreads: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DownloadsTabProps {
|
||||||
|
config: DownloadSettings;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONVERSION_FORMATS: Record<string, string[]> = {
|
||||||
|
MP3: ['32k', '64k', '96k', '128k', '192k', '256k', '320k'],
|
||||||
|
AAC: ['32k', '64k', '96k', '128k', '192k', '256k'],
|
||||||
|
OGG: ['64k', '96k', '128k', '192k', '256k', '320k'],
|
||||||
|
OPUS: ['32k', '64k', '96k', '128k', '192k', '256k'],
|
||||||
|
FLAC: [],
|
||||||
|
WAV: [],
|
||||||
|
ALAC: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- API Functions ---
|
||||||
|
const saveDownloadConfig = async (data: Partial<DownloadSettings>) => {
|
||||||
|
const { data: response } = await apiClient.post('/config', data);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Component ---
|
||||||
|
export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: saveDownloadConfig,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Download settings saved successfully!');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['config'] });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(`Failed to save settings: ${error.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { register, handleSubmit, watch } = useForm<DownloadSettings>({
|
||||||
|
values: config,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedFormat = watch('convertTo');
|
||||||
|
|
||||||
|
const onSubmit: SubmitHandler<DownloadSettings> = (data) => {
|
||||||
|
mutation.mutate({
|
||||||
|
...data,
|
||||||
|
maxConcurrentDownloads: Number(data.maxConcurrentDownloads),
|
||||||
|
maxRetries: Number(data.maxRetries),
|
||||||
|
retryDelaySeconds: Number(data.retryDelaySeconds),
|
||||||
|
retryDelayIncrease: Number(data.retryDelayIncrease),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div>Loading download settings...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
|
||||||
|
{/* Download Settings */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-xl font-semibold">Download Behavior</h3>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="maxConcurrentDownloads">Max Concurrent Downloads</label>
|
||||||
|
<input id="maxConcurrentDownloads" type="number" min="1" {...register('maxConcurrentDownloads')} className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label htmlFor="realTimeToggle">Real-time downloading</label>
|
||||||
|
<input id="realTimeToggle" type="checkbox" {...register('realTime')} className="h-6 w-6 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label htmlFor="fallbackToggle">Download Fallback</label>
|
||||||
|
<input id="fallbackToggle" type="checkbox" {...register('fallback')} className="h-6 w-6 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conversion Settings */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-xl font-semibold">Conversion</h3>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="convertToSelect">Convert To Format</label>
|
||||||
|
<select id="convertToSelect" {...register('convertTo')} className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
<option value="">No Conversion</option>
|
||||||
|
{Object.keys(CONVERSION_FORMATS).map(format => (
|
||||||
|
<option key={format} value={format}>{format}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="bitrateSelect">Bitrate</label>
|
||||||
|
<select id="bitrateSelect" {...register('bitrate')} className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" disabled={!selectedFormat || CONVERSION_FORMATS[selectedFormat]?.length === 0}>
|
||||||
|
<option value="">Auto</option>
|
||||||
|
{(CONVERSION_FORMATS[selectedFormat] || []).map(rate => (
|
||||||
|
<option key={rate} value={rate}>{rate}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Retry Options */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-xl font-semibold">Retries</h3>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="maxRetries">Max Retry Attempts</label>
|
||||||
|
<input id="maxRetries" type="number" min="0" {...register('maxRetries')} className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="retryDelaySeconds">Initial Retry Delay (s)</label>
|
||||||
|
<input id="retryDelaySeconds" type="number" min="1" {...register('retryDelaySeconds')} className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="retryDelayIncrease">Retry Delay Increase (s)</label>
|
||||||
|
<input id="retryDelayIncrease" type="number" min="0" {...register('retryDelayIncrease')} className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" disabled={mutation.isPending} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50">
|
||||||
|
{mutation.isPending ? 'Saving...' : 'Save Download Settings'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
spotizerr-ui/src/components/config/FormattingTab.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
import { useForm, type SubmitHandler } from 'react-hook-form';
|
||||||
|
import apiClient from '../../lib/api-client';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
// --- Type Definitions ---
|
||||||
|
interface FormattingSettings {
|
||||||
|
customDirFormat: string;
|
||||||
|
customTrackFormat: string;
|
||||||
|
tracknumPadding: boolean;
|
||||||
|
saveCover: boolean;
|
||||||
|
track: string;
|
||||||
|
album: string;
|
||||||
|
playlist: string;
|
||||||
|
compilation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormattingTabProps {
|
||||||
|
config: FormattingSettings;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API Functions ---
|
||||||
|
const saveFormattingConfig = async (data: Partial<FormattingSettings>) => {
|
||||||
|
const { data: response } = await apiClient.post('/config', data);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Placeholders ---
|
||||||
|
const placeholders = {
|
||||||
|
"Common": {
|
||||||
|
"%music%": "Track title",
|
||||||
|
"%artist%": "Track artist",
|
||||||
|
"%album%": "Album name",
|
||||||
|
"%ar_album%": "Album artist",
|
||||||
|
"%tracknum%": "Track number",
|
||||||
|
"%year%": "Year of release",
|
||||||
|
},
|
||||||
|
"Additional": {
|
||||||
|
"%discnum%": "Disc number",
|
||||||
|
"%date%": "Release date",
|
||||||
|
"%genre%": "Music genre",
|
||||||
|
"%isrc%": "ISRC",
|
||||||
|
"%explicit%": "Explicit flag",
|
||||||
|
"%duration%": "Track duration (s)",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const PlaceholderSelector = ({ onSelect }: { onSelect: (value: string) => void }) => (
|
||||||
|
<select
|
||||||
|
onChange={(e) => onSelect(e.target.value)}
|
||||||
|
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm mt-1"
|
||||||
|
>
|
||||||
|
<option value="">-- Insert Placeholder --</option>
|
||||||
|
{Object.entries(placeholders).map(([group, options]) => (
|
||||||
|
<optgroup label={group} key={group}>
|
||||||
|
{Object.entries(options).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>{`${value} - ${label}`}</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
// --- Component ---
|
||||||
|
export function FormattingTab({ config, isLoading }: FormattingTabProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const dirInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const trackInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: saveFormattingConfig,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Formatting settings saved!');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['config'] });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(`Failed to save settings: ${error.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { register, handleSubmit, setValue } = useForm<FormattingSettings>({
|
||||||
|
values: config,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Correctly register the refs for react-hook-form while also holding a local ref.
|
||||||
|
const { ref: dirFormatRef, ...dirFormatRest } = register('customDirFormat');
|
||||||
|
const { ref: trackFormatRef, ...trackFormatRest } = register('customTrackFormat');
|
||||||
|
|
||||||
|
const handlePlaceholderSelect = (field: 'customDirFormat' | 'customTrackFormat', inputRef: React.RefObject<HTMLInputElement | null>) => (value: string) => {
|
||||||
|
if (!value || !inputRef.current) return;
|
||||||
|
const { selectionStart, selectionEnd } = inputRef.current;
|
||||||
|
const currentValue = inputRef.current.value;
|
||||||
|
const newValue = currentValue.substring(0, selectionStart ?? 0) + value + currentValue.substring(selectionEnd ?? 0);
|
||||||
|
setValue(field, newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const onSubmit: SubmitHandler<FormattingSettings> = (data) => {
|
||||||
|
mutation.mutate(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div>Loading formatting settings...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-xl font-semibold">File Naming</h3>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="customDirFormat">Custom Directory Format</label>
|
||||||
|
<input
|
||||||
|
id="customDirFormat"
|
||||||
|
type="text"
|
||||||
|
{...dirFormatRest}
|
||||||
|
ref={(e) => {
|
||||||
|
dirFormatRef(e);
|
||||||
|
dirInputRef.current = e;
|
||||||
|
}}
|
||||||
|
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<PlaceholderSelector onSelect={handlePlaceholderSelect('customDirFormat', dirInputRef)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="customTrackFormat">Custom Track Format</label>
|
||||||
|
<input
|
||||||
|
id="customTrackFormat"
|
||||||
|
type="text"
|
||||||
|
{...trackFormatRest}
|
||||||
|
ref={(e) => {
|
||||||
|
trackFormatRef(e);
|
||||||
|
trackInputRef.current = e;
|
||||||
|
}}
|
||||||
|
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<PlaceholderSelector onSelect={handlePlaceholderSelect('customTrackFormat', trackInputRef)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label htmlFor="tracknumPaddingToggle">Track Number Padding</label>
|
||||||
|
<input id="tracknumPaddingToggle" type="checkbox" {...register('tracknumPadding')} className="h-6 w-6 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label htmlFor="saveCoverToggle">Save Album Cover</label>
|
||||||
|
<input id="saveCoverToggle" type="checkbox" {...register('saveCover')} className="h-6 w-6 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" disabled={mutation.isPending} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50">
|
||||||
|
{mutation.isPending ? 'Saving...' : 'Save Formatting Settings'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
149
spotizerr-ui/src/components/config/GeneralTab.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import apiClient from '../../lib/api-client';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useSettings } from '../../contexts/settings-context';
|
||||||
|
|
||||||
|
// --- Type Definitions ---
|
||||||
|
interface Credential {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeneralSettings {
|
||||||
|
service: 'spotify' | 'deezer';
|
||||||
|
spotify: string;
|
||||||
|
spotifyQuality: 'NORMAL' | 'HIGH' | 'VERY_HIGH';
|
||||||
|
deezer: string;
|
||||||
|
deezerQuality: 'MP3_128' | 'MP3_320' | 'FLAC';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeneralTabProps {
|
||||||
|
config: GeneralSettings;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API Functions ---
|
||||||
|
const fetchCredentials = async (service: 'spotify' | 'deezer'): Promise<Credential[]> => {
|
||||||
|
const { data } = await apiClient.get<string[]>(`/credentials/${service}`);
|
||||||
|
return data.map(name => ({ name }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveGeneralConfig = (data: Partial<GeneralSettings>) => apiClient.post('/config', data);
|
||||||
|
|
||||||
|
// --- Component ---
|
||||||
|
export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { settings: globalSettings, isLoading: settingsLoading } = useSettings();
|
||||||
|
|
||||||
|
const { data: spotifyAccounts, isLoading: spotifyLoading } = useQuery({ queryKey: ['credentials', 'spotify'], queryFn: () => fetchCredentials('spotify') });
|
||||||
|
const { data: deezerAccounts, isLoading: deezerLoading } = useQuery({ queryKey: ['credentials', 'deezer'], queryFn: () => fetchCredentials('deezer') });
|
||||||
|
|
||||||
|
const { register, handleSubmit } = useForm<GeneralSettings>({
|
||||||
|
values: config,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: saveGeneralConfig,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('General settings saved!');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['config'] });
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast.error(`Failed to save: ${e.message}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (data: GeneralSettings) => mutation.mutate(data);
|
||||||
|
|
||||||
|
const isLoading = isConfigLoading || spotifyLoading || deezerLoading || settingsLoading;
|
||||||
|
if (isLoading) return <p>Loading general settings...</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-xl font-semibold">Service Defaults</h3>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="service">Default Service</label>
|
||||||
|
<select
|
||||||
|
id="service"
|
||||||
|
{...register('service')}
|
||||||
|
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="spotify">Spotify</option>
|
||||||
|
<option value="deezer">Deezer</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-xl font-semibold">Spotify Settings</h3>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="spotifyAccount">Active Spotify Account</label>
|
||||||
|
<select
|
||||||
|
id="spotifyAccount"
|
||||||
|
{...register('spotify')}
|
||||||
|
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
{spotifyAccounts?.map(acc => <option key={acc.name} value={acc.name}>{acc.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="spotifyQuality">Spotify Quality</label>
|
||||||
|
<select
|
||||||
|
id="spotifyQuality"
|
||||||
|
{...register('spotifyQuality')}
|
||||||
|
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="NORMAL">OGG 96kbps</option>
|
||||||
|
<option value="HIGH">OGG 160kbps</option>
|
||||||
|
<option value="VERY_HIGH">OGG 320kbps (Premium)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-xl font-semibold">Deezer Settings</h3>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="deezerAccount">Active Deezer Account</label>
|
||||||
|
<select
|
||||||
|
id="deezerAccount"
|
||||||
|
{...register('deezer')}
|
||||||
|
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
{deezerAccounts?.map(acc => <option key={acc.name} value={acc.name}>{acc.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="deezerQuality">Deezer Quality</label>
|
||||||
|
<select
|
||||||
|
id="deezerQuality"
|
||||||
|
{...register('deezerQuality')}
|
||||||
|
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="MP3_128">MP3 128kbps</option>
|
||||||
|
<option value="MP3_320">MP3 320kbps</option>
|
||||||
|
<option value="FLAC">FLAC (HiFi)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-xl font-semibold">Content Filters</h3>
|
||||||
|
<div className="form-item--row">
|
||||||
|
<label>Filter Explicit Content</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`font-semibold ${globalSettings?.explicitFilter ? 'text-green-400' : 'text-red-400'}`}>
|
||||||
|
{globalSettings?.explicitFilter ? 'Enabled' : 'Disabled'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs bg-gray-600 text-white px-2 py-1 rounded-full">ENV</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
The explicit content filter is controlled by an environment variable and cannot be changed here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" disabled={mutation.isPending} className="btn-primary">
|
||||||
|
{mutation.isPending ? 'Saving...' : 'Save General Settings'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
spotizerr-ui/src/components/config/ServerTab.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
|
import apiClient from '../../lib/api-client';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
// --- Type Definitions ---
|
||||||
|
interface SpotifyApiSettings {
|
||||||
|
client_id: string;
|
||||||
|
client_secret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebhookSettings {
|
||||||
|
url: string;
|
||||||
|
events: string[];
|
||||||
|
available_events: string[]; // Provided by API, not saved
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API Functions ---
|
||||||
|
const fetchSpotifyApiConfig = async (): Promise<SpotifyApiSettings> => {
|
||||||
|
const { data } = await apiClient.get('/credentials/spotify_api_config');
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
const saveSpotifyApiConfig = (data: SpotifyApiSettings) => apiClient.put('/credentials/spotify_api_config', data);
|
||||||
|
|
||||||
|
const fetchWebhookConfig = async (): Promise<WebhookSettings> => {
|
||||||
|
// Mock a response since backend endpoint doesn't exist
|
||||||
|
// This will prevent the UI from crashing.
|
||||||
|
return Promise.resolve({
|
||||||
|
url: '',
|
||||||
|
events: [],
|
||||||
|
available_events: ["download_start", "download_complete", "download_failed", "watch_added"],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const saveWebhookConfig = (data: Partial<WebhookSettings>) => {
|
||||||
|
toast.info("Webhook configuration is not available.");
|
||||||
|
return Promise.resolve(data);
|
||||||
|
};
|
||||||
|
const testWebhook = (url: string) => {
|
||||||
|
toast.info("Webhook testing is not available.");
|
||||||
|
return Promise.resolve(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Components ---
|
||||||
|
function SpotifyApiForm() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { data, isLoading } = useQuery({ queryKey: ['spotifyApiConfig'], queryFn: fetchSpotifyApiConfig });
|
||||||
|
const { register, handleSubmit, reset } = useForm<SpotifyApiSettings>();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: saveSpotifyApiConfig,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Spotify API settings saved!');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['spotifyApiConfig'] });
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(`Failed to save: ${e.message}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => { if (data) reset(data); }, [data, reset]);
|
||||||
|
|
||||||
|
const onSubmit = (formData: SpotifyApiSettings) => mutation.mutate(formData);
|
||||||
|
|
||||||
|
if (isLoading) return <p>Loading Spotify API settings...</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="client_id">Client ID</label>
|
||||||
|
<input id="client_id" type="password" {...register('client_id')} className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="Optional"/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="client_secret">Client Secret</label>
|
||||||
|
<input id="client_secret" type="password" {...register('client_secret')} className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" disabled={mutation.isPending} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50">
|
||||||
|
{mutation.isPending ? 'Saving...' : 'Save Spotify API'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WebhookForm() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { data, isLoading } = useQuery({ queryKey: ['webhookConfig'], queryFn: fetchWebhookConfig });
|
||||||
|
const { register, handleSubmit, control, reset, watch } = useForm<WebhookSettings>();
|
||||||
|
const currentUrl = watch('url');
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: saveWebhookConfig,
|
||||||
|
onSuccess: () => {
|
||||||
|
// No toast needed since the function shows one
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['webhookConfig'] });
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(`Failed to save: ${e.message}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const testMutation = useMutation({
|
||||||
|
mutationFn: testWebhook,
|
||||||
|
onSuccess: () => {
|
||||||
|
// No toast needed
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(`Webhook test failed: ${e.message}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => { if (data) reset(data); }, [data, reset]);
|
||||||
|
|
||||||
|
const onSubmit = (formData: WebhookSettings) => mutation.mutate(formData);
|
||||||
|
|
||||||
|
if (isLoading) return <p>Loading Webhook settings...</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="webhookUrl">Webhook URL</label>
|
||||||
|
<input id="webhookUrl" type="url" {...register('url')} className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="https://example.com/webhook" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label>Webhook Events</label>
|
||||||
|
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||||
|
{data?.available_events.map((event) => (
|
||||||
|
<Controller
|
||||||
|
key={event}
|
||||||
|
name="events"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-5 w-5 rounded"
|
||||||
|
checked={field.value?.includes(event) ?? false}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = field.value || [];
|
||||||
|
const newValues = e.target.checked
|
||||||
|
? [...value, event]
|
||||||
|
: value.filter((v) => v !== event);
|
||||||
|
field.onChange(newValues);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="capitalize">{event.replace(/_/g, ' ')}</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="submit" disabled={mutation.isPending} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50">
|
||||||
|
{mutation.isPending ? 'Saving...' : 'Save Webhook'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => testMutation.mutate(currentUrl)} disabled={!currentUrl || testMutation.isPending} className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700 disabled:opacity-50">
|
||||||
|
Test
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServerTab() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold">Spotify API</h3>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Provide your own API credentials to avoid rate-limiting issues.</p>
|
||||||
|
<SpotifyApiForm />
|
||||||
|
</div>
|
||||||
|
<hr className="border-gray-600" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold">Webhooks</h3>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Get notifications for events like download completion. (Currently disabled)</p>
|
||||||
|
<WebhookForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
spotizerr-ui/src/components/config/WatchTab.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useForm, type SubmitHandler, Controller } from 'react-hook-form';
|
||||||
|
import apiClient from '../../lib/api-client';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
// --- Type Definitions ---
|
||||||
|
const ALBUM_GROUPS = ["album", "single", "compilation", "appears_on"] as const;
|
||||||
|
|
||||||
|
type AlbumGroup = typeof ALBUM_GROUPS[number];
|
||||||
|
|
||||||
|
interface WatchSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
watchPollIntervalSeconds: number;
|
||||||
|
watchedArtistAlbumGroup: AlbumGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API Functions ---
|
||||||
|
const fetchWatchConfig = async (): Promise<WatchSettings> => {
|
||||||
|
const { data } = await apiClient.get('/config/watch');
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveWatchConfig = async (data: Partial<WatchSettings>) => {
|
||||||
|
const { data: response } = await apiClient.post('/config/watch', data);
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Component ---
|
||||||
|
export function WatchTab() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: config, isLoading } = useQuery({
|
||||||
|
queryKey: ['watchConfig'],
|
||||||
|
queryFn: fetchWatchConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: saveWatchConfig,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Watch settings saved successfully!');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['watchConfig'] });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(`Failed to save settings: ${error.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { register, handleSubmit, control, reset } = useForm<WatchSettings>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (config) {
|
||||||
|
reset(config);
|
||||||
|
}
|
||||||
|
}, [config, reset]);
|
||||||
|
|
||||||
|
const onSubmit: SubmitHandler<WatchSettings> = (data) => {
|
||||||
|
mutation.mutate({
|
||||||
|
...data,
|
||||||
|
watchPollIntervalSeconds: Number(data.watchPollIntervalSeconds),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div>Loading watch settings...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-xl font-semibold">Watchlist Behavior</h3>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label htmlFor="watchEnabledToggle">Enable Watchlist</label>
|
||||||
|
<input id="watchEnabledToggle" type="checkbox" {...register('enabled')} className="h-6 w-6 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="watchPollIntervalSeconds">Watch Poll Interval (seconds)</label>
|
||||||
|
<input id="watchPollIntervalSeconds" type="number" min="60" {...register('watchPollIntervalSeconds')} className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
How often to check watched items for updates.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-xl font-semibold">Artist Album Groups</h3>
|
||||||
|
<p className="text-sm text-gray-500">Select which album groups to monitor for watched artists.</p>
|
||||||
|
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||||
|
{ALBUM_GROUPS.map((group) => (
|
||||||
|
<Controller
|
||||||
|
key={group}
|
||||||
|
name="watchedArtistAlbumGroup"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-5 w-5 rounded"
|
||||||
|
checked={field.value?.includes(group) ?? false}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = field.value || [];
|
||||||
|
const newValues = e.target.checked
|
||||||
|
? [...value, group]
|
||||||
|
: value.filter((v) => v !== group);
|
||||||
|
field.onChange(newValues);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="capitalize">{group.replace('_', ' ')}</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" disabled={mutation.isPending} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50">
|
||||||
|
{mutation.isPending ? 'Saving...' : 'Save Watch Settings'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
spotizerr-ui/src/contexts/QueueProvider.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { useState, useCallback, type ReactNode, useEffect, useRef } from 'react';
|
||||||
|
import apiClient from '../lib/api-client';
|
||||||
|
import { QueueContext, type QueueItem } from './queue-context';
|
||||||
|
|
||||||
|
// --- Helper Types ---
|
||||||
|
interface TaskStatus {
|
||||||
|
status: 'downloading' | 'completed' | 'error' | 'queued';
|
||||||
|
progress?: number;
|
||||||
|
speed?: string;
|
||||||
|
size?: string;
|
||||||
|
eta?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QueueProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [items, setItems] = useState<QueueItem[]>([]);
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const pollingIntervals = useRef<Record<string, number>>({});
|
||||||
|
|
||||||
|
// --- Core Action: Add Item ---
|
||||||
|
const addItem = useCallback(async (item: Omit<QueueItem, 'status'>) => {
|
||||||
|
const newItem: QueueItem = { ...item, status: 'queued' };
|
||||||
|
setItems(prev => [...prev, newItem]);
|
||||||
|
toggleVisibility();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// This endpoint should initiate the download and return a task ID
|
||||||
|
const response = await apiClient.post<{ taskId: string }>(`/download/${item.type}`, { id: item.id });
|
||||||
|
const { taskId } = response.data;
|
||||||
|
|
||||||
|
// Update item with taskId and start polling
|
||||||
|
setItems(prev => prev.map(i => i.id === item.id ? { ...i, taskId, status: 'pending' } : i));
|
||||||
|
startPolling(taskId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to start download for ${item.name}:`, error);
|
||||||
|
setItems(prev => prev.map(i => i.id === item.id ? { ...i, status: 'error', error: 'Failed to start download' } : i));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// --- Polling Logic ---
|
||||||
|
const startPolling = (taskId: string) => {
|
||||||
|
if (pollingIntervals.current[taskId]) return; // Already polling
|
||||||
|
|
||||||
|
const intervalId = window.setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<TaskStatus>(`/download/status/${taskId}`);
|
||||||
|
const statusUpdate = response.data;
|
||||||
|
|
||||||
|
setItems(prev => prev.map(item => {
|
||||||
|
if (item.taskId === taskId) {
|
||||||
|
const updatedItem = {
|
||||||
|
...item,
|
||||||
|
status: statusUpdate.status,
|
||||||
|
progress: statusUpdate.progress,
|
||||||
|
speed: statusUpdate.speed,
|
||||||
|
size: statusUpdate.size,
|
||||||
|
eta: statusUpdate.eta,
|
||||||
|
error: statusUpdate.status === 'error' ? statusUpdate.message : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (statusUpdate.status === 'completed' || statusUpdate.status === 'error') {
|
||||||
|
stopPolling(taskId);
|
||||||
|
}
|
||||||
|
return updatedItem;
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Polling failed for task ${taskId}:`, error);
|
||||||
|
stopPolling(taskId);
|
||||||
|
setItems(prev => prev.map(i => i.taskId === taskId ? { ...i, status: 'error', error: 'Connection lost' } : i));
|
||||||
|
}
|
||||||
|
}, 2000); // Poll every 2 seconds
|
||||||
|
|
||||||
|
pollingIntervals.current[taskId] = intervalId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopPolling = (taskId: string) => {
|
||||||
|
clearInterval(pollingIntervals.current[taskId]);
|
||||||
|
delete pollingIntervals.current[taskId];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
Object.values(pollingIntervals.current).forEach(clearInterval);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// --- Other Actions ---
|
||||||
|
const removeItem = useCallback((id: string) => {
|
||||||
|
const itemToRemove = items.find(i => i.id === id);
|
||||||
|
if (itemToRemove && itemToRemove.taskId) {
|
||||||
|
stopPolling(itemToRemove.taskId);
|
||||||
|
// Optionally, call an API to cancel the backend task
|
||||||
|
// apiClient.post(`/download/cancel/${itemToRemove.taskId}`);
|
||||||
|
}
|
||||||
|
setItems(prev => prev.filter(item => item.id !== id));
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
const clearQueue = useCallback(() => {
|
||||||
|
Object.values(pollingIntervals.current).forEach(clearInterval);
|
||||||
|
pollingIntervals.current = {};
|
||||||
|
setItems([]);
|
||||||
|
// Optionally, call an API to cancel all tasks
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleVisibility = useCallback(() => setIsVisible(prev => !prev), []);
|
||||||
|
|
||||||
|
const value = { items, isVisible, addItem, removeItem, clearQueue, toggleVisibility };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueueContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</QueueContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
spotizerr-ui/src/contexts/SettingsProvider.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { type ReactNode } from 'react';
|
||||||
|
import apiClient from '../lib/api-client';
|
||||||
|
import { SettingsContext, type AppSettings } from './settings-context';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
// --- Case Conversion Utility ---
|
||||||
|
// This is added here to simplify the fix and avoid module resolution issues.
|
||||||
|
function snakeToCamel(str: string): string {
|
||||||
|
return str.replace(/(_\w)/g, m => m[1].toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertKeysToCamelCase(obj: unknown): unknown {
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return obj.map(v => convertKeysToCamelCase(v));
|
||||||
|
}
|
||||||
|
if (typeof obj === 'object' && obj !== null) {
|
||||||
|
return Object.keys(obj).reduce((acc: Record<string, unknown>, key: string) => {
|
||||||
|
const camelKey = snakeToCamel(key);
|
||||||
|
acc[camelKey] = convertKeysToCamelCase((obj as Record<string, unknown>)[key]);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redefine AppSettings to match the flat structure of the API response
|
||||||
|
export type FlatAppSettings = {
|
||||||
|
service: 'spotify' | 'deezer';
|
||||||
|
spotify: string;
|
||||||
|
spotifyQuality: 'NORMAL' | 'HIGH' | 'VERY_HIGH';
|
||||||
|
deezer: string;
|
||||||
|
deezerQuality: 'MP3_128' | 'MP3_320' | 'FLAC';
|
||||||
|
maxConcurrentDownloads: number;
|
||||||
|
realTime: boolean;
|
||||||
|
fallback: boolean;
|
||||||
|
convertTo: 'MP3' | 'AAC' | 'OGG' | 'OPUS' | 'FLAC' | 'WAV' | 'ALAC' | '';
|
||||||
|
bitrate: string;
|
||||||
|
maxRetries: number;
|
||||||
|
retryDelaySeconds: number;
|
||||||
|
retryDelayIncrease: number;
|
||||||
|
customDirFormat: string;
|
||||||
|
customTrackFormat: string;
|
||||||
|
tracknumPadding: boolean;
|
||||||
|
saveCover: boolean;
|
||||||
|
explicitFilter: boolean;
|
||||||
|
// Add other fields from the old AppSettings as needed by other parts of the app
|
||||||
|
watch: AppSettings['watch'];
|
||||||
|
// Add defaults for the new download properties
|
||||||
|
threads: number;
|
||||||
|
path: string;
|
||||||
|
skipExisting: boolean;
|
||||||
|
m3u: boolean;
|
||||||
|
hlsThreads: number;
|
||||||
|
// Add defaults for the new formatting properties
|
||||||
|
track: string;
|
||||||
|
album: string;
|
||||||
|
playlist: string;
|
||||||
|
compilation: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultSettings: FlatAppSettings = {
|
||||||
|
service: 'spotify',
|
||||||
|
spotify: '',
|
||||||
|
spotifyQuality: 'NORMAL',
|
||||||
|
deezer: '',
|
||||||
|
deezerQuality: 'MP3_128',
|
||||||
|
maxConcurrentDownloads: 3,
|
||||||
|
realTime: false,
|
||||||
|
fallback: false,
|
||||||
|
convertTo: '',
|
||||||
|
bitrate: '',
|
||||||
|
maxRetries: 3,
|
||||||
|
retryDelaySeconds: 5,
|
||||||
|
retryDelayIncrease: 5,
|
||||||
|
customDirFormat: '%ar_album%/%album%',
|
||||||
|
customTrackFormat: '%tracknum%. %music%',
|
||||||
|
tracknumPadding: true,
|
||||||
|
saveCover: true,
|
||||||
|
explicitFilter: false,
|
||||||
|
// Add defaults for the new download properties
|
||||||
|
threads: 4,
|
||||||
|
path: '/downloads',
|
||||||
|
skipExisting: true,
|
||||||
|
m3u: false,
|
||||||
|
hlsThreads: 8,
|
||||||
|
// Add defaults for the new formatting properties
|
||||||
|
track: '{artist_name}/{album_name}/{track_number} - {track_name}',
|
||||||
|
album: '{artist_name}/{album_name}',
|
||||||
|
playlist: 'Playlists/{playlist_name}',
|
||||||
|
compilation: 'Compilations/{album_name}',
|
||||||
|
watch: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchSettings = async (): Promise<FlatAppSettings> => {
|
||||||
|
const { data } = await apiClient.get('/config');
|
||||||
|
// Transform the keys before returning the data
|
||||||
|
return convertKeysToCamelCase(data) as FlatAppSettings;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SettingsProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { data: settings, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['config'],
|
||||||
|
queryFn: fetchSettings,
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use default settings on error to prevent app crash
|
||||||
|
const value = { settings: isError ? defaultSettings : (settings || null), isLoading };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</SettingsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
spotizerr-ui/src/contexts/queue-context.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
export interface QueueItem {
|
||||||
|
id: string; // This is the Spotify ID
|
||||||
|
type: 'track' | 'album' | 'artist' | 'playlist';
|
||||||
|
name: string;
|
||||||
|
// --- Real-time progress fields ---
|
||||||
|
status: 'pending' | 'downloading' | 'completed' | 'error' | 'queued';
|
||||||
|
taskId?: string; // The backend task ID for polling
|
||||||
|
progress?: number;
|
||||||
|
speed?: string;
|
||||||
|
size?: string;
|
||||||
|
eta?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueueContextType {
|
||||||
|
items: QueueItem[];
|
||||||
|
isVisible: boolean;
|
||||||
|
addItem: (item: Omit<QueueItem, 'status'>) => void;
|
||||||
|
removeItem: (id: string) => void;
|
||||||
|
clearQueue: () => void;
|
||||||
|
toggleVisibility: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QueueContext = createContext<QueueContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function useQueue() {
|
||||||
|
const context = useContext(QueueContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useQueue must be used within a QueueProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
54
spotizerr-ui/src/contexts/settings-context.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
// This new type reflects the flat structure of the /api/config response
|
||||||
|
export interface AppSettings {
|
||||||
|
service: 'spotify' | 'deezer';
|
||||||
|
spotify: string;
|
||||||
|
spotifyQuality: 'NORMAL' | 'HIGH' | 'VERY_HIGH';
|
||||||
|
deezer: string;
|
||||||
|
deezerQuality: 'MP3_128' | 'MP3_320' | 'FLAC';
|
||||||
|
maxConcurrentDownloads: number;
|
||||||
|
realTime: boolean;
|
||||||
|
fallback: boolean;
|
||||||
|
convertTo: 'MP3' | 'AAC' | 'OGG' | 'OPUS' | 'FLAC' | 'WAV' | 'ALAC' | '';
|
||||||
|
bitrate: string;
|
||||||
|
maxRetries: number;
|
||||||
|
retryDelaySeconds: number;
|
||||||
|
retryDelayIncrease: number;
|
||||||
|
customDirFormat: string;
|
||||||
|
customTrackFormat: string;
|
||||||
|
tracknumPadding: boolean;
|
||||||
|
saveCover: boolean;
|
||||||
|
explicitFilter: boolean;
|
||||||
|
// Properties from the old 'downloads' object
|
||||||
|
threads: number;
|
||||||
|
path: string;
|
||||||
|
skipExisting: boolean;
|
||||||
|
m3u: boolean;
|
||||||
|
hlsThreads: number;
|
||||||
|
// Properties from the old 'formatting' object
|
||||||
|
track: string;
|
||||||
|
album: string;
|
||||||
|
playlist: string;
|
||||||
|
compilation: string;
|
||||||
|
watch: {
|
||||||
|
enabled: boolean;
|
||||||
|
// Add other watch properties from the old type if they still exist in the API response
|
||||||
|
};
|
||||||
|
// Add other root-level properties from the API if they exist
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsContextType {
|
||||||
|
settings: AppSettings | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsContext = createContext<SettingsContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function useSettings() {
|
||||||
|
const context = useContext(SettingsContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useSettings must be used within a SettingsProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
1
spotizerr-ui/src/index.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
41
spotizerr-ui/src/lib/api-client.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
const apiClient = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
timeout: 10000, // 10 seconds timeout
|
||||||
|
});
|
||||||
|
|
||||||
|
// Response interceptor for error handling
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
const contentType = response.headers['content-type'];
|
||||||
|
if (contentType && contentType.includes('application/json')) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
// If the response is not JSON, reject it to trigger the error handling
|
||||||
|
const error = new Error('Invalid response type. Expected JSON.');
|
||||||
|
toast.error('API Error', {
|
||||||
|
description: 'Received an invalid response from the server. Expected JSON data.',
|
||||||
|
});
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
if (error.code === 'ECONNABORTED') {
|
||||||
|
toast.error('Request Timed Out', {
|
||||||
|
description: 'The server did not respond in time. Please try again later.',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const errorMessage = error.response?.data?.error || error.message || 'An unknown error occurred.';
|
||||||
|
toast.error('API Error', {
|
||||||
|
description: errorMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default apiClient;
|
||||||
11
spotizerr-ui/src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { RouterProvider } from '@tanstack/react-router';
|
||||||
|
import { router } from './router';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
81
spotizerr-ui/src/router.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { createRouter, createRootRoute, createRoute } from '@tanstack/react-router';
|
||||||
|
import { Root } from './routes/root';
|
||||||
|
import { Album } from './routes/album';
|
||||||
|
import { Artist } from './routes/artist';
|
||||||
|
import { Track } from './routes/track';
|
||||||
|
import { Home } from './routes/home';
|
||||||
|
import { Config } from './routes/config';
|
||||||
|
import { Playlist } from './routes/playlist';
|
||||||
|
import { History } from './routes/history';
|
||||||
|
import { Watchlist } from './routes/watchlist';
|
||||||
|
|
||||||
|
const rootRoute = createRootRoute({
|
||||||
|
component: Root,
|
||||||
|
});
|
||||||
|
|
||||||
|
const indexRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/',
|
||||||
|
component: Home,
|
||||||
|
});
|
||||||
|
|
||||||
|
const albumRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/album/$albumId',
|
||||||
|
component: Album,
|
||||||
|
});
|
||||||
|
|
||||||
|
const artistRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/artist/$artistId',
|
||||||
|
component: Artist,
|
||||||
|
});
|
||||||
|
|
||||||
|
const trackRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/track/$trackId',
|
||||||
|
component: Track,
|
||||||
|
});
|
||||||
|
|
||||||
|
const configRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/config',
|
||||||
|
component: Config,
|
||||||
|
});
|
||||||
|
|
||||||
|
const playlistRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/playlist/$playlistId',
|
||||||
|
component: Playlist,
|
||||||
|
});
|
||||||
|
|
||||||
|
const historyRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/history',
|
||||||
|
component: History,
|
||||||
|
});
|
||||||
|
|
||||||
|
const watchlistRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/watchlist',
|
||||||
|
component: Watchlist,
|
||||||
|
});
|
||||||
|
|
||||||
|
const routeTree = rootRoute.addChildren([
|
||||||
|
indexRoute,
|
||||||
|
albumRoute,
|
||||||
|
artistRoute,
|
||||||
|
trackRoute,
|
||||||
|
configRoute,
|
||||||
|
playlistRoute,
|
||||||
|
historyRoute,
|
||||||
|
watchlistRoute,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const router = createRouter({ routeTree });
|
||||||
|
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface Register {
|
||||||
|
router: typeof router;
|
||||||
|
}
|
||||||
|
}
|
||||||
168
spotizerr-ui/src/routes/album.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { Link, useParams } from '@tanstack/react-router';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import apiClient from '../lib/api-client';
|
||||||
|
import { useQueue } from '../contexts/queue-context';
|
||||||
|
import { useSettings } from '../contexts/settings-context';
|
||||||
|
import type { AlbumType, TrackType } from '../types/spotify';
|
||||||
|
|
||||||
|
export const Album = () => {
|
||||||
|
const { albumId } = useParams({ from: '/album/$albumId' });
|
||||||
|
const [album, setAlbum] = useState<AlbumType | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { addItem, toggleVisibility } = useQueue();
|
||||||
|
const { settings } = useSettings();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchAlbum = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/album/info?id=${albumId}`);
|
||||||
|
setAlbum(response.data);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to load album');
|
||||||
|
console.error('Error fetching album:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (albumId) {
|
||||||
|
fetchAlbum();
|
||||||
|
}
|
||||||
|
}, [albumId]);
|
||||||
|
|
||||||
|
const handleDownloadTrack = (track: TrackType) => {
|
||||||
|
addItem({ id: track.id, type: 'track', name: track.name });
|
||||||
|
toggleVisibility();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadAlbum = () => {
|
||||||
|
if (!album) return;
|
||||||
|
addItem({ id: album.id, type: 'album', name: album.name });
|
||||||
|
toggleVisibility();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div className="text-red-500">{error}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!album) {
|
||||||
|
return <div>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isExplicitFilterEnabled = settings?.explicitFilter ?? false;
|
||||||
|
|
||||||
|
// Show placeholder for an entirely explicit album
|
||||||
|
if (isExplicitFilterEnabled && album.explicit) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center border rounded-lg">
|
||||||
|
<h2 className="text-2xl font-bold">Explicit Content Filtered</h2>
|
||||||
|
<p className="mt-2 text-gray-500">This album has been filtered based on your settings.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasExplicitTrack = album.tracks.items.some(track => track.explicit);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col md:flex-row items-start gap-6">
|
||||||
|
<img
|
||||||
|
src={album.images[0]?.url || '/placeholder.jpg'}
|
||||||
|
alt={album.name}
|
||||||
|
className="w-48 h-48 object-cover rounded-lg shadow-lg"
|
||||||
|
/>
|
||||||
|
<div className="flex-grow space-y-2">
|
||||||
|
<h1 className="text-3xl font-bold">{album.name}</h1>
|
||||||
|
<p className="text-lg text-gray-500 dark:text-gray-400">
|
||||||
|
By{' '}
|
||||||
|
{album.artists.map((artist, index) => (
|
||||||
|
<span key={artist.id}>
|
||||||
|
<Link
|
||||||
|
to="/artist/$artistId"
|
||||||
|
params={{ artistId: artist.id }}
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
{artist.name}
|
||||||
|
</Link>
|
||||||
|
{index < album.artists.length - 1 && ', '}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||||
|
{new Date(album.release_date).getFullYear()} • {album.total_tracks} songs
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 dark:text-gray-600">
|
||||||
|
{album.label}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleDownloadAlbum}
|
||||||
|
disabled={isExplicitFilterEnabled && hasExplicitTrack}
|
||||||
|
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||||
|
title={isExplicitFilterEnabled && hasExplicitTrack ? 'Album contains explicit tracks' : 'Download Full Album'}
|
||||||
|
>
|
||||||
|
Download Album
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-xl font-semibold">Tracks</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{album.tracks.items.map((track, index) => {
|
||||||
|
if (isExplicitFilterEnabled && track.explicit) {
|
||||||
|
return (
|
||||||
|
<div key={index} className="flex items-center justify-between p-3 bg-gray-100 dark:bg-gray-800 rounded-lg opacity-50">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400 w-8 text-right">{index + 1}</span>
|
||||||
|
<p className="font-medium text-gray-500">Explicit track filtered</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-500">--:--</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={track.id}
|
||||||
|
className="flex items-center justify-between p-3 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400 w-8 text-right">{index + 1}</span>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{track.name}</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{track.artists.map((artist, index) => (
|
||||||
|
<span key={artist.id}>
|
||||||
|
<Link
|
||||||
|
to="/artist/$artistId"
|
||||||
|
params={{ artistId: artist.id }}
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
{artist.name}
|
||||||
|
</Link>
|
||||||
|
{index < track.artists.length - 1 && ', '}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
|
{Math.floor(track.duration_ms / 60000)}:
|
||||||
|
{((track.duration_ms % 60000) / 1000).toFixed(0).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownloadTrack(track)}
|
||||||
|
className="p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-full"
|
||||||
|
title="Download"
|
||||||
|
>
|
||||||
|
<img src="/download.svg" alt="Download" className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
253
spotizerr-ui/src/routes/artist.tsx
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import { Link, useParams } from '@tanstack/react-router';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import apiClient from '../lib/api-client';
|
||||||
|
import { useQueue } from '../contexts/queue-context';
|
||||||
|
import type { AlbumType } from '../types/spotify';
|
||||||
|
|
||||||
|
interface ArtistInfo {
|
||||||
|
artist: {
|
||||||
|
name: string;
|
||||||
|
images: { url: string }[];
|
||||||
|
followers: { total: number };
|
||||||
|
};
|
||||||
|
topTracks: Track[];
|
||||||
|
albums: AlbumGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Track {
|
||||||
|
id: string;
|
||||||
|
name:string;
|
||||||
|
duration_ms: number;
|
||||||
|
album: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
images: { url: string }[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UAlbum extends AlbumType {
|
||||||
|
is_known?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlbumGroup {
|
||||||
|
album: UAlbum[];
|
||||||
|
single: UAlbum[];
|
||||||
|
appears_on: UAlbum[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Artist = () => {
|
||||||
|
const { artistId } = useParams({ from: '/artist/$artistId' });
|
||||||
|
const [artistInfo, setArtistInfo] = useState<ArtistInfo | null>(null);
|
||||||
|
const [isWatched, setIsWatched] = useState(false);
|
||||||
|
const [isWatchEnabled, setIsWatchEnabled] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const { addItem, toggleVisibility } = useQueue();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchAllData = async () => {
|
||||||
|
if (!artistId) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const [infoRes, watchConfigRes, watchStatusRes] = await Promise.all([
|
||||||
|
apiClient.get<ArtistInfo>(`/artist/info?id=${artistId}`),
|
||||||
|
apiClient.get('/config/watch'),
|
||||||
|
apiClient.get(`/artist/watch/status?id=${artistId}`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setArtistInfo(infoRes.data);
|
||||||
|
setIsWatchEnabled(watchConfigRes.data.enabled);
|
||||||
|
setIsWatched(watchStatusRes.data.is_watched);
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
// The API client interceptor will now handle showing the error toast
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchAllData();
|
||||||
|
}, [artistId]);
|
||||||
|
|
||||||
|
const handleDownloadTrack = (track: Track) => {
|
||||||
|
addItem({ id: track.id, type: 'track', name: track.name });
|
||||||
|
toggleVisibility();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadAll = () => {
|
||||||
|
if (!artistId || !artistInfo) return;
|
||||||
|
addItem({ id: artistId, type: 'artist', name: artistInfo.artist.name });
|
||||||
|
toggleVisibility();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWatch = async () => {
|
||||||
|
if (!artistId) return;
|
||||||
|
const originalState = isWatched;
|
||||||
|
setIsWatched(!originalState); // Optimistic update
|
||||||
|
try {
|
||||||
|
await apiClient.post(originalState ? '/artist/unwatch' : '/artist/watch', { artistId });
|
||||||
|
toast.success(`Artist ${originalState ? 'unwatched' : 'watched'} successfully.`);
|
||||||
|
} catch {
|
||||||
|
setIsWatched(originalState); // Revert on error
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSync = async () => {
|
||||||
|
if (!artistId) return;
|
||||||
|
toast.info('Syncing artist...', { id: 'sync-artist' });
|
||||||
|
try {
|
||||||
|
await apiClient.post('/artist/sync', { artistId });
|
||||||
|
toast.success('Artist sync completed.', { id: 'sync-artist' });
|
||||||
|
} catch {
|
||||||
|
toast.error('Artist sync failed.', { id: 'sync-artist' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkAsKnown = async (albumId: string, known: boolean) => {
|
||||||
|
if (!artistId) return;
|
||||||
|
try {
|
||||||
|
await apiClient.post('/artist/album/mark', { artistId, albumId, known });
|
||||||
|
setArtistInfo(prev => {
|
||||||
|
if (!prev) return null;
|
||||||
|
const updateAlbums = (albums: UAlbum[]) => albums.map(a => a.id === albumId ? { ...a, is_known: known } : a);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
albums: {
|
||||||
|
album: updateAlbums(prev.albums.album),
|
||||||
|
single: updateAlbums(prev.albums.single),
|
||||||
|
appears_on: updateAlbums(prev.albums.appears_on),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
toast.success(`Album marked as ${known ? 'seen' : 'unseen'}.`);
|
||||||
|
} catch {
|
||||||
|
// Error toast handled by interceptor
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) return <div>Loading artist...</div>;
|
||||||
|
if (!artistInfo) return <div className="p-4 text-center">Could not load artist details.</div>;
|
||||||
|
|
||||||
|
|
||||||
|
const { artist, topTracks, albums } = artistInfo;
|
||||||
|
|
||||||
|
const renderAlbumCard = (album: UAlbum) => (
|
||||||
|
<div key={album.id} className="w-40 flex-shrink-0 group relative">
|
||||||
|
<Link to="/album/$albumId" params={{ albumId: album.id }}>
|
||||||
|
<img
|
||||||
|
src={album.images[0]?.url || '/placeholder.jpg'}
|
||||||
|
alt={album.name}
|
||||||
|
className={`w-full h-40 object-cover rounded-lg shadow-md group-hover:shadow-lg transition-shadow ${album.is_known ? 'opacity-50' : ''}`}
|
||||||
|
/>
|
||||||
|
<p className="mt-2 text-sm font-semibold truncate">{album.name}</p>
|
||||||
|
<p className="text-xs text-gray-500">{new Date(album.release_date).getFullYear()}</p>
|
||||||
|
</Link>
|
||||||
|
{isWatched && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleMarkAsKnown(album.id, !album.is_known)}
|
||||||
|
title={album.is_known ? 'Mark as not seen' : 'Mark as seen'}
|
||||||
|
className="absolute top-1 right-1 bg-white/70 dark:bg-black/70 p-1.5 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
<img src={album.is_known ? '/check.svg' : '/plus-circle.svg'} alt="Mark" className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Artist Header */}
|
||||||
|
<div className="flex flex-col md:flex-row items-center gap-8">
|
||||||
|
<img
|
||||||
|
src={artist.images[0]?.url || '/placeholder.jpg'}
|
||||||
|
alt={artist.name}
|
||||||
|
className="w-48 h-48 rounded-full object-cover shadow-2xl"
|
||||||
|
/>
|
||||||
|
<div className="text-center md:text-left flex-grow">
|
||||||
|
<h1 className="text-5xl font-extrabold">{artist.name}</h1>
|
||||||
|
<p className="text-gray-500 mt-2">{artist.followers.total.toLocaleString()} followers</p>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2 justify-center md:justify-start">
|
||||||
|
<button
|
||||||
|
onClick={handleDownloadAll}
|
||||||
|
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<img src="/download.svg" alt="" className="w-5 h-5" />
|
||||||
|
Download All
|
||||||
|
</button>
|
||||||
|
{isWatchEnabled && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleWatch}
|
||||||
|
className={`px-4 py-2 rounded-lg transition-colors flex items-center justify-center gap-2 ${
|
||||||
|
isWatched
|
||||||
|
? 'bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600'
|
||||||
|
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<img src={isWatched ? '/eye-crossed.svg' : '/eye.svg'} alt="" className="w-5 h-5" />
|
||||||
|
{isWatched ? 'Unwatch' : 'Watch'}
|
||||||
|
</button>
|
||||||
|
{isWatched && (
|
||||||
|
<button
|
||||||
|
onClick={handleSync}
|
||||||
|
className="p-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
title="Sync Artist"
|
||||||
|
>
|
||||||
|
<img src="/refresh-cw.svg" alt="Sync" className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Tracks */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Top Tracks</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{topTracks.map((track) => (
|
||||||
|
<div key={track.id} className="flex items-center justify-between p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<img src={track.album.images[2]?.url || '/placeholder.jpg'} alt={track.album.name} className="w-12 h-12 rounded-md" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">{track.name}</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{Math.floor(track.duration_ms / 60000)}:
|
||||||
|
{((track.duration_ms % 60000) / 1000).toFixed(0).padStart(2, '0')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => handleDownloadTrack(track)} className="p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-full">
|
||||||
|
<img src="/download.svg" alt="Download" className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Albums */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Albums</h2>
|
||||||
|
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||||
|
{albums.album.map(renderAlbumCard)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Singles */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Singles & EPs</h2>
|
||||||
|
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||||
|
{albums.single.map(renderAlbumCard)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Appears On */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Appears On</h2>
|
||||||
|
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||||
|
{albums.appears_on.map(renderAlbumCard)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
spotizerr-ui/src/routes/config.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { GeneralTab } from '../components/config/GeneralTab';
|
||||||
|
import { DownloadsTab } from '../components/config/DownloadsTab';
|
||||||
|
import { FormattingTab } from '../components/config/FormattingTab';
|
||||||
|
import { AccountsTab } from '../components/config/AccountsTab';
|
||||||
|
import { WatchTab } from '../components/config/WatchTab';
|
||||||
|
import { ServerTab } from '../components/config/ServerTab';
|
||||||
|
import { useSettings } from '../contexts/settings-context';
|
||||||
|
|
||||||
|
const ConfigComponent = () => {
|
||||||
|
const [activeTab, setActiveTab] = useState('general');
|
||||||
|
|
||||||
|
// Get settings from the context instead of fetching here
|
||||||
|
const { settings: config, isLoading } = useSettings();
|
||||||
|
|
||||||
|
const renderTabContent = () => {
|
||||||
|
if (isLoading) return <p className="text-center">Loading configuration...</p>;
|
||||||
|
if (!config) return <p className="text-center text-red-500">Error loading configuration.</p>;
|
||||||
|
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'general':
|
||||||
|
return <GeneralTab config={config} isLoading={isLoading} />;
|
||||||
|
case 'downloads':
|
||||||
|
return <DownloadsTab config={config} isLoading={isLoading} />;
|
||||||
|
case 'formatting':
|
||||||
|
return <FormattingTab config={config} isLoading={isLoading} />;
|
||||||
|
case 'accounts':
|
||||||
|
return <AccountsTab />;
|
||||||
|
case 'watch':
|
||||||
|
return <WatchTab />;
|
||||||
|
case 'server':
|
||||||
|
return <ServerTab />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Configuration</h1>
|
||||||
|
<p className="text-gray-500">Manage application settings and services.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-8">
|
||||||
|
<aside className="w-1/4">
|
||||||
|
<nav className="flex flex-col space-y-1">
|
||||||
|
<button onClick={() => setActiveTab('general')} className={`p-2 rounded-md text-left ${activeTab === 'general' ? 'bg-gray-100 dark:bg-gray-800 font-semibold' : ''}`}>General</button>
|
||||||
|
<button onClick={() => setActiveTab('downloads')} className={`p-2 rounded-md text-left ${activeTab === 'downloads' ? 'bg-gray-100 dark:bg-gray-800 font-semibold' : ''}`}>Downloads</button>
|
||||||
|
<button onClick={() => setActiveTab('formatting')} className={`p-2 rounded-md text-left ${activeTab === 'formatting' ? 'bg-gray-100 dark:bg-gray-800 font-semibold' : ''}`}>Formatting</button>
|
||||||
|
<button onClick={() => setActiveTab('accounts')} className={`p-2 rounded-md text-left ${activeTab === 'accounts' ? 'bg-gray-100 dark:bg-gray-800 font-semibold' : ''}`}>Accounts</button>
|
||||||
|
<button onClick={() => setActiveTab('watch')} className={`p-2 rounded-md text-left ${activeTab === 'watch' ? 'bg-gray-100 dark:bg-gray-800 font-semibold' : ''}`}>Watch</button>
|
||||||
|
<button onClick={() => setActiveTab('server')} className={`p-2 rounded-md text-left ${activeTab === 'server' ? 'bg-gray-100 dark:bg-gray-800 font-semibold' : ''}`}>Server</button>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="w-3/4">
|
||||||
|
{renderTabContent()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const Config = () => {
|
||||||
|
return (
|
||||||
|
<ConfigComponent />
|
||||||
|
)
|
||||||
|
};
|
||||||
203
spotizerr-ui/src/routes/history.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
|
import apiClient from '../lib/api-client';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
createColumnHelper,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
getSortedRowModel,
|
||||||
|
type SortingState,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
|
||||||
|
// --- Type Definitions ---
|
||||||
|
type HistoryEntry = {
|
||||||
|
item_name: string;
|
||||||
|
item_artist: string;
|
||||||
|
download_type: 'track' | 'album' | 'playlist' | 'artist';
|
||||||
|
service_used: string;
|
||||||
|
quality_profile: string;
|
||||||
|
status_final: 'COMPLETED' | 'ERROR' | 'CANCELLED';
|
||||||
|
timestamp_completed: number;
|
||||||
|
error_message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Column Definitions ---
|
||||||
|
const columnHelper = createColumnHelper<HistoryEntry>();
|
||||||
|
const columns = [
|
||||||
|
columnHelper.accessor('item_name', { header: 'Name' }),
|
||||||
|
columnHelper.accessor('item_artist', { header: 'Artist' }),
|
||||||
|
columnHelper.accessor('download_type', { header: 'Type', cell: info => <span className="capitalize">{info.getValue()}</span> }),
|
||||||
|
columnHelper.accessor('status_final', {
|
||||||
|
header: 'Status',
|
||||||
|
cell: info => {
|
||||||
|
const status = info.getValue();
|
||||||
|
const statusClass = {
|
||||||
|
COMPLETED: 'text-green-500',
|
||||||
|
ERROR: 'text-red-500',
|
||||||
|
CANCELLED: 'text-yellow-500',
|
||||||
|
}[status];
|
||||||
|
return <span className={`font-semibold ${statusClass}`}>{status}</span>;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('timestamp_completed', {
|
||||||
|
header: 'Date Completed',
|
||||||
|
cell: info => new Date(info.getValue() * 1000).toLocaleString(),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('error_message', {
|
||||||
|
header: 'Details',
|
||||||
|
cell: info => info.getValue() ? (
|
||||||
|
<button onClick={() => toast.info('Error Details', { description: info.getValue() })} className="text-blue-500 hover:underline">
|
||||||
|
Show Error
|
||||||
|
</button>
|
||||||
|
) : null,
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
export const History = () => {
|
||||||
|
const [data, setData] = useState<HistoryEntry[]>([]);
|
||||||
|
const [totalEntries, setTotalEntries] = useState(0);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// State for TanStack Table
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([{ id: 'timestamp_completed', desc: true }]);
|
||||||
|
const [{ pageIndex, pageSize }, setPagination] = useState({ pageIndex: 0, pageSize: 25 });
|
||||||
|
|
||||||
|
// State for filters
|
||||||
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
|
const [typeFilter, setTypeFilter] = useState('');
|
||||||
|
|
||||||
|
const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchHistory = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
limit: `${pageSize}`,
|
||||||
|
offset: `${pageIndex * pageSize}`,
|
||||||
|
sort_by: sorting[0]?.id ?? 'timestamp_completed',
|
||||||
|
sort_order: sorting[0]?.desc ? 'DESC' : 'ASC',
|
||||||
|
});
|
||||||
|
if (statusFilter) params.append('status_final', statusFilter);
|
||||||
|
if (typeFilter) params.append('download_type', typeFilter);
|
||||||
|
|
||||||
|
const response = await apiClient.get<{ entries: HistoryEntry[], total_count: number }>(`/history?${params.toString()}`);
|
||||||
|
setData(response.data.entries);
|
||||||
|
setTotalEntries(response.data.total_count);
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to load history.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchHistory();
|
||||||
|
}, [pageIndex, pageSize, sorting, statusFilter, typeFilter]);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
pageCount: Math.ceil(totalEntries / pageSize),
|
||||||
|
state: { sorting, pagination },
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
manualPagination: true,
|
||||||
|
manualSorting: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h1 className="text-3xl font-bold">Download History</h1>
|
||||||
|
|
||||||
|
{/* Filter Controls */}
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)} className="p-2 border rounded-md dark:bg-gray-800 dark:border-gray-700">
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="COMPLETED">Completed</option>
|
||||||
|
<option value="ERROR">Error</option>
|
||||||
|
<option value="CANCELLED">Cancelled</option>
|
||||||
|
</select>
|
||||||
|
<select value={typeFilter} onChange={e => setTypeFilter(e.target.value)} className="p-2 border rounded-md dark:bg-gray-800 dark:border-gray-700">
|
||||||
|
<option value="">All Types</option>
|
||||||
|
<option value="track">Track</option>
|
||||||
|
<option value="album">Album</option>
|
||||||
|
<option value="playlist">Playlist</option>
|
||||||
|
<option value="artist">Artist</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full">
|
||||||
|
<thead>
|
||||||
|
{table.getHeaderGroups().map(headerGroup => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map(header => (
|
||||||
|
<th key={header.id} className="p-2 text-left">
|
||||||
|
{header.isPlaceholder ? null : (
|
||||||
|
<div
|
||||||
|
{...{
|
||||||
|
className: header.column.getCanSort() ? 'cursor-pointer select-none' : '',
|
||||||
|
onClick: header.column.getToggleSortingHandler(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
{{ asc: ' ▲', desc: ' ▼'}[header.column.getIsSorted() as string] ?? null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{isLoading ? (
|
||||||
|
<tr><td colSpan={columns.length} className="text-center p-4">Loading...</td></tr>
|
||||||
|
) : table.getRowModel().rows.length === 0 ? (
|
||||||
|
<tr><td colSpan={columns.length} className="text-center p-4">No history entries found.</td></tr>
|
||||||
|
) : (
|
||||||
|
table.getRowModel().rows.map(row => (
|
||||||
|
<tr key={row.id} className="border-b dark:border-gray-700">
|
||||||
|
{row.getVisibleCells().map(cell => (
|
||||||
|
<td key={cell.id} className="p-2">
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination Controls */}
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} className="p-2 border rounded-md disabled:opacity-50">
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span>
|
||||||
|
Page{' '}
|
||||||
|
<strong>
|
||||||
|
{table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
<button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} className="p-2 border rounded-md disabled:opacity-50">
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
<select
|
||||||
|
value={table.getState().pagination.pageSize}
|
||||||
|
onChange={e => table.setPageSize(Number(e.target.value))}
|
||||||
|
className="p-2 border rounded-md dark:bg-gray-800 dark:border-gray-700"
|
||||||
|
>
|
||||||
|
{[10, 25, 50, 100].map(size => (
|
||||||
|
<option key={size} value={size}>
|
||||||
|
Show {size}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
spotizerr-ui/src/routes/home.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { useDebounce } from 'use-debounce';
|
||||||
|
import apiClient from '../lib/api-client';
|
||||||
|
import { useQueue } from '../contexts/queue-context';
|
||||||
|
|
||||||
|
// --- Type Definitions ---
|
||||||
|
interface Image { url: string; }
|
||||||
|
interface BaseItem { id: string; name: string; }
|
||||||
|
interface Artist extends BaseItem { images?: Image[]; }
|
||||||
|
interface Album extends BaseItem { images?: Image[]; artists: Artist[]; }
|
||||||
|
interface Track extends BaseItem { album: Album; artists: Artist[]; }
|
||||||
|
interface Playlist extends BaseItem { images?: Image[]; owner: { display_name: string }; }
|
||||||
|
|
||||||
|
type SearchResultItem = Artist | Album | Track | Playlist;
|
||||||
|
type SearchType = 'artist' | 'album' | 'track' | 'playlist';
|
||||||
|
|
||||||
|
// --- Component ---
|
||||||
|
export function Home() {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [searchType, setSearchType] = useState<SearchType>('track');
|
||||||
|
const [results, setResults] = useState<SearchResultItem[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [debouncedQuery] = useDebounce(query, 500);
|
||||||
|
const { addItem, toggleVisibility } = useQueue();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const performSearch = async () => {
|
||||||
|
if (debouncedQuery.trim().length < 2) {
|
||||||
|
setResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<{ items: SearchResultItem[] }>('/search', {
|
||||||
|
params: { q: debouncedQuery, search_type: searchType, limit: 40 },
|
||||||
|
});
|
||||||
|
setResults(response.data.items);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search failed:', error);
|
||||||
|
setResults([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
performSearch();
|
||||||
|
}, [debouncedQuery, searchType]);
|
||||||
|
|
||||||
|
const handleDownloadTrack = (track: Track) => {
|
||||||
|
addItem({ id: track.id, type: 'track', name: track.name });
|
||||||
|
toggleVisibility();
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderResult = (item: SearchResultItem) => {
|
||||||
|
switch (searchType) {
|
||||||
|
case 'track': {
|
||||||
|
const track = item as Track;
|
||||||
|
return (
|
||||||
|
<div key={track.id} className="p-2 flex items-center gap-4 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg">
|
||||||
|
<img src={track.album.images?.[0]?.url || '/placeholder.jpg'} alt={track.album.name} className="w-12 h-12 rounded" />
|
||||||
|
<div className="flex-grow">
|
||||||
|
<p className="font-semibold">{track.name}</p>
|
||||||
|
<p className="text-sm text-gray-500">{track.artists.map(a => a.name).join(', ')}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => handleDownloadTrack(track)} className="p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-full">
|
||||||
|
<img src="/download.svg" alt="Download" className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'album': {
|
||||||
|
const album = item as Album;
|
||||||
|
return (
|
||||||
|
<Link to="/album/$albumId" params={{ albumId: album.id }} key={album.id} className="block p-2 text-center hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg">
|
||||||
|
<img src={album.images?.[0]?.url || '/placeholder.jpg'} alt={album.name} className="w-full h-auto object-cover rounded shadow-md aspect-square" />
|
||||||
|
<p className="mt-2 font-semibold truncate">{album.name}</p>
|
||||||
|
<p className="text-sm text-gray-500">{album.artists.map(a => a.name).join(', ')}</p>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'artist': {
|
||||||
|
const artist = item as Artist;
|
||||||
|
return (
|
||||||
|
<Link to="/artist/$artistId" params={{ artistId: artist.id }} key={artist.id} className="block p-2 text-center hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg">
|
||||||
|
<img src={artist.images?.[0]?.url || '/placeholder.jpg'} alt={artist.name} className="w-full h-auto object-cover rounded-full shadow-md aspect-square" />
|
||||||
|
<p className="mt-2 font-semibold truncate">{artist.name}</p>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'playlist': {
|
||||||
|
const playlist = item as Playlist;
|
||||||
|
return (
|
||||||
|
<Link to="/playlist/$playlistId" params={{ playlistId: playlist.id }} key={playlist.id} className="block p-2 text-center hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg">
|
||||||
|
<img src={playlist.images?.[0]?.url || '/placeholder.jpg'} alt={playlist.name} className="w-full h-auto object-cover rounded shadow-md aspect-square" />
|
||||||
|
<p className="mt-2 font-semibold truncate">{playlist.name}</p>
|
||||||
|
<p className="text-sm text-gray-500">by {playlist.owner.display_name}</p>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const gridClass = useMemo(() => {
|
||||||
|
switch(searchType) {
|
||||||
|
case 'album':
|
||||||
|
case 'artist':
|
||||||
|
case 'playlist':
|
||||||
|
return "grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4";
|
||||||
|
case 'track':
|
||||||
|
return "flex flex-col gap-1";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}, [searchType]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="relative">
|
||||||
|
<img src="/search.svg" alt="" className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search for songs, albums, artists..."
|
||||||
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-700 rounded-full bg-gray-100 dark:bg-gray-800"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={searchType}
|
||||||
|
onChange={(e) => setSearchType(e.target.value as SearchType)}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 bg-transparent border-none text-gray-500"
|
||||||
|
>
|
||||||
|
<option value="track">Tracks</option>
|
||||||
|
<option value="album">Albums</option>
|
||||||
|
<option value="artist">Artists</option>
|
||||||
|
<option value="playlist">Playlists</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{isLoading && <p>Loading...</p>}
|
||||||
|
{!isLoading && debouncedQuery && results.length === 0 && <p>No results found.</p>}
|
||||||
|
<div className={gridClass}>
|
||||||
|
{results.map(renderResult)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
spotizerr-ui/src/routes/playlist.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { Link, useParams } from '@tanstack/react-router';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import apiClient from '../lib/api-client';
|
||||||
|
import { useQueue } from '../contexts/queue-context';
|
||||||
|
import { useSettings } from '../contexts/settings-context';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { ImageType, TrackType } from '../types/spotify';
|
||||||
|
|
||||||
|
// --- Type Definitions ---
|
||||||
|
interface SimplifiedAlbumType {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
images: ImageType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlaylistTrackType extends TrackType {
|
||||||
|
album: SimplifiedAlbumType;
|
||||||
|
}
|
||||||
|
interface PlaylistItemType { track: PlaylistTrackType | null; }
|
||||||
|
|
||||||
|
interface PlaylistDetailsType {
|
||||||
|
id:string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
images: ImageType[];
|
||||||
|
owner: { display_name?: string };
|
||||||
|
followers?: { total: number };
|
||||||
|
tracks: { items: PlaylistItemType[]; total: number; };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Playlist = () => {
|
||||||
|
const { playlistId } = useParams({ from: '/playlist/$playlistId' });
|
||||||
|
const [playlist, setPlaylist] = useState<PlaylistDetailsType | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const { addItem, toggleVisibility } = useQueue();
|
||||||
|
const { settings } = useSettings();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPlaylist = async () => {
|
||||||
|
if (!playlistId) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<PlaylistDetailsType>(`/playlist/info?id=${playlistId}`);
|
||||||
|
setPlaylist(response.data);
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to load playlist details.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchPlaylist();
|
||||||
|
}, [playlistId]);
|
||||||
|
|
||||||
|
const handleDownloadTrack = (track: PlaylistTrackType) => {
|
||||||
|
addItem({ id: track.id, type: 'track', name: track.name });
|
||||||
|
toggleVisibility();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadPlaylist = () => {
|
||||||
|
if (!playlist) return;
|
||||||
|
// This assumes a backend endpoint that can handle a whole playlist download by its ID
|
||||||
|
addItem({ id: playlist.id, type: 'playlist', name: playlist.name });
|
||||||
|
toggleVisibility();
|
||||||
|
toast.success(`Queued playlist: ${playlist.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) return <div>Loading playlist...</div>;
|
||||||
|
if (!playlist) return <div>Playlist not found.</div>;
|
||||||
|
|
||||||
|
const isExplicitFilterEnabled = settings?.explicitFilter ?? false;
|
||||||
|
const hasExplicitTrack = playlist.tracks.items.some(item => item.track?.explicit);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col md:flex-row items-start gap-8">
|
||||||
|
<img src={playlist.images[0]?.url || '/placeholder.jpg'} alt={playlist.name} className="w-48 h-48 object-cover rounded-lg shadow-lg"/>
|
||||||
|
<div className="flex-grow space-y-2">
|
||||||
|
<h1 className="text-4xl font-bold">{playlist.name}</h1>
|
||||||
|
<p className="text-gray-500">By {playlist.owner.display_name}</p>
|
||||||
|
{playlist.description && <p className="text-sm text-gray-400" dangerouslySetInnerHTML={{ __html: playlist.description }} />}
|
||||||
|
<p className="text-sm text-gray-500">{playlist.followers?.total.toLocaleString()} followers • {playlist.tracks.total} songs</p>
|
||||||
|
<div className="pt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleDownloadPlaylist}
|
||||||
|
disabled={isExplicitFilterEnabled && hasExplicitTrack}
|
||||||
|
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:bg-gray-500"
|
||||||
|
title={isExplicitFilterEnabled && hasExplicitTrack ? "Playlist contains explicit tracks and can't be downloaded" : 'Download all tracks in playlist'}
|
||||||
|
>
|
||||||
|
Download Playlist
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{playlist.tracks.items.map(({ track }, index) => {
|
||||||
|
if (!track) return null; // Handle cases where a track might be unavailable
|
||||||
|
|
||||||
|
if (isExplicitFilterEnabled && track.explicit) {
|
||||||
|
return (
|
||||||
|
<div key={index} className="flex items-center p-3 text-sm bg-gray-100 dark:bg-gray-800 rounded-lg opacity-60">
|
||||||
|
<span className="w-8 text-gray-500">{index + 1}</span>
|
||||||
|
<span className="font-medium text-gray-500">Explicit track filtered</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={track.id} className="flex items-center gap-4 p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg">
|
||||||
|
<span className="w-6 text-right text-gray-500">{index + 1}</span>
|
||||||
|
<img src={track.album.images[track.album.images.length - 1]?.url || '/placeholder.jpg'} alt="" className="w-10 h-10 rounded" />
|
||||||
|
<div className="flex-grow">
|
||||||
|
<p className="font-semibold">{track.name}</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{track.artists.map(a => <Link key={a.id} to="/artist/$artistId" params={{artistId: a.id}} className="hover:underline">{a.name}</Link>).reduce((prev, curr) => <>{prev}, {curr}</>)}
|
||||||
|
{' • '}
|
||||||
|
<Link to="/album/$albumId" params={{albumId: track.album.id}} className="hover:underline">{track.album.name}</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-500 hidden md:block">
|
||||||
|
{Math.floor(track.duration_ms / 60000)}:{((track.duration_ms % 60000) / 1000).toFixed(0).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
<button onClick={() => handleDownloadTrack(track)} className="p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-full">
|
||||||
|
<img src="/download.svg" alt="Download" className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
spotizerr-ui/src/routes/root.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { Outlet } from '@tanstack/react-router';
|
||||||
|
import { QueueProvider } from '../contexts/QueueProvider';
|
||||||
|
import { useQueue } from '../contexts/queue-context';
|
||||||
|
import { Queue } from '../components/Queue';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { SettingsProvider } from '../contexts/SettingsProvider';
|
||||||
|
import { Toaster } from 'sonner';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
// Create a client
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
function AppLayout() {
|
||||||
|
const { toggleVisibility } = useQueue();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
|
<header className="sticky top-0 z-40 w-full border-b bg-background/95 backdrop-blur-sm">
|
||||||
|
<div className="container mx-auto h-14 flex items-center justify-between">
|
||||||
|
<Link to="/" className="flex items-center gap-2">
|
||||||
|
<img src="/music.svg" alt="Logo" className="w-6 h-6" />
|
||||||
|
<h1 className="text-xl font-bold">Spotizerr</h1>
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link to="/watchlist" className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700">
|
||||||
|
<img src="/binoculars.svg" alt="Watchlist" className="w-6 h-6" />
|
||||||
|
</Link>
|
||||||
|
<Link to="/history" className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700">
|
||||||
|
<img src="/history.svg" alt="History" className="w-6 h-6" />
|
||||||
|
</Link>
|
||||||
|
<Link to="/config" className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700">
|
||||||
|
<img src="/settings.svg" alt="Settings" className="w-6 h-6" />
|
||||||
|
</Link>
|
||||||
|
<button onClick={toggleVisibility} className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700">
|
||||||
|
<img src="/queue.svg" alt="Queue" className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="container mx-auto px-4 py-8">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<Queue />
|
||||||
|
<Toaster richColors />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Root() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<SettingsProvider>
|
||||||
|
<QueueProvider>
|
||||||
|
<AppLayout />
|
||||||
|
</QueueProvider>
|
||||||
|
</SettingsProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
spotizerr-ui/src/routes/track.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { Link, useParams } from '@tanstack/react-router';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import apiClient from '../lib/api-client';
|
||||||
|
import { useQueue } from '../contexts/queue-context';
|
||||||
|
import type { TrackType, ImageType } from '../types/spotify';
|
||||||
|
|
||||||
|
interface SimplifiedAlbum {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
images: ImageType[];
|
||||||
|
album_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrackDetails extends TrackType {
|
||||||
|
album: SimplifiedAlbum;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Track = () => {
|
||||||
|
const { trackId } = useParams({ from: '/track/$trackId' });
|
||||||
|
const [track, setTrack] = useState<TrackDetails | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { addItem, toggleVisibility } = useQueue();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchTrack = async () => {
|
||||||
|
if (!trackId) return;
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<TrackDetails>(`/track/info?id=${trackId}`);
|
||||||
|
setTrack(response.data);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to load track details.');
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchTrack();
|
||||||
|
}, [trackId]);
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
if (!track) return;
|
||||||
|
addItem({ id: track.id, type: 'track', name: track.name });
|
||||||
|
toggleVisibility();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) return <div className="text-red-500">{error}</div>;
|
||||||
|
if (!track) return <div>Loading...</div>;
|
||||||
|
|
||||||
|
const minutes = Math.floor(track.duration_ms / 60000);
|
||||||
|
const seconds = ((track.duration_ms % 60000) / 1000).toFixed(0).padStart(2, '0');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col md:flex-row items-center gap-8 p-4">
|
||||||
|
<img
|
||||||
|
src={track.album.images[0]?.url || '/placeholder.jpg'}
|
||||||
|
alt={track.album.name}
|
||||||
|
className="w-64 h-64 object-cover rounded-lg shadow-2xl"
|
||||||
|
/>
|
||||||
|
<div className="flex-grow space-y-3 text-center md:text-left">
|
||||||
|
<h1 className="text-4xl font-extrabold">{track.name}</h1>
|
||||||
|
<p className="text-xl text-gray-500">
|
||||||
|
By{' '}
|
||||||
|
{track.artists.map((artist, index) => (
|
||||||
|
<span key={artist.id}>
|
||||||
|
<Link to="/artist/$artistId" params={{ artistId: artist.id }} className="hover:underline">
|
||||||
|
{artist.name}
|
||||||
|
</Link>
|
||||||
|
{index < track.artists.length - 1 && ', '}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
<p className="text-lg text-gray-400">
|
||||||
|
From the {track.album.album_type}{' '}
|
||||||
|
<Link to="/album/$albumId" params={{ albumId: track.album.id }} className="hover:underline font-semibold">
|
||||||
|
{track.album.name}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-center md:justify-start gap-4 text-sm text-gray-500">
|
||||||
|
<span>{minutes}:{seconds}</span>
|
||||||
|
{track.explicit && <span className="px-2 py-0.5 bg-gray-200 dark:bg-gray-700 text-xs font-semibold rounded-full">EXPLICIT</span>}
|
||||||
|
</div>
|
||||||
|
<div className="pt-4">
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="px-6 py-3 bg-green-600 text-white rounded-full hover:bg-green-700 transition-colors flex items-center justify-center gap-2 text-lg"
|
||||||
|
>
|
||||||
|
<img src="/download.svg" alt="" className="w-6 h-6" />
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
147
spotizerr-ui/src/routes/watchlist.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import apiClient from '../lib/api-client';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useSettings } from '../contexts/settings-context';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
// --- Type Definitions ---
|
||||||
|
interface Image {
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WatchedArtist {
|
||||||
|
itemType: 'artist';
|
||||||
|
spotify_id: string;
|
||||||
|
name: string;
|
||||||
|
images?: Image[];
|
||||||
|
total_albums?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WatchedPlaylist {
|
||||||
|
itemType: 'playlist';
|
||||||
|
spotify_id: string;
|
||||||
|
name: string;
|
||||||
|
images?: Image[];
|
||||||
|
owner?: { display_name?: string };
|
||||||
|
total_tracks?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WatchedItem = WatchedArtist | WatchedPlaylist;
|
||||||
|
|
||||||
|
export const Watchlist = () => {
|
||||||
|
const { settings, isLoading: settingsLoading } = useSettings();
|
||||||
|
const [items, setItems] = useState<WatchedItem[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchWatchlist = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const [artistsRes, playlistsRes] = await Promise.all([
|
||||||
|
apiClient.get<Omit<WatchedArtist, 'itemType'>[]>('/artist/watch/list'),
|
||||||
|
apiClient.get<Omit<WatchedPlaylist, 'itemType'>[]>('/playlist/watch/list'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const artists: WatchedItem[] = artistsRes.data.map(a => ({ ...a, itemType: 'artist' }));
|
||||||
|
const playlists: WatchedItem[] = playlistsRes.data.map(p => ({ ...p, itemType: 'playlist' }));
|
||||||
|
|
||||||
|
setItems([...artists, ...playlists]);
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to load watchlist.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settingsLoading && settings?.watch?.enabled) {
|
||||||
|
fetchWatchlist();
|
||||||
|
} else if (!settingsLoading) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [settings, settingsLoading, fetchWatchlist]);
|
||||||
|
|
||||||
|
const handleUnwatch = async (item: WatchedItem) => {
|
||||||
|
toast.promise(
|
||||||
|
apiClient.delete(`/${item.itemType}/watch/${item.spotify_id}`), {
|
||||||
|
loading: `Unwatching ${item.name}...`,
|
||||||
|
success: () => {
|
||||||
|
setItems(prev => prev.filter(i => i.spotify_id !== item.spotify_id));
|
||||||
|
return `${item.name} has been unwatched.`;
|
||||||
|
},
|
||||||
|
error: `Failed to unwatch ${item.name}.`
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheck = async (item: WatchedItem) => {
|
||||||
|
toast.promise(
|
||||||
|
apiClient.post(`/${item.itemType}/watch/trigger_check/${item.spotify_id}`), {
|
||||||
|
loading: `Checking ${item.name} for updates...`,
|
||||||
|
success: (res: { data: { message?: string }}) => res.data.message || `Check triggered for ${item.name}.`,
|
||||||
|
error: `Failed to trigger check for ${item.name}.`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckAll = () => {
|
||||||
|
toast.promise(Promise.all([
|
||||||
|
apiClient.post('/artist/watch/trigger_check'),
|
||||||
|
apiClient.post('/playlist/watch/trigger_check'),
|
||||||
|
]), {
|
||||||
|
loading: 'Triggering checks for all watched items...',
|
||||||
|
success: 'Successfully triggered checks for all items.',
|
||||||
|
error: 'Failed to trigger one or more checks.'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading || settingsLoading) {
|
||||||
|
return <div className="text-center">Loading Watchlist...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!settings?.watch?.enabled) {
|
||||||
|
return (
|
||||||
|
<div className="text-center p-8">
|
||||||
|
<h2 className="text-2xl font-bold mb-2">Watchlist Disabled</h2>
|
||||||
|
<p>The watchlist feature is currently disabled. You can enable it in the settings.</p>
|
||||||
|
<Link to="/config" className="text-blue-500 hover:underline mt-4 inline-block">Go to Settings</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center p-8">
|
||||||
|
<h2 className="text-2xl font-bold mb-2">Watchlist is Empty</h2>
|
||||||
|
<p>Start watching artists or playlists to see them here.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h1 className="text-3xl font-bold">Watched Artists & Playlists</h1>
|
||||||
|
<button onClick={handleCheckAll} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
||||||
|
Check All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||||
|
{items.map(item => (
|
||||||
|
<div key={item.spotify_id} className="bg-card p-4 rounded-lg shadow space-y-2 flex flex-col">
|
||||||
|
<a href={`/${item.itemType}/${item.spotify_id}`} className="flex-grow">
|
||||||
|
<img
|
||||||
|
src={item.images?.[0]?.url || '/images/placeholder.jpg'}
|
||||||
|
alt={item.name}
|
||||||
|
className="w-full h-auto object-cover rounded-md aspect-square"
|
||||||
|
/>
|
||||||
|
<h3 className="font-bold pt-2 truncate">{item.name}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground capitalize">{item.itemType}</p>
|
||||||
|
</a>
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<button onClick={() => handleUnwatch(item)} className="w-full px-3 py-1.5 text-sm bg-red-600 text-white rounded-md hover:bg-red-700">Unwatch</button>
|
||||||
|
<button onClick={() => handleCheck(item)} className="w-full px-3 py-1.5 text-sm bg-gray-600 text-white rounded-md hover:bg-gray-700">Check</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
spotizerr-ui/src/types/settings.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// This new type reflects the flat structure of the /api/config response
|
||||||
|
export interface AppSettings {
|
||||||
|
service: 'spotify' | 'deezer';
|
||||||
|
spotify: string;
|
||||||
|
spotifyQuality: 'NORMAL' | 'HIGH' | 'VERY_HIGH';
|
||||||
|
deezer: string;
|
||||||
|
deezerQuality: 'MP3_128' | 'MP3_320' | 'FLAC';
|
||||||
|
maxConcurrentDownloads: number;
|
||||||
|
realTime: boolean;
|
||||||
|
fallback: boolean;
|
||||||
|
convertTo: 'MP3' | 'AAC' | 'OGG' | 'OPUS' | 'FLAC' | 'WAV' | 'ALAC' | '';
|
||||||
|
bitrate: string;
|
||||||
|
maxRetries: number;
|
||||||
|
retryDelaySeconds: number;
|
||||||
|
retryDelayIncrease: number;
|
||||||
|
customDirFormat: string;
|
||||||
|
customTrackFormat: string;
|
||||||
|
tracknumPadding: boolean;
|
||||||
|
saveCover: boolean;
|
||||||
|
explicitFilter: boolean;
|
||||||
|
// Properties from the old 'downloads' object
|
||||||
|
threads: number;
|
||||||
|
path: string;
|
||||||
|
skipExisting: boolean;
|
||||||
|
m3u: boolean;
|
||||||
|
hlsThreads: number;
|
||||||
|
// Properties from the old 'formatting' object
|
||||||
|
track: string;
|
||||||
|
album: string;
|
||||||
|
playlist: string;
|
||||||
|
compilation: string;
|
||||||
|
watch: {
|
||||||
|
enabled: boolean;
|
||||||
|
// Add other watch properties from the old type if they still exist in the API response
|
||||||
|
};
|
||||||
|
// Add other root-level properties from the API if they exist
|
||||||
|
}
|
||||||
33
spotizerr-ui/src/types/spotify.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export interface ImageType {
|
||||||
|
url: string;
|
||||||
|
height?: number;
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArtistType {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrackType {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
artists: ArtistType[];
|
||||||
|
duration_ms: number;
|
||||||
|
explicit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlbumType {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
artists: ArtistType[];
|
||||||
|
images: ImageType[];
|
||||||
|
release_date: string;
|
||||||
|
total_tracks: number;
|
||||||
|
label: string;
|
||||||
|
copyrights: Array<{ text: string; type: string }>;
|
||||||
|
explicit: boolean;
|
||||||
|
tracks: {
|
||||||
|
items: TrackType[];
|
||||||
|
};
|
||||||
|
}
|
||||||
1
spotizerr-ui/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
33
spotizerr-ui/tsconfig.app.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Path Aliases */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
spotizerr-ui/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
25
spotizerr-ui/tsconfig.node.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
27
spotizerr-ui/vite.config.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
import { dirname, resolve } from 'path'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = dirname(__filename)
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:7171',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
409
src/js/album.ts
@@ -1,409 +0,0 @@
|
|||||||
import { downloadQueue } from './queue.js';
|
|
||||||
|
|
||||||
// Define interfaces for API data
|
|
||||||
interface Image {
|
|
||||||
url: string;
|
|
||||||
height?: number;
|
|
||||||
width?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Artist {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
external_urls: {
|
|
||||||
spotify: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Track {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
artists: Artist[];
|
|
||||||
duration_ms: number;
|
|
||||||
explicit: boolean;
|
|
||||||
external_urls: {
|
|
||||||
spotify: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Album {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
artists: Artist[];
|
|
||||||
images: Image[];
|
|
||||||
release_date: string;
|
|
||||||
total_tracks: number;
|
|
||||||
label: string;
|
|
||||||
copyrights: { text: string; type: string }[];
|
|
||||||
explicit: boolean;
|
|
||||||
tracks: {
|
|
||||||
items: Track[];
|
|
||||||
// Add other properties from Spotify API if needed (e.g., total, limit, offset)
|
|
||||||
};
|
|
||||||
external_urls: {
|
|
||||||
spotify: string;
|
|
||||||
};
|
|
||||||
// Add other album properties if available
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const pathSegments = window.location.pathname.split('/');
|
|
||||||
const albumId = pathSegments[pathSegments.indexOf('album') + 1];
|
|
||||||
|
|
||||||
if (!albumId) {
|
|
||||||
showError('No album ID provided.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch album info directly
|
|
||||||
fetch(`/api/album/info?id=${encodeURIComponent(albumId)}`)
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) throw new Error('Network response was not ok');
|
|
||||||
return response.json() as Promise<Album>; // Add Album type
|
|
||||||
})
|
|
||||||
.then(data => renderAlbum(data))
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error:', error);
|
|
||||||
showError('Failed to load album.');
|
|
||||||
});
|
|
||||||
|
|
||||||
const queueIcon = document.getElementById('queueIcon');
|
|
||||||
if (queueIcon) {
|
|
||||||
queueIcon.addEventListener('click', () => {
|
|
||||||
downloadQueue.toggleVisibility();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attempt to set initial watchlist button visibility from cache
|
|
||||||
const watchlistButton = document.getElementById('watchlistButton') as HTMLAnchorElement | null;
|
|
||||||
if (watchlistButton) {
|
|
||||||
const cachedWatchEnabled = localStorage.getItem('spotizerr_watch_enabled_cached');
|
|
||||||
if (cachedWatchEnabled === 'true') {
|
|
||||||
watchlistButton.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch watch config to determine if watchlist button should be visible
|
|
||||||
async function updateWatchlistButtonVisibility() {
|
|
||||||
if (watchlistButton) {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/config/watch');
|
|
||||||
if (response.ok) {
|
|
||||||
const watchConfig = await response.json();
|
|
||||||
localStorage.setItem('spotizerr_watch_enabled_cached', watchConfig.enabled ? 'true' : 'false');
|
|
||||||
if (watchConfig && watchConfig.enabled === false) {
|
|
||||||
watchlistButton.classList.add('hidden');
|
|
||||||
} else {
|
|
||||||
watchlistButton.classList.remove('hidden'); // Ensure it's shown if enabled
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error('Failed to fetch watch config, defaulting to hidden');
|
|
||||||
// Don't update cache on error
|
|
||||||
watchlistButton.classList.add('hidden'); // Hide if config fetch fails
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching watch config:', error);
|
|
||||||
// Don't update cache on error
|
|
||||||
watchlistButton.classList.add('hidden'); // Hide on error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updateWatchlistButtonVisibility();
|
|
||||||
});
|
|
||||||
|
|
||||||
function renderAlbum(album: Album) {
|
|
||||||
// Hide loading and error messages.
|
|
||||||
const loadingEl = document.getElementById('loading');
|
|
||||||
if (loadingEl) loadingEl.classList.add('hidden');
|
|
||||||
|
|
||||||
const errorSectionEl = document.getElementById('error'); // Renamed to avoid conflict with error var in catch
|
|
||||||
if (errorSectionEl) errorSectionEl.classList.add('hidden');
|
|
||||||
|
|
||||||
// Check if album itself is marked explicit and filter is enabled
|
|
||||||
const isExplicitFilterEnabled = downloadQueue.isExplicitFilterEnabled();
|
|
||||||
if (isExplicitFilterEnabled && album.explicit) {
|
|
||||||
// Show placeholder for explicit album
|
|
||||||
const placeholderContent = `
|
|
||||||
<div class="explicit-filter-placeholder">
|
|
||||||
<h2>Explicit Content Filtered</h2>
|
|
||||||
<p>This album contains explicit content and has been filtered based on your settings.</p>
|
|
||||||
<p>The explicit content filter is controlled by environment variables.</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const contentContainer = document.getElementById('album-header');
|
|
||||||
if (contentContainer) {
|
|
||||||
contentContainer.innerHTML = placeholderContent;
|
|
||||||
contentContainer.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
return; // Stop rendering the actual album content
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseUrl = window.location.origin;
|
|
||||||
|
|
||||||
// Set album header info.
|
|
||||||
const albumNameEl = document.getElementById('album-name');
|
|
||||||
if (albumNameEl) {
|
|
||||||
albumNameEl.innerHTML =
|
|
||||||
`<a href="${baseUrl}/album/${album.id || ''}">${album.name || 'Unknown Album'}</a>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const albumArtistEl = document.getElementById('album-artist');
|
|
||||||
if (albumArtistEl) {
|
|
||||||
albumArtistEl.innerHTML =
|
|
||||||
`By ${album.artists?.map(artist =>
|
|
||||||
`<a href="${baseUrl}/artist/${artist?.id || ''}">${artist?.name || 'Unknown Artist'}</a>`
|
|
||||||
).join(', ') || 'Unknown Artist'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const releaseYear = album.release_date ? new Date(album.release_date).getFullYear() : 'N/A';
|
|
||||||
const albumStatsEl = document.getElementById('album-stats');
|
|
||||||
if (albumStatsEl) {
|
|
||||||
albumStatsEl.textContent =
|
|
||||||
`${releaseYear} • ${album.total_tracks || '0'} songs • ${album.label || 'Unknown Label'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const albumCopyrightEl = document.getElementById('album-copyright');
|
|
||||||
if (albumCopyrightEl) {
|
|
||||||
albumCopyrightEl.textContent =
|
|
||||||
album.copyrights?.map(c => c?.text || '').filter(text => text).join(' • ') || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageSrc = album.images?.[0]?.url || '/static/images/placeholder.jpg';
|
|
||||||
const albumImageEl = document.getElementById('album-image') as HTMLImageElement | null;
|
|
||||||
if (albumImageEl) {
|
|
||||||
albumImageEl.src = imageSrc;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create (if needed) the Home Button.
|
|
||||||
let homeButton = document.getElementById('homeButton') as HTMLButtonElement | null;
|
|
||||||
if (!homeButton) {
|
|
||||||
homeButton = document.createElement('button');
|
|
||||||
homeButton.id = 'homeButton';
|
|
||||||
homeButton.className = 'home-btn';
|
|
||||||
|
|
||||||
const homeIcon = document.createElement('img');
|
|
||||||
homeIcon.src = '/static/images/home.svg';
|
|
||||||
homeIcon.alt = 'Home';
|
|
||||||
homeButton.appendChild(homeIcon);
|
|
||||||
|
|
||||||
// Insert as first child of album-header.
|
|
||||||
const headerContainer = document.getElementById('album-header');
|
|
||||||
if (headerContainer) { // Null check
|
|
||||||
headerContainer.insertBefore(homeButton, headerContainer.firstChild);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (homeButton) { // Null check
|
|
||||||
homeButton.addEventListener('click', () => {
|
|
||||||
window.location.href = window.location.origin;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if any track in the album is explicit when filter is enabled
|
|
||||||
let hasExplicitTrack = false;
|
|
||||||
if (isExplicitFilterEnabled && album.tracks?.items) {
|
|
||||||
hasExplicitTrack = album.tracks.items.some(track => track && track.explicit);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create (if needed) the Download Album Button.
|
|
||||||
let downloadAlbumBtn = document.getElementById('downloadAlbumBtn') as HTMLButtonElement | null;
|
|
||||||
if (!downloadAlbumBtn) {
|
|
||||||
downloadAlbumBtn = document.createElement('button');
|
|
||||||
downloadAlbumBtn.id = 'downloadAlbumBtn';
|
|
||||||
downloadAlbumBtn.textContent = 'Download Full Album';
|
|
||||||
downloadAlbumBtn.className = 'download-btn download-btn--main';
|
|
||||||
const albumHeader = document.getElementById('album-header');
|
|
||||||
if (albumHeader) albumHeader.appendChild(downloadAlbumBtn); // Null check
|
|
||||||
}
|
|
||||||
|
|
||||||
if (downloadAlbumBtn) { // Null check for downloadAlbumBtn
|
|
||||||
if (isExplicitFilterEnabled && hasExplicitTrack) {
|
|
||||||
// Disable the album download button and display a message explaining why
|
|
||||||
downloadAlbumBtn.disabled = true;
|
|
||||||
downloadAlbumBtn.classList.add('download-btn--disabled');
|
|
||||||
downloadAlbumBtn.innerHTML = `<span title="Cannot download entire album because it contains explicit tracks">Album Contains Explicit Tracks</span>`;
|
|
||||||
} else {
|
|
||||||
// Normal behavior when no explicit tracks are present
|
|
||||||
downloadAlbumBtn.addEventListener('click', () => {
|
|
||||||
// Remove any other download buttons (keeping the full-album button in place).
|
|
||||||
document.querySelectorAll('.download-btn').forEach(btn => {
|
|
||||||
if (btn.id !== 'downloadAlbumBtn') btn.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (downloadAlbumBtn) { // Inner null check
|
|
||||||
downloadAlbumBtn.disabled = true;
|
|
||||||
downloadAlbumBtn.textContent = 'Queueing...';
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadWholeAlbum(album)
|
|
||||||
.then(() => {
|
|
||||||
if (downloadAlbumBtn) downloadAlbumBtn.textContent = 'Queued!'; // Inner null check
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
showError('Failed to queue album download: ' + (err?.message || 'Unknown error'));
|
|
||||||
if (downloadAlbumBtn) downloadAlbumBtn.disabled = false; // Inner null check
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render each track.
|
|
||||||
const tracksList = document.getElementById('tracks-list');
|
|
||||||
if (tracksList) { // Null check
|
|
||||||
tracksList.innerHTML = '';
|
|
||||||
|
|
||||||
if (album.tracks?.items) {
|
|
||||||
album.tracks.items.forEach((track, index) => {
|
|
||||||
if (!track) return; // Skip null or undefined tracks
|
|
||||||
|
|
||||||
// Skip explicit tracks if filter is enabled
|
|
||||||
if (isExplicitFilterEnabled && track.explicit) {
|
|
||||||
// Add a placeholder for filtered explicit tracks
|
|
||||||
const trackElement = document.createElement('div');
|
|
||||||
trackElement.className = 'track track-filtered';
|
|
||||||
trackElement.innerHTML = `
|
|
||||||
<div class="track-number">${index + 1}</div>
|
|
||||||
<div class="track-info">
|
|
||||||
<div class="track-name explicit-filtered">Explicit Content Filtered</div>
|
|
||||||
<div class="track-artist">This track is not shown due to explicit content filter settings</div>
|
|
||||||
</div>
|
|
||||||
<div class="track-duration">--:--</div>
|
|
||||||
`;
|
|
||||||
tracksList.appendChild(trackElement);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trackElement = document.createElement('div');
|
|
||||||
trackElement.className = 'track';
|
|
||||||
trackElement.innerHTML = `
|
|
||||||
<div class="track-number">${index + 1}</div>
|
|
||||||
<div class="track-info">
|
|
||||||
<div class="track-name">
|
|
||||||
<a href="${baseUrl}/track/${track.id || ''}">${track.name || 'Unknown Track'}</a>
|
|
||||||
</div>
|
|
||||||
<div class="track-artist">
|
|
||||||
${track.artists?.map(a =>
|
|
||||||
`<a href="${baseUrl}/artist/${a?.id || ''}">${a?.name || 'Unknown Artist'}</a>`
|
|
||||||
).join(', ') || 'Unknown Artist'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="track-duration">${msToTime(track.duration_ms || 0)}</div>
|
|
||||||
<button class="download-btn download-btn--circle"
|
|
||||||
data-id="${track.id || ''}"
|
|
||||||
data-type="track"
|
|
||||||
data-name="${track.name || 'Unknown Track'}"
|
|
||||||
title="Download">
|
|
||||||
<img src="/static/images/download.svg" alt="Download">
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
tracksList.appendChild(trackElement);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reveal header and track list.
|
|
||||||
const albumHeaderEl = document.getElementById('album-header');
|
|
||||||
if (albumHeaderEl) albumHeaderEl.classList.remove('hidden');
|
|
||||||
|
|
||||||
const tracksContainerEl = document.getElementById('tracks-container');
|
|
||||||
if (tracksContainerEl) tracksContainerEl.classList.remove('hidden');
|
|
||||||
attachDownloadListeners();
|
|
||||||
|
|
||||||
// If on a small screen, re-arrange the action buttons.
|
|
||||||
if (window.innerWidth <= 480) {
|
|
||||||
let actionsContainer = document.getElementById('album-actions');
|
|
||||||
if (!actionsContainer) {
|
|
||||||
actionsContainer = document.createElement('div');
|
|
||||||
actionsContainer.id = 'album-actions';
|
|
||||||
const albumHeader = document.getElementById('album-header');
|
|
||||||
if (albumHeader) albumHeader.appendChild(actionsContainer); // Null check
|
|
||||||
}
|
|
||||||
if (actionsContainer) { // Null check for actionsContainer
|
|
||||||
actionsContainer.innerHTML = ''; // Clear any previous content
|
|
||||||
const homeBtn = document.getElementById('homeButton');
|
|
||||||
if (homeBtn) actionsContainer.appendChild(homeBtn); // Null check
|
|
||||||
|
|
||||||
const dlAlbumBtn = document.getElementById('downloadAlbumBtn');
|
|
||||||
if (dlAlbumBtn) actionsContainer.appendChild(dlAlbumBtn); // Null check
|
|
||||||
|
|
||||||
const queueToggle = document.querySelector('.queue-toggle');
|
|
||||||
if (queueToggle) {
|
|
||||||
actionsContainer.appendChild(queueToggle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadWholeAlbum(album: Album) {
|
|
||||||
const albumIdToDownload = album.id || '';
|
|
||||||
if (!albumIdToDownload) {
|
|
||||||
throw new Error('Missing album ID');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use the centralized downloadQueue.download method
|
|
||||||
await downloadQueue.download(albumIdToDownload, 'album', { name: album.name || 'Unknown Album' });
|
|
||||||
// Make the queue visible after queueing
|
|
||||||
downloadQueue.toggleVisibility(true);
|
|
||||||
} catch (error: any) { // Add type for error
|
|
||||||
showError('Album download failed: ' + (error?.message || 'Unknown error'));
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function msToTime(duration: number): string {
|
|
||||||
const minutes = Math.floor(duration / 60000);
|
|
||||||
const seconds = ((duration % 60000) / 1000).toFixed(0);
|
|
||||||
return `${minutes}:${seconds.padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showError(message: string) {
|
|
||||||
const errorEl = document.getElementById('error');
|
|
||||||
if (errorEl) { // Null check
|
|
||||||
errorEl.textContent = message || 'An error occurred';
|
|
||||||
errorEl.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachDownloadListeners() {
|
|
||||||
document.querySelectorAll('.download-btn').forEach((btn) => {
|
|
||||||
const button = btn as HTMLButtonElement; // Cast to HTMLButtonElement
|
|
||||||
if (button.id === 'downloadAlbumBtn') return;
|
|
||||||
button.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const currentTarget = e.currentTarget as HTMLButtonElement | null; // Cast currentTarget
|
|
||||||
if (!currentTarget) return;
|
|
||||||
|
|
||||||
const itemId = currentTarget.dataset.id || '';
|
|
||||||
const type = currentTarget.dataset.type || '';
|
|
||||||
const name = currentTarget.dataset.name || 'Unknown';
|
|
||||||
|
|
||||||
if (!itemId) {
|
|
||||||
showError('Missing item ID for download in album page');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Remove the button immediately after click.
|
|
||||||
currentTarget.remove();
|
|
||||||
startDownload(itemId, type, { name }); // albumType will be undefined
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startDownload(itemId: string, type: string, item: { name: string }, albumType?: string) { // Add types and make albumType optional
|
|
||||||
if (!itemId || !type) {
|
|
||||||
showError('Missing ID or type for download');
|
|
||||||
return Promise.reject(new Error('Missing ID or type for download')); // Return a rejected promise
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use the centralized downloadQueue.download method
|
|
||||||
await downloadQueue.download(itemId, type, item, albumType);
|
|
||||||
|
|
||||||
// Make the queue visible after queueing
|
|
||||||
downloadQueue.toggleVisibility(true);
|
|
||||||
} catch (error: any) { // Add type for error
|
|
||||||
showError('Download failed: ' + (error?.message || 'Unknown error'));
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
854
src/js/artist.ts
@@ -1,854 +0,0 @@
|
|||||||
// Import the downloadQueue singleton
|
|
||||||
import { downloadQueue } from './queue.js';
|
|
||||||
|
|
||||||
// Define interfaces for API data
|
|
||||||
interface Image {
|
|
||||||
url: string;
|
|
||||||
height?: number;
|
|
||||||
width?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Artist {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
external_urls: {
|
|
||||||
spotify: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Album {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
artists: Artist[];
|
|
||||||
images: Image[];
|
|
||||||
album_type: string; // "album", "single", "compilation"
|
|
||||||
album_group?: string; // "album", "single", "compilation", "appears_on"
|
|
||||||
external_urls: {
|
|
||||||
spotify: string;
|
|
||||||
};
|
|
||||||
explicit?: boolean; // Added to handle explicit filter
|
|
||||||
total_tracks?: number;
|
|
||||||
release_date?: string;
|
|
||||||
is_locally_known?: boolean; // Added for local DB status
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ArtistData {
|
|
||||||
items: Album[];
|
|
||||||
total: number;
|
|
||||||
// Add other properties if available from the API
|
|
||||||
// For watch status, the artist object itself might have `is_watched` if we extend API
|
|
||||||
// For now, we fetch status separately.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interface for watch status response
|
|
||||||
interface WatchStatusResponse {
|
|
||||||
is_watched: boolean;
|
|
||||||
artist_data?: any; // The artist data from DB if watched
|
|
||||||
}
|
|
||||||
|
|
||||||
// Added: Interface for global watch config
|
|
||||||
interface GlobalWatchConfig {
|
|
||||||
enabled: boolean;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Added: Helper function to fetch global watch config
|
|
||||||
async function getGlobalWatchConfig(): Promise<GlobalWatchConfig> {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/config/watch');
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error('Failed to fetch global watch config, assuming disabled.');
|
|
||||||
return { enabled: false }; // Default to disabled on error
|
|
||||||
}
|
|
||||||
return await response.json() as GlobalWatchConfig;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching global watch config:', error);
|
|
||||||
return { enabled: false }; // Default to disabled on error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
|
||||||
const pathSegments = window.location.pathname.split('/');
|
|
||||||
const artistId = pathSegments[pathSegments.indexOf('artist') + 1];
|
|
||||||
|
|
||||||
if (!artistId) {
|
|
||||||
showError('No artist ID provided.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const globalWatchConfig = await getGlobalWatchConfig(); // Fetch global config
|
|
||||||
const isGlobalWatchActuallyEnabled = globalWatchConfig.enabled;
|
|
||||||
|
|
||||||
// Fetch artist info directly
|
|
||||||
fetch(`/api/artist/info?id=${encodeURIComponent(artistId)}`)
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) throw new Error('Network response was not ok');
|
|
||||||
return response.json() as Promise<ArtistData>;
|
|
||||||
})
|
|
||||||
.then(data => renderArtist(data, artistId, isGlobalWatchActuallyEnabled))
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error:', error);
|
|
||||||
showError('Failed to load artist info.');
|
|
||||||
});
|
|
||||||
|
|
||||||
const queueIcon = document.getElementById('queueIcon');
|
|
||||||
if (queueIcon) {
|
|
||||||
queueIcon.addEventListener('click', () => downloadQueue.toggleVisibility());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attempt to set initial watchlist button visibility from cache
|
|
||||||
const watchlistButton = document.getElementById('watchlistButton') as HTMLAnchorElement | null;
|
|
||||||
if (watchlistButton) {
|
|
||||||
const cachedWatchEnabled = localStorage.getItem('spotizerr_watch_enabled_cached');
|
|
||||||
if (cachedWatchEnabled === 'true') {
|
|
||||||
watchlistButton.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch watch config to determine if watchlist button should be visible
|
|
||||||
async function updateWatchlistButtonVisibility() {
|
|
||||||
if (watchlistButton) {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/config/watch');
|
|
||||||
if (response.ok) {
|
|
||||||
const watchConfig = await response.json();
|
|
||||||
localStorage.setItem('spotizerr_watch_enabled_cached', watchConfig.enabled ? 'true' : 'false');
|
|
||||||
if (watchConfig && watchConfig.enabled === false) {
|
|
||||||
watchlistButton.classList.add('hidden');
|
|
||||||
} else {
|
|
||||||
watchlistButton.classList.remove('hidden'); // Ensure it's shown if enabled
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error('Failed to fetch watch config for artist page, defaulting to hidden');
|
|
||||||
// Don't update cache on error
|
|
||||||
watchlistButton.classList.add('hidden'); // Hide if config fetch fails
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching watch config for artist page:', error);
|
|
||||||
// Don't update cache on error
|
|
||||||
watchlistButton.classList.add('hidden'); // Hide on error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updateWatchlistButtonVisibility();
|
|
||||||
|
|
||||||
// Initialize the watch button after main artist rendering
|
|
||||||
// This is done inside renderArtist after button element is potentially created.
|
|
||||||
});
|
|
||||||
|
|
||||||
async function renderArtist(artistData: ArtistData, artistId: string, isGlobalWatchEnabled: boolean) {
|
|
||||||
const loadingEl = document.getElementById('loading');
|
|
||||||
if (loadingEl) loadingEl.classList.add('hidden');
|
|
||||||
|
|
||||||
const errorEl = document.getElementById('error');
|
|
||||||
if (errorEl) errorEl.classList.add('hidden');
|
|
||||||
|
|
||||||
// Fetch watch status upfront to avoid race conditions for album button rendering
|
|
||||||
let isArtistActuallyWatched = false; // Default
|
|
||||||
if (isGlobalWatchEnabled) { // Only fetch if globally enabled
|
|
||||||
isArtistActuallyWatched = await getArtistWatchStatus(artistId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if explicit filter is enabled
|
|
||||||
const isExplicitFilterEnabled = downloadQueue.isExplicitFilterEnabled();
|
|
||||||
|
|
||||||
const firstAlbum = artistData.items?.[0];
|
|
||||||
const artistName = firstAlbum?.artists?.[0]?.name || 'Unknown Artist';
|
|
||||||
const artistImageSrc = firstAlbum?.images?.[0]?.url || '/static/images/placeholder.jpg';
|
|
||||||
|
|
||||||
const artistNameEl = document.getElementById('artist-name');
|
|
||||||
if (artistNameEl) {
|
|
||||||
artistNameEl.innerHTML =
|
|
||||||
`<a href="/artist/${artistId}" class="artist-link">${artistName}</a>`;
|
|
||||||
}
|
|
||||||
const artistStatsEl = document.getElementById('artist-stats');
|
|
||||||
if (artistStatsEl) {
|
|
||||||
artistStatsEl.textContent = `${artistData.total || '0'} albums`;
|
|
||||||
}
|
|
||||||
const artistImageEl = document.getElementById('artist-image') as HTMLImageElement | null;
|
|
||||||
if (artistImageEl) {
|
|
||||||
artistImageEl.src = artistImageSrc;
|
|
||||||
}
|
|
||||||
|
|
||||||
const watchArtistBtn = document.getElementById('watchArtistBtn') as HTMLButtonElement | null;
|
|
||||||
const syncArtistBtn = document.getElementById('syncArtistBtn') as HTMLButtonElement | null;
|
|
||||||
|
|
||||||
if (!isGlobalWatchEnabled) {
|
|
||||||
if (watchArtistBtn) {
|
|
||||||
watchArtistBtn.classList.add('hidden');
|
|
||||||
watchArtistBtn.disabled = true;
|
|
||||||
}
|
|
||||||
if (syncArtistBtn) {
|
|
||||||
syncArtistBtn.classList.add('hidden');
|
|
||||||
syncArtistBtn.disabled = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (watchArtistBtn) {
|
|
||||||
initializeWatchButton(artistId, isArtistActuallyWatched);
|
|
||||||
} else {
|
|
||||||
console.warn("Watch artist button not found in HTML.");
|
|
||||||
}
|
|
||||||
// Sync button visibility is managed by initializeWatchButton
|
|
||||||
}
|
|
||||||
|
|
||||||
// Define the artist URL (used by both full-discography and group downloads)
|
|
||||||
// const artistUrl = `https://open.spotify.com/artist/${artistId}`; // Not directly used here anymore
|
|
||||||
|
|
||||||
// Home Button
|
|
||||||
let homeButton = document.getElementById('homeButton') as HTMLButtonElement | null;
|
|
||||||
if (!homeButton) {
|
|
||||||
homeButton = document.createElement('button');
|
|
||||||
homeButton.id = 'homeButton';
|
|
||||||
homeButton.className = 'home-btn';
|
|
||||||
homeButton.innerHTML = `<img src="/static/images/home.svg" alt="Home">`;
|
|
||||||
const artistHeader = document.getElementById('artist-header');
|
|
||||||
if (artistHeader) artistHeader.prepend(homeButton);
|
|
||||||
}
|
|
||||||
if (homeButton) {
|
|
||||||
homeButton.addEventListener('click', () => window.location.href = window.location.origin);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download Whole Artist Button using the new artist API endpoint
|
|
||||||
let downloadArtistBtn = document.getElementById('downloadArtistBtn') as HTMLButtonElement | null;
|
|
||||||
if (!downloadArtistBtn) {
|
|
||||||
downloadArtistBtn = document.createElement('button');
|
|
||||||
downloadArtistBtn.id = 'downloadArtistBtn';
|
|
||||||
downloadArtistBtn.className = 'download-btn download-btn--main';
|
|
||||||
downloadArtistBtn.textContent = 'Download All Discography';
|
|
||||||
const artistHeader = document.getElementById('artist-header');
|
|
||||||
if (artistHeader) artistHeader.appendChild(downloadArtistBtn);
|
|
||||||
}
|
|
||||||
|
|
||||||
// When explicit filter is enabled, disable all download buttons
|
|
||||||
if (isExplicitFilterEnabled) {
|
|
||||||
if (downloadArtistBtn) {
|
|
||||||
downloadArtistBtn.disabled = true;
|
|
||||||
downloadArtistBtn.classList.add('download-btn--disabled');
|
|
||||||
downloadArtistBtn.innerHTML = `<span title="Direct artist downloads are restricted when explicit filter is enabled. Please visit individual album pages.">Downloads Restricted</span>`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (downloadArtistBtn) {
|
|
||||||
downloadArtistBtn.addEventListener('click', () => {
|
|
||||||
document.querySelectorAll('.download-btn:not(#downloadArtistBtn)').forEach(btn => btn.remove());
|
|
||||||
if (downloadArtistBtn) {
|
|
||||||
downloadArtistBtn.disabled = true;
|
|
||||||
downloadArtistBtn.textContent = 'Queueing...';
|
|
||||||
}
|
|
||||||
startDownload(
|
|
||||||
artistId,
|
|
||||||
'artist',
|
|
||||||
{ name: artistName, artist: artistName },
|
|
||||||
'album,single,compilation,appears_on'
|
|
||||||
)
|
|
||||||
.then((taskIds) => {
|
|
||||||
if (downloadArtistBtn) {
|
|
||||||
downloadArtistBtn.textContent = 'Artist queued';
|
|
||||||
downloadQueue.toggleVisibility(true);
|
|
||||||
if (Array.isArray(taskIds)) {
|
|
||||||
downloadArtistBtn.title = `${taskIds.length} albums queued for download`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
if (downloadArtistBtn) {
|
|
||||||
downloadArtistBtn.textContent = 'Download All Discography';
|
|
||||||
downloadArtistBtn.disabled = false;
|
|
||||||
}
|
|
||||||
showError('Failed to queue artist download: ' + (err?.message || 'Unknown error'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const albumGroups: Record<string, Album[]> = {};
|
|
||||||
const appearingAlbums: Album[] = [];
|
|
||||||
|
|
||||||
(artistData.items || []).forEach(album => {
|
|
||||||
if (!album) return;
|
|
||||||
if (isExplicitFilterEnabled && album.explicit) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (album.album_group === 'appears_on') {
|
|
||||||
appearingAlbums.push(album);
|
|
||||||
} else {
|
|
||||||
const type = (album.album_type || 'unknown').toLowerCase();
|
|
||||||
if (!albumGroups[type]) albumGroups[type] = [];
|
|
||||||
albumGroups[type].push(album);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const groupsContainer = document.getElementById('album-groups');
|
|
||||||
if (groupsContainer) {
|
|
||||||
groupsContainer.innerHTML = '';
|
|
||||||
|
|
||||||
// Use the definitively fetched watch status for rendering album buttons
|
|
||||||
// const isArtistWatched = watchArtistBtn && watchArtistBtn.dataset.watching === 'true'; // Old way
|
|
||||||
// const useThisWatchStatusForAlbums = isArtistActuallyWatched; // Old way, now combination of global and individual
|
|
||||||
|
|
||||||
for (const [groupType, albums] of Object.entries(albumGroups)) {
|
|
||||||
const groupSection = document.createElement('section');
|
|
||||||
groupSection.className = 'album-group';
|
|
||||||
|
|
||||||
const groupHeaderHTML = isExplicitFilterEnabled ?
|
|
||||||
`<div class="album-group-header">
|
|
||||||
<h3>${capitalize(groupType)}s</h3>
|
|
||||||
<div class="download-note">Visit album pages to download content</div>
|
|
||||||
</div>` :
|
|
||||||
`<div class="album-group-header">
|
|
||||||
<h3>${capitalize(groupType)}s</h3>
|
|
||||||
<button class="download-btn download-btn--main group-download-btn"
|
|
||||||
data-group-type="${groupType}">
|
|
||||||
Download All ${capitalize(groupType)}s
|
|
||||||
</button>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
groupSection.innerHTML = groupHeaderHTML;
|
|
||||||
const albumsListContainer = document.createElement('div');
|
|
||||||
albumsListContainer.className = 'albums-list';
|
|
||||||
|
|
||||||
albums.forEach(album => {
|
|
||||||
if (!album) return;
|
|
||||||
const albumElement = document.createElement('div');
|
|
||||||
albumElement.className = 'album-card';
|
|
||||||
albumElement.dataset.albumId = album.id;
|
|
||||||
|
|
||||||
let albumCardHTML = `
|
|
||||||
<a href="/album/${album.id || ''}" class="album-link">
|
|
||||||
<img src="${album.images?.[1]?.url || album.images?.[0]?.url || '/static/images/placeholder.jpg'}"
|
|
||||||
alt="Album cover"
|
|
||||||
class="album-cover ${album.is_locally_known === false ? 'album-missing-in-db' : ''}">
|
|
||||||
</a>
|
|
||||||
<div class="album-info">
|
|
||||||
<div class="album-title">${album.name || 'Unknown Album'}</div>
|
|
||||||
<div class="album-artist">${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
albumElement.innerHTML = albumCardHTML;
|
|
||||||
|
|
||||||
const albumCardActions = document.createElement('div');
|
|
||||||
albumCardActions.className = 'album-card-actions';
|
|
||||||
|
|
||||||
// Persistent Mark as Known/Missing button (if artist is watched) - Appears first (left)
|
|
||||||
if (isGlobalWatchEnabled && isArtistActuallyWatched && album.id) {
|
|
||||||
const toggleKnownBtn = document.createElement('button');
|
|
||||||
toggleKnownBtn.className = 'toggle-known-status-btn persistent-album-action-btn';
|
|
||||||
toggleKnownBtn.dataset.albumId = album.id;
|
|
||||||
|
|
||||||
if (album.is_locally_known) {
|
|
||||||
toggleKnownBtn.dataset.status = 'known';
|
|
||||||
toggleKnownBtn.innerHTML = '<img src="/static/images/check.svg" alt="Mark as missing">';
|
|
||||||
toggleKnownBtn.title = 'Mark album as not in local library (Missing)';
|
|
||||||
toggleKnownBtn.classList.add('status-known'); // Green
|
|
||||||
} else {
|
|
||||||
toggleKnownBtn.dataset.status = 'missing';
|
|
||||||
toggleKnownBtn.innerHTML = '<img src="/static/images/missing.svg" alt="Mark as known">';
|
|
||||||
toggleKnownBtn.title = 'Mark album as in local library (Known)';
|
|
||||||
toggleKnownBtn.classList.add('status-missing'); // Red
|
|
||||||
}
|
|
||||||
albumCardActions.appendChild(toggleKnownBtn); // Add to actions container
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persistent Download Button (if not explicit filter) - Appears second (right)
|
|
||||||
if (!isExplicitFilterEnabled) {
|
|
||||||
const downloadBtn = document.createElement('button');
|
|
||||||
downloadBtn.className = 'download-btn download-btn--circle persistent-download-btn';
|
|
||||||
downloadBtn.innerHTML = '<img src="/static/images/download.svg" alt="Download album">';
|
|
||||||
downloadBtn.title = 'Download this album';
|
|
||||||
downloadBtn.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
downloadBtn.disabled = true;
|
|
||||||
downloadBtn.innerHTML = '<img src="/static/images/refresh.svg" alt="Queueing..." class="icon-spin">';
|
|
||||||
startDownload(album.id, 'album', { name: album.name, artist: album.artists?.[0]?.name || 'Unknown Artist', type: 'album' })
|
|
||||||
.then(() => {
|
|
||||||
downloadBtn.innerHTML = '<img src="/static/images/check.svg" alt="Queued">';
|
|
||||||
showNotification(`Album '${album.name}' queued for download.`);
|
|
||||||
downloadQueue.toggleVisibility(true);
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
downloadBtn.disabled = false;
|
|
||||||
downloadBtn.innerHTML = '<img src="/static/images/download.svg" alt="Download album">';
|
|
||||||
showError(`Failed to queue album: ${err?.message || 'Unknown error'}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
albumCardActions.appendChild(downloadBtn); // Add to actions container
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only append albumCardActions if it has any buttons
|
|
||||||
if (albumCardActions.hasChildNodes()) {
|
|
||||||
albumElement.appendChild(albumCardActions);
|
|
||||||
}
|
|
||||||
|
|
||||||
albumsListContainer.appendChild(albumElement);
|
|
||||||
});
|
|
||||||
groupSection.appendChild(albumsListContainer);
|
|
||||||
groupsContainer.appendChild(groupSection);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (appearingAlbums.length > 0) {
|
|
||||||
const featuringSection = document.createElement('section');
|
|
||||||
featuringSection.className = 'album-group';
|
|
||||||
const featuringHeaderHTML = isExplicitFilterEnabled ?
|
|
||||||
`<div class="album-group-header">
|
|
||||||
<h3>Featuring</h3>
|
|
||||||
<div class="download-note">Visit album pages to download content</div>
|
|
||||||
</div>` :
|
|
||||||
`<div class="album-group-header">
|
|
||||||
<h3>Featuring</h3>
|
|
||||||
<button class="download-btn download-btn--main group-download-btn"
|
|
||||||
data-group-type="appears_on">
|
|
||||||
Download All Featuring Albums
|
|
||||||
</button>
|
|
||||||
</div>`;
|
|
||||||
featuringSection.innerHTML = featuringHeaderHTML;
|
|
||||||
const appearingAlbumsListContainer = document.createElement('div');
|
|
||||||
appearingAlbumsListContainer.className = 'albums-list';
|
|
||||||
|
|
||||||
appearingAlbums.forEach(album => {
|
|
||||||
if (!album) return;
|
|
||||||
const albumElement = document.createElement('div');
|
|
||||||
albumElement.className = 'album-card';
|
|
||||||
albumElement.dataset.albumId = album.id; // Set dataset for appears_on albums too
|
|
||||||
|
|
||||||
let albumCardHTML = `
|
|
||||||
<a href="/album/${album.id || ''}" class="album-link">
|
|
||||||
<img src="${album.images?.[1]?.url || album.images?.[0]?.url || '/static/images/placeholder.jpg'}"
|
|
||||||
alt="Album cover"
|
|
||||||
class="album-cover ${album.is_locally_known === false ? 'album-missing-in-db' : ''}">
|
|
||||||
</a>
|
|
||||||
<div class="album-info">
|
|
||||||
<div class="album-title">${album.name || 'Unknown Album'}</div>
|
|
||||||
<div class="album-artist">${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
albumElement.innerHTML = albumCardHTML;
|
|
||||||
|
|
||||||
const albumCardActions_AppearsOn = document.createElement('div');
|
|
||||||
albumCardActions_AppearsOn.className = 'album-card-actions';
|
|
||||||
|
|
||||||
// Persistent Mark as Known/Missing button for appearing_on albums (if artist is watched) - Appears first (left)
|
|
||||||
if (isGlobalWatchEnabled && isArtistActuallyWatched && album.id) {
|
|
||||||
const toggleKnownBtn = document.createElement('button');
|
|
||||||
toggleKnownBtn.className = 'toggle-known-status-btn persistent-album-action-btn';
|
|
||||||
toggleKnownBtn.dataset.albumId = album.id;
|
|
||||||
if (album.is_locally_known) {
|
|
||||||
toggleKnownBtn.dataset.status = 'known';
|
|
||||||
toggleKnownBtn.innerHTML = '<img src="/static/images/check.svg" alt="Mark as missing">';
|
|
||||||
toggleKnownBtn.title = 'Mark album as not in local library (Missing)';
|
|
||||||
toggleKnownBtn.classList.add('status-known'); // Green
|
|
||||||
} else {
|
|
||||||
toggleKnownBtn.dataset.status = 'missing';
|
|
||||||
toggleKnownBtn.innerHTML = '<img src="/static/images/missing.svg" alt="Mark as known">';
|
|
||||||
toggleKnownBtn.title = 'Mark album as in local library (Known)';
|
|
||||||
toggleKnownBtn.classList.add('status-missing'); // Red
|
|
||||||
}
|
|
||||||
albumCardActions_AppearsOn.appendChild(toggleKnownBtn); // Add to actions container
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persistent Download Button for appearing_on albums (if not explicit filter) - Appears second (right)
|
|
||||||
if (!isExplicitFilterEnabled) {
|
|
||||||
const downloadBtn = document.createElement('button');
|
|
||||||
downloadBtn.className = 'download-btn download-btn--circle persistent-download-btn';
|
|
||||||
downloadBtn.innerHTML = '<img src="/static/images/download.svg" alt="Download album">';
|
|
||||||
downloadBtn.title = 'Download this album';
|
|
||||||
downloadBtn.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
downloadBtn.disabled = true;
|
|
||||||
downloadBtn.innerHTML = '<img src="/static/images/refresh.svg" alt="Queueing..." class="icon-spin">';
|
|
||||||
startDownload(album.id, 'album', { name: album.name, artist: album.artists?.[0]?.name || 'Unknown Artist', type: 'album' })
|
|
||||||
.then(() => {
|
|
||||||
downloadBtn.innerHTML = '<img src="/static/images/check.svg" alt="Queued">';
|
|
||||||
showNotification(`Album '${album.name}' queued for download.`);
|
|
||||||
downloadQueue.toggleVisibility(true);
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
downloadBtn.disabled = false;
|
|
||||||
downloadBtn.innerHTML = '<img src="/static/images/download.svg" alt="Download album">';
|
|
||||||
showError(`Failed to queue album: ${err?.message || 'Unknown error'}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
albumCardActions_AppearsOn.appendChild(downloadBtn); // Add to actions container
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only append albumCardActions_AppearsOn if it has any buttons
|
|
||||||
if (albumCardActions_AppearsOn.hasChildNodes()) {
|
|
||||||
albumElement.appendChild(albumCardActions_AppearsOn);
|
|
||||||
}
|
|
||||||
|
|
||||||
appearingAlbumsListContainer.appendChild(albumElement);
|
|
||||||
});
|
|
||||||
featuringSection.appendChild(appearingAlbumsListContainer);
|
|
||||||
groupsContainer.appendChild(featuringSection);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const artistHeaderEl = document.getElementById('artist-header');
|
|
||||||
if (artistHeaderEl) artistHeaderEl.classList.remove('hidden');
|
|
||||||
const albumsContainerEl = document.getElementById('albums-container');
|
|
||||||
if (albumsContainerEl) albumsContainerEl.classList.remove('hidden');
|
|
||||||
|
|
||||||
if (!isExplicitFilterEnabled) {
|
|
||||||
attachAlbumActionListeners(artistId, isGlobalWatchEnabled);
|
|
||||||
attachGroupDownloadListeners(artistId, artistName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachGroupDownloadListeners(artistId: string, artistName: string) {
|
|
||||||
document.querySelectorAll('.group-download-btn').forEach(btn => {
|
|
||||||
const button = btn as HTMLButtonElement;
|
|
||||||
button.addEventListener('click', async (e) => {
|
|
||||||
const target = e.target as HTMLButtonElement | null;
|
|
||||||
if (!target) return;
|
|
||||||
const groupType = target.dataset.groupType || 'album';
|
|
||||||
target.disabled = true;
|
|
||||||
const displayType = groupType === 'appears_on' ? 'Featuring Albums' : `${capitalize(groupType)}s`;
|
|
||||||
target.textContent = `Queueing all ${displayType}...`;
|
|
||||||
try {
|
|
||||||
const taskIds = await startDownload(
|
|
||||||
artistId,
|
|
||||||
'artist',
|
|
||||||
{ name: artistName || 'Unknown Artist', artist: artistName || 'Unknown Artist' },
|
|
||||||
groupType
|
|
||||||
);
|
|
||||||
const totalQueued = Array.isArray(taskIds) ? taskIds.length : 0;
|
|
||||||
target.textContent = `Queued all ${displayType}`;
|
|
||||||
target.title = `${totalQueued} albums queued for download`;
|
|
||||||
downloadQueue.toggleVisibility(true);
|
|
||||||
} catch (error: any) {
|
|
||||||
target.textContent = `Download All ${displayType}`;
|
|
||||||
target.disabled = false;
|
|
||||||
showError(`Failed to queue download for all ${groupType}: ${error?.message || 'Unknown error'}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachAlbumActionListeners(artistIdForContext: string, isGlobalWatchEnabled: boolean) {
|
|
||||||
const groupsContainer = document.getElementById('album-groups');
|
|
||||||
if (!groupsContainer) return;
|
|
||||||
|
|
||||||
groupsContainer.addEventListener('click', async (event) => {
|
|
||||||
const target = event.target as HTMLElement;
|
|
||||||
const button = target.closest('.toggle-known-status-btn') as HTMLButtonElement | null;
|
|
||||||
|
|
||||||
if (button && button.dataset.albumId) {
|
|
||||||
if (!isGlobalWatchEnabled) {
|
|
||||||
showNotification("Watch feature is currently disabled globally.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const albumId = button.dataset.albumId;
|
|
||||||
const currentStatus = button.dataset.status;
|
|
||||||
|
|
||||||
// Optimistic UI update
|
|
||||||
button.disabled = true;
|
|
||||||
const originalIcon = button.innerHTML; // Save original icon
|
|
||||||
button.innerHTML = '<img src="/static/images/refresh.svg" alt="Updating..." class="icon-spin">';
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (currentStatus === 'known') {
|
|
||||||
await handleMarkAlbumAsMissing(artistIdForContext, albumId);
|
|
||||||
button.dataset.status = 'missing';
|
|
||||||
button.innerHTML = '<img src="/static/images/missing.svg" alt="Mark as known">'; // Update to missing.svg
|
|
||||||
button.title = 'Mark album as in local library (Known)';
|
|
||||||
button.classList.remove('status-known');
|
|
||||||
button.classList.add('status-missing');
|
|
||||||
const albumCard = button.closest('.album-card') as HTMLElement | null;
|
|
||||||
if (albumCard) {
|
|
||||||
const coverImg = albumCard.querySelector('.album-cover') as HTMLImageElement | null;
|
|
||||||
if (coverImg) coverImg.classList.add('album-missing-in-db');
|
|
||||||
}
|
|
||||||
showNotification(`Album marked as missing from local library.`);
|
|
||||||
} else {
|
|
||||||
await handleMarkAlbumAsKnown(artistIdForContext, albumId);
|
|
||||||
button.dataset.status = 'known';
|
|
||||||
button.innerHTML = '<img src="/static/images/check.svg" alt="Mark as missing">'; // Update to check.svg
|
|
||||||
button.title = 'Mark album as not in local library (Missing)';
|
|
||||||
button.classList.remove('status-missing');
|
|
||||||
button.classList.add('status-known');
|
|
||||||
const albumCard = button.closest('.album-card') as HTMLElement | null;
|
|
||||||
if (albumCard) {
|
|
||||||
const coverImg = albumCard.querySelector('.album-cover') as HTMLImageElement | null;
|
|
||||||
if (coverImg) coverImg.classList.remove('album-missing-in-db');
|
|
||||||
}
|
|
||||||
showNotification(`Album marked as present in local library.`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to update album status:', error);
|
|
||||||
showError('Failed to update album status. Please try again.');
|
|
||||||
// Revert UI on error
|
|
||||||
button.dataset.status = currentStatus; // Revert status
|
|
||||||
button.innerHTML = originalIcon; // Revert icon
|
|
||||||
// Revert card style if needed (though if API failed, actual state is unchanged)
|
|
||||||
} finally {
|
|
||||||
button.disabled = false; // Re-enable button
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleMarkAlbumAsKnown(artistId: string, albumId: string) {
|
|
||||||
// Ensure albumId is a string and not undefined.
|
|
||||||
if (!albumId || typeof albumId !== 'string') {
|
|
||||||
console.error('Invalid albumId provided to handleMarkAlbumAsKnown:', albumId);
|
|
||||||
throw new Error('Invalid album ID.');
|
|
||||||
}
|
|
||||||
const response = await fetch(`/api/artist/watch/${artistId}/albums`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify([albumId]) // API expects an array of album IDs
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({ message: 'Failed to mark album as known.' }));
|
|
||||||
throw new Error(errorData.message || `HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleMarkAlbumAsMissing(artistId: string, albumId: string) {
|
|
||||||
// Ensure albumId is a string and not undefined.
|
|
||||||
if (!albumId || typeof albumId !== 'string') {
|
|
||||||
console.error('Invalid albumId provided to handleMarkAlbumAsMissing:', albumId);
|
|
||||||
throw new Error('Invalid album ID.');
|
|
||||||
}
|
|
||||||
const response = await fetch(`/api/artist/watch/${artistId}/albums`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify([albumId]) // API expects an array of album IDs
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({ message: 'Failed to mark album as missing.' }));
|
|
||||||
throw new Error(errorData.message || `HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
// For DELETE, Spotify often returns 204 No Content, or we might return custom JSON.
|
|
||||||
// If expecting JSON:
|
|
||||||
// return response.json();
|
|
||||||
// If handling 204 or simple success message:
|
|
||||||
const result = await response.json(); // Assuming the backend sends a JSON response
|
|
||||||
console.log('Mark as missing result:', result);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add startDownload function (similar to track.js and main.js)
|
|
||||||
/**
|
|
||||||
* Starts the download process via centralized download queue
|
|
||||||
*/
|
|
||||||
async function startDownload(itemId: string, type: string, item: { name: string, artist?: string, type?: string }, albumType?: string) {
|
|
||||||
if (!itemId || !type) {
|
|
||||||
showError('Missing ID or type for download');
|
|
||||||
return Promise.reject(new Error('Missing ID or type for download')); // Return a rejected promise
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use the centralized downloadQueue.download method for all downloads including artist downloads
|
|
||||||
const result = await downloadQueue.download(itemId, type, item, albumType);
|
|
||||||
|
|
||||||
// Make the queue visible after queueing
|
|
||||||
downloadQueue.toggleVisibility(true);
|
|
||||||
|
|
||||||
// Return the result for tracking
|
|
||||||
return result;
|
|
||||||
} catch (error: any) { // Add type for error
|
|
||||||
showError('Download failed: ' + (error?.message || 'Unknown error'));
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// UI Helpers
|
|
||||||
function showError(message: string) {
|
|
||||||
const errorEl = document.getElementById('error');
|
|
||||||
if (errorEl) {
|
|
||||||
errorEl.textContent = message || 'An error occurred';
|
|
||||||
errorEl.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function capitalize(str: string) {
|
|
||||||
return str ? str.charAt(0).toUpperCase() + str.slice(1) : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getArtistWatchStatus(artistId: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/artist/watch/${artistId}/status`);
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({})); // Catch if res not json
|
|
||||||
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
const data: WatchStatusResponse = await response.json();
|
|
||||||
return data.is_watched;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching artist watch status:', error);
|
|
||||||
showError('Could not fetch watch status.');
|
|
||||||
return false; // Assume not watching on error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function watchArtist(artistId: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/artist/watch/${artistId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
// Optionally handle success message from response.json()
|
|
||||||
await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error watching artist:', error);
|
|
||||||
showError('Failed to watch artist.');
|
|
||||||
throw error; // Re-throw to allow caller to handle UI update failure
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function unwatchArtist(artistId: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/artist/watch/${artistId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
// Optionally handle success message
|
|
||||||
await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error unwatching artist:', error);
|
|
||||||
showError('Failed to unwatch artist.');
|
|
||||||
throw error; // Re-throw
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateWatchButton(artistId: string, isWatching: boolean) {
|
|
||||||
const watchArtistBtn = document.getElementById('watchArtistBtn') as HTMLButtonElement | null;
|
|
||||||
const syncArtistBtn = document.getElementById('syncArtistBtn') as HTMLButtonElement | null;
|
|
||||||
|
|
||||||
if (watchArtistBtn) {
|
|
||||||
const img = watchArtistBtn.querySelector('img');
|
|
||||||
if (isWatching) {
|
|
||||||
if (img) img.src = '/static/images/eye-crossed.svg';
|
|
||||||
watchArtistBtn.innerHTML = `<img src="/static/images/eye-crossed.svg" alt="Unwatch"> Unwatch Artist`;
|
|
||||||
watchArtistBtn.classList.add('watching');
|
|
||||||
watchArtistBtn.title = "Stop watching this artist";
|
|
||||||
if (syncArtistBtn) syncArtistBtn.classList.remove('hidden');
|
|
||||||
} else {
|
|
||||||
if (img) img.src = '/static/images/eye.svg';
|
|
||||||
watchArtistBtn.innerHTML = `<img src="/static/images/eye.svg" alt="Watch"> Watch Artist`;
|
|
||||||
watchArtistBtn.classList.remove('watching');
|
|
||||||
watchArtistBtn.title = "Watch this artist for new releases";
|
|
||||||
if (syncArtistBtn) syncArtistBtn.classList.add('hidden');
|
|
||||||
}
|
|
||||||
watchArtistBtn.dataset.watching = isWatching ? 'true' : 'false';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initializeWatchButton(artistId: string, initialIsWatching: boolean) {
|
|
||||||
const watchArtistBtn = document.getElementById('watchArtistBtn') as HTMLButtonElement | null;
|
|
||||||
const syncArtistBtn = document.getElementById('syncArtistBtn') as HTMLButtonElement | null;
|
|
||||||
|
|
||||||
if (!watchArtistBtn) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
watchArtistBtn.disabled = true;
|
|
||||||
if (syncArtistBtn) syncArtistBtn.disabled = true;
|
|
||||||
|
|
||||||
// const isWatching = await getArtistWatchStatus(artistId); // No longer fetch here, use parameter
|
|
||||||
updateWatchButton(artistId, initialIsWatching); // Use passed status
|
|
||||||
watchArtistBtn.disabled = false;
|
|
||||||
if (syncArtistBtn) syncArtistBtn.disabled = !(watchArtistBtn.dataset.watching === 'true');
|
|
||||||
|
|
||||||
watchArtistBtn.addEventListener('click', async () => {
|
|
||||||
const currentlyWatching = watchArtistBtn.dataset.watching === 'true';
|
|
||||||
watchArtistBtn.disabled = true;
|
|
||||||
if (syncArtistBtn) syncArtistBtn.disabled = true;
|
|
||||||
try {
|
|
||||||
if (currentlyWatching) {
|
|
||||||
await unwatchArtist(artistId);
|
|
||||||
updateWatchButton(artistId, false);
|
|
||||||
// Re-fetch and re-render artist data, passing the global watch status again
|
|
||||||
const newArtistData = await (await fetch(`/api/artist/info?id=${encodeURIComponent(artistId)}`)).json() as ArtistData;
|
|
||||||
// Assuming renderArtist needs the global status, which it does. We need to get it or have it available.
|
|
||||||
// Since initializeWatchButton is called from renderArtist, we can assume isGlobalWatchEnabled is in that scope.
|
|
||||||
// This part is tricky as initializeWatchButton doesn't have isGlobalWatchEnabled.
|
|
||||||
// Let's re-fetch global config or rely on the fact that if this button is clickable, global is on.
|
|
||||||
// For simplicity, the re-render will pick up the global status from its own scope if called from top level.
|
|
||||||
// The click handler itself does not need to pass isGlobalWatchEnabled to renderArtist, renderArtist's caller does.
|
|
||||||
// Let's ensure renderArtist is called correctly after watch/unwatch.
|
|
||||||
const globalWatchConfig = await getGlobalWatchConfig(); // Re-fetch for re-render
|
|
||||||
renderArtist(newArtistData, artistId, globalWatchConfig.enabled);
|
|
||||||
} else {
|
|
||||||
await watchArtist(artistId);
|
|
||||||
updateWatchButton(artistId, true);
|
|
||||||
// Re-fetch and re-render artist data
|
|
||||||
const newArtistData = await (await fetch(`/api/artist/info?id=${encodeURIComponent(artistId)}`)).json() as ArtistData;
|
|
||||||
const globalWatchConfig = await getGlobalWatchConfig(); // Re-fetch for re-render
|
|
||||||
renderArtist(newArtistData, artistId, globalWatchConfig.enabled);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// On error, revert button to its state before the click attempt
|
|
||||||
updateWatchButton(artistId, currentlyWatching);
|
|
||||||
}
|
|
||||||
watchArtistBtn.disabled = false;
|
|
||||||
if (syncArtistBtn) syncArtistBtn.disabled = !(watchArtistBtn.dataset.watching === 'true');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add event listener for the sync button
|
|
||||||
if (syncArtistBtn) {
|
|
||||||
syncArtistBtn.addEventListener('click', async () => {
|
|
||||||
syncArtistBtn.disabled = true;
|
|
||||||
const originalButtonContent = syncArtistBtn.innerHTML; // Store full HTML
|
|
||||||
const textNode = Array.from(syncArtistBtn.childNodes).find(node => node.nodeType === Node.TEXT_NODE);
|
|
||||||
const originalText = textNode ? textNode.nodeValue : 'Sync Watched Artist'; // Fallback text
|
|
||||||
|
|
||||||
syncArtistBtn.innerHTML = `<img src="/static/images/refresh.svg" alt="Sync"> Syncing...`; // Keep icon
|
|
||||||
try {
|
|
||||||
await triggerArtistSync(artistId);
|
|
||||||
showNotification('Artist sync triggered successfully.');
|
|
||||||
} catch (error) {
|
|
||||||
// Error is shown by triggerArtistSync
|
|
||||||
}
|
|
||||||
syncArtistBtn.innerHTML = originalButtonContent; // Restore full original HTML
|
|
||||||
syncArtistBtn.disabled = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
if (watchArtistBtn) watchArtistBtn.disabled = false;
|
|
||||||
if (syncArtistBtn) syncArtistBtn.disabled = true;
|
|
||||||
updateWatchButton(artistId, false); // On error fetching initial status (though now it's passed)
|
|
||||||
// This line might be less relevant if initialIsWatching is guaranteed by caller
|
|
||||||
// but as a fallback it sets to a non-watching state.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// New function to trigger artist sync
|
|
||||||
async function triggerArtistSync(artistId: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/artist/watch/trigger_check/${artistId}`, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
await response.json(); // Contains success message
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error triggering artist sync:', error);
|
|
||||||
showError('Failed to trigger artist sync.');
|
|
||||||
throw error; // Re-throw
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Displays a temporary notification message.
|
|
||||||
*/
|
|
||||||
function showNotification(message: string) {
|
|
||||||
// Basic notification - consider a more robust solution for production
|
|
||||||
const notificationEl = document.createElement('div');
|
|
||||||
notificationEl.className = 'notification'; // Ensure this class is styled
|
|
||||||
notificationEl.textContent = message;
|
|
||||||
document.body.appendChild(notificationEl);
|
|
||||||
setTimeout(() => {
|
|
||||||
notificationEl.remove();
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
1240
src/js/config.ts
@@ -1,188 +0,0 @@
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const historyTableBody = document.getElementById('history-table-body') as HTMLTableSectionElement | null;
|
|
||||||
const prevButton = document.getElementById('prev-page') as HTMLButtonElement | null;
|
|
||||||
const nextButton = document.getElementById('next-page') as HTMLButtonElement | null;
|
|
||||||
const pageInfo = document.getElementById('page-info') as HTMLSpanElement | null;
|
|
||||||
const limitSelect = document.getElementById('limit-select') as HTMLSelectElement | null;
|
|
||||||
const statusFilter = document.getElementById('status-filter') as HTMLSelectElement | null;
|
|
||||||
const typeFilter = document.getElementById('type-filter') as HTMLSelectElement | null;
|
|
||||||
|
|
||||||
let currentPage = 1;
|
|
||||||
let limit = 25;
|
|
||||||
let totalEntries = 0;
|
|
||||||
let currentSortBy = 'timestamp_completed';
|
|
||||||
let currentSortOrder = 'DESC';
|
|
||||||
|
|
||||||
async function fetchHistory(page = 1) {
|
|
||||||
if (!historyTableBody || !prevButton || !nextButton || !pageInfo || !limitSelect || !statusFilter || !typeFilter) {
|
|
||||||
console.error('One or more critical UI elements are missing for history page.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const offset = (page - 1) * limit;
|
|
||||||
let apiUrl = `/api/history?limit=${limit}&offset=${offset}&sort_by=${currentSortBy}&sort_order=${currentSortOrder}`;
|
|
||||||
|
|
||||||
const statusVal = statusFilter.value;
|
|
||||||
if (statusVal) {
|
|
||||||
apiUrl += `&status_final=${statusVal}`;
|
|
||||||
}
|
|
||||||
const typeVal = typeFilter.value;
|
|
||||||
if (typeVal) {
|
|
||||||
apiUrl += `&download_type=${typeVal}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(apiUrl);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
renderHistory(data.entries);
|
|
||||||
totalEntries = data.total_count;
|
|
||||||
currentPage = Math.floor(offset / limit) + 1;
|
|
||||||
updatePagination();
|
|
||||||
updateSortIndicators();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching history:', error);
|
|
||||||
if (historyTableBody) {
|
|
||||||
historyTableBody.innerHTML = '<tr><td colspan="9">Error loading history.</td></tr>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderHistory(entries: any[]) {
|
|
||||||
if (!historyTableBody) return;
|
|
||||||
|
|
||||||
historyTableBody.innerHTML = ''; // Clear existing rows
|
|
||||||
if (!entries || entries.length === 0) {
|
|
||||||
historyTableBody.innerHTML = '<tr><td colspan="9">No history entries found.</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
entries.forEach(entry => {
|
|
||||||
const row = historyTableBody.insertRow();
|
|
||||||
row.insertCell().textContent = entry.item_name || 'N/A';
|
|
||||||
row.insertCell().textContent = entry.item_artist || 'N/A';
|
|
||||||
row.insertCell().textContent = entry.download_type ? entry.download_type.charAt(0).toUpperCase() + entry.download_type.slice(1) : 'N/A';
|
|
||||||
row.insertCell().textContent = entry.service_used || 'N/A';
|
|
||||||
// Construct Quality display string
|
|
||||||
let qualityDisplay = entry.quality_profile || 'N/A';
|
|
||||||
if (entry.convert_to) {
|
|
||||||
qualityDisplay = `${entry.convert_to.toUpperCase()}`;
|
|
||||||
if (entry.bitrate) {
|
|
||||||
qualityDisplay += ` ${entry.bitrate}k`;
|
|
||||||
}
|
|
||||||
qualityDisplay += ` (${entry.quality_profile || 'Original'})`;
|
|
||||||
} else if (entry.bitrate) { // Case where convert_to might not be set, but bitrate is (e.g. for OGG Vorbis quality settings)
|
|
||||||
qualityDisplay = `${entry.bitrate}k (${entry.quality_profile || 'Profile'})`;
|
|
||||||
}
|
|
||||||
row.insertCell().textContent = qualityDisplay;
|
|
||||||
|
|
||||||
const statusCell = row.insertCell();
|
|
||||||
statusCell.textContent = entry.status_final || 'N/A';
|
|
||||||
statusCell.className = `status-${entry.status_final}`;
|
|
||||||
|
|
||||||
row.insertCell().textContent = entry.timestamp_added ? new Date(entry.timestamp_added * 1000).toLocaleString() : 'N/A';
|
|
||||||
row.insertCell().textContent = entry.timestamp_completed ? new Date(entry.timestamp_completed * 1000).toLocaleString() : 'N/A';
|
|
||||||
|
|
||||||
const detailsCell = row.insertCell();
|
|
||||||
const detailsButton = document.createElement('button');
|
|
||||||
detailsButton.innerHTML = `<img src="/static/images/info.svg" alt="Details">`;
|
|
||||||
detailsButton.className = 'details-btn btn-icon';
|
|
||||||
detailsButton.title = 'Show Details';
|
|
||||||
detailsButton.onclick = () => showDetailsModal(entry);
|
|
||||||
detailsCell.appendChild(detailsButton);
|
|
||||||
|
|
||||||
if (entry.status_final === 'ERROR' && entry.error_message) {
|
|
||||||
const errorSpan = document.createElement('span');
|
|
||||||
errorSpan.textContent = ' (Show Error)';
|
|
||||||
errorSpan.className = 'error-message-toggle';
|
|
||||||
errorSpan.style.marginLeft = '5px';
|
|
||||||
errorSpan.onclick = (e) => {
|
|
||||||
e.stopPropagation(); // Prevent click on row if any
|
|
||||||
let errorDetailsDiv = row.querySelector('.error-details') as HTMLElement | null;
|
|
||||||
if (!errorDetailsDiv) {
|
|
||||||
errorDetailsDiv = document.createElement('div');
|
|
||||||
errorDetailsDiv.className = 'error-details';
|
|
||||||
const newCell = row.insertCell(); // This will append to the end of the row
|
|
||||||
newCell.colSpan = 9; // Span across all columns
|
|
||||||
newCell.appendChild(errorDetailsDiv);
|
|
||||||
// Visually, this new cell will be after the 'Details' button cell.
|
|
||||||
// To make it appear as part of the status cell or below the row, more complex DOM manipulation or CSS would be needed.
|
|
||||||
}
|
|
||||||
errorDetailsDiv.textContent = entry.error_message;
|
|
||||||
// Toggle display by directly manipulating the style of the details div
|
|
||||||
errorDetailsDiv.style.display = errorDetailsDiv.style.display === 'none' ? 'block' : 'none';
|
|
||||||
};
|
|
||||||
statusCell.appendChild(errorSpan);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePagination() {
|
|
||||||
if (!pageInfo || !prevButton || !nextButton) return;
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(totalEntries / limit) || 1;
|
|
||||||
pageInfo.textContent = `Page ${currentPage} of ${totalPages}`;
|
|
||||||
prevButton.disabled = currentPage === 1;
|
|
||||||
nextButton.disabled = currentPage === totalPages;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showDetailsModal(entry: any) {
|
|
||||||
const details = `Task ID: ${entry.task_id}\n` +
|
|
||||||
`Type: ${entry.download_type}\n` +
|
|
||||||
`Name: ${entry.item_name}\n` +
|
|
||||||
`Artist: ${entry.item_artist}\n` +
|
|
||||||
`Album: ${entry.item_album || 'N/A'}\n` +
|
|
||||||
`URL: ${entry.item_url}\n` +
|
|
||||||
`Spotify ID: ${entry.spotify_id || 'N/A'}\n` +
|
|
||||||
`Service Used: ${entry.service_used || 'N/A'}\n` +
|
|
||||||
`Quality Profile (Original): ${entry.quality_profile || 'N/A'}\n` +
|
|
||||||
`ConvertTo: ${entry.convert_to || 'N/A'}\n` +
|
|
||||||
`Bitrate: ${entry.bitrate ? entry.bitrate + 'k' : 'N/A'}\n` +
|
|
||||||
`Status: ${entry.status_final}\n` +
|
|
||||||
`Error: ${entry.error_message || 'None'}\n` +
|
|
||||||
`Added: ${new Date(entry.timestamp_added * 1000).toLocaleString()}\n` +
|
|
||||||
`Completed/Ended: ${new Date(entry.timestamp_completed * 1000).toLocaleString()}\n\n` +
|
|
||||||
`Original Request: ${JSON.stringify(JSON.parse(entry.original_request_json || '{}'), null, 2)}\n\n` +
|
|
||||||
`Last Status Object: ${JSON.stringify(JSON.parse(entry.last_status_obj_json || '{}'), null, 2)}`;
|
|
||||||
alert(details);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.querySelectorAll('th[data-sort]').forEach(headerCell => {
|
|
||||||
headerCell.addEventListener('click', () => {
|
|
||||||
const sortField = (headerCell as HTMLElement).dataset.sort;
|
|
||||||
if (!sortField) return;
|
|
||||||
|
|
||||||
if (currentSortBy === sortField) {
|
|
||||||
currentSortOrder = currentSortOrder === 'ASC' ? 'DESC' : 'ASC';
|
|
||||||
} else {
|
|
||||||
currentSortBy = sortField;
|
|
||||||
currentSortOrder = 'DESC';
|
|
||||||
}
|
|
||||||
fetchHistory(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function updateSortIndicators() {
|
|
||||||
document.querySelectorAll('th[data-sort]').forEach(headerCell => {
|
|
||||||
const th = headerCell as HTMLElement;
|
|
||||||
th.classList.remove('sort-asc', 'sort-desc');
|
|
||||||
if (th.dataset.sort === currentSortBy) {
|
|
||||||
th.classList.add(currentSortOrder === 'ASC' ? 'sort-asc' : 'sort-desc');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
prevButton?.addEventListener('click', () => fetchHistory(currentPage - 1));
|
|
||||||
nextButton?.addEventListener('click', () => fetchHistory(currentPage + 1));
|
|
||||||
limitSelect?.addEventListener('change', (e) => {
|
|
||||||
limit = parseInt((e.target as HTMLSelectElement).value, 10);
|
|
||||||
fetchHistory(1);
|
|
||||||
});
|
|
||||||
statusFilter?.addEventListener('change', () => fetchHistory(1));
|
|
||||||
typeFilter?.addEventListener('change', () => fetchHistory(1));
|
|
||||||
|
|
||||||
// Initial fetch
|
|
||||||
fetchHistory();
|
|
||||||
});
|
|
||||||
626
src/js/main.ts
@@ -1,626 +0,0 @@
|
|||||||
// main.ts
|
|
||||||
import { downloadQueue } from './queue.js';
|
|
||||||
|
|
||||||
// Define interfaces for API data and search results
|
|
||||||
interface Image {
|
|
||||||
url: string;
|
|
||||||
height?: number;
|
|
||||||
width?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Artist {
|
|
||||||
id?: string; // Artist ID might not always be present in search results for track artists
|
|
||||||
name: string;
|
|
||||||
external_urls?: { spotify?: string };
|
|
||||||
genres?: string[]; // For artist type results
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Album {
|
|
||||||
id?: string; // Album ID might not always be present
|
|
||||||
name: string;
|
|
||||||
images?: Image[];
|
|
||||||
album_type?: string; // Used in startDownload
|
|
||||||
artists?: Artist[]; // Album can have artists too
|
|
||||||
total_tracks?: number;
|
|
||||||
release_date?: string;
|
|
||||||
external_urls?: { spotify?: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Track {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
artists: Artist[];
|
|
||||||
album: Album;
|
|
||||||
duration_ms?: number;
|
|
||||||
explicit?: boolean;
|
|
||||||
external_urls: { spotify: string };
|
|
||||||
href?: string; // Some spotify responses use href
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Playlist {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
owner: { display_name?: string; id?: string };
|
|
||||||
images?: Image[];
|
|
||||||
tracks: { total: number }; // Simplified for search results
|
|
||||||
external_urls: { spotify: string };
|
|
||||||
href?: string; // Some spotify responses use href
|
|
||||||
explicit?: boolean; // Playlists themselves aren't explicit, but items can be
|
|
||||||
}
|
|
||||||
|
|
||||||
// Specific item types for search results
|
|
||||||
interface TrackResultItem extends Track {}
|
|
||||||
interface AlbumResultItem extends Album { id: string; images?: Image[]; explicit?: boolean; external_urls: { spotify: string }; href?: string; }
|
|
||||||
interface PlaylistResultItem extends Playlist {}
|
|
||||||
interface ArtistResultItem extends Artist { id: string; images?: Image[]; explicit?: boolean; external_urls: { spotify: string }; href?: string; followers?: { total: number }; }
|
|
||||||
|
|
||||||
// Union type for any search result item
|
|
||||||
type SearchResultItem = TrackResultItem | AlbumResultItem | PlaylistResultItem | ArtistResultItem;
|
|
||||||
|
|
||||||
// Interface for the API response structure
|
|
||||||
interface SearchResponse {
|
|
||||||
items: SearchResultItem[];
|
|
||||||
// Add other top-level properties from the search API if needed (e.g., total, limit, offset)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interface for the item passed to downloadQueue.download
|
|
||||||
interface DownloadQueueItem {
|
|
||||||
name: string;
|
|
||||||
artist?: string;
|
|
||||||
album?: { name: string; album_type?: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// DOM elements
|
|
||||||
const searchInput = document.getElementById('searchInput') as HTMLInputElement | null;
|
|
||||||
const searchButton = document.getElementById('searchButton') as HTMLButtonElement | null;
|
|
||||||
const searchType = document.getElementById('searchType') as HTMLSelectElement | null;
|
|
||||||
const resultsContainer = document.getElementById('resultsContainer');
|
|
||||||
const queueIcon = document.getElementById('queueIcon');
|
|
||||||
const emptyState = document.getElementById('emptyState');
|
|
||||||
const loadingResults = document.getElementById('loadingResults');
|
|
||||||
const watchlistButton = document.getElementById('watchlistButton') as HTMLAnchorElement | null;
|
|
||||||
|
|
||||||
// Initialize the queue
|
|
||||||
if (queueIcon) {
|
|
||||||
queueIcon.addEventListener('click', () => {
|
|
||||||
downloadQueue.toggleVisibility();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add event listeners
|
|
||||||
if (searchButton) {
|
|
||||||
searchButton.addEventListener('click', performSearch);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchInput) {
|
|
||||||
searchInput.addEventListener('keypress', function(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
performSearch();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-detect and handle pasted Spotify URLs
|
|
||||||
searchInput.addEventListener('input', function(e: Event) {
|
|
||||||
const target = e.target as HTMLInputElement;
|
|
||||||
const inputVal = target.value.trim();
|
|
||||||
if (isSpotifyUrl(inputVal)) {
|
|
||||||
const details = getSpotifyResourceDetails(inputVal);
|
|
||||||
if (details && searchType) {
|
|
||||||
searchType.value = details.type;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore last search type if no URL override
|
|
||||||
const savedType = localStorage.getItem('lastSearchType');
|
|
||||||
if (searchType && savedType && ['track','album','playlist','artist'].includes(savedType)) {
|
|
||||||
searchType.value = savedType;
|
|
||||||
}
|
|
||||||
// Save last selection on change
|
|
||||||
if (searchType) {
|
|
||||||
searchType.addEventListener('change', () => {
|
|
||||||
localStorage.setItem('lastSearchType', searchType.value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attempt to set initial watchlist button visibility from cache
|
|
||||||
if (watchlistButton) {
|
|
||||||
const cachedWatchEnabled = localStorage.getItem('spotizerr_watch_enabled_cached');
|
|
||||||
if (cachedWatchEnabled === 'true') {
|
|
||||||
watchlistButton.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch watch config to determine if watchlist button should be visible
|
|
||||||
async function updateWatchlistButtonVisibility() {
|
|
||||||
if (watchlistButton) {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/config/watch');
|
|
||||||
if (response.ok) {
|
|
||||||
const watchConfig = await response.json();
|
|
||||||
localStorage.setItem('spotizerr_watch_enabled_cached', watchConfig.enabled ? 'true' : 'false');
|
|
||||||
if (watchConfig && watchConfig.enabled === false) {
|
|
||||||
watchlistButton.classList.add('hidden');
|
|
||||||
} else {
|
|
||||||
watchlistButton.classList.remove('hidden'); // Ensure it's shown if enabled
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error('Failed to fetch watch config, defaulting to hidden');
|
|
||||||
// Don't update cache on error, rely on default hidden or previous cache state until success
|
|
||||||
watchlistButton.classList.add('hidden'); // Hide if config fetch fails
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching watch config:', error);
|
|
||||||
// Don't update cache on error
|
|
||||||
watchlistButton.classList.add('hidden'); // Hide on error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updateWatchlistButtonVisibility();
|
|
||||||
|
|
||||||
// Check for URL parameters
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const query = urlParams.get('q');
|
|
||||||
const type = urlParams.get('type');
|
|
||||||
|
|
||||||
if (query && searchInput) {
|
|
||||||
searchInput.value = query;
|
|
||||||
if (type && searchType && ['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 currentQuery = searchInput?.value.trim();
|
|
||||||
if (!currentQuery) return;
|
|
||||||
|
|
||||||
// Handle direct Spotify URLs
|
|
||||||
if (isSpotifyUrl(currentQuery)) {
|
|
||||||
const details = getSpotifyResourceDetails(currentQuery);
|
|
||||||
if (details && details.id) {
|
|
||||||
// Redirect to the appropriate page
|
|
||||||
window.location.href = `/${details.type}/${details.id}`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update URL without reloading page
|
|
||||||
const currentSearchType = searchType?.value || 'track';
|
|
||||||
const newUrl = `${window.location.pathname}?q=${encodeURIComponent(currentQuery)}&type=${currentSearchType}`;
|
|
||||||
window.history.pushState({ path: newUrl }, '', newUrl);
|
|
||||||
|
|
||||||
// Show loading state
|
|
||||||
showEmptyState(false);
|
|
||||||
showLoading(true);
|
|
||||||
if(resultsContainer) resultsContainer.innerHTML = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = `/api/search?q=${encodeURIComponent(currentQuery)}&search_type=${currentSearchType}&limit=40`;
|
|
||||||
const response = await fetch(url);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Network response was not ok');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json() as SearchResponse; // Assert type for API response
|
|
||||||
|
|
||||||
// Hide loading indicator
|
|
||||||
showLoading(false);
|
|
||||||
|
|
||||||
// Render results
|
|
||||||
if (data && data.items && data.items.length > 0) {
|
|
||||||
if(resultsContainer) resultsContainer.innerHTML = '';
|
|
||||||
|
|
||||||
// Filter out items with null/undefined essential display parameters
|
|
||||||
const validItems = filterValidItems(data.items, currentSearchType);
|
|
||||||
|
|
||||||
if (validItems.length === 0) {
|
|
||||||
// No valid items found after filtering
|
|
||||||
if(resultsContainer) resultsContainer.innerHTML = `
|
|
||||||
<div class="empty-search-results">
|
|
||||||
<p>No valid results found for "${currentQuery}"</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
validItems.forEach((item, index) => {
|
|
||||||
const cardElement = createResultCard(item, currentSearchType, index);
|
|
||||||
|
|
||||||
// Store the item data directly on the button element
|
|
||||||
const downloadBtn = cardElement.querySelector('.download-btn') as HTMLButtonElement | null;
|
|
||||||
if (downloadBtn) {
|
|
||||||
downloadBtn.dataset.itemIndex = index.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if(resultsContainer) resultsContainer.appendChild(cardElement);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Attach download handlers to the newly created cards
|
|
||||||
attachDownloadListeners(validItems);
|
|
||||||
} else {
|
|
||||||
// No results found
|
|
||||||
if(resultsContainer) resultsContainer.innerHTML = `
|
|
||||||
<div class="empty-search-results">
|
|
||||||
<p>No results found for "${currentQuery}"</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error:', error);
|
|
||||||
showLoading(false);
|
|
||||||
if(resultsContainer) resultsContainer.innerHTML = `
|
|
||||||
<div class="error">
|
|
||||||
<p>Error searching: ${error.message}</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filters out items with null/undefined essential display parameters based on search type
|
|
||||||
*/
|
|
||||||
function filterValidItems(items: SearchResultItem[], type: string): SearchResultItem[] {
|
|
||||||
if (!items) return [];
|
|
||||||
|
|
||||||
return items.filter(item => {
|
|
||||||
// Skip null/undefined items
|
|
||||||
if (!item) return false;
|
|
||||||
|
|
||||||
// Skip explicit content if filter is enabled
|
|
||||||
if (downloadQueue.isExplicitFilterEnabled() && ('explicit' in item && item.explicit === true)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check essential parameters based on search type
|
|
||||||
switch (type) {
|
|
||||||
case 'track':
|
|
||||||
const trackItem = item as TrackResultItem;
|
|
||||||
return (
|
|
||||||
trackItem.name &&
|
|
||||||
trackItem.artists &&
|
|
||||||
trackItem.artists.length > 0 &&
|
|
||||||
trackItem.artists[0] &&
|
|
||||||
trackItem.artists[0].name &&
|
|
||||||
trackItem.album &&
|
|
||||||
trackItem.album.name &&
|
|
||||||
trackItem.external_urls &&
|
|
||||||
trackItem.external_urls.spotify
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'album':
|
|
||||||
const albumItem = item as AlbumResultItem;
|
|
||||||
return (
|
|
||||||
albumItem.name &&
|
|
||||||
albumItem.artists &&
|
|
||||||
albumItem.artists.length > 0 &&
|
|
||||||
albumItem.artists[0] &&
|
|
||||||
albumItem.artists[0].name &&
|
|
||||||
albumItem.external_urls &&
|
|
||||||
albumItem.external_urls.spotify
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'playlist':
|
|
||||||
const playlistItem = item as PlaylistResultItem;
|
|
||||||
return (
|
|
||||||
playlistItem.name &&
|
|
||||||
playlistItem.owner &&
|
|
||||||
playlistItem.owner.display_name &&
|
|
||||||
playlistItem.tracks &&
|
|
||||||
playlistItem.external_urls &&
|
|
||||||
playlistItem.external_urls.spotify
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'artist':
|
|
||||||
const artistItem = item as ArtistResultItem;
|
|
||||||
return (
|
|
||||||
artistItem.name &&
|
|
||||||
artistItem.external_urls &&
|
|
||||||
artistItem.external_urls.spotify
|
|
||||||
);
|
|
||||||
|
|
||||||
default:
|
|
||||||
// Default case - just check if the item exists (already handled by `if (!item) return false;`)
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attaches download handlers to result cards
|
|
||||||
*/
|
|
||||||
function attachDownloadListeners(items: SearchResultItem[]) {
|
|
||||||
document.querySelectorAll('.download-btn').forEach((btnElm) => {
|
|
||||||
const btn = btnElm as HTMLButtonElement;
|
|
||||||
btn.addEventListener('click', (e: Event) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
// Get the item index from the button's dataset
|
|
||||||
const itemIndexStr = btn.dataset.itemIndex;
|
|
||||||
if (!itemIndexStr) return;
|
|
||||||
const itemIndex = parseInt(itemIndexStr, 10);
|
|
||||||
|
|
||||||
// Get the corresponding item
|
|
||||||
const item = items[itemIndex];
|
|
||||||
if (!item) return;
|
|
||||||
|
|
||||||
const currentSearchType = searchType?.value || 'track';
|
|
||||||
let itemId = item.id || ''; // Use item.id directly
|
|
||||||
|
|
||||||
if (!itemId) { // Check if ID was found
|
|
||||||
showError('Could not determine download ID');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare metadata for the download
|
|
||||||
let metadata: DownloadQueueItem;
|
|
||||||
if (currentSearchType === 'track') {
|
|
||||||
const trackItem = item as TrackResultItem;
|
|
||||||
metadata = {
|
|
||||||
name: trackItem.name || 'Unknown',
|
|
||||||
artist: trackItem.artists ? trackItem.artists[0]?.name : undefined,
|
|
||||||
album: trackItem.album ? { name: trackItem.album.name, album_type: trackItem.album.album_type } : undefined
|
|
||||||
};
|
|
||||||
} else if (currentSearchType === 'album') {
|
|
||||||
const albumItem = item as AlbumResultItem;
|
|
||||||
metadata = {
|
|
||||||
name: albumItem.name || 'Unknown',
|
|
||||||
artist: albumItem.artists ? albumItem.artists[0]?.name : undefined,
|
|
||||||
album: { name: albumItem.name, album_type: albumItem.album_type}
|
|
||||||
};
|
|
||||||
} else if (currentSearchType === 'playlist') {
|
|
||||||
const playlistItem = item as PlaylistResultItem;
|
|
||||||
metadata = {
|
|
||||||
name: playlistItem.name || 'Unknown',
|
|
||||||
// artist for playlist is owner
|
|
||||||
artist: playlistItem.owner?.display_name
|
|
||||||
};
|
|
||||||
} else if (currentSearchType === 'artist') {
|
|
||||||
const artistItem = item as ArtistResultItem;
|
|
||||||
metadata = {
|
|
||||||
name: artistItem.name || 'Unknown',
|
|
||||||
artist: artistItem.name // For artist type, artist is the item name itself
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
metadata = { name: item.name || 'Unknown' }; // Fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disable the button and update text
|
|
||||||
btn.disabled = true;
|
|
||||||
|
|
||||||
// For artist downloads, show a different message since it will queue multiple albums
|
|
||||||
if (currentSearchType === 'artist') {
|
|
||||||
btn.innerHTML = 'Queueing albums...';
|
|
||||||
} else {
|
|
||||||
btn.innerHTML = 'Queueing...';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start the download
|
|
||||||
startDownload(itemId, currentSearchType, metadata,
|
|
||||||
(item as AlbumResultItem).album_type || ((item as TrackResultItem).album ? (item as TrackResultItem).album.album_type : null))
|
|
||||||
.then(() => {
|
|
||||||
// For artists, show how many albums were queued
|
|
||||||
if (currentSearchType === 'artist') {
|
|
||||||
btn.innerHTML = 'Albums queued!';
|
|
||||||
// Open the queue automatically for artist downloads
|
|
||||||
downloadQueue.toggleVisibility(true);
|
|
||||||
} else {
|
|
||||||
btn.innerHTML = 'Queued!';
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error: any) => {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.innerHTML = 'Download';
|
|
||||||
showError('Failed to queue download: ' + error.message);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts the download process via API
|
|
||||||
*/
|
|
||||||
async function startDownload(itemId: string, type: string, item: DownloadQueueItem, albumType: string | null | undefined) {
|
|
||||||
if (!itemId || !type) {
|
|
||||||
showError('Missing ID or type for download');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use the centralized downloadQueue.download method
|
|
||||||
await downloadQueue.download(itemId, type, item, albumType);
|
|
||||||
|
|
||||||
// Make the queue visible after queueing
|
|
||||||
downloadQueue.toggleVisibility(true);
|
|
||||||
} catch (error: any) {
|
|
||||||
showError('Download failed: ' + (error.message || 'Unknown error'));
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows an error message
|
|
||||||
*/
|
|
||||||
function showError(message: string) {
|
|
||||||
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: string) {
|
|
||||||
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: string): boolean {
|
|
||||||
return url.includes('open.spotify.com') ||
|
|
||||||
url.includes('spotify:') ||
|
|
||||||
url.includes('link.tospotify.com');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts details from a Spotify URL
|
|
||||||
*/
|
|
||||||
function getSpotifyResourceDetails(url: string): { type: string; id: string } | null {
|
|
||||||
// Allow optional path segments (e.g. intl-fr) before resource type
|
|
||||||
const regex = /spotify\.com\/(?:[^\/]+\/)??(track|album|playlist|artist)\/([a-zA-Z0-9]+)/i;
|
|
||||||
const match = url.match(regex);
|
|
||||||
|
|
||||||
if (match) {
|
|
||||||
return {
|
|
||||||
type: match[1],
|
|
||||||
id: match[2]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats milliseconds to MM:SS
|
|
||||||
*/
|
|
||||||
function msToMinutesSeconds(ms: number | undefined): string {
|
|
||||||
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: SearchResultItem, type: string, index: number): HTMLDivElement {
|
|
||||||
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';
|
|
||||||
// Type guards to safely access images
|
|
||||||
if (type === 'album' || type === 'artist') {
|
|
||||||
const albumOrArtistItem = item as AlbumResultItem | ArtistResultItem;
|
|
||||||
if (albumOrArtistItem.images && albumOrArtistItem.images.length > 0) {
|
|
||||||
imageUrl = albumOrArtistItem.images[0].url;
|
|
||||||
}
|
|
||||||
} else if (type === 'track') {
|
|
||||||
const trackItem = item as TrackResultItem;
|
|
||||||
if (trackItem.album && trackItem.album.images && trackItem.album.images.length > 0) {
|
|
||||||
imageUrl = trackItem.album.images[0].url;
|
|
||||||
}
|
|
||||||
} else if (type === 'playlist') {
|
|
||||||
const playlistItem = item as PlaylistResultItem;
|
|
||||||
if (playlistItem.images && playlistItem.images.length > 0) {
|
|
||||||
imageUrl = playlistItem.images[0].url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the appropriate details based on type
|
|
||||||
let subtitle = '';
|
|
||||||
let details = '';
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'track':
|
|
||||||
{
|
|
||||||
const trackItem = item as TrackResultItem;
|
|
||||||
subtitle = trackItem.artists ? trackItem.artists.map((a: Artist) => a.name).join(', ') : 'Unknown Artist';
|
|
||||||
details = trackItem.album ? `<span>${trackItem.album.name}</span><span class="duration">${msToMinutesSeconds(trackItem.duration_ms)}</span>` : '';
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'album':
|
|
||||||
{
|
|
||||||
const albumItem = item as AlbumResultItem;
|
|
||||||
subtitle = albumItem.artists ? albumItem.artists.map((a: Artist) => a.name).join(', ') : 'Unknown Artist';
|
|
||||||
details = `<span>${albumItem.total_tracks || 0} tracks</span><span>${albumItem.release_date ? new Date(albumItem.release_date).getFullYear() : ''}</span>`;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'playlist':
|
|
||||||
{
|
|
||||||
const playlistItem = item as PlaylistResultItem;
|
|
||||||
subtitle = `By ${playlistItem.owner ? playlistItem.owner.display_name : 'Unknown'}`;
|
|
||||||
details = `<span>${playlistItem.tracks && playlistItem.tracks.total ? playlistItem.tracks.total : 0} tracks</span>`;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'artist':
|
|
||||||
{
|
|
||||||
const artistItem = item as ArtistResultItem;
|
|
||||||
subtitle = 'Artist';
|
|
||||||
details = artistItem.genres ? `<span>${artistItem.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" data-item-index="${index}">
|
|
||||||
<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: MouseEvent) => {
|
|
||||||
// Don't trigger if the download button was clicked
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (target.classList.contains('download-btn') ||
|
|
||||||
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: boolean) {
|
|
||||||
if (emptyState) {
|
|
||||||
emptyState.style.display = show ? 'flex' : 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show/hide the loading indicator
|
|
||||||
*/
|
|
||||||
function showLoading(show: boolean) {
|
|
||||||
if (loadingResults) {
|
|
||||||
loadingResults.classList.toggle('hidden', !show);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,864 +0,0 @@
|
|||||||
// Import the downloadQueue singleton from your working queue.js implementation.
|
|
||||||
import { downloadQueue } from './queue.js';
|
|
||||||
|
|
||||||
// Define interfaces for API data
|
|
||||||
interface Image {
|
|
||||||
url: string;
|
|
||||||
height?: number;
|
|
||||||
width?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Artist {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
external_urls?: { spotify?: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Album {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
images?: Image[];
|
|
||||||
external_urls?: { spotify?: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Track {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
artists: Artist[];
|
|
||||||
album: Album;
|
|
||||||
duration_ms: number;
|
|
||||||
explicit: boolean;
|
|
||||||
external_urls?: { spotify?: string };
|
|
||||||
is_locally_known?: boolean; // Added for local DB status
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PlaylistItem {
|
|
||||||
track: Track | null;
|
|
||||||
// Add other playlist item properties like added_at, added_by if needed
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Playlist {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
owner: {
|
|
||||||
display_name?: string;
|
|
||||||
id?: string;
|
|
||||||
};
|
|
||||||
images: Image[];
|
|
||||||
tracks: {
|
|
||||||
items: PlaylistItem[];
|
|
||||||
total: number;
|
|
||||||
};
|
|
||||||
followers?: {
|
|
||||||
total: number;
|
|
||||||
};
|
|
||||||
external_urls?: { spotify?: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WatchedPlaylistStatus {
|
|
||||||
is_watched: boolean;
|
|
||||||
playlist_data?: Playlist; // Optional, present if watched
|
|
||||||
}
|
|
||||||
|
|
||||||
// Added: Interface for global watch config
|
|
||||||
interface GlobalWatchConfig {
|
|
||||||
enabled: boolean;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DownloadQueueItem {
|
|
||||||
name: string;
|
|
||||||
artist?: string; // Can be a simple string for the queue
|
|
||||||
album?: { name: string }; // Match QueueItem's album structure
|
|
||||||
owner?: string; // For playlists, owner can be a string
|
|
||||||
// Add any other properties your item might have, compatible with QueueItem
|
|
||||||
}
|
|
||||||
|
|
||||||
// Added: Helper function to fetch global watch config
|
|
||||||
async function getGlobalWatchConfig(): Promise<GlobalWatchConfig> {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/config/watch');
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error('Failed to fetch global watch config, assuming disabled.');
|
|
||||||
return { enabled: false }; // Default to disabled on error
|
|
||||||
}
|
|
||||||
return await response.json() as GlobalWatchConfig;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching global watch config:', error);
|
|
||||||
return { enabled: false }; // Default to disabled on error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
|
||||||
// Parse playlist ID from URL
|
|
||||||
const pathSegments = window.location.pathname.split('/');
|
|
||||||
const playlistId = pathSegments[pathSegments.indexOf('playlist') + 1];
|
|
||||||
|
|
||||||
if (!playlistId) {
|
|
||||||
showError('No playlist ID provided.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const globalWatchConfig = await getGlobalWatchConfig(); // Fetch global config
|
|
||||||
const isGlobalWatchActuallyEnabled = globalWatchConfig.enabled;
|
|
||||||
|
|
||||||
// Fetch playlist info directly
|
|
||||||
fetch(`/api/playlist/info?id=${encodeURIComponent(playlistId)}`)
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) throw new Error('Network response was not ok');
|
|
||||||
return response.json() as Promise<Playlist>;
|
|
||||||
})
|
|
||||||
.then(data => renderPlaylist(data, isGlobalWatchActuallyEnabled))
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error:', error);
|
|
||||||
showError('Failed to load playlist.');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch initial watch status for the specific playlist
|
|
||||||
if (isGlobalWatchActuallyEnabled) {
|
|
||||||
fetchWatchStatus(playlistId); // This function then calls updateWatchButtons
|
|
||||||
} else {
|
|
||||||
// If global watch is disabled, ensure watch-related buttons are hidden/disabled
|
|
||||||
const watchBtn = document.getElementById('watchPlaylistBtn') as HTMLButtonElement;
|
|
||||||
const syncBtn = document.getElementById('syncPlaylistBtn') as HTMLButtonElement;
|
|
||||||
if (watchBtn) {
|
|
||||||
watchBtn.classList.add('hidden');
|
|
||||||
watchBtn.disabled = true;
|
|
||||||
// Remove any existing event listener to prevent actions
|
|
||||||
watchBtn.onclick = null;
|
|
||||||
}
|
|
||||||
if (syncBtn) {
|
|
||||||
syncBtn.classList.add('hidden');
|
|
||||||
syncBtn.disabled = true;
|
|
||||||
syncBtn.onclick = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const queueIcon = document.getElementById('queueIcon');
|
|
||||||
if (queueIcon) {
|
|
||||||
queueIcon.addEventListener('click', () => {
|
|
||||||
downloadQueue.toggleVisibility();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attempt to set initial watchlist button visibility from cache
|
|
||||||
const watchlistButton = document.getElementById('watchlistButton') as HTMLAnchorElement | null;
|
|
||||||
if (watchlistButton) {
|
|
||||||
const cachedWatchEnabled = localStorage.getItem('spotizerr_watch_enabled_cached');
|
|
||||||
if (cachedWatchEnabled === 'true') {
|
|
||||||
watchlistButton.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch watch config to determine if watchlist button should be visible
|
|
||||||
async function updateWatchlistButtonVisibility() {
|
|
||||||
if (watchlistButton) {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/config/watch');
|
|
||||||
if (response.ok) {
|
|
||||||
const watchConfig = await response.json();
|
|
||||||
localStorage.setItem('spotizerr_watch_enabled_cached', watchConfig.enabled ? 'true' : 'false');
|
|
||||||
if (watchConfig && watchConfig.enabled === false) {
|
|
||||||
watchlistButton.classList.add('hidden');
|
|
||||||
} else {
|
|
||||||
watchlistButton.classList.remove('hidden'); // Ensure it's shown if enabled
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error('Failed to fetch watch config for playlist page, defaulting to hidden');
|
|
||||||
// Don't update cache on error
|
|
||||||
watchlistButton.classList.add('hidden'); // Hide if config fetch fails
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching watch config for playlist page:', error);
|
|
||||||
// Don't update cache on error
|
|
||||||
watchlistButton.classList.add('hidden'); // Hide on error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updateWatchlistButtonVisibility();
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders playlist header and tracks.
|
|
||||||
*/
|
|
||||||
function renderPlaylist(playlist: Playlist, isGlobalWatchEnabled: boolean) {
|
|
||||||
// Hide loading and error messages
|
|
||||||
const loadingEl = document.getElementById('loading');
|
|
||||||
if (loadingEl) loadingEl.classList.add('hidden');
|
|
||||||
const errorEl = document.getElementById('error');
|
|
||||||
if (errorEl) errorEl.classList.add('hidden');
|
|
||||||
|
|
||||||
// Check if explicit filter is enabled
|
|
||||||
const isExplicitFilterEnabled = downloadQueue.isExplicitFilterEnabled();
|
|
||||||
|
|
||||||
// Update header info
|
|
||||||
const playlistNameEl = document.getElementById('playlist-name');
|
|
||||||
if (playlistNameEl) playlistNameEl.textContent = playlist.name || 'Unknown Playlist';
|
|
||||||
const playlistOwnerEl = document.getElementById('playlist-owner');
|
|
||||||
if (playlistOwnerEl) playlistOwnerEl.textContent = `By ${playlist.owner?.display_name || 'Unknown User'}`;
|
|
||||||
const playlistStatsEl = document.getElementById('playlist-stats');
|
|
||||||
if (playlistStatsEl) playlistStatsEl.textContent =
|
|
||||||
`${playlist.followers?.total || '0'} followers • ${playlist.tracks?.total || '0'} songs`;
|
|
||||||
const playlistDescriptionEl = document.getElementById('playlist-description');
|
|
||||||
if (playlistDescriptionEl) playlistDescriptionEl.textContent = playlist.description || '';
|
|
||||||
const image = playlist.images?.[0]?.url || '/static/images/placeholder.jpg';
|
|
||||||
const playlistImageEl = document.getElementById('playlist-image') as HTMLImageElement;
|
|
||||||
if (playlistImageEl) playlistImageEl.src = image;
|
|
||||||
|
|
||||||
// --- Add Home Button ---
|
|
||||||
let homeButton = document.getElementById('homeButton') as HTMLButtonElement;
|
|
||||||
if (!homeButton) {
|
|
||||||
homeButton = document.createElement('button');
|
|
||||||
homeButton.id = 'homeButton';
|
|
||||||
homeButton.className = 'home-btn';
|
|
||||||
// Use an <img> tag to display the SVG icon.
|
|
||||||
homeButton.innerHTML = `<img src="/static/images/home.svg" alt="Home">`;
|
|
||||||
// Insert the home button at the beginning of the header container.
|
|
||||||
const headerContainer = document.getElementById('playlist-header');
|
|
||||||
if (headerContainer) {
|
|
||||||
headerContainer.insertBefore(homeButton, headerContainer.firstChild);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
homeButton.addEventListener('click', () => {
|
|
||||||
// Navigate to the site's base URL.
|
|
||||||
window.location.href = window.location.origin;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if any track in the playlist is explicit when filter is enabled
|
|
||||||
let hasExplicitTrack = false;
|
|
||||||
if (isExplicitFilterEnabled && playlist.tracks?.items) {
|
|
||||||
hasExplicitTrack = playlist.tracks.items.some((item: PlaylistItem) => item?.track && item.track.explicit);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Add "Download Whole Playlist" Button ---
|
|
||||||
let downloadPlaylistBtn = document.getElementById('downloadPlaylistBtn') as HTMLButtonElement;
|
|
||||||
if (!downloadPlaylistBtn) {
|
|
||||||
downloadPlaylistBtn = document.createElement('button');
|
|
||||||
downloadPlaylistBtn.id = 'downloadPlaylistBtn';
|
|
||||||
downloadPlaylistBtn.textContent = 'Download Whole Playlist';
|
|
||||||
downloadPlaylistBtn.className = 'download-btn download-btn--main';
|
|
||||||
// Insert the button into the header container.
|
|
||||||
const headerContainer = document.getElementById('playlist-header');
|
|
||||||
if (headerContainer) {
|
|
||||||
headerContainer.appendChild(downloadPlaylistBtn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Add "Download Playlist's Albums" Button ---
|
|
||||||
let downloadAlbumsBtn = document.getElementById('downloadAlbumsBtn') as HTMLButtonElement;
|
|
||||||
if (!downloadAlbumsBtn) {
|
|
||||||
downloadAlbumsBtn = document.createElement('button');
|
|
||||||
downloadAlbumsBtn.id = 'downloadAlbumsBtn';
|
|
||||||
downloadAlbumsBtn.textContent = "Download Playlist's Albums";
|
|
||||||
downloadAlbumsBtn.className = 'download-btn download-btn--main';
|
|
||||||
// Insert the new button into the header container.
|
|
||||||
const headerContainer = document.getElementById('playlist-header');
|
|
||||||
if (headerContainer) {
|
|
||||||
headerContainer.appendChild(downloadAlbumsBtn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isExplicitFilterEnabled && hasExplicitTrack) {
|
|
||||||
// Disable both playlist buttons and display messages explaining why
|
|
||||||
if (downloadPlaylistBtn) {
|
|
||||||
downloadPlaylistBtn.disabled = true;
|
|
||||||
downloadPlaylistBtn.classList.add('download-btn--disabled');
|
|
||||||
downloadPlaylistBtn.innerHTML = `<span title="Cannot download entire playlist because it contains explicit tracks">Playlist Contains Explicit Tracks</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (downloadAlbumsBtn) {
|
|
||||||
downloadAlbumsBtn.disabled = true;
|
|
||||||
downloadAlbumsBtn.classList.add('download-btn--disabled');
|
|
||||||
downloadAlbumsBtn.innerHTML = `<span title="Cannot download albums from this playlist because it contains explicit tracks">Albums Access Restricted</span>`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Normal behavior when no explicit tracks are present
|
|
||||||
if (downloadPlaylistBtn) {
|
|
||||||
downloadPlaylistBtn.addEventListener('click', () => {
|
|
||||||
// Remove individual track download buttons (but leave the whole playlist button).
|
|
||||||
document.querySelectorAll('.download-btn').forEach(btn => {
|
|
||||||
if (btn.id !== 'downloadPlaylistBtn') {
|
|
||||||
btn.remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Disable the whole playlist button to prevent repeated clicks.
|
|
||||||
downloadPlaylistBtn.disabled = true;
|
|
||||||
downloadPlaylistBtn.textContent = 'Queueing...';
|
|
||||||
|
|
||||||
// Initiate the playlist download.
|
|
||||||
downloadWholePlaylist(playlist).then(() => {
|
|
||||||
downloadPlaylistBtn.textContent = 'Queued!';
|
|
||||||
}).catch((err: any) => {
|
|
||||||
showError('Failed to queue playlist download: ' + (err?.message || 'Unknown error'));
|
|
||||||
if (downloadPlaylistBtn) downloadPlaylistBtn.disabled = false; // Re-enable on error
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (downloadAlbumsBtn) {
|
|
||||||
downloadAlbumsBtn.addEventListener('click', () => {
|
|
||||||
// Remove individual track download buttons (but leave this album button).
|
|
||||||
document.querySelectorAll('.download-btn').forEach(btn => {
|
|
||||||
if (btn.id !== 'downloadAlbumsBtn') btn.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
downloadAlbumsBtn.disabled = true;
|
|
||||||
downloadAlbumsBtn.textContent = 'Queueing...';
|
|
||||||
|
|
||||||
downloadPlaylistAlbums(playlist)
|
|
||||||
.then(() => {
|
|
||||||
if (downloadAlbumsBtn) downloadAlbumsBtn.textContent = 'Queued!';
|
|
||||||
})
|
|
||||||
.catch((err: any) => {
|
|
||||||
showError('Failed to queue album downloads: ' + (err?.message || 'Unknown error'));
|
|
||||||
if (downloadAlbumsBtn) downloadAlbumsBtn.disabled = false; // Re-enable on error
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render tracks list
|
|
||||||
const tracksList = document.getElementById('tracks-list');
|
|
||||||
if (!tracksList) return;
|
|
||||||
|
|
||||||
tracksList.innerHTML = ''; // Clear any existing content
|
|
||||||
|
|
||||||
// Determine if the playlist is being watched to show/hide management buttons
|
|
||||||
const watchPlaylistButton = document.getElementById('watchPlaylistBtn') as HTMLButtonElement;
|
|
||||||
// isIndividuallyWatched checks if the button is visible and has the 'watching' class.
|
|
||||||
// This implies global watch is enabled if the button is even interactable for individual status.
|
|
||||||
const isIndividuallyWatched = watchPlaylistButton &&
|
|
||||||
watchPlaylistButton.classList.contains('watching') &&
|
|
||||||
!watchPlaylistButton.classList.contains('hidden');
|
|
||||||
|
|
||||||
if (playlist.tracks?.items) {
|
|
||||||
playlist.tracks.items.forEach((item: PlaylistItem, index: number) => {
|
|
||||||
if (!item || !item.track) return; // Skip null/undefined tracks
|
|
||||||
|
|
||||||
const track = item.track;
|
|
||||||
|
|
||||||
// Skip explicit tracks if filter is enabled
|
|
||||||
if (isExplicitFilterEnabled && track.explicit) {
|
|
||||||
// Add a placeholder for filtered explicit tracks
|
|
||||||
const trackElement = document.createElement('div');
|
|
||||||
trackElement.className = 'track track-filtered';
|
|
||||||
trackElement.innerHTML = `
|
|
||||||
<div class="track-number">${index + 1}</div>
|
|
||||||
<div class="track-info">
|
|
||||||
<div class="track-name explicit-filtered">Explicit Content Filtered</div>
|
|
||||||
<div class="track-artist">This track is not shown due to explicit content filter settings</div>
|
|
||||||
</div>
|
|
||||||
<div class="track-album">Not available</div>
|
|
||||||
<div class="track-duration">--:--</div>
|
|
||||||
`;
|
|
||||||
tracksList.appendChild(trackElement);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trackLink = `/track/${track.id || ''}`;
|
|
||||||
const artistLink = `/artist/${track.artists?.[0]?.id || ''}`;
|
|
||||||
const albumLink = `/album/${track.album?.id || ''}`;
|
|
||||||
|
|
||||||
const trackElement = document.createElement('div');
|
|
||||||
trackElement.className = 'track';
|
|
||||||
let trackHTML = `
|
|
||||||
<div class="track-number">${index + 1}</div>
|
|
||||||
<div class="track-info">
|
|
||||||
<div class="track-name">
|
|
||||||
<a href="${trackLink}" title="View track details">${track.name || 'Unknown Track'}</a>
|
|
||||||
</div>
|
|
||||||
<div class="track-artist">
|
|
||||||
<a href="${artistLink}" title="View artist details">${track.artists?.[0]?.name || 'Unknown Artist'}</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="track-album">
|
|
||||||
<a href="${albumLink}" title="View album details">${track.album?.name || 'Unknown Album'}</a>
|
|
||||||
</div>
|
|
||||||
<div class="track-duration">${msToTime(track.duration_ms || 0)}</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const actionsContainer = document.createElement('div');
|
|
||||||
actionsContainer.className = 'track-actions-container';
|
|
||||||
|
|
||||||
if (!(isExplicitFilterEnabled && hasExplicitTrack)) {
|
|
||||||
const downloadBtnHTML = `
|
|
||||||
<button class="download-btn download-btn--circle track-download-btn"
|
|
||||||
data-id="${track.id || ''}"
|
|
||||||
data-type="track"
|
|
||||||
data-name="${track.name || 'Unknown Track'}"
|
|
||||||
title="Download">
|
|
||||||
<img src="/static/images/download.svg" alt="Download">
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
actionsContainer.innerHTML += downloadBtnHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isGlobalWatchEnabled && isIndividuallyWatched) { // Check global and individual watch status
|
|
||||||
// Initial state is set based on track.is_locally_known
|
|
||||||
const isKnown = track.is_locally_known === true; // Ensure boolean check, default to false if undefined
|
|
||||||
const initialStatus = isKnown ? "known" : "missing";
|
|
||||||
const initialIcon = isKnown ? "/static/images/check.svg" : "/static/images/missing.svg";
|
|
||||||
const initialTitle = isKnown ? "Click to mark as missing from DB" : "Click to mark as known in DB";
|
|
||||||
|
|
||||||
const toggleKnownBtnHTML = `
|
|
||||||
<button class="action-btn toggle-known-status-btn"
|
|
||||||
data-id="${track.id || ''}"
|
|
||||||
data-playlist-id="${playlist.id || ''}"
|
|
||||||
data-status="${initialStatus}"
|
|
||||||
title="${initialTitle}">
|
|
||||||
<img src="${initialIcon}" alt="Mark as Missing/Known">
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
actionsContainer.innerHTML += toggleKnownBtnHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
trackElement.innerHTML = trackHTML;
|
|
||||||
trackElement.appendChild(actionsContainer);
|
|
||||||
tracksList.appendChild(trackElement);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reveal header and tracks container
|
|
||||||
const playlistHeaderEl = document.getElementById('playlist-header');
|
|
||||||
if (playlistHeaderEl) playlistHeaderEl.classList.remove('hidden');
|
|
||||||
const tracksContainerEl = document.getElementById('tracks-container');
|
|
||||||
if (tracksContainerEl) tracksContainerEl.classList.remove('hidden');
|
|
||||||
|
|
||||||
// Attach download listeners to newly rendered download buttons
|
|
||||||
attachTrackActionListeners(isGlobalWatchEnabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts milliseconds to minutes:seconds.
|
|
||||||
*/
|
|
||||||
function msToTime(duration: number) {
|
|
||||||
if (!duration || isNaN(duration)) return '0:00';
|
|
||||||
|
|
||||||
const minutes = Math.floor(duration / 60000);
|
|
||||||
const seconds = ((duration % 60000) / 1000).toFixed(0);
|
|
||||||
return `${minutes}:${seconds.padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Displays an error message in the UI.
|
|
||||||
*/
|
|
||||||
function showError(message: string) {
|
|
||||||
const errorEl = document.getElementById('error');
|
|
||||||
if (errorEl) {
|
|
||||||
errorEl.textContent = message || 'An error occurred';
|
|
||||||
errorEl.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attaches event listeners to all individual track action buttons (download, mark known, mark missing).
|
|
||||||
*/
|
|
||||||
function attachTrackActionListeners(isGlobalWatchEnabled: boolean) {
|
|
||||||
document.querySelectorAll('.track-download-btn').forEach((btn) => {
|
|
||||||
btn.addEventListener('click', (e: Event) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const currentTarget = e.currentTarget as HTMLButtonElement;
|
|
||||||
const itemId = currentTarget.dataset.id || '';
|
|
||||||
const type = currentTarget.dataset.type || 'track';
|
|
||||||
const name = currentTarget.dataset.name || 'Unknown';
|
|
||||||
if (!itemId) {
|
|
||||||
showError('Missing item ID for download on playlist page');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
currentTarget.remove();
|
|
||||||
startDownload(itemId, type, { name }, '');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('.toggle-known-status-btn').forEach((btn) => {
|
|
||||||
btn.addEventListener('click', async (e: Event) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const button = e.currentTarget as HTMLButtonElement;
|
|
||||||
const trackId = button.dataset.id || '';
|
|
||||||
const playlistId = button.dataset.playlistId || '';
|
|
||||||
const currentStatus = button.dataset.status;
|
|
||||||
const img = button.querySelector('img');
|
|
||||||
|
|
||||||
if (!trackId || !playlistId || !img) {
|
|
||||||
showError('Missing data for toggling track status');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isGlobalWatchEnabled) { // Added check
|
|
||||||
showNotification("Watch feature is currently disabled globally. Cannot change track status.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.disabled = true;
|
|
||||||
try {
|
|
||||||
if (currentStatus === 'missing') {
|
|
||||||
await handleMarkTrackAsKnown(playlistId, trackId);
|
|
||||||
button.dataset.status = 'known';
|
|
||||||
img.src = '/static/images/check.svg';
|
|
||||||
button.title = 'Click to mark as missing from DB';
|
|
||||||
} else {
|
|
||||||
await handleMarkTrackAsMissing(playlistId, trackId);
|
|
||||||
button.dataset.status = 'missing';
|
|
||||||
img.src = '/static/images/missing.svg';
|
|
||||||
button.title = 'Click to mark as known in DB';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Revert UI on error if needed, error is shown by handlers
|
|
||||||
showError('Failed to update track status. Please try again.');
|
|
||||||
}
|
|
||||||
button.disabled = false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleMarkTrackAsKnown(playlistId: string, trackId: string) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/playlist/watch/${playlistId}/tracks`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify([trackId]),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
const result = await response.json();
|
|
||||||
showNotification(result.message || 'Track marked as known.');
|
|
||||||
} catch (error: any) {
|
|
||||||
showError(`Failed to mark track as known: ${error.message}`);
|
|
||||||
throw error; // Re-throw for the caller to handle button state if needed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleMarkTrackAsMissing(playlistId: string, trackId: string) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/playlist/watch/${playlistId}/tracks`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify([trackId]),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
const result = await response.json();
|
|
||||||
showNotification(result.message || 'Track marked as missing.');
|
|
||||||
} catch (error: any) {
|
|
||||||
showError(`Failed to mark track as missing: ${error.message}`);
|
|
||||||
throw error; // Re-throw
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initiates the whole playlist download by calling the playlist endpoint.
|
|
||||||
*/
|
|
||||||
async function downloadWholePlaylist(playlist: Playlist) {
|
|
||||||
if (!playlist) {
|
|
||||||
throw new Error('Invalid playlist data');
|
|
||||||
}
|
|
||||||
|
|
||||||
const playlistId = playlist.id || '';
|
|
||||||
if (!playlistId) {
|
|
||||||
throw new Error('Missing playlist ID');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use the centralized downloadQueue.download method
|
|
||||||
await downloadQueue.download(playlistId, 'playlist', {
|
|
||||||
name: playlist.name || 'Unknown Playlist',
|
|
||||||
owner: playlist.owner?.display_name // Pass owner as a string
|
|
||||||
// total_tracks can also be passed if QueueItem supports it directly
|
|
||||||
});
|
|
||||||
// Make the queue visible after queueing
|
|
||||||
downloadQueue.toggleVisibility(true);
|
|
||||||
} catch (error: any) {
|
|
||||||
showError('Playlist download failed: ' + (error?.message || 'Unknown error'));
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initiates album downloads for each unique album in the playlist,
|
|
||||||
* adding a 20ms delay between each album download and updating the button
|
|
||||||
* with the progress (queued_albums/total_albums).
|
|
||||||
*/
|
|
||||||
async function downloadPlaylistAlbums(playlist: Playlist) {
|
|
||||||
if (!playlist?.tracks?.items) {
|
|
||||||
showError('No tracks found in this playlist.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a map of unique albums (using album ID as the key).
|
|
||||||
const albumMap = new Map<string, Album>();
|
|
||||||
playlist.tracks.items.forEach((item: PlaylistItem) => {
|
|
||||||
if (!item?.track?.album) return;
|
|
||||||
|
|
||||||
const album = item.track.album;
|
|
||||||
if (album && album.id) {
|
|
||||||
albumMap.set(album.id, album);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const uniqueAlbums = Array.from(albumMap.values());
|
|
||||||
const totalAlbums = uniqueAlbums.length;
|
|
||||||
if (totalAlbums === 0) {
|
|
||||||
showError('No albums found in this playlist.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get a reference to the "Download Playlist's Albums" button.
|
|
||||||
const downloadAlbumsBtn = document.getElementById('downloadAlbumsBtn') as HTMLButtonElement | null;
|
|
||||||
if (downloadAlbumsBtn) {
|
|
||||||
// Initialize the progress display.
|
|
||||||
downloadAlbumsBtn.textContent = `0/${totalAlbums}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Process each album sequentially.
|
|
||||||
for (let i = 0; i < totalAlbums; i++) {
|
|
||||||
const album = uniqueAlbums[i];
|
|
||||||
if (!album) continue;
|
|
||||||
|
|
||||||
const albumUrl = album.external_urls?.spotify || '';
|
|
||||||
if (!albumUrl) continue;
|
|
||||||
|
|
||||||
// Use the centralized downloadQueue.download method
|
|
||||||
await downloadQueue.download(
|
|
||||||
album.id, // Pass album ID directly
|
|
||||||
'album',
|
|
||||||
{
|
|
||||||
name: album.name || 'Unknown Album',
|
|
||||||
// If artist information is available on album objects from playlist, pass it
|
|
||||||
// artist: album.artists?.[0]?.name
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update button text with current progress.
|
|
||||||
if (downloadAlbumsBtn) {
|
|
||||||
downloadAlbumsBtn.textContent = `${i + 1}/${totalAlbums}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait 20 milliseconds before processing the next album.
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 20));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Once all albums have been queued, update the button text.
|
|
||||||
if (downloadAlbumsBtn) {
|
|
||||||
downloadAlbumsBtn.textContent = 'Queued!';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make the queue visible after queueing all albums
|
|
||||||
downloadQueue.toggleVisibility(true);
|
|
||||||
} catch (error: any) {
|
|
||||||
// Propagate any errors encountered.
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts the download process using the centralized download method from the queue.
|
|
||||||
*/
|
|
||||||
async function startDownload(itemId: string, type: string, item: DownloadQueueItem, albumType?: string) {
|
|
||||||
if (!itemId || !type) {
|
|
||||||
showError('Missing ID or type for download');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use the centralized downloadQueue.download method
|
|
||||||
await downloadQueue.download(itemId, type, item, albumType);
|
|
||||||
|
|
||||||
// Make the queue visible after queueing
|
|
||||||
downloadQueue.toggleVisibility(true);
|
|
||||||
} catch (error: any) {
|
|
||||||
showError('Download failed: ' + (error?.message || 'Unknown error'));
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A helper function to extract a display name from the URL.
|
|
||||||
*/
|
|
||||||
function extractName(url: string | null): string {
|
|
||||||
return url || 'Unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the watch status of the current playlist and updates the UI.
|
|
||||||
*/
|
|
||||||
async function fetchWatchStatus(playlistId: string) {
|
|
||||||
if (!playlistId) return;
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/playlist/watch/${playlistId}/status`);
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error || 'Failed to fetch watch status');
|
|
||||||
}
|
|
||||||
const data: WatchedPlaylistStatus = await response.json();
|
|
||||||
updateWatchButtons(data.is_watched, playlistId);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching watch status:', error);
|
|
||||||
// Don't show a blocking error, but maybe a small notification or log
|
|
||||||
// For now, assume not watched if status fetch fails, or keep buttons in default state
|
|
||||||
updateWatchButtons(false, playlistId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the Watch/Unwatch and Sync buttons based on the playlist's watch status.
|
|
||||||
*/
|
|
||||||
function updateWatchButtons(isWatched: boolean, playlistId: string) {
|
|
||||||
const watchBtn = document.getElementById('watchPlaylistBtn') as HTMLButtonElement;
|
|
||||||
const syncBtn = document.getElementById('syncPlaylistBtn') as HTMLButtonElement;
|
|
||||||
|
|
||||||
if (!watchBtn || !syncBtn) return;
|
|
||||||
|
|
||||||
const watchBtnImg = watchBtn.querySelector('img');
|
|
||||||
|
|
||||||
if (isWatched) {
|
|
||||||
watchBtn.innerHTML = `<img src="/static/images/eye-crossed.svg" alt="Unwatch"> Unwatch Playlist`;
|
|
||||||
watchBtn.classList.add('watching');
|
|
||||||
watchBtn.onclick = () => unwatchPlaylist(playlistId);
|
|
||||||
syncBtn.classList.remove('hidden');
|
|
||||||
syncBtn.onclick = () => syncPlaylist(playlistId);
|
|
||||||
} else {
|
|
||||||
watchBtn.innerHTML = `<img src="/static/images/eye.svg" alt="Watch"> Watch Playlist`;
|
|
||||||
watchBtn.classList.remove('watching');
|
|
||||||
watchBtn.onclick = () => watchPlaylist(playlistId);
|
|
||||||
syncBtn.classList.add('hidden');
|
|
||||||
}
|
|
||||||
watchBtn.disabled = false; // Enable after status is known
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds the current playlist to the watchlist.
|
|
||||||
*/
|
|
||||||
async function watchPlaylist(playlistId: string) {
|
|
||||||
const watchBtn = document.getElementById('watchPlaylistBtn') as HTMLButtonElement;
|
|
||||||
if (watchBtn) watchBtn.disabled = true;
|
|
||||||
// This function should only be callable if global watch is enabled.
|
|
||||||
// We can add a check here or rely on the UI not presenting the button.
|
|
||||||
// For safety, let's check global config again before proceeding.
|
|
||||||
const globalConfig = await getGlobalWatchConfig();
|
|
||||||
if (!globalConfig.enabled) {
|
|
||||||
showError("Cannot watch playlist, feature is disabled globally.");
|
|
||||||
if (watchBtn) {
|
|
||||||
watchBtn.disabled = false; // Re-enable if it was somehow clicked
|
|
||||||
updateWatchButtons(false, playlistId); // Reset button to non-watching state
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/playlist/watch/${playlistId}`, { method: 'PUT' });
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error || 'Failed to watch playlist');
|
|
||||||
}
|
|
||||||
updateWatchButtons(true, playlistId);
|
|
||||||
// Re-fetch and re-render playlist data
|
|
||||||
const newPlaylistInfoResponse = await fetch(`/api/playlist/info?id=${encodeURIComponent(playlistId)}`);
|
|
||||||
if (!newPlaylistInfoResponse.ok) throw new Error('Failed to re-fetch playlist info after watch.');
|
|
||||||
const newPlaylistData = await newPlaylistInfoResponse.json() as Playlist;
|
|
||||||
renderPlaylist(newPlaylistData, globalConfig.enabled); // Pass current global enabled state
|
|
||||||
|
|
||||||
showNotification(`Playlist added to watchlist. Tracks are being updated.`);
|
|
||||||
} catch (error: any) {
|
|
||||||
showError(`Error watching playlist: ${error.message}`);
|
|
||||||
if (watchBtn) watchBtn.disabled = false; // Re-enable on error before potential UI revert
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes the current playlist from the watchlist.
|
|
||||||
*/
|
|
||||||
async function unwatchPlaylist(playlistId: string) {
|
|
||||||
const watchBtn = document.getElementById('watchPlaylistBtn') as HTMLButtonElement;
|
|
||||||
if (watchBtn) watchBtn.disabled = true;
|
|
||||||
// Similarly, check global config
|
|
||||||
const globalConfig = await getGlobalWatchConfig();
|
|
||||||
if (!globalConfig.enabled) {
|
|
||||||
// This case should be rare if UI behaves, but good for robustness
|
|
||||||
showError("Cannot unwatch playlist, feature is disabled globally.");
|
|
||||||
if (watchBtn) {
|
|
||||||
watchBtn.disabled = false;
|
|
||||||
// updateWatchButtons(true, playlistId); // Or keep as is if it was 'watching'
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/playlist/watch/${playlistId}`, { method: 'DELETE' });
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error || 'Failed to unwatch playlist');
|
|
||||||
}
|
|
||||||
updateWatchButtons(false, playlistId);
|
|
||||||
// Re-fetch and re-render playlist data
|
|
||||||
const newPlaylistInfoResponse = await fetch(`/api/playlist/info?id=${encodeURIComponent(playlistId)}`);
|
|
||||||
if (!newPlaylistInfoResponse.ok) throw new Error('Failed to re-fetch playlist info after unwatch.');
|
|
||||||
const newPlaylistData = await newPlaylistInfoResponse.json() as Playlist;
|
|
||||||
renderPlaylist(newPlaylistData, globalConfig.enabled); // Pass current global enabled state
|
|
||||||
|
|
||||||
showNotification('Playlist removed from watchlist. Track statuses updated.');
|
|
||||||
} catch (error: any) {
|
|
||||||
showError(`Error unwatching playlist: ${error.message}`);
|
|
||||||
if (watchBtn) watchBtn.disabled = false; // Re-enable on error before potential UI revert
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Triggers a manual sync for the watched playlist.
|
|
||||||
*/
|
|
||||||
async function syncPlaylist(playlistId: string) {
|
|
||||||
const syncBtn = document.getElementById('syncPlaylistBtn') as HTMLButtonElement;
|
|
||||||
let originalButtonContent = ''; // Define outside
|
|
||||||
// Check global config
|
|
||||||
const globalConfig = await getGlobalWatchConfig();
|
|
||||||
if (!globalConfig.enabled) {
|
|
||||||
showError("Cannot sync playlist, feature is disabled globally.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (syncBtn) {
|
|
||||||
syncBtn.disabled = true;
|
|
||||||
originalButtonContent = syncBtn.innerHTML; // Store full HTML
|
|
||||||
syncBtn.innerHTML = `<img src="/static/images/refresh.svg" alt="Sync"> Syncing...`; // Keep icon
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/playlist/watch/trigger_check/${playlistId}`, { method: 'POST' });
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.error || 'Failed to trigger sync');
|
|
||||||
}
|
|
||||||
showNotification('Playlist sync triggered successfully.');
|
|
||||||
} catch (error: any) {
|
|
||||||
showError(`Error triggering sync: ${error.message}`);
|
|
||||||
} finally {
|
|
||||||
if (syncBtn) {
|
|
||||||
syncBtn.disabled = false;
|
|
||||||
syncBtn.innerHTML = originalButtonContent; // Restore full original HTML
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Displays a temporary notification message.
|
|
||||||
*/
|
|
||||||
function showNotification(message: string) {
|
|
||||||
// Basic notification - consider a more robust solution for production
|
|
||||||
const notificationEl = document.createElement('div');
|
|
||||||
notificationEl.className = 'notification';
|
|
||||||
notificationEl.textContent = message;
|
|
||||||
document.body.appendChild(notificationEl);
|
|
||||||
setTimeout(() => {
|
|
||||||
notificationEl.remove();
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
2755
src/js/queue.ts
258
src/js/track.ts
@@ -1,258 +0,0 @@
|
|||||||
// Import the downloadQueue singleton from your working queue.js implementation.
|
|
||||||
import { downloadQueue } from './queue.js';
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
// Parse track ID from URL. Expecting URL in the form /track/{id}
|
|
||||||
const pathSegments = window.location.pathname.split('/');
|
|
||||||
const trackId = pathSegments[pathSegments.indexOf('track') + 1];
|
|
||||||
|
|
||||||
if (!trackId) {
|
|
||||||
showError('No track ID provided.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch track info directly
|
|
||||||
fetch(`/api/track/info?id=${encodeURIComponent(trackId)}`)
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) throw new Error('Network response was not ok');
|
|
||||||
return response.json();
|
|
||||||
})
|
|
||||||
.then(data => renderTrack(data))
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error:', error);
|
|
||||||
showError('Error loading track');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Attach event listener to the queue icon to toggle the download queue
|
|
||||||
const queueIcon = document.getElementById('queueIcon');
|
|
||||||
if (queueIcon) {
|
|
||||||
queueIcon.addEventListener('click', () => {
|
|
||||||
downloadQueue.toggleVisibility();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attempt to set initial watchlist button visibility from cache
|
|
||||||
const watchlistButton = document.getElementById('watchlistButton') as HTMLAnchorElement | null;
|
|
||||||
if (watchlistButton) {
|
|
||||||
const cachedWatchEnabled = localStorage.getItem('spotizerr_watch_enabled_cached');
|
|
||||||
if (cachedWatchEnabled === 'true') {
|
|
||||||
watchlistButton.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch watch config to determine if watchlist button should be visible
|
|
||||||
async function updateWatchlistButtonVisibility() {
|
|
||||||
if (watchlistButton) {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/config/watch');
|
|
||||||
if (response.ok) {
|
|
||||||
const watchConfig = await response.json();
|
|
||||||
localStorage.setItem('spotizerr_watch_enabled_cached', watchConfig.enabled ? 'true' : 'false');
|
|
||||||
if (watchConfig && watchConfig.enabled === false) {
|
|
||||||
watchlistButton.classList.add('hidden');
|
|
||||||
} else {
|
|
||||||
watchlistButton.classList.remove('hidden'); // Ensure it's shown if enabled
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error('Failed to fetch watch config for track page, defaulting to hidden');
|
|
||||||
// Don't update cache on error
|
|
||||||
watchlistButton.classList.add('hidden'); // Hide if config fetch fails
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching watch config for track page:', error);
|
|
||||||
// Don't update cache on error
|
|
||||||
watchlistButton.classList.add('hidden'); // Hide on error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updateWatchlistButtonVisibility();
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders the track header information.
|
|
||||||
*/
|
|
||||||
function renderTrack(track: any) {
|
|
||||||
// Hide the loading and error messages.
|
|
||||||
const loadingEl = document.getElementById('loading');
|
|
||||||
if (loadingEl) loadingEl.classList.add('hidden');
|
|
||||||
const errorEl = document.getElementById('error');
|
|
||||||
if (errorEl) errorEl.classList.add('hidden');
|
|
||||||
|
|
||||||
// Check if track is explicit and if explicit filter is enabled
|
|
||||||
if (track.explicit && downloadQueue.isExplicitFilterEnabled()) {
|
|
||||||
// Show placeholder for explicit content
|
|
||||||
const loadingElExplicit = document.getElementById('loading');
|
|
||||||
if (loadingElExplicit) loadingElExplicit.classList.add('hidden');
|
|
||||||
|
|
||||||
const placeholderContent = `
|
|
||||||
<div class="explicit-filter-placeholder">
|
|
||||||
<h2>Explicit Content Filtered</h2>
|
|
||||||
<p>This track contains explicit content and has been filtered based on your settings.</p>
|
|
||||||
<p>The explicit content filter is controlled by environment variables.</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const contentContainer = document.getElementById('track-header');
|
|
||||||
if (contentContainer) {
|
|
||||||
contentContainer.innerHTML = placeholderContent;
|
|
||||||
contentContainer.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
return; // Stop rendering the actual track content
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update track information fields.
|
|
||||||
const trackNameEl = document.getElementById('track-name');
|
|
||||||
if (trackNameEl) {
|
|
||||||
trackNameEl.innerHTML =
|
|
||||||
`<a href="/track/${track.id || ''}" title="View track details">${track.name || 'Unknown Track'}</a>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trackArtistEl = document.getElementById('track-artist');
|
|
||||||
if (trackArtistEl) {
|
|
||||||
trackArtistEl.innerHTML =
|
|
||||||
`By ${track.artists?.map((a: any) =>
|
|
||||||
`<a href="/artist/${a?.id || ''}" title="View artist details">${a?.name || 'Unknown Artist'}</a>`
|
|
||||||
).join(', ') || 'Unknown Artist'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trackAlbumEl = document.getElementById('track-album');
|
|
||||||
if (trackAlbumEl) {
|
|
||||||
trackAlbumEl.innerHTML =
|
|
||||||
`Album: <a href="/album/${track.album?.id || ''}" title="View album details">${track.album?.name || 'Unknown Album'}</a> (${track.album?.album_type || 'album'})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trackDurationEl = document.getElementById('track-duration');
|
|
||||||
if (trackDurationEl) {
|
|
||||||
trackDurationEl.textContent =
|
|
||||||
`Duration: ${msToTime(track.duration_ms || 0)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trackExplicitEl = document.getElementById('track-explicit');
|
|
||||||
if (trackExplicitEl) {
|
|
||||||
trackExplicitEl.textContent =
|
|
||||||
track.explicit ? 'Explicit' : 'Clean';
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageUrl = (track.album?.images && track.album.images[0])
|
|
||||||
? track.album.images[0].url
|
|
||||||
: '/static/images/placeholder.jpg';
|
|
||||||
const trackAlbumImageEl = document.getElementById('track-album-image') as HTMLImageElement;
|
|
||||||
if (trackAlbumImageEl) trackAlbumImageEl.src = imageUrl;
|
|
||||||
|
|
||||||
// --- Insert Home Button (if not already present) ---
|
|
||||||
let homeButton = document.getElementById('homeButton') as HTMLButtonElement;
|
|
||||||
if (!homeButton) {
|
|
||||||
homeButton = document.createElement('button');
|
|
||||||
homeButton.id = 'homeButton';
|
|
||||||
homeButton.className = 'home-btn';
|
|
||||||
homeButton.innerHTML = `<img src="/static/images/home.svg" alt="Home" />`;
|
|
||||||
// Prepend the home button into the header.
|
|
||||||
const trackHeader = document.getElementById('track-header');
|
|
||||||
if (trackHeader) {
|
|
||||||
trackHeader.insertBefore(homeButton, trackHeader.firstChild);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
homeButton.addEventListener('click', () => {
|
|
||||||
window.location.href = window.location.origin;
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Move the Download Button from #actions into #track-header ---
|
|
||||||
let downloadBtn = document.getElementById('downloadTrackBtn') as HTMLButtonElement;
|
|
||||||
if (downloadBtn) {
|
|
||||||
// Remove the parent container (#actions) if needed.
|
|
||||||
const actionsContainer = document.getElementById('actions');
|
|
||||||
if (actionsContainer) {
|
|
||||||
actionsContainer.parentNode?.removeChild(actionsContainer);
|
|
||||||
}
|
|
||||||
// Set the inner HTML to use the download.svg icon.
|
|
||||||
downloadBtn.innerHTML = `<img src="/static/images/download.svg" alt="Download">`;
|
|
||||||
// Append the download button to the track header so it appears at the right.
|
|
||||||
const trackHeader = document.getElementById('track-header');
|
|
||||||
if (trackHeader) {
|
|
||||||
trackHeader.appendChild(downloadBtn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (downloadBtn) {
|
|
||||||
downloadBtn.addEventListener('click', () => {
|
|
||||||
downloadBtn.disabled = true;
|
|
||||||
downloadBtn.innerHTML = `<span>Queueing...</span>`;
|
|
||||||
|
|
||||||
const trackUrl = track.external_urls?.spotify || '';
|
|
||||||
if (!trackUrl) {
|
|
||||||
showError('Missing track URL');
|
|
||||||
downloadBtn.disabled = false;
|
|
||||||
downloadBtn.innerHTML = `<img src="/static/images/download.svg" alt="Download">`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const trackIdToDownload = track.id || '';
|
|
||||||
if (!trackIdToDownload) {
|
|
||||||
showError('Missing track ID for download');
|
|
||||||
downloadBtn.disabled = false;
|
|
||||||
downloadBtn.innerHTML = `<img src="/static/images/download.svg" alt="Download">`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the centralized downloadQueue.download method
|
|
||||||
downloadQueue.download(trackIdToDownload, 'track', { name: track.name || 'Unknown Track', artist: track.artists?.[0]?.name })
|
|
||||||
.then(() => {
|
|
||||||
downloadBtn.innerHTML = `<span>Queued!</span>`;
|
|
||||||
// Make the queue visible to show the download
|
|
||||||
downloadQueue.toggleVisibility(true);
|
|
||||||
})
|
|
||||||
.catch((err: any) => {
|
|
||||||
showError('Failed to queue track download: ' + (err?.message || 'Unknown error'));
|
|
||||||
downloadBtn.disabled = false;
|
|
||||||
downloadBtn.innerHTML = `<img src="/static/images/download.svg" alt="Download">`;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reveal the header now that track info is loaded.
|
|
||||||
const trackHeaderEl = document.getElementById('track-header');
|
|
||||||
if (trackHeaderEl) trackHeaderEl.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts milliseconds to minutes:seconds.
|
|
||||||
*/
|
|
||||||
function msToTime(duration: number) {
|
|
||||||
if (!duration || isNaN(duration)) return '0:00';
|
|
||||||
|
|
||||||
const minutes = Math.floor(duration / 60000);
|
|
||||||
const seconds = Math.floor((duration % 60000) / 1000);
|
|
||||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Displays an error message in the UI.
|
|
||||||
*/
|
|
||||||
function showError(message: string) {
|
|
||||||
const errorEl = document.getElementById('error');
|
|
||||||
if (errorEl) {
|
|
||||||
errorEl.textContent = message || 'An error occurred';
|
|
||||||
errorEl.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts the download process by calling the centralized downloadQueue method
|
|
||||||
*/
|
|
||||||
async function startDownload(itemId: string, type: string, item: any) {
|
|
||||||
if (!itemId || !type) {
|
|
||||||
showError('Missing ID or type for download');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use the centralized downloadQueue.download method
|
|
||||||
await downloadQueue.download(itemId, type, item);
|
|
||||||
|
|
||||||
// Make the queue visible after queueing
|
|
||||||
downloadQueue.toggleVisibility(true);
|
|
||||||
} catch (error: any) {
|
|
||||||
showError('Download failed: ' + (error?.message || 'Unknown error'));
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
688
src/js/watch.ts
@@ -1,688 +0,0 @@
|
|||||||
import { downloadQueue } from './queue.js'; // Assuming queue.js is in the same directory
|
|
||||||
|
|
||||||
// Interfaces for API data
|
|
||||||
interface Image {
|
|
||||||
url: string;
|
|
||||||
height?: number;
|
|
||||||
width?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Items from the initial /watch/list API calls ---
|
|
||||||
interface ArtistFromWatchList {
|
|
||||||
spotify_id: string; // Changed from id to spotify_id
|
|
||||||
name: string;
|
|
||||||
images?: Image[];
|
|
||||||
total_albums?: number; // Already provided by /api/artist/watch/list
|
|
||||||
}
|
|
||||||
|
|
||||||
// New interface for artists after initial processing (spotify_id mapped to id)
|
|
||||||
interface ProcessedArtistFromWatchList extends ArtistFromWatchList {
|
|
||||||
id: string; // This is the mapped spotify_id
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WatchedPlaylistOwner { // Kept as is, used by PlaylistFromWatchList
|
|
||||||
display_name?: string;
|
|
||||||
id?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PlaylistFromWatchList {
|
|
||||||
spotify_id: string; // Changed from id to spotify_id
|
|
||||||
name: string;
|
|
||||||
owner?: WatchedPlaylistOwner;
|
|
||||||
images?: Image[]; // Ensure images can be part of this initial fetch
|
|
||||||
total_tracks?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// New interface for playlists after initial processing (spotify_id mapped to id)
|
|
||||||
interface ProcessedPlaylistFromWatchList extends PlaylistFromWatchList {
|
|
||||||
id: string; // This is the mapped spotify_id
|
|
||||||
}
|
|
||||||
// --- End of /watch/list items ---
|
|
||||||
|
|
||||||
|
|
||||||
// --- Responses from /api/{artist|playlist}/info endpoints ---
|
|
||||||
interface AlbumWithImages { // For items in ArtistInfoResponse.items
|
|
||||||
images?: Image[];
|
|
||||||
// Other album properties like name, id etc., are not strictly needed for this specific change
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ArtistInfoResponse {
|
|
||||||
artist_id: string; // Matches key from artist.py
|
|
||||||
artist_name: string; // Matches key from artist.py
|
|
||||||
artist_image_url?: string; // Matches key from artist.py
|
|
||||||
total: number; // This is total_albums, matches key from artist.py
|
|
||||||
artist_external_url?: string; // Matches key from artist.py
|
|
||||||
items?: AlbumWithImages[]; // Add album items to get the first album's image
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlaylistInfoResponse is effectively the Playlist interface from playlist.ts
|
|
||||||
// For clarity, defining it here based on what's needed for the card.
|
|
||||||
interface PlaylistInfoResponse {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
owner: { display_name?: string; id?: string; }; // Matches Playlist.owner
|
|
||||||
images: Image[]; // Matches Playlist.images
|
|
||||||
tracks: { total: number; /* items: PlaylistItem[] - not needed for card */ }; // Matches Playlist.tracks
|
|
||||||
followers?: { total: number; }; // Matches Playlist.followers
|
|
||||||
external_urls?: { spotify?: string }; // Matches Playlist.external_urls
|
|
||||||
}
|
|
||||||
// --- End of /info endpoint responses ---
|
|
||||||
|
|
||||||
|
|
||||||
// --- Final combined data structure for rendering cards ---
|
|
||||||
interface FinalArtistCardItem {
|
|
||||||
itemType: 'artist';
|
|
||||||
id: string; // Spotify ID
|
|
||||||
name: string; // Best available name (from /info or fallback)
|
|
||||||
imageUrl?: string; // Best available image URL (from /info or fallback)
|
|
||||||
total_albums: number;// From /info or fallback
|
|
||||||
external_urls?: { spotify?: string }; // From /info
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FinalPlaylistCardItem {
|
|
||||||
itemType: 'playlist';
|
|
||||||
id: string; // Spotify ID
|
|
||||||
name: string; // Best available name (from /info or fallback)
|
|
||||||
imageUrl?: string; // Best available image URL (from /info or fallback)
|
|
||||||
owner_name?: string; // From /info or fallback
|
|
||||||
total_tracks: number;// From /info or fallback
|
|
||||||
followers_count?: number; // From /info
|
|
||||||
description?: string | null; // From /info, for potential use (e.g., tooltip)
|
|
||||||
external_urls?: { spotify?: string }; // From /info
|
|
||||||
}
|
|
||||||
|
|
||||||
type FinalCardItem = FinalArtistCardItem | FinalPlaylistCardItem;
|
|
||||||
// --- End of final card data structure ---
|
|
||||||
|
|
||||||
// The type for items initially fetched from /watch/list, before detailed processing
|
|
||||||
// Updated to use ProcessedArtistFromWatchList for artists and ProcessedPlaylistFromWatchList for playlists
|
|
||||||
type InitialWatchedItem =
|
|
||||||
(ProcessedArtistFromWatchList & { itemType: 'artist' }) |
|
|
||||||
(ProcessedPlaylistFromWatchList & { itemType: 'playlist' });
|
|
||||||
|
|
||||||
// Interface for a settled promise (fulfilled)
|
|
||||||
interface CustomPromiseFulfilledResult<T> {
|
|
||||||
status: 'fulfilled';
|
|
||||||
value: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interface for a settled promise (rejected)
|
|
||||||
interface CustomPromiseRejectedResult {
|
|
||||||
status: 'rejected';
|
|
||||||
reason: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
type CustomSettledPromiseResult<T> = CustomPromiseFulfilledResult<T> | CustomPromiseRejectedResult;
|
|
||||||
|
|
||||||
// Original WatchedItem type, which will be replaced by FinalCardItem for rendering
|
|
||||||
interface WatchedArtistOriginal {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
images?: Image[];
|
|
||||||
total_albums?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WatchedPlaylistOriginal {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
owner?: WatchedPlaylistOwner;
|
|
||||||
images?: Image[];
|
|
||||||
total_tracks?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
type WatchedItem = (WatchedArtistOriginal & { itemType: 'artist' }) | (WatchedPlaylistOriginal & { itemType: 'playlist' });
|
|
||||||
|
|
||||||
// Added: Interface for global watch config
|
|
||||||
interface GlobalWatchConfig {
|
|
||||||
enabled: boolean;
|
|
||||||
[key: string]: any; // Allow other properties
|
|
||||||
}
|
|
||||||
|
|
||||||
// Added: Helper function to fetch global watch config
|
|
||||||
async function getGlobalWatchConfig(): Promise<GlobalWatchConfig> {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/config/watch');
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error('Failed to fetch global watch config, assuming disabled.');
|
|
||||||
return { enabled: false }; // Default to disabled on error
|
|
||||||
}
|
|
||||||
return await response.json() as GlobalWatchConfig;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching global watch config:', error);
|
|
||||||
return { enabled: false }; // Default to disabled on error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async function() {
|
|
||||||
const watchedItemsContainer = document.getElementById('watchedItemsContainer');
|
|
||||||
const loadingIndicator = document.getElementById('loadingWatchedItems');
|
|
||||||
const emptyStateIndicator = document.getElementById('emptyWatchedItems');
|
|
||||||
const queueIcon = document.getElementById('queueIcon');
|
|
||||||
const checkAllWatchedBtn = document.getElementById('checkAllWatchedBtn') as HTMLButtonElement | null;
|
|
||||||
|
|
||||||
// Fetch global watch config first
|
|
||||||
const globalWatchConfig = await getGlobalWatchConfig();
|
|
||||||
|
|
||||||
if (queueIcon) {
|
|
||||||
queueIcon.addEventListener('click', () => {
|
|
||||||
downloadQueue.toggleVisibility();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checkAllWatchedBtn) {
|
|
||||||
checkAllWatchedBtn.addEventListener('click', async () => {
|
|
||||||
checkAllWatchedBtn.disabled = true;
|
|
||||||
const originalText = checkAllWatchedBtn.innerHTML;
|
|
||||||
checkAllWatchedBtn.innerHTML = '<img src="/static/images/refresh-cw.svg" alt="Refreshing..."> Checking...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const artistCheckPromise = fetch('/api/artist/watch/trigger_check', { method: 'POST' });
|
|
||||||
const playlistCheckPromise = fetch('/api/playlist/watch/trigger_check', { method: 'POST' });
|
|
||||||
|
|
||||||
// Use Promise.allSettled-like behavior to handle both responses
|
|
||||||
const results = await Promise.all([
|
|
||||||
artistCheckPromise.then(async res => ({
|
|
||||||
ok: res.ok,
|
|
||||||
data: await res.json().catch(() => ({ error: 'Invalid JSON response' })),
|
|
||||||
type: 'artist'
|
|
||||||
})).catch(e => ({ ok: false, data: { error: e.message || 'Request failed' }, type: 'artist' })),
|
|
||||||
playlistCheckPromise.then(async res => ({
|
|
||||||
ok: res.ok,
|
|
||||||
data: await res.json().catch(() => ({ error: 'Invalid JSON response' })),
|
|
||||||
type: 'playlist'
|
|
||||||
})).catch(e => ({ ok: false, data: { error: e.message || 'Request failed' }, type: 'playlist' }))
|
|
||||||
]);
|
|
||||||
|
|
||||||
const artistResult = results.find(r => r.type === 'artist');
|
|
||||||
const playlistResult = results.find(r => r.type === 'playlist');
|
|
||||||
|
|
||||||
let successMessages: string[] = [];
|
|
||||||
let errorMessages: string[] = [];
|
|
||||||
|
|
||||||
if (artistResult) {
|
|
||||||
if (artistResult.ok) {
|
|
||||||
successMessages.push(artistResult.data.message || 'Artist check triggered.');
|
|
||||||
} else {
|
|
||||||
errorMessages.push(`Artist check failed: ${artistResult.data.error || 'Unknown error'}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playlistResult) {
|
|
||||||
if (playlistResult.ok) {
|
|
||||||
successMessages.push(playlistResult.data.message || 'Playlist check triggered.');
|
|
||||||
} else {
|
|
||||||
errorMessages.push(`Playlist check failed: ${playlistResult.data.error || 'Unknown error'}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorMessages.length > 0) {
|
|
||||||
showNotification(errorMessages.join(' '), true);
|
|
||||||
if (successMessages.length > 0) { // If some succeeded and some failed
|
|
||||||
// Delay the success message slightly so it doesn't overlap or get missed
|
|
||||||
setTimeout(() => showNotification(successMessages.join(' ')), 1000);
|
|
||||||
}
|
|
||||||
} else if (successMessages.length > 0) {
|
|
||||||
showNotification(successMessages.join(' '));
|
|
||||||
} else {
|
|
||||||
showNotification('Could not determine check status for artists or playlists.', true);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error: any) { // Catch for unexpected issues with Promise.all or setup
|
|
||||||
console.error('Error in checkAllWatchedBtn handler:', error);
|
|
||||||
showNotification(`An unexpected error occurred: ${error.message}`, true);
|
|
||||||
} finally {
|
|
||||||
checkAllWatchedBtn.disabled = false;
|
|
||||||
checkAllWatchedBtn.innerHTML = originalText;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial load is now conditional
|
|
||||||
if (globalWatchConfig.enabled) {
|
|
||||||
if (checkAllWatchedBtn) checkAllWatchedBtn.classList.remove('hidden');
|
|
||||||
loadWatchedItems();
|
|
||||||
} else {
|
|
||||||
// Watch feature is disabled globally
|
|
||||||
showLoading(false);
|
|
||||||
showEmptyState(false);
|
|
||||||
if (checkAllWatchedBtn) checkAllWatchedBtn.classList.add('hidden'); // Hide the button
|
|
||||||
|
|
||||||
if (watchedItemsContainer) {
|
|
||||||
watchedItemsContainer.innerHTML = `
|
|
||||||
<div class="empty-state-container">
|
|
||||||
<img src="/static/images/eye-crossed.svg" alt="Watch Disabled" class="empty-state-icon">
|
|
||||||
<p class="empty-state-message">The Watchlist feature is currently disabled in the application settings.</p>
|
|
||||||
<p class="empty-state-submessage">Please enable it in <a href="/settings" class="settings-link">Settings</a> to use this page.</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
// Ensure the main loading indicator is also hidden if it was shown by default
|
|
||||||
if (loadingIndicator) loadingIndicator.classList.add('hidden');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const MAX_NOTIFICATIONS = 3;
|
|
||||||
|
|
||||||
async function loadWatchedItems() {
|
|
||||||
const watchedItemsContainer = document.getElementById('watchedItemsContainer');
|
|
||||||
const loadingIndicator = document.getElementById('loadingWatchedItems');
|
|
||||||
const emptyStateIndicator = document.getElementById('emptyWatchedItems');
|
|
||||||
|
|
||||||
showLoading(true);
|
|
||||||
showEmptyState(false);
|
|
||||||
if (watchedItemsContainer) watchedItemsContainer.innerHTML = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [artistsResponse, playlistsResponse] = await Promise.all([
|
|
||||||
fetch('/api/artist/watch/list'),
|
|
||||||
fetch('/api/playlist/watch/list')
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!artistsResponse.ok || !playlistsResponse.ok) {
|
|
||||||
throw new Error('Failed to load initial watched items list');
|
|
||||||
}
|
|
||||||
|
|
||||||
const artists: ArtistFromWatchList[] = await artistsResponse.json();
|
|
||||||
const playlists: PlaylistFromWatchList[] = await playlistsResponse.json();
|
|
||||||
|
|
||||||
const initialItems: InitialWatchedItem[] = [
|
|
||||||
...artists.map(artist => ({
|
|
||||||
...artist,
|
|
||||||
id: artist.spotify_id, // Map spotify_id to id for artists
|
|
||||||
itemType: 'artist' as const
|
|
||||||
})),
|
|
||||||
...playlists.map(playlist => ({
|
|
||||||
...playlist,
|
|
||||||
id: playlist.spotify_id, // Map spotify_id to id for playlists
|
|
||||||
itemType: 'playlist' as const
|
|
||||||
}))
|
|
||||||
];
|
|
||||||
|
|
||||||
if (initialItems.length === 0) {
|
|
||||||
showLoading(false);
|
|
||||||
showEmptyState(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch detailed info for each item
|
|
||||||
const detailedItemPromises = initialItems.map(async (initialItem) => {
|
|
||||||
try {
|
|
||||||
if (initialItem.itemType === 'artist') {
|
|
||||||
const infoResponse = await fetch(`/api/artist/info?id=${initialItem.id}`);
|
|
||||||
if (!infoResponse.ok) {
|
|
||||||
console.warn(`Failed to fetch artist info for ${initialItem.name} (ID: ${initialItem.id}): ${infoResponse.status}`);
|
|
||||||
// Fallback to initial data if info fetch fails
|
|
||||||
return {
|
|
||||||
itemType: 'artist',
|
|
||||||
id: initialItem.id,
|
|
||||||
name: initialItem.name,
|
|
||||||
imageUrl: (initialItem as ArtistFromWatchList).images?.[0]?.url, // Cast to access images
|
|
||||||
total_albums: (initialItem as ArtistFromWatchList).total_albums || 0, // Cast to access total_albums
|
|
||||||
} as FinalArtistCardItem;
|
|
||||||
}
|
|
||||||
const info: ArtistInfoResponse = await infoResponse.json();
|
|
||||||
return {
|
|
||||||
itemType: 'artist',
|
|
||||||
id: initialItem.id, // Use the ID from the watch list, as /info might have 'artist_id'
|
|
||||||
name: info.artist_name || initialItem.name, // Prefer info, fallback to initial
|
|
||||||
imageUrl: info.items?.[0]?.images?.[0]?.url || info.artist_image_url || (initialItem as ProcessedArtistFromWatchList).images?.[0]?.url, // Prioritize first album image from items
|
|
||||||
total_albums: info.total, // 'total' from ArtistInfoResponse is total_albums
|
|
||||||
external_urls: { spotify: info.artist_external_url }
|
|
||||||
} as FinalArtistCardItem;
|
|
||||||
} else { // Playlist
|
|
||||||
const infoResponse = await fetch(`/api/playlist/info?id=${initialItem.id}`);
|
|
||||||
if (!infoResponse.ok) {
|
|
||||||
console.warn(`Failed to fetch playlist info for ${initialItem.name} (ID: ${initialItem.id}): ${infoResponse.status}`);
|
|
||||||
// Fallback to initial data if info fetch fails
|
|
||||||
return {
|
|
||||||
itemType: 'playlist',
|
|
||||||
id: initialItem.id,
|
|
||||||
name: initialItem.name,
|
|
||||||
imageUrl: (initialItem as ProcessedPlaylistFromWatchList).images?.[0]?.url, // Cast to access images
|
|
||||||
owner_name: (initialItem as ProcessedPlaylistFromWatchList).owner?.display_name, // Cast to access owner
|
|
||||||
total_tracks: (initialItem as ProcessedPlaylistFromWatchList).total_tracks || 0, // Cast to access total_tracks
|
|
||||||
} as FinalPlaylistCardItem;
|
|
||||||
}
|
|
||||||
const info: PlaylistInfoResponse = await infoResponse.json();
|
|
||||||
return {
|
|
||||||
itemType: 'playlist',
|
|
||||||
id: initialItem.id, // Use ID from watch list
|
|
||||||
name: info.name || initialItem.name, // Prefer info, fallback to initial
|
|
||||||
imageUrl: info.images?.[0]?.url || (initialItem as ProcessedPlaylistFromWatchList).images?.[0]?.url, // Prefer info, fallback to initial (ProcessedPlaylistFromWatchList)
|
|
||||||
owner_name: info.owner?.display_name || (initialItem as ProcessedPlaylistFromWatchList).owner?.display_name, // Prefer info, fallback to initial (ProcessedPlaylistFromWatchList)
|
|
||||||
total_tracks: info.tracks.total, // 'total' from PlaylistInfoResponse.tracks
|
|
||||||
followers_count: info.followers?.total,
|
|
||||||
description: info.description,
|
|
||||||
external_urls: info.external_urls
|
|
||||||
} as FinalPlaylistCardItem;
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error(`Error processing item ${initialItem.name} (ID: ${initialItem.id}):`, e);
|
|
||||||
// Return a fallback structure if processing fails catastrophically
|
|
||||||
return {
|
|
||||||
itemType: initialItem.itemType,
|
|
||||||
id: initialItem.id,
|
|
||||||
name: initialItem.name + " (Error loading details)",
|
|
||||||
imageUrl: initialItem.images?.[0]?.url,
|
|
||||||
// Add minimal common fields for artists and playlists for fallback
|
|
||||||
...(initialItem.itemType === 'artist' ? { total_albums: (initialItem as ProcessedArtistFromWatchList).total_albums || 0 } : {}),
|
|
||||||
...(initialItem.itemType === 'playlist' ? { total_tracks: (initialItem as ProcessedPlaylistFromWatchList).total_tracks || 0 } : {}),
|
|
||||||
} as FinalCardItem; // Cast to avoid TS errors, knowing one of the spreads will match
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Simulating Promise.allSettled behavior for compatibility
|
|
||||||
const settledResults: CustomSettledPromiseResult<FinalCardItem>[] = await Promise.all(
|
|
||||||
detailedItemPromises.map(p =>
|
|
||||||
p.then(value => ({ status: 'fulfilled', value } as CustomPromiseFulfilledResult<FinalCardItem>))
|
|
||||||
.catch(reason => ({ status: 'rejected', reason } as CustomPromiseRejectedResult))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const finalItems: FinalCardItem[] = settledResults
|
|
||||||
.filter((result): result is CustomPromiseFulfilledResult<FinalCardItem> => result.status === 'fulfilled')
|
|
||||||
.map(result => result.value)
|
|
||||||
.filter(item => item !== null) as FinalCardItem[]; // Ensure no nulls from catastrophic failures
|
|
||||||
|
|
||||||
showLoading(false);
|
|
||||||
|
|
||||||
if (finalItems.length === 0) {
|
|
||||||
showEmptyState(true);
|
|
||||||
// Potentially show a different message if initialItems existed but all failed to load details
|
|
||||||
if (initialItems.length > 0 && watchedItemsContainer) {
|
|
||||||
watchedItemsContainer.innerHTML = `<div class="error"><p>Could not load details for any watched items. Please check the console for errors.</p></div>`;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (watchedItemsContainer) {
|
|
||||||
// Clear previous content
|
|
||||||
watchedItemsContainer.innerHTML = '';
|
|
||||||
|
|
||||||
if (finalItems.length > 8) {
|
|
||||||
const playlistItems = finalItems.filter(item => item.itemType === 'playlist') as FinalPlaylistCardItem[];
|
|
||||||
const artistItems = finalItems.filter(item => item.itemType === 'artist') as FinalArtistCardItem[];
|
|
||||||
|
|
||||||
// Create and append Playlist section
|
|
||||||
if (playlistItems.length > 0) {
|
|
||||||
const playlistSection = document.createElement('div');
|
|
||||||
playlistSection.className = 'watched-items-group';
|
|
||||||
const playlistHeader = document.createElement('h2');
|
|
||||||
playlistHeader.className = 'watched-group-header';
|
|
||||||
playlistHeader.textContent = 'Watched Playlists';
|
|
||||||
playlistSection.appendChild(playlistHeader);
|
|
||||||
const playlistGrid = document.createElement('div');
|
|
||||||
playlistGrid.className = 'results-grid'; // Use existing grid style
|
|
||||||
playlistItems.forEach(item => {
|
|
||||||
const cardElement = createWatchedItemCard(item);
|
|
||||||
playlistGrid.appendChild(cardElement);
|
|
||||||
});
|
|
||||||
playlistSection.appendChild(playlistGrid);
|
|
||||||
watchedItemsContainer.appendChild(playlistSection);
|
|
||||||
} else {
|
|
||||||
const noPlaylistsMessage = document.createElement('p');
|
|
||||||
noPlaylistsMessage.textContent = 'No watched playlists.';
|
|
||||||
noPlaylistsMessage.className = 'empty-group-message';
|
|
||||||
// Optionally add a header for consistency even if empty
|
|
||||||
const playlistHeader = document.createElement('h2');
|
|
||||||
playlistHeader.className = 'watched-group-header';
|
|
||||||
playlistHeader.textContent = 'Watched Playlists';
|
|
||||||
watchedItemsContainer.appendChild(playlistHeader);
|
|
||||||
watchedItemsContainer.appendChild(noPlaylistsMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create and append Artist section
|
|
||||||
if (artistItems.length > 0) {
|
|
||||||
const artistSection = document.createElement('div');
|
|
||||||
artistSection.className = 'watched-items-group';
|
|
||||||
const artistHeader = document.createElement('h2');
|
|
||||||
artistHeader.className = 'watched-group-header';
|
|
||||||
artistHeader.textContent = 'Watched Artists';
|
|
||||||
artistSection.appendChild(artistHeader);
|
|
||||||
const artistGrid = document.createElement('div');
|
|
||||||
artistGrid.className = 'results-grid'; // Use existing grid style
|
|
||||||
artistItems.forEach(item => {
|
|
||||||
const cardElement = createWatchedItemCard(item);
|
|
||||||
artistGrid.appendChild(cardElement);
|
|
||||||
});
|
|
||||||
artistSection.appendChild(artistGrid);
|
|
||||||
watchedItemsContainer.appendChild(artistSection);
|
|
||||||
} else {
|
|
||||||
const noArtistsMessage = document.createElement('p');
|
|
||||||
noArtistsMessage.textContent = 'No watched artists.';
|
|
||||||
noArtistsMessage.className = 'empty-group-message';
|
|
||||||
// Optionally add a header for consistency even if empty
|
|
||||||
const artistHeader = document.createElement('h2');
|
|
||||||
artistHeader.className = 'watched-group-header';
|
|
||||||
artistHeader.textContent = 'Watched Artists';
|
|
||||||
watchedItemsContainer.appendChild(artistHeader);
|
|
||||||
watchedItemsContainer.appendChild(noArtistsMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
} else { // 8 or fewer items, render them directly
|
|
||||||
finalItems.forEach(item => {
|
|
||||||
const cardElement = createWatchedItemCard(item);
|
|
||||||
watchedItemsContainer.appendChild(cardElement);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error loading watched items:', error);
|
|
||||||
showLoading(false);
|
|
||||||
if (watchedItemsContainer) {
|
|
||||||
watchedItemsContainer.innerHTML = `<div class="error"><p>Error loading watched items: ${error.message}</p></div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createWatchedItemCard(item: FinalCardItem): HTMLDivElement {
|
|
||||||
const cardElement = document.createElement('div');
|
|
||||||
cardElement.className = 'watched-item-card';
|
|
||||||
cardElement.dataset.itemId = item.id;
|
|
||||||
cardElement.dataset.itemType = item.itemType;
|
|
||||||
|
|
||||||
// Check Now button HTML is no longer generated separately here for absolute positioning
|
|
||||||
|
|
||||||
let imageUrl = '/static/images/placeholder.jpg';
|
|
||||||
if (item.imageUrl) {
|
|
||||||
imageUrl = item.imageUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
let detailsHtml = '';
|
|
||||||
let typeBadgeClass = '';
|
|
||||||
let typeName = '';
|
|
||||||
|
|
||||||
if (item.itemType === 'artist') {
|
|
||||||
typeName = 'Artist';
|
|
||||||
typeBadgeClass = 'artist';
|
|
||||||
const artist = item as FinalArtistCardItem;
|
|
||||||
detailsHtml = artist.total_albums !== undefined ? `<span>${artist.total_albums} albums</span>` : '';
|
|
||||||
} else if (item.itemType === 'playlist') {
|
|
||||||
typeName = 'Playlist';
|
|
||||||
typeBadgeClass = 'playlist';
|
|
||||||
const playlist = item as FinalPlaylistCardItem;
|
|
||||||
detailsHtml = playlist.owner_name ? `<span>By: ${playlist.owner_name}</span>` : '';
|
|
||||||
detailsHtml += playlist.total_tracks !== undefined ? `<span> • ${playlist.total_tracks} tracks</span>` : '';
|
|
||||||
if (playlist.followers_count !== undefined) {
|
|
||||||
detailsHtml += `<span> • ${playlist.followers_count} followers</span>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cardElement.innerHTML = `
|
|
||||||
<div class="item-art-wrapper">
|
|
||||||
<img class="item-art" src="${imageUrl}" alt="${item.name}" onerror="handleImageError(this)">
|
|
||||||
</div>
|
|
||||||
<div class="item-name">${item.name}</div>
|
|
||||||
<div class="item-details">${detailsHtml}</div>
|
|
||||||
<span class="item-type-badge ${typeBadgeClass}">${typeName}</span>
|
|
||||||
<div class="item-actions">
|
|
||||||
<button class="btn-icon unwatch-item-btn" data-id="${item.id}" data-type="${item.itemType}" title="Unwatch">
|
|
||||||
<img src="/static/images/eye-crossed.svg" alt="Unwatch">
|
|
||||||
</button>
|
|
||||||
<button class="btn-icon check-item-now-btn" data-id="${item.id}" data-type="${item.itemType}" title="Check Now">
|
|
||||||
<img src="/static/images/refresh.svg" alt="Check">
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Add click event to navigate to the item's detail page
|
|
||||||
cardElement.addEventListener('click', (e: MouseEvent) => {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
// Don't navigate if any button within the card was clicked
|
|
||||||
if (target.closest('button')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.location.href = `/${item.itemType}/${item.id}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add event listener for the "Check Now" button
|
|
||||||
const checkNowBtn = cardElement.querySelector('.check-item-now-btn') as HTMLButtonElement | null;
|
|
||||||
if (checkNowBtn) {
|
|
||||||
checkNowBtn.addEventListener('click', (e: MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const itemId = checkNowBtn.dataset.id;
|
|
||||||
const itemType = checkNowBtn.dataset.type as 'artist' | 'playlist';
|
|
||||||
if (itemId && itemType) {
|
|
||||||
triggerItemCheck(itemId, itemType, checkNowBtn);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add event listener for the "Unwatch" button
|
|
||||||
const unwatchBtn = cardElement.querySelector('.unwatch-item-btn') as HTMLButtonElement | null;
|
|
||||||
if (unwatchBtn) {
|
|
||||||
unwatchBtn.addEventListener('click', (e: MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const itemId = unwatchBtn.dataset.id;
|
|
||||||
const itemType = unwatchBtn.dataset.type as 'artist' | 'playlist';
|
|
||||||
if (itemId && itemType) {
|
|
||||||
unwatchItem(itemId, itemType, unwatchBtn, cardElement);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return cardElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showLoading(show: boolean) {
|
|
||||||
const loadingIndicator = document.getElementById('loadingWatchedItems');
|
|
||||||
if (loadingIndicator) loadingIndicator.classList.toggle('hidden', !show);
|
|
||||||
}
|
|
||||||
|
|
||||||
function showEmptyState(show: boolean) {
|
|
||||||
const emptyStateIndicator = document.getElementById('emptyWatchedItems');
|
|
||||||
if (emptyStateIndicator) emptyStateIndicator.classList.toggle('hidden', !show);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function unwatchItem(itemId: string, itemType: 'artist' | 'playlist', buttonElement: HTMLButtonElement, cardElement: HTMLElement) {
|
|
||||||
const originalButtonContent = buttonElement.innerHTML;
|
|
||||||
buttonElement.disabled = true;
|
|
||||||
buttonElement.innerHTML = '<img src="/static/images/refresh.svg" class="spin-counter-clockwise" alt="Unwatching...">'; // Assuming a small loader icon
|
|
||||||
|
|
||||||
const endpoint = `/api/${itemType}/watch/${itemId}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(endpoint, { method: 'DELETE' });
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(errorData.error || `Server error: ${response.status}`);
|
|
||||||
}
|
|
||||||
const result = await response.json();
|
|
||||||
showNotification(result.message || `${itemType.charAt(0).toUpperCase() + itemType.slice(1)} unwatched successfully.`);
|
|
||||||
|
|
||||||
cardElement.style.transition = 'opacity 0.5s ease, transform 0.5s ease';
|
|
||||||
cardElement.style.opacity = '0';
|
|
||||||
cardElement.style.transform = 'scale(0.9)';
|
|
||||||
setTimeout(() => {
|
|
||||||
cardElement.remove();
|
|
||||||
const watchedItemsContainer = document.getElementById('watchedItemsContainer');
|
|
||||||
const playlistGroups = document.querySelectorAll('.watched-items-group .results-grid');
|
|
||||||
let totalItemsLeft = 0;
|
|
||||||
|
|
||||||
if (playlistGroups.length > 0) { // Grouped view
|
|
||||||
playlistGroups.forEach(group => {
|
|
||||||
totalItemsLeft += group.childElementCount;
|
|
||||||
});
|
|
||||||
// If a group becomes empty, we might want to remove the group header or show an empty message for that group.
|
|
||||||
// This can be added here if desired.
|
|
||||||
} else if (watchedItemsContainer) { // Non-grouped view
|
|
||||||
totalItemsLeft = watchedItemsContainer.childElementCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (totalItemsLeft === 0) {
|
|
||||||
// If all items are gone (either from groups or directly), reload to show empty state.
|
|
||||||
// This also correctly handles the case where the initial list had <= 8 items.
|
|
||||||
loadWatchedItems();
|
|
||||||
}
|
|
||||||
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(`Error unwatching ${itemType}:`, error);
|
|
||||||
showNotification(`Failed to unwatch: ${error.message}`, true);
|
|
||||||
buttonElement.disabled = false;
|
|
||||||
buttonElement.innerHTML = originalButtonContent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function triggerItemCheck(itemId: string, itemType: 'artist' | 'playlist', buttonElement: HTMLButtonElement) {
|
|
||||||
const originalButtonContent = buttonElement.innerHTML; // Will just be the img
|
|
||||||
buttonElement.disabled = true;
|
|
||||||
// Keep the icon, but we can add a class for spinning or use the same icon.
|
|
||||||
// For simplicity, just using the same icon. Text "Checking..." is removed.
|
|
||||||
buttonElement.innerHTML = '<img src="/static/images/refresh.svg" alt="Checking...">';
|
|
||||||
|
|
||||||
const endpoint = `/api/${itemType}/watch/trigger_check/${itemId}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(endpoint, { method: 'POST' });
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({})); // Handle non-JSON error responses
|
|
||||||
throw new Error(errorData.error || `Server error: ${response.status}`);
|
|
||||||
}
|
|
||||||
const result = await response.json();
|
|
||||||
showNotification(result.message || `Successfully triggered check for ${itemType}.`);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(`Error triggering ${itemType} check:`, error);
|
|
||||||
showNotification(`Failed to trigger check: ${error.message}`, true);
|
|
||||||
} finally {
|
|
||||||
buttonElement.disabled = false;
|
|
||||||
buttonElement.innerHTML = originalButtonContent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to show notifications (can be moved to a shared utility file if used elsewhere)
|
|
||||||
function showNotification(message: string, isError: boolean = false) {
|
|
||||||
const notificationArea = document.getElementById('notificationArea') || createNotificationArea();
|
|
||||||
|
|
||||||
// Limit the number of visible notifications
|
|
||||||
while (notificationArea.childElementCount >= MAX_NOTIFICATIONS) {
|
|
||||||
const oldestNotification = notificationArea.firstChild; // In column-reverse, firstChild is visually the bottom one
|
|
||||||
if (oldestNotification) {
|
|
||||||
oldestNotification.remove();
|
|
||||||
} else {
|
|
||||||
break; // Should not happen if childElementCount > 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const notification = document.createElement('div');
|
|
||||||
notification.className = `notification-toast ${isError ? 'error' : 'success'}`;
|
|
||||||
notification.textContent = message;
|
|
||||||
|
|
||||||
notificationArea.appendChild(notification);
|
|
||||||
|
|
||||||
// Auto-remove after 5 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
notification.classList.add('hide');
|
|
||||||
setTimeout(() => notification.remove(), 500); // Remove from DOM after fade out
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createNotificationArea(): HTMLElement {
|
|
||||||
const area = document.createElement('div');
|
|
||||||
area.id = 'notificationArea';
|
|
||||||
document.body.appendChild(area);
|
|
||||||
return area;
|
|
||||||
}
|
|
||||||
@@ -1,406 +0,0 @@
|
|||||||
/* Base Styles */
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background: linear-gradient(135deg, #121212, #1e1e1e);
|
|
||||||
color: #ffffff;
|
|
||||||
min-height: 100vh;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Main App Container */
|
|
||||||
#app {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Album Header */
|
|
||||||
#album-header {
|
|
||||||
display: flex;
|
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
align-items: center;
|
|
||||||
padding-bottom: 1.5rem;
|
|
||||||
border-bottom: 1px solid #2a2a2a;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
#album-image {
|
|
||||||
width: 200px;
|
|
||||||
height: 200px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
|
||||||
#album-image:hover {
|
|
||||||
transform: scale(1.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
#album-info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#album-name {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
background: linear-gradient(90deg, #1db954, #17a44b);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
#album-artist,
|
|
||||||
#album-stats {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
color: #b3b3b3;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#album-copyright {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: #b3b3b3;
|
|
||||||
opacity: 0.8;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tracks Container */
|
|
||||||
#tracks-container {
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#tracks-container h2 {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
border-bottom: 1px solid #2a2a2a;
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tracks List */
|
|
||||||
#tracks-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Individual Track Styling */
|
|
||||||
.track {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 40px 1fr auto auto;
|
|
||||||
align-items: center;
|
|
||||||
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-color: var(--color-surface-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-number {
|
|
||||||
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;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-name {
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-artist {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-duration {
|
|
||||||
color: var(--color-text-tertiary);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin-right: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading and Error States */
|
|
||||||
.loading,
|
|
||||||
.error {
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 1rem;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
color: #c0392b;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Utility Classes */
|
|
||||||
.hidden {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Unified Download Button Base Style */
|
|
||||||
.download-btn {
|
|
||||||
background-color: #1db954;
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.6rem 1.2rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s ease, transform 0.2s ease;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-btn:hover {
|
|
||||||
background-color: #17a44b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-btn:active {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Circular Variant for Compact Areas */
|
|
||||||
.download-btn--circle {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 50%;
|
|
||||||
font-size: 0; /* Hide any text */
|
|
||||||
background-color: #1db954;
|
|
||||||
border: none;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s ease, transform 0.2s ease;
|
|
||||||
margin: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-btn--circle img {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
filter: brightness(0) invert(1);
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-btn--circle:hover {
|
|
||||||
background-color: #17a44b;
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-btn--circle:active {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Home Button Styling */
|
|
||||||
.home-btn {
|
|
||||||
background-color: transparent;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-right: 1rem;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Queue Toggle Button */
|
|
||||||
.queue-toggle {
|
|
||||||
background: #1db954;
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: bold;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
|
||||||
transition: background-color 0.3s ease, transform 0.2s ease;
|
|
||||||
z-index: 1002;
|
|
||||||
/* Remove any fixed positioning by default for mobile; fixed positioning remains for larger screens */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Actions Container for Small Screens */
|
|
||||||
#album-actions {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive Styles */
|
|
||||||
|
|
||||||
/* Medium Devices (Tablets) */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
#album-header {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#album-image {
|
|
||||||
width: 180px;
|
|
||||||
height: 180px;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#album-name {
|
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#album-artist,
|
|
||||||
#album-stats {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track {
|
|
||||||
grid-template-columns: 30px 1fr auto auto;
|
|
||||||
padding: 0.6rem 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-duration {
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Small Devices (Mobile Phones) */
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
#app {
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#album-header {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Center the album cover */
|
|
||||||
#album-image {
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
#album-name {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#album-artist,
|
|
||||||
#album-stats,
|
|
||||||
#album-copyright {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track {
|
|
||||||
grid-template-columns: 30px 1fr auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-info {
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-name, .track-artist {
|
|
||||||
max-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure the actions container lays out buttons properly */
|
|
||||||
#album-actions {
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Remove extra margins from the queue toggle */
|
|
||||||
.queue-toggle {
|
|
||||||
position: static;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Prevent anchor links from appearing all blue */
|
|
||||||
a {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover,
|
|
||||||
a:focus {
|
|
||||||
color: #1db954;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* (Optional) Override for circular download button pseudo-element */
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
@@ -1,637 +0,0 @@
|
|||||||
/* Base Styles */
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: #121212;
|
|
||||||
color: #ffffff;
|
|
||||||
min-height: 100vh;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Main App Container */
|
|
||||||
#app {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Artist Header */
|
|
||||||
#artist-header {
|
|
||||||
display: flex;
|
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
align-items: center;
|
|
||||||
padding: 20px;
|
|
||||||
border-bottom: 1px solid #2a2a2a;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: linear-gradient(135deg, rgba(0,0,0,0.5), transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
#artist-image {
|
|
||||||
width: 200px;
|
|
||||||
height: 200px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#artist-info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#artist-name {
|
|
||||||
font-size: 2rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#artist-stats {
|
|
||||||
font-size: 1rem;
|
|
||||||
color: #b3b3b3;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Albums Container */
|
|
||||||
#albums-container {
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#albums-container h2 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
border-bottom: 1px solid #2a2a2a;
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Album groups layout */
|
|
||||||
.album-groups {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Album group section */
|
|
||||||
.album-group {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-group-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-group-header h3 {
|
|
||||||
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: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
||||||
gap: 1.5rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Album card styling */
|
|
||||||
.album-card {
|
|
||||||
background-color: var(--color-surface);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
overflow: hidden;
|
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
|
||||||
position: relative;
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-card:hover {
|
|
||||||
transform: translateY(-5px);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-cover {
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 1/1;
|
|
||||||
object-fit: cover;
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-info {
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-title {
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-artist {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Track Card (for Albums or Songs) */
|
|
||||||
.track {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 1rem;
|
|
||||||
background: #181818;
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: background 0.3s ease;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.track:hover {
|
|
||||||
background: #2a2a2a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-number {
|
|
||||||
width: 30px;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
text-align: center;
|
|
||||||
margin-right: 1rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-info {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-artist {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: #b3b3b3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-album {
|
|
||||||
max-width: 200px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: #b3b3b3;
|
|
||||||
margin-left: 1rem;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-duration {
|
|
||||||
width: 60px;
|
|
||||||
text-align: right;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: #b3b3b3;
|
|
||||||
margin-left: 1rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading and Error States */
|
|
||||||
.loading,
|
|
||||||
.error {
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 1rem;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
color: #c0392b;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Utility Classes */
|
|
||||||
.hidden {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Unified Download Button Base Style */
|
|
||||||
.download-btn {
|
|
||||||
background-color: #1db954;
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s ease, transform 0.2s ease;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-btn:hover {
|
|
||||||
background-color: #17a44b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-btn:active {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Circular Variant for Compact Areas (e.g. album download buttons) */
|
|
||||||
.download-btn--circle {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 50%;
|
|
||||||
font-size: 0; /* Hide any text */
|
|
||||||
background-color: #1db954;
|
|
||||||
border: none;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s ease, transform 0.2s ease;
|
|
||||||
margin: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-btn--circle img {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
filter: brightness(0) invert(1);
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-btn--circle:hover {
|
|
||||||
background-color: #17a44b;
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-btn--circle:active {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Home Button Styling */
|
|
||||||
.home-btn {
|
|
||||||
background-color: transparent;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-right: 1rem;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Watch Artist Button Styling */
|
|
||||||
.watch-btn {
|
|
||||||
background-color: transparent;
|
|
||||||
color: #ffffff;
|
|
||||||
border: 1px solid #ffffff;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.watch-btn:hover {
|
|
||||||
background-color: #ffffff;
|
|
||||||
color: #121212;
|
|
||||||
border-color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.watch-btn.watching {
|
|
||||||
background-color: #1db954; /* Spotify green for "watching" state */
|
|
||||||
color: #ffffff;
|
|
||||||
border-color: #1db954;
|
|
||||||
}
|
|
||||||
|
|
||||||
.watch-btn.watching:hover {
|
|
||||||
background-color: #17a44b; /* Darker green on hover */
|
|
||||||
border-color: #17a44b;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.watch-btn:active {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Styling for icons within watch and sync buttons */
|
|
||||||
.watch-btn img,
|
|
||||||
.sync-btn img {
|
|
||||||
width: 16px; /* Adjust size as needed */
|
|
||||||
height: 16px; /* Adjust size as needed */
|
|
||||||
margin-right: 8px; /* Space between icon and text */
|
|
||||||
filter: brightness(0) invert(1); /* Make icons white */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive Styles */
|
|
||||||
|
|
||||||
/* Medium Devices (Tablets) */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
#artist-header {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#artist-image {
|
|
||||||
width: 180px;
|
|
||||||
height: 180px;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-album,
|
|
||||||
.track-duration {
|
|
||||||
margin-left: 0;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
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) */
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
#app {
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#artist-name {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track {
|
|
||||||
padding: 0.8rem;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-number {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin-right: 0;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-info {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-album,
|
|
||||||
.track-duration {
|
|
||||||
margin-left: 0;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.albums-list {
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-info {
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-title {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-artist {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Prevent anchor links from appearing blue */
|
|
||||||
a {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover,
|
|
||||||
a:focus {
|
|
||||||
color: #1db954;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toggle Known Status Button for Tracks/Albums */
|
|
||||||
.toggle-known-status-btn {
|
|
||||||
background-color: transparent;
|
|
||||||
border: 1px solid var(--color-text-secondary);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
padding: 5px;
|
|
||||||
border-radius: 50%; /* Make it circular */
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 30px; /* Fixed size */
|
|
||||||
height: 30px; /* Fixed size */
|
|
||||||
transition: background-color 0.2s, border-color 0.2s, color 0.2s, opacity 0.2s; /* Added opacity */
|
|
||||||
/* opacity: 0; Initially hidden, JS will make it visible if artist is watched via persistent-album-action-btn */
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-known-status-btn img {
|
|
||||||
width: 16px; /* Adjust icon size */
|
|
||||||
height: 16px;
|
|
||||||
filter: brightness(0) invert(1); /* Make icon white consistently */
|
|
||||||
margin: 0; /* Ensure no accidental margin for centering */
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-known-status-btn:hover {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
background-color: rgba(var(--color-primary-rgb), 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-known-status-btn[data-status="known"] {
|
|
||||||
/* Optional: specific styles if it's already known, e.g., a slightly different border */
|
|
||||||
border-color: var(--color-success); /* Green border for known items */
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-known-status-btn[data-status="missing"] {
|
|
||||||
/* Optional: specific styles if it's missing, e.g., a warning color */
|
|
||||||
border-color: var(--color-warning); /* Orange border for missing items */
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-known-status-btn:active {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure album download button also fits well within actions container */
|
|
||||||
.album-actions-container .album-download-btn {
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
padding: 5px; /* Ensure padding doesn't make it too big */
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-actions-container .album-download-btn img {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Album actions container */
|
|
||||||
.album-actions-container {
|
|
||||||
/* position: absolute; */ /* No longer needed if buttons are positioned individually */
|
|
||||||
/* bottom: 8px; */
|
|
||||||
/* right: 8px; */
|
|
||||||
/* display: flex; */
|
|
||||||
/* gap: 8px; */
|
|
||||||
/* background-color: rgba(0, 0, 0, 0.6); */
|
|
||||||
/* padding: 5px; */
|
|
||||||
/* border-radius: var(--radius-sm); */
|
|
||||||
/* opacity: 0; */ /* Ensure it doesn't hide buttons if it still wraps them elsewhere */
|
|
||||||
/* transition: opacity 0.2s ease-in-out; */
|
|
||||||
display: none; /* Hide this container if it solely relied on hover and now buttons are persistent */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* .album-card:hover .album-actions-container { */
|
|
||||||
/* opacity: 1; */ /* Remove this hover effect */
|
|
||||||
/* } */
|
|
||||||
|
|
||||||
/* Album card actions container - for persistent buttons at the bottom */
|
|
||||||
.album-card-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between; /* Pushes children to ends */
|
|
||||||
align-items: center;
|
|
||||||
padding: 8px; /* Spacing around the buttons */
|
|
||||||
border-top: 1px solid var(--color-surface-darker, #2a2a2a); /* Separator line */
|
|
||||||
/* Ensure it takes up full width of the card if not already */
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Persistent action button (e.g., toggle known/missing) on album card - BOTTOM-LEFT */
|
|
||||||
.persistent-album-action-btn {
|
|
||||||
/* position: absolute; */ /* No longer absolute */
|
|
||||||
/* bottom: 8px; */
|
|
||||||
/* left: 8px; */
|
|
||||||
/* z-index: 2; */
|
|
||||||
opacity: 1; /* Ensure it is visible */
|
|
||||||
/* Specific margin if needed, but flexbox space-between should handle it */
|
|
||||||
margin: 0; /* Reset any previous margins */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Persistent download button on album card - BOTTOM-RIGHT */
|
|
||||||
.persistent-download-btn {
|
|
||||||
/* position: absolute; */ /* No longer absolute */
|
|
||||||
/* bottom: 8px; */
|
|
||||||
/* right: 8px; */
|
|
||||||
/* z-index: 2; */
|
|
||||||
opacity: 1; /* Ensure it is visible */
|
|
||||||
/* Specific margin if needed, but flexbox space-between should handle it */
|
|
||||||
margin: 0; /* Reset any previous margins */
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-cover.album-missing-in-db {
|
|
||||||
border: 3px dashed var(--color-warning); /* Example: orange dashed border */
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* NEW STYLES FOR BUTTON STATES */
|
|
||||||
.persistent-album-action-btn.status-missing {
|
|
||||||
background-color: #d9534f; /* Bootstrap's btn-danger red */
|
|
||||||
border-color: #d43f3a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.persistent-album-action-btn.status-missing:hover {
|
|
||||||
background-color: #c9302c;
|
|
||||||
border-color: #ac2925;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure icon is white on colored background */
|
|
||||||
.persistent-album-action-btn.status-missing img {
|
|
||||||
filter: brightness(0) invert(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.persistent-album-action-btn.status-known {
|
|
||||||
background-color: #5cb85c; /* Bootstrap's btn-success green */
|
|
||||||
border-color: #4cae4c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.persistent-album-action-btn.status-known:hover {
|
|
||||||
background-color: #449d44;
|
|
||||||
border-color: #398439;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure icon is white on colored background */
|
|
||||||
.persistent-album-action-btn.status-known img {
|
|
||||||
filter: brightness(0) invert(1);
|
|
||||||
}
|
|
||||||
/* END OF NEW STYLES */
|
|
||||||
|
|
||||||
/* Spinning Icon Animation */
|
|
||||||
@keyframes spin {
|
|
||||||
0% { transform: rotate(0deg); }
|
|
||||||
100% { transform: rotate(-360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-spin {
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
body {
|
|
||||||
font-family: sans-serif;
|
|
||||||
margin: 0;
|
|
||||||
background-color: #121212;
|
|
||||||
color: #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
padding: 20px;
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
color: #1DB954; /* Spotify Green */
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin-top: 20px;
|
|
||||||
background-color: #1e1e1e;
|
|
||||||
}
|
|
||||||
|
|
||||||
th, td {
|
|
||||||
border: 1px solid #333;
|
|
||||||
padding: 10px 12px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
background-color: #282828;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
tr:nth-child(even) {
|
|
||||||
background-color: #222;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination {
|
|
||||||
margin-top: 20px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination button, .pagination select {
|
|
||||||
padding: 8px 12px;
|
|
||||||
margin: 0 5px;
|
|
||||||
background-color: #1DB954;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination button:disabled {
|
|
||||||
background-color: #555;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filters {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
display: flex;
|
|
||||||
gap: 15px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filters label, .filters select, .filters input {
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filters select, .filters input {
|
|
||||||
padding: 8px;
|
|
||||||
background-color: #282828;
|
|
||||||
color: #e0e0e0;
|
|
||||||
border: 1px solid #333;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-COMPLETED { color: #1DB954; font-weight: bold; }
|
|
||||||
.status-ERROR { color: #FF4136; font-weight: bold; }
|
|
||||||
.status-CANCELLED { color: #AAAAAA; }
|
|
||||||
|
|
||||||
.error-message-toggle {
|
|
||||||
cursor: pointer;
|
|
||||||
color: #FF4136; /* Red for error indicator */
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-details {
|
|
||||||
display: none; /* Hidden by default */
|
|
||||||
white-space: pre-wrap; /* Preserve formatting */
|
|
||||||
background-color: #303030;
|
|
||||||
padding: 5px;
|
|
||||||
margin-top: 5px;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Styling for the Details icon button in the table */
|
|
||||||
.details-btn {
|
|
||||||
background-color: transparent; /* Or a subtle color like #282828 */
|
|
||||||
border: none;
|
|
||||||
border-radius: 50%; /* Make it circular */
|
|
||||||
padding: 5px; /* Adjust padding to control size */
|
|
||||||
cursor: pointer;
|
|
||||||
display: inline-flex; /* Important for aligning the image */
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.details-btn img {
|
|
||||||
width: 16px; /* Icon size */
|
|
||||||
height: 16px;
|
|
||||||
filter: invert(1); /* Make icon white if it's dark, adjust if needed */
|
|
||||||
}
|
|
||||||
|
|
||||||
.details-btn:hover {
|
|
||||||
background-color: #333; /* Darker on hover */
|
|
||||||
}
|
|
||||||
@@ -1,530 +0,0 @@
|
|||||||
/* 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;
|
|
||||||
/* Adding accent green if not present, or ensuring it is */
|
|
||||||
--color-accent-green: #22c55e; /* Example: A Tailwind-like green */
|
|
||||||
--color-accent-green-dark: #16a34a; /* Darker shade for hover */
|
|
||||||
|
|
||||||
/* 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* General styles for floating action buttons (FABs) */
|
|
||||||
.floating-icon {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 1000; /* Base z-index, can be overridden */
|
|
||||||
border-radius: 50%;
|
|
||||||
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 48px; /* Standard size */
|
|
||||||
height: 48px; /* Standard size */
|
|
||||||
background-color: #282828; /* Dark background */
|
|
||||||
transition: background-color 0.3s ease, transform 0.2s ease;
|
|
||||||
text-decoration: none !important; /* Ensure no underline for <a> tags */
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-icon:hover {
|
|
||||||
background-color: #333; /* Slightly lighter on hover */
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-icon:active {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-icon img {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
filter: invert(1); /* White icon */
|
|
||||||
margin: 0; /* Reset any margin if inherited */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Styles for buttons that are specifically floating icons (like home button when it's a FAB) */
|
|
||||||
/* This ensures that if a .home-btn also has .floating-icon, it gets the correct FAB styling. */
|
|
||||||
.home-btn.floating-icon,
|
|
||||||
.settings-icon.floating-icon, /* If settings button is an <a> or <button> with this class */
|
|
||||||
.back-button.floating-icon, /* If back button is an <a> or <button> with this class */
|
|
||||||
.history-nav-btn.floating-icon, /* If history button is an <a> or <button> with this class */
|
|
||||||
.queue-icon.floating-icon, /* If queue button is an <a> or <button> with this class */
|
|
||||||
.watch-nav-btn.floating-icon { /* If watch button is an <a> or <button> with this class */
|
|
||||||
/* Specific overrides if needed, but mostly inherits from .floating-icon */
|
|
||||||
/* For example, if a specific button needs a different background */
|
|
||||||
/* background-color: var(--color-primary); */ /* Example if some should use primary color */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Add styles for explicit content filter */
|
|
||||||
.explicit-filter-placeholder {
|
|
||||||
background-color: #2a2a2a;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 2rem;
|
|
||||||
margin: 1rem 0;
|
|
||||||
text-align: center;
|
|
||||||
color: #f5f5f5;
|
|
||||||
border: 1px solid #444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.explicit-filter-placeholder h2 {
|
|
||||||
color: #ff5555;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-filtered {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-name.explicit-filtered {
|
|
||||||
color: #ff5555;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Add styles for disabled download buttons */
|
|
||||||
.download-btn--disabled {
|
|
||||||
background-color: #666;
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-btn--disabled:hover {
|
|
||||||
background-color: #666;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Add styles for download note in artist view */
|
|
||||||
.download-note {
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-style: italic;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.watchlist-icon {
|
|
||||||
position: fixed;
|
|
||||||
right: 20px;
|
|
||||||
bottom: 90px; /* Positioned above the queue icon */
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive adjustments for floating icons */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.floating-icon {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
right: 15px;
|
|
||||||
}
|
|
||||||
.settings-icon {
|
|
||||||
bottom: 15px; /* Adjust for smaller screens */
|
|
||||||
}
|
|
||||||
.queue-icon {
|
|
||||||
bottom: 15px; /* Adjust for smaller screens */
|
|
||||||
}
|
|
||||||
.watchlist-icon {
|
|
||||||
bottom: 75px; /* Adjust for smaller screens, above queue icon */
|
|
||||||
}
|
|
||||||
.home-btn.floating-icon { /* Specific for home button if it's also floating */
|
|
||||||
left: 15px;
|
|
||||||
bottom: 15px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure images inside btn-icon are sized correctly */
|
|
||||||
.btn-icon img {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
filter: brightness(0) invert(1);
|
|
||||||
}
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
/* ICON STYLES */
|
|
||||||
.settings-icon img,
|
|
||||||
#queueIcon img {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
vertical-align: middle;
|
|
||||||
filter: invert(1);
|
|
||||||
transition: opacity 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-icon:hover img,
|
|
||||||
#queueIcon:hover img {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
#queueIcon {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
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 {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
vertical-align: middle;
|
|
||||||
margin-right: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-chevron {
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-btn .type-icon {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
margin-right: 0.3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Container for Title and Buttons */
|
|
||||||
.title-and-view {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding-right: 1rem; /* Extra right padding so buttons don't touch the edge */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Container for the buttons next to the title */
|
|
||||||
.title-buttons {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Small Download Button Styles */
|
|
||||||
.download-btn-small {
|
|
||||||
background-color: #1db954; /* White background */
|
|
||||||
border: none;
|
|
||||||
border-radius: 50%; /* Circular shape */
|
|
||||||
padding: 6px; /* Adjust padding for desired size */
|
|
||||||
cursor: pointer;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: background-color 0.3s ease, transform 0.2s ease;
|
|
||||||
margin-left: 8px; /* Space between adjacent buttons */
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-btn-small img {
|
|
||||||
width: 20px; /* Slightly bigger icon */
|
|
||||||
height: 20px;
|
|
||||||
filter: brightness(0) invert(1); /* Makes the icon white */
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-btn-small:hover {
|
|
||||||
background-color: #1db954b4; /* Light gray on hover */
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* View Button Styles (unchanged) */
|
|
||||||
.view-btn {
|
|
||||||
background-color: #1db954;
|
|
||||||
border: none;
|
|
||||||
border-radius: 50%;
|
|
||||||
padding: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: background-color 0.3s ease, transform 0.2s ease;
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-btn img {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
filter: brightness(0) invert(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-btn:hover {
|
|
||||||
background-color: #1db954b0;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile Compatibility Tweaks */
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.view-btn,
|
|
||||||
.download-btn-small {
|
|
||||||
padding: 6px 10px;
|
|
||||||
font-size: 13px;
|
|
||||||
margin: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Mobile compatibility tweaks */
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.view-btn {
|
|
||||||
padding: 6px 10px; /* Slightly larger padding on mobile for easier tap targets */
|
|
||||||
font-size: 13px; /* Ensure readability on smaller screens */
|
|
||||||
margin: 4px; /* Reduce margins to better fit mobile layouts */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Positioning for floating action buttons */
|
|
||||||
/* Base .floating-icon style is now in base.css */
|
|
||||||
|
|
||||||
/* Left-aligned buttons (Home, Settings, Back, History) */
|
|
||||||
.home-btn, .settings-icon, .back-button, .history-nav-btn {
|
|
||||||
left: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-icon { /* Covers config, main */
|
|
||||||
bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-btn { /* Covers album, artist, playlist, track, watch, history */
|
|
||||||
bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-button { /* Specific to config page */
|
|
||||||
bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* New History button specific positioning - above other left buttons */
|
|
||||||
.history-nav-btn {
|
|
||||||
bottom: 80px; /* Positioned 60px above the buttons at 20px (48px button height + 12px margin) */
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Right-aligned buttons (Queue, Watch) */
|
|
||||||
.queue-icon, .watch-nav-btn {
|
|
||||||
right: 20px;
|
|
||||||
z-index: 1002; /* Ensure these are above the sidebar (z-index: 1001) and other FABs (z-index: 1000) */
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-icon {
|
|
||||||
bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Watch button specific positioning - above Queue */
|
|
||||||
.watch-nav-btn {
|
|
||||||
bottom: 80px; /* Positioned 60px above the queue button (48px button height + 12px margin) */
|
|
||||||
}
|
|
||||||
@@ -1,338 +0,0 @@
|
|||||||
/* GENERAL STYLING & UTILITIES */
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
/* Use a subtle dark gradient for a modern feel */
|
|
||||||
background: linear-gradient(135deg, #121212, #1e1e1e);
|
|
||||||
color: #ffffff;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Main container for page content */
|
|
||||||
.container {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* LOADING & ERROR STATES */
|
|
||||||
.loading,
|
|
||||||
.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: #fff;
|
|
||||||
background-color: rgba(192, 57, 43, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
background: rgba(18, 18, 18, 0.95);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
padding: 20px 0;
|
|
||||||
z-index: 100;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input-container {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input {
|
|
||||||
flex: 1;
|
|
||||||
padding: 12px 20px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 25px;
|
|
||||||
background: var(--color-surface);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-size: 16px;
|
|
||||||
outline: none;
|
|
||||||
transition: background-color 0.3s ease, box-shadow 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input:focus {
|
|
||||||
background: var(--color-surface-hover);
|
|
||||||
box-shadow: 0 0 0 2px rgba(29, 185, 84, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-type {
|
|
||||||
padding: 12px 15px;
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: none;
|
|
||||||
border-radius: 25px;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.3s ease;
|
|
||||||
min-width: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-type:hover,
|
|
||||||
.search-type:focus {
|
|
||||||
background: var(--color-surface-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-button {
|
|
||||||
padding: 12px 25px;
|
|
||||||
background-color: var(--color-primary);
|
|
||||||
border: none;
|
|
||||||
border-radius: 25px;
|
|
||||||
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: var(--color-primary-hover);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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: 20px;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Result card style */
|
|
||||||
.result-card {
|
|
||||||
background: var(--color-surface);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-card:hover {
|
|
||||||
transform: translateY(-5px);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Album art styling */
|
|
||||||
.album-art-wrapper {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-art-wrapper::before {
|
|
||||||
content: "";
|
|
||||||
display: block;
|
|
||||||
padding-top: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-art {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-card:hover .album-art {
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Track title and details */
|
|
||||||
.track-title {
|
|
||||||
padding: 1rem 1rem 0.5rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-artist {
|
|
||||||
padding: 0 1rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
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: var(--color-text-tertiary);
|
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Download button within result cards */
|
|
||||||
.download-btn {
|
|
||||||
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 */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile responsiveness */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.search-header {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
padding: 15px 0;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input-container {
|
|
||||||
flex: 1 1 100%;
|
|
||||||
order: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-button {
|
|
||||||
order: 2;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.results-grid {
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
|
||||||
gap: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,562 +0,0 @@
|
|||||||
/* Base Styles */
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background: linear-gradient(135deg, #121212, #1e1e1e);
|
|
||||||
color: #ffffff;
|
|
||||||
min-height: 100vh;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Main App Container */
|
|
||||||
#app {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Playlist Header */
|
|
||||||
#playlist-header {
|
|
||||||
display: flex;
|
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
align-items: center;
|
|
||||||
padding-bottom: 1.5rem;
|
|
||||||
border-bottom: 1px solid #2a2a2a;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
#playlist-image {
|
|
||||||
width: 200px;
|
|
||||||
height: 200px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
|
||||||
#playlist-image:hover {
|
|
||||||
transform: scale(1.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
#playlist-info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#playlist-name {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
background: linear-gradient(90deg, #1db954, #17a44b);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
#playlist-owner,
|
|
||||||
#playlist-stats,
|
|
||||||
#playlist-description {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
color: #b3b3b3;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tracks Container */
|
|
||||||
#tracks-container {
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#tracks-container h2 {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
border-bottom: 1px solid #2a2a2a;
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tracks List */
|
|
||||||
#tracks-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Individual Track Styling */
|
|
||||||
.track {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 1rem;
|
|
||||||
background: #181818;
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: background 0.3s ease;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track:hover {
|
|
||||||
background: #2a2a2a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-number {
|
|
||||||
width: 30px;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
text-align: center;
|
|
||||||
margin-right: 1rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-info {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-artist {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: #b3b3b3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* When displaying track album info on the side */
|
|
||||||
.track-album {
|
|
||||||
max-width: 200px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: #b3b3b3;
|
|
||||||
margin-left: 1rem;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-duration {
|
|
||||||
width: 60px;
|
|
||||||
text-align: right;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: #b3b3b3;
|
|
||||||
margin-left: 1rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading and Error States */
|
|
||||||
.loading,
|
|
||||||
.error {
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 1rem;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
color: #c0392b;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Utility Classes */
|
|
||||||
.hidden {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Unified Download Button Base Style */
|
|
||||||
.download-btn {
|
|
||||||
background-color: #1db954;
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.6rem 1.2rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s ease, transform 0.2s ease;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Style for icons within download buttons */
|
|
||||||
.download-btn img {
|
|
||||||
margin-right: 0.5rem; /* Space between icon and text */
|
|
||||||
width: 20px; /* Icon width */
|
|
||||||
height: 20px; /* Icon height */
|
|
||||||
vertical-align: middle; /* Align icon with text */
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-btn:hover {
|
|
||||||
background-color: #17a44b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-btn:active {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Circular Variant for Compact Areas (e.g., in a queue list) */
|
|
||||||
.download-btn--circle {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 50%;
|
|
||||||
font-size: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-btn--circle::before {
|
|
||||||
content: "↓";
|
|
||||||
font-size: 16px;
|
|
||||||
color: #fff;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Icon next to text */
|
|
||||||
.download-btn .btn-icon {
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Home Button Styling */
|
|
||||||
.home-btn {
|
|
||||||
background-color: transparent;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-right: 1rem;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-btn img {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
filter: invert(1); /* Makes the SVG icon appear white */
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-btn:hover img {
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-btn:active img {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Home Icon (SVG) */
|
|
||||||
.home-icon {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Download Queue Toggle Button */
|
|
||||||
.queue-toggle {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 20px;
|
|
||||||
right: 20px;
|
|
||||||
background: #1db954;
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: bold;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
|
||||||
transition: background-color 0.3s ease, transform 0.2s ease;
|
|
||||||
z-index: 1002;
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-toggle:hover {
|
|
||||||
background: #1ed760;
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-toggle:active {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive Styles */
|
|
||||||
|
|
||||||
/* Medium Devices (Tablets) */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
#playlist-header {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#playlist-image {
|
|
||||||
width: 180px;
|
|
||||||
height: 180px;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-album,
|
|
||||||
.track-duration {
|
|
||||||
margin-left: 0;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Small Devices (Mobile Phones) */
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
#app {
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#playlist-name {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Adjust track layout to vertical & centered */
|
|
||||||
.track {
|
|
||||||
padding: 0.8rem;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-number {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin-right: 0;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-info {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-album,
|
|
||||||
.track-duration {
|
|
||||||
margin-left: 0;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* Prevent anchor links from appearing all blue */
|
|
||||||
a {
|
|
||||||
color: inherit; /* Inherit color from the parent */
|
|
||||||
text-decoration: none; /* Remove default underline */
|
|
||||||
transition: color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover,
|
|
||||||
a:focus {
|
|
||||||
color: #1db954; /* Change to a themed green on hover/focus */
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Override for the circular download button variant */
|
|
||||||
.download-btn--circle {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 50%;
|
|
||||||
font-size: 0; /* Hide any text */
|
|
||||||
background-color: #1db954; /* Use the same green as the base button */
|
|
||||||
border: none;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s ease, transform 0.2s ease;
|
|
||||||
margin: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Remove the default pseudo-element that inserts an arrow */
|
|
||||||
.download-btn--circle::before {
|
|
||||||
content: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Style the image inside the circular download button */
|
|
||||||
.download-btn--circle img {
|
|
||||||
width: 20px; /* Control icon size */
|
|
||||||
height: 20px;
|
|
||||||
filter: brightness(0) invert(1); /* Ensure the icon appears white */
|
|
||||||
display: block;
|
|
||||||
margin: 0; /* Explicitly remove any margin */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hover and active states for the circular download button */
|
|
||||||
.download-btn--circle:hover {
|
|
||||||
background-color: #17a44b;
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Notification Styling */
|
|
||||||
.notification {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 20px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
background-color: #333;
|
|
||||||
color: #fff;
|
|
||||||
padding: 10px 20px;
|
|
||||||
border-radius: 5px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
|
||||||
z-index: 1005; /* Ensure it's above most other elements */
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.5s ease-in-out;
|
|
||||||
animation: fadeInOut 3s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeInOut {
|
|
||||||
0% { opacity: 0; }
|
|
||||||
10% { opacity: 1; }
|
|
||||||
90% { opacity: 1; }
|
|
||||||
100% { opacity: 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Watch and Sync Button Specific Styles */
|
|
||||||
.watch-btn {
|
|
||||||
background-color: #535353; /* A neutral dark gray */
|
|
||||||
}
|
|
||||||
|
|
||||||
.watch-btn:hover {
|
|
||||||
background-color: #6f6f6f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sync-btn {
|
|
||||||
background-color: #28a745; /* A distinct green for sync */
|
|
||||||
}
|
|
||||||
|
|
||||||
.sync-btn:hover {
|
|
||||||
background-color: #218838;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sync-btn.hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toggle Known Status Button for Tracks/Albums */
|
|
||||||
.toggle-known-status-btn {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: none;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s ease, transform 0.2s ease;
|
|
||||||
margin-left: 0.5rem; /* Spacing from other buttons if any */
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-known-status-btn img {
|
|
||||||
width: 18px; /* Adjust icon size as needed */
|
|
||||||
height: 18px;
|
|
||||||
filter: brightness(0) invert(1); /* White icon */
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-known-status-btn[data-status="known"] {
|
|
||||||
background-color: #28a745; /* Green for known/available */
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-known-status-btn[data-status="known"]:hover {
|
|
||||||
background-color: #218838; /* Darker green on hover */
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-known-status-btn[data-status="missing"] {
|
|
||||||
background-color: #dc3545; /* Red for missing */
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-known-status-btn[data-status="missing"]:hover {
|
|
||||||
background-color: #c82333; /* Darker red on hover */
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-known-status-btn:active {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-actions-container {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-left: auto; /* Pushes action buttons to the right */
|
|
||||||
}
|
|
||||||
@@ -1,746 +0,0 @@
|
|||||||
/* ---------------------- */
|
|
||||||
/* DOWNLOAD QUEUE STYLES */
|
|
||||||
/* ---------------------- */
|
|
||||||
|
|
||||||
/* Container for the download queue sidebar */
|
|
||||||
#downloadQueue {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
right: -350px; /* Hidden offscreen by default */
|
|
||||||
width: 350px;
|
|
||||||
height: 100vh;
|
|
||||||
background: #181818;
|
|
||||||
padding: 20px;
|
|
||||||
transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
z-index: 1001;
|
|
||||||
/* Remove overflow-y here to delegate scrolling to the queue items container */
|
|
||||||
box-shadow: -20px 0 30px rgba(0, 0, 0, 0.4);
|
|
||||||
|
|
||||||
/* Added for flex layout */
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* When active, the sidebar slides into view */
|
|
||||||
#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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Queue subtitle with statistics */
|
|
||||||
.queue-subtitle {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 5px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: #b3b3b3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-stat {
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-stat-active {
|
|
||||||
color: #4a90e2;
|
|
||||||
background-color: rgba(74, 144, 226, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-stat-completed {
|
|
||||||
color: #1DB954;
|
|
||||||
background-color: rgba(29, 185, 84, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-stat-error {
|
|
||||||
color: #ff5555;
|
|
||||||
background-color: rgba(255, 85, 85, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Refresh queue button */
|
|
||||||
#refreshQueueBtn {
|
|
||||||
background: #2a2a2a;
|
|
||||||
border: none;
|
|
||||||
color: #fff;
|
|
||||||
padding: 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.3s ease, transform 0.2s ease;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#refreshQueueBtn:hover {
|
|
||||||
background: #333;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
#refreshQueueBtn:active {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
#refreshQueueBtn.refreshing {
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Artist queue message */
|
|
||||||
.queue-artist-message {
|
|
||||||
background: #2a2a2a;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
color: #fff;
|
|
||||||
text-align: center;
|
|
||||||
border-left: 4px solid #4a90e2;
|
|
||||||
animation: pulse 1.5s infinite;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0% { opacity: 0.8; }
|
|
||||||
50% { opacity: 1; }
|
|
||||||
100% { opacity: 0.8; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Cancel all button styling */
|
|
||||||
#cancelAllBtn {
|
|
||||||
background: #8b0000; /* Dark blood red */
|
|
||||||
border: none;
|
|
||||||
color: #fff;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
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: #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 */
|
|
||||||
.close-btn {
|
|
||||||
background: #2a2a2a;
|
|
||||||
border: none;
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: #ffffff;
|
|
||||||
font-size: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
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 */
|
|
||||||
#queueItems {
|
|
||||||
/* 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 */
|
|
||||||
.queue-item {
|
|
||||||
background: #2a2a2a;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
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(-5px);
|
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Title text in a queue item */
|
|
||||||
.queue-item .title {
|
|
||||||
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: 11px;
|
|
||||||
color: #1DB954;
|
|
||||||
text-transform: uppercase;
|
|
||||||
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 */
|
|
||||||
.queue-item .log {
|
|
||||||
font-size: 13px;
|
|
||||||
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,
|
|
||||||
.queue-item.download-success {
|
|
||||||
border-left-color: #1DB954;
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-item--error {
|
|
||||||
border-left-color: #ff5555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-item--processing {
|
|
||||||
border-left-color: #4a90e2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Progress bar for downloads */
|
|
||||||
.status-bar {
|
|
||||||
height: 3px;
|
|
||||||
background: #1DB954;
|
|
||||||
width: 0;
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
margin-top: 8px;
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Overall progress container for albums and playlists */
|
|
||||||
.overall-progress-container {
|
|
||||||
margin-top: 12px;
|
|
||||||
padding-top: 8px;
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
position: relative; /* Positioning context for z-index */
|
|
||||||
z-index: 2; /* Ensure overall progress appears above track progress */
|
|
||||||
}
|
|
||||||
|
|
||||||
.overall-progress-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: #b3b3b3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overall-progress-label {
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overall-progress-count {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1DB954;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overall-progress-bar-container {
|
|
||||||
height: 6px;
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 3px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overall-progress-bar {
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, #4a90e2, #7a67ee); /* Changed to blue-purple gradient */
|
|
||||||
width: 0;
|
|
||||||
border-radius: 3px;
|
|
||||||
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overall-progress-bar.complete {
|
|
||||||
background: #4a90e2; /* Changed to solid blue for completed overall progress */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Track progress bar container */
|
|
||||||
.track-progress-bar-container {
|
|
||||||
height: 4px;
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 2px;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-top: 8px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1; /* Ensure it's below the overall progress */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Track progress bar */
|
|
||||||
.track-progress-bar {
|
|
||||||
height: 100%;
|
|
||||||
background: #1DB954; /* Keep green for track-level progress */
|
|
||||||
width: 0;
|
|
||||||
border-radius: 2px;
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
box-shadow: 0 0 3px rgba(29, 185, 84, 0.5); /* Add subtle glow to differentiate */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Complete state for track progress */
|
|
||||||
/* Real-time progress style */
|
|
||||||
.track-progress-bar.real-time {
|
|
||||||
background: #1DB954; /* Vivid green for real-time progress */
|
|
||||||
background: #1DB954;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pulsing animation for indeterminate progress */
|
|
||||||
.track-progress-bar.progress-pulse {
|
|
||||||
background: linear-gradient(90deg, #1DB954 0%, #2cd267 50%, #1DB954 100%); /* Keep in green family */
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: progress-pulse-slide 1.5s ease infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes progress-pulse-slide {
|
|
||||||
0% { background-position: 0% 50%; }
|
|
||||||
50% { background-position: 100% 50%; }
|
|
||||||
100% { background-position: 0% 50%; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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) */
|
|
||||||
.log--success {
|
|
||||||
color: #1DB954 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log--error {
|
|
||||||
color: #ff5555 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log--warning {
|
|
||||||
color: #ffaa00 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log--info {
|
|
||||||
color: #4a90e2 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loader animations for real-time progress */
|
|
||||||
@keyframes progress-pulse {
|
|
||||||
0% { opacity: 0.5; }
|
|
||||||
50% { opacity: 1; }
|
|
||||||
100% { opacity: 0.5; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-indicator {
|
|
||||||
display: inline-block;
|
|
||||||
margin-left: 8px;
|
|
||||||
animation: progress-pulse 1.5s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading spinner style */
|
|
||||||
.loading-spinner {
|
|
||||||
display: inline-block;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-spinner.small {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-width: 1px;
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Cancel button inside each queue item */
|
|
||||||
.cancel-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 5px;
|
|
||||||
outline: none;
|
|
||||||
margin-top: 10px;
|
|
||||||
/* 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 {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
filter: invert(1);
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancel-btn:hover img {
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancel-btn:active img {
|
|
||||||
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: 15px;
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#queueFooter button {
|
|
||||||
background: #1DB954;
|
|
||||||
border: none;
|
|
||||||
padding: 10px 18px;
|
|
||||||
border-radius: 20px;
|
|
||||||
color: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------- */
|
|
||||||
/* ERROR BUTTONS STYLES */
|
|
||||||
/* -------------------------- */
|
|
||||||
|
|
||||||
/* Container for error action buttons */
|
|
||||||
.error-buttons {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Base styles for error buttons */
|
|
||||||
.error-buttons button {
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hover state for all error buttons */
|
|
||||||
.error-buttons button:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-buttons button:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Specific styles for the Close (X) error button */
|
|
||||||
.close-error-btn {
|
|
||||||
background-color: #333;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-error-btn:hover {
|
|
||||||
background-color: #444;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Specific styles for the Retry button */
|
|
||||||
.retry-btn {
|
|
||||||
background-color: #ff5555;
|
|
||||||
color: #fff;
|
|
||||||
padding: 6px 15px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.retry-btn:hover {
|
|
||||||
background-color: #ff6b6b;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Error state styling */
|
|
||||||
.queue-item.error {
|
|
||||||
border-left: 4px solid #ff5555;
|
|
||||||
background-color: rgba(255, 85, 85, 0.05);
|
|
||||||
transition: none !important; /* Remove all transitions */
|
|
||||||
transform: none !important; /* Prevent any transform */
|
|
||||||
position: relative !important; /* Keep normal positioning */
|
|
||||||
left: 0 !important; /* Prevent any left movement */
|
|
||||||
right: 0 !important; /* Prevent any right movement */
|
|
||||||
top: 0 !important; /* Prevent any top movement */
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-item.error:hover {
|
|
||||||
background-color: rgba(255, 85, 85, 0.1);
|
|
||||||
transform: none !important; /* Force disable any transform */
|
|
||||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2) !important; /* Keep original shadow */
|
|
||||||
position: relative !important; /* Force normal positioning */
|
|
||||||
left: 0 !important; /* Prevent any left movement */
|
|
||||||
right: 0 !important; /* Prevent any right movement */
|
|
||||||
top: 0 !important; /* Prevent any top movement */
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message {
|
|
||||||
color: #ff5555;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------- */
|
|
||||||
/* MOBILE RESPONSIVE ADJUSTMENTS */
|
|
||||||
/* ------------------------------- */
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
/* Make the sidebar full width on mobile */
|
|
||||||
#downloadQueue {
|
|
||||||
width: 100%;
|
|
||||||
right: -100%; /* Off-screen fully */
|
|
||||||
padding: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* When active, the sidebar slides into view from full width */
|
|
||||||
#downloadQueue.active {
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Adjust header and title for smaller screens */
|
|
||||||
.sidebar-header {
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
padding-bottom: 12px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-header h2 {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Reduce the size of the close buttons */
|
|
||||||
.close-btn {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Adjust queue items padding */
|
|
||||||
.queue-item {
|
|
||||||
padding: 12px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure text remains legible on smaller screens */
|
|
||||||
.queue-item .log,
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,360 +0,0 @@
|
|||||||
/* Base Styles */
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background: linear-gradient(135deg, #121212, #1e1e1e);
|
|
||||||
color: #ffffff;
|
|
||||||
min-height: 100vh;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* App Container */
|
|
||||||
#app {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Track Header:
|
|
||||||
We assume an HTML structure like:
|
|
||||||
<div id="track-header">
|
|
||||||
<img id="track-album-image" ... />
|
|
||||||
<div id="track-info">
|
|
||||||
... (track details: name, artist, album, duration, explicit)
|
|
||||||
</div>
|
|
||||||
<!-- Download button will be appended here -->
|
|
||||||
</div>
|
|
||||||
*/
|
|
||||||
#track-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
padding-bottom: 1.5rem;
|
|
||||||
border-bottom: 1px solid #2a2a2a;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Album Image */
|
|
||||||
#track-album-image {
|
|
||||||
width: 200px;
|
|
||||||
height: 200px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
|
||||||
#track-album-image:hover {
|
|
||||||
transform: scale(1.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Track Info */
|
|
||||||
#track-info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
/* For mobile, the text block can wrap if needed */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Track Text Elements */
|
|
||||||
#track-name {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
background: linear-gradient(90deg, #1db954, #17a44b);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
#track-artist,
|
|
||||||
#track-album,
|
|
||||||
#track-duration,
|
|
||||||
#track-explicit {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
color: #b3b3b3;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Download Button */
|
|
||||||
.download-btn {
|
|
||||||
background-color: #1db954;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.6rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s ease, transform 0.2s ease;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Download Button Icon */
|
|
||||||
.download-btn img {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
filter: brightness(0) invert(1);
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hover and Active States */
|
|
||||||
.download-btn:hover {
|
|
||||||
background-color: #17a44b;
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
.download-btn:active {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Home Button Styling */
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading and Error Messages */
|
|
||||||
#loading,
|
|
||||||
#error {
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 1rem;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
#error {
|
|
||||||
color: #c0392b;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Utility class to hide elements */
|
|
||||||
.hidden {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive Styles for Tablets and Smaller Devices */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
#app {
|
|
||||||
padding: 15px;
|
|
||||||
}
|
|
||||||
#track-header {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
#track-album-image {
|
|
||||||
width: 180px;
|
|
||||||
height: 180px;
|
|
||||||
}
|
|
||||||
#track-name {
|
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
#track-artist,
|
|
||||||
#track-album,
|
|
||||||
#track-duration,
|
|
||||||
#track-explicit {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
.download-btn {
|
|
||||||
padding: 0.5rem;
|
|
||||||
margin-top: 0.8rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive Styles for Mobile Phones */
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
#track-album-image {
|
|
||||||
width: 150px;
|
|
||||||
height: 150px;
|
|
||||||
}
|
|
||||||
#track-name {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
}
|
|
||||||
#track-artist,
|
|
||||||
#track-album,
|
|
||||||
#track-duration,
|
|
||||||
#track-explicit {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.download-btn {
|
|
||||||
padding: 0.5rem;
|
|
||||||
margin-top: 0.8rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Prevent anchor links from appearing all blue */
|
|
||||||
a {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color 0.2s ease;
|
|
||||||
}
|
|
||||||
a:hover,
|
|
||||||
a:focus {
|
|
||||||
color: #1db954;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
/* Ensure the header lays out its children with space-between */
|
|
||||||
#track-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
padding-bottom: 1.5rem;
|
|
||||||
border-bottom: 1px solid #2a2a2a;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* (Optional) If you need to style the download button specifically: */
|
|
||||||
.download-btn {
|
|
||||||
background-color: #1db954;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.6rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s ease, transform 0.2s ease;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Style the download button's icon */
|
|
||||||
.download-btn img {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
filter: brightness(0) invert(1);
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hover and active states */
|
|
||||||
.download-btn:hover {
|
|
||||||
background-color: #17a44b;
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
.download-btn:active {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive adjustments remain as before */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
#track-header {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,359 +0,0 @@
|
|||||||
/* static/css/watch/watch.css */
|
|
||||||
|
|
||||||
/* General styles for the watch page, similar to main.css */
|
|
||||||
body {
|
|
||||||
font-family: var(--font-family-sans-serif);
|
|
||||||
background-color: var(--background-color);
|
|
||||||
color: white;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-container {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.watch-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
padding-bottom: 15px;
|
|
||||||
border-bottom: 1px solid var(--border-color-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.watch-header h1 {
|
|
||||||
color: white;
|
|
||||||
font-size: 2em;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.check-all-btn {
|
|
||||||
padding: 10px 15px;
|
|
||||||
font-size: 0.9em;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px; /* Space between icon and text */
|
|
||||||
background-color: var(--color-accent-green); /* Green background */
|
|
||||||
color: white; /* Ensure text is white for contrast */
|
|
||||||
border: none; /* Remove default border */
|
|
||||||
}
|
|
||||||
|
|
||||||
.check-all-btn:hover {
|
|
||||||
background-color: var(--color-accent-green-dark); /* Darker green on hover */
|
|
||||||
}
|
|
||||||
|
|
||||||
.check-all-btn img {
|
|
||||||
width: 18px; /* Slightly larger for header button */
|
|
||||||
height: 18px;
|
|
||||||
filter: brightness(0) invert(1); /* Ensure header icon is white */
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-to-search-btn {
|
|
||||||
padding: 10px 20px;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Styling for the grid of watched items, similar to results-grid */
|
|
||||||
.results-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); /* Responsive grid */
|
|
||||||
gap: 20px;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Individual watched item card styling, inspired by result-card from main.css */
|
|
||||||
.watched-item-card {
|
|
||||||
background-color: var(--color-surface);
|
|
||||||
border-radius: var(--border-radius-medium);
|
|
||||||
padding: 15px;
|
|
||||||
box-shadow: var(--shadow-soft);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
|
||||||
cursor: pointer;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.watched-item-card:hover {
|
|
||||||
transform: translateY(-5px);
|
|
||||||
box-shadow: var(--shadow-medium);
|
|
||||||
border-top: 1px solid var(--border-color-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-art-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
padding-bottom: 100%; /* 1:1 Aspect Ratio */
|
|
||||||
position: relative;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
border-radius: var(--border-radius-soft);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-art {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover; /* Cover the area, cropping if necessary */
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-name {
|
|
||||||
font-size: 1.1em;
|
|
||||||
font-weight: bold;
|
|
||||||
color: white;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2; /* Limit to 2 lines */
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
line-clamp: 2;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
min-height: 2.4em; /* Reserve space for two lines */
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-details {
|
|
||||||
font-size: 0.9em;
|
|
||||||
color: white;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
line-height: 1.4;
|
|
||||||
width: 100%; /* Ensure it takes full width for centering/alignment */
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-details span {
|
|
||||||
display: block; /* Each detail on a new line */
|
|
||||||
margin-bottom: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-type-badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 3px 8px;
|
|
||||||
font-size: 0.75em;
|
|
||||||
font-weight: bold;
|
|
||||||
border-radius: var(--border-radius-small);
|
|
||||||
margin-bottom: 10px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-type-badge.artist {
|
|
||||||
background-color: var(--color-accent-blue-bg);
|
|
||||||
color: var(--color-accent-blue-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-type-badge.playlist {
|
|
||||||
background-color: var(--color-accent-green-bg);
|
|
||||||
color: var(--color-accent-green-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Action buttons (e.g., Go to item, Unwatch) */
|
|
||||||
.item-actions {
|
|
||||||
margin-top: auto;
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding-top: 10px;
|
|
||||||
border-top: 1px solid var(--border-color-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-actions .btn-icon {
|
|
||||||
padding: 0;
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 0;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-actions .check-item-now-btn {
|
|
||||||
background-color: var(--color-accent-green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-actions .check-item-now-btn:hover {
|
|
||||||
background-color: var(--color-accent-green-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-actions .check-item-now-btn img,
|
|
||||||
.item-actions .unwatch-item-btn img {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
filter: brightness(0) invert(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-actions .unwatch-item-btn {
|
|
||||||
background-color: var(--color-error);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-actions .unwatch-item-btn:hover {
|
|
||||||
background-color: #a52a2a;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading and Empty State - reuse from main.css if possible or define here */
|
|
||||||
.loading,
|
|
||||||
.empty-state {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px 20px;
|
|
||||||
color: var(--text-color-muted);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading.hidden,
|
|
||||||
.empty-state.hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-indicator {
|
|
||||||
font-size: 1.2em;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state-content {
|
|
||||||
max-width: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state-icon {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
opacity: 0.7;
|
|
||||||
filter: brightness(0) invert(1); /* Added to make icon white */
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state h2 {
|
|
||||||
font-size: 1.5em;
|
|
||||||
color: white;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state p {
|
|
||||||
font-size: 1em;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure floating icons from base.css are not obscured or mispositioned */
|
|
||||||
/* No specific overrides needed if base.css handles them well */
|
|
||||||
|
|
||||||
/* Responsive adjustments if needed */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.results-grid {
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
|
||||||
}
|
|
||||||
.watch-header h1 {
|
|
||||||
font-size: 1.5em;
|
|
||||||
}
|
|
||||||
.watched-group-header {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.results-grid {
|
|
||||||
grid-template-columns: 1fr; /* Single column on very small screens */
|
|
||||||
}
|
|
||||||
.watched-item-card {
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
.item-name {
|
|
||||||
font-size: 1em;
|
|
||||||
}
|
|
||||||
.item-details {
|
|
||||||
font-size: 0.8em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.watched-items-group {
|
|
||||||
margin-bottom: 2rem; /* Space between groups */
|
|
||||||
}
|
|
||||||
|
|
||||||
.watched-group-header {
|
|
||||||
font-size: 1.8rem;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-group-message {
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
padding: 1rem;
|
|
||||||
text-align: center;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure the main watchedItemsContainer still behaves like a grid if there are few items */
|
|
||||||
#watchedItemsContainer:not(:has(.watched-items-group)) {
|
|
||||||
display: grid;
|
|
||||||
/* Assuming results-grid styles are already defined elsewhere,
|
|
||||||
or copy relevant grid styles here if needed */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Notification Toast Styles */
|
|
||||||
#notificationArea {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 20px;
|
|
||||||
left: 50%; /* Center horizontally */
|
|
||||||
transform: translateX(-50%); /* Adjust for exact centering */
|
|
||||||
z-index: 2000;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column-reverse;
|
|
||||||
gap: 10px;
|
|
||||||
width: auto; /* Allow width to be determined by content */
|
|
||||||
max-width: 90%; /* Prevent it from being too wide on large screens */
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-toast {
|
|
||||||
padding: 12px 20px;
|
|
||||||
border-radius: var(--border-radius-medium);
|
|
||||||
color: white; /* Default text color to white */
|
|
||||||
font-size: 0.9em;
|
|
||||||
box-shadow: var(--shadow-strong);
|
|
||||||
opacity: 1;
|
|
||||||
transition: opacity 0.5s ease, transform 0.5s ease;
|
|
||||||
transform: translateX(0); /* Keep this for the hide animation */
|
|
||||||
text-align: center; /* Center text within the toast */
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-toast.success {
|
|
||||||
background-color: var(--color-success); /* Use existing success color */
|
|
||||||
/* color: var(--color-accent-green-text); REMOVE - use white */
|
|
||||||
/* border: 1px solid var(--color-accent-green-text); REMOVE */
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-toast.error {
|
|
||||||
background-color: var(--color-error); /* Use existing error color */
|
|
||||||
/* color: var(--color-accent-red-text); REMOVE - use white */
|
|
||||||
/* border: 1px solid var(--color-accent-red-text); REMOVE */
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-toast.hide {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(100%); /* Slide down for exit, or could keep translateX if preferred */
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin-counter-clockwise {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: rotate(-360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.spin-counter-clockwise {
|
|
||||||
animation: spin-counter-clockwise 1s linear infinite;
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Album Viewer - Spotizerr</title>
|
|
||||||
<!-- Add the new base.css first -->
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/base.css') }}">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/queue/queue.css') }}">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/icons.css') }}">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/album/album.css') }}" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="app-container">
|
|
||||||
<div id="album-header" class="content-header hidden">
|
|
||||||
<!-- Album Image -->
|
|
||||||
<img id="album-image" class="header-image" alt="Album cover" onerror="this.src='/static/images/placeholder.jpg'">
|
|
||||||
|
|
||||||
<!-- Album Info -->
|
|
||||||
<div id="album-info" class="header-info">
|
|
||||||
<h1 id="album-name" class="header-title"></h1>
|
|
||||||
<p id="album-artist" class="header-subtitle"></p>
|
|
||||||
<p id="album-stats" class="header-subtitle"></p>
|
|
||||||
<p id="album-copyright" class="album-copyright"></p>
|
|
||||||
|
|
||||||
<!-- Download Button -->
|
|
||||||
<div class="header-actions">
|
|
||||||
<button id="downloadAlbumBtn" class="download-btn btn-primary">
|
|
||||||
<img src="{{ url_for('static', filename='images/download.svg') }}" alt="Download">
|
|
||||||
Download Full Album
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="tracks-container" class="hidden">
|
|
||||||
<h2 class="section-title">Tracks</h2>
|
|
||||||
<div id="tracks-list" class="tracks-list"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading and Error states -->
|
|
||||||
<div id="loading" class="loading">
|
|
||||||
<div class="loading-indicator">Loading...</div>
|
|
||||||
</div>
|
|
||||||
<div id="error" class="error hidden">Error loading album</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Fixed floating buttons for home and queue -->
|
|
||||||
<a href="/history" class="btn-icon history-nav-btn floating-icon home-btn" aria-label="Download History" title="Go to Download History">
|
|
||||||
<img src="{{ url_for('static', filename='images/history.svg') }}" alt="History" onerror="handleImageError(this)"/>
|
|
||||||
</a>
|
|
||||||
<button id="homeButton" class="btn-icon home-btn floating-icon settings-icon" aria-label="Return to home">
|
|
||||||
<img src="{{ url_for('static', filename='images/home.svg') }}" alt="Home">
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<a id="watchlistButton" href="/watchlist" class="btn-icon watchlist-icon floating-icon hidden" aria-label="Watchlist" title="Go to Watchlist">
|
|
||||||
<img src="{{ url_for('static', filename='images/binoculars.svg') }}" alt="Watchlist" onerror="handleImageError(this)"/>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<button
|
|
||||||
id="queueIcon"
|
|
||||||
class="btn-icon queue-icon floating-icon"
|
|
||||||
aria-label="Download queue"
|
|
||||||
aria-controls="downloadQueue"
|
|
||||||
aria-expanded="false"
|
|
||||||
>
|
|
||||||
<img src="{{ url_for('static', filename='images/queue.svg') }}" alt="Queue Icon">
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<script type="module" src="{{ url_for('static', filename='js/album.js') }}"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Artist Viewer - Spotizerr</title>
|
|
||||||
<!-- Add the new base.css first -->
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/base.css') }}">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/queue/queue.css') }}">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/icons.css') }}">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/artist/artist.css') }}" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="app-container">
|
|
||||||
<!-- Artist header container -->
|
|
||||||
<div id="artist-header" class="content-header hidden">
|
|
||||||
<!-- Artist Image -->
|
|
||||||
<img id="artist-image" class="header-image" alt="Artist image" onerror="this.src='/static/images/placeholder.jpg'">
|
|
||||||
|
|
||||||
<!-- Artist Info -->
|
|
||||||
<div id="artist-info" class="header-info">
|
|
||||||
<h1 id="artist-name" class="header-title"></h1>
|
|
||||||
<p id="artist-stats" class="header-subtitle"></p>
|
|
||||||
|
|
||||||
<!-- Download Button -->
|
|
||||||
<div class="header-actions">
|
|
||||||
<button id="downloadArtistBtn" class="download-btn btn-primary">
|
|
||||||
<img src="{{ url_for('static', filename='images/download.svg') }}" alt="Download">
|
|
||||||
Download All Discography
|
|
||||||
</button>
|
|
||||||
<button id="watchArtistBtn" class="watch-btn btn-secondary"> <img src="{{ url_for('static', filename='images/eye.svg') }}" alt="Watch"> Watch Artist </button>
|
|
||||||
<button id="syncArtistBtn" class="download-btn sync-btn hidden">
|
|
||||||
<img src="{{ url_for('static', filename='images/refresh.svg') }}" alt="Sync">
|
|
||||||
Sync Watched Artist
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Albums container -->
|
|
||||||
<div id="albums-container" class="hidden">
|
|
||||||
<!-- This container will hold one section per album type -->
|
|
||||||
<div id="album-groups" class="album-groups"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading and Error states -->
|
|
||||||
<div id="loading" class="loading">
|
|
||||||
<div class="loading-indicator">Loading...</div>
|
|
||||||
</div>
|
|
||||||
<div id="error" class="error hidden">Error loading artist info</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Fixed floating buttons for home and queue -->
|
|
||||||
<a href="/history" class="btn-icon history-nav-btn floating-icon home-btn" aria-label="Download History" title="Go to Download History">
|
|
||||||
<img src="{{ url_for('static', filename='images/history.svg') }}" alt="History" onerror="handleImageError(this)"/>
|
|
||||||
</a>
|
|
||||||
<button id="homeButton" class="btn-icon home-btn floating-icon settings-icon" aria-label="Return to home">
|
|
||||||
<img src="{{ url_for('static', filename='images/home.svg') }}" alt="Home">
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<a id="watchlistButton" href="/watchlist" class="btn-icon watchlist-icon floating-icon hidden" aria-label="Watchlist" title="Go to Watchlist">
|
|
||||||
<img src="{{ url_for('static', filename='images/binoculars.svg') }}" alt="Watchlist" onerror="handleImageError(this)"/>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<button
|
|
||||||
id="queueIcon"
|
|
||||||
class="btn-icon queue-icon floating-icon"
|
|
||||||
aria-label="Download queue"
|
|
||||||
aria-controls="downloadQueue"
|
|
||||||
aria-expanded="false"
|
|
||||||
>
|
|
||||||
<img src="{{ url_for('static', filename='images/queue.svg') }}" alt="Queue Icon">
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<script type="module" src="{{ url_for('static', filename='js/artist.js') }}"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,412 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Configuration - Spotizerr</title>
|
|
||||||
<!-- Add the new base.css first -->
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/base.css') }}" />
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/config/config.css') }}" />
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/icons.css') }}">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/queue/queue.css') }}">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="app-container">
|
|
||||||
<div class="config-container">
|
|
||||||
<header class="config-header">
|
|
||||||
<h1 class="header-title">Configuration</h1>
|
|
||||||
<span class="version-text">Set on build</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="account-config card">
|
|
||||||
<h2 class="section-title">Download Settings</h2>
|
|
||||||
<!-- Default service selection - new element -->
|
|
||||||
<div class="config-item">
|
|
||||||
<label>Default Service:</label>
|
|
||||||
<select id="defaultServiceSelect" class="form-select">
|
|
||||||
<option value="spotify">Spotify</option>
|
|
||||||
</select>
|
|
||||||
<div class="setting-description">
|
|
||||||
The default service to use for downloads when not explicitly specified
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Your account config section remains unchanged -->
|
|
||||||
<div class="config-item spotify-specific">
|
|
||||||
<label>Active Spotify Account:</label>
|
|
||||||
<select id="spotifyAccountSelect" class="form-select"></select>
|
|
||||||
<div id="spotifyAccountMessage" style="display: none; color: #888; margin-top: 5px;"></div>
|
|
||||||
</div>
|
|
||||||
<div class="config-item spotify-specific">
|
|
||||||
<label>Spotify Quality:</label>
|
|
||||||
<select id="spotifyQualitySelect" class="form-select">
|
|
||||||
<option value="NORMAL">OGG 96</option>
|
|
||||||
<option value="HIGH">OGG 160</option>
|
|
||||||
<option value="VERY_HIGH">OGG 320 (premium)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="config-item deezer-specific">
|
|
||||||
<label>Active Deezer Account:</label>
|
|
||||||
<select id="deezerAccountSelect" class="form-select"></select>
|
|
||||||
<div id="deezerAccountMessage" style="display: none; color: #888; margin-top: 5px;"></div>
|
|
||||||
</div>
|
|
||||||
<div class="config-item deezer-specific">
|
|
||||||
<label>Deezer Quality:</label>
|
|
||||||
<select id="deezerQualitySelect" class="form-select">
|
|
||||||
<option value="MP3_128">MP3 128</option>
|
|
||||||
<option value="MP3_320">MP3 320 (sometimes premium)</option>
|
|
||||||
<option value="FLAC">FLAC (premium)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<!-- Explicit Filter Status -->
|
|
||||||
<div class="config-item">
|
|
||||||
<label>Explicit Content Filter:</label>
|
|
||||||
<div class="env-controlled-setting">
|
|
||||||
<span id="explicitFilterStatus" class="env-controlled-value">Loading...</span>
|
|
||||||
<div class="env-controlled-badge">ENV</div>
|
|
||||||
</div>
|
|
||||||
<div class="setting-description">
|
|
||||||
Filter explicit content. Controlled by environment variable EXPLICIT_FILTER.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="config-item">
|
|
||||||
<label>Download Fallback:</label>
|
|
||||||
<label class="switch">
|
|
||||||
<input type="checkbox" id="fallbackToggle" />
|
|
||||||
<span class="slider"></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="config-item">
|
|
||||||
<label>Real time downloading:</label>
|
|
||||||
<label class="switch">
|
|
||||||
<input type="checkbox" id="realTimeToggle" />
|
|
||||||
<span class="slider"></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="config-item">
|
|
||||||
<label for="maxConcurrentDownloads">Max Concurrent Downloads:</label>
|
|
||||||
<input type="number" id="maxConcurrentDownloads" min="1" value="3" class="form-input">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- New Conversion Options -->
|
|
||||||
<h2 class="section-title">Conversion Settings</h2>
|
|
||||||
<div class="config-item">
|
|
||||||
<label for="convertToSelect">Convert To Format:</label>
|
|
||||||
<select id="convertToSelect" class="form-select">
|
|
||||||
<option value="">No Conversion</option>
|
|
||||||
<option value="MP3">MP3</option>
|
|
||||||
<option value="AAC">AAC</option>
|
|
||||||
<option value="OGG">OGG</option>
|
|
||||||
<option value="OPUS">OPUS</option>
|
|
||||||
<option value="FLAC">FLAC</option>
|
|
||||||
<option value="WAV">WAV</option>
|
|
||||||
<option value="ALAC">ALAC</option>
|
|
||||||
</select>
|
|
||||||
<div class="setting-description">
|
|
||||||
Select a format to convert downloaded files to. "No Conversion" keeps the original format.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="config-item">
|
|
||||||
<label for="bitrateSelect">Bitrate:</label>
|
|
||||||
<select id="bitrateSelect" class="form-select" disabled>
|
|
||||||
<!-- Options will be populated by JavaScript -->
|
|
||||||
<option value="">N/A</option>
|
|
||||||
</select>
|
|
||||||
<div class="setting-description">
|
|
||||||
Select the bitrate for the chosen format. Only applicable for lossy formats.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- New Retry Options -->
|
|
||||||
<h2 class="section-title">Retry Options</h2>
|
|
||||||
<div class="config-item">
|
|
||||||
<label for="maxRetries">Max Retry Attempts:</label>
|
|
||||||
<input type="number" id="maxRetries" min="0" max="10" value="3" class="form-input">
|
|
||||||
</div>
|
|
||||||
<div class="config-item">
|
|
||||||
<label for="retryDelaySeconds">Initial Retry Delay (seconds):</label>
|
|
||||||
<input type="number" id="retryDelaySeconds" min="1" value="5" class="form-input">
|
|
||||||
</div>
|
|
||||||
<div class="config-item">
|
|
||||||
<label for="retryDelayIncrease">Retry Delay Increase (seconds):</label>
|
|
||||||
<input type="number" id="retryDelayIncrease" min="0" value="5" class="form-input">
|
|
||||||
<div class="setting-description">
|
|
||||||
The amount of additional delay to add for each retry attempt
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- New Formatting Options -->
|
|
||||||
<h2 class="section-title">Formatting Options</h2>
|
|
||||||
<div class="config-item">
|
|
||||||
<label>Custom Directory Format:</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="customDirFormat"
|
|
||||||
placeholder="e.g. %artist%/%album%"
|
|
||||||
class="form-input"
|
|
||||||
/>
|
|
||||||
<div class="format-help">
|
|
||||||
<select id="dirFormatHelp" class="format-selector">
|
|
||||||
<option value="">-- Select placeholder --</option>
|
|
||||||
<optgroup label="Common">
|
|
||||||
<option value="%music%">%music% - Track title</option>
|
|
||||||
<option value="%artist%">%artist% - Track artist. If multiple artists, use %artist_1%, %artist_2%, etc. to select a specific one.</option>
|
|
||||||
<option value="%album%">%album% - Album name</option>
|
|
||||||
<option value="%ar_album%">%ar_album% - Album artist. If multiple album artists, use %ar_album_1%, %ar_album_2%, etc. to select a specific one.</option>
|
|
||||||
<option value="%tracknum%">%tracknum% - Track number</option>
|
|
||||||
<option value="%year%">%year% - Year of release</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Additional">
|
|
||||||
<option value="%discnum%">%discnum% - Disc number</option>
|
|
||||||
<option value="%date%">%date% - Release date</option>
|
|
||||||
<option value="%genre%">%genre% - Music genre</option>
|
|
||||||
<option value="%isrc%">%isrc% - International Standard Recording Code</option>
|
|
||||||
<option value="%explicit%">%explicit% - Explicit content flag</option>
|
|
||||||
<option value="%duration%">%duration% - Track duration (seconds)</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Metadata">
|
|
||||||
<option value="%publisher%">%publisher% - Publisher information</option>
|
|
||||||
<option value="%composer%">%composer% - Track composer</option>
|
|
||||||
<option value="%copyright%">%copyright% - Copyright information</option>
|
|
||||||
<option value="%author%">%author% - Author information</option>
|
|
||||||
<option value="%lyricist%">%lyricist% - Lyricist information</option>
|
|
||||||
<option value="%version%">%version% - Version information</option>
|
|
||||||
<option value="%comment%">%comment% - Comment field</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Other">
|
|
||||||
<option value="%encodedby%">%encodedby% - Encoded by information</option>
|
|
||||||
<option value="%language%">%language% - Language information</option>
|
|
||||||
<option value="%lyrics%">%lyrics% - Track lyrics</option>
|
|
||||||
<option value="%mood%">%mood% - Mood information</option>
|
|
||||||
<option value="%rating%">%rating% - Track rating</option>
|
|
||||||
<option value="%website%">%website% - Website information</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="ReplayGain">
|
|
||||||
<option value="%replaygain_album_gain%">%replaygain_album_gain% - Album gain</option>
|
|
||||||
<option value="%replaygain_album_peak%">%replaygain_album_peak% - Album peak</option>
|
|
||||||
<option value="%replaygain_track_gain%">%replaygain_track_gain% - Track gain</option>
|
|
||||||
<option value="%replaygain_track_peak%">%replaygain_track_peak% - Track peak</option>
|
|
||||||
</optgroup>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="config-item">
|
|
||||||
<label>Custom Track Format:</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="customTrackFormat"
|
|
||||||
placeholder="e.g. %tracknum% - %music%"
|
|
||||||
class="form-input"
|
|
||||||
/>
|
|
||||||
<div class="format-help">
|
|
||||||
<select id="trackFormatHelp" class="format-selector">
|
|
||||||
<option value="">-- Select placeholder --</option>
|
|
||||||
<optgroup label="Common">
|
|
||||||
<option value="%music%">%music% - Track title</option>
|
|
||||||
<option value="%artist%">%artist% - Track artist. If multiple artists, use %artist_1%, %artist_2%, etc. to select a specific one.</option>
|
|
||||||
<option value="%album%">%album% - Album name</option>
|
|
||||||
<option value="%ar_album%">%ar_album% - Album artist. If multiple album artists, use %ar_album_1%, %ar_album_2%, etc. to select a specific one.</option>
|
|
||||||
<option value="%tracknum%">%tracknum% - Track number</option>
|
|
||||||
<option value="%year%">%year% - Year of release</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Additional">
|
|
||||||
<option value="%discnum%">%discnum% - Disc number</option>
|
|
||||||
<option value="%date%">%date% - Release date</option>
|
|
||||||
<option value="%genre%">%genre% - Music genre</option>
|
|
||||||
<option value="%isrc%">%isrc% - International Standard Recording Code</option>
|
|
||||||
<option value="%explicit%">%explicit% - Explicit content flag</option>
|
|
||||||
<option value="%duration%">%duration% - Track duration (seconds)</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Metadata">
|
|
||||||
<option value="%publisher%">%publisher% - Publisher information</option>
|
|
||||||
<option value="%composer%">%composer% - Track composer</option>
|
|
||||||
<option value="%copyright%">%copyright% - Copyright information</option>
|
|
||||||
<option value="%author%">%author% - Author information</option>
|
|
||||||
<option value="%lyricist%">%lyricist% - Lyricist information</option>
|
|
||||||
<option value="%version%">%version% - Version information</option>
|
|
||||||
<option value="%comment%">%comment% - Comment field</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="Other">
|
|
||||||
<option value="%encodedby%">%encodedby% - Encoded by information</option>
|
|
||||||
<option value="%language%">%language% - Language information</option>
|
|
||||||
<option value="%lyrics%">%lyrics% - Track lyrics</option>
|
|
||||||
<option value="%mood%">%mood% - Mood information</option>
|
|
||||||
<option value="%rating%">%rating% - Track rating</option>
|
|
||||||
<option value="%website%">%website% - Website information</option>
|
|
||||||
<option value="%quality%">%quality% - Quality of the track</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="ReplayGain">
|
|
||||||
<option value="%replaygain_album_gain%">%replaygain_album_gain% - Album gain</option>
|
|
||||||
<option value="%replaygain_album_peak%">%replaygain_album_peak% - Album peak</option>
|
|
||||||
<option value="%replaygain_track_gain%">%replaygain_track_gain% - Track gain</option>
|
|
||||||
<option value="%replaygain_track_peak%">%replaygain_track_peak% - Track peak</option>
|
|
||||||
</optgroup>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="setting-description">
|
|
||||||
Note that these placeholder depend on the metadata of the track, if one entry is not available in a track, the placeholder will be replaced with an empty string.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- New Track Number Padding Toggle -->
|
|
||||||
<div class="config-item">
|
|
||||||
<label>Track Number Padding:</label>
|
|
||||||
<label class="switch">
|
|
||||||
<input type="checkbox" id="tracknumPaddingToggle" />
|
|
||||||
<span class="slider"></span>
|
|
||||||
</label>
|
|
||||||
<div class="setting-description">
|
|
||||||
When enabled: "01. Track" - When disabled: "1. Track"
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- New Save Cover Art Toggle -->
|
|
||||||
<div class="config-item">
|
|
||||||
<label>Save Cover Art:</label>
|
|
||||||
<label class="switch">
|
|
||||||
<input type="checkbox" id="saveCoverToggle" />
|
|
||||||
<span class="slider"></span>
|
|
||||||
</label>
|
|
||||||
<div class="setting-description">
|
|
||||||
When enabled, cover art will saved as cover.jpg in the same directory as the track.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="watch-options-config card">
|
|
||||||
<h2 class="section-title">Watch Options</h2>
|
|
||||||
<div class="config-item">
|
|
||||||
<label>Enable Watch Feature:</label>
|
|
||||||
<label class="switch">
|
|
||||||
<input type="checkbox" id="watchEnabledToggle" />
|
|
||||||
<span class="slider"></span>
|
|
||||||
</label>
|
|
||||||
<div class="setting-description">
|
|
||||||
Enable or disable the entire watch feature (monitoring playlists and artists for new content).
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="watchEnabledWarning" class="config-item urgent-warning-message" style="display: none;">
|
|
||||||
<svg class="warning-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="24px" height="24px"><path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/></svg>
|
|
||||||
Warning: Enable "Real time downloading" in the Download Settings to avoid rate-limiting issues. If you don't, you WILL (pretty much immediately) encounter API rate limits, and the watch feature WILL break.
|
|
||||||
</div>
|
|
||||||
<div class="config-item">
|
|
||||||
<label for="watchedArtistAlbumGroup">Artist Page - Album Groups to Watch:</label>
|
|
||||||
<div id="watchedArtistAlbumGroupChecklist" class="checklist-container">
|
|
||||||
<div class="checklist-item">
|
|
||||||
<input type="checkbox" id="albumGroup-album" name="watchedArtistAlbumGroup" value="album">
|
|
||||||
<label for="albumGroup-album">Album</label>
|
|
||||||
</div>
|
|
||||||
<div class="checklist-item">
|
|
||||||
<input type="checkbox" id="albumGroup-single" name="watchedArtistAlbumGroup" value="single">
|
|
||||||
<label for="albumGroup-single">Single</label>
|
|
||||||
</div>
|
|
||||||
<div class="checklist-item">
|
|
||||||
<input type="checkbox" id="albumGroup-compilation" name="watchedArtistAlbumGroup" value="compilation">
|
|
||||||
<label for="albumGroup-compilation">Compilation</label>
|
|
||||||
</div>
|
|
||||||
<div class="checklist-item">
|
|
||||||
<input type="checkbox" id="albumGroup-appears_on" name="watchedArtistAlbumGroup" value="appears_on">
|
|
||||||
<label for="albumGroup-appears_on">Appears On</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="setting-description">
|
|
||||||
Select which album groups to monitor on watched artist pages.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="config-item">
|
|
||||||
<label for="watchPollIntervalSeconds">Watch Poll Interval (seconds):</label>
|
|
||||||
<input type="number" id="watchPollIntervalSeconds" min="60" value="3600" class="form-input">
|
|
||||||
<div class="setting-description">
|
|
||||||
How often to check watched items for updates (e.g., new playlist tracks, new artist albums).
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="master-accounts-config-section">
|
|
||||||
<h2 class="section-title">Accounts configuration</h2>
|
|
||||||
|
|
||||||
<!-- Global Spotify API Credentials Card: MOVED HERE -->
|
|
||||||
<div class="global-api-keys-config card"> <!-- Changed class to global-api-keys-config -->
|
|
||||||
<h2 class="section-title">Global Spotify API Credentials</h2>
|
|
||||||
<div class="config-item">
|
|
||||||
<label for="globalSpotifyClientId">Client ID:</label>
|
|
||||||
<input type="text" id="globalSpotifyClientId" class="form-input" placeholder="Enter your Spotify Client ID">
|
|
||||||
</div>
|
|
||||||
<div class="config-item">
|
|
||||||
<label for="globalSpotifyClientSecret">Client Secret:</label>
|
|
||||||
<input type="password" id="globalSpotifyClientSecret" class="form-input" placeholder="Enter your Spotify Client Secret">
|
|
||||||
</div>
|
|
||||||
<div class="config-item">
|
|
||||||
<button id="saveSpotifyApiConfigBtn" class="btn btn-primary">Save</button>
|
|
||||||
</div>
|
|
||||||
<div id="spotifyApiConfigStatus" class="status-message" style="margin-top: 10px;"></div>
|
|
||||||
</div>
|
|
||||||
<!-- End Global Spotify API Credentials Card -->
|
|
||||||
|
|
||||||
<div class="accounts-section">
|
|
||||||
<div class="service-tabs">
|
|
||||||
<button class="tab-button active" data-service="spotify">Spotify</button>
|
|
||||||
<button class="tab-button" data-service="deezer">Deezer</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Wrapper for the list and the add button -->
|
|
||||||
<div class="credentials-list-wrapper card">
|
|
||||||
<div class="credentials-list-items">
|
|
||||||
<!-- Dynamic credential items will be rendered here by JavaScript -->
|
|
||||||
<!-- "No credentials" message will also be rendered here -->
|
|
||||||
</div>
|
|
||||||
<div class="add-account-item">
|
|
||||||
<button id="showAddAccountFormBtn" class="btn-add-account-styled" type="button">
|
|
||||||
<img src="{{ url_for('static', filename='images/plus-circle.svg') }}" alt="Add" /> Add New Account
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="credentials-form card">
|
|
||||||
<h2 id="formTitle" class="section-title">Add New Spotify Account</h2>
|
|
||||||
<form id="credentialForm">
|
|
||||||
<div id="serviceFields"></div>
|
|
||||||
<!-- Region Hints START -->
|
|
||||||
<div id="spotifyRegionHint" class="setting-description" style="display:none; margin-left: 10px; margin-top: -5px; margin-bottom:15px; font-size: 0.9em;">
|
|
||||||
<small>Region not matching your account may lead to issues. Check it <a href="https://www.spotify.com/mx/account/profile/" target="_blank" rel="noopener noreferrer">here</a>.</small>
|
|
||||||
</div>
|
|
||||||
<div id="deezerRegionHint" class="setting-description" style="display:none; margin-left: 10px; margin-top: -5px; margin-bottom:15px; font-size: 0.9em;">
|
|
||||||
<small>Region not matching your account may lead to issues. Check it <a href="https://www.deezer.com/account/country_selector" target="_blank" rel="noopener noreferrer">here</a>.</small>
|
|
||||||
</div>
|
|
||||||
<!-- Region Hints END -->
|
|
||||||
<div id="searchFields" style="display: none;"></div>
|
|
||||||
<button type="submit" id="submitCredentialBtn" class="btn btn-primary save-btn">Save Account</button>
|
|
||||||
<button type="button" id="cancelAddAccountBtn" class="btn btn-secondary cancel-btn btn-cancel-icon" style="margin-left: 10px;" title="Cancel">
|
|
||||||
<img src="{{ url_for('static', filename='images/cross.svg') }}" alt="Cancel" />
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<div id="configSuccess" class="success"></div>
|
|
||||||
<div id="configError" class="error"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div> <!-- End of accounts-section -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Fixed floating buttons for back and queue -->
|
|
||||||
<a href="/history" class="btn-icon history-nav-btn floating-icon settings-icon" aria-label="Download History" title="Go to Download History">
|
|
||||||
<img src="{{ url_for('static', filename='images/history.svg') }}" alt="History" onerror="handleImageError(this)"/>
|
|
||||||
</a>
|
|
||||||
<a href="/" class="back-button floating-icon settings-icon" aria-label="Back to app">
|
|
||||||
<img src="{{ url_for('static', filename='images/arrow-left.svg') }}" alt="Back" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a id="watchlistButton" href="/watchlist" class="btn-icon watchlist-icon floating-icon hidden" aria-label="Watchlist" title="Go to Watchlist">
|
|
||||||
<img src="{{ url_for('static', filename='images/binoculars.svg') }}" alt="Watchlist" onerror="handleImageError(this)"/>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<button
|
|
||||||
id="queueIcon"
|
|
||||||
class="btn-icon queue-icon floating-icon"
|
|
||||||
aria-label="Download queue"
|
|
||||||
aria-controls="downloadQueue"
|
|
||||||
aria-expanded="false"
|
|
||||||
>
|
|
||||||
<img src="{{ url_for('static', filename='images/queue.svg') }}" alt="Queue" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Load config.js as a module so you can import queue.js -->
|
|
||||||
<script type="module" src="{{ url_for('static', filename='js/config.js') }}"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Download History</title>
|
|
||||||
<!-- Link to global stylesheets first -->
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/base.css') }}">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/icons.css') }}">
|
|
||||||
<!-- Link to page-specific stylesheet -->
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/history/history.css') }}">
|
|
||||||
<!-- Helper function for image errors, if not already in base.css or loaded globally -->
|
|
||||||
<script>
|
|
||||||
function handleImageError(img) {
|
|
||||||
img.onerror = null; // Prevent infinite loop if placeholder also fails
|
|
||||||
img.src = "{{ url_for('static', filename='images/placeholder.jpg') }}";
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>Download History</h1>
|
|
||||||
|
|
||||||
<div class="filters">
|
|
||||||
<label for="status-filter">Status:</label>
|
|
||||||
<select id="status-filter">
|
|
||||||
<option value="">All</option>
|
|
||||||
<option value="COMPLETED">Completed</option>
|
|
||||||
<option value="ERROR">Error</option>
|
|
||||||
<option value="CANCELLED">Cancelled</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<label for="type-filter">Type:</label>
|
|
||||||
<select id="type-filter">
|
|
||||||
<option value="">All</option>
|
|
||||||
<option value="track">Track</option>
|
|
||||||
<option value="album">Album</option>
|
|
||||||
<option value="playlist">Playlist</option>
|
|
||||||
<option value="artist">Artist</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th data-sort="item_name">Name</th>
|
|
||||||
<th data-sort="item_artist">Artist</th>
|
|
||||||
<th data-sort="download_type">Type</th>
|
|
||||||
<th data-sort="service_used">Service</th>
|
|
||||||
<th data-sort="quality_profile">Quality</th>
|
|
||||||
<th data-sort="status_final">Status</th>
|
|
||||||
<th data-sort="timestamp_added">Date Added</th>
|
|
||||||
<th data-sort="timestamp_completed">Date Completed/Ended</th>
|
|
||||||
<th>Details</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="history-table-body">
|
|
||||||
<!-- Rows will be inserted here by JavaScript -->
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div class="pagination">
|
|
||||||
<button id="prev-page" disabled>Previous</button>
|
|
||||||
<span id="page-info">Page 1 of 1</span>
|
|
||||||
<button id="next-page" disabled>Next</button>
|
|
||||||
<select id="limit-select">
|
|
||||||
<option value="10">10 per page</option>
|
|
||||||
<option value="25" selected>25 per page</option>
|
|
||||||
<option value="50">50 per page</option>
|
|
||||||
<option value="100">100 per page</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Fixed floating buttons for home and queue -->
|
|
||||||
<a href="/" class="btn-icon home-btn floating-icon" aria-label="Return to home" title="Go to Home">
|
|
||||||
<img src="{{ url_for('static', filename='images/home.svg') }}" alt="Home" onerror="handleImageError(this)"/>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Link to the new TypeScript file (compiled to JS) -->
|
|
||||||
<script type="module" src="{{ url_for('static', filename='js/history.js') }}"></script>
|
|
||||||
<!-- Queue icon, assuming queue.js handles its own initialization if included -->
|
|
||||||
<!-- You might want to include queue.js here if the queue icon is desired on this page -->
|
|
||||||
<!-- <script type="module" src="{{ url_for('static', filename='js/queue.js') }}"></script> -->
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Spotizerr</title>
|
|
||||||
<!-- Add the new base.css first -->
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/base.css') }}" />
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/main.css') }}" />
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/icons.css') }}" />
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/queue/queue.css') }}">
|
|
||||||
<script>
|
|
||||||
// Helper function to handle image loading errors
|
|
||||||
function handleImageError(img) {
|
|
||||||
img.src = '/static/images/placeholder.jpg';
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="app-container">
|
|
||||||
<div class="search-header">
|
|
||||||
<div class="search-input-container">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="search-input"
|
|
||||||
placeholder="Search tracks, albums, playlists or artists... (Or paste in a spotify url)"
|
|
||||||
id="searchInput"
|
|
||||||
/>
|
|
||||||
<select class="search-type" id="searchType">
|
|
||||||
<option value="track">Tracks</option>
|
|
||||||
<option value="album">Albums</option>
|
|
||||||
<option value="playlist">Playlists</option>
|
|
||||||
<option value="artist">Artists</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="search-button btn-primary" id="searchButton" aria-label="Search">
|
|
||||||
<img src="{{ url_for('static', filename='images/search.svg') }}" alt="" />
|
|
||||||
Search
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Results container -->
|
|
||||||
<div id="resultsContainer" class="results-grid"></div>
|
|
||||||
|
|
||||||
<!-- Empty state when there are no results -->
|
|
||||||
<div id="emptyState" class="empty-state">
|
|
||||||
<div class="empty-state-content">
|
|
||||||
<img src="{{ url_for('static', filename='images/music.svg') }}" alt="Music" class="empty-state-icon" />
|
|
||||||
<h2>Search for music</h2>
|
|
||||||
<p>Find and download your favorite tracks, albums, playlists or artists</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading indicator -->
|
|
||||||
<div id="loadingResults" class="loading hidden">
|
|
||||||
<div class="loading-indicator">Searching...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Fixed floating buttons for settings and queue -->
|
|
||||||
<a href="/history" class="btn-icon history-nav-btn floating-icon settings-icon" aria-label="Download History" title="Go to Download History">
|
|
||||||
<img src="{{ url_for('static', filename='images/history.svg') }}" alt="History" onerror="handleImageError(this)"/>
|
|
||||||
</a>
|
|
||||||
<a href="/config" class="btn-icon settings-icon floating-icon" aria-label="Settings">
|
|
||||||
<img src="{{ url_for('static', filename='images/settings.svg') }}" alt="Settings" onerror="handleImageError(this)"/>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a id="watchlistButton" href="/watchlist" class="btn-icon watchlist-icon floating-icon hidden" aria-label="Watchlist" title="Go to Watchlist">
|
|
||||||
<img src="{{ url_for('static', filename='images/binoculars.svg') }}" alt="Watchlist" onerror="handleImageError(this)"/>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<button
|
|
||||||
id="queueIcon"
|
|
||||||
class="btn-icon queue-icon floating-icon"
|
|
||||||
aria-label="Download queue"
|
|
||||||
aria-controls="downloadQueue"
|
|
||||||
aria-expanded="false"
|
|
||||||
>
|
|
||||||
<img src="{{ url_for('static', filename='images/queue.svg') }}" alt="" onerror="handleImageError(this)"/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<script type="module" src="{{ url_for('static', filename='js/main.js') }}"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Playlist Viewer - Spotizerr</title>
|
|
||||||
<!-- Add the new base.css first -->
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/base.css') }}">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/queue/queue.css') }}">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/icons.css') }}">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/playlist/playlist.css') }}" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="app-container">
|
|
||||||
<div id="playlist-header" class="content-header hidden">
|
|
||||||
<!-- Playlist Image -->
|
|
||||||
<img id="playlist-image" class="header-image" alt="Playlist cover" onerror="this.src='/static/images/placeholder.jpg'">
|
|
||||||
|
|
||||||
<!-- Playlist Info -->
|
|
||||||
<div id="playlist-info" class="header-info">
|
|
||||||
<h1 id="playlist-name" class="header-title"></h1>
|
|
||||||
<p id="playlist-owner" class="header-subtitle"></p>
|
|
||||||
<p id="playlist-stats" class="header-subtitle"></p>
|
|
||||||
<p id="playlist-description" class="playlist-description"></p>
|
|
||||||
|
|
||||||
<!-- Download Buttons -->
|
|
||||||
<div class="header-actions">
|
|
||||||
<button id="downloadPlaylistBtn" class="download-btn btn-primary">
|
|
||||||
<img src="{{ url_for('static', filename='images/download.svg') }}" alt="Download">
|
|
||||||
Download Whole Playlist
|
|
||||||
</button>
|
|
||||||
<button id="downloadAlbumsBtn" class="download-btn">
|
|
||||||
<img src="{{ url_for('static', filename='images/album.svg') }}" alt="Albums">
|
|
||||||
Download Playlist's Albums
|
|
||||||
</button>
|
|
||||||
<button id="watchPlaylistBtn" class="download-btn watch-btn">
|
|
||||||
<img src="{{ url_for('static', filename='images/eye.svg') }}" alt="Watch">
|
|
||||||
Watch Playlist
|
|
||||||
</button>
|
|
||||||
<button id="syncPlaylistBtn" class="download-btn sync-btn hidden">
|
|
||||||
<img src="{{ url_for('static', filename='images/refresh.svg') }}" alt="Sync">
|
|
||||||
Sync Watched Playlist
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="tracks-container" class="hidden">
|
|
||||||
<h2 class="section-title">Tracks</h2>
|
|
||||||
<div id="tracks-list" class="tracks-list"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading and Error states -->
|
|
||||||
<div id="loading" class="loading">
|
|
||||||
<div class="loading-indicator">Loading...</div>
|
|
||||||
</div>
|
|
||||||
<div id="error" class="error hidden">Error loading playlist</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Fixed floating buttons for home and queue -->
|
|
||||||
<a href="/history" class="btn-icon history-nav-btn floating-icon home-btn" aria-label="Download History" title="Go to Download History">
|
|
||||||
<img src="{{ url_for('static', filename='images/history.svg') }}" alt="History" onerror="handleImageError(this)"/>
|
|
||||||
</a>
|
|
||||||
<button id="homeButton" class="btn-icon home-btn floating-icon settings-icon" aria-label="Return to home">
|
|
||||||
<img src="{{ url_for('static', filename='images/home.svg') }}" alt="Home">
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<a id="watchlistButton" href="/watchlist" class="btn-icon watchlist-icon floating-icon hidden" aria-label="Watchlist" title="Go to Watchlist">
|
|
||||||
<img src="{{ url_for('static', filename='images/binoculars.svg') }}" alt="Watchlist" onerror="handleImageError(this)"/>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<button
|
|
||||||
id="queueIcon"
|
|
||||||
class="btn-icon queue-icon floating-icon"
|
|
||||||
aria-label="Download queue"
|
|
||||||
aria-controls="downloadQueue"
|
|
||||||
aria-expanded="false"
|
|
||||||
>
|
|
||||||
<img src="{{ url_for('static', filename='images/queue.svg') }}" alt="Queue Icon">
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<script type="module" src="{{ url_for('static', filename='js/playlist.js') }}"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Track Viewer - Spotizerr</title>
|
|
||||||
<!-- Add the new base.css first -->
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/base.css') }}">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/queue/queue.css') }}">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/icons.css') }}">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/track/track.css') }}">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="app-container">
|
|
||||||
<div id="track-header" class="content-header hidden">
|
|
||||||
<!-- Album Image -->
|
|
||||||
<img id="track-album-image" class="header-image" alt="Album cover" onerror="this.src='/static/images/placeholder.jpg'">
|
|
||||||
|
|
||||||
<!-- Track Info -->
|
|
||||||
<div id="track-info" class="header-info">
|
|
||||||
<h1 id="track-name" class="header-title"></h1>
|
|
||||||
<p id="track-artist" class="header-subtitle"></p>
|
|
||||||
<p id="track-album" class="header-subtitle"></p>
|
|
||||||
|
|
||||||
<div class="track-details">
|
|
||||||
<span id="track-duration" class="track-detail-item"></span>
|
|
||||||
<span id="track-explicit" class="track-detail-item"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Download Button moved here for better mobile layout -->
|
|
||||||
<div class="header-actions">
|
|
||||||
<button id="downloadTrackBtn" class="download-btn btn-primary">
|
|
||||||
<img src="{{ url_for('static', filename='images/download.svg') }}" alt="Download">
|
|
||||||
Download Track
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading and Error states -->
|
|
||||||
<div id="loading" class="loading">
|
|
||||||
<div class="loading-indicator">Loading...</div>
|
|
||||||
</div>
|
|
||||||
<div id="error" class="error hidden">Error loading track</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Fixed floating buttons for home and queue -->
|
|
||||||
<a href="/history" class="btn-icon history-nav-btn floating-icon home-btn" aria-label="Download History" title="Go to Download History">
|
|
||||||
<img src="{{ url_for('static', filename='images/history.svg') }}" alt="History" onerror="handleImageError(this)"/>
|
|
||||||
</a>
|
|
||||||
<button id="homeButton" class="btn-icon home-btn floating-icon settings-icon" aria-label="Return to home">
|
|
||||||
<img src="{{ url_for('static', filename='images/home.svg') }}" alt="Home">
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<a id="watchlistButton" href="/watchlist" class="btn-icon watchlist-icon floating-icon hidden" aria-label="Watchlist" title="Go to Watchlist">
|
|
||||||
<img src="{{ url_for('static', filename='images/binoculars.svg') }}" alt="Watchlist" onerror="handleImageError(this)"/>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<button
|
|
||||||
id="queueIcon"
|
|
||||||
class="btn-icon queue-icon floating-icon"
|
|
||||||
aria-label="Download queue"
|
|
||||||
aria-controls="downloadQueue"
|
|
||||||
aria-expanded="false"
|
|
||||||
>
|
|
||||||
<img src="{{ url_for('static', filename='images/queue.svg') }}" alt="Queue Icon">
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- The download queue container will be inserted by queue.js -->
|
|
||||||
<script type="module" src="{{ url_for('static', filename='js/track.js') }}"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Watched Items - Spotizerr</title>
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/base.css') }}">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/queue/queue.css') }}">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/icons.css') }}">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/watch/watch.css') }}">
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Helper function to handle image loading errors
|
|
||||||
function handleImageError(img) {
|
|
||||||
img.src = '/static/images/placeholder.jpg'; // Ensure this placeholder exists
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="app-container">
|
|
||||||
<div class="watch-header">
|
|
||||||
<h1>Watched Artists & Playlists</h1>
|
|
||||||
<button id="checkAllWatchedBtn" class="btn btn-secondary check-all-btn">
|
|
||||||
<img src="{{ url_for('static', filename='images/refresh-cw.svg') }}" alt="Refresh"> Check All
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="watchedItemsContainer" class="results-grid">
|
|
||||||
<!-- Watched items will be dynamically inserted here -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="loadingWatchedItems" class="loading hidden">
|
|
||||||
<div class="loading-indicator">Loading watched items...</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="emptyWatchedItems" class="empty-state hidden">
|
|
||||||
<div class="empty-state-content">
|
|
||||||
<img src="{{ url_for('static', filename='images/binoculars.svg') }}" alt="Binoculars" class="empty-state-icon" />
|
|
||||||
<h2>Nothing to see here yet!</h2>
|
|
||||||
<p>Start watching artists or playlists, and they'll appear here.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Fixed floating buttons for settings and queue -->
|
|
||||||
<a href="/history" class="btn-icon history-nav-btn floating-icon home-btn" aria-label="Download History" title="Go to Download History">
|
|
||||||
<img src="{{ url_for('static', filename='images/history.svg') }}" alt="History" onerror="handleImageError(this)"/>
|
|
||||||
</a>
|
|
||||||
<a href="/" class="btn-icon home-btn floating-icon settings-icon" aria-label="Return to Home" title="Return to Home">
|
|
||||||
<img src="{{ url_for('static', filename='images/home.svg') }}" alt="Home" onerror="handleImageError(this)"/>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<button
|
|
||||||
id="queueIcon"
|
|
||||||
class="btn-icon queue-icon floating-icon"
|
|
||||||
aria-label="Download queue"
|
|
||||||
aria-controls="downloadQueue"
|
|
||||||
aria-expanded="false"
|
|
||||||
>
|
|
||||||
<img src="{{ url_for('static', filename='images/queue.svg') }}" alt="Queue" onerror="handleImageError(this)"/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<script type="module" src="{{ url_for('static', filename='js/watch.js') }}"></script>
|
|
||||||
<!-- Include queue.js if queueIcon functionality is desired on this page too -->
|
|
||||||
<script type="module" src="{{ url_for('static', filename='js/queue.js') }}"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||