Migrate to Vite + ES module architecture
Split monolithic app.js (~1850 lines) into maintainable ES modules: - src/state.js — shared mutable state (S object) - src/utils.js — md5, esc, formatDur, toast - src/api.js — Subsonic API, Navidrome REST, iTunes API - src/player.js — playback, transport, queue, media session, preview - src/views.js — navigation, page loaders, card makers, search - src/ui.js — all modals, overlays, sidebar, settings - src/pwa.js — service worker registration + update banner - src/main.js — app init, connect, window globals Updated Dockerfile to multi-stage (Node build → nginx serve). Static assets (sw.js, manifest.json, icon.svg) moved to public/. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
ce53c48835
commit
9aded56713
18 files changed
+3134
-2
No files matched your search
@@ -1,3 +1,4 @@
|
||||
.DS_Store
|
||||
*.log
|
||||
node_modules/
|
||||
dist/
|
||||
+8
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<script>window.NS_SERVER_URL='';window.NS_BACKEND_URL='';</script>
|
||||
<script src="config.js" onerror="0"></script>
|
||||
<script src="app.js" defer></script>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
||||
Generated
+985
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "novasonic",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="90" fill="#07070f"/>
|
||||
<rect width="512" height="512" rx="90" fill="url(#bg)"/>
|
||||
<defs>
|
||||
<radialGradient id="bg" cx="30%" cy="40%" r="80%">
|
||||
<stop offset="0%" stop-color="#1a1a2e"/>
|
||||
<stop offset="100%" stop-color="#07070f"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<!-- Music note -->
|
||||
<g fill="#e8a020">
|
||||
<!-- Note head 1 -->
|
||||
<ellipse cx="180" cy="340" rx="48" ry="36" transform="rotate(-15 180 340)"/>
|
||||
<!-- Note head 2 -->
|
||||
<ellipse cx="332" cy="300" rx="48" ry="36" transform="rotate(-15 332 300)"/>
|
||||
<!-- Stem -->
|
||||
<rect x="218" y="160" width="18" height="185" rx="9"/>
|
||||
<rect x="370" y="120" width="18" height="185" rx="9"/>
|
||||
<!-- Beam -->
|
||||
<rect x="218" y="160" width="170" height="22" rx="11"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 855 B |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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()))
|
||||
);
|
||||
});
|
||||
+92
@@ -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 []; }
|
||||
}
|
||||
+164
@@ -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,
|
||||
});
|
||||
+354
@@ -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 ? `<img src="${cv}" style="width:100%;height:100%;object-fit:cover">` : '<div class="no-art">♪</div>';
|
||||
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 ? `<img src="${cv}" style="width:100%;height:100%;object-fit:cover">` : '<div class="no-art">♪</div>';
|
||||
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
|
||||
? '<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>'
|
||||
: '<polygon points="5 3 19 12 5 21 5 3"/>';
|
||||
if(S.npOpen) {
|
||||
document.getElementById('np-play-icon').innerHTML = playing
|
||||
? '<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>'
|
||||
: '<polygon points="5 3 19 12 5 21 5 3"/>';
|
||||
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='<div class="empty-state" style="padding:40px 16px"><div class="icon">🎵</div><p>Leer</p></div>'; return; }
|
||||
S.queue.forEach((s, i) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'queue-item' + (i === S.qIdx ? ' active' : '');
|
||||
item.innerHTML = `<div class="qi-idx">${i+1}</div><div class="qi-info"><div class="qi-title">${esc(s.title)}</div><div class="qi-artist">${esc(s.artist||'')}</div></div>`;
|
||||
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||'—') + '<span class="preview-badge">30s Preview</span>';
|
||||
document.getElementById('preview-sub').textContent = (track.artistName||'') + (track.collectionName?' · '+track.collectionName:'');
|
||||
const art = track.artworkUrl100 || track.artworkUrl60 || '';
|
||||
document.getElementById('preview-art').innerHTML = art ? `<img src="${art}" style="width:100%;height:100%;object-fit:cover">` : '<div class="no-art" style="font-size:16px">♪</div>';
|
||||
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 ? `<img src="${cv}" style="width:100%;height:100%;object-fit:cover">` : '<div class="no-art">♪</div>';
|
||||
updateStarBtn();
|
||||
document.title = `${s.title} — NovaSonic`;
|
||||
} catch {}
|
||||
}
|
||||
+34
@@ -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 = `
|
||||
<span class="update-banner-text">✦ Update verfügbar${version ? ' <span style="opacity:.55;font-size:10px">('+version+')</span>' : ''}</span>
|
||||
<button class="update-banner-btn" onclick="window.location.reload()">Neu laden</button>
|
||||
<button class="update-banner-close" onclick="document.getElementById('update-banner').remove()">✕</button>
|
||||
`;
|
||||
document.body.appendChild(b);
|
||||
}
|
||||
@@ -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: {},
|
||||
};
|
||||
@@ -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 = `<span style="font-size:14px;width:18px;text-align:center">${item.icon}</span><span>${item.label}</span>`;
|
||||
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 = '<div style="font-size:12px;color:var(--text3);padding:8px">Lädt…</div>';
|
||||
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 = '<div style="font-size:12px;color:var(--text3);padding:8px">Noch keine Playlists vorhanden</div>';
|
||||
return;
|
||||
}
|
||||
S.cachedPlaylists.forEach(pl => {
|
||||
const row = document.createElement('div'); row.className = 'pl-row';
|
||||
row.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/></svg>
|
||||
<span>${esc(pl.name)}</span><span style="margin-left:auto;font-size:11px;color:var(--text3)">${pl.songCount} Titel</span>`;
|
||||
row.onclick = () => addSongsToPlaylist(pl.id, pl.name);
|
||||
list.appendChild(row);
|
||||
});
|
||||
} catch(e) { list.innerHTML = `<div style="color:var(--danger);font-size:12px;padding:8px">${e.message}</div>`; }
|
||||
}
|
||||
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 = '<div style="font-size:12px;color:var(--text3);padding:8px 0">Füge Songs später per Rechtsklick hinzu.</div>';
|
||||
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
|
||||
? `<img src="${cv}" style="width:100%;height:100%;object-fit:cover">`
|
||||
: '<div class="no-art" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:32px">🎵</div>';
|
||||
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]) =>
|
||||
`<div class="info-row"><span class="info-label">${l}</span><span class="info-val">${esc(String(v))}</span></div>`
|
||||
).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 ? `<div class="modal-art"><img src="${art}"></div>` : '<div class="modal-art" style="display:flex;align-items:center;justify-content:center;font-size:28px">🎵</div>'}
|
||||
<div class="qi-info"><div class="mti-name">${esc(track.trackName||track.collectionName||'—')}</div><div class="mti-sub">${esc(track.artistName||'—')}${track.collectionName?' · '+esc(track.collectionName):''}</div></div>`;
|
||||
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 = `<div class="job-header">
|
||||
<div class="job-title">${esc(j.title)}</div>
|
||||
<div class="job-status">${esc(j.artist)}</div>
|
||||
</div>
|
||||
<div class="job-bar"><div class="job-fill ${isDone?'done':isErr?'error':''}" style="width:${isDone?100:isErr?100:j.progress}%"></div></div>
|
||||
<div class="job-sub">${statusText}</div>`;
|
||||
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
|
||||
? `<img src="${cv}" style="width:100%;height:100%;object-fit:cover">`
|
||||
: '<div class="no-art" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:36px">🎶</div>';
|
||||
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 = `<img src="${preview}" style="width:100%;height:100%;object-fit:cover">`;
|
||||
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 = `<img src="${bust}" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\'>🎶</div>'">`;
|
||||
document.getElementById('edit-pl-cover').innerHTML = `<img src="${bust}" style="width:100%;height:100%;object-fit:cover">`;
|
||||
}, 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
|
||||
? `<img src="${cv}" style="width:100%;height:100%;object-fit:cover">`
|
||||
: '<div class="no-art" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:36px">🎶</div>';
|
||||
}
|
||||
}
|
||||
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 = '<div class="no-art" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:36px">🎶</div>';
|
||||
document.getElementById('playlist-cover').innerHTML = '<div class="no-art">🎶</div>';
|
||||
} 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
|
||||
? '<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>'
|
||||
: '<polygon points="5 3 19 12 5 21 5 3"/>';
|
||||
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 = '<div style="text-align:center;padding:40px;color:rgba(255,255,255,.3)">Warteschlange leer</div>';
|
||||
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 = `<div class="nqi-num">${i+1}</div><div class="nqi-art">${cv ? `<img src="${cv}">` : '♪'}</div><div class="nqi-info"><div class="nqi-title">${esc(s.title)}</div><div class="nqi-artist">${esc(s.artist||'')}</div></div>`;
|
||||
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 = `
|
||||
<div class="thumb">${cv ? `<img src="${cv}">` : '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:22px">🎵</div>'}</div>
|
||||
<div><div class="title">${esc(s.title)}</div><div class="sub">${esc(s.artist||'')}${s.album?' · '+esc(s.album):''}</div></div>`;
|
||||
const items = [
|
||||
{ icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>', label: 'Song-Info anzeigen', fn: () => { closeNpMenu(); showSongInfo(s); } },
|
||||
{ icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>', label: 'Zu Playlist hinzufügen', fn: () => { closeNpMenu(); openPlModal([s]); } },
|
||||
{ icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg>', label: 'Zum Künstler', fn: () => { closeNpMenu(); npGoArtist(); } },
|
||||
{ icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg>', 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: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>', label: s._starred||s.starred ? 'Aus Favoriten entfernen' : 'Zu Favoriten hinzufügen', fn: () => { closeNpMenu(); npToggleStar(); } },
|
||||
{ icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>', label: 'Herunterladen', fn: () => { closeNpMenu(); dlSongLocal(s); } },
|
||||
];
|
||||
document.getElementById('np-sheet-items').innerHTML = items.map((item,i) =>
|
||||
`<div class="np-sheet-item" data-idx="${i}">${item.icon}<span>${item.label}</span></div>`
|
||||
).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');
|
||||
}
|
||||
@@ -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<<c)|(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<l;i++)m[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<m.length;i+=16){
|
||||
var A=a,B=b,C=c,D=d;
|
||||
a=ff(a,b,c,d,m[i+0],7,-680876936); d=ff(d,a,b,c,m[i+1],12,-389564586); c=ff(c,d,a,b,m[i+2],17,606105819); b=ff(b,c,d,a,m[i+3],22,-1044525330);
|
||||
a=ff(a,b,c,d,m[i+4],7,-176418897); d=ff(d,a,b,c,m[i+5],12,1200080426); c=ff(c,d,a,b,m[i+6],17,-1473231341); b=ff(b,c,d,a,m[i+7],22,-45705983);
|
||||
a=ff(a,b,c,d,m[i+8],7,1770035416); d=ff(d,a,b,c,m[i+9],12,-1958414417); c=ff(c,d,a,b,m[i+10],17,-42063); b=ff(b,c,d,a,m[i+11],22,-1990404162);
|
||||
a=ff(a,b,c,d,m[i+12],7,1804603682); d=ff(d,a,b,c,m[i+13],12,-40341101); c=ff(c,d,a,b,m[i+14],17,-1502002290); b=ff(b,c,d,a,m[i+15],22,1236535329);
|
||||
a=gg(a,b,c,d,m[i+1],5,-165796510); d=gg(d,a,b,c,m[i+6],9,-1069501632); c=gg(c,d,a,b,m[i+11],14,643717713); b=gg(b,c,d,a,m[i+0],20,-373897302);
|
||||
a=gg(a,b,c,d,m[i+5],5,-701558691); d=gg(d,a,b,c,m[i+10],9,38016083); c=gg(c,d,a,b,m[i+15],14,-660478335); b=gg(b,c,d,a,m[i+4],20,-405537848);
|
||||
a=gg(a,b,c,d,m[i+9],5,568446438); d=gg(d,a,b,c,m[i+14],9,-1019803690); c=gg(c,d,a,b,m[i+3],14,-187363961); b=gg(b,c,d,a,m[i+8],20,1163531501);
|
||||
a=gg(a,b,c,d,m[i+13],5,-1444681467); d=gg(d,a,b,c,m[i+2],9,-51403784); c=gg(c,d,a,b,m[i+7],14,1735328473); b=gg(b,c,d,a,m[i+12],20,-1926607734);
|
||||
a=hh(a,b,c,d,m[i+5],4,-378558); d=hh(d,a,b,c,m[i+8],11,-2022574463); c=hh(c,d,a,b,m[i+11],16,1839030562); b=hh(b,c,d,a,m[i+14],23,-35309556);
|
||||
a=hh(a,b,c,d,m[i+1],4,-1530992060); d=hh(d,a,b,c,m[i+4],11,1272893353); c=hh(c,d,a,b,m[i+7],16,-155497632); b=hh(b,c,d,a,m[i+10],23,-1094730640);
|
||||
a=hh(a,b,c,d,m[i+13],4,681279174); d=hh(d,a,b,c,m[i+0],11,-358537222); c=hh(c,d,a,b,m[i+3],16,-722521979); b=hh(b,c,d,a,m[i+6],23,76029189);
|
||||
a=hh(a,b,c,d,m[i+9],4,-640364487); d=hh(d,a,b,c,m[i+12],11,-421815835); c=hh(c,d,a,b,m[i+15],16,530742520); b=hh(b,c,d,a,m[i+2],23,-995338651);
|
||||
a=ii(a,b,c,d,m[i+0],6,-198630844); d=ii(d,a,b,c,m[i+7],10,1126891415); c=ii(c,d,a,b,m[i+14],15,-1416354905); b=ii(b,c,d,a,m[i+5],21,-57434055);
|
||||
a=ii(a,b,c,d,m[i+12],6,1700485571); d=ii(d,a,b,c,m[i+3],10,-1894986606); c=ii(c,d,a,b,m[i+10],15,-1051523); b=ii(b,c,d,a,m[i+1],21,-2054922799);
|
||||
a=ii(a,b,c,d,m[i+8],6,1873313359); d=ii(d,a,b,c,m[i+15],10,-30611744); c=ii(c,d,a,b,m[i+6],15,-1560198380); b=ii(b,c,d,a,m[i+13],21,1309151649);
|
||||
a=ii(a,b,c,d,m[i+4],6,-145523070); d=ii(d,a,b,c,m[i+11],10,-1120210379);c=ii(c,d,a,b,m[i+2],15,718787259); b=ii(b,c,d,a,m[i+9],21,-343485551);
|
||||
a=safeAdd(a,A);b=safeAdd(b,B);c=safeAdd(c,C);d=safeAdd(d,D);
|
||||
}
|
||||
function hex(n){var s='';for(var j=0;j<4;j++)s+=('0'+((n>>>(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,'>').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);
|
||||
}
|
||||
+767
@@ -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 = `<div class="card-art">
|
||||
${imgUrl ? `<img src="${imgUrl}" loading="lazy" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\'>${fallback}</div>'">` : `<div class="no-art">${fallback}</div>`}
|
||||
<div class="play-overlay"><button class="play-btn"><svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg></button></div>
|
||||
</div>
|
||||
<div class="card-info"><div class="card-title">${esc(title)}</div><div class="card-sub">${esc(sub||'')}</div></div>`;
|
||||
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 = `<div class="song-num">${idx+1}</div>
|
||||
<div class="song-thumb">${cv ? `<img src="${cv}" loading="lazy" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\'>♪</div>'">` : '<div class="no-art">♪</div>'}</div>
|
||||
<div class="song-info">
|
||||
<div class="song-title">${esc(s.title)}</div>
|
||||
<div class="song-artist">
|
||||
${s.artistId||s.artist ? `<span class="song-link" onclick="event.stopPropagation();s_goArtist(event,'${esc(s.artist||'')}','${s.artistId||''}')">${esc(s.artist||'')}</span>` : ''}
|
||||
${s.albumId||s.album ? `<span style="color:var(--text3)"> · </span><span class="song-link" onclick="event.stopPropagation();s_goAlbum(event,'${s.albumId||''}','${esc(s.album||'')}','${esc(s.artist||'')}','${s.artistId||''}')">${esc(s.album||'')}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="song-meta">
|
||||
<span class="song-dur">${formatDur(s.duration)}</span>
|
||||
<div class="song-actions">
|
||||
<button class="icon-btn${s._starred?' starred':''}" title="Favorit"><svg width="12" height="12" viewBox="0 0 24 24" fill="${s._starred?'currentColor':'none'}" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></button>
|
||||
<button class="icon-btn" title="Download"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></button>
|
||||
</div>
|
||||
</div>`;
|
||||
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 = `
|
||||
<div class="gtrack-art">${art ? `<img src="${art}" loading="lazy">` : '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:22px">🎵</div>'}</div>
|
||||
<div class="gtrack-info">
|
||||
<div class="gtrack-title">${esc(t.trackName||t.collectionName||'—')}</div>
|
||||
<div class="gtrack-sub">${esc(t.artistName||'—')} · ${esc(t.collectionName||'')} ${t.releaseDate?'· '+new Date(t.releaseDate).getFullYear():''}</div>
|
||||
</div>
|
||||
<div class="gtrack-actions">
|
||||
${t.previewUrl ? `<button class="icon-btn blue" title="30s Preview" onclick="event.stopPropagation();playPreview(${JSON.stringify(t).replace(/"/g,'"')})"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>` : ''}
|
||||
<button class="icon-btn green" title="Auf Server laden" onclick="event.stopPropagation();openDlModal(${JSON.stringify(t).replace(/"/g,'"')})">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
</button>
|
||||
</div>`;
|
||||
el.onclick = () => { if(t.previewUrl) playPreview(t); };
|
||||
return el;
|
||||
}
|
||||
|
||||
function mkSection(title) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'search-section';
|
||||
d.innerHTML = `<div class="search-section-title">${title}</div>`;
|
||||
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 = '<div class="spinner"></div>';
|
||||
try {
|
||||
const r = await api(method, extra);
|
||||
c.innerHTML = '';
|
||||
(r.albumList2?.album || []).forEach(a => c.appendChild(makeAlbumCard(a)));
|
||||
} catch(e) { c.innerHTML = `<div style="color:var(--text3);font-size:13px;padding:16px">${e.message}</div>`; }
|
||||
}
|
||||
|
||||
// ─── ARTISTS ───
|
||||
export async function loadArtists() {
|
||||
const g = document.getElementById('artists-grid');
|
||||
g.innerHTML = '<div class="spinner"></div>';
|
||||
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 = `<p style="color:var(--danger)">${e.message}</p>`; }
|
||||
}
|
||||
|
||||
// ─── ALBUMS ───
|
||||
export async function loadAlbums() {
|
||||
const g = document.getElementById('albums-grid');
|
||||
g.innerHTML = '<div class="spinner"></div>';
|
||||
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 = `<p style="color:var(--danger)">${e.message}</p>`; }
|
||||
}
|
||||
|
||||
// ─── 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 = '<div class="spinner"></div>';
|
||||
|
||||
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 = `<p style="color:var(--danger)">${albumRes.reason?.message}</p>`;
|
||||
}
|
||||
|
||||
const info = infoRes.status==='fulfilled' ? infoRes.value.artistInfo2 : null;
|
||||
if(info?.biography) {
|
||||
const bioEl = document.getElementById('artist-bio-section');
|
||||
const bio = info.biography.replace(/<a [^>]+>/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 = `<div class="section-title" style="margin-bottom:10px">Über</div>
|
||||
<div class="artist-bio" id="artist-bio-text">${esc(short)}${bio.length>400
|
||||
? ` <button onclick="expandArtistBio()" style="background:none;border:none;cursor:pointer;color:var(--accent);font-size:12px;padding:0;margin-left:6px">Mehr lesen</button>`
|
||||
: ''}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
const similar = info?.similarArtist || [];
|
||||
if(similar.length) {
|
||||
const simEl = document.getElementById('artist-similar-section');
|
||||
if(simEl) {
|
||||
simEl.innerHTML = `<div class="section-title" style="margin-bottom:10px">Ähnliche Künstler</div>
|
||||
<div class="similar-chips">${similar.map(a =>
|
||||
`<span class="similar-chip" onclick="navigate('artist-detail',{id:'${esc(a.id||a.name)}',name:'${esc(a.name)}'})">
|
||||
${esc(a.name)}
|
||||
</span>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
const topSongs = topRes.status==='fulfilled' ? (topRes.value.topSongs?.song||[]) : [];
|
||||
if(topSongs.length) {
|
||||
const tsEl = document.getElementById('artist-topsongs-section');
|
||||
if(tsEl) {
|
||||
tsEl.innerHTML = `<div class="section-title" style="margin-bottom:10px">Top Songs</div>`;
|
||||
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 = `<div class="topsong-num">${i+1}</div>
|
||||
<div class="topsong-thumb">${cv ? `<img src="${cv}" loading="lazy">` : '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:18px">🎵</div>'}</div>
|
||||
<div><div class="topsong-title">${esc(s.title)}</div><div style="font-size:11px;color:var(--text2)">${esc(s.album||'')}</div></div>
|
||||
<div class="topsong-plays">${s.playCount ? s.playCount.toLocaleString() : '—'}</div>`;
|
||||
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 ? `<img src="${cv}" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\'>💿</div>'">` : '<div class="no-art">💿</div>';
|
||||
const sl = document.getElementById('album-songs');
|
||||
sl.innerHTML = '<div class="spinner"></div>';
|
||||
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 = `<p style="color:var(--danger)">${e.message}</p>`; }
|
||||
}
|
||||
|
||||
// ─── PLAYLISTS ───
|
||||
export async function loadPlaylists() {
|
||||
const g = document.getElementById('playlists-grid');
|
||||
g.innerHTML = '<div class="spinner"></div>';
|
||||
try {
|
||||
const r = await api('getPlaylists');
|
||||
const lists = r.playlists?.playlist || [];
|
||||
g.innerHTML = '';
|
||||
if(!lists.length) { g.innerHTML = '<div class="empty-state"><div class="icon">🎶</div><p>Keine Playlists vorhanden</p></div>'; 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 = `<p style="color:var(--danger)">${e.message}</p>`; }
|
||||
}
|
||||
|
||||
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 ? `<img src="${cv}" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\'>🎶</div>'">` : '<div class="no-art">🎶</div>';
|
||||
document.getElementById('btn-edit-playlist').onclick = () => openEditPlModal(S._currentPlaylist);
|
||||
const sl = document.getElementById('playlist-songs');
|
||||
sl.innerHTML = '<div class="spinner"></div>';
|
||||
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 = `<p style="color:var(--danger)">${e.message}</p>`; }
|
||||
}
|
||||
|
||||
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 = '<div class="spinner"></div>';
|
||||
try {
|
||||
const r = await api('getStarred2');
|
||||
const songs = (r.starred2?.song || []).map(s => ({...s,_starred:true}));
|
||||
el.innerHTML = '';
|
||||
if(!songs.length) { el.innerHTML = '<div class="empty-state"><div class="icon">⭐</div><p>Noch keine Favoriten</p></div>'; return; }
|
||||
songs.forEach((s,i) => el.appendChild(makeSongItem(s, i, songs)));
|
||||
} catch(e) { el.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
|
||||
}
|
||||
|
||||
export async function loadRandom() {
|
||||
const el = document.getElementById('random-songs');
|
||||
el.innerHTML = '<div class="spinner"></div>';
|
||||
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 = `<p style="color:var(--danger)">${e.message}</p>`; }
|
||||
}
|
||||
|
||||
// ─── 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 = '<div class="spinner"></div>';
|
||||
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 = '<div class="empty-state"><div class="icon">🔍</div><p>Keine Ergebnisse</p></div>'; 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 = `<img src="${img}" loading="lazy" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\'>🎤</div>'"><div class="play-overlay"><button class="play-btn"><svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg></button></div>`;
|
||||
});
|
||||
});
|
||||
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 = `<p style="color:var(--danger)">${e.message}</p>`; }
|
||||
}
|
||||
|
||||
// ─── 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 = '<div class="empty-state"><div class="icon">🔍</div><p>Gib einen Suchbegriff ein</p></div>'; return; }
|
||||
el.innerHTML = '<div class="spinner"></div>';
|
||||
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 = '<div class="no-art">🎤</div>'; 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 = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
|
||||
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 = '<div class="empty-state"><div class="icon">🔍</div><p>Keine Ergebnisse gefunden</p></div>';
|
||||
}
|
||||
} catch(e) { el.innerHTML = `<p style="color:var(--danger)">Suche fehlgeschlagen: ${e.message}</p>`; }
|
||||
}
|
||||
|
||||
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 = `<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px">
|
||||
<button class="back-btn" style="margin:0" onclick="document.getElementById('global-search-input').dispatchEvent(new Event('input'))"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>Zurück</button>
|
||||
<div style="flex:1">
|
||||
<div style="font-size:15px;font-weight:700">${esc(album.collectionName)}</div>
|
||||
<div style="font-size:12px;color:var(--text2)">${esc(album.artistName)}</div>
|
||||
</div>
|
||||
<button class="btn-outline blue" style="font-size:12px;padding:7px 12px" onclick="openDlModal({artistName:'${esc(album.artistName)}',trackName:'${esc(album.collectionName)}',artworkUrl100:'${album.artworkUrl100||''}',collectionName:'${esc(album.collectionName)}',isAlbum:true,itunesData:${JSON.stringify(album).replace(/"/g,'"')}})">⬇ Album laden</button>
|
||||
</div>
|
||||
<div class="search-section-title">Tracks</div>`;
|
||||
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 = '<div class="spinner"></div>';
|
||||
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 = `<div style="display:flex;align-items:center;gap:10px;margin-bottom:20px">
|
||||
<button class="back-btn" style="margin:0" onclick="onGlobalSearch(document.getElementById('global-search-input').value)"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>Zurück</button>
|
||||
<div style="font-size:20px;font-weight:800">${esc(artistName)}</div>
|
||||
</div>
|
||||
<div class="section-title" style="margin-bottom:12px">Alben</div>`;
|
||||
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 = `<p style="color:var(--danger)">${e.message}</p>`; }
|
||||
}
|
||||
|
||||
// ─── NEW RELEASES ───
|
||||
export async function loadNewReleases() {
|
||||
const albumEl = document.getElementById('nr-albums');
|
||||
const songEl = document.getElementById('nr-songs');
|
||||
albumEl.innerHTML = '<div class="spinner"></div>'; 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 = `
|
||||
<div class="gtrack-art">${art ? `<img src="${art}">` : `<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:22px">🎵</div>`}</div>
|
||||
<div class="gtrack-info">
|
||||
<div class="gtrack-title">${esc(s.name)}</div>
|
||||
<div class="gtrack-sub">${esc(s.artistName||'')}</div>
|
||||
</div>
|
||||
<div class="gtrack-actions">
|
||||
<span style="font-size:11px;color:var(--text3);font-family:var(--mono)">#${i+1}</span>
|
||||
<button class="icon-btn green" title="Auf Server laden" onclick="event.stopPropagation();openDlModal({trackName:'${esc(s.name)}',artistName:'${esc(s.artistName||'')}',artworkUrl100:'${s.artworkUrl||''}',collectionName:''})">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
</button>
|
||||
</div>`;
|
||||
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 = `<p style="color:var(--danger)">${e.message}</p>`; }
|
||||
}
|
||||
|
||||
// ─── RECOMMENDATIONS ───
|
||||
export async function loadRecommendations() {
|
||||
const el = document.getElementById('reco-content');
|
||||
const currentSong = S.queue[S.qIdx];
|
||||
if(!currentSong) {
|
||||
el.innerHTML = '<div class="empty-state"><div class="icon">🎵</div><p>Spiele zuerst Musik in deiner Bibliothek</p></div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = '<div class="spinner"></div>';
|
||||
try {
|
||||
const artist = currentSong.artist || '';
|
||||
const title = currentSong.title || '';
|
||||
let html = `<div style="padding:14px;background:var(--surface);border-radius:10px;margin-bottom:20px;display:flex;align-items:center;gap:12px">
|
||||
<div style="font-size:28px">🎧</div>
|
||||
<div><div style="font-size:12px;color:var(--text2)">Basierend auf</div><div style="font-size:15px;font-weight:700">${esc(title)}</div><div style="font-size:13px;color:var(--text2)">${esc(artist)}</div></div>
|
||||
</div>`;
|
||||
el.innerHTML = html + '<div class="spinner"></div>';
|
||||
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 = '💡 <strong>Tipp:</strong> Füge einen Last.fm API Key in den Einstellungen hinzu für bessere Empfehlungen & Similar Artists.';
|
||||
el.appendChild(note);
|
||||
}
|
||||
} catch(e) { el.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
|
||||
}
|
||||
|
||||
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 += `<div class="section-title" style="margin-bottom:10px">Ähnliche Künstler</div><div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:24px">`;
|
||||
el.innerHTML += simArtistList.map(a => `<button class="reco-artist-chip" onclick="searchSimilarArtist('${esc(a.name)}')">${esc(a.name)}</button>`).join('');
|
||||
el.innerHTML += '</div>';
|
||||
}
|
||||
if(simTrackList.length) {
|
||||
const sec = document.createElement('div');
|
||||
sec.innerHTML = `<div class="section-title" style="margin-bottom:10px">Ähnliche Tracks</div>`;
|
||||
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 = `<div class="gtrack-art"><div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:22px">🎵</div></div>
|
||||
<div class="gtrack-info"><div class="gtrack-title">${esc(t.name)}</div><div class="gtrack-sub">${esc(t.artist?.name||'')}</div></div>
|
||||
<div class="gtrack-actions">
|
||||
<button class="icon-btn blue" onclick="event.stopPropagation();searchAndPreview('${esc(t.name)}','${esc(t.artist?.name||'')}')"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>
|
||||
<button class="icon-btn green" onclick="event.stopPropagation();openDlModal({trackName:'${esc(t.name)}',artistName:'${esc(t.artist?.name||'')}',artworkUrl100:'',collectionName:''})"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></button>
|
||||
</div>`;
|
||||
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 = `<div class="section-title" style="margin-bottom:10px">Top Tracks von ${esc(artist)}</div>`;
|
||||
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 = `<div class="gtrack-art"><div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:22px">🎵</div></div>
|
||||
<div class="gtrack-info"><div class="gtrack-title">${esc(t.name)}</div><div class="gtrack-sub">${esc(t.artist?.name||artist)}</div></div>
|
||||
<div class="gtrack-actions">
|
||||
<button class="icon-btn blue" onclick="event.stopPropagation();searchAndPreview('${esc(t.name)}','${esc(artist)}')"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>
|
||||
<button class="icon-btn green" onclick="event.stopPropagation();openDlModal({trackName:'${esc(t.name)}',artistName:'${esc(artist)}',artworkUrl100:'',collectionName:''})"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></button>
|
||||
</div>`;
|
||||
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 = `<div class="section-title" style="margin-bottom:10px">Mehr von ${esc(artist)}</div>`;
|
||||
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});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user