├── web ├── ssl │ └── .gitkeep ├── .npmrc ├── src │ ├── routes │ │ ├── +layout.server.ts │ │ ├── panel │ │ │ ├── mobile │ │ │ │ ├── +layout.server.ts │ │ │ │ ├── layouts │ │ │ │ │ ├── ask-server-ip.svelte │ │ │ │ │ ├── customized.svelte │ │ │ │ │ └── mirror.svelte │ │ │ │ ├── +page.svelte │ │ │ │ └── +layout.svelte │ │ │ ├── desktop │ │ │ │ ├── +layout.server.ts │ │ │ │ └── +layout.svelte │ │ │ ├── +layout.server.ts │ │ │ ├── +page.svelte │ │ │ └── +layout.svelte │ │ ├── +page.svelte │ │ └── +layout.svelte │ ├── lib │ │ ├── index.ts │ │ ├── utils │ │ │ ├── enums.ts │ │ │ ├── api.ts │ │ │ └── misc.ts │ │ ├── api-return-types.ts │ │ ├── update-checker.ts │ │ ├── demo │ │ │ ├── demo-audio.ts │ │ │ ├── configs.ts │ │ │ ├── demo-sounds.ts │ │ │ └── demo-data.ts │ │ └── preview-audio.ts │ ├── stores │ │ ├── sounds.ts │ │ ├── customized-layout.ts │ │ ├── settings.ts │ │ └── mirror-layout.ts │ ├── app.d.ts │ ├── components │ │ ├── demo │ │ │ └── DisabledInDemoPopup.svelte │ │ ├── showcase │ │ │ └── tour-tip-custom.svelte │ │ ├── settings │ │ │ ├── mirror-layout-settings.svelte │ │ │ └── customized-layout-settings.svelte │ │ ├── icons │ │ │ └── ko-fi.svelte │ │ ├── mobile │ │ │ ├── install-pwa.svelte │ │ │ ├── toolbar-buttons.svelte │ │ │ └── toolbar.svelte │ │ ├── dekstop │ │ │ ├── pairing-qrcode.svelte │ │ │ ├── sound-extractor.svelte │ │ │ ├── sound-bank-lookup.svelte │ │ │ ├── sound-previewer.svelte │ │ │ └── youtube-extractor.svelte │ │ ├── buttons │ │ │ ├── sound.svelte │ │ │ ├── manual-host.svelte │ │ │ └── qrcode-scan.svelte │ │ ├── drawers │ │ │ └── mobile-settings.svelte │ │ ├── modals │ │ │ └── download-file-modal.svelte │ │ └── double-range-slider.svelte │ ├── app.html │ ├── server │ │ └── socket.ts │ ├── app.postcss │ └── client │ │ └── connections.ts ├── static │ ├── banner.png │ ├── logo_192.png │ ├── soundpad.png │ ├── soundpaddon.ico │ ├── demo-sounds │ │ ├── bell.mp3 │ │ ├── bonk.mp3 │ │ ├── nope.mp3 │ │ ├── quack.mp3 │ │ ├── Cantina.mp3 │ │ ├── murloc.mp3 │ │ ├── crickets.mp3 │ │ ├── freebird.mp3 │ │ ├── fake earrape.mp3 │ │ ├── nyan-cat-original.mp3 │ │ ├── nyan-cat-original.ogg │ │ ├── Spooky Scary Skeleton.mp3 │ │ ├── realtime-volume-detection.mp3 │ │ └── taking-the-hobbits-to-isengard-mp3cut.mp3 │ ├── img │ │ ├── guide │ │ │ ├── pairing.png │ │ │ ├── mobile_guide.png │ │ │ ├── website_import.png │ │ │ ├── youtube_import.png │ │ │ └── soundback_import.png │ │ ├── layout_base.png │ │ ├── layout_grid.png │ │ └── layout_list.png │ ├── soundpad_banner.png │ └── logo.svg ├── postcss.config.cjs ├── .gitignore ├── tsconfig.json ├── tailwind.config.ts ├── README.md ├── svelte.config.js ├── vite.config.ts └── package.json ├── assets ├── banner.png ├── logo.ico ├── logo_128.png ├── logo_192.png ├── logo_256.png └── logo.svg ├── desktop ├── src │ ├── main │ │ ├── .gitignore │ │ ├── socketIoHandler.ts │ │ ├── auto-minimize-tray-option.ts │ │ ├── routes │ │ │ ├── data.ts │ │ │ ├── index.ts │ │ │ ├── proxy.ts │ │ │ ├── soundpad.ts │ │ │ └── import │ │ │ │ ├── youtube.ts │ │ │ │ └── url.ts │ │ ├── download-location-tray-option.ts │ │ ├── start-with-windows-tray-option.ts │ │ ├── utils │ │ │ ├── yt-dlp.ts │ │ │ └── misc.ts │ │ ├── index.ts │ │ ├── add-system-tray.ts │ │ └── websocket-server.ts │ ├── preload.js │ ├── index.css │ ├── customTypes │ │ └── index.d.ts │ ├── index.html │ └── index.js ├── assets │ └── soundpaddon.ico ├── README.md ├── tsconfig.json ├── tsconfig.main.json ├── fetch-sources.ts ├── .gitignore ├── forge.config.js └── package.json ├── .vscode ├── launch.json └── settings.json └── readme.md /web/ssl/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /assets/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seblor/Soundpaddon/HEAD/assets/banner.png -------------------------------------------------------------------------------- /assets/logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seblor/Soundpaddon/HEAD/assets/logo.ico -------------------------------------------------------------------------------- /desktop/src/main/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | dist 4 | **/*.js 5 | **/*.js.map -------------------------------------------------------------------------------- /assets/logo_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seblor/Soundpaddon/HEAD/assets/logo_128.png -------------------------------------------------------------------------------- /assets/logo_192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seblor/Soundpaddon/HEAD/assets/logo_192.png -------------------------------------------------------------------------------- /assets/logo_256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seblor/Soundpaddon/HEAD/assets/logo_256.png -------------------------------------------------------------------------------- /web/src/routes/+layout.server.ts: -------------------------------------------------------------------------------- 1 | export const prerender = true 2 | export const ssr = true -------------------------------------------------------------------------------- /web/static/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seblor/Soundpaddon/HEAD/web/static/banner.png -------------------------------------------------------------------------------- /web/src/lib/index.ts: -------------------------------------------------------------------------------- 1 | // place files you want to import through the `$lib` alias in this folder. 2 | -------------------------------------------------------------------------------- /web/src/routes/panel/mobile/+layout.server.ts: -------------------------------------------------------------------------------- 1 | export const prerender = true 2 | export const ssr = false -------------------------------------------------------------------------------- /web/static/logo_192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seblor/Soundpaddon/HEAD/web/static/logo_192.png -------------------------------------------------------------------------------- /web/static/soundpad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seblor/Soundpaddon/HEAD/web/static/soundpad.png -------------------------------------------------------------------------------- /web/src/routes/panel/desktop/+layout.server.ts: -------------------------------------------------------------------------------- 1 | export const prerender = true 2 | export const ssr = false -------------------------------------------------------------------------------- /web/static/soundpaddon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seblor/Soundpaddon/HEAD/web/static/soundpaddon.ico -------------------------------------------------------------------------------- /desktop/assets/soundpaddon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seblor/Soundpaddon/HEAD/desktop/assets/soundpaddon.ico -------------------------------------------------------------------------------- /web/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } -------------------------------------------------------------------------------- /web/static/demo-sounds/bell.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seblor/Soundpaddon/HEAD/web/static/demo-sounds/bell.mp3 -------------------------------------------------------------------------------- /web/static/demo-sounds/bonk.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seblor/Soundpaddon/HEAD/web/static/demo-sounds/bonk.mp3 -------------------------------------------------------------------------------- /web/static/demo-sounds/nope.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seblor/Soundpaddon/HEAD/web/static/demo-sounds/nope.mp3 -------------------------------------------------------------------------------- /web/static/demo-sounds/quack.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seblor/Soundpaddon/HEAD/web/static/demo-sounds/quack.mp3 -------------------------------------------------------------------------------- /web/static/img/guide/pairing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seblor/Soundpaddon/HEAD/web/static/img/guide/pairing.png -------------------------------------------------------------------------------- /web/static/img/layout_base.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seblor/Soundpaddon/HEAD/web/static/img/layout_base.png -------------------------------------------------------------------------------- /web/static/img/layout_grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seblor/Soundpaddon/HEAD/web/static/img/layout_grid.png -------------------------------------------------------------------------------- /web/static/img/layout_list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seblor/Soundpaddon/HEAD/web/static/img/layout_list.png -------------------------------------------------------------------------------- /web/static/soundpad_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seblor/Soundpaddon/HEAD/web/static/soundpad_banner.png -------------------------------------------------------------------------------- /web/static/demo-sounds/Cantina.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seblor/Soundpaddon/HEAD/web/static/demo-sounds/Cantina.mp3 -------------------------------------------------------------------------------- /web/static/demo-sounds/murloc.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seblor/Soundpaddon/HEAD/web/static/demo-sounds/murloc.mp3 -------------------------------------------------------------------------------- /web/static/demo-sounds/crickets.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seblor/Soundpaddon/HEAD/web/static/demo-sounds/crickets.mp3 -------------------------------------------------------------------------------- /web/static/demo-sounds/freebird.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seblor/Soundpaddon/HEAD/web/static/demo-sounds/freebird.mp3 -------------------------------------------------------------------------------- /web/static/img/guide/mobile_guide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seblor/Soundpaddon/HEAD/web/static/img/guide/mobile_guide.png -------------------------------------------------------------------------------- /web/static/demo-sounds/fake earrape.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seblor/Soundpaddon/HEAD/web/static/demo-sounds/fake earrape.mp3 -------------------------------------------------------------------------------- /web/static/img/guide/website_import.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seblor/Soundpaddon/HEAD/web/static/img/guide/website_import.png -------------------------------------------------------------------------------- /web/static/img/guide/youtube_import.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seblor/Soundpaddon/HEAD/web/static/img/guide/youtube_import.png -------------------------------------------------------------------------------- /web/static/img/guide/soundback_import.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seblor/Soundpaddon/HEAD/web/static/img/guide/soundback_import.png -------------------------------------------------------------------------------- /web/static/demo-sounds/nyan-cat-original.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seblor/Soundpaddon/HEAD/web/static/demo-sounds/nyan-cat-original.mp3 -------------------------------------------------------------------------------- /web/static/demo-sounds/nyan-cat-original.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seblor/Soundpaddon/HEAD/web/static/demo-sounds/nyan-cat-original.ogg -------------------------------------------------------------------------------- /web/static/demo-sounds/Spooky Scary Skeleton.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seblor/Soundpaddon/HEAD/web/static/demo-sounds/Spooky Scary Skeleton.mp3 -------------------------------------------------------------------------------- /web/static/demo-sounds/realtime-volume-detection.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seblor/Soundpaddon/HEAD/web/static/demo-sounds/realtime-volume-detection.mp3 -------------------------------------------------------------------------------- /web/static/demo-sounds/taking-the-hobbits-to-isengard-mp3cut.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seblor/Soundpaddon/HEAD/web/static/demo-sounds/taking-the-hobbits-to-isengard-mp3cut.mp3 -------------------------------------------------------------------------------- /desktop/src/preload.js: -------------------------------------------------------------------------------- 1 | // See the Electron documentation for details on how to use preload scripts: 2 | // https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts 3 | -------------------------------------------------------------------------------- /desktop/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, 3 | Arial, sans-serif; 4 | margin: auto; 5 | max-width: 38rem; 6 | padding: 2rem; 7 | } 8 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | build 4 | host-build 5 | /.svelte-kit 6 | /package 7 | .env 8 | .env.* 9 | !.env.example 10 | vite.config.js.timestamp-* 11 | vite.config.ts.timestamp-* 12 | ssl/*.key 13 | ssl/*.pem -------------------------------------------------------------------------------- /web/src/lib/utils/enums.ts: -------------------------------------------------------------------------------- 1 | export enum DRAWER_TYPES { 2 | MOBILE_SETTINGS = "mobile-settings", 3 | } 4 | 5 | export enum AUTOSTART_ACTIONS { 6 | ENABLE = "enable", 7 | DISABLE = "disable", 8 | GET = "get", 9 | } 10 | -------------------------------------------------------------------------------- /web/src/lib/api-return-types.ts: -------------------------------------------------------------------------------- 1 | export type SOUND_SOURCES = 'myinstants' | 'freesound' | 'voicy' | 'uwupad' | 'pixabay' | 'webpage' 2 | 3 | export type FetchedSound = { 4 | source: SOUND_SOURCES, 5 | name: string, 6 | url: string, 7 | } 8 | -------------------------------------------------------------------------------- /web/src/stores/sounds.ts: -------------------------------------------------------------------------------- 1 | import { demoData } from '$lib/demo/demo-sounds'; 2 | import { checkIsDemo } from '$lib/utils/misc'; 3 | import { writable } from 'svelte/store'; 4 | 5 | export const sounds = writable((checkIsDemo() ? demoData : [])); 6 | -------------------------------------------------------------------------------- /desktop/src/customTypes/index.d.ts: -------------------------------------------------------------------------------- 1 | export type SOUND_SOURCES = 'myinstants' | 'freesound' | 'voicy' | 'webpage' | 'uwupad' | 'pixabay'; 2 | 3 | export type FetchedSound = { 4 | source: SOUND_SOURCES, 5 | name: string, 6 | url: string, 7 | } 8 | -------------------------------------------------------------------------------- /web/src/routes/panel/+layout.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "@sveltejs/kit" 2 | import isMobile from 'is-mobile' 3 | 4 | export const prerender = true 5 | export const ssr = false 6 | 7 | if (isMobile()) { 8 | redirect(301, '/panel/mobile') 9 | } -------------------------------------------------------------------------------- /desktop/README.md: -------------------------------------------------------------------------------- 1 | # Creating binaries 2 | 3 | - Build both the main and the renderer with `npm run build` (or both `build:main` and `build:renderer` scripts). 4 | - Check if the build is working fine by creating a dev build with `npm run package`. 5 | - If everything works fine, create the binaries with `npm run make`. -------------------------------------------------------------------------------- /desktop/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Hello World! 6 | 7 | 8 | 9 |

💖 Hello World!

10 |

Welcome to your Electron application.

11 | 12 | 13 | -------------------------------------------------------------------------------- /web/src/lib/utils/api.ts: -------------------------------------------------------------------------------- 1 | import { get } from "svelte/store"; 2 | import { ipToSSLDomain } from "../../client/connections"; 3 | import { serverHost } from "../../stores/settings"; 4 | 5 | export function getEndpointUrl() { 6 | const server = get(serverHost) 7 | return `https://${ipToSSLDomain(server.ip)}:${server.port}/api`; 8 | } -------------------------------------------------------------------------------- /web/src/routes/panel/+page.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 |
13 | -------------------------------------------------------------------------------- /web/src/app.d.ts: -------------------------------------------------------------------------------- 1 | import 'unplugin-icons/types/svelte' 2 | 3 | // See https://kit.svelte.dev/docs/types#app 4 | // for information about these interfaces 5 | // and what to do when importing types 6 | declare namespace App { 7 | // interface Locals {} 8 | // interface PageData {} 9 | // interface Error {} 10 | // interface Platform {} 11 | } 12 | -------------------------------------------------------------------------------- /web/src/components/demo/DisabledInDemoPopup.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | {#if checkIsDemo()} 6 |
7 |

Disabled in demo

8 |
9 |
10 | {/if} 11 | -------------------------------------------------------------------------------- /desktop/src/main/socketIoHandler.ts: -------------------------------------------------------------------------------- 1 | import { Server } from 'socket.io'; 2 | import { Server as HttpServer } from 'node:http'; 3 | import onWebsocketConnection from './websocket-server'; 4 | 5 | export default function injectSocketIO(server: HttpServer) { 6 | const io = new Server(server); 7 | 8 | io.on('connection', onWebsocketConnection); 9 | 10 | console.log('SocketIO injected'); 11 | } 12 | -------------------------------------------------------------------------------- /web/src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Soundpaddon 7 | 8 | %sveltekit.head% 9 | 10 | 11 |
%sveltekit.body%
12 | 13 | 14 | -------------------------------------------------------------------------------- /web/src/routes/panel/desktop/+layout.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
14 | 15 |
16 | -------------------------------------------------------------------------------- /web/src/routes/panel/mobile/layouts/ask-server-ip.svelte: -------------------------------------------------------------------------------- 1 |
2 |

Welcome to Soundpaddon!

3 |
4 |

Please scan the QRCode shown on your computer with your phone or manually enter your computer's IP using the buttons bellow.

5 |

Make sure that both devices are on the same local network.

6 |
7 |
-------------------------------------------------------------------------------- /desktop/src/main/auto-minimize-tray-option.ts: -------------------------------------------------------------------------------- 1 | import Store from 'electron-store'; 2 | 3 | const store = new Store(); 4 | 5 | export function enableMinimizeOnWinClose (): void { 6 | store.set(`minimizeOnWinClose`, true); 7 | } 8 | 9 | export function disableMinimizeOnWinClose (): void { 10 | store.set(`minimizeOnWinClose`, false); 11 | } 12 | 13 | export function getMinimizeOnWinClose (): boolean { 14 | return Boolean(store.get(`minimizeOnWinClose`, false)); 15 | } 16 | -------------------------------------------------------------------------------- /web/src/components/showcase/tour-tip-custom.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 |

{@html message}

9 | 10 | 13 |
14 | 15 | 23 | -------------------------------------------------------------------------------- /desktop/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "allowJs": true, 5 | "module": "commonjs", 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "noImplicitAny": true, 9 | "sourceMap": true, 10 | "baseUrl": ".", 11 | "outDir": "dist", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "paths": { 15 | "*": [ 16 | "node_modules/*" 17 | ] 18 | } 19 | }, 20 | "include": [ 21 | "src/**/*", 22 | "*.ts", 23 | "forge.config.ts", 24 | "src/customTypes/index.d.ts" 25 | ] 26 | } -------------------------------------------------------------------------------- /web/src/components/settings/mirror-layout-settings.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 23 | -------------------------------------------------------------------------------- /desktop/tsconfig.main.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "allowJs": true, 5 | "module": "commonjs", 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "noImplicitAny": true, 9 | "sourceMap": true, 10 | "outDir": "./src/main/dist", 11 | "declaration": true, 12 | "baseUrl": "./src/main", 13 | "rootDir": "./src/main", 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "paths": { 17 | "*": [ 18 | "node_modules/*" 19 | ] 20 | } 21 | }, 22 | "include": [ 23 | "src/main/src/**/*", 24 | "src/main/*.ts" 25 | ] 26 | } -------------------------------------------------------------------------------- /desktop/src/main/routes/data.ts: -------------------------------------------------------------------------------- 1 | import type { Application, Request, Response } from 'express'; 2 | import { networkInterfaces } from 'os' 3 | import bodyParser from 'body-parser' 4 | import { App } from 'electron/main'; 5 | 6 | const nets = networkInterfaces(); 7 | 8 | export default function registerRoutes(app: Application, electronApp: App) { 9 | app.get('/api/data', bodyParser.text(), async function (req: Request, res: Response) { 10 | const localIPs = Object.values(nets).flat().filter(net => net && net.family === 'IPv4' && !net.address.startsWith('127')).map(net => net?.address) 11 | return res.send(JSON.stringify({ localIPs })) 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /desktop/src/main/routes/index.ts: -------------------------------------------------------------------------------- 1 | import registerDataRoutes from './data' 2 | import registerSoundpadRoutes from './soundpad' 3 | import refisterProxyRoutes from './proxy' 4 | import importYoutubeRoutes from './import/youtube' 5 | import importUrlRoutes from './import/url' 6 | import { type Application } from 'express' 7 | import { App } from 'electron/main' 8 | 9 | export const registerRoutes = (app: Application, electronApp: App) => { 10 | registerDataRoutes(app, electronApp) 11 | registerSoundpadRoutes(app, electronApp) 12 | refisterProxyRoutes(app, electronApp) 13 | importYoutubeRoutes(app, electronApp) 14 | importUrlRoutes(app, electronApp) 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "type": "node", 5 | "request": "launch", 6 | "name": "Electron Main", 7 | "runtimeExecutable": "${workspaceFolder}/desktop/node_modules/@electron-forge/cli/script/vscode.sh", 8 | "windows": { 9 | "runtimeExecutable": "${workspaceFolder}/desktop/node_modules/@electron-forge/cli/script/vscode.cmd" 10 | }, 11 | // runtimeArgs will be passed directly to your Electron application 12 | "runtimeArgs": [ 13 | "--trace-warnings" 14 | ], 15 | "cwd": "${workspaceFolder}/desktop", 16 | "console": "integratedTerminal" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /desktop/src/main/download-location-tray-option.ts: -------------------------------------------------------------------------------- 1 | 2 | import { App } from 'electron'; 3 | import Store from 'electron-store'; 4 | import path from 'path'; 5 | 6 | const store = new Store(); 7 | 8 | export function setDownloadLocation (location: string): void { 9 | store.set(`downloadLocation`, location); 10 | } 11 | 12 | export function getDownloadLocation (electronApp: App): string { 13 | return store.get(`downloadLocation`, path.join(electronApp.getPath('userData'), 'sounds')) as string; 14 | } 15 | 16 | export function resetDownloadLocation (electronApp: App): void { 17 | store.set(`downloadLocation`, path.join(electronApp.getPath('userData'), 'sounds')); 18 | } 19 | 20 | -------------------------------------------------------------------------------- /web/src/lib/update-checker.ts: -------------------------------------------------------------------------------- 1 | import { version } from '$app/environment'; 2 | 3 | export async function checkForUpdate (): Promise<{ newUpdateAvailable: boolean, latestUpdateVersion: string }> { 4 | const apiUrl = 'https://api.github.com/repos/Seblor/Soundpaddon/releases/latest'; 5 | 6 | const response = await fetch(apiUrl); 7 | const data = await response.json(); 8 | const latestVersion = data.tag_name; 9 | const currentVersion = version; 10 | if (latestVersion === currentVersion || latestVersion === 'v' + currentVersion) { 11 | return { newUpdateAvailable: false, latestUpdateVersion: latestVersion }; 12 | } 13 | return { newUpdateAvailable: true, latestUpdateVersion: latestVersion }; 14 | } 15 | -------------------------------------------------------------------------------- /web/src/server/socket.ts: -------------------------------------------------------------------------------- 1 | import { Server } from 'socket.io' 2 | import '../host/add-system-tray' 3 | 4 | // === socket.io setup for development === 5 | 6 | import onWebsocketConnection from '../host/websocket-server'; 7 | 8 | export function devSocketSetup() { 9 | console.log('[DEV] Starting to listen for socket connections'); 10 | const webSocketServer = { 11 | name: 'webSocketServer', 12 | configureServer(server: any) { 13 | if (!server.httpServer) return 14 | 15 | const io = new Server(server.httpServer) 16 | 17 | io.on('connection', onWebsocketConnection) 18 | io.on('disconnect', () => { 19 | console.log('Client disconnected') 20 | }) 21 | } 22 | } 23 | 24 | return webSocketServer 25 | } 26 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ESNext", 5 | "types": [ 6 | "node" 7 | ], 8 | "allowJs": true, 9 | "checkJs": true, 10 | "esModuleInterop": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "allowImportingTsExtensions": true, 13 | "resolveJsonModule": true, 14 | "skipLibCheck": true, 15 | "sourceMap": true, 16 | "strict": true, 17 | "moduleResolution": "Node", 18 | "typeRoots": [ 19 | "node_modules/@types", 20 | "src/customTypes" 21 | ] 22 | } 23 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 24 | // 25 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 26 | // from the referenced tsconfig.json - TypeScript does not merge them in 27 | } -------------------------------------------------------------------------------- /web/src/components/icons/ko-fi.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 13 | 14 | -------------------------------------------------------------------------------- /web/src/components/mobile/install-pwa.svelte: -------------------------------------------------------------------------------- 1 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /web/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path' 2 | import type { Config } from 'tailwindcss' 3 | import { skeleton } from '@skeletonlabs/tw-plugin' 4 | import { customTheme, customThemeDark } from './theme' 5 | 6 | export default { 7 | darkMode: 'class', 8 | content: ['./src/**/*.{html,js,svelte,ts}', join(require.resolve('@skeletonlabs/skeleton'), '../**/*.{html,js,svelte,ts}')], 9 | theme: { 10 | extend: {}, 11 | }, 12 | plugins: [ 13 | skeleton({ 14 | themes: { 15 | custom: [ 16 | customTheme, 17 | customThemeDark, 18 | ], 19 | preset: [ 20 | { 21 | name: 'wintry', 22 | enhancements: true, 23 | }, 24 | { 25 | name: 'skeleton', 26 | enhancements: true, 27 | }, 28 | { 29 | name: 'modern', 30 | enhancements: true, 31 | }, 32 | { 33 | name: 'crimson', 34 | enhancements: true, 35 | }, 36 | ], 37 | }, 38 | }), 39 | ], 40 | } satisfies Config; 41 | -------------------------------------------------------------------------------- /web/src/routes/panel/mobile/layouts/customized.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
10 |
11 |
14 | Header 15 |
16 |
20 | {#each $sounds as sound (sound.index)} 21 | 22 | {/each} 23 |
24 |
25 |
26 | -------------------------------------------------------------------------------- /desktop/src/main/routes/proxy.ts: -------------------------------------------------------------------------------- 1 | import type { Application, Request, Response } from 'express'; 2 | import { App } from 'electron/main'; 3 | import request from 'request'; 4 | import bodyParser from 'body-parser'; 5 | import { createProxyMiddleware } from 'http-proxy-middleware'; 6 | 7 | export default function registerRoutes(app: Application, electronApp: App) { 8 | // app.get('/api/proxy/:url', bodyParser.json(), async function (req: Request, res: Response) { 9 | // console.log(decodeURIComponent(req.params.url)); 10 | // request(decodeURIComponent(req.params.url)).pipe(res).on('error', console.error).on('close', console.log); 11 | // }) 12 | 13 | app.use('/api/proxy', createProxyMiddleware({ 14 | router: (req) => new URL(req.url.substring(1)), 15 | pathRewrite: (path, req) => (new URL(path.substring(1))).pathname, 16 | followRedirects: true, 17 | changeOrigin: true, 18 | on: { 19 | proxyRes: (proxyRes, req, res) => { 20 | res.setHeader('Access-Control-Allow-Origin', '*'); 21 | } 22 | }, 23 | logger: console 24 | })) 25 | } 26 | -------------------------------------------------------------------------------- /desktop/fetch-sources.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'node:child_process'; 2 | import fs from 'node:fs'; 3 | 4 | const sources = { 5 | renderer: { 6 | from: '../web/build', 7 | to: './src/renderer', 8 | }, 9 | } 10 | 11 | function copyRecursiveSync(src: string, dest: string) { 12 | const exists = fs.existsSync(src); 13 | if (!exists) { 14 | console.log(`Source ${src} does not exist`); 15 | return; 16 | } 17 | const stats = fs.statSync(src); 18 | const isDirectory = exists && stats.isDirectory(); 19 | if (exists && isDirectory) { 20 | fs.mkdirSync(dest, { recursive: true }); 21 | fs.readdirSync(src).forEach((childItemName) => { 22 | copyRecursiveSync(`${src}/${childItemName}`, `${dest}/${childItemName}`); 23 | }); 24 | } else { 25 | fs.copyFileSync(src, dest); 26 | } 27 | } 28 | 29 | try { 30 | fs.rmdirSync(sources.renderer.to, { recursive: true }); 31 | } catch (error) {} 32 | 33 | execSync('npm run build', { stdio: 'inherit', cwd: '../web' }); 34 | 35 | copyRecursiveSync(sources.renderer.from, sources.renderer.to); 36 | console.log('Copied all sources'); 37 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # create-svelte 2 | 3 | Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/main/packages/create-svelte). 4 | 5 | ## Creating a project 6 | 7 | If you're seeing this, you've probably already done this step. Congrats! 8 | 9 | ```bash 10 | # create a new project in the current directory 11 | npm create svelte@latest 12 | 13 | # create a new project in my-app 14 | npm create svelte@latest my-app 15 | ``` 16 | 17 | ## Developing 18 | 19 | Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: 20 | 21 | ```bash 22 | npm run dev 23 | 24 | # or start the server and open the app in a new browser tab 25 | npm run dev -- --open 26 | ``` 27 | 28 | ## Building 29 | 30 | To create a production version of your app: 31 | 32 | ```bash 33 | npm run build 34 | ``` 35 | 36 | You can preview the production build with `npm run preview`. 37 | 38 | > To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment. 39 | -------------------------------------------------------------------------------- /web/src/routes/panel/mobile/+page.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 |
20 |
21 | {#if $isConnected} 22 | {#if $selectedLayout === "mirror"} 23 | 24 | {:else} 25 | 26 | {/if} 27 | {:else} 28 | 29 | {/if} 30 | 31 |
32 |
33 | 34 |
35 |
36 | -------------------------------------------------------------------------------- /desktop/src/main/start-with-windows-tray-option.ts: -------------------------------------------------------------------------------- 1 | // Courtesy of https://github.com/marklagendijk/node-start-on-windows-boot changed to return promises instead of using callbacks 2 | 3 | import WinReg from 'winreg'; 4 | 5 | export function enableAutoStart(name: string, file: string) { 6 | return new Promise((resolve, reject) => { 7 | getKey() 8 | .set(name, WinReg.REG_SZ, file, (err: any) => !err ? resolve() : reject(err)); 9 | }); 10 | } 11 | 12 | export function disableAutoStart(name: string) { 13 | return new Promise((resolve, reject) => { 14 | getKey() 15 | .remove(name, (err: any) => !err ? resolve() : reject(err)); 16 | }); 17 | } 18 | 19 | export function getAutoStartValue(name: string) { 20 | return new Promise((resolve) => { 21 | getKey().get(name, (error: any, result: any) => { 22 | if (error) { 23 | resolve(false) 24 | } 25 | resolve(true) 26 | }); 27 | }); 28 | } 29 | 30 | const RUN_LOCATION = '\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'; 31 | function getKey() { 32 | return new WinReg({ 33 | hive: WinReg.HKCU, //CurrentUser, 34 | key: RUN_LOCATION 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /desktop/src/main/routes/soundpad.ts: -------------------------------------------------------------------------------- 1 | import bodyParser from 'body-parser'; 2 | import { App } from 'electron/main'; 3 | import { type Application, type Request, type Response, } from 'express'; 4 | import Soundpad from 'soundpad.js' 5 | 6 | const soundpadClient = new Soundpad({ 7 | autoReconnect: true, 8 | startSoundpadOnConnect: true, 9 | }) 10 | 11 | soundpadClient.connect() 12 | 13 | export default function registerRoutes(app: Application, electronApp: App) { 14 | app.post('/api/soundpad', bodyParser.text(), async function (req: Request, res: Response) { 15 | const data = req.body; 16 | 17 | await soundpadClient.connectionAwaiter 18 | return res.send(await soundpadClient.sendQuery(data)) 19 | }) 20 | 21 | app.options('/api/soundpad', bodyParser.text(), async function (req: Request, res: Response) { 22 | const headers = { 23 | 'Access-Control-Allow-Origin': '*', 24 | 'Access-Control-Allow-Methods': 'POST, OPTIONS', 25 | 'Access-Control-Allow-Headers': 'Content-Type', 26 | 'Access-Control-Max-Age': '600', 27 | } 28 | for (const [key, value] of Object.entries(headers)) { 29 | res.setHeader(key, value) 30 | } 31 | return res 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /web/src/stores/customized-layout.ts: -------------------------------------------------------------------------------- 1 | import { localStorageStore } from "@skeletonlabs/skeleton"; 2 | import type { Sound } from "soundpad.js/lib/web"; 3 | import { derived, type Writable } from "svelte/store"; 4 | 5 | type Cell = { 6 | content: Sound | CustomLayout | null 7 | } 8 | 9 | type CustomLayout = { 10 | name: string 11 | rows: number 12 | columns: number 13 | cells: Cell[][] 14 | } 15 | 16 | export const portaitLayout: Writable = localStorageStore('v1_portaitLayout', createNewLayout('Portrait', 8, 5)); 17 | export const landscapeLayout: Writable = localStorageStore('v1_landscapeLayout', createNewLayout('Landscape', 5, 8)); 18 | 19 | export const customizedLayoutLandscapeAdaptor: Writable<'grid' | 'list'> = localStorageStore('v1_customizedLayoutLandscapeAdaptor', 'grid'); 20 | export const isCustomLayoutLandscapeGrid = derived(customizedLayoutLandscapeAdaptor, ($customizedLayoutLandscapeAdaptor) => $customizedLayoutLandscapeAdaptor === 'grid'); 21 | 22 | export function createNewLayout(name: string, rows: number, columns: number): CustomLayout { 23 | return { name, rows, columns, cells: Array.from({ length: rows }, () => Array.from({ length: columns }, () => ({ content: null }))) } 24 | } 25 | -------------------------------------------------------------------------------- /web/src/lib/utils/misc.ts: -------------------------------------------------------------------------------- 1 | import type { PopupSettings } from "@skeletonlabs/skeleton"; 2 | 3 | export function sleep (ms: number): Promise { 4 | return new Promise(resolve => setTimeout(resolve, ms)) 5 | } 6 | 7 | export function checkIsDemo () { 8 | return (window.top ?? window).location.href.includes('?demo') 9 | } 10 | 11 | export const demoPopupConfig: PopupSettings = { 12 | event: "hover", 13 | target: "disabledDemo", 14 | placement: "top", 15 | }; 16 | 17 | export function isHttps () { 18 | return window.location.protocol === "https:" 19 | }; 20 | 21 | export function rgbToHsl (rgb: string): string { 22 | const [r, g, b] = rgb.split(/(..)/).filter(a => a).map(num => parseInt(num, 16) / 255); 23 | 24 | const max = Math.max(r, g, b), min = Math.min(r, g, b); 25 | let h = 0, s, l = (max + min) / 2; 26 | 27 | if (max == min) { 28 | h = s = 0; // achromatic 29 | } else { 30 | const d = max - min; 31 | s = l > 0.5 ? d / (2 - max - min) : d / (max + min); 32 | 33 | switch (max) { 34 | case r: h = (g - b) / d + (g < b ? 6 : 0); break; 35 | case g: h = (b - r) / d + 2; break; 36 | case b: h = (r - g) / d + 4; break; 37 | } 38 | 39 | h /= 6; 40 | } 41 | 42 | return `${h * 360} ${s * 100}% ${l * 100}%`; 43 | } 44 | -------------------------------------------------------------------------------- /web/src/components/dekstop/pairing-qrcode.svelte: -------------------------------------------------------------------------------- 1 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /web/src/lib/demo/demo-audio.ts: -------------------------------------------------------------------------------- 1 | import { writable } from "svelte/store"; 2 | 3 | let audioElement = document.createElement("audio"); 4 | audioElement.crossOrigin = "anonymous"; 5 | audioElement.preload = "none"; 6 | 7 | audioElement.addEventListener("play", () => { 8 | isAudioPlaying.set(true); 9 | }) 10 | 11 | audioElement.addEventListener("pause", () => { 12 | isAudioPlaying.set(false); 13 | playbackPosition.set(0); 14 | }) 15 | 16 | audioElement.addEventListener("timeupdate", () => { 17 | playbackPosition.set(audioElement.currentTime / audioElement.duration); 18 | }) 19 | 20 | export function playAudio(url: string) { 21 | audioElement.src = url; 22 | audioElement.play(); 23 | } 24 | 25 | export function stopAudio() { 26 | audioElement.pause(); 27 | audioElement.currentTime = 0; 28 | } 29 | 30 | export function togglePause() { 31 | if (audioElement.paused) { 32 | audioElement.play(); 33 | } else { 34 | audioElement.pause(); 35 | } 36 | } 37 | 38 | export function seek(position: number) { 39 | if (audioElement.duration) { 40 | audioElement.currentTime = position * audioElement.duration; 41 | } 42 | } 43 | 44 | export function setVolume(newVolume: number) { 45 | audioElement.volume = newVolume; 46 | } 47 | 48 | export const isAudioPlaying = writable(false); 49 | export const playbackPosition = writable(0); 50 | -------------------------------------------------------------------------------- /desktop/src/main/utils/yt-dlp.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron'; 2 | import path from 'path'; 3 | import YTDlpWrap from 'yt-dlp-wrap'; 4 | import os from 'os'; 5 | 6 | const ytDlpWrap = new YTDlpWrap(path.join(app.getPath('assets'), 'yt-dlp.exe')); 7 | ytDlpWrap.getVersion().then(console.log) 8 | 9 | export function getYtDlpWrapper () { 10 | return ytDlpWrap; 11 | } 12 | 13 | export async function updateYTDlp () { 14 | try { 15 | const currentVersion = await getYtDlpWrapper().getVersion(); 16 | const latestReleases = await YTDlpWrap.getGithubReleases(); 17 | if (latestReleases[0].tag_name.trim() !== currentVersion.trim()) { 18 | console.log('downloading latest yt-dlp version:', latestReleases[0].tag_name); 19 | await YTDlpWrap.downloadFromGithub(path.join(app.getPath('assets'), 'yt-dlp.exe'), latestReleases[0].tag_name, os.platform()); 20 | } 21 | console.log('yt-dlp is up to date:', await getYtDlpWrapper().getVersion()); 22 | } catch (error) { 23 | const latestReleases = await YTDlpWrap.getGithubReleases(); 24 | console.log('downloading latest yt-dlp version:', latestReleases[0].tag_name); 25 | await YTDlpWrap.downloadFromGithub(path.join(app.getPath('assets'), 'yt-dlp.exe'), latestReleases[0].tag_name, os.platform()); 26 | console.log('yt-dlp is up to date:', await getYtDlpWrapper().getVersion()); 27 | } 28 | } -------------------------------------------------------------------------------- /web/src/stores/settings.ts: -------------------------------------------------------------------------------- 1 | import { isHttps } from '$lib/utils/misc'; 2 | import { localStorageStore } from '@skeletonlabs/skeleton'; 3 | import { writable, type Writable } from 'svelte/store'; 4 | 5 | /** 6 | * === Settings === 7 | */ 8 | 9 | export const settingsOpacity: Writable = writable(1); 10 | 11 | /** 12 | * === Persistent Settings === 13 | */ 14 | 15 | export const serverHost: Writable<{ ip: string, port: number }> = localStorageStore('v1_serverHost', { ip: isHttps() ? '127.0.0.1' : location.hostname, port: isHttps() ? 8555 : 8556 }) 16 | 17 | export const mirrorLayoutSoundButtonSize: Writable = localStorageStore('v1_mirrorLayoutSoundButtonSize', 60); 18 | 19 | export const showSearchBar: Writable = localStorageStore('v1_showSearchBar', false); 20 | 21 | export const enableSoundpadColors: Writable = localStorageStore('v1_enableSoundpadColors', true); 22 | 23 | export const selectedLayout: Writable<'mirror' | 'customized'> = localStorageStore('v1_selectedLayout', 'mirror'); 24 | 25 | export const customizedLayoutSoundColumns: Writable = localStorageStore('v1_customizedLayoutSoundColumns', 5); 26 | export const customizedLayoutSoundRows: Writable = localStorageStore('v1_customizedLayoutSoundRows', 8); 27 | 28 | /** 29 | * === Drawer functions === 30 | */ 31 | 32 | export function makeDrawerTransparent() { 33 | settingsOpacity.set(0.5); 34 | } 35 | 36 | export function makeDrawerOpaque() { 37 | settingsOpacity.set(1); 38 | } 39 | -------------------------------------------------------------------------------- /web/src/routes/panel/mobile/+layout.svelte: -------------------------------------------------------------------------------- 1 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /web/svelte.config.js: -------------------------------------------------------------------------------- 1 | // import adapter from '@sveltejs/adapter-auto'; 2 | // import adapterNode from '@sveltejs/adapter-node'; 3 | import adapterStatic from '@sveltejs/adapter-static'; 4 | import adapterVercel from '@sveltejs/adapter-vercel'; 5 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 6 | import { readFileSync } from 'fs'; 7 | import { fileURLToPath } from 'url'; 8 | 9 | const file = fileURLToPath(new URL('package.json', import.meta.url)); 10 | const json = readFileSync(file, 'utf8'); 11 | const pkg = JSON.parse(json); 12 | 13 | let adapter = adapterStatic({ 14 | fallback: 'index.html' 15 | }); 16 | 17 | if (process.env.VERCEL_ENV) { 18 | adapter = adapterVercel(); 19 | } 20 | 21 | /** @type {import('@sveltejs/kit').Config} */ 22 | const config = { 23 | extensions: ['.svelte'], 24 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors 25 | // for more information about preprocessors 26 | preprocess: [vitePreprocess()], 27 | optimizeDeps: { exclude: ['@skeletonlabs/skeleton', 'driver.js/dist/driver.css'] }, 28 | 29 | kit: { 30 | // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. 31 | // If your environment is not supported or you settled on a specific environment, switch out the adapter. 32 | // See https://kit.svelte.dev/docs/adapters for more information about adapters. 33 | adapter: adapter, 34 | csrf: { 35 | checkOrigin: false, 36 | }, 37 | version: { 38 | name: pkg.version 39 | } 40 | } 41 | }; 42 | 43 | export default config; 44 | -------------------------------------------------------------------------------- /web/src/lib/demo/configs.ts: -------------------------------------------------------------------------------- 1 | import { checkIsDemo } from "$lib/utils/misc"; 2 | import { localStorageStore } from "@skeletonlabs/skeleton"; 3 | import type { Config } from "driver.js"; 4 | import { get } from "svelte/store"; 5 | 6 | const _shownDrivers = localStorageStore>('demo', {}); 7 | const _shownDriversDemo = new Set(); 8 | 9 | export const shownDrivers = { 10 | has: (id: string): boolean => { 11 | if (checkIsDemo()) { 12 | return _shownDriversDemo.has(id); 13 | } 14 | return id in get(_shownDrivers) 15 | }, 16 | add: (id: string): void => { 17 | if (checkIsDemo()) { 18 | _shownDriversDemo.add(id); 19 | return; 20 | } 21 | _shownDrivers.update((drivers) => { 22 | drivers[id] = true; 23 | return drivers; 24 | }) 25 | }, 26 | }; 27 | 28 | export const driverStyleConfig: Config = { 29 | popoverClass: "guide", 30 | onPopoverRender(popover, opts) { 31 | popover.nextButton.classList.add( 32 | "!btn", 33 | "!btn-sm", 34 | "!variant-filled-primary", 35 | "!border-0", 36 | "!color-white", 37 | "![text-shadow:none]", 38 | ); 39 | popover.previousButton.classList.add( 40 | "!btn", 41 | "!btn-sm", 42 | "!variant-filled-primary", 43 | "!border-0", 44 | "!color-white", 45 | "![text-shadow:none]", 46 | ); 47 | } 48 | }; 49 | 50 | export const driverConfig: Config = { 51 | showProgress: true, 52 | nextBtnText: "Next", 53 | prevBtnText: "Previous", 54 | ...driverStyleConfig 55 | }; 56 | -------------------------------------------------------------------------------- /web/src/app.postcss: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | @tailwind variants; 5 | 6 | ::-webkit-scrollbar-corner { 7 | background: rgba(0, 0, 0, 0.5); 8 | } 9 | 10 | .no-scrollbar { 11 | scrollbar-width: none; 12 | } 13 | 14 | /* Works on Chrome, Edge, and Safari */ 15 | *::-webkit-scrollbar { 16 | width: 2px; 17 | height: 2px; 18 | } 19 | 20 | *::-webkit-scrollbar-track { 21 | background: var(--scroll-bar-bg-color); 22 | } 23 | 24 | *::-webkit-scrollbar-thumb { 25 | background-color: var(--scroll-bar-color); 26 | border-radius: 20px; 27 | border: 3px solid var(--scroll-bar-bg-color); 28 | } 29 | 30 | .highlighted-bar { 31 | background-color: rgb(var(--color-surface-900)); 32 | background-image: radial-gradient(at 50% 0%, 33 | rgba(var(--color-secondary-500) / 0.18) 0px, 34 | transparent 75%), 35 | radial-gradient(at 100% 0%, 36 | rgba(var(--color-tertiary-500) / 0.18) 0px, 37 | transparent 50%); 38 | } 39 | 40 | body { 41 | height: 100%; 42 | } 43 | 44 | /* Customizing guide */ 45 | .guide { 46 | background-color: rgb(var(--color-surface-900)) !important; 47 | color: white !important; 48 | background-image: radial-gradient(at 50% 0%, 49 | rgba(var(--color-secondary-500) / 0.18) 0px, 50 | transparent 75%), 51 | radial-gradient(at 100% 0%, 52 | rgba(var(--color-tertiary-500) / 0.18) 0px, 53 | transparent 50%); 54 | 55 | } 56 | 57 | .guide .driver-popover-description { 58 | display: flex !important; 59 | flex-direction: column; 60 | gap: 0.5rem; 61 | text-align: justify; 62 | } -------------------------------------------------------------------------------- /desktop/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | .DS_Store 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # TypeScript cache 43 | *.tsbuildinfo 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | .env.test 63 | 64 | # parcel-bundler cache (https://parceljs.org/) 65 | .cache 66 | 67 | # next.js build output 68 | .next 69 | 70 | # nuxt.js build output 71 | .nuxt 72 | 73 | # vuepress build output 74 | .vuepress/dist 75 | 76 | # Serverless directories 77 | .serverless/ 78 | 79 | # FuseBox cache 80 | .fusebox/ 81 | 82 | # DynamoDB Local files 83 | .dynamodb/ 84 | 85 | # Webpack 86 | .webpack/ 87 | 88 | # Vite 89 | .vite/ 90 | 91 | # Electron-Forge 92 | out/ 93 | 94 | 95 | src/renderer -------------------------------------------------------------------------------- /web/src/components/buttons/sound.svelte: -------------------------------------------------------------------------------- 1 | 33 | 34 | 45 | -------------------------------------------------------------------------------- /desktop/forge.config.js: -------------------------------------------------------------------------------- 1 | const { FusesPlugin } = require('@electron-forge/plugin-fuses'); 2 | const { FuseV1Options, FuseVersion } = require('@electron/fuses'); 3 | // const FFmpegStatic = require("ffmpeg-static-electron-forge").default 4 | // const path = require('path'); 5 | 6 | // const iconPath = path.join(__dirname, './assets/soundpaddon') 7 | const iconPath = './assets/soundpaddon.ico' 8 | 9 | module.exports.packagerConfig = { 10 | asar: { 11 | unpack: 'ffmpeg.exe' 12 | }, 13 | icon: iconPath, 14 | }; 15 | module.exports.rebuildConfig = {}; 16 | module.exports.makers = [ 17 | { 18 | name: '@electron-forge/maker-squirrel', 19 | config: { 20 | iconUrl: 'https://www.soundpaddon.app/soundpaddon.ico', 21 | setupIcon: iconPath, 22 | authors: 'Sébastien "Seblor" Lorentz', 23 | description: 'Soundpaddon, an addon for Soundpad', 24 | setupExe: 'Soundpaddon-x64.exe', 25 | }, 26 | }, 27 | { 28 | name: '@electron-forge/maker-zip', 29 | platforms: ['darwin'], 30 | }, 31 | { 32 | name: '@electron-forge/maker-deb', 33 | config: {}, 34 | }, 35 | { 36 | name: '@electron-forge/maker-rpm', 37 | config: {}, 38 | }, 39 | ]; 40 | module.exports.plugins = [ 41 | { 42 | name: '@electron-forge/plugin-auto-unpack-natives', 43 | config: {}, 44 | }, 45 | // Fuses are used to enable/disable various Electron functionality 46 | // at package time, before code signing the application 47 | new FusesPlugin({ 48 | version: FuseVersion.V1, 49 | [FuseV1Options.RunAsNode]: false, 50 | [FuseV1Options.EnableCookieEncryption]: true, 51 | [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false, 52 | [FuseV1Options.EnableNodeCliInspectArguments]: false, 53 | [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true, 54 | [FuseV1Options.OnlyLoadAppFromAsar]: true, 55 | }), 56 | // new FFmpegStatic({ 57 | // remove: true, // Required 58 | // path: path.join(__dirname, "src/"), // Set path of main build 59 | // }), 60 | ]; 61 | -------------------------------------------------------------------------------- /desktop/src/main/utils/misc.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch-commonjs'; 2 | import fs from 'node:fs'; 3 | import path from 'node:path'; 4 | import process from 'node:process'; 5 | // @ts-ignore 6 | import Progress from 'node-fetch-progress'; 7 | import Soundpad from 'soundpad.js'; 8 | 9 | const soundpad = new Soundpad({ 10 | startSoundpadOnConnect: true, 11 | }); 12 | soundpad.connect(); 13 | 14 | export function sleep(ms: number): Promise { 15 | return new Promise(resolve => setTimeout(resolve, ms)) 16 | } 17 | 18 | export function isBuild(): boolean { 19 | return process.env.npm_lifecycle_event === 'build' 20 | } 21 | 22 | export function timeMarkToSeconds(timeMark: string): number { 23 | const [hours, minutes, seconds] = timeMark.split('.')[0].split(':').map(Number); 24 | return hours * 60 * 60 + minutes * 60 + seconds; 25 | } 26 | 27 | export async function importToSoundpad(fileToImport: string) { 28 | await soundpad.connectionAwaiter 29 | return soundpad.addSound(fileToImport); 30 | } 31 | 32 | export function downloadFile(url: string, targetPath: string, update: (progress: DownloadProgress) => void = () => { }): Promise { 33 | if (!fs.existsSync(path.join(targetPath, '..'))) { 34 | fs.mkdirSync(path.join(targetPath, '..'), { recursive: true }) 35 | } 36 | return new Promise((resolve, reject) => { 37 | fetch(url).then(res => { 38 | const progress = new Progress(res, { throttle: 100 }) 39 | progress.on('progress', update) 40 | const dest = fs.createWriteStream(targetPath); 41 | if (res.body === null) { 42 | reject(new Error('Response body is null')) 43 | return 44 | } 45 | res.body.pipe(dest); 46 | dest.on('finish', () => { 47 | dest.close() 48 | resolve() 49 | }); 50 | }) 51 | }) 52 | } 53 | 54 | type DownloadProgress = { 55 | total: number, 56 | done: number, 57 | totalh: string, 58 | doneh: string, 59 | startedAt: number, 60 | elapsed: number, 61 | rate: number, 62 | rateh: string, 63 | estimated: number, 64 | progress: number, 65 | eta: number, 66 | etah: string, 67 | etaDate: Date, 68 | } -------------------------------------------------------------------------------- /web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { purgeCss } from 'vite-plugin-tailwind-purgecss'; 2 | import { sveltekit } from '@sveltejs/kit/vite'; 3 | import { SvelteKitPWA } from '@vite-pwa/sveltekit' 4 | import { defineConfig, type UserConfig } from 'vite'; 5 | import pluginSsl from '@vitejs/plugin-basic-ssl' 6 | import Icons from 'unplugin-icons/vite' 7 | import fetch from 'node-fetch' 8 | import fs from 'fs' 9 | 10 | const enableHttps = !process.env.VERCEL_ENV; 11 | const keyUrl = 'https://local.soundpaddon.app/server.pem' 12 | const certUrl = 'https://local.soundpaddon.app/server.key' 13 | 14 | // === Vite config === 15 | 16 | const config: UserConfig = { 17 | build: { 18 | target: 'node20', 19 | }, 20 | server: { 21 | port: 8080, 22 | proxy: {} 23 | }, 24 | preview: { 25 | port: 8081, 26 | }, 27 | plugins: [ 28 | sveltekit(), 29 | purgeCss(), 30 | // pluginSsl({ 31 | // domains: ['local-ip.sh:8080'], 32 | // certDir: 'ssl', 33 | // name: 'local-ip.sh', 34 | // }), 35 | SvelteKitPWA({ 36 | disable: false, 37 | devOptions: { 38 | enabled: true, 39 | }, 40 | base: '/', 41 | registerType: 'autoUpdate', 42 | manifest: { 43 | start_url: '/panel', 44 | display: 'standalone', 45 | name: 'Soundpaddon', 46 | id: 'com.soundpaddon', 47 | theme_color: '#000000', 48 | icons: [{ 49 | src: '/logo_192.png', 50 | purpose: 'any', 51 | sizes: '192x192', 52 | }] 53 | } 54 | }), 55 | Icons({ 56 | compiler: 'svelte', 57 | }), 58 | ].concat(process.env.NODE_ENV === 'production' ? [] : [ 59 | // (await import('./src/server/socket')).devSocketSetup() 60 | ]), 61 | }; 62 | 63 | function downloadFile(url: string, targetPath: string): Promise { 64 | return new Promise((resolve, reject) => { 65 | fetch(url).then(res => { 66 | const dest = fs.createWriteStream(targetPath); 67 | if (res.body === null) { 68 | reject(new Error('Response body is null')) 69 | return 70 | } 71 | res.body.pipe(dest); 72 | dest.on('finish', () => { 73 | dest.close() 74 | resolve() 75 | }); 76 | }) 77 | }) 78 | } 79 | 80 | if (enableHttps && config.server) { 81 | await Promise.all([ 82 | downloadFile(keyUrl, 'ssl/server.key'), 83 | downloadFile(certUrl, 'ssl/server.pem'), 84 | ]) 85 | console.log('downloaded ssl files'); 86 | config.server.https = { 87 | key: 'ssl/server.pem', 88 | cert: 'ssl/server.key', 89 | } 90 | } 91 | 92 | 93 | export default defineConfig(config) 94 | -------------------------------------------------------------------------------- /desktop/src/main/index.ts: -------------------------------------------------------------------------------- 1 | import http from 'node:http'; 2 | import https from 'node:https'; 3 | import fs from 'node:fs'; 4 | import path from 'node:path'; 5 | import express from 'express'; 6 | import cors from 'cors'; 7 | import injectSocketIO from './socketIoHandler'; 8 | import { registerRoutes } from './routes/index'; 9 | import { createTray } from './add-system-tray'; 10 | import { type App } from 'electron/main'; 11 | import { downloadFile } from './utils/misc'; 12 | 13 | const keyUrl = 'https://local.soundpaddon.app/server.pem' 14 | const certUrl = 'https://local.soundpaddon.app/server.key' 15 | 16 | export async function createHttpServer({ 17 | certificateRootPath, 18 | pathToServe, 19 | electronApp, 20 | }: { 21 | certificateRootPath: string 22 | pathToServe: string 23 | electronApp: App 24 | }): Promise { 25 | await Promise.all([ 26 | downloadFile(keyUrl, path.join(certificateRootPath, 'server.key')), 27 | downloadFile(certUrl, path.join(certificateRootPath, 'server.pem')), 28 | ]) 29 | 30 | const app = express(); 31 | 32 | app.use(cors({ 33 | origin: /(.*\.)?soundpaddon.app|.*\.my\.local-ip\.co:.*|.*.local-ip\.sh:.*|.*/, 34 | methods: ['OPTIONS', 'POST', 'GET'], 35 | credentials: true, 36 | maxAge: 2592000, 37 | })) 38 | 39 | registerRoutes(app, electronApp); 40 | 41 | app.use(express.static(pathToServe, { 42 | redirect: false, 43 | extensions: ['html'] 44 | })); 45 | app.get('*', function (request, response) { 46 | console.log(path.resolve(__dirname, 'index.html')); 47 | response.sendFile(path.resolve(__dirname, 'index.html')); 48 | }); 49 | 50 | // SvelteKit handlers 51 | // app.use(handler); 52 | 53 | const httpsServer = https.createServer({ 54 | key: fs.readFileSync(path.join(certificateRootPath, 'server.pem')), 55 | cert: fs.readFileSync(path.join(certificateRootPath, 'server.key')), 56 | }, app) 57 | 58 | const httpServer = http.createServer(app) 59 | 60 | // Inject SocketIO 61 | injectSocketIO(httpsServer); 62 | injectSocketIO(httpServer); 63 | 64 | await Promise.all([ 65 | new Promise(resolve => { 66 | httpsServer 67 | .listen(8555) 68 | .once('listening', resolve) 69 | }), 70 | new Promise(resolve => { 71 | httpServer 72 | .listen(8556) 73 | .once('listening', resolve) 74 | }) 75 | ]) 76 | } 77 | 78 | export function setSystemTray(app: App, iconPath: string) { 79 | createTray(app, iconPath) 80 | } 81 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Soundpaddon 2 | 3 | ![Soundpaddon Logo](web/static/logo.svg) 4 | 5 | Enhance your Soundpad experience with Soundpaddon. 6 | 7 | ## Table of Contents 8 | 9 | - [Soundpaddon](#soundpaddon) 10 | - [Table of Contents](#table-of-contents) 11 | - [About](#about) 12 | - [Features](#features) 13 | - [Installation](#installation) 14 | - [Building the project](#building-the-project) 15 | - [Contributing](#contributing) 16 | - [Support](#support) 17 | - [Shameless donation plug](#shameless-donation-plug) 18 | 19 | ## About 20 | 21 | Soundpaddon is an addon for Soundpad that enhances your experience by providing additional features and integrations. 22 | 23 | ## Features 24 | 25 | - Import sounds from YouTube videos 26 | - Import sounds from various soundbanks 27 | - Import all sounds found on a web page 28 | - Pair your devices to control Soundpad 29 | - Interactive mobile preview 30 | 31 | ## Installation 32 | 33 | To install Soundpaddon, follow these steps: 34 | 35 | 1. Clone the repository: 36 | ```sh 37 | git clone https://github.com/Seblor/Soundpaddon.git 38 | ``` 39 | 2. Navigate to the project directory: 40 | ```sh 41 | cd Soundpaddon 42 | ``` 43 | 3. Install dependencies: 44 | ```sh 45 | cd ./web # Navigate to the web interface directory 46 | npm ci # Install the web interface dependencies 47 | 48 | cd ../desktop # Navigate to the desktop interface directory 49 | npm ci # Install the desktop interface dependencies 50 | ``` 51 | 52 | ## Building the project 53 | 54 | To build the project, run the following commands: 55 | 56 | ```sh 57 | npm run build # Builds the web interface, copies it to the server directory, and transpiles the server 58 | npm run package # Builds the project and creates an installer. 59 | ``` 60 | 61 | To create the application installer, run the following command: 62 | 63 | ```sh 64 | npm run make 65 | ``` 66 | 67 | Some dependencies will most likely be needed, please refer to your specific error messages for more information as I did not record all dependencies I installed during this project (I sincerely welcome any help on this part). 68 | 69 | ## Contributing 70 | 71 | All contributions are welcome! 72 | 73 | ## Support 74 | 75 | For support, join my [Discord server](https://support.soundpaddon.app). 76 | 77 | ## Shameless donation plug 78 | 79 | If you like this project, consider supporting it by starring the repository or [buying me a coffee](https://ko-fi.com/seblor). 80 | -------------------------------------------------------------------------------- /desktop/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "soundpaddon-desktop", 3 | "productName": "Soundpaddon", 4 | "version": "1.6.2", 5 | "description": "My Electron application description", 6 | "main": "src/index.js", 7 | "repository": "https://github.com/Seblor/Soundpaddon", 8 | "scripts": { 9 | "start": "electron-forge start", 10 | "build": "npm run build:renderer && tsc -p tsconfig.main.json", 11 | "build:renderer": "tsx fetch-sources.ts", 12 | "build:main": "tsc -p tsconfig.main.json --watch", 13 | "package": "electron-forge package", 14 | "make": "electron-forge make", 15 | "publish": "electron-forge publish", 16 | "lint": "echo \"No linting configured\"" 17 | }, 18 | "devDependencies": { 19 | "@electron-forge/cli": "^7.4.0", 20 | "@electron-forge/maker-deb": "^7.4.0", 21 | "@electron-forge/maker-rpm": "^7.4.0", 22 | "@electron-forge/maker-squirrel": "^7.4.0", 23 | "@electron-forge/maker-wix": "^7.4.0", 24 | "@electron-forge/maker-zip": "^7.4.0", 25 | "@electron-forge/plugin-auto-unpack-natives": "^7.4.0", 26 | "@electron-forge/plugin-fuses": "^7.4.0", 27 | "@electron/fuses": "^1.8.0", 28 | "@types/electron-squirrel-startup": "^1.0.2", 29 | "@types/express": "^4.17.21", 30 | "@types/fluent-ffmpeg": "^2.1.24", 31 | "@types/jsdom": "^21.1.6", 32 | "@types/lodash": "^4.17.0", 33 | "@types/node": "^20.12.7", 34 | "@types/request": "^2.48.12", 35 | "@types/winreg": "^1.2.36", 36 | "electron": "^38.2.2", 37 | "electron-wix-msi": "^5.1.3", 38 | "fork-ts-checker-webpack-plugin": "^9.0.2", 39 | "tsx": "^4.7.2" 40 | }, 41 | "keywords": [], 42 | "author": { 43 | "name": "Seblor", 44 | "email": "github@seblor.fr" 45 | }, 46 | "license": "MIT", 47 | "dependencies": { 48 | "@ffmpeg-installer/ffmpeg": "^1.1.0", 49 | "body-parser": "^1.20.2", 50 | "cors": "^2.8.5", 51 | "electron-squirrel-startup": "^1.0.0", 52 | "electron-store": "^8.2.0", 53 | "express": "^4.19.2", 54 | "fluent-ffmpeg": "^2.1.2", 55 | "fuse.js": "^7.0.0", 56 | "http-proxy-middleware": "^3.0.0", 57 | "jsdom": "^24.0.0", 58 | "lodash": "^4.17.21", 59 | "node-fetch-commonjs": "^3.3.2", 60 | "node-fetch-progress": "^1.0.2", 61 | "request": "^2.88.2", 62 | "socket.io": "^4.7.5", 63 | "soundpad.js": "^2.6.2", 64 | "undici": "^7.4.0", 65 | "winreg": "^1.2.5", 66 | "yt-dlp-wrap": "^2.3.12", 67 | "ytdl-core": "^4.11.5" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /web/src/components/buttons/manual-host.svelte: -------------------------------------------------------------------------------- 1 | 55 | 56 | 65 | 66 |
67 | Enter the host's IP address 68 |
69 | 77 |
onHostEntered(userHost)} 81 | on:keypress={(e) => { 82 | if (e.key === "Enter") { 83 | onHostEntered(userHost); 84 | } 85 | }} 86 | > 87 | 88 |
89 |
90 |
91 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "prettier.documentSelectors": [ 3 | "**/*.svelte" 4 | ], 5 | "tailwindCSS.classAttributes": [ 6 | "class", 7 | "accent", 8 | "active", 9 | "animIndeterminate", 10 | "aspectRatio", 11 | "background", 12 | "badge", 13 | "bgBackdrop", 14 | "bgDark", 15 | "bgDrawer", 16 | "bgLight", 17 | "blur", 18 | "border", 19 | "button", 20 | "buttonAction", 21 | "buttonBack", 22 | "buttonClasses", 23 | "buttonComplete", 24 | "buttonDismiss", 25 | "buttonNeutral", 26 | "buttonNext", 27 | "buttonPositive", 28 | "buttonTextCancel", 29 | "buttonTextConfirm", 30 | "buttonTextFirst", 31 | "buttonTextLast", 32 | "buttonTextNext", 33 | "buttonTextPrevious", 34 | "buttonTextSubmit", 35 | "caretClosed", 36 | "caretOpen", 37 | "chips", 38 | "color", 39 | "controlSeparator", 40 | "controlVariant", 41 | "cursor", 42 | "display", 43 | "element", 44 | "fill", 45 | "fillDark", 46 | "fillLight", 47 | "flex", 48 | "flexDirection", 49 | "gap", 50 | "gridColumns", 51 | "height", 52 | "hover", 53 | "inactive", 54 | "indent", 55 | "justify", 56 | "meter", 57 | "padding", 58 | "position", 59 | "regionAnchor", 60 | "regionBackdrop", 61 | "regionBody", 62 | "regionCaption", 63 | "regionCaret", 64 | "regionCell", 65 | "regionChildren", 66 | "regionChipList", 67 | "regionChipWrapper", 68 | "regionCone", 69 | "regionContent", 70 | "regionControl", 71 | "regionDefault", 72 | "regionDrawer", 73 | "regionFoot", 74 | "regionFootCell", 75 | "regionFooter", 76 | "regionHead", 77 | "regionHeadCell", 78 | "regionHeader", 79 | "regionIcon", 80 | "regionInput", 81 | "regionInterface", 82 | "regionInterfaceText", 83 | "regionLabel", 84 | "regionLead", 85 | "regionLegend", 86 | "regionList", 87 | "regionListItem", 88 | "regionNavigation", 89 | "regionPage", 90 | "regionPanel", 91 | "regionRowHeadline", 92 | "regionRowMain", 93 | "regionSummary", 94 | "regionSymbol", 95 | "regionTab", 96 | "regionTrail", 97 | "ring", 98 | "rounded", 99 | "select", 100 | "shadow", 101 | "slotDefault", 102 | "slotFooter", 103 | "slotHeader", 104 | "slotLead", 105 | "slotMessage", 106 | "slotMeta", 107 | "slotPageContent", 108 | "slotPageFooter", 109 | "slotPageHeader", 110 | "slotSidebarLeft", 111 | "slotSidebarRight", 112 | "slotTrail", 113 | "spacing", 114 | "text", 115 | "track", 116 | "transition", 117 | "width", 118 | "zIndex" 119 | ] 120 | } -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "soundpaddon-web", 3 | "version": "1.6.2", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite dev --host", 7 | "build": "vite build", 8 | "build:electron": "cross-env ELECTRON=electron vite build", 9 | "preview": "vite preview", 10 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 11 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" 12 | }, 13 | "devDependencies": { 14 | "@iconify-json/icon-park": "^1.1.13", 15 | "@iconify-json/logos": "^1.1.42", 16 | "@iconify-json/mdi": "^1.1.64", 17 | "@skeletonlabs/skeleton": "2.9.0", 18 | "@skeletonlabs/tw-plugin": "0.3.1", 19 | "@sveltejs/adapter-auto": "^3.0.0", 20 | "@sveltejs/adapter-node": "^5.0.1", 21 | "@sveltejs/adapter-static": "^3.0.1", 22 | "@sveltejs/adapter-vercel": "^5.2.0", 23 | "@sveltejs/kit": "^2.0.0", 24 | "@sveltejs/vite-plugin-svelte": "^3.0.0", 25 | "@tailwindcss/forms": "^0.5.7", 26 | "@types/express": "^4.17.21", 27 | "@types/lodash": "^4.17.0", 28 | "@types/node": "^20.12.6", 29 | "@types/qrcode": "^1.5.5", 30 | "@types/sortablejs": "^1.15.8", 31 | "@types/winreg": "^1.2.36", 32 | "@types/youtube-player": "^5.5.11", 33 | "@vite-pwa/sveltekit": "^0.4.0", 34 | "@vitejs/plugin-basic-ssl": "^1.1.0", 35 | "autoprefixer": "10.4.18", 36 | "cross-env": "^7.0.3", 37 | "esbuild": "^0.20.2", 38 | "postcss": "8.4.35", 39 | "svelte": "^4.2.7", 40 | "svelte-check": "^3.6.0", 41 | "sveltekit-sse": "^0.8.13", 42 | "tailwindcss": "3.4.1", 43 | "tslib": "^2.4.1", 44 | "typescript": "^5.0.0", 45 | "vite": "^5.0.3", 46 | "vite-plugin-tailwind-purgecss": "0.2.0" 47 | }, 48 | "type": "module", 49 | "dependencies": { 50 | "@floating-ui/dom": "^1.6.3", 51 | "@vercel/analytics": "^1.2.2", 52 | "driver.js": "^1.3.1", 53 | "fuzzy": "^0.1.3", 54 | "html5-qrcode": "^2.3.8", 55 | "is-mobile": "^4.0.0", 56 | "lodash": "^4.17.21", 57 | "lz-string": "^1.5.0", 58 | "node-fetch": "^3.3.2", 59 | "nosleep.js": "^0.12.0", 60 | "qrcode": "^1.5.3", 61 | "socket.io": "^4.7.5", 62 | "socket.io-client": "^4.7.5", 63 | "sortablejs": "^1.15.2", 64 | "soundpad.js": "^2.6.2", 65 | "svelte-gestures": "^4.0.0", 66 | "unplugin-icons": "^0.18.5", 67 | "youtube-player": "^5.6.0" 68 | }, 69 | "pkg": { 70 | "outputPath": "dist", 71 | "options": [ 72 | "experimental-modules" 73 | ], 74 | "targets": [ 75 | "node20-win-x64" 76 | ] 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /web/src/stores/mirror-layout.ts: -------------------------------------------------------------------------------- 1 | import { localStorageStore } from '@skeletonlabs/skeleton'; 2 | import type { Sound } from 'soundpad.js/lib/web'; 3 | import { get, type Writable } from 'svelte/store'; 4 | import { enableSoundpadColors } from './settings'; 5 | import { rgbToHsl } from '$lib/utils/misc'; 6 | 7 | /** 8 | * === Sound Metadata === 9 | */ 10 | 11 | export enum SOUND_COLORS_HSL { 12 | BLUE = '200 93% 27%', 13 | GREEN = '120 93% 27%', 14 | ORANGE = '30 93% 27%', 15 | PINK = '320 93% 27%', 16 | PURPLE = '280 93% 27%', 17 | RED = '0 93% 27%', 18 | YELLOW = '60 93% 27%', 19 | } 20 | 21 | 22 | export type CustomSoundData = { 23 | name: string; 24 | color: SOUND_COLORS_HSL; 25 | } 26 | 27 | const defaultSoundMetadata: CustomSoundData = { 28 | color: SOUND_COLORS_HSL.BLUE, 29 | name: '' 30 | } 31 | 32 | /** 33 | * === Settings === 34 | */ 35 | 36 | /** 37 | * === Persistent Settings === 38 | */ 39 | 40 | export const soundOrder: Writable> = localStorageStore('v1_soundOrder', []); 41 | export const soundMetadata: Writable> = localStorageStore('v1_soundMetadata', {}); 42 | 43 | export function getSoundOrderForCategory (categoryName: string): Array { 44 | if (categoryName !== 'All sounds') { 45 | return []; 46 | } 47 | return get(soundOrder); 48 | } 49 | 50 | export function getSoundName (sound: Sound): string { 51 | return getSoundMetadata(sound).name; 52 | } 53 | 54 | export function getSoundMetadata (sound: Sound): CustomSoundData { 55 | return get(soundMetadata)[sound.url] ?? { 56 | color: get(enableSoundpadColors) && sound.color ? rgbToHsl(sound.color) ?? SOUND_COLORS_HSL.BLUE : SOUND_COLORS_HSL.BLUE, 57 | name: generateSoundNameFromSoundpad(sound) 58 | }; 59 | } 60 | 61 | export function setSoundMetadata (sound: Sound, data: Partial) { 62 | const newMetadata = { 63 | ...getSoundMetadata(sound), 64 | ...data 65 | } 66 | 67 | if (newMetadata.color === SOUND_COLORS_HSL.BLUE && newMetadata.name === generateSoundNameFromSoundpad(sound)) { 68 | soundMetadata.update((metadata) => { 69 | delete metadata[sound.url]; 70 | return metadata; 71 | }); 72 | return; 73 | } 74 | soundMetadata.update((metadata) => { 75 | metadata[sound.url] = newMetadata; 76 | return metadata; 77 | }); 78 | } 79 | 80 | export function generateSoundNameFromSoundpad (sound: Sound): string { 81 | return sound.tag 82 | ? sound.tag.replace(/^\d+-/, "") 83 | : RegExp(/.+\/([^/]+)\..+$/).exec(sound.url.replace(/\/\/|\\\\|\\/g, "/"))?.[1] ?? 84 | "" 85 | } 86 | -------------------------------------------------------------------------------- /web/src/client/connections.ts: -------------------------------------------------------------------------------- 1 | import { io } from "socket.io-client"; 2 | import Soundpad from "soundpad.js/lib/web"; 3 | import { serverHost } from "../stores/settings"; 4 | import { get, writable, type Writable } from "svelte/store"; 5 | import { checkIsDemo, isHttps } from "$lib/utils/misc"; 6 | 7 | const soundpadClient = new Soundpad(); 8 | 9 | const server = get(serverHost); 10 | 11 | const serverHostname = isHttps() ? ipToSSLDomain(server.ip) : location.hostname; 12 | 13 | const socket = io(`${isHttps() ? 'wss' : 'ws'}://${serverHostname}:${server.port}`, { 14 | autoConnect: false, 15 | transports: ["websocket"], 16 | }); 17 | 18 | soundpadClient.connect((query: string) => { 19 | if (checkIsDemo()) { 20 | return Promise.resolve(''); 21 | } 22 | return fetch(`${isHttps() ? 'https' : 'http'}://${serverHostname}:${server.port}/api/soundpad`, { method: "POST", body: query }) 23 | .then((data) => data.text()) 24 | .then((data) => { 25 | return data; 26 | }); 27 | }); 28 | 29 | export const isConnected: Writable = writable(false); 30 | 31 | socket.on("connect", () => { 32 | isConnected.set(true); 33 | }); 34 | 35 | if (checkIsDemo()) { 36 | isConnected.set(true); 37 | }; 38 | 39 | export { 40 | soundpadClient, 41 | socket, 42 | } 43 | 44 | export function ipToSSLDomain(ip: string): string { 45 | return `${ip.replace(/\./g, '-')}.local.soundpaddon.app`; 46 | } 47 | 48 | /** 49 | * 50 | * @param ip Checks if the host is reachable 51 | * @param timeout 52 | */ 53 | export async function testHostIp(ip: string, timeout: number = 1000): Promise<'https' | 'http' | 'offline'> { 54 | return new Promise(async resolve => { 55 | const [https, http] = await Promise.allSettled([ 56 | fetchWithTimeout(`https://${ipToSSLDomain(ip)}:${get(serverHost).port}/api/data`, timeout), 57 | fetchWithTimeout(`http://${ip}:${get(serverHost).port + 1}/api/data`, timeout), 58 | ]).catch(() => { 59 | resolve('offline'); 60 | return Promise.reject(); 61 | }) 62 | if (https.status === 'fulfilled' && https.value) { 63 | resolve('https'); 64 | } 65 | if (http.status === 'rejected') { 66 | resolve('http'); 67 | } 68 | resolve('offline'); 69 | }) 70 | } 71 | 72 | function fetchWithTimeout(url: string, timeout: number): Promise { 73 | return new Promise((resolve, reject) => { 74 | const controller = new AbortController(); 75 | const signal = controller.signal; 76 | setTimeout(() => { 77 | controller.abort(); 78 | reject(new Error('timeout')); 79 | }, timeout); 80 | fetch(url, { signal }) 81 | .then(resolve) 82 | .catch(reject); 83 | }); 84 | } 85 | -------------------------------------------------------------------------------- /web/src/routes/panel/+layout.svelte: -------------------------------------------------------------------------------- 1 | 46 | 47 | 48 | 49 | 50 | 51 | 52 |
55 |
56 |
59 | 65 |

66 | {drawerTitles[drawerId]} 67 |

68 |
69 |
70 |
{ 74 | if (e.detail.direction === "right") drawerStore.close(); 75 | }} 76 | > 77 | {#if $drawerStore.id === DRAWER_TYPES.MOBILE_SETTINGS} 78 | 79 | {:else} 80 | 81 | {/if} 82 |
83 |
84 |
85 | 86 | 87 | -------------------------------------------------------------------------------- /web/src/components/dekstop/sound-extractor.svelte: -------------------------------------------------------------------------------- 1 | 53 | 54 |
55 |
56 | 70 |
71 |
75 | {#each soundsFound as soundFound (soundFound.name + soundFound.url)} 76 | 77 | {/each} 78 |
79 |
80 |
81 |
82 | 83 | 84 | -------------------------------------------------------------------------------- /web/src/components/mobile/toolbar-buttons.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 |
20 | 28 | 36 |
37 | 38 |
39 |
44 | 57 | 58 | 71 |
72 |
73 | 74 |
75 | 86 |
87 | 88 | 89 | -------------------------------------------------------------------------------- /web/src/lib/demo/demo-sounds.ts: -------------------------------------------------------------------------------- 1 | import type { FetchedSound } from "$lib/api-return-types"; 2 | import type { Sound } from "soundpad.js/lib/web"; 3 | 4 | export const demoData: Sound[] = [ 5 | { 6 | "index": 1, 7 | "url": "/demo-sounds/taking-the-hobbits-to-isengard-mp3cut.mp3", 8 | "artist": "", 9 | "title": "taking-the-hobbits-to-isengard-mp3cut", 10 | "duration": "0:08", 11 | "addedOn": "2024-04-17", 12 | "lastPlayedOn": "", 13 | "playCount": 0 14 | }, 15 | { 16 | "index": 2, 17 | "url": "/demo-sounds/murloc.mp3", 18 | "artist": "", 19 | "title": "murloc", 20 | "duration": "0:03", 21 | "addedOn": "2024-04-17", 22 | "lastPlayedOn": "", 23 | "playCount": 0 24 | }, 25 | { 26 | "index": 3, 27 | "url": "/demo-sounds/freebird.mp3", 28 | "artist": "", 29 | "title": "freebird", 30 | "duration": "0:17", 31 | "addedOn": "2024-04-17", 32 | "lastPlayedOn": "", 33 | "playCount": 0 34 | }, 35 | { 36 | "index": 4, 37 | "url": "/demo-sounds/bonk.mp3", 38 | "artist": "", 39 | "title": "bonk", 40 | "duration": "0:02", 41 | "addedOn": "2024-04-17", 42 | "lastPlayedOn": "2024-04-17", 43 | "playCount": 2 44 | }, 45 | { 46 | "index": 5, 47 | "url": "/demo-sounds/bell.mp3", 48 | "artist": "", 49 | "title": "bell", 50 | "duration": "0:03", 51 | "addedOn": "2024-04-17", 52 | "lastPlayedOn": "2024-04-17", 53 | "playCount": 1 54 | }, 55 | { 56 | "index": 6, 57 | "url": "/demo-sounds/quack.mp3", 58 | "artist": "", 59 | "title": "quack", 60 | "duration": "0:01", 61 | "addedOn": "2024-04-17", 62 | "lastPlayedOn": "", 63 | "playCount": 0 64 | }, 65 | { 66 | "index": 7, 67 | "url": "/demo-sounds/Spooky Scary Skeleton.mp3", 68 | "artist": "", 69 | "title": "Spooky Scary Skeleton", 70 | "duration": "0:06", 71 | "addedOn": "2024-04-17", 72 | "lastPlayedOn": "2024-04-17", 73 | "playCount": 1 74 | }, 75 | { 76 | "index": 8, 77 | "url": "/demo-sounds/nope.mp3", 78 | "artist": "", 79 | "title": "nope", 80 | "duration": "0:00", 81 | "addedOn": "2024-04-17", 82 | "lastPlayedOn": "2024-04-17", 83 | "playCount": 1 84 | }, 85 | { 86 | "index": 9, 87 | "url": "/demo-sounds/Cantina.mp3", 88 | "artist": "", 89 | "title": "Cantina", 90 | "duration": "0:28", 91 | "addedOn": "2024-04-17", 92 | "lastPlayedOn": "", 93 | "playCount": 0 94 | }, 95 | ] 96 | 97 | export const demoExtractor: FetchedSound[] = [ 98 | { 99 | "name": "original.mp3", 100 | "url": "/demo-sounds/nyan-cat-original.mp3", 101 | "source": "webpage" 102 | }, 103 | { 104 | "name": "original.ogg", 105 | "url": "/demo-sounds/nyan-cat-original.ogg", 106 | "source": "webpage" 107 | }, 108 | ] 109 | 110 | export const demoSoundbanks: FetchedSound[] = [ 111 | { 112 | "name": "realtime-volume-detection.mp3", 113 | "url": "/demo-sounds/realtime-volume-detection.mp3", 114 | "source": "myinstants" 115 | }, 116 | { 117 | "name": "crickets.mp3", 118 | "url": "/demo-sounds/crickets.mp3", 119 | "source": "voicy" 120 | }, 121 | { 122 | "name": "fake earrape.mp3", 123 | "url": "/demo-sounds/fake earrape.mp3", 124 | "source": "myinstants" 125 | }, 126 | ] 127 | -------------------------------------------------------------------------------- /web/src/lib/preview-audio.ts: -------------------------------------------------------------------------------- 1 | import { getEndpointUrl } from "./utils/api"; 2 | import { checkIsDemo } from "./utils/misc"; 3 | 4 | let audioElement = undefined as unknown as HTMLAudioElement; 5 | const outputAudio = new Audio(); 6 | 7 | export function initAudioPreviewer () { 8 | audioElement = document.createElement("audio") 9 | audioElement.crossOrigin = "anonymous"; 10 | audioElement.preload = "none"; 11 | 12 | const audioContext = new AudioContext(); 13 | 14 | const source = audioContext.createMediaElementSource(audioElement); 15 | const delayNode = audioContext.createDelay(); 16 | delayNode.delayTime.setValueAtTime(0.1, audioContext.currentTime); 17 | const destination = audioContext.createMediaStreamDestination(); 18 | outputAudio.srcObject = destination.stream; 19 | outputAudio.play(); 20 | 21 | const safetyAnalyser = audioContext.createAnalyser(); 22 | 23 | source.connect(delayNode); 24 | delayNode.connect(destination); 25 | source.connect(safetyAnalyser); 26 | 27 | audioElement.addEventListener("playing", () => { 28 | outputAudio.volume = 0.5; 29 | protectEars(safetyAnalyser); 30 | }); 31 | 32 | audioElement.addEventListener("loadeddata", () => { 33 | listeners.onLoaded(); 34 | }); 35 | 36 | audioElement.addEventListener("pause", () => { 37 | listeners.onPause(); 38 | }); 39 | 40 | audioElement.addEventListener("ended", () => { 41 | listeners.onEnd(); 42 | }); 43 | } 44 | 45 | type Listeners = { 46 | onLoaded: () => void; 47 | onPlay: () => void; 48 | onPause: () => void; 49 | onEnd: () => void; 50 | onEarRape: () => void; 51 | onError: () => void; 52 | } 53 | 54 | const listeners = { 55 | onLoaded: () => { }, 56 | onPlay: () => { }, 57 | onPause: () => { }, 58 | onEnd: () => { }, 59 | onEarRape: () => { }, 60 | onError: () => { }, 61 | } as Listeners; 62 | 63 | export async function previewAudio (url: string, newListeners: Partial) { 64 | listeners.onEnd(); 65 | audioElement.pause(); 66 | await new Promise(resolve => setTimeout(resolve, 100)); // Wait for the audio to stop playing 67 | 68 | audioElement.src = checkIsDemo() ? url : `${getEndpointUrl()}/proxy/${url}` 69 | 70 | 71 | listeners.onLoaded = newListeners.onLoaded ?? (() => { }); 72 | listeners.onPlay = newListeners.onPlay ?? (() => { }); 73 | listeners.onPause = newListeners.onPause ?? (() => { }); 74 | listeners.onEnd = newListeners.onEnd ?? (() => { }); 75 | listeners.onEarRape = newListeners.onEarRape ?? (() => { }); 76 | listeners.onError = newListeners.onError ?? (() => { }); 77 | 78 | audioElement.onerror = () => { 79 | if (audioElement.src.includes('proxy')) { // Filters errors from setting src to an empty string 80 | listeners.onError(); 81 | } 82 | }; 83 | 84 | audioElement.play(); 85 | } 86 | 87 | export function stopPreview () { 88 | audioElement.pause(); 89 | listeners.onPause(); 90 | audioElement.currentTime = 0; 91 | audioElement.src = ""; 92 | } 93 | 94 | function protectEars (analyser: AnalyserNode) { 95 | analyser.fftSize = 2048; 96 | analyser.minDecibels = -90; 97 | 98 | const bufferLength = analyser.frequencyBinCount; 99 | const fbc_array = new Uint8Array(bufferLength); 100 | 101 | analyser.getByteTimeDomainData(fbc_array); 102 | const dataArray = new Uint8Array(fbc_array.buffer); 103 | 104 | if (dataArray.filter((val) => val >= 240).length > 2) { 105 | outputAudio.volume = 0.1; 106 | listeners.onEarRape(); 107 | } 108 | 109 | if (audioElement.paused === false) { 110 | setTimeout(protectEars.bind(null, analyser), 100); 111 | } 112 | } -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 55 | -------------------------------------------------------------------------------- /web/static/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 55 | -------------------------------------------------------------------------------- /web/src/components/dekstop/sound-bank-lookup.svelte: -------------------------------------------------------------------------------- 1 | 55 | 56 |
57 | 71 |
72 | {#each sources as clickedSource} 73 | 96 | {/each} 97 |
98 |
99 |
103 | {#each filteredSounds as soundFound (soundFound.name + soundFound.url)} 104 | 105 | {/each} 106 |
107 |
108 |
109 | 110 | 111 | -------------------------------------------------------------------------------- /desktop/src/main/add-system-tray.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { closeSoundpad } from 'soundpad.js' 4 | import { disableAutoStart, enableAutoStart, getAutoStartValue } from './start-with-windows-tray-option' 5 | import { Menu, shell, Tray, type App } from 'electron'; 6 | import { BrowserWindow, dialog, type KeyboardEvent, type MenuItem, type MenuItemConstructorOptions } from 'electron/main'; 7 | import { enableMinimizeOnWinClose, getMinimizeOnWinClose, disableMinimizeOnWinClose } from './auto-minimize-tray-option'; 8 | import { getDownloadLocation, resetDownloadLocation, setDownloadLocation } from './download-location-tray-option'; 9 | 10 | export async function createTray (app: App, iconPath: string) { 11 | const tray = new Tray(iconPath) 12 | 13 | const contextMenu = Menu.buildFromTemplate(await generateSystrayTemplate(app, tray)) 14 | tray.setToolTip('Soundpaddon') 15 | tray.setContextMenu(contextMenu) 16 | 17 | tray.on('click', (e: KeyboardEvent) => { 18 | const appWindow = BrowserWindow.getAllWindows()[0] 19 | if (appWindow.isVisible()) { 20 | appWindow.focus() 21 | } else { 22 | appWindow.show() 23 | } 24 | }) 25 | } 26 | 27 | async function generateSystrayTemplate (app: App, tray: Tray): Promise<(MenuItemConstructorOptions | MenuItem)[]> { 28 | return [ 29 | { 30 | label: 'Open Soundpaddon with Windows', 31 | type: 'checkbox', 32 | checked: await getAutoStartValue('Soundpaddon'), 33 | click: async () => { 34 | if (await getAutoStartValue('Soundpaddon')) { 35 | console.log('disabling autostart'); 36 | await disableAutoStart('Soundpaddon') 37 | } else { 38 | console.log('enabling autostart'); 39 | await enableAutoStart('Soundpaddon', app.getPath('exe')).catch(console.error) 40 | } 41 | 42 | // Update the context menu with the new checkbox value 43 | tray.setContextMenu(Menu.buildFromTemplate(await generateSystrayTemplate(app, tray))) 44 | } 45 | }, 46 | { 47 | label: 'Minimize to tray when closing Soundpaddon\'s window', 48 | type: 'checkbox', 49 | checked: getMinimizeOnWinClose(), 50 | click: async () => { 51 | if (getMinimizeOnWinClose()) { 52 | console.log('disabling minimize on close'); 53 | disableMinimizeOnWinClose() 54 | } else { 55 | console.log('enabling minimize on close'); 56 | enableMinimizeOnWinClose() 57 | } 58 | 59 | // Update the context menu with the new checkbox value 60 | tray.setContextMenu(Menu.buildFromTemplate(await generateSystrayTemplate(app, tray))) 61 | } 62 | }, 63 | { 64 | label: 'Sound download location', 65 | submenu: [ 66 | { 67 | label: 'Set download location', 68 | click: async () => { 69 | const result = await dialog.showOpenDialog({ 70 | properties: ['openDirectory'] 71 | }) 72 | if (!result.canceled && result.filePaths.length > 0) { 73 | setDownloadLocation(result.filePaths[0]) 74 | } 75 | } 76 | }, 77 | { 78 | label: 'Open download location', 79 | click: async () => { 80 | const location = getDownloadLocation(app) 81 | shell.openPath(location) 82 | } 83 | }, 84 | { 85 | label: 'Reset download location', 86 | click: async () => { 87 | resetDownloadLocation(app) 88 | } 89 | } 90 | ], 91 | }, 92 | { type: 'separator' }, 93 | { 94 | label: 'Close Soundpad and Soundpaddon', 95 | type: 'normal', 96 | click: async () => { 97 | await closeSoundpad() 98 | app.exit() 99 | } 100 | }, 101 | { 102 | label: 'Close Soundpaddon', 103 | type: 'normal', 104 | click: () => { 105 | app.exit() 106 | } 107 | } 108 | ] 109 | } 110 | -------------------------------------------------------------------------------- /web/src/components/settings/customized-layout-settings.svelte: -------------------------------------------------------------------------------- 1 | 38 | 39 |
40 | 58 | 59 |
60 | 61 | 79 | 80 |
81 | 82 |
83 | Sounds disposition in landscape orientation 84 |
85 | List 86 | 93 | Grid 94 | 100 |
101 |
102 |
103 | 104 | 119 | -------------------------------------------------------------------------------- /web/src/components/drawers/mobile-settings.svelte: -------------------------------------------------------------------------------- 1 | 45 | 46 |
47 | 60 |
61 |
62 | Show search bar 63 | 69 |
70 |
71 | Enable Soundpad colors 72 | 78 |
79 |
80 | 81 | 96 | 97 | 127 |
128 | -------------------------------------------------------------------------------- /web/src/components/buttons/qrcode-scan.svelte: -------------------------------------------------------------------------------- 1 | 125 | 126 |
131 | 132 | 145 | -------------------------------------------------------------------------------- /web/src/components/mobile/toolbar.svelte: -------------------------------------------------------------------------------- 1 | 88 | 89 | {#if $isConnected} 90 |
91 | 92 |
{}} 110 | > 111 | 112 |
113 |
114 | 115 |
116 |
117 | {:else} 118 |
119 | {#if isSafari} 120 | 121 | 130 | {:else} 131 | 132 | {/if} 133 | 134 |
135 | {/if} 136 | -------------------------------------------------------------------------------- /desktop/src/main/routes/import/youtube.ts: -------------------------------------------------------------------------------- 1 | import bodyParser from 'body-parser'; 2 | import { type Application, type Request, type Response, } from 'express'; 3 | import { App } from 'electron/main'; 4 | import path from 'node:path'; 5 | import ffmpeg from 'fluent-ffmpeg'; 6 | import ffmpegInstaller from '@ffmpeg-installer/ffmpeg' 7 | import fs from 'node:fs'; 8 | import { importToSoundpad, timeMarkToSeconds } from '../../utils/misc'; 9 | import { getDownloadLocation } from '../../download-location-tray-option'; 10 | import { getYtDlpWrapper, updateYTDlp } from '../../utils/yt-dlp'; 11 | 12 | updateYTDlp(); 13 | 14 | ffmpeg.setFfmpegPath(ffmpegInstaller.path.replace('app.asar', 'app.asar.unpacked')) 15 | 16 | const responsesToUpdate: Array<(data: { stepName: string, progress: number, isDone: boolean }[]) => void> = []; 17 | 18 | export default function registerRoutes (app: Application, electronApp: App) { 19 | app.post('/api/import/youtube', bodyParser.json(), async function (req: Request, res: Response) { 20 | const data = req.body as { 21 | url: string, 22 | name: string, 23 | start: number, 24 | duration: number, 25 | }; 26 | 27 | const statuses = [ 28 | { stepName: 'Initializing', progress: -1, isDone: false }, 29 | { stepName: 'Downloading', progress: 0, isDone: false }, 30 | { stepName: 'Converting', progress: 0, isDone: false }, 31 | { stepName: 'Cleaning Up', progress: 0, isDone: false }, 32 | ] 33 | 34 | responsesToUpdate.forEach(listener => listener(statuses)); 35 | 36 | const outputPath = path.join(getDownloadLocation(electronApp), `${data.name}.mp3`); 37 | 38 | fs.mkdirSync(path.dirname(outputPath), { recursive: true }); 39 | 40 | 41 | /** 42 | * Download with yt-dlp 43 | */ 44 | await new Promise(resolve => { 45 | getYtDlpWrapper() 46 | .exec([ 47 | data.url, 48 | '-f', 49 | 'bestaudio', 50 | '-o', 51 | `temp_${data.name}`, 52 | ], { 53 | cwd: electronApp.getPath('temp'), 54 | }) 55 | .on('progress', (progress) => { 56 | if (!statuses[0].isDone) { 57 | statuses[0].isDone = true; 58 | } 59 | statuses[1].progress = progress.percent; 60 | responsesToUpdate.forEach(listener => listener(statuses)); 61 | } 62 | ) 63 | .on('error', console.log) 64 | .on('close', () => { 65 | statuses[1].isDone = true; 66 | resolve(); 67 | }); 68 | }); 69 | 70 | responsesToUpdate.forEach(listener => listener(statuses)); 71 | 72 | /** 73 | * Convert with ffmpeg 74 | */ 75 | await new Promise(resolve => { 76 | ffmpeg(path.join(electronApp.getPath('temp'), `temp_${data.name}`)) 77 | .audioBitrate(128) 78 | .setStartTime(data.start) 79 | .setDuration(data.duration) 80 | .on('progress', (progress) => { 81 | statuses[2].progress = progress.percent; 82 | responsesToUpdate.forEach(listener => listener(statuses)); 83 | }) 84 | .on('end', resolve) 85 | .save(outputPath); 86 | }) 87 | 88 | statuses[2].isDone = true; 89 | statuses[3].progress = -1; 90 | responsesToUpdate.forEach(listener => listener(statuses)); 91 | 92 | /** 93 | * Cleanup 94 | */ 95 | 96 | fs.unlinkSync(path.join(electronApp.getPath('temp'), `temp_${data.name}`)); 97 | statuses[3].isDone = true; 98 | responsesToUpdate.forEach(listener => listener(statuses)); 99 | 100 | await importToSoundpad(outputPath); 101 | 102 | return res.end() 103 | }) 104 | 105 | app.get('/api/import/youtube/progress', (req, res) => { 106 | res.setHeader('Cache-Control', 'no-cache'); 107 | res.setHeader('Content-Type', 'text/event-stream'); 108 | res.setHeader('Access-Control-Allow-Origin', '*'); 109 | res.setHeader('Connection', 'keep-alive'); 110 | res.flushHeaders(); // flush the headers to establish SSE with client 111 | 112 | const listener = (statuses: { stepName: string, progress: number, isDone: boolean }[]) => { 113 | if (statuses.every(s => s.isDone)) { 114 | res.write(`data: ${JSON.stringify(statuses)}\n\n`); 115 | responsesToUpdate.splice(responsesToUpdate.indexOf(listener), 1); 116 | res.end(); 117 | } else { 118 | res.write(`data: ${JSON.stringify(statuses)}\n\n`); 119 | } 120 | } 121 | 122 | responsesToUpdate.push(listener); 123 | 124 | // If client closes connection, stop sending events 125 | res.on('close', () => { 126 | responsesToUpdate.splice(responsesToUpdate.indexOf(listener), 1); 127 | res.end(); 128 | }); 129 | }); 130 | } 131 | -------------------------------------------------------------------------------- /desktop/src/main/websocket-server.ts: -------------------------------------------------------------------------------- 1 | import { type Socket } from "socket.io"; 2 | import Soundpad, { PlayStatus, type Category, type Sound } from 'soundpad.js' 3 | import { sleep } from "./utils/misc"; 4 | import _ from 'lodash' 5 | // import {execSync} from 'child_process' 6 | 7 | // execSync(`PowerShell -Command "Add-Type -AssemblyName PresentationFramework;[System.Windows.MessageBox]::Show('Hello World', '${process.env.name}', 0, 16)"`) 8 | 9 | // Using multiple clients since Soundpad uses a named pipe, race conditions may mix responses in a single message 10 | const clients = { 11 | playbackFetcher: new Soundpad({ 12 | autoReconnect: true, 13 | startSoundpadOnConnect: true, 14 | }), 15 | soundsFetcher: new Soundpad({ 16 | autoReconnect: true, 17 | startSoundpadOnConnect: true, 18 | }), 19 | categoriesFetcher: new Soundpad({ 20 | autoReconnect: true, 21 | startSoundpadOnConnect: true, 22 | }), 23 | } 24 | 25 | // Handle creating/removing shortcuts on Windows when installing/uninstalling. 26 | if (require('electron-squirrel-startup')) { 27 | process.exit(); 28 | } 29 | 30 | Promise.all(Object.values(clients).map(client => client.connect())) 31 | 32 | 33 | /** 34 | * === Playback Fetcher === 35 | */ 36 | let playbackPosition = 0 37 | let playbackDuration = 0 38 | let playbackStatus = PlayStatus.STOPPED 39 | const socketsToNotify: Socket[] = [] 40 | 41 | setImmediate(async () => { 42 | while (true) { 43 | await Promise.all(Object.values(clients).map(client => client.connectionAwaiter)) 44 | 45 | const newPlaybackPosition = await clients.playbackFetcher.getPlaybackPosition() 46 | const newPlaybackDuration = await clients.playbackFetcher.getPlaybackDuration() 47 | const newPlaybackStatus = await clients.playbackFetcher.getPlayStatus() 48 | 49 | if ([PlayStatus.PLAYING, PlayStatus.PAUSED].includes(playbackStatus) && newPlaybackStatus === PlayStatus.STOPPED) { 50 | socketsToNotify.forEach(socket => socket.emit('playback-position', 0)) 51 | } 52 | 53 | if (newPlaybackStatus === PlayStatus.PLAYING && (newPlaybackPosition !== playbackPosition || newPlaybackDuration !== playbackDuration)) { 54 | playbackPosition = newPlaybackPosition 55 | playbackDuration = newPlaybackDuration 56 | socketsToNotify.forEach(socket => socket.emit('playback-position', playbackPosition / playbackDuration)) 57 | } 58 | playbackStatus = newPlaybackStatus 59 | await sleep(25) 60 | } 61 | }) 62 | 63 | /** 64 | * === Sounds & Categories Fetcher === 65 | */ 66 | 67 | let sounds: Sound[] = [] 68 | let categories: Category[] = [] 69 | 70 | setImmediate(async () => { 71 | while (true) { 72 | await Promise.all(Object.values(clients).map(client => client.connectionAwaiter)) 73 | 74 | const [newSounds, newCategories] = await Promise.all([ 75 | clients.soundsFetcher.getSoundListJSON(), 76 | clients.categoriesFetcher.getCategoriesJSON(true, true), 77 | ]) 78 | 79 | if (newSounds && _.isEqual(soundListToComparable(newSounds), soundListToComparable(sounds)) === false) { 80 | sounds = newSounds 81 | socketsToNotify.forEach(socket => socket.emit('sounds', sounds)) 82 | } 83 | if (newCategories && _.isEqual(categoriesToComparable(newCategories), categoriesToComparable(categories)) === false) { 84 | categories = newCategories 85 | socketsToNotify.forEach(socket => socket.emit('categories', categories)) 86 | } 87 | await sleep(1000) 88 | } 89 | }) 90 | 91 | /** 92 | * === Export === 93 | */ 94 | 95 | export default async function onWebsocketConnection(socket: Socket): Promise { 96 | await Promise.all(Object.values(clients).map(client => client.connectionAwaiter)) 97 | 98 | socket.emit('sounds', sounds) 99 | socketsToNotify.push(socket) 100 | socket.on('disconnect', () => { 101 | socketsToNotify.splice(socketsToNotify.indexOf(socket), 1) 102 | }) 103 | } 104 | 105 | function soundListToComparable(sounds: Sound[]): { 106 | index: number; 107 | title: string; 108 | url: string; 109 | }[] { 110 | return sounds.map(sound => ({ 111 | index: sound.index, 112 | title: sound.tag, 113 | color: sound.color, 114 | url: sound.url, 115 | })) 116 | } 117 | 118 | function categoriesToComparable(categories: Category[]): { 119 | index: number; 120 | icon: string; 121 | name: string; 122 | sounds: ReturnType; 123 | }[] { 124 | return categories.map(category => ({ 125 | index: category.index, 126 | icon: category.icon ?? 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z/C/HgAGgwJ/lK3Q6wAAAABJRU5ErkJggg==', // Transparent 1x1 PNG 127 | name: category.name, 128 | sounds: soundListToComparable(category.sounds ?? []), 129 | subCategories: categoriesToComparable(category.subCategories ?? []), 130 | })) 131 | } 132 | -------------------------------------------------------------------------------- /web/src/components/modals/download-file-modal.svelte: -------------------------------------------------------------------------------- 1 | 79 | 80 | {#if $modalStore[0]?.meta?.sound != undefined} 81 |
82 |
83 | Download
{soundData.sound.name}
84 | ? 85 |
86 |
87 | 96 |
97 | {#if soundData.isEarRape || hasEarrapeInName} 98 | 114 | {/if} 115 | {#if error} 116 |
{error}
117 | {/if} 118 | 138 |
139 | {/if} 140 | -------------------------------------------------------------------------------- /web/src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 | {@html ` 22 | 23 | `} 32 | 33 |
36 |
37 | Soundpaddon icon 38 | Soundpaddon 39 |
40 |
41 | 53 | 55 | 58 |
59 |
60 | 61 |
66 |
67 |
68 | Soundpaddon icon 69 | Soundpaddon 70 |
71 | Enhance your Soundpad experience 72 | 84 |
85 | 86 |
87 |
88 |

What is Soundpaddon?

89 |

90 | Soundpaddon is a free and open-source application that adds a few 91 | features for Soundpad. 92 |

93 |

94 | It allows you to control Soundpad remotely from any device connected to 95 | your local network. It also makes it fast and easy to import sounds from 96 | the web, YouTube, and more. 97 |

98 |
99 |
100 |

How does it work ?

101 |

102 | Soundpaddon runs on your computer and communicates with the devices you 103 | paired with. To pair devices, simply flash the QRCode on the screen with 104 | your phone, and it just works ! 105 |

106 | Diagram representing how Soundpaddon works 110 |
111 |
112 | 113 |
116 | 125 |
126 |
127 | -------------------------------------------------------------------------------- /desktop/src/index.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | const { app, BrowserWindow, screen, shell } = require('electron'); 4 | const { nativeImage } = require('electron/common'); 5 | const path = require('node:path'); 6 | const { dialog } = require('electron/main'); 7 | const net = require('net'); 8 | const { getSoundpadPath, isSoundpadOpened, openSoundpad, waitForPipe } = require('soundpad.js'); 9 | const { existsSync } = require('node:fs'); 10 | const Store = require('electron-store'); 11 | 12 | const store = new Store(); 13 | 14 | const iconPath = path.join(__dirname, '../assets/soundpaddon.ico') 15 | 16 | // Handle creating/removing shortcuts on Windows when installing/uninstalling. 17 | if (require('electron-squirrel-startup')) { 18 | app.quit(); 19 | } else { 20 | // Quit when all windows are closed, except on macOS. There, it's common 21 | // for applications and their menu bar to stay active until the user quits 22 | // explicitly with Cmd + Q. 23 | app.on('window-all-closed', () => { 24 | if (process.platform !== 'darwin') { 25 | app.quit(); 26 | } 27 | }); 28 | 29 | // This method will be called when Electron has finished 30 | // initialization and is ready to create browser windows. 31 | // Some APIs can only be used after this event occurs. 32 | checkIfSoundpadIsInstalledAndPurchased().then(async (checkResult) => { 33 | if (checkResult === false) { 34 | app.quit(); 35 | return; 36 | } 37 | app.whenReady() 38 | .then(async () => { 39 | const host = require('./main/dist/index.js') 40 | await host.createHttpServer({ 41 | certificateRootPath: path.join(app.getPath('userData'), 'ssl'), 42 | pathToServe: __dirname + '/renderer', 43 | electronApp: app, 44 | }) 45 | 46 | host.setSystemTray(app, iconPath); 47 | 48 | createWindow(); 49 | 50 | // On OS X it's common to re-create a window in the app when the 51 | // dock icon is clicked and there are no other windows open. 52 | app.on('activate', () => { 53 | if (BrowserWindow.getAllWindows().length === 0) { 54 | createWindow(); 55 | } 56 | }); 57 | }); 58 | }); 59 | } 60 | 61 | const createWindow = async () => { 62 | let factor = screen.getPrimaryDisplay().scaleFactor; 63 | 64 | const baseResolution = 1080; // The app was designed for 1080p resolution. 65 | factor = screen.getPrimaryDisplay().workAreaSize.height / baseResolution; 66 | 67 | // Create the browser window. 68 | const mainWindow = new BrowserWindow({ 69 | icon: nativeImage.createFromPath(iconPath), 70 | autoHideMenuBar: true, 71 | width: (screen.getPrimaryDisplay().workAreaSize.width * 0.75), 72 | height: (screen.getPrimaryDisplay().workAreaSize.height * 0.75), 73 | webPreferences: { 74 | zoomFactor: factor, 75 | preload: './preload.js', 76 | } 77 | }); 78 | 79 | mainWindow.on('close', function (event) { 80 | if (store.get('minimizeOnWinClose', false)) { 81 | event.preventDefault(); 82 | mainWindow.hide(); 83 | } 84 | }); 85 | 86 | mainWindow.on('moved', () => { 87 | 88 | const newFactor = screen.getDisplayMatching( 89 | mainWindow.getBounds() 90 | ).workAreaSize.height / baseResolution; 91 | 92 | if (newFactor === factor) { 93 | return 94 | } 95 | 96 | mainWindow.setSize( 97 | Math.round(mainWindow.getSize()[0] * newFactor / factor), 98 | Math.round(mainWindow.getSize()[1] * newFactor / factor) 99 | ); 100 | 101 | factor = newFactor; 102 | 103 | mainWindow.webContents.setZoomFactor(factor ** 0.25) 104 | mainWindow.show() 105 | }) 106 | 107 | mainWindow.once('ready-to-show', () => { 108 | factor = screen.getPrimaryDisplay().workAreaSize.height / baseResolution; 109 | mainWindow.webContents.setZoomFactor(factor ** 0.25) 110 | mainWindow.show() 111 | }) 112 | 113 | const externalUrls = [ 114 | 'https://ko-fi.com/seblor', 115 | 'https://github.com/Seblor/Soundpaddon/releases/latest', 116 | 'https://support.soundpaddon.app/', 117 | ]; 118 | 119 | mainWindow.webContents.setWindowOpenHandler(({ url }) => { 120 | if (externalUrls.includes(url)) { 121 | shell.openExternal(url); 122 | return { 123 | action: 'deny' 124 | } 125 | } 126 | return { action: 'deny' } 127 | }) 128 | 129 | // and load the index.html of the app. 130 | mainWindow.loadURL('https://127-0-0-1.local.soundpaddon.app:8555/panel/desktop'); 131 | 132 | // Open the DevTools. 133 | // mainWindow.webContents.openDevTools(); 134 | }; 135 | 136 | // In this file you can include the rest of your app's specific main process 137 | // code. You can also put them in separate files and import them here. 138 | 139 | async function checkIfSoundpadIsInstalledAndPurchased () { 140 | if (getSoundpadPath() === null || existsSync(getSoundpadPath()) === false) { 141 | dialog.showErrorBox('Soundpad is not installed', 'Please install Soundpad to use Soundpaddon.'); 142 | return false; 143 | } 144 | openSoundpad(false) 145 | let isSoundpadOpen = false; 146 | while (!isSoundpadOpen) { 147 | isSoundpadOpen = await isSoundpadOpened(false); 148 | await new Promise(resolve => setTimeout(resolve, 100)); 149 | } 150 | const isPipeOpen = await Promise.race([ 151 | waitForPipe().then(() => true), 152 | new Promise(resolve => setTimeout(resolve, 2e3)).then(() => false), 153 | ]) 154 | if (isPipeOpen === false) { 155 | dialog.showErrorBox('Soundpad is in trial version', 'Remote control of Soundpad is not available in the trial version. Please purchase Soundpad to use Soundpaddon.'); 156 | return false; 157 | } 158 | return true; 159 | } 160 | -------------------------------------------------------------------------------- /web/src/components/double-range-slider.svelte: -------------------------------------------------------------------------------- 1 | 107 | 108 |
109 |
110 |
120 |
130 |
139 |
140 |
141 | 142 | 193 | -------------------------------------------------------------------------------- /web/src/components/dekstop/sound-previewer.svelte: -------------------------------------------------------------------------------- 1 | 174 | 175 |
178 |
179 | {sound.name} 180 |
181 |
185 | 201 | 213 |
214 |
215 | -------------------------------------------------------------------------------- /web/src/lib/demo/demo-data.ts: -------------------------------------------------------------------------------- 1 | export const demoCategories = [ 2 | { 3 | "index": 1, 4 | "type": 1, 5 | "name": "All sounds", 6 | "icon": "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAABHNCSVQICAgIfAhkiAAAAyBJREFUaIHtWTuOFDEQfTXiHHMlIhIIdiTENSaYayAkNoCEiCvNPboeQftTLld1b7YEftJse+36uv15bQMLCwsL7wnxFZevf18u5AOQKwQAAche0A03/fnxdZbHAyJXkLssi2WWgvXC8kekV1CCSKrvYoOACm/6w/mfEtASvHVGAgQuoo9YHnvwIEA1T/Qyix2Uemqvh2lv9Wp8KwCtvgZ8mLIWXEvk7dEKUhIbcSDP/jZ6IyZ5cXXt5VidGttZArRWB2tjDDaYsF66LVZbrr3aH3xW0UDeJxQm0GCGgE1iQhvwYxBNX0rZd4g6PTobma+zBKhmItL0RjTLjOPm2wwbluBb7DXIUlF7tPqr9oT9rbVc7MTvmCYxgGefkFOgzyD+ZzS8h2HQFoNqxtlma0h0m9zkf0pAN72DNEn0VUg3vafyrUetDk3AUVvyg9XtCUb+FxYWFt4XMxv98vvlAj4gl2tjlLKLqvKmvz6/xvJyHTcau4Bnm2C1n1LR7p8CZeB/SqAGY3fKshZfMLPBLo9g/e+6+ZqftWP0j8y/h8i1b4omCiJno+nui6RsNyz0gOGfTifwH3OhgRWaRCJ+BWDn80D98MiTqPWVCyX2hyRrLIZqHCYwCRkjhwzROvBs0o9xJk/vz9udEbwBZ3CYyAEaowz0ow+T6TPDyfo6Yu4Pg3kOcHsOWVtypVvARou8IV19SDCuqzkykB3qYHSIt7FRyh3UkoT9qCltqbyOgRq9eZhV20x+CGRi/wsLCwvvi5mNfvr+IiKP6BSMypv++fZ6Kh9tpgeE9E0gQAb+vZwIylknpuVZZGaDs7zdqGCWdebL/vBL5MDqa0B0MtfPOiecnI02ZPp2k/L86ohvWV8jDsicY4KSERJ7hGj1iH5s7sZQowsyntpZP+1ovjyrzGkCwwEqk/pIJNJj0o653Q61qZ2jjEFA5gIFP74z2PZoVDFJ6Ijwwei9LYFtP+sslwr9UoLYSZuDbs9+UVH0mq67rNgVMFx4DBcZJpn4ouScjRK47xTZ9xT3tkhe9Zl20WDnbLJn/7P452KjCwsL/xn+ARQyzxwbUfD6AAAAAElFTkSuQmCC", 7 | "hidden": true 8 | }, 9 | { 10 | "index": 2, 11 | "name": "Musics", 12 | "icon": "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAABHNCSVQICAgIfAhkiAAABAFJREFUaIHNWTtzElEU/i7ZQOLeZcPg+GgMismo8ZXKcSy0crS0sLa2sNfSyrGydkYLf4CFv8A0jil9JBoTNamSmEFgYaMBhWsB6GY597GwBs7MKeCePd937vnu2WVhAIBbL8dGkj/uMrDLAK7AyIRZWPw2JxjmG7X6Qzy7UWZt8q+ZwPlBMVKbbKPE5u9fbNYasbYfDC95lbFD1igeWYC4ArA+kw1MTtcsxnC+i8DA+Oisi9iEBTG0bI3MUi/3UNwe74dBB4a0Q21aZAH21hPkN9b1Scav4sv0BWz3gG9vPY0FI9EqJeyG9nMBmZoqgMotABSRKRuQV+ZoeX8FYB3cKwJCSFyCW1sG/xkBRlFDggSOUERy4xWyml0Ku+0tIBkH+1YH+rU3cCtRiBSRKW9ExPhvEmoZ9z4rZBSSU20lNvm0JRT8IgBGWD1zDnVqobRkLCPbW+yWz9hhOq9BBaEOqC0JSIDeGsqoRMqnPnFKUQAQoQBdIdPwMvQKLxvIaGcFfKf72jqgPtTmEtLwbwrspA7Ta94nrYzs6geC6Fl4jgo0koQ0FdSKgHNS0u53cKsqMMn0cafwXfk4o+4qfR+QJhRAMg9/jF7l5S+hYRCUz2dSPr57TEE+gBvbGBUTKLmH6DVvGdm/I3N3AXb1IyGfM/C4asPa5CN3QLMT2/yEREbv4VaoTSgj4212h7vT+K5AC+PG0wEIIKWQUfXr7lwq+Tg5qO47BvwlU0jbBBeltE5GnW4K2P4SIZ/T7eljBCj1iB34t76dvQifjFmAWw1+XsXBLUI+6am+5aMoQDGF/noOXpqO4pWAjKor4ESM7+QghGi5dnDIz3Ei0hkOJSs4p+mYygqybXLZygIRMGMonwBorIe4485xiYwW2zIqY5waV+kpFAxomxRhme1AOEnHJuGlAU48yPHqKpAqSqbP5O5Wq9qukQU9hZoa/gEv8Bk6tlbE/soyMX1mWjcv445LgGOREATg5GkZ1Ypw69+6v0/nQ/LpYYwGDmzEN3NUOyfhOQCvhmMXSWn5fLI7h8mjhMT6GKP/vJC9pPlB0rFT8OyoYw/yGRr9WUiSbPQo/JQBf+cYCpElq7Ye78RhT6PkHNCC+c4RRQ4FproD0fNR7tvHNTI6CW+fjIiu9P85hTo+mlPKqJ6d7eHmpbfAi61+JNSWEZfJ6AB8ntZcr8A0O8QGuTQ1+HaellEqj5LViYtXQmzk5uP+xsCATfIPzZDWRNAa8H9k/WNHfBodBtvNV1/A0NbXImZBNNcAlhsgk5b1slGiuWNBYB4Qubj57IUJxuYTjcbIPQhB/G4iLxkqZ0zcT+DF7bUGE7NCNOe0r8f3jJuah2iKeYjm9cbzO3N/AC6HO/ttTZ5vAAAAAElFTkSuQmCC", 13 | "sounds": [ 14 | { 15 | "index": 1, 16 | "url": "/demo-sounds/taking-the-hobbits-to-isengard-mp3cut.mp3", 17 | "artist": "", 18 | "title": "taking-the-hobbits-to-isengard-mp3cut", 19 | "duration": "0:08", 20 | "tag": "", 21 | "addedOn": "2024-04-17", 22 | "lastPlayedOn": "2024-04-17", 23 | "playCount": 1 24 | }, 25 | { 26 | "index": 3, 27 | "url": "/demo-sounds/freebird.mp3", 28 | "artist": "", 29 | "title": "freebird", 30 | "duration": "0:17", 31 | "tag": "", 32 | "addedOn": "2024-04-17", 33 | "lastPlayedOn": "2024-04-17", 34 | "playCount": 3 35 | }, 36 | { 37 | "index": 9, 38 | "url": "/demo-sounds/Cantina.mp3", 39 | "artist": "", 40 | "title": "Cantina", 41 | "duration": "0:28", 42 | "tag": "", 43 | "addedOn": "2024-04-17", 44 | "lastPlayedOn": "", 45 | "playCount": 0 46 | } 47 | ] 48 | }, 49 | { 50 | "index": 3, 51 | "name": "D&D campaign", 52 | "icon": "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAABHNCSVQICAgIfAhkiAAAA0ZJREFUaIHNWT1MU1EU/u7j8YBQsaVQqyaCkCAmIGUwDgySmBBlA3XRwcRJBncZTUyMk5suLsbNxMFBBxIicbGbKYnAoASiKT+Wx48PLU/b60CBFvruz/v/kjs057xzv++c03PfDwEA3PlQX6P9fkBALgMYhBComJv7mKIE6cKO+QQvRzZIifwnQpEKihEbVomiy//+kn61Rt1+HF7yLJCkWounKkAHAeIwWGDtdFUlBKkjBALjw8MRYlEVNLRshaCyzTbE+ZwPgQqEtEIlWkIt1Lj6Ap1LWfubNQzhW9clbNuPYAnVlwz/mUBnZqL04xT07rv4UedOaH8EVCCL5rlHaN4TojmLpoBScJcnIneFdP/US/HtLcUDZlLQlp45EqGIOXot4jk6Nu1pUCv5WZBlaDCT9zDXGqtu3HqPC4sZIRGRxXeI9w5jTcj7AIqnmW4axnTvOKbPXYHJdc6g1UYrudBCJR/WEKi9iLmemzA4kbSNr2iUbCHF2lg+hTgCqMhuHZjvucEWkZ9BbMe1CtgBX8RKMsm4fgmRLZ1dzUNL7BxgVaHCb4+rdZzt+ACzClpe7n/gzklcVaBV3LPYPA5ENmXjVYfgScyCfAvm6xlttLOGRn8rwCEsUxxRexlUIWeWj1CVZCA3RAK4G5UBn5ugAGYJhDYCDgpVn1+2dtJiMCQqqjqvPgWVCrKBBu59RZhbyJxHJG9tNo61W1iq8/R+Ch1CS+4jrB/CTsDUqAdTqMix7cfgBPs1idNbDHtdF9Y1uYT6VwHzM85nvzBdjHgKhkisMhcX3swJnAPGJPqyM2yfugGsROTaB3BljFrYKRBZf43O3KpA/AT0kymp8bkHxxXQcq/Ql3MUAmbLEL7X2rs28JPYbLmN2VgT9nlIt5AbHWQTZvwWZqNNju6lAqpAAnrbddttU46y1+t+CElAbxstERfcj+PmwweOBPQzo2XZlh+VLAi1kBEdQSbqZBvvkmTxhSakzwhVaAX8jcz53oGfA/Ko5MsXEFp9u8RU0OICQNoDZLILO4mixbwKijRA293m4wcoIWmlUKgZB6WMh7yKS0K1CKEPFbwdWygQ2k9pcYr/ls4vbmwetEjToMVrhTf3p/4DGG2cteGp/NEAAAAASUVORK5CYII=", 53 | "sounds": [ 54 | { 55 | "index": 2, 56 | "url": "/demo-sounds/murloc.mp3", 57 | "artist": "", 58 | "title": "murloc", 59 | "duration": "0:03", 60 | "tag": "", 61 | "addedOn": "2024-04-17", 62 | "lastPlayedOn": "", 63 | "playCount": 0 64 | }, 65 | { 66 | "index": 4, 67 | "url": "/demo-sounds/bonk.mp3", 68 | "artist": "", 69 | "title": "bonk", 70 | "duration": "0:02", 71 | "tag": "", 72 | "addedOn": "2024-04-17", 73 | "lastPlayedOn": "2024-04-17", 74 | "playCount": 5 75 | }, 76 | { 77 | "index": 5, 78 | "url": "/demo-sounds/bell.mp3", 79 | "artist": "", 80 | "title": "bell", 81 | "duration": "0:03", 82 | "tag": "", 83 | "addedOn": "2024-04-17", 84 | "lastPlayedOn": "2024-04-17", 85 | "playCount": 4 86 | } 87 | ] 88 | }, 89 | { 90 | "index": 4, 91 | "name": "New Category", 92 | "icon": "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAABHNCSVQICAgIfAhkiAAAA8VJREFUaIHFWT9v00AU/zVElZCgAjEh4iLBUAFi6GZnYmFgYEw+gWekuAjBxIqQknwBf4J6Z+nAFnvjGyDUi0QnKiRYChIMzxefn8/xnX1JflKUxu7dvd+79+/e7cEVwswHMALg5d8cAkC2+o79xMWye51Gh9kAwASF4DYQABIAc8T+sq0I7QmE2RRA1Hp8GRFif95moD0BMpVT6DWe5B+B2M/YOGlWI+hNLAUwtt0NOwIkxCl7KkBmYKfBMJuAzI8rYmzjH+YE9MK33npl3gmAGXtqTMKMQFV4kS/CzcQH+YWPqmZlFJppxg0ALNgYIxLNBPTCD0u2ut4vdEhBu8eJnKLsH40k1hOoaiZF7A/Z+xn0TmmCBLE/ZmuqJKrKYug1LKBqlcymWEiSays8AIwQZuf5XAQiJPJfHqp+V0I9AXKuQHlShDi9zbaFB2BRIgEMlb+DXBYt9AQK05Dgjmdj7yYoa5oUpSbJCSO4Qt0OqIwpzktQBg74AAcI8rkJFJ5VU9LuQh0B1a7niunIMLkpRPkaxdp6mVaoRqFqYlGjBPeLTYBHunMU5lpJnHvKP8rK0lrD71/eSw6uXxvNzy6wvLxqI7QNEhCRJSBNiJLVAh3MI3p+F+LjMabjQwxu77sQtA4jAEIWh71c81M4iipbJDJFmA160FeEVvjx+28lxG2BiAdg0kO3TAoAuPj5p9axN0wk6sFtQqpfKSfy6dVRev/OvmgeYYamWsg5Xjy9FXz7cOx9fv1I+A9udJ5v6wQknh0deOm7J1i8fYwuRHZGQCJ4eBOSSBv0HcvTCrOz75ifXbQa2wcVTFtxZA4peJfs3YdMzVuEC8HlVH1Qxdems2a/mjvBgbzM7yP2lwizIdydsCpwLDignJXJiQsSslx2UjJvQHCAHfSLKEQPTrRDqOAzzp6OBPeUg9Q/5XmpGWwWRmmHUjTsjEONJ+wUqMpSOtDY5IE5agikX3/Be/PFpamoJ0K12Kw0ucwzMXXIUt2r5eWVS+GTVQeETDcqvWOwLSW2kS/K7ZQCQtdmtCNAmuGdZJeIWPNMJaPtgtsXc7F/As1WOkDCHFRVlKhr47etRiNYhFUDCKjapgN7uTdVgy53ZANQO7Br0it3oJs64gzdbilpQd7Tt4E0k7rg0Lm93gxqhweoCbE1oAs9Ir4usp00Xfp13wEV6y+7ZR5J8szedE271nQk3BKwQbm+0UEg9g+bptn5mXgNjEr7XRJoCsNGPrVLAk33y0YEducDAO/9qzCyf2D3PjAELxnoPs5IeAD4DwdMgt2eaoVMAAAAAElFTkSuQmCC", 93 | "sounds": [ 94 | { 95 | "index": 6, 96 | "url": "/demo-sounds/quack.mp3", 97 | "artist": "", 98 | "title": "quack", 99 | "duration": "0:01", 100 | "tag": "", 101 | "addedOn": "2024-04-17", 102 | "lastPlayedOn": "", 103 | "playCount": 0 104 | }, 105 | { 106 | "index": 7, 107 | "url": "/demo-sounds/Spooky Scary Skeleton.mp3", 108 | "artist": "", 109 | "title": "Spooky Scary Skeleton", 110 | "duration": "0:06", 111 | "tag": "", 112 | "addedOn": "2024-04-17", 113 | "lastPlayedOn": "2024-04-17", 114 | "playCount": 1 115 | }, 116 | { 117 | "index": 8, 118 | "url": "/demo-sounds/nope.mp3", 119 | "artist": "", 120 | "title": "nope", 121 | "duration": "0:00", 122 | "tag": "", 123 | "addedOn": "2024-04-17", 124 | "lastPlayedOn": "2024-04-17", 125 | "playCount": 1 126 | } 127 | ] 128 | } 129 | ] -------------------------------------------------------------------------------- /web/src/components/dekstop/youtube-extractor.svelte: -------------------------------------------------------------------------------- 1 | 183 | 184 |
185 | 193 |
194 |
195 | {secondsToHHMMSS(start * videoDuration)} 196 |
197 |
198 |
201 | 202 |
203 |
204 |
205 | 206 |
207 |
208 |
209 | {secondsToHHMMSS(end * videoDuration)} 210 |
211 |
212 | 220 | 233 |
234 | {#if isDownloading && !processRunning} 235 |
236 | 237 |
238 | {/if} 239 | {#if isDownloading && processRunning} 240 |
241 | {#each currentProgress as process} 242 |
245 | {process.stepName} 246 | 268 |
269 | {/each} 270 |
271 | {/if} 272 |
273 |
274 | 275 | 276 | -------------------------------------------------------------------------------- /desktop/src/main/routes/import/url.ts: -------------------------------------------------------------------------------- 1 | import bodyParser from 'body-parser'; 2 | import { type Application, type Request, type Response, } from 'express'; 3 | import { App } from 'electron/main'; 4 | import path from 'node:path'; 5 | import ffmpeg from 'fluent-ffmpeg'; 6 | import ffmpegInstaller from '@ffmpeg-installer/ffmpeg' 7 | import { importToSoundpad, timeMarkToSeconds } from '../../utils/misc'; 8 | import jsdom from 'jsdom'; 9 | import fetch from 'node-fetch-commonjs'; 10 | import Fuse from 'fuse.js' 11 | import type { FetchedSound, SOUND_SOURCES } from '../../../customTypes'; 12 | import fs from 'node:fs'; 13 | import { getDownloadLocation } from '../../download-location-tray-option'; 14 | 15 | ffmpeg.setFfmpegPath(ffmpegInstaller.path.replace('app.asar', 'app.asar.unpacked')) 16 | 17 | const responsesToUpdate: Array<(newProgress: number, isDone: boolean) => void> = []; 18 | 19 | export default function registerRoutes (app: Application, electronApp: App) { 20 | app.post('/api/import/url', bodyParser.json(), async function (req: Request, res: Response) { 21 | const data = req.body as { 22 | name: string, 23 | url: string, 24 | }; 25 | 26 | const outputPath = path.join(getDownloadLocation(electronApp), (data.name ? path.normalize(data.name) : path.basename(data.url)).replace(/\.[^/.]+$/, '') + '.mp3'); 27 | 28 | fs.mkdirSync(path.dirname(outputPath), { recursive: true }); 29 | 30 | await new Promise(resolve => { 31 | ffmpeg(data.url) 32 | .addOutputOption('-map_metadata', '-1') 33 | .audioBitrate(128) 34 | .on('progress', (progress) => { 35 | responsesToUpdate.forEach(listener => listener(progress.percent, false)); 36 | }) 37 | .on('end', resolve) 38 | .on('error', (err) => { 39 | res.status(500).send('Error while converting audio'); 40 | resolve('Error while converting audio'); 41 | }) 42 | .save(outputPath); 43 | }) 44 | 45 | await importToSoundpad(outputPath); 46 | 47 | return res.end() 48 | }) 49 | 50 | app.get('/api/import/url/search/:searchQuery', bodyParser.json(), async function (req: Request, res: Response) { 51 | const searchQuery = req.params.searchQuery; 52 | 53 | const allSounds = await Promise.all( 54 | Object.values(importers) 55 | .map(importer => importer(searchQuery).catch(() => [] as Array)) 56 | ) 57 | 58 | const fuse = new Fuse(allSounds.flat(), { 59 | keys: ['name'], 60 | includeScore: true, 61 | isCaseSensitive: false, 62 | shouldSort: true, 63 | }); 64 | 65 | return res.send(fuse.search(searchQuery) 66 | .sort((a, b) => a.score - b.score) 67 | .map(result => result.item)) 68 | }) 69 | 70 | app.get('/api/import/url/extract/:webPageUrl', bodyParser.json(), async function (req: Request, res: Response) { 71 | const webPageUrl = decodeURIComponent(req.params.webPageUrl ?? ''); 72 | if (webPageUrl === '') { 73 | return res.status(400).send('Invalid URL'); 74 | } 75 | 76 | const fetchHeaders = await fetch(webPageUrl).then(res => res.headers).catch(() => { 77 | res.send([]) 78 | return null 79 | }) 80 | 81 | if (fetchHeaders.get('content-type').startsWith('audio')) { 82 | return res.send([{ 83 | name: webPageUrl.split('/').pop() ?? 'audio', 84 | url: webPageUrl, 85 | source: 'webpage', 86 | }]) 87 | } 88 | 89 | const webPage = await jsdom.JSDOM.fromURL(webPageUrl).catch(() => { 90 | res.send([]) 91 | return null 92 | }) 93 | 94 | const allowedExtensions = [ 95 | 'mp3', 96 | 'wav', 97 | 'ogg', 98 | 'm4a', 99 | 'flac', 100 | ] 101 | 102 | const audioFilesFound = [...webPage.window.document.body.innerHTML.matchAll(new RegExp(`/?([a-zA-Z0-9/-_.]+/([a-zA-Z0-9-_.]+\\.(${allowedExtensions.join('|')})))`, 'g'))] 103 | .map(match => { 104 | const name = match[2] 105 | const fullUrl = new URL(match[0], webPageUrl).href 106 | let url = '' 107 | if (fullUrl.startsWith('http')) { 108 | url = fullUrl 109 | } else if (fullUrl.startsWith('/')) { 110 | url = new URL(fullUrl, webPageUrl).href 111 | } else { 112 | url = webPageUrl.replace(/\/[^/]*$/, `/${fullUrl}`) 113 | } 114 | return ({ 115 | name, 116 | url, 117 | source: 'webpage', 118 | }) 119 | }) as Array 120 | 121 | const uniqueSounds = audioFilesFound.filter((sound, index, self) => 122 | index === self.findIndex(t => ( 123 | t.url === sound.url 124 | )) 125 | ) 126 | 127 | return res.send(uniqueSounds) 128 | }) 129 | 130 | app.get('/api/import/url/progress', (req, res) => { 131 | res.setHeader('Cache-Control', 'no-cache'); 132 | res.setHeader('Content-Type', 'text/event-stream'); 133 | res.setHeader('Access-Control-Allow-Origin', '*'); 134 | res.setHeader('Connection', 'keep-alive'); 135 | res.flushHeaders(); // flush the headers to establish SSE with client 136 | 137 | const listener = (newProgress: number, isDone: boolean) => { 138 | if (isDone) { 139 | res.write(`data: ${newProgress}\n\n`); 140 | responsesToUpdate.splice(responsesToUpdate.indexOf(listener), 1); 141 | res.end(); 142 | } else { 143 | res.write(`data: ${newProgress}\n\n`); 144 | } 145 | } 146 | 147 | responsesToUpdate.push(listener); 148 | 149 | // If client closes connection, stop sending events 150 | res.on('close', () => { 151 | responsesToUpdate.splice(responsesToUpdate.indexOf(listener), 1); 152 | res.end(); 153 | }); 154 | }); 155 | } 156 | 157 | const importers: Partial Promise>> = { 158 | myinstants: async function (searchFilter: string) { 159 | const output = await jsdom.JSDOM.fromURL(getURL('myinstants', searchFilter)); 160 | const sounds = [...output.window.document.querySelectorAll('.instant')].map(el => { 161 | const name = el.querySelector('a').textContent 162 | const url = RegExp(/\/media.*\.mp3/).exec(el.outerHTML) 163 | if (!url) { 164 | return null 165 | } 166 | return { source: 'myinstants' as SOUND_SOURCES, name, url: `https://www.myinstants.com${url[0]}` } 167 | }) 168 | return sounds.filter(Boolean) 169 | }, 170 | voicy: async function (searchFilter: string) { 171 | const output = await fetch(getURL('voicy', searchFilter)).then(res => res.json()) as { 172 | error: boolean, 173 | errorMessage: string | null, 174 | data: Array<{ 175 | name: string, 176 | source: string, 177 | }> 178 | } 179 | const sounds = output.data.map(data => { 180 | return { source: 'voicy' as SOUND_SOURCES, name: data.name, url: `https://files.voicy.network/public${data.source}` } 181 | }) 182 | return sounds 183 | }, 184 | freesound: async function (searchFilter: string) { 185 | const output = await jsdom.JSDOM.fromURL(getURL('freesound', searchFilter)); 186 | const sounds = [...output.window.document.querySelectorAll('.bw-player[data-mp3]')].map(el => { 187 | return { source: 'freesound' as SOUND_SOURCES, name: el.getAttribute('data-title'), url: el.getAttribute('data-mp3') } 188 | }) 189 | return sounds.filter(Boolean) 190 | }, 191 | uwupad: async function (searchFilter: string) { 192 | const output = await fetch(getURL('uwupad', searchFilter)).then(res => res.json()).catch(console.error) as Array<{ 193 | title: string, 194 | extension: string, 195 | id: number, 196 | }> 197 | const sounds = output.map(data => { 198 | return { source: 'uwupad' as SOUND_SOURCES, name: data.title, url: `https://cdn.uwupad.me/${data.id}.${data.extension}` } 199 | }) 200 | return sounds 201 | }, 202 | pixabay: async function (searchFilter: string) { 203 | const output = await jsdom.JSDOM.fromURL(getURL('pixabay', searchFilter)); 204 | const sounds = [...output.window.document.querySelectorAll('div[class^="nameAndTitle"]>a')] 205 | .map((el: HTMLAnchorElement) => el.href.match(/sound-effects\/(.+)-(\d+)\//)) 206 | .filter(el => el) 207 | .map((match) => { 208 | return { source: 'pixabay' as SOUND_SOURCES, name: match[1].replace(/-/g, ' '), url: `https://pixabay.com//fr/sound-effects/download/id-${match[2]}.mp3` } 209 | }) 210 | return sounds 211 | }, 212 | } 213 | 214 | function getURL (source: SOUND_SOURCES, searchFilter: string): string { 215 | switch (source) { 216 | case 'myinstants': 217 | return `https://www.myinstants.com/search/?name=${encodeURIComponent(searchFilter)}`; 218 | case 'freesound': 219 | return `https://freesound.org/search/?q=${encodeURIComponent(searchFilter)}`; 220 | case 'voicy': 221 | return `https://server.voicy.network/api/clips?Type=0&Search=${encodeURIComponent(searchFilter)}&Quantity=20&Index=0&NSFW=true`; 222 | case 'uwupad': 223 | return `https://uwupad.me/api/search?query=${encodeURIComponent(searchFilter)}&limit=20&offset=0`; 224 | case 'pixabay': 225 | return `https://pixabay.com/fr/sound-effects/search/${encodeURIComponent(searchFilter)}/`; 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /web/src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 163 | 164 | 165 | {@html webManifestLink} 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 184 | 188 | 189 | 193 | 194 | 195 | 196 | -------------------------------------------------------------------------------- /web/src/routes/panel/mobile/layouts/mirror.svelte: -------------------------------------------------------------------------------- 1 | 196 | 197 |
200 | 201 |
205 |
209 | {#each categories as category, index} 210 | 211 | {#each Array(category.depth).fill(0) as _, depth} 212 |
216 | {/each} 217 |
218 | category icon 224 | {category.name} 225 |
226 | 227 | {/each} 228 |
229 | 230 | 231 |
232 | {#if $showSearchBar} 233 |
234 | 240 |
241 | {/if} 242 |
247 | {#each soundsToDisplay as sound, index (sound.url + sound.index)} 248 |
{ 255 | e.preventDefault(); 256 | // @ts-ignore 257 | openCustomizationPopup(sound, e.target); 258 | }} 259 | > 260 | 261 |
262 | {/each} 263 |
264 |
265 |
266 | 267 |
268 | 269 | 270 | 271 | 276 | 277 |
278 | {#if soundEdited} 279 |
280 | 294 | { 296 | if (e.key === "Enter") { 297 | popupCloseBtn.click(); 298 | } 299 | }} 300 | type="text" 301 | class="input text-xl text-center" 302 | bind:value={$soundEditedName} 303 | /> 304 | 305 | 307 |
308 |
309 | {#each Object.values(SOUND_COLORS_HSL) as color} 310 |
324 | {/if} 325 |
326 | --------------------------------------------------------------------------------