├── .env-example
├── .github
└── workflows
│ └── playwright.yml
├── .gitignore
├── LICENSE
├── README.md
├── components.json
├── eslint.config.js
├── index.html
├── package-lock.json
├── package.json
├── playwright.config.ts
├── public
├── apple-touch-icon.png
├── assets
│ ├── chromecast
│ │ ├── bg.jpg
│ │ ├── default.css
│ │ └── logo.png
│ ├── icons
│ │ ├── icon-192x192.png
│ │ ├── icon-512x512.png
│ │ └── icon.svg
│ ├── screenshots
│ │ ├── screenshot-01-home-wide.jpg
│ │ ├── screenshot-01-home-wide.png
│ │ ├── screenshot-01-home.jpg
│ │ ├── screenshot-01-home.png
│ │ ├── screenshot-02-radio-wide.jpg
│ │ ├── screenshot-02-radio-wide.png
│ │ ├── screenshot-02-radio.jpg
│ │ ├── screenshot-02-radio.png
│ │ ├── screenshot-03-radio-page-wide.jpg
│ │ ├── screenshot-03-radio-page-wide.png
│ │ ├── screenshot-03-radio-page.jpg
│ │ ├── screenshot-03-radio-page.png
│ │ ├── screenshot-04-podcast-wide.jpg
│ │ ├── screenshot-04-podcast-wide.png
│ │ ├── screenshot-04-podcast.jpg
│ │ └── screenshot-04-podcast.png
│ ├── social-share.jpg
│ └── social-share.png
├── favicon-16x16.png
├── favicon-32x32.png
├── favicon.ico
├── header-bg.jpg
├── icon-192x192-maskable.png
├── icon-192x192.png
├── icon-512x512-maskable.png
├── icon-512x512.png
├── icon.svg
├── logo-text-white.svg
├── logo-text.svg
├── manifest.json
├── mstile-150x150.png
├── safari-pinned-tab.svg
├── shortcut-playlists-192x192.png
├── shortcut-podcasts-192x192.png
├── shortcut-settings-192x192.png
└── shortcut-stations-192x192.png
├── scripts
├── fetchPodcasts.js
└── processRadioPodcasts.js
├── src
├── app.css
├── app.tsx
├── assets
│ ├── data
│ │ ├── featured
│ │ │ └── podcasts.json
│ │ ├── genres.json
│ │ ├── languages.json
│ │ ├── podcasts.json
│ │ ├── stations.json
│ │ └── stations
│ │ │ └── podcasts.json
│ └── icons
│ │ └── tiktok.svg
├── components
│ ├── appShell
│ │ ├── appShell.tsx
│ │ ├── toast
│ │ │ ├── toast.tsx
│ │ │ ├── toaster.tsx
│ │ │ └── types.ts
│ │ └── useAppShell.ts
│ ├── content-section.tsx
│ ├── day-timeline.tsx
│ ├── image-background.tsx
│ ├── loader.tsx
│ ├── player
│ │ ├── player.tsx
│ │ └── usePlayer.ts
│ ├── podcast-card.tsx
│ ├── radio-station-card.tsx
│ ├── share-button.tsx
│ └── ui
│ │ ├── badge.tsx
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── dropdown-list.tsx
│ │ ├── input.tsx
│ │ ├── radio-button-list.tsx
│ │ ├── radio-button.tsx
│ │ ├── switch.tsx
│ │ └── tag-select.tsx
├── global.d.ts
├── hooks
│ ├── useCastApi.ts
│ ├── useHead.ts
│ ├── useNoise.ts
│ ├── usePodcastData.ts
│ └── useRadioBrowser.ts
├── lib
│ ├── convertTime.ts
│ ├── opmlUtil.ts
│ ├── playlistUtil.ts
│ ├── reconnectUtil.ts
│ ├── styleClass.ts
│ ├── utils.ts
│ ├── validationUtil.ts
│ └── version.ts
├── main.tsx
├── pages
│ ├── about
│ │ └── index.tsx
│ ├── homepage
│ │ └── index.tsx
│ ├── not-found
│ │ └── index.tsx
│ ├── playlist
│ │ ├── index.tsx
│ │ ├── playlist.module.css
│ │ └── usePlaylist.ts
│ ├── playlists
│ │ ├── index.tsx
│ │ ├── types.ts
│ │ └── usePlaylists.ts
│ ├── podcast
│ │ ├── index.tsx
│ │ └── usePodcast.ts
│ ├── podcasts
│ │ ├── index.tsx
│ │ └── usePodcasts.ts
│ ├── radio-station
│ │ ├── index.tsx
│ │ └── useRadioStation.ts
│ ├── radio-stations
│ │ ├── index.tsx
│ │ └── useRadioStations.ts
│ └── settings
│ │ ├── index.tsx
│ │ ├── types.ts
│ │ └── useSettings.ts
├── store
│ ├── db
│ │ ├── db.ts
│ │ └── migration.ts
│ ├── signals
│ │ ├── log.ts
│ │ ├── player.ts
│ │ ├── playlist.ts
│ │ ├── podcast.ts
│ │ ├── radio.ts
│ │ ├── settings.ts
│ │ └── ui.ts
│ └── types.ts
└── vite-env.d.ts
├── tests
├── homepage.spec.ts
└── radio.spec.ts
├── tsconfig.app.json
├── tsconfig.app.tsbuildinfo
├── tsconfig.json
├── tsconfig.node.json
├── tsconfig.node.tsbuildinfo
└── vite.config.ts
/.env-example:
--------------------------------------------------------------------------------
1 | FIREBASE_LOGIN_URL="https://example.com"
2 | FIREBASE_USER_EMAIL="info@example.com"
3 | FIREBASE_USER_PASSWORD="password"
4 | FIREBASE_URL="https://example.com"
5 | VITE_BASE_URL="https://1tuner.com"
6 | RADIO_BROWSER_WORKER_URL="https://example.com"
--------------------------------------------------------------------------------
/.github/workflows/playwright.yml:
--------------------------------------------------------------------------------
1 | name: Playwright Tests
2 | on:
3 | push:
4 | branches: [ main, master ]
5 | pull_request:
6 | branches: [ main, master ]
7 | jobs:
8 | test:
9 | timeout-minutes: 60
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v4
13 | - uses: actions/setup-node@v4
14 | with:
15 | node-version: lts/*
16 | - name: Install dependencies
17 | run: npm ci
18 | - name: Install Playwright Browsers
19 | run: npx playwright install --with-deps
20 | - name: Run Playwright tests
21 | run: npx playwright test
22 | - uses: actions/upload-artifact@v4
23 | if: ${{ !cancelled() }}
24 | with:
25 | name: playwright-report
26 | path: playwright-report/
27 | retention-days: 30
28 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 | .well-known
15 |
16 | # Editor directories and files
17 | .vscode/*
18 | !.vscode/extensions.json
19 | .idea
20 | .DS_Store
21 | *.suo
22 | *.ntvs*
23 | *.njsproj
24 | *.sln
25 | *.sw?
26 | .aider*
27 | .gemini
28 | .playwright-mcp
29 | .env
30 | .firebase
31 | .firebaserc
32 | /firebase.json
33 |
34 | # Playwright
35 | /test-results/
36 | /playwright-report/
37 | /blob-report/
38 | /playwright/.cache/
39 | /playwright/.auth/
40 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) Robin Bakker (https://robinbakker.nl)
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 1tuner
2 |
3 | 
4 |
5 | A web app to listen to online radio. Also, create your own daily schedule (planning) and listen to different radio stations consecutively - the player will switch between radio streams automatically.
6 |
7 | 📻 Listen now at [1tuner.com](https://1tuner.com)
8 |
9 | 📖 See also [Creating a web app as side project](https://robinbakker.nl/en/blog/creating-a-web-app-as-side-project/) and [Updating my web app side project](https://robinbakker.nl/en/blog/updating-my-web-app-side-project/)
10 |
11 | 🚀 Built with [Preact](https://github.com/preactjs/preact)
12 |
13 | ## License
14 |
15 | MIT © [Robin Bakker](https://robinbakker.nl)
16 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "src/app.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "~/components",
15 | "utils": "~/lib/utils",
16 | "ui": "~/components/ui",
17 | "lib": "~/lib",
18 | "hooks": "~/hooks"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import { FlatCompat } from '@eslint/eslintrc';
2 | import pluginJs from '@eslint/js';
3 | import pluginReact from 'eslint-plugin-react';
4 | import globals from 'globals';
5 | import tseslint from 'typescript-eslint';
6 |
7 | const compat = new FlatCompat();
8 |
9 | /** @type {import('eslint').Linter.Config[]} */
10 | export default [
11 | { files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'] },
12 | {
13 | ignores: ['dist/**/*', 'node_modules/**/*', 'public/**/*'],
14 | },
15 | { languageOptions: { globals: globals.browser } },
16 | pluginJs.configs.recommended,
17 | ...tseslint.configs.recommended,
18 | pluginReact.configs.flat['jsx-runtime'],
19 | ...compat.extends('plugin:react-hooks/recommended'),
20 | ];
21 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
37 | 1tuner.com
38 |
39 |
40 |
41 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "1tuner",
3 | "private": true,
4 | "version": "3.1.16",
5 | "description": "1 web app to listen to radio and podcasts",
6 | "author": "Robin Bakker",
7 | "license": "MIT",
8 | "type": "module",
9 | "scripts": {
10 | "start": "vite",
11 | "dev": "vite",
12 | "prefetch": "node ./scripts/fetchPodcasts.js",
13 | "process-radio-podcasts": "node ./scripts/processRadioPodcasts.js",
14 | "prebuild": "npm run prefetch && npm run process-radio-podcasts",
15 | "build": "tsc -b && vite build",
16 | "preview": "vite preview"
17 | },
18 | "prettier": {
19 | "semi": true,
20 | "trailingComma": "all",
21 | "singleQuote": true,
22 | "printWidth": 120,
23 | "plugins": [
24 | "prettier-plugin-organize-imports"
25 | ]
26 | },
27 | "dependencies": {
28 | "@preact/signals": "^2.3.2",
29 | "@tailwindcss/vite": "^4.1.14",
30 | "class-variance-authority": "^0.7.1",
31 | "fast-xml-parser": "^5.3.0",
32 | "idb": "^8.0.3",
33 | "lucide-preact": "^0.544.0",
34 | "preact": "^10.27.2",
35 | "preact-iso": "^2.11.0",
36 | "tailwindcss-animate": "^1.0.7"
37 | },
38 | "devDependencies": {
39 | "@eslint/js": "^9.37.0",
40 | "@playwright/test": "^1.55.1",
41 | "@preact/preset-vite": "^2.10.2",
42 | "@tailwindcss/container-queries": "^0.1.1",
43 | "@types/chromecast-caf-sender": "^1.0.11",
44 | "@types/node": "^24.6.2",
45 | "clsx": "^2.1.1",
46 | "country-flag-emoji-polyfill": "^0.1.8",
47 | "dotenv": "^17.2.3",
48 | "eslint": "^9.37.0",
49 | "eslint-plugin-react": "^7.37.5",
50 | "eslint-plugin-react-hooks": "^6.1.1",
51 | "globals": "^16.4.0",
52 | "postcss": "^8.5.6",
53 | "prettier-plugin-organize-imports": "^4.3.0",
54 | "tailwind-merge": "^3.3.1",
55 | "tailwindcss": "^4.1.14",
56 | "typescript": "^5.9.3",
57 | "typescript-eslint": "^8.45.0",
58 | "vite": "^7.1.9",
59 | "vite-plugin-pwa": "^1.0.3"
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/playwright.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, devices } from '@playwright/test';
2 |
3 | /**
4 | * See https://playwright.dev/docs/test-configuration.
5 | */
6 | export default defineConfig({
7 | testDir: './tests',
8 | /* Run tests in files in parallel */
9 | fullyParallel: true,
10 | /* Fail the build on CI if you accidentally left test.only in the source code. */
11 | forbidOnly: !!process.env.CI,
12 | /* Retry on CI only */
13 | retries: process.env.CI ? 2 : 0,
14 | /* Opt out of parallel tests on CI. */
15 | workers: process.env.CI ? 1 : undefined,
16 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */
17 | reporter: 'html',
18 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
19 | use: {
20 | /* Base URL to use in actions like `await page.goto('/')`. */
21 | baseURL: 'http://localhost:5173',
22 |
23 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
24 | trace: 'on-first-retry',
25 | },
26 |
27 | /* Configure projects for major browsers */
28 | projects: [
29 | {
30 | name: 'chromium',
31 | use: { ...devices['Desktop Chrome'] },
32 | },
33 |
34 | // {
35 | // name: 'firefox',
36 | // use: { ...devices['Desktop Firefox'] },
37 | // },
38 |
39 | // {
40 | // name: 'webkit',
41 | // use: { ...devices['Desktop Safari'] },
42 | // },
43 |
44 | /* Test against mobile viewports. */
45 | // {
46 | // name: 'Mobile Chrome',
47 | // use: { ...devices['Pixel 5'] },
48 | // },
49 | // {
50 | // name: 'Mobile Safari',
51 | // use: { ...devices['iPhone 12'] },
52 | // },
53 |
54 | /* Test against branded browsers. */
55 | // {
56 | // name: 'Microsoft Edge',
57 | // use: { ...devices['Desktop Edge'], channel: 'msedge' },
58 | // },
59 | // {
60 | // name: 'Google Chrome',
61 | // use: { ...devices['Desktop Chrome'], channel: 'chrome' },
62 | // },
63 | ],
64 |
65 | /* Run your local dev server before starting the tests */
66 | webServer: {
67 | command: 'npm run dev',
68 | url: 'http://localhost:5173',
69 | reuseExistingServer: !process.env.CI,
70 | },
71 | });
72 |
--------------------------------------------------------------------------------
/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/assets/chromecast/bg.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/chromecast/bg.jpg
--------------------------------------------------------------------------------
/public/assets/chromecast/default.css:
--------------------------------------------------------------------------------
1 | .background {
2 | background: center no-repeat url(bg.jpg);
3 | background-size: cover;
4 | }
5 |
6 | .logo {
7 | background-image: url(logo.png);
8 | }
9 |
10 | .progressBar {
11 | background-color: #ff6000;
12 | }
13 |
--------------------------------------------------------------------------------
/public/assets/chromecast/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/chromecast/logo.png
--------------------------------------------------------------------------------
/public/assets/icons/icon-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/icons/icon-192x192.png
--------------------------------------------------------------------------------
/public/assets/icons/icon-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/icons/icon-512x512.png
--------------------------------------------------------------------------------
/public/assets/icons/icon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/assets/screenshots/screenshot-01-home-wide.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/screenshots/screenshot-01-home-wide.jpg
--------------------------------------------------------------------------------
/public/assets/screenshots/screenshot-01-home-wide.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/screenshots/screenshot-01-home-wide.png
--------------------------------------------------------------------------------
/public/assets/screenshots/screenshot-01-home.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/screenshots/screenshot-01-home.jpg
--------------------------------------------------------------------------------
/public/assets/screenshots/screenshot-01-home.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/screenshots/screenshot-01-home.png
--------------------------------------------------------------------------------
/public/assets/screenshots/screenshot-02-radio-wide.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/screenshots/screenshot-02-radio-wide.jpg
--------------------------------------------------------------------------------
/public/assets/screenshots/screenshot-02-radio-wide.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/screenshots/screenshot-02-radio-wide.png
--------------------------------------------------------------------------------
/public/assets/screenshots/screenshot-02-radio.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/screenshots/screenshot-02-radio.jpg
--------------------------------------------------------------------------------
/public/assets/screenshots/screenshot-02-radio.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/screenshots/screenshot-02-radio.png
--------------------------------------------------------------------------------
/public/assets/screenshots/screenshot-03-radio-page-wide.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/screenshots/screenshot-03-radio-page-wide.jpg
--------------------------------------------------------------------------------
/public/assets/screenshots/screenshot-03-radio-page-wide.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/screenshots/screenshot-03-radio-page-wide.png
--------------------------------------------------------------------------------
/public/assets/screenshots/screenshot-03-radio-page.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/screenshots/screenshot-03-radio-page.jpg
--------------------------------------------------------------------------------
/public/assets/screenshots/screenshot-03-radio-page.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/screenshots/screenshot-03-radio-page.png
--------------------------------------------------------------------------------
/public/assets/screenshots/screenshot-04-podcast-wide.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/screenshots/screenshot-04-podcast-wide.jpg
--------------------------------------------------------------------------------
/public/assets/screenshots/screenshot-04-podcast-wide.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/screenshots/screenshot-04-podcast-wide.png
--------------------------------------------------------------------------------
/public/assets/screenshots/screenshot-04-podcast.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/screenshots/screenshot-04-podcast.jpg
--------------------------------------------------------------------------------
/public/assets/screenshots/screenshot-04-podcast.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/screenshots/screenshot-04-podcast.png
--------------------------------------------------------------------------------
/public/assets/social-share.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/social-share.jpg
--------------------------------------------------------------------------------
/public/assets/social-share.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/assets/social-share.png
--------------------------------------------------------------------------------
/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/favicon-32x32.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/favicon.ico
--------------------------------------------------------------------------------
/public/header-bg.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/header-bg.jpg
--------------------------------------------------------------------------------
/public/icon-192x192-maskable.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/icon-192x192-maskable.png
--------------------------------------------------------------------------------
/public/icon-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/icon-192x192.png
--------------------------------------------------------------------------------
/public/icon-512x512-maskable.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/icon-512x512-maskable.png
--------------------------------------------------------------------------------
/public/icon-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/icon-512x512.png
--------------------------------------------------------------------------------
/public/icon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/logo-text-white.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/logo-text.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "1tuner.com",
3 | "short_name": "1tuner",
4 | "description": "1 web app to listen to online radio",
5 | "start_url": "/?source=pwa",
6 | "id": "/?homescreen=2",
7 | "display": "standalone",
8 | "orientation": "portrait-primary",
9 | "background_color": "#ebebeb",
10 | "theme_color": "#111111",
11 | "launch_handler": {
12 | "client_mode": ["focus-existing", "auto"]
13 | },
14 | "icons": [
15 | {
16 | "src": "/icon-192x192.png",
17 | "type": "image/png",
18 | "sizes": "192x192"
19 | },
20 | {
21 | "purpose": "any",
22 | "src": "/icon-512x512.png",
23 | "type": "image/png",
24 | "sizes": "512x512"
25 | },
26 | {
27 | "purpose": "maskable",
28 | "src": "/icon-192x192-maskable.png",
29 | "type": "image/png",
30 | "sizes": "192x192"
31 | },
32 | {
33 | "purpose": "maskable",
34 | "src": "/icon-512x512-maskable.png",
35 | "type": "image/png",
36 | "sizes": "512x512"
37 | }
38 | ],
39 | "shortcuts": [
40 | {
41 | "name": "Stations",
42 | "short_name": "Stations",
43 | "description": "Listen to the radio",
44 | "url": "/radio-stations?source=pwa",
45 | "icons": [{ "src": "/shortcut-stations-192x192.png", "sizes": "192x192" }]
46 | },
47 | {
48 | "name": "Podcasts",
49 | "short_name": "Podcasts",
50 | "description": "Find and listen to podcasts",
51 | "url": "/podcasts?source=pwa",
52 | "icons": [{ "src": "/shortcut-podcasts-192x192.png", "sizes": "192x192" }]
53 | },
54 | {
55 | "name": "Playlists",
56 | "short_name": "Playlists",
57 | "description": "Your own radio playlists",
58 | "url": "/playlists?source=pwa",
59 | "icons": [{ "src": "/shortcut-playlists-192x192.png", "sizes": "192x192" }]
60 | },
61 | {
62 | "name": "Settings",
63 | "short_name": "Settings",
64 | "description": "Change the app settings",
65 | "url": "/settings?source=pwa",
66 | "icons": [{ "src": "/shortcut-settings-192x192.png", "sizes": "192x192" }]
67 | }
68 | ],
69 | "screenshots": [
70 | {
71 | "src": "/assets/screenshots/screenshot-01-home.jpg",
72 | "type": "image/jpeg",
73 | "sizes": "1170x2532",
74 | "form_factor": "narrow"
75 | },
76 | {
77 | "src": "/assets/screenshots/screenshot-02-radio.jpg",
78 | "type": "image/jpeg",
79 | "sizes": "1170x2532",
80 | "form_factor": "narrow"
81 | },
82 | {
83 | "src": "/assets/screenshots/screenshot-04-podcast.jpg",
84 | "type": "image/jpeg",
85 | "sizes": "1170x2532",
86 | "form_factor": "narrow"
87 | },
88 | {
89 | "src": "/assets/screenshots/screenshot-03-radio-page.jpg",
90 | "type": "image/jpeg",
91 | "sizes": "1170x2532",
92 | "form_factor": "narrow"
93 | },
94 | {
95 | "src": "/assets/screenshots/screenshot-01-home-wide.jpg",
96 | "type": "image/jpeg",
97 | "sizes": "2732x2048",
98 | "form_factor": "wide"
99 | },
100 | {
101 | "src": "/assets/screenshots/screenshot-02-radio-wide.jpg",
102 | "type": "image/jpeg",
103 | "sizes": "2732x2048",
104 | "form_factor": "wide"
105 | },
106 | {
107 | "src": "/assets/screenshots/screenshot-04-podcast-wide.jpg",
108 | "type": "image/jpeg",
109 | "sizes": "2732x2048",
110 | "form_factor": "wide"
111 | },
112 | {
113 | "src": "/assets/screenshots/screenshot-03-radio-page-wide.jpg",
114 | "type": "image/jpeg",
115 | "sizes": "2732x2048",
116 | "form_factor": "wide"
117 | }
118 | ],
119 | "categories": ["entertainment", "music", "news"]
120 | }
121 |
--------------------------------------------------------------------------------
/public/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/mstile-150x150.png
--------------------------------------------------------------------------------
/public/safari-pinned-tab.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
7 |
8 | Created by potrace 1.11, written by Peter Selinger 2001-2013
9 |
10 |
12 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/public/shortcut-playlists-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/shortcut-playlists-192x192.png
--------------------------------------------------------------------------------
/public/shortcut-podcasts-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/shortcut-podcasts-192x192.png
--------------------------------------------------------------------------------
/public/shortcut-settings-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/shortcut-settings-192x192.png
--------------------------------------------------------------------------------
/public/shortcut-stations-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/robinbakker/1tuner/8c9c4ca90cfcb38f0b11541f4833de4741b6aa1d/public/shortcut-stations-192x192.png
--------------------------------------------------------------------------------
/scripts/fetchPodcasts.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | import * as dotenv from 'dotenv';
3 | import * as fs from 'fs/promises';
4 | import * as path from 'path';
5 |
6 | dotenv.config();
7 |
8 | async function fetchPodcasts() {
9 | try {
10 | const FIREBASE_LOGIN_URL = process.env.FIREBASE_LOGIN_URL;
11 | const FIREBASE_USER_EMAIL = process.env.FIREBASE_USER_EMAIL;
12 | const FIREBASE_USER_PASSWORD = process.env.FIREBASE_USER_PASSWORD;
13 | const FIREBASE_URL = process.env.FIREBASE_URL;
14 |
15 | const firebaseAuth = await fetch(FIREBASE_LOGIN_URL, {
16 | body: JSON.stringify({
17 | email: FIREBASE_USER_EMAIL,
18 | password: FIREBASE_USER_PASSWORD,
19 | returnSecureToken: true,
20 | }),
21 | method: 'POST',
22 | });
23 |
24 | const firebaseAuthResult = await firebaseAuth.json();
25 | const firebaseAuthToken = firebaseAuthResult.idToken;
26 | const podcastDataUrl = `${FIREBASE_URL}/podcasts.json?auth=${firebaseAuthToken}`;
27 |
28 | const response = await fetch(podcastDataUrl);
29 | const data = await response.json();
30 |
31 | const keyArray = Object.keys(data).reverse();
32 | const podcasts = keyArray
33 | .slice(0, 500)
34 | .map((key) => data[key])
35 | .filter((pc) => pc && pc.feedUrl && pc.title)
36 | .map((pc) => ({
37 | title: pc.title,
38 | description: pc.description,
39 | imageUrl: pc.logo600 || pc.logo,
40 | url: pc.feedUrl,
41 | categories: pc.genres ? pc.genres.map((g) => g.name).filter((g) => !!g) : [],
42 | addedDate: Date.now(),
43 | lastFetched: Date.now(),
44 | episodes: [],
45 | }));
46 |
47 | await fs.mkdir(path.join(process.cwd(), 'src/assets/data'), { recursive: true });
48 | await fs.writeFile(path.join(process.cwd(), 'src/assets/data/podcasts.json'), JSON.stringify(podcasts, null, 2));
49 |
50 | console.log('Podcasts data fetched and saved successfully!');
51 | } catch (error) {
52 | console.error('Error fetching podcasts:', error);
53 | process.exit(1);
54 | }
55 | }
56 |
57 | fetchPodcasts();
58 |
--------------------------------------------------------------------------------
/scripts/processRadioPodcasts.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | import * as fs from 'fs/promises';
3 | import * as path from 'path';
4 |
5 | async function processPodcastsForStations() {
6 | try {
7 | // Read the podcast and stations data
8 | const podcastsData = JSON.parse(
9 | await fs.readFile(path.join(process.cwd(), 'src/assets/data/podcasts.json'), 'utf-8'),
10 | );
11 | const stationsData = JSON.parse(
12 | await fs.readFile(path.join(process.cwd(), 'src/assets/data/stations.json'), 'utf-8'),
13 | );
14 |
15 | // Create a map of podcast URLs to their full data for quick lookup
16 | const podcastMap = new Map();
17 | podcastsData.forEach((podcast) => {
18 | podcastMap.set(podcast.url.toLowerCase(), podcast);
19 | });
20 |
21 | // Process each station
22 | const stationPodcasts = {};
23 |
24 | stationsData.stations.forEach((station) => {
25 | if (station.podcasts && station.podcasts.length > 0) {
26 | const matchedPodcasts = station.podcasts
27 | .map((podcastUrl) => podcastMap.get(podcastUrl.toLowerCase()))
28 | .filter(Boolean); // Remove any undefined entries
29 |
30 | if (matchedPodcasts.length > 0) {
31 | stationPodcasts[station.id] = matchedPodcasts;
32 | }
33 | }
34 | });
35 |
36 | // Create the data directory if it doesn't exist
37 | await fs.mkdir(path.join(process.cwd(), 'src/assets/data/stations'), { recursive: true });
38 |
39 | // Save the results
40 | await fs.writeFile(
41 | path.join(process.cwd(), 'src/assets/data/stations/podcasts.json'),
42 | JSON.stringify(stationPodcasts, null, 2),
43 | );
44 |
45 | console.log('Station podcasts processed and saved successfully!');
46 | } catch (error) {
47 | console.error('Error processing station podcasts:', error);
48 | process.exit(1);
49 | }
50 | }
51 |
52 | processPodcastsForStations();
53 |
--------------------------------------------------------------------------------
/src/app.css:
--------------------------------------------------------------------------------
1 | @import 'tailwindcss';
2 |
3 | @plugin 'tailwindcss-animate';
4 | @plugin '@tailwindcss/container-queries';
5 |
6 | @custom-variant dark (&:where(.dark, .dark *));
7 |
8 | @theme {
9 | --radius-lg: var(--radius);
10 | --radius-md: calc(var(--radius) - 2px);
11 | --radius-sm: calc(var(--radius) - 4px);
12 |
13 | --color-background: hsl(var(--background));
14 | --color-foreground: hsl(var(--foreground));
15 |
16 | --color-card: hsl(var(--card));
17 | --color-card-foreground: hsl(var(--card-foreground));
18 |
19 | --color-popover: hsl(var(--popover));
20 | --color-popover-foreground: hsl(var(--popover-foreground));
21 |
22 | --color-primary: hsl(var(--primary));
23 | --color-primary-foreground: hsl(var(--primary-foreground));
24 |
25 | --color-secondary: hsl(var(--secondary));
26 | --color-secondary-foreground: hsl(var(--secondary-foreground));
27 |
28 | --color-muted: hsl(var(--muted));
29 | --color-muted-foreground: hsl(var(--muted-foreground));
30 |
31 | --color-accent: hsl(var(--accent));
32 | --color-accent-foreground: hsl(var(--accent-foreground));
33 |
34 | --color-destructive: hsl(var(--destructive));
35 | --color-destructive-foreground: hsl(var(--destructive-foreground));
36 |
37 | --color-border: hsl(var(--border));
38 | --color-input: hsl(var(--input));
39 | --color-ring: hsl(var(--ring));
40 |
41 | --color-chart-1: hsl(var(--chart-1));
42 | --color-chart-2: hsl(var(--chart-2));
43 | --color-chart-3: hsl(var(--chart-3));
44 | --color-chart-4: hsl(var(--chart-4));
45 | --color-chart-5: hsl(var(--chart-5));
46 | }
47 |
48 | /*
49 | The default border color has changed to `currentColor` in Tailwind CSS v4,
50 | so we've added these compatibility styles to make sure everything still
51 | looks the same as it did with Tailwind CSS v3.
52 |
53 | If we ever want to remove these styles, we need to add an explicit border
54 | color utility to any element that depends on these defaults.
55 | */
56 | @layer base {
57 | *,
58 | ::after,
59 | ::before,
60 | ::backdrop,
61 | ::file-selector-button {
62 | border-color: var(--color-gray-200, currentColor);
63 | }
64 | }
65 |
66 | @utility maximize-enter {
67 | transform: translateY(100%);
68 |
69 | @media (width >= theme(--breakpoint-md)) {
70 | transform: translateX(100%);
71 | }
72 | }
73 |
74 | @utility maximize-enter-active {
75 | transform: translateY(0);
76 | transition: transform 300ms ease-in-out;
77 |
78 | @media (width >= theme(--breakpoint-md)) {
79 | transform: translateX(0);
80 | }
81 | }
82 |
83 | @utility maximize-exit {
84 | transform: translateY(0);
85 | }
86 |
87 | @utility maximize-exit-active {
88 | transform: translateY(100%);
89 | transition: transform 300ms ease-in-out;
90 |
91 | @media (width >= theme(--breakpoint-md)) {
92 | transform: translateX(100%);
93 | }
94 | }
95 |
96 | @layer utilities {
97 | html,
98 | :host {
99 | font-family:
100 | 'Twemoji Country Flags', ui-sans-serif, system-ui, sans-serif,
101 | 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
102 | 'Noto Color Emoji';
103 | }
104 | }
105 |
106 | @layer base {
107 | :root {
108 | --background: 60 9.1% 97.8%; /* stone-50 */
109 | --foreground: 12 6.5% 33%;
110 | --card: 0 0% 100%; /* white */
111 | --card-foreground: 20 14.3% 4.9%; /* stone-900 */
112 | --popover: 0 0% 100%; /* white */
113 | --popover-foreground: 20 14.3% 4.9%; /* stone-900 */
114 | --primary: 22.59deg 100% 50%;
115 | --primary-foreground: 0 0% 98%;
116 | --secondary: 0 0% 96.1%;
117 | --secondary-foreground: 0 0% 9%;
118 | --muted: 60 4.8% 95.9%; /* stone-100 */
119 | --muted-foreground: 25 5.3% 44.7%; /* stone-500 */
120 | --accent: 60 4.8% 95.9%; /* stone-100 */
121 | --accent-foreground: 24 9.8% 10%; /* stone-900 */
122 | --destructive: 0 84.2% 60.2%; /* red-500 */
123 | --destructive-foreground: 60 9.1% 97.8%; /* stone-50 */
124 | --border: 20 5.9% 90%; /* stone-200 */
125 | --input: 20 5.9% 90%; /* stone-200 */
126 | --ring: 20 14.3% 4.9%; /* stone-900 */
127 | --chart-1: 12 76% 61%;
128 | --chart-2: 173 58% 39%;
129 | --chart-3: 197 37% 24%;
130 | --chart-4: 43 74% 66%;
131 | --chart-5: 27 87% 67%;
132 | --radius: 0.5rem;
133 | }
134 |
135 | .dark {
136 | --background: 24 9.8% 10%; /* stone-900 */
137 | --foreground: 60 9.1% 75%;
138 | --card: 12 6.5% 15.1%; /* stone-800 */
139 | --card-foreground: 60 9.1% 97.8%; /* stone-50 */
140 | --popover: 24 9.8% 10%; /* stone-900 */
141 | --popover-foreground: 60 9.1% 97.8%; /* stone-50 */
142 | --primary: 22.59deg 100% 50%;
143 | --primary-foreground: 0 0% 98%;
144 | --secondary: 0 0% 96.1%;
145 | --secondary-foreground: 0 0% 9%;
146 | --muted: 12 6.5% 15.1%; /* stone-800 */
147 | --muted-foreground: 24 5.4% 63.9%; /* stone-400 */
148 | --accent: 12 6.5% 15.1%; /* stone-800 */
149 | --accent-foreground: 60 9.1% 97.8%; /* stone-50 */
150 | --destructive: 0 62.8% 30.6%; /* red-700 */
151 | --destructive-foreground: 60 9.1% 97.8%; /* stone-50 */
152 | --border: 12 6.5% 15.1%; /* stone-800 */
153 | --input: 12 6.5% 15.1%; /* stone-800 */
154 | --ring: 24 5.7% 82.9%; /* stone-200 */
155 | --chart-1: 220 70% 50%;
156 | --chart-2: 160 60% 45%;
157 | --chart-3: 30 80% 55%;
158 | --chart-4: 280 65% 60%;
159 | --chart-5: 340 75% 55%;
160 | }
161 |
162 | section ::-webkit-scrollbar {
163 | width: 10px;
164 | height: 10px;
165 | }
166 |
167 | section ::-webkit-scrollbar-track {
168 | background: transparent;
169 | }
170 |
171 | section ::-webkit-scrollbar-thumb {
172 | background: rgb(156 163 175 / 0.5); /* gray-400 with 50% opacity */
173 | border-radius: 5px;
174 | }
175 |
176 | section ::-webkit-scrollbar-thumb:hover {
177 | background: rgb(156 163 175 / 0.7);
178 | }
179 | }
180 |
181 | @layer utilities {
182 | .is-dragging {
183 | user-select: none;
184 | -webkit-user-select: none;
185 | }
186 |
187 | .is-dragging * {
188 | cursor: ns-resize !important;
189 | }
190 |
191 | select.custom-select-arrow {
192 | appearance: none;
193 | background-image:
194 | linear-gradient(45deg, transparent 50%, hsl(var(--muted-foreground)) 50%),
195 | linear-gradient(135deg, hsl(var(--muted-foreground)) 50%, transparent 50%);
196 | background-position:
197 | calc(100% - 1.35rem) 1.25rem,
198 | calc(100% - 1rem) 1.25rem;
199 | background-size:
200 | 0.35rem 0.35rem,
201 | 0.35rem 0.35rem;
202 | background-repeat: no-repeat;
203 | }
204 |
205 | select.custom-select-arrow--small {
206 | background-position:
207 | calc(100% - 0.6rem) 0.75rem,
208 | calc(100% - 0.35rem) 0.75rem;
209 | background-size:
210 | 0.25rem 0.25rem,
211 | 0.25rem 0.25rem;
212 | }
213 | }
214 |
215 | @layer base {
216 | * {
217 | @apply border-border;
218 | }
219 | body {
220 | @apply bg-background text-foreground;
221 | }
222 |
223 | .app-shell-header {
224 | background: linear-gradient(to bottom, hsl(var(--card)), transparent 50%);
225 | }
226 |
227 | .player-minimized-backdrop {
228 | background: linear-gradient(to top, hsl(var(--card)), transparent 50%);
229 | }
230 | }
231 |
--------------------------------------------------------------------------------
/src/app.tsx:
--------------------------------------------------------------------------------
1 | import { ErrorBoundary, LocationProvider, Route, Router } from 'preact-iso';
2 | import { useEffect } from 'preact/hooks';
3 | import './app.css';
4 | import { AppShell } from './components/appShell/appShell';
5 | import { AboutPage } from './pages/about';
6 | import { Homepage } from './pages/homepage';
7 | import { NotFound } from './pages/not-found';
8 | import { PlaylistPage } from './pages/playlist';
9 | import { PlaylistsPage } from './pages/playlists';
10 | import { PodcastPage } from './pages/podcast';
11 | import { PodcastsPage } from './pages/podcasts';
12 | import { RadioStationPage } from './pages/radio-station';
13 | import { RadioStationsPage } from './pages/radio-stations';
14 | import { SettingsPage } from './pages/settings';
15 | import { loadStateFromDB, saveStateToDB } from './store/db/db';
16 | import { migrateOldData } from './store/db/migration';
17 | import { isPlayerMaximized } from './store/signals/player';
18 |
19 | export function App() {
20 | useEffect(() => {
21 | async function initializeApp() {
22 | await migrateOldData();
23 | console.log('Loading state from DB...');
24 | await loadStateFromDB();
25 | }
26 |
27 | initializeApp();
28 |
29 | const handleBeforeUnload = () => {
30 | console.log('Saving state to DB...');
31 | saveStateToDB();
32 | };
33 |
34 | const handleVisibilityChange = () => {
35 | if (document.visibilityState === 'hidden') {
36 | console.log('App hidden, saving state...');
37 | saveStateToDB();
38 | }
39 | };
40 |
41 | window.addEventListener('beforeunload', handleBeforeUnload);
42 | document.addEventListener('visibilitychange', handleVisibilityChange);
43 | window.addEventListener('pagehide', handleBeforeUnload); // Add pagehide event for iOS
44 |
45 | return () => {
46 | window.removeEventListener('beforeunload', handleBeforeUnload);
47 | document.removeEventListener('visibilitychange', handleVisibilityChange);
48 | window.removeEventListener('pagehide', handleBeforeUnload);
49 | saveStateToDB();
50 | };
51 | }, []);
52 |
53 | useEffect(() => {
54 | if (isPlayerMaximized.value) {
55 | // Prevent scrolling on mobile only
56 | if (window.innerWidth < 768) {
57 | document.body.style.overflow = 'hidden';
58 | }
59 | } else {
60 | document.body.style.overflow = '';
61 | }
62 |
63 | return () => {
64 | document.body.style.overflow = '';
65 | };
66 | }, []);
67 |
68 | return (
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 | );
88 | }
89 |
--------------------------------------------------------------------------------
/src/assets/data/featured/podcasts.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id": "ZmVlZHMuc2ltcGxlY2FzdC5jb20vNTRuQUdjSWw=",
4 | "title": "The Daily",
5 | "description": "This is what the news should sound like. The biggest stories of our time, told by the best journalists in the world. Twenty minutes a day, five days a week, ready by 6 a.m.",
6 | "imageUrl": "https://is2-ssl.mzstatic.com/image/thumb/Podcasts124/v4/92/61/e6/9261e6a3-0d4a-8d4f-bd30-6c72562868ac/mza_15829936928695092211.jpeg/600x600bb.jpg",
7 | "url": "https://feeds.simplecast.com/54nAGcIl",
8 | "feedUrl": "https://feeds.simplecast.com/54nAGcIl",
9 | "addedDate": 1743541655611,
10 | "lastFetched": 1743541655611
11 | },
12 | {
13 | "id": "cnNzLmFydDE5LmNvbS92YW5kYWFn",
14 | "title": "NRC Vandaag",
15 | "description": "Vandaag is de dagelijkse podcast van NRC. Elke werkdag hoor je hét verhaal van de dag, verteld door onze beste journalisten.",
16 | "imageUrl": "https://content.production.cdn.art19.com/images/2e/d0/18/ab/2ed018ab-e157-4212-9f27-910ee353f0e1/91502b4bd0fde49ef44f4d5a3d8bc772ebc052dc6a2a3fea3f1473035e78fa243366aaf45d3376d959c28075262335c58da6cf79c1cf6890be2ee46354818675.jpeg",
17 | "url": "https://rss.art19.com/vandaag",
18 | "feedUrl": "https://rss.art19.com/vandaag",
19 | "addedDate": 1743541655610,
20 | "lastFetched": 1743541655610
21 | },
22 | {
23 | "id": "cG9kY2FzdC5ucG8ubmwvZmVlZC9kZS1kYWcueG1s",
24 | "title": "De Dag",
25 | "description": "Elke werkdag biedt 'De Dag' je twintig minuten verdieping bij één onderwerp uit het nieuws.",
26 | "imageUrl": "https://is5-ssl.mzstatic.com/image/thumb/Podcasts123/v4/95/55/fa/9555fabb-343e-3859-4c05-8f421f5a4885/mza_1355691311593021135.jpg/600x600bb.jpg",
27 | "url": "https://podcast.npo.nl/feed/de-dag.xml",
28 | "feedUrl": "https://podcast.npo.nl/feed/de-dag.xml",
29 | "addedDate": 1743541655611,
30 | "lastFetched": 1743541655611
31 | },
32 | {
33 | "id": "cYW5jaG9yLmZtL3MvMjFjNzM0YzQvcG9kY2FzdC9yc3M=",
34 | "title": "Maarten van Rossem en Tom Jessen",
35 | "description": "Maarten van Rossem en Tom Jessen geven hun kijk op het nieuws en de actualiteit. Ook wisselende onderwerpen uit de geschiedenis komen voorbij.",
36 | "imageUrl": "https://d3t3ozftmdmh3i.cloudfront.net/production/podcast_uploaded_nologo/5567033/5567033-1682276210669-9c8c45b803bd.jpg",
37 | "url": "https://anchor.fm/s/21c734c4/podcast/rss",
38 | "feedUrl": "https://anchor.fm/s/21c734c4/podcast/rss",
39 | "addedDate": 1743541655611,
40 | "lastFetched": 1743541655611
41 | },
42 | {
43 | "id": "cG9kY2FzdC5kYXJrbmV0ZGlhcmllcy5jb20=",
44 | "title": "Darknet Diaries",
45 | "description": "Explore true stories of the dark side of the Internet with host Jack Rhysider as he takes you on a journey through the chilling world of hacking, data breaches, and cyber crime.",
46 | "imageUrl": "https://f.prxu.org/7057/images/18744d5d-8091-4bb8-b41a-1ed06efcd508/uploads_2F1562951997273-pdd2keiryql-99f75240ab90a579e25720d85d3057b2_2Fdarknet-diaries-rss.jpg",
47 | "url": "https://podcast.darknetdiaries.com/",
48 | "feedUrl": "https://podcast.darknetdiaries.com/",
49 | "addedDate": 1743541655611,
50 | "lastFetched": 1743541655611
51 | },
52 | {
53 | "id": "ZmVlZHMuc2ltcGxlY2FzdC5jb20vbDJpOVluVGQ=",
54 | "title": "Hard Fork",
55 | "description": "“Hard Fork” is a show about the future that’s already here. Each week, journalists Kevin Roose and Casey Newton explore and make sense of the latest in the rapidly changing world of tech",
56 | "imageUrl": "https://image.simplecastcdn.com/images/4105a47a-42e5-4ccc-887a-832af7989986/23965394-f5e4-4fdb-b150-639f4910353e/3000x3000/nyt-hf-album-art-3000-2.jpg?aid=rss_feed",
57 | "url": "https://feeds.simplecast.com/l2i9YnTd",
58 | "feedUrl": "https://feeds.simplecast.com/l2i9YnTd",
59 | "addedDate": 1743541655611,
60 | "lastFetched": 1743541655611
61 | }
62 | ]
63 |
--------------------------------------------------------------------------------
/src/assets/data/genres.json:
--------------------------------------------------------------------------------
1 | {
2 | "genres": [
3 | {
4 | "id": "alternative",
5 | "name": "Alternative"
6 | },
7 | {
8 | "id": "blues",
9 | "name": "Blues"
10 | },
11 | {
12 | "id": "classical",
13 | "name": "Classical"
14 | },
15 | {
16 | "id": "country",
17 | "name": "Country"
18 | },
19 | {
20 | "id": "dance",
21 | "name": "Dance"
22 | },
23 | {
24 | "id": "disco",
25 | "name": "Disco"
26 | },
27 | {
28 | "id": "electronic",
29 | "name": "Electronic"
30 | },
31 | {
32 | "id": "folk",
33 | "name": "Folk"
34 | },
35 | {
36 | "id": "funk",
37 | "name": "Funk"
38 | },
39 | {
40 | "id": "hiphop",
41 | "name": "Hip hop"
42 | },
43 | {
44 | "id": "jazz",
45 | "name": "Jazz"
46 | },
47 | {
48 | "id": "latin",
49 | "name": "Latin"
50 | },
51 | {
52 | "id": "local",
53 | "name": "Local"
54 | },
55 | {
56 | "id": "news",
57 | "name": "News"
58 | },
59 | {
60 | "id": "nonstop",
61 | "name": "Non-stop"
62 | },
63 | {
64 | "id": "oldies",
65 | "name": "Oldies"
66 | },
67 | {
68 | "id": "pop",
69 | "name": "Pop"
70 | },
71 | {
72 | "id": "rap",
73 | "name": "Rap"
74 | },
75 | {
76 | "id": "rnb",
77 | "name": "R&B"
78 | },
79 | {
80 | "id": "rock",
81 | "name": "Rock"
82 | },
83 | {
84 | "id": "soul",
85 | "name": "Soul"
86 | },
87 | {
88 | "id": "talk",
89 | "name": "Talk"
90 | },
91 | {
92 | "id": "reggae",
93 | "name": "Reggae"
94 | },
95 | {
96 | "id": "comedy",
97 | "name": "Comedy"
98 | },
99 | {
100 | "id": "drama",
101 | "name": "Drama"
102 | },
103 | {
104 | "id": "history",
105 | "name": "History"
106 | },
107 | {
108 | "id": "metal",
109 | "name": "Metal"
110 | },
111 | {
112 | "id": "metalcore",
113 | "name": "Metalcore"
114 | },
115 | {
116 | "id": "hardrock",
117 | "name": "Hard rock"
118 | },
119 | {
120 | "id": "hardcore",
121 | "name": "Hardcore"
122 | },
123 | {
124 | "id": "punk",
125 | "name": "Punk"
126 | },
127 | {
128 | "id": "world",
129 | "name": "World"
130 | },
131 | {
132 | "id": "beat",
133 | "name": "Beat"
134 | },
135 | {
136 | "id": "kids",
137 | "name": "Kids"
138 | },
139 | {
140 | "id": "indie",
141 | "name": "Indie"
142 | },
143 | {
144 | "id": "americana",
145 | "name": "Americana"
146 | },
147 | {
148 | "id": "chanson",
149 | "name": "Chanson"
150 | },
151 | {
152 | "id": "filmmusic",
153 | "name": "Film music"
154 | },
155 | {
156 | "id": "sports",
157 | "name": "Sports"
158 | }
159 | ]
160 | }
161 |
--------------------------------------------------------------------------------
/src/assets/icons/tiktok.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/src/components/appShell/toast/toast.tsx:
--------------------------------------------------------------------------------
1 | // src/components/ui/toast/Toast.tsx
2 | import { X } from 'lucide-preact';
3 | import { cn } from '~/lib/utils';
4 | import { dismissToast } from '~/store/signals/ui';
5 | import { ToastProps } from './types';
6 |
7 | const toastVariants = {
8 | default: 'bg-white dark:bg-stone-800 text-stone-950 dark:text-stone-50',
9 | success: 'bg-green-100 dark:bg-green-900 text-green-950 dark:text-green-50',
10 | error: 'bg-red-100 dark:bg-red-900 text-red-950 dark:text-red-50',
11 | };
12 |
13 | export function Toast({ id, title, description, variant = 'default', onClose }: ToastProps) {
14 | return (
15 |
21 |
22 | {title &&
{title}
}
23 | {description &&
{description}
}
24 |
25 |
{
28 | onClose?.();
29 | dismissToast(id);
30 | }}
31 | class="p-2 hover:bg-stone-200 rounded-full transition-colors shrink-0"
32 | >
33 |
34 |
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/src/components/appShell/toast/toaster.tsx:
--------------------------------------------------------------------------------
1 | import { For } from '@preact/signals/utils';
2 | import { cn } from '~/lib/utils';
3 | import { toasts } from '~/store/signals/ui';
4 | import { Toast } from './toast';
5 |
6 | type Props = {
7 | isPlayerOpen?: boolean;
8 | isPlayerMaximized?: boolean;
9 | };
10 |
11 | export function Toaster({ isPlayerOpen, isPlayerMaximized }: Props) {
12 | return (
13 |
22 | {(toast) => }
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/appShell/toast/types.ts:
--------------------------------------------------------------------------------
1 | export type ToastProps = {
2 | id: string;
3 | title?: string;
4 | description?: string;
5 | variant?: 'default' | 'success' | 'error';
6 | duration?: number;
7 | onClose?: () => void;
8 | };
9 |
--------------------------------------------------------------------------------
/src/components/appShell/useAppShell.ts:
--------------------------------------------------------------------------------
1 | import { useLocation } from 'preact-iso';
2 | import { useEffect, useMemo, useRef } from 'preact/hooks';
3 | import { playerState } from '~/store/signals/player';
4 | import { uiIsScrolled } from '~/store/signals/ui';
5 |
6 | export const useAppShell = () => {
7 | const headerSentinelRef = useRef(null);
8 | const { path } = useLocation();
9 |
10 | useEffect(() => {
11 | if (!headerSentinelRef.current) {
12 | uiIsScrolled.value = false;
13 | return;
14 | }
15 |
16 | const observer = new IntersectionObserver(
17 | ([entry]) => {
18 | uiIsScrolled.value = !entry.isIntersecting;
19 | },
20 | {
21 | threshold: 1.0,
22 | rootMargin: '100px 0px 0px 0px', // Adjust the margin as needed
23 | },
24 | );
25 |
26 | // We observe a small empty div at the top of the page
27 | observer.observe(headerSentinelRef.current);
28 |
29 | return () => {
30 | observer.disconnect();
31 | };
32 | }, []);
33 |
34 | const handleBackClick = () => {
35 | history.back();
36 | };
37 |
38 | const isActive = (checkPath: string) => {
39 | return checkPath === '/' ? path === checkPath : path.startsWith(checkPath);
40 | };
41 |
42 | const isMainRoute = useMemo(() => {
43 | return ['/', '/radio-stations', '/podcasts', '/settings'].includes(path);
44 | }, [path]);
45 |
46 | return {
47 | headerSentinelRef,
48 | isActive,
49 | isMainRoute,
50 | isScrolled: uiIsScrolled.value,
51 | handleBackClick,
52 | isPlayerOpen: !!playerState.value?.streams?.length,
53 | };
54 | };
55 |
--------------------------------------------------------------------------------
/src/components/content-section.tsx:
--------------------------------------------------------------------------------
1 | import { Search } from 'lucide-preact';
2 | import { JSX } from 'preact';
3 | import { useCallback } from 'preact/hooks';
4 | import { cn } from '~/lib/utils';
5 | import { Button } from './ui/button';
6 |
7 | interface Props {
8 | title?: string;
9 | children: JSX.Element;
10 | moreLink?: string;
11 | isScrollable?: boolean;
12 | hasNoPadding?: boolean;
13 | className?: string;
14 | insetClassName?: string;
15 | hasSearchButton?: boolean;
16 | }
17 |
18 | export const ContentSection = ({
19 | title,
20 | moreLink,
21 | children,
22 | isScrollable,
23 | hasNoPadding,
24 | className,
25 | insetClassName,
26 | hasSearchButton = false,
27 | }: Props) => {
28 | const MoreLink = useCallback(
29 | ({ isSearch }: { isSearch?: boolean }) => {
30 | const link = isSearch ? `${moreLink}?focus-search=true` : moreLink;
31 | return (
32 |
33 |
34 | {isSearch ? : <>More...>}
35 |
36 |
37 | );
38 | },
39 | [moreLink],
40 | );
41 |
42 | const padding = hasNoPadding ? '' : insetClassName ? insetClassName : 'px-4 md:px-6';
43 |
44 | return (
45 |
46 |
47 | {!!title && (
48 |
49 | {title} {moreLink && }
50 |
51 | )}
52 |
{hasSearchButton && moreLink && }
53 |
54 | {children}
55 |
56 | );
57 | };
58 |
--------------------------------------------------------------------------------
/src/components/day-timeline.tsx:
--------------------------------------------------------------------------------
1 | import { HTMLAttributes } from 'preact/compat';
2 |
3 | export const DayTimeline = (props: HTMLAttributes) => {
4 | return (
5 |
6 | {new Array(13).fill(0).map((_, i) => (
7 |
11 | {`${`${i * 2}`.padStart(2, '0')}:00`}
12 | {i < 12 && (
13 |
14 | )}
15 |
16 | ))}
17 |
18 | );
19 | };
20 |
--------------------------------------------------------------------------------
/src/components/image-background.tsx:
--------------------------------------------------------------------------------
1 | export const ImageBackground = ({ imgSrc }: { imgSrc: string | undefined }) => {
2 | if (!imgSrc) {
3 | return null;
4 | }
5 |
6 | return (
7 |
8 | {[...new Array(10)].map((_, index) => (
9 |
14 | {index}
15 |
16 | ))}
17 |
18 | );
19 | };
20 |
--------------------------------------------------------------------------------
/src/components/loader.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'preact/hooks';
2 |
3 | export const Loader = () => {
4 | const needleRef = useRef(null);
5 |
6 | useEffect(() => {
7 | const needle = needleRef.current;
8 | if (!needle) return;
9 |
10 | const animate = () => {
11 | const randomPosition = Math.random() * 100;
12 | needle.style.left = `${randomPosition}%`;
13 |
14 | const duration = 500 + Math.random() * 1000; // Random duration between 0.5s and 1.5s
15 | needle.style.transition = `left ${duration}ms ease-in-out`;
16 |
17 | setTimeout(animate, duration);
18 | };
19 |
20 | animate();
21 |
22 | return () => {
23 | if (needle) {
24 | needle.style.transition = 'none';
25 | }
26 | };
27 | }, []);
28 |
29 | return (
30 |
40 | );
41 | };
42 |
--------------------------------------------------------------------------------
/src/components/podcast-card.tsx:
--------------------------------------------------------------------------------
1 | import { getPodcastUrlID, normalizedUrlWithoutScheme, slugify, stripHtml } from '~/lib/utils';
2 | import { Podcast } from '~/store/types';
3 | import { Card, CardContent } from './ui/card';
4 |
5 | interface Props {
6 | podcast: Podcast;
7 | size?: 'default' | 'large';
8 | }
9 |
10 | export const PodcastCard = ({ podcast, size }: Props) => {
11 | if (size === 'large') {
12 | return (
13 |
14 |
15 |
16 |
17 |
18 |
19 | {podcast.title}
20 |
21 |
{stripHtml(podcast.description)}
22 |
23 |
24 |
25 |
26 | );
27 | }
28 | return (
29 |
34 |
35 |
44 |
45 |
46 |
47 |
48 | {podcast.title}
49 |
50 |
51 |
52 |
53 |
54 | );
55 | };
56 |
--------------------------------------------------------------------------------
/src/components/radio-station-card.tsx:
--------------------------------------------------------------------------------
1 | import { Bookmark, Play, Trash2 } from 'lucide-preact';
2 | import { Badge } from '~/components/ui/badge';
3 | import { useRadioBrowser } from '~/hooks/useRadioBrowser';
4 | import { cn } from '~/lib/utils';
5 | import { playerState } from '~/store/signals/player';
6 | import {
7 | addRadioBrowserStation,
8 | addRecentlyVisitedRadioStation,
9 | deleteBrowserStation,
10 | followedRadioStationIDs,
11 | getRadioStationLanguage,
12 | RADIO_BROWSER_PARAM_PREFIX,
13 | } from '~/store/signals/radio';
14 | import { RadioStation } from '~/store/types';
15 | import { Button } from './ui/button';
16 |
17 | interface Props {
18 | station: RadioStation;
19 | size?: 'default' | 'large';
20 | hasDeleteHidden?: boolean;
21 | }
22 |
23 | export const RadioStationCard = ({ station, size = 'default', hasDeleteHidden = false }: Props) => {
24 | const { setStationClick } = useRadioBrowser();
25 |
26 | const onClickPlay = (e: { preventDefault: () => void; stopPropagation: () => void }) => {
27 | e.preventDefault();
28 | e.stopPropagation();
29 | addRadioBrowserStation(station);
30 | addRecentlyVisitedRadioStation(station.id);
31 | playerState.value = {
32 | playType: 'radio',
33 | isPlaying: true,
34 | contentID: station.id,
35 | title: station.name,
36 | description: '',
37 | imageUrl: station.logosource,
38 | streams: station.streams,
39 | pageLocation: `/radio-station/${station.id}`,
40 | };
41 | if (station.stationuuid) {
42 | setStationClick(station.stationuuid);
43 | }
44 | };
45 |
46 | const handleDeleteStation = (e: { preventDefault: () => void; stopPropagation: () => void }) => {
47 | e.preventDefault();
48 | e.stopPropagation();
49 | if (
50 | station.id.startsWith(RADIO_BROWSER_PARAM_PREFIX) &&
51 | confirm(
52 | `This is a locally saved station (from RadioBrowser).\n\nAre you sure you want to delete "${station.name}"?`,
53 | )
54 | ) {
55 | deleteBrowserStation(station.id);
56 | }
57 | };
58 |
59 | const RadioFlag = () => {
60 | const stationLanguage = getRadioStationLanguage(station);
61 | if (!stationLanguage?.flag) return null;
62 |
63 | return (
64 |
65 | {stationLanguage.flag}
66 |
67 | );
68 | };
69 |
70 | const isFollowing = followedRadioStationIDs.value.includes(station.id);
71 |
72 | if (size === 'large') {
73 | return (
74 |
79 |
80 |
81 |
90 |
91 |
92 |
93 |
98 |
99 |
100 |
101 |
102 |
110 | {station.name}
111 | {!hasDeleteHidden && station.id.startsWith(RADIO_BROWSER_PARAM_PREFIX) && (
112 |
119 |
120 |
121 | )}
122 |
123 |
124 |
125 | {station.genres?.map((genre) => (
126 |
127 | {genre}
128 |
129 | ))}
130 |
131 |
132 | {followedRadioStationIDs.value.includes(station.id) && (
133 |
137 | )}
138 |
139 |
140 | );
141 | }
142 |
143 | return (
144 |
145 |
146 |
155 |
156 |
157 |
162 |
163 |
164 |
165 |
166 | {station.name}
167 |
168 |
169 |
170 |
171 |
172 | );
173 | };
174 |
--------------------------------------------------------------------------------
/src/components/share-button.tsx:
--------------------------------------------------------------------------------
1 | import { Share2 } from 'lucide-preact';
2 | import { useCallback } from 'preact/hooks';
3 | import { cn } from '~/lib/utils';
4 | import { uiState } from '~/store/signals/ui';
5 |
6 | type Props = {
7 | hasDarkBackground: boolean;
8 | shareUrl?: string;
9 | className?: string;
10 | };
11 |
12 | export function ShareButton({ hasDarkBackground, shareUrl, className }: Props) {
13 | const handleShare = useCallback(async () => {
14 | if (!navigator.share) return;
15 | try {
16 | await navigator.share({
17 | title: uiState.value.headerTitle || '1tuner.com | listen to radio & podcasts',
18 | url: shareUrl || window?.location.href,
19 | });
20 | } catch (error) {
21 | console.error('Error sharing:', error);
22 | }
23 | }, [shareUrl, uiState.value.headerTitle]);
24 |
25 | return (
26 |
35 |
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/src/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import { cva, type VariantProps } from 'class-variance-authority';
2 | import { HTMLAttributes } from 'preact/compat';
3 | import { cn } from '~/lib/utils';
4 |
5 | const badgeVariants = cva(
6 | 'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2',
7 | {
8 | variants: {
9 | variant: {
10 | default: 'border-transparent bg-primary text-primary-foreground shadow-sm hover:bg-primary/80',
11 | secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
12 | destructive: 'border-transparent bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/80',
13 | outline: 'text-foreground',
14 | },
15 | },
16 | defaultVariants: {
17 | variant: 'default',
18 | },
19 | },
20 | );
21 |
22 | export interface BadgeProps extends HTMLAttributes, VariantProps {}
23 |
24 | function Badge({ variant, ...props }: BadgeProps) {
25 | return
;
26 | }
27 |
28 | export { Badge, badgeVariants };
29 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import { cva, type VariantProps } from 'class-variance-authority';
2 | import { ComponentChildren } from 'preact';
3 | import { ButtonHTMLAttributes, forwardRef } from 'preact/compat';
4 | import { cn } from '~/lib/utils';
5 |
6 | const buttonVariants = cva(
7 | cn(
8 | 'inline-flex items-center justify-center whitespace-nowrap text-sm font-medium transition-colors cursor-pointer',
9 | 'focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none',
10 | 'disabled:opacity-50',
11 | ),
12 | {
13 | variants: {
14 | variant: {
15 | default: 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90',
16 | destructive: 'bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90',
17 | outline: 'border border-current/33 bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
18 | secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
19 | ghost: 'border border-white/10 text-white/70 hover:border-white/30 hover:text-white/90',
20 | link: 'text-primary underline-offset-4 hover:underline',
21 | },
22 | styleSize: {
23 | default: 'h-9 px-4 py-2 rounded-lg',
24 | sm: 'h-8 rounded-lg px-3 text-xs',
25 | lg: 'h-10 rounded-lg px-8',
26 | icon: 'h-12 w-12 rounded-full',
27 | },
28 | },
29 | defaultVariants: {
30 | variant: 'default',
31 | styleSize: 'default',
32 | },
33 | },
34 | );
35 |
36 | export interface ButtonProps extends ButtonHTMLAttributes, VariantProps {
37 | asChild?: boolean;
38 | children?: ComponentChildren;
39 | }
40 |
41 | export const Button = forwardRef(
42 | ({ variant, styleSize, asChild = false, children, ...props }, ref) => {
43 | const classes = buttonVariants({ variant, styleSize, class: props.class });
44 |
45 | if (asChild && children && typeof children === 'object' && 'type' in children) {
46 | const Child = children.type;
47 | // @ts-expect-error - Passing refs through might need additional typing
48 | return ;
49 | }
50 |
51 | return (
52 |
53 | {children}
54 |
55 | );
56 | },
57 | );
58 |
--------------------------------------------------------------------------------
/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import { forwardRef, HTMLAttributes } from 'preact/compat';
2 | import { cn } from '~/lib/utils';
3 |
4 | const Card = forwardRef>(({ ...props }, ref) => (
5 |
6 | ));
7 | Card.displayName = 'Card';
8 |
9 | const CardHeader = forwardRef>(({ ...props }, ref) => (
10 |
11 | ));
12 | CardHeader.displayName = 'CardHeader';
13 |
14 | const CardTitle = forwardRef>(({ ...props }, ref) => (
15 |
16 | ));
17 | CardTitle.displayName = 'CardTitle';
18 |
19 | const CardDescription = forwardRef>(({ ...props }, ref) => (
20 |
21 | ));
22 | CardDescription.displayName = 'CardDescription';
23 |
24 | const CardContent = forwardRef>(({ ...props }, ref) => (
25 |
26 | ));
27 | CardContent.displayName = 'CardContent';
28 |
29 | const CardFooter = forwardRef>(({ ...props }, ref) => (
30 |
31 | ));
32 | CardFooter.displayName = 'CardFooter';
33 |
34 | export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
35 |
--------------------------------------------------------------------------------
/src/components/ui/dropdown-list.tsx:
--------------------------------------------------------------------------------
1 | import { ChevronDown } from 'lucide-preact';
2 | import { JSX } from 'preact';
3 | import { HTMLAttributes, useRef, useState } from 'preact/compat';
4 | import { cn } from '~/lib/utils';
5 |
6 | export interface DropdownOption {
7 | value: string;
8 | label: JSX.Element | string;
9 | searchableLabel?: string;
10 | }
11 |
12 | interface DropdownListProps extends HTMLAttributes {
13 | options: DropdownOption[];
14 | value?: string;
15 | onChangeOption: (value: string) => void;
16 | placeholder?: string;
17 | align?: 'left' | 'right';
18 | useNativePopover?: boolean;
19 | id?: string;
20 | trigger?: React.ReactNode;
21 | }
22 |
23 | export function DropdownList({
24 | options,
25 | value,
26 | onChangeOption,
27 | placeholder = 'Select...',
28 | align = 'left',
29 | useNativePopover = false,
30 | trigger,
31 | ...props
32 | }: DropdownListProps) {
33 | const [isOpen, setIsOpen] = useState(false);
34 | const [searchTerm, setSearchTerm] = useState('');
35 | const [highlightedIndex, setHighlightedIndex] = useState(0);
36 | const popoverRef = useRef(null);
37 | const searchInputRef = useRef(null);
38 |
39 | const selectedOption = options.find((o) => o.value === value);
40 | const filteredOptions = options.filter((o) => {
41 | const searchableText = o.searchableLabel || (typeof o.label === 'string' ? o.label : '');
42 | return searchableText.toLowerCase().includes(searchTerm.toLowerCase());
43 | });
44 |
45 | const closePopover = () => {
46 | if (useNativePopover && popoverRef.current) {
47 | popoverRef.current.hidePopover();
48 | } else {
49 | setIsOpen(false);
50 | }
51 | setSearchTerm('');
52 | };
53 |
54 | const handleKeyDown = (e: KeyboardEvent) => {
55 | switch (e.key) {
56 | case 'ArrowDown':
57 | e.preventDefault();
58 | setHighlightedIndex((prev) => (prev < filteredOptions.length - 1 ? prev + 1 : prev));
59 | break;
60 | case 'ArrowUp':
61 | e.preventDefault();
62 | setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : prev));
63 | break;
64 | case 'Enter':
65 | e.preventDefault();
66 | if (filteredOptions[highlightedIndex]) {
67 | onChangeOption(filteredOptions[highlightedIndex].value);
68 | closePopover();
69 | setSearchTerm('');
70 | }
71 | break;
72 | case 'Escape':
73 | closePopover();
74 | setSearchTerm('');
75 | break;
76 | }
77 | };
78 |
79 | const defaultTrigger = (
80 | {
83 | if (useNativePopover && popoverRef.current) {
84 | popoverRef.current.togglePopover();
85 | } else {
86 | setIsOpen(!isOpen);
87 | }
88 | }}
89 | popoverTarget={useNativePopover ? `${props.id}-popover` : undefined}
90 | class={cn(
91 | 'inline-flex items-center justify-between w-full px-3 py-2',
92 | 'border rounded-lg bg-background shadow-xs',
93 | 'hover:bg-accent hover:text-accent-foreground',
94 | )}
95 | >
96 | {selectedOption?.label || placeholder}
97 |
98 |
99 | );
100 |
101 | const content = (
102 |
115 |
116 | {
121 | setSearchTerm((e.target as HTMLInputElement).value);
122 | setHighlightedIndex(0);
123 | }}
124 | placeholder="Type to search..."
125 | class="w-full px-3 py-2 border rounded-md focus:outline-hidden focus:ring-2"
126 | />
127 |
128 |
129 | {!filteredOptions.length ? (
130 | No options found
131 | ) : (
132 | filteredOptions.map((option, index) => (
133 | {
138 | onChangeOption(option.value);
139 | closePopover();
140 | }}
141 | class={cn(
142 | 'px-4 py-2 cursor-pointer flex items-center gap-2',
143 | 'hover:bg-accent hover:text-accent-foreground',
144 | index === highlightedIndex && 'bg-accent text-accent-foreground',
145 | )}
146 | >
147 | {option.label}
148 |
149 | ))
150 | )}
151 |
152 |
153 | );
154 |
155 | return (
156 |
157 | {trigger || defaultTrigger}
158 | {!useNativePopover && isOpen && content}
159 | {useNativePopover && content}
160 |
161 | );
162 | }
163 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import { forwardRef, InputHTMLAttributes } from 'preact/compat';
2 | import { cn } from '~/lib/utils';
3 |
4 | const Input = forwardRef>(({ type, ...props }, ref) => {
5 | return (
6 |
19 | );
20 | });
21 | Input.displayName = 'Input';
22 |
23 | export { Input };
24 |
--------------------------------------------------------------------------------
/src/components/ui/radio-button-list.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback } from 'preact/hooks';
2 | import { RadioButton as RadioButtonListOption, RadioButtonProps } from './radio-button';
3 |
4 | export type RadioButtonListOption = Pick;
5 |
6 | type RadioButtonListProps = {
7 | name: string;
8 | options: RadioButtonListOption[];
9 | value: string;
10 | onChange: (value: string) => void;
11 | };
12 |
13 | export const RadioButtonList = ({ name, options, value, onChange }: RadioButtonListProps) => {
14 | const onRadioButtonChange = useCallback((value: string) => {
15 | onChange(value);
16 | }, [onChange]);
17 |
18 | return (
19 |
20 | {options.map((option) => {
21 | return (
22 |
23 |
31 |
32 | );
33 | })}
34 |
35 | );
36 | };
37 |
--------------------------------------------------------------------------------
/src/components/ui/radio-button.tsx:
--------------------------------------------------------------------------------
1 | export type RadioButtonProps = {
2 | name: string;
3 | label: string;
4 | value: string;
5 | checked: boolean;
6 | description?: string;
7 | onChange: (value: string) => void;
8 | };
9 |
10 | export const RadioButton = ({ name, label, value, checked, description, onChange }: RadioButtonProps) => {
11 | const id = `${name}-${value}`;
12 |
13 | const onClick = (e: MouseEvent) => {
14 | onChange((e.currentTarget as HTMLInputElement).value);
15 | };
16 |
17 | return (
18 |
19 |
20 |
29 |
30 |
31 |
32 |
33 |
{label}
34 | {!!description &&
{description}
}
35 |
36 |
37 |
38 | );
39 | };
40 |
--------------------------------------------------------------------------------
/src/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | import type { ComponentProps } from 'preact';
2 | import { cn } from '~/lib/utils';
3 |
4 | type Props = ComponentProps<'input'> & {
5 | label?: string;
6 | };
7 |
8 | export function Switch({ label, ...props }: Props) {
9 | return (
10 |
11 |
12 |
13 |
27 |
28 | {label &&
{label} }
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/src/global.d.ts:
--------------------------------------------------------------------------------
1 | interface Window {
2 | __HEAD_DATA__?: HeadData;
3 | chrome: {
4 | cast: {
5 | initialized: boolean;
6 | ApiConfig: typeof chrome.cast.ApiConfig;
7 | initialize: (
8 | apiConfig: chrome.cast.ApiConfig,
9 | successCallback: () => void,
10 | errorCallback: (error: Error) => void,
11 | ) => void;
12 | SessionRequest: typeof chrome.cast.SessionRequest;
13 | isAvailable: boolean;
14 | requestSession: (
15 | successCallback: (session: chrome.cast.Session) => void,
16 | errorCallback: (error: Error) => void,
17 | ) => void;
18 | media: {
19 | LoadRequest: typeof chrome.cast.media.LoadRequest;
20 | MediaInfo: typeof chrome.cast.media.MediaInfo;
21 | GenericMediaMetadata: typeof chrome.cast.media.GenericMediaMetadata;
22 | };
23 | };
24 | };
25 | __onGCastApiAvailable?: (isAvailable: boolean) => void;
26 | }
27 |
28 | // declare namespace chrome.cast {
29 | // interface Session {
30 | // loadMedia(request: media.LoadRequest, arg1: (media: any) => void, arg2: (error: any) => void): unknown;
31 | // media: any;
32 | // stop: () => void;
33 | // }
34 |
35 | // class ApiConfig {
36 | // constructor(
37 | // sessionRequest: SessionRequest,
38 | // sessionListener: (session: Session) => void,
39 | // receiverListener: (availability: string) => void,
40 | // );
41 | // }
42 |
43 | // class SessionRequest {
44 | // constructor(applicationId: string);
45 | // }
46 |
47 | // namespace media {
48 | // interface LoadRequest {
49 | // mediaInfo: MediaInfo;
50 | // }
51 |
52 | // interface MediaInfo {
53 | // contentUrl: string;
54 | // contentType: string;
55 | // metadata?: GenericMediaMetadata;
56 | // }
57 |
58 | // interface GenericMediaMetadata {
59 | // title?: string;
60 | // subtitle?: string;
61 | // images?: ImageInfo[];
62 | // }
63 |
64 | // interface ImageInfo {
65 | // url: string;
66 | // }
67 |
68 | // interface Media {
69 | // getEstimatedTime(): number;
70 | // seek(request: any): unknown;
71 | // duration(
72 | // arg0: string,
73 | // playerState: any,
74 | // currentTime: (arg0: string, playerState: any, currentTime: any, duration: any) => unknown,
75 | // duration: any,
76 | // ): unknown;
77 | // currentTime(arg0: string, playerState: any, currentTime: any, duration: any): unknown;
78 | // addUpdateListener(handleMediaStatusUpdate: (isAlive: boolean) => void): unknown;
79 | // removeUpdateListener(): unknown;
80 | // playerState: any;
81 | // play: (request?: any) => void;
82 | // pause: (request?: any) => void;
83 | // }
84 |
85 | // class LoadRequest {
86 | // autoplay: boolean;
87 | // constructor(mediaInfo: MediaInfo);
88 | // }
89 |
90 | // class MediaInfo {
91 | // constructor(url: string, contentType: string);
92 | // metadata: GenericMediaMetadata;
93 | // }
94 |
95 | // class GenericMediaMetadata {
96 | // constructor();
97 | // title: string;
98 | // subtitle: string;
99 | // images: ImageInfo[];
100 | // }
101 | // }
102 | // }
103 |
--------------------------------------------------------------------------------
/src/hooks/useHead.ts:
--------------------------------------------------------------------------------
1 | import { useLayoutEffect } from 'preact/hooks';
2 |
3 | export type HeadData = {
4 | title: string;
5 | description?: string;
6 | image?: string;
7 | url?: string;
8 | type?: string;
9 | };
10 |
11 | export const defaultHeadData = {
12 | title: '1tuner | listen to radio and podcasts',
13 | description: 'Listen to radio and podcasts for free. Discover new music and shows, create your own playlists.',
14 | image: `${import.meta.env.VITE_BASE_URL}/assets/social-share.jpg`,
15 | url: import.meta.env.VITE_BASE_URL,
16 | type: 'website',
17 | };
18 |
19 | export const useHead = (data: HeadData) => {
20 | // For prerendering
21 | if (
22 | typeof window === 'undefined' &&
23 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
24 | (typeof (globalThis as any).__HEAD_DATA__ === 'undefined' || (globalThis as any).__HEAD_DATA__.title !== data.title)
25 | ) {
26 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
27 | (globalThis as any).__HEAD_DATA__ = { ...defaultHeadData, ...data, title: `${data.title} | 1tuner.com` };
28 | }
29 |
30 | useLayoutEffect(() => {
31 | if (typeof document === 'undefined') return;
32 |
33 | const updateMetaTag = (property: string, content: string) => {
34 | let element = document.querySelector(`meta[property="${property}"]`);
35 | if (!element) {
36 | element = document.createElement('meta');
37 | element.setAttribute('property', property);
38 | document.head.appendChild(element);
39 | }
40 | element.setAttribute('content', content);
41 | };
42 |
43 | const updateHead = (newData: HeadData) => {
44 | document.title = newData.title ? `${newData.title} | 1tuner.com` : defaultHeadData.title;
45 | updateMetaTag('og:title', newData.title || defaultHeadData.title);
46 | updateMetaTag('og:description', newData.description || '');
47 | updateMetaTag('og:image', newData.image || defaultHeadData.image);
48 | updateMetaTag('og:url', newData.url || defaultHeadData.url);
49 | updateMetaTag('og:type', newData.type || defaultHeadData.type);
50 | };
51 | updateHead(data);
52 |
53 | return () => {
54 | if (typeof document === 'undefined') return;
55 | updateHead(defaultHeadData);
56 | };
57 | }, [data]);
58 | };
59 |
--------------------------------------------------------------------------------
/src/hooks/useNoise.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useRef } from 'preact/hooks';
2 | import { settingsState } from '~/store/signals/settings';
3 |
4 | export const useNoise = () => {
5 | const audioContextRef = useRef(null);
6 | const noiseSourceRef = useRef(null);
7 | const gainNodeRef = useRef(null);
8 |
9 | const startNoise = useCallback(() => {
10 | if (noiseSourceRef.current || settingsState.value.disableReconnectNoise) {
11 | return; // Noise is already playing or reconnect noise is disabled
12 | }
13 | if (!audioContextRef.current) {
14 | audioContextRef.current = new AudioContext();
15 | }
16 |
17 | const audioContext = audioContextRef.current;
18 |
19 | // Create a buffer with random noise
20 | const bufferSize = audioContext.sampleRate * 1; // 1 second of audio
21 | const buffer = audioContext.createBuffer(1, bufferSize, audioContext.sampleRate);
22 | const data = buffer.getChannelData(0);
23 |
24 | for (let i = 0; i < bufferSize; i++) {
25 | data[i] = Math.random() * 2 - 1; // Generate random noise
26 | }
27 |
28 | // Create a buffer source
29 | const noiseSource = audioContext.createBufferSource();
30 | noiseSource.buffer = buffer;
31 | noiseSource.loop = true;
32 |
33 | // Create a gain node to control the volume
34 | const gainNode = audioContext.createGain();
35 | gainNode.gain.value = 0.1; // Set the volume (0.1 = 10% of full volume)
36 |
37 | // Connect the noise source to the gain node, then to the destination
38 | noiseSource.connect(gainNode);
39 | gainNode.connect(audioContext.destination);
40 |
41 | noiseSource.start();
42 |
43 | noiseSourceRef.current = noiseSource;
44 | gainNodeRef.current = gainNode;
45 | }, [settingsState.value.disableReconnectNoise]);
46 |
47 | const stopNoise = useCallback(() => {
48 | if (noiseSourceRef.current) {
49 | noiseSourceRef.current.stop();
50 | noiseSourceRef.current.disconnect();
51 | noiseSourceRef.current = null;
52 | }
53 |
54 | if (gainNodeRef.current) {
55 | gainNodeRef.current.disconnect();
56 | gainNodeRef.current = null;
57 | }
58 |
59 | if (audioContextRef.current) {
60 | audioContextRef.current.close();
61 | audioContextRef.current = null;
62 | }
63 | }, []);
64 |
65 | return {
66 | startNoise,
67 | stopNoise,
68 | };
69 | };
70 |
--------------------------------------------------------------------------------
/src/hooks/usePodcastData.ts:
--------------------------------------------------------------------------------
1 | import { XMLParser } from 'fast-xml-parser';
2 | import { useCallback, useState } from 'preact/hooks';
3 | import { delay, getTimeStringFromSeconds } from '~/lib/utils';
4 | import { getPodcast } from '~/store/signals/podcast';
5 | import { Podcast } from '~/store/types';
6 |
7 | const FETCH_TIMEOUT = 10000; // 10 seconds
8 | const MAX_RETRIES = 2;
9 | const VALID_CONTENT_TYPES = [
10 | 'application/rss+xml',
11 | 'application/xml',
12 | 'text/xml',
13 | 'application/rdf+xml',
14 | 'application/text',
15 | 'text/plain',
16 | 'text/plain;charset=utf-8',
17 | ];
18 |
19 | const isValidPodcastFeed = (xmlData: string): boolean => {
20 | const hasRssTag = /]*>/i.test(xmlData);
21 | const hasChannelTag = /]*>/i.test(xmlData);
22 | const hasItemTag = /- ]*>/i.test(xmlData);
23 | const hasEnclosureTag = /
]*>/i.test(xmlData);
24 |
25 | return hasRssTag && hasChannelTag && hasItemTag && hasEnclosureTag;
26 | };
27 |
28 | export const usePodcastData = () => {
29 | const [isLoading, setIsLoading] = useState(false);
30 |
31 | const getDurationString = useCallback((duration: string) => {
32 | const durationParts = duration.split(':');
33 | if (durationParts.length >= 2) {
34 | return `${durationParts[0]}:${durationParts[1]}`;
35 | }
36 | return getTimeStringFromSeconds(+duration);
37 | }, []);
38 |
39 | const fetchWithTimeout = useCallback(async (url: string, options: RequestInit = {}) => {
40 | const controller = new AbortController();
41 | const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
42 |
43 | try {
44 | const response = await fetch(url, {
45 | ...options,
46 | signal: controller.signal,
47 | });
48 | clearTimeout(timeoutId);
49 | return response;
50 | } catch (error) {
51 | clearTimeout(timeoutId);
52 | throw error;
53 | }
54 | }, []);
55 |
56 | const getResponseText = useCallback(async (response: Response) => {
57 | if (!response.ok) throw new Error('Network response failed');
58 |
59 | // Validate content type
60 | const contentType = response.headers.get('content-type')?.toLowerCase() || '';
61 | const isValidContentType = VALID_CONTENT_TYPES.some((type) => contentType.includes(type));
62 |
63 | if (!isValidContentType) {
64 | throw new Error('Invalid content type: Not a valid RSS feed');
65 | }
66 |
67 | const xmlData = await response.text();
68 |
69 | if (!isValidPodcastFeed(xmlData)) {
70 | throw new Error('Invalid feed: Not a podcast RSS feed');
71 | }
72 |
73 | return xmlData;
74 | }, []);
75 |
76 | const fetchFeed = useCallback(
77 | async (feedUrl: string, retryCount = 0): Promise => {
78 | // Try direct fetch first
79 | try {
80 | const response = await fetchWithTimeout(feedUrl);
81 | return await getResponseText(response);
82 | } catch {
83 | // If direct fetch fails, try proxy
84 | try {
85 | const proxyResponse = await fetchWithTimeout('https://request.tuner.workers.dev', {
86 | method: 'POST',
87 | body: feedUrl,
88 | });
89 | return await getResponseText(proxyResponse);
90 | } catch (proxyError) {
91 | // If we haven't reached max retries, try again after a delay
92 | if (retryCount < MAX_RETRIES) {
93 | await delay(1000 * (retryCount + 1)); // Exponential backoff
94 | return fetchFeed(feedUrl, retryCount + 1);
95 | }
96 | throw new Error(
97 | `Failed to fetch feed after ${MAX_RETRIES} retries: ${
98 | proxyError instanceof Error ? proxyError.message : 'Unknown error'
99 | }`,
100 | );
101 | }
102 | }
103 | },
104 | [fetchWithTimeout, getResponseText],
105 | );
106 |
107 | const fetchPodcastData = useCallback(
108 | async (id: string, feedUrl: string, skipCache = false) => {
109 | if (!id || !feedUrl) return null;
110 |
111 | let podcastData = getPodcast(id);
112 | setIsLoading(true);
113 |
114 | try {
115 | if (!podcastData || skipCache || Date.now() - podcastData.lastFetched > 24 * 60 * 60 * 1000) {
116 | const xmlData = await fetchFeed(feedUrl);
117 |
118 | const parser = new XMLParser({
119 | ignoreAttributes: false,
120 | attributeNamePrefix: '@_',
121 | });
122 | const result = parser.parse(xmlData);
123 |
124 | if (!result.rss || !result.rss.channel) {
125 | throw new Error('Invalid podcast RSS feed structure');
126 | }
127 |
128 | const channel = result.rss.channel;
129 |
130 | podcastData = {
131 | id,
132 | title: channel.title,
133 | description: channel.description,
134 | imageUrl: channel.image?.url || channel['itunes:image']?.['@_href'] || '',
135 | url: feedUrl,
136 | feedUrl: feedUrl,
137 | categories: channel.categories,
138 | addedDate: podcastData?.addedDate || Date.now(),
139 | lastFetched: Date.now(),
140 | episodes: (channel.item || [])
141 | .slice(0, 50)
142 | .map(
143 | (item: {
144 | title: string;
145 | description: string;
146 | guid?: { '#text': string };
147 | pubDate: string;
148 | enclosure?: { '@_url': string; '@_type': string };
149 | 'itunes:duration'?: string;
150 | duration?: string;
151 | }) => ({
152 | title: item.title,
153 | description: item.description,
154 | guid: item.guid?.['#text'],
155 | pubDate: new Date(item.pubDate),
156 | audio: item.enclosure?.['@_url'],
157 | mimeType: item.enclosure?.['@_type'],
158 | duration: getDurationString(`${item['itunes:duration'] ?? item['duration']}`),
159 | currentTime:
160 | podcastData?.episodes?.find((ep) => ep.audio === item.enclosure?.['@_url'])?.currentTime || 0,
161 | }),
162 | ),
163 | } as Podcast;
164 | }
165 | return podcastData;
166 | } catch (error) {
167 | console.error('Error fetching podcast:', error);
168 | return null;
169 | } finally {
170 | setIsLoading(false);
171 | }
172 | },
173 | [getDurationString, fetchFeed],
174 | );
175 |
176 | return {
177 | isLoading,
178 | fetchPodcastData,
179 | };
180 | };
181 |
--------------------------------------------------------------------------------
/src/hooks/useRadioBrowser.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useState } from 'preact/hooks';
2 | import { validationUtil } from '~/lib/validationUtil';
3 | import { lastRadioSearchResult, RADIO_BROWSER_PARAM_PREFIX, radioLanguages } from '~/store/signals/radio';
4 | import { RadioStation } from '~/store/types';
5 |
6 | type RadioBrowserStation = {
7 | stationuuid: string;
8 | name: string;
9 | url: string;
10 | url_resolved: string;
11 | favicon: string;
12 | tags: string;
13 | countrycode: string;
14 | codec: string;
15 | homepage: string;
16 | lastchangetime_iso8601: string;
17 | };
18 |
19 | export const useRadioBrowser = () => {
20 | const [isLoading, setIsLoading] = useState(false);
21 |
22 | const getMimeType = (codec: string | null | undefined) => {
23 | switch (codec?.toLowerCase()) {
24 | case 'mp3':
25 | return 'audio/mpeg';
26 | case 'aac':
27 | case 'aac+':
28 | return 'audio/aac';
29 | case 'ogg':
30 | return 'audio/ogg';
31 | case 'wma':
32 | return 'audio/x-ms-wma';
33 | case 'flac':
34 | return 'audio/flac';
35 | case 'hls':
36 | return 'application/vnd.apple.mpegurl';
37 | case 'opus':
38 | return 'audio/ogg; codecs=opus';
39 | default:
40 | return 'audio/mpeg'; // Fallback MIME type
41 | }
42 | };
43 |
44 | const mapRadioBrowserStation = useCallback((station: RadioBrowserStation, index = 0): RadioStation => {
45 | const language = radioLanguages.value.find((l) => l.abbr === station.countrycode)?.id || station.countrycode;
46 | return {
47 | id: `rb-${station.stationuuid}`,
48 | stationuuid: station.stationuuid,
49 | name: station.name,
50 | logosource: station.favicon,
51 | streams: [{ url: station.url_resolved, mimetype: getMimeType(station.codec) }],
52 | displayorder: index + 1,
53 | language,
54 | lastChanged: station.lastchangetime_iso8601,
55 | website: station.homepage,
56 | genres: station.tags
57 | .split(',')
58 | .map((tag) => tag.trim())
59 | .filter(Boolean),
60 | };
61 | }, []);
62 |
63 | const getStation = useCallback(
64 | async (stationID: string) => {
65 | const stationuuid = validationUtil.getSanitizedUuid(stationID?.replace(RADIO_BROWSER_PARAM_PREFIX, ''));
66 | if (!stationuuid || stationuuid.length !== 36) return null;
67 | const stationFromSearchResult = lastRadioSearchResult.value?.radioBrowserSearchResult?.find(
68 | (s) => s.stationuuid === stationuuid,
69 | );
70 | if (stationFromSearchResult) return stationFromSearchResult;
71 | setIsLoading(true);
72 | try {
73 | const response = await fetch(`${import.meta.env.VITE_RADIO_BROWSER_WORKER_URL}/station`, {
74 | method: 'POST',
75 | headers: {
76 | 'Content-Type': 'application/x-www-form-urlencoded',
77 | },
78 | body: new URLSearchParams({ uuids: stationuuid }),
79 | });
80 |
81 | if (!response.ok) {
82 | throw new Error('Station request failed');
83 | }
84 |
85 | const data = (await response.json()) as RadioBrowserStation[];
86 | return data.length > 0 ? mapRadioBrowserStation(data[0]) : null;
87 | } catch (error) {
88 | console.error('Error fetching station:', error);
89 | return null;
90 | } finally {
91 | setIsLoading(false);
92 | }
93 | },
94 | [mapRadioBrowserStation],
95 | );
96 |
97 | const searchStations = useCallback(
98 | async (params: Record) => {
99 | setIsLoading(true);
100 | try {
101 | const response = await fetch(`${import.meta.env.VITE_RADIO_BROWSER_WORKER_URL}/stations`, {
102 | method: 'POST',
103 | headers: {
104 | 'Content-Type': 'application/x-www-form-urlencoded',
105 | },
106 | body: new URLSearchParams(params),
107 | });
108 |
109 | if (!response.ok) {
110 | throw new Error('Search request failed');
111 | }
112 |
113 | const data = (await response.json()) as RadioBrowserStation[];
114 |
115 | return data
116 | .filter((s) => s.favicon && s.favicon !== 'null' && s.url_resolved)
117 | .map((station, index): RadioStation => mapRadioBrowserStation(station, index));
118 | } catch (error) {
119 | console.error('Error fetching stations:', error);
120 | return [];
121 | } finally {
122 | setIsLoading(false);
123 | }
124 | },
125 | [mapRadioBrowserStation],
126 | );
127 |
128 | const searchStationsByQuery = useCallback(
129 | async (query: string) => {
130 | return searchStations({ name: query });
131 | },
132 | [searchStations],
133 | );
134 |
135 | const searchStationsByCountry = useCallback(
136 | async (country: string) => {
137 | return searchStations({
138 | countrycode: country.toLowerCase() === 'uk' ? 'gb' : country,
139 | reverse: 'true',
140 | order: 'clickcount',
141 | limit: '20',
142 | });
143 | },
144 | [searchStations],
145 | );
146 |
147 | const setStationClick = useCallback(async (uuid: string) => {
148 | if (!uuid) return;
149 | try {
150 | fetch(`${import.meta.env.VITE_RADIO_BROWSER_WORKER_URL}/station-click`, {
151 | method: 'POST',
152 | headers: {
153 | 'Content-Type': 'application/x-www-form-urlencoded',
154 | },
155 | body: new URLSearchParams({ uuid: uuid }),
156 | });
157 | } catch (error) {
158 | console.error('Error station click:', error);
159 | }
160 | }, []);
161 |
162 | return {
163 | isLoading,
164 | searchStationsByQuery,
165 | searchStationsByCountry,
166 | getStation,
167 | setStationClick,
168 | };
169 | };
170 |
--------------------------------------------------------------------------------
/src/lib/convertTime.ts:
--------------------------------------------------------------------------------
1 | export const getLocalTimeFromUrlKey = (key: string, timeZone?: string) => {
2 | try {
3 | const hourMatch = key.match(/h(\d+)/);
4 | if (!hourMatch) return undefined;
5 | let h = +hourMatch[1];
6 | if (h < 0 || h > 23) return undefined;
7 |
8 | const minuteMatch = key.match(/m(\d+)/);
9 | let m = minuteMatch ? +minuteMatch[1] : 0;
10 | if (m < 0 || m > 59) return undefined;
11 |
12 | if (timeZone) {
13 | const { hour, minute } = convertToTargetTimezone(h, m, timeZone);
14 | h = hour;
15 | m = minute;
16 | }
17 |
18 | return `${`${h}`.padStart(2, '0')}:${`${m}`.padStart(2, '0')}`;
19 | } catch {
20 | return undefined;
21 | }
22 | };
23 |
24 | export const getValidTimeZone = (timeZone: string | undefined) => {
25 | if (!!timeZone && isValidTimeZone(timeZone)) return timeZone;
26 | return Intl.DateTimeFormat().resolvedOptions().timeZone;
27 | };
28 |
29 | export const convertToTargetTimezone = (hour: number, minute = 0, sourceTimeZone: string, targetTimeZone?: string) => {
30 | // First get a ISO string of the current date in UTC
31 | const now = new Date();
32 | const year = now.getUTCFullYear();
33 | const month = (now.getUTCMonth() + 1).toString().padStart(2, '0');
34 | const day = now.getUTCDate().toString().padStart(2, '0');
35 | const hourStr = hour.toString().padStart(2, '0');
36 | const minuteStr = minute.toString().padStart(2, '0');
37 |
38 | // Create a Date object for the source timezone
39 | const sourceDate = new Date(`${year}-${month}-${day}T${hourStr}:${minuteStr}:00${getTimeZoneOffset(sourceTimeZone)}`);
40 |
41 | const timeZone = getValidTimeZone(targetTimeZone);
42 |
43 | // Convert the source date to the target timezone
44 | return {
45 | hour: +sourceDate.toLocaleString('en-US', { hour: 'numeric', hour12: false, timeZone: timeZone }),
46 | minute: +sourceDate.toLocaleString('en-US', { minute: 'numeric', timeZone: timeZone }),
47 | };
48 | };
49 |
50 | const getTimeZoneOffset = (timezone: string) => {
51 | const date = new Date();
52 | const utcDate = new Date(date.toLocaleString('en-US', { timeZone: 'UTC' })).getTime();
53 | const tzDate = new Date(date.toLocaleString('en-US', { timeZone: timezone })).getTime();
54 | const offset = (tzDate - utcDate) / 60000; // Offset in minutes
55 |
56 | const hours = Math.floor(Math.abs(offset) / 60)
57 | .toString()
58 | .padStart(2, '0');
59 | const minutes = (Math.abs(offset) % 60).toString().padStart(2, '0');
60 | return `${offset >= 0 ? '+' : '-'}${hours}:${minutes}`;
61 | };
62 |
63 | const isValidTimeZone = (timezone: string) => {
64 | try {
65 | // Try to create a Date object with the given timezone to validate it
66 | new Date().toLocaleString('en-US', { timeZone: timezone });
67 | return true;
68 | } catch {
69 | return false;
70 | }
71 | };
72 |
--------------------------------------------------------------------------------
/src/lib/opmlUtil.ts:
--------------------------------------------------------------------------------
1 | import { Podcast } from '~/store/types';
2 |
3 | const escapeXml = (unsafe: string): string => {
4 | return unsafe
5 | .replace(/&/g, '&')
6 | .replace(//g, '>')
8 | .replace(/"/g, '"')
9 | .replace(/'/g, ''');
10 | };
11 |
12 | const generatePodcastsOpml = (podcasts: Podcast[]) => {
13 | const date = new Date().toISOString();
14 | return `
15 |
16 |
17 | 1tuner.com export
18 | ${date}
19 |
20 |
21 |
22 | ${podcasts
23 | .map(
24 | (podcast) =>
25 | ` `,
26 | )
27 | .join('\n')}
28 |
29 |
30 | `;
31 | };
32 |
33 | export const opmlUtil = {
34 | generatePodcastsOpml,
35 | };
36 |
--------------------------------------------------------------------------------
/src/lib/playlistUtil.ts:
--------------------------------------------------------------------------------
1 | import { RuleDestination } from '~/pages/playlists/types';
2 | import { playerState } from '~/store/signals/player';
3 | import { playlists } from '~/store/signals/playlist';
4 | import { getRadioStation } from '~/store/signals/radio';
5 | import { Playlist, PlaylistItem, PlaylistRuleType } from '~/store/types';
6 | import { getLocalTimeFromUrlKey } from './convertTime';
7 | import { getTimeInMinutesFromTimeString } from './utils';
8 |
9 | const isSameUrl = (url1: string | undefined, url2: string | undefined) => {
10 | if (!url1 || !url2) return false;
11 | if (url1.toLocaleLowerCase() === url2.toLocaleLowerCase()) return true;
12 |
13 | const url1Params = new URLSearchParams(url1.split('?')[1]);
14 | const url2Params = new URLSearchParams(url2.split('?')[1]);
15 | url1Params.delete('tz');
16 | url2Params.delete('tz');
17 | const normalizedParams1 = Array.from(url1Params.entries())
18 | .map(([key, value]) => getLocalTimeFromUrlKey(key) + value)
19 | .sort();
20 | const normalizedParams2 = Array.from(url2Params.entries())
21 | .map(([key, value]) => getLocalTimeFromUrlKey(key) + value)
22 | .sort();
23 |
24 | return JSON.stringify(normalizedParams1) === JSON.stringify(normalizedParams2);
25 | };
26 |
27 | const playPlaylistByUrl = (playlistUrl: string | undefined, shouldPlay?: boolean) => {
28 | if (!playlistUrl) return;
29 | const currentPlaylist = playlists.value?.find((p) => isSameUrl(p.url, playlistUrl));
30 | playPlaylist(currentPlaylist, shouldPlay);
31 | };
32 |
33 | const playPlaylist = (playlist: Playlist | undefined, shouldPlay?: boolean) => {
34 | if (!playlist) return;
35 | const now = new Date();
36 | const minutes = now.getHours() * 60 + now.getMinutes();
37 | const currentItemIndex = playlist.items.findIndex(
38 | (i, index) =>
39 | getTimeInMinutesFromTimeString(i.time) < minutes &&
40 | (!playlist.items[index + 1] || getTimeInMinutesFromTimeString(playlist.items[index + 1].time) > minutes),
41 | );
42 | const currentItem = playlist.items[currentItemIndex];
43 | const currentStation = getRadioStation(currentItem?.stationID ?? '');
44 | const nextItem =
45 | currentItemIndex + 1 < playlist.items.length ? playlist.items[currentItemIndex + 1] : playlist.items[0];
46 | const nextStation = getRadioStation(nextItem?.stationID ?? '');
47 | if (!currentStation) return;
48 | //const isInCurrentPlaystate = playerState.value?.pageLocation === currentPlaylist.url;
49 |
50 | if (!currentStation || !nextStation) {
51 | console.error('No station found');
52 | return;
53 | }
54 |
55 | playerState.value = {
56 | playType: 'playlist',
57 | isPlaying: shouldPlay || playerState.value?.isPlaying || false,
58 | contentID: playlist.url,
59 | title: playlist.name,
60 | description: `${currentItem.time} ${currentStation.name} - ${nextItem.time} ${nextStation.name}`,
61 | imageUrl: currentStation.logosource,
62 | streams: currentStation.streams,
63 | pageLocation: playlist.url,
64 | };
65 | };
66 |
67 | const getPlaylistDataByUrl = (url: string) => {
68 | if (!url) return null;
69 | const urlObj = new URL(url.startsWith('http') ? url : `https://1tuner.com/${url}`);
70 | const query = Object.fromEntries(urlObj.searchParams.entries());
71 | const name = decodeURIComponent(urlObj.pathname.replaceAll('//', '/').split('/')?.[2] ?? '');
72 | const list: PlaylistItem[] = [];
73 | Object.keys(query).forEach((key) => {
74 | if (key === 'tz') return;
75 | const startTime = getLocalTimeFromUrlKey(key);
76 | const station = getRadioStation(query[key] as string);
77 | if (startTime && station) {
78 | list.push({ time: startTime, stationID: station.id });
79 | }
80 | });
81 | return {
82 | name,
83 | url: urlObj.pathname + urlObj.search,
84 | items: list.sort((a, b) => a.time.localeCompare(b.time)),
85 | timeZone: query.tz as string | undefined,
86 | } as Playlist;
87 | };
88 |
89 | const ruleTypeToDestination = (ruleType: PlaylistRuleType): RuleDestination => {
90 | switch (ruleType) {
91 | case PlaylistRuleType.podcastToStation:
92 | return RuleDestination.RadioStation;
93 | case PlaylistRuleType.podcastToPlaylist:
94 | return RuleDestination.Playlist;
95 | default:
96 | return RuleDestination.Nothing;
97 | }
98 | };
99 |
100 | export const playlistUtil = {
101 | playPlaylistByUrl,
102 | playPlaylist,
103 | isSameUrl,
104 | getPlaylistDataByUrl,
105 | ruleTypeToDestination,
106 | };
107 |
--------------------------------------------------------------------------------
/src/lib/reconnectUtil.ts:
--------------------------------------------------------------------------------
1 | const getQuickReconnectTimes = (maxReconnectAttempts: number) => Math.min(maxReconnectAttempts / 2, 20);
2 |
3 | const getReconnectTimeoutMs = (reconnectAttempts: number, maxReconnectAttempts: number) => {
4 | const quickReconnectTimes = getQuickReconnectTimes(maxReconnectAttempts);
5 | return reconnectAttempts <= quickReconnectTimes ? 500 : 1000 * Math.min(reconnectAttempts - quickReconnectTimes, 10);
6 | };
7 |
8 | export const reconnectUtil = {
9 | getQuickReconnectTimes,
10 | getReconnectTimeoutMs: getReconnectTimeoutMs,
11 | };
12 |
--------------------------------------------------------------------------------
/src/lib/styleClass.ts:
--------------------------------------------------------------------------------
1 | import { cn } from './utils';
2 |
3 | const selectBaseClass = cn(
4 | 'custom-select-arrow',
5 | 'border border-stone-400 dark:border-stone-200 rounded-lg',
6 | 'text-sm disabled:opacity-50 disabled:pointer-events-none dark:bg-stone-900 dark:border-stone-600',
7 | );
8 |
9 | const select = cn(selectBaseClass, 'py-3 px-4 pe-9 block w-full');
10 |
11 | const selectSmall = cn(selectBaseClass, 'custom-select-arrow--small', 'py-1 px-2 pe-5 block');
12 |
13 | const textLink = cn('text-primary hover:underline');
14 |
15 | export const styleClass = {
16 | select,
17 | selectSmall,
18 | textLink,
19 | };
20 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from 'clsx';
2 | import { twMerge } from 'tailwind-merge';
3 |
4 | export const cn = (...inputs: ClassValue[]) => {
5 | return twMerge(clsx(inputs));
6 | };
7 |
8 | export const normalizedUrlWithoutScheme = (url: string): string => {
9 | return url ? url.replace(/^https?:\/\//, '').replace(/\/+$/, '') : '';
10 | };
11 |
12 | export const getPodcastUrlID = (url: string): string => {
13 | const normalizedUrl = normalizedUrlWithoutScheme(url);
14 | return btoa(normalizedUrl);
15 | };
16 |
17 | // See https://gist.github.com/hagemann/382adfc57adbd5af078dc93feef01fe1
18 | export const slugify = (text: string): string => {
19 | const a = 'àáâäæãåāăąçćčđďèéêëēėęěğǵḧîïíīįìłḿñńǹňôöòóœøōõőṕŕřßśšşșťțûüùúūǘůűųẃẍÿýžźż·/_,:;';
20 | const b = 'aaaaaaaaaacccddeeeeeeeegghiiiiiilmnnnnoooooooooprrsssssttuuuuuuuuuwxyyzzz------';
21 | const p = new RegExp(a.split('').join('|'), 'g');
22 |
23 | return text
24 | .toString()
25 | .toLowerCase()
26 | .replace(/\s+/g, '-') // Replace spaces with -
27 | .replace(p, (c) => b.charAt(a.indexOf(c))) // Replace special characters
28 | .replace(/&/g, '-and-') // Replace & with 'and'
29 | .replace(/[^\w-]+/g, '') // Remove all non-word characters
30 | .replace(/--+/g, '-') // Replace multiple - with single -
31 | .replace(/^-+/, '') // Trim - from start of text
32 | .replace(/-+$/, ''); // Trim - from end of text
33 | };
34 |
35 | export const stripHtml = (html: string) => {
36 | if (typeof window === 'undefined') return html;
37 | const tmp = document.createElement('div');
38 | tmp.innerHTML = html;
39 | return tmp.textContent || tmp.innerText || '';
40 | };
41 |
42 | const getTime = (part1: number, part2: number) => {
43 | if (!part2) part2 = 0;
44 | return `${part1.toString().padStart(2, '0')}:${part2.toString().padStart(2, '0')}`;
45 | };
46 |
47 | export const getTimeStringFromSeconds = (secs: number) => {
48 | if (!secs) return getTime(0, 0);
49 | const minutes = secs / 60;
50 | return getTime(Math.floor(minutes / 60), Math.floor(minutes % 60));
51 | };
52 |
53 | export const getTimeStringFromMinutes = (minutes: number) => {
54 | return getTimeStringFromSeconds(minutes * 60);
55 | };
56 |
57 | export const getTimeInMinutesFromTimeString = (timeString: string) => {
58 | const [hours, minutes] = timeString.split(':').map(Number);
59 | return hours * 60 + minutes;
60 | };
61 |
62 | export const roundTo15Minutes = (minutes: number) => {
63 | return Math.round(minutes / 15) * 15;
64 | };
65 |
66 | export const getColorString = (text: string) => {
67 | if (!text) return 'rgba(50,50,50,.75)';
68 | const idNrTxt = `${[...text].map((c) => c.charCodeAt(0)).reduce((v1, v2) => v1 + v2)}`;
69 | const offset = 140;
70 | const nr1 = +idNrTxt.substring(0, 2);
71 | const nr2 = +idNrTxt.substring(2);
72 | const r = offset + nr1 - 90,
73 | g = offset - nr1 + text.length,
74 | b = offset + nr2;
75 | return `rgba(${r},${g},${b},.75)`;
76 | };
77 |
78 | export const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
79 |
--------------------------------------------------------------------------------
/src/lib/validationUtil.ts:
--------------------------------------------------------------------------------
1 | export const MAX_SEARCH_LENGTH = 100;
2 | export const MIN_SEARCH_LENGTH = 1;
3 | const UUID_LENGTH = 36;
4 |
5 | const getSanitizedSearchQuery = (query: string | null | undefined): string => {
6 | if (!query) return '';
7 | const sanitizedQuery = query.trim().slice(0, MAX_SEARCH_LENGTH);
8 |
9 | // Return empty string if below minimum length
10 | if (sanitizedQuery.length < MIN_SEARCH_LENGTH) {
11 | return '';
12 | }
13 |
14 | // Remove any characters that are not letters, numbers, spaces, hyphens, or underscores
15 | return sanitizedQuery.replace(/[^a-zA-Z0-9\s\-_]/g, '');
16 | };
17 |
18 | const getSanitizedUuid = (uuid: string | null | undefined): string => {
19 | if (!uuid) return '';
20 | const sanitizedUuid = uuid
21 | .trim()
22 | .replace(/[^a-zA-Z0-9\s\-_]/g, '')
23 | .slice(0, UUID_LENGTH);
24 |
25 | // Return empty string if below minimum length
26 | if (sanitizedUuid.length < UUID_LENGTH) {
27 | return '';
28 | }
29 |
30 | return sanitizedUuid;
31 | };
32 |
33 | export const validationUtil = { getSanitizedSearchQuery, getSanitizedUuid };
34 |
--------------------------------------------------------------------------------
/src/lib/version.ts:
--------------------------------------------------------------------------------
1 | // Import version from package.json
2 | import { version } from '../../package.json';
3 |
4 | export const APP_VERSION = version;
5 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { polyfillCountryFlagEmojis } from 'country-flag-emoji-polyfill';
2 | import { hydrate, prerender as ssr } from 'preact-iso';
3 | import { App } from './app.tsx';
4 | import podcasts from './assets/data/featured/podcasts.json';
5 | import { genres } from './assets/data/genres.json';
6 | import { languages } from './assets/data/languages.json';
7 | import { stations } from './assets/data/stations.json';
8 | import { defaultHeadData, HeadData } from './hooks/useHead.ts';
9 | import { getPodcastUrlID } from './lib/utils.ts';
10 | import { featuredPodcasts } from './store/signals/podcast.ts';
11 | import { radioGenres, radioLanguages, radioStations, setStationPodcasts } from './store/signals/radio.ts';
12 | import { RadioStation } from './store/types.ts';
13 |
14 | polyfillCountryFlagEmojis();
15 |
16 | // Make sure the radio signals are in the air before (pre)rendering :)
17 | radioStations.value = stations as RadioStation[];
18 | radioLanguages.value = languages;
19 | radioGenres.value = genres;
20 | featuredPodcasts.value = podcasts;
21 |
22 | // Load station podcasts data
23 | if (typeof window !== 'undefined') {
24 | // Load station podcasts data in browser
25 | import('./assets/data/stations/podcasts.json')
26 | .then((module) => {
27 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
28 | const podcasts = Object.keys(module.default).reduce((acc: { [key: string]: any[] }, key: string) => {
29 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
30 | acc[key] = (module.default as { [key: string]: any[] })[key].map((podcast) => ({
31 | ...podcast,
32 | id: getPodcastUrlID(podcast.url),
33 | feedUrl: podcast.url,
34 | }));
35 | return acc;
36 | }, {});
37 | setStationPodcasts(podcasts);
38 | })
39 | .catch(console.error);
40 | }
41 |
42 | if (typeof window !== 'undefined') {
43 | hydrate( , document.getElementById('app')!);
44 | }
45 |
46 | export async function prerender() {
47 | // Load both podcast data and station podcasts during prerender
48 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
49 | if (!(globalThis as any).__PRERENDER_PODCASTS__) {
50 | const [podcastData, stationPodcastsData] = await Promise.all([
51 | import('./assets/data/podcasts.json'),
52 | import('./assets/data/stations/podcasts.json'),
53 | ]);
54 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
55 | (globalThis as any).__PRERENDER_PODCASTS__ = podcastData.default
56 | .map((pc) => ({
57 | ...pc,
58 | id: getPodcastUrlID(pc.url),
59 | }))
60 | .concat(featuredPodcasts.value.map((fp) => ({ ...fp, categories: [], episodes: [] })));
61 |
62 | const formattedStationPodcasts = Object.keys(stationPodcastsData.default).reduce(
63 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
64 | (acc: { [key: string]: any[] }, key: string) => {
65 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
66 | acc[key] = (stationPodcastsData.default as { [key: string]: any[] })[key].map((podcast) => ({
67 | ...podcast,
68 | id: getPodcastUrlID(podcast.url),
69 | feedUrl: podcast.url,
70 | }));
71 | return acc;
72 | },
73 | {},
74 | );
75 | setStationPodcasts(formattedStationPodcasts);
76 | }
77 | //return await ssr( );
78 | const { html, links } = await ssr( );
79 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
80 | const headData = { ...defaultHeadData, ...((globalThis as any).__HEAD_DATA__ || {}) } as HeadData;
81 |
82 | return {
83 | html,
84 | // Optionally add additional links that should be
85 | // prerendered (if they haven't already been -- these will be deduped)
86 | links,
87 | // Optionally configure and add elements to the `` of
88 | // the prerendered HTML document
89 | head: {
90 | // Sets the "lang" attribute: ``
91 | //lang: 'en',
92 | // Sets the title for the current page: `My cool page `
93 | title: headData.title,
94 | // Sets any additional elements you want injected into the ``:
95 | //
96 | //
97 | elements: new Set([
98 | { type: 'meta', props: { property: 'og:title', content: headData.title } },
99 | { type: 'meta', props: { property: 'og:description', content: headData.description } },
100 | { type: 'meta', props: { property: 'og:image', content: headData.image } },
101 | { type: 'meta', props: { property: 'og:url', content: headData.url } },
102 | { type: 'meta', props: { property: 'og:type', content: headData.type } },
103 | ]),
104 | },
105 | };
106 | }
107 |
--------------------------------------------------------------------------------
/src/pages/about/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'preact/hooks';
2 | import { useHead } from '~/hooks/useHead';
3 | import { styleClass } from '~/lib/styleClass';
4 | import { APP_VERSION } from '~/lib/version';
5 | import { isDBLoaded } from '~/store/db/db';
6 | import { uiState } from '~/store/signals/ui';
7 |
8 | export const AboutPage = () => {
9 | useHead({
10 | title: 'About',
11 | });
12 |
13 | useEffect(() => {
14 | if (!isDBLoaded.value) return;
15 | const previousState = { ...uiState.value };
16 | uiState.value = {
17 | ...previousState,
18 | headerTitle: 'About',
19 | headerDefaultTextColor: 'default',
20 | };
21 |
22 | return () =>
23 | (uiState.value = {
24 | ...previousState,
25 | headerTitle: '',
26 | headerDefaultTextColor: 'default',
27 | });
28 | }, [isDBLoaded.value]);
29 |
30 | return (
31 |
32 |
About
33 |
34 | Welcome to 1tuner.com — your personal mix of online radio and podcasts!
35 |
36 | Create your perfect listening day by building a playlist that automatically switches between your favorite radio
37 | streams.
38 |
39 |
40 | This is a free web app. No account needed. No ads. 💡 Tip: Add it to your home screen for the best experience!
41 |
42 |
Privacy, cookies, tracking...
43 |
I guess I'm not that much interested in you! But here’s what you should know:
44 |
45 |
46 | Your recently played stations/podcasts, your playlists and settings, are stored in your browser (locally) to
47 | keep things running smoothly. You can manage this data on the{' '}
48 |
49 | Settings
50 | {' '}
51 | page — or just clear your browser data to reset everything.
52 |
53 |
54 | For visitor statistics I use{' '}
55 |
56 | Cloudflare Web Analytics
57 |
58 | . No personal data is shared or stored.
59 |
60 |
61 | Audio and logos are loaded directly from radio stations or podcast sources, or via{' '}
62 |
63 | cloudinary.com
64 |
65 | . Some of these sources may track media requests (just FYI).
66 |
67 |
68 | Podcast search is powered by{' '}
69 |
70 | podcastindex.org
71 | {' '}
72 | or the Apple iTunes Search API — you can change this on the{' '}
73 |
74 | Settings
75 | {' '}
76 | page.
77 |
78 |
79 | For podcast episode data, the RSS feed is fetched directly from the podcast source. If it fails, it is fetched
80 | via a pass-through server (currently using a{' '}
81 |
82 | Cloudflare Worker
83 |
84 | ).
85 |
86 |
87 | For extended radio data I use{' '}
88 |
89 | radio-browser.info
90 |
91 | , which is a free and open API for radio stations.
92 |
93 | If a regular search doesn't return results or you'd like to explore more, an extra search can be performed via
94 | their server. When you play a station, a click is registered on their end for stats purposes — but no personal
95 | data is shared or stored.
96 |
97 |
98 |
Who made this?
99 |
100 | This is a side project by{' '}
101 |
102 | Robin Bakker
103 |
104 | .
105 | Curious how it all started? Check out:{' '}
106 |
112 | Creating a web app as side project
113 |
114 | . Find the code on{' '}
115 |
116 | GitHub
117 |
118 | .
119 |
120 | Do you miss a feature? Spotted a bug? Oh no! Please let me know:{' '}
121 |
122 | @1tuner.com
123 |
124 | .
125 |
126 |
127 | Or{' '}
128 |
129 | buy me a ☕ + 🍪
130 |
131 | !
132 |
133 |
v{APP_VERSION}
134 |
135 | );
136 | };
137 |
--------------------------------------------------------------------------------
/src/pages/homepage/index.tsx:
--------------------------------------------------------------------------------
1 | import { computed } from '@preact/signals';
2 | import { For } from '@preact/signals/utils';
3 | import { ContentSection } from '~/components/content-section';
4 | import { PodcastCard } from '~/components/podcast-card';
5 | import { RadioStationCard } from '~/components/radio-station-card';
6 | import { ShareButton } from '~/components/share-button';
7 | import { styleClass } from '~/lib/styleClass';
8 | import { featuredPodcasts, recentlyVisitedPodcasts } from '~/store/signals/podcast';
9 | import { recentlyVisitedRadioStations } from '~/store/signals/radio';
10 | import { hasAppUpdatedMessage } from '~/store/signals/ui';
11 |
12 | const homepagePodcasts = computed(() => {
13 | if (recentlyVisitedPodcasts.value.length > 10) {
14 | return recentlyVisitedPodcasts.value.slice(0, 10);
15 | }
16 | return (recentlyVisitedPodcasts.value || [])
17 | .concat(featuredPodcasts.value.filter((p) => !recentlyVisitedPodcasts.value.some((rp) => p.id === rp.id)))
18 | .slice(0, 10);
19 | });
20 |
21 | export const Homepage = () => {
22 | return (
23 | <>
24 |
25 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | {(station) => (
41 |
42 |
43 |
44 | )}
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 | {(podcast) => (
53 |
54 |
55 |
56 | )}
57 |
58 |
59 |
60 |
61 |
62 | <>
63 | {hasAppUpdatedMessage.value && (
64 |
65 | ℹ️ 1tuner has been updated! You can now "follow" radio stations and podcasts, and lots of other
66 | improvements where made. I hope you like it!
67 |
68 | )}
69 |
70 | With this free app you can listen to online{' '}
71 |
72 | radio stations
73 |
74 | ,{' '}
75 |
76 | podcasts
77 | {' '}
78 | and create{' '}
79 |
80 | playlists
81 |
82 | .
83 | Just add this site to your homescreen and you're good to go!
84 |
85 |
86 | This app stores information in your browser to save your preferences and Cloudflare Web Analytics is used
87 | for basic analytics.{' '}
88 |
89 | Read more
90 |
91 |
92 | >
93 |
94 | >
95 | );
96 | };
97 |
--------------------------------------------------------------------------------
/src/pages/not-found/index.tsx:
--------------------------------------------------------------------------------
1 | import { useHead } from '~/hooks/useHead';
2 |
3 | export const NotFound = () => {
4 | useHead({
5 | title: '404 - Page not found',
6 | });
7 | return (
8 |
9 |
Page not found... 😢
10 |
11 | Maybe try another page?
12 |
13 |
14 | );
15 | };
16 |
--------------------------------------------------------------------------------
/src/pages/playlist/playlist.module.css:
--------------------------------------------------------------------------------
1 | .timeline li::after {
2 | position: absolute;
3 | content: '';
4 | display: block;
5 | top: 1.4rem;
6 | bottom: 0.2rem;
7 | left: calc(50%);
8 | border-right: 0.1rem dashed currentColor;
9 | }
10 |
11 | .timeline > li:last-child:after {
12 | display: none;
13 | }
14 |
--------------------------------------------------------------------------------
/src/pages/playlists/types.ts:
--------------------------------------------------------------------------------
1 | import { RadioStation } from '~/store/types';
2 |
3 | export interface PlaylistData {
4 | url: string;
5 | name: string;
6 | stations: RadioStation[];
7 | stationPercentages: StationPercentage[];
8 | }
9 |
10 | export interface StationPercentage {
11 | stationID: string;
12 | logo: string;
13 | name: string;
14 | startTime: string;
15 | endTime: string;
16 | percentage: number;
17 | startPercentage: number;
18 | isActive: boolean;
19 | }
20 |
21 | export enum RuleDestination {
22 | Nothing = 0,
23 | RadioStation = 1,
24 | Playlist = 2,
25 | }
26 |
--------------------------------------------------------------------------------
/src/pages/playlists/usePlaylists.ts:
--------------------------------------------------------------------------------
1 | import { ChangeEvent } from 'preact/compat';
2 | import { useCallback, useMemo, useState } from 'preact/hooks';
3 | import { useHead } from '~/hooks/useHead';
4 | import { playlistUtil } from '~/lib/playlistUtil';
5 | import { playlistRules, playlists } from '~/store/signals/playlist';
6 | import { getRadioStation } from '~/store/signals/radio';
7 | import { PlaylistItem, PlaylistRuleType, RadioStation } from '~/store/types';
8 | import { PlaylistData, RuleDestination, StationPercentage } from './types';
9 |
10 | export const usePlaylists = () => {
11 | const startHour = 6;
12 | const endHour = 21;
13 | const [ruleDestinationValue, setRuleDestinationValue] = useState(
14 | playlistUtil.ruleTypeToDestination(playlistRules.value?.[0]?.ruleType),
15 | );
16 |
17 | useHead({
18 | title: 'Playlists',
19 | url: `${import.meta.env.VITE_BASE_URL}/podcasts`,
20 | });
21 |
22 | const currentTimePercentage = useMemo(() => {
23 | const now = new Date();
24 | const hour = now.getHours();
25 | const minute = now.getMinutes();
26 | if (hour < startHour || hour >= endHour) return null;
27 |
28 | const totalMinutes = (hour - startHour) * 60 + minute;
29 | const totalPeriodMinutes = (endHour - startHour) * 60;
30 | return (totalMinutes / totalPeriodMinutes) * 100;
31 | }, []);
32 |
33 | const getPercentagePerStation = useCallback(
34 | (items: PlaylistItem[], stations: RadioStation[]) => {
35 | const totalMinutes = (endHour - startHour) * 60;
36 | const plItems = [...items].sort((a, b) => a.time.localeCompare(b.time));
37 | const timeRanges: { stationID: string; start: Date; end: Date }[] = [];
38 |
39 | plItems.forEach((item, index) => {
40 | let itemTime = new Date(`1970-01-01T${item.time}`);
41 | const itemHour = itemTime.getHours();
42 |
43 | let endTime: Date;
44 | const nextItem = plItems[index + 1];
45 |
46 | if (itemHour < startHour || itemHour >= endHour) {
47 | if (nextItem) {
48 | const nextItemTime = new Date(`1970-01-01T${nextItem.time}`);
49 | const nextItemHour = nextItemTime.getHours();
50 | if (nextItemHour > startHour) {
51 | itemTime = new Date(`1970-01-01T${startHour.toString().padStart(2, '0')}:00`);
52 | endTime = new Date(`1970-01-01T${nextItem.time}`);
53 | } else {
54 | return;
55 | }
56 | } else {
57 | return;
58 | }
59 | } else if (nextItem) {
60 | endTime = new Date(`1970-01-01T${nextItem.time}`);
61 | if (endTime.getHours() >= endHour) {
62 | endTime = new Date(`1970-01-01T${endHour}:00`);
63 | }
64 | } else {
65 | endTime = new Date(`1970-01-01T${endHour}:00`);
66 | }
67 |
68 | if (itemTime < endTime) {
69 | timeRanges.push({
70 | stationID: item.stationID,
71 | start: itemTime,
72 | end: endTime,
73 | });
74 | }
75 | });
76 |
77 | let currentPercentage = 0;
78 | const stationPercentages: StationPercentage[] = timeRanges.map((range) => {
79 | const duration = (range.end.getTime() - range.start.getTime()) / (1000 * 60); // in minutes
80 | const percentage = (duration / totalMinutes) * 100;
81 | const startPercentage = currentPercentage;
82 | currentPercentage += percentage;
83 | const station = stations.find((s) => s.id === range.stationID);
84 | const isActive =
85 | !!currentTimePercentage &&
86 | currentTimePercentage >= startPercentage &&
87 | currentTimePercentage < startPercentage + percentage;
88 |
89 | return {
90 | stationID: range.stationID,
91 | percentage,
92 | startPercentage,
93 | startTime: range.start.toTimeString().substring(0, 5),
94 | endTime: range.end.toTimeString().substring(0, 5),
95 | name: station?.name || range.stationID,
96 | logo: station?.logosource,
97 | isActive,
98 | } as StationPercentage;
99 | });
100 |
101 | return stationPercentages;
102 | },
103 | [currentTimePercentage],
104 | );
105 |
106 | const playlistsData = useMemo((): PlaylistData[] => {
107 | return (playlists.value || []).map((p) => {
108 | const stations = [...new Set((p.items ?? []).map((i) => i.stationID))]
109 | .map((id) => getRadioStation(id))
110 | .filter(Boolean) as RadioStation[];
111 | let url = p.url ? p.url.replace(import.meta.env.VITE_BASE_URL, '') : '';
112 | if (!url.startsWith('/')) url = `/${url}`;
113 | if (url.startsWith('//')) url = url.substring(1);
114 | return {
115 | url,
116 | name: p.name,
117 | stations,
118 | stationPercentages: getPercentagePerStation(p.items, stations),
119 | };
120 | });
121 | }, [playlists.value, getPercentagePerStation]);
122 |
123 | const handleDeletePlaylist = (playlist: PlaylistData) => {
124 | if (confirm(`Are you sure you want to delete "${playlist.name}"?`)) {
125 | playlists.value = (playlists.value || []).filter(
126 | (p) => p.url && p.url.replace(import.meta.env.VITE_BASE_URL, '') !== playlist.url,
127 | );
128 | }
129 | };
130 |
131 | const handlePlay = (playlist: PlaylistData) => {
132 | playlistUtil.playPlaylistByUrl(playlist.url, true);
133 | };
134 |
135 | const handleRuleDestinationChange = (e: ChangeEvent) => {
136 | const newValue = e.currentTarget.value as unknown as RuleDestination;
137 | setRuleDestinationValue(newValue);
138 | if (+newValue === +RuleDestination.Nothing) {
139 | playlistRules.value = [];
140 | }
141 | };
142 |
143 | const handleRuleStationChange = (value: string) => {
144 | playlistRules.value = [{ ruleType: PlaylistRuleType.podcastToStation, stationID: value }];
145 | };
146 |
147 | const handleRulePlaylistChange = (value: string) => {
148 | playlistRules.value = [{ ruleType: PlaylistRuleType.podcastToPlaylist, playlistUrl: value }];
149 | };
150 |
151 | return {
152 | playlistsData,
153 | currentTimePercentage,
154 | ruleDestinationValue,
155 | handlePlay,
156 | handleDeletePlaylist,
157 | handleRuleDestinationChange,
158 | handleRuleStationChange,
159 | handleRulePlaylistChange,
160 | };
161 | };
162 |
--------------------------------------------------------------------------------
/src/pages/podcast/usePodcast.ts:
--------------------------------------------------------------------------------
1 | import { useLocation, useRoute } from 'preact-iso';
2 | import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';
3 | import { useHead } from '~/hooks/useHead';
4 | import { usePodcastData } from '~/hooks/usePodcastData';
5 | import { getPodcastUrlID, normalizedUrlWithoutScheme, slugify, stripHtml } from '~/lib/utils';
6 | import { isDBLoaded } from '~/store/db/db';
7 | import { playerState } from '~/store/signals/player';
8 | import { addRecentlyVisitedPodcast, followPodcast, isFollowedPodcast, unfollowPodcast } from '~/store/signals/podcast';
9 | import { uiState } from '~/store/signals/ui';
10 | import { Episode, Podcast } from '~/store/types';
11 |
12 | export const usePodcast = () => {
13 | const { params } = useRoute();
14 | const { route } = useLocation();
15 | const [isFollowing, setIsFollowing] = useState(false);
16 | const [podcast, setPodcast] = useState(
17 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
18 | (globalThis as any)?.__PRERENDER_PODCASTS__?.find((p: Podcast) => p.id === params.id),
19 | );
20 | const { fetchPodcastData, isLoading } = usePodcastData();
21 |
22 | useHead({
23 | title: podcast ? podcast.title : 'Podcast',
24 | description: podcast ? stripHtml(podcast.description).slice(0, 200) : undefined,
25 | image: podcast?.imageUrl,
26 | url: podcast
27 | ? `${import.meta.env.VITE_BASE_URL}/podcast/${slugify(podcast.title)}/${getPodcastUrlID(podcast.url)}`
28 | : undefined,
29 | type: 'music.playlist',
30 | });
31 |
32 | const paramsFeedUrl = useMemo(() => {
33 | return params.id ? `https://${normalizedUrlWithoutScheme(atob(params.id))}` : '';
34 | }, [params.id]);
35 |
36 | const lastPlayedEpisode = useMemo(() => {
37 | return podcast?.episodes?.find((e) => e.currentTime && e.currentTime > 0);
38 | }, [podcast?.episodes]);
39 |
40 | const nowPlayingState = useMemo(() => {
41 | return playerState.value?.playType === 'podcast' && playerState.value.contentID === params.id
42 | ? playerState.value
43 | : null;
44 | }, [playerState.value, params.id]);
45 |
46 | const selectedEpisodeID = useMemo(() => {
47 | if (!podcast?.episodes) return -1;
48 | const episodeID = params.episodeID ? decodeURIComponent(params.episodeID) : null;
49 | if (episodeID && podcast?.episodes?.some((ep) => ep.guid === episodeID)) {
50 | return episodeID;
51 | }
52 | return null;
53 | }, [params.episodeID, podcast?.episodes]);
54 |
55 | useEffect(() => {
56 | if (isDBLoaded.value && params.episodeID && !selectedEpisodeID) {
57 | route(`/podcast/${params.name}/${params.id}`, true);
58 | }
59 | }, [isDBLoaded.value, params.episodeID, params.id, params.name, route, selectedEpisodeID]);
60 |
61 | const getPodcastData = useCallback(
62 | async (skipCache = false) => {
63 | if (!params.id || !isDBLoaded.value) return;
64 |
65 | const podcastData = await fetchPodcastData(params.id, paramsFeedUrl, skipCache);
66 | if (!podcastData) return;
67 |
68 | uiState.value = { ...uiState.value, headerTitle: podcastData.title };
69 |
70 | addRecentlyVisitedPodcast(podcastData);
71 | setPodcast(podcastData);
72 |
73 | if (isFollowedPodcast(params.id)) {
74 | setIsFollowing(true);
75 | }
76 | },
77 | [params.id, fetchPodcastData, paramsFeedUrl, isDBLoaded.value],
78 | );
79 |
80 | useEffect(() => {
81 | getPodcastData();
82 |
83 | return () => {
84 | uiState.value = { ...uiState.value, headerTitle: '' };
85 | };
86 | }, [getPodcastData]);
87 |
88 | useEffect(() => {
89 | if (nowPlayingState) {
90 | getPodcastData();
91 | }
92 | }, [getPodcastData, nowPlayingState]);
93 |
94 | const toggleFollow = () => {
95 | if (!podcast) return;
96 |
97 | if (isFollowing) {
98 | unfollowPodcast(params.id);
99 | } else {
100 | followPodcast(podcast);
101 | }
102 | setIsFollowing(!isFollowing);
103 | };
104 |
105 | const handleEpisodeClick = useCallback(
106 | (episode: Episode) => {
107 | if (!podcast) return;
108 | playerState.value = {
109 | playType: 'podcast',
110 | isPlaying: true,
111 | contentID: params.id,
112 | title: episode.title,
113 | description: podcast.title,
114 | imageUrl: podcast.imageUrl,
115 | streams: [{ mimetype: episode.mimeType || 'audio/mpeg', url: episode.audio }],
116 | pageLocation: `/podcast/${params.name}/${params.id}`,
117 | currentTime: episode.currentTime || 0,
118 | shareUrl: `/podcast/${params.name}/${params.id}${episode.guid ? `/${encodeURIComponent(episode.guid)}` : ''}`,
119 | };
120 | },
121 | [podcast, params.id, params.name],
122 | );
123 |
124 | const handleFetchNewEpisodes = async () => {
125 | if (isLoading) return;
126 | getPodcastData(true);
127 | };
128 |
129 | const handleShowAllEpisodesClick = () => {
130 | route(`/podcast/${params.name}/${params.id}`);
131 | };
132 |
133 | return {
134 | params,
135 | isLoading,
136 | podcast,
137 | lastPlayedEpisode,
138 | isFollowing,
139 | nowPlayingState,
140 | selectedEpisodeID,
141 | toggleFollow,
142 | handleEpisodeClick,
143 | handleFetchNewEpisodes,
144 | handleShowAllEpisodesClick,
145 | };
146 | };
147 |
--------------------------------------------------------------------------------
/src/pages/podcasts/index.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 | import { Search, X } from 'lucide-preact';
3 | import { useCallback } from 'preact/hooks';
4 | import { ContentSection } from '~/components/content-section';
5 | import { Loader } from '~/components/loader';
6 | import { PodcastCard } from '~/components/podcast-card';
7 | import { Button } from '~/components/ui/button';
8 | import { Input } from '~/components/ui/input';
9 | import { cn, slugify } from '~/lib/utils';
10 | import { MAX_SEARCH_LENGTH } from '~/lib/validationUtil';
11 | import { Podcast } from '~/store/types';
12 | import { featuredPodcasts, followedPodcasts, recentlyVisitedPodcasts } from '../../store/signals/podcast';
13 | import { usePodcasts } from './usePodcasts';
14 |
15 | export const PodcastsPage = () => {
16 | const { isLoading, isScrolled, searchTerm, searchInputRef, setSearchTerm, searchResults } = usePodcasts();
17 |
18 | const renderPodcastList = useCallback(
19 | (podcasts: Podcast[] | undefined, title: string, nrToSmall?: number) => {
20 | if (isLoading) return Loading...
;
21 | if (!podcasts?.length) return null;
22 | const slugTitle = slugify(title);
23 | if (podcasts.length <= (nrToSmall || 999)) {
24 | return (
25 |
26 |
27 | {podcasts.map((podcast) => (
28 |
29 | ))}
30 |
31 |
32 | );
33 | }
34 | return (
35 |
41 |
42 | {podcasts.map((podcast) => (
43 |
44 |
45 |
46 | ))}
47 |
48 |
49 |
50 | );
51 | },
52 | [isLoading],
53 | );
54 |
55 | return (
56 |
57 |
109 |
110 | {typeof window === 'undefined' && (globalThis as any).__PRERENDER_PODCASTS__ && (
111 | <>{renderPodcastList((globalThis as any).__PRERENDER_PODCASTS__, 'Podcasts')}>
112 | )}
113 | {isLoading ? (
114 |
115 | ) : (
116 | <>
117 | {searchResults.length > 0 ? (
118 | renderPodcastList(searchResults, 'Search Results')
119 | ) : (
120 | <>
121 | {renderPodcastList(
122 | [...followedPodcasts.value].sort((a, b) => {
123 | const aIndex = recentlyVisitedPodcasts.value.findIndex((p) => p.id === a.id);
124 | const bIndex = recentlyVisitedPodcasts.value.findIndex((p) => p.id === b.id);
125 |
126 | // If both podcasts are in recently visited
127 | if (aIndex !== -1 && bIndex !== -1) {
128 | return aIndex - bIndex;
129 | }
130 | // If only a is in recently visited
131 | if (aIndex !== -1) {
132 | return -1;
133 | }
134 | // If only b is in recently visited
135 | if (bIndex !== -1) {
136 | return 1;
137 | }
138 | // If neither is in recently visited, maintain original order
139 | return 0;
140 | }),
141 | 'Following',
142 | 2,
143 | )}
144 | {renderPodcastList(
145 | recentlyVisitedPodcasts.value.filter((p) => !followedPodcasts.value.some((fp) => fp.id === p.id)),
146 | 'Last visited',
147 | )}
148 | {renderPodcastList(featuredPodcasts.value, 'Featured')}
149 | >
150 | )}
151 | >
152 | )}
153 |
154 |
155 | );
156 | };
157 |
--------------------------------------------------------------------------------
/src/pages/podcasts/usePodcasts.ts:
--------------------------------------------------------------------------------
1 | import { useLocation } from 'preact-iso';
2 | import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
3 | import { useHead } from '~/hooks/useHead';
4 | import { validationUtil } from '~/lib/validationUtil';
5 | import { isDBLoaded } from '~/store/db/db';
6 | import {
7 | clearLastPodcastSearchResult,
8 | lastPodcastSearchResult,
9 | setLastPodcastSearchResult,
10 | } from '~/store/signals/podcast';
11 | import { settingsState } from '~/store/signals/settings';
12 | import { uiIsScrolled } from '~/store/signals/ui';
13 | import { Podcast, PodcastSearchProvider } from '~/store/types';
14 |
15 | export const usePodcasts = () => {
16 | const { query, route } = useLocation();
17 | const [searchTerm, setSearchTerm] = useState(lastPodcastSearchResult.value?.query || '');
18 | const [isLoading, setIsLoading] = useState(false);
19 | const searchTimeout = useRef();
20 | const searchInputRef = useRef(null);
21 | const isInitialized = useRef(false);
22 |
23 | useHead({
24 | title: 'Podcasts',
25 | });
26 |
27 | useEffect(() => {
28 | if (!isDBLoaded.value || isInitialized.current) return;
29 |
30 | const initialSearchQuery = query['q'] ? decodeURIComponent(query['q']) || '' : '';
31 |
32 | if (initialSearchQuery) {
33 | const validatedQuery = validationUtil.getSanitizedSearchQuery(initialSearchQuery);
34 | setSearchTerm(validatedQuery);
35 | } else {
36 | setSearchTerm('');
37 | }
38 | isInitialized.current = true;
39 | }, [query, isDBLoaded.value]);
40 |
41 | const updateURLParams = useCallback(
42 | (search?: string) => {
43 | const url = new URL(window.location.href);
44 |
45 | if (search) {
46 | const validatedSearch = validationUtil.getSanitizedSearchQuery(search);
47 | url.searchParams.set('q', encodeURIComponent(validatedSearch));
48 | } else {
49 | url.searchParams.delete('q');
50 | }
51 |
52 | route(url.pathname + url.search, true);
53 | },
54 | [route],
55 | );
56 |
57 | useEffect(() => {
58 | const searchQuery = validationUtil.getSanitizedSearchQuery(searchTerm);
59 |
60 | if (searchQuery && searchQuery !== lastPodcastSearchResult.value?.query) {
61 | setIsLoading(true);
62 | updateURLParams(searchQuery);
63 |
64 | // Clear existing timeout
65 | if (searchTimeout.current) {
66 | clearTimeout(searchTimeout.current);
67 | }
68 |
69 | // Set new timeout
70 | searchTimeout.current = setTimeout(async () => {
71 | try {
72 | const isAppleSearch = settingsState.value?.podcastSearchProvider === PodcastSearchProvider.Apple;
73 | const response = isAppleSearch
74 | ? await fetch(`https://itunes.apple.com/search?term=${encodeURIComponent(searchQuery)}&media=podcast`)
75 | : await fetch('https://podcastindex.tuner.workers.dev', {
76 | method: 'POST',
77 | body: searchQuery,
78 | });
79 |
80 | if (!response.ok) {
81 | throw new Error('Search request failed 💥');
82 | }
83 |
84 | const data = await response.json();
85 | if (
86 | !data ||
87 | (isAppleSearch && (!data.results || !data.results.length)) ||
88 | (!isAppleSearch && (!data.feeds || !data.feeds.length))
89 | ) {
90 | throw new Error(
91 | `Sorry, nothing found for "${searchQuery}"... 😥 Maybe you can try to change your search query?`,
92 | );
93 | }
94 | let searchResults: Podcast[];
95 | if (isAppleSearch) {
96 | searchResults =
97 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
98 | data.results.map((r: any) => ({
99 | ...r,
100 | id: r.trackId,
101 | title: r.collectionName,
102 | description: r.artistName,
103 | imageUrl: r.artworkUrl600 || r.artworkUrl100,
104 | url: r.feedUrl,
105 | }));
106 | } else {
107 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
108 | searchResults = data.feeds.map((f: any) => ({ ...f, imageUrl: f.image }));
109 | }
110 | setLastPodcastSearchResult(searchQuery, searchResults);
111 | } catch (error) {
112 | console.error('Error fetching podcasts:', error);
113 | } finally {
114 | setIsLoading(false);
115 | }
116 | }, 500); // 500ms delay
117 | } else {
118 | if (!searchQuery) {
119 | clearLastPodcastSearchResult();
120 | }
121 | setIsLoading(false);
122 | }
123 |
124 | // Cleanup timeout on component unmount
125 | return () => {
126 | if (searchTimeout.current) {
127 | clearTimeout(searchTimeout.current);
128 | }
129 | };
130 | }, [searchTerm]);
131 |
132 | useEffect(() => {
133 | if (query['focus-search']) {
134 | const url = new URL(window.location.href);
135 | url.searchParams.delete('focus-search');
136 | route(url.pathname + url.search, true);
137 | searchInputRef.current?.focus();
138 | }
139 | }, [query, route]);
140 |
141 | return {
142 | searchTerm,
143 | setSearchTerm,
144 | searchInputRef,
145 | searchResults: lastPodcastSearchResult.value?.result || [],
146 | isLoading,
147 | isScrolled: !!uiIsScrolled.value,
148 | };
149 | };
150 |
--------------------------------------------------------------------------------
/src/pages/radio-station/useRadioStation.ts:
--------------------------------------------------------------------------------
1 | import { useRoute } from 'preact-iso';
2 | import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';
3 | import { useHead } from '~/hooks/useHead';
4 | import { useRadioBrowser } from '~/hooks/useRadioBrowser';
5 | import { isDBLoaded } from '~/store/db/db';
6 | import { playerState } from '~/store/signals/player';
7 | import {
8 | addRadioBrowserStation,
9 | followRadioStation,
10 | getRadioStation,
11 | getStationPodcasts,
12 | isFollowedRadioStation,
13 | RADIO_BROWSER_PARAM_PREFIX,
14 | radioStations,
15 | unfollowRadioStation,
16 | } from '~/store/signals/radio';
17 | import { uiState } from '~/store/signals/ui';
18 |
19 | export const useRadioStation = () => {
20 | const { params } = useRoute();
21 | const { getStation: getRadioBrowserStation } = useRadioBrowser();
22 | const [isFollowing, setIsFollowing] = useState(isFollowedRadioStation(params.id));
23 | const localRadioStationData = getRadioStation(params.id);
24 | const [radioStation, setRadioStation] = useState(localRadioStationData);
25 | const [isFetchingData, setIsFetchingData] = useState(typeof window !== 'undefined' && !localRadioStationData);
26 |
27 | useEffect(() => {
28 | if (radioStation?.id && localRadioStationData?.id && localRadioStationData?.id !== radioStation?.id) {
29 | setRadioStation(localRadioStationData);
30 | }
31 | }, [localRadioStationData, params.id, radioStation?.id]);
32 |
33 | useEffect(() => {
34 | const fetchRadioStation = async () => {
35 | const station = await getRadioBrowserStation(params.id);
36 | if (station) {
37 | setRadioStation(station);
38 | addRadioBrowserStation(station);
39 | }
40 | setIsFetchingData(false);
41 | };
42 |
43 | if (isFetchingData && isDBLoaded.value) {
44 | fetchRadioStation();
45 | }
46 | }, [isFetchingData, isDBLoaded.value, getRadioBrowserStation, params.id]);
47 |
48 | const radioStationDescription = useMemo(() => {
49 | if (!radioStation) return undefined;
50 | if (radioStation.language.indexOf('nl') === 0) {
51 | return `Luister naar ${radioStation.name} live op 1tuner.com`;
52 | } else if (radioStation.language.indexOf('de') === 0) {
53 | return `Hören Sie ${radioStation.name} live auf 1tuner.com`;
54 | } else if (radioStation.language.indexOf('fr') === 0) {
55 | return `Écoutez ${radioStation.name} en direct sur 1tuner.com`;
56 | } else if (radioStation.language.indexOf('es') === 0) {
57 | return `Escucha ${radioStation.name} en vivo en 1tuner.com`;
58 | } else if (radioStation.language.indexOf('it') === 0) {
59 | return `Ascolta ${radioStation.name} in diretta su 1tuner.com`;
60 | } else if (radioStation.language.indexOf('pt') === 0) {
61 | return `Ouça ${radioStation.name} ao vivo no 1tuner.com`;
62 | } else if (radioStation.language.indexOf('pl') === 0) {
63 | return `Słuchaj ${radioStation.name} na żywo na 1tuner.com`;
64 | } else if (radioStation.language.indexOf('sv') === 0) {
65 | return `Lyssna på ${radioStation.name} live på 1tuner.com`;
66 | } else {
67 | return `Listen to ${radioStation.name} live on 1tuner.com`;
68 | }
69 | }, [radioStation]);
70 |
71 | useHead({
72 | title: radioStation ? radioStation.name : 'Radio Station',
73 | description: radioStationDescription,
74 | image: radioStation?.logosource,
75 | url: radioStation ? `https://1tuner.com/radio-station/${radioStation.id}` : undefined,
76 | type: 'music.radio_station',
77 | });
78 |
79 | const isPlaying = useMemo(() => {
80 | return !!(playerState.value?.isPlaying && playerState.value.contentID === radioStation?.id);
81 | }, [radioStation?.id]);
82 |
83 | const toggleFollow = () => {
84 | if (!radioStation) return;
85 |
86 | if (isFollowing) {
87 | unfollowRadioStation(radioStation.id);
88 | } else {
89 | followRadioStation(radioStation.id);
90 | }
91 | setIsFollowing(!isFollowing);
92 | };
93 |
94 | const stationPodcasts = useMemo(() => {
95 | return radioStation?.id ? getStationPodcasts(radioStation.id) : [];
96 | }, [radioStation?.id]);
97 |
98 | useEffect(() => {
99 | setIsFollowing(isFollowedRadioStation(params.id));
100 | }, [setIsFollowing, params.id]);
101 |
102 | useEffect(() => {
103 | if (!isDBLoaded.value || isFetchingData) return;
104 | const previousState = { ...uiState.value };
105 | uiState.value = {
106 | ...previousState,
107 | headerTitle: radioStation?.name ?? '',
108 | headerDefaultTextColor: 'light',
109 | };
110 |
111 | return () =>
112 | (uiState.value = {
113 | ...previousState,
114 | headerTitle: '',
115 | headerDefaultTextColor: 'default',
116 | });
117 | }, [isDBLoaded.value, isFetchingData, radioStation?.name]);
118 |
119 | const getRelatedStations = useCallback(
120 | (maxResults: number) => {
121 | if (!radioStation || !radioStations.value?.length || maxResults <= 0) {
122 | return [];
123 | }
124 | const genreSet = new Set(radioStation.genres ?? []);
125 | const language = radioStation.language;
126 | const stationId = radioStation.id;
127 |
128 | // 1. Add explicitly related stations first (in order)
129 | const result = radioStation.related
130 | ? radioStation.related
131 | .map((relId) => radioStations.value.find((s) => s.id === relId))
132 | .filter((s): s is (typeof radioStations.value)[0] => !!s && s.id !== stationId)
133 | .slice(0, maxResults)
134 | : [];
135 |
136 | // Helper to check if genres match exactly
137 | const genresMatch = (a: string[], b: string[]) => a.length === b.length && a.every((g) => b.includes(g));
138 |
139 | // 2. Exact match: same genres and same language
140 | for (const s of radioStations.value) {
141 | if (result.length >= maxResults) break;
142 | if (
143 | s.id !== stationId &&
144 | s.language === language &&
145 | genresMatch(s.genres, radioStation.genres) &&
146 | !result.some((r) => r.id === s.id)
147 | ) {
148 | result.push(s);
149 | }
150 | }
151 |
152 | const overlappingStations = radioStations.value
153 | .filter(
154 | (s) =>
155 | s.id !== stationId && !genresMatch(s.genres, radioStation.genres) && !result.some((r) => r.id === s.id),
156 | )
157 | .map((s) => ({
158 | station: s,
159 | overlap: s.genres.filter((g) => genreSet.has(g)).length,
160 | }))
161 | .filter((item) => item.overlap > 0)
162 | .sort((a, b) => b.overlap - a.overlap)
163 | .map((item) => item.station);
164 |
165 | // 3. Most overlapping genres, same language (not already included)
166 | for (const s of overlappingStations.filter((s) => s.language === language)) {
167 | if (result.length >= maxResults) break;
168 | result.push(s);
169 | }
170 |
171 | // 4. Same genres, other languages
172 | for (const s of radioStations.value) {
173 | if (result.length >= maxResults) break;
174 | if (
175 | s.id !== stationId &&
176 | s.language !== language &&
177 | genresMatch(s.genres, radioStation.genres) &&
178 | !result.some((r) => r.id === s.id)
179 | ) {
180 | result.push(s);
181 | }
182 | }
183 |
184 | // 5. Most overlapping genres, other languages (not already included)
185 | for (const s of overlappingStations.filter((s) => s.language !== language)) {
186 | if (result.length >= maxResults) break;
187 | result.push(s);
188 | }
189 |
190 | return result.slice(0, maxResults);
191 | },
192 | [radioStation, radioStations.value],
193 | );
194 |
195 | const relatedStations = useMemo(() => {
196 | return getRelatedStations(8);
197 | }, [getRelatedStations]);
198 |
199 | return {
200 | params,
201 | isPlaying,
202 | isFetchingData,
203 | isRadioBrowserStation: params.id.startsWith(RADIO_BROWSER_PARAM_PREFIX),
204 | radioStation,
205 | stationPodcasts,
206 | isFollowing,
207 | relatedStations,
208 | toggleFollow,
209 | };
210 | };
211 |
--------------------------------------------------------------------------------
/src/pages/settings/types.ts:
--------------------------------------------------------------------------------
1 | export type ThemeOption = 'default' | 'light' | 'dark';
2 |
--------------------------------------------------------------------------------
/src/store/db/db.ts:
--------------------------------------------------------------------------------
1 | import { signal } from '@preact/signals';
2 | import { DBSchema, IDBPDatabase, openDB } from 'idb';
3 | import { logState } from '../signals/log';
4 | import { isPlayerMaximized, playerState } from '../signals/player';
5 | import { playlistRules, playlists } from '../signals/playlist';
6 | import { followedPodcasts, recentlyVisitedPodcasts } from '../signals/podcast';
7 | import {
8 | followedRadioStationIDs,
9 | radioBrowserStations,
10 | radioSearchFilters,
11 | recentlyVisitedRadioStationIDs,
12 | } from '../signals/radio';
13 | import { settingsState } from '../signals/settings';
14 | import {
15 | LogState,
16 | PlayerState,
17 | Playlist,
18 | PlaylistRule,
19 | Podcast,
20 | RadioSearchFilters,
21 | RadioStation,
22 | SettingsState,
23 | } from '../types';
24 |
25 | export const isDBLoaded = signal(false);
26 |
27 | export const dbName = '1tuner';
28 | export const dbVersion = 9;
29 | export const storeName = 'appState';
30 |
31 | export enum AppStateKey {
32 | FollowedPodcasts = 'followedPodcasts',
33 | RecentlyVisitedPodcasts = 'recentlyVisitedPodcasts',
34 | RadioBrowserStations = 'radioBrowserStations',
35 | FollowedRadioStationIDs = 'followedRadioStationIDs',
36 | RecentlyVisitedRadioStationIDs = 'recentlyVisitedRadioStationIDs',
37 | RadioSearchFilters = 'radioSearchFilters',
38 | Playlists = 'playlists',
39 | PlaylistRules = 'playlistRules',
40 | PlayerState = 'playerState',
41 | SettingsState = 'settingsState',
42 | LogState = 'logState',
43 | IsPlayerMaximized = 'isPlayerMaximized',
44 | }
45 |
46 | type DBData =
47 | | Podcast[]
48 | | RadioStation[]
49 | | Playlist[]
50 | | PlaylistRule[]
51 | | string[]
52 | | LogState[]
53 | | RadioSearchFilters
54 | | PlayerState
55 | | SettingsState
56 | | boolean
57 | | null;
58 | interface TunerDB extends DBSchema {
59 | appState: {
60 | key: string;
61 | value: DBData;
62 | };
63 | }
64 |
65 | const dbPromise =
66 | typeof window !== 'undefined'
67 | ? openDB(dbName, dbVersion, {
68 | upgrade(db) {
69 | if (!db.objectStoreNames.contains(storeName)) {
70 | db.createObjectStore(storeName);
71 | }
72 | },
73 | })
74 | : null;
75 |
76 | const getFromDB = async (db: IDBPDatabase | null, key: AppStateKey): Promise => {
77 | if (!db) return null;
78 | return (await db.get(storeName, key)) as T | null;
79 | };
80 |
81 | export async function loadStateFromDB() {
82 | if (typeof window === 'undefined') return;
83 | const db = await dbPromise;
84 | followedPodcasts.value = (await getFromDB(db, AppStateKey.FollowedPodcasts)) || [];
85 | recentlyVisitedPodcasts.value = (await getFromDB(db, AppStateKey.RecentlyVisitedPodcasts)) || [];
86 | radioBrowserStations.value = (await getFromDB(db, AppStateKey.RadioBrowserStations)) || [];
87 | followedRadioStationIDs.value = (await getFromDB(db, AppStateKey.FollowedRadioStationIDs)) || [];
88 | recentlyVisitedRadioStationIDs.value =
89 | (await getFromDB(db, AppStateKey.RecentlyVisitedRadioStationIDs)) || [];
90 | radioSearchFilters.value = (await getFromDB(db, AppStateKey.RadioSearchFilters)) || null;
91 | playlists.value = (await getFromDB(db, AppStateKey.Playlists)) || [];
92 | playlistRules.value = (await getFromDB(db, AppStateKey.PlaylistRules)) || [];
93 | playerState.value = (await getFromDB(db, AppStateKey.PlayerState)) || null;
94 | settingsState.value = (await getFromDB(db, AppStateKey.SettingsState)) || ({} as SettingsState);
95 | logState.value = (await getFromDB(db, AppStateKey.LogState)) || [];
96 | isPlayerMaximized.value = (await getFromDB(db, AppStateKey.IsPlayerMaximized)) || false;
97 | isDBLoaded.value = true;
98 | }
99 |
100 | export async function saveStateToDB() {
101 | if (typeof window === 'undefined') return;
102 | const db = await dbPromise;
103 | if (!db) return;
104 |
105 | try {
106 | const tx = db.transaction(storeName, 'readwrite');
107 | await Promise.all([
108 | tx.store.put(followedPodcasts.value, AppStateKey.FollowedPodcasts),
109 | tx.store.put(recentlyVisitedPodcasts.value, AppStateKey.RecentlyVisitedPodcasts),
110 | tx.store.put(radioBrowserStations.value, AppStateKey.RadioBrowserStations),
111 | tx.store.put(followedRadioStationIDs.value, AppStateKey.FollowedRadioStationIDs),
112 | tx.store.put(recentlyVisitedRadioStationIDs.value, AppStateKey.RecentlyVisitedRadioStationIDs),
113 | tx.store.put(radioSearchFilters.value, AppStateKey.RadioSearchFilters),
114 | tx.store.put(playlists.value, AppStateKey.Playlists),
115 | tx.store.put(playlistRules.value, AppStateKey.PlaylistRules),
116 | tx.store.put(playerState.value, AppStateKey.PlayerState),
117 | tx.store.put(settingsState.value, AppStateKey.SettingsState),
118 | tx.store.put(logState.value, AppStateKey.LogState),
119 | tx.store.put(isPlayerMaximized.value, AppStateKey.IsPlayerMaximized),
120 | ]);
121 | await tx.done;
122 | } catch (error) {
123 | console.error('Error saving state to DB:', error);
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/src/store/db/migration.ts:
--------------------------------------------------------------------------------
1 | import { DBSchema, openDB } from 'idb';
2 | import { playlistUtil } from '~/lib/playlistUtil';
3 | import { getPodcastUrlID } from '~/lib/utils';
4 | import { hasAppUpdatedMessage } from '../signals/ui';
5 | import { Podcast } from '../types';
6 | import { AppStateKey, dbName, dbVersion, storeName } from './db';
7 |
8 | interface OldKeyvalStore extends DBSchema {
9 | keyval: {
10 | key: string;
11 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
12 | value: any;
13 | };
14 | }
15 |
16 | interface OldPodcastEpisode {
17 | title: string;
18 | description?: string;
19 | duration: string;
20 | durationSeconds?: number;
21 | secondsElapsed?: number;
22 | pubDate: string;
23 | length?: string;
24 | type?: string;
25 | url: string;
26 | }
27 |
28 | interface OldStation {
29 | id: string;
30 | }
31 |
32 | interface OldPlaylist {
33 | name: string;
34 | href: string;
35 | }
36 |
37 | interface OldPodcast {
38 | name: string;
39 | logo: string;
40 | language?: string;
41 | description?: string;
42 | feedUrl: string;
43 | modified: Date;
44 | episodes?: OldPodcastEpisode[];
45 | }
46 |
47 | export async function migrateOldData() {
48 | try {
49 | // First check if old database exists
50 | const databases = await window.indexedDB.databases();
51 | const oldDbExists = databases.some((db) => db.name === 'keyval-store');
52 |
53 | if (!oldDbExists) {
54 | console.log('No old database found, skipping migration');
55 | return;
56 | }
57 |
58 | hasAppUpdatedMessage.value = true;
59 |
60 | // Open old database
61 | const oldDb = await openDB('keyval-store', 1, {
62 | blocked() {
63 | console.log('Old database blocked');
64 | },
65 | });
66 |
67 | if (!oldDb) {
68 | console.log('No old database found, skipping migration');
69 | return;
70 | }
71 |
72 | // Verify the required store exists
73 | if (!oldDb.objectStoreNames.contains('keyval')) {
74 | console.log('Old database structure invalid, skipping migration');
75 | oldDb.close();
76 | return;
77 | }
78 |
79 | // Check if already migrated
80 | const migrationInfo = await oldDb.get('keyval', 'migration-info');
81 | if (migrationInfo?.migrated) {
82 | console.log('Data already migrated on', migrationInfo.migrationDate);
83 | oldDb.close();
84 | return;
85 | }
86 |
87 | // Open new database (reuse existing configuration)
88 | const newDb = await openDB(dbName, dbVersion, {
89 | blocked() {
90 | console.log('New database blocked');
91 | },
92 | });
93 |
94 | // Migrate radio stations
95 | const lastStationList = (await oldDb.get('keyval', 'last-station-list')) as OldStation[];
96 | if (lastStationList?.length > 0) {
97 | console.log('Migrating radio station data...');
98 | const stationIds = lastStationList.map((station) => station.id);
99 | await newDb.put(storeName, stationIds, AppStateKey.RecentlyVisitedRadioStationIDs);
100 | }
101 |
102 | // Migrate podcasts
103 | const oldPodcastList = (await oldDb.get('keyval', 'podcast-list')) as OldPodcast[];
104 | if (oldPodcastList?.length > 0) {
105 | console.log('Migrating podcast data...');
106 |
107 | // Sort podcasts by modified date (newest first)
108 | const sortedPodcasts = [...oldPodcastList].sort(
109 | (a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime(),
110 | );
111 |
112 | const migratedPodcasts = sortedPodcasts.map(
113 | (podcast) =>
114 | ({
115 | id: getPodcastUrlID(podcast.feedUrl),
116 | title: podcast.name,
117 | feedUrl: podcast.feedUrl,
118 | url: podcast.feedUrl,
119 | imageUrl: podcast.logo,
120 | addedDate: new Date(podcast.modified).getTime(),
121 | description: podcast.description || '',
122 | episodes: podcast.episodes?.map((episode) => ({
123 | title: episode.title,
124 | description: episode.description,
125 | pubDate: new Date(episode.pubDate),
126 | duration: episode.duration,
127 | audio: episode.url,
128 | mimeType: episode.type,
129 | currentTime: episode.secondsElapsed,
130 | })),
131 | }) as Podcast,
132 | );
133 |
134 | await newDb.put(storeName, migratedPodcasts, AppStateKey.RecentlyVisitedPodcasts);
135 | }
136 |
137 | // Migrate playlists
138 | const oldPlaylists = (await oldDb.get('keyval', 'playlists')) as OldPlaylist[];
139 | if (oldPlaylists?.length > 0) {
140 | console.log('Migrating playlists...');
141 | const migratedPlaylists = oldPlaylists.map((pl) => playlistUtil.getPlaylistDataByUrl(pl.href)).filter(Boolean);
142 | if (migratedPlaylists.length) {
143 | await newDb.put(storeName, migratedPlaylists, AppStateKey.Playlists);
144 | }
145 | }
146 |
147 | // Mark old database as migrated
148 | await oldDb.put(
149 | 'keyval',
150 | {
151 | migrated: true,
152 | migrationDate: new Date().toISOString(),
153 | version: '2.0',
154 | },
155 | 'migration-info',
156 | );
157 |
158 | oldDb.close();
159 | newDb.close();
160 |
161 | console.log('Migration completed successfully');
162 | } catch (error) {
163 | console.error('Migration failed:', error);
164 | }
165 | }
166 |
--------------------------------------------------------------------------------
/src/store/signals/log.ts:
--------------------------------------------------------------------------------
1 | import { computed, signal } from '@preact/signals';
2 | import { LogState } from '../types';
3 | import { settingsState } from './settings';
4 |
5 | export const logState = signal([] as LogState[]);
6 |
7 | export const addLogEntry = (entry: Omit) => {
8 | if (!settingsState.value?.enableLogging) return;
9 | const newEntry: LogState = {
10 | ...entry,
11 | timestamp: new Date(),
12 | };
13 | logState.value = [...logState.value, newEntry].slice(-200); // Keep only the last 200 entries
14 | };
15 |
16 | export const hasLog = computed(() => logState.value.length > 0);
17 |
18 | export const logStateByDay = computed(() => {
19 | if (!hasLog.value) return {};
20 | const grouped: Record = {};
21 | logState.value.forEach((entry) => {
22 | const date = entry.timestamp.toISOString().split('T')[0]; // Get date in YYYY-MM-DD format
23 | if (!grouped[date]) {
24 | grouped[date] = [];
25 | }
26 | grouped[date].push(entry);
27 | });
28 | return grouped;
29 | });
30 |
31 | export const logDays = computed(() => {
32 | return Object.keys(logStateByDay.value).sort((a, b) => new Date(b).getTime() - new Date(a).getTime());
33 | });
34 |
--------------------------------------------------------------------------------
/src/store/signals/player.ts:
--------------------------------------------------------------------------------
1 | import { signal } from '@preact/signals';
2 | import { PlayerState } from '~/store/types';
3 |
4 | export const isPlayerMaximized = signal(false);
5 | export const playerState = signal(null);
6 |
7 | export const togglePlayPause = () => {
8 | if (playerState.value) {
9 | playerState.value = {
10 | ...playerState.value,
11 | isPlaying: !playerState.value.isPlaying,
12 | };
13 | }
14 | };
15 |
16 | export const togglePlayerMaximized = () => {
17 | isPlayerMaximized.value = !isPlayerMaximized.value;
18 | };
19 |
--------------------------------------------------------------------------------
/src/store/signals/playlist.ts:
--------------------------------------------------------------------------------
1 | import { signal } from '@preact/signals';
2 | import { Playlist, PlaylistRule } from '../types';
3 |
4 | export const playlists = signal(null);
5 | export const playlistRules = signal([]);
6 |
--------------------------------------------------------------------------------
/src/store/signals/podcast.ts:
--------------------------------------------------------------------------------
1 | import { computed, signal } from '@preact/signals';
2 | import { Podcast, PodcastSearchResult } from '../types';
3 |
4 | export const followedPodcasts = signal([]);
5 | export const recentlyVisitedPodcasts = signal([]);
6 | export const lastPodcastSearchResult = signal(null);
7 | export const featuredPodcasts = signal([]);
8 |
9 | export const savedPodcasts = computed(() => {
10 | return [...followedPodcasts.value, ...recentlyVisitedPodcasts.value];
11 | });
12 |
13 | export const getPodcast = (id: string): Podcast | undefined => {
14 | return savedPodcasts.value.find((p) => p.id === id);
15 | };
16 |
17 | export const updatePodcast = (updatedPodcast: Podcast) => {
18 | followedPodcasts.value = followedPodcasts.value.map((p) => (p.id === updatedPodcast.id ? updatedPodcast : p));
19 | recentlyVisitedPodcasts.value = recentlyVisitedPodcasts.value.map((p) =>
20 | p.id === updatedPodcast.id ? updatedPodcast : p,
21 | );
22 | };
23 |
24 | export const updatePodcastEpisodeCurrentTime = (podcastID: string, episodeAudioUrl: string, currentTime: number) => {
25 | const podcast = getPodcast(podcastID);
26 | if (!podcast || !episodeAudioUrl) return;
27 | updatePodcast({
28 | ...podcast,
29 | episodes: podcast.episodes?.map((episode) => {
30 | if (episode.audio === episodeAudioUrl) {
31 | return { ...episode, currentTime: currentTime };
32 | }
33 | return episode;
34 | }),
35 | });
36 | };
37 |
38 | export const addRecentlyVisitedPodcast = (podcast: Podcast) => {
39 | recentlyVisitedPodcasts.value = [podcast, ...recentlyVisitedPodcasts.value.filter((p) => p.id !== podcast.id)].slice(
40 | 0,
41 | 10,
42 | );
43 | };
44 |
45 | export const followPodcast = (podcast: Podcast) => {
46 | if (!followedPodcasts.value.some((p) => p.id === podcast.id)) {
47 | followedPodcasts.value = [...followedPodcasts.value, podcast];
48 | } else {
49 | unfollowPodcast(podcast.id);
50 | }
51 | };
52 |
53 | export const unfollowPodcast = (id: string) => {
54 | followedPodcasts.value = followedPodcasts.value.filter((p) => p.id !== id);
55 | };
56 |
57 | export const isFollowedPodcast = (id: string) => {
58 | return followedPodcasts.value.some((p) => p.id === id);
59 | };
60 |
61 | export const setLastPodcastSearchResult = (query: string, result: Podcast[]) => {
62 | lastPodcastSearchResult.value = { query, result };
63 | };
64 |
65 | export const clearLastPodcastSearchResult = () => {
66 | lastPodcastSearchResult.value = null;
67 | };
68 |
--------------------------------------------------------------------------------
/src/store/signals/radio.ts:
--------------------------------------------------------------------------------
1 | import { computed, signal } from '@preact/signals';
2 | import { Genre, Language, Podcast, RadioSearchFilters, RadioSearchResult, RadioStation } from '~/store/types';
3 | import { playerState } from './player';
4 |
5 | export const RADIO_BROWSER_PARAM_PREFIX = 'rb-';
6 |
7 | export const radioStations = signal([]);
8 | export const radioBrowserStations = signal([]);
9 | export const radioSearchFilters = signal(null);
10 | export const radioLanguages = signal([]);
11 | export const radioGenres = signal([]);
12 | export const followedRadioStationIDs = signal([]);
13 | export const recentlyVisitedRadioStationIDs = signal([]);
14 | export const lastRadioSearchResult = signal(null);
15 | export const stationPodcasts = signal>({});
16 |
17 | export const allRadioStations = computed(() => [...radioStations.value, ...radioBrowserStations.value]);
18 |
19 | export const userLanguage = computed(() => {
20 | const navLang = navigator.language;
21 | if (radioLanguages.value.some((lang) => lang.id === navLang)) return navLang;
22 | const lang = radioLanguages.value.find((lang) => navLang.startsWith(lang.id))?.id;
23 | return lang || '';
24 | });
25 |
26 | export const recentlyVisitedRadioStations = computed(() => {
27 | let stations = recentlyVisitedRadioStationIDs.value
28 | .map((id) => allRadioStations.value.find((r) => r.id === id))
29 | .filter((r) => !!r);
30 | if (stations.length < 10) {
31 | const userLang = userLanguage.value === 'en' ? '' : userLanguage.value;
32 | const langs = [...new Set([userLang, 'en-UK', 'en-US', 'en'])].filter(Boolean);
33 | stations = [
34 | ...stations,
35 | ...[...allRadioStations.value]
36 | .sort((a, b) => a.displayorder - b.displayorder)
37 | .sort((a, b) => langs.indexOf(a.language) - langs.indexOf(b.language))
38 | .filter((rs) => langs.includes(rs.language) && !stations.some((s) => s.id === rs.id)),
39 | ];
40 | }
41 | return stations.slice(0, 10);
42 | });
43 |
44 | export const activeRadioFilterCount = computed(() => {
45 | return (radioSearchFilters.value?.regions?.length || 0) + (radioSearchFilters.value?.genres?.length || 0);
46 | });
47 |
48 | export const getRadioStation = (id: string | undefined): RadioStation | undefined => {
49 | if (!id) return undefined;
50 | return allRadioStations.value.find((r) => r.id === id);
51 | };
52 |
53 | export const getRadioStationLanguage = (radioStation: RadioStation): Language | undefined => {
54 | if (!radioStation) return undefined;
55 | return radioLanguages.value.find((l) => l.id === radioStation.language);
56 | };
57 |
58 | export const addRecentlyVisitedRadioStation = (id: string | undefined) => {
59 | if (!id) return;
60 | recentlyVisitedRadioStationIDs.value = [id, ...recentlyVisitedRadioStationIDs.value.filter((s) => s !== id)].slice(
61 | 0,
62 | 10,
63 | );
64 | };
65 |
66 | export const addRadioBrowserStation = (radioStation: RadioStation) => {
67 | if (!radioStation?.id.startsWith(RADIO_BROWSER_PARAM_PREFIX)) return;
68 | radioBrowserStations.value = [
69 | { ...radioStation },
70 | ...radioBrowserStations.value.filter((s) => s.id !== radioStation.id),
71 | ].slice(0, 100);
72 | };
73 |
74 | export const deleteBrowserStation = (id: string) => {
75 | radioBrowserStations.value = radioBrowserStations.value.filter((s) => s.id !== id);
76 | recentlyVisitedRadioStationIDs.value = recentlyVisitedRadioStationIDs.value.filter((s) => s !== id);
77 | followedRadioStationIDs.value = followedRadioStationIDs.value.filter((s) => s !== id);
78 | };
79 |
80 | export const getRadioGenres = (): Genre[] =>
81 | radioGenres.value
82 | .filter((g) => allRadioStations.value.some((r) => r.genres.includes(g.id)))
83 | .sort((a, b) => a.name.localeCompare(b.name));
84 |
85 | export const followRadioStation = (id: string) => {
86 | if (!followedRadioStationIDs.value.some((s) => s === id)) {
87 | followedRadioStationIDs.value = [...followedRadioStationIDs.value, id];
88 | } else {
89 | unfollowRadioStation(id);
90 | }
91 | };
92 |
93 | export const unfollowRadioStation = (id: string) => {
94 | followedRadioStationIDs.value = followedRadioStationIDs.value.filter((s) => s !== id);
95 | };
96 |
97 | export const isFollowedRadioStation = (id: string) => {
98 | return followedRadioStationIDs.value.some((s) => s === id);
99 | };
100 |
101 | export const setLastRadioSearchResultQuery = (query: string) => {
102 | lastRadioSearchResult.value = { query };
103 | };
104 |
105 | export const clearLastRadioSearchResult = () => {
106 | lastRadioSearchResult.value = null;
107 | };
108 |
109 | export const playRadioStationByID = (stationID: string | undefined) => {
110 | playRadioStation(getRadioStation(stationID));
111 | };
112 |
113 | export const playRadioStation = (station: RadioStation | undefined, shouldAddToRecentlyVisited = true) => {
114 | if (!station) return;
115 | if (shouldAddToRecentlyVisited) {
116 | addRecentlyVisitedRadioStation(station.id);
117 | }
118 | playerState.value = {
119 | playType: 'radio',
120 | isPlaying: playerState.value?.isPlaying || true,
121 | contentID: station.id,
122 | title: station.name,
123 | description: '',
124 | imageUrl: station.logosource,
125 | streams: station.streams,
126 | pageLocation: `/radio-station/${station.id}`,
127 | };
128 | };
129 |
130 | export const playNextRadioStation = (isPrev?: boolean) => {
131 | if (!playerState.value?.contentID) return;
132 | const currentIndex = recentlyVisitedRadioStationIDs.value.findIndex((s) => s === playerState.value?.contentID);
133 | if (currentIndex === -1) return;
134 | let newIndex = isPrev ? currentIndex - 1 : currentIndex + 1;
135 | if (newIndex < 0) newIndex = recentlyVisitedRadioStationIDs.value.length - 1;
136 | if (newIndex >= recentlyVisitedRadioStationIDs.value.length) newIndex = 0;
137 | if (currentIndex === recentlyVisitedRadioStationIDs.value.length - 1) {
138 | playRadioStation(
139 | allRadioStations.value.find((s) => s.id === recentlyVisitedRadioStationIDs.value[newIndex]),
140 | false,
141 | );
142 | } else {
143 | playRadioStation(
144 | allRadioStations.value.find((s) => s.id === recentlyVisitedRadioStationIDs.value[newIndex]),
145 | false,
146 | );
147 | }
148 | };
149 |
150 | export const getStationPodcasts = (stationId: string): Podcast[] => {
151 | return stationPodcasts.value[stationId] || [];
152 | };
153 |
154 | export const setStationPodcasts = (podcastData: Record) => {
155 | stationPodcasts.value = podcastData;
156 | };
157 |
--------------------------------------------------------------------------------
/src/store/signals/settings.ts:
--------------------------------------------------------------------------------
1 | import { signal } from '@preact/signals';
2 | import { SettingsState } from '../types';
3 |
4 | export const DEFAULT_MAX_RECONNECT_ATTEMPTS = 200;
5 |
6 | export const settingsState = signal({} as SettingsState);
7 |
--------------------------------------------------------------------------------
/src/store/signals/ui.ts:
--------------------------------------------------------------------------------
1 | import { signal } from '@preact/signals';
2 | import { ToastProps } from '~/components/appShell/toast/types';
3 | import { UIState } from '../types';
4 |
5 | export const uiState = signal({
6 | headerTitle: '',
7 | headerDefaultTextColor: 'default',
8 | });
9 | export const uiIsScrolled = signal(false);
10 | export const hasAppUpdatedMessage = signal(false);
11 | export const toasts = signal([]);
12 |
13 | const TOAST_DURATION = 5000;
14 |
15 | export const addToast = (toast: Omit) => {
16 | if (toasts.value.some((t) => t.title === toast.title && t.description === toast.description)) return;
17 |
18 | const id = new Date().toISOString();
19 | const newToast = { ...toast, id, duration: toast.duration || TOAST_DURATION };
20 |
21 | toasts.value = [...toasts.value, newToast];
22 |
23 | // Auto dismiss
24 | setTimeout(() => {
25 | dismissToast(id);
26 | }, newToast.duration);
27 |
28 | return id;
29 | };
30 |
31 | export const dismissToast = (id: string) => {
32 | toasts.value = toasts.value.filter((t) => t.id !== id);
33 | };
34 |
--------------------------------------------------------------------------------
/src/store/types.ts:
--------------------------------------------------------------------------------
1 | import { ThemeOption } from '~/pages/settings/types';
2 |
3 | export interface Stream {
4 | mimetype: string;
5 | url: string;
6 | }
7 |
8 | export enum SocialAccountType {
9 | Facebook = 'facebook',
10 | Twitter = 'twitter',
11 | Instagram = 'instagram',
12 | Youtube = 'youtube',
13 | TikTok = 'tiktok',
14 | }
15 |
16 | export interface SocialAccount {
17 | type: SocialAccountType;
18 | title: string;
19 | url: string;
20 | account?: string;
21 | }
22 |
23 | export interface RadioStation {
24 | id: string;
25 | displayorder: number;
26 | name: string;
27 | logosource: string;
28 | language: string;
29 | genres: string[];
30 | streams: Stream[];
31 | website?: string;
32 | social?: SocialAccount[];
33 | podcasts?: string[];
34 | related?: string[];
35 | stationuuid?: string;
36 | lastChanged?: string;
37 | }
38 |
39 | export interface Language {
40 | id: string;
41 | abbr: string;
42 | country: string;
43 | country_en: string;
44 | displayorder: number;
45 | flag?: string;
46 | name: string;
47 | name_en: string;
48 | }
49 |
50 | export interface Genre {
51 | id: string;
52 | name: string;
53 | }
54 |
55 | export interface Podcast {
56 | id: string;
57 | title: string;
58 | description: string;
59 | imageUrl: string;
60 | url: string;
61 | feedUrl: string;
62 | categories?: string[];
63 | addedDate: number;
64 | lastFetched: number;
65 | episodes?: Episode[];
66 | }
67 |
68 | export interface Episode {
69 | title: string;
70 | description: string;
71 | guid?: string;
72 | pubDate: Date;
73 | duration: string;
74 | audio: string;
75 | mimeType: string;
76 | currentTime?: number;
77 | }
78 |
79 | export interface PlaylistItem {
80 | time: string;
81 | stationID: string;
82 | }
83 |
84 | export interface Playlist {
85 | url: string;
86 | name: string;
87 | items: PlaylistItem[];
88 | timeZone?: string;
89 | oldUrl?: string;
90 | }
91 |
92 | export interface PlayerState {
93 | playType: 'none' | 'radio' | 'podcast' | 'playlist';
94 | isPlaying: boolean;
95 | contentID: string;
96 | title: string;
97 | description?: string;
98 | imageUrl: string;
99 | streams: Stream[];
100 | pageLocation: string;
101 | currentTime?: number;
102 | shareUrl?: string;
103 | }
104 |
105 | export interface PodcastJsonGenre {
106 | genreId: string;
107 | name: string;
108 | }
109 |
110 | export interface PodcastSearchResult {
111 | query: string;
112 | result: Podcast[];
113 | }
114 |
115 | export interface RadioSearchResult {
116 | query: string;
117 | radioBrowserSearchResult?: RadioStation[];
118 | }
119 |
120 | export interface RadioSearchFilters {
121 | regions: string[];
122 | genres: string[];
123 | }
124 |
125 | export interface UIState {
126 | headerTitle: string;
127 | headerDefaultTextColor: 'light' | 'default';
128 | }
129 |
130 | export enum PodcastSearchProvider {
131 | Apple = 'Apple',
132 | PodcastIndex = 'PodcastIndex',
133 | }
134 |
135 | export interface SettingsState {
136 | theme?: ThemeOption;
137 | radioStreamMaxReconnects?: number;
138 | podcastSearchProvider?: PodcastSearchProvider;
139 | enableChromecast?: boolean;
140 | disableReconnectNoise?: boolean;
141 | enableLogging?: boolean;
142 | }
143 |
144 | export enum PlaylistRuleType {
145 | podcastToStation = 'podcastToStation',
146 | podcastToPlaylist = 'podcastToPlaylist',
147 | }
148 |
149 | export interface PlaylistRule {
150 | ruleType: PlaylistRuleType;
151 | podcastID?: string;
152 | stationID?: string;
153 | playlistUrl?: string;
154 | }
155 |
156 | export interface LogState {
157 | timestamp: Date;
158 | level: 'info' | 'warn' | 'error';
159 | message: string;
160 | }
161 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/tests/homepage.spec.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from '@playwright/test';
2 |
3 | test('homepage has title', async ({ page }) => {
4 | await page.goto('/');
5 |
6 | // Expect a title "to contain" a substring.
7 | await expect(page).toHaveTitle(/1tuner/);
8 | });
9 |
--------------------------------------------------------------------------------
/tests/radio.spec.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from '@playwright/test';
2 |
3 | test('Search for a radio station, play and stop it', async ({ page }) => {
4 | await page.goto('/');
5 |
6 | // Search for "kink"
7 | await page.getByRole('link', { name: 'Radio' }).first().click();
8 | await page.getByTitle('🇺🇸 English (United States)').getByRole('button').click();
9 | await page.getByPlaceholder('Search radio stations...').fill('kink');
10 |
11 | // Click the station
12 | await page.getByRole('heading', { name: 'KINK' }).first().click();
13 | await page.getByLabel('Play').first().click();
14 |
15 | // Wait for the player to be visible
16 | await expect(page.getByLabel('Close player')).toBeVisible();
17 | await page.getByLabel('Close player').click();
18 |
19 | // Go back to the homepage
20 | //await page.goto('/');
21 |
22 | //await expect(page.getByTitle('Play KINK')).toBeVisible();
23 | });
24 |
25 | test('Follow a radio station and check it on the radio stations page', async ({ page }) => {
26 | await page.goto('/');
27 |
28 | // Search for "kink"
29 | await page.getByRole('link', { name: 'Radio' }).first().click();
30 | await page.getByTitle('🇺🇸 English (United States)').getByRole('button').click();
31 | await page.getByPlaceholder('Search radio stations...').fill('kink');
32 |
33 | // Click the station
34 | await page.getByRole('heading', { name: 'KINK' }).first().click();
35 |
36 | // Click the follow button
37 | await page.getByRole('button', { name: 'Follow' }).click();
38 |
39 | // Navigate to the radio stations page
40 | await page.getByRole('link', { name: 'Radio' }).first().click();
41 |
42 | // Check that the station is in the "Following" list on the radio stations page
43 | await expect(page.getByTitle('Play KINK').first()).toBeVisible();
44 | });
45 |
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "useDefineForClassFields": true,
5 | "module": "ESNext",
6 | "lib": ["ES2022", "DOM", "DOM.Iterable"],
7 | "skipLibCheck": true,
8 | "paths": {
9 | "react": ["./node_modules/preact/compat/"],
10 | "react-dom": ["./node_modules/preact/compat/"],
11 | "~/*": ["./src/*"]
12 | },
13 |
14 | /* Bundler mode */
15 | "moduleResolution": "bundler",
16 | "allowImportingTsExtensions": true,
17 | "isolatedModules": true,
18 | "moduleDetection": "force",
19 | "noEmit": true,
20 | "jsx": "react-jsx",
21 | "jsxImportSource": "preact",
22 |
23 | /* Linting */
24 | "strict": true,
25 | "noUnusedLocals": true,
26 | "noUnusedParameters": true,
27 | "noFallthroughCasesInSwitch": true,
28 | "baseUrl": "."
29 | },
30 | "include": ["src"]
31 | }
32 |
--------------------------------------------------------------------------------
/tsconfig.app.tsbuildinfo:
--------------------------------------------------------------------------------
1 | {"root":["./src/app.tsx","./src/global.d.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/content-section.tsx","./src/components/day-timeline.tsx","./src/components/image-background.tsx","./src/components/loader.tsx","./src/components/podcast-card.tsx","./src/components/radio-station-card.tsx","./src/components/share-button.tsx","./src/components/appshell/appshell.tsx","./src/components/appshell/useappshell.ts","./src/components/appshell/toast/toast.tsx","./src/components/appshell/toast/toaster.tsx","./src/components/appshell/toast/types.ts","./src/components/player/player.tsx","./src/components/player/useplayer.ts","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dropdown-list.tsx","./src/components/ui/input.tsx","./src/components/ui/radio-button-list.tsx","./src/components/ui/radio-button.tsx","./src/components/ui/switch.tsx","./src/components/ui/tag-select.tsx","./src/hooks/usecastapi.ts","./src/hooks/usehead.ts","./src/hooks/usenoise.ts","./src/hooks/usepodcastdata.ts","./src/hooks/useradiobrowser.ts","./src/lib/converttime.ts","./src/lib/opmlutil.ts","./src/lib/playlistutil.ts","./src/lib/reconnectutil.ts","./src/lib/styleclass.ts","./src/lib/utils.ts","./src/lib/validationutil.ts","./src/lib/version.ts","./src/pages/about/index.tsx","./src/pages/homepage/index.tsx","./src/pages/not-found/index.tsx","./src/pages/playlist/index.tsx","./src/pages/playlist/useplaylist.ts","./src/pages/playlists/index.tsx","./src/pages/playlists/types.ts","./src/pages/playlists/useplaylists.ts","./src/pages/podcast/index.tsx","./src/pages/podcast/usepodcast.ts","./src/pages/podcasts/index.tsx","./src/pages/podcasts/usepodcasts.ts","./src/pages/radio-station/index.tsx","./src/pages/radio-station/useradiostation.ts","./src/pages/radio-stations/index.tsx","./src/pages/radio-stations/useradiostations.ts","./src/pages/settings/index.tsx","./src/pages/settings/types.ts","./src/pages/settings/usesettings.ts","./src/store/types.ts","./src/store/db/db.ts","./src/store/db/migration.ts","./src/store/signals/log.ts","./src/store/signals/player.ts","./src/store/signals/playlist.ts","./src/store/signals/podcast.ts","./src/store/signals/radio.ts","./src/store/signals/settings.ts","./src/store/signals/ui.ts"],"version":"5.9.3"}
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }],
4 | "compilerOptions": {
5 | "baseUrl": ".",
6 | "paths": {
7 | "~/*": ["./src/*"]
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "lib": ["ES2023"],
5 | "module": "ESNext",
6 | "skipLibCheck": true,
7 |
8 | /* Bundler mode */
9 | "moduleResolution": "bundler",
10 | "allowImportingTsExtensions": true,
11 | "isolatedModules": true,
12 | "moduleDetection": "force",
13 | "noEmit": true,
14 |
15 | /* Linting */
16 | "strict": true,
17 | "noUnusedLocals": true,
18 | "noUnusedParameters": true,
19 | "noFallthroughCasesInSwitch": true
20 | },
21 | "include": ["vite.config.ts"]
22 | }
23 |
--------------------------------------------------------------------------------
/tsconfig.node.tsbuildinfo:
--------------------------------------------------------------------------------
1 | {"root":["./vite.config.ts"],"version":"5.9.3"}
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import preact from '@preact/preset-vite';
2 | import tailwindcss from '@tailwindcss/vite';
3 | import path from 'path';
4 | import { defineConfig } from 'vite';
5 | import { VitePWA } from 'vite-plugin-pwa';
6 | import { APP_VERSION } from './src/lib/version';
7 |
8 | // https://vitejs.dev/config/
9 | export default defineConfig({
10 | plugins: [
11 | preact({ prerender: { enabled: true, renderTarget: '#app' } }),
12 | tailwindcss(),
13 | VitePWA({
14 | injectRegister: 'script-defer',
15 | registerType: 'autoUpdate',
16 | manifest: false,
17 | workbox: {
18 | globPatterns: ['assets/**/*.{js,css,html}', 'manifest.json'],
19 | cacheId: `1tuner-${APP_VERSION}`,
20 | clientsClaim: true,
21 | skipWaiting: true,
22 | cleanupOutdatedCaches: true,
23 | maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, // 5MB
24 | runtimeCaching: [
25 | {
26 | urlPattern: /^https:\/\/.*\.(png|jpg|jpeg|svg|gif|ico)$/,
27 | handler: 'CacheFirst',
28 | options: {
29 | cacheName: 'image-cache',
30 | expiration: {
31 | maxEntries: 200,
32 | maxAgeSeconds: 7 * 24 * 60 * 60, // 7 days
33 | },
34 | },
35 | },
36 | {
37 | urlPattern: ({ request }) => {
38 | return request.destination === 'audio' || request.destination === 'video';
39 | },
40 | handler: 'NetworkOnly',
41 | },
42 | ],
43 | },
44 | }),
45 | ],
46 | resolve: {
47 | alias: {
48 | '~': path.resolve(__dirname, './src'),
49 | },
50 | },
51 | });
52 |
--------------------------------------------------------------------------------