commit 7e256a669e21733faef2667923db99dd50013c3a Author: Gregor Klevze Date: Sun Apr 26 13:01:40 2026 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b29d85b --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# Dependencies +node_modules/ + +# Build output +dist/ +.vite/ + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Runtime/cache +.cache/ +.parcel-cache/ +.eslintcache + +# Environment files +.env +.env.* +!.env.example + +# OS/editor noise +.DS_Store +Thumbs.db +desktop.ini + +# Local IDE settings that should not be shared +.idea/ +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Test/coverage output +coverage/ +playwright-report/ +test-results/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7ec058a --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,15 @@ +{ + "workbench.colorCustomizations": { + "activityBar.background": "#0F3046", + "titleBar.activeBackground": "#164462", + "titleBar.activeForeground": "#F6FAFD", + "titleBar.inactiveBackground": "#0F3046", + "titleBar.inactiveForeground": "#F6FAFD", + "statusBar.background": "#0F3046", + "statusBar.foreground": "#F6FAFD", + "statusBar.debuggingBackground": "#0F3046", + "statusBar.debuggingForeground": "#F6FAFD", + "statusBar.noFolderBackground": "#0F3046", + "statusBar.noFolderForeground": "#F6FAFD" + } +} \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..ed9f05a --- /dev/null +++ b/index.html @@ -0,0 +1,27 @@ + + + + + + + RadioPlayer + + + + + + + + + + + + + + + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1b5af19 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,871 @@ +{ + "name": "radioplayer-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "radioplayer-web", + "version": "0.1.0", + "dependencies": { + "@vitejs/plugin-react": "^6.0.1", + "react": "^19.2.5", + "react-dom": "^19.2.5" + }, + "devDependencies": { + "vite": "^8.0.10" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "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/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "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==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/vite": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..83e3fc1 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "radioplayer-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.5", + "react-dom": "^19.2.5" + }, + "devDependencies": { + "@vitejs/plugin-react": "^6.0.1", + "vite": "^8.0.10" + } +} diff --git a/public/assets/appIcon.png b/public/assets/appIcon.png new file mode 100644 index 0000000..d789348 Binary files /dev/null and b/public/assets/appIcon.png differ diff --git a/public/assets/favicon_io.zip b/public/assets/favicon_io.zip new file mode 100644 index 0000000..15adffb Binary files /dev/null and b/public/assets/favicon_io.zip differ diff --git a/public/assets/favicon_io/android-chrome-192x192.png b/public/assets/favicon_io/android-chrome-192x192.png new file mode 100644 index 0000000..4757781 Binary files /dev/null and b/public/assets/favicon_io/android-chrome-192x192.png differ diff --git a/public/assets/favicon_io/android-chrome-512x512.png b/public/assets/favicon_io/android-chrome-512x512.png new file mode 100644 index 0000000..8f1299a Binary files /dev/null and b/public/assets/favicon_io/android-chrome-512x512.png differ diff --git a/public/assets/favicon_io/app-icon.png b/public/assets/favicon_io/app-icon.png new file mode 100644 index 0000000..8f1299a Binary files /dev/null and b/public/assets/favicon_io/app-icon.png differ diff --git a/public/assets/favicon_io/apple-touch-icon.png b/public/assets/favicon_io/apple-touch-icon.png new file mode 100644 index 0000000..f29ebcf Binary files /dev/null and b/public/assets/favicon_io/apple-touch-icon.png differ diff --git a/public/assets/favicon_io/favicon-16x16.png b/public/assets/favicon_io/favicon-16x16.png new file mode 100644 index 0000000..4eb99da Binary files /dev/null and b/public/assets/favicon_io/favicon-16x16.png differ diff --git a/public/assets/favicon_io/favicon-32x32.png b/public/assets/favicon_io/favicon-32x32.png new file mode 100644 index 0000000..73c330b Binary files /dev/null and b/public/assets/favicon_io/favicon-32x32.png differ diff --git a/public/assets/favicon_io/icon.ico b/public/assets/favicon_io/icon.ico new file mode 100644 index 0000000..586e969 Binary files /dev/null and b/public/assets/favicon_io/icon.ico differ diff --git a/public/assets/favicon_io/site.webmanifest b/public/assets/favicon_io/site.webmanifest new file mode 100644 index 0000000..45dc8a2 --- /dev/null +++ b/public/assets/favicon_io/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file diff --git a/public/assets/javascript.svg b/public/assets/javascript.svg new file mode 100644 index 0000000..f9abb2b --- /dev/null +++ b/public/assets/javascript.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/radioplayer-logo-192.png b/public/assets/radioplayer-logo-192.png new file mode 100644 index 0000000..435588b Binary files /dev/null and b/public/assets/radioplayer-logo-192.png differ diff --git a/public/assets/radioplayer-logo-512.png b/public/assets/radioplayer-logo-512.png new file mode 100644 index 0000000..5d8c117 Binary files /dev/null and b/public/assets/radioplayer-logo-512.png differ diff --git a/public/assets/radioplayer-logo.png b/public/assets/radioplayer-logo.png new file mode 100644 index 0000000..0b7fd74 Binary files /dev/null and b/public/assets/radioplayer-logo.png differ diff --git a/public/assets/tauri.svg b/public/assets/tauri.svg new file mode 100644 index 0000000..0c0e6aa --- /dev/null +++ b/public/assets/tauri.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..e31e294 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,42 @@ +{ + "id": "radioplayer", + "name": "RadioPlayer", + "short_name": "Radio", + "description": "RadioPlayer - stream and cast your favorite radio stations.", + "start_url": "./", + "scope": ".", + "display": "standalone", + "display_override": ["window-controls-overlay", "standalone", "minimal-ui", "browser"], + "orientation": "portrait-primary", + "background_color": "#111318", + "theme_color": "#111318", + "categories": ["music", "entertainment"], + "icons": [ + { + "src": "assets/radioplayer-logo-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "assets/radioplayer-logo-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "shortcuts": [ + { + "name": "Open RadioPlayer", + "short_name": "Open", + "description": "Launch RadioPlayer", + "url": "./", + "icons": [ + { + "src": "assets/radioplayer-logo-192.png", + "sizes": "192x192" + } + ] + } + ] +} diff --git a/public/stations.json b/public/stations.json new file mode 100644 index 0000000..38fb0ac --- /dev/null +++ b/public/stations.json @@ -0,0 +1,1420 @@ +[ + { + "id": "Radio1", + "title": "Radio 1", + "slogan": "Več dobre glasbe", + "logo": "http://datacache.radio.si/api/radiostations/logo/radio1.svg", + "liveAudio": "http://live.radio1.si/Radio1", + "liveVideo": null, + "poster": "", + "lastSongs": "http://data.radio.si/api/lastsongsxml/radio1/json", + "epg": "http://spored.radio.si/api/now/radio1", + "defaultText": "www.radio1.si", + "www": "https://www.radio1.si", + "mountPoints": [ + "Radio1", + "Radio1BK", + "Radio1CE", + "Radio1GOR", + "Radio1KOR", + "Radio1LI", + "Radio1MB", + "Radio1NM", + "Radio1OB", + "Radio1PO", + "Radio1PR", + "Radio1PRI", + "Radio1PT", + "Radio1RIB", + "Radio1VE", + "Radio1VR", + "Radio1SAV" + ], + "social": [ + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg", + "link": "tel:+38651300300" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", + "link": "http://m.radio1.si" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg", + "link": "http://www.youtube.com/user/radio1slovenia" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "http://facebook.com/RadioEna" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", + "link": "http://www.instagram.com/radio1slo" + } + ], + "enabled": true, + "radioApiIO": "https://onair.radioapi.io/ingest/infonet/radio1?format=aim&key=83B0Cq0wZH5DmLyZ13qr&cid=50668", + "rpUid": "705167", + "dabUser": "radio1", + "dabPass": "sUbSGhmzdwKQT", + "dabDefaultImg": "http://media.radio.si/logo/dns/radio1/320x240.png", + "small": false + }, + { + "id": "Aktual", + "title": "Radio Aktual", + "slogan": "Narejen za vaša ušesa", + "logo": "http://datacache.radio.si/api/radiostations/logo/aktual.svg", + "liveAudio": "http://live.radio.si/Aktual", + "liveVideo": "https://radio.serv.si/AktualTV/video.m3u8", + "poster": "https://cdn1.radio.si/900/screenaktual_90c0280a8.jpg", + "lastSongs": "http://data.radio.si/api/lastsongsxml/aktual/json", + "epg": null, + "defaultText": "", + "www": "https://radioaktual.si", + "mountPoints": [ + "Aktual" + ], + "social": [ + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg", + "link": "tel:+386158801430" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", + "link": "https://radioaktual.si/" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg", + "link": "https://www.youtube.com/user/raktual?sub_confirmation=1" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "https://www.facebook.com/raktual" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", + "link": "https://www.instagram.com/radioaktual/" + } + ], + "enabled": true, + "radioApiIO": "", + "rpUid": "705160", + "dabUser": "aktual", + "dabPass": "GB31GZd5st0M", + "dabDefaultImg": "http://media.radio.si/logo/dns/aktual/RadioAktual_DAB.jpg", + "small": false + }, + { + "id": "Veseljak", + "title": "Radio Veseljak", + "slogan": "Najboljša domača glasba", + "logo": "http://datacache.radio.si/api/radiostations/logo/veseljak.svg", + "liveAudio": "http://live.radio.si/Veseljak", + "liveVideo": "https://radio.serv.si/VeseljakGolicaTV/video.m3u8", + "poster": "https://cdn1.radio.si/900/screenveseljak_166218c26.jpg", + "lastSongs": "http://data.radio.si/api/lastsongsxml/veseljak/json", + "epg": null, + "defaultText": "www.veseljak.si", + "www": "https://veseljak.si/", + "mountPoints": [ + "Veseljak", + "VeseljakPO" + ], + "social": [ + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg", + "link": "tel:+38615880110" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", + "link": "https://veseljak.si/" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg", + "link": "https://www.youtube.com/c/VESELJAKNAJBOLJSADOMACAGLASBA" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "https://www.facebook.com/RadioVeseljak" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", + "link": "https://www.instagram.com/veseljak.si/" + } + ], + "enabled": true, + "radioApiIO": "", + "rpUid": "705166", + "dabUser": "veseljak", + "dabPass": "sLRDCAX9j3k2", + "dabDefaultImg": "http://media.radio.si/logo/dns/veseljak/RadioVeseljak_DAB.jpg", + "small": false + }, + { + "id": "Radio1Rock", + "title": "Radio 1 ROCK", + "slogan": "100% Rock", + "logo": "http://datacache.radio.si/api/radiostations/logo/radio1rock.svg", + "liveAudio": "http://live.radio.si/Radio1Rock", + "liveVideo": null, + "poster": null, + "lastSongs": "http://data.radio.si/api/lastsongsxml/radio1rock/json", + "epg": "http://spored.radio.si/api/now/radio1rock", + "defaultText": "www.radio1rock.si", + "www": "https://radio1rock.si/", + "mountPoints": [ + "Radio1Rock" + ], + "social": [ + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg", + "link": "tel:+38683879300" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", + "link": "https://www.radio1rock.si/" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "https://www.facebook.com/R1Rock" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", + "link": "https://www.instagram.com/R1rock.si/" + } + ], + "enabled": true, + "radioApiIO": "https://onair.radioapi.io/ingest/infonet/radiobob?format=aim&key=83B0Cq0wZH5DmLyZ13qr&cid=61109", + "rpUid": "705162", + "dabUser": "radiobob", + "dabPass": "cjT24PpyVxit6", + "dabDefaultImg": "http://media.radio.si/logo/dns/radio1rock/320x240.png", + "small": false + }, + { + "id": "Radio80", + "title": "Radio 1 80-a", + "slogan": "Tvoji najljubši hiti 80-ih", + "logo": "http://datacache.radio.si/api/radiostations/logo/radio80.svg", + "liveAudio": "http://live.radio.si/Radio80", + "liveVideo": null, + "poster": null, + "lastSongs": "http://data.radio.si/api/lastsongsxml/radio80/json", + "epg": "http://spored.radio.si/api/now/radio80", + "defaultText": "www.radio80.si", + "www": "https://radio80.si/", + "mountPoints": [ + "Radio80" + ], + "social": [ + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg", + "link": "tel:+38615008875" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", + "link": "https://www.radio80.si/" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg", + "link": "https://www.youtube.com/radio1slovenia" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "https://www.facebook.com/radioena" + } + ], + "enabled": true, + "radioApiIO": "https://onair.radioapi.io/ingest/infonet/radio180-a?format=aim&key=83B0Cq0wZH5DmLyZ13qr&cid=89760", + "rpUid": "705102", + "dabUser": "radio80", + "dabPass": "nc6da2LolcBXC", + "dabDefaultImg": "http://media.radio.si/logo/dns/radio80/320x240.png", + "small": false + }, + { + "id": "Radio90", + "title": "Radio 1 90-a", + "slogan": "Samo hiti 90-ih", + "logo": "http://datacache.radio.si/api/radiostations/logo/radio90.svg", + "liveAudio": "http://live.radio.si/Radio90", + "liveVideo": null, + "poster": null, + "lastSongs": "http://data.radio.si/api/lastsongsxml/radio90/json", + "epg": null, + "defaultText": "www.radio1.si", + "www": "https://radio1.si/", + "mountPoints": [ + "Radio90" + ], + "social": [ + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg", + "link": "tel:+38615008875" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", + "link": "https://www.radio1.si/" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg", + "link": "https://www.youtube.com/radio1slovenia" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "https://www.facebook.com/radioena" + } + ], + "enabled": true, + "radioApiIO": "", + "rpUid": "705172", + "dabUser": "radio90", + "dabPass": "P2RyUrHcyq7M", + "dabDefaultImg": "http://media.radio.si/logo/dns/radio90/320x240.png", + "small": false + }, + { + "id": "Country", + "title": "Radio 1 Country", + "slogan": "Samo največji country hiti", + "logo": "http://datacache.radio.si/api/radiostations/logo/country.svg", + "liveAudio": "http://live.radio.si/Country", + "liveVideo": null, + "poster": null, + "lastSongs": "http://data.radio.si/api/lastsongsxml/country/json", + "epg": "http://spored.radio.si/api/now/toti", + "defaultText": "www.radio1.si", + "www": "https://radio1.si/", + "mountPoints": [ + "Country", + "Toti" + ], + "social": [ + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg", + "link": "tel:+38615008875" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", + "link": "https://www.radio1.si/" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg", + "link": "https://www.youtube.com/radio1slovenia" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "https://www.facebook.com/radioena" + } + ], + "enabled": true, + "radioApiIO": "", + "rpUid": "705181", + "dabUser": "country", + "dabPass": "h4aagv93jq", + "dabDefaultImg": "http://media.radio.si/logo/dns/country/320x240.png", + "small": false + }, + { + "id": "Toti", + "title": "Toti radio", + "slogan": "Toti hudi hiti", + "logo": "http://datacache.radio.si/api/radiostations/logo/toti.svg", + "liveAudio": "http://live.radio.si/Toti", + "liveVideo": null, + "poster": null, + "lastSongs": "http://data.radio.si/api/lastsongsxml/toti/json", + "epg": "http://spored.radio.si/api/now/toti", + "defaultText": "www.totiradio.si", + "www": "https://totiradio.si/", + "mountPoints": [ + "Maxi", + "Toti" + ], + "social": [ + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg", + "link": "tel:+38651220220" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", + "link": "https://totiradio.si/" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg", + "link": "https://www.youtube.com/user/radioantenaslo" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "https://www.facebook.com/HitradioAntena" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", + "link": "https://www.instagram.com/radioantena.si/" + } + ], + "enabled": true, + "radioApiIO": "https://onair.radioapi.io/ingest/infonet/totiradio?format=aim&key=83B0Cq0wZH5DmLyZ13qr&cid=91414", + "rpUid": "705108", + "dabUser": "toti", + "dabPass": "wmAos05tECsmf", + "dabDefaultImg": "http://media.radio.si/logo/dns/toti/320x240.png", + "small": false + }, + { + "id": "Antena", + "title": "Radio Antena", + "slogan": "Največ hitov, najmanj govora", + "logo": "http://datacache.radio.si/api/radiostations/logo/antena.svg", + "liveAudio": "http://live.radio.si/Antena", + "liveVideo": "https://radio.serv.si/BestTV/video.m3u8", + "poster": "https://cdn1.radio.si/900/screenbest_6559e3ac8.jpg", + "lastSongs": "http://data.radio.si/api/lastsongsxml/antena/json", + "epg": "http://spored.radio.si/api/now/antena", + "defaultText": "www.radioantena.si", + "www": "https://radioantena.si/", + "mountPoints": [ + "Antena" + ], + "social": [ + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg", + "link": "tel:+38612425630 " + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", + "link": "https://radioantena.si/" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg", + "link": "https://www.youtube.com/user/radioantenaslo" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "https://www.facebook.com/HitradioAntena" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", + "link": "https://www.instagram.com/radioantena.si/" + } + ], + "enabled": true, + "radioApiIO": "https://onair.radioapi.io/ingest/infonet/radioantena?format=aim&key=83B0Cq0wZH5DmLyZ13qr&cid=37864", + "rpUid": "705161", + "dabUser": "radioantena", + "dabPass": "nGkMhFk77jnBQ", + "dabDefaultImg": "http://media.radio.si/logo/dns/antena/320x240.png", + "small": false + }, + { + "id": "BestFM", + "title": "BestFM", + "slogan": "Muska, muska, muska", + "logo": "http://datacache.radio.si/api/radiostations/logo/bestfm.svg", + "liveAudio": "http://live.radio.si/BestFM", + "liveVideo": "https://radio.serv.si/BestTV/video.m3u8", + "poster": "https://cdn1.radio.si/900/screenbest_6559e3ac8.jpg", + "lastSongs": "http://data.radio.si/api/lastsongsxml/bestfm/json", + "epg": "", + "defaultText": "www.bestfm.si", + "www": "https://bestfm.si/", + "mountPoints": [ + "BestFM" + ], + "social": [ + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg", + "link": "tel:+38673372030" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", + "link": "https://bestfm.si/" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "https://www.facebook.com/profile.php?id=100086776586975" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", + "link": "https://www.instagram.com/bestfm.si/" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", + "link": "https://www.instagram.com/radiokrka/" + } + ], + "enabled": true, + "radioApiIO": "", + "rpUid": "705115", + "dabUser": "bestfm", + "dabPass": "momo911x", + "dabDefaultImg": "http://media.radio.si/logo/dns/bestfm/BestFM_DAB.jpg", + "small": false + }, + { + "id": "Krka", + "title": "Radio Krka", + "slogan": "Dolenjska v srcu", + "logo": "http://datacache.radio.si/api/radiostations/logo/krka.svg", + "liveAudio": "http://live.radio.si/Krka", + "liveVideo": null, + "poster": null, + "lastSongs": "http://data.radio.si/api/lastsongsxml/krka/json", + "epg": "", + "defaultText": "www.radiokrka.si", + "www": "https://radiokrka.si/", + "mountPoints": [ + "Krka" + ], + "social": [ + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg", + "link": "tel:+38673372030" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", + "link": "https://radiokrka.si/" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg", + "link": "https://www.youtube.com/user/radiokrka" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "https://www.facebook.com/radiokrka" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", + "link": "https://www.instagram.com/radiokrka/" + } + ], + "enabled": true, + "radioApiIO": "", + "rpUid": "705120", + "dabUser": "krka", + "dabPass": "qBi6z!um2Gm", + "dabDefaultImg": "http://media.radio.si/logo/dns/krka/RadioKrka_DAB.jpg", + "small": false + }, + { + "id": "Klasik", + "title": "Klasik radio", + "slogan": "Glasba, ki vas sprosti", + "logo": "https://data.radio.si/api/radiostations/logo/klasik.svg", + "liveAudio": "http://live.radio.si/Klasik", + "liveVideo": null, + "poster": null, + "lastSongs": "https://data.radio.si/api/lastsongsxml/klasik/json", + "epg": "", + "defaultText": "www.klasikradio.si", + "www": "https://www.klasikradio.si/", + "mountPoints": [ + "Klasik" + ], + "social": [ + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg", + "link": "tel:+38612425630" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", + "link": "https://www.klasikradio.si/" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg", + "link": "https://www.youtube.com/channel/UCd7OpUbSIoZarJgwFf4aIxw" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "https://www.facebook.com/radiosalomon" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", + "link": "https://www.instagram.com/radiosalomon/" + } + ], + "enabled": true, + "radioApiIO": "", + "rpUid": "705176", + "dabUser": "klasik", + "dabPass": "mQTpTR9XEbiF", + "dabDefaultImg": "http://media.radio.si/logo/dns/klasik/320x240.png", + "small": false + }, + { + "id": "Maxi", + "title": "Toti Maxi", + "slogan": "Sama dobra glasba", + "logo": "https://data.radio.si/api/radiostations/logo/maxi.svg", + "liveAudio": "http://live.radio.si/Maxi", + "liveVideo": null, + "poster": null, + "lastSongs": "https://data.radio.si/api/lastsongsxml/toti/json", + "epg": "", + "defaultText": "www.totimaxi.si", + "www": "https://www.radiomaxi.si/", + "mountPoints": [ + "Maxi" + ], + "social": [ + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg", + "link": "tel:+38631628444" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", + "link": "https://www.radiomaxi.si/" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "https://www.facebook.com/profile.php?id=100064736766638" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "https://www.facebook.com/RadioPtuj" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", + "link": "https://www.instagram.com/radio_ptuj/" + } + ], + "enabled": true, + "radioApiIO": "https://onair.radioapi.io/ingest/infonet/totiradio?format=aim&key=83B0Cq0wZH5DmLyZ13qr&cid=37998", + "rpUid": "705109", + "dabUser": "ptuj", + "dabPass": "cwv4jXVKMYT", + "dabDefaultImg": "http://media.radio.si/logo/dns/ptuj/RadioPtuj_DAB.jpg", + "small": false + }, + { + "id": "Salomon", + "title": "Radio Salomon", + "slogan": "Izbrana urbana glasba", + "logo": "http://datacache.radio.si/api/radiostations/logo/salomon.svg", + "liveAudio": "http://live.radio.si/Salomon", + "liveVideo": null, + "poster": null, + "lastSongs": "http://data.radio.si/api/lastsongsxml/salomon/json", + "epg": "", + "defaultText": "www.radiosalomon.si", + "www": "https://radiosalomon.si/", + "mountPoints": [ + "Salomon" + ], + "social": [ + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg", + "link": "tel:+386015880111" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", + "link": "https://radiosalomon.si/" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg", + "link": "https://www.youtube.com/channel/UCd7OpUbSIoZarJgwFf4aIxw" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "https://www.facebook.com/radiosalomon" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", + "link": "https://www.instagram.com/radiosalomon/" + } + ], + "enabled": true, + "radioApiIO": "", + "rpUid": "705116", + "dabUser": "salomon", + "dabPass": "a1bfadd8b8ut", + "dabDefaultImg": "http://media.radio.si/logo/dns/salomon/RadioSalomon_DAB.jpg", + "small": false + }, + { + "id": "Ptuj", + "title": "Radio Ptuj", + "slogan": "Največje uspešnice vseh časov", + "logo": "https://data.radio.si/api/radiostations/logo/ptuj.svg", + "liveAudio": "http://live.radio.si/Ptuj", + "liveVideo": null, + "poster": null, + "lastSongs": "https://data.radio.si/api/lastsongsxml/ptuj/json", + "epg": "", + "defaultText": "www.radio-ptuj.si", + "www": "https://www.radio-ptuj.si/", + "mountPoints": [ + "Ptuj" + ], + "social": [ + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg", + "link": "tel:+38627493420" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", + "link": "https://www.radio-ptuj.si/" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg", + "link": "https://www.youtube.com/@RadioPtuj" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "https://www.facebook.com/RadioPtuj" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", + "link": "https://www.instagram.com/radio_ptuj/" + } + ], + "enabled": true, + "radioApiIO": "", + "rpUid": "705119", + "dabUser": "ptuj", + "dabPass": "cwv4jXVKMYT", + "dabDefaultImg": "http://media.radio.si/logo/dns/ptuj/RadioPtuj_DAB.jpg", + "small": false + }, + { + "id": "Fantasy", + "title": "Radio Fantasy", + "slogan": "Same dobre vibracije", + "logo": "https://data.radio.si/api/radiostations/logo/fantasy.svg", + "liveAudio": "http://live.radio.si/Fantasy", + "liveVideo": null, + "poster": null, + "lastSongs": "https://data.radio.si/api/lastsongsxml/fantasy/json", + "epg": "http://spored.radio.si/api/now/koroski", + "defaultText": "", + "www": "https://rfantasy.si/", + "mountPoints": [ + "Fantasy" + ], + "social": [ + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg", + "link": "tel:+38634903921" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", + "link": "https://www.rfantasy.si/" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg", + "link": "https://www.youtube.com/c/RadioFantasyTv" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "https://www.facebook.com/RadioFantasySlo" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", + "link": "https://www.instagram.com/radiofantasyslo/" + } + ], + "enabled": true, + "radioApiIO": "https://onair.radioapi.io/ingest/infonet/radiofantasy?format=aim&key=83B0Cq0wZH5DmLyZ13qr&cid=61118", + "rpUid": "", + "dabUser": "koroski", + "dabPass": "num87dhket", + "dabDefaultImg": "http://media.radio.si/logo/dns/koroski/320x240.png", + "small": true + }, + { + "id": "Robin", + "title": "Radio Robin", + "slogan": "Brez tebe ni mene", + "logo": "https://data.radio.si/api/radiostations/logo/robin.svg", + "liveAudio": "http://live.radio.si/Robin", + "liveVideo": null, + "poster": null, + "lastSongs": "https://data.radio.si/api/lastsongsxml/robin/json", + "epg": "http://spored.radio.si/api/now/robin", + "defaultText": "www.robin.si", + "www": "https://www.robin.si/", + "mountPoints": [ + "Robin" + ], + "social": [ + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg", + "link": "tel:+38653302822" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", + "link": "https://www.robin.si/" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg", + "link": "https://www.youtube.com/channel/UCACfPObotnJAnVXfCZNMlUg" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "https://www.facebook.com/Radio.Robin.goriski" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", + "link": "https://www.instagram.com/radio_robin/" + } + ], + "enabled": true, + "radioApiIO": "https://onair.radioapi.io/ingest/infonet/radiorobin?format=aim&key=83B0Cq0wZH5DmLyZ13qr&cid=37984", + "rpUid": "705103", + "dabUser": "radiorobin", + "dabPass": "rt5mo9b9", + "dabDefaultImg": "http://media.radio.si/logo/dns/robin/320x240.png", + "small": false + }, + { + "id": "Koroski", + "title": "Koroški radio", + "slogan": "Ritem Koroške", + "logo": "https://data.radio.si/api/radiostations/logo/koroski.svg", + "liveAudio": "http://live.radio.si/Koroski", + "liveVideo": null, + "poster": null, + "lastSongs": "https://data.radio.si/api/lastsongsxml/koroski/json", + "epg": "http://spored.radio.si/api/now/koroski", + "defaultText": "www.koroski-radio.si", + "www": "https://www.koroski-radio.si/", + "mountPoints": [ + "Koroski" + ], + "social": [ + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg", + "link": "tel:+38628841245" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", + "link": "https://www.koroski-radio.si/" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg", + "link": "https://www.youtube.com/channel/UCLwH6lX4glK4o1N77JkeaJw" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "https://www.facebook.com/KoroskiRadio" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", + "link": "https://www.instagram.com/koroski_r/" + } + ], + "enabled": true, + "radioApiIO": "", + "rpUid": "705105", + "dabUser": "koroski", + "dabPass": "num87dhket", + "dabDefaultImg": "http://media.radio.si/logo/dns/koroski/320x240.png", + "small": true + }, + { + "id": "VeseljakZlatiZvoki", + "title": "Veseljak Zlati zvoki", + "slogan": "Najvecja zakladnica slovenske domace glasbe", + "logo": "https://data.radio.si/api/radiostations/logo/veseljakzlatizvoki.svg", + "liveAudio": "http://live.radio.si/VeseljakZlatiZvoki", + "liveVideo": null, + "poster": null, + "lastSongs": "https://data.radio.si/api/lastsongsxml/veseljakzlatizvoki/json", + "epg": "", + "defaultText": "www.veseljak.si", + "www": "https://www.veseljak.si/", + "mountPoints": [ + "VeseljakZlatiZvoki" + ], + "social": [ + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg", + "link": "tel:+38615880110" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", + "link": "https://veseljak.si/" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg", + "link": "https://www.youtube.com/c/VESELJAKNAJBOLJSADOMACAGLASBA" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "https://www.facebook.com/RadioVeseljak" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", + "link": "https://www.instagram.com/veseljak.si/" + } + ], + "enabled": true, + "radioApiIO": "", + "rpUid": "705175", + "dabUser": "zlatizvoki", + "dabPass": "4jeeUnjA4qYV", + "dabDefaultImg": "http://media.radio.si/logo/dns/veseljakzlatizvoki/RadioVeseljakZlatiZvoki_DAB.jpg", + "small": false + }, + { + "id": "RockMB", + "title": "Rock Maribor", + "slogan": "100% Rock", + "logo": "https://data.radio.si/api/radiostations/logo/rockmb.svg", + "liveAudio": "http://live.radio.si/RockMB", + "liveVideo": null, + "poster": null, + "lastSongs": "https://data.radio.si/api/lastsongsxml/radio1rock/json", + "epg": "http://spored.radio.si/api/now/triglav", + "defaultText": "www.rockmaribor.si", + "www": "https://rockmaribor.si/", + "mountPoints": [ + "RockMB" + ], + "social": [ + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", + "link": "https://rockmaribor.si/" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "https://www.facebook.com/RockMaribor.si" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "https://www.facebook.com/RadioTriglav" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", + "link": "https://www.instagram.com/radiotriglav/" + } + ], + "enabled": true, + "radioApiIO": "https://onair.radioapi.io/ingest/infonet/rockmaribor?format=aim&key=83B0Cq0wZH5DmLyZ13qr&cid=61116", + "rpUid": "705107", + "dabUser": "triglav", + "dabPass": "ogFLUKodMUCB5", + "dabDefaultImg": "http://media.radio.si/logo/dns/triglav/320x240.png", + "small": false + }, + { + "id": "Kranj", + "title": "Radio Kranj", + "slogan": "", + "logo": "https://data.radio.si/api/radiostations/logo/kranj.svg", + "liveAudio": "http://live.radio.si/Kranj", + "liveVideo": null, + "poster": null, + "lastSongs": "https://data.radio.si/api/lastsongsxml/kranj/json", + "epg": "http://spored.radio.si/api/now/kranj", + "defaultText": "www.radio-kranj.si", + "www": "https://radio-kranj.si/", + "mountPoints": [ + "Kranj" + ], + "social": [ + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg", + "link": "tel:+38651303505" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", + "link": "https://www.radio-kranj.si/" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg", + "link": "https://www.youtube.com/channel/UCe_Ze0SEHCSLLNUbWM0aBgA" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "https://www.facebook.com/pages/Radio-Kranj/1760816170864847" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", + "link": "https://www.instagram.com/radiokranj/" + } + ], + "enabled": true, + "radioApiIO": "https://onair.radioapi.io/ingest/infonet/radiokranj?format=aim&key=83B0Cq0wZH5DmLyZ13qr&cid=61102", + "rpUid": "705104", + "dabUser": "kranj", + "dabPass": "ui8z3Ezzosyxw", + "dabDefaultImg": "http://media.radio.si/logo/dns/kranj/320x240.png", + "small": false + }, + { + "id": "Celje", + "title": "Radio Celje", + "slogan": "Vedno z menoj", + "logo": "https://data.radio.si/api/radiostations/logo/celje.svg", + "liveAudio": "http://live.radio.si/Celje", + "liveVideo": null, + "poster": null, + "lastSongs": "https://data.radio.si/api/lastsongsxml/celje/json", + "epg": "", + "defaultText": "www.radiocelje.si", + "www": "https://www.radiocelje.si/", + "mountPoints": [ + "Celje" + ], + "social": [ + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg", + "link": "tel:+386034225100" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", + "link": "https://www.radiocelje.si/" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg", + "link": "https://www.youtube.com/channel/UC99aNwZXokG6nnJLfSn5DSw" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "https://www.facebook.com/radiocelje" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", + "link": "https://www.instagram.com/radiocelje/" + } + ], + "enabled": true, + "radioApiIO": "", + "rpUid": "705117", + "dabUser": "celje", + "dabPass": "dunk7815g", + "dabDefaultImg": "http://media.radio.si/logo/dns/celje/RadioCelje_DAB.jpg", + "small": false + }, + { + "id": "Triglav", + "title": "Radio Triglav", + "slogan": "Radio za radovedne", + "logo": "https://data.radio.si/api/radiostations/logo/triglav.svg", + "liveAudio": "http://live.radio.si/Triglav", + "liveVideo": null, + "poster": null, + "lastSongs": "https://data.radio.si/api/lastsongsxml/triglav/json", + "epg": "http://spored.radio.si/api/now/triglav", + "defaultText": "www.radiotriglav.si", + "www": "https://radiotriglav.si/", + "mountPoints": [ + "Triglav" + ], + "social": [ + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg", + "link": "tel:+38651654064" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", + "link": "https://www.radiotriglav.si/" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "https://www.facebook.com/RadioTriglav" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", + "link": "https://www.instagram.com/radiotriglav/" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", + "link": "http://www.instagram.com/radio1slo" + } + ], + "enabled": true, + "radioApiIO": "https://onair.radioapi.io/ingest/infonet/radiotriglav?format=aim&key=83B0Cq0wZH5DmLyZ13qr&cid=38020", + "rpUid": "705106", + "dabUser": "triglav", + "dabPass": "ogFLUKodMUCB5", + "dabDefaultImg": "http://media.radio.si/logo/dns/triglav/320x240.png", + "small": false + }, + { + "id": "Velenje", + "title": "Radio Velenje", + "slogan": "Ker smo radi na kamot", + "logo": "http://datacache.radio.si/api/radiostations/logo/velenje.svg", + "liveAudio": "http://live.radio.si/Velenje", + "liveVideo": null, + "poster": null, + "lastSongs": "http://data.radio.si/api/lastsongsxml/velenje/json", + "epg": "", + "defaultText": "www.veseljak.si", + "www": "https://veseljak.si/", + "mountPoints": [ + "Velenje" + ], + "social": [ + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg", + "link": "tel:+38615880110" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", + "link": "https://veseljak.si/" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg", + "link": "https://www.youtube.com/c/VESELJAKNAJBOLJSADOMACAGLASBA" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "https://www.facebook.com/RadioVeseljak" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", + "link": "https://www.instagram.com/veseljak.si/" + } + ], + "enabled": true, + "radioApiIO": "", + "rpUid": "705118", + "dabUser": "velenje", + "dabPass": "e9mopbu11", + "dabDefaultImg": "http://media.radio.si/logo/dns/velenje/RadioVelenje_DAB.jpg", + "small": false + }, + { + "id": "AktualK", + "title": "Radio Aktual Kum", + "slogan": "Narejen za vaša ušesa", + "logo": "http://datacache.radio.si/api/radiostations/logo/aktualk.svg", + "liveAudio": "http://live.radio.si/AktualK", + "liveVideo": null, + "poster": null, + "lastSongs": "http://data.radio.si/api/lastsongsxml/aktualk/json", + "epg": "", + "defaultText": "", + "www": null, + "mountPoints": [ + "AktualK" + ], + "social": [ + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg", + "link": "tel:+386158801430" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", + "link": "https://radioaktual.si/" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg", + "link": "https://www.youtube.com/user/raktual" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "https://www.facebook.com/raktual" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", + "link": "https://www.instagram.com/stories/radioaktual/" + } + ], + "enabled": true, + "radioApiIO": "", + "rpUid": "", + "dabUser": null, + "dabPass": null, + "dabDefaultImg": null, + "small": false + }, + { + "id": "AktualRomantika", + "title": "Radio Aktual - Romantika", + "slogan": "Kot nezna dlan, ki boza te", + "logo": "http://datacache.radio.si/api/radiostations/logo/aktualromantika.svg", + "liveAudio": "http://live.radio.si/AktualRomantika", + "liveVideo": null, + "poster": null, + "lastSongs": "http://data.radio.si/api/lastsongsxml/aktualromantika/json", + "epg": "", + "defaultText": "", + "www": null, + "mountPoints": [ + "AktualRomantika" + ], + "social": [ + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg", + "link": "tel:+386158801430" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", + "link": "https://radioaktual.si/" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg", + "link": "https://www.youtube.com/user/raktual" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "https://www.facebook.com/raktual" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", + "link": "https://www.instagram.com/stories/radioaktual/" + } + ], + "enabled": true, + "radioApiIO": "", + "rpUid": "705174", + "dabUser": "romantika", + "dabPass": "Z75biJ5t7CpK", + "dabDefaultImg": "http://media.radio.si/logo/dns/aktualromantika/RadioAktualRomnatika_DAB.jpg", + "small": false + }, + { + "id": "Stop", + "title": "Stop", + "slogan": "Revija Stop: Več kot pol stoletja ob vaši strani!", + "logo": "http://datacache.radio.si/api/radiostations/logo/stop.svg", + "liveAudio": "http://live.radio.si/Stop", + "liveVideo": null, + "poster": null, + "lastSongs": "http://data.radio.si/api/lastsongsxml/stop/json", + "epg": "", + "defaultText": "www.revijastop.si", + "www": "https://revijastop.si/", + "mountPoints": [ + "Stop" + ], + "social": [ + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", + "link": "https://revijastop.si/" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", + "link": "https://radiosalomon.si/" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg", + "link": "https://www.youtube.com/channel/UCd7OpUbSIoZarJgwFf4aIxw" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "https://www.facebook.com/radiosalomon" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", + "link": "https://www.instagram.com/radiosalomon/" + } + ], + "enabled": true, + "radioApiIO": "", + "rpUid": "", + "dabUser": null, + "dabPass": null, + "dabDefaultImg": null, + "small": false + }, + { + "id": "Radio1SLO", + "title": "Radio 1 slovenski hiti", + "slogan": "Sama dobra slovenska glasba", + "logo": "http://datacache.radio.si/api/radiostations/logo/radio1slo.svg", + "liveAudio": "http://live.radio.si/Radio1SLO", + "liveVideo": null, + "poster": null, + "lastSongs": "http://data.radio.si/api/lastsongsxml/radio1slo/json", + "epg": "", + "defaultText": "www.radio1.si", + "www": "https://www.radio1.si", + "mountPoints": [ + "Radio1SLO" + ], + "social": [ + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg", + "link": "tel:+38651300300" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", + "link": "http://radio1.si" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg", + "link": "http://www.youtube.com/user/radio1slovenia" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "http://facebook.com/RadioEna" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", + "link": "http://www.instagram.com/radio1slo" + } + ], + "enabled": true, + "radioApiIO": "", + "rpUid": "705110", + "dabUser": "1slovenske", + "dabPass": "ionb9hkd48", + "dabDefaultImg": "http://media.radio.si/logo/dns/radio1slo/320x240.png", + "small": false + }, + { + "id": "RockCE", + "title": "Rock Celje", + "slogan": "100% Rock", + "logo": "https://data.radio.si/api/radiostations/logo/rockce.svg", + "liveAudio": "http://live.radio.si/RockCE", + "liveVideo": null, + "poster": null, + "lastSongs": "https://data.radio.si/api/lastsongsxml/rockce/json", + "epg": null, + "defaultText": "www.rock-celje.si", + "www": "https://rock-celje.si/", + "mountPoints": [ + "RockCE" + ], + "social": [ + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg", + "link": "tel:+38628841245" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", + "link": "https://www.rock-celje.si/" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "https://www.facebook.com/RockCelje" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "https://www.facebook.com/radiosalomon" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", + "link": "https://www.instagram.com/radiosalomon/" + } + ], + "enabled": true, + "radioApiIO": "", + "rpUid": "", + "dabUser": null, + "dabPass": null, + "dabDefaultImg": null, + "small": false + }, + { + "id": "Radio1Bozicne", + "title": "ROŠKARJEVE BOŽIČNE", + "slogan": "100% Božične", + "logo": "https://data.radio.si/api/radiostations/logo/radio1bozicne.svg", + "liveAudio": "http://live.radio1.si/Radio1Bozicne", + "liveVideo": null, + "poster": null, + "lastSongs": "https://data.radio.si/api/lastsongsxml/radio1bozicne/json", + "epg": null, + "defaultText": "www.radio1.si", + "www": "https://radio1.si/", + "mountPoints": [ + "Radio1Bozicne" + ], + "social": [ + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", + "link": "https://radio1.si/" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", + "link": "https://radioaktual.si/" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg", + "link": "https://www.youtube.com/user/raktual" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "https://www.facebook.com/raktual" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", + "link": "https://www.instagram.com/stories/radioaktual/" + } + ], + "enabled": true, + "radioApiIO": "", + "rpUid": "", + "dabUser": null, + "dabPass": null, + "dabDefaultImg": null, + "small": false + }, + { + "id": "TotiBozicni", + "title": "TOTI BOŽIČNI RADIO", + "slogan": "Tvoje mesto, tvoj radio", + "logo": "http://datacache.radio.si/api/radiostations/logo/totibozicne.svg", + "liveAudio": "http://live.radio.si/TotiBozicne", + "liveVideo": null, + "poster": null, + "lastSongs": "http://data.radio.si/api/lastsongsxml/totibozicni/json", + "epg": null, + "defaultText": "www.totiradio.si", + "www": "https://totiradio.si/", + "mountPoints": [ + "TotiBozicni" + ], + "social": [ + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-telefon-01.svg", + "link": "tel:+38651220220" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-www-01.svg", + "link": "https://totiradio.si/" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-youtube-01.svg", + "link": "https://www.youtube.com/user/raktual" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-facebook-01.svg", + "link": "https://www.facebook.com/raktual" + }, + { + "icon": "http://datacache.radio.si/api/radiostations/logo/ikona-instagram-01.svg", + "link": "https://www.instagram.com/stories/radioaktual/" + } + ], + "enabled": true, + "radioApiIO": "", + "rpUid": "", + "dabUser": null, + "dabPass": null, + "dabDefaultImg": null, + "small": false + }, + { + "id": "Hit", + "title": "Radio HIT", + "slogan": "Samo nostalgija", + "logo": "http://datacache.radio.si/api/radiostations/logo/hit.svg", + "liveAudio": "http://live.radio.si/Hit", + "liveVideo": null, + "poster": null, + "lastSongs": "http://data.radio.si/api/lastsongsxml/hit/json", + "epg": "http://spored.radio.si/api/now/hit", + "defaultText": "www.radiohit.si", + "www": "https://radiohit.si/", + "mountPoints": [ + "Hit" + ], + "social": [], + "enabled": true, + "radioApiIO": "https://onair.radioapi.io/ingest/infonet/radiohit?format=aim&key=83B0Cq0wZH5DmLyZ13qr&cid=61120", + "rpUid": "705141", + "dabUser": "hit", + "dabPass": "aaYqKTBBdUJt", + "dabDefaultImg": "http://media.radio.si/logo/dns/hit/320x240.png", + "small": false + } +] \ No newline at end of file diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..a291995 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,105 @@ +// NOTE: This service worker is for the web/PWA build. +// For development we aggressively unregister SWs in `src/player.js`. +// +// Bump this value whenever caching logic changes to guarantee clients don't +// keep an old UI after updates. +const CACHE_NAME = 'radioplayer-pwa-v4'; + +const CORE_ASSETS = [ + './', + 'index.html', + 'stations.json', + 'manifest.json', + 'assets/radioplayer-logo-192.png', + 'assets/radioplayer-logo-512.png', +]; + +const CORE_PATHS = new Set(CORE_ASSETS.map((p) => new URL(p, self.registration.scope).pathname)); + +self.addEventListener('install', (event) => { + // Activate updated SW as soon as it's installed. + self.skipWaiting(); + event.waitUntil( + caches.open(CACHE_NAME).then(async (cache) => { + const reqs = CORE_ASSETS.map((p) => new Request(p, { cache: 'reload' })); + await cache.addAll(reqs); + + // Vite fingerprints JS/CSS assets in production. Parse the built HTML so + // the installed PWA can launch offline after its first install. + try { + const indexResp = await fetch(new Request('./', { cache: 'reload' })); + const indexText = await indexResp.clone().text(); + await cache.put('./', indexResp); + + const assetUrls = [...indexText.matchAll(/(?:src|href)="([^"]+)"/g)] + .map((match) => match[1]) + .filter((assetPath) => assetPath.startsWith('./assets/') || assetPath.startsWith('assets/')) + .map((assetPath) => new URL(assetPath, self.registration.scope).href); + + await Promise.all(assetUrls.map((assetUrl) => { + return cache.add(new Request(assetUrl, { cache: 'reload' })).catch(() => {}); + })); + } catch (e) { + // If HTML parsing fails, runtime caching below still catches assets. + } + }) + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + Promise.all([ + self.clients.claim(), + caches.keys().then((keys) => Promise.all( + keys.map((k) => { if (k !== CACHE_NAME) return caches.delete(k); return null; }) + )), + ]) + ); +}); + +self.addEventListener('fetch', (event) => { + // Only handle GET requests + if (event.request.method !== 'GET') return; + + const url = new URL(event.request.url); + + // Don't cache cross-origin requests (station logos, APIs, etc.). + if (url.origin !== self.location.origin) { + return; + } + + const isCore = CORE_PATHS.has(url.pathname); + const isHtmlNavigation = event.request.mode === 'navigate' || (event.request.headers.get('accept') || '').includes('text/html'); + + // Network-first for navigations and core assets to prevent "old UI" issues. + if (isHtmlNavigation || isCore) { + event.respondWith( + fetch(event.request) + .then((networkResp) => { + const respClone = networkResp.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(event.request, respClone)).catch(() => {}); + return networkResp; + }) + .catch(() => caches.match(event.request).then((cached) => cached || caches.match('./') || caches.match('index.html'))) + ); + return; + } + + event.respondWith( + caches.match(event.request).then((cached) => { + if (cached) return cached; + return fetch(event.request).then((networkResp) => { + // Optionally cache new resources (best-effort) + try { + const respClone = networkResp.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(event.request, respClone)).catch(()=>{}); + } catch (e) {} + return networkResp; + }).catch(() => { + // If offline and HTML navigation, return cached index.html + if (event.request.mode === 'navigate') return caches.match('./') || caches.match('index.html'); + return new Response('', { status: 503, statusText: 'Service Unavailable' }); + }); + }) + ); +}); diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..262c06e --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,344 @@ +import { useEffect, useState } from 'react'; + +function formatClock(date) { + return new Intl.DateTimeFormat(undefined, { + hour: '2-digit', + minute: '2-digit', + }).format(date); +} + +function formatDate(date) { + return new Intl.DateTimeFormat(undefined, { + weekday: 'short', + month: 'short', + day: 'numeric', + }).format(date); +} + +function EditIcon() { + return ( + + ); +} + +function ListIcon() { + return ( + + ); +} + +function InstallIcon() { + return ( + + ); +} + +function CastIcon({ size = 22 }) { + return ( + + ); +} + +function VolumeIcon() { + return ( + + + + + ); +} + +function MutedIcon() { + return ( + + + + + + ); +} + +function HeaderControls() { + const [now, setNow] = useState(() => new Date()); + + useEffect(() => { + const intervalId = window.setInterval(() => setNow(new Date()), 1000); + return () => window.clearInterval(intervalId); + }, []); + + return ( +
+
+
+ +
+
RadioPlayer
+ +
+
+
+ + + + +
+
+
+
+ ); +} + +function ArtworkPanel() { + return ( +
+
+
+
+ station logo + 1 +
+
+ +
+ +
+ +
+
+
+ ); +} + +function TrackInfo() { + return ( +
+

+
+
+
+
+

+

+
+ Output: + +
+

+ ); +} + +function ProgressBar() { + return ( +
+
+
+
+
+
+ ); +} + +function PlayerControls() { + return ( +
+ + + + + +
+ ); +} + +function VolumeControl() { + return ( +
+ +
+ +
+ 80% +
+ ); +} + +function RetroStarfield() { + const stars = Array.from({ length: 120 }, (_, index) => { + const angle = (index * 137.508) % 360; + const radius = 90 + ((index * 47) % 520); + const verticalBias = ((index * 29) % 180) - 90; + const x0 = Math.cos(angle * Math.PI / 180) * radius * 0.34; + const y0 = Math.sin(angle * Math.PI / 180) * radius * 0.24 + verticalBias * 0.18; + const x1 = Math.cos((angle + 24) * Math.PI / 180) * radius * 1.08; + const y1 = Math.sin((angle + 18) * Math.PI / 180) * radius * 0.72 + verticalBias; + const size = 2 + (index % 6) * 0.75; + const duration = 7 + (index % 9) * 1.1; + const delay = -((index * 0.71) % duration); + + return { + id: index, + style: { + '--x0': `${x0.toFixed(1)}px`, + '--y0': `${y0.toFixed(1)}px`, + '--x1': `${x1.toFixed(1)}px`, + '--y1': `${y1.toFixed(1)}px`, + '--star-size': `${size.toFixed(1)}px`, + '--star-duration': `${duration.toFixed(1)}s`, + '--star-delay': `${delay.toFixed(1)}s`, + }, + }; + }); + + return ( + + ); +} + +function StationsOverlay() { + return ( + + ); +} + +function EditorOverlay() { + return ( + + ); +} + +export default function App() { + return ( +
+
+ + + + + + + + + +
+
+ ); +} diff --git a/src/main.jsx b/src/main.jsx new file mode 100644 index 0000000..380b00c --- /dev/null +++ b/src/main.jsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { flushSync } from 'react-dom'; +import App from './App.jsx'; +import './styles.css'; + +const rootEl = document.getElementById('root'); +const root = createRoot(rootEl); + +flushSync(() => { + root.render( + + + , + ); +}); + +import('./player.js') + .then(({ initPlayer }) => initPlayer()) + .catch((err) => { + console.error('Failed to initialise player controller', err); + }); diff --git a/src/player.js b/src/player.js new file mode 100644 index 0000000..4e743de --- /dev/null +++ b/src/player.js @@ -0,0 +1,1401 @@ +// Web version of RadioPlayer — HTML5 Audio + Google Cast Web Sender SDK. + +// ── Audio engine ────────────────────────────────────────────────────────────── +const audio = new Audio(); +audio.preload = 'none'; + +// ── Cast state ──────────────────────────────────────────────────────────────── +// 'local' = HTML5 audio, 'cast' = Google Cast session active +let castMode = 'local'; +let castContext = null; // cast.framework.CastContext instance +let castPlayerController = null; // cast.framework.RemotePlayerController +let castInitialized = false; +// When true, local audio also plays alongside the Cast session. +let castBothMode = false; +let deferredInstallPrompt = null; + +// State +let stations = []; +let currentIndex = 0; +let isPlaying = false; +let isMuted = false; +let currentVolume = 0.8; // 0–1 float + +// UI Elements +const stationNameEl = document.getElementById('station-name'); +const stationSubtitleEl = document.getElementById('station-subtitle'); +const nowPlayingEl = document.getElementById('now-playing'); +const nowArtistEl = document.getElementById('now-artist'); +const nowTitleEl = document.getElementById('now-title'); +const statusTextEl = document.getElementById('status-text'); +const statusDotEl = document.querySelector('.status-dot'); +const engineBadgeEl = document.getElementById('engine-badge'); +const engineLabelEl = document.getElementById('engine-label'); +const playBtn = document.getElementById('play-btn'); +const iconPlay = document.getElementById('icon-play'); +const iconStop = document.getElementById('icon-stop'); +const prevBtn = document.getElementById('prev-btn'); +const nextBtn = document.getElementById('next-btn'); +const volumeSlider = document.getElementById('volume-slider'); +const volumeValue = document.getElementById('volume-value'); +const muteBtn = document.getElementById('mute-btn'); +const iconVolume = document.getElementById('icon-volume'); +const iconMuted = document.getElementById('icon-muted'); +const castOverlay = document.getElementById('cast-overlay'); +const closeOverlayBtn = document.getElementById('close-overlay'); +const deviceListEl = document.getElementById('device-list'); +const coverflowStageEl = document.getElementById('artwork-coverflow-stage'); +const coverflowPrevBtn = document.getElementById('artwork-prev'); +const coverflowNextBtn = document.getElementById('artwork-next'); +const artworkPlaceholder = document.querySelector('.artwork-placeholder'); +const logoTextEl = document.querySelector('.station-logo-text'); +const logoImgEl = document.getElementById('station-logo-img'); + +// Editor +const editBtn = document.getElementById('edit-stations-btn'); +const stationsListBtn = document.getElementById('stations-list-btn'); +const installAppBtn = document.getElementById('install-app-btn'); +const castBtn = document.getElementById('cast-btn'); +const editorOverlay = document.getElementById('editor-overlay'); +const editorCloseBtn = document.getElementById('editor-close-btn'); +const editorListEl = document.getElementById('editor-list'); +const addStationForm = document.getElementById('add-station-form'); +const usTitle = document.getElementById('us_title'); +const usUrl = document.getElementById('us_url'); +const usLogo = document.getElementById('us_logo'); +const usWww = document.getElementById('us_www'); +const usId = document.getElementById('us_id'); +const usIndex = document.getElementById('us_index'); + +// Cast output toggle +const castOutputRow = document.getElementById('cast-output-row'); +const castOutputBtn = document.getElementById('cast-output-btn'); +const castOutputText = document.getElementById('cast-output-text'); + +// ── Utilities ──────────────────────────────────────────────────────────────── + +const STATION_THEMES = [ + { accent: '#4dd7c8', accent2: '#ffb45c', accent3: '#8fb3ff', page: '#171b22', panel: '#111821' }, + { accent: '#ff7aa8', accent2: '#ffd166', accent3: '#8fb3ff', page: '#22151d', panel: '#1b131a' }, + { accent: '#7ce38b', accent2: '#53c7ff', accent3: '#f0c86a', page: '#132018', panel: '#101a16' }, + { accent: '#b28cff', accent2: '#6ee7d8', accent3: '#ff9f6e', page: '#19162a', panel: '#141221' }, + { accent: '#ff9f43', accent2: '#4dd7c8', accent3: '#feca57', page: '#241912', panel: '#1c1510' }, + { accent: '#6ecbff', accent2: '#b28cff', accent3: '#ff7aa8', page: '#111a25', panel: '#101720' }, + { accent: '#f7d36b', accent2: '#ff7a7a', accent3: '#6ee7d8', page: '#211b10', panel: '#1a160e' }, + { accent: '#9cff6e', accent2: '#ff8bd1', accent3: '#78a7ff', page: '#14200f', panel: '#111a0e' }, + { accent: '#ff6f61', accent2: '#ffd166', accent3: '#80ed99', page: '#241513', panel: '#1c1110' }, + { accent: '#64dfdf', accent2: '#c77dff', accent3: '#f6bd60', page: '#101f22', panel: '#0e191c' }, + { accent: '#a3cef1', accent2: '#f28482', accent3: '#f6bd60', page: '#121b24', panel: '#101720' }, + { accent: '#d0f4de', accent2: '#ff99c8', accent3: '#a9def9', page: '#10201a', panel: '#0e1815' }, +]; + +function toHttpsIfHttp(url) { + if (!url || typeof url !== 'string') return ''; + return url.startsWith('http://') ? ('https://' + url.slice('http://'.length)) : url; +} + +function hashString(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) - hash) + str.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash); +} + +function hexToRgb(hex) { + const normalized = String(hex || '').replace('#', '').trim(); + if (!/^[0-9a-f]{6}$/i.test(normalized)) return null; + const num = parseInt(normalized, 16); + return `${(num >> 16) & 255}, ${(num >> 8) & 255}, ${num & 255}`; +} + +function getStationTheme(station) { + const rawTheme = station?.raw?.theme || station?.theme || {}; + const key = `${station?.id || ''}:${station?.name || station?.title || ''}`; + const fallback = STATION_THEMES[hashString(key) % STATION_THEMES.length]; + return { + accent: rawTheme.accent || rawTheme.primary || station?.raw?.color || fallback.accent, + accent2: rawTheme.accent2 || rawTheme.secondary || fallback.accent2, + accent3: rawTheme.accent3 || fallback.accent3, + page: rawTheme.page || rawTheme.background || fallback.page, + panel: rawTheme.panel || fallback.panel, + }; +} + +function applyStationTheme(station) { + if (!station) return; + const theme = getStationTheme(station); + const root = document.documentElement; + root.style.setProperty('--accent', theme.accent); + root.style.setProperty('--accent-2', theme.accent2); + root.style.setProperty('--accent-3', theme.accent3); + root.style.setProperty('--page-bg', theme.page); + root.style.setProperty('--theme-panel', theme.panel); + root.style.setProperty('--accent-rgb', hexToRgb(theme.accent) || '77, 215, 200'); + root.style.setProperty('--accent-2-rgb', hexToRgb(theme.accent2) || '255, 180, 92'); + root.style.setProperty('--accent-3-rgb', hexToRgb(theme.accent3) || '143, 179, 255'); + root.style.setProperty('--theme-page-rgb', hexToRgb(theme.page) || '17, 19, 24'); + root.style.setProperty('--theme-panel-rgb', hexToRgb(theme.panel) || '16, 20, 27'); + root.style.setProperty('--accent-glow', `rgba(${hexToRgb(theme.accent) || '77, 215, 200'}, 0.34)`); +} + +function getMetadataFetchUrl(url) { + if (!url || typeof url !== 'string') return ''; + + const safeUrl = toHttpsIfHttp(url) || url; + if (!import.meta.env.DEV) return safeUrl; + + try { + const parsed = new URL(safeUrl); + if (parsed.hostname === 'data.radio.si') { + return `/radio-si-data${parsed.pathname}${parsed.search}`; + } + } catch (e) { + return safeUrl; + } + + return safeUrl; +} + +function uniqueNonEmpty(urls) { + const out = []; + const seen = new Set(); + for (const u of urls) { + if (!u || typeof u !== 'string') continue; + const trimmed = u.trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + out.push(trimmed); + } + return out; +} + +function setImgWithFallback(imgEl, urls, onFinalError) { + const candidates = uniqueNonEmpty(urls); + let i = 0; + + if (!imgEl || candidates.length === 0) { + if (imgEl) { imgEl.onload = null; imgEl.onerror = null; imgEl.src = ''; } + if (onFinalError) onFinalError(); + return; + } + + const tryNext = () => { + if (i >= candidates.length) { + if (onFinalError) onFinalError(); + return; + } + imgEl.src = candidates[i++]; + }; + + imgEl.onerror = tryNext; + try { imgEl.referrerPolicy = 'no-referrer'; } catch (e) {} + tryNext(); +} + +// ── Global error handlers ──────────────────────────────────────────────────── + +window.addEventListener('error', (ev) => { + try { + console.error('Uncaught error', ev.error || ev.message || ev); + if (statusTextEl) statusTextEl.textContent = 'Error: ' + (ev.error?.message ?? ev.message ?? 'Unknown'); + } catch (e) { /* ignore */ } +}); + +window.addEventListener('unhandledrejection', (ev) => { + try { + console.error('Unhandled rejection', ev.reason); + if (statusTextEl) statusTextEl.textContent = 'Error: ' + (ev.reason?.message ?? String(ev.reason)); + } catch (e) { /* ignore */ } +}); + +// ── Audio event wiring ─────────────────────────────────────────────────────── + +audio.addEventListener('waiting', () => { + if (!isPlaying) return; + if (statusTextEl) statusTextEl.textContent = 'Buffering...'; + if (statusDotEl) statusDotEl.style.backgroundColor = 'var(--text-muted)'; +}); + +audio.addEventListener('playing', () => { + if (statusTextEl) statusTextEl.textContent = 'Playing'; + if (statusDotEl) statusDotEl.style.backgroundColor = 'var(--success)'; + isPlaying = true; + updateUI(); +}); + +audio.addEventListener('stalled', () => { + if (!isPlaying) return; + if (statusTextEl) statusTextEl.textContent = 'Reconnecting...'; + if (statusDotEl) statusDotEl.style.backgroundColor = 'var(--text-muted)'; +}); + +audio.addEventListener('error', () => { + if (!isPlaying) return; + const err = audio.error; + const msg = err ? audioErrorMessage(err.code) : 'Stream error'; + if (statusTextEl) statusTextEl.textContent = msg; + if (statusDotEl) statusDotEl.style.backgroundColor = 'var(--danger)'; + isPlaying = false; + updateUI(); +}); + +audio.addEventListener('ended', () => { + // Live streams shouldn't end; if they do, try to reconnect. + if (isPlaying) { + const url = audio.src; + setTimeout(() => { + audio.src = url; + audio.load(); + audio.play().catch(() => {}); + }, 2000); + } +}); + +function audioErrorMessage(code) { + switch (code) { + case 1: return 'Playback aborted'; + case 2: return 'Network error'; + case 3: return 'Decode error'; + case 4: return 'Stream not supported'; + default: return 'Stream error'; + } +} + +// ── Volume ─────────────────────────────────────────────────────────────────── + +function saveVolumeToStorage(val) { + try { localStorage.setItem('volume', String(val)); } catch (e) { /* ignore */ } +} + +function getSavedVolume() { + try { + const v = localStorage.getItem('volume'); + if (!v) return null; + const n = Number(v); + return Number.isFinite(n) && n >= 0 && n <= 100 ? n : null; + } catch (e) { return null; } +} + +function restoreSavedVolume() { + const saved = getSavedVolume(); + const vol = saved !== null ? saved : 80; + if (volumeSlider) volumeSlider.value = String(vol); + if (volumeValue) volumeValue.textContent = `${vol}%`; + currentVolume = vol / 100; + audio.volume = currentVolume; +} + +// ── Station persistence ─────────────────────────────────────────────────────── + +function saveLastStationId(id) { + try { if (id) localStorage.setItem('lastStationId', id); } catch (e) { /* ignore */ } +} + +function getLastStationId() { + try { return localStorage.getItem('lastStationId'); } catch (e) { return null; } +} + +// ── castBothMode persistence & UI ──────────────────────────────────────────── + +function saveCastBothMode(val) { + try { localStorage.setItem('castBothMode', val ? '1' : '0'); } catch (e) { /* ignore */ } +} + +function restoreCastBothMode() { + try { + castBothMode = localStorage.getItem('castBothMode') === '1'; + } catch (e) { castBothMode = false; } +} + +function updateCastOutputToggleUI() { + if (!castOutputRow || !castOutputBtn || !castOutputText) return; + + if (castMode === 'cast') { + castOutputRow.classList.remove('hidden'); + } else { + castOutputRow.classList.add('hidden'); + return; + } + + if (castBothMode) { + castOutputBtn.setAttribute('aria-pressed', 'true'); + castOutputText.textContent = 'Cast + Local'; + castOutputBtn.title = 'Currently: Cast + This computer — click to cast only'; + } else { + castOutputBtn.setAttribute('aria-pressed', 'false'); + castOutputText.textContent = 'Cast only'; + castOutputBtn.title = 'Currently: Cast only — click to also play on this computer'; + } +} + +function toggleCastBothMode() { + castBothMode = !castBothMode; + saveCastBothMode(castBothMode); + updateCastOutputToggleUI(); + + if (castMode !== 'cast' || !isPlaying) return; + + if (castBothMode) { + // Start local playback in parallel + playLocal(); + } else { + // Stop local audio, keep Cast going + audio.pause(); + audio.src = ''; + } +} + +// ── User Stations (localStorage) ──────────────────────────────────────────── + +function loadUserStations() { + try { + const raw = localStorage.getItem('userStations'); + return raw ? JSON.parse(raw) : []; + } catch (e) { + console.error('Error reading user stations', e); + return []; + } +} + +function saveUserStations(arr) { + try { + localStorage.setItem('userStations', JSON.stringify(arr || [])); + } catch (e) { + console.error('Error saving user stations', e); + } +} + +// ── Load stations ──────────────────────────────────────────────────────────── + +async function loadStations() { + try { + stopCurrentSongPollers(); + const resp = await fetch('stations.json'); + const raw = await resp.json(); + + stations = raw + .map((s) => { + if (s.name && s.url) return s; + const name = s.title || s.id || s.name || 'Unknown'; + const url = s.liveAudio || s.liveVideo || s.liveStream || s.url || ''; + return { + id: s.id || name, + name, + url, + logo: s.logo || s.poster || '', + enabled: typeof s.enabled === 'boolean' ? s.enabled : true, + raw: s, + }; + }) + .filter((s) => s.enabled !== false && s.url && s.url.length > 0); + + const userNormalized = loadUserStations() + .map((s) => { + const name = s.title || s.name || s.id || 'UserStation'; + const url = s.url || s.liveAudio || s.liveVideo || ''; + return { + id: s.id || `user-${name.replace(/\s+/g, '-')}`, + name, + url, + logo: s.logo || '', + enabled: typeof s.enabled === 'boolean' ? s.enabled : true, + raw: s, + _user: true, + }; + }) + .filter((s) => s.url && s.url.length > 0); + + stations = stations.concat(userNormalized); + console.debug('loadStations: loaded', stations.length, 'stations'); + + if (stations.length > 0) { + const lastId = getLastStationId(); + if (lastId) { + const found = stations.findIndex((s) => s.id === lastId); + currentIndex = found >= 0 ? found : 0; + } else { + currentIndex = 0; + } + loadStation(currentIndex); + renderCoverflow(); + startCurrentSongPollers(); + } + } catch (e) { + console.error('Failed to load stations', e); + if (statusTextEl) statusTextEl.textContent = 'Error loading stations'; + } +} + +// ── Coverflow ──────────────────────────────────────────────────────────────── + +let coverflowPointerId = null; +let coverflowStartX = 0; +let coverflowLastX = 0; +let coverflowAccum = 0; +let coverflowMoved = false; +let coverflowWheelLock = false; + +function renderCoverflow() { + try { + if (!coverflowStageEl) return; + coverflowStageEl.innerHTML = ''; + + stations.forEach((s, idx) => { + const item = document.createElement('div'); + item.className = 'coverflow-item'; + item.dataset.idx = String(idx); + item.setAttribute('role', 'button'); + item.setAttribute('tabindex', '0'); + const fallbackLabel = (s?.name ?? '?').trim(); + item.title = fallbackLabel; + item.setAttribute('aria-label', `Select ${fallbackLabel}`); + + const rawLogoUrl = s.logo || s.raw?.logo || s.raw?.poster || ''; + if (rawLogoUrl) { + const img = document.createElement('img'); + img.alt = `${s.name} logo`; + setImgWithFallback(img, [toHttpsIfHttp(rawLogoUrl), rawLogoUrl], () => { + item.innerHTML = ''; + item.classList.add('fallback'); + item.textContent = fallbackLabel; + }); + item.appendChild(img); + } else { + item.classList.add('fallback'); + item.textContent = fallbackLabel; + } + + item.addEventListener('click', async (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + if (coverflowMoved) return; + const idxClicked = Number(item.dataset.idx); + if (idxClicked !== currentIndex) await setStationByIndex(idxClicked); + else openStationsOverlay(); + }); + item.addEventListener('keydown', async (ev) => { + if (ev.key !== 'Enter' && ev.key !== ' ') return; + ev.preventDefault(); + const idxSelected = Number(item.dataset.idx); + if (idxSelected !== currentIndex) await setStationByIndex(idxSelected); + else openStationsOverlay(); + }); + item.addEventListener('dblclick', () => { + if (Number(item.dataset.idx) === currentIndex) openStationsOverlay(); + }); + + coverflowStageEl.appendChild(item); + }); + + wireCoverflowInteractions(); + updateCoverflowTransforms(); + } catch (e) { + console.debug('renderCoverflow failed', e); + } +} + +function wireCoverflowInteractions() { + try { + const host = document.getElementById('artwork-coverflow'); + if (!host) return; + + if (coverflowPrevBtn) { + coverflowPrevBtn.onpointerdown = (ev) => ev.stopPropagation(); + coverflowPrevBtn.onclick = (ev) => { + ev.stopPropagation(); ev.preventDefault(); + setStationByIndex((currentIndex - 1 + stations.length) % stations.length); + }; + } + if (coverflowNextBtn) { + coverflowNextBtn.onpointerdown = (ev) => ev.stopPropagation(); + coverflowNextBtn.onclick = (ev) => { + ev.stopPropagation(); ev.preventDefault(); + setStationByIndex((currentIndex + 1) % stations.length); + }; + } + + host.onpointerdown = (ev) => { + if (!stations || stations.length <= 1) return; + if (ev.target?.closest?.('.coverflow-arrow')) return; + coverflowPointerId = ev.pointerId; + coverflowStartX = ev.clientX; + coverflowLastX = ev.clientX; + coverflowAccum = 0; + coverflowMoved = false; + try { host.setPointerCapture(ev.pointerId); } catch (e) {} + }; + host.onpointermove = (ev) => { + if (coverflowPointerId === null || ev.pointerId !== coverflowPointerId) return; + const dx = ev.clientX - coverflowLastX; + coverflowLastX = ev.clientX; + if (Math.abs(ev.clientX - coverflowStartX) > 6) coverflowMoved = true; + coverflowAccum += dx; + const threshold = 36; + if (coverflowAccum >= threshold) { + coverflowAccum = 0; + setStationByIndex((currentIndex - 1 + stations.length) % stations.length); + } else if (coverflowAccum <= -threshold) { + coverflowAccum = 0; + setStationByIndex((currentIndex + 1) % stations.length); + } + }; + host.onpointerup = (ev) => { + if (coverflowPointerId === null || ev.pointerId !== coverflowPointerId) return; + coverflowPointerId = null; + setTimeout(() => { coverflowMoved = false; }, 0); + try { host.releasePointerCapture(ev.pointerId); } catch (e) {} + }; + host.onpointercancel = () => { coverflowPointerId = null; coverflowMoved = false; }; + + host.onwheel = (ev) => { + if (!stations || stations.length <= 1 || coverflowWheelLock) return; + const delta = Math.abs(ev.deltaX) > Math.abs(ev.deltaY) ? ev.deltaX : ev.deltaY; + if (Math.abs(delta) < 6) return; + ev.preventDefault(); + coverflowWheelLock = true; + if (delta > 0) setStationByIndex((currentIndex + 1) % stations.length); + else setStationByIndex((currentIndex - 1 + stations.length) % stations.length); + setTimeout(() => { coverflowWheelLock = false; }, 160); + }; + } catch (e) { + console.debug('wireCoverflowInteractions failed', e); + } +} + +function updateCoverflowTransforms() { + try { + if (!coverflowStageEl) return; + const items = coverflowStageEl.querySelectorAll('.coverflow-item'); + const n = stations?.length ?? 0; + if (n <= 0) return; + + const stageWidth = coverflowStageEl.clientWidth || 320; + const isMobile = window.matchMedia('(max-width: 760px)').matches; + const isNarrow = window.matchMedia('(max-width: 380px)').matches; + const maxVisible = 1; + const spacing = isMobile ? Math.min(78, Math.max(62, stageWidth / 3.25)) : Math.min(116, Math.max(94, stageWidth / 3.1)); + const depth = isMobile ? 26 : 36; + const rotation = isMobile ? 0 : 8; + const scaleStep = isMobile ? 0.08 : 0.1; + + items.forEach((el) => { + const idx = Number(el.dataset.idx); + let offset = idx - currentIndex; + const half = Math.floor(n / 2); + if (offset > half) offset -= n; + if (offset < -half) offset += n; + + el.dataset.offset = String(offset); + el.setAttribute('aria-current', offset === 0 ? 'true' : 'false'); + + if (Math.abs(offset) > maxVisible) { + el.classList.remove('selected'); + el.style.opacity = '0'; + el.style.pointerEvents = 'none'; + el.style.transform = `translate(-50%, -50%) translateX(${Math.sign(offset || 1) * (stageWidth / 2)}px) scale(0.72)`; + return; + } + + const abs = Math.abs(offset); + const dir = offset === 0 ? 0 : (offset > 0 ? 1 : -1); + const isAdjacent = abs === 1; + el.style.opacity = String(1 - abs * 0.2); + el.style.zIndex = String(100 - abs); + el.style.pointerEvents = 'auto'; + el.style.transform = `translate(-50%, -50%) translateX(${dir * abs * spacing}px) translateZ(${-abs * depth}px) rotateY(${dir * -rotation * abs}deg) scale(${isAdjacent ? 0.88 : 1 - abs * scaleStep})`; + if (offset === 0) el.classList.add('selected'); + else el.classList.remove('selected'); + }); + } catch (e) { + console.debug('updateCoverflowTransforms failed', e); + } +} + +async function setStationByIndex(idx) { + if (idx < 0 || idx >= stations.length) return; + const wasPlaying = isPlaying; + if (wasPlaying) await stop(); + currentIndex = idx; + saveLastStationId(stations[currentIndex].id); + loadStation(currentIndex); + updateCoverflowTransforms(); + if (wasPlaying) await play(); +} + +// ── Current Song Polling ───────────────────────────────────────────────────── + +const currentSongPollers = new Map(); + +function stopCurrentSongPollers() { + for (const entry of currentSongPollers.values()) { + try { if (entry?.intervalId) clearInterval(entry.intervalId); } catch (e) {} + try { if (entry?.timeoutId) clearTimeout(entry.timeoutId); } catch (e) {} + } + currentSongPollers.clear(); +} + +function startCurrentSongPollers() { + stopCurrentSongPollers(); + const s = stations[currentIndex]; + if (!s) return; + + const url = getMetadataFetchUrl(s.raw?.currentSong || s.raw?.lastSongs); + if (!url || typeof url !== 'string' || url.length === 0) return; + + const doFetch = () => { + fetchAndStoreCurrentSong(s, currentIndex, url); + }; + + doFetch(); + const iid = setInterval(doFetch, 10000); + currentSongPollers.set(s.id || currentIndex, { intervalId: iid, timeoutId: null }); +} + +async function fetchAndStoreCurrentSong(station, idx, url) { + try { + let rawBody = null; + try { + const resp = await fetch(url, { cache: 'no-store' }); + rawBody = await resp.text(); + } catch (e) { + // CORS or network error — silently skip; now-playing simply won't display. + return; + } + + if (!rawBody) return; + + let data = null; + try { + const first = JSON.parse(rawBody); + data = typeof first === 'string' ? JSON.parse(first) : first; + } catch (e) { + return; + } + + let now = null; + if (data) { + if (data.currentSong?.artist || data.currentSong?.title) { + now = { artist: data.currentSong.artist || '', title: data.currentSong.title || '' }; + } else if (Array.isArray(data.lastSongs) && data.lastSongs.length > 0) { + const first = data.lastSongs[0]; + if (first?.artist || first?.title) now = { artist: first.artist || '', title: first.title || '' }; + } else if (data.artist || data.title) { + now = { artist: data.artist || '', title: data.title || '' }; + } + } + + if (now) { + station.currentSongInfo = now; + if (idx === currentIndex) updateNowPlayingUI(); + + // If provider gives timing info, schedule a single-shot refresh at song end. + try { + const key = station.id || idx; + const providerCS = data?.currentSong ?? null; + const startStr = providerCS?.playTimeStartSec ?? providerCS?.playTimeStart ?? null; + const lengthStr = providerCS?.playTimeLengthSec ?? providerCS?.playTimeLength ?? null; + if (startStr && lengthStr) { + const nowDate = new Date(); + const parts = startStr.split(':').map(Number); + if (parts.length >= 2) { + const startDate = new Date(nowDate.getFullYear(), nowDate.getMonth(), nowDate.getDate(), parts[0], parts[1], parts[2] || 0, 0); + const deltaStart = startDate.getTime() - nowDate.getTime(); + if (deltaStart > 12 * 3600000) startDate.setDate(startDate.getDate() - 1); + if (deltaStart < -12 * 3600000) startDate.setDate(startDate.getDate() + 1); + + const lenParts = lengthStr.split(':').map(Number); + let lenSec = 0; + if (lenParts.length === 3) lenSec = lenParts[0] * 3600 + lenParts[1] * 60 + lenParts[2]; + else if (lenParts.length === 2) lenSec = lenParts[0] * 60 + lenParts[1]; + else lenSec = Number(lenParts[0]) || 0; + + const msUntilEnd = startDate.getTime() + lenSec * 1000 - nowDate.getTime(); + if (msUntilEnd > 1000) { + const entry = currentSongPollers.get(key); + if (entry?.intervalId) try { clearInterval(entry.intervalId); } catch (e) {} + if (entry?.timeoutId) try { clearTimeout(entry.timeoutId); } catch (e) {} + + const timeoutId = setTimeout(async () => { + try { await fetchAndStoreCurrentSong(station, idx, url); } catch (e) { /* ignore */ } + finally { if (currentIndex === idx) startCurrentSongPollers(); } + }, msUntilEnd + 250); + + currentSongPollers.set(key, { intervalId: null, timeoutId }); + } + } + } + } catch (e) { + console.debug('Failed scheduling next-song fetch', e); + } + } + } catch (e) { + console.debug('currentSong fetch failed for', url, e.message || e); + } +} + +function updateNowPlayingUI() { + const station = stations[currentIndex]; + if (!station) return; + if (nowPlayingEl && nowArtistEl && nowTitleEl) { + if (station.currentSongInfo?.artist && station.currentSongInfo?.title) { + nowArtistEl.textContent = station.currentSongInfo.artist; + nowTitleEl.textContent = station.currentSongInfo.title; + nowPlayingEl.classList.remove('hidden'); + } else { + nowArtistEl.textContent = ''; + nowTitleEl.textContent = ''; + nowPlayingEl.classList.add('hidden'); + } + } + if (stationSubtitleEl) stationSubtitleEl.textContent = 'Live Stream'; +} + +// ── Load station UI ─────────────────────────────────────────────────────────── + +function loadStation(index) { + if (index < 0 || index >= stations.length) return; + const station = stations[index]; + + applyStationTheme(station); + + if (stationNameEl) stationNameEl.textContent = station.name; + if (stationSubtitleEl) stationSubtitleEl.textContent = 'Live Stream'; + if (nowPlayingEl) nowPlayingEl.classList.add('hidden'); + if (nowArtistEl) nowArtistEl.textContent = ''; + if (nowTitleEl) nowTitleEl.textContent = ''; + + try { + if (logoTextEl && station.name) { + logoTextEl.textContent = String(station.name).trim(); + logoTextEl.classList.add('logo-name'); + } + + const rawLogo = station.logo || station.raw?.logo || ''; + const rawPoster = station.raw?.poster || station.poster || ''; + + if (logoImgEl) { + logoImgEl.classList.add('hidden'); + if (logoTextEl) logoTextEl.classList.remove('hidden'); + + const candidates = uniqueNonEmpty([ + toHttpsIfHttp(rawLogo), rawLogo, + toHttpsIfHttp(rawPoster), rawPoster, + ]); + + setImgWithFallback(logoImgEl, candidates, () => { + logoImgEl.classList.add('hidden'); + if (logoTextEl) logoTextEl.classList.remove('hidden'); + }); + + logoImgEl.onload = () => { + logoImgEl.classList.remove('hidden'); + if (logoTextEl) logoTextEl.classList.add('hidden'); + }; + } + } catch (e) { /* non-fatal */ } + + try { updateCoverflowTransforms(); } catch (e) {} + try { startCurrentSongPollers(); } catch (e) {} +} + +// ── Google Cast initialisation ──────────────────────────────────────────────── + +// Called by the Cast SDK once it has loaded (window.__onGCastApiAvailable callback). +function initCast() { + if (castInitialized) { + updateCastButtonUI(); + return; + } + + try { + castInitialized = true; + castContext = cast.framework.CastContext.getInstance(); + castContext.setOptions({ + receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID, + autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED, + }); + + const remotePlayer = new cast.framework.RemotePlayer(); + castPlayerController = new cast.framework.RemotePlayerController(remotePlayer); + + // Sync volume slider with Cast session volume + castPlayerController.addEventListener( + cast.framework.RemotePlayerEventType.VOLUME_LEVEL_CHANGED, + () => { + if (castMode !== 'cast') return; + const vol = Math.round(remotePlayer.volumeLevel * 100); + if (volumeSlider) volumeSlider.value = String(vol); + if (volumeValue) volumeValue.textContent = `${vol}%`; + currentVolume = remotePlayer.volumeLevel; + } + ); + + // Track Cast session state changes + castContext.addEventListener( + cast.framework.CastContextEventType.SESSION_STATE_CHANGED, + (ev) => { + const SS = cast.framework.SessionState; + if (ev.sessionState === SS.SESSION_STARTED || ev.sessionState === SS.SESSION_RESUMED) { + castMode = 'cast'; + updateEngineBadge(); + updateCastButtonUI(); + updateCastOutputToggleUI(); + if (isPlaying) { + // Hand off to Cast; local audio continues only in castBothMode + if (!castBothMode) { + audio.pause(); + audio.src = ''; + } + castPlayCurrent(); + } + } else if (ev.sessionState === SS.SESSION_ENDED || ev.sessionState === SS.SESSION_START_FAILED) { + castMode = 'local'; + updateEngineBadge(); + updateCastButtonUI(); + updateCastOutputToggleUI(); + updateUI(); + } + } + ); + + console.log('Cast SDK initialised'); + updateCastButtonUI(); + } catch (e) { + castInitialized = false; + console.warn('Cast init failed:', e); + updateCastButtonUI(); + } +} + +// The Cast SDK calls this global when it is ready. +window['__onGCastApiAvailable'] = (isAvailable) => { + if (isAvailable) initCast(); + else updateCastButtonUI(); +}; + +if (window.cast?.framework && window.chrome?.cast) { + initCast(); +} + +function updateCastButtonUI() { + if (!castBtn) return; + const sdkReady = !!castContext; + castBtn.classList.toggle('cast-ready', sdkReady); + castBtn.classList.toggle('cast-active', castMode === 'cast'); + castBtn.setAttribute('aria-pressed', castMode === 'cast' ? 'true' : 'false'); + castBtn.title = sdkReady ? 'Cast to device' : 'Cast is not ready yet'; +} + +async function requestCastSession() { + if (!castContext) { + if (statusTextEl) statusTextEl.textContent = 'Cast not available'; + updateCastButtonUI(); + return; + } + + try { + await castContext.requestSession(); + } catch (e) { + console.debug('Cast session request failed:', e); + } finally { + updateCastButtonUI(); + } +} + +async function castPlayCurrent() { + try { + const session = castContext?.getCurrentSession(); + if (!session) return; + + const station = stations[currentIndex]; + if (!station) return; + + const streamUrl = toHttpsIfHttp(station.url) || station.url; + const contentType = streamUrl.includes('.ogg') ? 'audio/ogg' : 'audio/mpeg'; + + const mediaInfo = new chrome.cast.media.MediaInfo(streamUrl, contentType); + mediaInfo.streamType = chrome.cast.media.StreamType.LIVE; + const meta = new chrome.cast.media.MusicTrackMediaMetadata(); + meta.title = station.name; + meta.artist = station.currentSongInfo?.artist ?? ''; + meta.songName = station.currentSongInfo?.title ?? ''; + if (station.logo) meta.images = [new chrome.cast.Image(toHttpsIfHttp(station.logo) || station.logo)]; + mediaInfo.metadata = meta; + + const request = new chrome.cast.media.LoadRequest(mediaInfo); + await session.loadMedia(request); + + isPlaying = true; + if (statusTextEl) statusTextEl.textContent = 'Casting...'; + if (statusDotEl) statusDotEl.style.backgroundColor = 'var(--success)'; + if (stationSubtitleEl) stationSubtitleEl.textContent = `Casting to ${session.getCastDevice().friendlyName}`; + updateUI(); + } catch (e) { + console.error('Cast play failed:', e); + if (statusTextEl) statusTextEl.textContent = 'Cast error — playing locally'; + castMode = 'local'; + updateEngineBadge(); + playLocal(); + } +} + +function updateEngineBadge() { + if (!engineBadgeEl) return; + if (castMode === 'cast') { + engineBadgeEl.className = 'engine-badge engine-cast'; + engineBadgeEl.title = 'Google Cast playback'; + engineBadgeEl.innerHTML = `CAST`; + } else { + engineBadgeEl.className = 'engine-badge engine-html'; + engineBadgeEl.title = 'HTML5 Audio playback'; + engineBadgeEl.innerHTML = `HTML5`; + } +} + +// ── Playback ────────────────────────────────────────────────────────────────── + +async function playLocal() { + const station = stations[currentIndex]; + if (!station) return; + + if (statusTextEl) statusTextEl.textContent = 'Buffering...'; + if (statusDotEl) statusDotEl.style.backgroundColor = 'var(--text-muted)'; + + const streamUrl = toHttpsIfHttp(station.url) || station.url; + audio.src = streamUrl; + audio.volume = isMuted ? 0 : currentVolume; + audio.load(); + + try { + await audio.play(); + isPlaying = true; + updateUI(); + } catch (e) { + console.warn('audio.play() failed:', e.message); + if (statusTextEl) statusTextEl.textContent = 'Click play to start'; + isPlaying = false; + updateUI(); + } +} + +async function play() { + const station = stations[currentIndex]; + if (!station) return; + + if (castMode === 'cast') { + // Only kill local audio if NOT in both-mode + if (!castBothMode) { + audio.pause(); + audio.src = ''; + } + await castPlayCurrent(); + // In both-mode also start local playback + if (castBothMode) { + playLocal(); // intentionally not awaited — fire-and-forget alongside cast + } + } else { + await playLocal(); + } +} + +async function stop() { + if (castMode === 'cast') { + try { + const session = castContext?.getCurrentSession(); + const media = session?.getMediaSession(); + if (media) { + media.stop(new chrome.cast.media.StopRequest(), () => {}, () => {}); + } + } catch (e) { console.warn('Cast stop error:', e); } + } + // Always stop local audio + audio.pause(); + audio.src = ''; + isPlaying = false; + updateUI(); +} + +async function togglePlay() { + if (isPlaying) await stop(); + else await play(); +} + +async function playNext() { + if (stations.length === 0) return; + await setStationByIndex((currentIndex + 1) % stations.length); +} + +async function playPrev() { + if (stations.length === 0) return; + await setStationByIndex((currentIndex - 1 + stations.length) % stations.length); +} + +function updateUI() { + if (isPlaying) { + if (iconPlay) iconPlay.classList.add('hidden'); + if (iconStop) iconStop.classList.remove('hidden'); + if (playBtn) playBtn.classList.add('playing'); + if (statusTextEl && statusTextEl.textContent === 'Ready') statusTextEl.textContent = 'Playing'; + if (statusDotEl) statusDotEl.style.backgroundColor = 'var(--success)'; + if (stationSubtitleEl && castMode !== 'cast') stationSubtitleEl.textContent = 'Live Stream'; + } else { + if (iconPlay) iconPlay.classList.remove('hidden'); + if (iconStop) iconStop.classList.add('hidden'); + if (playBtn) playBtn.classList.remove('playing'); + if (statusTextEl) statusTextEl.textContent = 'Ready'; + if (statusDotEl) statusDotEl.style.backgroundColor = 'var(--text-muted)'; + if (stationSubtitleEl) stationSubtitleEl.textContent = 'Live Stream'; + } + updateEngineBadge(); + updateCastOutputToggleUI(); + updateCastButtonUI(); +} + +// ── Volume / Mute ───────────────────────────────────────────────────────────── + +function handleVolumeInput() { + const val = Number(volumeSlider.value); + if (volumeValue) volumeValue.textContent = `${val}%`; + currentVolume = val / 100; + if (!isMuted) audio.volume = currentVolume; + // Sync volume to active Cast session + if (castMode === 'cast') { + try { + const session = castContext?.getCurrentSession(); + if (session) session.setVolume(currentVolume, () => {}, () => {}); + } catch (e) { /* ignore */ } + } + saveVolumeToStorage(val); +} + +function toggleMute() { + isMuted = !isMuted; + audio.volume = isMuted ? 0 : currentVolume; + if (iconVolume) iconVolume.classList.toggle('hidden', isMuted); + if (iconMuted) iconMuted.classList.toggle('hidden', !isMuted); +} + +// ── Stations overlay ────────────────────────────────────────────────────────── + +async function openStationsOverlay() { + if (!castOverlay || !deviceListEl) return; + castOverlay.classList.remove('hidden'); + castOverlay.setAttribute('aria-hidden', 'false'); + deviceListEl.classList.add('stations-grid'); + deviceListEl.innerHTML = ''; + + const titleEl = document.getElementById('deviceTitle'); + if (titleEl) titleEl.textContent = 'Stations'; + + if (!stations || stations.length === 0) { + deviceListEl.classList.remove('stations-grid'); + deviceListEl.innerHTML = '
  • No stations found
    Check stations.json
  • '; + return; + } + + for (let idx = 0; idx < stations.length; idx++) { + const s = stations[idx]; + const li = document.createElement('li'); + li.className = 'station-card' + (currentIndex === idx ? ' selected' : ''); + + const logoUrl = s.logo || s.raw?.logo || s.raw?.poster || ''; + const title = s.name || s.title || s.id || 'Station'; + const subtitle = s.raw?.www || s.id || ''; + + const left = document.createElement('div'); + left.className = 'station-card-left'; + + if (logoUrl) { + const img = document.createElement('img'); + img.className = 'station-card-logo'; + img.alt = `${title} logo`; + img.referrerPolicy = 'no-referrer'; + const fallback = document.createElement('div'); + fallback.className = 'station-card-fallback'; + fallback.textContent = title.charAt(0).toUpperCase(); + img.onerror = () => { left.replaceChild(fallback, img); }; + img.src = toHttpsIfHttp(logoUrl) || logoUrl; + left.appendChild(img); + } else { + const fb = document.createElement('div'); + fb.className = 'station-card-fallback'; + fb.textContent = title.charAt(0).toUpperCase(); + left.appendChild(fb); + } + + const body = document.createElement('div'); + body.className = 'station-card-body'; + const tEl = document.createElement('div'); + tEl.className = 'station-card-title'; + tEl.textContent = title; + const sEl = document.createElement('div'); + sEl.className = 'station-card-sub'; + sEl.textContent = subtitle; + body.appendChild(tEl); + body.appendChild(sEl); + + li.appendChild(left); + li.appendChild(body); + + li.onclick = async () => { + closeCastOverlay(); + await setStationByIndex(idx); + try { await play(); } catch (e) { console.error('Failed to play station from grid', e); } + }; + + deviceListEl.appendChild(li); + } +} + +function closeCastOverlay() { + if (!castOverlay) return; + castOverlay.classList.add('hidden'); + castOverlay.setAttribute('aria-hidden', 'true'); + const titleEl = document.getElementById('deviceTitle'); + if (titleEl) titleEl.textContent = 'Stations'; + if (deviceListEl) deviceListEl.classList.remove('stations-grid'); +} + +// ── Editor overlay ──────────────────────────────────────────────────────────── + +function openEditorOverlay() { + renderUserStationsList(); + if (editorOverlay) { editorOverlay.classList.remove('hidden'); editorOverlay.setAttribute('aria-hidden', 'false'); } +} + +function closeEditorOverlay() { + if (editorOverlay) { editorOverlay.classList.add('hidden'); editorOverlay.setAttribute('aria-hidden', 'true'); } + if (addStationForm) addStationForm.reset(); + if (usIndex) usIndex.value = ''; +} + +function renderUserStationsList() { + const list = loadUserStations(); + if (!editorListEl) return; + editorListEl.innerHTML = ''; + if (!list || list.length === 0) { + editorListEl.innerHTML = '
  • No user stations
    Add your stream using the form below
  • '; + return; + } + + list.forEach((s, idx) => { + const li = document.createElement('li'); + li.className = 'device'; + const main = s.title || s.name || s.id || 'User Station'; + const sub = s.url || ''; + li.innerHTML = `
    +
    +
    ${escapeHtml(main)}
    +
    ${escapeHtml(sub)}
    +
    +
    + + +
    +
    `; + editorListEl.appendChild(li); + }); + + editorListEl.querySelectorAll('.edit-btn').forEach((b) => { + b.addEventListener('click', () => editUserStation(Number(b.getAttribute('data-idx')))); + }); + editorListEl.querySelectorAll('.delete-btn').forEach((b) => { + b.addEventListener('click', () => deleteUserStation(Number(b.getAttribute('data-idx')))); + }); +} + +function escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function editUserStation(idx) { + const list = loadUserStations(); + const s = list[idx]; + if (!s) return; + if (usTitle) usTitle.value = s.title || s.name || ''; + if (usUrl) usUrl.value = s.url || s.liveAudio || ''; + if (usLogo) usLogo.value = s.logo || ''; + if (usWww) usWww.value = s.www || s.website || ''; + if (usId) usId.value = s.id || ''; + if (usIndex) usIndex.value = String(idx); +} + +function deleteUserStation(idx) { + const list = loadUserStations(); + list.splice(idx, 1); + saveUserStations(list); + loadStations(); + renderUserStationsList(); +} + +addStationForm?.addEventListener('submit', (e) => { + e.preventDefault(); + const list = loadUserStations(); + + // Validate URL before saving + const urlValue = usUrl?.value.trim() ?? ''; + try { new URL(urlValue); } catch (_) { + if (statusTextEl) statusTextEl.textContent = 'Invalid stream URL'; + return; + } + + const station = { + id: usId?.value || `user-${Date.now()}`, + title: usTitle?.value.trim() ?? '', + url: urlValue, + logo: usLogo?.value.trim() ?? '', + www: usWww?.value.trim() ?? '', + enabled: true, + }; + + const idx = usIndex?.value === '' ? -1 : Number(usIndex?.value); + if (idx >= 0 && idx < list.length) list[idx] = station; + else list.push(station); + + saveUserStations(list); + renderUserStationsList(); + loadStations(); + if (addStationForm) addStationForm.reset(); + if (usIndex) usIndex.value = ''; +}); + +// ── Artwork pointer fallback ────────────────────────────────────────────────── + +function ensureArtworkPointerFallback() { + try { + if (artworkPlaceholder) artworkPlaceholder.style.cursor = 'pointer'; + } catch (e) { /* ignore */ } +} + +// ── Event listeners ─────────────────────────────────────────────────────────── + +function setupEventListeners() { + playBtn?.addEventListener('click', togglePlay); + prevBtn?.addEventListener('click', playPrev); + nextBtn?.addEventListener('click', playNext); + volumeSlider?.addEventListener('input', handleVolumeInput); + muteBtn?.addEventListener('click', toggleMute); + + closeOverlayBtn?.addEventListener('click', closeCastOverlay); + castOverlay?.addEventListener('click', (e) => { if (e.target === castOverlay) closeCastOverlay(); }); + + editBtn?.addEventListener('click', openEditorOverlay); + stationsListBtn?.addEventListener('click', openStationsOverlay); + installAppBtn?.addEventListener('click', promptInstallApp); + castBtn?.addEventListener('click', requestCastSession); + editorCloseBtn?.addEventListener('click', closeEditorOverlay); + + artworkPlaceholder?.addEventListener('click', openStationsOverlay); + castOutputBtn?.addEventListener('click', toggleCastBothMode); + window.addEventListener('resize', updateCoverflowTransforms); + + // Keyboard shortcuts + document.addEventListener('keydown', (e) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + if (e.code === 'Space') { e.preventDefault(); togglePlay(); } + else if (e.code === 'ArrowRight') playNext(); + else if (e.code === 'ArrowLeft') playPrev(); + else if (e.code === 'KeyM') toggleMute(); + }); +} + +async function promptInstallApp() { + if (!deferredInstallPrompt) return; + deferredInstallPrompt.prompt(); + try { await deferredInstallPrompt.userChoice; } catch (e) { /* ignore */ } + deferredInstallPrompt = null; + installAppBtn?.classList.add('hidden'); +} + +// ── Media Session API (for OS media controls / lock screen) ────────────────── + +function updateMediaSession() { + if (!('mediaSession' in navigator)) return; + const station = stations[currentIndex]; + if (!station) return; + try { + navigator.mediaSession.metadata = new MediaMetadata({ + title: station.name, + artist: station.currentSongInfo?.artist ?? '', + album: 'Live Radio', + artwork: station.logo ? [{ src: toHttpsIfHttp(station.logo), sizes: '512x512' }] : [], + }); + navigator.mediaSession.setActionHandler('play', () => play()); + navigator.mediaSession.setActionHandler('pause', () => stop()); + navigator.mediaSession.setActionHandler('previoustrack', () => playPrev()); + navigator.mediaSession.setActionHandler('nexttrack', () => playNext()); + } catch (e) { /* ignore */ } +} + +// ── Init ────────────────────────────────────────────────────────────────────── + +let hasInitialized = false; + +async function init() { + if (hasInitialized) return; + hasInitialized = true; + + try { + console.group('RadioPlayer init'); + console.log('location:', location.href); + console.log('userAgent:', navigator.userAgent); + console.groupEnd(); + + restoreSavedVolume(); + restoreCastBothMode(); + await loadStations(); + setupEventListeners(); + ensureArtworkPointerFallback(); + updateUI(); + + // Update Media Session when station or song changes + audio.addEventListener('playing', updateMediaSession); + } catch (e) { + console.error('Error during init', e); + if (statusTextEl) statusTextEl.textContent = 'Init error: ' + (e?.message ?? String(e)); + } +} + +// ── Service Worker registration (PWA) ──────────────────────────────────────── + +if ('serviceWorker' in navigator && import.meta.env.DEV) { + window.addEventListener('load', () => { + navigator.serviceWorker.getRegistrations() + .then((registrations) => Promise.all(registrations.map((reg) => reg.unregister()))) + .catch((err) => console.debug('ServiceWorker dev cleanup failed:', err)); + + if ('caches' in window) { + caches.keys() + .then((keys) => Promise.all(keys.map((key) => caches.delete(key)))) + .catch((err) => console.debug('Cache dev cleanup failed:', err)); + } + }); +} else if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('sw.js') + .then((reg) => console.log('ServiceWorker registered:', reg.scope)) + .catch((err) => console.debug('ServiceWorker registration failed:', err)); + }); +} + +window.addEventListener('beforeinstallprompt', (event) => { + event.preventDefault(); + deferredInstallPrompt = event; + installAppBtn?.classList.remove('hidden'); +}); + +window.addEventListener('appinstalled', () => { + deferredInstallPrompt = null; + installAppBtn?.classList.add('hidden'); +}); + +export function initPlayer() { + return init(); +} diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..965ecb3 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,1372 @@ +:root { + color-scheme: dark; + --page-bg: #111318; + --panel: rgba(22, 27, 34, 0.82); + --panel-strong: rgba(16, 20, 27, 0.94); + --panel-soft: rgba(255, 255, 255, 0.06); + --border: rgba(255, 255, 255, 0.12); + --border-strong: rgba(255, 255, 255, 0.2); + --text-main: #f7f8fb; + --text-muted: rgba(247, 248, 251, 0.68); + --text-soft: rgba(247, 248, 251, 0.5); + --accent: #4dd7c8; + --accent-2: #ffb45c; + --accent-3: #8fb3ff; + --accent-rgb: 77, 215, 200; + --accent-2-rgb: 255, 180, 92; + --accent-3-rgb: 143, 179, 255; + --theme-page-rgb: 17, 19, 24; + --theme-panel-rgb: 16, 20, 27; + --theme-panel: #10141b; + --accent-glow: rgba(77, 215, 200, 0.34); + --danger: #ff7087; + --success: #84f2a8; + --shadow: 0 26px 70px rgba(0, 0, 0, 0.38); + --radius: 18px; +} + +* { + box-sizing: border-box; +} + +html { + min-height: 100%; + background: var(--page-bg); + font-family: Inter, "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif; +} + +body { + margin: 0; + min-height: 100vh; + width: 100%; + color: var(--text-main); + background: + radial-gradient(circle at 18% 8%, rgba(var(--accent-rgb), 0.28), transparent 34%), + radial-gradient(circle at 86% 82%, rgba(var(--accent-2-rgb), 0.22), transparent 32%), + linear-gradient(180deg, rgba(var(--theme-page-rgb), 1) 0%, #101218 58%, rgba(var(--theme-panel-rgb), 0.95) 100%); + overflow-x: hidden; + transition: background 0.45s ease; +} + +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(rgba(255, 255, 255, 0.045) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px); + background-size: 42px 42px; + mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.62), transparent 78%); + animation: grid-drift 18s linear infinite; +} + +body::after { + content: ""; + position: fixed; + inset: -28vmax; + pointer-events: none; + background: + radial-gradient(circle at 24% 32%, rgba(var(--accent-rgb), 0.22), transparent 22vmax), + radial-gradient(circle at 78% 64%, rgba(var(--accent-2-rgb), 0.18), transparent 24vmax), + radial-gradient(circle at 52% 82%, rgba(var(--accent-3-rgb), 0.16), transparent 20vmax); + filter: blur(28px); + opacity: 0.88; + transform: translate3d(0, 0, 0); + animation: ambient-drift 22s ease-in-out infinite alternate; +} + +@keyframes grid-drift { + from { + background-position: 0 0, 0 0; + } + to { + background-position: 42px 42px, -42px 42px; + } +} + +@keyframes ambient-drift { + 0% { + transform: translate3d(-1.5%, -1%, 0) scale(1); + } + 45% { + transform: translate3d(2%, 1.5%, 0) scale(1.035); + } + 100% { + transform: translate3d(-0.5%, 2%, 0) scale(1.07); + } +} + +button, +input { + font: inherit; +} + +button { + cursor: pointer; +} + +input { + cursor: text; + user-select: text; +} + +button:focus-visible, +input:focus-visible, +.station-card:focus-visible, +.coverflow-item:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; +} + +::-webkit-scrollbar { + width: 10px; +} + +::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.18); + border: 3px solid transparent; + border-radius: 999px; + background-clip: content-box; +} + +.app-container { + position: relative; + z-index: 1; + width: 100%; + max-width: 100vw; + min-height: 100vh; + padding: clamp(14px, 3vw, 40px); + display: grid; + place-items: center; +} + +.starfield { + position: absolute; + inset: 0; + z-index: 1; + overflow: hidden; + pointer-events: none; + perspective: 760px; + perspective-origin: 50% 48%; + opacity: 1; + border-radius: inherit; + background: + radial-gradient(circle at 50% 48%, rgba(var(--accent-rgb), 0.13), transparent 34%), + radial-gradient(circle at 76% 68%, rgba(var(--accent-2-rgb), 0.10), transparent 28%); + mask-image: radial-gradient(ellipse at center, rgba(0,0,0,0.98), rgba(0,0,0,0.88) 64%, transparent 98%); +} + +.starfield-plane { + position: absolute; + inset: -8%; + transform-style: preserve-3d; + animation: starfield-tilt 18s ease-in-out infinite alternate; +} + +.star { + position: absolute; + left: 50%; + top: 50%; + width: var(--star-size); + height: var(--star-size); + border-radius: 50%; + background: rgba(255,255,255,0.92); + box-shadow: + 0 0 12px rgba(255,255,255,0.72), + 0 0 28px rgba(var(--accent-rgb),0.52); + opacity: 0; + transform-style: preserve-3d; + animation: star-tunnel var(--star-duration) linear infinite; + animation-delay: var(--star-delay); +} + +.star::after { + content: ""; + position: absolute; + left: 50%; + top: 50%; + width: calc(var(--star-size) * 7); + height: 1px; + border-radius: 999px; + background: linear-gradient(90deg, currentColor, transparent); + opacity: 0.38; + transform: translate(-100%, -50%); +} + +.star:nth-child(3n) { + color: rgba(var(--accent-rgb),0.95); + background: rgba(var(--accent-rgb),0.86); + box-shadow: 0 0 16px rgba(var(--accent-rgb),0.76); +} + +.star:nth-child(4n) { + color: rgba(var(--accent-2-rgb),0.9); + background: rgba(var(--accent-2-rgb),0.72); + box-shadow: 0 0 16px rgba(var(--accent-2-rgb),0.58); +} + +.star:nth-child(7n) { + filter: blur(1px); +} + +@keyframes starfield-tilt { + from { + transform: rotateX(7deg) rotateY(-10deg) translateX(-1.2%); + } + to { + transform: rotateX(-4deg) rotateY(11deg) translateX(1.5%); + } +} + +@keyframes star-tunnel { + 0% { + opacity: 0; + transform: translate3d(var(--x0), var(--y0), -680px) scale(0.22); + } + 12% { + opacity: 0.36; + } + 58% { + opacity: 0.92; + } + 100% { + opacity: 0; + transform: translate3d(var(--x1), var(--y1), 260px) scale(1.65); + } +} + +.glass-card { + position: relative; + z-index: 1; + overflow: hidden; + width: min(1060px, 100%); + max-width: 100%; + min-width: 0; + min-height: min(720px, calc(100vh - 48px)); + display: grid; + grid-template-columns: minmax(300px, 0.92fr) minmax(340px, 1.08fr); + grid-template-areas: + "header header" + "artwork info" + "artwork progress" + "artwork controls" + "artwork volume"; + gap: 18px 28px; + align-items: center; + padding: clamp(18px, 3vw, 34px); + border: 1px solid var(--border); + border-radius: 28px; + background: + linear-gradient(145deg, rgba(var(--accent-rgb), 0.1), transparent 42%), + linear-gradient(315deg, rgba(var(--accent-2-rgb), 0.08), transparent 36%), + linear-gradient(180deg, rgba(var(--theme-panel-rgb), 0.72), rgba(255,255,255,0.03)); + box-shadow: var(--shadow); + backdrop-filter: blur(26px) saturate(135%); + transition: background 0.45s ease, border-color 0.45s ease; +} + +.glass-card > :not(.starfield) { + position: relative; + z-index: 2; +} + +header { + grid-area: header; +} + +.header-top-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; +} + +.brand-block { + min-width: 0; + display: flex; + align-items: center; + gap: 12px; +} + +.brand-logo { + width: 46px; + height: 46px; + flex: 0 0 46px; + border-radius: 14px; + box-shadow: 0 10px 24px rgba(var(--accent-rgb), 0.18); +} + +.brand-copy { + min-width: 0; + display: flex; + align-items: baseline; + gap: 14px; +} + +.app-title { + min-width: 0; + font-size: clamp(1rem, 2vw, 1.18rem); + font-weight: 800; + letter-spacing: 0; + color: var(--text-main); +} + +.datetime { + display: inline-flex; + align-items: baseline; + gap: 8px; + color: var(--text-muted); + white-space: nowrap; +} + +.datetime-time { + color: var(--text-main); + font-size: 0.98rem; + font-weight: 850; +} + +.datetime-date { + font-size: 0.82rem; + font-weight: 700; +} + +.header-icons-left { + order: 2; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.header-close { + display: none; +} + +.icon-btn { + width: 42px; + height: 42px; + padding: 0; + border: 1px solid var(--border); + border-radius: 12px; + color: var(--text-main); + background: rgba(255, 255, 255, 0.065); + display: inline-flex; + align-items: center; + justify-content: center; + transition: transform 0.16s ease, background 0.16s ease, border-color 0.16s ease; + -webkit-app-region: no-drag; +} + +.icon-btn:hover { + transform: translateY(-2px); + border-color: var(--border-strong); + background: rgba(255, 255, 255, 0.11); +} + +.icon-btn.small { + width: 40px; + height: 40px; +} + +.artwork-section { + grid-area: artwork; + align-self: stretch; + display: flex; + align-items: center; + justify-content: center; + min-width: 0; +} + +.artwork-stack { + width: min(100%, 410px); + display: grid; + justify-items: center; + gap: 18px; +} + +.artwork-container { + width: min(100%, 360px); + aspect-ratio: 1; + padding: 10px; + border-radius: 32px; + background: linear-gradient(145deg, rgba(255,255,255,0.16), rgba(255,255,255,0.04)); + border: 1px solid var(--border); + box-shadow: 0 28px 72px rgba(0,0,0,0.36); +} + +.artwork-placeholder { + position: relative; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + overflow: hidden; + border-radius: 24px; + cursor: pointer; + background: + linear-gradient(135deg, rgba(var(--accent-rgb), 0.9), rgba(var(--accent-3-rgb), 0.72) 48%, rgba(var(--accent-2-rgb), 0.86)); + box-shadow: inset 0 0 55px rgba(0, 0, 0, 0.26); + transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.45s ease; +} + +.artwork-placeholder::before { + content: ""; + position: absolute; + inset: 0; + background: + linear-gradient(120deg, rgba(255,255,255,0.26), transparent 34%), + repeating-linear-gradient(135deg, rgba(255,255,255,0.05) 0 1px, transparent 1px 12px); + opacity: 0.62; + animation: artwork-sheen 9s ease-in-out infinite alternate; +} + +@keyframes artwork-sheen { + from { + transform: translateX(-3%) translateY(-2%); + opacity: 0.5; + } + to { + transform: translateX(4%) translateY(3%); + opacity: 0.72; + } +} + +.artwork-placeholder:hover { + transform: translateY(-2px); + box-shadow: inset 0 0 55px rgba(0, 0, 0, 0.2), 0 18px 42px rgba(var(--accent-rgb), 0.18); +} + +.station-logo-img { + position: relative; + z-index: 2; + width: 88%; + height: 88%; + object-fit: contain; + padding: 14px; + border-radius: 18px; + cursor: pointer; +} + +.station-logo-text { + position: relative; + z-index: 2; + max-width: 88%; + color: var(--text-main); + font-size: clamp(2.2rem, 8vw, 4.2rem); + font-weight: 850; + line-height: 1; + text-align: center; + text-shadow: 0 8px 22px rgba(0, 0, 0, 0.36); + cursor: pointer; +} + +.station-logo-text.logo-name { + font-size: clamp(1.5rem, 4.8vw, 2.8rem); + line-height: 1.05; + overflow: hidden; + display: -webkit-box; + line-clamp: 3; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; +} + +.artwork-coverflow { + position: relative; + width: min(100%, 430px); + height: 128px; + overflow: hidden; + -webkit-app-region: no-drag; +} + +.artwork-coverflow-stage { + position: absolute; + inset: 0 44px; + z-index: 1; + perspective: 900px; + transform-style: preserve-3d; +} + +.coverflow-item { + position: absolute; + left: 50%; + top: 50%; + width: 92px; + height: 82px; + border-radius: 20px; + background: rgba(255,255,255,0.08); + border: 1px solid var(--border); + box-shadow: 0 12px 28px rgba(0,0,0,0.28); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + transform-style: preserve-3d; + cursor: pointer; + transition: border-color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease, opacity 0.16s ease; + will-change: transform, opacity; +} + +.coverflow-item.selected { + background: rgba(var(--accent-rgb), 0.16); + border-color: rgba(var(--accent-rgb), 0.48); + box-shadow: 0 14px 34px rgba(var(--accent-rgb), 0.18); +} + +.coverflow-item:not(.selected):hover { + background: rgba(255,255,255,0.12); + border-color: rgba(255,255,255,0.28); +} + +.coverflow-item img { + width: 100%; + height: 100%; + object-fit: contain; + padding: 10px; +} + +.coverflow-item.fallback { + padding: 10px; + color: var(--text-main); + font-size: 0.86rem; + font-weight: 800; + line-height: 1.08; + text-align: center; +} + +.coverflow-arrow { + position: absolute; + top: 50%; + z-index: 3; + width: 38px; + height: 38px; + border-radius: 999px; + border: 1px solid var(--border); + background: rgba(12, 15, 20, 0.68); + color: var(--text-main); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.6rem; + line-height: 1; + transform: translateY(-50%); +} + +.coverflow-arrow.left { left: 0; } +.coverflow-arrow.right { right: 0; } + +.track-info { + grid-area: info; + min-width: 0; + min-height: 232px; + display: flex; + flex-direction: column; + justify-content: end; + align-items: flex-start; + text-align: left; +} + +.track-info h2 { + width: 100%; + margin: 0; + font-size: clamp(2.1rem, 5vw, 4.6rem); + font-weight: 850; + line-height: 0.96; + letter-spacing: 0; + overflow-wrap: anywhere; +} + +#now-playing { + width: 100%; + min-height: 58px; + margin-top: 18px; +} + +#now-playing .now-artist, +#now-playing .now-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#now-playing .now-artist { + color: var(--accent); + font-size: clamp(1rem, 2vw, 1.18rem); + font-weight: 800; +} + +#now-playing .now-title { + margin-top: 3px; + color: var(--text-main); + font-size: clamp(0.96rem, 1.6vw, 1.08rem); + font-weight: 600; +} + +#now-playing.hidden { + visibility: hidden; +} + +.track-info p { + margin: 12px 0 0; + color: var(--text-muted); + font-size: 0.98rem; +} + +.status-indicator-wrap { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 9px; + margin-top: 14px; + color: var(--text-muted); + font-size: 0.88rem; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--success); + box-shadow: 0 0 12px var(--success); +} + +.engine-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 9px; + border-radius: 999px; + border: 1px solid var(--border); + background: rgba(255,255,255,0.06); + color: var(--text-main); + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0; + text-transform: uppercase; +} + +.engine-badge svg { + width: 13px; + height: 13px; +} + +.engine-cast { + border-color: rgba(143,179,255,0.48); + box-shadow: 0 0 14px rgba(143,179,255,0.12); +} + +.engine-html { + border-color: rgba(255,255,255,0.18); +} + +.cast-output-row { + display: flex; + align-items: center; + gap: 8px; + margin-top: 10px; +} + +.cast-output-label { + color: var(--text-soft); + font-size: 0.8rem; +} + +.cast-output-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 11px; + border-radius: 999px; + border: 1px solid var(--border); + background: rgba(255,255,255,0.07); + color: var(--text-main); + font-size: 0.82rem; + font-weight: 700; +} + +.cast-output-toggle[aria-pressed="true"] { + border-color: rgba(132,242,168,0.5); + background: rgba(132,242,168,0.12); + color: var(--success); +} + +.progress-container { + grid-area: progress; + width: 100%; + height: 10px; + padding: 3px; + border-radius: 999px; + background: rgba(255,255,255,0.08); + border: 1px solid rgba(255,255,255,0.08); +} + +.progress-bar { + position: relative; + width: 100%; + height: 100%; +} + +.progress-fill { + width: 100%; + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + box-shadow: 0 0 18px var(--accent-glow); +} + +.progress-handle { + position: absolute; + right: -1px; + top: 50%; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--text-main); + box-shadow: 0 0 20px rgba(255,255,255,0.52); + transform: translate(50%, -50%); +} + +.controls-section { + grid-area: controls; + display: grid; + grid-template-columns: 64px 96px 64px; + justify-content: start; + align-items: center; + gap: 18px; +} + +.control-btn { + border: none; + color: var(--text-main); + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.14s ease, background 0.14s ease, box-shadow 0.14s ease; +} + +.control-btn:hover { + transform: translateY(-2px); +} + +.control-btn:active { + transform: scale(0.96); +} + +.control-btn.secondary { + width: 64px; + height: 64px; + border-radius: 18px; + background: rgba(255,255,255,0.07); + border: 1px solid var(--border); +} + +.control-btn.primary { + width: 96px; + height: 96px; + border-radius: 28px; + background: linear-gradient(145deg, var(--accent), var(--accent-3)); + box-shadow: 0 18px 42px rgba(var(--accent-rgb), 0.24), inset 0 1px 0 rgba(255,255,255,0.28); +} + +.control-btn.primary.playing { + animation: pulse-ring 2s ease-in-out infinite; +} + +@keyframes pulse-ring { + 0%, 100% { + box-shadow: 0 18px 42px rgba(var(--accent-rgb), 0.24), 0 0 0 0 rgba(var(--accent-rgb), 0.34); + } + 50% { + box-shadow: 0 18px 42px rgba(var(--accent-rgb), 0.24), 0 0 0 10px rgba(var(--accent-rgb), 0); + } +} + +.icon-container { + position: relative; + width: 34px; + height: 34px; +} + +.icon-container svg { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.volume-section { + grid-area: volume; + display: grid; + grid-template-columns: 40px minmax(120px, 320px) 44px; + align-items: center; + gap: 12px; +} + +.slider-container { + min-width: 0; +} + +input[type=range] { + width: 100%; + height: 28px; + background: transparent; + -webkit-appearance: none; + appearance: none; + cursor: pointer; +} + +input[type=range]::-webkit-slider-runnable-track { + height: 7px; + border-radius: 999px; + background: rgba(255,255,255,0.16); +} + +input[type=range]::-webkit-slider-thumb { + width: 19px; + height: 19px; + margin-top: -6px; + border: 3px solid var(--page-bg); + border-radius: 50%; + background: var(--accent); + -webkit-appearance: none; + box-shadow: 0 0 0 1px rgba(255,255,255,0.24), 0 8px 16px rgba(0,0,0,0.24); +} + +#volume-value { + color: var(--text-muted); + font-size: 0.86rem; + font-weight: 700; + text-align: right; +} + +.hidden { + display: none !important; +} + +.overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: rgba(7, 10, 14, 0.68); + backdrop-filter: blur(18px); + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; +} + +.overlay:not(.hidden) { + opacity: 1; + pointer-events: auto; +} + +.modal { + width: min(860px, 100%); + max-height: min(760px, calc(100vh - 40px)); + display: flex; + flex-direction: column; + padding: 22px; + border-radius: 24px; + border: 1px solid var(--border); + background: var(--panel-strong); + box-shadow: var(--shadow); + color: var(--text-main); + animation: pop 0.2s ease; +} + +@keyframes pop { + from { transform: translateY(10px) scale(0.98); opacity: 0; } + to { transform: translateY(0) scale(1); opacity: 1; } +} + +.modal h2 { + margin: 0 0 16px; + font-size: clamp(1.18rem, 2.5vw, 1.5rem); + font-weight: 850; + letter-spacing: 0; +} + +.device-list { + list-style: none; + padding: 2px; + margin: 0 0 18px; + overflow-y: auto; +} + +.stations-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); + gap: 12px; +} + +.station-card, +.device { + list-style: none; + border: 1px solid rgba(255,255,255,0.1); + background: rgba(255,255,255,0.055); + transition: transform 0.14s ease, background 0.14s ease, border-color 0.14s ease; +} + +.station-card { + min-height: 88px; + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + border-radius: 16px; + cursor: pointer; +} + +.station-card:hover, +.device:hover { + transform: translateY(-2px); + border-color: var(--border-strong); + background: rgba(255,255,255,0.09); +} + +.station-card.selected { + border-color: rgba(var(--accent-rgb), 0.52); + background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.18), rgba(var(--accent-3-rgb), 0.12)); +} + +.station-card-left { + flex: 0 0 58px; + width: 58px; + height: 58px; + display: flex; + align-items: center; + justify-content: center; +} + +.station-card-logo, +.station-card-fallback { + width: 58px; + height: 58px; + border-radius: 14px; + background: rgba(255,255,255,0.08); +} + +.station-card-logo { + object-fit: contain; + padding: 8px; +} + +.station-card-fallback { + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; + font-weight: 850; +} + +.station-card-body { + min-width: 0; +} + +.station-card-title { + color: var(--text-main); + font-size: 0.98rem; + font-weight: 800; + line-height: 1.12; +} + +.station-card-sub { + margin-top: 5px; + color: var(--text-muted); + font-size: 0.82rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.device { + margin-bottom: 10px; + padding: 13px 14px; + border-radius: 14px; +} + +.device-main { + color: var(--text-main); + font-size: 0.94rem; + font-weight: 800; +} + +.device-sub { + margin-top: 4px; + color: var(--text-muted); + font-size: 0.78rem; + overflow-wrap: anywhere; +} + +.editor-station-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.editor-station-copy { + min-width: 0; +} + +.editor-station-actions, +.editor-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.field-row { + margin-bottom: 10px; +} + +.modal input, +.modal textarea, +.modal select { + width: 100%; + padding: 12px 13px; + border-radius: 12px; + border: 1px solid var(--border); + background: rgba(255,255,255,0.06); + color: var(--text-main); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.03); +} + +.modal input::placeholder, +.modal textarea::placeholder { + color: var(--text-soft); +} + +.btn { + min-height: 42px; + padding: 10px 16px; + border: 1px solid transparent; + border-radius: 12px; + color: var(--text-main); + background: rgba(255,255,255,0.09); + font-weight: 800; + transition: transform 0.14s ease, background 0.14s ease, border-color 0.14s ease; +} + +.btn:hover { + transform: translateY(-1px); + border-color: var(--border-strong); + background: rgba(255,255,255,0.13); +} + +.btn.cancel { + width: 100%; + color: #0c1819; + background: var(--accent); +} + +.btn.secondary { + background: rgba(255,255,255,0.08); +} + +.btn.edit-btn { + color: #071314; + background: var(--accent); +} + +.btn.delete-btn { + background: rgba(255, 112, 135, 0.16); + color: #ffd3db; + border-color: rgba(255, 112, 135, 0.25); +} + +#add-station-form { + border-top: 1px solid rgba(255,255,255,0.1); + padding-top: 16px; +} + +.cast-btn, +.install-btn { + width: auto; + min-width: 82px; + padding: 0 12px; + gap: 8px; +} + +.cast-btn span, +.install-btn span { + font-size: 0.86rem; + font-weight: 800; + line-height: 1; +} + +.cast-btn:not(.cast-ready) { + color: var(--text-soft); +} + +.cast-btn.cast-active, +.cast-btn.cast-ready:hover { + border-color: rgba(143,179,255,0.5); + background: rgba(143,179,255,0.14); +} + +@media (max-width: 760px) { + .app-container { + align-items: start; + padding: 10px; + } + + .glass-card { + min-height: calc(100vh - 20px); + width: 100%; + grid-template-columns: 1fr; + grid-template-areas: + "header" + "artwork" + "info" + "progress" + "controls" + "volume"; + gap: 13px; + padding: 16px; + border-radius: 22px; + } + + .brand-block { + gap: 9px; + } + + .brand-logo { + width: 40px; + height: 40px; + flex-basis: 40px; + border-radius: 12px; + } + + .brand-copy { + flex-direction: column; + align-items: flex-start; + gap: 2px; + } + + .app-title { + font-size: 1rem; + } + + .datetime { + gap: 7px; + } + + .datetime-time { + font-size: 0.92rem; + } + + .datetime-date { + font-size: 0.76rem; + } + + .header-icons-left { + gap: 6px; + } + + .icon-btn { + width: 40px; + height: 40px; + border-radius: 12px; + } + + .cast-btn, + .install-btn { + width: 40px; + min-width: 40px; + padding: 0; + } + + .cast-btn span, + .install-btn span { + display: none; + } + + .artwork-section { + align-self: auto; + } + + .artwork-stack { + width: 100%; + gap: 10px; + } + + .artwork-container { + width: min(72vw, 280px); + padding: 8px; + border-radius: 26px; + } + + .artwork-placeholder { + border-radius: 20px; + } + + .artwork-coverflow { + width: min(100%, calc(100vw - 42px)); + height: 92px; + margin-inline: auto; + } + + .artwork-coverflow-stage { + inset: 0 38px; + } + + .coverflow-item { + width: 72px; + height: 64px; + border-radius: 16px; + } + + .coverflow-item.fallback { + font-size: 0.74rem; + } + + .coverflow-arrow { + width: 36px; + height: 36px; + background: rgba(12, 15, 20, 0.78); + } + + .track-info { + min-height: 150px; + align-items: center; + justify-content: center; + text-align: center; + } + + .track-info h2 { + font-size: clamp(1.75rem, 10vw, 2.8rem); + } + + #now-playing { + min-height: 48px; + margin-top: 12px; + } + + .status-indicator-wrap, + .cast-output-row { + justify-content: center; + } + + .controls-section { + grid-template-columns: 56px 82px 56px; + justify-content: center; + gap: 16px; + } + + .control-btn.secondary { + width: 56px; + height: 56px; + border-radius: 17px; + } + + .control-btn.primary { + width: 82px; + height: 82px; + border-radius: 24px; + } + + .volume-section { + grid-template-columns: 40px minmax(0, 1fr) 44px; + padding-bottom: 4px; + } + + .overlay { + align-items: end; + padding: 0; + } + + .modal { + width: 100%; + max-height: 88vh; + border-radius: 22px 22px 0 0; + padding: 18px; + border-inline: none; + border-bottom: none; + } + + .stations-grid { + grid-template-columns: 1fr; + gap: 10px; + } + + .editor-station-row { + align-items: stretch; + flex-direction: column; + } + + .editor-station-actions, + .editor-actions { + width: 100%; + } + + .editor-station-actions .btn, + .editor-actions .btn { + flex: 1; + } +} + +@media (min-width: 761px) and (max-width: 980px) { + .glass-card { + grid-template-columns: minmax(260px, 0.9fr) minmax(300px, 1.1fr); + gap: 16px 22px; + } + + .artwork-container { + width: min(100%, 320px); + } + + .track-info h2 { + font-size: clamp(2rem, 5vw, 3.7rem); + } +} + +@media (max-width: 380px) { + .glass-card { + padding: 13px; + } + + .artwork-container { + width: min(74vw, 238px); + } + + .artwork-coverflow { + height: 80px; + } + + .coverflow-item { + width: 62px; + height: 56px; + font-size: 0.66rem; + } + + .controls-section { + grid-template-columns: 52px 76px 52px; + gap: 12px; + } + + .control-btn.secondary { + width: 52px; + height: 52px; + } + + .control-btn.primary { + width: 76px; + height: 76px; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; + transition-duration: 0.001ms !important; + } +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..cd743b6 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + base: './', + server: { + host: '127.0.0.1', + port: 5173, + strictPort: false, + proxy: { + '/radio-si-data': { + target: 'https://data.radio.si', + changeOrigin: true, + secure: true, + rewrite: (path) => path.replace(/^\/radio-si-data/, ''), + }, + }, + }, + preview: { + host: '127.0.0.1', + port: 4173, + strictPort: false, + }, +});