diff --git a/.gitignore b/.gitignore index 7cfd02b..6cfd8ed 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .DS_Store *.log node_modules/ +dist/ diff --git a/Dockerfile b/Dockerfile index 304eda0..380baa0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,12 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package.json ./ +RUN npm install +COPY . . +RUN npm run build + FROM nginx:alpine -COPY index.html styles.css app.js sw.js manifest.json icon.svg /usr/share/nginx/html/ +COPY --from=builder /app/dist /usr/share/nginx/html/ COPY nginx.conf /etc/nginx/conf.d/default.conf COPY docker-entrypoint.sh /docker-entrypoint.sh RUN chmod +x /docker-entrypoint.sh diff --git a/index.html b/index.html index acff5ee..2fe3244 100644 --- a/index.html +++ b/index.html @@ -15,7 +15,7 @@ - + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..90d8d2f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,985 @@ +{ + "name": "novasonic", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "novasonic", + "devDependencies": { + "vite": "^5.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2a604e2 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "novasonic", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "vite": "^5.4.0" + } +} diff --git a/public/config.js b/public/config.js new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/public/config.js @@ -0,0 +1 @@ + diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..42b0a97 --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..1e2b74d --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,25 @@ +{ + "name": "NovaSonic", + "short_name": "NovaSonic", + "description": "Self-hosted music streaming PWA", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "portrait-primary", + "background_color": "#07070f", + "theme_color": "#07070f", + "icons": [ + { + "src": "icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "maskable" + } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..ff3af43 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,40 @@ +const CACHE = 'ns-v38'; +const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg']; +const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js']; + +self.addEventListener('install', e => { + e.waitUntil(caches.open(CACHE).then(c => c.addAll(PRECACHE))); + self.skipWaiting(); +}); + +self.addEventListener('activate', e => { + e.waitUntil( + caches.keys() + .then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))) + .then(() => self.clients.claim()) + .then(() => self.clients.matchAll({ type: 'window', includeUncontrolled: true })) + .then(clients => clients.forEach(c => c.postMessage({ type: 'SW_UPDATED', version: CACHE }))) + ); +}); + +self.addEventListener('message', e => { + if (e.data?.type === 'GET_VERSION') { + e.source.postMessage({ type: 'VERSION', version: CACHE }); + } +}); + +self.addEventListener('fetch', e => { + if (!e.request.url.startsWith(self.location.origin)) return; + if (SKIP_PATHS.some(p => e.request.url.includes(p))) return; + e.respondWith( + fetch(e.request) + .then(res => { + if (res.status === 200 && e.request.method === 'GET') { + const clone = res.clone(); + caches.open(CACHE).then(c => c.put(e.request, clone)); + } + return res; + }) + .catch(() => caches.match(e.request).then(r => r || Response.error())) + ); +}); diff --git a/src/api.js b/src/api.js new file mode 100644 index 0000000..1e0c169 --- /dev/null +++ b/src/api.js @@ -0,0 +1,92 @@ +import { S } from './state.js'; +import { md5 } from './utils.js'; + +// ─── SUBSONIC ─── +export function apiUrl(method, extra='') { + const salt = Math.random().toString(36).slice(2,10); + const token = md5(S.cfg.pass + salt); + return `${S.cfg.url}/rest/${method}?u=${encodeURIComponent(S.cfg.user)}&t=${token}&s=${salt}&v=1.16.1&c=NovaSonic&f=json${extra}`; +} +export async function api(method, extra='') { + const res = await fetch(apiUrl(method, extra)); + if(!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + const r = data['subsonic-response']; + if(r.status !== 'ok') throw new Error(r.error?.message || 'API Error'); + return r; +} +export function streamUrl(id) { return apiUrl('stream', `&id=${id}&format=raw`); } +export function downloadUrl(id) { return apiUrl('download', `&id=${id}`); } +export function coverUrl(id, size=300) { return id ? apiUrl('getCoverArt', `&id=${id}&size=${size}`) : null; } + +// ─── NAVIDROME REST AUTH ─── +export async function ndAuth() { + if(S._ndToken) return S._ndToken; + const res = await fetch(`${S.cfg.url}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: S.cfg.user, password: S.cfg.pass }), + }); + if(!res.ok) throw new Error('Navidrome login fehlgeschlagen'); + const d = await res.json(); + S._ndToken = d.token; + return S._ndToken; +} + +// ─── ITUNES ─── +const ITUNES = 'https://itunes.apple.com'; + +export async function itunesSearch(query, entity='musicTrack', limit=25) { + const r = await fetch(`${ITUNES}/search?term=${encodeURIComponent(query)}&media=music&entity=${entity}&limit=${limit}&country=de`); + const d = await r.json(); + return d.results || []; +} + +export async function itunesLookupAlbum(collectionId) { + const r = await fetch(`${ITUNES}/lookup?id=${collectionId}&entity=song`); + const d = await r.json(); + return (d.results || []).filter(x => x.wrapperType === 'track'); +} + +export async function itunesTopCharts() { + const urls = [ + 'https://rss.applemarketingtools.com/api/v2/de/music/most-played/50/albums.json', + 'https://rss.applemarketingtools.com/api/v2/us/music/most-played/50/albums.json', + ]; + for(const url of urls) { + try { + const r = await fetch(url, { signal: AbortSignal.timeout(6000) }); + if(!r.ok) continue; + const d = await r.json(); + if(d.feed?.results?.length) return d.feed.results; + } catch {} + } + try { + const r = await fetch('https://itunes.apple.com/search?term=new+music&media=music&entity=album&limit=50&country=de'); + const d = await r.json(); + return (d.results||[]).map(a => ({ + name: a.collectionName, artistName: a.artistName, + artworkUrl: a.artworkUrl100?.replace('100x100','300x300'), artworkUrl100: a.artworkUrl100, + })); + } catch { return []; } +} + +export async function itunesTopSongs() { + const urls = [ + 'https://rss.applemarketingtools.com/api/v2/de/music/most-played/50/songs.json', + 'https://rss.applemarketingtools.com/api/v2/us/music/most-played/50/songs.json', + ]; + for(const url of urls) { + try { + const r = await fetch(url, { signal: AbortSignal.timeout(6000) }); + if(!r.ok) continue; + const d = await r.json(); + if(d.feed?.results?.length) return d.feed.results; + } catch {} + } + try { + const r = await fetch('https://itunes.apple.com/search?term=top+hits&media=music&entity=musicTrack&limit=50&country=de'); + const d = await r.json(); + return (d.results||[]).map(t => ({ name: t.trackName, artistName: t.artistName, artworkUrl: t.artworkUrl100 })); + } catch { return []; } +} diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..dd2b8e6 --- /dev/null +++ b/src/main.js @@ -0,0 +1,164 @@ +import { S } from './state.js'; +import { toast } from './utils.js'; +import { api } from './api.js'; +import { injectPWA } from './pwa.js'; +import { + playQueue, playCurrent, togglePlay, prevSong, nextSong, onEnded, + toggleShuffle, toggleRepeat, toggleQueue, setVolume, seekTo, + initProgressScrub, updateProgress, updatePreviewProgress, stopPreview, + toggleRadioMode, starCurrent, downloadCurrent, renderQueue, playPreview, + toggleStar, dlSongLocal, _applyRadioBtn, _restoreLastPlayed, setPlayIcon, +} from './player.js'; +import { + navigate, _navGoBack, _navLoad, loadHomeData, onLocalSearch, onMobileSearch, + onGlobalSearch, s_goArtist, s_goAlbum, playerBarGoArtist, playerBarGoAlbum, + searchSimilarArtist, searchAndPreview, loadItunesAlbum, expandArtistBio, +} from './views.js'; +import { + openNowPlaying, closeNowPlaying, switchNpTab, npGoArtist, npGoAlbum, + openNpMenu, closeNpMenu, openSettings, closeSettings, saveSettings, + checkBackend, openSidebar, closeSidebar, switchNavMode, showCtxMenu, + openPlModal, closePlModal, addSongsToPlaylist, createAndAddPlaylist, + createPlaylistPrompt, showSongInfo, closeInfoModal, openDlModal, closeDlModal, + selectDlType, executeDownload, openEditPlModal, closeEditPlModal, + saveEditPlaylist, editPlUploadCover, editPlCoverSelected, editPlRemoveCover, + toggleMobileSearch, closeMobileSearch, switchSetupTab, +} from './ui.js'; + +// ─── CONNECT ─── +async function doConnect() { + const url = document.getElementById('srv-url').value.trim().replace(/\/$/,''); + const user = document.getElementById('srv-user').value.trim(); + const pass = document.getElementById('srv-pass').value; + const lfKey = document.getElementById('lastfm-key').value.trim(); + const backUrl = document.getElementById('backend-url').value.trim().replace(/\/$/,''); + if(!url || !user || !pass) { _showSetupError('Bitte Server-URL, Benutzernamen und Passwort ausfüllen.'); return; } + const btn = document.getElementById('btn-connect'); + btn.disabled=true; btn.textContent='Verbinde…'; + document.getElementById('setup-error').style.display='none'; + S.cfg = {url, user, pass, lastfmKey:lfKey, backendUrl:backUrl}; + try { + await api('ping'); + localStorage.setItem('ns_cfg', JSON.stringify(S.cfg)); + startApp(); + } catch(e) { + S.cfg = {url:'', user:'', pass:'', lastfmKey:'', backendUrl:''}; + _showSetupError('Verbindung fehlgeschlagen: '+e.message); + btn.disabled=false; btn.textContent='Verbinden'; + } +} +function _showSetupError(m) { const el=document.getElementById('setup-error'); el.textContent=m; el.style.display='block'; } + +function disconnect() { + localStorage.removeItem('ns_cfg'); + localStorage.removeItem('ns_last_played'); + sessionStorage.removeItem('ns_nav'); + S.cfg = {url:'', user:'', pass:'', lastfmKey:'', backendUrl:''}; + S.queue=[]; S.qIdx=-1; S._navStack=[]; S._navCursor=-1; + S.audio.pause(); S.audio.src=''; + document.getElementById('app').style.display='none'; + document.getElementById('setup-screen').style.display='flex'; +} + +async function startApp() { + document.getElementById('setup-screen').style.display='none'; + document.getElementById('app').style.display='flex'; + if(_navLoad() && S._navCursor >= 0 && S._navStack[S._navCursor]) { + const last = S._navStack[S._navCursor]; + S._navJumping = true; + navigate(last.page, last.data); + S._navJumping = false; + } else { + navigate('home'); + } + loadHomeData(); + _restoreLastPlayed(); + if(localStorage.getItem('ns_radio')==='1') { S.radioMode=true; _applyRadioBtn(); } +} + +// ─── INIT ─── +window.addEventListener('load', () => { + // Wire up audio elements from DOM + S.audio = document.getElementById('audio-element'); + S.previewAudio = document.getElementById('preview-audio'); + + const saved = localStorage.getItem('ns_cfg'); + if(saved) { S.cfg = JSON.parse(saved); startApp(); } + if(window.NS_SERVER_URL?.trim()) { const el=document.getElementById('srv-url'); if(el&&!el.value) el.value=window.NS_SERVER_URL.trim(); } + if(window.NS_BACKEND_URL?.trim()) { const el=document.getElementById('backend-url'); if(el&&!el.value) el.value=window.NS_BACKEND_URL.trim(); } + + document.getElementById('srv-pass').addEventListener('keydown', e => { if(e.key==='Enter') doConnect(); }); + S.audio.addEventListener('timeupdate', updateProgress); + S.audio.addEventListener('ended', onEnded); + S.audio.addEventListener('play', () => { + setPlayIcon(true); + document.getElementById('player-art').classList.add('spinning'); + if(navigator.mediaSession) navigator.mediaSession.playbackState = 'playing'; + }); + S.audio.addEventListener('pause', () => { + setPlayIcon(false); + document.getElementById('player-art').classList.remove('spinning'); + if(navigator.mediaSession) navigator.mediaSession.playbackState = 'paused'; + }); + S.previewAudio.addEventListener('timeupdate', updatePreviewProgress); + S.previewAudio.addEventListener('ended', stopPreview); + S.audio.volume = 0.8; + + injectPWA(); + initProgressScrub(); + + // Click player-art to open Now Playing + document.getElementById('player-art')?.addEventListener('click', openNowPlaying); + + // Swipe-up on player bar to open Now Playing + const playerBar = document.querySelector('.player'); + if(playerBar) { + let _sy=0, _st=0; + playerBar.addEventListener('touchstart', e => { + if(e.target.closest('button') || e.target.closest('.progress-bar') || e.target.closest('.volume-wrap')) return; + _sy=e.touches[0].clientY; _st=Date.now(); + }, {passive:true}); + playerBar.addEventListener('touchend', e => { + if(!_sy) return; + const dy=_sy-e.changedTouches[0].clientY, dt=Date.now()-_st; + if(dy>40 || (dt<300&&dy>15)) openNowPlaying(); + _sy=0; + }, {passive:true}); + } +}); + +window.addEventListener('popstate', () => _navGoBack()); +document.addEventListener('keydown', e => { + if(e.key !== 'Backspace') return; + const t = document.activeElement?.tagName; + if(t==='INPUT' || t==='TEXTAREA' || t==='SELECT') return; + e.preventDefault(); + _navGoBack(); +}); +try { history.replaceState({ nsIdx: 0 }, '', ''); } catch {} + +// ─── EXPOSE GLOBALS (for HTML onclick attributes) ─── +Object.assign(window, { + // Navigation + navigate, onLocalSearch, onMobileSearch, onGlobalSearch, s_goArtist, s_goAlbum, + playerBarGoArtist, playerBarGoAlbum, searchSimilarArtist, searchAndPreview, + loadItunesAlbum, expandArtistBio, + // Player + playQueue, togglePlay, prevSong, nextSong, toggleShuffle, toggleRepeat, + toggleQueue, setVolume, seekTo, starCurrent, downloadCurrent, playPreview, + toggleStar, dlSongLocal, toggleRadioMode, + // Now Playing + openNowPlaying, closeNowPlaying, switchNpTab, npGoArtist, npGoAlbum, + openNpMenu, closeNpMenu, + // UI / Modals + openSettings, closeSettings, saveSettings, checkBackend, + openSidebar, closeSidebar, showCtxMenu, + openPlModal, closePlModal, addSongsToPlaylist, createAndAddPlaylist, createPlaylistPrompt, + showSongInfo, closeInfoModal, + openDlModal, closeDlModal, selectDlType, executeDownload, + openEditPlModal, closeEditPlModal, saveEditPlaylist, + editPlUploadCover, editPlCoverSelected, editPlRemoveCover, + toggleMobileSearch, closeMobileSearch, switchSetupTab, + // App + doConnect, disconnect, +}); diff --git a/src/player.js b/src/player.js new file mode 100644 index 0000000..320403b --- /dev/null +++ b/src/player.js @@ -0,0 +1,354 @@ +import { S } from './state.js'; +import { esc, formatDur, toast } from './utils.js'; +import { api, streamUrl, coverUrl, downloadUrl } from './api.js'; +// views.js circular import — safe because only used inside function bodies +import { loadRecommendations } from './views.js'; + +// ─── PLAYBACK ─── +export function playQueue(songs, startIdx) { + S.queue = songs.map(s => ({...s})); + S.qIdx = startIdx; + playCurrent(); + renderQueue(); + if(S.radioMode) fillRadioQueue(); +} + +export function playCurrent() { + if(S.qIdx < 0 || S.qIdx >= S.queue.length) return; + const s = S.queue[S.qIdx]; + S.audio.src = streamUrl(s.id); + S.audio.play(); + updatePlayerUI(s); + renderQueue(); +} + +export function updatePlayerUI(s) { + document.getElementById('player-title').textContent = s.title; + document.getElementById('player-artist-name').textContent = s.artist || '—'; + document.getElementById('player-album-sep').style.display = s.album ? '' : 'none'; + document.getElementById('player-album-name').textContent = s.album || ''; + const art = document.getElementById('player-art'); + const cv = coverUrl(s.coverArt, 100); + art.innerHTML = cv ? `` : '
♪
'; + updateStarBtn(); + document.title = `${s.title} — NovaSonic`; + updateMediaSession(s); + if(document.getElementById('page-recommendations').classList.contains('active')) loadRecommendations(); + try { + localStorage.setItem('ns_last_played', JSON.stringify({ + song: { id:s.id, title:s.title, artist:s.artist, album:s.album, albumId:s.albumId, + artistId:s.artistId, coverArt:s.coverArt, duration:s.duration, _starred:s._starred||false }, + queue: S.queue.map(t => ({ id:t.id, title:t.title, artist:t.artist, album:t.album, + albumId:t.albumId, artistId:t.artistId, coverArt:t.coverArt, + duration:t.duration, _starred:t._starred||false })), + qIdx: S.qIdx, + })); + } catch {} + _syncNpDirect(s); +} + +export function _syncNpDirect(s) { + if(!S.npOpen || !s) return; + const cv = coverUrl(s.coverArt, 400); + const cover = document.getElementById('np-cover'); + const bg = document.getElementById('np-bg'); + if(cover) cover.innerHTML = cv ? `` : '
♪
'; + if(bg) bg.style.backgroundImage = cv ? `url('${cv}')` : 'none'; + const t = document.getElementById('np-title'); if(t) t.textContent = s.title || '—'; + const an = document.getElementById('np-artist-name'); if(an) an.textContent = s.artist || '—'; + const asep = document.getElementById('np-album-sep'); if(asep) asep.style.display = s.album ? '' : 'none'; + const aln = document.getElementById('np-album-name'); if(aln) aln.textContent = s.album || ''; + syncNpStar(); +} + +// ─── TRANSPORT ─── +export function togglePlay() { if(S.audio.src) S.audio.paused ? S.audio.play() : S.audio.pause(); } + +export function setPlayIcon(playing) { + document.getElementById('play-icon').innerHTML = playing + ? '' + : ''; + if(S.npOpen) { + document.getElementById('np-play-icon').innerHTML = playing + ? '' + : ''; + const cover = document.getElementById('np-cover'); + if(cover) cover.classList.toggle('playing', playing); + } +} + +export function prevSong() { + if(S.audio.currentTime > 3) { S.audio.currentTime = 0; return; } + if(S.qIdx > 0) { S.qIdx--; playCurrent(); } +} + +export function nextSong() { + if(S.shuffle) S.qIdx = Math.floor(Math.random() * S.queue.length); + else if(S.qIdx < S.queue.length - 1) S.qIdx++; + else if(S.repeat) S.qIdx = 0; + else if(S.radioMode) { fillRadioQueue().then(() => { if(S.qIdx < S.queue.length-1) { S.qIdx++; playCurrent(); } }); return; } + else return; + if(S.radioMode && S.queue.length - S.qIdx < 5) fillRadioQueue(); + playCurrent(); +} + +export function onEnded() { + if(S.repeat === 'one') { S.audio.currentTime = 0; S.audio.play(); return; } + nextSong(); +} + +export function toggleShuffle() { + S.shuffle = !S.shuffle; + document.getElementById('btn-shuffle').classList.toggle('active', S.shuffle); + document.getElementById('np-shuffle-btn')?.classList.toggle('active', S.shuffle); + document.getElementById('badge-shuffle')?.classList.toggle('on', S.shuffle); +} + +export function toggleRepeat() { + S.repeat = S.repeat === false ? 'all' : S.repeat === 'all' ? 'one' : false; + document.getElementById('btn-repeat').classList.toggle('active', S.repeat !== false); + document.getElementById('btn-repeat').classList.toggle('repeat-one', S.repeat === 'one'); + const nb = document.getElementById('np-repeat-btn'); + if(nb) { nb.classList.toggle('active', S.repeat !== false); nb.classList.toggle('repeat-one', S.repeat === 'one'); } + const badge = document.getElementById('badge-repeat'); + if(badge) { badge.classList.toggle('on', S.repeat !== false); badge.textContent = S.repeat === 'one' ? '🔂 Einmal' : '🔁 Repeat'; } +} + +// ─── RADIO ─── +export function _applyRadioBtn() { + const btn = document.getElementById('btn-radio-mode'); + if(btn) { btn.classList.toggle('active', S.radioMode); btn.style.color = S.radioMode ? 'var(--blue)' : ''; } + const nb = document.getElementById('np-radio-btn'); + if(nb) { nb.classList.toggle('active', S.radioMode); nb.style.color = S.radioMode ? 'var(--blue)' : ''; } +} + +export async function toggleRadioMode() { + S.radioMode = !S.radioMode; + localStorage.setItem('ns_radio', S.radioMode ? '1' : '0'); + _applyRadioBtn(); + if(S.radioMode) await fillRadioQueue(); +} + +export async function fillRadioQueue() { + if(!S.radioMode) return; + try { + const r = await api('getRandomSongs', '&size=20'); + S.queue.push(...(r.randomSongs?.song || [])); + renderQueue(); + } catch {} +} + +// ─── PROGRESS & SCRUBBING ─── +export function updateProgress() { + if(!S.audio.duration || S._scrubbing) return; + syncMediaSessionPosition(); + const p = (S.audio.currentTime / S.audio.duration) * 100; + document.getElementById('progress-fill').style.width = p + '%'; + document.getElementById('time-cur').textContent = formatDur(S.audio.currentTime); + document.getElementById('time-total').textContent = formatDur(S.audio.duration); + if(S.npOpen) { + const fill = document.getElementById('np-fill'); + const cur = document.getElementById('np-cur'); + const tot = document.getElementById('np-tot'); + if(fill) fill.style.width = p + '%'; + if(cur) cur.textContent = formatDur(S.audio.currentTime); + if(tot) tot.textContent = formatDur(S.audio.duration); + } +} + +export function seekTo(e) { + const bar = document.getElementById('progress-bar'); + const rect = bar.getBoundingClientRect(); + S.audio.currentTime = ((e.clientX - rect.left) / rect.width) * S.audio.duration; +} + +export function initProgressScrub() { + _initBarScrub( + document.getElementById('progress-bar'), + document.getElementById('progress-fill'), + pos => { document.getElementById('time-cur').textContent = formatDur(pos * S.audio.duration); } + ); + _initBarScrub( + document.getElementById('np-bar'), + document.getElementById('np-fill'), + pos => { const el = document.getElementById('np-cur'); if(el) el.textContent = formatDur(pos * S.audio.duration); } + ); +} + +function _initBarScrub(bar, fill, onMove) { + if(!bar || !fill) return; + function getPos(clientX) { const r = bar.getBoundingClientRect(); return Math.max(0, Math.min(1, (clientX-r.left)/r.width)); } + function startScrub(pos) { S._scrubbing=true; fill.style.transition='none'; fill.style.width=(pos*100)+'%'; onMove(pos); } + function moveScrub(pos) { fill.style.width=(pos*100)+'%'; onMove(pos); } + function endScrub(pos) { S._scrubbing=false; fill.style.transition=''; if(S.audio.duration) S.audio.currentTime=pos*S.audio.duration; } + bar.addEventListener('touchstart', e => { startScrub(getPos(e.touches[0].clientX)); e.stopPropagation(); }, {passive:true}); + bar.addEventListener('touchmove', e => { if(S._scrubbing) moveScrub(getPos(e.touches[0].clientX)); }, {passive:true}); + bar.addEventListener('touchend', e => { if(S._scrubbing) endScrub(getPos(e.changedTouches[0].clientX)); }, {passive:true}); + bar.addEventListener('mousedown', e => { + startScrub(getPos(e.clientX)); e.preventDefault(); + const mm = e2 => { if(S._scrubbing) moveScrub(getPos(e2.clientX)); }; + const mu = e2 => { if(S._scrubbing) endScrub(getPos(e2.clientX)); document.removeEventListener('mousemove',mm); document.removeEventListener('mouseup',mu); }; + document.addEventListener('mousemove', mm); + document.addEventListener('mouseup', mu); + }); +} + +export function setVolume(v) { S.audio.volume = v/100; S.previewAudio.volume = v/100; } + +// ─── MEDIA SESSION ─── +export function updateMediaSession(s) { + if(!navigator.mediaSession) return; + const artwork = s.coverArt ? [ + { src: coverUrl(s.coverArt,96), sizes:'96x96', type:'image/jpeg' }, + { src: coverUrl(s.coverArt,300), sizes:'300x300', type:'image/jpeg' }, + { src: coverUrl(s.coverArt,512), sizes:'512x512', type:'image/jpeg' }, + ] : []; + navigator.mediaSession.metadata = new MediaMetadata({ title:s.title, artist:s.artist||'', album:s.album||'', artwork }); + navigator.mediaSession.setActionHandler('play', togglePlay); + navigator.mediaSession.setActionHandler('pause', togglePlay); + navigator.mediaSession.setActionHandler('previoustrack', prevSong); + navigator.mediaSession.setActionHandler('nexttrack', nextSong); + navigator.mediaSession.setActionHandler('seekbackward', d => { S.audio.currentTime = Math.max(0, S.audio.currentTime-(d.seekOffset||10)); }); + navigator.mediaSession.setActionHandler('seekforward', d => { S.audio.currentTime = Math.min(S.audio.duration||0, S.audio.currentTime+(d.seekOffset||10)); }); + navigator.mediaSession.setActionHandler('seekto', d => { if(d.seekTime!=null) S.audio.currentTime = d.seekTime; }); +} + +export function syncMediaSessionPosition() { + if(!navigator.mediaSession?.setPositionState || !S.audio.duration || isNaN(S.audio.duration)) return; + const now = Date.now(); + if(now - S._msPosLast < 1000) return; + S._msPosLast = now; + try { navigator.mediaSession.setPositionState({ duration:S.audio.duration, playbackRate:S.audio.playbackRate||1, position:Math.min(S.audio.currentTime, S.audio.duration) }); } catch {} +} + +// ─── STAR ─── +export async function toggleStar(song, btn) { + try { + await api(song._starred ? 'unstar' : 'star', `&id=${song.id}`); + song._starred = !song._starred; + if(btn) { + btn.classList.toggle('starred', song._starred); + btn.querySelector('svg')?.setAttribute('fill', song._starred ? 'currentColor' : 'none'); + } + if(S.queue[S.qIdx]?.id === song.id) updateStarBtn(); + } catch(e) { toast('Fehler: ' + e.message); } +} + +export function starCurrent() { + if(S.qIdx < 0 || !S.queue[S.qIdx]) return; + toggleStar(S.queue[S.qIdx], document.getElementById('btn-star-current')); +} + +export function updateStarBtn() { + const s = S.queue[S.qIdx]; + const icon = document.getElementById('star-icon'); + if(!icon) return; + const isStarred = s && (s._starred || s.starred); + icon.setAttribute('fill', isStarred ? 'var(--accent)' : 'none'); + icon.setAttribute('stroke', isStarred ? 'var(--accent)' : 'var(--text3)'); +} + +export function syncNpStar() { + if(!S.npOpen) return; + const s = S.queue[S.qIdx]; + const icon = document.getElementById('np-star-icon'); + if(!icon || !s) return; + const starred = s._starred || s.starred; + icon.setAttribute('fill', starred ? 'var(--accent)' : 'none'); + icon.setAttribute('stroke', starred ? 'var(--accent)' : 'rgba(255,255,255,.5)'); +} + +export function npToggleStar() { + const s = S.queue[S.qIdx]; + if(!s) return; + api(s._starred || s.starred ? 'unstar' : 'star', `&id=${s.id}`) + .then(() => { s._starred = !(s._starred || s.starred); s.starred = null; syncNpStar(); updateStarBtn(); }) + .catch(e => toast('Fehler: ' + e.message)); +} + +// ─── QUEUE PANEL ─── +export function renderQueue() { + const el = document.getElementById('queue-list'); + el.innerHTML = ''; + if(!S.queue.length) { el.innerHTML='
🎵

Leer

'; return; } + S.queue.forEach((s, i) => { + const item = document.createElement('div'); + item.className = 'queue-item' + (i === S.qIdx ? ' active' : ''); + item.innerHTML = `
${i+1}
${esc(s.title)}
${esc(s.artist||'')}
`; + item.onclick = () => { S.qIdx = i; playCurrent(); }; + el.appendChild(item); + }); + el.querySelector('.active')?.scrollIntoView({ block: 'nearest' }); +} + +export function toggleQueue() { + S.queueOpen = !S.queueOpen; + document.getElementById('queue-panel').classList.toggle('open', S.queueOpen); + if(S.queueOpen) renderQueue(); +} + +// ─── DOWNLOAD ─── +export function dlSongLocal(s) { + const a = document.createElement('a'); + a.href = downloadUrl(s.id); + a.download = `${s.artist||'Unknown'} - ${s.title}.${s.suffix||'mp3'}`; + document.body.appendChild(a); a.click(); document.body.removeChild(a); + toast(`⬇ ${s.title}`); +} + +export function downloadCurrent() { + if(S.qIdx >= 0 && S.queue[S.qIdx]) dlSongLocal(S.queue[S.qIdx]); +} + +// ─── PREVIEW ─── +export function playPreview(track) { + if(!track.previewUrl) { toast('Kein Preview verfügbar'); return; } + const wasPlaying = !S.audio.paused; + if(wasPlaying) S.audio.pause(); + S.previewCurrent = track; + S.previewCurrent._resumeMain = wasPlaying; + S.previewAudio.volume = S.audio.volume; + S.previewAudio.src = track.previewUrl; + S.previewAudio.play(); + document.getElementById('preview-title').innerHTML = esc(track.trackName||'—') + '30s Preview'; + document.getElementById('preview-sub').textContent = (track.artistName||'') + (track.collectionName?' · '+track.collectionName:''); + const art = track.artworkUrl100 || track.artworkUrl60 || ''; + document.getElementById('preview-art').innerHTML = art ? `` : '
♪
'; + document.getElementById('preview-bar').classList.add('visible'); + document.getElementById('preview-fill').style.width = '0%'; +} + +export function stopPreview(resume) { + const shouldResume = resume !== false && S.previewCurrent?._resumeMain; + S.previewAudio.pause(); S.previewAudio.src = ''; + document.getElementById('preview-bar').classList.remove('visible'); + document.getElementById('preview-fill').style.width = '0%'; + S.previewCurrent = null; + if(shouldResume && S.audio.src) S.audio.play(); +} + +export function updatePreviewProgress() { + if(!S.previewAudio.duration) return; + document.getElementById('preview-fill').style.width = (S.previewAudio.currentTime / S.previewAudio.duration * 100) + '%'; +} + +// ─── RESTORE LAST PLAYED ─── +export function _restoreLastPlayed() { + try { + const saved = JSON.parse(localStorage.getItem('ns_last_played') || 'null'); + if(!saved || !saved.song) return; + S.queue = saved.queue || [saved.song]; + S.qIdx = saved.qIdx || 0; + const s = S.queue[S.qIdx]; + if(!s) return; + document.getElementById('player-title').textContent = s.title || '—'; + document.getElementById('player-artist-name').textContent = s.artist || '—'; + document.getElementById('player-album-sep').style.display = s.album ? '' : 'none'; + document.getElementById('player-album-name').textContent = s.album || ''; + const art = document.getElementById('player-art'); + const cv = coverUrl(s.coverArt, 100); + art.innerHTML = cv ? `` : '
♪
'; + updateStarBtn(); + document.title = `${s.title} — NovaSonic`; + } catch {} +} diff --git a/src/pwa.js b/src/pwa.js new file mode 100644 index 0000000..b824a81 --- /dev/null +++ b/src/pwa.js @@ -0,0 +1,34 @@ +export function injectPWA() { + if(!('serviceWorker' in navigator)) return; + + navigator.serviceWorker.addEventListener('message', e => { + if(e.data?.type === 'SW_UPDATED') { + localStorage.setItem('sw-version', e.data.version); + showUpdateBanner(e.data.version); + } + if(e.data?.type === 'VERSION') { + const prev = localStorage.getItem('sw-version'); + if(prev && prev !== e.data.version) showUpdateBanner(e.data.version); + localStorage.setItem('sw-version', e.data.version); + } + }); + + navigator.serviceWorker.register('sw.js', { updateViaCache: 'none' }).then(reg => { + reg.update().catch(() => {}); + const ctrl = navigator.serviceWorker.controller; + if(ctrl) ctrl.postMessage({ type: 'GET_VERSION' }); + }).catch(() => {}); +} + +export function showUpdateBanner(version) { + if(document.getElementById('update-banner')) return; + const b = document.createElement('div'); + b.id = 'update-banner'; + b.className = 'update-banner'; + b.innerHTML = ` + ✦ Update verfügbar${version ? ' ('+version+')' : ''} + + + `; + document.body.appendChild(b); +} diff --git a/src/state.js b/src/state.js new file mode 100644 index 0000000..76ebfc7 --- /dev/null +++ b/src/state.js @@ -0,0 +1,47 @@ +// Shared mutable state — imported as { S } in every module +export const S = { + cfg: { url: '', user: '', pass: '', lastfmKey: '', backendUrl: '' }, + + // Playback + queue: [], + qIdx: -1, + shuffle: false, + repeat: false, + radioMode: false, + audio: null, + previewAudio: null, + previewCurrent: null, + _scrubbing: false, + _msPosLast: 0, + queueOpen: false, + + // Now Playing overlay + npOpen: false, + npQueueMode: false, + + // Navigation history + _navStack: [], + _navCursor: -1, + _navJumping: false, + + // Search + searchTimer: null, + globalSearchTimer: null, + _mobileSearchTimer: null, + + // Download + downloadTarget: null, + dlType: 'track', + jobs: {}, + + // Playlists + _currentPlaylist: null, + plModalSongs: [], + cachedPlaylists: [], + + // Navidrome REST auth + _ndToken: null, + + // Artist images cache + artistImgCache: {}, +}; diff --git a/src/ui.js b/src/ui.js new file mode 100644 index 0000000..e5308de --- /dev/null +++ b/src/ui.js @@ -0,0 +1,519 @@ +import { S } from './state.js'; +import { esc, formatDur, toast } from './utils.js'; +import { api, coverUrl, ndAuth } from './api.js'; +// Circular imports — safe (only used inside function bodies) +import { playQueue, dlSongLocal, toggleStar, togglePlay, renderQueue, syncNpStar, updateStarBtn, npToggleStar, playCurrent, _syncNpDirect, setPlayIcon } from './player.js'; +import { navigate, loadPlaylists } from './views.js'; + +// ─── SIDEBAR ─── +export function openSidebar() { + document.getElementById('sidebar').classList.add('mobile-open'); + document.getElementById('mobile-overlay').classList.add('active'); +} +export function closeSidebar() { + document.getElementById('sidebar').classList.remove('mobile-open'); + document.getElementById('mobile-overlay').classList.remove('active'); +} +export function switchNavMode(mode) { + const lib = document.getElementById('bnav-library'); + const disc = document.getElementById('bnav-discover'); + if(!lib || !disc) return; + lib.style.display = mode==='library' ? 'flex' : 'none'; + disc.style.display = mode==='discover' ? 'flex' : 'none'; + document.getElementById('bnav-tab-library')?.classList.toggle('active', mode==='library'); + document.getElementById('bnav-tab-discover')?.classList.toggle('active', mode==='discover'); +} + +// ─── MOBILE SEARCH ─── +export function toggleMobileSearch() { + const bar = document.getElementById('mobile-search-bar'); + const btn = document.getElementById('mobile-search-btn'); + const open = bar.style.display==='none' || bar.style.display===''; + bar.style.display = open ? 'block' : 'none'; + btn.style.color = open ? 'var(--accent)' : 'var(--text2)'; + if(open) { setTimeout(() => document.getElementById('mobile-search-input').focus(), 50); } + else { closeMobileSearch(); } +} +export function closeMobileSearch() { + const bar = document.getElementById('mobile-search-bar'); + const inp = document.getElementById('mobile-search-input'); + const btn = document.getElementById('mobile-search-btn'); + bar.style.display = 'none'; + btn.style.color = 'var(--text2)'; + inp.value = ''; + navigate('home'); +} + +// ─── SETUP ─── +export function switchSetupTab(t) { + document.querySelectorAll('.setup-tab').forEach((el,i) => el.classList.toggle('active', i===(t==='navidrome'?0:1))); + document.querySelectorAll('.setup-pane').forEach((el,i) => el.classList.toggle('active', i===(t==='navidrome'?0:1))); +} +export function showSetupError(m) { + const el = document.getElementById('setup-error'); + el.textContent = m; el.style.display = 'block'; +} + +// ─── SETTINGS ─── +export function openSettings() { + document.getElementById('s-url').value = S.cfg.url || ''; + document.getElementById('s-user').value = S.cfg.user || ''; + document.getElementById('s-pass').value = ''; + document.getElementById('s-lastfm').value = S.cfg.lastfmKey || ''; + document.getElementById('s-backend').value = S.cfg.backendUrl || ''; + document.getElementById('settings-modal').classList.add('open'); + checkBackend(); +} +export function closeSettings() { document.getElementById('settings-modal').classList.remove('open'); } +export function saveSettings() { + const pass = document.getElementById('s-pass').value; + S.cfg.url = document.getElementById('s-url').value.trim().replace(/\/$/,''); + S.cfg.user = document.getElementById('s-user').value.trim(); + if(pass) S.cfg.pass = pass; + S.cfg.lastfmKey = document.getElementById('s-lastfm').value.trim(); + S.cfg.backendUrl = document.getElementById('s-backend').value.trim().replace(/\/$/,''); + localStorage.setItem('ns_cfg', JSON.stringify(S.cfg)); + closeSettings(); toast('✅ Gespeichert'); +} +export async function checkBackend() { + const dot = document.getElementById('backend-status-dot'); + const txt = document.getElementById('backend-status-text'); + const url = document.getElementById('s-backend').value.trim() || S.cfg.backendUrl; + if(!url) { dot.className='status-dot'; txt.textContent='Keine URL konfiguriert'; return; } + dot.className='status-dot'; txt.textContent='Verbinde…'; + try { + const r = await fetch(`${url}/api/health`, {signal: AbortSignal.timeout(4000)}); + const d = await r.json(); + dot.className='status-dot ok'; + txt.textContent = `Verbunden · yt-dlp ${d.ytdlpVersion||'OK'} · ${d.musicPath||''}`; + } catch(e) { dot.className='status-dot err'; txt.textContent='Nicht erreichbar: '+e.message; } +} + +// ─── CONTEXT MENU ─── +export function showCtxMenu(e, song, list, idx) { + const menu = document.getElementById('ctx-menu'); + menu.innerHTML = ''; + const items = [ + { icon: '▶', label: 'Abspielen', fn: () => playQueue(list, idx) }, + { icon: '+', label: 'Als Nächstes einfügen', fn: () => { + S.queue.splice(S.qIdx+1, 0, {...song}); + renderQueue(); toast('In Warteschlange eingefügt'); + }}, + { icon: '⬇', label: 'Am Ende der Queue', fn: () => { + S.queue.push({...song}); renderQueue(); toast('Zur Queue hinzugefügt'); + }}, + { sep: true }, + { icon: '🎶', label: 'Zu Playlist hinzufügen →', submenu: true, fn: () => openPlModal([song]) }, + { icon: '⭐', label: song._starred ? 'Aus Favoriten entfernen' : 'Zu Favoriten', fn: () => { + api(song._starred ? 'unstar' : 'star', `&id=${song.id}`) + .then(() => { song._starred = !song._starred; toast(song._starred ? '⭐ Favorit hinzugefügt' : 'Favorit entfernt'); }) + .catch(err => toast('Fehler: ' + err.message)); + }}, + { sep: true }, + { icon: 'ℹ', label: 'Song-Info', fn: () => showSongInfo(song) }, + { icon: '⬇', label: 'Herunterladen', fn: () => dlSongLocal(song) }, + ]; + items.forEach(item => { + if(item.sep) { const d=document.createElement('div'); d.className='ctx-separator'; menu.appendChild(d); return; } + const el = document.createElement('div'); el.className = 'ctx-item'; + el.innerHTML = `${item.icon}${item.label}`; + el.onclick = () => { hideCtxMenu(); item.fn(); }; + menu.appendChild(el); + }); + const vw=window.innerWidth, vh=window.innerHeight; + let x=e.clientX, y=e.clientY; + menu.style.left='-9999px'; menu.style.top='-9999px'; menu.style.display='block'; + const w=menu.offsetWidth, h=menu.offsetHeight; + if(x+w>vw-8) x=vw-w-8; + if(y+h>vh-8) y=vh-h-8; + menu.style.left=x+'px'; menu.style.top=y+'px'; +} +export function hideCtxMenu() { + const m = document.getElementById('ctx-menu'); + m.style.display='none'; m.innerHTML=''; +} +document.addEventListener('click', e => { + if(!document.getElementById('ctx-menu').contains(e.target)) hideCtxMenu(); +}); +document.addEventListener('keydown', e => { if(e.key==='Escape') hideCtxMenu(); }); + +// ─── PLAYLIST MODAL (Add to / Create) ─── +export async function openPlModal(songs) { + S.plModalSongs = songs; + document.getElementById('pl-modal-title').textContent = + songs.length===1 ? `"${songs[0].title}" zu Playlist` : `${songs.length} Songs zu Playlist`; + document.getElementById('pl-new-name').value = ''; + const list = document.getElementById('pl-list'); + list.innerHTML = '
Lädt…
'; + document.getElementById('pl-modal').classList.add('open'); + try { + const r = await api('getPlaylists'); + S.cachedPlaylists = r.playlists?.playlist || []; + list.innerHTML = ''; + if(!S.cachedPlaylists.length) { + list.innerHTML = '
Noch keine Playlists vorhanden
'; + return; + } + S.cachedPlaylists.forEach(pl => { + const row = document.createElement('div'); row.className = 'pl-row'; + row.innerHTML = ` + ${esc(pl.name)}${pl.songCount} Titel`; + row.onclick = () => addSongsToPlaylist(pl.id, pl.name); + list.appendChild(row); + }); + } catch(e) { list.innerHTML = `
${e.message}
`; } +} +export function closePlModal() { + document.getElementById('pl-modal').classList.remove('open'); + S.plModalSongs = []; +} +export async function addSongsToPlaylist(plId, plName) { + closePlModal(); + try { + const ids = S.plModalSongs.map(s => `&songIdToAdd=${s.id}`).join(''); + await api('updatePlaylist', `&playlistId=${plId}${ids}`); + toast(`✅ ${S.plModalSongs.length===1 ? S.plModalSongs[0].title : S.plModalSongs.length+' Songs'} → ${plName}`); + } catch(e) { toast('Fehler: ' + e.message); } +} +export async function createAndAddPlaylist() { + const name = document.getElementById('pl-new-name').value.trim(); + if(!name) { document.getElementById('pl-new-name').focus(); return; } + closePlModal(); + try { + const ids = S.plModalSongs.map(s => `&songId=${s.id}`).join(''); + await api('createPlaylist', `&name=${encodeURIComponent(name)}${ids}`); + toast(`✅ Playlist "${name}" erstellt`); + if(document.getElementById('page-playlists').classList.contains('active')) loadPlaylists(); + } catch(e) { toast('Fehler: ' + e.message); } +} +export function createPlaylistPrompt() { + document.getElementById('pl-new-name').value = ''; + S.plModalSongs = []; + document.getElementById('pl-modal-title').textContent = 'Neue Playlist erstellen'; + document.getElementById('pl-list').innerHTML = '
Füge Songs später per Rechtsklick hinzu.
'; + document.getElementById('pl-modal').classList.add('open'); +} +document.addEventListener('click', e => { + const m = document.getElementById('pl-modal'); + if(m.classList.contains('open') && e.target===m) closePlModal(); +}); + +// ─── SONG INFO MODAL ─── +export function showSongInfo(s) { + const cv = coverUrl(s.coverArt, 200); + document.getElementById('info-cover').innerHTML = cv + ? `` + : '
🎵
'; + document.getElementById('info-top-title').textContent = s.title || '—'; + document.getElementById('info-top-sub').textContent = (s.artist||'—') + (s.album ? ' · '+s.album : ''); + const fields = [ + ['Titel', s.title], + ['Künstler', s.artist], + ['Album', s.album], + ['Jahr', s.year], + ['Track #', s.track], + ['Disc #', s.discNumber], + ['Genre', s.genre], + ['Dauer', formatDur(s.duration)], + ['Bitrate', s.bitRate ? s.bitRate+' kbps' : null], + ['Format', s.suffix?.toUpperCase()], + ['Dateigröße', s.size ? Math.round(s.size/1024/1024*10)/10+' MB' : null], + ['Pfad', s.path], + ['Plays', s.playCount], + ].filter(([,v]) => v!=null && v!=='' && v!==undefined); + document.getElementById('info-rows').innerHTML = fields.map(([l,v]) => + `
${l}${esc(String(v))}
` + ).join(''); + document.getElementById('info-modal').classList.add('open'); +} +export function closeInfoModal() { document.getElementById('info-modal').classList.remove('open'); } +document.addEventListener('click', e => { + const m = document.getElementById('info-modal'); + if(m.classList.contains('open') && e.target===m) closeInfoModal(); +}); + +// ─── DOWNLOAD MODAL ─── +export function openDlModal(track) { + if(!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert. Bitte in Einstellungen eintragen.'); return; } + S.downloadTarget = track; S.dlType = 'track'; + document.querySelectorAll('.modal-opt').forEach(o => o.classList.toggle('selected', o.dataset.type==='track')); + const info = document.getElementById('dl-modal-info'); + const art = track.artworkUrl100 || track.artworkUrl60 || ''; + info.innerHTML = `${art ? `` : ''} +
${esc(track.trackName||track.collectionName||'—')}
${esc(track.artistName||'—')}${track.collectionName?' · '+esc(track.collectionName):''}
`; + document.getElementById('dl-album-sub').textContent = track.collectionName ? `"${track.collectionName}" herunterladen` : 'Album herunterladen'; + document.getElementById('download-modal').classList.add('open'); +} +export function closeDlModal() { document.getElementById('download-modal').classList.remove('open'); } +export function selectDlType(el, type) { + S.dlType = type; + document.querySelectorAll('.modal-opt').forEach(o => o.classList.remove('selected')); + el.classList.add('selected'); +} +export async function executeDownload() { + if(!S.downloadTarget || !S.cfg.backendUrl) return; + closeDlModal(); + const jobId = 'job_' + Date.now(); + const payload = { + type: S.dlType, + artist: S.downloadTarget.artistName || 'Unknown Artist', + title: S.downloadTarget.trackName || S.downloadTarget.collectionName || 'Unknown', + album: S.downloadTarget.collectionName || '', + artworkUrl: S.downloadTarget.artworkUrl100 || S.downloadTarget.artworkUrl60 || '', + itunesId: S.downloadTarget.trackId || S.downloadTarget.collectionId || null, + collectionId: S.downloadTarget.collectionId || null, + }; + addJob(jobId, payload.title, payload.artist); + try { + const res = await fetch(`${S.cfg.backendUrl}/api/download`, { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({...payload, jobId}), + }); + if(!res.ok) throw new Error(`HTTP ${res.status}`); + pollJob(jobId); + } catch(e) { + updateJob(jobId, 'error', 'Verbindung zum Backend fehlgeschlagen'); + toast('❌ Backend nicht erreichbar: '+e.message); + } +} + +// ─── JOBS ─── +function addJob(id, title, artist) { + S.jobs[id] = {title, artist, progress:0, status:'pending'}; + renderJobs(); +} +function updateJob(id, status, msg, progress) { + if(!S.jobs[id]) return; + S.jobs[id] = {...S.jobs[id], status, msg:msg||'', progress: progress ?? S.jobs[id].progress}; + renderJobs(); + if(status==='done') setTimeout(() => { delete S.jobs[id]; renderJobs(); }, 5000); + if(status==='error') setTimeout(() => { delete S.jobs[id]; renderJobs(); }, 8000); +} +function renderJobs() { + const panel = document.getElementById('jobs-panel'); + panel.innerHTML = ''; + Object.entries(S.jobs).forEach(([id,j]) => { + const c = document.createElement('div'); c.className = 'job-card'; + const isDone=j.status==='done', isErr=j.status==='error'; + const statusText = isErr ? 'Fehler' : isDone ? '✓ Fertig! Bibliothek wird gescannt…' : j.msg || 'Wird heruntergeladen…'; + c.innerHTML = `
+
${esc(j.title)}
+
${esc(j.artist)}
+
+
+
${statusText}
`; + panel.appendChild(c); + }); +} +async function pollJob(jobId) { + if(!S.jobs[jobId]) return; + try { + const res = await fetch(`${S.cfg.backendUrl}/api/status/${jobId}`); + const d = await res.json(); + updateJob(jobId, d.status, d.message, d.progress); + if(d.status!=='done' && d.status!=='error') setTimeout(() => pollJob(jobId), 1500); + else if(d.status==='done') toast(`✅ ${S.jobs[jobId]?.title||'Track'} gespeichert!`); + } catch { updateJob(jobId, 'error', 'Status-Abfrage fehlgeschlagen'); } +} + +// ─── EDIT PLAYLIST MODAL ─── +export function openEditPlModal(pl) { + const overlay = document.getElementById('edit-pl-overlay'); + document.getElementById('edit-pl-name').value = pl.name || ''; + document.getElementById('edit-pl-desc').value = pl.comment || ''; + const tog = document.getElementById('edit-pl-public-toggle'); + tog.classList.toggle('on', !!pl.public); + const cv = coverUrl(pl.coverArt, 300); + document.getElementById('edit-pl-cover').innerHTML = cv + ? `` + : '
🎶
'; + overlay.classList.add('open'); +} +export function closeEditPlModal() { document.getElementById('edit-pl-overlay').classList.remove('open'); } +export async function saveEditPlaylist() { + const pl = S._currentPlaylist; if(!pl) return; + const name = document.getElementById('edit-pl-name').value.trim(); + if(!name) { toast('Name darf nicht leer sein'); return; } + const comment = document.getElementById('edit-pl-desc').value.trim(); + const pub = document.getElementById('edit-pl-public-toggle').classList.contains('on'); + const btn = document.querySelector('.edit-pl-btn-save'); + btn.disabled=true; btn.textContent='Speichern…'; + try { + await api('updatePlaylist', `&playlistId=${pl.id}&name=${encodeURIComponent(name)}&comment=${encodeURIComponent(comment)}&public=${pub}`); + S._currentPlaylist = {...S._currentPlaylist, name, comment, public:pub}; + document.getElementById('playlist-detail-name').textContent = name; + const descEl = document.getElementById('playlist-detail-desc'); + if(descEl) { descEl.textContent=comment; descEl.style.display=comment?'':'none'; } + closeEditPlModal(); + toast('Playlist aktualisiert'); + } catch(e) { toast('Fehler: '+e.message); } + finally { btn.disabled=false; btn.textContent='Speichern'; } +} +export function editPlUploadCover() { document.getElementById('edit-pl-file').click(); } +export async function editPlCoverSelected(input) { + const file = input.files?.[0]; if(!file || !S._currentPlaylist) return; + input.value = ''; + const preview = URL.createObjectURL(file); + document.getElementById('edit-pl-cover').innerHTML = ``; + try { + const token = await ndAuth(); + const form = new FormData(); form.append('image', file, file.name); + const res = await fetch(`${S.cfg.url}/api/playlist/${S._currentPlaylist.id}/image`, { + method: 'POST', + headers: {'X-Nd-Authorization': 'Bearer '+token}, + body: form, + }); + if(!res.ok) throw new Error(res.status); + toast('Cover aktualisiert'); + setTimeout(() => { + const bust = coverUrl(S._currentPlaylist.coverArt, 300) + '&_cb=' + Date.now(); + const detailImg = document.getElementById('playlist-cover'); + if(detailImg) detailImg.innerHTML = ``; + document.getElementById('edit-pl-cover').innerHTML = ``; + }, 800); + } catch(e) { + S._ndToken = null; + toast('Cover-Upload fehlgeschlagen: '+e.message); + const cv = coverUrl(S._currentPlaylist.coverArt, 300); + document.getElementById('edit-pl-cover').innerHTML = cv + ? `` + : '
🎶
'; + } +} +export async function editPlRemoveCover() { + if(!S._currentPlaylist) return; + try { + const token = await ndAuth(); + const res = await fetch(`${S.cfg.url}/api/playlist/${S._currentPlaylist.id}/image`, { + method: 'DELETE', headers: {'X-Nd-Authorization': 'Bearer '+token}, + }); + if(!res.ok) throw new Error(res.status); + toast('Cover entfernt'); + S._currentPlaylist.coverArt = null; + document.getElementById('edit-pl-cover').innerHTML = '
🎶
'; + document.getElementById('playlist-cover').innerHTML = '
🎶
'; + } catch(e) { S._ndToken = null; toast('Fehler: '+e.message); } +} + +// ─── NOW PLAYING OVERLAY ─── +export function openNowPlaying() { + S.npOpen = true; + const overlay = document.getElementById('np-overlay'); + overlay.classList.add('open'); + if(S.npQueueMode) switchNpTab('player'); + syncNowPlaying(); + if(!overlay._swipeAttached) { + overlay._swipeAttached = true; + let startY=0, startT=0, dragging=false; + const handle = document.getElementById('np-drag-handle'); + function onStart(clientY) { startY=clientY; startT=Date.now(); dragging=true; overlay.style.transition='none'; } + function onMove(clientY) { if(!dragging) return; const dy=Math.max(0,clientY-startY); overlay.style.transform=`translateY(${dy}px)`; } + function onEnd(clientY) { + if(!dragging) return; dragging=false; overlay.style.transition=''; + const dy=clientY-startY, dt=Date.now()-startT; + if(dt<250&&dy>50 || dy>window.innerHeight*0.3) closeNowPlaying(); + else overlay.style.transform=''; + } + overlay.addEventListener('touchstart', e => { + const rect=overlay.getBoundingClientRect(); + if(e.touches[0].clientY-rect.top < 100) onStart(e.touches[0].clientY); + }, {passive:true}); + overlay.addEventListener('touchmove', e => onMove(e.touches[0].clientY), {passive:true}); + overlay.addEventListener('touchend', e => onEnd(e.changedTouches[0].clientY), {passive:true}); + handle.addEventListener('mousedown', e => { onStart(e.clientY); e.preventDefault(); }); + document.addEventListener('mousemove', e => onMove(e.clientY)); + document.addEventListener('mouseup', e => onEnd(e.clientY)); + } +} + +export function closeNowPlaying() { + S.npOpen = false; + const overlay = document.getElementById('np-overlay'); + overlay.style.transform = ''; + overlay.classList.remove('open'); +} + +export function syncNowPlaying() { + if(!S.npOpen) return; + const s = S.queue[S.qIdx]; + if(!s) return; + _syncNpDirect(s); + const shuffleBtn = document.getElementById('np-shuffle-btn'); + const repeatBtn = document.getElementById('np-repeat-btn'); + const volSlider = document.getElementById('np-vol-slider'); + if(shuffleBtn) shuffleBtn.classList.toggle('active', S.shuffle); + if(repeatBtn) repeatBtn.classList.toggle('active', S.repeat !== false); + if(volSlider) volSlider.value = Math.round(S.audio.volume * 100); + const playing = !S.audio.paused; + const npIcon = document.getElementById('np-play-icon'); + if(npIcon) npIcon.innerHTML = playing + ? '' + : ''; + const cover = document.getElementById('np-cover'); + if(cover) cover.classList.toggle('playing', playing); +} + +export function switchNpTab(tab) { + S.npQueueMode = tab==='queue'; + document.getElementById('np-overlay').classList.toggle('queue-mode', S.npQueueMode); + document.getElementById('np-tab-player').classList.toggle('active', !S.npQueueMode); + document.getElementById('np-tab-queue').classList.toggle('active', S.npQueueMode); + if(S.npQueueMode) renderNpQueue(); +} + +export function renderNpQueue() { + const el = document.getElementById('np-queue-list'); + if(!el) return; + el.innerHTML = ''; + if(!S.queue.length) { + el.innerHTML = '
Warteschlange leer
'; + return; + } + S.queue.forEach((s, i) => { + const item = document.createElement('div'); + item.className = 'np-queue-item' + (i===S.qIdx ? ' nq-active' : ''); + const cv = coverUrl(s.coverArt, 60); + item.innerHTML = `
${i+1}
${cv ? `` : '♪'}
${esc(s.title)}
${esc(s.artist||'')}
`; + item.onclick = () => { S.qIdx=i; playCurrent(); switchNpTab('player'); }; + el.appendChild(item); + }); + el.querySelector('.nq-active')?.scrollIntoView({block:'nearest'}); +} + +export function npGoArtist() { + const s = S.queue[S.qIdx]; if(!s) return; + closeNowPlaying(); + if(s.artistId) navigate('artist-detail', {id:s.artistId, name:s.artist}); +} +export function npGoAlbum() { + const s = S.queue[S.qIdx]; if(!s || !s.albumId) return; + closeNowPlaying(); + navigate('album-detail', {id:s.albumId, name:s.album||'', artist:s.artist||'', artistId:s.artistId||'', coverArt:s.coverArt||''}); +} + +// ─── NP ACTION SHEET ─── +export function openNpMenu() { + const s = S.queue[S.qIdx]; if(!s) return; + const cv = coverUrl(s.coverArt, 80); + document.getElementById('np-sheet-song').innerHTML = ` +
${cv ? `` : '
🎵
'}
+
${esc(s.title)}
${esc(s.artist||'')}${s.album?' · '+esc(s.album):''}
`; + const items = [ + { icon: '', label: 'Song-Info anzeigen', fn: () => { closeNpMenu(); showSongInfo(s); } }, + { icon: '', label: 'Zu Playlist hinzufügen', fn: () => { closeNpMenu(); openPlModal([s]); } }, + { icon: '', label: 'Zum Künstler', fn: () => { closeNpMenu(); npGoArtist(); } }, + { icon: '', label: 'Zum Album', fn: () => { closeNpMenu(); closeNowPlaying(); if(s.albumId) navigate('album-detail',{id:s.albumId,name:s.album,artist:s.artist,artistId:s.artistId,coverArt:s.coverArt}); } }, + { icon: '', label: s._starred||s.starred ? 'Aus Favoriten entfernen' : 'Zu Favoriten hinzufügen', fn: () => { closeNpMenu(); npToggleStar(); } }, + { icon: '', label: 'Herunterladen', fn: () => { closeNpMenu(); dlSongLocal(s); } }, + ]; + document.getElementById('np-sheet-items').innerHTML = items.map((item,i) => + `
${item.icon}${item.label}
` + ).join(''); + document.querySelectorAll('.np-sheet-item').forEach((el,i) => el.addEventListener('click', () => items[i].fn())); + document.getElementById('np-sheet-overlay').classList.add('open'); + document.getElementById('np-sheet').classList.add('open'); +} +export function closeNpMenu() { + document.getElementById('np-sheet-overlay').classList.remove('open'); + document.getElementById('np-sheet').classList.remove('open'); +} diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..8d4e96e --- /dev/null +++ b/src/utils.js @@ -0,0 +1,54 @@ +export function md5(input) { + function safeAdd(x,y){var l=(x&0xffff)+(y&0xffff);return((x>>16)+(y>>16)+(l>>16)<<16)|(l&0xffff)} + function rol(n,c){return(n<>>32-c)} + function cmn(q,a,b,x,s,t){return safeAdd(rol(safeAdd(safeAdd(a,q),safeAdd(x,t)),s),b)} + function ff(a,b,c,d,x,s,t){return cmn((b&c)|(~b&d),a,b,x,s,t)} + function gg(a,b,c,d,x,s,t){return cmn((b&d)|(c&~d),a,b,x,s,t)} + function hh(a,b,c,d,x,s,t){return cmn(b^c^d,a,b,x,s,t)} + function ii(a,b,c,d,x,s,t){return cmn(c^(b|~d),a,b,x,s,t)} + var l=input.length,m=[]; + for(var i=0;i>2]|=(input.charCodeAt(i)&0xff)<<((i%4)*8); + m[l>>2]|=0x80<<((l%4)*8); + m[((l+8>>6)+1)*16-2]=l*8; + var a=1732584193,b=-271733879,c=-1732584194,d=271733878; + for(var i=0;i>>(j*8))&0xff).toString(16)).slice(-2);return s} + return hex(a)+hex(b)+hex(c)+hex(d); +} + +export function esc(s) { + return (s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +} + +export function formatDur(s) { + if(!s) return '0:00'; + const m = Math.floor(s/60), sec = Math.floor(s%60); + return `${m}:${sec.toString().padStart(2,'0')}`; +} + +export function toast(msg) { + const t = document.createElement('div'); + t.className = 'toast'; + t.textContent = msg; + document.getElementById('toast-container').appendChild(t); + setTimeout(() => t.remove(), 3000); +} diff --git a/src/views.js b/src/views.js new file mode 100644 index 0000000..7162693 --- /dev/null +++ b/src/views.js @@ -0,0 +1,767 @@ +import { S } from './state.js'; +import { esc, formatDur, toast } from './utils.js'; +import { api, coverUrl } from './api.js'; +import { itunesSearch, itunesLookupAlbum, itunesTopCharts, itunesTopSongs } from './api.js'; +// Circular imports — safe (only used inside function bodies) +import { playQueue, dlSongLocal, toggleStar, playPreview } from './player.js'; +import { showCtxMenu, openEditPlModal, openPlModal, openDlModal, closeSidebar, switchNavMode, closeNowPlaying, closeNpMenu } from './ui.js'; + +// ─── NAVIGATION HISTORY ─── +export function _navSave() { + try { sessionStorage.setItem('ns_nav', JSON.stringify({stack: S._navStack, cursor: S._navCursor})); } catch {} +} + +export function _navLoad() { + try { + const s = JSON.parse(sessionStorage.getItem('ns_nav') || 'null'); + if(s && Array.isArray(s.stack) && s.stack.length) { + S._navStack = s.stack; + S._navCursor = s.cursor; + return true; + } + } catch {} + return false; +} + +export function navigate(page, data=null) { + document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); + document.querySelectorAll('.nav-item,.bnav-item').forEach(n => n.classList.remove('active','active-blue')); + const cls = ['discover','new-releases','recommendations'].includes(page) ? 'active-blue' : 'active'; + document.querySelectorAll(`[data-page="${page}"]`).forEach(el => el.classList.add(cls)); + const pageEl = document.getElementById(`page-${page}`); + if(pageEl) { pageEl.classList.add('active'); document.getElementById('main-content').scrollTop = 0; } + closeSidebar(); + if(['discover','new-releases','recommendations','random'].includes(page)) switchNavMode('discover'); + else if(['home','artists','albums','starred','playlists'].includes(page)) switchNavMode('library'); + + if(page==='artists' && !document.getElementById('artists-grid').children.length) loadArtists(); + if(page==='albums' && !document.getElementById('albums-grid').children.length) loadAlbums(); + if(page==='starred') loadStarred(); + if(page==='random') loadRandom(); + if(page==='artist-detail' && data) loadArtistDetail(data); + if(page==='album-detail' && data) loadAlbumDetail(data); + if(page==='new-releases') loadNewReleases(); + if(page==='playlists') loadPlaylists(); + if(page==='playlist-detail' && data) loadPlaylistDetail(data); + if(page==='recommendations') loadRecommendations(); + + if(!S._navJumping) { + S._navStack = S._navStack.slice(0, S._navCursor + 1); + const safe = data ? JSON.parse(JSON.stringify(data)) : null; + const last = S._navStack[S._navCursor]; + if(!last || last.page !== page || JSON.stringify(last.data) !== JSON.stringify(safe)) { + S._navStack.push({ page, data: safe }); + } + S._navCursor = S._navStack.length - 1; + _navSave(); + try { history.pushState({ nsIdx: S._navCursor }, '', ''); } catch {} + } +} + +export function _navGoBack() { + if(document.getElementById('np-sheet')?.classList.contains('open')) { closeNpMenu(); return; } + if(S.npOpen) { closeNowPlaying(); return; } + if(S._navCursor > 0) { + S._navCursor--; + const entry = S._navStack[S._navCursor]; + _navSave(); + S._navJumping = true; + navigate(entry.page, entry.data); + S._navJumping = false; + } + try { history.pushState({ nsIdx: S._navCursor }, '', ''); } catch {} +} + +// ─── CARD MAKERS ─── +export function makeCard(title, sub, imgUrl, fallback='💿') { + const c = document.createElement('div'); + c.className = 'card'; + c.innerHTML = `
+ ${imgUrl ? `` : `
${fallback}
`} +
+
+
${esc(title)}
${esc(sub||'')}
`; + return c; +} + +export function makeAlbumCard(a) { + const cv = coverUrl(a.coverArt, 200); + const c = makeCard(a.name, a.artist||'', cv, '💿'); + c.querySelector('.play-btn').onclick = (e) => { e.stopPropagation(); quickPlayAlbum(a); }; + c.onclick = () => navigate('album-detail', a); + return c; +} + +export function makeSongItem(s, idx, list) { + const el = document.createElement('div'); + if(s.starred && !s._starred) s._starred = true; + el.className = 'song-item' + (S.queue[S.qIdx]?.id === s.id ? ' playing' : ''); + const cv = coverUrl(s.coverArt, 60); + el.innerHTML = `
${idx+1}
+
${cv ? `` : '
♪
'}
+
+
${esc(s.title)}
+
+ ${s.artistId||s.artist ? `${esc(s.artist||'')}` : ''} + ${s.albumId||s.album ? ` · ${esc(s.album||'')}` : ''} +
+
+
+ ${formatDur(s.duration)} +
+ + +
+
`; + const btns = el.querySelectorAll('.icon-btn'); + btns[0].onclick = (e) => { e.stopPropagation(); toggleStar(s, btns[0]); }; + btns[1].onclick = (e) => { e.stopPropagation(); dlSongLocal(s); }; + el.onclick = () => playQueue(list, idx); + el.addEventListener('contextmenu', e => { e.preventDefault(); showCtxMenu(e, s, list, idx); }); + return el; +} + +export function makeGlobalTrackItem(t, idx, list) { + const el = document.createElement('div'); + el.className = 'gtrack-item'; + const art = t.artworkUrl60 || t.artworkUrl100 || ''; + el.innerHTML = ` +
${art ? `` : '
🎵
'}
+
+
${esc(t.trackName||t.collectionName||'—')}
+
${esc(t.artistName||'—')} · ${esc(t.collectionName||'')} ${t.releaseDate?'· '+new Date(t.releaseDate).getFullYear():''}
+
+
+ ${t.previewUrl ? `` : ''} + +
`; + el.onclick = () => { if(t.previewUrl) playPreview(t); }; + return el; +} + +function mkSection(title) { + const d = document.createElement('div'); + d.className = 'search-section'; + d.innerHTML = `
${title}
`; + return d; +} + +// ─── ARTIST IMAGE ─── +export async function getArtistImage(artistId) { + if(S.artistImgCache[artistId] !== undefined) return S.artistImgCache[artistId]; + try { + const r = await api('getArtistInfo2', `&id=${artistId}&count=0`); + const info = r.artistInfo2 || {}; + const img = info.largeImageUrl || info.mediumImageUrl || info.smallImageUrl || null; + const isBlank = img && (img.includes('2a96cbd8b46e442fc41c2b86b821562f') || img.endsWith('/noimage/')); + S.artistImgCache[artistId] = isBlank ? null : img; + return S.artistImgCache[artistId]; + } catch { S.artistImgCache[artistId] = null; return null; } +} + +// ─── HOME ─── +export async function loadHomeData() { + loadAlbumRow('home-random', 'getAlbumList2', '&type=random&size=20'); + loadAlbumRow('home-newest', 'getAlbumList2', '&type=newest&size=20'); + loadAlbumRow('home-frequent', 'getAlbumList2', '&type=frequent&size=20'); +} + +export async function loadAlbumRow(id, method, extra) { + const c = document.getElementById(id); + if(!c) return; + c.innerHTML = '
'; + try { + const r = await api(method, extra); + c.innerHTML = ''; + (r.albumList2?.album || []).forEach(a => c.appendChild(makeAlbumCard(a))); + } catch(e) { c.innerHTML = `
${e.message}
`; } +} + +// ─── ARTISTS ─── +export async function loadArtists() { + const g = document.getElementById('artists-grid'); + g.innerHTML = '
'; + try { + const r = await api('getArtists'); + const all = (r.artists?.index || []).flatMap(i => i.artist || []); + g.innerHTML = ''; + all.forEach(a => { + const c = makeCard(a.name, a.albumCount+' Alben', null, '🎤'); + c.onclick = () => navigate('artist-detail', a); + g.appendChild(c); + getArtistImage(a.id).then(imgUrl => { + if(!imgUrl) return; + const artEl = c.querySelector('.card-art'); + if(!artEl) return; + const overlay = artEl.querySelector('.play-overlay'); + const img = document.createElement('img'); + img.src = imgUrl; img.loading = 'lazy'; img.style.cssText = 'width:100%;height:100%;object-fit:cover;transition:transform .4s'; + artEl.innerHTML = ''; artEl.appendChild(img); if(overlay) artEl.appendChild(overlay); + c.addEventListener('mouseenter', () => img.style.transform = 'scale(1.05)'); + c.addEventListener('mouseleave', () => img.style.transform = 'scale(1)'); + }); + }); + } catch(e) { g.innerHTML = `

${e.message}

`; } +} + +// ─── ALBUMS ─── +export async function loadAlbums() { + const g = document.getElementById('albums-grid'); + g.innerHTML = '
'; + try { + const r = await api('getAlbumList2', '&type=alphabeticalByName&size=500'); + g.innerHTML = ''; + (r.albumList2?.album || []).forEach(a => g.appendChild(makeAlbumCard(a))); + } catch(e) { g.innerHTML = `

${e.message}

`; } +} + +// ─── ARTIST DETAIL ─── +export async function loadArtistDetail(artist) { + document.getElementById('artist-detail-name').textContent = artist.name; + document.getElementById('artist-back-btn').onclick = () => navigate('artists'); + const hero = document.getElementById('artist-detail-hero'); + hero.style.background = 'linear-gradient(135deg, var(--surface2), var(--surface))'; + ['artist-bio-section','artist-similar-section','artist-topsongs-section'].forEach(id => { + const el = document.getElementById(id); if(el) el.innerHTML = ''; + }); + const g = document.getElementById('artist-albums-grid'); + g.innerHTML = '
'; + + const [imgRes, albumRes, infoRes, topRes] = await Promise.allSettled([ + getArtistImage(artist.id), + api('getArtist', `&id=${artist.id}`), + api('getArtistInfo2', `&id=${artist.id}&count=8`), + api('getTopSongs', `&artist=${encodeURIComponent(artist.name)}&count=10`) + ]); + + const img = imgRes.status==='fulfilled' ? imgRes.value : null; + if(img) { + hero.style.background = `url('${img}') center/cover no-repeat`; + hero.querySelector('.artist-hero-overlay').style.background = + 'linear-gradient(to right,rgba(7,7,15,.9) 0%,rgba(7,7,15,.5) 60%,rgba(7,7,15,.2) 100%)'; + } + + if(albumRes.status==='fulfilled') { + g.innerHTML = ''; + (albumRes.value.artist?.album || []).forEach(a => g.appendChild(makeAlbumCard({...a,_fromArtist:artist}))); + } else { + g.innerHTML = `

${albumRes.reason?.message}

`; + } + + const info = infoRes.status==='fulfilled' ? infoRes.value.artistInfo2 : null; + if(info?.biography) { + const bioEl = document.getElementById('artist-bio-section'); + const bio = info.biography.replace(/]+>/g,'').replace(/<\/a>/g,'').replace(/<[^>]+>/g,'').trim(); + const short = bio.length > 400 ? bio.slice(0,400)+'…' : bio; + if(short && bioEl) { + window._artistBioFull = bio; + bioEl.innerHTML = `
Über
+
${esc(short)}${bio.length>400 + ? ` ` + : ''}
`; + } + } + + const similar = info?.similarArtist || []; + if(similar.length) { + const simEl = document.getElementById('artist-similar-section'); + if(simEl) { + simEl.innerHTML = `
Ähnliche Künstler
+
${similar.map(a => + ` + ${esc(a.name)} + `).join('')} +
`; + } + } + + const topSongs = topRes.status==='fulfilled' ? (topRes.value.topSongs?.song||[]) : []; + if(topSongs.length) { + const tsEl = document.getElementById('artist-topsongs-section'); + if(tsEl) { + tsEl.innerHTML = `
Top Songs
`; + const list = document.createElement('div'); + list.style.marginBottom = '20px'; + topSongs.forEach((s, i) => { + const cv = coverUrl(s.coverArt, 60); + const row = document.createElement('div'); + row.className = 'topsong-item'; + row.innerHTML = `
${i+1}
+
${cv ? `` : '
🎵
'}
+
${esc(s.title)}
${esc(s.album||'')}
+
${s.playCount ? s.playCount.toLocaleString() : '—'}
`; + row.onclick = () => playQueue(topSongs, i); + row.addEventListener('contextmenu', e => { e.preventDefault(); showCtxMenu(e, s, topSongs, i); }); + list.appendChild(row); + }); + tsEl.appendChild(list); + } + } +} + +// ─── ALBUM DETAIL ─── +export async function loadAlbumDetail(album) { + document.getElementById('album-back-btn').onclick = () => + album._fromArtist ? navigate('artist-detail', album._fromArtist) : navigate('albums'); + document.getElementById('album-detail-name').textContent = album.name; + const artistEl = document.getElementById('album-detail-artist'); + artistEl.textContent = album.artist || ''; + artistEl.onclick = () => { if(album.artistId) navigate('artist-detail', {id:album.artistId, name:album.artist}); }; + const cw = document.getElementById('album-cover-wrap'); + const cv = coverUrl(album.coverArt, 300); + cw.innerHTML = cv ? `` : '
💿
'; + const sl = document.getElementById('album-songs'); + sl.innerHTML = '
'; + try { + const r = await api('getAlbum', `&id=${album.id}`); + const songs = r.album?.song || []; + const totalMin = Math.round(songs.reduce((s,t) => s+(t.duration||0), 0) / 60); + document.getElementById('album-detail-stats').textContent = `${songs.length} Titel · ${r.album.year||''} · ${totalMin} Min.`; + document.getElementById('btn-play-album').onclick = () => playQueue(songs, 0); + document.getElementById('btn-dl-album').onclick = () => { + songs.forEach((s,i) => setTimeout(() => dlSongLocal(s), i*400)); + toast(`⬇ ${songs.length} Titel Download gestartet`); + }; + sl.innerHTML = ''; + songs.forEach((s,i) => sl.appendChild(makeSongItem(s, i, songs))); + } catch(e) { sl.innerHTML = `

${e.message}

`; } +} + +// ─── PLAYLISTS ─── +export async function loadPlaylists() { + const g = document.getElementById('playlists-grid'); + g.innerHTML = '
'; + try { + const r = await api('getPlaylists'); + const lists = r.playlists?.playlist || []; + g.innerHTML = ''; + if(!lists.length) { g.innerHTML = '
🎶

Keine Playlists vorhanden

'; return; } + lists.forEach(pl => { + const cv = coverUrl(pl.coverArt, 200); + const c = makeCard(pl.name, pl.songCount+' Titel', cv, '🎶'); + c.querySelector('.play-btn').onclick = (e) => { e.stopPropagation(); quickPlayPlaylist(pl); }; + c.onclick = () => navigate('playlist-detail', pl); + g.appendChild(c); + }); + } catch(e) { g.innerHTML = `

${e.message}

`; } +} + +export async function quickPlayPlaylist(pl) { + try { const r = await api('getPlaylist', `&id=${pl.id}`); playQueue(r.playlist?.entry || [], 0); } + catch(e) { toast('Fehler: ' + e.message); } +} + +export async function loadPlaylistDetail(pl) { + S._currentPlaylist = {...pl}; + document.getElementById('playlist-detail-name').textContent = pl.name; + const cw = document.getElementById('playlist-cover'); + const cv = coverUrl(pl.coverArt, 300); + cw.innerHTML = cv ? `` : '
🎶
'; + document.getElementById('btn-edit-playlist').onclick = () => openEditPlModal(S._currentPlaylist); + const sl = document.getElementById('playlist-songs'); + sl.innerHTML = '
'; + try { + const r = await api('getPlaylist', `&id=${pl.id}`); + const full = r.playlist || {}; + S._currentPlaylist = {...pl, ...full, id: pl.id}; + const songs = full.entry || []; + const totalMin = Math.round(songs.reduce((s,t) => s+(t.duration||0), 0) / 60); + document.getElementById('playlist-detail-stats').textContent = `${songs.length} Titel · ${totalMin} Min.`; + const descEl = document.getElementById('playlist-detail-desc'); + if(descEl) { descEl.textContent = full.comment||''; descEl.style.display = full.comment ? '' : 'none'; } + document.getElementById('btn-play-playlist').onclick = () => playQueue(songs, 0); + document.getElementById('btn-shuffle-playlist').onclick = () => { + const shuffled = [...songs].sort(() => Math.random()-.5); + playQueue(shuffled, 0); + }; + sl.innerHTML = ''; + songs.forEach((s,i) => sl.appendChild(makeSongItem(s, i, songs))); + } catch(e) { sl.innerHTML = `

${e.message}

`; } +} + +export async function quickPlayAlbum(a) { + try { const r = await api('getAlbum', `&id=${a.id}`); playQueue(r.album?.song || [], 0); } + catch(e) { toast('Fehler: ' + e.message); } +} + +// ─── STARRED / RANDOM ─── +export async function loadStarred() { + const el = document.getElementById('starred-songs'); + el.innerHTML = '
'; + try { + const r = await api('getStarred2'); + const songs = (r.starred2?.song || []).map(s => ({...s,_starred:true})); + el.innerHTML = ''; + if(!songs.length) { el.innerHTML = '
⭐

Noch keine Favoriten

'; return; } + songs.forEach((s,i) => el.appendChild(makeSongItem(s, i, songs))); + } catch(e) { el.innerHTML = `

${e.message}

`; } +} + +export async function loadRandom() { + const el = document.getElementById('random-songs'); + el.innerHTML = '
'; + try { + const r = await api('getRandomSongs', '&size=50'); + const songs = r.randomSongs?.song || []; + el.innerHTML = ''; + songs.forEach((s,i) => el.appendChild(makeSongItem(s, i, songs))); + } catch(e) { el.innerHTML = `

${e.message}

`; } +} + +// ─── LOCAL SEARCH ─── +export function onLocalSearch(val) { + clearTimeout(S.searchTimer); + if(!val.trim()) { navigate('home'); return; } + navigate('search'); + S.searchTimer = setTimeout(() => doLocalSearch(val), 350); +} + +export async function doLocalSearch(q) { + const el = document.getElementById('search-results'); + el.innerHTML = '
'; + try { + const r = await api('search3', `&query=${encodeURIComponent(q)}&songCount=30&albumCount=10&artistCount=6`); + const res = r.searchResult3 || {}; + el.innerHTML = ''; + const artists = res.artist||[], albums = res.album||[], songs = res.song||[]; + if(!artists.length && !albums.length && !songs.length) { + el.innerHTML = '
🔍

Keine Ergebnisse

'; return; + } + if(artists.length) { + const s = mkSection('Künstler'); + const g = document.createElement('div'); g.className = 'scroll-row'; + artists.forEach(a => { + const c = makeCard(a.name, a.albumCount+' Alben', null, '🎤'); + c.onclick = () => navigate('artist-detail', a); + g.appendChild(c); + getArtistImage(a.id).then(img => { + if(!img) return; + const artEl = c.querySelector('.card-art'); + if(!artEl) return; + artEl.innerHTML = `
`; + }); + }); + s.appendChild(g); el.appendChild(s); + } + if(albums.length) { + const s = mkSection('Alben'); + const g = document.createElement('div'); g.className = 'scroll-row'; + albums.forEach(a => g.appendChild(makeAlbumCard(a))); + s.appendChild(g); el.appendChild(s); + } + if(songs.length) { + const s = mkSection('Titel'); + const g = document.createElement('div'); g.className = 'scroll-row'; + songs.forEach((so,i) => { + const cv = coverUrl(so.coverArt, 200); + const c = makeCard(so.title, so.artist||'', cv, '🎵'); + c.querySelector('.play-btn').onclick = (e) => { e.stopPropagation(); playQueue(songs, i); }; + c.onclick = () => playQueue(songs, i); + g.appendChild(c); + }); + s.appendChild(g); el.appendChild(s); + } + } catch(e) { el.innerHTML = `

${e.message}

`; } +} + +// ─── MOBILE SEARCH ─── +export function onMobileSearch(val) { + clearTimeout(S._mobileSearchTimer); + if(!val.trim()) { navigate('home'); return; } + navigate('search'); + S._mobileSearchTimer = setTimeout(() => doLocalSearch(val), 300); +} + +// ─── GLOBAL (ITUNES) SEARCH ─── +export function onGlobalSearch(val) { + clearTimeout(S.globalSearchTimer); + const el = document.getElementById('global-search-results'); + if(!val.trim()) { el.innerHTML = '
🔍

Gib einen Suchbegriff ein

'; return; } + el.innerHTML = '
'; + S.globalSearchTimer = setTimeout(() => doGlobalSearch(val), 400); +} + +export async function doGlobalSearch(q) { + const el = document.getElementById('global-search-results'); + try { + const [tracks, albums, artists] = await Promise.all([ + itunesSearch(q,'musicTrack',25), + itunesSearch(q,'album',10), + itunesSearch(q,'musicArtist',6) + ]); + el.innerHTML = ''; + if(artists.length) { + const s = mkSection('Künstler'); + const row = document.createElement('div'); row.className = 'scroll-row'; + artists.forEach(a => { + const c = makeCard(a.artistName, 'iTunes Künstler', null, '🎤'); + c.onclick = () => loadItunesArtistResults(a.artistId, a.artistName); + row.appendChild(c); + itunesSearch(a.artistName,'album',1).then(res => { + if(!res[0]) return; + const imgUrl = res[0].artworkUrl100?.replace('100x100','300x300'); + if(!imgUrl) return; + const artEl = c.querySelector('.card-art'); + if(!artEl) return; + const img = document.createElement('img'); + img.src = imgUrl; img.loading = 'lazy'; img.style.cssText = 'width:100%;height:100%;object-fit:cover;transition:transform .4s'; + const overlay = artEl.querySelector('.play-overlay'); + artEl.innerHTML = ''; artEl.appendChild(img); if(overlay) artEl.appendChild(overlay); + img.onerror = () => { artEl.innerHTML = '
🎤
'; if(overlay) artEl.appendChild(overlay); }; + c.addEventListener('mouseenter', () => img.style.transform = 'scale(1.05)'); + c.addEventListener('mouseleave', () => img.style.transform = 'scale(1)'); + }).catch(() => {}); + }); + s.appendChild(row); el.appendChild(s); + } + if(albums.length) { + const s = mkSection('Alben'); + const row = document.createElement('div'); row.className = 'scroll-row'; + albums.forEach(a => { + const art = a.artworkUrl100?.replace('100x100','300x300'); + const c = makeCard(a.collectionName, a.artistName, art, '💿'); + c.querySelector('.play-btn').onclick = (e) => { e.stopPropagation(); loadItunesAlbum(a); }; + c.querySelector('.dl-btn-overlay')?.remove(); + const dlOverlay = document.createElement('button'); + dlOverlay.className = 'dl-btn-overlay'; + dlOverlay.title = 'Auf Server laden'; + dlOverlay.innerHTML = ``; + dlOverlay.onclick = (e) => { e.stopPropagation(); openDlModal({artistName:a.artistName,trackName:a.collectionName,artworkUrl100:a.artworkUrl100,collectionName:a.collectionName,isAlbum:true,itunesData:a}); }; + c.querySelector('.play-overlay').appendChild(dlOverlay); + c.onclick = () => loadItunesAlbum(a); + row.appendChild(c); + }); + s.appendChild(row); el.appendChild(s); + } + if(tracks.length) { + const s = mkSection('Titel'); + const list = document.createElement('div'); list.className = 'song-list'; + tracks.forEach((t,i) => list.appendChild(makeGlobalTrackItem(t, i, tracks))); + s.appendChild(list); el.appendChild(s); + } + if(!artists.length && !albums.length && !tracks.length) { + el.innerHTML = '
🔍

Keine Ergebnisse gefunden

'; + } + } catch(e) { el.innerHTML = `

Suche fehlgeschlagen: ${e.message}

`; } +} + +export async function loadItunesAlbum(album) { + try { + const tracks = await itunesLookupAlbum(album.collectionId); + if(!tracks.length) { toast('Keine Tracks gefunden'); return; } + const el = document.getElementById('global-search-results'); + const s = document.createElement('div'); s.className = 'search-section'; + s.innerHTML = `
+ +
+
${esc(album.collectionName)}
+
${esc(album.artistName)}
+
+ +
+
Tracks
`; + const list = document.createElement('div'); list.className = 'song-list'; + tracks.forEach((t,i) => list.appendChild(makeGlobalTrackItem(t, i, tracks))); + s.appendChild(list); + el.innerHTML = ''; el.appendChild(s); + } catch(e) { toast('Fehler: ' + e.message); } +} + +export async function loadItunesArtistResults(artistId, artistName) { + const el = document.getElementById('global-search-results'); + el.innerHTML = '
'; + try { + const albums = await itunesSearch(artistName, 'album', 20); + const artistAlbums = albums.filter(a => a.artistId===artistId || a.artistName===artistName); + el.innerHTML = ''; + const header = document.createElement('div'); + header.innerHTML = `
+ +
${esc(artistName)}
+
+
Alben
`; + el.appendChild(header); + const grid = document.createElement('div'); grid.className = 'grid grid-4'; + (artistAlbums.length ? artistAlbums : albums).forEach(a => { + const art = a.artworkUrl100?.replace('100x100','300x300'); + const c = makeCard(a.collectionName, a.artistName, art, '💿'); + c.onclick = () => loadItunesAlbum(a); + grid.appendChild(c); + }); + el.appendChild(grid); + } catch(e) { el.innerHTML = `

${e.message}

`; } +} + +// ─── NEW RELEASES ─── +export async function loadNewReleases() { + const albumEl = document.getElementById('nr-albums'); + const songEl = document.getElementById('nr-songs'); + albumEl.innerHTML = '
'; songEl.innerHTML = ''; + try { + const [albums, songs] = await Promise.all([itunesTopCharts(), itunesTopSongs()]); + albumEl.innerHTML = ''; + albums.slice(0,30).forEach(a => { + const art = a.artworkUrl100?.replace('100x100','300x300') || a.artworkUrl; + const c = makeCard(a.name, a.artistName, art, '💿'); + c.onclick = () => itunesSearch(a.name+' '+a.artistName,'album',5).then(res => { if(res[0]) loadItunesAlbum(res[0]); }); + albumEl.appendChild(c); + }); + songs.slice(0,40).forEach((s, i) => { + const el = document.createElement('div'); el.className = 'gtrack-item'; + const art = s.artworkUrl?.replace('100x100','60x60'); + el.innerHTML = ` +
${art ? `` : `
🎵
`}
+
+
${esc(s.name)}
+
${esc(s.artistName||'')}
+
+
+ #${i+1} + +
`; + el.onclick = () => { itunesSearch(s.name+' '+s.artistName,'musicTrack',1).then(res => { if(res[0]?.previewUrl) playPreview(res[0]); }); }; + songEl.appendChild(el); + }); + } catch(e) { albumEl.innerHTML = `

${e.message}

`; } +} + +// ─── RECOMMENDATIONS ─── +export async function loadRecommendations() { + const el = document.getElementById('reco-content'); + const currentSong = S.queue[S.qIdx]; + if(!currentSong) { + el.innerHTML = '
🎵

Spiele zuerst Musik in deiner Bibliothek

'; + return; + } + el.innerHTML = '
'; + try { + const artist = currentSong.artist || ''; + const title = currentSong.title || ''; + let html = `
+
🎧
+
Basierend auf
${esc(title)}
${esc(artist)}
+
`; + el.innerHTML = html + '
'; + if(S.cfg.lastfmKey) { + await loadLastfmRecommendations(el, artist, title, html); + } else { + await loadItunesFallbackRecommendations(el, artist, title, html); + const note = document.createElement('div'); + note.style.cssText = 'margin-top:16px;padding:12px;background:var(--bg2);border:1px solid var(--border);border-radius:8px;font-size:12px;color:var(--text3)'; + note.innerHTML = '💡 Tipp: Füge einen Last.fm API Key in den Einstellungen hinzu für bessere Empfehlungen & Similar Artists.'; + el.appendChild(note); + } + } catch(e) { el.innerHTML = `

${e.message}

`; } +} + +async function loadLastfmRecommendations(el, artist, title, html) { + const BASE = 'https://ws.audioscrobbler.com/2.0'; + const key = S.cfg.lastfmKey; + try { + const [simArtists, topTracks, simTracks] = await Promise.all([ + fetch(`${BASE}/?method=artist.getSimilar&artist=${encodeURIComponent(artist)}&api_key=${key}&format=json&limit=12`).then(r=>r.json()), + fetch(`${BASE}/?method=artist.getTopTracks&artist=${encodeURIComponent(artist)}&api_key=${key}&format=json&limit=15`).then(r=>r.json()), + fetch(`${BASE}/?method=track.getSimilar&artist=${encodeURIComponent(artist)}&track=${encodeURIComponent(title)}&api_key=${key}&format=json&limit=15`).then(r=>r.json()), + ]); + el.innerHTML = html; + const simArtistList = simArtists.similarartists?.artist || []; + const topTrackList = topTracks.toptracks?.track || []; + const simTrackList = simTracks.similartracks?.track || []; + if(simArtistList.length) { + el.innerHTML += `
Ähnliche Künstler
`; + el.innerHTML += simArtistList.map(a => ``).join(''); + el.innerHTML += '
'; + } + if(simTrackList.length) { + const sec = document.createElement('div'); + sec.innerHTML = `
Ähnliche Tracks
`; + const list = document.createElement('div'); list.className = 'song-list'; + simTrackList.slice(0,15).forEach(t => { + const row = document.createElement('div'); row.className = 'gtrack-item'; + row.innerHTML = `
🎵
+
${esc(t.name)}
${esc(t.artist?.name||'')}
+
+ + +
`; + row.onclick = () => searchAndPreview(t.name, t.artist?.name||''); + list.appendChild(row); + }); + sec.appendChild(list); el.appendChild(sec); + } + if(topTrackList.length) { + const sec = document.createElement('div'); sec.style.marginTop = '20px'; + sec.innerHTML = `
Top Tracks von ${esc(artist)}
`; + const list = document.createElement('div'); list.className = 'song-list'; + topTrackList.slice(0,10).forEach(t => { + const row = document.createElement('div'); row.className = 'gtrack-item'; + row.innerHTML = `
🎵
+
${esc(t.name)}
${esc(t.artist?.name||artist)}
+
+ + +
`; + row.onclick = () => searchAndPreview(t.name, artist); + list.appendChild(row); + }); + sec.appendChild(list); el.appendChild(sec); + } + } catch { await loadItunesFallbackRecommendations(el, artist, title, html); } +} + +async function loadItunesFallbackRecommendations(el, artist, title, html) { + const results = await itunesSearch(artist, 'musicTrack', 20); + el.innerHTML = html; + const sec = document.createElement('div'); + sec.innerHTML = `
Mehr von ${esc(artist)}
`; + const list = document.createElement('div'); list.className = 'song-list'; + results.filter(t => t.artistName===artist).slice(0,15).forEach(t => list.appendChild(makeGlobalTrackItem(t, 0, [t]))); + if(!list.children.length) results.slice(0,15).forEach(t => list.appendChild(makeGlobalTrackItem(t, 0, [t]))); + sec.appendChild(list); el.appendChild(sec); +} + +// ─── HELPERS ─── +export function expandArtistBio() { + const el = document.getElementById('artist-bio-text'); + if(el && window._artistBioFull) el.textContent = window._artistBioFull; +} + +export function searchSimilarArtist(artistName) { + navigate('discover'); + document.getElementById('global-search-input').value = artistName; + onGlobalSearch(artistName); +} + +export async function searchAndPreview(trackName, artistName) { + try { + const res = await itunesSearch(trackName+' '+artistName, 'musicTrack', 1); + if(res[0]?.previewUrl) playPreview(res[0]); else toast('Kein Preview verfügbar'); + } catch { toast('Suche fehlgeschlagen'); } +} + +export function s_goArtist(e, name, id) { + if(!name) return; + navigate('artist-detail', {id: id||name, name}); +} + +export function s_goAlbum(e, albumId, albumName, artist, artistId) { + if(albumId) navigate('album-detail', {id:albumId, name:albumName, artist, artistId:artistId||'', coverArt:''}); +} + +export function playerBarGoAlbum() { + const s = S.queue[S.qIdx]; + if(!s) return; + if(S.npOpen) closeNowPlaying(); + if(s.albumId) navigate('album-detail', {id:s.albumId, name:s.album||'', artist:s.artist||'', artistId:s.artistId||'', coverArt:s.coverArt||''}); +} + +export function playerBarGoArtist() { + const s = S.queue[S.qIdx]; + if(!s || !s.artist) return; + if(S.npOpen) closeNowPlaying(); + navigate('artist-detail', {id: s.artistId||s.artist, name: s.artist}); +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..506258f --- /dev/null +++ b/vite.config.js @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + build: { + outDir: 'dist', + assetsDir: 'assets', + }, +});