Merge remote-tracking branch 'upstream' into taglib-wasm
This commit is contained in:
commit
de472c5891
35 changed files with 2442 additions and 2684 deletions
|
|
@ -1,52 +1,26 @@
|
|||
# ------------------------------------------------------------
|
||||
# Base Image
|
||||
# ------------------------------------------------------------
|
||||
FROM debian:unstable-slim
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV LANG=C.UTF-8
|
||||
ENV LC_ALL=C.UTF-8
|
||||
FROM mcr.microsoft.com/devcontainers/base:debian
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# System Dependencies
|
||||
# ------------------------------------------------------------
|
||||
RUN apt-get update && apt-get upgrade -y && \
|
||||
RUN apt update && apt upgrade -y && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
ca-certificates \
|
||||
git \
|
||||
build-essential \
|
||||
sudo \
|
||||
git-lfs \
|
||||
fish \
|
||||
unzip \
|
||||
xz-utils \
|
||||
libatomic1 \
|
||||
libc6 \
|
||||
wget \
|
||||
nodejs \
|
||||
npm && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Create Non-Root User
|
||||
# ------------------------------------------------------------
|
||||
ARG USERNAME=devuser
|
||||
ARG UID=1000
|
||||
ARG GID=1000
|
||||
|
||||
RUN groupadd --gid ${GID} ${USERNAME} && \
|
||||
useradd --uid ${UID} --gid ${GID} -m -s /usr/bin/fish ${USERNAME} && \
|
||||
echo "${USERNAME} ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
|
||||
|
||||
USER ${USERNAME}
|
||||
WORKDIR /home/${USERNAME}
|
||||
npm \
|
||||
curl
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Install Bun (Non-Root)
|
||||
# ------------------------------------------------------------
|
||||
ENV BUN_INSTALL=/home/${USERNAME}/.bun
|
||||
ENV PATH="${BUN_INSTALL}/bin:${PATH}"
|
||||
|
||||
ENV BUN_INSTALL="$HOME/.bun"
|
||||
ENV PATH="$BUN_INSTALL/bin:$PATH"
|
||||
|
||||
RUN curl -fsSL https://bun.sh/install | bash
|
||||
|
||||
# ------------------------------------------------------------
|
||||
|
|
@ -58,7 +32,7 @@ RUN curl -fsSL https://opencode.ai/install -o opencode-install && \
|
|||
rm opencode-install
|
||||
|
||||
# Add OpenCode to PATH permanently
|
||||
ENV PATH="/home/${USERNAME}/.opencode/bin:${PATH}"
|
||||
ENV PATH="$HOME/.opencode/bin:$PATH"
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Ensure fish is Default Shell
|
||||
|
|
|
|||
|
|
@ -1,22 +1,14 @@
|
|||
{
|
||||
"name": "debian-npm-fish-devcontainer",
|
||||
"name": "Monochrome Dev Container",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile"
|
||||
"context": "..",
|
||||
"dockerfile": "./Dockerfile"
|
||||
},
|
||||
|
||||
"remoteUser": "devuser",
|
||||
|
||||
"features": {},
|
||||
|
||||
"postCreateCommand": "git config --local core.editor \"code --wait\" && git config --local commit.gpgsign false && npm install && bun install",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
|
||||
}
|
||||
},
|
||||
|
||||
"postCreateCommand": "npm install",
|
||||
|
||||
"remoteEnv": {
|
||||
"SHELL": "/usr/bin/fish"
|
||||
}
|
||||
"mounts": ["source=${env:HOME}/.gitconfig,target=/home/vscode/.gitconfig,type=bind,consistency=cached"]
|
||||
}
|
||||
|
|
|
|||
23
.vscode/tasks.json
vendored
Normal file
23
.vscode/tasks.json
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "build",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
},
|
||||
"problemMatcher": [],
|
||||
"label": "npm: build",
|
||||
"detail": "vite build"
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "dev",
|
||||
"problemMatcher": [],
|
||||
"label": "npm: dev",
|
||||
"detail": "vite"
|
||||
}
|
||||
]
|
||||
}
|
||||
21
bun.lock
21
bun.lock
|
|
@ -16,6 +16,7 @@
|
|||
"cookie-session": "^2.1.1",
|
||||
"dashjs": "^5.1.1",
|
||||
"fuse.js": "^7.1.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"jose": "^6.2.0",
|
||||
"npm": "^11.11.0",
|
||||
"pocketbase": "^0.26.8",
|
||||
|
|
@ -27,6 +28,7 @@
|
|||
"@types/node": "^25.3.5",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"formidable": "^3.5.4",
|
||||
"globals": "^17.4.0",
|
||||
"htmlhint": "^1.9.2",
|
||||
"miniflare": "^4.20260301.1",
|
||||
|
|
@ -42,6 +44,7 @@
|
|||
},
|
||||
},
|
||||
"overrides": {
|
||||
"serialize-javascript": "^7.0.3",
|
||||
"source-map": "^0.7.4",
|
||||
"sourcemap-codec": "npm:@jridgewell/sourcemap-codec@^1.4.14",
|
||||
},
|
||||
|
|
@ -422,12 +425,16 @@
|
|||
|
||||
"@neutralinojs/neu": ["@neutralinojs/neu@11.7.0", "", { "dependencies": { "@electron/asar": "^3.0.3", "chalk": "^4.1.0", "chokidar": "^4.0.3", "commander": "^7.2.0", "configstore": "^5.0.1", "edit-json-file": "^1.6.2", "follow-redirects": "^1.13.1", "fs-extra": "^9.0.1", "pe-library": "^1.0.1", "png2icons": "^2.0.1", "postject": "1.0.0-alpha.6", "recursive-readdir": "^2.2.2", "resedit": "^2.0.2", "spawn-command": "^1.0.0", "tcp-port-used": "^1.0.2", "uuid": "^8.3.2", "websocket": "^1.0.35", "zip-lib": "^1.0.4" }, "bin": { "neu": "bin/neu.js" } }, "sha512-fUqvR70a+BpKI9mrD92ldZkVC24Rs8XL/9m7zmOCLgCRys3yuWy7vEsxpHzKMzqTiQJkTYIsLmcR8VMzNIjuSw=="],
|
||||
|
||||
"@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||
|
||||
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
||||
|
||||
"@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="],
|
||||
|
||||
"@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="],
|
||||
|
||||
"@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="],
|
||||
|
|
@ -552,6 +559,8 @@
|
|||
|
||||
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
|
||||
|
||||
"asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="],
|
||||
|
||||
"astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="],
|
||||
|
||||
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
||||
|
|
@ -678,6 +687,8 @@
|
|||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"dezalgo": ["dezalgo@1.0.4", "", { "dependencies": { "asap": "^2.0.0", "wrappy": "1" } }, "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig=="],
|
||||
|
||||
"dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="],
|
||||
|
||||
"dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="],
|
||||
|
|
@ -778,7 +789,7 @@
|
|||
|
||||
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
|
||||
|
||||
"flatted": ["flatted@3.3.4", "", {}, "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA=="],
|
||||
"flatted": ["flatted@3.4.0", "", {}, "sha512-kC6Bb+ooptOIvWj5B63EQWkF0FEnNjV2ZNkLMLZRDDduIiWeFF4iKnslwhiWxjAdbg4NzTNo6h0qLuvFrcx+Sw=="],
|
||||
|
||||
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
|
||||
|
||||
|
|
@ -786,6 +797,8 @@
|
|||
|
||||
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
|
||||
|
||||
"formidable": ["formidable@3.5.4", "", { "dependencies": { "@paralleldrive/cuid2": "^2.2.2", "dezalgo": "^1.0.4", "once": "^1.4.0" } }, "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug=="],
|
||||
|
||||
"fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
|
||||
|
||||
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
||||
|
|
@ -848,6 +861,8 @@
|
|||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"hls.js": ["hls.js@1.6.15", "", {}, "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA=="],
|
||||
|
||||
"hookified": ["hookified@1.15.1", "", {}, "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg=="],
|
||||
|
||||
"html-entities": ["html-entities@2.6.0", "", {}, "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ=="],
|
||||
|
|
@ -1148,8 +1163,6 @@
|
|||
|
||||
"r-json": ["r-json@1.3.1", "", { "dependencies": { "w-json": "1.3.10" } }, "sha512-5nhRFfjVMQdrwKUfUlRpDUCocdKtjSnYZ1R/86mpZDV3MfsZ3dYYNjSGuMX+mPBvFvQBhdzxSqxkuLPLv4uFGg=="],
|
||||
|
||||
"randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="],
|
||||
|
||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"recursive-readdir": ["recursive-readdir@2.2.3", "", { "dependencies": { "minimatch": "^3.0.5" } }, "sha512-8HrF5ZsXk5FAH9dgsx3BlUer73nIhuj+9OrQwEbLTPOBzGkL1lsFCR01am+v+0m2Cmbs1nP12hLDl5FA7EszKA=="],
|
||||
|
|
@ -1196,7 +1209,7 @@
|
|||
|
||||
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"serialize-javascript": ["serialize-javascript@6.0.2", "", { "dependencies": { "randombytes": "^2.1.0" } }, "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g=="],
|
||||
"serialize-javascript": ["serialize-javascript@7.0.4", "", {}, "sha512-DuGdB+Po43Q5Jxwpzt1lhyFSYKryqoNjQSA9M92tyw0lyHIOur+XCalOUe0KTJpyqzT8+fQ5A0Jf7vCx/NKmIg=="],
|
||||
|
||||
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
|
||||
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ class ServerAPI {
|
|||
|
||||
getCoverUrl(id, size = '1280') {
|
||||
if (!id) return '';
|
||||
const formattedId = id.replace(/-/g, '/');
|
||||
const formattedId = String(id).replace(/-/g, '/');
|
||||
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ class ServerAPI {
|
|||
|
||||
getCoverUrl(id, size = '1080') {
|
||||
if (!id) return '';
|
||||
const formattedId = id.replace(/-/g, '/');
|
||||
const formattedId = String(id).replace(/-/g, '/');
|
||||
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ class ServerAPI {
|
|||
|
||||
getCoverUrl(id, size = '1280') {
|
||||
if (!id) return '';
|
||||
const formattedId = id.replace(/-/g, '/');
|
||||
const formattedId = String(id).replace(/-/g, '/');
|
||||
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
|
||||
}
|
||||
|
||||
|
|
|
|||
828
index.html
828
index.html
|
|
@ -36,32 +36,37 @@
|
|||
</head>
|
||||
|
||||
<body>
|
||||
<audio id="audio-player" crossorigin="anonymous"></audio>
|
||||
<video id="audio-player" crossorigin="anonymous" style="display: none"></video>
|
||||
<div id="context-menu">
|
||||
<ul>
|
||||
<li data-action="shuffle-play-card" data-type-filter="album,playlist,mix,user-playlist">
|
||||
Shuffle play
|
||||
</li>
|
||||
<li data-action="start-mix" data-type-filter="album,track">Start mix</li>
|
||||
<li data-action="start-mix" data-type-filter="album,track,video">Start mix</li>
|
||||
<li data-action="play-next">Play next</li>
|
||||
<li data-action="add-to-queue">Add to queue</li>
|
||||
<li
|
||||
data-action="toggle-like"
|
||||
data-label-track="Like"
|
||||
data-label-unlike-track="Unlike"
|
||||
data-label-album="Save album to library"
|
||||
data-label-unlike-album="Remove album from library"
|
||||
data-label-playlist="Save playlist to library"
|
||||
data-label-unlike-playlist="Remove playlist from library"
|
||||
data-label-video="Like"
|
||||
data-label-unlike-video="Unlike"
|
||||
>
|
||||
Like
|
||||
</li>
|
||||
<li data-action="toggle-pin" data-type-filter="album,artist,playlist,user-playlist">Pin</li>
|
||||
<li data-action="add-to-playlist" data-type-filter="track">Add to playlist</li>
|
||||
<li data-action="go-to-artist" data-type-filter="track,album">Go to artist</li>
|
||||
<li data-action="go-to-album" data-type-filter="track">Go to album</li>
|
||||
<li data-action="add-to-playlist" data-type-filter="track,video">Add to playlist</li>
|
||||
<li data-action="go-to-artist" data-type-filter="track,album,video">Go to artist</li>
|
||||
<li data-action="go-to-album" data-type-filter="track,video">Go to album</li>
|
||||
<li data-action="copy-link">Copy link</li>
|
||||
<li data-action="open-in-new-tab">Open in new tab</li>
|
||||
<li data-action="open-in-harmony" data-type-filter="album">Open in Harmony</li>
|
||||
<li data-action="track-info" data-type-filter="track">Track info</li>
|
||||
<li data-action="open-original-url" data-type-filter="track">Open original URL</li>
|
||||
<li data-action="track-info" data-type-filter="track,video">Track info</li>
|
||||
<li data-action="open-original-url" data-type-filter="track,video">Open original URL</li>
|
||||
<li data-action="download">Download</li>
|
||||
<li class="separator"></li>
|
||||
<li
|
||||
|
|
@ -116,7 +121,23 @@
|
|||
|
||||
<div id="fullscreen-cover-overlay" style="display: none">
|
||||
<div class="fullscreen-cover-content">
|
||||
<canvas id="visualizer-canvas"></canvas>
|
||||
<div id="visualizer-container">
|
||||
<canvas id="visualizer-canvas"></canvas>
|
||||
</div>
|
||||
<div
|
||||
id="fullscreen-video-container"
|
||||
style="
|
||||
display: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: black;
|
||||
z-index: 0;
|
||||
"
|
||||
></div>
|
||||
<button id="toggle-ui-btn" class="fullscreen-ui-toggle" title="Toggle UI">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
|
@ -158,6 +179,7 @@
|
|||
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
|
||||
alt="Album Cover"
|
||||
/>
|
||||
|
||||
<div class="fullscreen-track-info">
|
||||
<h2 id="fullscreen-track-title"></h2>
|
||||
<h3 id="fullscreen-track-artist"></h3>
|
||||
|
|
@ -2353,6 +2375,7 @@
|
|||
<h2 class="section-title" id="search-results-title">Search Results</h2>
|
||||
<div class="search-tabs">
|
||||
<button class="search-tab active" data-tab="tracks">Tracks</button>
|
||||
<button class="search-tab" data-tab="videos">Videos</button>
|
||||
<button class="search-tab" data-tab="albums">Albums</button>
|
||||
<button class="search-tab" data-tab="artists">Artists</button>
|
||||
<button class="search-tab" data-tab="playlists">Playlists</button>
|
||||
|
|
@ -2360,6 +2383,9 @@
|
|||
<div class="search-tab-content active" id="search-tab-tracks">
|
||||
<div class="track-list" id="search-tracks-container"></div>
|
||||
</div>
|
||||
<div class="search-tab-content" id="search-tab-videos">
|
||||
<div class="card-grid" id="search-videos-container"></div>
|
||||
</div>
|
||||
<div class="search-tab-content" id="search-tab-albums">
|
||||
<div class="card-grid" id="search-albums-container"></div>
|
||||
</div>
|
||||
|
|
@ -2398,6 +2424,7 @@
|
|||
<h2 class="section-title">Favorites</h2>
|
||||
<div class="search-tabs">
|
||||
<button class="search-tab active" data-tab="tracks">Liked Tracks</button>
|
||||
<button class="search-tab" data-tab="videos">Videos</button>
|
||||
<button class="search-tab" data-tab="albums">Albums</button>
|
||||
<button class="search-tab" data-tab="artists">Artists</button>
|
||||
<button class="search-tab" data-tab="playlists">Playlists and Mixes</button>
|
||||
|
|
@ -2470,6 +2497,9 @@
|
|||
</div>
|
||||
<div class="track-list" id="library-tracks-container"></div>
|
||||
</div>
|
||||
<div class="search-tab-content" id="library-tab-videos">
|
||||
<div class="card-grid" id="library-videos-container"></div>
|
||||
</div>
|
||||
<div class="search-tab-content" id="library-tab-albums">
|
||||
<div class="card-grid" id="library-albums-container"></div>
|
||||
</div>
|
||||
|
|
@ -3172,6 +3202,10 @@
|
|||
<h2 class="section-title">Albums</h2>
|
||||
<div class="card-grid" id="artist-detail-albums"></div>
|
||||
</section>
|
||||
<section class="content-section" id="artist-section-videos" style="display: none">
|
||||
<h2 class="section-title">Videos</h2>
|
||||
<div class="card-grid" id="artist-detail-videos"></div>
|
||||
</section>
|
||||
<section class="content-section" id="artist-section-eps" style="display: none">
|
||||
<h2 class="section-title">EPs and Singles</h2>
|
||||
<div class="card-grid" id="artist-detail-eps"></div>
|
||||
|
|
@ -3428,6 +3462,7 @@
|
|||
<button class="settings-tab" data-tab="scrobbling">Scrobbling</button>
|
||||
<button class="settings-tab" data-tab="audio">Audio</button>
|
||||
<button class="settings-tab" data-tab="downloads">Downloads</button>
|
||||
<button class="settings-tab" data-tab="instances">Instances</button>
|
||||
<button class="settings-tab" data-tab="system">System</button>
|
||||
</div>
|
||||
<div class="settings-tab-content active" id="settings-tab-appearance">
|
||||
|
|
@ -3665,10 +3700,13 @@
|
|||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Full-screen Visualizer</span>
|
||||
<span class="description">Enable particle visualizer in full-screen mode</span>
|
||||
<span class="description">Enable the visualizer in full-screen mode</span>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="visualizer-enabled-toggle" checked />
|
||||
|
|
@ -3685,6 +3723,7 @@
|
|||
<option value="particles">Particles</option>
|
||||
<option value="unknown-pleasures">Unknown Pleasures</option>
|
||||
<option value="butterchurn">Butterchurn (Milkdrop)</option>
|
||||
<option value="kawarp">Kawarp</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-item" id="visualizer-mode-setting">
|
||||
|
|
@ -3740,6 +3779,31 @@
|
|||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item" id="visualizer-dimming-setting">
|
||||
<div class="info">
|
||||
<span class="label">Visualizer Brightness</span>
|
||||
<span class="description"
|
||||
>Adjust the brightness of the visualizer. Lower this if the visualizer is
|
||||
too bright for you.</span
|
||||
>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 10px">
|
||||
<input
|
||||
type="range"
|
||||
id="visualizer-dimming-slider"
|
||||
min="0.1"
|
||||
max="1.0"
|
||||
step="0.05"
|
||||
value="1.0"
|
||||
style="width: 100px"
|
||||
/>
|
||||
<span
|
||||
id="visualizer-dimming-value"
|
||||
style="font-size: 0.9rem; min-width: 3em; text-align: right"
|
||||
>100%</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Butterchurn Settings -->
|
||||
<div class="setting-item" id="butterchurn-cycle-setting" style="display: none">
|
||||
<div class="info">
|
||||
|
|
@ -3868,6 +3932,33 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Compact Artists</span>
|
||||
<span class="description"
|
||||
>Show artist cards in a compact, horizontal layout</span
|
||||
>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="compact-artist-toggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Compact Albums</span>
|
||||
<span class="description"
|
||||
>Show album cards in a compact, horizontal layout</span
|
||||
>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="compact-album-toggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group" id="sidebar-order-settings-group">
|
||||
<div class="sidebar-settings-section sidebar-settings-main">
|
||||
<span class="sidebar-settings-section-label">TOP SECTION</span>
|
||||
|
|
@ -4502,43 +4593,6 @@
|
|||
<option value="LOW">AAC 96kbps</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Download Quality</span>
|
||||
<span class="description">Quality for track downloads</span>
|
||||
</div>
|
||||
<select id="download-quality-setting">
|
||||
<option value="HI_RES_LOSSLESS">Hi-Res FLAC (24-bit)</option>
|
||||
<option value="LOSSLESS">FLAC (Lossless)</option>
|
||||
<option value="MP3_320">MP3 320kbps</option>
|
||||
<option value="HIGH">AAC 320kbps</option>
|
||||
<option value="LOW">AAC 96kbps</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Lossless Container</span>
|
||||
<span class="description">Container format for lossless downloads</span>
|
||||
</div>
|
||||
<select id="lossless-container-setting">
|
||||
<option value="flac">FLAC</option>
|
||||
<option value="alac">Apple Lossless</option>
|
||||
<option value="nochange">Don't change</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Cover Art Size</span>
|
||||
<span class="description">Size for downloaded/embedded cover art</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="cover-art-size-setting"
|
||||
class="template-input"
|
||||
style="width: 120px; text-align: right"
|
||||
placeholder="1280x1280"
|
||||
/>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Show Quality Badges</span>
|
||||
|
|
@ -4571,6 +4625,9 @@
|
|||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">ReplayGain Mode</span>
|
||||
|
|
@ -4624,7 +4681,7 @@
|
|||
<span class="label">Playback Speed</span>
|
||||
<span class="description">Adjust playback speed (0.01x - 100x)</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 12px">
|
||||
<div class="playback-speed-control">
|
||||
<input
|
||||
type="range"
|
||||
id="playback-speed-slider"
|
||||
|
|
@ -4632,7 +4689,7 @@
|
|||
max="4.0"
|
||||
step="0.01"
|
||||
value="1.0"
|
||||
style="width: 150px"
|
||||
class="playback-speed-slider"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
|
|
@ -4641,18 +4698,16 @@
|
|||
max="100"
|
||||
step="0.01"
|
||||
value="1.0"
|
||||
style="
|
||||
width: 80px;
|
||||
text-align: center;
|
||||
font-family: var(--font-family);
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
"
|
||||
class="playback-speed-number-input"
|
||||
/>
|
||||
<span style="font-family: var(--font-family)">x</span>
|
||||
<span class="playback-speed-unit">x</span>
|
||||
<button
|
||||
id="playback-speed-reset"
|
||||
class="btn-secondary"
|
||||
title="Reset to default"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -4924,342 +4979,367 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-tab-content" id="settings-tab-downloads">
|
||||
<div class="settings-list">
|
||||
<div class="settings-group">
|
||||
<div class="info">
|
||||
<span class="label">Compact Artists</span>
|
||||
<span class="description">Show artist cards in a compact, horizontal layout</span>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Zipped Bulk Downloads</span>
|
||||
<span class="description"
|
||||
>Download multiple tracks as a single ZIP file (requires browser
|
||||
support)</span
|
||||
>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="zipped-bulk-downloads-toggle" checked />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="compact-artist-toggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Compact Albums</span>
|
||||
<span class="description">Show album cards in a compact, horizontal layout</span>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Download Lyrics</span>
|
||||
<span class="description"
|
||||
>Include .lrc files when downloading tracks/albums</span
|
||||
>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="download-lyrics-toggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="compact-album-toggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-tab-content" id="settings-tab-downloads">
|
||||
<div class="settings-list">
|
||||
<div class="settings-group">
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Zipped Bulk Downloads</span>
|
||||
<span class="description"
|
||||
>Download multiple tracks as a single ZIP file (requires browser support)</span
|
||||
>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Romaji Lyrics</span>
|
||||
<span class="description"
|
||||
>Convert Japanese lyrics to Romaji (Latin characters)</span
|
||||
>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="romaji-lyrics-toggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="zipped-bulk-downloads-toggle" checked />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Download Lyrics</span>
|
||||
<span class="description">Include .lrc files when downloading tracks/albums</span>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Download Quality</span>
|
||||
<span class="description">Quality for track downloads</span>
|
||||
</div>
|
||||
<select id="download-quality-setting">
|
||||
<option value="HI_RES_LOSSLESS">Hi-Res FLAC (24-bit)</option>
|
||||
<option value="LOSSLESS">FLAC (Lossless)</option>
|
||||
<option value="MP3_320">MP3 320kbps</option>
|
||||
<option value="HIGH">AAC 320kbps</option>
|
||||
<option value="LOW">AAC 96kbps</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="download-lyrics-toggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Romaji Lyrics</span>
|
||||
<span class="description"
|
||||
>Convert Japanese lyrics to Romaji (Latin characters)</span
|
||||
>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Lossless Container</span>
|
||||
<span class="description">Container format for lossless downloads</span>
|
||||
</div>
|
||||
<select id="lossless-container-setting">
|
||||
<option value="flac">FLAC</option>
|
||||
<option value="alac">Apple Lossless</option>
|
||||
<option value="nochange">Don't change</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="romaji-lyrics-toggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Filename Template</span>
|
||||
<span class="description"
|
||||
>Customize download filenames. Available: {trackNumber}, {artist}, {title},
|
||||
{album}</span
|
||||
>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="filename-template"
|
||||
class="template-input"
|
||||
placeholder="{trackNumber} - {artist} - {title}"
|
||||
/>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">ZIP Folder Template</span>
|
||||
<span class="description"
|
||||
>Customize album folder names. Available: {albumTitle}, {albumArtist},
|
||||
{year}</span
|
||||
>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="zip-folder-template"
|
||||
class="template-input"
|
||||
placeholder="{albumTitle} - {albumArtist} - monochrome.tf"
|
||||
/>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Generate M3U</span>
|
||||
<span class="description">Include M3U playlist files in downloads</span>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="generate-m3u-toggle" checked />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Generate M3U8</span>
|
||||
<span class="description">Include extended M3U8 playlist files in downloads</span>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="generate-m3u8-toggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Generate CUE</span>
|
||||
<span class="description"
|
||||
>Include CUE sheets for gapless playback in downloads</span
|
||||
>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="generate-cue-toggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Generate NFO</span>
|
||||
<span class="description">Include NFO files for media center compatibility</span>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="generate-nfo-toggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Generate JSON</span>
|
||||
<span class="description">Include JSON files with rich metadata</span>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="generate-json-toggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Relative Paths</span>
|
||||
<span class="description">Use relative paths in playlist files</span>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="relative-paths-toggle" checked />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Separate Discs in ZIP</span>
|
||||
<span class="description"
|
||||
>Put tracks in Disc folders when a release has multiple discs</span
|
||||
>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="separate-discs-zip-toggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-tab-content" id="settings-tab-system">
|
||||
<div class="settings-list">
|
||||
<div class="settings-group">
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Keyboard Shortcuts</span>
|
||||
<span class="description">View and customize keyboard shortcuts</span>
|
||||
</div>
|
||||
<button id="customize-shortcuts-btn" class="btn-secondary">Customize</button>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Cache</span>
|
||||
<span class="description" id="cache-info"
|
||||
>Stores API responses to reduce requests</span
|
||||
>
|
||||
</div>
|
||||
<button id="clear-cache-btn" class="btn-secondary">Clear Cache</button>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Auto-Update App</span>
|
||||
<span class="description"
|
||||
>Automatically reload when a new version is available</span
|
||||
>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="pwa-auto-update-toggle" checked />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-item" id="desktop-update-container" style="display: none">
|
||||
<div class="info">
|
||||
<span class="label">Desktop Update</span>
|
||||
<span class="description">Check for updates to the desktop application</span>
|
||||
</div>
|
||||
<button id="check-desktop-updates-btn" class="btn-secondary">Check for Updates</button>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Analytics</span>
|
||||
<span class="description">Send anonymous usage data to help improve the app</span>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="analytics-toggle" checked />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Reset Local Data</span>
|
||||
<span class="description"
|
||||
>Clear all local storage and cached data (does not affect cloud sync)</span
|
||||
>
|
||||
</div>
|
||||
<button id="reset-local-data-btn" class="btn-secondary danger">Reset</button>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Clear Cloud Data</span>
|
||||
<span class="description"
|
||||
>Delete all your data from the cloud (cannot be undone)</span
|
||||
>
|
||||
</div>
|
||||
<button id="firebase-clear-cloud-btn" class="btn-secondary danger">
|
||||
Clear Cloud Data
|
||||
</button>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Backup & Restore</span>
|
||||
<span class="description">Export or import your library and history as JSON</span>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem">
|
||||
<button id="export-library-btn" class="btn-secondary">Export</button>
|
||||
<button id="import-library-btn" class="btn-secondary">Import</button>
|
||||
<input type="file" id="import-library-input" style="display: none" accept=".json" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Export All Settings</span>
|
||||
<span class="description">Export all app settings as JSON</span>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem">
|
||||
<button id="export-settings-btn" class="btn-secondary">Export</button>
|
||||
<button id="import-settings-btn" class="btn-secondary">Import</button>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Cover Art Size</span>
|
||||
<span class="description">Size for downloaded/embedded cover art</span>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
id="import-settings-input"
|
||||
style="display: none"
|
||||
accept=".json"
|
||||
type="text"
|
||||
id="cover-art-size-setting"
|
||||
class="template-input"
|
||||
style="width: 120px; text-align: right"
|
||||
placeholder="1280x1280"
|
||||
/>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Filename Template</span>
|
||||
<span class="description"
|
||||
>Customize download filenames. Available: {trackNumber}, {artist}, {title},
|
||||
{album}</span
|
||||
>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="filename-template"
|
||||
class="template-input"
|
||||
placeholder="{trackNumber} - {artist} - {title}"
|
||||
/>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">ZIP Folder Template</span>
|
||||
<span class="description"
|
||||
>Customize album folder names. Available: {albumTitle}, {albumArtist},
|
||||
{year}</span
|
||||
>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="zip-folder-template"
|
||||
class="template-input"
|
||||
placeholder="{albumTitle} - {albumArtist} - monochrome.tf"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">ADVANCED: Custom Database/Auth</span>
|
||||
<span class="description">Configure custom PocketBase and Firebase instances</span>
|
||||
</div>
|
||||
<button id="custom-db-btn" class="btn-secondary">Configure</button>
|
||||
</div>
|
||||
<div id="api-instance-manager">
|
||||
<div class="setting-item" style="padding-bottom: 1rem; border: none">
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">API Instances</span>
|
||||
<span class="description">Manage and prioritize API instances.</span>
|
||||
<span class="label">Generate M3U</span>
|
||||
<span class="description">Include M3U playlist files in downloads</span>
|
||||
</div>
|
||||
<button id="refresh-speed-test-btn" class="btn-secondary">
|
||||
Refresh Instance List
|
||||
</button>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="generate-m3u-toggle" checked />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Generate M3U8</span>
|
||||
<span class="description"
|
||||
>Include extended M3U8 playlist files in downloads</span
|
||||
>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="generate-m3u8-toggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Generate CUE</span>
|
||||
<span class="description"
|
||||
>Include CUE sheets for gapless playback in downloads</span
|
||||
>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="generate-cue-toggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Generate NFO</span>
|
||||
<span class="description"
|
||||
>Include NFO files for media center compatibility</span
|
||||
>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="generate-nfo-toggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Generate JSON</span>
|
||||
<span class="description">Include JSON files with rich metadata</span>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="generate-json-toggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<ul id="api-instance-list"></ul>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" style="padding-bottom: 1rem; border-top: 1px solid var(--border)">
|
||||
<div class="info">
|
||||
<span class="label">Blocked Content</span>
|
||||
<span class="description"
|
||||
>Manage artists, albums, and tracks you've blocked from recommendations</span
|
||||
>
|
||||
<div class="settings-group">
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Relative Paths</span>
|
||||
<span class="description">Use relative paths in playlist files</span>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="relative-paths-toggle" checked />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem">
|
||||
<button id="manage-blocked-btn" class="btn-secondary">Manage</button>
|
||||
<button
|
||||
id="clear-all-blocked-btn"
|
||||
class="btn-secondary danger"
|
||||
style="display: none"
|
||||
>
|
||||
Clear All
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Separate Discs in ZIP</span>
|
||||
<span class="description"
|
||||
>Put tracks in Disc folders when a release has multiple discs</span
|
||||
>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="separate-discs-zip-toggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-tab-content" id="settings-tab-instances">
|
||||
<div class="settings-list">
|
||||
<div class="settings-group">
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">ADVANCED: Custom Database/Auth</span>
|
||||
<span class="description"
|
||||
>Configure custom PocketBase and Firebase instances</span
|
||||
>
|
||||
</div>
|
||||
<button id="custom-db-btn" class="btn-secondary">Configure</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div id="api-instance-manager">
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">API Instances</span>
|
||||
<span class="description">Manage and prioritize API instances.</span>
|
||||
</div>
|
||||
<button id="refresh-speed-test-btn" class="btn-secondary">
|
||||
Refresh Instance List
|
||||
</button>
|
||||
</div>
|
||||
<ul id="api-instance-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-tab-content" id="settings-tab-system">
|
||||
<div class="settings-list">
|
||||
<div class="settings-group">
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Keyboard Shortcuts</span>
|
||||
<span class="description">View and customize keyboard shortcuts</span>
|
||||
</div>
|
||||
<button id="customize-shortcuts-btn" class="btn-secondary">Customize</button>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Cache</span>
|
||||
<span class="description" id="cache-info"
|
||||
>Stores API responses to reduce requests</span
|
||||
>
|
||||
</div>
|
||||
<button id="clear-cache-btn" class="btn-secondary">Clear Cache</button>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Auto-Update App</span>
|
||||
<span class="description"
|
||||
>Automatically reload when a new version is available</span
|
||||
>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="pwa-auto-update-toggle" checked />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-item" id="desktop-update-container" style="display: none">
|
||||
<div class="info">
|
||||
<span class="label">Desktop Update</span>
|
||||
<span class="description">Check for updates to the desktop application</span>
|
||||
</div>
|
||||
<button id="check-desktop-updates-btn" class="btn-secondary">
|
||||
Check for Updates
|
||||
</button>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Analytics</span>
|
||||
<span class="description"
|
||||
>Send anonymous usage data to help improve the app</span
|
||||
>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="analytics-toggle" checked />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Reset Local Data</span>
|
||||
<span class="description"
|
||||
>Clear all local storage and cached data (does not affect cloud sync)</span
|
||||
>
|
||||
</div>
|
||||
<button id="reset-local-data-btn" class="btn-secondary danger">Reset</button>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Clear Cloud Data</span>
|
||||
<span class="description"
|
||||
>Delete all your data from the cloud (cannot be undone)</span
|
||||
>
|
||||
</div>
|
||||
<button id="firebase-clear-cloud-btn" class="btn-secondary danger">
|
||||
Clear Cloud Data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="blocked-content-list" style="display: none">
|
||||
<div id="blocked-artists-section" style="margin-bottom: 1rem">
|
||||
<h4
|
||||
style="font-size: 0.9rem; margin-bottom: 0.5rem; color: var(--muted-foreground)"
|
||||
>
|
||||
Blocked Artists
|
||||
</h4>
|
||||
<ul id="blocked-artists-list" class="blocked-items-list"></ul>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Backup & Restore</span>
|
||||
<span class="description"
|
||||
>Export or import your library and history as JSON</span
|
||||
>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem">
|
||||
<button id="export-library-btn" class="btn-secondary">Export</button>
|
||||
<button id="import-library-btn" class="btn-secondary">Import</button>
|
||||
<input
|
||||
type="file"
|
||||
id="import-library-input"
|
||||
style="display: none"
|
||||
accept=".json"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div id="blocked-albums-section" style="margin-bottom: 1rem">
|
||||
<h4
|
||||
style="font-size: 0.9rem; margin-bottom: 0.5rem; color: var(--muted-foreground)"
|
||||
>
|
||||
Blocked Albums
|
||||
</h4>
|
||||
<ul id="blocked-albums-list" class="blocked-items-list"></ul>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Export All Settings</span>
|
||||
<span class="description">Export all app settings as JSON</span>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem">
|
||||
<button id="export-settings-btn" class="btn-secondary">Export</button>
|
||||
<button id="import-settings-btn" class="btn-secondary">Import</button>
|
||||
<input
|
||||
type="file"
|
||||
id="import-settings-input"
|
||||
style="display: none"
|
||||
accept=".json"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div id="blocked-tracks-section" style="margin-bottom: 1rem">
|
||||
<h4
|
||||
style="font-size: 0.9rem; margin-bottom: 0.5rem; color: var(--muted-foreground)"
|
||||
>
|
||||
Blocked Tracks
|
||||
</h4>
|
||||
<ul id="blocked-tracks-list" class="blocked-items-list"></ul>
|
||||
</div>
|
||||
<div
|
||||
id="blocked-empty-message"
|
||||
style="
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
color: var(--muted-foreground);
|
||||
display: none;
|
||||
"
|
||||
>
|
||||
No blocked content
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Blocked Content</span>
|
||||
<span class="description"
|
||||
>Manage artists, albums, and tracks you've blocked from
|
||||
recommendations</span
|
||||
>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem">
|
||||
<button id="manage-blocked-btn" class="btn-secondary">Manage</button>
|
||||
<button
|
||||
id="clear-all-blocked-btn"
|
||||
class="btn-secondary danger"
|
||||
style="display: none"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export class AuthManager {
|
|||
this.user = await auth.get();
|
||||
this.updateUI(this.user);
|
||||
this.authListeners.forEach((listener) => listener(this.user));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
this.user = null;
|
||||
this.updateUI(null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -200,6 +200,19 @@ const syncManager = {
|
|||
};
|
||||
}
|
||||
|
||||
if (type === 'video') {
|
||||
return {
|
||||
...base,
|
||||
type: 'video',
|
||||
title: item.title || null,
|
||||
duration: item.duration || null,
|
||||
image: item.image || item.cover || null,
|
||||
artist: item.artist || (item.artists && item.artists.length > 0 ? item.artists[0] : null) || null,
|
||||
artists: item.artists?.map((a) => ({ id: a.id, name: a.name || null })) || [],
|
||||
album: item.album || { title: 'Video', cover: item.image || item.cover },
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'album') {
|
||||
return {
|
||||
...base,
|
||||
|
|
@ -280,7 +293,7 @@ const syncManager = {
|
|||
id: playlist.id,
|
||||
name: playlist.name,
|
||||
cover: playlist.cover || null,
|
||||
tracks: playlist.tracks ? playlist.tracks.map((t) => this._minifyItem('track', t)) : [],
|
||||
tracks: playlist.tracks ? playlist.tracks.map((t) => this._minifyItem(t.type || 'track', t)) : [],
|
||||
createdAt: playlist.createdAt || Date.now(),
|
||||
updatedAt: playlist.updatedAt || Date.now(),
|
||||
numberOfTracks: playlist.tracks ? playlist.tracks.length : 0,
|
||||
|
|
@ -572,7 +585,9 @@ const syncManager = {
|
|||
id: playlist.id,
|
||||
name: playlist.name,
|
||||
cover: playlist.cover || null,
|
||||
tracks: playlist.tracks ? playlist.tracks.map((t) => this._minifyItem('track', t)) : [],
|
||||
tracks: playlist.tracks
|
||||
? playlist.tracks.map((t) => this._minifyItem(t.type || 'track', t))
|
||||
: [],
|
||||
createdAt: playlist.createdAt || Date.now(),
|
||||
updatedAt: playlist.updatedAt || Date.now(),
|
||||
numberOfTracks: playlist.tracks ? playlist.tracks.length : 0,
|
||||
|
|
|
|||
358
js/api.js
358
js/api.js
|
|
@ -10,6 +10,7 @@ import { trackDateSettings, losslessContainerSettings } from './storage.js';
|
|||
import { APICache } from './cache.js';
|
||||
import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js';
|
||||
import { DashDownloader } from './dash-downloader.js';
|
||||
import { HlsDownloader } from './hls-downloader.js';
|
||||
import { encodeToMp3, MP3EncodingError } from './mp3-encoder.js';
|
||||
import { ffmpeg, loadFfmpeg } from './ffmpeg.js';
|
||||
|
||||
|
|
@ -59,6 +60,18 @@ export class LosslessAPI {
|
|||
}
|
||||
}
|
||||
|
||||
if (options.allowedDomains) {
|
||||
instances = instances.filter((instance) => {
|
||||
const url = typeof instance === 'string' ? instance : instance.url;
|
||||
return options.allowedDomains.some((domain) => url.includes(domain));
|
||||
});
|
||||
if (instances.length === 0) {
|
||||
throw new Error(
|
||||
`No API instances configured for type: ${type} matching allowedDomains: ${options.allowedDomains.join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const maxTotalAttempts = instances.length * 2; // Allow some retries across instances
|
||||
let lastError = null;
|
||||
let instanceIndex = Math.floor(Math.random() * instances.length);
|
||||
|
|
@ -156,8 +169,15 @@ export class LosslessAPI {
|
|||
prepareTrack(track) {
|
||||
let normalized = track;
|
||||
|
||||
if (track.type && typeof track.type === 'string') {
|
||||
const lowType = track.type.toLowerCase();
|
||||
if (lowType === 'video' || lowType === 'track') {
|
||||
normalized = { ...track, type: lowType };
|
||||
}
|
||||
}
|
||||
|
||||
if (!track.artist && Array.isArray(track.artists) && track.artists.length > 0) {
|
||||
normalized = { ...track, artist: track.artists[0] };
|
||||
normalized = { ...normalized, artist: track.artists[0] };
|
||||
}
|
||||
|
||||
const derivedQuality = deriveTrackQuality(normalized);
|
||||
|
|
@ -181,6 +201,16 @@ export class LosslessAPI {
|
|||
return playlist;
|
||||
}
|
||||
|
||||
prepareVideo(video) {
|
||||
let normalized = { ...video, type: 'video' };
|
||||
|
||||
if (!video.artist && Array.isArray(video.artists) && video.artists.length > 0) {
|
||||
normalized.artist = video.artists[0];
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
prepareArtist(artist) {
|
||||
if (!artist.type && Array.isArray(artist.artistTypes) && artist.artistTypes.length > 0) {
|
||||
return { ...artist, type: artist.artistTypes[0] };
|
||||
|
|
@ -264,8 +294,22 @@ export class LosslessAPI {
|
|||
}
|
||||
|
||||
extractStreamUrlFromManifest(manifest) {
|
||||
if (!manifest) return null;
|
||||
|
||||
try {
|
||||
const decoded = atob(manifest);
|
||||
let decoded;
|
||||
if (typeof manifest === 'string') {
|
||||
try {
|
||||
decoded = atob(manifest);
|
||||
} catch {
|
||||
decoded = manifest;
|
||||
}
|
||||
} else if (typeof manifest === 'object') {
|
||||
if (manifest.urls?.[0]) return manifest.urls[0];
|
||||
return null;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if it's a DASH manifest (XML)
|
||||
if (decoded.includes('<MPD')) {
|
||||
|
|
@ -414,6 +458,53 @@ export class LosslessAPI {
|
|||
}
|
||||
}
|
||||
|
||||
async searchVideos(query, options = {}) {
|
||||
const cached = await this.cache.get('search_videos', query);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const response = await this.fetchWithRetry(`/search/?v=${encodeURIComponent(query)}`, {
|
||||
...options,
|
||||
allowedDomains: ['api.monochrome.tf', 'arran.monochrome.tf'],
|
||||
});
|
||||
const data = await response.json();
|
||||
const normalized = this.normalizeSearchResponse(data, 'videos');
|
||||
const result = {
|
||||
...normalized,
|
||||
items: normalized.items.map((v) => this.prepareVideo(v)),
|
||||
};
|
||||
|
||||
await this.cache.set('search_videos', query, result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') throw error;
|
||||
console.error('Video search failed:', error);
|
||||
return { items: [], limit: 0, offset: 0, totalNumberOfItems: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
async getVideo(id) {
|
||||
const cached = await this.cache.get('video', id);
|
||||
if (cached) return cached;
|
||||
|
||||
const response = await this.fetchWithRetry(`/video/?id=${id}`, {
|
||||
type: 'streaming',
|
||||
allowedDomains: ['api.monochrome.tf', 'arran.monochrome.tf'],
|
||||
});
|
||||
const jsonResponse = await response.json();
|
||||
|
||||
const data = jsonResponse.data || jsonResponse;
|
||||
|
||||
const result = {
|
||||
track: data,
|
||||
info: data,
|
||||
originalTrackUrl: data.OriginalTrackUrl || null,
|
||||
};
|
||||
|
||||
await this.cache.set('video', id, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async getAlbum(id) {
|
||||
const cached = await this.cache.get('album', id);
|
||||
if (cached) return cached;
|
||||
|
|
@ -769,9 +860,11 @@ export class LosslessAPI {
|
|||
|
||||
const albumMap = new Map();
|
||||
const trackMap = new Map();
|
||||
const videoMap = new Map();
|
||||
|
||||
const isTrack = (v) => v?.id && v.duration && v.album;
|
||||
const isAlbum = (v) => v?.id && 'numberOfTracks' in v;
|
||||
const isVideo = (v) => v?.id && v.type === 'VIDEO';
|
||||
|
||||
const scan = (value, visited = new Set()) => {
|
||||
if (!value || typeof value !== 'object' || visited.has(value)) return;
|
||||
|
|
@ -785,6 +878,7 @@ export class LosslessAPI {
|
|||
const item = value.item || value;
|
||||
if (isAlbum(item)) albumMap.set(item.id, this.prepareAlbum(item));
|
||||
if (isTrack(item)) trackMap.set(item.id, this.prepareTrack(item));
|
||||
if (isVideo(item)) videoMap.set(item.id, this.prepareVideo(item));
|
||||
|
||||
Object.values(value).forEach((nested) => scan(nested, visited));
|
||||
};
|
||||
|
|
@ -792,25 +886,23 @@ export class LosslessAPI {
|
|||
entries.forEach((entry) => scan(entry));
|
||||
|
||||
if (!options.lightweight) {
|
||||
// Attempt to find more albums/EPs via search since the direct feed might be limited
|
||||
try {
|
||||
const searchResults = await this.searchAlbums(artist.name);
|
||||
if (searchResults && searchResults.items) {
|
||||
const videoSearch = await this.searchVideos(artist.name);
|
||||
if (videoSearch && videoSearch.items) {
|
||||
const numericArtistId = Number(artistId);
|
||||
|
||||
for (const item of searchResults.items) {
|
||||
for (const item of videoSearch.items) {
|
||||
const itemArtistId = item.artist?.id;
|
||||
const matchesArtist =
|
||||
itemArtistId === numericArtistId ||
|
||||
(Array.isArray(item.artists) && item.artists.some((a) => a.id === numericArtistId));
|
||||
|
||||
if (matchesArtist && !albumMap.has(item.id)) {
|
||||
albumMap.set(item.id, item);
|
||||
if (matchesArtist && !videoMap.has(item.id)) {
|
||||
videoMap.set(item.id, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch additional albums via search:', e);
|
||||
console.warn('Failed to fetch additional videos via search:', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -826,10 +918,14 @@ export class LosslessAPI {
|
|||
.sort((a, b) => (b.popularity || 0) - (a.popularity || 0))
|
||||
.slice(0, 15);
|
||||
|
||||
const videos = Array.from(videoMap.values()).sort(
|
||||
(a, b) => new Date(b.releaseDate || 0) - new Date(a.releaseDate || 0)
|
||||
);
|
||||
|
||||
// Enrich tracks with album release dates
|
||||
const tracks = options.lightweight ? topTracks : await this.enrichTracksWithAlbumDates(topTracks);
|
||||
|
||||
const result = { ...artist, albums, eps, tracks };
|
||||
const result = { ...artist, albums, eps, tracks, videos };
|
||||
|
||||
await this.cache.set('artist', cacheKey, result);
|
||||
return result;
|
||||
|
|
@ -1108,25 +1204,97 @@ export class LosslessAPI {
|
|||
return streamUrl;
|
||||
}
|
||||
|
||||
async getVideoStreamUrl(id) {
|
||||
const cacheKey = `video_stream_${id}`;
|
||||
|
||||
if (this.streamCache.has(cacheKey)) {
|
||||
return this.streamCache.get(cacheKey);
|
||||
}
|
||||
|
||||
const lookup = await this.getVideo(id);
|
||||
|
||||
let streamUrl;
|
||||
|
||||
const findValue = (obj, key) => {
|
||||
if (!obj || typeof obj !== 'object') return null;
|
||||
if (obj[key]) return obj[key];
|
||||
for (const v of Object.values(obj)) {
|
||||
if (v && typeof v === 'object') {
|
||||
const f = findValue(v, key);
|
||||
if (f) return f;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const manifest = findValue(lookup, 'manifest') || findValue(lookup, 'Manifest');
|
||||
if (manifest) {
|
||||
streamUrl = this.extractStreamUrlFromManifest(manifest);
|
||||
}
|
||||
|
||||
if (!streamUrl) {
|
||||
streamUrl =
|
||||
findValue(lookup, 'OriginalTrackUrl') ||
|
||||
findValue(lookup, 'originalTrackUrl') ||
|
||||
findValue(lookup, 'url') ||
|
||||
findValue(lookup, 'streamUrl') ||
|
||||
findValue(lookup, 'manifestUrl');
|
||||
}
|
||||
|
||||
if (!streamUrl) {
|
||||
throw new Error(`Could not resolve video stream URL for ID: ${id}`);
|
||||
}
|
||||
|
||||
this.streamCache.set(cacheKey, streamUrl);
|
||||
return streamUrl;
|
||||
}
|
||||
|
||||
async downloadTrack(id, quality = 'HI_RES_LOSSLESS', filename, options = {}) {
|
||||
// Load ffmpeg in the background.
|
||||
loadFfmpeg().catch(console.error);
|
||||
|
||||
const { onProgress, track } = options;
|
||||
const prefetchPromises = prefetchMetadataObjects(track, this);
|
||||
const isVideo = track?.type === 'video';
|
||||
|
||||
try {
|
||||
// MP3_320 is not a native TIDAL quality, we download LOSSLESS and convert
|
||||
const downloadQuality = quality === 'MP3_320' ? 'LOSSLESS' : quality;
|
||||
|
||||
const lookup = await this.getTrack(id, downloadQuality);
|
||||
let lookup;
|
||||
if (isVideo) {
|
||||
lookup = await this.getVideo(id);
|
||||
} else {
|
||||
lookup = await this.getTrack(id, downloadQuality);
|
||||
}
|
||||
|
||||
let streamUrl;
|
||||
let blob;
|
||||
|
||||
if (lookup.originalTrackUrl) {
|
||||
streamUrl = lookup.originalTrackUrl;
|
||||
} else {
|
||||
streamUrl = this.extractStreamUrlFromManifest(lookup.info.manifest);
|
||||
const findValue = (obj, key) => {
|
||||
if (!obj || typeof obj !== 'object') return null;
|
||||
if (obj[key]) return obj[key];
|
||||
for (const v of Object.values(obj)) {
|
||||
if (v && typeof v === 'object') {
|
||||
const f = findValue(v, key);
|
||||
if (f) return f;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const manifest = isVideo
|
||||
? findValue(lookup, 'manifest') || findValue(lookup, 'Manifest')
|
||||
: lookup.info?.manifest;
|
||||
|
||||
if (!manifest) {
|
||||
throw new Error('Could not resolve manifest');
|
||||
}
|
||||
|
||||
streamUrl = this.extractStreamUrlFromManifest(manifest);
|
||||
if (!streamUrl) {
|
||||
throw new Error('Could not resolve stream URL');
|
||||
}
|
||||
|
|
@ -1142,6 +1310,8 @@ export class LosslessAPI {
|
|||
});
|
||||
} catch (dashError) {
|
||||
console.error('DASH download failed:', dashError);
|
||||
if (isVideo) throw dashError;
|
||||
|
||||
// Fallback to LOSSLESS if DASH fails, but not if we're already downloading LOSSLESS
|
||||
if (downloadQuality !== 'LOSSLESS') {
|
||||
console.warn('Falling back to LOSSLESS (16-bit) download.');
|
||||
|
|
@ -1149,6 +1319,17 @@ export class LosslessAPI {
|
|||
}
|
||||
throw dashError;
|
||||
}
|
||||
} else if (streamUrl.includes('.m3u8') || streamUrl.includes('application/vnd.apple.mpegurl')) {
|
||||
try {
|
||||
const downloader = new HlsDownloader();
|
||||
blob = await downloader.downloadHlsStream(streamUrl, {
|
||||
signal: options.signal,
|
||||
onProgress: options.onProgress,
|
||||
});
|
||||
} catch (hlsError) {
|
||||
console.error('HLS download failed:', hlsError);
|
||||
throw hlsError;
|
||||
}
|
||||
} else {
|
||||
const response = await fetch(streamUrl, {
|
||||
cache: 'no-store',
|
||||
|
|
@ -1159,7 +1340,6 @@ export class LosslessAPI {
|
|||
throw new Error(`Fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
// ... (standard handling for Content-Length and body reader)
|
||||
const contentLength = response.headers.get('Content-Length');
|
||||
const totalBytes = contentLength ? parseInt(contentLength, 10) : 0;
|
||||
|
||||
|
|
@ -1185,7 +1365,8 @@ export class LosslessAPI {
|
|||
}
|
||||
}
|
||||
|
||||
blob = new Blob(chunks, { type: response.headers.get('Content-Type') || 'audio/flac' });
|
||||
const defaultMime = isVideo ? 'video/mp4' : 'audio/flac';
|
||||
blob = new Blob(chunks, { type: response.headers.get('Content-Type') || defaultMime });
|
||||
} else {
|
||||
blob = await response.blob();
|
||||
if (onProgress) {
|
||||
|
|
@ -1198,91 +1379,78 @@ export class LosslessAPI {
|
|||
}
|
||||
}
|
||||
|
||||
// Convert to MP3 320kbps if requested
|
||||
if (quality === 'MP3_320') {
|
||||
try {
|
||||
blob = await encodeToMp3(
|
||||
blob,
|
||||
(progress) => {
|
||||
console.log(progress);
|
||||
onProgress?.(progress);
|
||||
},
|
||||
options.signal
|
||||
);
|
||||
} catch (encodingError) {
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
stage: 'error',
|
||||
message: `Encoding failed: ${encodingError.message}`,
|
||||
});
|
||||
if (!isVideo) {
|
||||
// Convert to MP3 320kbps if requested
|
||||
if (quality === 'MP3_320') {
|
||||
try {
|
||||
blob = await encodeToMp3(blob, onProgress, options.signal);
|
||||
} catch (encodingError) {
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
stage: 'error',
|
||||
message: `Encoding failed: ${encodingError.message}`,
|
||||
});
|
||||
}
|
||||
throw encodingError;
|
||||
}
|
||||
throw encodingError;
|
||||
}
|
||||
}
|
||||
|
||||
if (quality.endsWith('LOSSLESS')) {
|
||||
try {
|
||||
switch (losslessContainerSettings.getContainer()) {
|
||||
case 'flac':
|
||||
if ((await getExtensionFromBlob(blob)) != 'flac') {
|
||||
if (quality.endsWith('LOSSLESS')) {
|
||||
try {
|
||||
switch (losslessContainerSettings.getContainer()) {
|
||||
case 'flac':
|
||||
blob = await ffmpeg(
|
||||
blob,
|
||||
{ args: ['-c:a', 'copy'] },
|
||||
{ args: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'] },
|
||||
'output.flac',
|
||||
'audio/flac',
|
||||
(progress) => {
|
||||
console.log(progress);
|
||||
onProgress?.(progress);
|
||||
},
|
||||
onProgress,
|
||||
options.signal
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'alac':
|
||||
blob = await ffmpeg(
|
||||
blob,
|
||||
{ args: ['-c:a', 'alac'] },
|
||||
'output.m4a',
|
||||
'audio/mp4',
|
||||
(progress) => {
|
||||
console.log(progress);
|
||||
onProgress?.(progress);
|
||||
},
|
||||
options.signal
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
break;
|
||||
case 'alac':
|
||||
blob = await ffmpeg(
|
||||
blob,
|
||||
{ args: ['-c:a', 'alac'] },
|
||||
'output.m4a',
|
||||
'audio/mp4',
|
||||
onProgress,
|
||||
options.signal
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.error('Lossless container conversion failed:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Add metadata if track information is provided
|
||||
if (track) {
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
stage: 'processing',
|
||||
message: 'Adding metadata...',
|
||||
});
|
||||
}
|
||||
|
||||
console.error('Lossless container conversion failed:', error);
|
||||
}
|
||||
}
|
||||
const enrichedTrack = { ...track };
|
||||
if (lookup.info) {
|
||||
enrichedTrack.replayGain = {
|
||||
trackReplayGain: lookup.info.trackReplayGain,
|
||||
trackPeakAmplitude: lookup.info.trackPeakAmplitude,
|
||||
albumReplayGain: lookup.info.albumReplayGain,
|
||||
albumPeakAmplitude: lookup.info.albumPeakAmplitude,
|
||||
};
|
||||
}
|
||||
|
||||
// Add metadata if track information is provided
|
||||
if (track) {
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
stage: 'processing',
|
||||
message: 'Adding metadata...',
|
||||
});
|
||||
blob = await addMetadataToAudio(blob, enrichedTrack, this, quality, prefetchPromises);
|
||||
}
|
||||
|
||||
const enrichedTrack = { ...track };
|
||||
if (lookup.info) {
|
||||
enrichedTrack.replayGain = {
|
||||
trackReplayGain: lookup.info.trackReplayGain,
|
||||
trackPeakAmplitude: lookup.info.trackPeakAmplitude,
|
||||
albumReplayGain: lookup.info.albumReplayGain,
|
||||
albumPeakAmplitude: lookup.info.albumPeakAmplitude,
|
||||
};
|
||||
}
|
||||
|
||||
blob = await addMetadataToAudio(blob, enrichedTrack, this, quality, prefetchPromises);
|
||||
}
|
||||
|
||||
// Detect actual format and fix filename extension if needed
|
||||
|
|
@ -1296,6 +1464,7 @@ export class LosslessAPI {
|
|||
}
|
||||
|
||||
this.triggerDownload(blob, finalFilename);
|
||||
return blob;
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
throw error;
|
||||
|
|
@ -1331,23 +1500,10 @@ export class LosslessAPI {
|
|||
return id;
|
||||
}
|
||||
|
||||
const formattedId = id.replace(/-/g, '/');
|
||||
const formattedId = String(id).replace(/-/g, '/');
|
||||
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
|
||||
}
|
||||
|
||||
getVideoCoverUrl(id, size = '1280') {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = id.split('-');
|
||||
if (parts.length !== 5) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `https://resources.tidal.com/videos/${parts[0]}/${parts[1]}/${parts[2]}/${parts[3]}/${parts[4]}/${size}x${size}.mp4`;
|
||||
}
|
||||
|
||||
getArtistPictureUrl(id, size = '320') {
|
||||
if (!id) {
|
||||
return `https://picsum.photos/seed/${Math.random()}/${size}`;
|
||||
|
|
@ -1357,7 +1513,7 @@ export class LosslessAPI {
|
|||
return id;
|
||||
}
|
||||
|
||||
const formattedId = id.replace(/-/g, '/');
|
||||
const formattedId = String(id).replace(/-/g, '/');
|
||||
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1963,20 +1963,23 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
|
||||
db.getPlaylist(playlistId).then(async (playlist) => {
|
||||
let trackId = null;
|
||||
let trackType = null;
|
||||
|
||||
// Prefer ID if available (from sorted view)
|
||||
if (btn.dataset.trackId) {
|
||||
trackId = btn.dataset.trackId;
|
||||
trackType = btn.dataset.type || 'track';
|
||||
} else if (btn.dataset.trackIndex) {
|
||||
// Fallback to index (legacy/unsorted)
|
||||
const index = parseInt(btn.dataset.trackIndex);
|
||||
if (playlist && playlist.tracks[index]) {
|
||||
trackId = playlist.tracks[index].id;
|
||||
trackType = playlist.tracks[index].type || 'track';
|
||||
}
|
||||
}
|
||||
|
||||
if (trackId) {
|
||||
const updatedPlaylist = await db.removeTrackFromPlaylist(playlistId, trackId);
|
||||
const updatedPlaylist = await db.removeTrackFromPlaylist(playlistId, trackId, trackType);
|
||||
syncManager.syncUserPlaylist(updatedPlaylist, 'update');
|
||||
const scrollTop = document.querySelector('.main-content').scrollTop;
|
||||
await ui.renderPlaylistPage(playlistId, 'user');
|
||||
|
|
@ -2986,8 +2989,6 @@ function showCustomizeShortcutsModal() {
|
|||
let recordingAction = null;
|
||||
let recordingTimeout = null;
|
||||
|
||||
const shortcuts = keyboardShortcuts.getShortcuts();
|
||||
|
||||
const formatKey = (key) => {
|
||||
if (!key) return 'none';
|
||||
const keyMap = {
|
||||
|
|
|
|||
39
js/db.js
39
js/db.js
|
|
@ -1,7 +1,7 @@
|
|||
export class MusicDatabase {
|
||||
constructor() {
|
||||
this.dbName = 'MonochromeDB';
|
||||
this.version = 8;
|
||||
this.version = 9;
|
||||
this.db = null;
|
||||
}
|
||||
|
||||
|
|
@ -29,6 +29,10 @@ export class MusicDatabase {
|
|||
const store = db.createObjectStore('favorites_tracks', { keyPath: 'id' });
|
||||
store.createIndex('addedAt', 'addedAt', { unique: false });
|
||||
}
|
||||
if (!db.objectStoreNames.contains('favorites_videos')) {
|
||||
const store = db.createObjectStore('favorites_videos', { keyPath: 'id' });
|
||||
store.createIndex('addedAt', 'addedAt', { unique: false });
|
||||
}
|
||||
if (!db.objectStoreNames.contains('favorites_albums')) {
|
||||
const store = db.createObjectStore('favorites_albums', { keyPath: 'id' });
|
||||
store.createIndex('addedAt', 'addedAt', { unique: false });
|
||||
|
|
@ -88,7 +92,7 @@ export class MusicDatabase {
|
|||
// History API
|
||||
async addToHistory(track) {
|
||||
const storeName = 'history_tracks';
|
||||
const minified = this._minifyItem('track', track);
|
||||
const minified = this._minifyItem(track.type || 'track', track);
|
||||
const timestamp = Date.now();
|
||||
const entry = { ...minified, timestamp };
|
||||
|
||||
|
|
@ -209,6 +213,7 @@ export class MusicDatabase {
|
|||
|
||||
_minifyItem(type, item) {
|
||||
if (!item) return item;
|
||||
const normalizedType = (type || '').toLowerCase();
|
||||
|
||||
// Base properties to keep
|
||||
const base = {
|
||||
|
|
@ -216,7 +221,7 @@ export class MusicDatabase {
|
|||
addedAt: item.addedAt || null,
|
||||
};
|
||||
|
||||
if (type === 'track') {
|
||||
if (normalizedType === 'track') {
|
||||
return {
|
||||
...base,
|
||||
title: item.title || null,
|
||||
|
|
@ -256,6 +261,19 @@ export class MusicDatabase {
|
|||
};
|
||||
}
|
||||
|
||||
if (normalizedType === 'video') {
|
||||
return {
|
||||
...base,
|
||||
type: 'video',
|
||||
title: item.title || null,
|
||||
duration: item.duration || null,
|
||||
image: item.image || item.cover || null,
|
||||
artist: item.artist || (item.artists && item.artists.length > 0 ? item.artists[0] : null) || null,
|
||||
artists: item.artists?.map((a) => ({ id: a.id, name: a.name || null })) || [],
|
||||
album: item.album || { title: 'Video', cover: item.image || item.cover },
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'album') {
|
||||
return {
|
||||
...base,
|
||||
|
|
@ -548,7 +566,7 @@ export class MusicDatabase {
|
|||
const playlist = {
|
||||
id: id,
|
||||
name: name,
|
||||
tracks: tracks.map((t) => this._minifyItem('track', { ...t, addedAt: Date.now() })),
|
||||
tracks: tracks.map((t) => this._minifyItem(t.type || 'track', { ...t, addedAt: Date.now() })),
|
||||
cover: cover,
|
||||
description: description,
|
||||
createdAt: Date.now(),
|
||||
|
|
@ -570,7 +588,7 @@ export class MusicDatabase {
|
|||
if (!playlist) throw new Error('Playlist not found');
|
||||
playlist.tracks = playlist.tracks || [];
|
||||
const trackWithDate = { ...track, addedAt: Date.now() };
|
||||
const minifiedTrack = this._minifyItem('track', trackWithDate);
|
||||
const minifiedTrack = this._minifyItem(track.type || 'track', trackWithDate);
|
||||
if (playlist.tracks.some((t) => t.id === track.id)) return;
|
||||
playlist.tracks.push(minifiedTrack);
|
||||
playlist.updatedAt = Date.now();
|
||||
|
|
@ -591,7 +609,7 @@ export class MusicDatabase {
|
|||
for (const track of tracks) {
|
||||
if (!playlist.tracks.some((t) => t.id === track.id)) {
|
||||
const trackWithDate = { ...track, addedAt: Date.now() };
|
||||
playlist.tracks.push(this._minifyItem('track', trackWithDate));
|
||||
playlist.tracks.push(this._minifyItem(track.type || 'track', trackWithDate));
|
||||
addedCount++;
|
||||
}
|
||||
}
|
||||
|
|
@ -606,11 +624,16 @@ export class MusicDatabase {
|
|||
return playlist;
|
||||
}
|
||||
|
||||
async removeTrackFromPlaylist(playlistId, trackId) {
|
||||
async removeTrackFromPlaylist(playlistId, trackId, trackType = null) {
|
||||
const playlist = await this.performTransaction('user_playlists', 'readonly', (store) => store.get(playlistId));
|
||||
if (!playlist) throw new Error('Playlist not found');
|
||||
playlist.tracks = playlist.tracks || [];
|
||||
playlist.tracks = playlist.tracks.filter((t) => t.id != trackId);
|
||||
playlist.tracks = playlist.tracks.filter((t) => {
|
||||
if (trackType) {
|
||||
return !(t.id == trackId && (t.type || 'track') === trackType);
|
||||
}
|
||||
return t.id != trackId;
|
||||
});
|
||||
playlist.updatedAt = Date.now();
|
||||
this._updatePlaylistMetadata(playlist);
|
||||
await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export function initializeDiscordRPC(player) {
|
|||
|
||||
let coverUrl = 'monochrome';
|
||||
if (track.album?.cover) {
|
||||
const coverId = track.album.cover.replace(/-/g, '/');
|
||||
const coverId = String(track.album.cover).replace(/-/g, '/');
|
||||
coverUrl = `https://resources.tidal.com/images/${coverId}/320x320.jpg`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -195,11 +195,21 @@ export function updateDownloadProgress(trackId, progress) {
|
|||
const percent = progress.totalBytes ? Math.round((progress.receivedBytes / progress.totalBytes) * 100) : 0;
|
||||
|
||||
progressFill.style.width = `${percent}%`;
|
||||
progressFill.style.background = 'var(--highlight)';
|
||||
|
||||
const receivedMB = (progress.receivedBytes / (1024 * 1024)).toFixed(1);
|
||||
const totalMB = progress.totalBytes ? (progress.totalBytes / (1024 * 1024)).toFixed(1) : '?';
|
||||
|
||||
statusEl.textContent = `Downloading: ${receivedMB}MB / ${totalMB}MB (${percent}%)`;
|
||||
} else if (progress.stage === 'encoding') {
|
||||
const percent = progress.progress ? Math.round(progress.progress) : 0;
|
||||
progressFill.style.width = `${percent}%`;
|
||||
progressFill.style.background = '#3b82f6'; // Blue for encoding
|
||||
statusEl.textContent = `Converting: ${percent}%`;
|
||||
} else if (progress.stage === 'finalizing' || progress.stage === 'processing') {
|
||||
progressFill.style.width = '100%';
|
||||
progressFill.style.background = '#3b82f6';
|
||||
statusEl.textContent = progress.message || 'Processing...';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -268,7 +278,7 @@ function removeBulkDownloadTask(notifEl) {
|
|||
}, 300);
|
||||
}
|
||||
|
||||
async function downloadTrackBlob(track, quality, api, lyricsManager = null, signal = null) {
|
||||
async function downloadTrackBlob(track, quality, api, lyricsManager = null, signal = null, onProgress = null) {
|
||||
// Load ffmpeg in the background.
|
||||
loadFfmpeg().catch(console.error);
|
||||
|
||||
|
|
@ -348,7 +358,7 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null, sign
|
|||
// Fallback
|
||||
if (downloadQuality !== 'LOSSLESS') {
|
||||
console.warn('Falling back to LOSSLESS (16-bit) download.');
|
||||
return downloadTrackBlob(track, 'LOSSLESS', api, lyricsManager, signal);
|
||||
return downloadTrackBlob(track, 'LOSSLESS', api, lyricsManager, signal, onProgress);
|
||||
}
|
||||
throw dashError;
|
||||
}
|
||||
|
|
@ -362,23 +372,21 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null, sign
|
|||
|
||||
// Convert to MP3 320kbps if requested
|
||||
if (quality === 'MP3_320') {
|
||||
blob = await encodeToMp3(blob, console.log, signal);
|
||||
blob = await encodeToMp3(blob, onProgress || (() => undefined), signal);
|
||||
}
|
||||
|
||||
if (quality.endsWith('LOSSLESS')) {
|
||||
try {
|
||||
switch (losslessContainerSettings.getContainer()) {
|
||||
case 'flac':
|
||||
if ((await getExtensionFromBlob(blob)) != 'flac') {
|
||||
blob = await ffmpeg(
|
||||
blob,
|
||||
{ args: ['-c:a', 'copy'] },
|
||||
'output.flac',
|
||||
'audio/flac',
|
||||
console.log,
|
||||
signal
|
||||
);
|
||||
}
|
||||
blob = await ffmpeg(
|
||||
blob,
|
||||
{ args: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'] },
|
||||
'output.flac',
|
||||
'audio/flac',
|
||||
onProgress,
|
||||
signal
|
||||
);
|
||||
break;
|
||||
case 'alac':
|
||||
blob = await ffmpeg(
|
||||
|
|
@ -386,7 +394,7 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null, sign
|
|||
{ args: ['-c:a', 'alac'] },
|
||||
'output.m4a',
|
||||
'audio/mp4',
|
||||
console.log,
|
||||
onProgress,
|
||||
signal
|
||||
);
|
||||
break;
|
||||
|
|
@ -561,7 +569,9 @@ async function bulkDownloadToZipStream(
|
|||
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
|
||||
|
||||
try {
|
||||
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal);
|
||||
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal, (p) => {
|
||||
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p);
|
||||
});
|
||||
const filename = buildTrackFilename(track, quality, extension);
|
||||
const discNumber = discLayout.resolveDiscNumber(i);
|
||||
yield {
|
||||
|
|
@ -703,7 +713,9 @@ async function bulkDownloadToZipBlob(
|
|||
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
|
||||
|
||||
try {
|
||||
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal);
|
||||
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal, (p) => {
|
||||
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p);
|
||||
});
|
||||
const filename = buildTrackFilename(track, quality, extension);
|
||||
const discNumber = discLayout.resolveDiscNumber(i);
|
||||
yield {
|
||||
|
|
@ -846,7 +858,9 @@ async function bulkDownloadToZipNeutralino(
|
|||
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
|
||||
|
||||
try {
|
||||
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal);
|
||||
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal, (p) => {
|
||||
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p);
|
||||
});
|
||||
const filename = buildTrackFilename(track, quality, extension);
|
||||
const discNumber = discLayout.resolveDiscNumber(i);
|
||||
yield {
|
||||
|
|
@ -1308,12 +1322,21 @@ function createBulkDownloadNotification(type, name, _totalItems) {
|
|||
return notifEl;
|
||||
}
|
||||
|
||||
function updateBulkDownloadProgress(notifEl, current, total, currentItem) {
|
||||
function updateBulkDownloadProgress(notifEl, current, total, currentItem, ffmpegProgress = null) {
|
||||
const progressFill = notifEl.querySelector('.download-progress-fill');
|
||||
const statusEl = notifEl.querySelector('.download-status');
|
||||
|
||||
if (ffmpegProgress && (ffmpegProgress.stage === 'encoding' || ffmpegProgress.stage === 'finalizing')) {
|
||||
const percent = ffmpegProgress.progress ? Math.round(ffmpegProgress.progress) : 100;
|
||||
progressFill.style.width = `${percent}%`;
|
||||
progressFill.style.background = '#3b82f6'; // Blue for encoding
|
||||
statusEl.textContent = `Converting ${current}/${total}: ${percent}%`;
|
||||
return;
|
||||
}
|
||||
|
||||
const percent = total > 0 ? Math.round((current / total) * 100) : 0;
|
||||
progressFill.style.width = `${percent}%`;
|
||||
progressFill.style.background = 'var(--highlight)';
|
||||
statusEl.textContent = `${current}/${total} - ${currentItem}`;
|
||||
}
|
||||
|
||||
|
|
|
|||
132
js/events.js
132
js/events.js
|
|
@ -50,6 +50,7 @@ import {
|
|||
trackSetSleepTimer,
|
||||
trackCancelSleepTimer,
|
||||
trackStartMix,
|
||||
trackEvent,
|
||||
} from './analytics.js';
|
||||
|
||||
let currentTrackIdForWaveform = null;
|
||||
|
|
@ -952,11 +953,13 @@ export async function handleTrackAction(
|
|||
// Track like/unlike
|
||||
if (added) {
|
||||
if (type === 'track') trackLikeTrack(item);
|
||||
else if (type === 'video') trackEvent('Like Video', { title: item.title });
|
||||
else if (type === 'album') trackLikeAlbum(item);
|
||||
else if (type === 'artist') trackLikeArtist(item);
|
||||
else if (type === 'playlist' || type === 'user-playlist') trackLikePlaylist(item);
|
||||
} else {
|
||||
if (type === 'track') trackUnlikeTrack(item);
|
||||
else if (type === 'video') trackEvent('Unlike Video', { title: item.title });
|
||||
else if (type === 'album') trackUnlikeAlbum(item);
|
||||
else if (type === 'artist') trackUnlikeArtist(item);
|
||||
else if (type === 'playlist' || type === 'user-playlist') trackUnlikePlaylist(item);
|
||||
|
|
@ -976,7 +979,9 @@ export async function handleTrackAction(
|
|||
const selector =
|
||||
type === 'track'
|
||||
? `[data-track-id="${id}"] .like-btn`
|
||||
: `.card[data-${type}-id="${id}"] .like-btn, .card[data-playlist-id="${id}"] .like-btn`;
|
||||
: type === 'video'
|
||||
? `.card[data-video-id="${id}"] .like-btn`
|
||||
: `.card[data-${type}-id="${id}"] .like-btn, .card[data-playlist-id="${id}"] .like-btn`;
|
||||
|
||||
// Also check header buttons
|
||||
const headerBtn = document.getElementById(`like-${type}-btn`);
|
||||
|
|
@ -985,12 +990,12 @@ export async function handleTrackAction(
|
|||
if (headerBtn) elementsToUpdate.push(headerBtn);
|
||||
|
||||
const nowPlayingLikeBtn = document.getElementById('now-playing-like-btn');
|
||||
if (nowPlayingLikeBtn && type === 'track' && player?.currentTrack?.id === item.id) {
|
||||
if (nowPlayingLikeBtn && (type === 'track' || type === 'video') && player?.currentTrack?.id === item.id) {
|
||||
elementsToUpdate.push(nowPlayingLikeBtn);
|
||||
}
|
||||
|
||||
const fsLikeBtn = document.getElementById('fs-like-btn');
|
||||
if (fsLikeBtn && type === 'track' && player?.currentTrack?.id === item.id) {
|
||||
if (fsLikeBtn && (type === 'track' || type === 'video') && player?.currentTrack?.id === item.id) {
|
||||
elementsToUpdate.push(fsLikeBtn);
|
||||
}
|
||||
|
||||
|
|
@ -1011,7 +1016,9 @@ export async function handleTrackAction(
|
|||
const itemSelector =
|
||||
type === 'track'
|
||||
? `.track-item[data-track-id="${id}"]`
|
||||
: `.card[data-${type}-id="${id}"], .card[data-playlist-id="${id}"]`;
|
||||
: type === 'video'
|
||||
? `.video-card[data-video-id="${id}"]`
|
||||
: `.card[data-${type}-id="${id}"], .card[data-playlist-id="${id}"]`;
|
||||
|
||||
const itemEl = document.querySelector(itemSelector);
|
||||
|
||||
|
|
@ -1020,29 +1027,63 @@ export async function handleTrackAction(
|
|||
const container = itemEl.parentElement;
|
||||
itemEl.remove();
|
||||
if (container && container.children.length === 0) {
|
||||
const msg = type === 'track' ? 'No liked tracks yet.' : `No liked ${type}s yet.`;
|
||||
const msg =
|
||||
type === 'track'
|
||||
? 'No liked tracks yet.'
|
||||
: type === 'video'
|
||||
? 'No liked videos yet.'
|
||||
: `No liked ${type}s yet.`;
|
||||
container.innerHTML = `<div class="placeholder-text">${msg}</div>`;
|
||||
}
|
||||
} else if (added && !itemEl && ui && type === 'track') {
|
||||
// Add item (specifically for tracks currently)
|
||||
const tracksContainer = document.getElementById('library-tracks-container');
|
||||
if (tracksContainer) {
|
||||
// Remove placeholder if it exists
|
||||
const placeholder = tracksContainer.querySelector('.placeholder-text');
|
||||
if (placeholder) placeholder.remove();
|
||||
} else if (added && !itemEl && ui && (type === 'track' || type === 'video')) {
|
||||
// Add item
|
||||
if (type === 'track') {
|
||||
const tracksContainer = document.getElementById('library-tracks-container');
|
||||
if (tracksContainer) {
|
||||
const placeholder = tracksContainer.querySelector('.placeholder-text');
|
||||
if (placeholder) placeholder.remove();
|
||||
|
||||
// Create track element
|
||||
const index = tracksContainer.children.length;
|
||||
const trackHTML = ui.createTrackItemHTML(item, index, true, false);
|
||||
const index = tracksContainer.children.length;
|
||||
const trackHTML = ui.createTrackItemHTML(item, index, true, false);
|
||||
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = trackHTML;
|
||||
const newEl = tempDiv.firstElementChild;
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = trackHTML;
|
||||
const newEl = tempDiv.firstElementChild;
|
||||
|
||||
if (newEl) {
|
||||
tracksContainer.appendChild(newEl);
|
||||
trackDataStore.set(newEl, item);
|
||||
ui.updateLikeState(newEl, 'track', item.id);
|
||||
if (newEl) {
|
||||
tracksContainer.appendChild(newEl);
|
||||
trackDataStore.set(newEl, item);
|
||||
ui.updateLikeState(newEl, 'track', item.id);
|
||||
}
|
||||
}
|
||||
} else if (type === 'video') {
|
||||
const videosTabContent = document.getElementById('library-tab-videos');
|
||||
if (videosTabContent) {
|
||||
const grid = videosTabContent.querySelector('.card-grid');
|
||||
if (grid) {
|
||||
const placeholder = grid.querySelector('.placeholder-text');
|
||||
if (placeholder) grid.innerHTML = '';
|
||||
|
||||
const videoHTML = ui.createVideoCardHTML(item);
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = videoHTML;
|
||||
const newEl = tempDiv.firstElementChild;
|
||||
|
||||
if (newEl) {
|
||||
grid.appendChild(newEl);
|
||||
trackDataStore.set(newEl, item);
|
||||
ui.updateLikeState(newEl, 'video', item.id);
|
||||
newEl.addEventListener('click', (e) => {
|
||||
if (
|
||||
e.target.closest('.card-play-btn') ||
|
||||
e.target.closest('.card-image-container')
|
||||
) {
|
||||
e.stopPropagation();
|
||||
player.playVideo(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1058,10 +1099,14 @@ export async function handleTrackAction(
|
|||
// Removed empty check to allow creating new playlist
|
||||
|
||||
const trackId = item.id;
|
||||
const trackType = item.type || 'track';
|
||||
const playlistsWithTrack = new Set();
|
||||
|
||||
for (const playlist of playlists) {
|
||||
if (playlist.tracks && playlist.tracks.some((track) => track.id == trackId)) {
|
||||
if (
|
||||
playlist.tracks &&
|
||||
playlist.tracks.some((t) => t.id == trackId && (t.type || 'track') === trackType)
|
||||
) {
|
||||
playlistsWithTrack.add(playlist.id);
|
||||
}
|
||||
}
|
||||
|
|
@ -1435,9 +1480,10 @@ async function updateContextMenuLikeState(contextMenu, contextTrack) {
|
|||
const type = contextMenu._contextType || 'track';
|
||||
|
||||
const likeItem = contextMenu.querySelector('li[data-action="toggle-like"]');
|
||||
let isLiked = false;
|
||||
if (likeItem) {
|
||||
const isLiked = await db.isFavorite(type, contextTrack.id);
|
||||
likeItem.textContent = isLiked ? 'Unlike' : 'Like';
|
||||
const key = type === 'playlist' ? contextTrack.uuid : contextTrack.id;
|
||||
isLiked = await db.isFavorite(type, key);
|
||||
}
|
||||
|
||||
const pinItem = contextMenu.querySelector('li[data-action="toggle-pin"]');
|
||||
|
|
@ -1500,8 +1546,10 @@ async function updateContextMenuLikeState(contextMenu, contextTrack) {
|
|||
|
||||
// Update labels for Like/Save
|
||||
if (item.dataset.action === 'toggle-like') {
|
||||
const labelKey = `label${type.charAt(0).toUpperCase() + type.slice(1).replace('User-playlist', 'Playlist')}`;
|
||||
const label = item.dataset[labelKey] || item.dataset.labelTrack || 'Like';
|
||||
const labelPrefix = isLiked ? 'labelUnlike' : 'label';
|
||||
const labelKey = `${labelPrefix}${type.charAt(0).toUpperCase() + type.slice(1).replace('User-playlist', 'Playlist')}`;
|
||||
const fallbackKey = isLiked ? 'labelUnlikeTrack' : 'labelTrack';
|
||||
const label = item.dataset[labelKey] || item.dataset[fallbackKey] || (isLiked ? 'Unlike' : 'Like');
|
||||
item.textContent = label;
|
||||
}
|
||||
});
|
||||
|
|
@ -1642,7 +1690,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
|||
contextMenu._originalHTML = null;
|
||||
}
|
||||
contextMenu._contextTrack = contextTrack;
|
||||
contextMenu._contextType = 'track';
|
||||
contextMenu._contextType = menuBtn.dataset.type || trackItem.dataset.type || 'track';
|
||||
await updateContextMenuLikeState(contextMenu, contextTrack);
|
||||
const rect = menuBtn.getBoundingClientRect();
|
||||
positionMenu(contextMenu, rect.left, rect.bottom + 5, rect);
|
||||
|
|
@ -1667,15 +1715,19 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
|||
if (isSearch) {
|
||||
const clickedTrack = trackDataStore.get(trackItem);
|
||||
if (clickedTrack) {
|
||||
player.setQueue([clickedTrack], 0);
|
||||
document.getElementById('shuffle-btn').classList.remove('active');
|
||||
player.playTrackFromQueue();
|
||||
if (trackItem.dataset.type === 'video') {
|
||||
player.playVideo(clickedTrack);
|
||||
} else {
|
||||
player.setQueue([clickedTrack], 0);
|
||||
document.getElementById('shuffle-btn').classList.remove('active');
|
||||
player.playTrackFromQueue();
|
||||
|
||||
api.getTrackRecommendations(clickedTrack.id).then((recs) => {
|
||||
if (recs && recs.length > 0) {
|
||||
player.addToQueue(recs);
|
||||
}
|
||||
});
|
||||
api.getTrackRecommendations(clickedTrack.id).then((recs) => {
|
||||
if (recs && recs.length > 0) {
|
||||
player.addToQueue(recs);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const parentList = trackItem.closest('.track-list');
|
||||
|
|
@ -1760,7 +1812,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
|||
});
|
||||
|
||||
contextMenu._contextTrack = contextTrack;
|
||||
contextMenu._contextType = 'track';
|
||||
contextMenu._contextType = contextTrack.type || 'track';
|
||||
await updateContextMenuLikeState(contextMenu, contextTrack);
|
||||
positionMenu(contextMenu, e.clientX, e.clientY);
|
||||
}
|
||||
|
|
@ -1916,7 +1968,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
|||
player,
|
||||
api,
|
||||
lyricsManager,
|
||||
'track',
|
||||
player.currentTrack.type || 'track',
|
||||
ui,
|
||||
scrobbler
|
||||
);
|
||||
|
|
@ -1954,7 +2006,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
|||
player,
|
||||
api,
|
||||
lyricsManager,
|
||||
'track',
|
||||
player.currentTrack.type || 'track',
|
||||
ui,
|
||||
scrobbler
|
||||
);
|
||||
|
|
@ -1975,7 +2027,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
|||
player,
|
||||
api,
|
||||
lyricsManager,
|
||||
'track',
|
||||
player.currentTrack.type || 'track',
|
||||
ui,
|
||||
scrobbler
|
||||
);
|
||||
|
|
|
|||
107
js/hls-downloader.js
Normal file
107
js/hls-downloader.js
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
export class HlsDownloader {
|
||||
constructor() {}
|
||||
|
||||
async downloadHlsStream(masterUrl, options = {}) {
|
||||
const { onProgress, signal } = options;
|
||||
|
||||
const response = await fetch(masterUrl, { signal });
|
||||
const masterText = await response.text();
|
||||
|
||||
const variantUrl = this.getBestVariantUrl(masterUrl, masterText);
|
||||
|
||||
const mediaResponse = await fetch(variantUrl, { signal });
|
||||
const mediaText = await mediaResponse.text();
|
||||
|
||||
const segments = this.parseMediaPlaylist(variantUrl, mediaText);
|
||||
if (segments.length === 0) {
|
||||
throw new Error('No segments found in HLS playlist');
|
||||
}
|
||||
|
||||
const chunks = [];
|
||||
let downloadedBytes = 0;
|
||||
const totalSegments = segments.length;
|
||||
|
||||
for (let i = 0; i < totalSegments; i++) {
|
||||
if (signal?.aborted) throw new Error('AbortError');
|
||||
|
||||
const segmentUrl = segments[i];
|
||||
const segmentResponse = await fetch(segmentUrl, { signal });
|
||||
|
||||
if (!segmentResponse.ok) {
|
||||
throw new Error(`Failed to fetch segment ${i}: ${segmentResponse.status}`);
|
||||
}
|
||||
|
||||
const chunk = await segmentResponse.arrayBuffer();
|
||||
chunks.push(chunk);
|
||||
downloadedBytes += chunk.byteLength;
|
||||
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
stage: 'downloading',
|
||||
receivedBytes: downloadedBytes,
|
||||
totalBytes: undefined,
|
||||
currentSegment: i + 1,
|
||||
totalSegments: totalSegments,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const mimeType = segments[0].endsWith('.m4s') || segments[0].includes('mp4') ? 'video/mp4' : 'video/mp2t';
|
||||
return new Blob(chunks, { type: mimeType });
|
||||
}
|
||||
|
||||
getBestVariantUrl(masterUrl, masterText) {
|
||||
if (!masterText.includes('#EXT-X-STREAM-INF')) {
|
||||
return masterUrl;
|
||||
}
|
||||
|
||||
const lines = masterText.split('\n');
|
||||
const variants = [];
|
||||
let currentVariant = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('#EXT-X-STREAM-INF:')) {
|
||||
const bandwidthMatch = trimmed.match(/BANDWIDTH=(\d+)/);
|
||||
const resolutionMatch = trimmed.match(/RESOLUTION=(\d+x\d+)/);
|
||||
currentVariant = {
|
||||
bandwidth: bandwidthMatch ? parseInt(bandwidthMatch[1], 10) : 0,
|
||||
resolution: resolutionMatch ? resolutionMatch[1] : 'unknown',
|
||||
};
|
||||
} else if (trimmed && !trimmed.startsWith('#')) {
|
||||
if (currentVariant) {
|
||||
currentVariant.url = this.resolveUrl(masterUrl, trimmed);
|
||||
variants.push(currentVariant);
|
||||
currentVariant = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (variants.length === 0) return masterUrl;
|
||||
|
||||
variants.sort((a, b) => b.bandwidth - a.bandwidth);
|
||||
return variants[0].url;
|
||||
}
|
||||
|
||||
parseMediaPlaylist(mediaUrl, mediaText) {
|
||||
const lines = mediaText.split('\n');
|
||||
const segments = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && !trimmed.startsWith('#')) {
|
||||
segments.push(this.resolveUrl(mediaUrl, trimmed));
|
||||
}
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
resolveUrl(baseUrl, relativeUrl) {
|
||||
try {
|
||||
return new URL(relativeUrl, baseUrl).href;
|
||||
} catch {
|
||||
return relativeUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -779,9 +779,6 @@ export function openLyricsPanel(track, audioPlayer, lyricsManager, forceOpen = f
|
|||
const offsetDisplay = manager.getOffsetDisplayString(manager.timingOffset);
|
||||
|
||||
container.innerHTML = `
|
||||
<button id="close-side-panel-btn" class="btn-icon" title="Close">
|
||||
${SVG_CLOSE}
|
||||
</button>
|
||||
<div class="lyrics-timing-controls">
|
||||
<button id="lyrics-timing-minus-btn" class="btn-icon" title="Decrease delay (lyrics earlier) -0.5s">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
|
|
@ -810,6 +807,9 @@ export function openLyricsPanel(track, audioPlayer, lyricsManager, forceOpen = f
|
|||
<button id="genius-toggle-btn" class="btn-icon ${isGeniusMode ? 'active-genius' : ''}" title="Genius Mode" style="${isGeniusMode ? 'color: #ffff64;' : ''}">
|
||||
${isGeniusMode ? SVG_GENIUS_ACTIVE : SVG_GENIUS_INACTIVE}
|
||||
</button>
|
||||
<button id="close-side-panel-btn" class="btn-icon" title="Close">
|
||||
${SVG_CLOSE}
|
||||
</button>
|
||||
`;
|
||||
|
||||
container.querySelector('#close-side-panel-btn').addEventListener('click', () => {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export class MusicAPI {
|
|||
this.tidalAPI = new LosslessAPI(settings);
|
||||
this.qobuzAPI = new QobuzAPI();
|
||||
this._settings = settings;
|
||||
this.videoArtworkCache = new Map();
|
||||
}
|
||||
|
||||
getCurrentProvider() {
|
||||
|
|
@ -47,6 +48,11 @@ export class MusicAPI {
|
|||
return this.tidalAPI.searchPlaylists(query, options);
|
||||
}
|
||||
|
||||
async searchVideos(query, options = {}) {
|
||||
const provider = options.provider || this.getCurrentProvider();
|
||||
return this.tidalAPI.searchVideos(query, options);
|
||||
}
|
||||
|
||||
// Get methods
|
||||
async getTrack(id, quality, provider = null) {
|
||||
const p = provider || this.getProviderFromId(id) || this.getCurrentProvider();
|
||||
|
|
@ -88,6 +94,22 @@ export class MusicAPI {
|
|||
return null;
|
||||
}
|
||||
|
||||
async getVideo(id, provider = null) {
|
||||
const p = provider || this.getProviderFromId(id) || this.getCurrentProvider();
|
||||
const api = this.getAPI(p);
|
||||
const cleanId = this.stripProviderPrefix(id);
|
||||
return api.getVideo(cleanId);
|
||||
}
|
||||
|
||||
async getVideoStreamUrl(id, provider = null) {
|
||||
const p = provider || this.getProviderFromId(id) || this.getCurrentProvider();
|
||||
const api = this.getAPI(p);
|
||||
const cleanId = this.stripProviderPrefix(id);
|
||||
if (typeof api.getVideoStreamUrl === 'function') {
|
||||
return api.getVideoStreamUrl(cleanId);
|
||||
}
|
||||
}
|
||||
|
||||
async getArtistSocials(artistName) {
|
||||
return this.tidalAPI.getArtistSocials(artistName);
|
||||
}
|
||||
|
|
@ -131,12 +153,27 @@ export class MusicAPI {
|
|||
return this.tidalAPI.getCoverUrl(id, size);
|
||||
}
|
||||
|
||||
getVideoCoverUrl(videoCoverId, fallbackCoverId, size = '1280') {
|
||||
if (videoCoverId) {
|
||||
const videoUrl = this.tidalAPI.getVideoCoverUrl(videoCoverId, size);
|
||||
if (videoUrl) return videoUrl;
|
||||
async getVideoArtwork(title, artist) {
|
||||
const cacheKey = `${title}-${artist}`.toLowerCase();
|
||||
if (this.videoArtworkCache.has(cacheKey)) {
|
||||
return this.videoArtworkCache.get(cacheKey);
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `https://artwork.boidu.dev/?s=${encodeURIComponent(title)}&a=${encodeURIComponent(artist)}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
const result = {
|
||||
videoUrl: data.videoUrl || null,
|
||||
hlsUrl: data.animated || null,
|
||||
};
|
||||
this.videoArtworkCache.set(cacheKey, result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch video artwork:', error);
|
||||
return null;
|
||||
}
|
||||
return this.getCoverUrl(fallbackCoverId, size);
|
||||
}
|
||||
|
||||
getArtistPictureUrl(id, size = '320') {
|
||||
|
|
|
|||
166
js/player.js
166
js/player.js
|
|
@ -18,6 +18,7 @@ import {
|
|||
audioEffectsSettings,
|
||||
} from './storage.js';
|
||||
import { audioContextManager } from './audio-context.js';
|
||||
import Hls from 'hls.js';
|
||||
|
||||
export class Player {
|
||||
constructor(audioElement, api, quality = 'HI_RES_LOSSLESS') {
|
||||
|
|
@ -42,6 +43,7 @@ export class Player {
|
|||
typeof window !== 'undefined' &&
|
||||
(window.matchMedia?.('(display-mode: standalone)')?.matches || window.navigator?.standalone === true);
|
||||
|
||||
this.hls = null;
|
||||
// Sleep timer properties
|
||||
this.sleepTimer = null;
|
||||
this.sleepTimerEndTime = null;
|
||||
|
|
@ -200,10 +202,9 @@ export class Player {
|
|||
const artistEl = document.querySelector('.now-playing-bar .artist');
|
||||
|
||||
if (coverEl) {
|
||||
const videoCoverUrl = track.album?.videoCover
|
||||
? this.api.tidalAPI.getVideoCoverUrl(track.album.videoCover)
|
||||
: null;
|
||||
const coverUrl = videoCoverUrl || this.api.getCoverUrl(track.album?.cover);
|
||||
const videoCoverUrl = track.videoUrl || track.videoCoverUrl || track.album?.videoCoverUrl || null;
|
||||
const coverUrl =
|
||||
videoCoverUrl || this.api.getCoverUrl(track.image || track.cover || track.album?.cover);
|
||||
|
||||
if (videoCoverUrl) {
|
||||
if (coverEl.tagName === 'IMG') {
|
||||
|
|
@ -215,7 +216,10 @@ export class Player {
|
|||
video.playsInline = true;
|
||||
video.className = coverEl.className;
|
||||
video.id = coverEl.id;
|
||||
video.style.objectFit = 'cover';
|
||||
coverEl.replaceWith(video);
|
||||
} else if (coverEl.tagName === 'VIDEO' && coverEl.src !== videoCoverUrl) {
|
||||
coverEl.src = videoCoverUrl;
|
||||
}
|
||||
} else {
|
||||
if (coverEl.tagName === 'VIDEO') {
|
||||
|
|
@ -402,6 +406,60 @@ export class Player {
|
|||
}
|
||||
}
|
||||
|
||||
setupHlsVideo(video, result, fallbackImg) {
|
||||
const url = result.videoUrl || result.hlsUrl || result; // Allow passing just the URL
|
||||
if (!url) return;
|
||||
|
||||
if (this.hls) {
|
||||
this.hls.destroy();
|
||||
this.hls = null;
|
||||
}
|
||||
|
||||
if (typeof url === 'string' && (url.includes('.m3u8') || url.includes('application/vnd.apple.mpegurl'))) {
|
||||
if (Hls.isSupported()) {
|
||||
this.hls = new Hls();
|
||||
this.hls.loadSource(url);
|
||||
this.hls.attachMedia(video);
|
||||
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
video.play().catch(() => {});
|
||||
});
|
||||
this.hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
if (data.fatal) {
|
||||
console.warn('HLS fatal error:', data.type);
|
||||
if (fallbackImg) video.replaceWith(fallbackImg);
|
||||
this.hls.destroy();
|
||||
this.hls = null;
|
||||
}
|
||||
});
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = url;
|
||||
} else {
|
||||
if (fallbackImg) video.replaceWith(fallbackImg);
|
||||
}
|
||||
} else {
|
||||
video.src = url;
|
||||
video.onerror = () => {
|
||||
if (result && result.hlsUrl) {
|
||||
this.setupHlsVideo(video, { videoUrl: null, hlsUrl: result.hlsUrl }, fallbackImg);
|
||||
} else if (fallbackImg) {
|
||||
video.replaceWith(fallbackImg);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async playVideo(video) {
|
||||
if (!video) return;
|
||||
const videoTrack = {
|
||||
...video,
|
||||
type: 'video',
|
||||
artist: video.artist || (video.artists && video.artists[0]) || 'Unknown Artist',
|
||||
album: video.album || { title: 'Video', cover: video.image || video.cover },
|
||||
};
|
||||
this.setQueue([videoTrack], 0);
|
||||
await this.playTrackFromQueue();
|
||||
}
|
||||
|
||||
async playTrackFromQueue(startTime = 0, recursiveCount = 0) {
|
||||
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
|
||||
if (this.currentQueueIndex < 0 || this.currentQueueIndex >= currentQueue.length) {
|
||||
|
|
@ -431,34 +489,42 @@ export class Player {
|
|||
const trackArtistsHTML = getTrackArtistsHTML(track);
|
||||
const yearDisplay = getTrackYearDisplay(track);
|
||||
|
||||
const coverEl = document.querySelector('.now-playing-bar .cover');
|
||||
if (coverEl) {
|
||||
const videoCoverUrl = track.album?.videoCover
|
||||
? this.api.tidalAPI.getVideoCoverUrl(track.album.videoCover)
|
||||
: null;
|
||||
const coverUrl = videoCoverUrl || this.api.getCoverUrl(track.album?.cover);
|
||||
const trackInfo = document.querySelector('.now-playing-bar .track-info');
|
||||
const coverEl = trackInfo?.querySelector('.cover:not(#audio-player)');
|
||||
|
||||
if (videoCoverUrl) {
|
||||
if (coverEl.tagName === 'IMG') {
|
||||
const video = document.createElement('video');
|
||||
video.src = videoCoverUrl;
|
||||
video.autoplay = true;
|
||||
video.loop = true;
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.className = coverEl.className;
|
||||
video.id = coverEl.id;
|
||||
coverEl.replaceWith(video);
|
||||
if (track.type === 'video') {
|
||||
if (coverEl) coverEl.style.display = 'none';
|
||||
if (this.audio) {
|
||||
const isInFullscreen = document.getElementById('fullscreen-cover-overlay')?.style.display === 'flex';
|
||||
|
||||
if (!isInFullscreen) {
|
||||
this.audio.style.display = 'block';
|
||||
this.audio.className = 'cover video-cover-mirror';
|
||||
this.audio.style.width = '56px';
|
||||
this.audio.style.height = '56px';
|
||||
this.audio.style.borderRadius = 'var(--radius-sm)';
|
||||
this.audio.style.objectFit = 'cover';
|
||||
this.audio.style.gridArea = 'none';
|
||||
this.audio.muted = false;
|
||||
|
||||
if (trackInfo && this.audio.parentElement !== trackInfo) {
|
||||
trackInfo.insertBefore(this.audio, trackInfo.firstChild);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (coverEl.tagName === 'VIDEO') {
|
||||
const img = document.createElement('img');
|
||||
img.src = coverUrl;
|
||||
img.className = coverEl.className;
|
||||
img.id = coverEl.id;
|
||||
coverEl.replaceWith(img);
|
||||
} else {
|
||||
coverEl.src = coverUrl;
|
||||
}
|
||||
} else {
|
||||
if (coverEl) {
|
||||
coverEl.style.display = 'block';
|
||||
const coverUrl = this.api.getCoverUrl(track.image || track.cover || track.album?.cover);
|
||||
if (coverEl.src !== coverUrl) coverEl.src = coverUrl;
|
||||
}
|
||||
if (this.audio) {
|
||||
const isInFullscreen = document.getElementById('fullscreen-cover-overlay')?.style.display === 'flex';
|
||||
if (!isInFullscreen) {
|
||||
this.audio.style.display = 'none';
|
||||
if (this.audio.parentElement !== document.body) {
|
||||
document.body.appendChild(this.audio);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -568,7 +634,47 @@ export class Player {
|
|||
}
|
||||
const played = await this.safePlay();
|
||||
if (!played) return;
|
||||
} else if (track.type === 'video') {
|
||||
if (this.dashInitialized) {
|
||||
this.dashPlayer.reset();
|
||||
this.dashInitialized = false;
|
||||
}
|
||||
if (this.hls) {
|
||||
this.hls.destroy();
|
||||
this.hls = null;
|
||||
}
|
||||
|
||||
streamUrl = await this.api.getVideoStreamUrl(track.id);
|
||||
|
||||
if (streamUrl.includes('.m3u8') || streamUrl.includes('application/vnd.apple.mpegurl')) {
|
||||
this.setupHlsVideo(this.audio, streamUrl, null);
|
||||
} else if (streamUrl.startsWith('blob:') || streamUrl.includes('.mpd')) {
|
||||
this.dashPlayer.initialize(this.audio, streamUrl, true);
|
||||
this.dashInitialized = true;
|
||||
} else {
|
||||
this.audio.src = streamUrl;
|
||||
}
|
||||
|
||||
this.applyAudioEffects();
|
||||
|
||||
if (window.monochromeUi) {
|
||||
const lyricsManager = window.monochromeUi.lyricsManager;
|
||||
window.monochromeUi.showFullscreenCover(track, this.getNextTrack(), lyricsManager, this.audio);
|
||||
}
|
||||
|
||||
const canPlay = await this.waitForCanPlayOrTimeout();
|
||||
if (!canPlay) return;
|
||||
|
||||
if (startTime > 0) {
|
||||
this.audio.currentTime = startTime;
|
||||
}
|
||||
|
||||
await this.safePlay();
|
||||
} else {
|
||||
if (this.hls) {
|
||||
this.hls.destroy();
|
||||
this.hls = null;
|
||||
}
|
||||
const isQobuz = String(track.id).startsWith('q:');
|
||||
|
||||
if (isQobuz) {
|
||||
|
|
|
|||
|
|
@ -37,12 +37,14 @@ let currentFavoriteAlbums = [];
|
|||
const api = new MusicAPI(apiSettings);
|
||||
|
||||
async function uploadImage(file) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const response = await fetch('/upload', { method: 'POST', body: formData });
|
||||
if (!response.ok) throw new Error(`Upload failed: ${response.status}`);
|
||||
const data = await response.json();
|
||||
if (!data.success) throw new Error(data.error || 'Upload failed');
|
||||
return data.url;
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
|
|
|
|||
|
|
@ -331,12 +331,12 @@ export class QobuzAPI {
|
|||
}
|
||||
|
||||
// Similar/recommendation methods
|
||||
async getSimilarArtists(artistId) {
|
||||
async getSimilarArtists(_artistId) {
|
||||
// Qobuz doesn't have a direct similar artists endpoint in this simplified API
|
||||
return [];
|
||||
}
|
||||
|
||||
async getSimilarAlbums(albumId) {
|
||||
async getSimilarAlbums(_albumId) {
|
||||
// Qobuz doesn't have a direct similar albums endpoint in this simplified API
|
||||
return [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ import {
|
|||
musicProviderSettings,
|
||||
analyticsSettings,
|
||||
modalSettings,
|
||||
keyboardShortcuts,
|
||||
} from './storage.js';
|
||||
import { audioContextManager, EQ_PRESETS } from './audio-context.js';
|
||||
import { getButterchurnPresets } from './visualizers/butterchurn.js';
|
||||
|
|
@ -901,33 +900,56 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
|||
// ========================================
|
||||
const playbackSpeedSlider = document.getElementById('playback-speed-slider');
|
||||
const playbackSpeedInput = document.getElementById('playback-speed-input');
|
||||
const playbackSpeedReset = document.getElementById('playback-speed-reset');
|
||||
|
||||
if (playbackSpeedSlider && playbackSpeedInput) {
|
||||
const currentSpeed = audioEffectsSettings.getSpeed();
|
||||
// Clamp slider to its range (0.25-4), but show actual value in input
|
||||
playbackSpeedSlider.value = Math.max(0.25, Math.min(4.0, currentSpeed));
|
||||
playbackSpeedInput.value = currentSpeed;
|
||||
|
||||
// Slider only controls 0.25-4 range
|
||||
playbackSpeedSlider.addEventListener('input', (e) => {
|
||||
const speed = parseFloat(e.target.value) || 1.0;
|
||||
playbackSpeedInput.value = speed;
|
||||
player.setPlaybackSpeed(speed);
|
||||
});
|
||||
|
||||
// Input allows full 0.01-100 range
|
||||
const handleInputChange = () => {
|
||||
const speed = parseFloat(playbackSpeedInput.value) || 1.0;
|
||||
const validSpeed = Math.max(0.01, Math.min(100, speed));
|
||||
// Helper function to update both controls
|
||||
const updatePlaybackSpeedControls = (speed) => {
|
||||
const validSpeed = Math.max(0.01, Math.min(100, parseFloat(speed) || 1.0));
|
||||
playbackSpeedInput.value = validSpeed;
|
||||
// Only update slider if value is within slider range
|
||||
if (validSpeed >= 0.25 && validSpeed <= 4.0) {
|
||||
playbackSpeedSlider.value = validSpeed;
|
||||
}
|
||||
player.setPlaybackSpeed(validSpeed);
|
||||
return validSpeed;
|
||||
};
|
||||
|
||||
playbackSpeedInput.addEventListener('change', handleInputChange);
|
||||
playbackSpeedInput.addEventListener('blur', handleInputChange);
|
||||
// Initialize with current value
|
||||
const currentSpeed = audioEffectsSettings.getSpeed();
|
||||
updatePlaybackSpeedControls(currentSpeed);
|
||||
|
||||
playbackSpeedSlider.addEventListener('input', (e) => {
|
||||
const speed = parseFloat(e.target.value);
|
||||
playbackSpeedInput.value = speed;
|
||||
audioEffectsSettings.setSpeed(speed);
|
||||
player.setPlaybackSpeed(speed);
|
||||
});
|
||||
|
||||
playbackSpeedInput.addEventListener('input', (e) => {
|
||||
const speed = parseFloat(e.target.value);
|
||||
if (!isNaN(speed) && speed >= 0.01 && speed <= 100) {
|
||||
if (speed >= 0.25 && speed <= 4.0) {
|
||||
playbackSpeedSlider.value = speed;
|
||||
}
|
||||
audioEffectsSettings.setSpeed(speed);
|
||||
player.setPlaybackSpeed(speed);
|
||||
}
|
||||
});
|
||||
|
||||
playbackSpeedInput.addEventListener('change', (e) => {
|
||||
const speed = parseFloat(e.target.value);
|
||||
const validSpeed = updatePlaybackSpeedControls(speed);
|
||||
audioEffectsSettings.setSpeed(validSpeed);
|
||||
player.setPlaybackSpeed(validSpeed);
|
||||
});
|
||||
|
||||
if (playbackSpeedReset) {
|
||||
playbackSpeedReset.addEventListener('click', () => {
|
||||
const defaultSpeed = audioEffectsSettings.resetSpeed();
|
||||
updatePlaybackSpeedControls(defaultSpeed);
|
||||
player.setPlaybackSpeed(defaultSpeed);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
|
|
@ -2110,6 +2132,21 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
|||
});
|
||||
}
|
||||
|
||||
const visualizerDimmingSlider = document.getElementById('visualizer-dimming-slider');
|
||||
const visualizerDimmingValue = document.getElementById('visualizer-dimming-value');
|
||||
if (visualizerDimmingSlider && visualizerDimmingValue) {
|
||||
const currentDimming = visualizerSettings.getDimAmount();
|
||||
visualizerDimmingSlider.value = currentDimming;
|
||||
visualizerDimmingValue.textContent = `${(currentDimming * 100).toFixed(0)}%`;
|
||||
|
||||
visualizerDimmingSlider.addEventListener('input', (e) => {
|
||||
const newDimming = parseFloat(e.target.value);
|
||||
visualizerSettings.setDimAmount(newDimming);
|
||||
visualizerDimmingValue.textContent = `${(newDimming * 100).toFixed(0)}%`;
|
||||
window.dispatchEvent(new CustomEvent('visualizer-dim-change', { detail: { dimAmount: newDimming } }));
|
||||
});
|
||||
}
|
||||
|
||||
// Visualizer Smart Intensity
|
||||
const smartIntensityToggle = document.getElementById('smart-intensity-toggle');
|
||||
if (smartIntensityToggle) {
|
||||
|
|
@ -2288,20 +2325,20 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
|||
updateButterchurnSettingsVisibility();
|
||||
}
|
||||
|
||||
// Watch for audio tab becoming active and refresh presets
|
||||
const audioTabContent = document.getElementById('settings-tab-audio');
|
||||
if (audioTabContent) {
|
||||
// Watch for appearance tab becoming active and refresh presets
|
||||
const appearanceTabContent = document.getElementById('settings-tab-appearance');
|
||||
if (appearanceTabContent) {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
|
||||
if (audioTabContent.classList.contains('active')) {
|
||||
console.log('[Settings] Audio tab became active, refreshing presets');
|
||||
if (appearanceTabContent.classList.contains('active')) {
|
||||
console.log('[Settings] Appearance tab became active, refreshing presets');
|
||||
updateButterchurnSettingsVisibility();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
observer.observe(audioTabContent, { attributes: true });
|
||||
observer.observe(appearanceTabContent, { attributes: true });
|
||||
}
|
||||
|
||||
// Visualizer Mode Select
|
||||
|
|
|
|||
|
|
@ -763,6 +763,7 @@ export const visualizerSettings = {
|
|||
MODE_KEY: 'visualizer-mode', // 'solid' or 'blended'
|
||||
PRESET_KEY: 'visualizer-preset',
|
||||
BUTTERCHURN_CYCLE_KEY: 'butterchurn-cycle-duration',
|
||||
DIM_AMOUNT_KEY: 'visualizer-dim-amount',
|
||||
|
||||
getPreset() {
|
||||
try {
|
||||
|
|
@ -828,6 +829,20 @@ export const visualizerSettings = {
|
|||
localStorage.setItem(this.SMART_INTENSITY_KEY, enabled);
|
||||
},
|
||||
|
||||
getDimAmount() {
|
||||
try {
|
||||
const val = localStorage.getItem(this.DIM_AMOUNT_KEY);
|
||||
if (val === null) return 1.0;
|
||||
return parseFloat(val);
|
||||
} catch {
|
||||
return 1.0;
|
||||
}
|
||||
},
|
||||
|
||||
setDimAmount(value) {
|
||||
localStorage.setItem(this.DIM_AMOUNT_KEY, value);
|
||||
},
|
||||
|
||||
// Butterchurn preset cycle duration in seconds
|
||||
getButterchurnCycleDuration() {
|
||||
try {
|
||||
|
|
@ -1352,6 +1367,11 @@ export const audioEffectsSettings = {
|
|||
localStorage.setItem(this.SPEED_KEY, validSpeed.toString());
|
||||
},
|
||||
|
||||
resetSpeed() {
|
||||
this.setSpeed(1.0);
|
||||
return 1.0;
|
||||
},
|
||||
|
||||
// Preserve pitch when changing speed (default true)
|
||||
isPreservePitchEnabled() {
|
||||
try {
|
||||
|
|
|
|||
12
js/taglib.ts
12
js/taglib.ts
|
|
@ -28,6 +28,10 @@ export async function addMetadataWithTagLib(
|
|||
audioData: Uint8Array,
|
||||
data: Omit<AddMetadataMessage, 'type' | 'wasmUrl' | 'audioData'>
|
||||
) {
|
||||
if (!(audioData instanceof Uint8Array)) {
|
||||
audioData = new Uint8Array(audioData);
|
||||
}
|
||||
|
||||
const worker = new Worker(new URL(TagLibWorker, import.meta.url), { type: 'module' });
|
||||
const wasmUrl = await fetchTagLib();
|
||||
|
||||
|
|
@ -43,11 +47,15 @@ export async function addMetadataWithTagLib(
|
|||
};
|
||||
worker.onerror = reject;
|
||||
worker.onmessageerror = reject;
|
||||
worker.postMessage({ ...data, type: 'Add', wasmUrl, audioData });
|
||||
worker.postMessage({ ...data, type: 'Add', wasmUrl, audioData }, [audioData.buffer]);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMetadataWithTagLib(audioData: Uint8Array) {
|
||||
if (!(audioData instanceof Uint8Array)) {
|
||||
audioData = new Uint8Array(audioData);
|
||||
}
|
||||
|
||||
const worker = new Worker(new URL(TagLibWorker, import.meta.url), { type: 'module' });
|
||||
const wasmUrl = await fetchTagLib();
|
||||
|
||||
|
|
@ -63,6 +71,6 @@ export async function getMetadataWithTagLib(audioData: Uint8Array) {
|
|||
};
|
||||
worker.onerror = reject;
|
||||
worker.onmessageerror = reject;
|
||||
worker.postMessage({ type: 'Get', wasmUrl, audioData });
|
||||
worker.postMessage({ type: 'Get', wasmUrl, audioData }, [audioData.buffer]);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
// filepath: /workspaces/monochrome/js/taglib.worker.ts
|
||||
declare var self: DedicatedWorkerGlobalScope;
|
||||
|
||||
import { TagLib, type PictureType } from 'taglib-wasm';
|
||||
import { doTimed, doTimedAsync } from './doTimed';
|
||||
|
|
@ -95,6 +96,8 @@ async function addMetadataToAudio(message: AddMetadataMessage): Promise<Uint8Arr
|
|||
try {
|
||||
doTimed('Tagging file', () => {
|
||||
const isMp4 = file.isMP4();
|
||||
const media = file.audioProperties();
|
||||
const needsCombinedTrackDisc = isMp4 || media.containerFormat.toLowerCase() === 'mp3';
|
||||
|
||||
if (title) {
|
||||
file.setProperty('TITLE', title);
|
||||
|
|
@ -116,36 +119,36 @@ async function addMetadataToAudio(message: AddMetadataMessage): Promise<Uint8Arr
|
|||
if (trackNumber) {
|
||||
let trackString = String(trackNumber);
|
||||
|
||||
if (isMp4 && trackNumber && totalTracks) {
|
||||
if (needsCombinedTrackDisc && trackNumber && totalTracks) {
|
||||
trackString = `${trackNumber}/${totalTracks}`;
|
||||
}
|
||||
|
||||
if (isMp4) {
|
||||
if (needsCombinedTrackDisc) {
|
||||
file.setProperty('TRACKNUMBER', trackString);
|
||||
} else {
|
||||
file.setProperty('TRACKNUMBER', String(trackNumber));
|
||||
}
|
||||
}
|
||||
|
||||
if (!isMp4 && totalTracks) {
|
||||
if (!needsCombinedTrackDisc && totalTracks) {
|
||||
file.setProperty('TRACKTOTAL', String(totalTracks));
|
||||
}
|
||||
|
||||
if (discNumber) {
|
||||
let discString = String(discNumber);
|
||||
|
||||
if (isMp4 && discNumber && totalDiscs) {
|
||||
if (needsCombinedTrackDisc && discNumber && totalDiscs) {
|
||||
discString = `${discNumber}/${totalDiscs}`;
|
||||
}
|
||||
|
||||
if (isMp4) {
|
||||
if (needsCombinedTrackDisc) {
|
||||
file.setProperty('DISCNUMBER', discString);
|
||||
} else {
|
||||
file.setProperty('DISCNUMBER', String(discNumber));
|
||||
}
|
||||
}
|
||||
|
||||
if (!isMp4 && totalDiscs) {
|
||||
if (!needsCombinedTrackDisc && totalDiscs) {
|
||||
file.setProperty('DISCTOTAL', String(totalDiscs));
|
||||
}
|
||||
|
||||
|
|
@ -304,10 +307,13 @@ self.onmessage = async (event: MessageEvent<TagLibWorkerMessage>) => {
|
|||
case 'Add':
|
||||
try {
|
||||
const result = await addMetadataToAudio(event.data as AddMetadataMessage);
|
||||
self.postMessage({
|
||||
type: event.data.type,
|
||||
data: result,
|
||||
} satisfies TagLibFileResponse);
|
||||
self.postMessage(
|
||||
{
|
||||
type: event.data.type,
|
||||
data: result,
|
||||
} satisfies TagLibFileResponse,
|
||||
[result.buffer, event.data.audioData.buffer]
|
||||
);
|
||||
} catch (error) {
|
||||
self.postMessage({
|
||||
type: event.data.type,
|
||||
|
|
@ -319,10 +325,13 @@ self.onmessage = async (event: MessageEvent<TagLibWorkerMessage>) => {
|
|||
case 'Get':
|
||||
try {
|
||||
const result = await getMetadataFromAudio(event.data as GetMetadataMessage);
|
||||
self.postMessage({
|
||||
type: event.data.type,
|
||||
data: result,
|
||||
} satisfies TagLibMetadataResponse);
|
||||
self.postMessage(
|
||||
{
|
||||
type: event.data.type,
|
||||
data: result,
|
||||
} satisfies TagLibMetadataResponse,
|
||||
[event.data.audioData.buffer]
|
||||
);
|
||||
} catch (error) {
|
||||
self.postMessage({
|
||||
type: event.data.type,
|
||||
|
|
|
|||
608
js/ui.js
608
js/ui.js
|
|
@ -38,6 +38,7 @@ import { getVibrantColorFromImage } from './vibrant-color.js';
|
|||
import { syncManager } from './accounts/pocketbase.js';
|
||||
import { Visualizer } from './visualizer.js';
|
||||
import { navigate } from './router.js';
|
||||
import { sidePanelManager } from './side-panel.js';
|
||||
import {
|
||||
renderUnreleasedPage as renderUnreleasedTrackerPage,
|
||||
renderTrackerArtistPage as renderTrackerArtistContent,
|
||||
|
|
@ -49,6 +50,7 @@ import {
|
|||
createTrackFromSong,
|
||||
} from './tracker.js';
|
||||
import { trackSearch, trackChangeSort } from './analytics.js';
|
||||
import Hls from 'hls.js';
|
||||
|
||||
fontSettings.applyFont();
|
||||
fontSettings.applyFontSize();
|
||||
|
|
@ -120,6 +122,12 @@ export class UIRenderer {
|
|||
window.addEventListener('theme-changed', () => {
|
||||
this.updateGlobalTheme();
|
||||
});
|
||||
|
||||
window.addEventListener('visualizer-dim-change', () => {
|
||||
if (this.visualizer) {
|
||||
this.visualizer.updateDimming();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Helper for Heart Icon
|
||||
|
|
@ -253,7 +261,7 @@ export class UIRenderer {
|
|||
likeBtn.style.display = 'none';
|
||||
} else {
|
||||
likeBtn.style.display = 'flex';
|
||||
this.updateLikeState(likeBtn.parentElement, 'track', track.id);
|
||||
this.updateLikeState(likeBtn.parentElement, track.type || 'track', track.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -283,7 +291,7 @@ export class UIRenderer {
|
|||
fsLikeBtn.style.display = 'none';
|
||||
} else {
|
||||
fsLikeBtn.style.display = 'flex';
|
||||
this.updateLikeState(fsLikeBtn.parentElement, 'track', track.id);
|
||||
this.updateLikeState(fsLikeBtn.parentElement, track.type || 'track', track.id);
|
||||
}
|
||||
}
|
||||
if (fsAddPlaylistBtn) {
|
||||
|
|
@ -334,9 +342,23 @@ export class UIRenderer {
|
|||
createTrackItemHTML(track, index, showCover = false, hasMultipleDiscs = false, useTrackNumber = false) {
|
||||
const isUnavailable = track.isUnavailable;
|
||||
const isBlocked = contentBlockingSettings?.shouldHideTrack(track);
|
||||
const trackImageHTML = showCover
|
||||
? this.getCoverHTML(track.album?.videoCover, track.album?.cover, 'Track Cover', 'track-item-cover')
|
||||
: '';
|
||||
const isVideo = track.type === 'video';
|
||||
|
||||
let trackImageHTML = '';
|
||||
if (showCover) {
|
||||
if (isVideo && this.currentPage === 'playlist') {
|
||||
trackImageHTML = `<div class="track-item-cover video-icon-placeholder" style="display: flex; align-items: center; justify-content: center; background: var(--secondary);"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity: 0.7;"><path d="m22 8-6 4 6 4V8Z"/><rect width="14" height="12" x="2" y="6" rx="2" ry="2"/></svg></div>`;
|
||||
} else if (isVideo && (this.currentPage === 'search' || this.currentPage === 'library')) {
|
||||
trackImageHTML = `<div class="track-item-cover video-icon-placeholder" style="display: flex; align-items: center; justify-content: center; background: var(--secondary);"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="opacity: 0.7;"><path d="M8 5v14l11-7z"/></svg></div>`;
|
||||
} else {
|
||||
trackImageHTML = this.getCoverHTML(
|
||||
track.image || track.cover || track.album?.cover,
|
||||
'Track Cover',
|
||||
'track-item-cover',
|
||||
'lazy'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let displayIndex;
|
||||
if (hasMultipleDiscs && !showCover) {
|
||||
|
|
@ -348,6 +370,9 @@ export class UIRenderer {
|
|||
displayIndex = index + 1;
|
||||
}
|
||||
|
||||
const videoIcon = isVideo
|
||||
? '<span class="video-item-icon" title="Music Video" style="display: inline-flex; align-items: center; margin-right: 4px; color: var(--muted-foreground);"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 8-6 4 6 4V8Z"/><rect width="14" height="12" x="2" y="6" rx="2" ry="2"/></svg></span>'
|
||||
: '';
|
||||
const trackNumberHTML = `<div class="track-number">${showCover ? trackImageHTML : displayIndex}</div>`;
|
||||
const explicitBadge = hasExplicitContent(track) ? this.createExplicitBadge() : '';
|
||||
const qualityBadge = createQualityBadgeHTML(track);
|
||||
|
|
@ -374,6 +399,7 @@ export class UIRenderer {
|
|||
|
||||
const classList = [
|
||||
'track-item',
|
||||
isVideo ? 'video-track-item' : '',
|
||||
isCurrentTrack ? 'playing' : '',
|
||||
isUnavailable ? 'unavailable' : '',
|
||||
isBlocked ? 'blocked' : '',
|
||||
|
|
@ -384,6 +410,7 @@ export class UIRenderer {
|
|||
return `
|
||||
<div class="${classList}"
|
||||
data-track-id="${track.id}"
|
||||
${isVideo ? 'data-type="video"' : 'data-type="track"'}
|
||||
${track.isLocal ? 'data-is-local="true"' : ''}
|
||||
${isUnavailable ? 'title="This track is currently unavailable"' : ''}
|
||||
${blockedTitle}>
|
||||
|
|
@ -391,6 +418,7 @@ export class UIRenderer {
|
|||
<div class="track-item-info">
|
||||
<div class="track-item-details">
|
||||
<div class="title">
|
||||
${videoIcon}
|
||||
${escapeHtml(trackTitle)}
|
||||
${explicitBadge}
|
||||
${qualityBadge}
|
||||
|
|
@ -406,12 +434,12 @@ export class UIRenderer {
|
|||
`;
|
||||
}
|
||||
|
||||
getCoverHTML(videoCover, cover, alt, className = 'card-image', loading = 'lazy') {
|
||||
const videoUrl = videoCover ? this.api.tidalAPI.getVideoCoverUrl(videoCover) : null;
|
||||
if (videoUrl) {
|
||||
return `<video src="${videoUrl}" class="${className}" alt="${alt}" autoplay loop muted playsinline></video>`;
|
||||
getCoverHTML(cover, alt, className = 'card-image', loading = 'lazy', videoCoverUrl = null) {
|
||||
const imageUrl = this.api.getCoverUrl(cover);
|
||||
if (videoCoverUrl) {
|
||||
return `<video src="${videoCoverUrl}" poster="${imageUrl}" class="${className}" alt="${alt}" preload="metadata" playsinline muted></video>`;
|
||||
}
|
||||
return `<img src="${this.api.getCoverUrl(cover)}" class="${className}" alt="${alt}" loading="${loading}">`;
|
||||
return `<img src="${imageUrl}" class="${className}" alt="${alt}" loading="${loading}">`;
|
||||
}
|
||||
|
||||
createBaseCardHTML({
|
||||
|
|
@ -617,7 +645,13 @@ export class UIRenderer {
|
|||
href: `/album/${album.id}`,
|
||||
title: `${escapeHtml(album.title)} ${explicitBadge} ${qualityBadge}`,
|
||||
subtitle: `${escapeHtml(artistName)} • ${yearDisplay}${typeLabel}`,
|
||||
imageHTML: this.getCoverHTML(album.videoCover, album.cover, escapeHtml(album.title)),
|
||||
imageHTML: this.getCoverHTML(
|
||||
album.cover,
|
||||
escapeHtml(album.title),
|
||||
'card-image',
|
||||
'lazy',
|
||||
album.videoCoverUrl
|
||||
),
|
||||
actionButtonsHTML: `
|
||||
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="album" title="Add to Liked">
|
||||
${this.createHeartIcon(false)}
|
||||
|
|
@ -631,6 +665,41 @@ export class UIRenderer {
|
|||
});
|
||||
}
|
||||
|
||||
createVideoCardHTML(video) {
|
||||
const duration = formatTime(video.duration);
|
||||
const artistName = getTrackArtists(video);
|
||||
|
||||
const cover = video.image || video.cover;
|
||||
let imageHTML;
|
||||
|
||||
if (cover) {
|
||||
imageHTML = this.getCoverHTML(cover, escapeHtml(video.title));
|
||||
} else {
|
||||
imageHTML = `<div class="card-image video-icon-placeholder" style="display: flex; align-items: center; justify-content: center; background: var(--secondary); aspect-ratio: 16/9; width: 100%;"><svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor" style="opacity: 0.7;"><path d="M8 5v14l11-7z"/></svg></div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="card video-card" data-video-id="${video.id}" data-type="video" draggable="true">
|
||||
<div class="card-image-container">
|
||||
${imageHTML}
|
||||
<div class="card-overlay">
|
||||
<button class="card-play-btn" title="Play video">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="video" title="Add to Liked">
|
||||
${this.createHeartIcon(false)}
|
||||
</button>
|
||||
<div class="video-duration-badge" style="position: absolute; bottom: 8px; right: 8px; background: rgba(0,0,0,0.7); color: white; padding: 2px 6px; border-radius: 4px; font-size: 12px; font-weight: 500;">${duration}</div>
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<div class="card-title" title="${escapeHtml(video.title)}">${escapeHtml(video.title)}</div>
|
||||
<div class="card-subtitle">${escapeHtml(artistName)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
createArtistCardHTML(artist) {
|
||||
const isCompact = cardSettings.isCompactArtist();
|
||||
const isBlocked = contentBlockingSettings?.shouldHideArtist(artist);
|
||||
|
|
@ -780,7 +849,7 @@ export class UIRenderer {
|
|||
if (element && track) {
|
||||
trackDataStore.set(element, track);
|
||||
// Async update for like button
|
||||
this.updateLikeState(element, 'track', track.id);
|
||||
this.updateLikeState(element, track.type || 'track', track.id);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -898,48 +967,88 @@ export class UIRenderer {
|
|||
if (!track) return;
|
||||
const overlay = document.getElementById('fullscreen-cover-overlay');
|
||||
const image = document.getElementById('fullscreen-cover-image');
|
||||
const videoContainer = document.getElementById('fullscreen-video-container');
|
||||
const title = document.getElementById('fullscreen-track-title');
|
||||
const artist = document.getElementById('fullscreen-track-artist');
|
||||
const nextTrackEl = document.getElementById('fullscreen-next-track');
|
||||
|
||||
const videoCoverUrl = track.album?.videoCover
|
||||
? this.api.tidalAPI.getVideoCoverUrl(track.album.videoCover, '1280')
|
||||
: null;
|
||||
const coverUrl = videoCoverUrl || this.api.getCoverUrl(track.album?.cover, '1280');
|
||||
const isRealVideo = track.type === 'video';
|
||||
const visualizerContainer = document.getElementById('visualizer-container');
|
||||
overlay.classList.toggle('is-video-mode', isRealVideo);
|
||||
|
||||
const fsLikeBtn = document.getElementById('fs-like-btn');
|
||||
if (fsLikeBtn) {
|
||||
this.updateLikeState(fsLikeBtn.parentElement, 'track', track.id);
|
||||
const toggleUiBtn = document.getElementById('toggle-ui-btn');
|
||||
if (toggleUiBtn) {
|
||||
toggleUiBtn.style.display = isRealVideo ? 'none' : 'flex';
|
||||
}
|
||||
|
||||
if (videoCoverUrl) {
|
||||
if (image.tagName === 'IMG') {
|
||||
const video = document.createElement('video');
|
||||
video.src = videoCoverUrl;
|
||||
video.autoplay = true;
|
||||
video.loop = true;
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.id = image.id;
|
||||
video.className = image.className;
|
||||
image.replaceWith(video);
|
||||
if (isRealVideo) {
|
||||
if (sidePanelManager.isActive('lyrics')) {
|
||||
sidePanelManager.close();
|
||||
}
|
||||
|
||||
if (videoContainer) {
|
||||
videoContainer.style.display = 'flex';
|
||||
const audioPlayer = document.getElementById('audio-player');
|
||||
if (audioPlayer && audioPlayer.parentElement !== videoContainer) {
|
||||
videoContainer.appendChild(audioPlayer);
|
||||
audioPlayer.style.display = 'block';
|
||||
audioPlayer.style.width = '100%';
|
||||
audioPlayer.style.height = '100%';
|
||||
audioPlayer.style.objectFit = 'contain';
|
||||
}
|
||||
}
|
||||
if (image) image.style.display = 'none';
|
||||
if (visualizerContainer) visualizerContainer.style.display = 'none';
|
||||
} else {
|
||||
if (image.tagName === 'VIDEO') {
|
||||
const img = document.createElement('img');
|
||||
img.src = coverUrl;
|
||||
img.id = image.id;
|
||||
img.className = image.className;
|
||||
image.replaceWith(img);
|
||||
if (videoContainer) {
|
||||
videoContainer.style.display = 'none';
|
||||
const audioPlayer = document.getElementById('audio-player');
|
||||
if (audioPlayer && audioPlayer.parentElement === videoContainer) {
|
||||
document.body.appendChild(audioPlayer);
|
||||
audioPlayer.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
if (image) image.style.display = 'block';
|
||||
if (visualizerContainer) visualizerContainer.style.display = 'block';
|
||||
|
||||
const currentImage = document.getElementById('fullscreen-cover-image');
|
||||
if (currentImage.src !== coverUrl || !videoCoverUrl) {
|
||||
currentImage.src = coverUrl;
|
||||
const videoCoverUrl = track.videoUrl || track.videoCoverUrl || track.album?.videoCoverUrl || null;
|
||||
const coverUrl = videoCoverUrl || this.api.getCoverUrl(track.album?.cover, '1280');
|
||||
|
||||
const fsLikeBtn = document.getElementById('fs-like-btn');
|
||||
if (fsLikeBtn) {
|
||||
this.updateLikeState(fsLikeBtn.parentElement, track.type || 'track', track.id);
|
||||
}
|
||||
|
||||
const currentImage = document.getElementById('fullscreen-cover-image');
|
||||
|
||||
if (videoCoverUrl) {
|
||||
if (currentImage.tagName === 'IMG') {
|
||||
const video = document.createElement('video');
|
||||
video.src = videoCoverUrl;
|
||||
video.autoplay = false;
|
||||
video.loop = false;
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.preload = 'metadata';
|
||||
video.className = currentImage.className;
|
||||
currentImage.replaceWith(video);
|
||||
} else if (currentImage.src !== videoCoverUrl) {
|
||||
currentImage.src = videoCoverUrl;
|
||||
}
|
||||
} else {
|
||||
if (currentImage.tagName === 'VIDEO') {
|
||||
const img = document.createElement('img');
|
||||
img.src = coverUrl;
|
||||
img.id = currentImage.id;
|
||||
img.className = currentImage.className;
|
||||
currentImage.replaceWith(img);
|
||||
} else if (currentImage.src !== coverUrl) {
|
||||
currentImage.src = coverUrl;
|
||||
}
|
||||
}
|
||||
overlay.style.setProperty('--bg-image', `url('${this.api.getCoverUrl(track.album?.cover, '1280')}')`);
|
||||
this.extractAndApplyColor(this.api.getCoverUrl(track.album?.cover, '80'));
|
||||
}
|
||||
overlay.style.setProperty('--bg-image', `url('${coverUrl}')`);
|
||||
this.extractAndApplyColor(this.api.getCoverUrl(track.album?.cover, '80'));
|
||||
|
||||
const qualityBadge = createQualityBadgeHTML(track);
|
||||
title.innerHTML = `${escapeHtml(track.title)} ${qualityBadge}`;
|
||||
|
|
@ -1051,6 +1160,28 @@ export class UIRenderer {
|
|||
const playerBar = document.querySelector('.now-playing-bar');
|
||||
if (playerBar) playerBar.style.removeProperty('display');
|
||||
|
||||
if (this.player?.currentTrack?.type === 'video') {
|
||||
const coverContainer = document.querySelector('.now-playing-bar .track-info');
|
||||
const audioPlayer = document.getElementById('audio-player');
|
||||
const imgCover = coverContainer?.querySelector('.cover:not(#audio-player)');
|
||||
|
||||
if (audioPlayer && coverContainer) {
|
||||
if (imgCover) imgCover.style.display = 'none';
|
||||
|
||||
audioPlayer.style.display = 'block';
|
||||
audioPlayer.classList.add('cover', 'video-cover-mirror');
|
||||
audioPlayer.style.width = '56px';
|
||||
audioPlayer.style.height = '56px';
|
||||
audioPlayer.style.borderRadius = 'var(--radius-sm)';
|
||||
audioPlayer.style.objectFit = 'cover';
|
||||
audioPlayer.style.gridArea = 'none';
|
||||
|
||||
if (audioPlayer.parentElement !== coverContainer) {
|
||||
coverContainer.insertBefore(audioPlayer, coverContainer.firstChild);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.fullscreenUpdateInterval) {
|
||||
cancelAnimationFrame(this.fullscreenUpdateInterval);
|
||||
this.fullscreenUpdateInterval = null;
|
||||
|
|
@ -1099,16 +1230,32 @@ export class UIRenderer {
|
|||
const isNearTopRight = e.clientY < 100 && e.clientX > rect.width - 150;
|
||||
|
||||
if (isUIHidden) {
|
||||
// When UI is hidden, only show button when mouse is near top-right
|
||||
if (isNearTopRight) {
|
||||
if (overlay.classList.contains('is-video-mode')) {
|
||||
toggleUI();
|
||||
} else if (isNearTopRight) {
|
||||
showButton();
|
||||
} else {
|
||||
hideButton();
|
||||
}
|
||||
} else if (overlay.classList.contains('is-video-mode')) {
|
||||
resetVideoHideTimer();
|
||||
}
|
||||
// When UI is visible, button stays visible (no auto-hide)
|
||||
};
|
||||
|
||||
let videoHideTimer = null;
|
||||
const resetVideoHideTimer = () => {
|
||||
if (videoHideTimer) clearTimeout(videoHideTimer);
|
||||
if (!overlay.classList.contains('is-video-mode') || isUIHidden) return;
|
||||
|
||||
videoHideTimer = setTimeout(() => {
|
||||
if (!isUIHidden && overlay.classList.contains('is-video-mode')) {
|
||||
toggleUI();
|
||||
}
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
resetVideoHideTimer();
|
||||
|
||||
// Toggle UI visibility
|
||||
const toggleUI = () => {
|
||||
isUIHidden = !isUIHidden;
|
||||
|
|
@ -1344,6 +1491,30 @@ export class UIRenderer {
|
|||
updateFsVolumeUI();
|
||||
};
|
||||
|
||||
const handleFsVolumeWheel = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const delta = e.deltaY > 0 ? -0.05 : 0.05;
|
||||
const currentVolume = this.player.userVolume;
|
||||
const newVolume = Math.max(0, Math.min(1, currentVolume + delta));
|
||||
|
||||
if (delta > 0 && audioPlayer.muted) {
|
||||
audioPlayer.muted = false;
|
||||
localStorage.setItem('muted', false);
|
||||
}
|
||||
|
||||
this.player.setVolume(newVolume);
|
||||
updateFsVolumeUI();
|
||||
};
|
||||
|
||||
[fsVolumeBar, fsVolumeBtn].forEach((el) => {
|
||||
if (el._fsVolumeWheelHandler) {
|
||||
el.removeEventListener('wheel', el._fsVolumeWheelHandler);
|
||||
}
|
||||
el._fsVolumeWheelHandler = handleFsVolumeWheel;
|
||||
el.addEventListener('wheel', handleFsVolumeWheel, { passive: false });
|
||||
});
|
||||
|
||||
const setFsVolume = (e) => {
|
||||
const rect = fsVolumeBar.getBoundingClientRect();
|
||||
const position = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
|
|
@ -1428,6 +1599,7 @@ export class UIRenderer {
|
|||
}
|
||||
|
||||
showPage(pageId) {
|
||||
this.currentPage = pageId;
|
||||
document.querySelectorAll('.page').forEach((page) => {
|
||||
page.classList.toggle('active', page.id === `page-${pageId}`);
|
||||
});
|
||||
|
|
@ -1467,11 +1639,13 @@ export class UIRenderer {
|
|||
this.showPage('library');
|
||||
|
||||
const tracksContainer = document.getElementById('library-tracks-container');
|
||||
const videosTabContent = document.getElementById('library-tab-videos');
|
||||
const albumsContainer = document.getElementById('library-albums-container');
|
||||
const artistsContainer = document.getElementById('library-artists-container');
|
||||
const playlistsContainer = document.getElementById('library-playlists-container');
|
||||
const localContainer = document.getElementById('library-local-container');
|
||||
const foldersContainer = document.getElementById('my-folders-container');
|
||||
const myPlaylistsContainer = document.getElementById('my-playlists-container');
|
||||
|
||||
const likedTracks = await db.getFavorites('track');
|
||||
const shuffleBtn = document.getElementById('shuffle-liked-tracks-btn');
|
||||
|
|
@ -1487,6 +1661,29 @@ export class UIRenderer {
|
|||
tracksContainer.innerHTML = createPlaceholder('No liked tracks yet.');
|
||||
}
|
||||
|
||||
const likedVideos = await db.getFavorites('video');
|
||||
if (videosTabContent) {
|
||||
const grid = videosTabContent.querySelector('.card-grid');
|
||||
if (likedVideos.length) {
|
||||
grid.innerHTML = likedVideos.map((v) => this.createVideoCardHTML(v)).join('');
|
||||
likedVideos.forEach((video) => {
|
||||
const el = grid.querySelector(`[data-video-id="${video.id}"]`);
|
||||
if (el) {
|
||||
trackDataStore.set(el, video);
|
||||
this.updateLikeState(el, 'video', video.id);
|
||||
el.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.card-play-btn') || e.target.closest('.card-image-container')) {
|
||||
e.stopPropagation();
|
||||
this.player.playVideo(video);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
grid.innerHTML = createPlaceholder('No liked videos yet.');
|
||||
}
|
||||
}
|
||||
|
||||
const likedAlbums = await db.getFavorites('album');
|
||||
if (likedAlbums.length) {
|
||||
albumsContainer.innerHTML = likedAlbums.map((a) => this.createAlbumCardHTML(a)).join('');
|
||||
|
|
@ -1557,9 +1754,7 @@ export class UIRenderer {
|
|||
foldersContainer.style.display = folders.length ? 'grid' : 'none';
|
||||
}
|
||||
|
||||
const myPlaylistsContainer = document.getElementById('my-playlists-container');
|
||||
const myPlaylists = await db.getPlaylists();
|
||||
|
||||
const playlistsInFolders = new Set();
|
||||
folders.forEach((folder) => {
|
||||
if (folder.playlists) {
|
||||
|
|
@ -1569,24 +1764,30 @@ export class UIRenderer {
|
|||
|
||||
const visiblePlaylists = myPlaylists.filter((p) => !playlistsInFolders.has(p.id));
|
||||
|
||||
if (visiblePlaylists.length) {
|
||||
myPlaylistsContainer.innerHTML = visiblePlaylists.map((p) => this.createUserPlaylistCardHTML(p)).join('');
|
||||
visiblePlaylists.forEach((playlist) => {
|
||||
const el = myPlaylistsContainer.querySelector(`[data-user-playlist-id="${playlist.id}"]`);
|
||||
if (el) {
|
||||
trackDataStore.set(el, playlist);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (folders.length === 0) {
|
||||
myPlaylistsContainer.innerHTML = createPlaceholder('No playlists yet. Create your first playlist!');
|
||||
if (myPlaylistsContainer) {
|
||||
if (visiblePlaylists.length) {
|
||||
myPlaylistsContainer.innerHTML = visiblePlaylists
|
||||
.map((p) => this.createUserPlaylistCardHTML(p))
|
||||
.join('');
|
||||
visiblePlaylists.forEach((playlist) => {
|
||||
const el = myPlaylistsContainer.querySelector(`[data-user-playlist-id="${playlist.id}"]`);
|
||||
if (el) {
|
||||
trackDataStore.set(el, playlist);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
myPlaylistsContainer.innerHTML = '';
|
||||
if (folders.length === 0) {
|
||||
myPlaylistsContainer.innerHTML = createPlaceholder('No playlists yet. Create your first playlist!');
|
||||
} else {
|
||||
myPlaylistsContainer.innerHTML = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render Local Files
|
||||
this.renderLocalFiles(localContainer);
|
||||
if (localContainer) {
|
||||
this.renderLocalFiles(localContainer);
|
||||
}
|
||||
}
|
||||
|
||||
async renderLocalFiles(container) {
|
||||
|
|
@ -2046,7 +2247,13 @@ export class UIRenderer {
|
|||
href: `/track/${track.id}`,
|
||||
title: `${escapeHtml(getTrackTitle(track))} ${explicitBadge} ${qualityBadge}`,
|
||||
subtitle: escapeHtml(getTrackArtists(track)),
|
||||
imageHTML: this.getCoverHTML(track.album?.videoCover, track.album?.cover, escapeHtml(track.title)),
|
||||
imageHTML: this.getCoverHTML(
|
||||
track.album?.cover,
|
||||
escapeHtml(track.title),
|
||||
'card-image',
|
||||
'lazy',
|
||||
track.videoUrl || track.album?.videoCoverUrl
|
||||
),
|
||||
actionButtonsHTML: `
|
||||
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="track" title="Add to Liked">
|
||||
${this.createHeartIcon(false)}
|
||||
|
|
@ -2382,6 +2589,102 @@ export class UIRenderer {
|
|||
return items.filter((item) => !favoriteIds.has(item.id));
|
||||
}
|
||||
|
||||
setupHlsVideo(video, result, fallbackImg) {
|
||||
const url = result.videoUrl || result.hlsUrl;
|
||||
if (!url) return;
|
||||
|
||||
if (url.endsWith('.m3u8')) {
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls();
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
video.play().catch(() => {});
|
||||
});
|
||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
if (data.fatal) {
|
||||
console.warn('HLS fatal error:', data.type);
|
||||
video.replaceWith(fallbackImg);
|
||||
hls.destroy();
|
||||
}
|
||||
});
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
// i heard safari supports HLS natively
|
||||
video.src = url;
|
||||
} else {
|
||||
video.replaceWith(fallbackImg);
|
||||
}
|
||||
} else {
|
||||
// MP4
|
||||
video.src = url;
|
||||
video.onerror = () => {
|
||||
if (result.hlsUrl) {
|
||||
// HLS fallback (for some reason alot of animated covers js dont work on MP4 lol)
|
||||
this.setupHlsVideo(video, { videoUrl: null, hlsUrl: result.hlsUrl }, fallbackImg);
|
||||
} else {
|
||||
video.replaceWith(fallbackImg);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
replaceVideoArtwork(container, type, id, result) {
|
||||
const url = result.videoUrl || result.hlsUrl;
|
||||
if (!url) return;
|
||||
|
||||
const card = container.querySelector(`[data-${type}-id="${id}"]`);
|
||||
if (!card) return;
|
||||
const img = card.querySelector('.card-image');
|
||||
if (img && img.tagName !== 'VIDEO') {
|
||||
const video = document.createElement('video');
|
||||
video.autoplay = false;
|
||||
video.loop = false;
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.preload = 'metadata';
|
||||
video.className = img.className;
|
||||
video.id = img.id;
|
||||
video.style.objectFit = 'cover';
|
||||
|
||||
video.poster = img.src;
|
||||
|
||||
video.onerror = () => {
|
||||
if (video.src === result.videoUrl && result.hlsUrl) {
|
||||
this.setupHlsVideo(video, { videoUrl: null, hlsUrl: result.hlsUrl }, img);
|
||||
return;
|
||||
}
|
||||
video.replaceWith(img);
|
||||
};
|
||||
|
||||
video.addEventListener(
|
||||
'error',
|
||||
(e) => {
|
||||
if (video.src === result.videoUrl && result.hlsUrl) {
|
||||
this.setupHlsVideo(video, { videoUrl: null, hlsUrl: result.hlsUrl }, img);
|
||||
return;
|
||||
}
|
||||
console.warn('Video decoding error:', e);
|
||||
video.replaceWith(img);
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
img.replaceWith(video);
|
||||
|
||||
this.setupHlsVideo(video, result, img);
|
||||
|
||||
// If HLS, dont play
|
||||
const hls = video._hls;
|
||||
if (hls) {
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
// Dont play
|
||||
});
|
||||
} else {
|
||||
video.src = url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async renderSearchPage(query) {
|
||||
this.showPage('search');
|
||||
document.getElementById('search-results-title').textContent = `Search Results for "${query}"`;
|
||||
|
|
@ -2404,14 +2707,16 @@ export class UIRenderer {
|
|||
|
||||
try {
|
||||
const provider = this.api.getCurrentProvider();
|
||||
const [tracksResult, artistsResult, albumsResult, playlistsResult] = await Promise.all([
|
||||
const [tracksResult, videosResult, artistsResult, albumsResult, playlistsResult] = await Promise.all([
|
||||
this.api.searchTracks(query, { signal, provider }),
|
||||
this.api.searchVideos(query, { signal, provider }),
|
||||
this.api.searchArtists(query, { signal, provider }),
|
||||
this.api.searchAlbums(query, { signal, provider }),
|
||||
this.api.searchPlaylists(query, { signal, provider }),
|
||||
]);
|
||||
|
||||
let finalTracks = tracksResult.items;
|
||||
let finalVideos = videosResult.items || [];
|
||||
let finalArtists = artistsResult.items;
|
||||
let finalAlbums = albumsResult.items;
|
||||
let finalPlaylists = playlistsResult.items;
|
||||
|
|
@ -2453,6 +2758,26 @@ export class UIRenderer {
|
|||
tracksContainer.innerHTML = createPlaceholder('No tracks found.');
|
||||
}
|
||||
|
||||
const videosContainer = document.getElementById('search-videos-container');
|
||||
if (videosContainer) {
|
||||
videosContainer.innerHTML = finalVideos.length
|
||||
? finalVideos.map((video) => this.createVideoCardHTML(video)).join('')
|
||||
: createPlaceholder('No videos found.');
|
||||
|
||||
finalVideos.forEach((video) => {
|
||||
const el = videosContainer.querySelector(`[data-video-id="${video.id}"]`);
|
||||
if (el) {
|
||||
trackDataStore.set(el, video);
|
||||
el.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.card-play-btn') || e.target.closest('.card-image-container')) {
|
||||
e.stopPropagation();
|
||||
this.player.playVideo(video);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
artistsContainer.innerHTML = finalArtists.length
|
||||
? finalArtists.map((artist) => this.createArtistCardHTML(artist)).join('')
|
||||
: createPlaceholder('No artists found.');
|
||||
|
|
@ -2607,8 +2932,37 @@ export class UIRenderer {
|
|||
|
||||
try {
|
||||
const { album, tracks } = await this.api.getAlbum(albumId, provider);
|
||||
this.currentAlbumId = albumId;
|
||||
|
||||
const videoCoverUrl = album.videoCoverUrl || null;
|
||||
|
||||
if (!videoCoverUrl && tracks.length > 0) {
|
||||
const firstTrack = tracks[0];
|
||||
this.api.getVideoArtwork(firstTrack.title, getTrackArtists(firstTrack)).then((result) => {
|
||||
if (result && this.currentPage === 'album' && this.currentAlbumId === albumId) {
|
||||
const url = result.videoUrl || result.hlsUrl;
|
||||
if (!url) return;
|
||||
album.videoCoverUrl = url;
|
||||
const currentImageEl = document.getElementById('album-detail-image');
|
||||
if (currentImageEl && currentImageEl.tagName !== 'VIDEO') {
|
||||
const video = document.createElement('video');
|
||||
video.autoplay = false;
|
||||
video.loop = false;
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.preload = 'metadata';
|
||||
video.className = currentImageEl.className;
|
||||
video.id = currentImageEl.id;
|
||||
video.style.opacity = '1';
|
||||
video.poster = currentImageEl.src;
|
||||
|
||||
this.setupHlsVideo(video, result, currentImageEl);
|
||||
currentImageEl.replaceWith(video);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const videoCoverUrl = album.videoCover ? this.api.tidalAPI.getVideoCoverUrl(album.videoCover) : null;
|
||||
const coverUrl = videoCoverUrl || this.api.getCoverUrl(album.cover);
|
||||
|
||||
if (videoCoverUrl) {
|
||||
|
|
@ -3085,6 +3439,7 @@ export class UIRenderer {
|
|||
removeBtn.innerHTML =
|
||||
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>';
|
||||
removeBtn.dataset.trackId = currentTracks[index].id;
|
||||
removeBtn.dataset.type = currentTracks[index].type || 'track';
|
||||
|
||||
const menuBtn = actionsDiv.querySelector('.track-menu-btn');
|
||||
actionsDiv.insertBefore(removeBtn, menuBtn);
|
||||
|
|
@ -3358,6 +3713,7 @@ export class UIRenderer {
|
|||
|
||||
try {
|
||||
const { mix, tracks } = await this.api.getMix(mixId, provider);
|
||||
this.currentMixId = mixId;
|
||||
|
||||
if (mix.cover) {
|
||||
imageEl.src = mix.cover;
|
||||
|
|
@ -3366,10 +3722,56 @@ export class UIRenderer {
|
|||
} else {
|
||||
// Try to get cover from first track album
|
||||
if (tracks.length > 0 && tracks[0].album?.cover) {
|
||||
const videoCoverUrl = tracks[0].album?.videoCover
|
||||
? this.api.tidalAPI.getVideoCoverUrl(tracks[0].album.videoCover)
|
||||
: null;
|
||||
const coverUrl = videoCoverUrl || this.api.getCoverUrl(tracks[0].album.cover);
|
||||
const firstTrack = tracks[0];
|
||||
let videoCoverUrl =
|
||||
firstTrack.videoUrl || firstTrack.videoCoverUrl || firstTrack.album?.videoCoverUrl || null;
|
||||
|
||||
if (!videoCoverUrl && (firstTrack.album || firstTrack.type === 'video')) {
|
||||
const fetchArtwork = () => {
|
||||
this.api.getVideoArtwork(firstTrack.title, getTrackArtists(firstTrack)).then((result) => {
|
||||
if (result && this.currentPage === 'mix' && this.currentMixId === mixId) {
|
||||
const url = result.videoUrl || result.hlsUrl;
|
||||
if (!url) return;
|
||||
firstTrack.album = firstTrack.album || {};
|
||||
firstTrack.album.videoCoverUrl = url;
|
||||
const currentImageEl = document.getElementById('mix-detail-image');
|
||||
if (currentImageEl && currentImageEl.tagName !== 'VIDEO') {
|
||||
const video = document.createElement('video');
|
||||
video.autoplay = false;
|
||||
video.loop = false;
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.preload = 'metadata';
|
||||
video.className = currentImageEl.className;
|
||||
video.id = currentImageEl.id;
|
||||
video.style.opacity = '1';
|
||||
video.poster = currentImageEl.src;
|
||||
|
||||
this.setupHlsVideo(video, result, currentImageEl);
|
||||
currentImageEl.replaceWith(video);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (firstTrack.type === 'video') {
|
||||
this.api
|
||||
.getVideoStreamUrl(firstTrack.id)
|
||||
.then((url) => {
|
||||
if (url) {
|
||||
firstTrack.videoUrl = url;
|
||||
this.renderMixPage(mixId);
|
||||
} else {
|
||||
fetchArtwork();
|
||||
}
|
||||
})
|
||||
.catch(fetchArtwork);
|
||||
} else {
|
||||
fetchArtwork();
|
||||
}
|
||||
}
|
||||
|
||||
const coverUrl = videoCoverUrl || this.api.getCoverUrl(firstTrack.album.cover);
|
||||
|
||||
if (videoCoverUrl) {
|
||||
if (imageEl.tagName === 'IMG') {
|
||||
|
|
@ -3759,6 +4161,25 @@ export class UIRenderer {
|
|||
}
|
||||
});
|
||||
|
||||
const videosSection = document.getElementById('artist-section-videos');
|
||||
const videosContainer = document.getElementById('artist-detail-videos');
|
||||
if (videosSection && videosContainer) {
|
||||
if (artist.videos && artist.videos.length > 0) {
|
||||
videosContainer.innerHTML = artist.videos.map((video) => this.createVideoCardHTML(video)).join('');
|
||||
videosSection.style.display = 'block';
|
||||
|
||||
artist.videos.forEach((video) => {
|
||||
const el = videosContainer.querySelector(`[data-video-id="${video.id}"]`);
|
||||
if (el) {
|
||||
trackDataStore.set(el, video);
|
||||
this.updateLikeState(el, 'track', video.id);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
videosSection.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Check for unreleased projects
|
||||
const unreleasedSection = document.getElementById('artist-section-unreleased');
|
||||
const unreleasedContainer = document.getElementById('artist-detail-unreleased');
|
||||
|
|
@ -4410,11 +4831,56 @@ export class UIRenderer {
|
|||
console.warn('getTrack failed, trying getTrackMetadata', e);
|
||||
track = await this.api.getTrackMetadata(trackId, provider);
|
||||
}
|
||||
this.currentTrackPageId = track.id;
|
||||
|
||||
const videoCoverUrl = track.album?.videoCover
|
||||
? this.api.tidalAPI.getVideoCoverUrl(track.album.videoCover)
|
||||
: null;
|
||||
const coverUrl = videoCoverUrl || this.api.getCoverUrl(track.album?.cover);
|
||||
let videoCoverUrl = track.videoUrl || track.videoCoverUrl || track.album?.videoCoverUrl || null;
|
||||
|
||||
if (!videoCoverUrl && (track.album || track.type === 'video')) {
|
||||
const fetchArtwork = () => {
|
||||
this.api.getVideoArtwork(track.title, getTrackArtists(track)).then((result) => {
|
||||
if (result && this.currentPage === 'track' && this.currentTrackPageId === track.id) {
|
||||
const url = result.videoUrl || result.hlsUrl;
|
||||
if (!url) return;
|
||||
track.album = track.album || {};
|
||||
track.album.videoCoverUrl = url;
|
||||
const currentImageEl = document.getElementById('track-detail-image');
|
||||
if (currentImageEl && currentImageEl.tagName !== 'VIDEO') {
|
||||
const video = document.createElement('video');
|
||||
video.autoplay = false;
|
||||
video.loop = false;
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.preload = 'metadata';
|
||||
video.className = currentImageEl.className;
|
||||
video.id = currentImageEl.id;
|
||||
video.style.opacity = '1';
|
||||
video.poster = currentImageEl.src;
|
||||
|
||||
this.setupHlsVideo(video, result, currentImageEl);
|
||||
currentImageEl.replaceWith(video);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (track.type === 'video') {
|
||||
this.api
|
||||
.getVideoStreamUrl(track.id)
|
||||
.then((url) => {
|
||||
if (url) {
|
||||
track.videoUrl = url;
|
||||
this.renderTrackPage(trackId, provider);
|
||||
} else {
|
||||
fetchArtwork();
|
||||
}
|
||||
})
|
||||
.catch(fetchArtwork);
|
||||
} else {
|
||||
fetchArtwork();
|
||||
}
|
||||
}
|
||||
|
||||
const coverUrl = videoCoverUrl || this.api.getCoverUrl(track.image || track.cover || track.album?.cover);
|
||||
|
||||
if (videoCoverUrl) {
|
||||
if (imageEl.tagName === 'IMG') {
|
||||
|
|
|
|||
27
js/utils.js
27
js/utils.js
|
|
@ -134,6 +134,23 @@ export const detectAudioFormat = (view, mimeType = '') => {
|
|||
return 'mp3';
|
||||
}
|
||||
|
||||
if (
|
||||
view.byteLength >= 7 &&
|
||||
view.getUint8(0) === 0x23 &&
|
||||
view.getUint8(1) === 0x45 &&
|
||||
view.getUint8(2) === 0x58 &&
|
||||
view.getUint8(3) === 0x54 &&
|
||||
view.getUint8(4) === 0x4d &&
|
||||
view.getUint8(5) === 0x33 &&
|
||||
view.getUint8(6) === 0x55
|
||||
) {
|
||||
return 'm3u8';
|
||||
}
|
||||
|
||||
if (view.byteLength >= 188 && view.getUint8(0) === 0x47 && view.getUint8(188) === 0x47) {
|
||||
return 'ts';
|
||||
}
|
||||
|
||||
// Fallback to MIME type
|
||||
if (mimeType === 'audio/flac') return 'flac';
|
||||
if (mimeType === 'audio/mp4' || mimeType === 'audio/x-m4a') return 'mp4';
|
||||
|
|
@ -153,10 +170,16 @@ export const getExtensionFromBlob = async (blob) => {
|
|||
|
||||
const format = detectAudioFormat(view, blob.type);
|
||||
|
||||
if (format === 'mp4') return 'm4a';
|
||||
if (format === 'mp4') {
|
||||
if (blob.type.includes('video')) return 'mp4';
|
||||
return 'm4a';
|
||||
}
|
||||
if (format) return format;
|
||||
|
||||
// Default fallback
|
||||
if (blob.type.includes('video')) return 'mp4';
|
||||
if (blob.type === 'audio/mp4' || blob.type === 'audio/x-m4a') return 'm4a';
|
||||
if (blob.type === 'audio/mpeg' || blob.type === 'audio/mp3') return 'mp3';
|
||||
|
||||
return 'flac';
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,12 @@ export class Visualizer {
|
|||
this._resizeBound = () => this.resize();
|
||||
}
|
||||
|
||||
updateDimming() {
|
||||
if (!this.canvas || !this.canvas.parentElement) return;
|
||||
const dimAmount = visualizerSettings.getDimAmount();
|
||||
this.canvas.parentElement.style.opacity = dimAmount.toString();
|
||||
}
|
||||
|
||||
get activePreset() {
|
||||
return this.presets[this.activePresetKey] || this.presets['lcd'];
|
||||
}
|
||||
|
|
@ -150,6 +156,8 @@ export class Visualizer {
|
|||
this.audioContext.resume();
|
||||
}
|
||||
|
||||
this.updateDimming();
|
||||
|
||||
// Set canvas dimensions before preset init so WebGL framebuffers are created at correct size
|
||||
this.resize();
|
||||
window.addEventListener('resize', this._resizeBound);
|
||||
|
|
|
|||
1951
package-lock.json
generated
1951
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -33,6 +33,7 @@
|
|||
"@types/node": "^25.3.5",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"formidable": "^3.5.4",
|
||||
"globals": "^17.4.0",
|
||||
"htmlhint": "^1.9.2",
|
||||
"miniflare": "^4.20260301.1",
|
||||
|
|
@ -47,7 +48,8 @@
|
|||
},
|
||||
"overrides": {
|
||||
"sourcemap-codec": "npm:@jridgewell/sourcemap-codec@^1.4.14",
|
||||
"source-map": "^0.7.4"
|
||||
"source-map": "^0.7.4",
|
||||
"serialize-javascript": "^7.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ffmpeg/core": "^0.12.10",
|
||||
|
|
@ -63,8 +65,9 @@
|
|||
"fuse.js": "^7.1.0",
|
||||
"jose": "^6.2.0",
|
||||
"npm": "^11.11.0",
|
||||
"pocketbase": "^0.26.8",
|
||||
"taglib-wasm": "^1.0.5",
|
||||
"uuid": "^13.0.0"
|
||||
"uuid": "^13.0.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"pocketbase": "^0.26.8"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
383
styles.css
383
styles.css
|
|
@ -381,18 +381,6 @@ body {
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
background-color: var(--muted);
|
||||
border: none;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.text-link {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
|
|
@ -499,6 +487,75 @@ kbd {
|
|||
margin: var(--space-2);
|
||||
}
|
||||
|
||||
/* Base Elements */
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
background-color: var(--muted);
|
||||
border: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
outline: none;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px rgb(var(--highlight-rgb) / 0.2);
|
||||
}
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
li {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.m-3 {
|
||||
margin: var(--space-3);
|
||||
}
|
||||
|
|
@ -964,23 +1021,6 @@ kbd {
|
|||
margin-top: -0.5rem;
|
||||
}
|
||||
|
||||
.sidebar-nav.bottom {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar-nav.bottom .nav-item a {
|
||||
padding: 0.5rem 0.75rem;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-nav.bottom .nav-item a svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
grid-area: main;
|
||||
overflow-y: auto;
|
||||
|
|
@ -1134,6 +1174,23 @@ kbd {
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-nav.bottom {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar-nav.bottom .nav-item a {
|
||||
padding: 0.5rem 0.75rem;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-nav.bottom .nav-item a svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.pinned-items-header {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
|
|
@ -1772,6 +1829,17 @@ input[type='search']::-webkit-search-cancel-button {
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-like-btn:hover,
|
||||
.card-menu-btn:hover,
|
||||
.edit-playlist-btn:hover,
|
||||
.delete-playlist-btn:hover {
|
||||
background: rgb(0, 0, 0, 0.7) !important;
|
||||
transform: scale(1.1) rotate(5deg) !important;
|
||||
|
||||
/* Playful rotation */
|
||||
border-color: rgb(255, 255, 255, 0.3) !important;
|
||||
}
|
||||
|
||||
.card:hover .card-like-btn,
|
||||
.card:hover .card-menu-btn,
|
||||
.card-like-btn.active,
|
||||
|
|
@ -1784,17 +1852,6 @@ input[type='search']::-webkit-search-cancel-button {
|
|||
/* Slight delay for staggered feel */
|
||||
}
|
||||
|
||||
.card-like-btn:hover,
|
||||
.card-menu-btn:hover,
|
||||
.edit-playlist-btn:hover,
|
||||
.delete-playlist-btn:hover {
|
||||
background: rgb(0, 0, 0, 0.7) !important;
|
||||
transform: scale(1.1) rotate(5deg) !important;
|
||||
|
||||
/* Playful rotation */
|
||||
border-color: rgb(255, 255, 255, 0.3) !important;
|
||||
}
|
||||
|
||||
.card-like-btn.active {
|
||||
color: #ef4444 !important;
|
||||
}
|
||||
|
|
@ -2474,6 +2531,7 @@ input[type='search']::-webkit-search-cancel-button {
|
|||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 0.8333rem;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
|
|
@ -2617,6 +2675,65 @@ input[type='search']::-webkit-search-cancel-button {
|
|||
width: 100px;
|
||||
}
|
||||
|
||||
.playback-speed-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.playback-speed-slider {
|
||||
appearance: none;
|
||||
width: 150px;
|
||||
height: 6px;
|
||||
background: var(--border);
|
||||
border-radius: var(--radius-full);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.playback-speed-slider::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: var(--primary);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.playback-speed-slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.playback-speed-number-input {
|
||||
width: 80px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--input);
|
||||
color: var(--foreground);
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.playback-speed-unit {
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted-foreground);
|
||||
min-width: 1rem;
|
||||
}
|
||||
|
||||
.playback-speed-number-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
/* Hide arrows/spinners for number input */
|
||||
.playback-speed-number-input::-webkit-outer-spin-button,
|
||||
.playback-speed-number-input::-webkit-inner-spin-button {
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.template-input {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
|
|
@ -3483,13 +3600,21 @@ input:checked + .slider::before {
|
|||
transition: background-image var(--transition);
|
||||
}
|
||||
|
||||
#visualizer-canvas {
|
||||
#visualizer-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
#visualizer-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.fullscreen-cover-content {
|
||||
|
|
@ -3615,10 +3740,6 @@ input:checked + .slider::before {
|
|||
}
|
||||
|
||||
/* When UI is hidden, only toggle button stays visible at right edge (when .visible class is added) */
|
||||
#fullscreen-cover-overlay.ui-hidden #toggle-ui-btn {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay.ui-hidden .fullscreen-lyrics-toggle,
|
||||
#fullscreen-cover-overlay.ui-hidden #close-fullscreen-cover-btn {
|
||||
|
|
@ -5219,10 +5340,111 @@ img[src=''] {
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6rem 2rem 2rem;
|
||||
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay.is-video-mode .fullscreen-main-view {
|
||||
justify-content: flex-end;
|
||||
align-items: flex-start;
|
||||
padding: 2rem 2rem 6rem;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(to top, rgb(0, 0, 0, 0.6) 0%, rgb(0, 0, 0, 0.2) 15%, transparent 40%);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay.is-video-mode .fullscreen-track-info {
|
||||
text-align: left;
|
||||
background: none !important;
|
||||
backdrop-filter: none !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
max-width: 40%;
|
||||
margin: 0;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay.is-video-mode #fullscreen-track-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 1px 3px rgb(0, 0, 0, 0.8);
|
||||
margin-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay.is-video-mode #fullscreen-track-artist {
|
||||
font-size: 0.9rem;
|
||||
text-shadow: 0 1px 2px rgb(0, 0, 0, 0.8);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay.is-video-mode .fullscreen-controls {
|
||||
position: absolute;
|
||||
bottom: 1.5rem;
|
||||
left: 2rem;
|
||||
right: 2rem;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
background: rgb(15, 15, 15, 0.5);
|
||||
backdrop-filter: blur(12px);
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgb(255, 255, 255, 0.05);
|
||||
box-shadow: 0 4px 20px rgb(0, 0, 0, 0.4);
|
||||
transition:
|
||||
flex 0.3s ease,
|
||||
padding-right 0.3s ease,
|
||||
margin-right 0.3s ease;
|
||||
transform 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 0.4s ease;
|
||||
pointer-events: auto;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay.is-video-mode .fullscreen-buttons {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay.is-video-mode .fullscreen-buttons button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay.is-video-mode .fullscreen-buttons button svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay.is-video-mode .fullscreen-buttons #fs-play-pause-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay.is-video-mode .fullscreen-progress-container {
|
||||
gap: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay.is-video-mode .fullscreen-volume-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay.is-video-mode .fullscreen-volume-container {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay.ui-hidden .fullscreen-main-view,
|
||||
#fullscreen-cover-overlay.ui-hidden .fullscreen-controls,
|
||||
#fullscreen-cover-overlay.ui-hidden #fullscreen-next-track,
|
||||
#fullscreen-cover-overlay.ui-hidden .fullscreen-lyrics-toggle,
|
||||
#fullscreen-cover-overlay.ui-hidden #close-fullscreen-cover-btn {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay.is-video-mode.ui-hidden .fullscreen-controls {
|
||||
transform: translateY(100px);
|
||||
}
|
||||
|
||||
#fullscreen-cover-overlay.is-video-mode.ui-hidden .fullscreen-main-view {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Modals */
|
||||
|
|
@ -6801,24 +7023,6 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn {
|
|||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
/* Inputs & Form Elements */
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast),
|
||||
background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--ring);
|
||||
box-shadow: 0 0 0 3px rgb(var(--highlight-rgb), 0.2);
|
||||
}
|
||||
|
||||
.modal.active .modal-content {
|
||||
animation: pop-in var(--transition-normal) var(--ease-out-back);
|
||||
box-shadow: var(--shadow-2xl);
|
||||
|
|
@ -7933,3 +8137,50 @@ textarea:focus {
|
|||
font-size: 0.85rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.video-card .card-image-container {
|
||||
aspect-ratio: 16 / 9 !important;
|
||||
}
|
||||
|
||||
.video-card .card-image {
|
||||
aspect-ratio: 16 / 9 !important;
|
||||
object-fit: cover !important;
|
||||
}
|
||||
|
||||
.video-duration-badge {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
background: rgb(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
#fullscreen-video-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
background: black;
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#fullscreen-video-container video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.search-tab[data-tab='videos'] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable", "webworker"],
|
||||
"types": ["vite/client", "node"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
|
|
|
|||
84
vite-plugin-upload.js
Normal file
84
vite-plugin-upload.js
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { formidable } from 'formidable';
|
||||
import fs from 'fs';
|
||||
import { Blob } from 'buffer';
|
||||
import { loadEnv } from 'vite';
|
||||
|
||||
export default function uploadPlugin() {
|
||||
let env = {};
|
||||
|
||||
const handler = async (req, res, next) => {
|
||||
if (req.url === '/upload' && req.method === 'POST') {
|
||||
const form = formidable({});
|
||||
|
||||
try {
|
||||
const [_fields, files] = await form.parse(req);
|
||||
const uploadedFile = files.file?.[0];
|
||||
|
||||
if (!uploadedFile) {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify({ success: false, error: 'No file provided' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const fileData = fs.readFileSync(uploadedFile.filepath);
|
||||
const useR2 = env.R2_ENABLED === 'true';
|
||||
|
||||
let url;
|
||||
|
||||
if (useR2) {
|
||||
// We could implement R2 upload here too, but for simplicity in dev
|
||||
// we'll stick to catbox unless specifically requested to match R2 perfectly.
|
||||
// However, to be helpful, let's at least mention it.
|
||||
console.log('R2 upload detected in env, but dev plugin is using catbox fallback for now.');
|
||||
}
|
||||
|
||||
// Forward to catbox.moe (default production behavior when R2 is disabled)
|
||||
const formData = new FormData();
|
||||
formData.append('reqtype', 'fileupload');
|
||||
formData.append(
|
||||
'fileToUpload',
|
||||
new Blob([fileData], { type: uploadedFile.mimetype }),
|
||||
uploadedFile.originalFilename
|
||||
);
|
||||
|
||||
const response = await fetch('https://catbox.moe/user/api.php', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
url = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload failed: ${url}`);
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
url: url.trim(),
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Local upload error:', err);
|
||||
res.statusCode = 500;
|
||||
res.end(JSON.stringify({ success: false, error: err.message }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
return {
|
||||
name: 'upload-plugin',
|
||||
config(_, { mode }) {
|
||||
env = loadEnv(mode, process.cwd(), '');
|
||||
},
|
||||
configureServer(server) {
|
||||
server.middlewares.use(handler);
|
||||
},
|
||||
configurePreviewServer(server) {
|
||||
server.middlewares.use(handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { VitePWA } from 'vite-plugin-pwa';
|
|||
import neutralino from 'vite-plugin-neutralino';
|
||||
import authGatePlugin from './vite-plugin-auth-gate.js';
|
||||
import path from 'path';
|
||||
import uploadPlugin from './vite-plugin-upload.js';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const IS_NEUTRALINO = mode === 'neutralino';
|
||||
|
|
@ -37,6 +38,7 @@ export default defineConfig(({ mode }) => {
|
|||
plugins: [
|
||||
IS_NEUTRALINO && neutralino(),
|
||||
authGatePlugin(),
|
||||
uploadPlugin(),
|
||||
VitePWA({
|
||||
registerType: 'prompt',
|
||||
workbox: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue